token-larper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +334 -0
  2. package/Start-TokenLarper.vbs +5 -0
  3. package/bin/cli.js +34 -0
  4. package/llms.txt +59 -0
  5. package/package.json +61 -0
  6. package/scripts/docs-demo-server.ts +141 -0
  7. package/scripts/launch-on-default-desktop.ps1 +42 -0
  8. package/scripts/launch-silent.vbs +6 -0
  9. package/scripts/run-server.ps1 +59 -0
  10. package/scripts/tray-host.ps1 +181 -0
  11. package/src/ccusage.ts +1075 -0
  12. package/src/client/App.tsx +560 -0
  13. package/src/client/charts.tsx +341 -0
  14. package/src/client/components/Calendar.tsx +207 -0
  15. package/src/client/components/DateRangePicker.tsx +131 -0
  16. package/src/client/components/KpiCards.tsx +122 -0
  17. package/src/client/components/RankChip.tsx +22 -0
  18. package/src/client/components/SelectMenu.tsx +116 -0
  19. package/src/client/components/SettingsDialog.tsx +207 -0
  20. package/src/client/components/ThemeSettings.tsx +131 -0
  21. package/src/client/context.tsx +60 -0
  22. package/src/client/index.html +54 -0
  23. package/src/client/lib/aggregate.ts +266 -0
  24. package/src/client/lib/range.ts +87 -0
  25. package/src/client/lib/rank.ts +323 -0
  26. package/src/client/lib/shareCard.ts +139 -0
  27. package/src/client/logo.svg +8 -0
  28. package/src/client/main.tsx +12 -0
  29. package/src/client/styles.css +1113 -0
  30. package/src/client/themes.ts +268 -0
  31. package/src/client/utils.ts +61 -0
  32. package/src/client/views/Models.tsx +71 -0
  33. package/src/client/views/Overview.tsx +280 -0
  34. package/src/client/views/Projects.tsx +88 -0
  35. package/src/client/views/Rank.tsx +255 -0
  36. package/src/client/views/Sessions.tsx +108 -0
  37. package/src/client/views/Tools.tsx +114 -0
  38. package/src/projects.ts +214 -0
  39. package/src/server.ts +173 -0
  40. package/src/startup.ts +110 -0
  41. package/src/tray.ts +293 -0
  42. package/src/types.ts +155 -0
