mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
de23216e49
* feat(usage): enhance usage stats backend and query hooks * feat(usage): redesign calendar date range picker with auto-switch and simplified layout * refactor(usage): streamline dashboard layout and stats components * refactor(usage): compact request log table with merged cache/multiplier columns and centered layout * feat(i18n): add cache short labels and usage stats translations for zh/en/ja * Align usage dashboard stats with range boundaries The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes. This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes. Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx Tested: pnpm typecheck Not-tested: Manual UI validation in the Tauri app * Preserve full-day usage filters at minute precision The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters. This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts. Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx Tested: pnpm typecheck Not-tested: Manual Tauri dashboard interaction * refactor(usage): move range presets into date picker, single-row layout - UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside popover top; clicking a preset applies immediately and closes popover - UsageDashboard: collapse to single row (app filters + refresh + picker); remove standalone preset buttons and summary stats bar - RequestLogTable: replace static Calendar badge with interactive UsageDateRangePicker via onRangeChange prop; single filter row * Keep usage pagination regression coverage aligned with the rendered UI The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments. Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption Confidence: high Scope-risk: narrow Reversibility: clean Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit * refactor(usage): clean up dead code and polish date range picker - Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS, timestampToLocalDatetime, and localDatetimeToTimestamp from usageRange.ts (replaced by the calendar picker) - Deduplicate getPresetLabel from UsageDashboard and UsageDateRangePicker into shared getUsageRangePresetLabel helper - Add aria-label, aria-current and aria-pressed to calendar day buttons so screen readers can disambiguate same-numbered days across adjacent months - Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja); the request log table renders R/W prefixes inline - Align customRangeHint copy with the removed 30-day limit by dropping "up to 30 days" wording (zh/en/ja) * fix(usage): align rollup cutoff to local midnight to keep days complete `rollup_and_prune` previously used `Utc::now() - retain_days * 86400` as the cutoff. Because rollups are bucketed by *local* date and detail rows below the cutoff are pruned, an unaligned cutoff left the youngest rolled-up day half-rolled-up and half-pruned. Combined with the new `compute_rollup_date_bounds` boundary trimming (which excludes any rollup day not fully covered by the requested range), custom range queries that touch that day silently under-count summary, trend, provider, and model stats. Fix the invariant at the source: snap the cutoff to the next local midnight after `(now - retain_days)`. Every rollup row now reflects a complete local day, so the boundary trimmer's all-or-nothing assumption holds. Includes unit tests for the cutoff math (typical case + already-on- midnight case). DST gap is handled defensively by bumping forward by an hour. Addresses Codex P2 review finding on PR #2002. --------- Co-authored-by: Jason <farion1231@gmail.com>
236 lines
7.6 KiB
TypeScript
236 lines
7.6 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Legend,
|
|
} from "recharts";
|
|
import { useUsageTrends } from "@/lib/query/usage";
|
|
import { Loader2 } from "lucide-react";
|
|
import {
|
|
fmtInt,
|
|
fmtUsd,
|
|
getLocaleFromLanguage,
|
|
parseFiniteNumber,
|
|
} from "./format";
|
|
import { resolveUsageRange } from "@/lib/usageRange";
|
|
import type { UsageRangeSelection } from "@/types/usage";
|
|
|
|
interface UsageTrendChartProps {
|
|
range: UsageRangeSelection;
|
|
rangeLabel: string;
|
|
appType?: string;
|
|
refreshIntervalMs: number;
|
|
}
|
|
|
|
export function UsageTrendChart({
|
|
range,
|
|
rangeLabel,
|
|
appType,
|
|
refreshIntervalMs,
|
|
}: UsageTrendChartProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const { startDate, endDate } = resolveUsageRange(range);
|
|
const { data: trends, isLoading } = useUsageTrends(range, appType, {
|
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-[350px] items-center justify-center rounded-xl bg-card/40 border border-border/50">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/30" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const durationSeconds = Math.max(endDate - startDate, 0);
|
|
const isHourly = durationSeconds <= 24 * 60 * 60;
|
|
const language = i18n.resolvedLanguage || i18n.language || "en";
|
|
const dateLocale = getLocaleFromLanguage(language);
|
|
const chartData =
|
|
trends?.map((stat) => {
|
|
const pointDate = new Date(stat.date);
|
|
const cost = parseFiniteNumber(stat.totalCost);
|
|
return {
|
|
rawDate: stat.date,
|
|
label: isHourly
|
|
? pointDate.toLocaleString(dateLocale, {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: pointDate.toLocaleDateString(dateLocale, {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
}),
|
|
hour: pointDate.getHours(),
|
|
inputTokens: stat.totalInputTokens,
|
|
outputTokens: stat.totalOutputTokens,
|
|
cacheCreationTokens: stat.totalCacheCreationTokens,
|
|
cacheReadTokens: stat.totalCacheReadTokens,
|
|
cost: cost ?? null,
|
|
};
|
|
}) || [];
|
|
|
|
const displayData = chartData;
|
|
|
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur-md">
|
|
<p className="mb-2 font-medium">{label}</p>
|
|
{payload.map((entry: any, index: number) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center gap-2 text-sm"
|
|
style={{ color: entry.color }}
|
|
>
|
|
<div
|
|
className="h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: entry.color }}
|
|
/>
|
|
<span className="font-medium">{entry.name}:</span>
|
|
<span>
|
|
{entry.dataKey === "cost"
|
|
? fmtUsd(entry.value, 6)
|
|
: fmtInt(entry.value, dateLocale)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-xl border border-border/50 bg-card/40 p-6 backdrop-blur-sm">
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">
|
|
{t("usage.trends", "使用趋势")}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">{rangeLabel}</p>
|
|
</div>
|
|
|
|
<div className="h-[350px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart
|
|
data={displayData}
|
|
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
|
>
|
|
<defs>
|
|
<linearGradient id="colorInput" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorOutput" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient
|
|
id="colorCacheCreation"
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="colorCacheRead" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
vertical={false}
|
|
stroke="hsl(var(--border))"
|
|
opacity={0.4}
|
|
/>
|
|
<XAxis
|
|
dataKey="label"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
|
dy={10}
|
|
/>
|
|
<YAxis
|
|
yAxisId="tokens"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
|
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
|
/>
|
|
<YAxis
|
|
yAxisId="cost"
|
|
orientation="right"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
|
|
tickFormatter={(value) => `$${value}`}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
<Legend />
|
|
<Area
|
|
yAxisId="tokens"
|
|
type="monotone"
|
|
dataKey="inputTokens"
|
|
name={t("usage.inputTokens", "输入 Tokens")}
|
|
stroke="#3b82f6"
|
|
fillOpacity={1}
|
|
fill="url(#colorInput)"
|
|
strokeWidth={2}
|
|
/>
|
|
<Area
|
|
yAxisId="tokens"
|
|
type="monotone"
|
|
dataKey="outputTokens"
|
|
name={t("usage.outputTokens", "输出 Tokens")}
|
|
stroke="#22c55e"
|
|
fillOpacity={1}
|
|
fill="url(#colorOutput)"
|
|
strokeWidth={2}
|
|
/>
|
|
<Area
|
|
yAxisId="tokens"
|
|
type="monotone"
|
|
dataKey="cacheCreationTokens"
|
|
name={t("usage.cacheCreationTokens", "缓存创建")}
|
|
stroke="#f97316"
|
|
fillOpacity={1}
|
|
fill="url(#colorCacheCreation)"
|
|
strokeWidth={2}
|
|
/>
|
|
<Area
|
|
yAxisId="tokens"
|
|
type="monotone"
|
|
dataKey="cacheReadTokens"
|
|
name={t("usage.cacheReadTokens", "缓存命中")}
|
|
stroke="#a855f7"
|
|
fillOpacity={1}
|
|
fill="url(#colorCacheRead)"
|
|
strokeWidth={2}
|
|
/>
|
|
<Area
|
|
yAxisId="cost"
|
|
type="monotone"
|
|
dataKey="cost"
|
|
name={t("usage.cost", "成本")}
|
|
stroke="#f43f5e"
|
|
fill="none"
|
|
strokeWidth={2}
|
|
strokeDasharray="4 4"
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|