mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-07 14:19:06 +08:00
* 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>
162 lines
4.0 KiB
TypeScript
162 lines
4.0 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { RequestLogTable } from "@/components/usage/RequestLogTable";
|
|
import type { UsageRangeSelection } from "@/types/usage";
|
|
|
|
const useRequestLogsMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (
|
|
key: string,
|
|
options?: {
|
|
defaultValue?: string;
|
|
},
|
|
) => options?.defaultValue ?? key,
|
|
i18n: {
|
|
resolvedLanguage: "en",
|
|
language: "en",
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/query/usage", () => ({
|
|
useRequestLogs: (args: unknown) => useRequestLogsMock(args),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/button", () => ({
|
|
Button: ({ children, ...props }: any) => (
|
|
<button {...props}>{children}</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/ui/input", () => ({
|
|
Input: (props: any) => <input {...props} />,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/select", () => ({
|
|
Select: ({ children }: any) => <div>{children}</div>,
|
|
SelectTrigger: ({ children, ...props }: any) => (
|
|
<button type="button" {...props}>
|
|
{children}
|
|
</button>
|
|
),
|
|
SelectValue: ({ placeholder }: any) => <span>{placeholder ?? null}</span>,
|
|
SelectContent: () => null,
|
|
SelectItem: () => null,
|
|
}));
|
|
|
|
vi.mock("@/components/ui/table", () => ({
|
|
Table: ({ children }: any) => <table>{children}</table>,
|
|
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
|
|
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
|
|
TableHead: ({ children, ...props }: any) => <th {...props}>{children}</th>,
|
|
TableHeader: ({ children }: any) => <thead>{children}</thead>,
|
|
TableRow: ({ children }: any) => <tr>{children}</tr>,
|
|
}));
|
|
|
|
describe("RequestLogTable", () => {
|
|
beforeEach(() => {
|
|
useRequestLogsMock.mockReset();
|
|
useRequestLogsMock.mockImplementation(
|
|
({ page = 0, pageSize = 20 }: { page?: number; pageSize?: number }) => ({
|
|
data: {
|
|
data: [],
|
|
total: 120,
|
|
page,
|
|
pageSize,
|
|
},
|
|
isLoading: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("resets pagination when the dashboard range changes", async () => {
|
|
const initialRange: UsageRangeSelection = { preset: "today" };
|
|
const nextRange: UsageRangeSelection = {
|
|
preset: "custom",
|
|
customStartDate: 1_710_000_000,
|
|
customEndDate: 1_710_086_400,
|
|
};
|
|
|
|
const { rerender } = render(
|
|
<RequestLogTable
|
|
range={initialRange}
|
|
rangeLabel="Today"
|
|
appType="all"
|
|
refreshIntervalMs={0}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "2" }));
|
|
|
|
await waitFor(() => {
|
|
expect(useRequestLogsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
page: 1,
|
|
range: initialRange,
|
|
}),
|
|
);
|
|
});
|
|
|
|
rerender(
|
|
<RequestLogTable
|
|
range={nextRange}
|
|
rangeLabel="Custom"
|
|
appType="all"
|
|
refreshIntervalMs={0}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(useRequestLogsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
page: 0,
|
|
range: nextRange,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("resets pagination when the dashboard app filter changes", async () => {
|
|
const range: UsageRangeSelection = { preset: "today" };
|
|
const { rerender } = render(
|
|
<RequestLogTable
|
|
range={range}
|
|
rangeLabel="Today"
|
|
appType="all"
|
|
refreshIntervalMs={0}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "2" }));
|
|
|
|
await waitFor(() => {
|
|
expect(useRequestLogsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
page: 1,
|
|
range,
|
|
}),
|
|
);
|
|
});
|
|
|
|
rerender(
|
|
<RequestLogTable
|
|
range={range}
|
|
rangeLabel="Today"
|
|
appType="claude"
|
|
refreshIntervalMs={0}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(useRequestLogsMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({
|
|
page: 0,
|
|
range,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|