From 7931c3ce5e78325deb37cbaac4b7647b670f6232 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 04:47:46 +0000 Subject: [PATCH 1/2] test(cmux-next stats): a week and a month draw one row of days; the default range loads before start (red) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15c2a-ee79-7910-9d76-ef0536b9880d&host=huge-blue --- webviews/test/stats-page.test.tsx | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/webviews/test/stats-page.test.tsx b/webviews/test/stats-page.test.tsx index ad7ddcd15c86..2acfce4ba8f2 100644 --- a/webviews/test/stats-page.test.tsx +++ b/webviews/test/stats-page.test.tsx @@ -281,3 +281,59 @@ test("with no host the page says where stats live", async () => { await store.start(); expect(store.getSnapshot()).toMatchObject({ unavailable: true, loading: false }); }); + +async function renderRange(days: 7 | 30 | 84) { + const { createRoot } = await import("react-dom/client"); + const { StatsPage } = await import("../src/pages/stats/StatsPage"); + const { StatsStore } = await import("../src/pages/stats/store"); + const { createStrings } = await import("../src/pages/shared/i18n"); + const table = (await import("../src/pages/stats/generated/strings.json")).default; + const client: PageClient = { + call: (_op: string, params: unknown) => + Promise.resolve(mockSummary("busy", (params as { days: number }).days, 120, NOON) as R), + subscribe: async () => () => {}, + handle: () => () => {}, + }; + const store = new StatsStore(client, () => 120); + act(() => root?.unmount()); + root = createRoot(host); + act(() => root!.render()); + await act(() => store.setRange(days)); + return [...host.querySelectorAll(".stats-days .stats-day")].map( + (node) => node.querySelector(".stats-day-label")?.textContent ?? "", + ); +} + +test("a week draws its seven days in one row under their weekday names, today last", async () => { + expect(await renderRange(7)).toEqual(["Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon"]); + expect(host.querySelector(".stats-heatmap")).toBeNull(); + expect(host.querySelectorAll(".stats-heat:not(.is-padding)").length).toBe(7); +}); + +test("a month draws one row of days, dated today and every seventh day before it", async () => { + const labels = await renderRange(30); + expect(labels.length).toBe(30); + expect(labels.filter(Boolean)).toEqual(["14", "21", "28", "5", "12"]); + expect(labels.at(-1)).toBe("12"); +}); + +test("twelve weeks keep the week-column heatmap", async () => { + expect(await renderRange(84)).toEqual([]); + expect(host.querySelectorAll(".stats-heatmap .stats-heat:not(.is-padding)").length).toBe(84); +}); + +test("picking the default range before the first read still reads it", async () => { + const { StatsStore } = await import("../src/pages/stats/store"); + let calls = 0; + const client: PageClient = { + call: () => { + calls++; + return new Promise(() => {}); + }, + subscribe: async () => () => {}, + handle: () => () => {}, + }; + const store = new StatsStore(client, () => 0); + void store.setRange(store.getSnapshot().range); + expect(calls).toBe(1); +}); From 9ab7539dfeae6e3e2de0af9c8d2c2f6103ea6ca5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 21 Oct 2026 04:50:20 +0000 Subject: [PATCH 2/2] fix(cmux-next stats): a week or a month is one row of days across the page; the default range loads when picked first The week-column heatmap drew a week as two 12px columns in an 850px section, and a month as five. Ranges up to 31 days now draw one row of days on the hour bars' rhythm, labelled with the weekday, or the date every seventh day. The store no longer starts out claiming a read is on its way, so setRange(84) before start() reads it (the gallery's 12-week state sat on Loading). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15c2a-ee79-7910-9d76-ef0536b9880d&host=huge-blue --- webviews/src/pages/stats/StatsPage.tsx | 39 +++++++++++++---- webviews/src/pages/stats/shape.ts | 12 ++++++ webviews/src/pages/stats/store.ts | 3 +- webviews/src/pages/stats/styles.css | 59 ++++++++++++++++++++++---- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/webviews/src/pages/stats/StatsPage.tsx b/webviews/src/pages/stats/StatsPage.tsx index 74c979784f3e..bbfb5adbf243 100644 --- a/webviews/src/pages/stats/StatsPage.tsx +++ b/webviews/src/pages/stats/StatsPage.tsx @@ -14,6 +14,7 @@ import { formatWait, heatmap, hourShares, + stripLabel, } from "./shape"; import type { StatsStore } from "./store"; import { RANGES, type StatsSummary } from "./types"; @@ -65,6 +66,8 @@ function Summary({ summary, strings }: { summary: StatsSummary; strings: Strings const empty = summary.turns === 0 && summary.workingMs === 0; const attention = attentionRows(summary); const ratio = new Intl.NumberFormat(language, { maximumFractionDigits: 2 }); + const cells = heatmap(summary.days, summary.until, summary.tzOffsetMinutes, range); + const days = cells.filter((cell) => cell.day); const tiles: { key: string; value: string; detail?: string }[] = [ { key: "stats.tile.agentTime", value: duration(summary.workingMs) }, { key: "stats.tile.waiting", value: duration(summary.waitingMs) }, @@ -94,15 +97,33 @@ function Summary({ summary, strings }: { summary: StatsSummary; strings: Strings {empty &&

{strings.t("stats.page.empty")}

}

{strings.t("stats.section.activity")}

-
- {heatmap(summary.days, summary.until, summary.tzOffsetMinutes, range).map((cell, index) => ( -
- ))} -
+ {range > 31 ? ( +
+ {cells.map((cell, index) => ( +
+ ))} +
+ ) : ( + // A week or a month is too few columns for the weekday grid: one row of days fills the width. +
+ {days.map((cell, index) => ( +
+
+ + {stripLabel(cell.day!, days.length - 1 - index, range, language)} + +
+ ))} +
+ )}

{strings.t("stats.section.hours")}

diff --git a/webviews/src/pages/stats/shape.ts b/webviews/src/pages/stats/shape.ts index 635981846f80..5f074d3e27cd 100644 --- a/webviews/src/pages/stats/shape.ts +++ b/webviews/src/pages/stats/shape.ts @@ -76,6 +76,18 @@ export function formatDay(day: string, language: string): string { }).format(new Date(`${day}T00:00:00Z`)); } +/** + * A day strip's label under `day`: the weekday over a week; over a month the day of the month on + * today and every seventh day before it (`fromEnd` counts back from today), else none. + */ +export function stripLabel(day: string, fromEnd: number, rangeDays: number, language: string): string { + if (rangeDays > 7 && fromEnd % 7 !== 0) return ""; + return new Intl.DateTimeFormat(language, { + ...(rangeDays > 7 ? { day: "numeric" } : { weekday: "short" }), + timeZone: "UTC", + }).format(new Date(`${day}T00:00:00Z`)); +} + /** `1 PM` or `13 Uhr` for an hour of the day, in the person's language. */ export function formatHour(hour: number, language: string): string { return new Intl.DateTimeFormat(language, { hour: "numeric", timeZone: "UTC" }).format(Date.UTC(2026, 0, 1, hour)); diff --git a/webviews/src/pages/stats/store.ts b/webviews/src/pages/stats/store.ts index 6ca0142cbf92..3dc24d34622d 100644 --- a/webviews/src/pages/stats/store.ts +++ b/webviews/src/pages/stats/store.ts @@ -17,7 +17,8 @@ export interface StatsSnapshot { } export class StatsStore { - private snapshot: StatsSnapshot = { range: RANGES[RANGES.length - 1], loading: true }; + // Nothing is on its way until the first read, so picking the default range first still reads it. + private snapshot: StatsSnapshot = { range: RANGES[RANGES.length - 1], loading: false }; private listeners = new Set<() => void>(); private request = 0; diff --git a/webviews/src/pages/stats/styles.css b/webviews/src/pages/stats/styles.css index 98c2cf952e6c..c93c9711faf8 100644 --- a/webviews/src/pages/stats/styles.css +++ b/webviews/src/pages/stats/styles.css @@ -1,5 +1,6 @@ -/* The Stats page: tiles, a week-column heatmap, hour bars and per-agent rows on the shared page - backdrop (pageBase.css). Heat and bars are the accent at rising strength. */ +/* The Stats page: tiles, a week-column heatmap (a row of days for a week or a month), hour bars + and per-agent rows on the shared page backdrop (pageBase.css). Heat and bars are the accent at + rising strength. */ .stats { max-width: 880px; margin: 0 auto; @@ -24,6 +25,7 @@ .stats-private, .stats-muted, +.stats-day-label, .stats-tile-label, .stats-tile-detail { color: var(--page-text-3); @@ -119,11 +121,50 @@ background: var(--page-hover); } -.stats-heat.is-padding { background: transparent; } -.stats-heat-1 { background: color-mix(in srgb, var(--cmux-accent, var(--page-text)) 25%, transparent); } -.stats-heat-2 { background: color-mix(in srgb, var(--cmux-accent, var(--page-text)) 45%, transparent); } -.stats-heat-3 { background: color-mix(in srgb, var(--cmux-accent, var(--page-text)) 70%, transparent); } -.stats-heat-4 { background: var(--cmux-accent, var(--page-text)); } +/* A week or a month: one row of days across the width on the hour bars' 2px gap, each over its + label (the weekday, or the date every seventh day). */ +.stats-days { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 2px; +} + +.stats-day { + min-width: 0; +} + +.stats-day > .stats-heat { + height: 24px; + border-radius: 2px; +} + +/* Centered under its day even where a date is wider than a month's narrow column. */ +.stats-day-label { + display: flex; + justify-content: center; + margin-top: 4px; + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.stats-heat.is-padding { + background: transparent; +} +.stats-heat-1 { + background: color-mix(in srgb, var(--cmux-accent, var(--page-text)) 25%, transparent); +} +.stats-heat-2 { + background: color-mix(in srgb, var(--cmux-accent, var(--page-text)) 45%, transparent); +} +.stats-heat-3 { + background: color-mix(in srgb, var(--cmux-accent, var(--page-text)) 70%, transparent); +} +.stats-heat-4 { + background: var(--cmux-accent, var(--page-text)); +} .stats-hours { display: grid; @@ -176,7 +217,9 @@ overflow: hidden; } -.stats-harness-bar { height: 100%; } +.stats-harness-bar { + height: 100%; +} .stats-harness-value { min-width: 4ch;