diff --git a/packages/cli/src/commands/handlers/stats.ts b/packages/cli/src/commands/handlers/stats.ts index 3502ac5efe2..321007a73df 100644 --- a/packages/cli/src/commands/handlers/stats.ts +++ b/packages/cli/src/commands/handlers/stats.ts @@ -1,5 +1,7 @@ import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client" import { Service } from "@opencode-ai/client/effect/service" +import { TokenUsage } from "@opencode-ai/schema/token-usage" +import { activityCalendar } from "@opencode-ai/util/activity-calendar" import { Effect, Option } from "effect" import { EOL } from "node:os" import { Commands } from "../commands" @@ -91,7 +93,7 @@ type RenderOptions = { const colors = terminalPalette() export function renderStats(stats: SessionStatsInfo, options: RenderOptions) { - const totalTokens = tokenTotal(stats.tokens) + const totalTokens = TokenUsage.total(stats.tokens) const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0 const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100 @@ -167,43 +169,23 @@ function renderActivity( color: boolean, width: number, ) { - const values = new Map(activity.map((day) => [day.date, day.steps])) - const rangeStart = dateOrdinal(new Date(from)) - const rangeEnd = dateOrdinal(new Date(to - 1)) - const end = new Date(to - 1) - end.setHours(12, 0, 0, 0) - end.setDate(end.getDate() + (7 - mondayIndex(end) - 1)) - const start = new Date(from) - start.setHours(12, 0, 0, 0) - start.setDate(start.getDate() - mondayIndex(start)) - const maxWeeks = Math.max(1, Math.min(53, width - 4)) - const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 - const latest = new Date(end) - latest.setDate(latest.getDate() - (maxWeeks - 1) * 7) - if (start < latest) start.setTime(latest.getTime()) - - const active = [...values.values()].filter((value) => value > 0) - const levels = [...new Set(active)].sort((a, b) => a - b) - const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => { - const date = new Date(start) - date.setDate(date.getDate() + week * 7) - return date - }) - const weeks = weekStarts.map((week) => - Array.from({ length: 7 }, (_, day) => { - const date = new Date(week) - date.setDate(date.getDate() + day) - const ordinal = dateOrdinal(date) - if (ordinal < rangeStart || ordinal > rangeEnd) return " " - return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color) - }), - ) + const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 }) + const months = calendar.months + .map((month, index) => + (index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span), + ) + .join("") + .trimEnd() const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] return [ - style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color), - ` ${style(monthLabels(weekStarts), "2", color)}`, + style( + calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity", + `1;${colors.primary}`, + color, + ), + ` ${style(months, "2", color)}`, ...weekdays.flatMap((label, day) => [ - `${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`, + `${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`, ...(day === weekdays.length - 1 ? [] : [""]), ]), "", @@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) { `${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`, width, ), - ` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`, + ` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`, ]), ...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []), ] @@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) { ...models.map((item) => tableRow( `${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`, - formatNumber(tokenTotal(item.tokens)), + formatNumber(TokenUsage.total(item.tokens)), formatNumber(item.steps), `$${item.cost.toFixed(2)}`, ), @@ -310,10 +292,6 @@ function truncate(value: string, width: number) { return value.length <= width ? value : value.slice(0, width - 1) + "…" } -function tokenTotal(tokens: SessionStatsInfo["tokens"]) { - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write -} - function formatNumber(value: number) { if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b` if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m` @@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) { return color ? `\x1b[${code}m${value}\x1b[0m` : value } -function activityGlyph(value: number, levels: number[], color: boolean) { - if (value === 0) return paintActivity(0, color) - const index = levels.indexOf(value) - const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4)) - return paintActivity(level, color) -} - function paintActivity(level: number, color: boolean) { const glyph = ["·", "░", "▒", "▓", "█"][level] if (!color) return glyph @@ -370,39 +341,3 @@ function terminalPalette() { } return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] } } - -function monthLabels(weeks: Date[]) { - const line: string[] = [] - weeks.reduce((previous, week, index) => { - const middle = new Date(week) - middle.setDate(middle.getDate() + 3) - const month = middle.getMonth() - if (month === previous) return previous - Intl.DateTimeFormat("en-US", { month: "short" }) - .format(middle) - .split("") - .forEach((character, offset) => { - line[index + offset] = character - }) - return month - }, -1) - return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ") - .join("") - .trimEnd() -} - -function mondayIndex(date: Date) { - return (date.getDay() + 6) % 7 -} - -function dateKey(date: Date) { - return [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, "0"), - String(date.getDate()).padStart(2, "0"), - ].join("-") -} - -function dateOrdinal(date: Date) { - return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000) -} diff --git a/packages/cli/test/stats.test.ts b/packages/cli/test/stats.test.ts index 0fdea8ad08c..e3d3d81b09a 100644 --- a/packages/cli/test/stats.test.ts +++ b/packages/cli/test/stats.test.ts @@ -48,6 +48,20 @@ describe("stats rendering", () => { expect(output).not.toContain("$12.34") }) + test("renders shared intensity levels and partial month labels", () => { + const output = renderStats( + { + ...stats, + range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() }, + activity: [{ date: "2026-04-29", steps: 1 }], + }, + options(), + ) + expect(output).toContain(" May") + expect(output).not.toContain("Apr") + expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588") + }) + test("renders only requested detail tables", () => { const output = renderStats(stats, options({ tools: true, cost: true })) expect(output).toContain("COST & TOKENS") diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts index 057ca61fe44..0704d97bf6d 100644 --- a/packages/core/src/session/stats.ts +++ b/packages/core/src/session/stats.ts @@ -8,6 +8,7 @@ import { Project } from "@opencode-ai/schema/project" import { Provider } from "@opencode-ai/schema/provider" import { SessionEvent } from "@opencode-ai/schema/session-event" import { ToolMode } from "@opencode-ai/schema/session-stats" +import { TokenUsage } from "@opencode-ai/schema/token-usage" import { Database } from "../database/database.js" import { EventTable } from "../event/sql.js" import { SessionMessageTable, SessionTable } from "./sql.js" @@ -341,7 +342,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { streak: longestStreak(days.map(([date]) => date)), activity: days.map(([date, steps]) => ({ date, steps })), models: [...models.values()] - .sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens)) + .sort((a, b) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens)) .map((model) => ({ ...model, cost: Money.USD.make(model.cost) })), } }) @@ -378,10 +379,6 @@ function addTokens(target: Tokens, source: Tokens) { target.cache.write += source.cache.write } -function tokenTotal(tokens: Tokens) { - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write -} - function addToolStatus( target: { calls: number; succeeded: number; failed: number; unfinished: number }, status: string | null, diff --git a/packages/schema/src/token-usage.ts b/packages/schema/src/token-usage.ts index 6f08add0cc6..b8ce1d2ec9c 100644 --- a/packages/schema/src/token-usage.ts +++ b/packages/schema/src/token-usage.ts @@ -12,3 +12,7 @@ export const Info = Schema.Struct({ write: Schema.Finite, }), }).annotate({ identifier: "TokenUsage.Info" }) + +export function total(tokens: Info) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} diff --git a/packages/schema/test/token-usage.test.ts b/packages/schema/test/token-usage.test.ts new file mode 100644 index 00000000000..ee5e850cb71 --- /dev/null +++ b/packages/schema/test/token-usage.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { TokenUsage } from "../src/token-usage.js" + +test("totals every token category", () => { + expect(TokenUsage.total({ input: 1, output: 2, reasoning: 4, cache: { read: 8, write: 16 } })).toBe(31) +}) + +test("supports zero and large usage totals without rounding", () => { + expect(TokenUsage.total({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })).toBe(0) + expect( + TokenUsage.total({ + input: 1_000_000_000, + output: 2_000_000_000, + reasoning: 3, + cache: { read: 4_000_000_000, write: 5 }, + }), + ).toBe(7_000_000_008) +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b81c29b9b66..752830b5a77 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1321,7 +1321,7 @@ function App(props: { pair?: DialogPairCredentials }) { - + diff --git a/packages/tui/src/feature-plugins/system/stats-data.ts b/packages/tui/src/feature-plugins/system/stats-data.ts new file mode 100644 index 00000000000..cafd2835320 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/stats-data.ts @@ -0,0 +1,18 @@ +import type { SessionStatsInfo } from "@opencode-ai/client" +import { TokenUsage } from "@opencode-ai/schema/token-usage" + +export function statsMetrics(stats: SessionStatsInfo) { + return [ + { + label: "tokens", + value: TokenUsage.total(stats.tokens), + }, + { label: "best streak", value: stats.streak }, + { label: "active days", value: stats.activeDays }, + { label: "sessions", value: stats.sessions }, + ] +} + +export function statsNumber(value: number) { + return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value) +} diff --git a/packages/tui/src/feature-plugins/system/stats.tsx b/packages/tui/src/feature-plugins/system/stats.tsx new file mode 100644 index 00000000000..7cce828cf91 --- /dev/null +++ b/packages/tui/src/feature-plugins/system/stats.tsx @@ -0,0 +1,218 @@ +import type { SessionStatsInfo } from "@opencode-ai/client" +import { Plugin } from "@opencode-ai/plugin/tui" +import { activityCalendar } from "@opencode-ai/util/activity-calendar" +import { TextAttributes } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createMemo, createResource, createSignal, For, Show } from "solid-js" +import { Logo } from "../../component/logo" +import { useTheme, useThemes } from "../../context/theme" +import { tint } from "../../theme/color" +import { statsMetrics, statsNumber } from "./stats-data" + +const digits: Record = { + "0": ["111", "101", "101", "101", "111"], + "1": ["010", "110", "010", "010", "111"], + "2": ["111", "001", "111", "100", "111"], + "3": ["111", "001", "111", "001", "111"], + "4": ["101", "101", "111", "001", "001"], + "5": ["111", "100", "111", "001", "111"], + "6": ["111", "100", "111", "101", "111"], + "7": ["111", "001", "010", "010", "010"], + "8": ["111", "101", "111", "101", "111"], + "9": ["111", "101", "111", "001", "111"], + ".": ["0", "0", "0", "0", "1"], + K: ["101", "110", "100", "110", "101"], + M: ["10001", "11011", "10101", "10001", "10001"], + B: ["110", "101", "110", "101", "110"], + T: ["111", "010", "010", "010", "010"], +} + +export function StatsPoster(props: { stats: SessionStatsInfo }) { + const dimensions = useTerminalDimensions() + const theme = useTheme() + const themes = useThemes() + const width = () => Math.max(12, Math.min(110, dimensions().width - 8)) + const compact = () => dimensions().height < 38 + const metrics = createMemo(() => statsMetrics(props.stats)) + const number = () => statsNumber(metrics()[0].value) + const dates = createMemo(() => + new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric" }).formatRange( + new Date(props.stats.range.from), + new Date(Math.max(props.stats.range.from, props.stats.range.to - 1)), + ), + ) + const letters = createMemo(() => Array.from(number()).map((char) => digits[char])) + const large = () => + letters().every(Boolean) && letters().reduce((sum, char) => sum + char[0].length * 2 + 2, -2) <= width() + const calendar = createMemo(() => + activityCalendar({ + activity: props.stats.activity, + from: props.stats.range.from, + to: props.stats.range.to, + maxWeeks: Math.floor((width() - 4) / 2), + }), + ) + const shades = createMemo(() => [ + theme.text.subdued, + ...[0.3, 0.5, 0.75, 1].map((alpha) => + tint(theme.background.default, theme.categorical[0][themes.mode() === "light" ? 800 : 200], alpha), + ), + ]) + + return ( + + + + opencode / stats + + {dates()} + + + + + + + {number()} + + } + > + + + {(row) => ( + + {letters() + .map((char) => char[row].replaceAll("1", "\u2588\u2588").replaceAll("0", " ")) + .join(" ")} + + )} + + + + TOKENS + + + + {" " + + calendar() + .months.map((month) => (month.label.length <= month.span * 2 ? month.label : "").padEnd(month.span * 2)) + .join("")} + + + {(day, index) => ( + + {day + " "} + + {(week) => ( + + {week[index()].level < 0 ? " " : week[index()].level === 0 ? "\u00b7 " : "\u25a0 "} + + )} + + + )} + + + Your last {calendar().weeks.length} weeks + + + + + {(metric) => ( + + + {statsNumber(metric.value)} + {metric.label === "best streak" ? " days" : ""} + + {metric.label} + + )} + + + + opencode.ai + + + ) +} + +function StatsPage(props: { context: Plugin.Context; onClose: () => void }) { + const [result] = createResource(() => { + const now = new Date() + return props.context.client.session.stats({ + from: new Date(now.getFullYear(), 0, 1).getTime(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + tools: "none", + }) + }) + const theme = useTheme() + + props.context.keymap.layer(() => ({ + commands: [{ bind: "escape", title: "back", run: props.onClose }], + })) + + return ( + + + Could not load stats. Reopen /stats to try again. + } + > + Gathering your stats...}> + {(value) => } + + + + + ) +} + +export default Plugin.define({ + id: "opencode.stats", + setup(context) { + const [previous, setPrevious] = createSignal({ ...context.ui.router.current() }) + context.ui.router.register({ + name: "stats", + render: () => context.ui.router.navigate(previous())} />, + }) + context.ui.slot({ + append: "app", + render() { + context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "stats.open", + title: "Usage statistics", + group: "System", + slash: { name: "stats" }, + palette: true, + run() { + const current = context.ui.router.current() + if (current.type === "plugin" && current.name === "stats") return + // The router exposes a mutable store; retain the route before navigating. + setPrevious({ ...current }) + context.ui.dialog.clear() + context.ui.router.navigate({ type: "plugin", name: "stats" }) + }, + }, + ], + })) + return null + }, + }) + }, +}) diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts index fabfaff68f3..a2cacb8513b 100644 --- a/packages/tui/src/plugin/builtins.ts +++ b/packages/tui/src/plugin/builtins.ts @@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer" import Notifications from "../feature-plugins/system/notifications" import Plugins from "../feature-plugins/system/plugins" import Storybook from "../feature-plugins/system/storybook" +import Stats from "../feature-plugins/system/stats" import Latex from "@opencode-ai/latex/plugin" import Merman from "@opencode-ai/merman/plugin" @@ -18,6 +19,7 @@ export const builtins = [ SidebarFooter, Notifications, Plugins, + Stats, Merman, Latex, // The storybook is a development tool; keep its route and palette commands out of diff --git a/packages/tui/test/stats.test.ts b/packages/tui/test/stats.test.ts new file mode 100644 index 00000000000..a694fec854f --- /dev/null +++ b/packages/tui/test/stats.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import type { SessionStatsInfo } from "@opencode-ai/client" +import { statsMetrics, statsNumber } from "../src/feature-plugins/system/stats-data" + +const stats: SessionStatsInfo = { + range: { from: new Date(2026, 0, 1).getTime(), to: new Date(2026, 0, 8).getTime() }, + sessions: 12, + subagents: 99, + prompts: 24, + steps: 50, + activeDays: 2, + streak: 2, + tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } }, + cost: 0, + tools: { mode: "none" }, + models: [], + activity: [ + { date: "2026-01-01", steps: 1 }, + { date: "2026-01-02", steps: 50 }, + ], +} + +describe("stats poster", () => { + test("includes all token categories and replaces subagents with tokens", () => { + expect(statsMetrics(stats)).toEqual([ + { label: "tokens", value: 15 }, + { label: "best streak", value: 2 }, + { label: "active days", value: 2 }, + { label: "sessions", value: 12 }, + ]) + expect([0, 685, 5284, 9_200_000_000].map(statsNumber)).toEqual(["0", "685", "5.3K", "9.2B"]) + }) +}) diff --git a/packages/tui/test/stats.view.test.tsx b/packages/tui/test/stats.view.test.tsx new file mode 100644 index 00000000000..c5f1f222b9d --- /dev/null +++ b/packages/tui/test/stats.view.test.tsx @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { Global } from "@opencode-ai/util/global" +import { Effect, FileSystem } from "effect" +import { createEventStream, createFetch, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" + +test("stats shows only this year and returns after errors or success", async () => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width: 100, height: 34, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const requests: URL[] = [] + const calls = createFetch((url) => { + if (url.pathname !== "/api/session/stats") return undefined + requests.push(url) + if (requests.length === 1) return json({ message: "offline" }, { status: 503 }) + return json({ + data: { + range: { from: new Date(new Date().getFullYear(), 0, 1).getTime(), to: Date.now() }, + sessions: 123, + subagents: 0, + prompts: 1, + steps: 1, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 0 } }, + cost: 0, + tools: { mode: "none" }, + activeDays: 1, + streak: 1, + activity: [{ date: `${new Date().getFullYear()}-01-01`, steps: 1 }], + models: [], + }, + }) + }, createEventStream()) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ animations: false, tabs: { enabled: false } }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + args: {}, + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + await setup.waitForFrame((frame) => frame.includes("commands")) + await setup.mockInput.typeText("/stats") + await setup.waitForFrame((frame) => frame.includes("Usage statistics")) + setup.mockInput.pressKey("RETURN") + await setup.waitForFrame((frame) => frame.includes("Could not load stats")) + setup.mockInput.pressKey("ESCAPE") + await setup.waitForFrame((frame) => frame.includes("commands") && !frame.includes("Could not load stats")) + await setup.mockInput.typeText("/stats") + await setup.waitForFrame((frame) => frame.includes("Usage statistics")) + setup.mockInput.pressKey("RETURN") + await setup.waitForFrame((frame) => frame.includes("TOKENS") && frame.includes("123")) + expect(requests[1].searchParams.get("tools")).toBe("none") + expect(Number(requests[1].searchParams.get("from"))).toBe(new Date(new Date().getFullYear(), 0, 1).getTime()) + expect(requests[1].searchParams.has("to")).toBe(false) + expect(setup.captureCharFrame()).not.toContain("all time") + expect(setup.captureCharFrame()).not.toContain("All projects") + expect(setup.captureCharFrame()).not.toContain("show this year") + expect(setup.captureCharFrame()).not.toContain("esc") + expect( + setup + .captureCharFrame() + .split("\n") + .find((line) => line.includes("opencode / stats")), + ).not.toContain("tab") + setup.mockInput.pressKey("RETURN") + setup.mockInput.pressKey("SPACE") + await setup.mockInput.typeText("rhp") + setup.mockInput.pressKey("TAB") + setup.mockInput.pressKey("RIGHT") + await setup.waitForVisualIdle() + expect(requests).toHaveLength(2) + expect(setup.captureCharFrame()).toContain("TOKENS") + expect(setup.captureCharFrame()).not.toContain("headline") + setup.mockInput.pressKey("ESCAPE") + await setup.waitForFrame((frame) => frame.includes("commands") && !frame.includes("opencode / stats")) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await task.finally(() => server.stop(true)) + } +}) diff --git a/packages/util/src/activity-calendar.test.ts b/packages/util/src/activity-calendar.test.ts new file mode 100644 index 00000000000..07458905f3b --- /dev/null +++ b/packages/util/src/activity-calendar.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test" +import { activityCalendar } from "./activity-calendar.js" + +const input = { + from: new Date(2026, 0, 1).getTime(), + to: new Date(2026, 0, 8).getTime(), + maxWeeks: 53, + activity: [ + { date: "2026-01-01", steps: 1 }, + { date: "2026-01-02", steps: 50 }, + ], +} + +describe("activity calendar", () => { + test("aligns Monday-first weeks and excludes cells outside the end-exclusive range", () => { + const calendar = activityCalendar(input) + expect(calendar.clipped).toBe(false) + expect(calendar.weeks).toHaveLength(2) + expect(calendar.weeks[0][0]).toEqual({ date: "2025-12-29", steps: 0, level: -1 }) + expect(calendar.weeks[0][3]).toEqual({ date: "2026-01-01", steps: 1, level: 2 }) + expect(calendar.weeks[0][4]).toEqual({ date: "2026-01-02", steps: 50, level: 4 }) + expect(calendar.weeks[1][2]).toEqual({ date: "2026-01-07", steps: 0, level: 0 }) + expect(calendar.weeks[1][3].level).toBe(-1) + expect(calendar.months).toEqual([{ week: 0, label: "Jan", span: 2 }]) + }) + + test("clips to the latest weeks and keeps empty days distinct from out-of-range cells", () => { + const calendar = activityCalendar({ ...input, activity: [], maxWeeks: 1 }) + expect(calendar.clipped).toBe(true) + expect(calendar.weeks).toHaveLength(1) + expect(calendar.weeks[0][0]).toEqual({ date: "2026-01-05", steps: 0, level: 0 }) + expect(calendar.weeks[0][3].level).toBe(-1) + }) + + test("caps long histories at 53 weeks", () => { + const calendar = activityCalendar({ ...input, from: new Date(2020, 0, 1).getTime(), maxWeeks: 200 }) + expect(calendar.clipped).toBe(true) + expect(calendar.weeks).toHaveLength(53) + expect(calendar.weeks.at(-1)?.[2].date).toBe("2026-01-07") + expect(calendar.months.reduce((sum, month) => sum + month.span, 0)).toBe(53) + }) + + test("returns month positions and spans for partial first and last months", () => { + const calendar = activityCalendar({ + ...input, + from: new Date(2026, 3, 29).getTime(), + to: new Date(2026, 4, 20).getTime(), + }) + expect(calendar.months).toEqual([ + { week: 0, label: "Apr", span: 1 }, + { week: 1, label: "May", span: 3 }, + ]) + const partial = activityCalendar({ + ...input, + from: new Date(2026, 3, 1).getTime(), + to: new Date(2026, 3, 2).getTime(), + }) + expect(partial.months).toEqual([{ week: 0, label: "Apr", span: 1 }]) + }) + + test("places month labels across a year boundary", () => { + const calendar = activityCalendar({ + ...input, + from: new Date(2025, 11, 22).getTime(), + to: new Date(2026, 0, 12).getTime(), + }) + expect(calendar.months).toEqual([ + { week: 0, label: "Dec", span: 1 }, + { week: 1, label: "Jan", span: 2 }, + ]) + }) + + test("keeps exactly seven distinct cells per week across DST and leap days", () => { + const calendar = activityCalendar({ + from: new Date(2024, 1, 26).getTime(), + to: new Date(2024, 2, 12).getTime(), + maxWeeks: 53, + activity: [ + { date: "2024-02-29", steps: 1 }, + { date: "2024-03-10", steps: 1 }, + ], + }) + expect(calendar.weeks).toHaveLength(3) + expect( + calendar.weeks + .flat() + .filter((day) => day.steps > 0) + .map((day) => day.date), + ).toEqual(["2024-02-29", "2024-03-10"]) + expect(new Set(calendar.weeks.flat().map((day) => day.date)).size).toBe(21) + }) + + test("uses the highest intensity for a single distinct positive count", () => { + const calendar = activityCalendar({ ...input, activity: input.activity.map((day) => ({ ...day, steps: 10 })) }) + expect( + calendar.weeks + .flat() + .filter((day) => day.steps > 0) + .map((day) => day.level), + ).toEqual([4, 4]) + }) + + test("ranks distinct activity counts in four levels", () => { + const calendar = activityCalendar({ + ...input, + to: new Date(2026, 0, 10).getTime(), + activity: Array.from({ length: 8 }, (_, index) => ({ date: `2026-01-0${index + 1}`, steps: index + 1 })), + }) + expect( + calendar.weeks + .flat() + .filter((day) => day.steps > 0) + .map((day) => day.level), + ).toEqual([1, 1, 2, 2, 3, 3, 4, 4]) + }) + + test("keeps intensity relative to the requested range when older weeks are clipped", () => { + const calendar = activityCalendar({ + ...input, + maxWeeks: 1, + activity: [ + { date: "2026-01-01", steps: 50 }, + { date: "2026-01-05", steps: 1 }, + ], + }) + expect(calendar.weeks[0][0].level).toBe(2) + }) + + test("returns an empty calendar for an empty range", () => { + expect(activityCalendar({ ...input, to: input.from, activity: [] })).toEqual({ + weeks: [], + months: [], + clipped: false, + }) + }) +}) diff --git a/packages/util/src/activity-calendar.ts b/packages/util/src/activity-calendar.ts new file mode 100644 index 00000000000..eb89d1cb23b --- /dev/null +++ b/packages/util/src/activity-calendar.ts @@ -0,0 +1,50 @@ +export function activityCalendar(input: { + activity: readonly { date: string; steps: number }[] + from: number + to: number + maxWeeks: number +}) { + // Extract local dates first, then use UTC ordinals so DST cannot add or drop cells. + const ordinal = (time: number) => { + const date = new Date(time) + return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000 + } + const from = ordinal(input.from) + const to = ordinal(input.to - 1) + const monday = from - ((new Date(from * 86_400_000).getUTCDay() + 6) % 7) + const total = input.to <= input.from ? 0 : Math.floor((to - monday) / 7) + 1 + const count = Math.min(total, 53, Math.max(1, Math.floor(input.maxWeeks))) + const start = monday + Math.max(0, total - count) * 7 + const values = new Map(input.activity.map((day) => [day.date, day.steps])) + const levels = [...new Set([...values.values()].filter((steps) => steps > 0))].sort((a, b) => a - b) + const weeks = Array.from({ length: count }, (_, week) => + Array.from({ length: 7 }, (_, day) => { + const date = start + week * 7 + day + const key = new Date(date * 86_400_000).toISOString().slice(0, 10) + const steps = values.get(key) ?? 0 + return { + date: key, + steps, + level: + date < from || date > to + ? -1 + : steps === 0 + ? 0 + : Math.max(1, Math.ceil(((levels.indexOf(steps) + 1) / levels.length) * 4)), + } + }), + ) + const formatter = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" }) + // Thursday identifies the month owning most of a week; partial edge weeks stay inside the range. + const months = weeks + .map((_, week) => ({ + week, + label: formatter.format(Math.min(to, Math.max(from, start + week * 7 + 3)) * 86_400_000), + })) + .filter((month, index, months) => index === 0 || month.label !== months[index - 1].label) + return { + weeks, + months: months.map((month, index) => ({ ...month, span: (months[index + 1]?.week ?? count) - month.week })), + clipped: count < total, + } +}