From 7274030ede709e8e35b41e05f7a993d3e69cd19e Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:25:49 -0500 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20multi-view=20enhancements=20?= =?UTF-8?q?=E2=80=94=20branches,=20network,=20PRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branches View: - Search bar to filter by branch/tag name - Tabs: Branches / Tags with lazy-loaded tag details - New server endpoint GET /api/repos/:owner/:repo/tags - Tags show name, commit message, author, date, SHA Network Graph View: - Date axis on x-axis (bottom), zooms/pans with graph - Adaptive tick format based on date span Pull Requests View: - CI/CD status icons (✅/❌/⏳/⚪) with hover tooltip showing all checks - Reviewer avatars with status rings (green/red/yellow/gray) - Author and Reviewer filter dropdowns - Sort moved to dropdown to save toolbar space - GraphQL query updated with statusCheckRollup, reviews, reviewRequests --- .../components/NetworkGraphVisualization.tsx | 48 +- client/src/lib/api.ts | 29 ++ client/src/pages/BranchesPage.tsx | 245 +++++++++-- client/src/pages/PullRequestsPage.tsx | 409 ++++++++++++++++-- server/src/routes/repo.routes.ts | 25 ++ server/src/services/github.service.ts | 250 ++++++++++- server/src/types/index.ts | 27 ++ 7 files changed, 969 insertions(+), 64 deletions(-) diff --git a/client/src/components/NetworkGraphVisualization.tsx b/client/src/components/NetworkGraphVisualization.tsx index 943ada8..566ed1c 100644 --- a/client/src/components/NetworkGraphVisualization.tsx +++ b/client/src/components/NetworkGraphVisualization.tsx @@ -194,6 +194,51 @@ export default function NetworkGraphVisualization({ } } + // Draw date axis at the bottom of the graph + { + const commitDates = layout.nodes.map((n) => new Date(n.committedDate).getTime()).filter(Boolean); + if (commitDates.length >= 2) { + const minTs = Math.min(...commitDates); + const maxTs = Math.max(...commitDates); + const minX = Math.min(...layout.nodes.map((n) => n.x)); + const maxX = Math.max(...layout.nodes.map((n) => n.x)); + + if (minTs < maxTs && minX < maxX) { + const xScale = d3.scaleTime() + .domain([new Date(minTs), new Date(maxTs)]) + .range([minX, maxX]); + + const spanDays = (maxTs - minTs) / 86400000; + const tickFmt = spanDays > 365 + ? d3.timeFormat('%b %Y') + : d3.timeFormat('%b %d'); + + const axisY = layout.totalHeight + 8; + const axisGroup = g.append('g') + .attr('class', 'date-axis') + .attr('transform', `translate(0,${axisY})`); + + const tickCount = Math.min(10, Math.max(3, Math.floor(viewWidth / 80))); + const axis = d3.axisBottom(xScale) + .ticks(tickCount) + .tickFormat(tickFmt as (value: Date | d3.NumberValue) => string) + .tickSize(4); + + axisGroup.call(axis); + + axisGroup.selectAll('text') + .style('fill', '#8b949e') + .style('font-size', '0.75rem'); + + axisGroup.selectAll('line') + .style('stroke', '#30363d'); + + axisGroup.select('.domain') + .style('stroke', '#30363d'); + } + } + } + // Draw nodes const nodeGroup = g.append('g').attr('class', 'nodes'); @@ -304,7 +349,8 @@ export default function NetworkGraphVisualization({ zoom.transform(d3svg, zoomTransformRef.current); } else { const scaleX = viewWidth / (layout.totalWidth + 60); - const scaleY = viewHeight / (layout.totalHeight + 20); + // +50 to account for the date axis at the bottom + const scaleY = viewHeight / (layout.totalHeight + 50); const scale = Math.min(scaleX, scaleY, 2); const contentW = layout.totalWidth * scale; const contentH = layout.totalHeight * scale; diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index aded8db..85a0247 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -119,6 +119,33 @@ export interface PullRequest { baseRefName: string; commits: { totalCount: number }; reviews: { totalCount: number }; + statusCheckRollup: { + state: string; + contexts: { + nodes: Array< + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null } + >; + }; + } | null; + reviewRequests: Array<{ login: string; avatarUrl: string }>; + reviewList: Array<{ author: { login: string; avatarUrl: string } | null; state: string }>; +} + +export interface TagInfo { + name: string; + message: string | null; + taggerName: string | null; + taggerDate: string | null; + commitOid: string; + commitAbbreviatedOid: string; + committedDate: string; + commitMessage: string; + author: { + name: string | null; + avatarUrl: string; + login: string | null; + }; } export interface BranchInfo { @@ -256,6 +283,8 @@ export const api = { apiFetch(`/api/repos/${owner}/${repo}/pulls?state=${state}`), branches: (owner: string, repo: string) => apiFetch(`/api/repos/${owner}/${repo}/branches`), + tags: (owner: string, repo: string) => + apiFetch(`/api/repos/${owner}/${repo}/tags`), codeFrequency: ( owner: string, repo: string, diff --git a/client/src/pages/BranchesPage.tsx b/client/src/pages/BranchesPage.tsx index 70de592..f1b4d6c 100644 --- a/client/src/pages/BranchesPage.tsx +++ b/client/src/pages/BranchesPage.tsx @@ -2,9 +2,10 @@ import { useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; -import type { BranchInfo } from '../lib/api.js'; +import type { BranchInfo, TagInfo } from '../lib/api.js'; type SortMode = 'updated' | 'name' | 'ahead'; +type TabMode = 'branches' | 'tags'; function timeAgo(dateStr: string): string { if (!dateStr) return ''; @@ -202,17 +203,119 @@ function BranchRow({ ); } +function TagRow({ tag }: { tag: TagInfo }) { + const date = tag.taggerDate ?? tag.committedDate; + const authorLogin = tag.author.login ?? tag.author.name ?? ''; + return ( +
+ {/* Tag icon + name */} +
+
+ 🏷️ + + {tag.name} + +
+
+ + {/* Commit message */} +
+ {(tag.message?.split('\n')[0] ?? tag.commitMessage.split('\n')[0])} +
+ + {/* Author + age */} +
+ {tag.author.avatarUrl && ( + {authorLogin} + )} + + {timeAgo(date)} + +
+ + {/* Commit SHA */} +
+ + {tag.commitAbbreviatedOid} + +
+
+ ); +} + export default function BranchesPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const navigate = useNavigate(); const [sortMode, setSortMode] = useState('updated'); + const [activeTab, setActiveTab] = useState('branches'); + const [searchQuery, setSearchQuery] = useState(''); - const { data: branches, isLoading, error } = useQuery({ + const { data: branches, isLoading: branchesLoading, error: branchesError } = useQuery({ queryKey: ['branches', owner, repo], queryFn: () => api.repos.branches(owner!, repo!), enabled: !!owner && !!repo, }); + const { data: tags, isLoading: tagsLoading, error: tagsError } = useQuery({ + queryKey: ['tags', owner, repo], + queryFn: () => api.repos.tags(owner!, repo!), + enabled: !!owner && !!repo && activeTab === 'tags', + }); + if (!owner || !repo) { return (
b.name.toLowerCase().includes(q)) : sorted; + const filteredTags = q && tags ? tags.filter((t) => t.name.toLowerCase().includes(q)) : (tags ?? []); + + const isLoading = activeTab === 'branches' ? branchesLoading : tagsLoading; + const error = activeTab === 'branches' ? branchesError : tagsError; + const isEmpty = activeTab === 'branches' ? filteredBranches.length === 0 : filteredTags.length === 0; + return (
- 🏷️ - + 🌿 + Branches {owner}/{repo} + + {/* Search bar */} +
+ + + + setSearchQuery(e.target.value)} + placeholder="Search..." + style={{ + background: 'transparent', + border: 'none', + outline: 'none', + color: '#dfe2eb', + fontSize: '0.8125rem', + width: 140, + }} + /> +
+
- {/* Sort controls */} - Sort: - {(['updated', 'name', 'ahead'] as SortMode[]).map((mode) => ( + + {/* Sort controls (branches only) */} + {activeTab === 'branches' && ( + <> + Sort: + {(['updated', 'name', 'ahead'] as SortMode[]).map((mode) => ( + + ))} + + )} +
+ + {/* Tabs */} +
+ {(['branches', 'tags'] as TabMode[]).map((tab) => ( ))}
{/* Column headers */} - {!isLoading && !error && sorted.length > 0 && ( + {!isLoading && !error && !isEmpty && (
- BRANCH + {activeTab === 'branches' ? 'BRANCH' : 'TAG'}
- LAST COMMIT + {activeTab === 'branches' ? 'LAST COMMIT' : 'MESSAGE'}
UPDATED
- AHEAD / BEHIND + {activeTab === 'branches' ? 'AHEAD / BEHIND' : 'COMMIT'}
)} @@ -352,7 +533,7 @@ export default function BranchesPage() { animation: 'spin 0.8s linear infinite', }} /> - Loading branches... + Loading {activeTab}...
) : error ? ( @@ -364,9 +545,9 @@ export default function BranchesPage() { fontSize: '0.875rem', }} > - {error instanceof Error ? error.message : 'Failed to load branches'} + {error instanceof Error ? error.message : `Failed to load ${activeTab}`}
- ) : sorted.length === 0 ? ( + ) : isEmpty ? (
- 🏷️ - No branches found + {activeTab === 'branches' ? '🌿' : '🏷️'} + + {searchQuery ? `No ${activeTab} matching "${searchQuery}"` : `No ${activeTab} found`} +
- ) : ( - sorted.map((branch) => ( + ) : activeTab === 'branches' ? ( + filteredBranches.map((branch) => ( )) + ) : ( + filteredTags.map((tag) => ( + + )) )} diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index ac123f8..49cca48 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; @@ -64,6 +64,333 @@ function StateBadge({ state }: { state: string }) { ); } +type CheckNode = + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null }; + +function CIStatusIcon({ rollup }: { pr: PullRequest; rollup: PullRequest['statusCheckRollup'] }) { + const [showTooltip, setShowTooltip] = useState(false); + const containerRef = useRef(null); + + if (!rollup) { + return ( + + ⚪ + + ); + } + + const icon = + rollup.state === 'SUCCESS' + ? '✅' + : rollup.state === 'FAILURE' || rollup.state === 'ERROR' + ? '❌' + : rollup.state === 'PENDING' + ? '⏳' + : '⚪'; + + const checks = rollup.contexts.nodes; + + function getCheckName(c: CheckNode): string { + return 'name' in c ? c.name : c.context; + } + function getCheckState(c: CheckNode): string { + if ('conclusion' in c) return c.conclusion ?? c.status; + return c.state; + } + function getCheckUrl(c: CheckNode): string | null { + return 'detailsUrl' in c ? c.detailsUrl : c.targetUrl; + } + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + {icon} + {showTooltip && checks.length > 0 && ( +
+ {checks.map((c, i) => { + const name = getCheckName(c); + const st = getCheckState(c); + const url = getCheckUrl(c); + const stColor = + st === 'SUCCESS' || st === 'success' + ? '#3fb950' + : st === 'FAILURE' || st === 'failure' || st === 'ERROR' || st === 'error' + ? '#f85149' + : '#d29922'; + return ( +
+ + {url ? ( + + {name} + + ) : ( + {name} + )} + {st} +
+ ); + })} +
+ )} +
+ ); +} + +function ReviewerAvatars({ pr }: { pr: PullRequest }) { + // Build a merged list: requested reviewers + people who reviewed + const reviewed = new Map(); + + for (const r of pr.reviewList) { + if (r.author) { + const existing = reviewed.get(r.author.login); + // Prefer APPROVED / CHANGES_REQUESTED over COMMENTED + if (!existing || existing.state === 'COMMENTED') { + reviewed.set(r.author.login, { ...r.author, state: r.state }); + } + } + } + + const allReviewers: Array<{ login: string; avatarUrl: string; state: string }> = []; + for (const req of pr.reviewRequests) { + if (!reviewed.has(req.login)) { + allReviewers.push({ ...req, state: 'PENDING' }); + } + } + for (const [, r] of reviewed) { + allReviewers.push(r); + } + + if (allReviewers.length === 0) return null; + + return ( +
+ {allReviewers.slice(0, 5).map((r) => { + const ringColor = + r.state === 'APPROVED' + ? '#3fb950' + : r.state === 'CHANGES_REQUESTED' + ? '#f85149' + : r.state === 'COMMENTED' + ? '#d29922' + : '#484f58'; + return ( + {r.login} + ); + })} +
+ ); +} + +const SORT_LABELS: Record = { + newest: 'Newest', + oldest: 'Oldest', + 'most-comments': 'Most reviews', + 'most-reviews': 'Most commits', +}; + +function SortDropdown({ sortMode, setSortMode }: { sortMode: SortMode; setSortMode: (m: SortMode) => void }) { + const [open, setOpen] = useState(false); + const modes: SortMode[] = ['newest', 'oldest', 'most-comments', 'most-reviews']; + return ( +
+ + {open && ( +
+ {modes.map((m) => ( + + ))} +
+ )} +
+ ); +} + +function FilterDropdown({ + label, + options, + value, + onChange, +}: { + label: string; + options: string[]; + value: string; + onChange: (v: string) => void; +}) { + const [open, setOpen] = useState(false); + if (options.length === 0) return null; + return ( +
+ + {open && ( +
+ + {options.map((opt) => ( + + ))} +
+ )} +
+ ); +} + function PRRow({ pr }: { pr: PullRequest }) { return ( + + {/* Reviewer avatars */} +
+ + +
); } @@ -213,6 +546,8 @@ export default function PullRequestsPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const [activeState, setActiveState] = useState('OPEN'); const [sortMode, setSortMode] = useState('newest'); + const [authorFilter, setAuthorFilter] = useState(''); + const [reviewerFilter, setReviewerFilter] = useState(''); const { since, until } = useDateRange(); @@ -244,6 +579,20 @@ export default function PullRequestsPage() { { label: 'Merged', value: 'MERGED' }, ]; + // Compute unique authors and reviewers for filter dropdowns + const uniqueAuthors = prs + ? [...new Set(prs.map((p) => p.author?.login).filter((l): l is string => !!l))].sort() + : []; + + const uniqueReviewers = prs + ? [ + ...new Set([ + ...prs.flatMap((p) => p.reviewRequests.map((r) => r.login)), + ...prs.flatMap((p) => p.reviewList.map((r) => r.author?.login ?? '')).filter(Boolean), + ]), + ].sort() + : []; + const sortedPrs = prs ? [...prs].sort((a, b) => { switch (sortMode) { @@ -264,10 +613,17 @@ export default function PullRequestsPage() { const created = new Date(pr.createdAt).getTime(); if (since && created < new Date(since).getTime()) return false; if (until && created > new Date(until).getTime()) return false; + if (authorFilter && pr.author?.login !== authorFilter) return false; + if (reviewerFilter) { + const hasReviewer = + pr.reviewRequests.some((r) => r.login === reviewerFilter) || + pr.reviewList.some((r) => r.author?.login === reviewerFilter); + if (!hasReviewer) return false; + } return true; }); - const isDateFiltered = !!since || !!until; + const isFiltered = !!(since || until || authorFilter || reviewerFilter); const totalCount = sortedPrs.length; const filteredCount = filteredPrs.length; @@ -308,31 +664,24 @@ export default function PullRequestsPage() { {/* Date range picker */} - {/* Sort controls */} - Sort: - {(['newest', 'oldest', 'most-comments', 'most-reviews'] as SortMode[]).map((mode) => ( - - ))} + {/* Author filter */} + + + {/* Reviewer filter */} + + + {/* Sort dropdown */} + {/* Tabs */} @@ -367,8 +716,8 @@ export default function PullRequestsPage() { ))} - {/* Date filter count badge */} - {isDateFiltered && !isLoading && prs && ( + {/* Filter count badge */} + {isFiltered && !isLoading && prs && ( 📋 - No pull requests in the selected date range + No pull requests match the current filters ) : ( diff --git a/server/src/routes/repo.routes.ts b/server/src/routes/repo.routes.ts index 757ab4a..cded430 100644 --- a/server/src/routes/repo.routes.ts +++ b/server/src/routes/repo.routes.ts @@ -190,6 +190,31 @@ router.get( } ); +// GET /api/repos/:owner/:repo/tags +router.get( + '/:owner/:repo/tags', + async (req: Request, res: Response): Promise => { + const owner = str(req.params['owner']); + const repo = str(req.params['repo']); + const cacheKey = cacheService.cacheKey(['tags', owner, repo]); + const cached = cacheService.get(cacheKey); + if (cached) { + res.json(cached); + return; + } + + try { + const service = getGitHubService(req); + const tags = await service.getTags(owner, repo); + cacheService.set(cacheKey, tags, 300); + res.json(tags); + } catch (err) { + console.error('Get tags error:', err); + res.status(500).json({ error: 'Failed to fetch tags' }); + } + } +); + // GET /api/repos/:owner/:repo/pulls router.get( '/:owner/:repo/pulls', diff --git a/server/src/services/github.service.ts b/server/src/services/github.service.ts index cb436f4..bb585b4 100644 --- a/server/src/services/github.service.ts +++ b/server/src/services/github.service.ts @@ -5,6 +5,7 @@ import type { CommitsPage, CommitDetail, PullRequestSummary, + TagInfo, UserRepo, UserOrg, ReposPage, @@ -286,11 +287,54 @@ export class GitHubService { } headRefName baseRefName - commits { + commits(last: 1) { totalCount + nodes { + commit { + statusCheckRollup { + state + contexts(first: 25) { + nodes { + ... on CheckRun { + name + conclusion + status + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + } + } + } + } + } + } } - reviews { + reviews(first: 20) { totalCount + nodes { + author { + login + avatarUrl + } + state + } + } + reviewRequests(first: 10) { + nodes { + requestedReviewer { + ... on User { + login + avatarUrl + } + ... on Team { + name + avatarUrl + } + } + } } } } @@ -298,13 +342,211 @@ export class GitHubService { } `; + interface RawPR { + number: number; + title: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; + author: { login: string; avatarUrl: string } | null; + headRefName: string; + baseRefName: string; + commits: { + totalCount: number; + nodes: Array<{ + commit: { + statusCheckRollup: { + state: string; + contexts: { + nodes: Array< + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null } + >; + }; + } | null; + }; + }>; + }; + reviews: { + totalCount: number; + nodes: Array<{ + author: { login: string; avatarUrl: string } | null; + state: string; + }>; + }; + reviewRequests: { + nodes: Array<{ + requestedReviewer: + | { login: string; avatarUrl: string } + | { name: string; avatarUrl: string } + | null; + }>; + }; + } + const result = await this.graphqlWithAuth<{ repository: { - pullRequests: { nodes: PullRequestSummary[] }; + pullRequests: { nodes: RawPR[] }; }; }>(query, { owner, repo, states }); - return result.repository.pullRequests.nodes; + return result.repository.pullRequests.nodes.map((pr) => { + const lastCommitNode = pr.commits.nodes[0]; + const rollup = lastCommitNode?.commit.statusCheckRollup ?? null; + + const reviewRequests = pr.reviewRequests.nodes + .map((rr) => { + const rv = rr.requestedReviewer; + if (!rv) return null; + if ('login' in rv) return { login: rv.login, avatarUrl: rv.avatarUrl }; + if ('name' in rv) return { login: rv.name, avatarUrl: rv.avatarUrl }; + return null; + }) + .filter((r): r is { login: string; avatarUrl: string } => r !== null); + + return { + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + createdAt: pr.createdAt, + updatedAt: pr.updatedAt, + author: pr.author, + headRefName: pr.headRefName, + baseRefName: pr.baseRefName, + commits: { totalCount: pr.commits.totalCount }, + reviews: { totalCount: pr.reviews.totalCount }, + statusCheckRollup: rollup, + reviewRequests, + reviewList: pr.reviews.nodes.map((r) => ({ + author: r.author, + state: r.state, + })), + }; + }); + } + + async getTags(owner: string, repo: string): Promise { + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + refs(refPrefix: "refs/tags/", first: 50, orderBy: { field: TAG_COMMIT_DATE, direction: DESC }, after: $cursor) { + nodes { + name + target { + ... on Tag { + message + tagger { name date } + target { + ... on Commit { + oid + abbreviatedOid + committedDate + message + author { name avatarUrl user { login } } + } + } + } + ... on Commit { + oid + abbreviatedOid + committedDate + message + author { name avatarUrl user { login } } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + `; + + interface TagNode { + name: string; + target: + | { + message?: string; + tagger?: { name: string; date: string } | null; + target?: { + oid: string; + abbreviatedOid: string; + committedDate: string; + message: string; + author: { name: string | null; avatarUrl: string; user: { login: string } | null } | null; + } | null; + // lightweight tag falls through as Commit + oid?: string; + abbreviatedOid?: string; + committedDate?: string; + author?: { name: string | null; avatarUrl: string; user: { login: string } | null } | null; + } + | null; + } + + const result = await this.graphqlWithAuth<{ + repository: { + refs: { + nodes: TagNode[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + }; + }>(query, { owner, repo, cursor: null }); + + return result.repository.refs.nodes.map((node) => { + const t = node.target; + if (!t) { + return { + name: node.name, + message: null, + taggerName: null, + taggerDate: null, + commitOid: '', + commitAbbreviatedOid: '', + committedDate: '', + commitMessage: '', + author: { name: null, avatarUrl: '', login: null }, + }; + } + + // Annotated tag: has tagger + nested target commit + if ('tagger' in t && t.tagger !== undefined) { + const commit = t.target; + return { + name: node.name, + message: t.message ?? null, + taggerName: t.tagger?.name ?? null, + taggerDate: t.tagger?.date ?? null, + commitOid: commit?.oid ?? '', + commitAbbreviatedOid: commit?.abbreviatedOid ?? '', + committedDate: commit?.committedDate ?? '', + commitMessage: commit?.message ?? '', + author: { + name: commit?.author?.name ?? null, + avatarUrl: commit?.author?.avatarUrl ?? '', + login: commit?.author?.user?.login ?? null, + }, + }; + } + + // Lightweight tag: target IS the commit + return { + name: node.name, + message: null, + taggerName: null, + taggerDate: null, + commitOid: t.oid ?? '', + commitAbbreviatedOid: t.abbreviatedOid ?? '', + committedDate: t.committedDate ?? '', + commitMessage: t.message ?? '', + author: { + name: t.author?.name ?? null, + avatarUrl: t.author?.avatarUrl ?? '', + login: t.author?.user?.login ?? null, + }, + }; + }); } async getBranches(owner: string, repo: string): Promise { diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 3027ad9..f22dc10 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -118,6 +118,33 @@ export interface PullRequestSummary { baseRefName: string; commits: { totalCount: number }; reviews: { totalCount: number }; + statusCheckRollup: { + state: string; + contexts: { + nodes: Array< + | { name: string; conclusion: string | null; status: string; detailsUrl: string | null } + | { context: string; state: string; targetUrl: string | null } + >; + }; + } | null; + reviewRequests: Array<{ login: string; avatarUrl: string }>; + reviewList: Array<{ author: { login: string; avatarUrl: string } | null; state: string }>; +} + +export interface TagInfo { + name: string; + message: string | null; + taggerName: string | null; + taggerDate: string | null; + commitOid: string; + commitAbbreviatedOid: string; + committedDate: string; + commitMessage: string; + author: { + name: string | null; + avatarUrl: string; + login: string | null; + }; } export interface GitHubUser { From 3c2d2681f4bbe95b5aea5143894ee7a547fc0457 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:45:06 -0500 Subject: [PATCH 02/11] fix: align network graph date axis with actual commit positions The network graph uses evenly-spaced x-positions (not proportional to time), but the date axis was using d3.scaleTime which assumes linear time-to-position mapping. This caused: - Dates misaligned with commit nodes (e.g. Nov commit shown at Oct) - Labels overlapping when commits are clustered Fix: sample actual commit positions and label them with their dates. Ticks are placed at real commit x-coordinates, not interpolated time positions. Labels are spaced at least 60px apart to prevent overlap. --- .../components/NetworkGraphVisualization.tsx | 98 ++++++++++++------- 1 file changed, 62 insertions(+), 36 deletions(-) diff --git a/client/src/components/NetworkGraphVisualization.tsx b/client/src/components/NetworkGraphVisualization.tsx index 566ed1c..22ee5e8 100644 --- a/client/src/components/NetworkGraphVisualization.tsx +++ b/client/src/components/NetworkGraphVisualization.tsx @@ -195,46 +195,72 @@ export default function NetworkGraphVisualization({ } // Draw date axis at the bottom of the graph + // Commits are evenly spaced (not proportional to time), so we sample + // a subset of commit positions and label them with their actual dates. { - const commitDates = layout.nodes.map((n) => new Date(n.committedDate).getTime()).filter(Boolean); - if (commitDates.length >= 2) { - const minTs = Math.min(...commitDates); - const maxTs = Math.max(...commitDates); - const minX = Math.min(...layout.nodes.map((n) => n.x)); - const maxX = Math.max(...layout.nodes.map((n) => n.x)); - - if (minTs < maxTs && minX < maxX) { - const xScale = d3.scaleTime() - .domain([new Date(minTs), new Date(maxTs)]) - .range([minX, maxX]); - - const spanDays = (maxTs - minTs) / 86400000; - const tickFmt = spanDays > 365 - ? d3.timeFormat('%b %Y') - : d3.timeFormat('%b %d'); - - const axisY = layout.totalHeight + 8; - const axisGroup = g.append('g') - .attr('class', 'date-axis') - .attr('transform', `translate(0,${axisY})`); - - const tickCount = Math.min(10, Math.max(3, Math.floor(viewWidth / 80))); - const axis = d3.axisBottom(xScale) - .ticks(tickCount) - .tickFormat(tickFmt as (value: Date | d3.NumberValue) => string) - .tickSize(4); - - axisGroup.call(axis); - - axisGroup.selectAll('text') - .style('fill', '#8b949e') - .style('font-size', '0.75rem'); + // Build array of {x, date} sorted by x (same as chronological) + const dateTicks = layout.nodes + .map((n) => ({ x: n.x, ts: new Date(n.committedDate).getTime() })) + .filter((d) => d.ts > 0) + .sort((a, b) => a.x - b.x); + + if (dateTicks.length >= 2) { + const axisY = layout.totalHeight + 8; + const axisGroup = g.append('g') + .attr('class', 'date-axis') + .attr('transform', `translate(0,${axisY})`); + + // Draw the baseline + const minX = dateTicks[0].x; + const maxX = dateTicks[dateTicks.length - 1].x; + axisGroup.append('line') + .attr('x1', minX) + .attr('x2', maxX) + .attr('y1', 0) + .attr('y2', 0) + .style('stroke', '#30363d'); + + // Pick evenly-spaced samples from the commit positions + const maxTicks = Math.min(10, Math.max(3, Math.floor(viewWidth / 100))); + const step = Math.max(1, Math.floor((dateTicks.length - 1) / (maxTicks - 1))); + const samples: Array<{ x: number; ts: number }> = []; + for (let i = 0; i < dateTicks.length; i += step) { + samples.push(dateTicks[i]); + } + // Always include the last tick + if (samples[samples.length - 1] !== dateTicks[dateTicks.length - 1]) { + samples.push(dateTicks[dateTicks.length - 1]); + } - axisGroup.selectAll('line') - .style('stroke', '#30363d'); + const spanDays = (dateTicks[dateTicks.length - 1].ts - dateTicks[0].ts) / 86400000; + const fmtStr = spanDays > 365 ? '%b %Y' : '%b %d'; + const fmt = d3.timeFormat(fmtStr); - axisGroup.select('.domain') + // Filter out samples whose labels would overlap (min 60px apart) + const filtered: typeof samples = [samples[0]]; + for (let i = 1; i < samples.length; i++) { + if (samples[i].x - filtered[filtered.length - 1].x >= 60) { + filtered.push(samples[i]); + } + } + + for (const sample of filtered) { + // Tick mark + axisGroup.append('line') + .attr('x1', sample.x) + .attr('x2', sample.x) + .attr('y1', 0) + .attr('y2', 4) .style('stroke', '#30363d'); + + // Label + axisGroup.append('text') + .attr('x', sample.x) + .attr('y', 18) + .attr('text-anchor', 'middle') + .style('fill', '#8b949e') + .style('font-size', '0.75rem') + .text(fmt(new Date(sample.ts))); } } } From bd6c5c8d502f16be9aefda64a5c54446b5cbc3c3 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:53:12 -0500 Subject: [PATCH 03/11] fix: CI status tooltip opens below icon instead of above The tooltip was opening upward (bottom: calc(100% + 6px)) which caused it to get clipped by the scroll container on the first PR row. Changed to open downward (top: calc(100% + 6px)) so it stays within the scrollable content area. --- client/src/pages/PullRequestsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index 49cca48..1edb552 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -114,7 +114,7 @@ function CIStatusIcon({ rollup }: { pr: PullRequest; rollup: PullRequest['status
Date: Tue, 14 Apr 2026 18:58:49 -0500 Subject: [PATCH 04/11] feat: add 'All' tab to Pull Requests view - New 'All' tab shows Open + Closed + Merged PRs together - Tab order: All | Open | Closed | Merged (Open still default) - Server handles state=ALL by passing all three states to GraphQL - Empty state message adapts for All tab --- client/src/pages/PullRequestsPage.tsx | 5 +++-- server/src/services/github.service.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index 1edb552..df26569 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -6,7 +6,7 @@ import type { PullRequest } from '../lib/api.js'; import { useDateRange } from '../contexts/DateRangeContext.js'; import DateRangePicker from '../components/DateRangePicker.js'; -type PRState = 'OPEN' | 'CLOSED' | 'MERGED'; +type PRState = 'ALL' | 'OPEN' | 'CLOSED' | 'MERGED'; type SortMode = 'newest' | 'oldest' | 'most-comments' | 'most-reviews'; function timeAgo(dateStr: string): string { @@ -574,6 +574,7 @@ export default function PullRequestsPage() { } const tabs: { label: string; value: PRState }[] = [ + { label: 'All', value: 'ALL' }, { label: 'Open', value: 'OPEN' }, { label: 'Closed', value: 'CLOSED' }, { label: 'Merged', value: 'MERGED' }, @@ -785,7 +786,7 @@ export default function PullRequestsPage() { > 📋 - No {activeState.toLowerCase()} pull requests + No {activeState === 'ALL' ? '' : activeState.toLowerCase() + ' '}pull requests
) : filteredPrs.length === 0 ? ( diff --git a/server/src/services/github.service.ts b/server/src/services/github.service.ts index bb585b4..f477368 100644 --- a/server/src/services/github.service.ts +++ b/server/src/services/github.service.ts @@ -269,7 +269,8 @@ export class GitHubService { repo: string, state?: string ): Promise { - const states = state ? [state.toUpperCase()] : ['OPEN']; + const upper = state?.toUpperCase(); + const states = upper === 'ALL' ? ['OPEN', 'CLOSED', 'MERGED'] : upper ? [upper] : ['OPEN']; const query = ` query($owner: String!, $repo: String!, $states: [PullRequestState!]) { repository(owner: $owner, name: $repo) { From 46b46bf45ea0725d0232541e64737616bbc14d01 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:04:11 -0500 Subject: [PATCH 05/11] feat: add GitHub repo link icon next to repo dropdown Small GitHub octocat SVG icon appears after the repo dropdown when a repo is selected. Links to github.com/{owner}/{repo} in a new tab. Hover: turns white with subtle blue background. Tooltip on hover. --- client/src/pages/AppLayout.tsx | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/client/src/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index 178a227..5c76aed 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -453,6 +453,41 @@ export default function AppLayout() { )} + {/* GitHub repo link */} + {params.owner && params.repo && ( + { + e.currentTarget.style.color = '#dfe2eb'; + e.currentTarget.style.background = 'rgba(88,166,255,0.1)'; + }} + onMouseOut={(e) => { + e.currentTarget.style.color = '#8b949e'; + e.currentTarget.style.background = 'transparent'; + }} + > + + + + + )} + {/* Spacer */}
From d895a83ace302386cb865b70a249e38aa29c8253 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:10:16 -0500 Subject: [PATCH 06/11] fix: remove date range filtering from PRs view The date picker was filtering PRs by createdAt, showing '12 of 50 shown' which was confusing. PRs have their own filtering (state tabs + author + reviewer) and the date range doesn't apply meaningfully. Removed: useDateRange hook, DateRangePicker component, date-based filtering from the PR filter logic. The 'N of M shown' badge now only appears when author or reviewer filters are active. --- client/src/pages/PullRequestsPage.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index df26569..f593795 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -3,8 +3,6 @@ import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; import type { PullRequest } from '../lib/api.js'; -import { useDateRange } from '../contexts/DateRangeContext.js'; -import DateRangePicker from '../components/DateRangePicker.js'; type PRState = 'ALL' | 'OPEN' | 'CLOSED' | 'MERGED'; type SortMode = 'newest' | 'oldest' | 'most-comments' | 'most-reviews'; @@ -549,8 +547,6 @@ export default function PullRequestsPage() { const [authorFilter, setAuthorFilter] = useState(''); const [reviewerFilter, setReviewerFilter] = useState(''); - const { since, until } = useDateRange(); - const { data: prs, isLoading, error } = useQuery({ queryKey: ['pulls', owner, repo, activeState], queryFn: () => api.repos.pullRequests(owner!, repo!, activeState), @@ -609,11 +605,8 @@ export default function PullRequestsPage() { }) : []; - // Client-side date filtering on createdAt + // Client-side filtering (author/reviewer only — date range does not apply to PRs) const filteredPrs = sortedPrs.filter((pr) => { - const created = new Date(pr.createdAt).getTime(); - if (since && created < new Date(since).getTime()) return false; - if (until && created > new Date(until).getTime()) return false; if (authorFilter && pr.author?.login !== authorFilter) return false; if (reviewerFilter) { const hasReviewer = @@ -624,7 +617,7 @@ export default function PullRequestsPage() { return true; }); - const isFiltered = !!(since || until || authorFilter || reviewerFilter); + const isFiltered = !!(authorFilter || reviewerFilter); const totalCount = sortedPrs.length; const filteredCount = filteredPrs.length; @@ -662,9 +655,6 @@ export default function PullRequestsPage() {
- {/* Date range picker */} - - {/* Author filter */} Date: Tue, 14 Apr 2026 19:16:20 -0500 Subject: [PATCH 07/11] Revert "fix: remove date range filtering from PRs view" This reverts commit d895a83ace302386cb865b70a249e38aa29c8253. --- client/src/pages/PullRequestsPage.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index f593795..df26569 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -3,6 +3,8 @@ import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; import type { PullRequest } from '../lib/api.js'; +import { useDateRange } from '../contexts/DateRangeContext.js'; +import DateRangePicker from '../components/DateRangePicker.js'; type PRState = 'ALL' | 'OPEN' | 'CLOSED' | 'MERGED'; type SortMode = 'newest' | 'oldest' | 'most-comments' | 'most-reviews'; @@ -547,6 +549,8 @@ export default function PullRequestsPage() { const [authorFilter, setAuthorFilter] = useState(''); const [reviewerFilter, setReviewerFilter] = useState(''); + const { since, until } = useDateRange(); + const { data: prs, isLoading, error } = useQuery({ queryKey: ['pulls', owner, repo, activeState], queryFn: () => api.repos.pullRequests(owner!, repo!, activeState), @@ -605,8 +609,11 @@ export default function PullRequestsPage() { }) : []; - // Client-side filtering (author/reviewer only — date range does not apply to PRs) + // Client-side date filtering on createdAt const filteredPrs = sortedPrs.filter((pr) => { + const created = new Date(pr.createdAt).getTime(); + if (since && created < new Date(since).getTime()) return false; + if (until && created > new Date(until).getTime()) return false; if (authorFilter && pr.author?.login !== authorFilter) return false; if (reviewerFilter) { const hasReviewer = @@ -617,7 +624,7 @@ export default function PullRequestsPage() { return true; }); - const isFiltered = !!(authorFilter || reviewerFilter); + const isFiltered = !!(since || until || authorFilter || reviewerFilter); const totalCount = sortedPrs.length; const filteredCount = filteredPrs.length; @@ -655,6 +662,9 @@ export default function PullRequestsPage() {
+ {/* Date range picker */} + + {/* Author filter */} Date: Tue, 14 Apr 2026 19:16:52 -0500 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20improve=20PR=20filter=20badge=20?= =?UTF-8?q?=E2=80=94=20show=20only=20when=20results=20are=20reduced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reverted removal of date picker from PRs - Badge now says 'Showing 12 of 50 PRs' (clearer wording) - Badge only appears when filteredCount < totalCount (hidden when all PRs match, avoiding confusing '50 of 50 shown') - Date picker filters PRs by createdAt as intended --- client/src/pages/PullRequestsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index df26569..18efbf6 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -718,7 +718,7 @@ export default function PullRequestsPage() { ))} {/* Filter count badge */} - {isFiltered && !isLoading && prs && ( + {isFiltered && !isLoading && prs && filteredCount < totalCount && ( - {filteredCount} of {totalCount} shown + Showing {filteredCount} of {totalCount} PRs )}
From 443616fdb944eeac073eb2428de5505a807a611c Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:22:44 -0500 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20paginate=20all=20PRs=20=E2=80=94?= =?UTF-8?q?=20fetch=20beyond=20the=20first=2050?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GraphQL query was limited to 'first: 50' with no pagination, so repos with 100+ PRs were cut off. Now: - Bumped page size to 100 - Added cursor-based pagination loop to fetch ALL PRs - Added pageInfo { hasNextPage, endCursor } to the query --- server/src/services/github.service.ts | 32 ++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/server/src/services/github.service.ts b/server/src/services/github.service.ts index f477368..8b5df24 100644 --- a/server/src/services/github.service.ts +++ b/server/src/services/github.service.ts @@ -272,9 +272,13 @@ export class GitHubService { const upper = state?.toUpperCase(); const states = upper === 'ALL' ? ['OPEN', 'CLOSED', 'MERGED'] : upper ? [upper] : ['OPEN']; const query = ` - query($owner: String!, $repo: String!, $states: [PullRequestState!]) { + query($owner: String!, $repo: String!, $states: [PullRequestState!], $cursor: String) { repository(owner: $owner, name: $repo) { - pullRequests(first: 50, states: $states, orderBy: { field: UPDATED_AT, direction: DESC }) { + pullRequests(first: 100, states: $states, orderBy: { field: UPDATED_AT, direction: DESC }, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } nodes { number title @@ -386,13 +390,29 @@ export class GitHubService { }; } - const result = await this.graphqlWithAuth<{ + interface PRPageResult { repository: { - pullRequests: { nodes: RawPR[] }; + pullRequests: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: RawPR[]; + }; }; - }>(query, { owner, repo, states }); + } + + const allPRs: RawPR[] = []; + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const page: PRPageResult = await this.graphqlWithAuth(query, { owner, repo, states, cursor }); + + allPRs.push(...page.repository.pullRequests.nodes); + const pi = page.repository.pullRequests.pageInfo; + hasMore = pi.hasNextPage; + cursor = pi.endCursor; + } - return result.repository.pullRequests.nodes.map((pr) => { + return allPRs.map((pr) => { const lastCommitNode = pr.commits.nodes[0]; const rollup = lastCommitNode?.commit.statusCheckRollup ?? null; From 71a67b713e8838aea0ed008fa153b1154b495585 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:54:59 -0500 Subject: [PATCH 10/11] feat: auto-load all commits and branches within date range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed 'Load more commits' and 'Load more branches' buttons from both Commit Graph and Network Graph views. The date range picker now controls how much data is loaded — everything within the selected range is fetched automatically. Changes: - GraphPage: autoFetchAll=true, removed load-more button, shows loading indicator - NetworkPage: autoFetchAll=true, auto-loads remaining branches after main commits, removed both load-more buttons, shows loading indicator - Both views show a subtle 'Loading… N commits' indicator while fetching --- client/src/pages/GraphPage.tsx | 31 +++++--------- client/src/pages/NetworkPage.tsx | 73 +++++++++----------------------- 2 files changed, 30 insertions(+), 74 deletions(-) diff --git a/client/src/pages/GraphPage.tsx b/client/src/pages/GraphPage.tsx index e7026b5..388d2c7 100644 --- a/client/src/pages/GraphPage.tsx +++ b/client/src/pages/GraphPage.tsx @@ -49,15 +49,13 @@ export default function GraphPage() { branchMap, isLoading: commitsLoading, error, - fetchNextPage, - hasNextPage, isFetchingNextPage, } = useMultiBranchCommits( owner!, repo!, effectiveBranches, !!owner && !!repo && effectiveBranches.length > 0, - false, + true, // auto-fetch all pages within the date range since, until ); @@ -245,32 +243,23 @@ export default function GraphPage() {
)} - {/* Load more button */} - {hasNextPage && commits.length > 0 && ( + {/* Loading more indicator */} + {isFetchingNextPage && commits.length > 0 && (
- + Loading commits… {commits.length} loaded
)}
diff --git a/client/src/pages/NetworkPage.tsx b/client/src/pages/NetworkPage.tsx index f605033..89486e3 100644 --- a/client/src/pages/NetworkPage.tsx +++ b/client/src/pages/NetworkPage.tsx @@ -53,20 +53,18 @@ export default function NetworkPage() { branchMap: mainBranchMap, isLoading: commitsLoading, error, - fetchNextPage, - hasNextPage, isFetchingNextPage, } = useMultiBranchCommits( owner!, repo!, fetchBranches, !!owner && !!repo && fetchBranches.length > 0, - false, // don't auto-fetch all pages — user clicks Load More + true, // auto-fetch all pages within the date range since, until ); - // Phase 2: Load other live branches ON DEMAND (not automatic) + // Phase 2: Auto-load other live branches const otherBranches = effectiveBranches.filter((b) => b !== defaultBranch); const [loadedBranchIdx, setLoadedBranchIdx] = useState(0); const [extraCommits, setExtraCommits] = useState([]); @@ -126,6 +124,14 @@ export default function NetworkPage() { const hasMoreBranches = loadedBranchIdx < otherBranches.length; + // Auto-load remaining branches once main commits are done + useEffect(() => { + if (!commitsLoading && !isFetchingNextPage && hasMoreBranches && !loadingExtra) { + loadMoreBranches(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commitsLoading, isFetchingNextPage, hasMoreBranches, loadingExtra, loadedBranchIdx]); + // Merge main + extra commits const { commits, branchMap } = useMemo(() => { const commitMap = new Map(); @@ -377,28 +383,16 @@ export default function NetworkPage() { /> )} - {/* Load more commits */} - {!commitsLoading && !isFetchingNextPage && hasNextPage && commits.length > 0 && ( -
- -
- )} - {isFetchingNextPage && ( -
- Loading more commits… + {/* Loading indicator */} + {(isFetchingNextPage || loadingExtra) && commits.length > 0 && ( +
+ Loading… {commits.length} commits + {loadingExtra ? `, ${otherBranches.length - loadedBranchIdx + extraCommits.length > 0 ? loadedBranchIdx : 0} of ${otherBranches.length} branches` : ''}
)} @@ -417,33 +411,6 @@ export default function NetworkPage() { No commits found for the selected branch(es)
)} - - {/* Load more branches button */} - {!commitsLoading && hasMoreBranches && !loadingExtra && commits.length > 0 && ( -
- -
- )} {/* Commit detail sidebar */} From 6e9b59dd6623784822c80e4c03f7f07745702f51 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:56:45 -0500 Subject: [PATCH 11/11] feat: add 1-week and 2-week presets, default to 1 week Date range presets now: 1 week | 2 weeks | 1 month | 3 months | 6 months | 1 year | All time | Custom Default changed from 'Last month' to 'Last week' for faster initial loads. --- client/src/contexts/DateRangeContext.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/contexts/DateRangeContext.tsx b/client/src/contexts/DateRangeContext.tsx index 229ac00..29c4013 100644 --- a/client/src/contexts/DateRangeContext.tsx +++ b/client/src/contexts/DateRangeContext.tsx @@ -1,9 +1,11 @@ import { createContext, useContext, useState, useMemo, useEffect } from 'react'; import type { ReactNode } from 'react'; -export type TimeRange = '1m' | '3m' | '6m' | '1y' | 'all' | 'custom'; +export type TimeRange = '1w' | '2w' | '1m' | '3m' | '6m' | '1y' | 'all' | 'custom'; export const TIME_RANGE_LABELS: Record = { + '1w': 'Last week', + '2w': 'Last 2 weeks', '1m': 'Last month', '3m': 'Last 3 months', '6m': 'Last 6 months', @@ -36,7 +38,9 @@ export function getDateRange(range: TimeRange): { since?: string; until?: string const now = new Date(); const untilDate = localToday(); const since = new Date(now); - if (range === '1m') since.setMonth(since.getMonth() - 1); + if (range === '1w') since.setDate(since.getDate() - 7); + else if (range === '2w') since.setDate(since.getDate() - 14); + else if (range === '1m') since.setMonth(since.getMonth() - 1); else if (range === '3m') since.setMonth(since.getMonth() - 3); else if (range === '6m') since.setMonth(since.getMonth() - 6); else if (range === '1y') since.setFullYear(since.getFullYear() - 1); @@ -58,9 +62,9 @@ interface DateRangeContextValue { const DateRangeContext = createContext(null); export function DateRangeProvider({ children }: { children: ReactNode }) { - const [timeRange, setTimeRange] = useState('1m'); - const [customSince, setCustomSince] = useState(() => getDateRange('1m').since!.slice(0, 10)); - const [customUntil, setCustomUntil] = useState(() => getDateRange('1m').until!.slice(0, 10)); + const [timeRange, setTimeRange] = useState('1w'); + const [customSince, setCustomSince] = useState(() => getDateRange('1w').since!.slice(0, 10)); + const [customUntil, setCustomUntil] = useState(() => getDateRange('1w').until!.slice(0, 10)); // Sync date inputs when switching to a preset useEffect(() => {