Projects what will be left of your weekly and 5-hour Claude limits at reset, from your recent usage rate

A Claude Code mod that tells you, like an electric car's range display, whether your current pace will carry you to the next limit reset.
It watches the weekly and 5-hour usage limits of your Claude Pro or Max subscription. It projects what will be left of each one when it resets, if you keep using Claude at the rate you have been.
<!-- Screenshot: add docs/pane.png and uncomment
-->
In a terminal, start claude and run:
/plugin install token-range-monitor --marketplace micke-dahlgren/token-range-monitor
Answer y to add the marketplace, then choose the user scope so it runs in every session. It also runs in sessions in the Claude desktop app's Code tab. The install line itself only works in a terminal.
To update later:
claude plugin update token-range-monitor
Above the prompt, one line:
Left at week reset −25% · Resets in 3.7 days · Left at 5h reset +12% · Resets in 2.4 hours · Details
The pane has one card per limit, showing:
A third card, Models, shows what each model costs and where your week went:
Open the pane with /token-range. You can also set the window from the prompt: /token-range 2d, /token-range 12h or /token-range reset.
Each limit's percentage and reset time arrive with Claude's responses. The mod records them and works out your usage rate over the chosen window:
The mod shows No data rather than a misleading number when there isn't enough behind the average:
The weekly card also answers "if I keep going like the last hour, when do I run out?"
The weekly percentage moves in whole points, too coarse to read one hour from. The 5-hour percentage moves several times faster, so the pace is read from it:
How is this worked out? under the line shows the numbers for your account. It's an estimate, and it settles as more usage is recorded.
Every response, subagents' included, reports its model, effort and tokens. Between two readings of the 5-hour limit that this computer watched throughout, the mod knows how far the limit rose and which models did the work. Over the last 14 days it solves for each model's weight. It reads the 5-hour limit because it moves several times faster than the weekly one, which gives many more readings to learn from. Models compare the same on either limit, and this week's points use the same exchange rate as the 1hr pace. Within a model, output, input and cache tokens are combined at that model's published price ratios, so only the weight across models is learned.
Each model is judged on its own. Its cost shows once the 90% range of its weight is within ±20%. Until then its row shows Learning with a meter, and its points this week wait under Not split yet. With steady use, Opus usually shows within hours. A cheap model you rarely use, like Haiku in subagents, takes longer.
Run it from this folder without installing:
claude --plugin-dir .
Check and test it:
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 538 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { RangeReading, RangeSettings, RangeStep, RangeWatch } from '../types'
5import {
6 DEFAULT_SETTINGS, KEEP, MIN, heartbeat, MIN_RECORDED_H, UNIT_MAX, UNIT_MIN, averageName, clampWindow, averageNote, chosenAverage,
7 drawingHeight, emptyChartSvg, emptyChartText, fit, fx, headerDraw, paceExplain, paceText, recentPace, withInfo, fiveChart, headerSvg, noteSvg, merge, parseWindow, project, rateText, recordedHours, resetsIn,
8 isShort, leftText, refine, runsOut, weekChart, windowHours,
9} from './range'
10import type { Average, Model } from './range'
11import { DEFAULT_PALETTES, palettesFor, resolveTheme } from './theme'
12import { baselineOf, learn, mergeSteps, MODELS_INFO, modelsCosts, modelsHead, modelsSpend, modelsText, shortNames, spend, units } from './models'
13
14const PANE = 'token-range-monitor'
15const TITLE = 'Token Range Monitor'
16const readings = atom({ plugin: 'token-range-monitor', key: 'readings' } as const, [])
17const settings = atom({ plugin: 'token-range-monitor', key: 'settings' } as const, DEFAULT_SETTINGS)
18const tick = atom({ plugin: 'token-range-monitor', key: 'tick' } as const, 0)
19const seen = atom({ plugin: 'token-range-monitor', key: 'seen' } as const, [])
20const palettes = atom({ plugin: 'token-range-monitor', key: 'palettes' } as const, DEFAULT_PALETTES)
21const steps = atom({ plugin: 'token-range-monitor', key: 'steps' } as const, [])
22
23/**
24 * Usage limits are the account's, so the record is too: everything is stored
25 * under the signed-in account (`<account>/`), and signing in to another
26 * account starts from that account's own record. Within it each session
27 * writes its readings under its own key (`r:`), and the spans it was watching
28 * under the twin key (`w:`), so sessions never overwrite each other, and every
29 * session reads them all. The responses it saw, each one's model and tokens,
30 * go under a third (`u:`).
31 */
32let account = ''
33const OWN = 'r:'
34const SEEN = 'w:'
35const STEPS = 'u:'
36const ownPrefix = () => `${account}/${OWN}`
37const seenKeyOf = (key: string) => key.replace(`/${OWN}`, `/${SEEN}`)
38const stepsKeyOf = (key: string) => key.replace(`/${OWN}`, `/${STEPS}`)
39const newOwnKey = (now: number) => `${ownPrefix()}${now.toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
40/** A stored list's key: whose (none for a record kept before accounts), and which kind. */
41const parseKey = (key: string) => /^(?:(.+)\/)?([rwu]):/.exec(key)
42
43let ownKey = ''
44let own: RangeReading[] = []
45let ownSeen: RangeWatch[] = []
46let othersSeen: RangeWatch[] = []
47let ownSteps: RangeStep[] = []
48let othersSteps: RangeStep[] = []
49
50/**
51 * The signed-in account and organisation, from Claude Code's own config:
52 * the limits belong to both. `none` off a subscription, which has no limits.
53 */
54let accountRead: { mtime: number; account: string } | undefined
55async function accountOf($: EngineInterface): Promise<string | null> {
56 try {
57 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
58 const file = dir ? `${dir}/.claude.json` : `${(await $.env.get('HOME')) ?? ''}/.claude.json`
59 // the file is read again only once it changed
60 const { mtimeMs } = await $.fs.stat(file)
61 if (accountRead?.mtime === mtimeMs) return accountRead.account
62 const o = (JSON.parse(await $.fs.read(file)) as { oauthAccount?: { accountUuid?: string; organizationUuid?: string } }).oauthAccount
63 const a = o?.accountUuid ? `${o.accountUuid}.${o.organizationUuid ?? ''}` : 'none'
64 accountRead = { mtime: mtimeMs, account: a }
65 return a
66 } catch {
67 // unreadable (mid-write, say): no answer, which is no sign-in elsewhere
68 return null
69 }
70}
71
72/** Follows a sign-in to another account: this session starts a fresh list under it. True when it moved. */
73async function followAccount($: EngineInterface): Promise<boolean> {
74 const now = await $.clock.now()
75 const a = (await accountOf($)) ?? (account || 'none')
76 if (a === account) return false
77 account = a
78 ownKey = newOwnKey(now)
79 own = []
80 ownSeen = []
81 ownSteps = []
82 return true
83}
84
85/**
86 * Claude Code keeps a store per copy of a plugin: one installed from the
87 * marketplace and one run from a folder don't share. Every copy's store sits
88 * in the same folder, so each copy also reads the others' (never writes them),
89 * and sessions running different copies still see each other's usage.
90 */
91async function otherCopies($: EngineInterface): Promise<Array<[string, unknown]>> {
92 try {
93 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
94 const stores = `${dir}/plugins/store`
95 const out: Array<[string, unknown]> = []
96 for (const f of await $.fs.list(stores)) {
97 if (!/^token-range-monitor_.*\.json$/.test(f.name)) continue
98 // a file being written as it's read is skipped this time, the others still count
99 try {
100 out.push(...Object.entries(JSON.parse(await $.fs.read(`${stores}/${f.name}`)) as Record<string, unknown>))
101 } catch { /* next minute */ }
102 }
103 return out
104 } catch {
105 return []
106 }
107}
108
109async function loadAll($: EngineInterface) {
110 const now = await $.clock.now()
111 const lists: RangeReading[][] = [own]
112 const spans: RangeWatch[] = []
113 const stepLists: RangeStep[][] = []
114 // the other copies' lists, read as they stand; this copy's own file is among them, and merging drops the repeats
115 for (const [key, value] of await otherCopies($)) {
116 const k = parseKey(key)
117 if (!k || !Array.isArray(value) || (k[1] !== undefined && k[1] !== account)) continue
118 if (k[2] === 'w') spans.push(...(value as RangeWatch[]))
119 else if (k[2] === 'u') stepLists.push(value as RangeStep[])
120 else lists.push(value as RangeReading[])
121 }
122 for (const key of await $.store.keys()) {
123 const k = parseKey(key)
124 if (!k || key === ownKey || key === seenKeyOf(ownKey) || key === stepsKeyOf(ownKey)) continue
125 const [, whose, kind] = k
126 const list = ((await $.store.get(key)) ?? []) as Array<RangeReading | RangeWatch | RangeStep>
127 // drop lists once everything in them has aged out, whichever account's
128 const end = (x: RangeReading | RangeWatch | RangeStep) => (kind === 'w' ? (x as RangeWatch)[1] : x[0])
129 if (list.every(x => end(x) < now - KEEP)) { await $.store.delete(key); continue }
130 if (whose === undefined) {
131 // a record kept before accounts: it was this account's, so it moves under it
132 await $.store.set(`${account}/${key}`, list)
133 await $.store.delete(key)
134 } else if (whose !== account) continue
135 if (kind === 'w') spans.push(...(list as RangeWatch[]))
136 else if (kind === 'u') stepLists.push(list as RangeStep[])
137 else lists.push(list as RangeReading[])
138 }
139 othersSeen = spans
140 othersSteps = mergeSteps(stepLists, now)
141 await update($, steps, () => mergeSteps([othersSteps, ownSteps], now))
142 await update($, readings, () => merge(lists, now))
143 await update($, seen, () => [...othersSeen, ...ownSeen])
144}
145
146/**
147 * Notes that this session just got fresh figures (a response arrived), for telling
148 * usage seen here from usage made elsewhere. Saved at most every 30 seconds.
149 */
150let seenSavedAt = 0
151async function watch($: EngineInterface) {
152 const now = await $.clock.now()
153 if (!ownKey) ownKey = newOwnKey(now)
154 const before = ownSeen.length
155 ownSeen = heartbeat(ownSeen, now)
156 if (ownSeen.length !== before || now - seenSavedAt > 30_000) {
157 seenSavedAt = now
158 await $.store.set(seenKeyOf(ownKey), ownSeen)
159 }
160 await update($, seen, () => [...othersSeen, ...ownSeen])
161}
162
163/** Notes one response: its model, effort and weighted tokens. Saved at most every 30 seconds. */
164let stepsSavedAt = 0
165let stepsUnsaved = false
166async function record($: EngineInterface, step: RangeStep) {
167 const now = step[0]
168 if (!ownKey) ownKey = newOwnKey(now)
169 ownSteps = [...ownSteps.filter(s => s[0] >= now - KEEP), step]
170 stepsUnsaved = true
171 if (now - stepsSavedAt > 30_000) await saveSteps($, now)
172 await update($, steps, list => [...list, step])
173}
174async function saveSteps($: EngineInterface, now: number) {
175 if (!stepsUnsaved || !ownKey) return
176 stepsSavedAt = now
177 stepsUnsaved = false
178 await $.store.set(stepsKeyOf(ownKey), ownSteps)
179}
180
181async function capture($: EngineInterface, limits: readonly SessionRateLimit[]) {
182 // figures from another account mean a sign-in since: read that account's record first
183 if (await followAccount($)) await loadAll($)
184 const now = await $.clock.now()
185 const known = await read($, readings)
186 const fresh: RangeReading[] = []
187 for (const rl of limits) {
188 const kind = rl.kind === 'five_hour' ? 0 : rl.kind === 'seven_day' ? 1 : -1
189 if (kind === -1 || !rl.resetsAt) continue
190 const resetsAt = Date.parse(rl.resetsAt)
191 let last: RangeReading | undefined
192 for (const r of known) if (r[1] === kind && (!last || r[0] >= last[0])) last = r
193 if (!last || last[2] !== rl.percentUsed || Math.abs(last[3] - resetsAt) > 5 * MIN) {
194 fresh.push([now, kind, rl.percentUsed, resetsAt])
195 }
196 }
197 if (fresh.length === 0) return
198 if (!ownKey) ownKey = newOwnKey(now)
199 own = merge([own, fresh], now)
200 await $.store.set(ownKey, own)
201 await update($, readings, list => merge([list, fresh], now))
202}
203
204/**
205 * Reads the person's theme and derives the card's palettes from it. A custom
206 * theme (`custom:<slug>`) is the person's own file or one a plugin ships.
207 */
208let themeFile: { slug: string; path: string } | undefined
209async function loadTheme($: EngineInterface) {
210 const setting = ((await $.settings.read()) as { theme?: unknown }).theme
211 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
212 const theme = await resolveTheme(setting, async slug => {
213 // found once, then read where it was; searched again only if it's gone
214 if (themeFile?.slug === slug && await $.fs.exists(themeFile.path)) return await $.fs.read(themeFile.path)
215 const own = `${dir}/themes/${slug}.json`
216 if (await $.fs.exists(own)) { themeFile = { slug, path: own }; return await $.fs.read(own) }
217 // plugins are cached as cache/<marketplace>/<plugin>/<version>/
218 const cache = `${dir}/plugins/cache`
219 for (const m of await $.fs.list(cache).catch(() => [])) {
220 for (const pl of await $.fs.list(`${cache}/${m.name}`).catch(() => [])) {
221 for (const ver of await $.fs.list(`${cache}/${m.name}/${pl.name}`).catch(() => [])) {
222 const file = `${cache}/${m.name}/${pl.name}/${ver.name}/themes/${slug}.json`
223 if (await $.fs.exists(file)) { themeFile = { slug, path: file }; return await $.fs.read(file) }
224 }
225 }
226 }
227 return undefined
228 })
229 const next = palettesFor(theme)
230 if (JSON.stringify(next) !== JSON.stringify(await read($, palettes))) await update($, palettes, () => next)
231}
232
233async function models($: EngineInterface) {
234 const list = await read($, readings)
235 const watched = await read($, seen)
236 const s = await read($, settings)
237 const pal = await read($, palettes)
238 await read($, tick)
239 const now = await $.clock.now()
240 const weekRecorded = recordedHours(list, 'week', now)
241 const weekAvg = chosenAverage(s)
242 // the 5-hour average runs from the window's opening: Anthropic's own figure, and the chart shows the whole window
243 const fiveAvg: Average = { type: 'reset' }
244 const stepList = await read($, steps)
245 const learned = learn(list, stepList, watched, now)
246 const projected = project(list, 'week', now, weekAvg, watched)
247 // the bars follow the responses behind each rise, each weighed by its model's cost where that's known
248 const shown = learned.models.filter(m => m.shown)
249 const usual = shown.length ? shown.reduce((a, m) => a + m.w, 0) / shown.length : 1
250 const weightOf = new Map(learned.models.map(m => [m.id, m.shown ? m.w : usual]))
251 const work = stepList.map(st => [st[0], st[3] * (weightOf.get(st[1]) ?? usual)] as const)
252 const shaped = (m: Model | null) => (m ? { ...m, increments: refine(m.increments, work) } : null)
253 const week = shaped(projected)
254 return {
255 now, s, weekRecorded, pal, week,
256 five: shaped(project(list, 'five', now, fiveAvg, watched)),
257 // the pace and the costs read the rises as the readings gave them
258 pace: projected ? recentPace(list, projected, now, watched) : null,
259 learned,
260 steps: stepList,
261 }
262}
263
264async function toggleFine($: EngineInterface) {
265 await update($, settings, s => ({ ...s, fine: !s.fine }))
266 await $.store.set('settings', await read($, settings))
267}
268
269async function choose($: EngineInterface, change: Partial<RangeSettings>) {
270 await update($, settings, s => ({ ...s, ...change }))
271 await $.store.set('settings', await read($, settings))
272}
273
274/** Sets the weekly window: any length, one that can't give an estimate yet saying so in the chart's place. */
275async function setWindow($: EngineInterface, n: number, unit: 'h' | 'd') {
276 await choose($, { mode: 'window', n: clampWindow(n, unit), unit })
277}
278
279export const register: Register = on => {
280 on('session.start', async ($, e, next) => {
281 account = ''
282 await followAccount($)
283 const saved = (await $.store.get('settings')) as RangeSettings | undefined
284 // a saved "everything recorded" (now retired) becomes the custom window it sat beside
285 if (saved) await update($, settings, () => ({ ...DEFAULT_SETTINGS, ...saved, mode: saved.mode === 'reset' ? 'reset' : 'window' }))
286 await $.command.register({
287 name: 'token-range',
288 description: 'Token Range Monitor: open the pane, or set what the weekly average covers (/token-range 2d, /token-range 6h, /token-range reset)',
289 argumentHint: '[window | reset]',
290 })
291 await loadAll($)
292 await loadTheme($)
293 await capture($, (await $.session.usage()).rateLimits)
294 // once a minute: pick up other sessions' readings and move "now" along
295 $.clock.every(60_000, async () => {
296 await saveSteps($, await $.clock.now())
297 await followAccount($)
298 await loadAll($)
299 await loadTheme($) // picks up a theme file edited in place
300 await capture($, (await $.session.usage()).rateLimits)
301 await update($, tick, n => n + 1)
302 })
303 return next(e)
304 })
305
306 // a turn's end and each tool call both follow a response, so the figures are fresh then;
307 // the once-a-minute check above only re-reads the last response's figures, so it isn't watching
308 on('session.measure', async ($, e, next) => {
309 if (e.changed.includes('rateLimits')) await capture($, e.rateLimits)
310 await watch($)
311 return next(e)
312 })
313
314 // a theme switched from /theme, the menu or another plugin redraws the card at once
315 on('config.set', { key: 'theme' }, async ($, e, next) => {
316 const result = await next(e)
317 await loadTheme($)
318 return result
319 }).catch(($, e, next) => next(e)) // never stands in the way of the theme change itself
320
321 // every model response, main and subagents': its model, effort and tokens, for learning what each model costs
322 on('turn.step', async function* ($, e, next) {
323 const r = yield* next(e)
324 try {
325 if (r?.usage) await record($, [await $.clock.now(), r.usage.model || e.model, e.effort === undefined ? '' : String(e.effort), units(r.usage), e.agentId ? 1 : 0])
326 } catch { /* never in the way of the response */ }
327 return r
328 })
329
330 on('tool.call', async ($, e, next) => {
331 await watch($)
332 return next(e)
333 })
334
335 on('command.run', { command: 'token-range' }, async ($, e) => {
336 const arg = e.args.trim().toLowerCase()
337 await $.ui.open({ id: PANE, title: TITLE })
338 if (!arg) return { text: 'Token Range Monitor opened.' }
339 if (arg === 'reset' || arg === 'since reset') {
340 await choose($, { mode: 'reset' })
341 return { text: 'Weekly average now covers the time since the reset.' }
342 }
343 const w = parseWindow(arg)
344 if (!w) return { text: `Couldn't read "${arg}". Use a window like 6h or 2d (at most 7d), or "reset".` }
345 await setWindow($, w.unit === 'h' ? w.n : w.n * (w.unit === 'w' ? 7 : 1), w.unit === 'h' ? 'h' : 'd')
346 // held to what's on offer: under 6 hours becomes 6
347 const avg: Average = { type: 'hours', hours: windowHours(await read($, settings)) }
348 return { text: `Weekly average now uses the last ${averageName(avg)}.` }
349 })
350
351 // C: one line above the prompt
352 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
353 if (e.props.hasSurvey) return next(e)
354 const { now, s, week, five } = await models($)
355 void now
356 if (!week && !five) return next(e)
357 const { Box, Text, Button } = $.ui.resolve(e)
358 return (
359 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
360 {week && (
361 <Box flexDirection="row" columnGap={1}>
362 <Text dimColor>Left at week reset</Text>
363 <Text bold dimColor={!!week.noData} color={week.noData ? undefined : week.over ? 'error' : 'success'}>{leftText(week)}</Text>
364 <Button key="reset-week" plain dimColor label={resetsIn(week, s.fine)} onPress={() => toggleFine($)} />
365 </Box>
366 )}
367 {week && five && <Text dimColor>·</Text>}
368 {five && (
369 <Box flexDirection="row" columnGap={1}>
370 <Text dimColor>Left at 5h reset</Text>
371 <Text bold dimColor={!!five.noData} color={five.noData ? undefined : five.over ? 'error' : 'success'}>{leftText(five)}</Text>
372 <Button key="reset-five" plain dimColor label={resetsIn(five, s.fine)} onPress={() => toggleFine($)} />
373 </Box>
374 )}
375 <Button key="details" label="Details" onPress={() => void $.ui.open({ id: PANE, title: TITLE })} />
376 </Box>
377 )
378 })
379
380 // B: the side pane
381 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
382 const { now, s, week, five, pal, pace, weekRecorded, learned, steps: stepList } = await models($)
383 const els = $.ui.resolve(e)
384 const { Box, Text, Button } = els
385 const Svg = 'Svg' in els ? els.Svg : null
386 // both cards fit the pane: charts drawn at its width, sharing the height that's left
387 const size = fit(e.props.bodyColumns)
388 // the weekly head carries the 1hr pace, its info circle opening a tooltip over the chart: head and chart are one drawing
389 const weekDrawing = (m: Model) => {
390 const chart = emptyChartText(m, now) ? emptyChartSvg(m, now, size.width, pal) : weekChart(m, now, size.width, pal, size.weekHeight)
391 if (!pace) return chart
392 const head = headerDraw(m, 'This week', size.width, pal, resetsIn(m, s.fine), pace)
393 return withInfo(head, chart, `${paceText(pace)}\n${paceExplain(pace)}`, size.width, pal)
394 }
395
396 // one dark card per limit, as on the car's display: head, chart, controls, then the notes
397 // every drawing in a frame as wide as the card, given its height: it fills the width, and its text keeps its size
398 const Draw = ({ svg, alt }: { svg: string; alt: string }) =>
399 Svg ? <Svg source={svg} height={drawingHeight(svg)} isInteractive alt={alt} /> : null
400 // `chart` may hold the head too (the weekly card with its pace): then no separate head is drawn
401 const block = (title: string, m: Model, chart: string | null, controls: JSX.Element | null, notes: string[]) => {
402 const headInChart = !!chart?.includes('class="info"')
403 return (
404 <Box
405 key={`block-${m.kind}`}
406 flexDirection="column"
407 gap={1}
408 marginBottom={1}
409 padding={2}
410 borderStyle="round"
411 borderColor="userMessageBackground"
412 backgroundColor="userMessageBackground"
413 >
414 {/* the countdown is drawn in the head, at its right, so its text takes the drawing's styles */}
415 {!headInChart && <Box flexDirection="row" justifyContent="space-between" alignItems="flex-start" columnGap={2}>
416 {Svg ? (
417 <Draw
418 svg={headerSvg(m, title, size.width, pal, resetsIn(m, s.fine))}
419 alt={`${title}. ${resetsIn(m, s.fine)}. ${isShort(m) ? `${runsOut(m)}. ` : ''}Left at reset ${leftText(m)}, average ${m.noData ? 'no data' : rateText(m, m.rate)}, limit ${rateText(m, m.limit)}.`}
420 />
421 ) : (
422 <Box flexDirection="column">
423 <Text bold>{title}</Text>
424 {isShort(m) && <Text color="error">{runsOut(m)}</Text>}
425 <Text>
426 {`${m.kind === 'week' ? 'Left at week reset' : 'Left at 5h reset'} ${leftText(m)} · Average ${m.noData ? 'no data' : rateText(m, m.rate)} · Limit ${rateText(m, m.limit)}${m.kind === 'week' && pace ? ` · 1hr pace ${pace.ready ? `${fx(pace.rate * 24)}%/day` : 'no data'}` : ''}`}
427 </Text>
428 </Box>
429 )}
430 {!Svg && <Button key={`reset-${m.kind}`} plain label={resetsIn(m, s.fine)} onPress={() => toggleFine($)} />}
431 </Box>}
432 {/* the chart and its controls are one unit: the controls stay right under the chart */}
433 <Box key={`graph-${m.kind}`} flexDirection="column" gap={1}>
434 {chart && Svg && (
435 <Draw
436 svg={chart}
437 alt={`${headInChart ? `${title}. ${resetsIn(m, s.fine)}. ${isShort(m) ? `${runsOut(m)}. ` : ''}Left at reset ${leftText(m)}, average ${m.noData ? 'no data' : rateText(m, m.rate)}, limit ${rateText(m, m.limit)}. ${pace ? `1hr pace ${pace.ready ? `${fx(pace.rate * 24)}%/day` : 'no data'}. ${paceText(pace)} ${paceExplain(pace)} ` : ''}` : ''}${emptyChartText(m, now) ? emptyChartText(m, now)!.join('. ') : `${averageNote(m)} Average ${rateText(m, m.rate)}, limit ${rateText(m, m.limit)}.`}`}
438 />
439 )}
440 {controls}
441 </Box>
442 <Box flexDirection="column" marginTop={controls ? 1 : 0}>
443 {notes.map(note => (Svg
444 ? <Draw svg={noteSvg(note, size.width, pal)} alt={note} />
445 : <Text color="inactive">{note}</Text>))}
446 </Box>
447 </Box>
448 )
449 }
450
451 // the window selector: native widgets, so hover, focus and clicks are the surface's own
452 const unit = s.unit === 'h' ? 'h' : 'd'
453 const n = clampWindow(s.n, unit)
454 const isCustom = s.mode === 'window'
455 const seg = (key: string, label: string, isOn: boolean, onPress: () => void, off = false) => (
456 <Button key={key} label={label} variant={isOn && !off ? 'primary' : 'secondary'} dimColor={!isOn || off} onPress={() => { if (!off) onPress() }} />
457 )
458 // until there's enough recorded for a weekly average, there's nothing to choose between: the choices dim and do nothing
459 const choicesOff = weekRecorded < MIN_RECORDED_H.week
460 const windowControls = (
461 // a block at the right under the chart: what the average covers, and for a custom window its length beneath
462 <Box flexDirection="column" alignSelf="flex-end" alignItems="flex-start" gap={1}>
463 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
464 {seg('mode-reset', 'Since reset', s.mode === 'reset', () => void choose($, { mode: 'reset' }), choicesOff)}
465 {seg('mode-custom', 'Custom', isCustom, () => void setWindow($, n, unit), choicesOff)}
466 </Box>
467 {isCustom && !choicesOff && (
468 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1} rowGap={1}>
469 <Text color="inactive">Last</Text>
470 <Button key="win-dec" label="−" dimColor={n <= UNIT_MIN[unit]} onPress={() => void setWindow($, n - 1, unit)} />
471 <Text bold color="text">{` ${n} `}</Text>
472 <Button key="win-inc" label="+" dimColor={n >= UNIT_MAX[unit]} onPress={() => void setWindow($, n + 1, unit)} />
473 {seg('unit-h', 'hours', unit === 'h', () => void setWindow($, n, 'h'))}
474 {seg('unit-d', 'days', unit === 'd', () => void setWindow($, n, 'd'))}
475 </Box>
476 )}
477 </Box>
478 )
479
480 // the models card: each model's cost against the baseline, and this week's points by model and effort
481 const base = baselineOf(learned, s.baseline)
482 const sp = week ? spend(week, learned, stepList, now) : null
483 const shown = learned.models.filter(m => m.shown)
484 const names = shortNames(shown)
485 const modelsCard = (
486 <Box key="block-models" flexDirection="column" gap={1} marginBottom={1} padding={2} borderStyle="round" borderColor="userMessageBackground" backgroundColor="userMessageBackground">
487 {Svg ? (
488 <>
489 {/* the head's info circle opens its tooltip over the rows: head and rows are one drawing */}
490 <Draw svg={withInfo(modelsHead(learned, now, pal), modelsCosts(learned, base, size.width, pal), MODELS_INFO, size.width, pal, 'What do these figures mean?')} alt={`Models. ${modelsText(learned, base, null).join(' ')}`} />
491 {base && (
492 <Box flexDirection="row" flexWrap="wrap" justifyContent="flex-end" alignItems="center" columnGap={1} rowGap={1}>
493 <Text color="inactive">Compare with</Text>
494 {shown.map(m => seg(`base-${m.id}`, names.get(m.id) ?? m.name, m === base, () => void choose($, { baseline: m.id })))}
495 </Box>
496 )}
497 {sp && <Draw svg={modelsSpend(sp, size.width, pal)} alt={modelsText({ ...learned, models: [] }, null, sp).join(' ')} />}
498 </>
499 ) : (
500 <Box flexDirection="column">
501 <Text bold>Models</Text>
502 {modelsText(learned, base, sp).map(line => <Text>{line}</Text>)}
503 {base && (
504 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
505 <Text color="inactive">Compare with</Text>
506 {shown.map(m => seg(`base-${m.id}`, names.get(m.id) ?? m.name, m === base, () => void choose($, { baseline: m.id })))}
507 </Box>
508 )}
509 </Box>
510 )}
511 </Box>
512 )
513
514 if (!week && !five) {
515 return (
516 <Box flexDirection="column" gap={1}>
517 <Text>No usage limits reported yet.</Text>
518 <Text dimColor>They arrive with Claude's next response, on a Pro or Max subscription.</Text>
519 </Box>
520 )
521 }
522
523 return (
524 <Box key="cards" flexDirection="column">
525 {week
526 ? block('This week', week, Svg ? weekDrawing(week) : null, windowControls, [
527 ...(Svg && emptyChartText(week, now) ? [] : [averageNote(week)]),
528 ])
529 : <Text dimColor>No weekly limit reported.</Text>}
530 {five
531 ? block('5-hour window', five, Svg ? (emptyChartText(five, now) ? emptyChartSvg(five, now, size.width, pal) : fiveChart(five, now, size.width, pal, size.fiveHeight)) : null, null, Svg && emptyChartText(five, now) ? [] : [averageNote(five)])
532 : <Text dimColor>No active 5-hour window.</Text>}
533 {modelsCard}
534 </Box>
535 )
536 })
537}
538hooks/range.ts 913 lines1import type { Palettes, RangeReading, RangeSettings } from '../types'
2import { DEFAULT_PALETTES, paletteStyle, v } from './theme'
3import type { PaletteKey } from './theme'
4
5export const MIN = 60_000
6export const HOUR = 60 * MIN
7export const DAY = 24 * HOUR
8
9export type Kind = 'five' | 'week'
10const KIND = { five: 0, week: 1 } as const
11const SPAN = { five: 5 * HOUR, week: 7 * DAY }
12/** How far back one reading's increase is spread when readings are far apart. */
13const SPREAD = 10 * MIN
14/** Readings older than this are dropped: enough for a 2-week window plus slack. */
15export const KEEP = 22 * DAY
16
17export const DEFAULT_SETTINGS: RangeSettings = { mode: 'reset', n: 1, unit: 'd', fine: false }
18export const UNIT_HOURS = { h: 1, d: 24, w: 168 } as const
19/** The longest window on offer per unit: a day in hours, the week's own length in days. */
20export const UNIT_MAX = { h: 24, d: 7 } as const
21
22export const windowHours = (s: Pick<RangeSettings, 'n' | 'unit'>) => Math.min(s.n * UNIT_HOURS[s.unit], KEEP / HOUR - 24)
23export const windowName = (s: Pick<RangeSettings, 'n' | 'unit'>) => `${s.n}${s.unit}`
24
25/** What an average covers: the last `hours`, or the time since the reset. */
26export type Average = { type: 'hours'; hours: number } | { type: 'reset' }
27
28/**
29 * Least data behind any average before it is an estimate. The weekly figure moves
30 * in coarse steps, so under six hours one step reads as a huge rate.
31 */
32export const MIN_RECORDED_H = { week: 6, five: 5 / 60 }
33/** The shortest window on offer per unit: in hours the weekly least, since anything shorter can't give an estimate. */
34export const UNIT_MIN = { h: MIN_RECORDED_H.week, d: 1 } as const
35/** `n` held to what the unit offers. */
36export const clampWindow = (n: number, unit: 'h' | 'd') => Math.min(UNIT_MAX[unit], Math.max(UNIT_MIN[unit], Math.round(n)))
37/** Least time since the reset before its average is an estimate: just after a reset a few % projects wildly. */
38export const MIN_SINCE_RESET_H = { week: 6, five: 0.25 }
39
40export const chosenAverage = (s: RangeSettings): Average =>
41 s.mode === 'window' ? { type: 'hours', hours: windowHours(s) } : { type: s.mode }
42
43/** Hours of usage on record for this limit: from its first reading to now. */
44export function recordedHours(readings: readonly RangeReading[], kind: Kind, now: number): number {
45 const first = ofKind(readings, kind)[0]
46 return first ? Math.max(0, (now - first[0]) / HOUR) : 0
47}
48
49/** Parses `2d`, `6h`, `1w`, `3 days`; null when it is none of those. */
50export function parseWindow(text: string): Pick<RangeSettings, 'n' | 'unit'> | null {
51 const m = /^\s*(\d{1,3})\s*(h|hours?|d|days?|w|weeks?)\s*$/i.exec(text)
52 if (!m) return null
53 const n = Number(m[1] ?? 0)
54 const unit = (m[2] ?? '').charAt(0).toLowerCase() as RangeSettings['unit']
55 if (n < 1 || n * UNIT_HOURS[unit] > 168) return null
56 return { n, unit }
57}
58
59const sameWindow = (a: RangeReading, b: RangeReading) => Math.abs(a[3] - b[3]) < 5 * MIN
60const sameWindowAs = (r: RangeReading, resetsAt: number) => Math.abs(r[3] - resetsAt) < 5 * MIN
61
62export function ofKind(readings: readonly RangeReading[], kind: Kind): RangeReading[] {
63 return readings.filter(r => r[1] === KIND[kind]).sort((a, b) => a[0] - b[0])
64}
65
66/** Merges reading lists, dropping duplicates and anything older than KEEP. */
67export function merge(lists: ReadonlyArray<readonly RangeReading[]>, now: number): RangeReading[] {
68 const seen = new Set<string>()
69 const out: RangeReading[] = []
70 for (const list of lists) {
71 for (const r of list) {
72 const id = `${r[0]}:${r[1]}:${r[2]}`
73 if (r[0] < now - KEEP || seen.has(id)) continue
74 seen.add(id)
75 out.push(r)
76 }
77 }
78 return out.sort((a, b) => a[0] - b[0])
79}
80
81/**
82 * A stretch of time some session on this computer was getting fresh figures:
83 * its turns were getting responses from Claude, each carrying the account's
84 * current usage. An open but idle session gets none, so it isn't watching.
85 * [from, to] in ms.
86 */
87export type Watch = [number, number]
88
89/**
90 * Two fresh figures further apart than this leave a hole: nothing here was
91 * watching in between. Long enough to span a pause while you read or type,
92 * short enough that an idle hour counts as away.
93 */
94export const WATCH_GAP = 15 * MIN
95
96/** Whether [a, b] lies wholly inside the watched spans. */
97export function isWatched(seen: readonly Watch[], a: number, b: number): boolean {
98 let t = a
99 for (const [s, e] of [...seen].sort((x, y) => x[0] - y[0])) {
100 if (s > t + WATCH_GAP) break
101 t = Math.max(t, e)
102 if (t >= b - WATCH_GAP) return true
103 }
104 return t >= b - WATCH_GAP
105}
106
107/** Adds a heartbeat at `now`: extends the last span when it is recent, else starts one. */
108export function heartbeat(seen: readonly Watch[], now: number): Watch[] {
109 const last = seen[seen.length - 1]
110 const kept = seen.filter(w => w[1] >= now - KEEP)
111 if (last && now - last[1] <= WATCH_GAP) return [...kept.slice(0, -1), [last[0], now]]
112 return [...kept, [now, now]]
113}
114
115export type Increment = {
116 start: number
117 end: number
118 amount: number
119 /** Set when the rise came over a gap this computer didn't watch: the gap's start and where the rise was placed. Drawn as the gap's block. */
120 hole?: [number, number]
121 /** For a rise seen while watching: when the reading before it was taken, so the rise came somewhere after. */
122 from?: number
123}
124
125/**
126 * Usage gained between readings. Within one window only rises past the highest
127 * reading so far count, so rounding wobble isn't counted twice; across a reset,
128 * the new window's whole reading counts.
129 *
130 * Where it goes in time: while this computer was watching, a rise sits in the
131 * minutes before the reading that saw it. A rise over a gap the computer
132 * wasn't watching came from elsewhere (another computer, claude.ai): it goes
133 * into the account's current 5-hour window when that opened inside the gap,
134 * since a 5-hour window opens with the first message of a stretch of work;
135 * with no such clue it is spread evenly over the gap.
136 */
137export function increments(readings: readonly RangeReading[], kind: Kind, seen: readonly Watch[] = []): Increment[] {
138 const rs = ofKind(readings, kind)
139 const fives = ofKind(readings, 'five')
140 const out: Increment[] = []
141 let high = rs[0]?.[2] ?? 0
142 let reached = rs[0]?.[0] ?? 0
143 for (let i = 1; i < rs.length; i++) {
144 const a = rs[i - 1]!, b = rs[i]!
145 let amount: number
146 let start: number
147 let hole: [number, number] | undefined
148 if (b[0] - a[0] <= SPREAD || isWatched(seen, a[0], b[0])) {
149 start = b[0] - Math.min(b[0] - a[0], SPREAD)
150 } else {
151 let five: RangeReading | undefined
152 for (const f of fives) if (f[0] <= b[0] + MIN) five = f
153 const fiveStart = five && five[3] > b[0] ? five[3] - SPAN.five : undefined
154 start = fiveStart !== undefined && fiveStart > a[0] ? fiveStart : a[0]
155 hole = [a[0], start > a[0] ? start : b[0]]
156 }
157 // the rise came after the figure first reached its last point: readings repeat while it stays put
158 let from = reached
159 if (sameWindow(a, b)) {
160 amount = b[2] - high
161 high = Math.max(high, b[2])
162 } else {
163 amount = b[2]
164 high = b[2]
165 start = Math.max(start, b[3] - SPAN[kind])
166 from = Math.max(a[0], b[3] - SPAN[kind])
167 }
168 if (amount > 0) {
169 out.push({ start: Math.min(start, b[0] - 1), end: b[0], amount, ...(hole ? { hole } : { from }) })
170 reached = b[0]
171 }
172 }
173 return out
174}
175
176/**
177 * Rises placed where the work behind them happened. A limit is read in whole
178 * points, so on its own a rise only says which reading saw it: every bar of
179 * one point would be the same height. Each rise seen while watching is shared
180 * among the responses made since the figure reached its previous point, by their weight
181 * (`points`: when, and how much work), so the total stays Anthropic's and the
182 * shape is the work's. A rise with no responses behind it, or over a gap,
183 * stays as it was.
184 */
185export function refine(incs: readonly Increment[], points: ReadonlyArray<readonly [number, number]>): Increment[] {
186 const pts = [...points].sort((a, b) => a[0] - b[0])
187 const out: Increment[] = []
188 let k = 0
189 for (const inc of [...incs].sort((a, b) => a.end - b.end)) {
190 if (inc.hole || inc.from === undefined) { out.push(inc); continue }
191 while (k < pts.length && pts[k]![0] <= inc.from) k++
192 let j = k, total = 0
193 while (j < pts.length && pts[j]![0] <= inc.end) total += pts[j++]![1]
194 if (total <= 0) { out.push(inc); continue }
195 for (let i = k; i < j; i++) {
196 const [t, w] = pts[i]!
197 if (w > 0) out.push({ start: t - MIN, end: t, amount: inc.amount * w / total })
198 }
199 }
200 return out
201}
202
203export function usedBetween(incs: readonly Increment[], from: number, to: number): number {
204 let sum = 0
205 for (const inc of incs) {
206 const overlap = Math.min(inc.end, to) - Math.max(inc.start, from)
207 if (overlap > 0) sum += inc.amount * overlap / (inc.end - inc.start)
208 }
209 return sum
210}
211
212export type Model = {
213 kind: Kind
214 /** % used now, and when the window resets (ms). */
215 pct: number
216 resetsAt: number
217 /** Hours until the reset. */
218 left: number
219 /** Average and limit, in % per hour. */
220 rate: number
221 limit: number
222 /** % projected to be left at the reset; negative means short by that much. */
223 arrive: number
224 over: boolean
225 /** Hours until 100% at this rate (Infinity at rate 0), and how long before the reset that is. */
226 runsOutIn: number
227 early: number
228 /** What the average covers, from when (ms), and for how many hours. */
229 average: Average
230 from: number
231 winH: number
232 /** When recording of this limit began (ms): before it the chart has no bars. */
233 recordedFrom: number
234 /**
235 * Usage since the reset from before recording began, when the average runs
236 * from the reset and the record begins inside this window: % used by the
237 * first reading, and when that was (ms). Drawn as one block up to then.
238 */
239 before: { pct: number; until: number } | null
240 /** Why there is no estimate, when there isn't enough behind the average; then the rate and what follows from it mean nothing. */
241 noData: string | null
242 increments: Increment[]
243}
244
245/**
246 * The projection for one limit at `now`, from the latest reading and the
247 * average `avg`. With too little behind that average (a window longer than
248 * the record, or just after a reset) there is no estimate: `noData` says why.
249 */
250export function project(readings: readonly RangeReading[], kind: Kind, now: number, avg: Average, seen: readonly Watch[] = []): Model | null {
251 const rs = ofKind(readings, kind)
252 const last = rs[rs.length - 1]
253 if (!last) return null
254 let pct = last[2], resetsAt = last[3]
255 if (resetsAt <= now) {
256 if (kind === 'five') return null // no active 5-hour window
257 while (resetsAt <= now) resetsAt += SPAN.week
258 pct = 0
259 }
260 const incs = increments(readings, kind, seen)
261 const recordedFrom = rs[0]![0]
262 const from = avg.type === 'reset' ? resetsAt - SPAN[kind] : now - avg.hours * HOUR
263 const winH = Math.max((now - from) / HOUR, 1 / 60)
264 // since the reset, Anthropic's own figure is exact; otherwise add up what was recorded
265 const recordedH = (now - recordedFrom) / HOUR
266 const sinceResetH = (now - (resetsAt - SPAN[kind])) / HOUR
267 const noData = avg.type === 'reset'
268 ? sinceResetH < MIN_SINCE_RESET_H[kind] ? `No estimate this soon after ${kind === 'five' ? 'the 5-hour window opened' : 'the reset'}. About ${dur(MIN_SINCE_RESET_H[kind] - sinceResetH)} to go.` : null
269 : avg.hours < MIN_RECORDED_H[kind] ? tooShort(kind) : avg.hours > recordedH ? needsMore(avg, recordedH) : null
270 const rate = noData ? 0 : (avg.type === 'reset' ? pct : usedBetween(incs, from, now)) / winH
271 const left = (resetsAt - now) / HOUR
272 const remaining = 100 - pct
273 const proj = pct + rate * left
274 const runsOutIn = rate > 0 ? remaining / rate : Infinity
275 // the window's first reading, when nothing was recorded before it, holds all usage up to then
276 const first = rs[0]!
277 const before = avg.type === 'reset' && sameWindowAs(first, resetsAt) && first[0] > from ? { pct: first[2], until: first[0] } : null
278 return {
279 kind, pct, resetsAt, left, rate, limit: remaining / left, arrive: 100 - proj, over: proj > 100,
280 runsOutIn, early: Math.max(0, left - runsOutIn), average: avg, from, winH, recordedFrom, before, noData, increments: incs,
281 }
282}
283
284/** Bar size in minutes so the window holds at most 48 bars. */
285export const bucketMinutes = (winMin: number) =>
286 [1, 5, 10, 15, 30, 60, 120, 180, 240, 360, 480, 720, 1440].find(b => winMin / b <= 48) ?? 1440
287
288/**
289 * Usage rate per bar from the average's start to now, in % per `perHours`
290 * hours: about `bucketMin` minutes a bar, the bars filling the span exactly.
291 */
292export function bars(m: Model, now: number, bucketMin: number, perHours: number): number[] {
293 const n = Math.max(1, Math.round(m.winH * 60 / bucketMin)), width = (now - m.from) / n, out: number[] = []
294 // usage seen as it happened; what came in over a gap is drawn as that gap's block instead
295 const seen = m.increments.filter(i => !i.hole)
296 for (let i = 0; i < n; i++) {
297 const s = m.from + i * width
298 out.push(usedBetween(seen, s, s + width) / (width / HOUR) * perHours)
299 }
300 return out
301}
302
303// ---- words ----
304
305export const signed = (x: number) => (Math.round(x) >= 0 ? '+' : '−') + Math.abs(Math.round(x)) + '%'
306export const fx = (v: number, d = 1) => v.toFixed(d)
307
308export function dur(h: number): string {
309 if (!isFinite(h)) return '—'
310 if (h >= 24) return Math.round(h % 24) % 24 === 0 ? `${Math.round(h / 24)}d` : `${Math.floor(h / 24)}d ${Math.round(h % 24)}h`
311 if (h >= 1) {
312 const m = Math.round(h % 1 * 60)
313 // a whole number of hours reads plainly: "8h", not "8h 00m"
314 return m === 0 || m === 60 ? `${Math.round(h)}h` : `${Math.floor(h)}h ${String(m).padStart(2, '0')}m`
315 }
316 return `${Math.max(0, Math.round(h * 60))}m`
317}
318
319/** "Resets in 3.7 days" / "Resets in 89 hours", and for the 5-hour window hours / minutes. */
320export function resetsIn(m: Model, fine: boolean): string {
321 if (m.kind === 'week') return fine ? `Resets in ${Math.round(m.left)} hours` : `Resets in ${fx(m.left / 24)} days`
322 // under an hour, hours read as "0.4 hours": minutes then, whichever unit was picked
323 const minutes = Math.round(m.left * 60)
324 if (fine || m.left < 1) return `Resets in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`
325 return `Resets in ${fx(m.left)} hours`
326}
327
328/** The name of an average, as its button shows it. */
329export const averageName = (avg: Average) =>
330 avg.type === 'reset' ? 'Since reset' : avg.hours < 1 ? `${Math.round(avg.hours * 60)}m` : windowName(hoursToWindow(avg.hours))
331
332const hoursToWindow = (h: number): Pick<RangeSettings, 'n' | 'unit'> =>
333 h % 24 === 0 ? { n: h / 24, unit: 'd' } : { n: h, unit: 'h' }
334
335/** One line on what the average covers. */
336export function averageNote(m: Model): string {
337 if (m.noData) return m.noData
338 if (m.average.type === 'reset') {
339 const since = m.kind === 'five' ? `the 5-hour window opened` : `the reset`
340 return m.recordedFrom > m.from
341 ? `Average since ${since} ${dur(m.winH)} ago, from Anthropic's figure.`
342 : `Average since ${since} ${dur(m.winH)} ago.`
343 }
344 return `Average over the last ${averageName(m.average)}.`
345}
346
347/** Why an average can't be picked yet, and when it can. */
348export const needsMore = (avg: { type: 'hours'; hours: number }, recordedH: number) =>
349 `${averageName(avg)} needs ${dur(avg.hours)} of recorded usage. ${dur(recordedH)} recorded so far, about ${dur(avg.hours - recordedH)} to go.`
350
351/** For a window shorter than a limit's least data: no estimate, whatever is on record. */
352const tooShort = (kind: Kind) =>
353 `${kind === 'week' ? 'Weekly' : '5-hour'} estimates need at least ${dur(MIN_RECORDED_H[kind])} of data. Set the window to ${dur(MIN_RECORDED_H[kind])} or more.`
354
355export const runsOut = (m: Model) => `Runs out in ${dur(m.runsOutIn)}, ${dur(m.early)} early`
356
357/** What's projected to be left at the reset, or "No data" without an estimate. */
358export const leftText = (m: Model) => (m.noData ? 'No data' : signed(m.arrive))
359export const isShort = (m: Model) => m.over && !m.noData
360
361export const rateText = (m: Model, perHour: number) =>
362 m.kind === 'week' ? `${fx(perHour * 24)}%/day` : `${fx(perHour)}%/h`
363
364// ---- drawing ----
365
366/** The card's colours, as CSS variables a chart's <style> sets from the person's theme (see theme.ts). */
367export const C = Object.fromEntries(
368 (['card', 'veil', 'fg', 'dim', 'off', 'barTop', 'barBottom', 'limit', 'over', 'under', 'bad', 'fable', 'opus', 'sonnet', 'haiku'] as const).map(k => [k, v(k)]),
369) as Record<PaletteKey, string>
370/** IBM Plex for words, Plex Mono for figures that line up; the system's own faces where Plex can't load. */
371export const FONT = "'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif"
372export const MONO = "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace"
373const FONTS = `<style>@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');</style>`
374
375/**
376 * Every text style in the drawings, by role: size in rem (1rem = 16px), weight,
377 * and colour as a palette key, so it follows the theme. Change one here and
378 * the layout around it follows. A colour that carries state (the green or red
379 * figure, the limit line's label) stays that state's colour.
380 */
381export const TEXT: Record<Role, { rem: number; weight: number; color: PaletteKey; mono?: boolean }> = {
382 /** The card's title, "This week". */
383 title: { rem: 1.2, weight: 500, color: 'fg' },
384 /** The run-out warning under the title. */
385 warning: { rem: 0.85, weight: 400, color: 'over' },
386 /** A figure's caption: "Average", small and mono. */
387 caption: { rem: 0.6875, weight: 500, color: 'dim', mono: true },
388 /** A figure: "19.4%/day". */
389 figure: { rem: 0.9375, weight: 500, color: 'fg', mono: true },
390 /** The axes, the unit line and notes inside the plot. */
391 axis: { rem: 0.7, weight: 400, color: 'dim', mono: true },
392 /** The labels on the average and limit lines. */
393 line: { rem: 0.8125, weight: 500, color: 'fg', mono: true },
394 /** The reset countdown beside the title: "Resets in 6.1 days". */
395 countdown: { rem: 0.75, weight: 400, color: 'dim' },
396 /** The note under a card: "Average since the reset 23h ago, ...". */
397 note: { rem: 0.78, weight: 400, color: 'dim' },
398}
399type Role = 'title' | 'warning' | 'caption' | 'figure' | 'axis' | 'line' | 'countdown' | 'note'
400const px = (role: Role) => TEXT[role].rem * 16
401/** About how wide a text runs, for laying out around it: mono is 0.6em a character. */
402const textWidth = (str: string, role: Role) =>
403 str.length * px(role) * (TEXT[role].mono ? 0.6 : TEXT[role].weight >= 600 ? 0.62 : 0.58)
404export const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<')
405/**
406 * The width a drawing tells the app it has. The app shows each drawing in a
407 * frame as wide as its slot, up to this; inside, the drawing fills the frame
408 * (`:root{width:100%}`) and is laid out in percentages and pixels, never a
409 * viewBox, so it stretches to any width while its text stays the size set.
410 */
411const FRAME_W = 2000
412/** A drawing of `content`, `height` pixels tall, filling whatever width its frame has. */
413export function drawing(content: string, height: number, pal: Palettes = DEFAULT_PALETTES): string {
414 return `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${Math.ceil(height)}">${FONTS}<style>:root{width:100%;height:100%;overflow:hidden}</style>${paletteStyle(pal)}${content}</svg>`
415}
416/** A drawing's height: what its frame is given. */
417export const drawingHeight = (svg: string) => Number(/^<svg [^>]* height="(\d+)">/.exec(svg)![1])
418/** A share of the width, as a length. */
419export const pct = (f: number) => `${(f * 100).toFixed(3)}%`
420/** Content placed with its origin at a share of the width (1: the right edge); it draws at negative x to sit left of it. */
421export const at = (f: number, content: string) => `<svg x="${pct(f)}" y="0" width="1" height="1" overflow="visible">${content}</svg>`
422
423/** A text in its role's style; `fill` and `weight` only where state sets them. */
424const text = (x: number | string, y: number, s: string, o: { role?: Role; fill?: string; anchor?: string; weight?: number } = {}) => {
425 const t = TEXT[o.role ?? 'axis']
426 return `<text x="${typeof x === 'number' ? x.toFixed(1) : x}" y="${y.toFixed(1)}" text-anchor="${o.anchor ?? 'start'}" style="fill:${o.fill ?? C[t.color]}" font-family="${t.mono ? MONO : FONT}" font-size="${t.rem}rem" font-weight="${o.weight ?? t.weight}">${esc(s)}</text>`
427}
428
429/**
430 * The card's head: the title, the run-out line when the projection goes over,
431 * and the three figures. The reset countdown beside it is a Button, not drawn here.
432 */
433/** Where the head's lines sit: the title, the warning, the captions and the figures, and its height. */
434function headLayout(m: Model, width: number, pace?: Pace) {
435 const isOver = m.over && !m.noData
436 const title = px('title')
437 const warning = isOver ? title + px('warning') * 1.6 : title
438 const stats: Array<{ label: string; value: string; fill?: string; info: boolean; w: number; x: number; row: number }> = []
439 const add = (label: string, value: string, fill: string | undefined, info = false) =>
440 stats.push({ label, value, ...(fill ? { fill } : {}), info, w: Math.max(textWidth(label, 'caption') + (info ? px('caption') + 6 : 0), textWidth(value, 'figure')), x: 0, row: 0 })
441 // the left-at-reset figure is coloured by its state; the others take the figure role's colour
442 add(m.kind === 'week' ? 'Left at week reset' : 'Left at 5h reset', m.noData ? 'No data' : signed(m.arrive), m.noData ? C.dim : m.over ? C.bad : C.under)
443 add('Average', m.noData ? 'No data' : rateText(m, m.rate), m.noData ? C.dim : undefined)
444 add('Limit', rateText(m, m.limit), undefined)
445 if (pace) add(PACE_LABEL, pace.ready ? `${fx(pace.rate * 24)}%/day` : 'No data', pace.ready ? undefined : C.dim, true)
446 // the figures flow left to right, onto another row where the card is too narrow for the next
447 const gap = px('figure') * 1.5
448 let x = 0, row = 0
449 for (const st of stats) {
450 if (x > 0 && x + st.w > width) { x = 0; row++ }
451 st.x = x; st.row = row
452 x += st.w + gap
453 }
454 const step = px('caption') * 1.6 + px('figure') * 1.5 + 6
455 const caption = (r: number) => warning + px('caption') * 2 + r * step
456 const figure = (r: number) => caption(r) + px('figure') * 1.5
457 return { isOver, title, warning, stats, caption, figure, height: Math.ceil(figure(row) + px('figure') * 0.4) }
458}
459
460/** Where the head's info circle sits, in drawn pixels: what the drawing around it places the tooltip by. */
461export type InfoSpot = { cx: number; cy: number; r: number }
462
463/**
464 * The head: title, warning, the figures, and the countdown. Given a pace, a
465 * fourth figure "1hr pace" whose caption ends in an info circle; the circle
466 * itself is drawn by `withInfo`, which can open its tooltip over the chart.
467 */
468export function headerDraw(m: Model, title: string, width: number, pal: Palettes = DEFAULT_PALETTES, countdown = '', pace?: Pace): { svg: string; height: number; info?: InfoSpot } {
469 const L = headLayout(m, width, pace)
470 let s = text(0, L.title, title, { role: 'title' })
471 if (L.isOver) s += text(0, L.warning, runsOut(m), { role: 'warning' })
472 // the countdown sits at the right on the title's line
473 if (countdown) s += text('100%', L.title, countdown, { role: 'countdown', anchor: 'end' })
474 let spot: InfoSpot | undefined
475 for (const st of L.stats) {
476 s += text(st.x, L.caption(st.row), st.label, { role: 'caption' })
477 s += text(st.x, L.figure(st.row), st.value, { role: 'figure', ...(st.fill ? { fill: st.fill } : {}) })
478 if (st.info) {
479 // the circle after the caption
480 const r = px('caption') * 0.5
481 spot = { cx: st.x + textWidth(st.label, 'caption') + 6 + r, cy: L.caption(st.row) - px('caption') * 0.35, r }
482 }
483 }
484 return { svg: drawing(s, L.height, pal), height: L.height, ...(spot ? { info: spot } : {}) }
485}
486export const headerSvg = (m: Model, title: string, width: number, pal: Palettes = DEFAULT_PALETTES, countdown = '') =>
487 headerDraw(m, title, width, pal, countdown).svg
488
489/**
490 * Moves labels apart so none overlap: each sits as near its own line as it can.
491 * Labels that would collide are grouped and centred on their lines' average,
492 * then the whole stack is kept between `lo` and `hi`. `ys` are the lines'
493 * heights; the result is in the same order.
494 */
495export function spreadLabels(ys: readonly number[], gap: number, lo: number, hi: number): number[] {
496 const order = ys.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y)
497 // clusters of labels that share space, each centred on its members' lines
498 let groups = order.map(o => ({ members: [o], top: o.y }))
499 const place = (g: { members: { y: number }[] }) =>
500 g.members.reduce((a, m) => a + m.y, 0) / g.members.length - (g.members.length - 1) * gap / 2
501 for (let changed = true; changed;) {
502 changed = false
503 for (let k = 1; k < groups.length; k++) {
504 const a = groups[k - 1]!, b = groups[k]!
505 if (a.top + a.members.length * gap > b.top) {
506 const merged = { members: [...a.members, ...b.members], top: 0 }
507 merged.top = place(merged)
508 groups.splice(k - 1, 2, merged)
509 changed = true
510 break
511 }
512 }
513 }
514 const out = new Array<number>(ys.length)
515 const flat: { i: number; y: number }[] = []
516 for (const g of groups) g.members.forEach((m, j) => flat.push({ i: (m as { i: number }).i, y: g.top + j * gap }))
517 // keep the stack inside the plot, pushing neighbours along
518 for (let k = 0; k < flat.length; k++) flat[k]!.y = Math.max(flat[k]!.y, lo + k * gap)
519 for (let k = flat.length - 1; k >= 0; k--) flat[k]!.y = Math.min(flat[k]!.y, hi - (flat.length - 1 - k) * gap)
520 for (const f of flat) out[f.i] = f.y
521 return out
522}
523
524export type ChartSpec = {
525 width: number
526 height: number
527 bars: number[]
528 /** The average, or null with no estimate: then no dotted line. */
529 avg: number | null
530 limit: number
531 xTicks: Array<{ f: number; label: string }>
532 marks: Array<{ f: number; label: string }>
533 /** The share of the chart, from the left, before recording began with nothing known of it: lightly tinted. */
534 unrecorded: number
535 /**
536 * Stretches whose usage is known only as a total (before recording began,
537 * or while this computer wasn't watching): each one low block at the rate
538 * that total comes to, labelled with it. Shares of the chart, the rate per
539 * bar unit, and a long and a short label.
540 */
541 blocks: Array<{ f0: number; f1: number; rate: number; label: string; short: string }>
542 unitLabel: string
543 /** The theme's palettes; absent, the default dark theme's. */
544 palettes?: Palettes
545}
546
547/**
548 * Bars from the average's start to Now in a framed plot, scaled to the tallest
549 * bar, with the average (dotted) and limit (solid) lines across all of them.
550 * Recorded slices with no usage show as a dashed baseline. A limit above the
551 * tallest bar sits on the top edge, its label marked ▲.
552 */
553export function chartSvg(c: ChartSpec): string {
554 const est = c.width, Hh = c.height
555 const ax = px('axis')
556 const padT = Math.ceil(ax * 1.5 + 12), padB = Math.ceil(ax * 1.5 + 12)
557 const peak = Math.max(...c.bars, c.avg ?? 0, ...c.blocks.map(b => b.rate), 0.0001) * 1.04
558 const top = peak >= 10 ? Math.ceil(peak / 2) * 2 : peak
559 const Y = (v: number) => Hh - padB - Math.min(v, top) / top * (Hh - padT - padB)
560 const base = Y(0)
561 const num = (v: number) => (top >= 10 ? String(Math.round(v)) : fx(v))
562 const veil = (x: number, y: number, w: number, h: number, r = 5) =>
563 `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="${r}" style="fill:${C.veil}" fill-opacity="0.6"/>`
564 let s = `<defs>
565 <linearGradient id="bar" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:${C.barTop}"/><stop offset="1" style="stop-color:${C.barBottom}"/></linearGradient>
566 <linearGradient id="plot" x1="0" y1="1" x2="0" y2="0"><stop offset="0" style="stop-color:${C.dim}" stop-opacity="0"/><stop offset="1" style="stop-color:${C.dim}" stop-opacity="0.07"/></linearGradient>
567 </defs>`
568 s += text(2, padT - ax * 0.6, c.unitLabel)
569 s += `<rect x="0" y="${padT}" width="100%" height="${base - padT}" rx="6" fill="url(#plot)"/>`
570 for (const g of [top / 4, top / 2, top * 3 / 4]) {
571 s += `<line x1="0" x2="100%" y1="${Y(g)}" y2="${Y(g)}" style="stroke:${C.dim}" stroke-opacity="0.18"/>`
572 }
573 for (const tk of c.xTicks) {
574 if (tk.f > 0 && tk.f < 1) s += `<line x1="${pct(tk.f)}" x2="${pct(tk.f)}" y1="${padT}" y2="${base}" style="stroke:${C.dim}" stroke-opacity="0.18" stroke-dasharray="4 4"/>`
575 const isNow = tk.f >= 1
576 s += text(pct(tk.f), Hh - ax * 0.6, tk.label, { anchor: isNow ? 'end' : tk.f <= 0 ? 'start' : 'middle', ...(isNow ? { fill: C.fg, weight: 600 } : {}) })
577 }
578 if (c.unrecorded > 0) {
579 s += `<rect x="0" y="${padT}" width="${pct(c.unrecorded)}" height="${base - padT}" style="fill:${C.dim}" fill-opacity="0.06"/>`
580 if (c.unrecorded * est > textWidth('not recorded', 'axis') + 16) s += text(pct(c.unrecorded / 2), base - ax * 0.6, 'not recorded', { anchor: 'middle' })
581 }
582 let late = '' // labels drawn last, over the bars and lines
583 // each stretch known only as a total: one low block at the rate it comes to, its line on top, and what's known as its label
584 for (const b of c.blocks) {
585 const y = Y(b.rate), room = (b.f1 - b.f0) * est
586 s += `<rect x="${pct(b.f0)}" y="${y.toFixed(1)}" width="${pct(b.f1 - b.f0)}" height="${(base - y).toFixed(1)}" style="fill:${C.barBottom}" fill-opacity="0.22"/>`
587 s += `<line x1="${pct(b.f0)}" x2="${pct(b.f1)}" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" style="stroke:${C.barBottom}" stroke-width="1.5"/>`
588 // above the line, or just inside it when the line is near the top
589 const ly = y - 6 - ax < padT ? y + ax + 4 : y - 6
590 const fits = (l: string) => textWidth(l, 'axis') + 8 <= room
591 const label = fits(b.label) ? b.label : fits(b.short) ? b.short : null
592 // on a backing, so a line crossing it doesn't run through the words
593 const tag = (x: number, l: string, anchor: 'middle' | 'start') => {
594 const w = textWidth(l, 'axis') + 10, bx = anchor === 'middle' ? x - w / 2 : x - 5
595 return `<rect x="${bx.toFixed(1)}" y="${(ly - ax).toFixed(1)}" width="${w.toFixed(1)}" height="${(ax + 5).toFixed(1)}" rx="4" style="fill:${C.veil}" fill-opacity="0.6"/>` + text(x, ly, l, { anchor, fill: C.fg })
596 }
597 if (label) late += at((b.f0 + b.f1) / 2, tag(0, label, 'middle'))
598 // too narrow to hold even the short one: it starts at the block and runs right, where there's room
599 else if (textWidth(b.short, 'axis') + 8 <= (1 - b.f0) * est) late += at(b.f0, tag(9, b.short, 'start'))
600 }
601 for (const mk of c.marks) {
602 s += `<line x1="${pct(mk.f)}" x2="${pct(mk.f)}" y1="${padT}" y2="${base}" style="stroke:${C.dim}" stroke-dasharray="3 3"/>`
603 const w = textWidth(`↺ ${mk.label}`, 'axis') + 12, h = ax + 6
604 // near the right edge the label goes on the left of its line, clear of the line labels
605 const rx = mk.f * est + 3 + w > est - 190 ? -3 - w : 3
606 late += at(mk.f, veil(rx, padT + 4, w, h) + text(rx + 6, padT + 4 + h / 2 + ax * 0.35, `↺ ${mk.label}`, { fill: C.fg }))
607 }
608 // the scale, inside the plot at its left, on a backing so bars don't run through it
609 for (const g of [top / 2, top]) {
610 const y = Y(g) + ax + 4, label = num(g)
611 late += veil(4, y - ax - 1, textWidth(label, 'axis') + 8, ax + 6, 4) + text(8, y, label)
612 }
613 const n = Math.max(1, c.bars.length)
614 // a recorded slice with no usage shows a dashed baseline; one inside a block or before recording doesn't
615 const covered = (f: number) => f < c.unrecorded || c.blocks.some(b => f >= b.f0 && f <= b.f1)
616 const bar = (i: number, v: number) => {
617 const y = Y(v)
618 return `<rect x="${pct((i + 0.25) / n)}" y="${y.toFixed(1)}" width="${pct(0.5 / n)}" height="${Math.max(0, base - y).toFixed(1)}" rx="1.5" fill="url(#bar)"/>`
619 }
620 c.bars.forEach((v, i) => {
621 if (v > 0) s += bar(i, v)
622 else if (!covered((i + 0.5) / n)) {
623 s += `<line x1="${pct(i / n)}" x2="${pct((i + 1) / n)}" y1="${base - 1}" y2="${base - 1}" style="stroke:${C.barBottom}" stroke-width="1.5" stroke-dasharray="5 4"/>`
624 }
625 })
626 const limitOff = c.limit > top
627 const yl = limitOff ? Y(top) : Y(c.limit)
628 const ya = c.avg !== null ? Y(c.avg) : null
629 s += `<line x1="0" x2="100%" y1="${yl}" y2="${yl}" style="stroke:${C.limit}" stroke-width="2.5"/>`
630 if (ya !== null) s += `<line x1="0" x2="100%" y1="${ya}" y2="${ya}" style="stroke:${C.fg}" stroke-width="3" stroke-dasharray="3 5" stroke-linecap="round"/>`
631 // the labels sit on their lines at the right edge, each on a backing so bars and lines behind don't show through
632 const label = (y: number, s2: string, fill?: string) => {
633 const w = textWidth(s2, 'line') + 16, h = px('line') + 8
634 return at(1, veil(-4 - w, y - h / 2, w, h, 6) + text(-12, y + px('line') * 0.35, s2, { role: 'line', anchor: 'end', ...(fill ? { fill } : {}) }))
635 }
636 // keep the labels clear of each other and inside the plot
637 const [lY, aY] = spreadLabels(ya === null ? [yl] : [yl, ya], px('line') + 12, padT + px('line') / 2 + 6, base - px('line') / 2 - 6)
638 s += late
639 s += label(lY!, `limit ${fx(c.limit)}${limitOff ? ' ▲' : ''}`, C.limit)
640 if (aY !== undefined && c.avg !== null) s += label(aY, `average ${fx(c.avg)}`)
641 return drawing(s, Hh, c.palettes ?? DEFAULT_PALETTES)
642}
643/** The weekly chart for model `m` at `now`. */
644export function weekChart(m: Model, now: number, width: number, pal: Palettes = DEFAULT_PALETTES, height = 400): string {
645 return timeChart(m, now, width, height, pal)
646}
647
648/** The 5-hour chart: the window since it opened. */
649export function fiveChart(m: Model, now: number, width: number, pal: Palettes = DEFAULT_PALETTES, height = 300): string {
650 return timeChart(m, now, width, height, pal)
651}
652
653/**
654 * Recorded usage a chart needs before its bars mean anything: the weekly
655 * figure moves in steps of about a point, so it needs hours to show a shape;
656 * the 5-hour one, half an hour.
657 */
658export const CHART_NEEDS_H = { week: 6, five: 0.5 }
659
660/** Hours until the chart has enough recorded behind it; 0 once it has. */
661export const chartWait = (m: Model, now: number) => Math.max(0, CHART_NEEDS_H[m.kind] - (now - m.recordedFrom) / HOUR)
662
663
664/**
665 * What the empty state says in the chart's place, or null when there is a
666 * chart to draw: an average that can't be worked out yet (a window shorter
667 * than the least, or longer than the record; too soon after the reset), or a
668 * record too short for the bars to show a shape.
669 */
670export function emptyChartText(m: Model, now: number): [string, string] | null {
671 const name = m.kind === 'week' ? 'Weekly' : '5-hour'
672 const recordedH = (now - m.recordedFrom) / HOUR
673 const avg = m.average
674 if (avg.type === 'hours' && avg.hours < MIN_RECORDED_H[m.kind]) {
675 return [`${name} averages need at least ${dur(MIN_RECORDED_H[m.kind])}`, `Pick a window of ${dur(MIN_RECORDED_H[m.kind])} or more, or Since reset.`]
676 }
677 if (avg.type === 'hours' && avg.hours > recordedH) {
678 return [`Needs ${dur(avg.hours)} of recorded usage`, `About ${dur(avg.hours - recordedH)} to go. ${dur(recordedH)} recorded so far.`]
679 }
680 if (m.noData) return ['No estimate yet', m.noData]
681 const wait = chartWait(m, now)
682 if (wait > 0) return [`Chart in about ${dur(wait)}`, `It shows once ${dur(CHART_NEEDS_H[m.kind])} of usage is recorded. The figures above already count.`]
683 return null
684}
685
686/** In the chart's place until it has enough behind it: what it waits for, and how long that takes. */
687function emptyLayout(m: Model, now: number, width: number) {
688 const [head, sub] = emptyChartText(m, now) ?? ['', '']
689 const lines = wrap(sub, Math.max(160, width - 32), 'note')
690 const top = 24 + px('caption')
691 return { head, lines, top, height: Math.ceil(top + 10 + linesHeight(lines.length, 'note') + 18) }
692}
693
694
695export function emptyChartSvg(m: Model, now: number, width: number, pal: Palettes = DEFAULT_PALETTES): string {
696 const L = emptyLayout(m, now, width), H = L.height
697 // a dashed box edge to edge: its right side drawn from the right edge in
698 const dash = `style="stroke:${C.dim}" stroke-opacity="0.35" stroke-dasharray="4 5"`
699 const s = `<line x1="1" x2="100%" y1="1" y2="1" ${dash}/><line x1="1" x2="100%" y1="${H - 1}" y2="${H - 1}" ${dash}/>`
700 + `<line x1="1" x2="1" y1="1" y2="${H - 1}" ${dash}/>` + at(1, `<line x1="-1" x2="-1" y1="1" y2="${H - 1}" ${dash}/>`)
701 + text('50%', L.top, L.head, { role: 'caption', anchor: 'middle', fill: C.fg })
702 + L.lines.map((l, i) => text('50%', L.top + 10 + baseline(i, 'note'), l, { role: 'note', anchor: 'middle' })).join('')
703 return drawing(s, H, pal)
704}
705/** Text broken into lines that fit `width` in a role's style; paragraphs split on newlines. */
706function wrap(note: string, width: number, role: Role): string[] {
707 const lines: string[] = []
708 for (const para of note.split('\n')) {
709 let first = true
710 for (const word of para.split(' ')) {
711 const last = lines[lines.length - 1]
712 if (!first && last !== undefined && textWidth(`${last} ${word}`, role) <= width) lines[lines.length - 1] = `${last} ${word}`
713 else lines.push(word)
714 first = false
715 }
716 }
717 return lines
718}
719/** A wrapped line's baseline, and the height `n` lines take. */
720const baseline = (i: number, role: Role) => (i + 1) * px(role) * 1.4 - px(role) * 0.3
721const linesHeight = (n: number, role: Role) => Math.ceil(n * px(role) * 1.4 + px(role) * 0.3)
722
723/** Text in a role's style, wrapped to `width`. `fill` where state sets the colour. */
724export function noteSvg(note: string, width: number, pal: Palettes = DEFAULT_PALETTES, role: Role = 'note', fill?: string): string {
725 const lines = wrap(note, width, role), H = linesHeight(lines.length, role)
726 return drawing(lines.map((l, i) => text(0, baseline(i, role), l, { role, ...(fill ? { fill } : {}) })).join(''), H, pal)
727}
728
729// ---- recent pace ----
730
731/** The stretch the recent pace looks back over. */
732export const PACE_HOURS = 1
733/** Weekly points recorded alongside the 5-hour figure before the two can be related. */
734export const PACE_MIN_POINTS = 3
735
736export type Pace =
737 | { ready: false; why: string }
738 | {
739 ready: true
740 /** 5-hour points per weekly point, over `basisH` hours of record. */
741 ratio: number
742 basisH: number
743 /** 5-hour points used in the last PACE_HOURS. */
744 recent: number
745 /** The weekly pace that is, in % per hour. */
746 rate: number
747 runsOutIn: number
748 early: number
749 over: boolean
750 arrive: number
751 }
752
753/**
754 * The weekly limit at the last hour's pace. The weekly figure moves in whole
755 * points, too coarse to read an hour from; the 5-hour figure moves several
756 * times faster. Over the record, the 5-hour points that went with each weekly
757 * point give the exchange rate; the last hour's 5-hour points, so exchanged,
758 * give the weekly pace.
759 */
760export function recentPace(readings: readonly RangeReading[], week: Model, now: number, seen: readonly Watch[] = []): Pace {
761 const fives = ofKind(readings, 'five'), weeks = ofKind(readings, 'week')
762 if (!fives.length || !weeks.length) return { ready: false, why: 'Your weekly and 5-hour usage arrive with Claude’s next response.' }
763 const fiveFor = (now - fives[0]![0]) / HOUR
764 if (fiveFor < PACE_HOURS) return { ready: false, why: `It needs ${dur(PACE_HOURS)} of recording. About ${dur(PACE_HOURS - fiveFor)} to go.` }
765 const start = Math.max(fives[0]![0], weeks[0]![0])
766 const fiveIncs = increments(readings, 'five', seen)
767 const weekPoints = usedBetween(week.increments, start, now)
768 if (weekPoints < PACE_MIN_POINTS) {
769 return { ready: false, why: `It needs your weekly usage to go up ${PACE_MIN_POINTS}% while recording. Up ${Math.floor(weekPoints)}% so far.` }
770 }
771 // the last hour has to be on record here, not a gap whose rise was placed in it afterwards
772 if (!isWatched(seen, now - PACE_HOURS * HOUR, now)) return { ready: false, why: `It needs the last ${dur(PACE_HOURS)} recorded without a break.` }
773 const ratio = usedBetween(fiveIncs, start, now) / weekPoints
774 const recent = usedBetween(fiveIncs, now - PACE_HOURS * HOUR, now)
775 const rate = ratio > 0 ? recent / ratio / PACE_HOURS : 0
776 const proj = week.pct + rate * week.left
777 const runsOutIn = rate > 0 ? (100 - week.pct) / rate : Infinity
778 return {
779 ready: true, ratio, basisH: (now - start) / HOUR, recent, rate, runsOutIn,
780 early: Math.max(0, week.left - runsOutIn), over: proj > 100, arrive: 100 - proj,
781 }
782}
783
784/** The caption of the pace figure. */
785export const PACE_LABEL = '1hr pace'
786
787/**
788 * The head and the chart (or its empty state) as one drawing, so the head's
789 * info circle can open its tooltip over the chart. A click or the pointer on
790 * the circle opens it, a click elsewhere closes it: no script, the circle
791 * takes focus on a click and CSS shows the box while it has focus or the
792 * pointer. Drawn interactive for that.
793 */
794export function withInfo(head: { svg: string; height: number; info?: InfoSpot }, chart: string, tip: string, width: number, pal: Palettes = DEFAULT_PALETTES, label = 'How is the 1hr pace worked out?'): string {
795 const inner = (svg: string) => /^<svg [^>]*>([\s\S]*)<\/svg>$/.exec(svg)![1]!
796 const hH = drawingHeight(head.svg), cH = drawingHeight(chart), gap = 10
797 const nest = (svg: string, y: number, h: number) => `<svg x="0" y="${y}" width="100%" height="${h}" overflow="visible">${inner(svg)}</svg>`
798 let H = hH + gap + cH, over = ''
799 if (head.info) {
800 const { cx, cy, r } = head.info
801 const boxW = Math.min(width, 440), pad = 12
802 const lines = wrap(tip, boxW - pad * 2, 'note')
803 const boxH = linesHeight(lines.length, 'note') + pad * 2 - px('note') * 0.3
804 // the box opens just under the circle and ends at it, so it stays inside the card whatever its real width
805 const bx = Math.max(0, cx + r + 4 - boxW), by = cy + r + 8
806 H = Math.max(H, Math.ceil(by + boxH + 2))
807 const info = `<g class="info" tabindex="0" role="button" aria-label="${esc(label)}">`
808 + `<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${(r + 4).toFixed(1)}" fill="transparent"/>`
809 + `<circle cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}" fill="transparent" style="stroke:${C.dim}" stroke-width="1.2"/>`
810 + `<text x="${cx.toFixed(1)}" y="${(cy + r * 0.55).toFixed(1)}" text-anchor="middle" style="fill:${C.dim}" font-family="${FONT}" font-size="${(r * 1.5).toFixed(1)}" font-weight="600">i</text></g>`
811 const box = `<g class="tip"><rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${boxW}" height="${boxH.toFixed(1)}" rx="8" style="fill:${C.veil};stroke:${C.dim}" fill-opacity="0.94" stroke-opacity="0.4"/>`
812 + lines.map((l, i) => text(bx + pad, by + pad + baseline(i, 'note') - px('note') * 0.15, l, { role: 'note', fill: C.fg })).join('') + `</g>`
813 const css = `<style>.info{cursor:pointer;outline:none}.info:hover circle+circle,.info:focus circle+circle{stroke:var(--fg)}.info:hover text,.info:focus text{fill:var(--fg)}`
814 + `.tip{display:none}.info:hover~.tip,.info:focus~.tip{display:inline}</style>`
815 over = css + info + box
816 }
817 return drawing(nest(head.svg, 0, hH) + nest(chart, hH + gap, cH) + over, H, pal)
818}
819/** What the pace comes to: the tooltip's first line. */
820export function paceText(p: Pace): string {
821 if (!p.ready) return `No pace yet. ${p.why}`
822 return p.over
823 ? `At this pace, the weekly limit runs out ${dur(p.early)} early.`
824 : `At this pace, the weekly limit lasts to the reset with ${Math.max(0, Math.round(p.arrive))}% left.`
825}
826
827/** How the pace is worked out, with this account's own numbers once there are some. */
828export function paceExplain(p: Pace): string {
829 if (!p.ready) return 'While recording, the plugin learns how much of your 5-hour limit goes with each 1% of your weekly limit.'
830 return [
831 `In the last hour you used ${fx(p.recent)}% of your 5-hour limit. That's about ${fx(p.recent / p.ratio, 2)}% of your weekly limit, or ${fx(p.rate * 24)}% a day.`,
832 'These numbers are estimates that get steadier over time.',
833 ].join('\n')
834}
835
836// ---- fitting the pane ----
837
838/**
839 * A cell of the desktop's code font in CSS pixels, as measured in the Code
840 * tab: the pane reports its width in these cells, the drawings are in pixels.
841 */
842export const CELL_PX = { w: 9.5 }
843
844export type Fit = { width: number; weekHeight: number; fiveHeight: number }
845
846/** Chart heights: a share of the card's width, within bounds. */
847const CHART_H = { week: { ratio: 0.55, min: 260, max: 440 }, five: { ratio: 0.42, min: 200, max: 340 } }
848
849/**
850 * Sizes the drawings for a pane `columns` wide: the card's inner width, for
851 * what wraps, and each chart's height, which follows that width.
852 */
853export function fit(columns: number): Fit {
854 // the card's inner width: the pane less the card's padding and edge. Drawings fill whatever width they get;
855 // this is for laying out what wraps (figures, notes) and for the charts' heights
856 const width = Math.max(240, Math.floor((columns - 6) * CELL_PX.w))
857 // the pane's reported rows don't follow its real height in the desktop app, so the charts' heights
858 // follow the card's width instead: a wider card, a taller chart, within bounds
859 const tall = (k: 'week' | 'five') => Math.round(Math.min(CHART_H[k].max, Math.max(CHART_H[k].min, width * CHART_H[k].ratio)))
860 return { width, weekHeight: tall('week'), fiveHeight: tall('five') }
861}
862const pctText = (v: number) => (v > 0 && v < 1 ? '<1%' : `${Math.round(v)}%`)
863
864/**
865 * The chart's stretches known only as a total: before recording began (the
866 * first reading's figure, since the reset), and each gap this computer wasn't
867 * watching. A weekly gap's rise could have come anywhere in it, so its block
868 * spans the gap; a 5-hour one belongs to the window it was seen in, so its
869 * block starts no earlier than the window opened.
870 */
871function knownBlocks(m: Model, now: number, perHours: number): ChartSpec['blocks'] {
872 const span = now - m.from, out: ChartSpec['blocks'] = []
873 const add = (a: number, b: number, amount: number, what: (p: string, h: string) => [string, string]) => {
874 const v0 = Math.max(a, m.from), v1 = Math.min(b, now)
875 if (v1 <= v0 || amount <= 0) return
876 const shown = amount * (v1 - v0) / (b - a), hours = (v1 - v0) / HOUR
877 const [label, short] = what(pctText(shown), dur(hours))
878 out.push({ f0: (v0 - m.from) / span, f1: (v1 - m.from) / span, rate: shown / hours * perHours, label, short })
879 }
880 if (m.before) add(m.from, m.before.until, m.before.pct, p => [`${p} before tracking`, p])
881 for (const inc of m.increments) {
882 if (!inc.hole) continue
883 add(m.kind === 'week' ? inc.hole[0] : inc.start, inc.end, inc.amount, (p, h) => [`${p} used while away, ${h}`, `${p} · ${h}`])
884 }
885 return out
886}
887
888function timeChart(m: Model, now: number, width: number, height: number, palettes: Palettes): string {
889 const isWeek = m.kind === 'week'
890 const values = bars(m, now, bucketMinutes(m.winH * 60), isWeek ? 24 : 1)
891 const barMin = m.winH * 60 / values.length
892 const ago = (h: number) => h >= 48 ? `−${Math.round(h / 24)}d` : h >= 2 ? `−${Math.round(h)}h` : `−${Math.round(h * 60)}m`
893 // the axis keeps one unit across, picked by the window's length
894 const tick = (h: number) => m.winH >= 48 ? `−${fx(h / 24, h / 24 < 10 && h % 24 ? 1 : 0)}d`
895 : m.winH >= 2 ? `−${Math.round(h * 10) % 10 && h < 5 ? fx(h) : Math.round(h)}h`
896 : `−${Math.round(h * 60)}m`
897 const perHours = isWeek ? 24 : 1
898 const marks: ChartSpec['marks'] = []
899 for (let k = 0; isWeek && k < 4; k++) {
900 const at = m.resetsAt - (k + 1) * SPAN.week
901 if (at > m.from && at < now) marks.push({ f: (at - m.from) / (now - m.from), label: `reset ${ago((now - at) / HOUR)}` })
902 }
903 return chartSvg({
904 width, height, bars: values, avg: m.noData ? null : m.rate * (isWeek ? 24 : 1), limit: m.limit * (isWeek ? 24 : 1),
905 xTicks: [0, 0.25, 0.5, 0.75].map(f => ({ f, label: tick(m.winH * (1 - f)) })).concat({ f: 1, label: 'Now' }),
906 marks,
907 unrecorded: m.before ? 0 : Math.min(1, Math.max(0, (m.recordedFrom - m.from) / (now - m.from))),
908 blocks: knownBlocks(m, now, perHours),
909 palettes,
910 unitLabel: `% per ${isWeek ? 'day' : 'hour'} · bar = ${barMin >= 60 ? dur(barMin / 60) : `${Math.max(1, Math.round(barMin))}m`}`,
911 })
912}
913hooks/theme.ts 203 lines1/**
2 * The card's colours, derived from the person's Claude Code theme rather than
3 * fixed: the theme's own tokens (text, inactive, claude, success, ...) set the
4 * palette, so a custom theme, or a mod that ships one, carries over to the charts.
5 *
6 * The charts are SVG images, which can't name theme keys the way a Text can,
7 * so each chart carries the palette as CSS variables: one set for a dark
8 * appearance and one for a light, chosen by `prefers-color-scheme`.
9 */
10import type { Palette, Palettes } from '../types'
11
12/** The theme tokens the palette is made from. */
13export type Tokens = {
14 text: string; inverseText: string; inactive: string; subtle: string; userMessageBackground: string
15 claude: string; success: string; error: string; warning: string; suggestion: string
16}
17const TOKEN_KEYS = ['text', 'inverseText', 'inactive', 'subtle', 'userMessageBackground', 'claude', 'success', 'error', 'warning', 'suggestion'] as const
18/** Tokens a theme gives its own hue: kept in the other appearance too. */
19const ACCENTS = ['claude', 'success', 'error', 'warning', 'suggestion'] as const
20
21/** Claude Code's built-in presets, as far as the palette needs them. */
22const DARK: Tokens = {
23 text: 'rgb(255,255,255)', inverseText: 'rgb(0,0,0)', inactive: 'rgb(153,153,153)', subtle: 'rgb(80,80,80)', userMessageBackground: 'rgb(55,55,55)',
24 claude: 'rgb(215,119,87)', success: 'rgb(78,186,101)', error: 'rgb(255,107,128)', warning: 'rgb(255,193,7)', suggestion: 'rgb(177,185,249)',
25}
26const LIGHT: Tokens = {
27 text: 'rgb(0,0,0)', inverseText: 'rgb(255,255,255)', inactive: 'rgb(102,102,102)', subtle: 'rgb(175,175,175)', userMessageBackground: 'rgb(240,240,240)',
28 claude: 'rgb(215,119,87)', success: 'rgb(44,122,57)', error: 'rgb(171,43,63)', warning: 'rgb(150,108,30)', suggestion: 'rgb(87,105,247)',
29}
30export const PRESETS: Record<string, Tokens> = {
31 dark: DARK,
32 light: LIGHT,
33 'dark-daltonized': { ...DARK, success: 'rgb(51,153,255)', error: 'rgb(255,102,102)', warning: 'rgb(255,204,0)' },
34 'light-daltonized': { ...LIGHT, success: 'rgb(0,102,153)', error: 'rgb(204,0,0)', warning: 'rgb(255,153,51)' },
35 'dark-ansi': { ...DARK, text: 'ansi:whiteBright', inactive: 'ansi:white', subtle: 'ansi:blackBright', claude: 'ansi:redBright', success: 'ansi:greenBright', error: 'ansi:redBright', warning: 'ansi:yellowBright', suggestion: 'ansi:blueBright' },
36 'light-ansi': { ...LIGHT, text: 'ansi:black', inactive: 'ansi:blackBright', subtle: 'ansi:white', claude: 'ansi:red', success: 'ansi:green', error: 'ansi:red', warning: 'ansi:yellow', suggestion: 'ansi:blue' },
37}
38
39/** A theme as the setting and its file describe it: which preset it builds on, and what it changes. */
40export type Theme = { base: string; overrides: Partial<Tokens> }
41
42const isDark = (base: string) => !base.startsWith('light')
43/** The preset of the other appearance, of the same kind (plain, daltonized, ansi). */
44const counterpart = (base: string) => (isDark(base) ? base.replace(/^dark/, 'light') : base.replace(/^light/, 'dark'))
45
46/** Reads a theme file's `{ base, overrides }`, keeping only tokens the palette uses and colours that parse. */
47export function parseThemeFile(json: unknown): Theme {
48 const o = (json ?? {}) as { base?: unknown; overrides?: Record<string, unknown> }
49 const base = typeof o.base === 'string' && PRESETS[o.base] ? o.base : 'dark'
50 const overrides: Partial<Tokens> = {}
51 for (const k of TOKEN_KEYS) {
52 const v = o.overrides?.[k]
53 if (typeof v === 'string' && parseColor(v)) overrides[k] = v
54 }
55 return { base, overrides }
56}
57
58/**
59 * The tokens for each appearance. The theme's own appearance takes the theme
60 * whole; the other takes its counterpart preset with the theme's accents, so
61 * the hues it chose carry over while text and background suit that appearance.
62 * `auto` has no appearance of its own: each takes its plain preset.
63 */
64export function tokensFor(theme: Theme | 'auto'): { dark: Tokens; light: Tokens } {
65 if (theme === 'auto') return { dark: DARK, light: LIGHT }
66 const own = { ...PRESETS[theme.base] ?? DARK, ...theme.overrides }
67 const accents: Partial<Tokens> = {}
68 for (const k of ACCENTS) if (theme.overrides[k]) accents[k] = theme.overrides[k]
69 const other = { ...PRESETS[counterpart(theme.base)] ?? LIGHT, ...accents }
70 return isDark(theme.base) ? { dark: own, light: other } : { dark: other, light: own }
71}
72
73// ---- colours ----
74
75type RGB = [number, number, number]
76
77const ANSI: Record<string, RGB> = {
78 black: [0, 0, 0], red: [205, 49, 49], green: [13, 188, 121], yellow: [229, 229, 16], blue: [36, 114, 200], magenta: [188, 63, 188], cyan: [17, 168, 205], white: [229, 229, 229],
79 blackBright: [102, 102, 102], redBright: [241, 76, 76], greenBright: [35, 209, 139], yellowBright: [245, 245, 67], blueBright: [59, 142, 234], magentaBright: [214, 112, 214], cyanBright: [41, 184, 219], whiteBright: [255, 255, 255],
80}
81const ANSI_ORDER = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const
82
83/** Parses the colours a theme file takes: `#rrggbb`, `#rgb`, `rgb(r,g,b)`, `ansi256(n)`, `ansi:<name>`. */
84export function parseColor(s: string): RGB | null {
85 const t = s.trim()
86 let m = /^#([0-9a-f]{6})$/i.exec(t)
87 if (m) { const n = parseInt(m[1]!, 16); return [n >> 16, (n >> 8) & 255, n & 255] }
88 m = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(t)
89 if (m) return [m[1]!, m[2]!, m[3]!].map(h => parseInt(h + h, 16)) as RGB
90 m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i.exec(t)
91 if (m) return [m[1]!, m[2]!, m[3]!].map(v => Math.min(255, Number(v))) as RGB
92 m = /^ansi256\(\s*(\d{1,3})\s*\)$/i.exec(t)
93 if (m) return ansi256(Number(m[1]))
94 m = /^ansi:(\w+)$/i.exec(t)
95 if (m) return ANSI[m[1]!] ?? null
96 return null
97}
98
99function ansi256(n: number): RGB | null {
100 if (n < 0 || n > 255) return null
101 if (n < 16) return ANSI[(n < 8 ? ANSI_ORDER[n] : `${ANSI_ORDER[n - 8]}Bright`)!] ?? null
102 if (n >= 232) { const v = 8 + (n - 232) * 10; return [v, v, v] }
103 const i = n - 16, step = (c: number) => (c === 0 ? 0 : 55 + c * 40)
104 return [step(Math.floor(i / 36)), step(Math.floor(i / 6) % 6), step(i % 6)]
105}
106
107const hex = (c: RGB) => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
108/** `a` moved toward `b` by `t` (0 = a, 1 = b). */
109const mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map(i => a[i]! + (b[i]! - a[i]!) * t) as RGB
110
111const luminance = (c: RGB) => {
112 const ch = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
113 return 0.2126 * ch(c[0]) + 0.7152 * ch(c[1]) + 0.0722 * ch(c[2])
114}
115export const contrast = (a: RGB, b: RGB) => {
116 const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p)
117 return (x! + 0.05) / (y! + 0.05)
118}
119/** `c` as it is, or moved toward `toward` just enough to reach `min` contrast on `bg`. */
120function legible(c: RGB, bg: RGB, toward: RGB, min: number): RGB {
121 for (let t = 0; t <= 1; t += 0.05) {
122 const x = mix(c, toward, t)
123 if (contrast(x, bg) >= min) return x
124 }
125 return toward
126}
127
128// ---- the palette ----
129
130export type PaletteKey = keyof Palette
131
132/**
133 * The card's palette from one appearance's tokens. The card is the theme's
134 * message background, and the veil behind a label its inverse text colour, the
135 * appearance's own black or white; bars are the Claude colour warming toward the
136 * warning colour; the limit line is the suggestion colour; over, under and
137 * short are warning, success and error. Every colour that carries meaning is
138 * nudged toward the text colour until it reads on the card.
139 */
140export function derivePalette(t: Tokens): Palette {
141 const c = (s: string, fallback: string) => parseColor(s) ?? parseColor(fallback)!
142 const fallback = luminance(c(t.inverseText, '#000')) < 0.5 ? DARK : LIGHT
143 const k = Object.fromEntries(TOKEN_KEYS.map(key => [key, c(t[key], fallback[key])])) as Record<keyof Tokens, RGB>
144 const card = k.userMessageBackground
145 const on = (x: RGB, min: number) => hex(legible(x, card, k.text, min))
146 return {
147 card: hex(card),
148 veil: hex(k.inverseText),
149 fg: on(k.text, 7),
150 dim: on(k.inactive, 4.5),
151 off: on(k.subtle, 1.8),
152 barTop: on(mix(k.claude, k.warning, 0.35), 2),
153 barBottom: on(k.claude, 2),
154 limit: on(k.suggestion, 3),
155 over: on(k.warning, 3),
156 under: on(k.success, 3),
157 bad: on(k.error, 3),
158 // the models: Claude's own colour for Opus, then hues apart from it and from each other
159 fable: on(mix(k.error, k.suggestion, 0.5), 3),
160 opus: on(k.claude, 3),
161 sonnet: on(k.suggestion, 3),
162 haiku: on(mix(k.success, k.suggestion, 0.35), 3),
163 }
164}
165
166export function palettesFor(theme: Theme | 'auto'): Palettes {
167 const t = tokensFor(theme)
168 return { dark: derivePalette(t.dark), light: derivePalette(t.light), own: theme === 'auto' || isDark(theme.base) ? 'dark' : 'light' }
169}
170
171export const DEFAULT_PALETTES = palettesFor({ base: 'dark', overrides: {} })
172
173/** A colour by name, for a style: `style="fill:${v('fg')}"`. */
174export const v = (key: PaletteKey) => `var(--${key})`
175
176/**
177 * The `<style>` a chart carries: its theme's own appearance as the default,
178 * the other one under `prefers-color-scheme`, so the chart follows the app's
179 * appearance where the surface passes it on, and the theme where it doesn't.
180 */
181export function paletteStyle(p: Palettes): string {
182 const vars = (pal: Palette) => Object.entries(pal).map(([key, val]) => `--${key}:${val}`).join(';')
183 const other = p.own === 'dark' ? 'light' : 'dark'
184 // shown in a frame, a drawing is a page of its own: one that doesn't say it can be dark gets an opaque
185 // white backdrop in a dark app, so each says it suits both, and stays see-through
186 return `<style>:root{color-scheme:light dark;background:transparent}svg{${vars(p[p.own])}}@media (prefers-color-scheme:${other}){svg{${vars(p[other])}}}</style>`
187}
188
189/** The theme a `theme` setting names: a preset, `auto`, or `custom:<slug>`, read by `readFile`. */
190export async function resolveTheme(setting: unknown, readFile: (slug: string) => Promise<string | undefined>): Promise<Theme | 'auto'> {
191 const name = typeof setting === 'string' ? setting : 'dark'
192 if (name === 'auto') return 'auto'
193 if (PRESETS[name]) return { base: name, overrides: {} }
194 const slug = /^custom:(.+)$/.exec(name)?.[1]
195 if (slug) {
196 const text = await readFile(slug).catch(() => undefined)
197 if (text) {
198 try { return parseThemeFile(JSON.parse(text)) } catch { /* fall through to the default */ }
199 }
200 }
201 return { base: 'dark', overrides: {} }
202}
203hooks/models.ts 433 lines1import type { Palettes, RangeReading, RangeStep } from '../types'
2import { C, DAY, FONT, HOUR, KEEP, MONO, at, drawing, esc, increments, isWatched, ofKind, pct, usedBetween } from './range'
3import type { InfoSpot, Model, Watch } from './range'
4import { DEFAULT_PALETTES } from './theme'
5
6/**
7 * What each model costs, learned from this account's own history.
8 *
9 * Every response reports its model and tokens. Between two readings of the
10 * 5-hour limit the plugin knows how far the limit rose and which models did
11 * the work then, so over many such stretches it can solve for each model's
12 * weight: rise ≈ Σ w·T. The 5-hour figure moves several times faster than the
13 * weekly one, so it gives several times the stretches; how models compare
14 * comes out the same from either limit, and the week's points are the 5-hour
15 * ones exchanged at the account's own rate (as the 1hr pace does). Within a
16 * model, its token kinds are combined at that model's published price ratios
17 * (UNITS), so only the weight across models is learned. A model's cost shows
18 * once its weight is known within ±SHOW_WITHIN.
19 */
20
21/** A response's tokens, weighted as a model's price list weighs them: input 1, output 5, cache writes 1.25, cache reads 0.1. */
22export const units = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) =>
23 u.input_tokens + 5 * u.output_tokens + 1.25 * u.cache_creation_input_tokens + 0.1 * u.cache_read_input_tokens
24
25/** How far back the costs are learned from. */
26export const LEARN_SPAN = 14 * DAY
27/** A cost shows once its 90% range is within this share of it. */
28export const SHOW_WITHIN = 0.2
29/** And once this many stretches had the model in them: a few stretches can fit too well by chance. */
30const MIN_STRETCHES = 5
31/** z for a 90% range. */
32const Z90 = 1.645
33
34const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku'] as const
35export type Family = (typeof FAMILIES)[number] | 'other'
36
37/** A model's name and family from its id: `claude-opus-5-5` is Opus 5.5, `claude-3-5-sonnet-20241022` Sonnet 3.5. */
38export function modelName(id: string): { name: string; family: Family; version: number[] } {
39 const parts = id.toLowerCase().replace(/\[.*\]$/, '').replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p) && p !== 'latest')
40 const family = (FAMILIES as readonly string[]).find(f => parts.includes(f)) as Family | undefined
41 const version = parts.filter(p => /^\d{1,2}$/.test(p)).map(Number)
42 if (!family) return { name: id, family: 'other', version }
43 const title = family[0]!.toUpperCase() + family.slice(1)
44 return { name: version.length ? `${title} ${version.join('.')}` : title, family, version }
45}
46
47/** Fable, Opus, Sonnet, Haiku, then the rest; newest version first within a family. */
48function byRank(a: string, b: string) {
49 const A = modelName(a), B = modelName(b)
50 const rank = (f: Family) => (f === 'other' ? 9 : FAMILIES.indexOf(f))
51 if (rank(A.family) !== rank(B.family)) return rank(A.family) - rank(B.family)
52 for (let i = 0; i < Math.max(A.version.length, B.version.length); i++) {
53 const d = (B.version[i] ?? 0) - (A.version[i] ?? 0)
54 if (d) return d
55 }
56 return a.localeCompare(b)
57}
58
59export type ModelCost = {
60 id: string
61 name: string
62 family: Family
63 /** 5-hour points per million weighted tokens; 0 until anything is known. */
64 w: number
65 /** The 90% range as a share of `w`: Infinity while nothing is known. */
66 rel: number
67 shown: boolean
68 /** Responses in the learning span, and how many of them subagents made. */
69 responses: number
70 sub: number
71 /** About how many more responses until it shows; 0 once it does or when it can't be told. */
72 more: number
73}
74
75export type Learned = {
76 models: ModelCost[]
77 /** When the first stretch learned from began (ms), or null with none yet. */
78 since: number | null
79 /** 5-hour points per weekly point over the same span, or null until the weekly figure has moved enough to tell. */
80 perWeek: number | null
81}
82
83/** Solves the normal equations A·x = b (A symmetric, small) and gives A's inverse too; null when A is singular. */
84function solve(A: number[][], b: number[]): { x: number[]; inv: number[][] } | null {
85 const n = b.length
86 const M = A.map((row, i) => [...row, ...Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)), b[i]!])
87 for (let c = 0; c < n; c++) {
88 let p = c
89 for (let r = c + 1; r < n; r++) if (Math.abs(M[r]![c]!) > Math.abs(M[p]![c]!)) p = r
90 if (Math.abs(M[p]![c]!) < 1e-12) return null
91 ;[M[c], M[p]] = [M[p]!, M[c]!]
92 const d = M[c]![c]!
93 for (let k = 0; k <= 2 * n; k++) M[c]![k]! /= d
94 for (let r = 0; r < n; r++) {
95 if (r === c) continue
96 const f = M[r]![c]!
97 if (f) for (let k = 0; k <= 2 * n; k++) M[r]![k]! -= f * M[c]![k]!
98 }
99 }
100 return { x: M.map(r => r[2 * n]!), inv: M.map(r => r.slice(n, 2 * n)) }
101}
102
103/**
104 * Each model's weight from the stretches between weekly readings that this
105 * computer watched whole, and how sure that weight is. Weights can't go
106 * below zero: a model whose best fit is negative is left out and refitted.
107 */
108export function learn(readings: readonly RangeReading[], steps: readonly RangeStep[], seen: readonly Watch[], now: number): Learned {
109 const from = now - LEARN_SPAN
110 const recent = steps.filter(s => s[0] >= from && s[0] <= now)
111 const ids = [...new Set(recent.map(s => s[1]))].sort(byRank)
112 const fives = ofKind(readings, 'five').filter(r => r[0] >= from)
113 const sorted = [...recent].sort((a, b) => a[0] - b[0])
114
115 // the stretches: how far the limit rose, and each model's tokens (in millions) meanwhile
116 const rows: Array<{ y: number; x: number[]; n: number }> = []
117 let since: number | null = null
118 for (let i = 1; i < fives.length; i++) {
119 const a = fives[i - 1]!, b = fives[i]!
120 // a reset in between, or a gap nobody here watched: usage from elsewhere would count against these models
121 if (Math.abs(a[3] - b[3]) > 5 * 60_000 || !isWatched(seen, a[0], b[0])) continue
122 const y = b[2] - a[2]
123 if (y < 0) continue
124 const x = ids.map(() => 0)
125 let n = 0
126 for (const s of sorted) if (s[0] > a[0] && s[0] <= b[0]) { x[ids.indexOf(s[1])]! += s[3] / 1e6; n++ }
127 if (n === 0) continue
128 rows.push({ y, x, n })
129 since ??= a[0]
130 }
131
132 // least squares over the models still in, dropping the most negative until none is
133 let active = ids.map((_, i) => i).filter(i => rows.some(r => r.x[i]! > 0))
134 let fit: { x: number[]; inv: number[][] } | null = null
135 while (active.length) {
136 const A = active.map(i => active.map(j => rows.reduce((s, r) => s + r.x[i]! * r.x[j]!, 0)))
137 const b = active.map(i => rows.reduce((s, r) => s + r.x[i]! * r.y, 0))
138 fit = solve(A, b)
139 if (!fit) break
140 const worst = fit.x.reduce((k, v, j) => (v < (fit!.x[k] ?? 0) ? j : k), 0)
141 if (fit.x[worst]! >= 0) break
142 active = active.filter((_, j) => j !== worst)
143 fit = null
144 }
145 const w = ids.map(() => 0), rel = ids.map(() => Infinity)
146 if (fit && rows.length > active.length) {
147 active.forEach((i, j) => { w[i] = fit!.x[j]! })
148 const rss = rows.reduce((s, r) => s + (r.y - r.x.reduce((t, v, i) => t + v * w[i]!, 0)) ** 2, 0)
149 // the limit is read at whole points, so each end of a stretch is off by part of the response that crossed
150 // the point, evenly anywhere in it: never trust a fit closer than that (two ends, each size²/12)
151 const perResponse = rows.reduce((s, r) => s + r.y, 0) / rows.reduce((s, r) => s + r.n, 0)
152 const s2 = Math.max(rss / (rows.length - active.length), perResponse ** 2 / 6)
153 active.forEach((i, j) => {
154 if (w[i]! > 0) rel[i] = Z90 * Math.sqrt(s2 * fit!.inv[j]![j]!) / w[i]!
155 })
156 }
157
158 // the exchange rate to weekly points, from what both limits rose by while watched
159 const weekPts = usedBetween(increments(readings, 'week', seen).filter(x => !x.hole), from, now)
160 const fivePts = usedBetween(increments(readings, 'five', seen).filter(x => !x.hole), from, now)
161
162 return {
163 since,
164 perWeek: weekPts >= 3 && fivePts > 0 ? fivePts / weekPts : null,
165 models: ids.map((id, i) => {
166 const mine = recent.filter(s => s[1] === id)
167 const stretches = rows.filter(r => r.x[i]! > 0).length
168 const shown = rel[i]! <= SHOW_WITHIN && stretches >= MIN_STRETCHES
169 // the range narrows about as the square root of what's behind it
170 const more = shown || !isFinite(rel[i]!) ? 0 : Math.max(1, Math.ceil(mine.length * ((rel[i]! / SHOW_WITHIN) ** 2 - 1)))
171 return { id, ...modelName(id), w: w[i]!, rel: rel[i]!, shown, responses: mine.length, sub: mine.filter(s => s[4]).length, more }
172 }),
173 }
174}
175
176/** The model the others compare to: the one picked, else the newest Sonnet shown, else the most used shown. Null under two shown. */
177export function baselineOf(l: Learned, picked: string | undefined): ModelCost | null {
178 const shown = l.models.filter(m => m.shown)
179 if (shown.length < 2) return null
180 return shown.find(m => m.id === picked) ?? shown.find(m => m.family === 'sonnet') ?? [...shown].sort((a, b) => b.responses - a.responses)[0]!
181}
182
183/** The names the baseline buttons show: the family alone, unless two versions of it are shown. */
184export function shortNames(models: readonly ModelCost[]): Map<string, string> {
185 const count = (f: Family) => models.filter(m => m.family === f).length
186 return new Map(models.map(m => [m.id, m.family !== 'other' && count(m.family) === 1 ? m.name.replace(/ [\d.]+$/, '') : m.name]))
187}
188
189export type Spend = {
190 /** % of the weekly limit used. */
191 week: number
192 /** Points per shown model, split by effort (largest first). */
193 models: Array<{ cost: ModelCost; pts: number; effort: Array<[string, number]> }>
194 /** Recorded here, but by models whose cost isn't known yet. */
195 unsplit: number
196 /** Used where this computer wasn't watching. */
197 away: number
198}
199
200/** This week's points by model and effort, from the responses since the reset and the learned weights. */
201export function spend(week: Model, l: Learned, steps: readonly RangeStep[], now: number): Spend {
202 const start = week.resetsAt - 7 * DAY
203 const models = (l.perWeek ? l.models.filter(m => m.shown) : []).map(cost => {
204 const by = new Map<string, number>()
205 for (const s of steps) if (s[1] === cost.id && s[0] > start && s[0] <= now) by.set(s[2] || 'default', (by.get(s[2] || 'default') ?? 0) + s[3] / 1e6 * cost.w / l.perWeek!)
206 const effort = [...by].sort((a, b) => b[1] - a[1])
207 return { cost, pts: effort.reduce((s, e) => s + e[1], 0), effort }
208 }).filter(m => m.pts > 0)
209 const recorded = usedBetween(week.increments.filter(i => !i.hole), start, now)
210 const split = models.reduce((s, m) => s + m.pts, 0)
211 return { week: week.pct, models, unsplit: Math.max(0, recorded - split), away: Math.max(0, week.pct - recorded) }
212}
213
214// ---- words ----
215
216const comma = (n: number) => String(Math.round(n)).replace(/\B(?=(\d{3})+$)/, ',')
217const times = (x: number) => (x >= 1 ? x.toFixed(1) : x.toFixed(2)) + '×'
218const one = (n: number) => n.toFixed(1).replace(/\.0$/, '')
219
220/** A model's cost against the baseline, and its likely range. */
221export function ratio(m: ModelCost, b: ModelCost): { x: string; range: string } {
222 const x = m.w / b.w, err = Math.hypot(m.rel, b.rel)
223 return { x: times(x), range: `${times(Math.max(0, x * (1 - err)))}–${times(x * (1 + err))}` }
224}
225
226const responsesText = (m: ModelCost) =>
227 `${comma(m.responses)} ${m.responses === 1 ? 'response' : 'responses'}${m.sub === m.responses && m.sub > 0 ? ', all subagents' : m.sub > m.responses / 2 ? ', mostly subagents' : ''}`
228
229/** The tooltip on the card's ⓘ. */
230export const MODELS_INFO = 'Cost: how much of your limit each model uses token for token, compared with the model you pick. Learned from your history; shown once it’s within ±20%. This week: points of the weekly limit that went to each model, by effort.'
231
232/** The card in words, for a reader that can't see it and for surfaces without drawings. */
233export function modelsText(l: Learned, b: ModelCost | null, sp: Spend | null): string[] {
234 const rows = l.models.map(m => {
235 if (!m.shown) return `${m.name}: learning, ${responsesText(m)}${m.more ? `, about ${comma(m.more)} more to go` : ''}.`
236 if (!b) return `${m.name}: known, ${responsesText(m)}.`
237 if (m === b) return `${m.name}: 1×, the baseline.`
238 const r = ratio(m, b)
239 return `${m.name}: ${r.x} ${b.name}, likely ${r.range}.`
240 })
241 if (sp) {
242 rows.push(`This week ${Math.round(sp.week)}% used: ${[
243 ...sp.models.map(m => `${m.cost.name} ${one(m.pts)} (${m.effort.map(([e, v]) => `${e} ${one(v)}`).join(', ')})`),
244 ...(sp.unsplit ? [`not split yet ${one(sp.unsplit)}`] : []),
245 `not recorded ${one(sp.away)}`,
246 ].join(', ')}.`)
247 }
248 return rows
249}
250
251// ---- drawing ----
252
253/** Lower effort draws lighter within its model's colour. */
254const SHADE: Record<string, number> = { low: 0.35, medium: 0.55, high: 0.78, xhigh: 0.9, max: 1 }
255const shade = (e: string) => SHADE[e] ?? 0.7
256const colorOf = (f: Family) => (f === 'other' ? C.dim : C[f])
257
258type Txt = { size: number; weight?: number; fill?: string; anchor?: string; mono?: boolean }
259const t = (x: number | string, y: number, s: string, o: Txt) =>
260 `<text x="${typeof x === 'number' ? x.toFixed(1) : x}" y="${y.toFixed(1)}" text-anchor="${o.anchor ?? 'start'}" style="fill:${o.fill ?? C.fg}" font-family="${o.mono ? MONO : FONT}" font-size="${o.size}px" font-weight="${o.weight ?? 400}">${esc(s)}</text>`
261/** About how wide a text runs. */
262const tw = (s: string, size: number, _mono = false) => s.length * size * 0.6
263const dot = (x: number, y: number, fill: string, size = 10, opacity = 1) =>
264 `<rect x="${x}" y="${(y - size / 2).toFixed(1)}" width="${size}" height="${size}" rx="${size * 0.3}" style="fill:${fill}"${opacity < 1 ? ` fill-opacity="${opacity}"` : ''}/>`
265const rule = (y: number) => `<line x1="0" x2="100%" y1="${y}" y2="${y}" style="stroke:${C.dim}" stroke-opacity="0.25"/>`
266const LABEL = 11
267
268/** A span in words: "14 days", "9 hours", "40 minutes". */
269const spanText = (h: number) => {
270 const [n, unit] = h >= 48 ? [Math.round(h / 24), 'day'] : h >= 1 ? [Math.round(h), 'hour'] : [Math.max(1, Math.round(h * 60)), 'minute']
271 return `${n} ${unit}${n === 1 ? '' : 's'}`
272}
273
274/** The card's head: "Models" and how long the costs were learned from; its info circle is drawn by `withInfo`, which opens the tooltip over the rows. */
275export function modelsHead(l: Learned, now: number, pal: Palettes = DEFAULT_PALETTES): { svg: string; height: number; info: InfoSpot } {
276 const size = 1.2 * 16, y = size, r = 8
277 const learned = l.since === null ? 'learning' : `learned from ${spanText((now - l.since) / HOUR)}`
278 const height = Math.ceil(y + 8)
279 return {
280 svg: drawing(t(0, y, 'Models', { size, weight: 500 }) + t('100%', y, learned, { size: 12, fill: C.dim, anchor: 'end' }), height, pal),
281 height,
282 info: { cx: tw('Models', size) * 0.95 + 14, cy: y - size * 0.32, r },
283 }
284}
285
286/** The cost rows, `width` wide: each model's cost against the baseline, or a meter while it learns. */
287export function modelsCosts(l: Learned, b: ModelCost | null, width: number, pal: Palettes = DEFAULT_PALETTES): string {
288 let s = '', y = 0
289 // ---- costs ----
290 l.models.forEach((m, i) => {
291 if (i > 0) s += rule(y)
292 const top = y + (i > 0 ? 11 : 4), name = top + 13, meta = name + 19
293 const learning = !m.shown
294 s += dot(0, name - 4.5, colorOf(m.family)) + t(18, name, m.name, { size: 15, weight: 500 })
295 const isBase = !!b && m === b
296 if (isBase) {
297 const x = 18 + tw(m.name, 15) + 10
298 s += `<rect x="${x.toFixed(1)}" y="${(name - 12).toFixed(1)}" width="${(tw('baseline', 11, true) + 12).toFixed(1)}" height="17" rx="4" style="fill:${C.dim}" fill-opacity="0.14"/>`
299 + t(x + 6, name, 'baseline', { size: 11, weight: 500, mono: true })
300 }
301 let fig: string, right: string
302 if (learning) { fig = ''; right = 'not sure enough yet' }
303 else if (!b) { fig = ''; right = 'compares once a second model shows' }
304 else if (isBase) { fig = '1×'; right = 'the others compare to this' }
305 else { const r = ratio(m, b); fig = r.x; right = `likely ${r.range}` }
306 if (learning) s += t('100%', name, 'Learning', { size: 12, mono: true, fill: C.over, anchor: 'end' })
307 else if (fig) s += t('100%', name, fig, { size: 15, weight: 500, mono: true, anchor: 'end' })
308 else s += t('100%', name, 'Known', { size: 12, mono: true, fill: C.dim, anchor: 'end' })
309 s += t(0, meta, responsesText(m), { size: 12.5, fill: C.dim }) + t('100%', meta, right, { size: 12.5, fill: C.dim, anchor: 'end' })
310 y = meta + 5
311 if (learning) {
312 // the meter runs from ±100% (nothing known) to ±20% (shown)
313 const p = isFinite(m.rel) ? Math.max(0.04, Math.min(1, (1 - Math.min(m.rel, 1)) / (1 - SHOW_WITHIN))) : 0.04
314 const note = `shows at ±20% · ${m.more ? `about ${comma(m.more)} more responses` : 'needs more responses'}`
315 // the text sits at the right on a backing of the card's colour, as wide as the text and a 16px gap:
316 // the track runs under it and stops short of it, however wide the frame is drawn
317 const noteW = tw(note, 11.5, true) * 1.05, gapW = 16
318 const room = Math.max(0.2, 1 - (noteW + gapW) / width)
319 const my = y + 11
320 s += `<rect x="0" y="${my - 3}" width="100%" height="6" rx="3" style="fill:${C.dim}" fill-opacity="0.14"/>`
321 + `<rect x="0" y="${my - 3}" width="${pct(room * p)}" height="6" rx="3" style="fill:${C.over}"/>`
322 + at(1, `<rect x="${-(noteW + gapW).toFixed(1)}" y="${my - 8}" width="${(noteW + gapW).toFixed(1)}" height="16" style="fill:${C.card}"/>`
323 + t(0, my + 4, note, { size: 11.5, mono: true, fill: C.dim, anchor: 'end' }))
324 y = my + 9
325 }
326 })
327 if (!l.models.length) {
328 s += t(0, 16, 'No responses recorded yet. Costs are learned from the responses your sessions get.', { size: 12.5, fill: C.dim })
329 y = 24
330 }
331 return drawing(s, y + 4, pal)
332}
333
334/** This week's points: a bar split by model and effort, each model's efforts as chips, and what isn't split or wasn't recorded. */
335export function modelsSpend(sp: Spend, width: number, pal: Palettes = DEFAULT_PALETTES): string {
336 let s = '', y = 14
337 const head = `This week · ${Math.round(sp.week)}% of limit used`, sub = 'points of the weekly limit, by model and effort'
338 s += t(0, y, head, { size: LABEL, weight: 500, mono: true, fill: C.dim })
339 if (head.length * LABEL * 0.6 + tw(sub, 12) + 16 <= width) s += t('100%', y, sub, { size: 12, fill: C.dim, anchor: 'end' })
340 else { y += 17; s += t(0, y, sub, { size: 12, fill: C.dim }) }
341 y += 10
342 const total = Math.max(sp.week, sp.models.reduce((a, m) => a + m.pts, 0) + sp.unsplit + sp.away, 0.0001)
343 const segs = [
344 ...sp.models.flatMap(m => m.effort.map(([e, v]) => ({ fill: colorOf(m.cost.family), o: shade(e), v }))),
345 ...(sp.unsplit ? [{ fill: C.over, o: 0.45, v: sp.unsplit }] : []),
346 ]
347 let f = 0, bar = `<rect x="0" y="0" width="100%" height="22" style="fill:${C.dim}" fill-opacity="0.12"/>`
348 for (const g of segs) {
349 bar += `<rect x="${pct(f)}" y="0" width="${pct(g.v / total)}" height="22" style="fill:${g.fill}" fill-opacity="${g.o}"/>`
350 if (f > 0) bar += `<line x1="${pct(f)}" x2="${pct(f)}" y1="0" y2="22" style="stroke:${C.card}"/>`
351 f += g.v / total
352 }
353 if (sp.away) bar += `<rect x="${pct(f)}" y="0" width="${pct(sp.away / total)}" height="22" fill="url(#away)"/>` + (f > 0 ? `<line x1="${pct(f)}" x2="${pct(f)}" y1="0" y2="22" style="stroke:${C.card}"/>` : '')
354 s += `<defs><pattern id="away" width="5" height="22" patternUnits="userSpaceOnUse"><rect width="2" height="22" style="fill:${C.dim}" fill-opacity="0.55"/></pattern>`
355 + `<clipPath id="stack"><rect x="0" y="0" width="100%" height="22" rx="5"/></clipPath></defs>`
356 + `<svg x="0" y="${y}" width="100%" height="22" overflow="hidden"><g clip-path="url(#stack)">${bar}</g></svg>`
357 y += 22 + 8
358
359 // each model: its points, then a chip per effort, largest first
360 for (const m of sp.models) {
361 s += rule(y)
362 const hy = y + 20
363 s += dot(0, hy - 4.5, colorOf(m.cost.family)) + t(17, hy, m.cost.name, { size: 13.5, weight: 500 }) + t('100%', hy, one(m.pts), { size: 13, weight: 500, mono: true, anchor: 'end' })
364 let cx = 17, cy = hy + 9
365 for (const [e, v] of m.effort) {
366 const a = one(v), share = `${Math.round(v / (m.pts || 1) * 100)}%`
367 const w = 8 + 8 + 6 + tw(e, 12.5) + 6 + tw(a, 12, true) + 6 + tw(share, 11.5, true) + 8
368 if (cx > 17 && cx + w > width) { cx = 17; cy += 28 }
369 let x = cx + 8
370 s += `<rect x="${cx.toFixed(1)}" y="${cy}" width="${w.toFixed(1)}" height="22" rx="5" style="fill:${C.dim}" fill-opacity="0.12"/>`
371 s += dot(x, cy + 11, colorOf(m.cost.family), 8, shade(e)); x += 14
372 s += t(x, cy + 15.5, e, { size: 12.5 }); x += tw(e, 12.5) + 6
373 s += t(x, cy + 15.5, a, { size: 12, weight: 500, mono: true }); x += tw(a, 12, true) + 6
374 s += t(x, cy + 15.5, share, { size: 11.5, mono: true, fill: C.dim })
375 cx += w + 6
376 }
377 y = cy + 22 + 7
378 }
379
380 // what isn't split by model: side by side when they fit, else one under the other
381 const legend = [
382 ...(sp.unsplit ? [{ label: 'Not split yet', v: sp.unsplit, sw: dot(0, 0, C.over, 10, 0.45) }] : []),
383 { label: 'Not recorded', v: sp.away, sw: `<rect x="0" y="-5" width="10" height="10" rx="3" fill="url(#away)" style="stroke:${C.dim}" stroke-opacity="0.55"/>` },
384 ]
385 const cols = legend.length > 1 && width >= 2 * 150 + 14 ? 2 : 1
386 if (sp.models.length) s += rule(y)
387 y += 6
388 legend.forEach((g, i) => {
389 const col = i % cols, row = Math.floor(i / cols), ly = y + 14 + row * 22
390 const x0 = col / cols, x1 = (col + 1) / cols
391 s += at(x0, `<g transform="translate(${col ? 7 : 0},${ly - 4.5})">${g.sw}</g>` + t(col ? 24 : 17, ly, g.label, { size: 13, fill: C.dim }))
392 s += at(x1, t(col < cols - 1 ? -7 : 0, ly, one(g.v), { size: 13, weight: 500, mono: true, fill: C.dim, anchor: 'end' }))
393 })
394 y += 14 + Math.ceil(legend.length / cols) * 22 - 14
395
396 if (!sp.models.length) {
397 const note = `No model’s cost is known yet, so this week can’t be split. The ${one(sp.unsplit)} points recorded here will split once a model’s figure shows.`
398 const lines = wrapAt(note, width - 26, 13)
399 const H = lines.length * 19 + 16
400 y += 14
401 const dash = `style="stroke:${C.dim}" stroke-opacity="0.45" stroke-dasharray="4 4" fill="none"`
402 s += `<rect x="0.5" y="${y + 0.5}" width="99.9%" height="${H}" rx="8" ${dash}/>`
403 + lines.map((ln, i) => t(12, y + 22 + i * 19, ln, { size: 13, fill: C.dim })).join('')
404 y += H + 2
405 }
406 return drawing(s, y + 6, pal)
407}
408
409/** Text broken into lines that fit `width` at `size`. */
410function wrapAt(s: string, width: number, size: number): string[] {
411 const lines: string[] = []
412 for (const word of s.split(' ')) {
413 const last = lines[lines.length - 1]
414 if (last !== undefined && tw(`${last} ${word}`, size) <= width) lines[lines.length - 1] = `${last} ${word}`
415 else lines.push(word)
416 }
417 return lines
418}
419
420/** Steps older than the readings are kept are dropped, and repeats merged. */
421export function mergeSteps(lists: ReadonlyArray<readonly RangeStep[]>, now: number): RangeStep[] {
422 const seen = new Set<string>(), out: RangeStep[] = []
423 for (const list of lists) {
424 for (const s of list) {
425 const id = `${s[0]}:${s[1]}:${s[3]}`
426 if (s[0] < now - KEEP || seen.has(id)) continue
427 seen.add(id)
428 out.push(s)
429 }
430 }
431 return out.sort((a, b) => a[0] - b[0])
432}
433types/index.d.ts 35 lines1/** One rate-limit reading: when, which window (0 = 5-hour, 1 = weekly), % used, reset time (ms). */
2export type RangeReading = [t: number, kind: 0 | 1, pct: number, resetsAt: number]
3
4/** A stretch of time a session on this computer was open and taking readings: [from, to] in ms. */
5export type RangeWatch = [from: number, to: number]
6
7/**
8 * One model response seen here, subagents' included: when, the model's id, the effort it was asked for
9 * ('' for none), its tokens weighted by that model's price ratios, and 1 when a subagent made it.
10 */
11export type RangeStep = [t: number, model: string, effort: string, units: number, sub: 0 | 1]
12
13/**
14 * What the weekly average covers (a window of n units, or since the reset), whether reset countdowns show
15 * the finer unit, and the model the others' costs compare to (a model id; absent, the default).
16 */
17export type RangeSettings = { mode: 'window' | 'reset'; n: number; unit: 'h' | 'd' | 'w'; fine: boolean; baseline?: string }
18
19/** The colours the card draws with, as hex. */
20export type Palette = {
21 card: string; veil: string; fg: string; dim: string; off: string
22 barTop: string; barBottom: string; limit: string; over: string; under: string; bad: string
23 /** A colour per model family. */
24 fable: string; opus: string; sonnet: string; haiku: string
25}
26
27/** The card's palettes for a dark and a light appearance, derived from the theme, and which one the theme itself is. */
28export type Palettes = { dark: Palette; light: Palette; own: 'dark' | 'light' }
29
30declare module 'claude-code' {
31 interface PluginState {
32 'token-range-monitor': { readings: RangeReading[]; seen: RangeWatch[]; steps: RangeStep[]; settings: RangeSettings; tick: number; palettes: Palettes }
33 }
34}
35