Skip to content

Commit 4111cb2

Browse files
arul28claude
andcommitted
Activity heatmap fills chart area; per-tab empty hints (desktop + iOS)
Heatmap cells now scale to the box: one tall row for <=7 points, 7-row calendar grid for longer ranges. Active tab with an all-zero series shows a muted hint while keeping the legend, instead of a bare chart. iOS hasActivity made github-aware to match desktop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cc1cade commit 4111cb2

3 files changed

Lines changed: 169 additions & 48 deletions

File tree

apps/desktop/src/renderer/components/usage/ActivityModule.tsx

Lines changed: 61 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -301,12 +301,12 @@ function ChartFrame({
301301

302302
function ActivityHeatmap({
303303
points,
304-
cellPx,
304+
height,
305305
reduced,
306306
tooltip,
307307
}: {
308308
points: AdeUsageDailyPoint[];
309-
cellPx: number;
309+
height: number;
310310
reduced: boolean;
311311
tooltip: ReturnType<typeof useDayTooltip>;
312312
}) {
@@ -316,31 +316,52 @@ function ActivityHeatmap({
316316
return ordered.map((point) => ({ point, intensity: Math.max(0, Math.min(1, dayValue(point) / max)) }));
317317
}, [points]);
318318

319+
// Fill the chart box instead of leaving it mostly blank: a short range lays out
320+
// as one tall row of large cells; longer ranges use a 7-row calendar-style grid
321+
// (columns = weeks) whose cells stretch to fill the available width and height.
322+
const rows = cells.length <= 7 ? 1 : 7;
323+
const cols = Math.max(1, Math.ceil(cells.length / rows));
324+
319325
return (
320-
<div className="flex min-h-0 flex-1 flex-col justify-center">
321-
<div
322-
className="grid w-full justify-start gap-[3px]"
323-
style={{ gridAutoFlow: "column", gridTemplateRows: `repeat(7, ${cellPx}px)`, gridAutoColumns: `${cellPx}px` }}
324-
aria-label="Daily activity heatmap"
325-
>
326-
{cells.map(({ point, intensity }) => (
327-
<span
328-
key={point.date}
329-
className="rounded-[2px]"
330-
style={{
331-
background:
332-
intensity === 0
333-
? "color-mix(in srgb, var(--color-fg) 7%, transparent)"
334-
: `color-mix(in srgb, ${HEATMAP_HUE} ${Math.round(24 + intensity * 68)}%, var(--color-card))`,
335-
transition: reduced ? undefined : "background 120ms ease",
336-
cursor: "default",
337-
}}
338-
onPointerEnter={(event) => tooltip.show(point, event.currentTarget)}
339-
onPointerLeave={tooltip.hide}
340-
onClick={(event) => tooltip.toggle(point, event.currentTarget)}
341-
/>
342-
))}
343-
</div>
326+
<div
327+
className="grid min-h-0 w-full flex-1 gap-[3px]"
328+
style={{
329+
height,
330+
gridAutoFlow: "column",
331+
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
332+
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
333+
}}
334+
role="img"
335+
aria-label="Daily activity heatmap"
336+
data-heatmap-rows={rows}
337+
>
338+
{cells.map(({ point, intensity }) => (
339+
<span
340+
key={point.date}
341+
className="rounded-[2px]"
342+
style={{
343+
background:
344+
intensity === 0
345+
? "color-mix(in srgb, var(--color-fg) 7%, transparent)"
346+
: `color-mix(in srgb, ${HEATMAP_HUE} ${Math.round(24 + intensity * 68)}%, var(--color-card))`,
347+
transition: reduced ? undefined : "background 120ms ease",
348+
cursor: "default",
349+
}}
350+
onPointerEnter={(event) => tooltip.show(point, event.currentTarget)}
351+
onPointerLeave={tooltip.hide}
352+
onClick={(event) => tooltip.toggle(point, event.currentTarget)}
353+
/>
354+
))}
355+
</div>
356+
);
357+
}
358+
359+
/** Muted, centered hint for a tab whose own series is empty while the module
360+
* has data on other tabs (so the global warm-empty state does not apply). */
361+
function TabEmptyHint({ message }: { message: string }) {
362+
return (
363+
<div className="flex min-h-0 flex-1 items-center justify-center text-center text-[11px] text-muted-fg">
364+
{message}
344365
</div>
345366
);
346367
}
@@ -358,8 +379,12 @@ function TokenBars({
358379
}) {
359380
const max = Math.max(1, ...points.map((p) => p.totalTokens));
360381
const anyCache = points.some((p) => (p.cachedTokens ?? 0) > 0);
382+
const anyTokens = points.some((p) => p.totalTokens > 0);
361383
return (
362384
<div className="flex min-h-0 flex-1 flex-col justify-end gap-2">
385+
{!anyTokens ? (
386+
<TabEmptyHint message="No token usage in this range." />
387+
) : (
363388
<ChartFrame height={height} ariaLabel="Token usage by day, split by input, output, and cache">
364389
{points.map((point) => {
365390
const total = Math.max(0, point.inputTokens + point.outputTokens + (point.cachedTokens ?? 0));
@@ -381,6 +406,7 @@ function TokenBars({
381406
);
382407
})}
383408
</ChartFrame>
409+
)}
384410
<Legend
385411
items={[
386412
{ color: TOKEN_COLORS.input, label: "Input" },
@@ -409,8 +435,12 @@ function CodeBars({
409435
? Math.max(1, ...points.map((p) => (p.githubAdditions ?? 0) + (p.githubDeletions ?? 0)))
410436
: 1;
411437
const max = Math.max(localMax, githubMax);
438+
const anyCode = points.some((p) => p.insertions + p.deletions > 0) || anyGithub;
412439
return (
413440
<div className="flex min-h-0 flex-1 flex-col justify-end gap-2">
441+
{!anyCode ? (
442+
<TabEmptyHint message="No code changes in this range." />
443+
) : (
414444
<ChartFrame height={height} ariaLabel="Code changes by day, additions and deletions">
415445
{points.map((point) => {
416446
const total = Math.max(0, point.insertions + point.deletions);
@@ -443,6 +473,7 @@ function CodeBars({
443473
);
444474
})}
445475
</ChartFrame>
476+
)}
446477
<Legend
447478
items={[
448479
{ color: CODE_COLORS.insertions, label: "Added" },
@@ -463,6 +494,9 @@ function ClientMix({ stats }: { stats: AdeUsageStats }) {
463494
if (client === "web") return <Globe size={12} />;
464495
return <Monitor size={12} />;
465496
};
497+
if (clients.length === 0) {
498+
return <TabEmptyHint message="No client activity in this range." />;
499+
}
466500
return (
467501
<div className="flex min-h-0 flex-1 flex-col justify-center gap-3">
468502
<div className="flex h-3 overflow-hidden rounded-full" style={{ background: "color-mix(in srgb, var(--color-fg) 6%, transparent)" }}>
@@ -748,7 +782,6 @@ export function ActivityModule({
748782

749783
const compactMode = variant === "compact";
750784
const chartHeight = compactMode ? 84 : 132;
751-
const cellPx = compactMode ? 8 : 11;
752785
const maxBars = compactMode ? 40 : 64;
753786
const chartPoints = useChartPoints(stats?.daily ?? [], maxBars);
754787
const hasActivity = (stats?.daily ?? []).some(dayHasActivity);
@@ -770,7 +803,7 @@ export function ActivityModule({
770803
} else if (!hasActivity) {
771804
chart = <WarmEmpty height={chartHeight} />;
772805
} else if (tab === "activity") {
773-
chart = <ActivityHeatmap points={stats.daily} cellPx={cellPx} reduced={reduced} tooltip={tooltip} />;
806+
chart = <ActivityHeatmap points={stats.daily} height={chartHeight} reduced={reduced} tooltip={tooltip} />;
774807
} else if (tab === "tokens") {
775808
chart = <TokenBars points={chartPoints} height={chartHeight} reduced={reduced} tooltip={tooltip} />;
776809
} else if (tab === "code") {

apps/desktop/src/renderer/components/usage/usage.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,50 @@ describe("usage components", () => {
645645
render(<ActivityModule stats={makeActivityStats()} variant="full" preset="7d" onPresetChange={vi.fn()} />);
646646
expect(screen.getByRole("button", { name: "30d" })).toBeTruthy();
647647
});
648+
649+
it("scales the heatmap to fill the box: one row for short ranges, seven for long", () => {
650+
const short = makeActivityStats();
651+
const { rerender, container } = render(<ActivityModule stats={short} preset="7d" onPresetChange={vi.fn()} />);
652+
const grid1 = container.querySelector('[aria-label="Daily activity heatmap"]')!;
653+
expect(grid1.getAttribute("data-heatmap-rows")).toBe("1");
654+
expect(grid1.children.length).toBe(short.daily.length);
655+
656+
const long = makeActivityStats({
657+
daily: Array.from({ length: 30 }, (_, i) => ({
658+
date: `2026-06-${String(i + 1).padStart(2, "0")}`,
659+
inputTokens: 100 * i, outputTokens: 50 * i, totalTokens: 150 * i, cachedTokens: 0,
660+
commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 1,
661+
})),
662+
} as unknown as Partial<AdeUsageStats>);
663+
rerender(<ActivityModule stats={long} preset="30d" onPresetChange={vi.fn()} />);
664+
const grid2 = container.querySelector('[aria-label="Daily activity heatmap"]')!;
665+
expect(grid2.getAttribute("data-heatmap-rows")).toBe("7");
666+
expect(grid2.children.length).toBe(30);
667+
});
668+
669+
it("shows a per-tab hint when the active tab is empty but the module has data", () => {
670+
const stats = makeActivityStats({
671+
daily: [
672+
{ date: "2026-07-08", inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cachedTokens: 0, commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 2 },
673+
{ date: "2026-07-09", inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cachedTokens: 0, commits: 0, prs: 0, insertions: 0, deletions: 0, filesChanged: 0, sessions: 1, interactions: 2 },
674+
],
675+
clients: [],
676+
} as unknown as Partial<AdeUsageStats>);
677+
render(<ActivityModule stats={stats} preset="7d" onPresetChange={vi.fn()} />);
678+
679+
// Global warm-empty must NOT show — the module has token activity.
680+
expect(screen.queryByText("Your activity will appear here after your first chat.")).toBeNull();
681+
682+
// Code tab: zeroed code series → hint, but the legend stays.
683+
fireEvent.click(screen.getByRole("tab", { name: "Code" }));
684+
expect(screen.getByText("No code changes in this range.")).toBeTruthy();
685+
expect(screen.getByText("Added")).toBeTruthy();
686+
expect(screen.getByText("Removed")).toBeTruthy();
687+
688+
// Clients tab: no client interactions → hint.
689+
fireEvent.click(screen.getByRole("tab", { name: "Clients" }));
690+
expect(screen.getByText("No client activity in this range.")).toBeTruthy();
691+
});
648692
});
649693

650694
describe("WorkActivityModule", () => {

apps/ios/ADE/Views/Work/WorkUsageActivityCarousel.swift

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ struct WorkUsageActivityCarousel: View {
177177
return daily.contains { point in
178178
(point.totalTokens ?? 0) > 0 || (point.sessions ?? 0) > 0
179179
|| (point.insertions ?? 0) > 0 || (point.deletions ?? 0) > 0
180+
|| (point.githubCommits ?? 0) > 0 || (point.githubPrs ?? 0) > 0
181+
|| (point.githubAdditions ?? 0) > 0 || (point.githubDeletions ?? 0) > 0
180182
|| (point.interactions ?? 0) > 0
181183
}
182184
}
@@ -443,25 +445,45 @@ private struct WorkUsageHeatmap: View {
443445
var body: some View {
444446
let buckets = workUsageBuckets(points, maxCount: 70)
445447
let maximum = max(1, buckets.map(\.activity).max() ?? 1)
446-
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 14), spacing: 3) {
447-
ForEach(buckets) { bucket in
448-
RoundedRectangle(cornerRadius: 2, style: .continuous)
449-
.fill(bucket.activity == 0
450-
? ADEColor.textMuted.opacity(0.10)
451-
: WorkUsageColors.heatmap.opacity(0.25 + 0.72 * Double(bucket.activity) / Double(maximum)))
452-
.frame(height: 11)
453-
.overlay {
454-
if selectedId == bucket.date {
455-
RoundedRectangle(cornerRadius: 2, style: .continuous)
456-
.stroke(ADEColor.textPrimary.opacity(0.6), lineWidth: 1)
448+
// Fill the box: a short range is one tall row of large cells; longer ranges
449+
// use a 7-row calendar grid (columns = weeks). Cells stretch to fill both
450+
// dimensions instead of leaving the chart area mostly blank.
451+
let rows = buckets.count <= 7 ? 1 : 7
452+
let columns = stride(from: 0, to: buckets.count, by: rows).map { start in
453+
Array(buckets[start..<min(start + rows, buckets.count)])
454+
}
455+
HStack(spacing: 3) {
456+
ForEach(Array(columns.enumerated()), id: \.offset) { _, column in
457+
VStack(spacing: 3) {
458+
ForEach(column) { bucket in
459+
cell(bucket, maximum: maximum)
460+
}
461+
if column.count < rows {
462+
ForEach(0..<(rows - column.count), id: \.self) { _ in
463+
Color.clear.frame(maxWidth: .infinity, maxHeight: .infinity)
457464
}
458465
}
459-
.contentShape(Rectangle())
460-
.onTapGesture { onSelect(detail(bucket)) }
461-
.accessibilityLabel("\(workUsageFormatDay(bucket.date)), \(workUsageCompact(bucket.tokens)) tokens")
466+
}
462467
}
463468
}
464-
.frame(maxHeight: .infinity, alignment: .center)
469+
.frame(maxWidth: .infinity, maxHeight: .infinity)
470+
}
471+
472+
private func cell(_ bucket: WorkUsageVisualBucket, maximum: Int) -> some View {
473+
RoundedRectangle(cornerRadius: 2, style: .continuous)
474+
.fill(bucket.activity == 0
475+
? ADEColor.textMuted.opacity(0.10)
476+
: WorkUsageColors.heatmap.opacity(0.25 + 0.72 * Double(bucket.activity) / Double(maximum)))
477+
.frame(maxWidth: .infinity, maxHeight: .infinity)
478+
.overlay {
479+
if selectedId == bucket.date {
480+
RoundedRectangle(cornerRadius: 2, style: .continuous)
481+
.stroke(ADEColor.textPrimary.opacity(0.6), lineWidth: 1)
482+
}
483+
}
484+
.contentShape(Rectangle())
485+
.onTapGesture { onSelect(detail(bucket)) }
486+
.accessibilityLabel("\(workUsageFormatDay(bucket.date)), \(workUsageCompact(bucket.tokens)) tokens")
465487
}
466488

467489
private func detail(_ bucket: WorkUsageVisualBucket) -> WorkUsageBucketDetail {
@@ -493,14 +515,22 @@ private struct WorkUsageBars: View {
493515
let githubMax = anyGithub ? (buckets.map(\.githubCode).max() ?? 1) : 1
494516
let maximum = max(1, localMax, githubMax)
495517
let anyCache = mode == .tokens && buckets.contains { $0.cachedTokens > 0 }
518+
let hasData = buckets.contains { (mode == .tokens ? $0.tokens : $0.code + $0.githubCode) > 0 }
496519

497520
VStack(spacing: 6) {
498-
GeometryReader { proxy in
499-
HStack(alignment: .bottom, spacing: 2) {
500-
ForEach(Array(buckets.enumerated()), id: \.element.id) { index, bucket in
501-
bar(bucket: bucket, index: index, height: proxy.size.height, maximum: maximum, anyGithub: anyGithub)
521+
if hasData {
522+
GeometryReader { proxy in
523+
HStack(alignment: .bottom, spacing: 2) {
524+
ForEach(Array(buckets.enumerated()), id: \.element.id) { index, bucket in
525+
bar(bucket: bucket, index: index, height: proxy.size.height, maximum: maximum, anyGithub: anyGithub)
526+
}
502527
}
503528
}
529+
} else {
530+
Text(mode == .tokens ? "No token usage in this range." : "No code changes in this range.")
531+
.font(.caption2)
532+
.foregroundStyle(ADEColor.textMuted)
533+
.frame(maxWidth: .infinity, maxHeight: .infinity)
504534
}
505535
legend(anyCache: anyCache, anyGithub: anyGithub)
506536
}
@@ -620,7 +650,7 @@ private struct WorkUsageClientMix: View {
620650
let visible = clients.filter { $0.interactions > 0 }.sorted { $0.interactions > $1.interactions }
621651
let total = max(1, visible.reduce(0) { $0 + $1.interactions })
622652
if visible.isEmpty {
623-
WorkUsageWarmEmpty()
653+
WorkUsageTabEmptyHint(message: "No client activity in this range.")
624654
} else {
625655
VStack(spacing: 10) {
626656
GeometryReader { proxy in
@@ -659,6 +689,20 @@ private struct WorkUsageClientMix: View {
659689
}
660690
}
661691

692+
/// Muted, centered hint for a tab whose own series is empty while the module has
693+
/// data on other tabs (so the global warm-empty state does not apply).
694+
private struct WorkUsageTabEmptyHint: View {
695+
let message: String
696+
var body: some View {
697+
Text(message)
698+
.font(.caption2)
699+
.foregroundStyle(ADEColor.textMuted)
700+
.multilineTextAlignment(.center)
701+
.frame(maxWidth: .infinity, maxHeight: .infinity)
702+
.accessibilityLabel(message)
703+
}
704+
}
705+
662706
private struct WorkUsageWarmEmpty: View {
663707
private let fractions: [CGFloat] = [0.35, 0.55, 0.4, 0.7, 0.5, 0.62, 0.44, 0.58, 0.48, 0.66, 0.4, 0.54]
664708

0 commit comments

Comments
 (0)