Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions webviews/src/pages/stats/StatsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
formatWait,
heatmap,
hourShares,
stripLabel,
} from "./shape";
import type { StatsStore } from "./store";
import { RANGES, type StatsSummary } from "./types";
Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -94,15 +97,33 @@ function Summary({ summary, strings }: { summary: StatsSummary; strings: Strings
{empty && <p className="stats-message stats-muted">{strings.t("stats.page.empty")}</p>}
<section className="stats-section">
<h2 className="stats-section-title">{strings.t("stats.section.activity")}</h2>
<div className="stats-heatmap">
{heatmap(summary.days, summary.until, summary.tzOffsetMinutes, range).map((cell, index) => (
<div
key={cell.day ?? `pad-${index}`}
className={cell.day ? `stats-heat stats-heat-${cell.level}` : "stats-heat is-padding"}
title={cell.day ? `${formatDay(cell.day, language)} · ${duration(cell.workingMs)}` : undefined}
/>
))}
</div>
{range > 31 ? (
<div className="stats-heatmap">
{cells.map((cell, index) => (
<div
key={cell.day ?? `pad-${index}`}
className={cell.day ? `stats-heat stats-heat-${cell.level}` : "stats-heat is-padding"}
title={cell.day ? `${formatDay(cell.day, language)} · ${duration(cell.workingMs)}` : undefined}
/>
))}
</div>
) : (
// A week or a month is too few columns for the weekday grid: one row of days fills the width.
<div className="stats-days">
{days.map((cell, index) => (
<div
key={cell.day}
className="stats-day"
title={`${formatDay(cell.day!, language)} · ${duration(cell.workingMs)}`}
>
<div className={`stats-heat stats-heat-${cell.level}`} />
<span className="stats-day-label">
{stripLabel(cell.day!, days.length - 1 - index, range, language)}
</span>
</div>
))}
</div>
)}
</section>
<section className="stats-section">
<h2 className="stats-section-title">{strings.t("stats.section.hours")}</h2>
Expand Down
12 changes: 12 additions & 0 deletions webviews/src/pages/stats/shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
3 changes: 2 additions & 1 deletion webviews/src/pages/stats/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
59 changes: 51 additions & 8 deletions webviews/src/pages/stats/styles.css
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +25,7 @@

.stats-private,
.stats-muted,
.stats-day-label,
.stats-tile-label,
.stats-tile-detail {
color: var(--page-text-3);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -176,7 +217,9 @@
overflow: hidden;
}

.stats-harness-bar { height: 100%; }
.stats-harness-bar {
height: 100%;
}

.stats-harness-value {
min-width: 4ch;
Expand Down
56 changes: 56 additions & 0 deletions webviews/test/stats-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <R,>(_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(<StatsPage store={store} strings={createStrings(table, ["en"])} />));
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);
});