Skip to content

Commit 71f0c38

Browse files
lidge-junclaude
andcommitted
fix(gui): expand heatmap to full-year GitHub-style contribution grid
The activity heatmap was only ~5 columns wide with 30d data selected. Now always renders 365 days (52+ weeks) with month labels on top and day-of-week labels (Mon/Wed/Fri) on the left, matching GitHub's contribution graph layout. Cells auto-size to fill the panel width. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ace38a commit 71f0c38

2 files changed

Lines changed: 67 additions & 34 deletions

File tree

gui/src/pages/Usage.tsx

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -89,37 +89,54 @@ interface HeatmapCell {
8989
dayOfWeek: number;
9090
}
9191

92-
function buildHeatmap(days: UsageDay[]): { weeks: HeatmapCell[][]; buckets: number[] } {
92+
function buildHeatmap(days: UsageDay[]): { weeks: HeatmapCell[][]; months: { label: string; col: number }[]; buckets: number[] } {
9393
const buckets = quantileBuckets(days.map(d => d.requests));
94-
const cells: HeatmapCell[] = days.map(d => {
95-
const dt = new Date(`${d.date}T00:00:00`);
96-
return {
97-
date: d.date,
98-
requests: d.requests,
99-
totalTokens: d.totalTokens,
100-
level: bucketLevel(d.requests, buckets),
101-
dayOfWeek: dt.getDay(),
102-
};
103-
});
104-
if (cells.length === 0) return { weeks: [], buckets };
94+
const dayMap = new Map(days.map(d => [d.date, d]));
95+
96+
const today = new Date();
97+
today.setHours(0, 0, 0, 0);
98+
const start = new Date(today);
99+
start.setDate(start.getDate() - 364);
100+
// Align to Sunday
101+
start.setDate(start.getDate() - start.getDay());
102+
105103
const weeks: HeatmapCell[][] = [];
106-
let week: HeatmapCell[] = new Array(cells[0].dayOfWeek).fill(null).map(() => ({
107-
date: "", requests: 0, totalTokens: 0, level: 0, dayOfWeek: 0,
108-
}));
109-
for (const cell of cells) {
110-
week.push(cell);
111-
if (cell.dayOfWeek === 6) {
104+
const months: { label: string; col: number }[] = [];
105+
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
106+
let lastMonthCol = -4;
107+
let prevMonthIdx = -1;
108+
let week: HeatmapCell[] = [];
109+
const cursor = new Date(start);
110+
111+
while (cursor <= today) {
112+
const iso = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, "0")}-${String(cursor.getDate()).padStart(2, "0")}`;
113+
const m = cursor.getMonth();
114+
if (cursor.getDay() === 0 && m !== prevMonthIdx && weeks.length - lastMonthCol >= 4) {
115+
months.push({ label: monthNames[m], col: weeks.length });
116+
lastMonthCol = weeks.length;
117+
prevMonthIdx = m;
118+
}
119+
const d = dayMap.get(iso);
120+
week.push({
121+
date: iso,
122+
requests: d?.requests ?? 0,
123+
totalTokens: d?.totalTokens ?? 0,
124+
level: d ? bucketLevel(d.requests, buckets) : 0,
125+
dayOfWeek: cursor.getDay(),
126+
});
127+
if (cursor.getDay() === 6) {
112128
weeks.push(week);
113129
week = [];
114130
}
131+
cursor.setDate(cursor.getDate() + 1);
115132
}
116133
if (week.length > 0) {
117134
while (week.length < 7) {
118135
week.push({ date: "", requests: 0, totalTokens: 0, level: 0, dayOfWeek: week.length });
119136
}
120137
weeks.push(week);
121138
}
122-
return { weeks, buckets };
139+
return { weeks, months, buckets };
123140
}
124141

125142
export default function Usage({ apiBase }: { apiBase: string }) {
@@ -194,17 +211,28 @@ export default function Usage({ apiBase }: { apiBase: string }) {
194211
<section className="panel" style={{ marginTop: 16 }}>
195212
<h3 className="panel-title">{t("usage.section.heatmap")}</h3>
196213
<div className="heatmap">
197-
<div className="heatmap-grid" style={{ gridTemplateColumns: `repeat(${heatmap.weeks.length}, 12px)` }}>
198-
{heatmap.weeks.map((week, wi) => (
199-
<div key={wi} className="heatmap-week">
200-
{week.map((cell, di) => (
201-
<div key={`${wi}-${di}`}
202-
className={`heatmap-cell heatmap-cell-${cell.level}`}
203-
title={cell.date ? `${cell.date}: ${cell.requests} req · ${formatTokens(cell.totalTokens)} tokens` : ""} />
204-
))}
205-
</div>
214+
<div className="heatmap-months" style={{ gridTemplateColumns: `28px repeat(${heatmap.weeks.length}, 1fr)` }}>
215+
<span className="heatmap-day-spacer" />
216+
{heatmap.months.map((m, i) => (
217+
<span key={i} className="heatmap-month" style={{ gridColumn: m.col + 2 }}>{m.label}</span>
206218
))}
207219
</div>
220+
<div className="heatmap-body">
221+
<div className="heatmap-days">
222+
<span /><span>Mon</span><span /><span>Wed</span><span /><span>Fri</span><span />
223+
</div>
224+
<div className="heatmap-grid" style={{ gridTemplateColumns: `repeat(${heatmap.weeks.length}, 1fr)` }}>
225+
{heatmap.weeks.map((week, wi) => (
226+
<div key={wi} className="heatmap-week">
227+
{week.map((cell, di) => (
228+
<div key={`${wi}-${di}`}
229+
className={`heatmap-cell heatmap-cell-${cell.level}`}
230+
title={cell.date ? `${cell.date}: ${cell.requests} req · ${formatTokens(cell.totalTokens)} tokens` : ""} />
231+
))}
232+
</div>
233+
))}
234+
</div>
235+
</div>
208236
<div className="heatmap-legend muted">
209237
<span>{t("usage.heatmap.less")}</span>
210238
{[0, 1, 2, 3, 4].map(l => <span key={l} className={`heatmap-cell heatmap-cell-${l}`} />)}

gui/src/styles.css

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -370,16 +370,21 @@ select.input { appearance: none; }
370370
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
371371
.panel-head .panel-title { margin: 0; }
372372
.panel-head .input { max-width: 220px; }
373-
.heatmap { display: flex; flex-direction: column; gap: 10px; overflow-x: auto; padding-bottom: 4px; }
374-
.heatmap-grid { display: flex; gap: 2px; }
375-
.heatmap-week { display: grid; grid-template-rows: repeat(7, 12px); gap: 2px; }
376-
.heatmap-cell { width: 12px; height: 12px; border-radius: 2px; background: var(--border); }
373+
.heatmap { display: flex; flex-direction: column; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
374+
.heatmap-months { display: grid; font-size: 11px; color: var(--muted); margin-bottom: -2px; }
375+
.heatmap-day-spacer { grid-column: 1; }
376+
.heatmap-month { white-space: nowrap; }
377+
.heatmap-body { display: flex; gap: 3px; }
378+
.heatmap-days { display: grid; grid-template-rows: repeat(7, 1fr); font-size: 10px; color: var(--muted); width: 25px; flex-shrink: 0; align-items: center; }
379+
.heatmap-grid { display: grid; gap: 3px; flex: 1; min-width: 0; }
380+
.heatmap-week { display: grid; grid-template-rows: repeat(7, 1fr); gap: 3px; }
381+
.heatmap-cell { aspect-ratio: 1; border-radius: 2px; background: var(--border); min-width: 0; }
377382
.heatmap-cell-0 { background: var(--border); }
378383
.heatmap-cell-1 { background: color-mix(in oklch, var(--green) 25%, var(--surface)); }
379384
.heatmap-cell-2 { background: color-mix(in oklch, var(--green) 50%, var(--surface)); }
380385
.heatmap-cell-3 { background: color-mix(in oklch, var(--green) 75%, var(--surface)); }
381386
.heatmap-cell-4 { background: var(--green); }
382-
.heatmap-legend { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; }
383-
.heatmap-legend .heatmap-cell { width: 10px; height: 10px; }
387+
.heatmap-legend { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; align-self: flex-end; }
388+
.heatmap-legend .heatmap-cell { width: 10px; height: 10px; aspect-ratio: auto; }
384389
.usage-bar { width: 100%; height: 6px; background: var(--border); border-radius: 999px; overflow: hidden; min-width: 60px; }
385390
.usage-bar-fill { height: 100%; background: var(--green); border-radius: 999px; }

0 commit comments

Comments
 (0)