@@ -0,0 +1,341 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import type { HarnessId, TimePeriodRow } from "../types.ts";
3
+ import { formatCompactNumber, formatCurrency } from "./utils.ts";
4
+
5
+ export type ChartMode = "tokens-by-harness" | "cost-by-harness" | "token-types";
6
+ export type Granularity = "daily" | "weekly" | "monthly" | "yearly";
7
+
8
+ // Seven validated categorical slots (CVD-checked in this adjacency order, light and dark);
9
+ // everything past slot 7 folds into one neutral "Other" series.
10
+ export const SERIES_SLOTS = 7;
11
+ export const OTHER_SERIES = "var(--series-other)";
12
+
13
+ export function seriesColor(slot: number): string {
14
+ return slot < SERIES_SLOTS ? `var(--series-${slot + 1})` : OTHER_SERIES;
15
+ }
16
+
17
+ export interface SeriesInfo {
18
+ key: string;
19
+ name: string;
20
+ color: string;
21
+ }
22
+
23
+ type Cost = (item: { verifiedCost: number; estimatedCost: number }) => number;
24
+
25
+ const TOKEN_TYPES: { key: string; name: string; pick: (r: TimePeriodRow) => number }[] = [
26
+ { key: "cacheRead", name: "Cache read", pick: (r) => r.cacheReadTokens },
27
+ { key: "input", name: "Input", pick: (r) => r.inputTokens },
28
+ { key: "output", name: "Output", pick: (r) => r.outputTokens },
29
+ { key: "cacheWrite", name: "Cache write", pick: (r) => r.cacheCreationTokens },
30
+ ];
31
+
32
+ export const TOKEN_TYPE_SERIES: SeriesInfo[] = TOKEN_TYPES.map((t, i) => ({
33
+ key: t.key,
34
+ name: t.name,
35
+ color: seriesColor(i),
36
+ }));
37
+
38
+ /** Round an axis maximum up to 1/2/2.5/5 × 10^n so ticks land on clean numbers. */
39
+ function niceTicks(max: number, count = 4): number[] {
40
+ if (max <= 0) return [0];
41
+ const raw = max / count;
42
+ const pow = 10 ** Math.floor(Math.log10(raw));
43
+ const step = ([1, 2, 2.5, 5, 10].find((m) => m * pow >= raw) ?? 10) * pow;
44
+ const top = Math.ceil(max / step) * step;
45
+ return Array.from({ length: Math.round(top / step) + 1 }, (_, i) => i * step);
46
+ }
47
+
48
+ function formatAxis(value: number, isCost: boolean): string {
49
+ if (isCost) {
50
+ if (value >= 1000) return `$${formatCompactNumber(value)}`;
51
+ return Number.isInteger(value) ? `$${value}` : `$${value.toFixed(value < 1 ? 2 : 1)}`;
52
+ }
53
+ if (value === 0) return "0";
54
+ return formatCompactNumber(value).replace(/\.0+([KMB])$/, "$1").replace(/(\.\d*?)0+([KMB])$/, "$1$2");
55
+ }
56
+
57
+ export function formatPeriodLabel(row: TimePeriodRow, granularity: Granularity, short = false): string {
58
+ if (granularity === "daily") {
59
+ const d = new Date(`${row.period}T00:00:00`);
60
+ return d.toLocaleDateString("en-US", short
61
+ ? { month: "short", day: "numeric" }
62
+ : { weekday: "short", month: "short", day: "numeric", year: "numeric" });
63
+ }
64
+ if (granularity === "weekly") return short ? row.period.slice(5) : row.label;
65
+ return row.label;
66
+ }
67
+
68
+ interface UsageBarChartProps {
69
+ rows: TimePeriodRow[];
70
+ mode: ChartMode;
71
+ granularity: Granularity;
72
+ costOf: Cost;
73
+ series: SeriesInfo[];
74
+ seriesOf: (harness: HarnessId) => SeriesInfo;
75
+ onSelect: (row: TimePeriodRow) => void;
76
+ }
77
+
78
+ export function UsageBarChart({ rows, mode, granularity, costOf, series, seriesOf, onSelect }: UsageBarChartProps) {
79
+ const [active, setActive] = useState<number | null>(null);
80
+ const plotRef = useRef<HTMLDivElement>(null);
81
+ const [plotWidth, setPlotWidth] = useState(800);
82
+
83
+ useEffect(() => {
84
+ const el = plotRef.current;
85
+ if (!el) return;
86
+ const observer = new ResizeObserver(([entry]) => entry && setPlotWidth(entry.contentRect.width));
87
+ observer.observe(el);
88
+ return () => observer.disconnect();
89
+ }, [rows.length === 0]);
90
+ const isCost = mode === "cost-by-harness";
91
+
92
+ const stacks = useMemo(
93
+ () =>
94
+ rows.map((row) => {
95
+ const values = new Map<string, number>();
96
+ if (mode === "token-types") {
97
+ for (const t of TOKEN_TYPES) values.set(t.key, t.pick(row));
98
+ } else {
99
+ for (const hb of Object.values(row.byHarness)) {
100
+ const key = seriesOf(hb.harness).key;
101
+ values.set(key, (values.get(key) ?? 0) + (isCost ? costOf(hb) : hb.totalTokens));
102
+ }
103
+ }
104
+ const total = isCost ? costOf(row) : row.totalTokens;
105
+ return { row, total, values };
106
+ }),
107
+ [rows, mode, isCost, costOf, seriesOf]
108
+ );
109
+
110
+ const ticks = niceTicks(Math.max(0, ...stacks.map((s) => s.total)));
111
+ const yMax = ticks[ticks.length - 1] || 1;
112
+ // Roughly one date label per 64px, so labels never collide on narrow screens.
113
+ const labelEvery = Math.max(1, Math.ceil(rows.length / Math.max(2, Math.floor(plotWidth / 64))));
114
+ const current = active !== null ? stacks[active] : undefined;
115
+
116
+ function indexFromPointer(event: React.MouseEvent<HTMLDivElement>): number {
117
+ const box = event.currentTarget.getBoundingClientRect();
118
+ const i = Math.floor(((event.clientX - box.left) / box.width) * rows.length);
119
+ return Math.min(rows.length - 1, Math.max(0, i));
120
+ }
121
+
122
+ function handleKey(event: React.KeyboardEvent<HTMLDivElement>) {
123
+ if (!rows.length) return;
124
+ const last = rows.length - 1;
125
+ const i = active ?? last;
126
+ let next = i;
127
+ if (event.key === "ArrowLeft") next = Math.max(0, i - 1);
128
+ else if (event.key === "ArrowRight") next = Math.min(last, i + 1);
129
+ else if (event.key === "Home") next = 0;
130
+ else if (event.key === "End") next = last;
131
+ else if ((event.key === "Enter" || event.key === " ") && active !== null) {
132
+ event.preventDefault();
133
+ onSelect(rows[active]!);
134
+ return;
135
+ } else return;
136
+ event.preventDefault();
137
+ setActive(next);
138
+ }
139
+
140
+ if (rows.length === 0) {
141
+ return <div className="uchart-empty">No usage recorded for this filter.</div>;
142
+ }
143
+
144
+ // The tooltip sits beside the active bar (never over it), flipping sides past the midpoint.
145
+ const tipSide = active !== null && active >= rows.length / 2 ? "left" : "right";
146
+ const tipSeries = current
147
+ ? (mode === "token-types" ? TOKEN_TYPE_SERIES : series)
148
+ .map((s) => ({ ...s, value: current.values.get(s.key) ?? 0 }))
149
+ .filter((s) => s.value > 0)
150
+ .sort((a, b) => b.value - a.value)
151
+ : [];
152
+
153
+ return (
154
+ <div className="uchart">
155
+ <div className="uchart-plot" ref={plotRef}>
156
+ <div className="uchart-grid" aria-hidden="true">
157
+ {ticks.map((t) => (
158
+ <div key={t} className="uchart-gridline" style={{ bottom: `${(t / yMax) * 100}%` }}>
159
+ <span>{formatAxis(t, isCost)}</span>
160
+ </div>
161
+ ))}
162
+ </div>
163
+
164
+ <div
165
+ className="uchart-bars"
166
+ role="group"
167
+ tabIndex={0}
168
+ aria-label="Usage chart. Use left and right arrow keys to move between periods, Enter to open details."
169
+ onMouseMove={(e) => setActive(indexFromPointer(e))}
170
+ onMouseLeave={() => setActive(null)}
171
+ onClick={(e) => onSelect(rows[indexFromPointer(e)]!)}
172
+ onKeyDown={handleKey}
173
+ onBlur={() => setActive(null)}
174
+ >
175
+ {stacks.map((s, i) => {
176
+ const segs = (mode === "token-types" ? TOKEN_TYPE_SERIES : series)
177
+ .map((info) => ({ info, value: s.values.get(info.key) ?? 0 }))
178
+ .filter((seg) => seg.value > 0);
179
+ return (
180
+ <div key={s.row.period} className={`uchart-col ${active === i ? "is-active" : ""}`}>
181
+ {s.total > 0 && (
182
+ <div className="uchart-stack" style={{ height: `${Math.max(0.6, (s.total / yMax) * 100)}%` }}>
183
+ {segs.map((seg) => (
184
+ <div key={seg.info.key} className="uchart-seg" style={{ flexGrow: seg.value, background: seg.info.color }} />
185
+ ))}
186
+ </div>
187
+ )}
188
+ </div>
189
+ );
190
+ })}
191
+ </div>
192
+
193
+ {current && (
194
+ <div
195
+ className={`uchart-tip side-${tipSide}`}
196
+ style={{ left: `calc(var(--axis-w) + (100% - var(--axis-w)) * ${(active! + 0.5) / rows.length})` }}
197
+ role="status"
198
+ >
199
+ <div className="uchart-tip-head">
200
+ <strong>{formatPeriodLabel(current.row, granularity)}</strong>
201
+ {current.total > 0 && (
202
+ <span>{isCost ? formatCurrency(current.total) : `${formatCompactNumber(current.total)} tokens`}</span>
203
+ )}
204
+ </div>
205
+ {current.total === 0 ? (
206
+ <div className="uchart-tip-empty">No usage</div>
207
+ ) : (
208
+ <ul>
209
+ {tipSeries.map((s) => (
210
+ <li key={s.key}>
211
+ <i style={{ background: s.color }} />
212
+ <span>{s.name}</span>
213
+ <b>{isCost ? formatCurrency(s.value) : formatCompactNumber(s.value)}</b>
214
+ </li>
215
+ ))}
216
+ </ul>
217
+ )}
218
+ {!isCost && current.total > 0 && (
219
+ <div className="uchart-tip-foot">{formatCurrency(costOf(current.row))} · click for models</div>
220
+ )}
221
+ </div>
222
+ )}
223
+ </div>
224
+
225
+ <div className="uchart-xaxis" aria-hidden="true">
226
+ {rows.map((row, i) => (
227
+ <span key={row.period}>
228
+ {i % labelEvery === (rows.length - 1) % labelEvery && <em>{formatPeriodLabel(row, granularity, true)}</em>}
229
+ </span>
230
+ ))}
231
+ </div>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ // ---------------------------------------------------------------------------
237
+
238
+ interface ActivityCalendarProps {
239
+ days: TimePeriodRow[];
240
+ costOf: Cost;
241
+ weeks?: number;
242
+ /** Cells outside this span are shaded back so the selected date range stands out. */
243
+ rangeStart?: string;
244
+ rangeEnd?: string;
245
+ }
246
+
247
+ function toDateKey(d: Date): string {
248
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
249
+ }
250
+
251
+ export function ActivityCalendar({ days, costOf, weeks, rangeStart, rangeEnd }: ActivityCalendarProps) {
252
+ const [hovered, setHovered] = useState<string | null>(null);
253
+ const [width, setWidth] = useState(0);
254
+ const containerRef = useRef<HTMLDivElement>(null);
255
+ const visibleWeeks = weeks ?? (width ? Math.max(14, Math.min(52, Math.floor((width + 3) / 18))) : 26);
256
+
257
+ useEffect(() => {
258
+ const container = containerRef.current;
259
+ if (!container) return;
260
+ const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? container.clientWidth));
261
+ observer.observe(container);
262
+ return () => observer.disconnect();
263
+ }, []);
264
+
265
+ const { columns, months, levels, byDate, activeCount } = useMemo(() => {
266
+ const byDate = new Map(days.map((d) => [d.period, d]));
267
+ const today = new Date();
268
+ today.setHours(0, 0, 0, 0);
269
+ // Columns are Monday-first weeks ending with the current week.
270
+ const start = new Date(today);
271
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7) - (visibleWeeks - 1) * 7);
272
+
273
+ const columns: { key: string; future: boolean }[][] = [];
274
+ const months: { col: number; label: string }[] = [];
275
+ const cursor = new Date(start);
276
+ for (let w = 0; w < visibleWeeks; w++) {
277
+ const col: { key: string; future: boolean }[] = [];
278
+ for (let d = 0; d < 7; d++) {
279
+ if (cursor.getDate() === 1 || (w === 0 && d === 0)) {
280
+ months.push({ col: w, label: cursor.toLocaleDateString("en-US", { month: "short" }) });
281
+ }
282
+ col.push({ key: toDateKey(cursor), future: cursor > today });
283
+ cursor.setDate(cursor.getDate() + 1);
284
+ }
285
+ columns.push(col);
286
+ }
287
+
288
+ // Quartiles of the visible active days, so one huge day doesn't wash out the rest.
289
+ const visible = columns.flat().map((c) => byDate.get(c.key)?.totalTokens ?? 0).filter((v) => v > 0).sort((a, b) => a - b);
290
+ const q = (p: number) => visible[Math.min(visible.length - 1, Math.floor(p * visible.length))] ?? 0;
291
+ const cuts = [q(0.25), q(0.5), q(0.75)];
292
+ const levels = (v: number) => (v <= 0 ? 0 : v <= cuts[0]! ? 1 : v <= cuts[1]! ? 2 : v <= cuts[2]! ? 3 : 4);
293
+
294
+ return { columns, months: months.filter((m, i, all) => i === 0 || m.col - all[i - 1]!.col >= 3), levels, byDate, activeCount: visible.length };
295
+ }, [days, visibleWeeks]);
296
+
297
+ const hoveredRow = hovered ? byDate.get(hovered) : undefined;
298
+ const hoveredLabel = hovered
299
+ ? new Date(`${hovered}T00:00:00`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
300
+ : "";
301
+
302
+ return (
303
+ <div className="cal" ref={containerRef}>
304
+ <div className="cal-months" aria-hidden="true" style={{ gridTemplateColumns: `repeat(${visibleWeeks}, minmax(0, 1fr))` }}>
305
+ {months.map((m) => (
306
+ <span key={`${m.col}-${m.label}`} style={{ gridColumn: `${m.col + 1} / span 3` }}>{m.label}</span>
307
+ ))}
308
+ </div>
309
+ <div
310
+ className="cal-grid"
311
+ role="img"
312
+ aria-label={`Daily usage calendar for the last ${visibleWeeks} weeks: ${activeCount} active days.`}
313
+ style={{ gridTemplateColumns: `repeat(${visibleWeeks}, minmax(0, 1fr))` }}
314
+ onMouseLeave={() => setHovered(null)}
315
+ >
316
+ {columns.map((col, w) =>
317
+ col.map((cell, d) => (
318
+ <div
319
+ key={cell.key}
320
+ className={`cal-cell ${cell.future ? "is-future" : `lvl-${levels(byDate.get(cell.key)?.totalTokens ?? 0)}`} ${hovered === cell.key ? "is-hovered" : ""} ${rangeStart && rangeEnd && (cell.key < rangeStart || cell.key > rangeEnd) ? "is-outside" : ""}`}
321
+ style={{ gridColumn: w + 1, gridRow: d + 1 }}
322
+ onMouseEnter={() => !cell.future && setHovered(cell.key)}
323
+ />
324
+ ))
325
+ )}
326
+ </div>
327
+ <div className="cal-foot">
328
+ <span className="cal-readout">
329
+ {hovered
330
+ ? hoveredRow
331
+ ? <><strong>{hoveredLabel}</strong> · {formatCompactNumber(hoveredRow.totalTokens)} tokens · {formatCurrency(costOf(hoveredRow))}</>
332
+ : <><strong>{hoveredLabel}</strong> · No usage</>
333
+ : `${activeCount} active days in the last ${visibleWeeks} weeks`}
334
+ </span>
335
+ <span className="cal-scale" aria-hidden="true">
336
+ Less {[0, 1, 2, 3, 4].map((l) => <i key={l} className={`cal-cell lvl-${l}`} />)} More
337
+ </span>
338
+ </div>
339
+ </div>
340
+ );
341
+ }
@@ -0,0 +1,207 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { localDateKey } from "../utils.ts";
4
+ import { parseDay, todayKey } from "../lib/range.ts";
5
+
6
+ interface CalendarProps {
7
+ startDate: string;
8
+ endDate: string;
9
+ minDate?: string;
10
+ maxDate?: string;
11
+ onRangeSelect: (start: string, end: string) => void;
12
+ }
13
+
14
+ const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
15
+ const MONTH_NAMES = [
16
+ "January", "February", "March", "April", "May", "June",
17
+ "July", "August", "September", "October", "November", "December",
18
+ ];
19
+
20
+ export function Calendar({
21
+ startDate,
22
+ endDate,
23
+ minDate,
24
+ maxDate = todayKey(),
25
+ onRangeSelect,
26
+ }: CalendarProps) {
27
+ const initialDate = useMemo(() => {
28
+ try {
29
+ return parseDay(endDate || todayKey());
30
+ } catch {
31
+ return new Date();
32
+ }
33
+ }, [endDate]);
34
+
35
+ const [viewDate, setViewDate] = useState<Date>(
36
+ new Date(initialDate.getFullYear(), initialDate.getMonth(), 1)
37
+ );
38
+ const [rangeStart, setRangeStart] = useState<string | null>(null);
39
+ const [hoverDate, setHoverDate] = useState<string | null>(null);
40
+
41
+ const year = viewDate.getFullYear();
42
+ const month = viewDate.getMonth();
43
+
44
+ function prevMonth() {
45
+ setViewDate(new Date(year, month - 1, 1));
46
+ }
47
+
48
+ function nextMonth() {
49
+ const next = new Date(year, month + 1, 1);
50
+ if (!maxDate || localDateKey(next).slice(0, 7) <= maxDate.slice(0, 7)) {
51
+ setViewDate(next);
52
+ }
53
+ }
54
+
55
+ // Days in month grid calculation
56
+ const calendarDays = useMemo(() => {
57
+ const firstDayIndex = new Date(year, month, 1).getDay();
58
+ const daysInCurrentMonth = new Date(year, month + 1, 0).getDate();
59
+ const daysInPrevMonth = new Date(year, month, 0).getDate();
60
+
61
+ const days: { key: string; dayNum: number; isCurrentMonth: boolean }[] = [];
62
+
63
+ // Leading days from previous month
64
+ for (let i = firstDayIndex - 1; i >= 0; i--) {
65
+ const d = daysInPrevMonth - i;
66
+ const prevDate = new Date(year, month - 1, d);
67
+ days.push({
68
+ key: localDateKey(prevDate),
69
+ dayNum: d,
70
+ isCurrentMonth: false,
71
+ });
72
+ }
73
+
74
+ // Days in current month
75
+ for (let d = 1; d <= daysInCurrentMonth; d++) {
76
+ const curDate = new Date(year, month, d);
77
+ days.push({
78
+ key: localDateKey(curDate),
79
+ dayNum: d,
80
+ isCurrentMonth: true,
81
+ });
82
+ }
83
+
84
+ // Trailing days from next month to complete row grid (multiples of 7)
85
+ const remaining = (7 - (days.length % 7)) % 7;
86
+ for (let d = 1; d <= remaining; d++) {
87
+ const nextDate = new Date(year, month + 1, d);
88
+ days.push({
89
+ key: localDateKey(nextDate),
90
+ dayNum: d,
91
+ isCurrentMonth: false,
92
+ });
93
+ }
94
+
95
+ return days;
96
+ }, [year, month]);
97
+
98
+ // Determine active start/end for display (including hover preview)
99
+ const effectiveStart = rangeStart || startDate;
100
+ const effectiveEnd = rangeStart
101
+ ? (hoverDate && hoverDate >= rangeStart ? hoverDate : rangeStart)
102
+ : endDate;
103
+
104
+ const [activeStart, activeEnd] =
105
+ effectiveStart <= effectiveEnd
106
+ ? [effectiveStart, effectiveEnd]
107
+ : [effectiveEnd, effectiveStart];
108
+
109
+ function handleDayClick(key: string, isDisabled: boolean) {
110
+ if (isDisabled) return;
111
+
112
+ if (!rangeStart) {
113
+ // First click: start picking
114
+ setRangeStart(key);
115
+ setHoverDate(null);
116
+ } else {
117
+ // Second click: finish picking
118
+ if (key < rangeStart) {
119
+ onRangeSelect(key, rangeStart);
120
+ } else {
121
+ onRangeSelect(rangeStart, key);
122
+ }
123
+ setRangeStart(null);
124
+ setHoverDate(null);
125
+ }
126
+ }
127
+
128
+ const today = todayKey();
129
+ const canGoNext = !maxDate || localDateKey(new Date(year, month + 1, 1)).slice(0, 7) <= maxDate.slice(0, 7);
130
+
131
+ return (
132
+ <div className="cal-container">
133
+ {/* Calendar Header: Month + Navigation */}
134
+ <div className="cal-header">
135
+ <span className="cal-title">
136
+ {MONTH_NAMES[month]} {year}
137
+ </span>
138
+ <div className="cal-nav">
139
+ <button
140
+ type="button"
141
+ className="cal-nav-btn"
142
+ onClick={prevMonth}
143
+ aria-label="Previous month"
144
+ >
145
+ <ChevronLeft size={16} />
146
+ </button>
147
+ <button
148
+ type="button"
149
+ className="cal-nav-btn"
150
+ onClick={nextMonth}
151
+ disabled={!canGoNext}
152
+ aria-label="Next month"
153
+ >
154
+ <ChevronRight size={16} />
155
+ </button>
156
+ </div>
157
+ </div>
158
+
159
+ {/* Weekday Row */}
160
+ <div className="cal-weekdays">
161
+ {WEEKDAYS.map((w) => (
162
+ <span key={w} className="cal-weekday">
163
+ {w}
164
+ </span>
165
+ ))}
166
+ </div>
167
+
168
+ {/* Day Cells Grid */}
169
+ <div className="cal-grid">
170
+ {calendarDays.map(({ key, dayNum, isCurrentMonth }) => {
171
+ const isToday = key === today;
172
+ const isDisabled =
173
+ Boolean(maxDate && key > maxDate) ||
174
+ Boolean(minDate && key < minDate);
175
+
176
+ const isSelectedStart = key === activeStart;
177
+ const isSelectedEnd = key === activeEnd;
178
+ const isInRange = key >= activeStart && key <= activeEnd;
179
+ const isSingle = activeStart === activeEnd && isSelectedStart;
180
+
181
+ let cellClass = "cal-day";
182
+ if (!isCurrentMonth) cellClass += " is-other-month";
183
+ if (isDisabled) cellClass += " is-disabled";
184
+ if (isToday) cellClass += " is-today";
185
+ if (isInRange) cellClass += " is-in-range";
186
+ if (isSelectedStart) cellClass += " is-range-start";
187
+ if (isSelectedEnd) cellClass += " is-range-end";
188
+ if (isSingle) cellClass += " is-single";
189
+
190
+ return (
191
+ <button
192
+ key={key}
193
+ type="button"
194
+ className={cellClass}
195
+ disabled={isDisabled}
196
+ onClick={() => handleDayClick(key, isDisabled)}
197
+ onMouseEnter={() => rangeStart && setHoverDate(key)}
198
+ title={key}
199
+ >
200
+ <span>{dayNum}</span>
201
+ </button>
202
+ );
203
+ })}
204
+ </div>
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,131 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Calendar as CalendarIcon, Check, ChevronDown } from "lucide-react";
3
+ import {
4
+ RANGE_PRESETS,
5
+ formatSpan,
6
+ presetRange,
7
+ rangeLabel,
8
+ todayKey,
9
+ type DateRange,
10
+ } from "../lib/range.ts";
11
+ import { Calendar } from "./Calendar.tsx";
12
+
13
+ interface Props {
14
+ range: DateRange;
15
+ firstDay: string | undefined;
16
+ onChange: (range: DateRange) => void;
17
+ }
18
+
19
+ export function DateRangePicker({ range, firstDay, onChange }: Props) {
20
+ const [open, setOpen] = useState(false);
21
+ const [selectedStart, setSelectedStart] = useState(range.start);
22
+ const [selectedEnd, setSelectedEnd] = useState(range.end);
23
+ const rootRef = useRef<HTMLDivElement>(null);
24
+ const triggerRef = useRef<HTMLButtonElement>(null);
25
+ const today = todayKey();
26
+
27
+ useEffect(() => {
28
+ if (!open) return;
29
+ setSelectedStart(range.start);
30
+ setSelectedEnd(range.end);
31
+ rootRef.current?.querySelector<HTMLButtonElement>(".range-option[aria-pressed='true']")?.focus();
32
+ const onPointer = (e: PointerEvent) => {
33
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
34
+ };
35
+ const onKey = (e: KeyboardEvent) => {
36
+ if (e.key === "Escape") {
37
+ setOpen(false);
38
+ triggerRef.current?.focus();
39
+ }
40
+ };
41
+ document.addEventListener("pointerdown", onPointer);
42
+ document.addEventListener("keydown", onKey);
43
+ return () => {
44
+ document.removeEventListener("pointerdown", onPointer);
45
+ document.removeEventListener("keydown", onKey);
46
+ };
47
+ }, [open, range.start, range.end]);
48
+
49
+ function choose(next: DateRange) {
50
+ onChange(next);
51
+ setOpen(false);
52
+ triggerRef.current?.focus();
53
+ }
54
+
55
+ function handleCalendarRangeSelect(start: string, end: string) {
56
+ setSelectedStart(start);
57
+ setSelectedEnd(end);
58
+ }
59
+
60
+ function applyCustomRange() {
61
+ choose({ preset: "custom", start: selectedStart, end: selectedEnd });
62
+ }
63
+
64
+ return (
65
+ <div className="range-picker" ref={rootRef}>
66
+ <span className="field-label" id="range-label">Date range</span>
67
+ <button
68
+ ref={triggerRef}
69
+ type="button"
70
+ className="range-trigger"
71
+ aria-haspopup="dialog"
72
+ aria-expanded={open}
73
+ aria-labelledby="range-label range-value"
74
+ onClick={() => setOpen((v) => !v)}
75
+ >
76
+ <CalendarIcon size={15} aria-hidden="true" />
77
+ <span id="range-value" className="range-value">
78
+ <strong>{rangeLabel(range)}</strong>
79
+ <span className="range-subspan">{formatSpan(range.start, range.end)}</span>
80
+ </span>
81
+ <ChevronDown size={14} className="range-chevron" aria-hidden="true" />
82
+ </button>
83
+
84
+ {open && (
85
+ <div className="range-popover" role="dialog" aria-label="Choose a date range">
86
+ <div className="range-options">
87
+ <span className="range-presets-title">Presets</span>
88
+ {RANGE_PRESETS.map((p) => (
89
+ <button
90
+ key={p.id}
91
+ type="button"
92
+ className="range-option"
93
+ aria-pressed={range.preset === p.id}
94
+ onClick={() => choose(presetRange(p.id, firstDay))}
95
+ >
96
+ <span className="range-check">
97
+ {range.preset === p.id && <Check size={14} strokeWidth={3} />}
98
+ </span>
99
+ {p.label}
100
+ </button>
101
+ ))}
102
+ </div>
103
+
104
+ <div className="range-custom">
105
+ <Calendar
106
+ startDate={selectedStart}
107
+ endDate={selectedEnd}
108
+ minDate={firstDay}
109
+ maxDate={today}
110
+ onRangeSelect={handleCalendarRangeSelect}
111
+ />
112
+ <div className="range-custom-footer">
113
+ <div className="range-custom-summary">
114
+ <span className="range-custom-span-text">
115
+ {formatSpan(selectedStart, selectedEnd)}
116
+ </span>
117
+ </div>
118
+ <button
119
+ type="button"
120
+ className="btn btn-primary range-apply-btn"
121
+ onClick={applyCustomRange}
122
+ >
123
+ Apply Range
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ )}
129
+ </div>
130
+ );
131
+ }