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.
- package/README.md +334 -0
- package/Start-TokenLarper.vbs +5 -0
- package/bin/cli.js +34 -0
- package/llms.txt +59 -0
- package/package.json +61 -0
- package/scripts/docs-demo-server.ts +141 -0
- package/scripts/launch-on-default-desktop.ps1 +42 -0
- package/scripts/launch-silent.vbs +6 -0
- package/scripts/run-server.ps1 +59 -0
- package/scripts/tray-host.ps1 +181 -0
- package/src/ccusage.ts +1075 -0
- package/src/client/App.tsx +560 -0
- package/src/client/charts.tsx +341 -0
- package/src/client/components/Calendar.tsx +207 -0
- package/src/client/components/DateRangePicker.tsx +131 -0
- package/src/client/components/KpiCards.tsx +122 -0
- package/src/client/components/RankChip.tsx +22 -0
- package/src/client/components/SelectMenu.tsx +116 -0
- package/src/client/components/SettingsDialog.tsx +207 -0
- package/src/client/components/ThemeSettings.tsx +131 -0
- package/src/client/context.tsx +60 -0
- package/src/client/index.html +54 -0
- package/src/client/lib/aggregate.ts +266 -0
- package/src/client/lib/range.ts +87 -0
- package/src/client/lib/rank.ts +323 -0
- package/src/client/lib/shareCard.ts +139 -0
- package/src/client/logo.svg +8 -0
- package/src/client/main.tsx +12 -0
- package/src/client/styles.css +1113 -0
- package/src/client/themes.ts +268 -0
- package/src/client/utils.ts +61 -0
- package/src/client/views/Models.tsx +71 -0
- package/src/client/views/Overview.tsx +280 -0
- package/src/client/views/Projects.tsx +88 -0
- package/src/client/views/Rank.tsx +255 -0
- package/src/client/views/Sessions.tsx +108 -0
- package/src/client/views/Tools.tsx +114 -0
- package/src/projects.ts +214 -0
- package/src/server.ts +173 -0
- package/src/startup.ts +110 -0
- package/src/tray.ts +293 -0
- 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
|
+
}
|