feat(tui): add shareable stats poster (#46563)

This commit is contained in:
Shoubhit Dash
2026-09-01 22:21:52 +05:30
committed by GitHub
parent 818804e181
commit 7856515140
13 changed files with 605 additions and 90 deletions
+19 -84
View File
@@ -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)
}
+14
View File
@@ -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")
+2 -5
View File
@@ -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,
+4
View File
@@ -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
}
+18
View File
@@ -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)
})
+1 -1
View File
@@ -1321,7 +1321,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<PaneResizeHandle resize={tabsResize} left={tabsResize.size() - 1} />
</Show>
</box>
<Show when={devtools()}>
<Show when={devtools() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
<DevToolsBar />
</Show>
<Show when={!startup.skipInitialLoading}>
@@ -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)
}
@@ -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<string, string[]> = {
"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 (
<box width={width()} flexDirection="column" alignItems="center" flexShrink={0} gap={compact() ? 1 : 2}>
<box width="100%" flexDirection={width() < 44 ? "column" : "row"} justifyContent="space-between">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
opencode / stats
</text>
<text fg={theme.text.subdued}>{dates()}</text>
</box>
<Show when={!compact()}>
<Logo />
</Show>
<box alignItems="center" gap={1}>
<Show
when={large()}
fallback={
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{number()}
</text>
}
>
<box>
<For each={[0, 1, 2, 3, 4]}>
{(row) => (
<text fg={theme.text.default} selectable={false}>
{letters()
.map((char) => char[row].replaceAll("1", "\u2588\u2588").replaceAll("0", " "))
.join(" ")}
</text>
)}
</For>
</box>
</Show>
<text fg={theme.text.subdued}>TOKENS</text>
</box>
<box alignItems="center">
<text fg={theme.text.subdued}>
{" " +
calendar()
.months.map((month) => (month.label.length <= month.span * 2 ? month.label : "").padEnd(month.span * 2))
.join("")}
</text>
<For each={["M", "T", "W", "T", "F", "S", "S"]}>
{(day, index) => (
<box flexDirection="row" height={1}>
<text fg={theme.text.subdued}>{day + " "}</text>
<For each={calendar().weeks}>
{(week) => (
<text fg={shades()[Math.max(0, week[index()].level)]} selectable={false}>
{week[index()].level < 0 ? " " : week[index()].level === 0 ? "\u00b7 " : "\u25a0 "}
</text>
)}
</For>
</box>
)}
</For>
<Show when={calendar().clipped}>
<text fg={theme.text.subdued}>Your last {calendar().weeks.length} weeks</text>
</Show>
</box>
<box width="100%" flexDirection="row" justifyContent="space-around">
<For each={metrics().slice(1)}>
{(metric) => (
<box alignItems="center">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
{statsNumber(metric.value)}
{metric.label === "best streak" ? " days" : ""}
</text>
<text fg={theme.text.subdued}>{metric.label}</text>
</box>
)}
</For>
</box>
<box width="100%" flexDirection="row" justifyContent="flex-end">
<text fg={theme.text.default}>opencode.ai</text>
</box>
</box>
)
}
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 (
<box width="100%" height="100%" backgroundColor={theme.background.default}>
<scrollbox
flexGrow={1}
contentOptions={{
alignItems: "center",
justifyContent: "center",
minHeight: "100%",
paddingTop: 1,
paddingBottom: 1,
}}
>
<Show
when={!result.error}
fallback={
<text fg={theme.text.feedback.error.default}>Could not load stats. Reopen /stats to try again.</text>
}
>
<Show when={result()} fallback={<text fg={theme.text.subdued}>Gathering your stats...</text>}>
{(value) => <StatsPoster stats={value()} />}
</Show>
</Show>
</scrollbox>
</box>
)
}
export default Plugin.define({
id: "opencode.stats",
setup(context) {
const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
context.ui.router.register({
name: "stats",
render: () => <StatsPage context={context} onClose={() => 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
},
})
},
})
+2
View File
@@ -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
+33
View File
@@ -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"])
})
})
+90
View File
@@ -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))
}
})
+136
View File
@@ -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,
})
})
})
+50
View File
@@ -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,
}
}