diff --git a/package.json b/package.json index 09e9b72..af86d4b 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "serve": "npm run build && npm start", "preview": "vite preview", "lint": "oxlint", - "test": "node --test server/lib/range.test.ts server/lib/localhost-intent.test.ts server/lib/localhost.test.ts server/lib/cursor-stamp.test.ts src/lib/types.test.ts src/lib/api.test.ts", + "test": "node --test server/lib/range.test.ts server/lib/localhost-intent.test.ts server/lib/localhost.test.ts server/lib/cursor-stamp.test.ts src/lib/types.test.ts src/lib/api.test.ts src/lib/cumulativeUsage.test.ts", "setup": "npm run build && npm run launchagent:install", "launchagent:install": "bash scripts/install-launchagent.sh", "launchagent:uninstall": "bash scripts/uninstall-launchagent.sh" diff --git a/server/index.ts b/server/index.ts index bbbccce..50ba979 100644 --- a/server/index.ts +++ b/server/index.ts @@ -100,11 +100,13 @@ async function buildPayload( ): Promise { const range = parseRange(typeof rangeRaw === 'string' ? rangeRaw : undefined) const { data, cached } = await collectRaw(force) + // Dashboard "this billing cycle" follows Cursor's plan cycle when known, + // so Claude's rolling weekly window does not pull the shared range back. + const sharedCycleStart = data.cursor.usageReset?.cycleStart ?? null const agents = withShares( [data.cursor, data.claude, data.codex].map((a) => { - const cycleStart = a.usageReset?.cycleStart - const since = rangeStartDate(range, new Date(), cycleStart) - const days = daysInRange(range, new Date(), cycleStart) + const since = rangeStartDate(range, new Date(), sharedCycleStart) + const days = daysInRange(range, new Date(), sharedCycleStart) return applyRange(a, range, since, days) }), ) diff --git a/src/components/AgentPanel.tsx b/src/components/AgentPanel.tsx index 23de417..b11db27 100644 --- a/src/components/AgentPanel.tsx +++ b/src/components/AgentPanel.tsx @@ -1,3 +1,8 @@ +import { useId } from 'react' +import { + buildCumulativeChart, + type CumulativeSeriesPoint, +} from '../lib/cumulativeUsage' import { formatResetAt, type AgentShare, @@ -62,7 +67,9 @@ function UsageRing({ n/a ) : ( <> - {percent % 1 === 0 ? percent.toFixed(0) : percent.toFixed(1)} + + {percent % 1 === 0 ? percent.toFixed(0) : percent.toFixed(1)} + % )} @@ -71,10 +78,312 @@ function UsageRing({ ) } +const LINE_META: Array<{ + id: AgentShare['id'] + label: string + color: string + dash?: string + width: number + marker: 'circle' | 'square' | 'diamond' +}> = [ + { + id: 'cursor', + label: 'Cursor', + color: '#f4f4f4', + width: 2.75, + marker: 'circle', + }, + { + id: 'claude', + label: 'Claude', + color: '#3ecf8e', + dash: '7 5', + width: 2.5, + marker: 'square', + }, + { + id: 'codex', + label: 'Codex', + color: '#f0b429', + dash: '2 5', + width: 2.5, + marker: 'diamond', + }, +] + const RING_COLOR: Record = { - cursor: '#f0f0f0', - claude: '#c8c8c8', - codex: '#9a9a9a', + cursor: '#f4f4f4', + claude: '#3ecf8e', + codex: '#f0b429', +} + +function linePath( + points: CumulativeSeriesPoint[], + key: AgentShare['id'], + xAt: (i: number) => number, + yAt: (v: number) => number, +): string { + let d = '' + let started = false + points.forEach((p, i) => { + const v = p[key] + if (v == null) { + started = false + return + } + const cmd = started ? 'L' : 'M' + d += `${cmd}${xAt(i).toFixed(1)} ${yAt(v).toFixed(1)} ` + started = true + }) + return d.trim() +} + +function Marker({ + kind, + x, + y, + color, +}: { + kind: 'circle' | 'square' | 'diamond' + x: number + y: number + color: string +}) { + if (kind === 'circle') { + return + } + if (kind === 'square') { + return ( + + ) + } + const s = 4.2 + return ( + + ) +} + +function LegendSwatch({ + color, + dash, + marker, +}: { + color: string + dash?: string + marker: 'circle' | 'square' | 'diamond' +}) { + return ( + + + + {marker === 'circle' ? ( + + ) : marker === 'square' ? ( + + ) : ( + + )} + + + ) +} + +function CumulativeUsageChart({ agents }: { agents: AgentShare[] }) { + const gradientId = useId().replace(/:/g, '') + const chart = buildCumulativeChart(agents) + const width = 720 + const height = 260 + const pad = { top: 18, right: 18, bottom: 42, left: 42 } + const innerW = width - pad.left - pad.right + const innerH = height - pad.top - pad.bottom + + const maxY = Math.max( + 1, + ...chart.points.flatMap((p) => + [p.cursor, p.claude, p.codex].filter((v): v is number => v != null), + ), + 100, + ) + + const xAt = (i: number) => + pad.left + + (chart.points.length <= 1 + ? innerW / 2 + : (i / (chart.points.length - 1)) * innerW) + const yAt = (v: number) => pad.top + innerH * (1 - Math.min(v, maxY) / maxY) + + const formatTick = (ymd: string) => { + const [, m, d] = ymd.split('-') + return `${Number(m)}/${Number(d)}` + } + + const xTicks = + chart.points.length <= 1 + ? [{ i: 0, label: 'now' }] + : [ + { i: 0, label: `start · ${formatTick(chart.startDate)}` }, + { + i: Math.floor((chart.points.length - 1) / 2), + label: formatTick( + chart.points[Math.floor((chart.points.length - 1) / 2)]!.date, + ), + }, + { + i: chart.points.length - 1, + label: `now · ${formatTick(chart.endDate)}`, + }, + ] + + // Marker cadence: endpoints + ~weekly for long cycles, else every point. + const markerStep = + chart.points.length > 21 ? 7 : chart.points.length > 10 ? 3 : 1 + + if (!chart.hasData) { + return ( +
+ No billing-cycle window yet to plot. +
+ ) + } + + return ( +
+
+

Cumulative usage

+

+ {chart.yLabel} from cycle start ({formatTick(chart.startDate)}) to now + ({formatTick(chart.endDate)}). +

+
+
+ {LINE_META.map((line) => ( + + + {line.label} + + ))} +
+ + + + + + + + + {[0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = pad.top + innerH * (1 - t) + const label = Math.round(maxY * t) + return ( + + + + {label} + + + ) + })} + {xTicks.map((tick) => { + const p = chart.points[tick.i] + if (!p) return null + return ( + + {tick.label} + + ) + })} + {LINE_META.map((line) => ( + + + {chart.points.map((p, i) => { + const v = p[line.id] + if (v == null) return null + const isEnd = i === 0 || i === chart.points.length - 1 + if (!isEnd && i % markerStep !== 0) return null + return ( + + ) + })} + + ))} + +
+ ) } export function AgentPanel({ agents }: Props) { @@ -95,9 +404,12 @@ export function AgentPanel({ agents }: Props) { return (
-

{a.name}

+

+ + {a.name} +

{usage?.at ? (

Resets {formatResetAt(usage.at)}

@@ -113,6 +425,8 @@ export function AgentPanel({ agents }: Props) { })} + + ) diff --git a/src/index.css b/src/index.css index 3394472..3bda8be 100644 --- a/src/index.css +++ b/src/index.css @@ -7,9 +7,9 @@ --muted: #7a7a7a; --dim: #3a3a3a; --accent: #d4d4d4; - --cursor: #f0f0f0; - --claude: #c4c4c4; - --codex: #9a9a9a; + --cursor: #f4f4f4; + --claude: #3ecf8e; + --codex: #f0b429; --ok: #bdbdbd; font-family: 'IBM Plex Mono', ui-monospace, monospace; line-height: 1.45; diff --git a/src/lib/cumulativeUsage.test.ts b/src/lib/cumulativeUsage.test.ts new file mode 100644 index 0000000..eb4108a --- /dev/null +++ b/src/lib/cumulativeUsage.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + buildCumulativeChart, + chartDomainStart, + enumerateDays, +} from '../../src/lib/cumulativeUsage.ts' +import type { AgentShare } from '../../src/lib/types.ts' + +function agent( + partial: Pick & Partial, +): AgentShare { + return { + score: 0, + percent: 0, + available: true, + metrics: {}, + stats: { + activeDays: partial.daily.length, + avgPerDay: 0, + peakDay: null, + peakValue: 0, + periodTotal: partial.daily.reduce((s, d) => s + d.primary, 0), + }, + ...partial, + } +} + +describe('enumerateDays', () => { + it('lists inclusive local calendar days', () => { + assert.deepEqual(enumerateDays('2026-07-28', '2026-07-30'), [ + '2026-07-28', + '2026-07-29', + '2026-07-30', + ]) + }) +}) + +describe('chartDomainStart', () => { + it('prefers Cursor billing cycle over Claude weekly start', () => { + const start = chartDomainStart( + [ + agent({ + id: 'cursor', + name: 'Cursor', + usageReset: { + ok: true, + cycleStart: '2026-07-27T18:05:50.000Z', + windows: [{ label: 'Billing cycle', at: null, usedPercent: 28 }], + }, + daily: [], + }), + agent({ + id: 'claude', + name: 'Claude', + usageReset: { + ok: true, + cycleStart: '2026-07-21T16:00:00.000Z', + windows: [{ label: 'Weekly', at: null }], + }, + daily: [], + }), + ], + '2026-07-28', + ) + const cursorLocal = new Date('2026-07-27T18:05:50.000Z') + const y = cursorLocal.getFullYear() + const m = String(cursorLocal.getMonth() + 1).padStart(2, '0') + const d = String(cursorLocal.getDate()).padStart(2, '0') + assert.equal(start, `${y}-${m}-${d}`) + }) + + it('falls back to today when no cycle starts exist', () => { + assert.equal(chartDomainStart([], '2026-07-28'), '2026-07-28') + }) +}) + +describe('buildCumulativeChart', () => { + it('spans billing-cycle start through today and scales plan percent', () => { + const agents: AgentShare[] = [ + agent({ + id: 'cursor', + name: 'Cursor', + usageReset: { + ok: true, + cycleStart: '2026-07-27T12:00:00.000Z', + windows: [{ label: 'Billing cycle', at: null, usedPercent: 40 }], + }, + daily: [ + { + date: '2026-07-28', + primary: 10, + primaryLabel: 'agentMessages', + }, + { + date: '2026-07-29', + primary: 30, + primaryLabel: 'agentMessages', + }, + ], + }), + agent({ + id: 'claude', + name: 'Claude Code', + usageReset: { + ok: true, + cycleStart: '2026-07-27T12:00:00.000Z', + windows: [{ label: 'Weekly', at: null }], + }, + daily: [ + { date: '2026-07-28', primary: 2, primaryLabel: 'messages' }, + { date: '2026-07-29', primary: 2, primaryLabel: 'messages' }, + ], + }), + agent({ + id: 'codex', + name: 'Codex', + usageReset: { + ok: true, + cycleStart: '2026-07-27T12:00:00.000Z', + windows: [{ label: 'Monthly', at: null, usedPercent: 0 }], + }, + daily: [], + }), + ] + + const chart = buildCumulativeChart(agents, '2026-07-29') + assert.equal(chart.hasData, true) + assert.equal(chart.endDate, '2026-07-29') + assert.equal(chart.points.at(-1)?.date, '2026-07-29') + assert.equal(chart.points[0]?.date, chart.startDate) + assert.equal(chart.points.at(-1)?.cursor, 40) + assert.equal(chart.points.at(-1)?.claude, 100) + assert.equal(chart.points.at(-1)?.codex, 0) + // time_0 is cycle start with cumulative 0 before later activity lands. + const first = chart.points[0] + assert.ok(first) + assert.equal(first.cursor, 0) + }) + + it('ends at today even when activity stopped earlier', () => { + const agents: AgentShare[] = [ + agent({ + id: 'cursor', + name: 'Cursor', + usageReset: { + ok: true, + cycleStart: '2026-07-01T12:00:00.000Z', + windows: [{ label: 'Billing cycle', at: null, usedPercent: 20 }], + }, + daily: [ + { date: '2026-07-02', primary: 5, primaryLabel: 'agentMessages' }, + ], + }), + ] + const chart = buildCumulativeChart(agents, '2026-07-10') + assert.equal(chart.endDate, '2026-07-10') + assert.equal(chart.points.at(-1)?.date, '2026-07-10') + assert.equal(chart.points.at(-1)?.cursor, 20) + assert.ok(chart.points.length >= 10) + }) +}) diff --git a/src/lib/cumulativeUsage.ts b/src/lib/cumulativeUsage.ts new file mode 100644 index 0000000..e722a06 --- /dev/null +++ b/src/lib/cumulativeUsage.ts @@ -0,0 +1,191 @@ +import type { AgentShare, UsageResetWindow } from './types' + +export type CumulativeSeriesPoint = { + date: string + cursor: number | null + claude: number | null + codex: number | null +} + +export type CumulativeChartModel = { + points: CumulativeSeriesPoint[] + /** Inclusive YYYY-MM-DD chart domain start (billing cycle). */ + startDate: string + /** Inclusive YYYY-MM-DD chart domain end (now). */ + endDate: string + yLabel: string + hasData: boolean +} + +const AGENT_IDS = ['cursor', 'claude', 'codex'] as const + +function primaryUsageWindow(agent: AgentShare): UsageResetWindow | null { + const windows = agent.usageReset?.windows ?? [] + return ( + windows.find((w) => w.usedPercent != null) ?? + windows.find((w) => /billing|month|week|primary/i.test(w.label)) ?? + windows[0] ?? + null + ) +} + +function parseYmd(ymd: string): Date { + const [y, m, d] = ymd.split('-').map(Number) + return new Date(y, m - 1, d) +} + +export function formatYmd(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** Inclusive local YYYY-MM-DD range. */ +export function enumerateDays(startYmd: string, endYmd: string): string[] { + const out: string[] = [] + const cur = parseYmd(startYmd) + const end = parseYmd(endYmd) + if (Number.isNaN(cur.getTime()) || Number.isNaN(end.getTime()) || cur > end) { + return out + } + while (cur <= end) { + out.push(formatYmd(cur)) + cur.setDate(cur.getDate() + 1) + } + return out +} + +function localYmdFromIso(iso: string | null | undefined): string | null { + if (!iso) return null + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return null + return formatYmd(d) +} + +/** Shared chart domain start: Cursor billing cycle when known, else today. */ +export function chartDomainStart( + agents: AgentShare[], + todayYmd = formatYmd(new Date()), +): string { + const byId = new Map(agents.map((a) => [a.id, a])) + const cursorStart = localYmdFromIso(byId.get('cursor')?.usageReset?.cycleStart) + if (cursorStart && cursorStart <= todayYmd) return cursorStart + + // Prefer real billing/month windows; take the most recently started one. + const billingStarts = agents + .filter((a) => + (a.usageReset?.windows ?? []).some((w) => + /billing|month/i.test(w.label), + ), + ) + .map((a) => localYmdFromIso(a.usageReset?.cycleStart)) + .flatMap((d) => (d != null && d <= todayYmd ? [d] : [])) + if (billingStarts.length > 0) { + return billingStarts.reduce((max, d) => (d > max ? d : max)) + } + + return todayYmd +} + +function agentStartDate( + _agent: AgentShare, + chartStart: string, +): string { + // Shared time_0 for all agents: this billing cycle start. + return chartStart +} + +/** + * Build one cumulative curve per agent for this billing cycle. + * X domain is always [billingCycleStart, today]. + * When plan used% is known, the line ends at that percent (activity weighted). + * Otherwise the line ends at 100% of that agent's own cycle activity. + */ +export function buildCumulativeChart( + agents: AgentShare[], + todayYmd = formatYmd(new Date()), +): CumulativeChartModel { + const byId = new Map(agents.map((a) => [a.id, a])) + const start = chartDomainStart(agents, todayYmd) + const end = todayYmd + const days = enumerateDays(start, end) + + if (days.length === 0) { + return { + points: [], + startDate: start, + endDate: end, + yLabel: 'Cumulative usage %', + hasData: false, + } + } + + const anyPlanPercent = AGENT_IDS.some((id) => { + const agent = byId.get(id) + if (!agent) return false + return primaryUsageWindow(agent)?.usedPercent != null + }) + + const series: Record<(typeof AGENT_IDS)[number], Array> = { + cursor: [], + claude: [], + codex: [], + } + + for (const id of AGENT_IDS) { + const agent = byId.get(id) + if (!agent?.available) { + series[id] = days.map(() => null) + continue + } + + const agentStart = agentStartDate(agent, start) + const dailyAmount = new Map(agent.daily.map((d) => [d.date, d.primary])) + // Only count activity inside the chart window for scaling. + const total = days.reduce((s, date) => { + if (date < agentStart) return s + return s + (dailyAmount.get(date) ?? 0) + }, 0) + const planPercent = primaryUsageWindow(agent)?.usedPercent + const scale = + total > 0 + ? planPercent != null + ? planPercent / total + : 100 / total + : 0 + + let running = 0 + series[id] = days.map((date) => { + if (date < agentStart) return null + running += dailyAmount.get(date) ?? 0 + if (total <= 0) { + // Flat line from cycle start: known plan % or 0. + return planPercent != null ? Math.min(planPercent, 100) : 0 + } + return Math.round(running * scale * 10) / 10 + }) + } + + const points: CumulativeSeriesPoint[] = days.map((date, i) => ({ + date, + cursor: series.cursor[i] ?? null, + claude: series.claude[i] ?? null, + codex: series.codex[i] ?? null, + })) + + const hasData = points.some( + (p) => + p.cursor != null || p.claude != null || p.codex != null, + ) + + return { + points, + startDate: start, + endDate: end, + yLabel: anyPlanPercent + ? 'Cumulative plan usage %' + : 'Cumulative activity %', + hasData, + } +}