mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 08:44:41 +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>
174 lines
5.6 KiB
TypeScript
174 lines
5.6 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { useUsageSummary } from "@/lib/query/usage";
|
|
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
|
import { motion } from "framer-motion";
|
|
import { fmtUsd, parseFiniteNumber } from "./format";
|
|
import type { UsageRangeSelection } from "@/types/usage";
|
|
|
|
interface UsageSummaryCardsProps {
|
|
range: UsageRangeSelection;
|
|
appType?: string;
|
|
refreshIntervalMs: number;
|
|
}
|
|
|
|
export function UsageSummaryCards({
|
|
range,
|
|
appType,
|
|
refreshIntervalMs,
|
|
}: UsageSummaryCardsProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const { data: summary, isLoading } = useUsageSummary(range, appType, {
|
|
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
|
});
|
|
|
|
const stats = useMemo(() => {
|
|
const totalRequests = summary?.totalRequests ?? 0;
|
|
const totalCost = parseFiniteNumber(summary?.totalCost);
|
|
|
|
const inputTokens = summary?.totalInputTokens ?? 0;
|
|
const outputTokens = summary?.totalOutputTokens ?? 0;
|
|
const totalTokens = inputTokens + outputTokens;
|
|
|
|
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
|
|
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
|
|
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
|
|
|
|
return [
|
|
{
|
|
title: t("usage.totalRequests"),
|
|
value: totalRequests.toLocaleString(),
|
|
icon: Activity,
|
|
color: "text-blue-500",
|
|
bg: "bg-blue-500/10",
|
|
subValue: null,
|
|
},
|
|
{
|
|
title: t("usage.totalCost"),
|
|
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
|
|
icon: DollarSign,
|
|
color: "text-green-500",
|
|
bg: "bg-green-500/10",
|
|
subValue: null,
|
|
},
|
|
{
|
|
title: t("usage.totalTokens"),
|
|
value: totalTokens.toLocaleString(),
|
|
icon: Layers,
|
|
color: "text-purple-500",
|
|
bg: "bg-purple-500/10",
|
|
subValue: (
|
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
|
<div className="flex justify-between items-center">
|
|
<span>{t("usage.input")}</span>
|
|
<span className="text-foreground/80">
|
|
{(inputTokens / 1000).toFixed(1)}k
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span>{t("usage.output")}</span>
|
|
<span className="text-foreground/80">
|
|
{(outputTokens / 1000).toFixed(1)}k
|
|
</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: t("usage.cacheTokens"),
|
|
value: totalCacheTokens.toLocaleString(),
|
|
icon: Database,
|
|
color: "text-orange-500",
|
|
bg: "bg-orange-500/10",
|
|
subValue: (
|
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
|
<div className="flex justify-between items-center">
|
|
<span>{t("usage.cacheWrite")}</span>
|
|
<span className="text-foreground/80">
|
|
{(cacheWriteTokens / 1000).toFixed(1)}k
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span>{t("usage.cacheRead")}</span>
|
|
<span className="text-foreground/80">
|
|
{(cacheReadTokens / 1000).toFixed(1)}k
|
|
</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
}, [summary, t]);
|
|
|
|
const container = {
|
|
hidden: { opacity: 0 },
|
|
show: {
|
|
opacity: 1,
|
|
transition: {
|
|
staggerChildren: 0.1,
|
|
},
|
|
},
|
|
};
|
|
|
|
const item = {
|
|
hidden: { opacity: 0, y: 20 },
|
|
show: { opacity: 1, y: 0 },
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<Card
|
|
key={i}
|
|
className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
|
|
>
|
|
<CardContent className="p-6 flex items-center justify-center min-h-[160px]">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
variants={container}
|
|
initial="hidden"
|
|
animate="show"
|
|
className="grid gap-4 md:grid-cols-4"
|
|
>
|
|
{stats.map((stat, i) => (
|
|
<motion.div key={i} variants={item}>
|
|
<Card className="relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
{stat.title}
|
|
</p>
|
|
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
|
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<h3 className="text-2xl font-bold truncate" title={stat.value}>
|
|
{stat.value}
|
|
</h3>
|
|
</div>
|
|
|
|
{stat.subValue || (
|
|
/* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
|
|
<div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
);
|
|
}
|