Skip to content

Commit d8db399

Browse files
feat(web): add date range filter to commit history view
Adds a date range dropdown next to the author filter using shadcn's range Calendar in a Popover. URL state is `?since=YYYY-MM-DD&until=YYYY-MM-DD` so ranges are shareable. Two clicks are required to form a range even when one is already selected — the component tracks an in-progress draft locally and intercepts react-day-picker v9's "adjust" behavior that would otherwise commit a new range in a single click. Single-day ranges require two clicks of the same date. The upper bound is made inclusive by appending end-of-day time before passing to git log. `since` also gets explicit midnight time to sidestep git's approxidate parser, which silently mishandles some bare YYYY-MM-DD forms. Future dates are disabled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2001211 commit d8db399

6 files changed

Lines changed: 516 additions & 6 deletions

File tree

packages/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"@radix-ui/react-scroll-area": "^1.1.0",
8282
"@radix-ui/react-select": "^2.1.6",
8383
"@radix-ui/react-separator": "^1.1.0",
84-
"@radix-ui/react-slot": "^1.1.1",
84+
"@radix-ui/react-slot": "^1.2.4",
8585
"@radix-ui/react-switch": "^1.2.4",
8686
"@radix-ui/react-tabs": "^1.1.2",
8787
"@radix-ui/react-toast": "^1.2.2",
@@ -166,6 +166,7 @@
166166
"pretty-bytes": "^6.1.1",
167167
"psl": "^1.15.0",
168168
"react": "19.2.4",
169+
"react-day-picker": "^9.14.0",
169170
"react-device-detect": "^2.2.3",
170171
"react-dom": "19.2.4",
171172
"react-hook-form": "^7.53.0",

packages/web/src/app/(app)/browse/[...path]/components/commitsPanel.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,39 @@ import { AuthorFilter } from "./authorFilter";
99
import { dedupeCommitAuthorsByEmail, escapeGitBreLiteral } from "./commitAuthors";
1010
import { CommitRow } from "./commitRow";
1111
import { CommitsPagination } from "./commitsPagination";
12+
import { DateFilter } from "./dateFilter";
1213

1314
interface CommitsPanelProps {
1415
path: string;
1516
repoName: string;
1617
revisionName?: string;
1718
page: number;
1819
author?: string;
20+
since?: string;
21+
until?: string;
1922
}
2023

2124
const COMMITS_PER_PAGE = 35;
2225
const AUTHORS_PER_PAGE = 100;
2326

