Skip to content

Commit 7d57c4b

Browse files
committed
feat(dashboard): responsive heatmap, 30d/60d/120d/360d ranges, CI fixes, and 221 tests
1 parent fbb14bc commit 7d57c4b

21 files changed

Lines changed: 123 additions & 73 deletions

File tree

src/core/webview/__tests__/usageStatsMessageHandler.spec.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vi.mock("../../../utils/export", () => ({
2020
saveLastExportPath: vi.fn(),
2121
}))
2222

23-
vi.mock("../task-persistence/taskMessages", () => ({
23+
vi.mock("../../task-persistence/taskMessages", () => ({
2424
readTaskMessages: vi.fn().mockResolvedValue([]),
2525
}))
2626

@@ -30,7 +30,6 @@ vi.mock("../../../services/stats/costRecalculation", () => ({
3030

3131
import * as vscode from "vscode"
3232
import { resolveDefaultSaveUri, saveLastExportPath } from "../../../utils/export"
33-
import { readTaskMessages } from "../task-persistence/taskMessages"
3433
import { getEffectiveCost } from "../../../services/stats/costRecalculation"
3534
import {
3635
handleGetUsageStats,

webview-ui/src/components/stats/UsageHeatmap.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,24 @@ function formatDisplayDate(dateKey: string): string {
6464
}
6565
}
6666

67+
// ── Range configuration ─────────────────────────────────────────────────────
68+
69+
type HeatmapRange = "30d" | "60d" | "120d" | "360d"
70+
71+
const RANGE_DAYS: Record<HeatmapRange, number> = {
72+
"30d": 30,
73+
"60d": 60,
74+
"120d": 120,
75+
"360d": 360,
76+
}
77+
78+
const RANGE_OPTIONS: HeatmapRange[] = ["30d", "60d", "120d", "360d"]
79+
6780
// ── UsageHeatmap ────────────────────────────────────────────────────────────
6881

6982
const UsageHeatmap = memo(({ buckets }: UsageHeatmapProps) => {
7083
const { t } = useAppTranslation()
71-
const [range, setRange] = useState<"30d" | "90d">("30d")
84+
const [range, setRange] = useState<HeatmapRange>("30d")
7285

7386
// Extract daily activity from buckets that have a "day" key
7487
const dailyMap = useMemo(() => {
@@ -96,7 +109,7 @@ const UsageHeatmap = memo(({ buckets }: UsageHeatmapProps) => {
96109

97110
// Generate the date range for display
98111
const days = useMemo(() => {
99-
const count = range === "30d" ? 30 : 90
112+
const count = RANGE_DAYS[range]
100113
const today = new Date()
101114
today.setHours(0, 0, 0, 0)
102115
const result: DailyActivity[] = []
@@ -128,9 +141,8 @@ const UsageHeatmap = memo(({ buckets }: UsageHeatmapProps) => {
128141

129142
const hasData = maxTokens > 0
130143

131-
// Grid columns: 7 for 30d (compact), 7 for 90d but smaller cells
132-
const cellSize = range === "30d" ? "w-4 h-4" : "w-2.5 h-2.5"
133-
const gap = range === "30d" ? "gap-1" : "gap-0.5"
144+
// Gap between cells: tighter for longer ranges
145+
const gap = range === "30d" ? "gap-0.5" : "gap-px"
134146

135147
return (
136148
<div className="flex flex-col gap-2" data-testid="usage-heatmap">
@@ -139,20 +151,16 @@ const UsageHeatmap = memo(({ buckets }: UsageHeatmapProps) => {
139151
{t("stats:heatmap.title")}
140152
</h4>
141153
<div className="flex gap-1">
142-
<Button
143-
variant={range === "30d" ? "primary" : "ghost"}
144-
size="sm"
145-
onClick={() => setRange("30d")}
146-
data-testid="heatmap-range-30d">
147-
{t("stats:heatmap.30d")}
148-
</Button>
149-
<Button
150-
variant={range === "90d" ? "primary" : "ghost"}
151-
size="sm"
152-
onClick={() => setRange("90d")}
153-
data-testid="heatmap-range-90d">
154-
{t("stats:heatmap.90d")}
155-
</Button>
154+
{RANGE_OPTIONS.map((option) => (
155+
<Button
156+
key={option}
157+
variant={range === option ? "primary" : "ghost"}
158+
size="sm"
159+
onClick={() => setRange(option)}
160+
data-testid={`heatmap-range-${option}`}>
161+
{t(`stats:heatmap.${option}`)}
162+
</Button>
163+
))}
156164
</div>
157165
</div>
158166

@@ -164,7 +172,9 @@ const UsageHeatmap = memo(({ buckets }: UsageHeatmapProps) => {
164172
<>
165173
<div
166174
className={`grid grid-flow-col grid-rows-7 ${gap} overflow-x-auto`}
167-
style={{ gridTemplateColumns: `repeat(${Math.ceil(days.length / 7)}, minmax(0, 1fr))` }}
175+
style={{
176+
gridTemplateColumns: `repeat(${Math.ceil(days.length / 7)}, minmax(14px, 1fr))`,
177+
}}
168178
role="img"
169179
aria-label={t("stats:heatmap.title")}>
170180
{days.map((day) => {
@@ -178,7 +188,7 @@ const UsageHeatmap = memo(({ buckets }: UsageHeatmapProps) => {
178188
: `${formatDisplayDate(day.date)}: ${t("stats:heatmap.noData")}`
179189
}>
180190
<div
181-
className={`${cellSize} rounded-sm transition-colors`}
191+
className="rounded-sm transition-colors min-w-[14px] min-h-[14px]"
182192
style={{
183193
backgroundColor: HEATMAP_COLORS[level],
184194
border: "1px solid rgba(255, 255, 255, 0.3)",

webview-ui/src/components/stats/__tests__/UsageHeatmap.spec.tsx

Lines changed: 37 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,22 @@ describe("UsageHeatmap", () => {
100100
expect(grid).toBeTruthy()
101101
})
102102

103-
it("renders 30d and 90d range toggle buttons", () => {
103+
it("renders 30d, 60d, 120d, and 360d range toggle buttons", () => {
104104
const { container } = render(<UsageHeatmap buckets={[]} />)
105105

106106
const btn30d = container.querySelector('[data-testid="heatmap-range-30d"]')
107-
const btn90d = container.querySelector('[data-testid="heatmap-range-90d"]')
107+
const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]')
108+
const btn120d = container.querySelector('[data-testid="heatmap-range-120d"]')
109+
const btn360d = container.querySelector('[data-testid="heatmap-range-360d"]')
108110

109111
expect(btn30d).toBeTruthy()
110-
expect(btn90d).toBeTruthy()
112+
expect(btn60d).toBeTruthy()
113+
expect(btn120d).toBeTruthy()
114+
expect(btn360d).toBeTruthy()
111115
expect(btn30d?.textContent).toContain("stats:heatmap.30d")
112-
expect(btn90d?.textContent).toContain("stats:heatmap.90d")
116+
expect(btn60d?.textContent).toContain("stats:heatmap.60d")
117+
expect(btn120d?.textContent).toContain("stats:heatmap.120d")
118+
expect(btn360d?.textContent).toContain("stats:heatmap.360d")
113119
})
114120

115121
it("defaults to 30d range", () => {
@@ -125,32 +131,32 @@ describe("UsageHeatmap", () => {
125131
expect(cells.length).toBe(30)
126132
})
127133

128-
it("switches to 90d range when 90d button is clicked", () => {
134+
it("switches to 60d range when 60d button is clicked", () => {
129135
const buckets = [
130136
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 }),
131137
]
132138

133139
const { container } = render(<UsageHeatmap buckets={buckets} />)
134140

135-
const btn90d = container.querySelector('[data-testid="heatmap-range-90d"]') as HTMLButtonElement
136-
fireEvent.click(btn90d)
141+
const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement
142+
fireEvent.click(btn60d)
137143

138-
// In 90d mode, 90 date cells are generated
144+
// In 60d mode, 60 date cells are generated
139145
const cells = container.querySelectorAll('[role="img"] [aria-label]')
140-
expect(cells.length).toBe(90)
146+
expect(cells.length).toBe(60)
141147
})
142148

143-
it("switches back to 30d range when 30d button is clicked after 90d", () => {
149+
it("switches back to 30d range when 30d button is clicked after 60d", () => {
144150
const buckets = [
145151
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 }),
146152
]
147153

148154
const { container } = render(<UsageHeatmap buckets={buckets} />)
149155

150-
// Switch to 90d
151-
const btn90d = container.querySelector('[data-testid="heatmap-range-90d"]') as HTMLButtonElement
152-
fireEvent.click(btn90d)
153-
expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(90)
156+
// Switch to 60d
157+
const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement
158+
fireEvent.click(btn60d)
159+
expect(container.querySelectorAll('[role="img"] [aria-label]').length).toBe(60)
154160

155161
// Switch back to 30d
156162
const btn30d = container.querySelector('[data-testid="heatmap-range-30d"]') as HTMLButtonElement
@@ -242,25 +248,24 @@ describe("UsageHeatmap", () => {
242248
expect(grid).toBeFalsy()
243249
})
244250

245-
it("uses smaller cell size in 90d mode", () => {
251+
it("uses tighter gap in 360d mode", () => {
246252
const buckets = [
247253
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 }),
248254
]
249255

250256
const { container } = render(<UsageHeatmap buckets={buckets} />)
251257

252-
// Cell size in 30d mode
253-
const btn90d = container.querySelector('[data-testid="heatmap-range-90d"]') as HTMLButtonElement
254-
fireEvent.click(btn90d)
258+
// Switch to 360d mode
259+
const btn360d = container.querySelector('[data-testid="heatmap-range-360d"]') as HTMLButtonElement
260+
fireEvent.click(btn360d)
255261

256-
// In 90d mode, smaller cell class is applied
262+
// In 360d mode, gap-px class is applied
257263
const grid = container.querySelector('[role="img"]')
258264
expect(grid).toBeTruthy()
259-
// In 90d mode, gap-0.5 class is applied
260-
expect(grid?.className).toContain("gap-0.5")
265+
expect(grid?.className).toContain("gap-px")
261266
})
262267

263-
it("uses larger cell size in 30d mode", () => {
268+
it("uses gap-0.5 in 30d mode", () => {
264269
const buckets = [
265270
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 }),
266271
]
@@ -270,13 +275,13 @@ describe("UsageHeatmap", () => {
270275
// Default 30d mode
271276
const grid = container.querySelector('[role="img"]')
272277
expect(grid).toBeTruthy()
273-
// In 30d mode, gap-1 class is applied
274-
expect(grid?.className).toContain("gap-1")
278+
// In 30d mode, gap-0.5 class is applied
279+
expect(grid?.className).toContain("gap-0.5")
275280
})
276281

277282
it("computes intensity levels based on max token value", () => {
278283
const buckets = [
279-
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 4000, events: 4 }), // 100% → level 4
284+
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 4000, events: 4 }), // 100% → level 5
280285
makeBucket({ key: { day: daysAgoKey(1) }, totalTokens: 1000, events: 1 }), // 25% → level 1
281286
]
282287

@@ -286,9 +291,9 @@ describe("UsageHeatmap", () => {
286291
const heatmap = container.querySelector('[data-testid="usage-heatmap"]')
287292
expect(heatmap?.textContent).not.toContain("stats:heatmap.noData")
288293

289-
// Legend should be rendered (4 level colors)
294+
// Legend should be rendered (6 level colors: 0-5)
290295
const legendCells = container.querySelectorAll(".w-3.h-3.rounded-sm")
291-
expect(legendCells.length).toBe(4)
296+
expect(legendCells.length).toBe(6)
292297
})
293298

294299
it("handles buckets with day key but zero events", () => {
@@ -319,21 +324,21 @@ describe("UsageHeatmap", () => {
319324
expect(style).toContain("repeat(5")
320325
})
321326

322-
it("renders grid with correct column count for 90d mode", () => {
327+
it("renders grid with correct column count for 60d mode", () => {
323328
const buckets = [
324329
makeBucket({ key: { day: daysAgoKey(0) }, totalTokens: 1000, events: 1 }),
325330
]
326331

327332
const { container } = render(<UsageHeatmap buckets={buckets} />)
328333

329-
const btn90d = container.querySelector('[data-testid="heatmap-range-90d"]') as HTMLButtonElement
330-
fireEvent.click(btn90d)
334+
const btn60d = container.querySelector('[data-testid="heatmap-range-60d"]') as HTMLButtonElement
335+
fireEvent.click(btn60d)
331336

332337
const grid = container.querySelector('[role="img"]')
333338
expect(grid).toBeTruthy()
334-
// 90d mode: 90 cells / 7 rows = 13 columns (ceil(90/7) = 13)
339+
// 60d mode: 60 cells / 7 rows = 9 columns (ceil(60/7) = 9)
335340
const style = grid?.getAttribute("style") ?? ""
336341
expect(style.toLowerCase()).toContain("grid-template-columns")
337-
expect(style).toContain("repeat(13")
342+
expect(style).toContain("repeat(9")
338343
})
339344
})

webview-ui/src/i18n/locales/ca/stats.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/de/stats.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/en/stats.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@
4040
},
4141
"heatmap": {
4242
"title": "Daily Activity",
43-
"90d": "90 Days",
4443
"30d": "30 Days",
44+
"60d": "60 Days",
45+
"120d": "120 Days",
46+
"360d": "360 Days",
4547
"less": "Less",
4648
"more": "More",
4749
"noData": "No data"

webview-ui/src/i18n/locales/es/stats.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/fr/stats.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/hi/stats.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webview-ui/src/i18n/locales/id/stats.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)