feat: add output/input ratio column to aggregation page

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.
This commit is contained in:
2026-04-07 15:09:23 +08:00
parent 9bb36432ba
commit 8b91aa3e97
4 changed files with 195 additions and 30 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { createContext, useContext, useState, useCallback, useSyncExternalStore, type ReactNode } from "react";
import dayjs from "dayjs";
import { type TimeRange, getTimeRange } from "@/lib/utils";
@@ -25,45 +25,70 @@ const TimeRangeContext = createContext<TimeRangeContextType>({
getEffectiveRange: () => ({}),
});
function loadSaved(): { range: TimeRange; customStart: string; customEnd: string } | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem("time-range");
if (!raw) return null;
const parsed = JSON.parse(raw);
return {
range: parsed.range || "30d",
customStart: parsed.customStart || "",
customEnd: parsed.customEnd || "",
};
} catch {
return null;
}
// 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("time-range", JSON.stringify({ range, customStart, customEnd }));
localStorage.setItem(STORAGE_KEY, JSON.stringify({ range, customStart, customEnd }));
emitChange();
}
export function TimeRangeProvider({ children }: { children: ReactNode }) {
const [range, setRangeState] = useState<TimeRange>(() => loadSaved()?.range ?? "30d");
const [customStart, setCustomStartState] = useState(() => loadSaved()?.customStart || dayjs().subtract(7, "day").format("YYYY-MM-DD"));
const [customEnd, setCustomEndState] = useState(() => loadSaved()?.customEnd || dayjs().format("YYYY-MM-DD"));
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 => { persist(r, prev, customEnd); return prev; });
}, [customEnd]);
setCustomStartState(prev => {
setCustomEndState(end => { persist(r, prev, end); return end; });
return prev;
});
}, []);
const setCustomStart = useCallback((s: string) => {
setCustomStartState(s);
persist(range, s, customEnd);
}, [range, customEnd]);
setRangeState(r => {
setCustomEndState(end => { persist(r, s, end); return end; });
return r;
});
}, []);
const setCustomEnd = useCallback((e: string) => {
setCustomEndState(e);
persist(range, customStart, e);
}, [range, customStart]);
setRangeState(r => {
setCustomStartState(start => { persist(r, start, e); return start; });
return r;
});
}, []);
const getEffectiveRange = useCallback(() => {
if (range === "custom") {