Add a sortable "Out/In Ratio" (completion_tokens / prompt_tokens) column with a portal-based tooltip explaining the metric. Fix hydration mismatch by switching to useSyncExternalStore for localStorage reads in TimeRangeProvider. Update CLAUDE.md with project documentation.
119 lines
3.4 KiB
TypeScript
119 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react";
|
|
import dayjs from "dayjs";
|
|
import { type TimeRange, getTimeRange } from "@/lib/utils";
|
|
|
|
interface TimeRangeContextType {
|
|
range: TimeRange;
|
|
setRange: (r: TimeRange) => void;
|
|
customStart: string;
|
|
customEnd: string;
|
|
setCustomStart: (s: string) => void;
|
|
setCustomEnd: (s: string) => void;
|
|
/** Returns { start?, end? } unix timestamps (seconds) ready for API calls */
|
|
getEffectiveRange: () => { start?: number; end?: number };
|
|
}
|
|
|
|
const TimeRangeContext = createContext<TimeRangeContextType>({
|
|
range: "30d",
|
|
setRange: () => {},
|
|
customStart: "",
|
|
customEnd: "",
|
|
setCustomStart: () => {},
|
|
setCustomEnd: () => {},
|
|
getEffectiveRange: () => ({}),
|
|
});
|
|
|
|
// Use useSyncExternalStore to safely read localStorage without hydration mismatch
|
|
const STORAGE_KEY = "time-range";
|
|
const DEFAULT_RANGE = "30d";
|
|
|
|
let listeners: Array<() => void> = [];
|
|
function subscribe(cb: () => void) {
|
|
listeners = [...listeners, cb];
|
|
return () => { listeners = listeners.filter(l => l !== cb); };
|
|
}
|
|
function emitChange() {
|
|
for (const l of listeners) l();
|
|
}
|
|
|
|
function getSnapshot(): string {
|
|
return localStorage.getItem(STORAGE_KEY) ?? "";
|
|
}
|
|
function getServerSnapshot(): string {
|
|
return "";
|
|
}
|
|
|
|
function persist(range: TimeRange, customStart: string, customEnd: string) {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ range, customStart, customEnd }));
|
|
emitChange();
|
|
}
|
|
|
|
export function TimeRangeProvider({ children }: { children: ReactNode }) {
|
|
const raw = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
|
|
const saved = (() => {
|
|
if (!raw) return null;
|
|
try { return JSON.parse(raw); } catch { return null; }
|
|
})();
|
|
|
|
const [range, setRangeState] = useState<TimeRange>(saved?.range ?? DEFAULT_RANGE);
|
|
const [customStart, setCustomStartState] = useState(
|
|
saved?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD")
|
|
);
|
|
const [customEnd, setCustomEndState] = useState(
|
|
saved?.customEnd || dayjs().format("YYYY-MM-DD")
|
|
);
|
|
|
|
const setRange = useCallback((r: TimeRange) => {
|
|
setRangeState(r);
|
|
setCustomStartState(prev => {
|
|
setCustomEndState(end => { persist(r, prev, end); return end; });
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
const setCustomStart = useCallback((s: string) => {
|
|
setCustomStartState(s);
|
|
setRangeState(r => {
|
|
setCustomEndState(end => { persist(r, s, end); return end; });
|
|
return r;
|
|
});
|
|
}, []);
|
|
|
|
const setCustomEnd = useCallback((e: string) => {
|
|
setCustomEndState(e);
|
|
setRangeState(r => {
|
|
setCustomStartState(start => { persist(r, start, e); return start; });
|
|
return r;
|
|
});
|
|
}, []);
|
|
|
|
const getEffectiveRange = useCallback(() => {
|
|
if (range === "custom") {
|
|
const result: { start?: number; end?: number } = {};
|
|
if (customStart) result.start = dayjs(customStart).startOf("day").unix();
|
|
if (customEnd) result.end = dayjs(customEnd).endOf("day").unix();
|
|
return result;
|
|
}
|
|
return getTimeRange(range);
|
|
}, [range, customStart, customEnd]);
|
|
|
|
return (
|
|
<TimeRangeContext.Provider value={{
|
|
range, setRange,
|
|
customStart, customEnd,
|
|
setCustomStart,
|
|
setCustomEnd,
|
|
getEffectiveRange,
|
|
}}>
|
|
{children}
|
|
</TimeRangeContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useTimeRange() {
|
|
return useContext(TimeRangeContext);
|
|
}
|