Skip to content

Commit 3e328ee

Browse files
committed
feat: redesign daily usage comparison
1 parent b02a48a commit 3e328ee

7 files changed

Lines changed: 446 additions & 124 deletions

File tree

src/App.test.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,11 +659,18 @@ describe("App", () => {
659659
const dailyTab = screen.getByRole("tab", { name: "Daily" });
660660
await userEvent.click(dailyTab);
661661

662-
const latestDailyCell = screen.getByRole("cell", { name: "2026-04-26" });
662+
expect(screen.getAllByRole("heading", { name: "Daily Usage" })).toHaveLength(1);
663+
expect(screen.getAllByText("Compare daily token composition, scale, and cost, with sorting by field.")).toHaveLength(1);
664+
const latestDailyCell = screen.getByRole("cell", { name: /2026-04-26/ });
663665
const inactiveDailyCell = screen.getByRole("cell", { name: "2026-04-24 to 2026-04-25" });
664666
expect(latestDailyCell.compareDocumentPosition(inactiveDailyCell) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
665667
expect(screen.getByText("No activity (2 days)")).toBeInTheDocument();
666668

669+
await userEvent.click(screen.getByRole("row", { name: "View sessions for 2026-04-26" }));
670+
expect(screen.getByRole("tab", { name: "Sessions" })).toHaveAttribute("aria-selected", "true");
671+
672+
await userEvent.click(dailyTab);
673+
667674
// Switch back to Dashboard tab to perform range selection
668675
const dashboardTab = screen.getByRole("tab", { name: "Dashboard" });
669676
await userEvent.click(dashboardTab);

src/App.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,6 @@ export default function App() {
111111

112112
const metrics = overview ? buildMetricCards(overview, range, t) : [];
113113
const projects = overview?.projects ?? [];
114-
const sortedDailyUsage = useMemo(
115-
() => (overview ? [...overview.daily].sort((left, right) => right.date.localeCompare(left.date)) : []),
116-
[overview],
117-
);
118114
const sortedMonthlyUsage = useMemo(
119115
() =>
120116
monthlyUsage
@@ -378,7 +374,7 @@ export default function App() {
378374
</div>
379375
<DailyUsageTable
380376
range={range}
381-
daily={sortedDailyUsage}
377+
daily={overview.daily}
382378
onRowClick={(date) => {
383379
setSelectedSessionDate(date);
384380
void handleViewChange("sessions");
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// @vitest-environment jsdom
2+
3+
import { render, screen, within } from "@testing-library/react";
4+
import userEvent from "@testing-library/user-event";
5+
import { beforeAll, describe, expect, it, vi } from "vitest";
6+
import { DailyUsageTable } from "@/components/daily-usage-table";
7+
import type { OverviewResponse } from "@/lib/api";
8+
import i18n from "@/i18n";
9+
10+
type Day = OverviewResponse["daily"][number];
11+
12+
const day = (date: string, overrides: Partial<Day> = {}): Day => ({
13+
date,
14+
inputTokens: 80,
15+
cachedInputTokens: 20,
16+
outputTokens: 20,
17+
totalTokens: 100,
18+
costUSD: 0.1,
19+
...overrides,
20+
});
21+
22+
const inactive = (date: string): Day => day(date, {
23+
inputTokens: 0,
24+
cachedInputTokens: 0,
25+
outputTokens: 0,
26+
totalTokens: 0,
27+
costUSD: 0,
28+
});
29+
30+
function activeDates() {
31+
return [...document.querySelectorAll<HTMLElement>("[data-daily-row]")].map((row) => row.dataset.dailyRow);
32+
}
33+
34+
async function selectSort(label: string) {
35+
const user = userEvent.setup();
36+
const labels = ["Date", "Total tokens", "Input", "Cached tokens", "Output", "Cost"];
37+
screen.getByRole("combobox", { name: "Sort by" }).focus();
38+
await user.keyboard(`[Enter][Home]${"[ArrowDown]".repeat(labels.indexOf(label))}[Enter]`);
39+
}
40+
41+
beforeAll(async () => {
42+
window.HTMLElement.prototype.scrollIntoView = vi.fn();
43+
await i18n.changeLanguage("en");
44+
});
45+
46+
describe("DailyUsageTable", () => {
47+
it("defaults to newest date and sorts every metric descending with newest-date tie breaking", async () => {
48+
const rows = [
49+
day("2026-04-01"),
50+
day("2026-04-02", { inputTokens: 150, cachedInputTokens: 50, outputTokens: 50, totalTokens: 200, costUSD: 0.3 }),
51+
day("2026-04-03", { inputTokens: 140, cachedInputTokens: 60, outputTokens: 60, totalTokens: 200, costUSD: 0.2 }),
52+
];
53+
render(<DailyUsageTable range="30d" daily={rows} />);
54+
55+
expect(activeDates()).toEqual(["2026-04-03", "2026-04-02", "2026-04-01"]);
56+
57+
const expected: Array<[string, string[]]> = [
58+
["Total tokens", ["2026-04-03", "2026-04-02", "2026-04-01"]],
59+
["Input", ["2026-04-02", "2026-04-03", "2026-04-01"]],
60+
["Cached tokens", ["2026-04-03", "2026-04-02", "2026-04-01"]],
61+
["Output", ["2026-04-03", "2026-04-02", "2026-04-01"]],
62+
["Cost", ["2026-04-02", "2026-04-03", "2026-04-01"]],
63+
["Date", ["2026-04-03", "2026-04-02", "2026-04-01"]],
64+
];
65+
66+
for (const [label, order] of expected) {
67+
await selectSort(label);
68+
expect(activeDates()).toEqual(order);
69+
expect(screen.getByRole("button", { name: "Descending" })).toBeInTheDocument();
70+
}
71+
72+
await selectSort("Total tokens");
73+
await userEvent.click(screen.getByRole("button", { name: "Descending" }));
74+
expect(activeDates()).toEqual(["2026-04-01", "2026-04-03", "2026-04-02"]);
75+
expect(screen.getByRole("button", { name: "Ascending" })).toBeInTheDocument();
76+
77+
await selectSort("Cost");
78+
expect(screen.getByRole("button", { name: "Descending" })).toBeInTheDocument();
79+
});
80+
81+
it("marks all positive tied peaks and does not mark zero values", () => {
82+
render(<DailyUsageTable range="7d" daily={[
83+
day("2026-04-03", { inputTokens: 100, cachedInputTokens: 40, outputTokens: 30, totalTokens: 130, costUSD: 0 }),
84+
day("2026-04-02", { inputTokens: 100, cachedInputTokens: 40, outputTokens: 30, totalTokens: 130, costUSD: 0 }),
85+
inactive("2026-04-01"),
86+
]} />);
87+
88+
for (const date of ["2026-04-03", "2026-04-02"]) {
89+
const row = document.querySelector(`[data-daily-row='${date}']`) as HTMLElement;
90+
expect(within(row).getAllByText("Highest")).toHaveLength(4);
91+
expect(within(row).queryByText("$0.00")).toBeInTheDocument();
92+
}
93+
expect(screen.getAllByText("Highest")).toHaveLength(8);
94+
});
95+
96+
it("normalizes total and cost bars to range peaks without double-counting cached input", () => {
97+
render(<DailyUsageTable range="7d" daily={[
98+
day("2026-04-03", { inputTokens: 140, cachedInputTokens: 60, outputTokens: 60, totalTokens: 200, costUSD: 0.3 }),
99+
day("2026-04-02", { inputTokens: 80, cachedInputTokens: 20, outputTokens: 20, totalTokens: 100, costUSD: 0.2 }),
100+
day("2026-04-01", { inputTokens: 40, cachedInputTokens: 10, outputTokens: 10, totalTokens: 50, costUSD: 0.1 }),
101+
]} />);
102+
103+
const peakRow = document.querySelector("[data-daily-row='2026-04-03']") as HTMLElement;
104+
expect(peakRow.querySelector<HTMLElement>("[data-token-bar]")?.style.width).toBe("100%");
105+
expect(peakRow.querySelector<HTMLElement>("[data-token-segment='uncached']")?.style.width).toBe("40%");
106+
expect(peakRow.querySelector<HTMLElement>("[data-token-segment='cached']")?.style.width).toBe("30%");
107+
expect(peakRow.querySelector<HTMLElement>("[data-token-segment='output']")?.style.width).toBe("30%");
108+
109+
const middleRow = document.querySelector("[data-daily-row='2026-04-02']") as HTMLElement;
110+
expect(middleRow.querySelector<HTMLElement>("[data-token-bar]")?.style.width).toBe("50%");
111+
expect(middleRow.querySelector("[data-cost-bar]")).toHaveAttribute("data-cost-tone", "medium");
112+
expect(Number.parseFloat(middleRow.querySelector<HTMLElement>("[data-cost-bar]")?.style.width ?? "0")).toBeCloseTo(66.6666666667, 5);
113+
114+
const lowRow = document.querySelector("[data-daily-row='2026-04-01']") as HTMLElement;
115+
expect(lowRow.querySelector("[data-cost-bar]")).toHaveAttribute("data-cost-tone", "low");
116+
expect(peakRow.querySelector("[data-cost-bar]")).toHaveAttribute("data-cost-tone", "high");
117+
});
118+
119+
it("merges consecutive inactive dates in date order and puts inactive ranges last for metric sorts", async () => {
120+
render(<DailyUsageTable range="30d" daily={[
121+
day("2026-04-02", { inputTokens: 40, cachedInputTokens: 10, outputTokens: 10, totalTokens: 50, costUSD: 0.05 }),
122+
day("2026-04-05"),
123+
inactive("2026-04-04"),
124+
inactive("2026-04-03"),
125+
day("2026-04-01", { inputTokens: 20, cachedInputTokens: 5, outputTokens: 5, totalTokens: 25, costUSD: 0.025 }),
126+
]} />);
127+
128+
const cells = screen.getAllByRole("cell").map((cell) => cell.textContent);
129+
expect(cells.findIndex((text) => text?.includes("2026-04-03 to 2026-04-04"))).toBeLessThan(cells.findIndex((text) => text?.includes("2026-04-02")));
130+
131+
await userEvent.click(screen.getByRole("button", { name: "Descending" }));
132+
expect(activeDates()).toEqual(["2026-04-01", "2026-04-02", "2026-04-05"]);
133+
expect(screen.getByRole("cell", { name: "2026-04-03 to 2026-04-04" })).toBeInTheDocument();
134+
135+
await selectSort("Total tokens");
136+
expect(document.querySelector("tbody tr")?.getAttribute("data-daily-row")).toBe("2026-04-05");
137+
const tableRows = [...document.querySelectorAll("tbody tr")];
138+
expect(tableRows.slice(0, 3).every((row) => row.hasAttribute("data-daily-row"))).toBe(true);
139+
expect(tableRows.slice(3).every((row) => !row.hasAttribute("data-daily-row"))).toBe(true);
140+
expect(screen.getByText("No activity (2 days)")).toBeInTheDocument();
141+
});
142+
143+
it("opens an active date with mouse or keyboard and exposes weekday and legends", async () => {
144+
const onRowClick = vi.fn();
145+
render(<DailyUsageTable range="7d" daily={[day("2026-04-03")]} onRowClick={onRowClick} />);
146+
const row = screen.getByRole("row", { name: "View sessions for 2026-04-03" });
147+
148+
expect(within(row).getByText("Fri")).toBeInTheDocument();
149+
expect(screen.getByLabelText("Token color legend")).toBeInTheDocument();
150+
expect(screen.getByLabelText(/Cost scale: green/)).toBeInTheDocument();
151+
152+
await userEvent.click(row);
153+
row.focus();
154+
await userEvent.keyboard("{Enter}");
155+
await userEvent.keyboard(" ");
156+
expect(onRowClick).toHaveBeenNthCalledWith(1, "2026-04-03");
157+
expect(onRowClick).toHaveBeenCalledTimes(3);
158+
});
159+
});

0 commit comments

Comments
 (0)