24-
export const CommitsPanel = async ({ path, repoName, revisionName, page, author }: CommitsPanelProps) => {
27+
export const CommitsPanel = async ({ path, repoName, revisionName, page, author, since, until }: CommitsPanelProps) => {
2528
const skip = (page - 1) * COMMITS_PER_PAGE;
2629

30+
// The URL stores dates as YYYY-MM-DD. Always pass explicit timestamps to
31+
// git: the bare-date form triggers approxidate quirks (returning 0 commits
32+
// in some cases), and bare `--until=YYYY-MM-DD` would also exclude commits
33+
// made on that day since it resolves to midnight at the start.
34+
const sinceForGit = since ? `${since}T00:00:00` : undefined;
35+
const untilForGit = until ? `${until}T23:59:59` : undefined;
36+
2737
const [commitsResponse, repoInfoResponse, authorsResponse] = await Promise.all([
2838
listCommits({
2939
repo: repoName,
3040
path: path || undefined,
3141
ref: revisionName,
3242
author: author ? escapeGitBreLiteral(author) : undefined,
43+
since: sinceForGit,
44+
until: untilForGit,
3345
maxCount: COMMITS_PER_PAGE,
3446
skip,
3547
}),
@@ -86,7 +98,10 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author
8698
revisionName={revisionName}
8799
/>
88100
</div>
89-
<AuthorFilter authors={authors} selectedAuthor={author} />
101+
<div className="flex flex-row items-center gap-2 flex-shrink-0">
102+
<AuthorFilter authors={authors} selectedAuthor={author} />
103+
<DateFilter since={since} until={until} />
104+
</div>
90105
</div>
91106
<Separator />
92107
<div className="flex-1 overflow-auto">
@@ -115,7 +130,7 @@ export const CommitsPanel = async ({ path, repoName, revisionName, page, author
115130
page={page}
116131
perPage={COMMITS_PER_PAGE}
117132
totalCount={totalCount}
118-
extraParams={{ author }}
133+
extraParams={{ author, since, until }}
119134
/>
120135
</div>
121136
</div>
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useMemo, useState } from "react";
4+
import { useRouter, usePathname, useSearchParams } from "next/navigation";
5+
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
6+
import { format } from "date-fns";
7+
import type { DateRange } from "react-day-picker";
8+
9+
import { Button } from "@/components/ui/button";
10+
import { Calendar } from "@/components/ui/calendar";
11+
import {
12+
Popover,
13+
PopoverContent,
14+
PopoverTrigger,
15+
} from "@/components/ui/popover";
16+
17+
interface DateFilterProps {
18+
since?: string;
19+
until?: string;
20+
}
21+
22+
// Parse 'YYYY-MM-DD' as a date in the local calendar (not UTC midnight).
23+
const parseLocalDate = (s: string): Date | undefined => {
24+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
25+
if (!match) {
26+
return undefined;
27+
}
28+
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
29+
};
30+
31+
const formatLocalDate = (d: Date): string => {
32+
const y = d.getFullYear();
33+
const m = String(d.getMonth() + 1).padStart(2, '0');
34+
const day = String(d.getDate()).padStart(2, '0');
35+
return `${y}-${m}-${day}`;
36+
};
37+
38+
const formatLabel = (from: Date | undefined, to: Date | undefined): string => {
39+
if (!from && !to) {
40+
return 'All time';
41+
}
42+
const currentYear = new Date().getFullYear();
43+
const fmt = (d: Date) =>
44+
d.getFullYear() === currentYear ? format(d, 'MMM d') : format(d, 'MMM d, yyyy');
45+
46+
if (from && to) {
47+
if (formatLocalDate(from) === formatLocalDate(to)) {
48+
return fmt(from);
49+
}
50+
return `${fmt(from)} - ${fmt(to)}`;
51+
}
52+
return fmt((from ?? to) as Date);
53+
};
54+
55+
export const DateFilter = ({ since, until }: DateFilterProps) => {
56+
const router = useRouter();
57+
const pathname = usePathname();
58+
const searchParams = useSearchParams();
59+
60+
const [isOpen, setIsOpen] = useState(false);
61+
const [timeZone, setTimeZone] = useState<string | undefined>(undefined);
62+
63+
const fromDate = useMemo(() => (since ? parseLocalDate(since) : undefined), [since]);
64+
const toDate = useMemo(() => (until ? parseLocalDate(until) : undefined), [until]);
65+
66+
const [month, setMonth] = useState<Date | undefined>(fromDate);
67+
68+
useEffect(() => {
69+
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
70+
}, []);
71+
72+
const selectedRange: DateRange | undefined = useMemo(() => {
73+
if (!fromDate && !toDate) {
74+
return undefined;
75+
}
76+
return { from: fromDate, to: toDate };
77+
}, [fromDate, toDate]);
78+
79+
// Track in-progress selection locally so DayPicker can distinguish between
80+
// a first click (`{from, to: undefined}`) and a completed range. Without
81+
// this, controlling `selected` directly off the URL would make every click
82+
// look like a fresh range start.
83+
const [draftRange, setDraftRange] = useState<DateRange | undefined>(selectedRange);
84+
85+
// Sync the draft with the URL whenever the popover is (re)opened, so a
86+
// half-finished selection from a previous session doesn't carry over.
87+
useEffect(() => {
88+
if (isOpen) {
89+
setDraftRange(selectedRange);
90+
}
91+
}, [isOpen, selectedRange]);
92+
93+
const navigateWithRange = useCallback(
94+
(from: Date | undefined, to: Date | undefined) => {
95+
const params = new URLSearchParams(searchParams);
96+
if (from) {
97+
params.set('since', formatLocalDate(from));
98+
} else {
99+
params.delete('since');
100+
}
101+
if (to) {
102+
params.set('until', formatLocalDate(to));
103+
} else {
104+
params.delete('until');
105+
}
106+
params.delete('page');
107+
const query = params.toString();
108+
setIsOpen(false);
109+
router.push(`${pathname}${query ? `?${query}` : ''}`);
110+
},
111+
[pathname, router, searchParams],
112+
);
113+
114+
const onSelect = useCallback(
115+
(selected: DateRange | undefined) => {
116+
const draftWasComplete = Boolean(draftRange?.from && draftRange.to);
117+
const draftWasPartial = Boolean(draftRange?.from && !draftRange.to);
118+
119+
// When a complete range already exists, react-day-picker v9 adjusts
120+
// the existing range based on where the click lands (moving one
121+
// endpoint), producing a new complete range in a single click. We
122+
// require two clicks to form a new range, so intercept that case:
123+
// infer the clicked date and demote to a partial range.
124+
if (draftWasComplete && selected?.from && selected.to) {
125+
const prevFromTime = draftRange!.from!.getTime();
126+
const prevToTime = draftRange!.to!.getTime();
127+
const fromIsNew =
128+
selected.from.getTime() !== prevFromTime &&
129+
selected.from.getTime() !== prevToTime;
130+
const toIsNew =
131+
selected.to.getTime() !== prevFromTime &&
132+
selected.to.getTime() !== prevToTime;
133+
// Prefer whichever endpoint is actually new; fall back to
134+
// `from` if both happen to match (shouldn't happen in practice).
135+
const clickedDate = fromIsNew
136+
? selected.from
137+
: toIsNew
138+
? selected.to
139+
: selected.from;
140+
setDraftRange({ from: clickedDate, to: undefined });
141+
return;
142+
}
143+
144+
// react-day-picker v9 can also return a complete single-day range
145+
// (`{from: D, to: D}`) on a single click when there was no prior
146+
// partial selection. Demote that to a partial range too.
147+
const isSingleDay =
148+
selected?.from &&
149+
selected.to &&
150+
selected.from.getTime() === selected.to.getTime();
151+
152+
if (isSingleDay && !draftWasPartial) {
153+
setDraftRange({ from: selected!.from, to: undefined });
154+
return;
155+
}
156+
157+
setDraftRange(selected);
158+
if (selected?.from && selected.to) {
159+
navigateWithRange(selected.from, selected.to);
160+
}
161+
},
162+
[draftRange, navigateWithRange],
163+
);
164+
165+
const onClear = useCallback(() => {
166+
navigateWithRange(undefined, undefined);
167+
}, [navigateWithRange]);
168+
169+
const onToday = useCallback(() => {
170+
setMonth(new Date());
171+
}, []);
172+
173+
const label = formatLabel(fromDate, toDate);
174+
const hasFilter = Boolean(fromDate || toDate);
175+
176+
return (
177+
<Popover open={isOpen} onOpenChange={setIsOpen}>
178+
<PopoverTrigger asChild>
179+
<Button
180+
variant="outline"
181+
size="sm"
182+
className="h-8 gap-2 flex-shrink-0"
183+
aria-label="Filter by date"
184+
>
185+
<CalendarIcon className="h-4 w-4 flex-shrink-0" />
186+
<span className="text-sm truncate max-w-[180px]">{label}</span>
187+
<ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
188+
</Button>
189+
</PopoverTrigger>
190+
<PopoverContent className="w-auto p-0" align="start">
191+
<Calendar
192+
mode="range"
193+
captionLayout="dropdown"
194+
selected={draftRange}
195+
onSelect={onSelect}
196+
month={month}
197+
onMonthChange={setMonth}
198+
timeZone={timeZone}
199+
numberOfMonths={1}
200+
disabled={{ after: new Date() }}
201+
/>
202+
<div className="flex flex-row items-center gap-4 px-3 py-2 border-t">
203+
<Button
204+
variant="link"
205+
size="sm"
206+
onClick={onClear}
207+
disabled={!hasFilter}
208+
className="h-auto p-0 text-foreground font-medium"
209+
>
210+
Clear
211+
</Button>
212+
<Button
213+
variant="link"
214+
size="sm"
215+
onClick={onToday}
216+
className="h-auto p-0"
217+
>
218+
Today
219+
</Button>
220+
</div>
221+
</PopoverContent>
222+
</Popover>
223+
);
224+
};

packages/web/src/app/(app)/browse/[...path]/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ interface BrowsePageProps {
7878
searchParams: Promise<{
7979
page?: string;
8080
author?: string;
81+
since?: string;
82+
until?: string;
8183
}>;
8284
}
8385

@@ -93,6 +95,8 @@ export default async function BrowsePage(props: BrowsePageProps) {
9395

9496
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1);
9597
const author = searchParams.author || undefined;
98+
const since = searchParams.since || undefined;
99+
const until = searchParams.until || undefined;
96100

97101
return (
98102
<div className="flex flex-col h-full">
@@ -115,6 +119,8 @@ export default async function BrowsePage(props: BrowsePageProps) {
115119
revisionName={revisionName}
116120
page={page}
117121
author={author}
122+
since={since}
123+
until={until}
118124
/>
119125
) : (
120126
<TreePreviewPanel

0 commit comments

Comments
 (0)