Rate-limit pace for Claude Code's 5-hour and weekly windows: usage against elapsed time, plain-language forecast, per-model quotas and burn-up history. A band…

Will you run out before the reset? WeekToken puts what you have used of Claude Code's rate limits next to how much of the window has passed, and tells you whether you are ahead of the clock or behind it. It covers the 5-hour window, the 7-day window and per-model weekly quotas such as Fable.

/weektoken pace | Burn-up history |
|---|---|
![]() | ![]() |
In the terminal, the band sits above the prompt and /weektoken docks beside the conversation:

A band above the prompt: ❮ 7-day ❯ · 64% used · 71% elapsed, a progress bar that stretches with the band, plus Details and Hide (in the desktop app, Hide is the close button at the band's right end).
/weektoken pane always show the same quota: switching in either one switches both./weektoken show, or ⊕ Show band at the bottom of the pane.The /weektoken pane:
Colors follow pace, not the raw percentage: the over-pace thresholds are Claude Code's own rate-limit warning calibration (five_hour: 0.9/0.72; seven_day: 0.25/0.15, 0.5/0.35, 0.75/0.6) and tighten as the window goes on.
| Source | Content |
|---|---|
| The session's own rate limits | The 5-hour and 7-day windows, updated after every reply |
cachedUsageUtilization in ~/.claude.json | Claude Code's own usage cache, including per-model quotas (Fable and others) |
Anthropic's usage endpoint, through Claude Code (only when you press ↻ Refresh); local claude -p --no-session-persistence /usage if that isn't available | Fresh per-model quotas; also the 5-hour and 7-day windows when there is no local reading from the last 10 minutes (a new session before its first reply) |
~/.weektoken/samples.jsonl (optional) | History from the WeekToken macOS app, imported read-only |
Samples are kept in the mod's own cross-session store, up to 8000; the oldest go first. Sessions open at the same time merge their samples and keep one copy of each reading. Without history to import from the WeekToken macOS app, history starts when you install the mod, since the API only reports current values.
A quota you are not using does not change, so old data still gives a valid pace.
| Command | Does |
|---|---|
/weektoken | Opens the pace pane |
/weektoken hide | Hides the band (kept across sessions) |
/weektoken show | Shows it again |
Nothing to configure. Two environment variables override the defaults; set them in your shell profile (e.g. ~/.zshrc) and start a new session:
| Variable | Effect |
|---|---|
WEEKTOKEN_LANG | zh or en forces the interface language. By default it follows Claude Code's language setting, then the system language |
WEEKTOKEN_HISTORY | Path of the WeekToken macOS app's history file; an empty string turns the import off |
CLAUDE_MODS_DISABLE | all, or a comma list containing weektoken, turns the mod off entirely |
Mods run with the same access as Claude Code itself and are not sandboxed. This is everything WeekToken does besides drawing its own band and pane.
Nothing WeekToken reads is sent anywhere. It makes one kind of request, only when you press ↻ Refresh: a GET to Anthropic's usage endpoint (https://api.anthropic.com/api/oauth/usage), the same one Claude Code's own /usage reads. The request goes through Claude Code, which adds the login it already has; the mod never sees the credential, only an opaque handle that Claude Code honors for Anthropic's own hosts alone. No body is sent, no model is called and no quota is used.
Without a subscription login (an API key, another provider), or if that request fails, Refresh runs Claude Code's /usage command instead (below), and Claude Code makes the same request itself.
Every command is fixed text, and none goes through a shell.
| Command | When | Why |
|---|---|---|
claude -p --no-session-persistence /usage | Only when you press ↻ Refresh and the usage endpoint above can't be used | A fallback source for per-model quotas such as Fable. The mod looks for claude on your PATH, then in ~/.local/bin, ~/.claude/local, /opt/homebrew/bin and /usr/local/bin. It saves no session. |
defaults read -g AppleLanguages | At session start, and when you change Claude Code's language | Reads the macOS language when Claude Code's own language setting is not set (macOS only). |
tail -n 8000 ~/.weektoken/samples.jsonl | At session start and every 10 minutes, only if the file exists | Imports history from the WeekToken macOS app. |
perl, with one fixed pattern and no shell | Only when ~/.claude.json is over 4 MiB and can't be read directly | Extracts just the cachedUsageUtilization entry from that file and prints it back to the mod; nothing else is read or sent. |
~/.claude.json, Claude Code's own file. Only its cachedUsageUtilization entry is used: Claude Code's usage cache, which includes per-model quotas. The file also holds account details; those are not used or kept. It is read at session start, every 5 minutes when the file has changed, and on Refresh.~/.weektoken/samples.jsonl, or the file named by WEEKTOKEN_HISTORY, if it exists.plugin.json, at session start, for the version shown after the author credit in the pane.HOME and PATH (to find the files and claude above), LANG, LC_ALL and LC_MESSAGES (the language), WEEKTOKEN_LANG, WEEKTOKEN_HISTORY and CLAUDE_MODS_DISABLE. It reads no credentials.language setting.In the mod's own store on your machine: samples (up to 8000) and their version stamp, when each model was last used, whether the band is shown and which quota it shows, the quota selected in the pane, the modification time of the imported history file, and whether the welcome notice was shown. The interface language is detected each session, not stored.
| Hook | What it does |
|---|---|
session.start | Registers /weektoken, loads the stored samples, reads the sources above and starts the 1-, 5- and 10-minute timers |
session.measure | Records the session's 5-hour and 7-day figures when they change |
turn.complete | Records the time and model of each reply, to tell when a quota was last used |
command.run, /weektoken only | Answers its own command: opens the pane, or shows or hides the band |
ui.render, AbovePrompt | Draws the band, then whatever other plugins or Claude Code draw there, below it |
ui.render, the weektoken pane only | Draws the /weektoken pane |
ui.press | Notes presses on its own pane and passes every press on unchanged |
ui.focus | Passes the event on unchanged. On its own pane in the desktop app, when a click only moved the focus to a button (the first click on an unfocused pane), it runs that button's action |
config.set, language only | Passes the change on unchanged, then detects the interface language again |
It changes no settings or permissions, and it leaves other plugins' events as they are.
From the Claude plugin directory: run /plugin directory in Claude Code, or add it under Customize > Plugins > Discover on claude.ai, which brings it to Claude Code at the next session start. Or from this repository, inside Claude Code: /plugin marketplace add 3dnow/claude-mods, then /plugin install weektoken@mj0111-mods. Then open a new session, or run /reload-plugins.
It runs wherever Claude Code does: the terminal, the IDE extensions and the desktop app's Code tab. Like every mod, it does not run in chat or Cowork.
"env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } to ~/.claude/settings.json firstclaude plugin validate .
claude plugin test
mj0111 · @mj0011sec on X
hooks/register.tsx 1134 lines1// WeekToken mod
2//
3// Pace: whether usage is running fast or slow relative to elapsed time.
4// · A band above the prompt: ‹ name › · used · elapsed + a bar that scales with width. Shows the tightest quota by default;
5// the prev/next arrows beside the name switch quota and remember it; once hidden it takes no row, restore from the pane.
6// · /weektoken opens the pane: quota arrows beside the title, pace rings with used/elapsed/reset, commentary, burn-up
7// Data: the session's own rateLimits (5-hour / 7-day, updated on every reply) + Claude Code's local usage cache
8// ~/.claude.json (incl. per-model quotas such as Fable) + history samples from WeekToken for macOS (read-only import).
9
10import { atom, read, update } from 'claude-code'
11import type { Register } from 'claude-code'
12
13import type { Activity, BandMode, BandShow, Note, PaneView, Sample } from '../types'
14import { L, langFromAppleLanguages, langFromEnv, langFromSetting, setLang, type Lang } from './i18n.ts'
15import * as P from './pace.ts'
16import { identity, identityText, statusText, STATUS } from './theme.ts'
17import { bandBarSvg, burnUpSvg, creditSvg, ringsSvg } from './svg.ts'
18
19const PANE = 'weektoken'
20const DEFAULT_VIEW: PaneView = { key: null, tab: 'pace', range: 'current', offset: 0 }
21
22const samplesA = atom({ plugin: 'weektoken', key: 'samples' } as const, [] as Sample[])
23const viewA = atom({ plugin: 'weektoken', key: 'view' } as const, DEFAULT_VIEW)
24const noteA = atom({ plugin: 'weektoken', key: 'note' } as const, null as Note)
25const bandA = atom({ plugin: 'weektoken', key: 'band' } as const, 'open' as BandMode)
26const confirmHideA = atom({ plugin: 'weektoken', key: 'confirmHide' } as const, false)
27// Quota being viewed (shared by band and pane; written together with view.key on switch, see selectQuota)
28const bandKeyA = atom({ plugin: 'weektoken', key: 'bandKey' } as const, null as string | null)
29const bandShowA = atom({ plugin: 'weektoken', key: 'bandShow' } as const, null as BandShow | null)
30const activityA = atom({ plugin: 'weektoken', key: 'activity' } as const, { byModel: {} } as Activity)
31// Whether the pane is open: the band button reads "Close" or "Details" from this
32const paneOpenA = atom({ plugin: 'weektoken', key: 'paneOpen' } as const, false)
33/** Loaded version: read from our own plugin.json at session start, drawn after the credit */
34const versionA = atom({ plugin: 'weektoken', key: 'version' } as const, '')
35const AUTHOR = { x: 'mj0011sec' }
36
37// CLAUDE_MODS_DISABLE=all, or weektoken in the comma list: all hooks pass through and no command is registered
38let disabled = false
39async function readDisabled($: any): Promise<boolean> {
40 const raw = ((await $.env.get('CLAUDE_MODS_DISABLE')) ?? '').toLowerCase()
41 disabled = raw.split(',').map((v: string) => v.trim()).some((v: string) => v === 'all' || v === 'weektoken')
42 return disabled
43}
44const langA = atom({ plugin: 'weektoken', key: 'lang' } as const, 'en' as Lang)
45
46// ======================================================================
47// Model: derive each quota's display state, the bottleneck and the pane's selected quota from samples
48// ======================================================================
49
50type QuotaRow = {
51 key: string
52 obs?: P.Obs
53 at?: number
54 len: P.WindowLength
55 d: P.Display
56 name: string
57 full: string
58}
59
60type Model = { keys: string[]; rows: Record<string, QuotaRow>; band: string | null; selected: string | null }
61
62function buildModel(samples: readonly Sample[], view: PaneView, now: number, bandKey: string | null = null): Model {
63 const keys = P.discoveredKeys(samples)
64 const rows: Record<string, QuotaRow> = {}
65 for (const k of keys) {
66 const lo = P.latestObs(samples, k)
67 const len = P.inferWindow(k, samples)
68 rows[k] = {
69 key: k,
70 obs: lo?.obs,
71 at: lo?.at,
72 len,
73 // No downgrade by data age: unused means unchanged, so old data still yields a pace (the footer says how long it's been idle)
74 d: P.display(lo?.obs, len.seconds, now),
75 name: P.displayName(k, lo?.obs.n),
76 full: P.fullName(k, lo?.obs.n),
77 }
78 }
79 const bottleneck = P.pickBottleneck(keys.map(k => ({ key: k, pace: rows[k].d.kind === 'pace' ? rows[k].d.pace : null })))
80 // Band: show the quota picked with the band arrows, otherwise the tightest one
81 const band = bandKey && rows[bandKey] ? bandKey : bottleneck
82 // Pane and band show the same quota (both written on switch); if none was picked, both show the tightest
83 const selected = view.key && rows[view.key] ? view.key : (band ?? keys[0] ?? null)
84 return { keys, rows, band, selected }
85}
86
87/** What the band draws, rounded to display precision; null if it can't be computed (band falls back to native rendering) */
88function bandShowOf(samples: readonly Sample[], bandKey: string | null, now: number): BandShow | null {
89 if (!samples.length) return null
90 const m = buildModel(samples, DEFAULT_VIEW, now, bandKey)
91 const row = m.band ? m.rows[m.band] : undefined
92 if (!row) return null
93 // Last observed window is past its reset time: that usage is void, so the band says "reset" until the next reply brings a new window
94 const ended = row.d.kind === 'usageOnly' && row.d.reason.kind === 'windowEnded'
95 const used = ended ? null : P.displayUsed(row.d)
96 return {
97 key: row.key,
98 ...(row.obs?.n ? { n: row.obs.n } : {}),
99 ...(ended ? { ended: true } : {}),
100 keys: m.keys,
101 used: used == null ? null : Math.round(used * 100),
102 elapsed: row.d.kind === 'pace' ? Math.round(row.d.pace.elapsed * 100) : null,
103 status: P.displayStatus(row.d),
104 }
105}
106
107// ======================================================================
108// Data collection
109// ======================================================================
110
111type RateLimitLike = { kind: string; percentUsed: number; resetsAt?: string }
112
113async function recordRateLimits($: any, list: readonly RateLimitLike[] | undefined, t: number): Promise<boolean> {
114 const w: Record<string, P.Obs> = {}
115 for (const rl of list ?? []) {
116 if (rl.kind !== 'five_hour' && rl.kind !== 'seven_day') continue
117 const r = rl.resetsAt ? Date.parse(rl.resetsAt) : NaN
118 w[rl.kind] = { u: Math.min(Math.max(rl.percentUsed, 0), 100), ...(Number.isFinite(r) ? { r } : {}) }
119 }
120 if (!Object.keys(w).length) return false
121 return addSample($, { t, w })
122}
123
124async function addSample($: any, s: Sample): Promise<boolean> {
125 let changed = false
126 await update($, samplesA, list => {
127 const next = P.appendSample(list ?? [], s)
128 changed = next !== list
129 return next
130 })
131 if (changed) {
132 await persistSamples($)
133 await refreshBand($)
134 }
135 return changed
136}
137
138/**
139 * Merge with what's stored before writing back: with several sessions open, each writing its full list would clobber samples another session just recorded.
140 * Other sessions' samples are merged into this one too. Read-merge-write isn't atomic; an occasional collision just lands one write later, since each session merges again on its next write.
141 */
142async function persistSamples($: any): Promise<void> {
143 const stored = await $.store.get('samples')
144 const mine = (await read($, samplesA)) ?? []
145 let merged = P.mergeSamples(Array.isArray(stored) ? (stored as Sample[]) : [], mine)
146 try {
147 await $.store.set('samples', merged)
148 } catch {
149 // Storage is size-capped (4 MiB per plugin): if the write fails, drop the oldest quarter and retry once
150 merged = merged.slice(Math.floor(merged.length / 4))
151 await $.store.set('samples', merged)
152 }
153 // Small revision tag: other sessions check it every minute and skip reading the full samples if unchanged
154 lastSeenRev = `${merged.length}:${merged[merged.length - 1]?.t ?? 0}`
155 await $.store.set('samplesRev', lastSeenRev)
156 if (merged.length !== mine.length) await update($, samplesA, () => merged)
157}
158
159let lastSeenRev: string | null = null
160
161/** Merge samples other sessions stored into this session (no write-back); touch state only if something is new */
162async function adoptStoredSamples($: any): Promise<boolean> {
163 const rev = await $.store.get('samplesRev')
164 if (typeof rev === 'string' && rev === lastSeenRev) return false
165 const stored = await $.store.get('samples')
166 if (!Array.isArray(stored)) return false
167 lastSeenRev = typeof rev === 'string' ? rev : null
168 const mine = (await read($, samplesA)) ?? []
169 const merged = P.mergeSamples(stored as Sample[], mine)
170 if (merged.length === mine.length) return false
171 await update($, samplesA, () => merged)
172 return true
173}
174
175const mergeActivity = (a: Activity, b: Activity): Activity => {
176 const byModel: Record<string, number> = { ...(a.byModel ?? {}) }
177 for (const [k, t] of Object.entries(b.byModel ?? {})) byModel[k] = Math.max(byModel[k] ?? 0, t)
178 return { any: Math.max(a.any ?? 0, b.any ?? 0) || undefined, byModel }
179}
180
181/** Record a reply (incl. subagents): any model counts toward 5-hour / 7-day; a model also counts toward its family's per-model quota */
182async function recordActivity($: any, model: string | undefined): Promise<void> {
183 const now = await $.clock.now()
184 const fam = P.modelFamily(model)
185 await update($, activityA, a => mergeActivity(a ?? { byModel: {} }, { any: now, byModel: fam ? { [fam]: now } : {} }))
186 // Other sessions write too: merge with the stored value before saving, keeping the later time
187 const stored = ((await $.store.get('activity')) as Activity | undefined) ?? { byModel: {} }
188 await $.store.set('activity', mergeActivity(stored, await read($, activityA)))
189}
190
191/** A tree that draws nothing: null, empty string, or only empty Box/Text */
192function isBlank(node: any): boolean {
193 if (node == null || node === false || node === '') return true
194 if (Array.isArray(node)) return node.every(isBlank)
195 if (typeof node === 'string') return node.trim() === ''
196 if (typeof node === 'object' && (node.type === 'Box' || node.type === 'Text')) return isBlank(node.children ?? node.props?.children)
197 return false
198}
199
200/** Recompute what the band draws; skip the write if unchanged, so no redraw */
201async function refreshBand($: any): Promise<void> {
202 const next = bandShowOf((await read($, samplesA)) ?? [], (await read($, bandKeyA)) ?? null, await $.clock.now())
203 const cur = (await read($, bandShowA)) ?? null
204 if (JSON.stringify(next) === JSON.stringify(cur)) return
205 await update($, bandShowA, () => next)
206}
207
208async function sampleSession($: any): Promise<boolean> {
209 const u = await $.session.usage()
210 return recordRateLimits($, u.rateLimits, await $.clock.now())
211}
212
213let cacheMtime = -1
214
215async function expandHome($: any, p: string): Promise<string> {
216 if (!p.startsWith('~/')) return p
217 const home = await $.env.get('HOME')
218 return home ? home + p.slice(1) : p
219}
220
221/** Claude Code's local usage cache (incl. per-model quotas); not re-parsed if the file is unchanged */
222async function sampleCache($: any, force = false): Promise<boolean> {
223 const path = await expandHome($, '~/.claude.json')
224 if (!(await $.fs.exists(path))) return false
225 const st = await $.fs.stat(path)
226 if (!force && st.mtimeMs === cacheMtime) return false
227 cacheMtime = st.mtimeMs
228 let text: string | null = null
229 try {
230 text = await $.fs.read(path)
231 } catch {
232 text = await extractCacheWithPerl($, path) // Read fails over 4 MiB: extract just the cache section
233 }
234 if (!text) return false
235 const s = P.parseClaudeJsonCache(text, await $.clock.now())
236 return s ? addSample($, s) : false
237}
238
239async function extractCacheWithPerl($: any, path: string): Promise<string | null> {
240 // -0777: slurp the whole file; program and regex are fixed text, the path is passed to perl as an argument, no shell
241 try {
242 const r = await $.process.run(['perl', '-0777', '-ne', 'print $1 if /"cachedUsageUtilization"\\s*:\\s*(\\{(?:[^{}"]++|"(?:\\\\.|[^"\\\\])*+"|(?1))*\\})/', path], { timeoutMs: 15_000 })
243 if (r.exitCode === 0 && r.stdout.trim().startsWith('{')) return `{"cachedUsageUtilization":${r.stdout.trim()}}`
244 } catch {}
245 return null
246}
247
248/** History from WeekToken for macOS (read-only); default location, override with WEEKTOKEN_HISTORY, empty string disables import. Skipped if the file is unchanged; returns the count newly imported */
249async function importHistory($: any): Promise<number> {
250 const file = ((await $.env.get('WEEKTOKEN_HISTORY')) ?? '~/.weektoken/samples.jsonl').trim()
251 if (!file) return 0
252 const path = await expandHome($, file)
253 if (!(await $.fs.exists(path))) return 0
254 const st = await $.fs.stat(path)
255 if ((await $.store.get('historyMtime')) === st.mtimeMs) return 0
256 const r = await $.process.run(['tail', '-n', '8000', path], { timeoutMs: 15_000 })
257 if (r.exitCode !== 0) return 0
258 const imported = P.parseSamplesJsonl(r.stdout, await $.clock.now())
259 let added = 0
260 await update($, samplesA, list => {
261 const merged = P.mergeSamples(list ?? [], imported)
262 added = merged.length - (list ?? []).length
263 return merged
264 })
265 await persistSamples($)
266 await $.store.set('historyMtime', st.mtimeMs)
267 if (added) await refreshBand($)
268 return added
269}
270
271async function loadState($: any): Promise<void> {
272 const stored = await $.store.get('samples')
273 const list = Array.isArray(stored) ? (stored as Sample[]) : []
274 await update($, samplesA, cur => P.mergeSamples(list, cur ?? []))
275 const v = (await $.store.get('view')) as { key?: string | null; bandKey?: string | null; pinned?: string | null } | undefined
276 // Band and pane show the same quota. Older versions stored them separately (the band's earlier in view.bandKey / view.pinned); on mismatch the band's wins
277 const bk = (await $.store.get('bandKey')) ?? v?.bandKey ?? v?.pinned ?? null
278 const k = typeof bk === 'string' ? bk : typeof v?.key === 'string' ? v.key : null
279 if (k) {
280 await update($, bandKeyA, () => k)
281 await update($, viewA, cur => ({ ...(cur ?? DEFAULT_VIEW), key: k }))
282 }
283 const act = (await $.store.get('activity')) as Activity | undefined
284 if (act && typeof act === 'object') await update($, activityA, cur => mergeActivity(cur ?? { byModel: {} }, act))
285 const band = await $.store.get('band')
286 // Older versions also had "collapsed" (mini); now always treated as shown
287 if (band === 'hidden' || band === 'open' || band === 'mini') await update($, bandA, () => (band === 'hidden' ? 'hidden' : 'open'))
288}
289
290/**
291 * Local claude's /usage: the only fresh source of per-model quotas (Fable etc.). A local command: no model call, no quota spent,
292 * no session record; it scans local sessions for the usage breakdown, about 10 s, so it only runs on manual refresh.
293 * The command is fixed text; claude is found by name. Processes started by the desktop app may not have it on PATH,
294 * so a few common install locations are appended to the existing PATH (env.PATH is used for program lookup, verified).
295 */
296async function usagePath($: any): Promise<string> {
297 const home = (await $.env.get('HOME')) ?? ''
298 const extra = home ? [`${home}/.local/bin`, `${home}/.claude/local`] : []
299 return [(await $.env.get('PATH')) ?? '/usr/bin:/bin', ...extra, '/opt/homebrew/bin', '/usr/local/bin'].join(':')
300}
301
302/** Run /usage and record a sample; ok = output received and recognized */
303async function sampleUsageCommand($: any): Promise<{ ok: boolean; changed: boolean }> {
304 const r = await $.process.run(['claude', '-p', '--no-session-persistence', '/usage'], { timeoutMs: 60_000, env: { PATH: await usagePath($) } })
305 if (r.exitCode !== 0) return { ok: false, changed: false }
306 const s = P.parseUsageCommand(r.stdout, await $.clock.now())
307 if (!s) return { ok: false, changed: false }
308 return { ok: true, changed: await addFresh($, s) }
309}
310
311const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
312
313/**
314 * The same figures /usage shows, straight from Anthropic's usage endpoint and through the host:
315 * $.session.authorize() answers a handle, and the engine attaches the session's own login to the request
316 * (first-party hosts only), so the mod never sees the credential. About half a second, where starting a whole
317 * `claude -p /usage` takes 7-9 s. Null without a subscription login or when the answer can't be read;
318 * refresh then falls back to the command.
319 */
320async function sampleUsageApi($: any): Promise<{ ok: boolean; changed: boolean } | null> {
321 const auth = await $.session.authorize()
322 if (!auth?.handle || auth.kind !== 'bearer') return null
323 const r = await $.http.fetch(USAGE_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
324 if (!r.ok) return null
325 let w: Record<string, P.Obs>
326 try { w = P.parseUtilization(JSON.parse(r.text)) } catch { return null }
327 if (!Object.keys(w).length) return null
328 return { ok: true, changed: await addFresh($, { t: await $.clock.now(), w }) }
329}
330
331/** Records a fresh account-wide reading. For 5-hour / 7-day the session's own values (second precision) win; use these only if there's no local reading within 10 minutes (new session, no reply yet) */
332async function addFresh($: any, s: Sample): Promise<boolean> {
333 const samples = (await read($, samplesA)) ?? []
334 const w: Record<string, P.Obs> = {}
335 for (const [k, o] of Object.entries(s.w)) {
336 if (P.isModelScoped(k)) w[k] = o
337 else if (k === 'five_hour' || k === 'seven_day') {
338 const lo = P.latestObs(samples, k)
339 if (!lo || s.t - lo.at > 10 * 60_000) w[k] = o
340 }
341 }
342 return Object.keys(w).length ? addSample($, { t: s.t, w }) : false
343}
344
345/** Manual refresh "in progress": kept in state so the button shows "Refreshing…"; treated as done after 90 s (subprocess timeout is 60 s) */
346const isBusy = (note: Note, now: number) => !!note?.busy && now - note.at < 90_000
347
348async function refreshAll($: any, manual: boolean): Promise<void> {
349 if (manual) {
350 const now = await $.clock.now()
351 if (isBusy(await read($, noteA), now)) return
352 await update($, noteA, () => ({ busy: true, at: now }))
353 }
354 try {
355 try { await sampleSession($) } catch {}
356 try { await sampleCache($, true) } catch {}
357 try { await importHistory($) } catch {}
358 if (!manual) return
359 // The usage endpoint first (about half a second); starting `claude -p /usage` only if that isn't available
360 let ok = false
361 try { ok = (await sampleUsageApi($))?.ok ?? false } catch {}
362 if (!ok) try { ok = (await sampleUsageCommand($)).ok } catch {}
363 // Nothing on success (footer and chart update themselves); notify only when /usage fails
364 if (!ok) $.ui.toast(L('没能运行 claude /usage:Fable 等分模型配额这次没更新', "Couldn't run claude /usage: per-model quotas such as Fable weren't updated"), { timeoutMs: 8_000 })
365 } finally {
366 if (manual) await update($, noteA, () => null)
367 }
368}
369
370// ======================================================================
371// UI language: WEEKTOKEN_LANG (zh / en) → Claude Code's language setting → system language (macOS preferred languages, then LC_ALL/LC_MESSAGES/LANG) → English
372// ======================================================================
373
374async function detectLang($: any): Promise<Lang> {
375 const forced = ((await $.env.get('WEEKTOKEN_LANG')) ?? '').trim().toLowerCase()
376 if (forced === 'zh' || forced === 'en') return forced
377 try {
378 const row = (await $.config.list()).find((r: { key: string }) => r.key === 'language')
379 const l = langFromSetting(row?.value)
380 if (l) return l
381 } catch {}
382 // macOS "system language" is the preferred-languages list; LANG is only the terminal env and may differ in processes started by the desktop app. Linux has no defaults, falls through to LANG
383 try {
384 const r = await $.process.run(['defaults', 'read', '-g', 'AppleLanguages'], { timeoutMs: 5_000 })
385 const l = r.exitCode === 0 ? langFromAppleLanguages(r.stdout) : null
386 if (l) return l
387 } catch {}
388 return langFromEnv(await $.env.get('LC_ALL')) ?? langFromEnv(await $.env.get('LC_MESSAGES')) ?? langFromEnv(await $.env.get('LANG')) ?? 'en'
389}
390
391/** Detect language; skip the write if unchanged (a write redraws band and pane). Returns whether it changed */
392async function refreshLang($: any): Promise<boolean> {
393 const l = await detectLang($)
394 setLang(l)
395 if ((await read($, langA)) === l) return false
396 await update($, langA, () => l)
397 return true
398}
399
400/** Sync language before drawing: after a module reload the variable resets to default, but the value in $.state remains */
401async function useLang($: any): Promise<void> {
402 setLang((await read($, langA)) ?? 'en')
403}
404
405// ======================================================================
406// Pane and band actions
407// ======================================================================
408
409async function openPane($: any): Promise<void> {
410 await $.ui.open({ id: PANE, title: 'WeekToken' })
411 await update($, paneOpenA, () => true)
412 await update($, viewA, v => ({ ...(v ?? DEFAULT_VIEW), explore: false }))
413}
414
415/** Band "Details": close the pane if it's open and in front, otherwise open it (or bring it to front). Asks the engine on press, not stored state */
416async function togglePane($: any): Promise<void> {
417 const pane = (await $.ui.panes()).find((p: { id: string; isShown: boolean }) => p.id === PANE)
418 if (pane?.isShown) {
419 await $.ui.close({ id: PANE })
420 await update($, paneOpenA, () => false)
421 } else await openPane($)
422}
423
424/** Sync whether the pane is actually open into state (session start, after reload) */
425async function syncPaneOpen($: any): Promise<void> {
426 const open = (await $.ui.panes()).some((p: { id: string }) => p.id === PANE)
427 if ((await read($, paneOpenA)) !== open) await update($, paneOpenA, () => open)
428}
429
430async function setView($: any, fn: (v: PaneView) => PaneView): Promise<void> {
431 await update($, viewA, v => fn(v ?? DEFAULT_VIEW))
432 const v = await read($, viewA)
433 await $.store.set('view', { key: v.key })
434}
435
436/**
437 * Switch quota: band and pane show the same one. Band arrows, pane side buttons and the burn-up dropdown all go through here; both switch and it's remembered.
438 * (Stored separately earlier because switching on the band made the pane's frame flicker; with the pace ring and bar now plain images it no longer does, so they're unified)
439 */
440async function selectQuota($: any, k: string): Promise<void> {
441 await update($, bandKeyA, () => k)
442 await update($, viewA, v => ({ ...(v ?? DEFAULT_VIEW), key: k, offset: 0, explore: false }))
443 await $.store.set('bandKey', k)
444 await $.store.set('view', { key: k })
445 await refreshBand($)
446}
447
448async function stepKey($: any, keys: readonly string[], sel: string, offset: number): Promise<void> {
449 const k = P.neighbor(sel, keys, offset)
450 if (k) await selectQuota($, k)
451}
452
453async function setBand($: any, mode: BandMode): Promise<void> {
454 await update($, bandA, () => mode)
455 await $.store.set('band', mode)
456}
457
458/** Show/hide in the pane: read current state on press and flip it, not the render-time snapshot */
459async function toggleBandHidden($: any): Promise<void> {
460 const cur = (await read($, bandA)) ?? 'open'
461 await setBand($, cur === 'hidden' ? 'open' : 'hidden')
462}
463
464/** Band "Hide" asks first: once hidden the band has no entry point, so tell the user to come back via /weektoken; dropped after 8 s without an answer */
465let confirmTimer: { cancel: () => void } | null = null
466
467async function askHideBand($: any): Promise<void> {
468 confirmTimer?.cancel()
469 await update($, confirmHideA, () => true)
470 confirmTimer = $.clock.after(8000, () => { void update($, confirmHideA, () => false).catch(() => {}) })
471}
472
473async function answerHideBand($: any, hide: boolean): Promise<void> {
474 confirmTimer?.cancel()
475 confirmTimer = null
476 await update($, confirmHideA, () => false)
477 if (hide) await setBand($, 'hidden')
478}
479
480/** Band prev/next arrows: switch quota, the pane follows */
481async function stepBand($: any, keys: readonly string[], cur: string, offset: number): Promise<void> {
482 await stepKey($, keys, cur, offset)
483}
484
485// When the desktop pane doesn't have the keyboard (focus in the prompt), the first click only hands focus to the pane: the engine sends ui.focus, no ui.press.
486// Measured: with focus, a click is "ui.press → ui.focus ~70ms later"; without focus, only ui.focus.
487// So when drawing the pane, record each button's action by key; if ui.focus lands on a button with no ui.press before or after, press it once on its behalf.
488// Events can't tell a click from Tab: if the pane already had the keyboard (only then do Tab / arrow keys move focus), don't press.
489// "Already had" comes from the last render's e.props.isFocused: the pane redraws with false when focus leaves (confirmed in device logs);
490// $.ui.panes() already reports "focused" inside ui.focus, even on the first click after blur, so it's unusable. Desktop only.
491const paneActions = new Map<string, () => unknown>()
492let paneSurface = 'desktop'
493let paneFocusedAtRender = false
494let lastPanePress: { el: string; at: number } | null = null
495
496/** Register a pane button's action (for the substitute press); errors are swallowed, no unhandled rejections */
497function paneAct(key: string, fn: () => unknown): () => Promise<void> {
498 const run = () => Promise.resolve().then(fn).then(() => {}, () => {})
499 paneActions.set(key, run)
500 return run
501}
502
503// ======================================================================
504// Rendering: the band above the prompt
505// ======================================================================
506
507const STATUS_DIM = new Set(['early', 'unknown'])
508const SVG_MAX = 120_000
509
510/** Async action fired by a click: errors are swallowed, no unhandled rejections */
511const quiet = (p: Promise<unknown>) => { void p.catch(() => {}) }
512
513/**
514 * Draw an SVG. Small and interactive images get explicit width/height (interactive ones sit in a sandboxed iframe and otherwise stretch to the full row);
515 * wide charts are fluid: no width/height, natural width, shrunk to pane width at most (a size wider than the pane gets scaled down as a bitmap and blurs)
516 */
517function pic($: any, e: any, source: string, alt: string, opts: { isInteractive?: boolean; fluid?: boolean; fillHeight?: number } = {}) {
518 const { Svg } = $.ui.resolve(e)
519 if (opts.fluid) return <Svg source={source} alt={alt} />
520 // Plain image with height only: the SVG declares no width (width="100%") and uses percentage geometry, so width follows the cell up to the image default 300px,
521 // stretching without distortion. No interactive frame: the host reloads it, flickering, whenever the same plugin redraws elsewhere (the pane) (confirmed on device)
522 if (opts.fillHeight) return <Svg source={source} alt={alt} height={opts.fillHeight} />
523 const m = /^<svg[^>]*?\swidth="([\d.]+)"\s+height="([\d.]+)"/.exec(source)
524 const size = m ? { width: Number(m[1]), height: Number(m[2]) } : {}
525 return opts.isInteractive
526 ? <Svg source={source} alt={alt} {...size} isInteractive />
527 : <Svg source={source} alt={alt} {...size} />
528}
529
530/** Text bullet for the terminal: usage blocks + time marker */
531function termBar($: any, e: any, key: string, used: number | null, elapsed: number | null, st: P.Status, width: number) {
532 const { Text } = $.ui.resolve(e)
533 const nU = Math.round((used ?? 0) * width)
534 const mark = elapsed == null ? -1 : Math.min(width - 1, Math.floor(elapsed * width))
535 const runs: { text: string; color?: string; dim?: boolean }[] = []
536 const push = (ch: string, color?: string, dim?: boolean) => {
537 const last = runs[runs.length - 1]
538 if (last && last.color === color && last.dim === dim) last.text += ch
539 else runs.push({ text: ch, color, dim })
540 }
541 for (let i = 0; i < width; i++) {
542 if (i === mark) push('│', STATUS[st].solid)
543 else if (i < nU) push('█', identityText(key))
544 else push('░', undefined, true)
545 }
546 return (
547 <Text>
548 {runs.map(r => <Text color={r.color} dimColor={r.dim}>{r.text}</Text>)}
549 </Text>
550 )
551}
552
553const termBarOf = ($: any, e: any, row: QuotaRow, width: number) =>
554 termBar($, e, row.key, P.displayUsed(row.d), row.d.kind === 'pace' ? row.d.pace.elapsed : null, P.displayStatus(row.d), width)
555
556/**
557 * The band's text line: ‹ name › · used · elapsed. One non-wrapping Text per segment (nested Text wraps on desktop);
558 * name and used don't shrink, elapsed is truncated first. Pace status is shown by the bar color, not text.
559 * With multiple quotas, prev/next arrows flank the name and on hover take the color of the quota they switch to.
560 */
561const bandTexts = (show: BandShow) => ({
562 name: P.displayName(show.key, show.n),
563 used: show.ended ? L('· 已重置', '· reset') : show.used != null ? L(`· 已用 ${show.used}%`, `· ${show.used}% used`) : null,
564 elapsed: show.elapsed != null ? L(`· 已过 ${show.elapsed}%`, `· ${show.elapsed}% elapsed`) : null,
565 details: L('详情', 'Details'),
566 closeDetails: L('收起', 'Close'),
567 hide: L('隐藏', 'Hide'),
568})
569
570/** Terminal cell width: CJK and full-width characters take two cells */
571const cells = (s: string) => [...s].reduce((n, ch) => n + (/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1), 0)
572
573/**
574 * Width of the band's name cell: sized to the longest quota name, name centered. Switching quotas moves neither the arrows nor the text after them.
575 * Counted in character cells; desktop fonts are proportional but a cell is slightly wider than a typical glyph, so no extra margin (with it the title looks too wide)
576 */
577const bandNameWidth = (show: BandShow, _term: boolean) =>
578 Math.max(...show.keys.map(k => cells(P.displayName(k, k === show.key ? show.n : undefined))), cells(P.displayName(show.key, show.n)))
579
580/**
581 * Terminal band layout: text at its actual width, the bar takes the remaining cells (max 48).
582 * Under 8 cells, drop the "elapsed" segment first; still short, skip the bar. Never let the line wrap to two.
583 */
584/**
585 * Band layout: the bar takes the remaining cells; when short, drop the "elapsed" segment first, then skip the bar (terminal).
586 * "Does it fit" assumes the worst case: longest quota name, widest reading such as "100%", the longer of the two button labels.
587 * Depends only on band width, not the current quota's numbers, so all quotas behave the same at a given width; none shows in full while another is cut to "elap…".
588 * Terminal counts exact character cells; on desktop fonts are proportional and native buttons have padding, so arrows and buttons use estimated cells and the bar keeps at least BAR_MIN.
589 */
590const BAR_MIN = 6
591function bandLayout(show: BandShow, cols: number, term: boolean): { bar: number; withElapsed: boolean } {
592 const t = bandTexts(show)
593 const used = cells(L('· 已用 100%', '· 100% used'))
594 const elapsed = cells(L('· 已过 100%', '· 100% elapsed'))
595 const buttons = Math.max(cells(t.details), cells(t.closeDetails)) + cells(t.hide)
596 const width = (withElapsed: boolean) => {
597 // Name plus both arrows form one group: terminal "h: ❮" / "l: ❯" are 4 cells each, one space apart; desktop native buttons have padding, 3 cells each, no spaces
598 const arrows = show.keys.length > 1 ? (term ? 4 + 4 + 2 : 3 + 3) : 0
599 const parts = [bandNameWidth(show, term) + arrows, used, ...(withElapsed ? [elapsed] : [])]
600 const line = parts.reduce((a, b) => a + b, 0) + parts.length - 1
601 // Terminal: one space between line, bar, spacer, Details and Hide; the engine draws " [-]" last, plus one more cell;
602 // Desktop: 2 cells of padding per button, one space between line, bar and the two buttons
603 return line + buttons + (term ? 4 + 5 : 4 + 3)
604 }
605 if (!term) return { bar: 1, withElapsed: !!t.elapsed && width(true) + BAR_MIN <= cols }
606 for (const withElapsed of [true, false]) {
607 const bar = Math.min(48, cols - width(withElapsed) - 1)
608 if (bar >= 8 || (!withElapsed && bar >= 4)) return { bar, withElapsed: withElapsed && t.elapsed != null }
609 }
610 return { bar: 0, withElapsed: false }
611}
612
613function bandLine($: any, e: any, show: BandShow, withElapsed = true) {
614 const { Box, Text, Button } = $.ui.resolve(e)
615 const term = e.surface === 'terminal'
616 const t = bandTexts(show)
617 const canSwitch = show.keys.length > 1
618 const arrow = (offset: -1 | 1) => (
619 <Box key={offset < 0 ? 'band-sw-prev' : 'band-sw-next'} flexShrink={0}>
620 <Button
621 key={offset < 0 ? 'band-prev' : 'band-next'}
622 label={offset < 0 ? '❮' : '❯'}
623 plain
624 {...(term ? { hotkey: offset < 0 ? 'h' : 'l' } : {})}
625 hover={{ color: identityText(P.neighbor(show.key, show.keys, offset) as string), bold: true }}
626 onPress={() => quiet(stepBand($, show.keys, show.key, offset))}
627 />
628 </Box>
629 )
630 return (
631 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1} minWidth={0} overflow="hidden">
632 {/* Arrows and name as one group; desktop native buttons have their own padding, so no gap inside, keeping the title from getting too wide */}
633 <Box flexDirection="row" alignItems="center" gap={term ? 1 : 0} flexShrink={0}>
634 {canSwitch ? arrow(-1) : null}
635 <Box flexShrink={0} width={bandNameWidth(show, term)} justifyContent="center"><Text color={identityText(show.key)} wrap="truncate-end">{t.name}</Text></Box>
636 {canSwitch ? arrow(1) : null}
637 </Box>
638 {t.used ? (
639 <Box flexShrink={0}><Text color={show.ended ? undefined : identityText(show.key)} dimColor={!!show.ended} bold={!show.ended} wrap="truncate-end">{t.used}</Text></Box>
640 ) : null}
641 {t.elapsed && withElapsed ? (
642 <Box flexShrink={100} minWidth={0}><Text dimColor wrap="truncate-end">{t.elapsed}</Text></Box>
643 ) : null}
644 </Box>
645 )
646}
647
648function drawConfirmHide($: any, e: any) {
649 const { Box, Text, Button } = $.ui.resolve(e)
650 return (
651 <Box flexDirection="row" alignItems="center" gap={1}>
652 <Box flexGrow={1} flexShrink={1} minWidth={0}>
653 <Text wrap="truncate-end">{L('隐藏横条?之后输入 /weektoken show 可恢复', 'Hide the band? Type /weektoken show to bring it back')}</Text>
654 </Box>
655 <Button key="band-hide-yes" label={L('隐藏', 'Hide')} variant="primary" onPress={() => quiet(answerHideBand($, true))} />
656 <Button key="band-hide-no" label={L('取消', 'Cancel')} onPress={() => quiet(answerHideBand($, false))} />
657 </Box>
658 )
659}
660
661function drawBand($: any, e: any, show: BandShow, paneOpen = false) {
662 const { Box, Button } = $.ui.resolve(e)
663 const term = e.surface === 'terminal'
664 const used = show.used == null ? null : show.used / 100
665 const elapsed = show.elapsed == null ? null : show.elapsed / 100
666 const full = P.fullName(show.key, show.n)
667 const label = show.ended ? L(`${full}:窗口已重置`, `${full}: window has reset`)
668 : show.used == null ? full
669 : show.elapsed == null ? L(`${full}:已用 ${show.used}%`, `${full}: ${show.used}% used`)
670 : L(`${full}:已用 ${show.used}%,已过 ${show.elapsed}%`, `${full}: ${show.used}% used, ${show.elapsed}% elapsed`)
671 const t = bandTexts(show)
672 // One text line + bar; details go in the pane, restore from the pane after hiding.
673 // Desktop: text is laid out at its own width first, the bar grows from 0 into the remaining space (at least BAR_MIN cells); if "elapsed" doesn't fit, the whole segment is hidden (see bandLayout).
674 // Terminal: the bar is fixed-width text, sized from bodyColumns (the conversation column's width when the pane is docked beside it)
675 const lay = bandLayout(show, e.props?.bodyColumns ?? (term ? 80 : 120), term)
676 const pace = used != null && elapsed != null ? ({ used, elapsed } as P.Pace) : null
677 return (
678 <Box flexDirection="row" alignItems="center" gap={1}>
679 {bandLine($, e, show, lay.withElapsed)}
680 {term ? (
681 lay.bar > 0 ? <Box flexShrink={0}>{termBar($, e, show.key, used, elapsed, show.status, lay.bar)}</Box> : null
682 ) : (
683 <Box width={0} flexGrow={1} flexShrink={1} minWidth={BAR_MIN}>
684 {pic($, e, bandBarSvg(pace, used, show.status, identity(show.key), label, 'fluid'), label, { fillHeight: 14 })}
685 </Box>
686 )}
687 {term ? <Box flexGrow={1} /> : null}
688 <Button key="open" label={paneOpen ? t.closeDetails : t.details} plain dimColor onPress={() => quiet(togglePane($))} />
689 <Button key="band-hide" label={t.hide} plain dimColor role="dismiss" onPress={() => quiet(askHideBand($))} />
690 </Box>
691 )
692}
693
694// ======================================================================
695// Rendering: pane
696// ======================================================================
697
698/** Pane footer note: when this quota has been unused for over half an hour, say how long ago it was last used (refresh progress shows on the Refresh button, not this line) */
699function noteLine(row: QuotaRow | undefined, samples: readonly Sample[], activity: Activity, now: number): string | null {
700 if (!row) return null
701 const lu = P.lastUsed(samples, row.key, activity)
702 if (!lu) return null
703 const age = (now - lu.at) / 1000
704 if (age < 1800) return null
705 const d = P.formatDuration(age)
706 const who = P.isModelScoped(row.key) ? row.name : null
707 if (lu.isLowerBound) return who ? L(`至少 ${d}没用过 ${who}`, `${who} unused for ${d}+`) : L(`至少 ${d}没有用量`, `No usage for ${d}+`)
708 return who ? L(`上次使用 ${who}:${d}前`, `Last used ${who} ${d} ago`) : L(`上次使用:${d}前`, `Last used ${d} ago`)
709}
710
711function trendOf(samples: readonly Sample[], row: QuotaRow): P.Trend | null {
712 if (row.d.kind !== 'pace' || row.obs?.r == null) return null
713 return P.buildSeries(row.key, row.obs.r, samples, row.len.seconds)?.trend ?? null
714}
715
716const trendText = (t: P.Trend): string =>
717 L({ accelerating: '↗ 最近在加速', easing: '↘ 最近在缓下来', steady: '→ 速度稳定' }, { accelerating: '↗ Speeding up lately', easing: '↘ Slowing down lately', steady: '→ Steady pace' })[t]
718
719function drawEmpty($: any, e: any) {
720 const { Box, Text } = $.ui.resolve(e)
721 return (
722 <Box flexDirection="column" alignItems="center" paddingY={1}>
723 <Text bold>{L('还没有数据', 'No data yet')}</Text>
724 <Text dimColor wrap="wrap">{L('等这个会话收到第一条回复,或点「刷新」', "Waiting for this session's first reply, or press Refresh")}</Text>
725 </Box>
726 )
727}
728
729function drawPaceTab($: any, e: any, samples: readonly Sample[], m: Model, row: QuotaRow) {
730 const { Box, Text, Button } = $.ui.resolve(e)
731 const term = e.surface === 'terminal'
732 const d = row.d
733 const st = P.displayStatus(d)
734 const pace = d.kind === 'pace' ? d.pace : null
735 const used = P.displayUsed(d)
736 const id = identity(row.key)
737 const label = P.accessibilityLabel(d, row.full)
738 const second = pace ? P.forecast(pace) : P.displayDetail(d)
739 const trend = trendOf(samples, row)
740 // Trend is only supplementary, no extra color (amber and the quota blue are near-complementary and look cheapest together)
741 const trendLine = trend && trend !== 'steady' ? <Text dimColor>{trendText(trend)}</Text> : null
742 const showBadge = !P.isVerifiedByData(row.len.source)
743 const canSwitch = m.keys.length > 1
744 const target = (offset: number) => P.neighbor(row.key, m.keys, offset) as string
745 // Readings rounded, matching the band
746 const metrics = pace
747 ? [
748 { v: `${Math.round(pace.used * 100)}%`, l: L('已用', 'Used'), accent: true },
749 { v: `${Math.round(pace.elapsed * 100)}%`, l: L('已过', 'Elapsed') },
750 { v: P.formatDuration(pace.timeToReset), l: L('距重置', 'Resets in') },
751 ]
752 : d.kind === 'usageOnly' ? [{ v: `${Math.round(d.obs.u)}%`, l: L('已用', 'Used'), accent: true }] : []
753
754 if (term) {
755 return (
756 <Box flexDirection="column" gap={1}>
757 <Box flexDirection="row" gap={1}>
758 {termBarOf($, e, row, 30)}
759 <Text color={STATUS_DIM.has(st) ? undefined : statusText(st)} bold>{pace?.burnRate != null ? `${pace.burnRate.toFixed(2)}×` : P.menuBarText(d).trim()}</Text>
760 <Text dimColor>{P.displayLabel(d)}</Text>
761 </Box>
762 <Text bold>{P.displayHeadline(d)}</Text>
763 {second ? <Text dimColor wrap="wrap">{second}</Text> : null}
764 {trendLine}
765 <Text>{metrics.map(x => `${x.l} ${x.v}`).join(' ')}</Text>
766 {showBadge ? <Text dimColor>?{P.windowNote(row.len)}</Text> : null}
767 {canSwitch ? (
768 <Box flexDirection="row" gap={2}>
769 <Button key="prev" label={`‹ ${m.rows[target(-1)]?.full ?? ''}`} hotkey="h" onPress={paneAct('prev', () => stepKey($, m.keys, row.key, -1))} />
770 <Button key="next" label={`${m.rows[target(1)]?.full ?? ''} ›`} hotkey="l" onPress={paneAct('next', () => stepKey($, m.keys, row.key, 1))} />
771 </Box>
772 ) : null}
773 </Box>
774 )
775 }
776
777 const center = pace && pace.burnRate != null
778 ? { kind: 'rate' as const, text: pace.burnRate.toFixed(2) }
779 : d.kind === 'usageOnly' ? { kind: 'pct' as const, text: `${Math.round(d.obs.u)}%` } : { kind: 'none' as const }
780 return (
781 <Box flexDirection="column" alignItems="center" gap={2}>
782 {/* Quota-switch arrows flank the title (see drawPane); this row holds only the image, centered */}
783 <Box flexDirection="row" justifyContent="center" width="100%">
784 {pic($, e, ringsSvg({ used, elapsed: pace?.elapsed ?? null, status: st, id, center, label: P.displayLabel(d), title: label, readings: pace ? metrics.map(x => ({ label: x.l.toUpperCase(), value: x.v, accent: !!x.accent })) : [] }), label)}
785 </Box>
786 <Box flexDirection="column" alignItems="center">
787 <Text bold>{P.displayHeadline(d)}</Text>
788 {second ? <Text dimColor wrap="wrap">{second}</Text> : null}
789 {trendLine}
790 </Box>
791 {showBadge ? <Text dimColor>?{P.windowNote(row.len)}</Text> : null}
792 </Box>
793 )
794}
795
796function drawBurnUpTab($: any, e: any, samples: readonly Sample[], m: Model, row: QuotaRow, view: PaneView, now: number) {
797 const { Box, Text, Button, Select } = $.ui.resolve(e)
798 const o = P.buildOverlay(row.key, samples, row.len.seconds, view.range, view.offset, now)
799 const ranges: P.Range[] = ['current', 'month', 'all']
800 // Desktop already has arrows beside the title, so none here; terminal uses a dropdown; surfaces without Select (mobile) fall back to prev/next buttons
801 const quotaPick = m.keys.length < 2 || e.surface !== 'terminal' ? null : Select ? (
802 <Select key="quota" options={m.keys.map(k => ({ value: k, label: P.pickName(k, m.rows[k].obs?.n) }))} value={row.key} onSelect={(k: string) => quiet(selectQuota($, k))} />
803 ) : (
804 <Box flexDirection="row" gap={1}>
805 <Button key="prev" label="‹" onPress={paneAct('prev', () => stepKey($, m.keys, row.key, -1))} />
806 <Button key="next" label="›" onPress={paneAct('next', () => stepKey($, m.keys, row.key, 1))} />
807 </Box>
808 )
809 const rangeRow = (
810 <Box flexDirection="row" alignItems="center" gap={1} flexWrap="wrap">
811 {quotaPick}
812 {ranges.map(r => (
813 <Button key={`range-${r}`} label={P.rangeLabel(r)} variant={view.range === r ? 'primary' : 'secondary'} onPress={paneAct(`range-${r}`, () => setView($, v => ({ ...v, range: r, offset: 0, explore: false })))} />
814 ))}
815 </Box>
816 )
817 const focused = o.focused
818 const canOlder = view.range === 'current' && view.offset < o.totalWindows - 1
819 const canNewer = view.range === 'current' && view.offset > 0
820 const nav = view.range === 'current' && focused ? (
821 <Box flexDirection="row" alignItems="center" gap={1}>
822 {canOlder ? <Button key="older" label={L('‹ 更早', '‹ Older')} onPress={paneAct('older', () => setView($, v => ({ ...v, offset: v.offset + 1 })))} /> : null}
823 <Text dimColor>{P.windowLabel(focused.series.reset, row.len.seconds, focused.isCurrent)}</Text>
824 {canNewer ? <Button key="newer" label={L('更近 ›', 'Newer ›')} onPress={paneAct('newer', () => setView($, v => ({ ...v, offset: v.offset - 1 })))} /> : null}
825 </Box>
826 ) : null
827 if (!o.entries.length) {
828 return (
829 <Box flexDirection="column" gap={1}>
830 {rangeRow}
831 <Text dimColor wrap="wrap">{L('这个窗口的采样还不够画趋势', 'Not enough samples in this window yet')}</Text>
832 </Box>
833 )
834 }
835 // "Last month / All" is normally a plain image (no flicker on band or pane redraws); hovering the chart reveals "Inspect lines" at top left,
836 // and only clicking it swaps in the hoverable frame: the line under the pointer goes bold with its date and peak, the rest fade
837 const W = row.len.seconds
838 const caption = P.overlayCaption(o)
839 if (e.surface === 'terminal') {
840 const s = focused!.series
841 return (
842 <Box flexDirection="column" gap={1}>
843 {rangeRow}
844 {nav}
845 <Text>{L(`峰值 ${Math.round(s.peak * 100)}%`, `Peak ${Math.round(s.peak * 100)}%`)}</Text>
846 {caption ? <Text dimColor wrap="wrap">{caption}</Text> : null}
847 <Text dimColor>{L('(曲线图在桌面端显示)', '(The chart shows in the desktop app)')}</Text>
848 </Box>
849 )
850 }
851 const title = L(`${row.full} 用量轨迹`, `${row.full} burn-up`)
852 const input = { width: 360, overlay: o, W, status: P.displayStatus(row.d), id: identity(row.key), title }
853 const plain = burnUpSvg(input)
854 // The hoverable version adds hit lines and labels; if it exceeds the cap, no inspect mode, the plain chart draws as usual
855 const live = view.range !== 'current' && o.entries.length > 1 ? burnUpSvg({ ...input, interactive: true }) : null
856 const canExplore = live != null && live.length <= SVG_MAX
857 const exploring = canExplore && !!view.explore
858 const svg = exploring && live ? live : plain
859 const alt = caption ? `${title}. ${caption}` : title
860 // Svg source is capped at 131072 chars; over that the engine rejects the whole tree. Overlays are already limited, this is a final backstop
861 if (svg.length > SVG_MAX) {
862 return (
863 <Box flexDirection="column" gap={1}>
864 {rangeRow}
865 {nav}
866 <Text dimColor wrap="wrap">{L('这个范围的数据太多,画不下:换「本窗口」或「近一月」看', 'Too much data to draw for this range: try This window or Last month')}</Text>
867 </Box>
868 )
869 }
870 const toggle = (
871 <Button
872 key="explore"
873 label={exploring ? L('✓ 完成', '✓ Done') : L('⤢ 逐条查看', '⤢ Inspect lines')}
874 variant={exploring ? 'primary' : 'secondary'}
875 onPress={paneAct('explore', () => setView($, v => ({ ...v, explore: !exploring })))}
876 />
877 )
878 const chart = canExplore ? (
879 <Box key="chart" flexDirection="column">
880 {exploring ? pic($, e, svg, alt, { isInteractive: true }) : pic($, e, svg, alt, { fluid: true })}
881 <Box position="absolute" top={0} left={4} {...(exploring ? {} : { display: 'none', hover: { display: 'flex' } })}>{toggle}</Box>
882 </Box>
883 ) : pic($, e, svg, alt, { fluid: true })
884 return (
885 <Box flexDirection="column" gap={1}>
886 {rangeRow}
887 {nav}
888 {chart}
889 {exploring ? <Text dimColor wrap="wrap">{L('鼠标指到一条线上,看它是哪个窗口、峰值多少', 'Point at a line to see its window and peak')}</Text> : null}
890 {caption ? <Text dimColor wrap="wrap">{caption}</Text> : null}
891 </Box>
892 )
893}
894
895/** Credit: bottom-right of the pane, the X handle, one line of 10px translucent text (drawn as an image; not clickable) */
896// Version read at pane render (reading versionA also redraws the pane once it's written)
897let paneVersion = ''
898
899function credit($: any, e: any) {
900 const { Box, Text } = $.ui.resolve(e)
901 const handle = `@${AUTHOR.x}${paneVersion ? ` · v${paneVersion}` : ''}`
902 return (
903 <Box flexDirection="row" justifyContent="flex-end">
904 {e.surface === 'terminal' ? <Text dimColor>{handle}</Text> : pic($, e, creditSvg(handle), handle)}
905 </Box>
906 )
907}
908
909function drawPane($: any, e: any, samples: readonly Sample[], activity: Activity, view: PaneView, note: Note, band: BandMode, m: Model, now: number) {
910 const { Box, Text, Button } = $.ui.resolve(e)
911 // At least as tall as the pane body (row count from the host; Box sizes are in rows on every surface); the spacer before the credit pushes it to the bottom
912 const fill = e.props?.scroll?.bodyRows ? Math.max(1, e.props.scroll.bodyRows) : '100%'
913 paneActions.clear()
914 paneSurface = e.surface
915 paneFocusedAtRender = !!e.props?.isFocused
916 const term = e.surface === 'terminal'
917 const sel = m.selected
918 const row = sel ? m.rows[sel] : undefined
919 const nl = noteLine(row, samples, activity, now)
920 const busy = isBusy(note, now)
921 const footer = (
922 <Box flexDirection="row" alignItems="center" gap={1}>
923 {nl ? <Text dimColor wrap="truncate-end">{nl}</Text> : null}
924 <Box flexGrow={1} />
925 <Button key="band-toggle" label={band === 'hidden' ? L('⊕ 显示横条', '⊕ Show band') : L('⊖ 隐藏横条', '⊖ Hide band')} onPress={paneAct('band-toggle', () => toggleBandHidden($))} />
926 <Button key="refresh" label={busy ? L('↻ 刷新中…', '↻ Refreshing…') : L('↻ 刷新', '↻ Refresh')} {...(term ? { hotkey: 'r' } : {})} onPress={paneAct('refresh', () => refreshAll($, true))} />
927 </Box>
928 )
929 if (!row) {
930 return (
931 <Box flexDirection="column" gap={term ? 1 : 2} paddingX={1} minHeight={fill}>
932 {drawEmpty($, e)}
933 {footer}
934 <Box flexGrow={1} />
935 {credit($, e)}
936 </Box>
937 )
938 }
939 // Desktop: quota-switch arrows flank the title, as on the band; the name cell is sized to the longest quota name so arrows don't move on switch.
940 // (Arrows used to sit at both ends of the ring row; once readings moved into the image it grew 50% wider and squeezed the arrows against the ring)
941 // The terminal pace tab has its own named buttons (h / l), so no title arrows
942 const headArrows = !term && m.keys.length > 1
943 const headArrow = (offset: -1 | 1) => {
944 const name = offset < 0 ? 'prev' : 'next'
945 return (
946 <Box key={`sw-${name}`} flexShrink={0}>
947 <Button
948 key={name}
949 label={offset < 0 ? '❮' : '❯'}
950 plain
951 hover={{ color: identityText(P.neighbor(row.key, m.keys, offset) as string), bold: true }}
952 onPress={paneAct(name, () => stepKey($, m.keys, row.key, offset))}
953 />
954 </Box>
955 )
956 }
957 const title = <Text color={identityText(row.key)} bold wrap="truncate-end">{row.full}</Text>
958 const header = headArrows ? (
959 <Box flexDirection="row" alignItems="center">
960 {headArrow(-1)}
961 <Box flexShrink={0} width={Math.max(...m.keys.map(k => cells(m.rows[k].full)))} justifyContent="center">{title}</Box>
962 {headArrow(1)}
963 </Box>
964 ) : (
965 <Box flexDirection="row" alignItems="center" gap={1}>{title}</Box>
966 )
967 const tabs = (
968 <Box flexDirection="row" gap={1}>
969 <Button key="tab-pace" label={L('配速', 'Pace')} {...(term ? { hotkey: '1' } : {})} variant={view.tab === 'pace' ? 'primary' : 'secondary'} onPress={paneAct('tab-pace', () => setView($, v => ({ ...v, tab: 'pace', explore: false })))} />
970 <Button key="tab-burnup" label={L('用量轨迹', 'Burn-up')} {...(term ? { hotkey: '2' } : {})} variant={view.tab === 'burnup' ? 'primary' : 'secondary'} onPress={paneAct('tab-burnup', () => setView($, v => ({ ...v, tab: 'burnup', explore: false })))} />
971 </Box>
972 )
973 return (
974 <Box flexDirection="column" gap={term ? 1 : 2} paddingX={1} minHeight={fill}>
975 {header}
976 {tabs}
977 {view.tab === 'burnup' ? drawBurnUpTab($, e, samples, m, row, view, now) : drawPaceTab($, e, samples, m, row)}
978 {footer}
979 <Box flexGrow={1} />
980 {credit($, e)}
981 </Box>
982 )
983}
984
985// ======================================================================
986// Hooks
987// ======================================================================
988
989
990const commandSpec = () => ({
991 name: 'weektoken',
992 description: L('打开 WeekToken 配速面板;show / hide 显示或隐藏横条', 'Open the WeekToken pace pane; show / hide the band'),
993 argumentHint: '[show | hide]',
994 immediate: true,
995})
996
997// Timer handles: on another session.start in the same environment (enable, process restart), cancel the old ones first so they don't stack
998let timers: { cancel: () => void }[] = []
999
1000export const register: Register = on => {
1001 on('session.start', async ($, e, next) => {
1002 if (await readDisabled($)) return next(e)
1003 try {
1004 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
1005 if (typeof manifest.version === 'string') await update($, versionA, () => manifest.version as string)
1006 } catch {}
1007 try { await refreshLang($) } catch {}
1008 await $.command.register(commandSpec())
1009 // "Refreshing" / "confirm hide" left from before the reload will never be cleared otherwise
1010 try { await update($, noteA, () => null) } catch {}
1011 try { await update($, confirmHideA, () => false) } catch {}
1012 try { await loadState($) } catch {}
1013 try { await importHistory($) } catch {}
1014 try { await sampleSession($) } catch {}
1015 try { await sampleCache($) } catch {}
1016 try { await refreshBand($) } catch {}
1017 try { await syncPaneOpen($) } catch {}
1018 // First run after install: say what the band is, when data shows up, and where the entry point is
1019 if ((await $.store.get('welcomed')) !== true) {
1020 await $.store.set('welcomed', true)
1021 $.ui.toast(L(
1022 'WeekToken 已启用:输入框上方显示用量配速,5 小时 / 7 天的数据在第一条回复后出现。输入 /weektoken 看详情和历史,/weektoken hide 隐藏横条',
1023 'WeekToken is on: the band above the prompt shows your usage pace; 5-hour and 7-day figures appear after the first reply. Type /weektoken for details and history, /weektoken hide to hide the band',
1024 ), { timeoutMs: 15_000 })
1025 }
1026 for (const t of timers) t.cancel()
1027 timers = [
1028 $.clock.every(5 * 60_000, () => { void sampleCache($).catch(() => {}) }),
1029 $.clock.every(10 * 60_000, () => { void importHistory($).catch(() => {}) }),
1030 // Every minute: merge samples recorded by other sessions; "elapsed %" moves with time, so recompute the band, writing nothing if the display is unchanged
1031 $.clock.every(60_000, () => { void adoptStoredSamples($).then(() => refreshBand($)).catch(() => {}) }),
1032 ]
1033 return next(e)
1034 })
1035
1036 // Every reply uses quota (subagents too): record time and model to determine "last used"
1037 on('turn.complete', async ($, e, next) => {
1038 if (disabled) return next(e)
1039 try { if (e.usage) await recordActivity($, e.usage.model) } catch {}
1040 return next(e)
1041 })
1042
1043 // After each reply, record a sample whenever the officially reported limit windows change (≥ 1 percentage point)
1044 on('session.measure', async ($, e, next) => {
1045 if (disabled) return next(e)
1046 try { await recordRateLimits($, e.rateLimits, await $.clock.now()) } catch {}
1047 return next(e)
1048 })
1049
1050 on('command.run', { command: 'weektoken' }, async ($, e) => {
1051 await useLang($)
1052 const arg = (e.args ?? '').trim().toLowerCase()
1053 if (arg === 'show') {
1054 await setBand($, 'open')
1055 await refreshBand($)
1056 return { text: L('横条已显示', 'Band shown') }
1057 }
1058 if (arg === 'hide') {
1059 await setBand($, 'hidden')
1060 return { text: L('横条已隐藏,输入 /weektoken show 恢复', 'Band hidden; type /weektoken show to bring it back') }
1061 }
1062 if (arg) return { text: L(`不认识的参数「${arg}」:可以用 show、hide,不带参数则打开面板`, `Unknown argument "${arg}": use show or hide, or nothing to open the pane`) }
1063 await refreshAll($, false)
1064 await openPane($)
1065 return { text: L('WeekToken 配速面板已打开', 'WeekToken pace pane opened') }
1066 })
1067
1068 // Workaround for the pane's first click only taking focus (see paneActions): record every press on the pane…
1069 on('ui.press', async ($, e, next) => {
1070 if (disabled || e.plugin !== 'weektoken') return next(e)
1071 const at = await $.clock.now()
1072 if (e.component === 'Pane') lastPanePress = { el: e.element, at }
1073 return next(e)
1074 })
1075 // …if focus lands on a button with no press before or after, and the pane didn't have the keyboard, press it once on its behalf
1076 on('ui.focus', async ($, e, next) => {
1077 const el = e.element
1078 if (disabled || e.plugin !== 'weektoken' || e.component !== 'Pane' || !el || e.origin.kind !== 'person' || paneSurface === 'terminal') return next(e)
1079 const hadKeyboard = paneFocusedAtRender
1080 const r = await next(e)
1081 const at = await $.clock.now()
1082 const pressed = (since: number) => lastPanePress != null && lastPanePress.el === el && lastPanePress.at >= since
1083 // Pane already had the keyboard: Tab / arrow keys moving focus, or a click that brings its own ui.press. Don't press in either case
1084 if (hadKeyboard || pressed(at - 300)) return r
1085 $.clock.after(250, () => {
1086 if (pressed(at)) return // arrived on its own afterwards
1087 const act = paneActions.get(el)
1088 if (!act) return
1089 void act()
1090 })
1091 return r
1092 })
1093
1094 // Claude Code's language changed in /config: pass the setting to the engine unchanged, re-detect shortly after (once the new value applies);
1095 // band and pane redraw accordingly, and the command description switches language too
1096 on('config.set', { key: 'language' }, async ($, e, next) => {
1097 if (!disabled) $.clock.after(300, () => { void refreshLang($).then(changed => (changed ? $.command.register(commandSpec()) : undefined)).catch(() => {}) })
1098 return next(e)
1099 })
1100
1101 // The band reads only these: shown/hidden, confirm prompt, language, content to draw (already rounded). Not samples or pane state.
1102 // After drawing itself it appends what later mods and the engine draw below, without blocking them (same wrapping Box whether or not anything is below)
1103 // When the pane closes (band "Close", the pane's ×, unload) the band button reverts to "Details"; the close passes through to the engine unchanged
1104 on('ui.close', { id: PANE }, async ($, e, next) => {
1105 if (!disabled) { try { await update($, paneOpenA, () => false) } catch {} }
1106 return next(e)
1107 })
1108
1109 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1110 if (disabled || e.props.hasSurvey) return next(e)
1111 if (((await read($, bandA)) ?? 'open') === 'hidden') return next(e)
1112 await useLang($)
1113 const show = (await read($, confirmHideA)) ? 'confirm' : await read($, bandShowA)
1114 if (!show) return next(e)
1115 const ours = show === 'confirm' ? drawConfirmHide($, e) : drawBand($, e, show, !!(await read($, paneOpenA)))
1116 const below = await next(e)
1117 const { Box } = $.ui.resolve(e)
1118 return <Box flexDirection="column">{ours}{isBlank(below) ? null : below}</Box>
1119 })
1120
1121 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1122 const samples = (await read($, samplesA)) ?? []
1123 const view = (await read($, viewA)) ?? DEFAULT_VIEW
1124 const note = await read($, noteA)
1125 const band = (await read($, bandA)) ?? 'open'
1126 await useLang($)
1127 const now = await $.clock.now()
1128 const activity = (await read($, activityA)) ?? { byModel: {} }
1129 const bandKey = (await read($, bandKeyA)) ?? null
1130 paneVersion = (await read($, versionA)) ?? ''
1131 return drawPane($, e, samples, activity, view, note, band, buildModel(samples, view, now, bandKey), now)
1132 })
1133}
1134hooks/i18n.ts 38 lines1// UI language: Chinese / English; any other language falls back to English.
2// register.tsx detects it at session start (mod setting → Claude Code language setting → system language), then calls setLang;
3// pure functions use L(zh, en) to pick text for the current language.
4
5export type Lang = 'zh' | 'en'
6
7let current: Lang = 'en'
8
9export const setLang = (l: Lang): void => { current = l }
10export const getLang = (): Lang => current
11
12/** Pick one of the two by the current language */
13export const L = <T>(zh: T, en: T): T => (current === 'zh' ? zh : en)
14
15const ZH = /^zh([-_.\s]|$)|chinese|mandarin|cantonese|中文|汉语|漢語|简体|繁體|繁体|普通话|国语|國語|粤语/i
16
17/** Claude Code language setting: unset (empty, or shown as "Default (English)") → null; Chinese → zh; any other language → en */
18export function langFromSetting(v: unknown): Lang | null {
19 if (typeof v !== 'string') return null
20 const s = v.trim()
21 if (!s || /^default\b/i.test(s)) return null
22 return ZH.test(s) ? 'zh' : 'en'
23}
24
25/** LANG / LC_ALL / LC_MESSAGES: C and POSIX don't count as a language */
26export function langFromEnv(v: string | undefined): Lang | null {
27 const s = (v ?? '').trim()
28 if (!s || /^(c|posix)([._]|$)/i.test(s)) return null
29 return /^zh([-_.]|$)/i.test(s) ? 'zh' : 'en'
30}
31
32/** Output of `defaults read -g AppleLanguages`: take the first (preferred) entry */
33export function langFromAppleLanguages(out: string): Lang | null {
34 const first = out.replace(/[()]/g, '').split(/[,\n]/).map(x => x.trim().replace(/^"|"$/g, '')).find(Boolean)
35 if (!first) return null
36 return /^zh([-_]|$)/i.test(first) ? 'zh' : 'en'
37}
38hooks/pace.ts 885 lines1// WeekToken pace model: a port of WeekTokenCore from the WeekToken macOS app.
2// Algorithms, thresholds and copy match the original (PaceModel / QuotaDisplay / PaceNarrator / QuotaCatalog /
3// Bottleneck / WindowInference / BurnUpSeries / BurnUpOverlay). Pure functions; never touches `$`.
4// Time: samples and reset times are ms timestamps; window length W is in seconds. Copy is bilingual zh/en, picked by the current language in i18n.ts.
5
6import { L } from './i18n.ts'
7
8export const FIVE_HOUR = 18000
9export const SEVEN_DAY = 604800
10
11/** One quota in an observation: usage 0–100, reset time (ms), display name from the server */
12export type Obs = { u: number; r?: number; n?: string }
13/** One sample: time (ms) + per-quota observations */
14export type Sample = { t: number; w: Record<string, Obs> }
15
16// ---------- Window length and source ----------
17
18export type LengthSource =
19 | { kind: 'declared' }
20 | { kind: 'measured'; n: number }
21 | { kind: 'corroborated'; n: number }
22 | { kind: 'clientDeclared' }
23 | { kind: 'assumed' }
24
25export type WindowLength = { seconds: number; source: LengthSource }
26
27export function sourceBadge(s: LengthSource): string {
28 switch (s.kind) {
29 case 'declared': return L('厂商提供', 'vendor-declared')
30 case 'measured': return L(`实测 n=${s.n}`, `measured n=${s.n}`)
31 case 'corroborated': return L(`已验证 n=${s.n}`, `verified n=${s.n}`)
32 case 'clientDeclared': return L('客户端标定', 'client default')
33 case 'assumed': return L('假定', 'assumed')
34 }
35}
36
37/** The UI shows the source only when it is not verified by local data */
38export const isVerifiedByData = (s: LengthSource): boolean =>
39 (s.kind === 'measured' || s.kind === 'corroborated') && s.n >= 1
40
41export function prior(key: string): WindowLength {
42 const k = key.toLowerCase()
43 if (k.includes('five_hour') || k.includes('5h') || k.includes('session') || k === 'primary') {
44 return { seconds: FIVE_HOUR, source: { kind: 'clientDeclared' } }
45 }
46 if (k.includes('seven_day') || k.includes('week') || k.includes('7d')) {
47 return { seconds: SEVEN_DAY, source: { kind: 'clientDeclared' } }
48 }
49 return { seconds: SEVEN_DAY, source: { kind: 'assumed' } }
50}
51
52const MIN_JUMP = 60
53const BOUND_TOLERANCE = 60
54const FIXED_PERIOD_TOLERANCE = 60
55
56function robustUpperBound(jumps: number[]): number {
57 const sorted = [...jumps].sort((a, b) => a - b)
58 if (sorted.length < 10) return sorted[0]
59 return sorted[Math.max(1, Math.floor(sorted.length / 20))]
60}
61
62/** Infer window length from resets_at jumps: each jump gives an interval [jump − sample gap, jump]; intersect them */
63export function inferWindow(key: string, samples: readonly Sample[]): WindowLength {
64 const p = prior(key)
65 const bounds: { lo: number; hi: number; jump: number }[] = []
66 let prevR: number | undefined
67 let prevT: number | undefined
68 for (const s of samples) {
69 const o = s.w[key]
70 if (!o || o.r == null) continue
71 if (prevR != null && prevT != null) {
72 const jump = (o.r - prevR) / 1000
73 const gap = (s.t - prevT) / 1000
74 if (jump > MIN_JUMP && jump >= 3600 && jump <= 30 * 86400) {
75 bounds.push({ lo: Math.max(0, jump - gap), hi: jump, jump })
76 }
77 }
78 prevR = o.r
79 prevT = s.t
80 }
81 if (bounds.length === 0) return p
82 const n = bounds.length
83 const jumps = bounds.map(b => b.jump)
84 const jMin = Math.min(...jumps)
85 const jMax = Math.max(...jumps)
86 // Fixed period: if the jumps are uniform, that is the exact W.
87 // But not when jumps exceed the prior: a rolling window's jump = W + idle gap from reset to first use; when that gap is constant (scheduled jobs)
88 // the jumps are uniform too, but that is the gap, not W — leave it to the interval inference below
89 const mean = jumps.reduce((a, b) => a + b, 0) / n
90 if (n >= 2 && jMax - jMin <= FIXED_PERIOD_TOLERANCE && mean <= p.seconds + FIXED_PERIOD_TOLERANCE) {
91 if (Math.abs(mean - p.seconds) <= FIXED_PERIOD_TOLERANCE) return { seconds: p.seconds, source: { kind: 'corroborated', n } }
92 return { seconds: mean, source: { kind: 'measured', n } }
93 }
94 // Rolling window: lower bound = max of the interval lower bounds, upper bound = robust minimum jump
95 const lo = Math.max(...bounds.map(b => b.lo))
96 const hi = robustUpperBound(jumps)
97 if (lo - hi > BOUND_TOLERANCE) {
98 // Observations contradict each other: fall back to the upper bound, which always holds
99 if (Math.abs(hi - p.seconds) <= BOUND_TOLERANCE) return { seconds: p.seconds, source: { kind: 'corroborated', n } }
100 return { seconds: hi, source: { kind: 'measured', n } }
101 }
102 if (p.seconds >= lo - BOUND_TOLERANCE && p.seconds <= hi + BOUND_TOLERANCE) {
103 return { seconds: p.seconds, source: { kind: 'corroborated', n } }
104 }
105 return { seconds: Math.min(Math.max(p.seconds, lo), hi), source: { kind: 'measured', n } }
106}
107
108// ---------- Pace ----------
109
110export type Status = 'unknown' | 'early' | 'comfortable' | 'onPace' | 'overPace' | 'exhausted'
111
112export type Pace = {
113 /** U: fraction used 0–1 */
114 used: number
115 /** T: fraction of time elapsed 0–1 */
116 elapsed: number
117 /** Window length (seconds) */
118 W: number
119 /** Computation time (ms) */
120 now: number
121 delta: number
122 burnRate: number | null
123 timeToReset: number
124 runway: number | null
125 exhaustionAt: number | null
126 leadTime: number
127 overPaceThreshold: number
128 status: Status
129}
130
131const clamp = (x: number, lo: number, hi: number) => Math.min(Math.max(x, lo), hi)
132
133export const earlyCutoff = (W: number) => Math.max(0.001, 300 / Math.max(W, 1))
134
135/** Official limit-warning calibration (from the claude binary): (utilization, timePct) */
136export function officialThresholds(W: number): [number, number][] {
137 return W <= 6 * 3600 ? [[0.9, 0.72]] : [[0.75, 0.6], [0.5, 0.35], [0.25, 0.15]]
138}
139
140/** Over-pace ratio threshold: tightens as the window progresses (1.67 → 1.43 → 1.25 → 1.1) */
141export function overPaceThreshold(elapsed: number, W: number): number {
142 const sorted = officialThresholds(W).sort((a, b) => a[1] - b[1])
143 for (const [util, timePct] of sorted) if (elapsed <= timePct) return util / timePct
144 return 1.1
145}
146
147export function paceFromValues(used: number, elapsed: number, W: number, now: number): Pace {
148 const cutoff = earlyCutoff(W)
149 const delta = used - elapsed
150 const burnRate = elapsed > cutoff ? used / elapsed : null
151 const timeToReset = Math.max(0, W * (1 - elapsed))
152 let runway: number | null
153 if (used <= 0) runway = null
154 else if (used >= 1) runway = 0
155 else if (elapsed <= cutoff) runway = null
156 else runway = (W * elapsed * (1 - used)) / used
157 const exhaustionAt = runway != null && runway < timeToReset ? now + runway * 1000 : null
158 const over = overPaceThreshold(elapsed, W)
159 let status: Status
160 if (used >= 1) status = 'exhausted'
161 else if (elapsed <= cutoff) status = 'early'
162 else if (burnRate == null) status = 'unknown'
163 else if (burnRate > over) status = 'overPace'
164 else if (burnRate < 1 / over) status = 'comfortable'
165 else status = 'onPace'
166 return { used, elapsed, W, now, delta, burnRate, timeToReset, runway, exhaustionAt, leadTime: delta * W, overPaceThreshold: over, status }
167}
168
169/** Build pace from one observation; no reset time, or reset already passed → null (no fabricated clamp to 100%) */
170export function paceFromObs(o: Obs, W: number, now: number): Pace | null {
171 if (o.r == null) return null
172 const remaining = (o.r - now) / 1000
173 if (remaining <= 0) return null
174 const used = clamp(o.u / 100, 0, 1)
175 const elapsed = 1 - Math.min(remaining, W) / W
176 return paceFromValues(used, elapsed, W, now)
177}
178
179const STATUS_ZH: Record<Status, string> = {
180 comfortable: '富余',
181 onPace: '贴合配速',
182 overPace: '超速',
183 exhausted: '已用尽',
184 early: '窗口初期',
185 unknown: '数据不足',
186}
187const STATUS_EN: Record<Status, string> = {
188 comfortable: 'Under pace',
189 onPace: 'On pace',
190 overPace: 'Over pace',
191 exhausted: 'Used up',
192 early: 'Too early',
193 unknown: 'No data yet',
194}
195export const statusLabel = (s: Status): string => L(STATUS_ZH, STATUS_EN)[s]
196
197// ---------- Three display states: pace / usage only / empty ----------
198
199export type UsageOnlyReason =
200 | { kind: 'notStarted' }
201 | { kind: 'windowEnded' }
202 | { kind: 'noResetTime' }
203 | { kind: 'stale'; age: number }
204
205export type Display =
206 | { kind: 'pace'; pace: Pace; obs: Obs }
207 | { kind: 'usageOnly'; obs: Obs; reason: UsageOnlyReason }
208 | { kind: 'empty' }
209
210export const staleThreshold = (W: number) => Math.max(15 * 60, 0.05 * W)
211
212export function display(o: Obs | undefined, W: number, now: number, observedAt?: number): Display {
213 if (!o) return { kind: 'empty' }
214 if (observedAt != null) {
215 const age = (now - observedAt) / 1000
216 if (age > staleThreshold(W)) return { kind: 'usageOnly', obs: o, reason: { kind: 'stale', age } }
217 }
218 const pace = paceFromObs(o, W, now)
219 if (pace) return { kind: 'pace', pace, obs: o }
220 if (o.r != null) return { kind: 'usageOnly', obs: o, reason: { kind: o.r <= now ? 'windowEnded' : 'noResetTime' } }
221 return { kind: 'usageOnly', obs: o, reason: { kind: o.u <= 0 ? 'notStarted' : 'noResetTime' } }
222}
223
224export function displayUsed(d: Display): number | null {
225 if (d.kind === 'pace') return d.pace.used
226 if (d.kind === 'usageOnly') return clamp(d.obs.u / 100, 0, 1)
227 return null
228}
229
230export function displayStatus(d: Display): Status {
231 return d.kind === 'pace' ? d.pace.status : 'unknown'
232}
233
234export function displayLabel(d: Display): string {
235 if (d.kind === 'pace') return statusLabel(d.pace.status)
236 if (d.kind === 'empty') return L('数据不足', 'No data yet')
237 switch (d.reason.kind) {
238 case 'notStarted': return L('窗口未开始', 'Not started')
239 case 'windowEnded': return L('窗口已结束', 'Window ended')
240 case 'noResetTime': return L('无重置时刻', 'No reset time')
241 case 'stale': return L('数据已过期', 'Stale data')
242 }
243}
244
245export function displayHeadline(d: Display): string {
246 if (d.kind === 'pace') return headline(d.pace)
247 if (d.kind === 'empty') return L('无法计算配速', "Can't compute pace")
248 switch (d.reason.kind) {
249 case 'notStarted': return L('额度已重置', 'Quota has reset')
250 case 'windowEnded': return L('窗口已重置', 'Window has reset')
251 case 'noResetTime': return L('只能显示用量', 'Usage only')
252 case 'stale': return L('拿不到当前数据', 'No current data')
253 }
254}
255
256export function displayDetail(d: Display): string | null {
257 if (d.kind !== 'usageOnly') return null
258 switch (d.reason.kind) {
259 case 'notStarted': return L('还没开始用。没有活跃窗口,接口就不返回重置时刻,配速无从谈起', 'Not used yet. With no active window the API returns no reset time, so there is no pace to compute')
260 case 'windowEnded': return L('重置时刻已过,这份数据描述的是上一个窗口。等下次取数刷新', 'The reset time has passed, so this data describes the previous window. Waiting for the next refresh')
261 case 'noResetTime': return L('接口没给重置时刻,定位不了窗口相位', 'The API gave no reset time, so the position in the window is unknown')
262 case 'stale': {
263 const mins = Math.floor(d.reason.age / 60)
264 const ago = mins >= 60 ? L(`${Math.floor(mins / 60)} 小时 ${mins % 60} 分`, `${Math.floor(mins / 60)}h ${mins % 60}m`) : L(`${mins} 分`, `${mins}m`)
265 return L(`这份数据是 ${ago}前的。拿旧用量配当前时间算出的配速会偏乐观,所以不给结论`, `This data is ${ago} old. Pairing old usage with the current time would look too optimistic, so no verdict`)
266 }
267 }
268}
269
270// ---------- Narration ----------
271
272/** Chinese: 1 day 2 hours / 1 hour 30 min / 5 min; English: 1d 2h / 1h 30m / 5m (truncated, not rounded) */
273export function formatDuration(seconds: number): string {
274 const s = Math.trunc(Math.abs(seconds))
275 const days = Math.floor(s / 86400)
276 const hours = Math.floor((s % 86400) / 3600)
277 const minutes = Math.floor((s % 3600) / 60)
278 if (days > 0) return hours > 0 ? L(`${days} 天 ${hours} 小时`, `${days}d ${hours}h`) : L(`${days} 天`, `${days}d`)
279 if (hours > 0) return minutes > 0 ? L(`${hours} 小时 ${minutes} 分`, `${hours}h ${minutes}m`) : L(`${hours} 小时`, `${hours}h`)
280 return L(`${minutes} 分`, `${minutes}m`)
281}
282
283export function headline(p: Pace): string {
284 if (p.status === 'exhausted') return L('额度已用尽', 'Quota used up')
285 if (p.status === 'early') return L('窗口刚开始,还看不出配速', 'Window just started, too early to tell')
286 if (p.status === 'unknown') return L('数据不足', 'Not enough data')
287 if (Math.abs(p.leadTime) < 3600) return L('与时间进度基本同步', 'In step with the clock')
288 const d = formatDuration(p.leadTime)
289 return p.leadTime > 0 ? L(`用量超前 ${d}`, `Usage ${d} ahead of the clock`) : L(`用量落后 ${d}`, `Usage ${d} behind the clock`)
290}
291
292const WEEKDAYS_ZH = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
293const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
294const pad2 = (n: number) => String(n).padStart(2, '0')
295
296function clockText(at: number, withWeekday: boolean): string {
297 const d = new Date(at)
298 const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
299 return withWeekday ? `${L(WEEKDAYS_ZH, WEEKDAYS_EN)[d.getDay()]} ${hm}` : hm
300}
301
302export function forecast(p: Pace): string | null {
303 if (p.status === 'exhausted') return L(`${formatDuration(p.timeToReset)}后重置`, `Resets in ${formatDuration(p.timeToReset)}`)
304 if (p.status === 'early' || p.burnRate == null) return null
305 if (p.exhaustionAt != null && p.runway != null) {
306 const at = clockText(p.exhaustionAt, p.runway >= 86400)
307 return L(`照这个速度,约 ${at} 用完`, `At this rate, runs out ~${at}`)
308 }
309 const end = Math.round(p.burnRate * 100)
310 return L(`照这个速度,重置时约用到 ${end}%`, `At this rate, ~${end}% by reset`)
311}
312
313const fmt4g = (x: number) => String(Number(x.toPrecision(4)))
314
315export function windowNote(len: WindowLength): string {
316 const days = len.seconds / 86400
317 const span = days >= 1 ? L(`${fmt4g(days)} 天`, `${fmt4g(days)}d`) : L(`${fmt4g(len.seconds / 3600)} 小时`, `${fmt4g(len.seconds / 3600)}h`)
318 return L(`窗口 ${span} · ${sourceBadge(len.source)}`, `Window ${span} · ${sourceBadge(len.source)}`)
319}
320
321/** Short menu-bar text: pace delta Δ; the leading space is intentional, as in the original */
322export function menuBarText(d: Display): string {
323 if (d.kind === 'empty') return ' —'
324 if (d.kind === 'usageOnly') return ` ${Math.round(d.obs.u)}%`
325 const p = d.pace
326 if (p.status === 'early') return ' ·'
327 if (p.status === 'exhausted') return L(' 用尽', ' out')
328 if (p.status === 'unknown') return ' —'
329 const x = p.delta * 100
330 return ` ${x < 0 ? '-' : '+'}${Math.abs(Math.round(x))}%`
331}
332
333export function pct(x: number): string {
334 const v = x * 100
335 return v < 10 ? `${v.toFixed(1)}%` : `${Math.round(v)}%`
336}
337
338/** Full description for screen readers and Svg alt */
339export function accessibilityLabel(d: Display, name: string): string {
340 if (d.kind === 'empty') return L(`${name},暂无数据`, `${name}, no data yet`)
341 if (d.kind === 'usageOnly') {
342 const why = L(
343 { notStarted: '窗口尚未开始', windowEnded: '窗口已结束', noResetTime: '缺少重置时刻,算不出配速', stale: '数据过旧,不再据此判断配速' },
344 { notStarted: 'window not started yet', windowEnded: 'window ended', noResetTime: 'no reset time, so no pace', stale: 'data too old to judge pace' },
345 )[d.reason.kind]
346 const u = pct(clamp(d.obs.u / 100, 0, 1))
347 return L(`${name},已用 ${u},${why}`, `${name}, ${u} used, ${why}`)
348 }
349 const p = d.pace
350 const parts = [name, L(`已用 ${pct(p.used)}`, `${pct(p.used)} used`), L(`时间过了 ${pct(p.elapsed)}`, `${pct(p.elapsed)} of the time elapsed`)]
351 const gap = pct(Math.abs(p.delta))
352 if (Math.abs(p.delta) >= 0.005) parts.push(p.delta > 0 ? L(`超出时间进度 ${gap}`, `${gap} ahead of the clock`) : L(`落后时间进度 ${gap}`, `${gap} behind the clock`))
353 else parts.push(L('与时间进度基本同步', 'in step with the clock'))
354 parts.push(statusLabel(p.status), L(`${formatDuration(p.timeToReset)}后重置`, `resets in ${formatDuration(p.timeToReset)}`))
355 return parts.join(L(',', ', '))
356}
357
358// ---------- Quota catalog ----------
359
360export const isWeekly = (key: string) => key.startsWith('seven_day') || key.startsWith('weekly_')
361
362const capitalize = (s: string) => s.replace(/\b([a-z])([a-z]*)/gi, (_, a: string, b: string) => a.toUpperCase() + b.toLowerCase())
363
364export function displayName(key: string, serverName?: string): string {
365 if (serverName) return serverName
366 if (key === 'five_hour') return L('5 小时', '5-hour')
367 if (key === 'seven_day') return L('7 天', '7-day')
368 if (key === 'spend') return L('额度包', 'Credits')
369 if (key === 'weekly_scoped') return L('分模型', 'Per-model')
370 if (key.startsWith('weekly_')) return capitalize(key.slice(7).replace(/_/g, ' '))
371 return key.replace(/_/g, ' ')
372}
373
374/** Short name for the quota picker dropdown: Fable / All models / 5 hours */
375export function pickName(key: string, serverName?: string): string {
376 if (key === 'five_hour') return L('5 小时', '5 hours')
377 if (key === 'seven_day') return L('全部模型', 'All models')
378 return displayName(key, serverName)
379}
380
381export function fullName(key: string, serverName?: string): string {
382 const week = L('7 天', '7-day')
383 if (serverName) return isWeekly(key) ? `${week} · ${serverName}` : serverName
384 if (key === 'five_hour') return L('5 小时会话额度', '5-hour session')
385 if (key === 'seven_day') return L('7 天 · 全部模型', '7-day · All models')
386 if (isWeekly(key)) return `${week} · ${displayName(key)}`
387 return displayName(key)
388}
389
390const order = (key: string) => (key === 'five_hour' ? 0 : key === 'seven_day' ? 1 : isWeekly(key) ? 2 : 9)
391
392export function sortKeys(keys: readonly string[]): string[] {
393 return [...keys].sort((a, b) => order(a) - order(b) || (a < b ? -1 : a > b ? 1 : 0))
394}
395
396export function isKnown(key: string): boolean {
397 const k = key.toLowerCase()
398 return k.includes('five_hour') || k.includes('seven_day') || k.includes('week') || k.includes('session') || k === 'primary'
399}
400
401/** Keys that had a reset time in history and were used or are known quotas (filters out spend / extra_usage / experimental zero-usage keys) */
402export function discoveredKeys(samples: readonly Sample[]): string[] {
403 const hadReset = new Set<string>()
404 const hadUse = new Set<string>()
405 for (const s of samples) {
406 for (const [k, o] of Object.entries(s.w)) {
407 if (o.r != null) hadReset.add(k)
408 if (o.u > 0) hadUse.add(k)
409 }
410 }
411 return sortKeys([...hadReset].filter(k => hadUse.has(k) || isKnown(k)))
412}
413
414export function neighbor(key: string, keys: readonly string[], offset: number): string | null {
415 if (keys.length < 2) return null
416 const i = keys.indexOf(key)
417 if (i < 0) return null
418 return keys[(((i + offset) % keys.length) + keys.length) % keys.length]
419}
420
421/** Latest observation for a key, with its observation time */
422export function latestObs(samples: readonly Sample[], key: string): { obs: Obs; at: number } | null {
423 for (let i = samples.length - 1; i >= 0; i--) {
424 const o = samples[i].w[key]
425 if (o) return { obs: o, at: samples[i].t }
426 }
427 return null
428}
429
430// ---------- Bottleneck: which quota the band above the input box shows ----------
431
432export function pickBottleneck(cands: readonly { key: string; pace: Pace | null }[]): string | null {
433 const tier = (p: Pace | null) => (!p ? 3 : p.used >= 1 ? 0 : p.runway != null && p.runway < p.timeToReset ? 1 : 2)
434 let best: { key: string; pace: Pace | null } | null = null
435 for (const c of cands) {
436 if (!best) { best = c; continue }
437 const a = tier(c.pace)
438 const b = tier(best.pace)
439 if (a !== b) { if (a < b) best = c; continue }
440 const cp = c.pace
441 const bp = best.pace
442 let better = false
443 if (a === 0 && cp && bp) better = cp.timeToReset > bp.timeToReset
444 else if (a === 1 && cp && bp) better = (cp.runway ?? Infinity) < (bp.runway ?? Infinity)
445 else if (a === 2 && cp && bp) better = (cp.burnRate ?? 0) > (bp.burnRate ?? 0)
446 else if (a === 3) better = c.key < best.key
447 if (better) best = c
448 }
449 return best ? best.key : null
450}
451
452// ---------- Usage trajectory (burn-up) ----------
453
454export type Point = { elapsed: number; used: number; t: number }
455export type Trend = 'accelerating' | 'easing' | 'steady'
456export type Series = {
457 key: string
458 reset: number
459 segments: Point[][]
460 gaps: { from: Point; to: Point }[]
461 midResets: { before: Point; after: Point }[]
462 projection: { from: Point; to: { elapsed: number; used: number } } | null
463 exhaustionAt: number | null
464 projectionCrossesGap: boolean
465 trend: Trend | null
466 peak: number
467}
468
469const WINDOW_TOLERANCE_MS = 90_000
470const RESET_DROP = 0.05
471const gapThreshold = (W: number) => Math.max(900, 0.02 * W)
472
473/** Windows (clustered by resets_at; within 90 s counts as the same one), newest first */
474export function windowsOf(key: string, samples: readonly Sample[]): number[] {
475 const resets = samples.map(s => s.w[key]?.r).filter((r): r is number => r != null).sort((a, b) => a - b)
476 const out: number[] = []
477 for (const r of resets) if (out.length === 0 || r - out[out.length - 1] > WINDOW_TOLERANCE_MS) out.push(r)
478 return out.reverse()
479}
480
481function slopeWithin(points: readonly Point[], lookback = 0.15): number | null {
482 if (points.length < 2) return null
483 const last = points[points.length - 1]
484 let anchor = points[0]
485 for (let i = points.length - 2; i >= 0; i--) {
486 if (last.elapsed - points[i].elapsed >= lookback) { anchor = points[i]; break }
487 }
488 if (anchor === last) anchor = points[0]
489 const dt = last.elapsed - anchor.elapsed
490 if (dt <= 0.001) return null
491 return (last.used - anchor.used) / dt
492}
493
494export function buildSeries(key: string, reset: number, samples: readonly Sample[], W: number): Series | null {
495 const pts: Point[] = []
496 for (const s of samples) {
497 const o = s.w[key]
498 if (!o || o.r == null || Math.abs(o.r - reset) > WINDOW_TOLERANCE_MS) continue
499 const remain = (reset - s.t) / 1000
500 if (remain < 0 || remain > W) continue
501 pts.push({ elapsed: 1 - remain / W, used: clamp(o.u / 100, 0, 1), t: s.t })
502 }
503 if (pts.length < 2) return null
504 pts.sort((a, b) => a.t - b.t)
505 const segments: Point[][] = [[pts[0]]]
506 const gaps: Series['gaps'] = []
507 const midResets: Series['midResets'] = []
508 for (let i = 1; i < pts.length; i++) {
509 const prev = pts[i - 1]
510 const p = pts[i]
511 if ((p.t - prev.t) / 1000 > gapThreshold(W)) { gaps.push({ from: prev, to: p }); segments.push([p]) }
512 else if (prev.used - p.used > RESET_DROP) { midResets.push({ before: prev, after: p }); segments.push([p]) }
513 else segments[segments.length - 1].push(p)
514 }
515 const last = pts[pts.length - 1]
516 let slope = slopeWithin(segments[segments.length - 1])
517 let projectionCrossesGap = false
518 if (slope == null) { slope = slopeWithin(pts); projectionCrossesGap = slope != null && segments.length > 1 }
519 let projection: Series['projection'] = null
520 let exhaustionAt: number | null = null
521 if (slope != null && slope > 0) {
522 const projected = last.used + slope * (1 - last.elapsed)
523 if (projected <= 1) projection = { from: last, to: { elapsed: 1, used: projected } }
524 else {
525 const at = Math.min(1, last.elapsed + (1 - last.used) / slope)
526 projection = { from: last, to: { elapsed: at, used: 1 } }
527 exhaustionAt = at
528 }
529 }
530 const overall = last.elapsed > 0.01 ? last.used / last.elapsed : null
531 let trend: Trend | null = null
532 if (slope != null && overall != null && overall > 0.01) {
533 const ratio = slope / overall
534 trend = ratio > 1.25 ? 'accelerating' : ratio < 0.75 ? 'easing' : 'steady'
535 }
536 return { key, reset, segments, gaps, midResets, projection, exhaustionAt, projectionCrossesGap, trend, peak: Math.max(...pts.map(q => q.used)) }
537}
538
539export type Range = 'current' | 'month' | 'all'
540export const rangeLabel = (r: Range): string =>
541 L({ current: '本窗口', month: '近一月', all: '全部' }, { current: 'This window', month: 'Last month', all: 'All' })[r]
542
543export type OverlayEntry = { series: Series; recency: number; isCurrent: boolean }
544/** inRange: number of windows in range; beyond MAX_OVERLAY overlaid, only an evenly spaced subset is drawn (entries fewer than inRange) */
545export type Overlay = { entries: OverlayEntry[]; totalWindows: number; inRange: number; focused: OverlayEntry | null; current: OverlayEntry | null }
546
547/** Max overlaid windows: Svg source is capped at 131072 chars; past that the engine rejects the whole pane */
548export const MAX_OVERLAY = 60
549
550/** Evenly pick n from windows ordered newest to oldest, keeping the newest and oldest */
551function spread(list: readonly number[], n: number): number[] {
552 if (list.length <= n) return [...list]
553 const out: number[] = []
554 for (let i = 0; i < n; i++) out.push(list[Math.round((i * (list.length - 1)) / (n - 1))])
555 return [...new Set(out)]
556}
557
558export function buildOverlay(key: string, samples: readonly Sample[], W: number, range: Range, offset: number, now: number): Overlay {
559 const all = windowsOf(key, samples)
560 let chosen: number[]
561 if (all.length === 0) chosen = []
562 else if (range === 'current') chosen = [all[clamp(offset, 0, all.length - 1)]]
563 else if (range === 'month') {
564 chosen = all.filter(r => now - r <= 31 * 86400 * 1000)
565 if (chosen.length === 0) chosen = [all[0]]
566 } else chosen = all
567 const inRange = chosen.length
568 chosen = spread(chosen, MAX_OVERLAY)
569 const newest = all[0]
570 const built = [...chosen].sort((a, b) => a - b)
571 .map(r => buildSeries(key, r, samples, W))
572 .filter((s): s is Series => s != null)
573 const entries = built.map((series, i) => ({
574 series,
575 recency: built.length > 1 ? i / (built.length - 1) : 1,
576 isCurrent: newest != null && Math.abs(series.reset - newest) <= WINDOW_TOLERANCE_MS,
577 }))
578 return {
579 entries,
580 totalWindows: all.length,
581 inRange,
582 focused: entries.length ? entries[entries.length - 1] : null,
583 current: entries.find(e => e.isCurrent) ?? null,
584 }
585}
586
587const md = (t: number) => { const d = new Date(t); return `${d.getMonth() + 1}/${d.getDate()}` }
588
589export function windowLabel(reset: number, W: number, isCurrent: boolean): string {
590 return `${md(reset - W * 1000)} – ${md(reset)}${isCurrent ? L('(本窗口)', ' (current)') : ''}`
591}
592
593/** Caption under the burn-up: peak trend for multiple windows, peak for a single past window; the current window relies on chart and legend, no extra text */
594export function overlayCaption(o: Overlay): string | null {
595 if (o.entries.length > 1) {
596 const first = Math.round(o.entries[0].series.peak * 100)
597 const last = Math.round(o.entries[o.entries.length - 1].series.peak * 100)
598 const used = o.entries.filter(e => e.series.peak >= 1).length
599 const ran = used ? L(`,${used} 个窗口用完过`, `, ${used} ran out`) : ''
600 const thinned = o.inRange > o.entries.length ? L(`(从 ${o.inRange} 个窗口里均匀取 ${o.entries.length} 个)`, ` (${o.entries.length} of ${o.inRange} windows, evenly spaced)`) : ''
601 return L(`峰值 ${first}% → ${last}%`, `Peak ${first}% → ${last}%`) + ran + thinned
602 }
603 const f = o.focused
604 if (f && !f.isCurrent) return L(`峰值 ${Math.round(f.series.peak * 100)}%`, `Peak ${Math.round(f.series.peak * 100)}%`)
605 return null
606}
607
608export function legendDates(o: Overlay): { from: string; to: string } | null {
609 if (o.entries.length < 2) return null
610 return { from: md(o.entries[0].series.reset), to: md(o.entries[o.entries.length - 1].series.reset) }
611}
612
613// ---------- Sample storage ----------
614
615/** Whether two observations are equivalent: same key set, usage diff < 0.001, reset time diff ≤ 5 s */
616export function sameSample(a: Sample, b: Sample): boolean {
617 const ka = Object.keys(a.w).sort()
618 const kb = Object.keys(b.w).sort()
619 if (ka.join() !== kb.join()) return false
620 return ka.every(k => {
621 const x = a.w[k]
622 const y = b.w[k]
623 if (Math.abs(x.u - y.u) >= 0.001) return false
624 if ((x.r == null) !== (y.r == null)) return false
625 return x.r == null || Math.abs(x.r - (y.r as number)) <= 5000
626 })
627}
628
629/**
630 * Append a sample: skip if equivalent to the latest one with the same key set and under 5 minutes apart; trim from the front past the cap.
631 * Sources cover different keys (live session has only 5h/7d, local cache also has Fable), so dedup only works against the same source.
632 */
633export function appendSample(list: readonly Sample[], s: Sample, max = 8000): Sample[] {
634 const sig = Object.keys(s.w).sort().join()
635 let last: Sample | undefined
636 for (let i = list.length - 1; i >= 0 && i >= list.length - 50; i--) {
637 if (Object.keys(list[i].w).sort().join() === sig) { last = list[i]; break }
638 }
639 if (last && s.t - last.t < 300_000 && sameSample(last, s)) return list as Sample[]
640 const out = [...list, s]
641 out.sort((a, b) => a.t - b.t)
642 return out.length > max ? out.slice(out.length - max) : out
643}
644
645/**
646 * Merge two sample lists: samples at the same ms combine into one (the latter wins); then dedup by appendSample's rules —
647 * when several sessions record the same reading at different ms, only the earliest within 5 minutes is kept.
648 */
649export function mergeSamples(a: readonly Sample[], b: readonly Sample[], max = 8000): Sample[] {
650 const byT = new Map<number, Sample>()
651 for (const s of a) byT.set(s.t, s)
652 for (const s of b) {
653 const prev = byT.get(s.t)
654 byT.set(s.t, prev ? { t: s.t, w: { ...prev.w, ...s.w } } : s)
655 }
656 const sorted = [...byT.values()].sort((x, y) => x.t - y.t)
657 const lastBySig = new Map<string, Sample>()
658 const out: Sample[] = []
659 for (const s of sorted) {
660 const sig = Object.keys(s.w).sort().join()
661 const last = lastBySig.get(sig)
662 if (last && s.t - last.t < 300_000 && sameSample(last, s)) continue
663 lastBySig.set(sig, s)
664 out.push(s)
665 }
666 return out.length > max ? out.slice(out.length - max) : out
667}
668
669// ---------- Data source parsing ----------
670
671function parseReset(v: unknown): number | undefined {
672 if (typeof v === 'number' && v > 0) return v < 1e12 ? v * 1000 : v
673 if (typeof v === 'string' && v) {
674 const t = Date.parse(v)
675 if (Number.isFinite(t)) return t
676 const n = Number(v)
677 if (Number.isFinite(n) && n > 0) return n < 1e12 ? n * 1000 : n
678 }
679 return undefined
680}
681
682const pctOf = (raw: number) => Math.min(Math.max(raw, 0), 100)
683
684export function quotaKey(kind: string | undefined, scopeName: string | undefined): string {
685 if (kind === 'session') return 'five_hour'
686 if (kind === 'weekly_all') return 'seven_day'
687 if (kind === 'weekly_scoped') return scopeName ? 'weekly_' + scopeName.toLowerCase().replace(/ /g, '_') : 'weekly_scoped'
688 return kind ?? 'unknown'
689}
690
691/** Parse an API-shaped usage response (top-level keys as fallback, limits[] takes precedence, incl. per-model quotas) */
692export function parseUtilization(root: unknown): Record<string, Obs> {
693 const out: Record<string, Obs> = {}
694 if (!root || typeof root !== 'object') return out
695 const obj = root as Record<string, unknown>
696 for (const [k, v] of Object.entries(obj)) {
697 if (!v || typeof v !== 'object' || Array.isArray(v)) continue
698 const o = v as Record<string, unknown>
699 const raw = typeof o.utilization === 'number' ? o.utilization : typeof o.percent === 'number' ? o.percent : null
700 if (raw == null) continue
701 out[k] = { u: pctOf(raw), r: parseReset(o.resets_at) }
702 }
703 for (const l of Array.isArray(obj.limits) ? obj.limits : []) {
704 if (!l || typeof l !== 'object') continue
705 const o = l as Record<string, unknown>
706 if (typeof o.percent !== 'number') continue
707 const scope = o.scope as { model?: { display_name?: unknown } } | undefined
708 const name = typeof scope?.model?.display_name === 'string' ? scope.model.display_name : undefined
709 const key = quotaKey(typeof o.kind === 'string' ? o.kind : undefined, name)
710 out[key] = { u: pctOf(o.percent), r: parseReset(o.resets_at), ...(name ? { n: name } : {}) }
711 }
712 // If a per-model quota appears both at top level (seven_day_<x>) and in limits[] (weekly_<x>), keep only the limits[] one
713 for (const k of Object.keys(out)) {
714 if (k.startsWith('seven_day_') && out['weekly_' + k.slice('seven_day_'.length)]) delete out[k]
715 }
716 return out
717}
718
719/** cachedUsageUtilization in ~/.claude.json: the official reader uses a 1-hour TTL; expired counts as absent */
720export function parseClaudeJsonCache(text: string, now: number): Sample | null {
721 let root: unknown
722 try { root = JSON.parse(text) } catch { return null }
723 const c = (root as { cachedUsageUtilization?: { fetchedAtMs?: unknown; utilization?: unknown } })?.cachedUsageUtilization
724 if (!c || typeof c.fetchedAtMs !== 'number') return null
725 const age = now - c.fetchedAtMs
726 if (age < 0 || age > 3600_000) return null
727 const w = parseUtilization(c.utilization)
728 return Object.keys(w).length ? { t: c.fetchedAtMs, w } : null
729}
730
731const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
732
733/** Wall-clock time in a time zone → ms timestamp; uses the local zone when the runtime has no time zone data */
734/** Common US time zone abbreviations → IANA names (Intl rejects abbreviations); Intl resolves DST for the location by date */
735const TZ_ALIAS: Record<string, string> = {
736 PST: 'America/Los_Angeles', PDT: 'America/Los_Angeles',
737 MST: 'America/Denver', MDT: 'America/Denver',
738 CST: 'America/Chicago', CDT: 'America/Chicago',
739 EST: 'America/New_York', EDT: 'America/New_York',
740}
741
742function zonedToUtc(y: number, mo: number, d: number, h: number, mi: number, tz?: string): number {
743 const local = () => new Date(y, mo, d, h, mi).getTime()
744 if (!tz) return local()
745 try {
746 const guess = Date.UTC(y, mo, d, h, mi)
747 const fmt = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
748 const p: Record<string, string> = {}
749 for (const part of fmt.formatToParts(new Date(guess))) p[part.type] = part.value
750 const wall = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute))
751 return guess - (wall - guess)
752 } catch {
753 return local()
754 }
755}
756
757/** Reset time in `/usage`: "Oct 7 at 5:59pm (Asia/Singapore)"; the date may be omitted (today); year is the nearest future one */
758export function parseResetText(text: string, now: number): number | undefined {
759 const m = /^(?:([A-Za-z]{3})[a-z]*\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\s+at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i.exec(text.trim())
760 // 12-hour format carries am/pm; 24-hour format must include minutes (17:59), so the 2 in "2h 30m" isn't read as an hour
761 if (!m || (!m[6] && m[5] == null)) return undefined
762 const h = m[6] ? (Number(m[4]) % 12) + (m[6].toLowerCase() === 'pm' ? 12 : 0) : Number(m[4])
763 if (h > 23) return undefined
764 const mi = Number(m[5] ?? 0)
765 const tz = m[7] ? (TZ_ALIAS[m[7].trim().toUpperCase()] ?? m[7].trim()) : undefined
766 // "Today" and "this year" use the time zone in parentheses: if the host zone is on a different date, the host would be off by a day
767 const today = dateIn(now, tz)
768 if (m[1]) {
769 const mo = MONTHS.indexOf(m[1].toLowerCase())
770 if (mo < 0) return undefined
771 const d = Number(m[2])
772 if (m[3]) return zonedToUtc(Number(m[3]), mo, d, h, mi, tz)
773 const t = zonedToUtc(today.y, mo, d, h, mi, tz)
774 // Only a date over half a year back is next year's ("Jan 2" seen in late Dec); a few days back is the past, left to the display to mark as reset
775 return t < now - 180 * 86400_000 ? zonedToUtc(today.y + 1, mo, d, h, mi, tz) : t
776 }
777 const t = zonedToUtc(today.y, today.mo, today.d, h, mi, tz)
778 return t < now ? t + 86400_000 : t
779}
780
781/** Year, month (0-based), day of now in a time zone; an unrecognized zone falls back to the host zone */
782function dateIn(now: number, tz?: string): { y: number; mo: number; d: number } {
783 if (tz) {
784 try {
785 const p: Record<string, string> = {}
786 for (const part of new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(new Date(now))) p[part.type] = part.value
787 return { y: Number(p.year), mo: Number(p.month) - 1, d: Number(p.day) }
788 } catch {}
789 }
790 const t = new Date(now)
791 return { y: t.getFullYear(), mo: t.getMonth(), d: t.getDate() }
792}
793
794/**
795 * Text output of `claude -p /usage` → one sample:
796 * "Current session: 3% used · resets …" → five_hour; "Current week (all models)" → seven_day;
797 * "Current week (Fable)" → weekly_fable (with display name). Returns null if unrecognized.
798 */
799export function parseUsageCommand(text: string, now: number): Sample | null {
800 const w: Record<string, Obs> = {}
801 // Strip color escapes and trailing whitespace before matching (\r counts as trailing whitespace)
802 for (const raw of text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').split('\n')) {
803 const line = raw.trimEnd()
804 const m = /^\s*Current (session|week \(([^)]+)\)):\s*([\d.]+)%\s*used(?:\s*[·•-]\s*resets\s+(.+))?$/i.exec(line)
805 if (!m) continue
806 const scope = m[2]
807 const key = !scope ? 'five_hour' : /^all models$/i.test(scope) ? 'seven_day' : quotaKey('weekly_scoped', scope)
808 const r = m[4] ? parseResetText(m[4], now) : undefined
809 w[key] = { u: Math.min(Math.max(Number(m[3]), 0), 100), ...(r != null ? { r } : {}), ...(scope && key.startsWith('weekly_') && key !== 'seven_day' ? { n: scope } : {}) }
810 }
811 return Object.keys(w).length ? { t: now, w } : null
812}
813
814/** Per-model weekly quota (e.g. weekly_fable): hints must include the model name */
815export const isModelScoped = (key: string) => key.startsWith('weekly_') && key !== 'weekly_scoped'
816
817/** Model id → family name (claude-fable-5-1 → fable), matching per-model quota weekly_<family> */
818export function modelFamily(model: string | undefined): string | null {
819 const m = /(fable|opus|sonnet|haiku)/i.exec(model ?? '')
820 return m ? m[1].toLowerCase() : null
821}
822
823/** Time of the latest reply across local sessions: any = any model, byModel = per family */
824export type Activity = { any?: number; byModel: Record<string, number> }
825
826/**
827 * When this quota was last used, taking the later of:
828 * · the latest local reply (5-hour / 7-day: any model; per-model quota: that family's models) — the limit percentage is coarse,
829 * steady use may not raise it 1% in half an hour, so watching increases alone would misread it as unused;
830 * · the last observed usage increase (covers use on other devices and on claude.ai).
831 * If neither exists, return the earliest observation time as a lower bound.
832 */
833export function lastUsed(samples: readonly Sample[], key: string, activity?: Activity): { at: number; isLowerBound: boolean } | null {
834 // Per-model quotas match local replies by family: weekly_fable and weekly_claude_fable both map to fable
835 const act = isModelScoped(key) ? activity?.byModel[modelFamily(key) ?? key.slice('weekly_'.length)] : activity?.any
836 const seen = lastIncrease(samples, key)
837 if (act != null && (!seen || seen.isLowerBound || act >= seen.at)) return { at: act, isLowerBound: false }
838 return seen
839}
840
841/**
842 * Time of the last usage increase. Compared with the reading at the last counted increase, it must rise half a point: two sources of different precision
843 * (session 64.2, cache 64) alternating won't count as an increase; on a window reset (a drop of over 5 points) restart from the new reading.
844 */
845function lastIncrease(samples: readonly Sample[], key: string): { at: number; isLowerBound: boolean } | null {
846 let base: number | undefined
847 let first: number | undefined
848 let last: number | undefined
849 for (const s of samples) {
850 const o = s.w[key]
851 if (!o) continue
852 if (first == null) first = s.t
853 if (base == null || o.u < base - 5) base = o.u
854 else if (o.u >= base + 0.5) { last = s.t; base = o.u }
855 }
856 if (last != null) return { at: last, isLowerBound: false }
857 return first != null ? { at: first, isLowerBound: true } : null
858}
859
860/** ~/.weektoken/samples.jsonl from the WeekToken macOS app: each line {t: seconds, rl: {key: {used_percentage, resets_at, display_name?}}} */
861export function parseSamplesJsonl(text: string, nowMs = Date.now()): Sample[] {
862 const out: Sample[] = []
863 for (const line of text.split('\n')) {
864 if (!line.trim()) continue
865 let row: { t?: unknown; rl?: unknown }
866 try { row = JSON.parse(line) } catch { continue }
867 if (typeof row.t !== 'number' || !row.rl || typeof row.rl !== 'object') continue
868 // Convention is seconds; ms is accepted too. Drop rows with absurd times (before 2020 or after tomorrow)
869 const t = row.t > 1e12 ? row.t : row.t * 1000
870 if (t < 1_577_836_800_000 || t > nowMs + 86400_000) continue
871 const w: Record<string, Obs> = {}
872 for (const [k, v] of Object.entries(row.rl as Record<string, unknown>)) {
873 if (!v || typeof v !== 'object') continue
874 const o = v as Record<string, unknown>
875 const used = typeof o.used_percentage === 'number' ? o.used_percentage : typeof o.used_percent === 'number' ? o.used_percent : null
876 if (used == null) continue
877 let r = parseReset(o.resets_at)
878 if (r == null && typeof o.resets_in_seconds === 'number') r = t + o.resets_in_seconds * 1000
879 w[k] = { u: pctOf(used), r, ...(typeof o.display_name === 'string' ? { n: o.display_name } : {}) }
880 }
881 if (Object.keys(w).length) out.push({ t, w })
882 }
883 return out.sort((a, b) => a.t - b.t)
884}
885hooks/theme.ts 47 lines1// Colors: values from PaceTheme / PaceVisuals in the WeekToken macOS app.
2// Status colors (pace-driven) are shared by light and dark mode; quota identity colors have light/dark variants.
3
4import type { Status } from './pace.ts'
5
6export type Tri = { start: string; end: string; solid: string }
7
8export const STATUS: Record<Status, Tri> = {
9 comfortable: { start: '#4FC94F', end: '#0A8A0A', solid: '#0CA30C' },
10 onPace: { start: '#2FA85F', end: '#05663A', solid: '#0A7D46' },
11 overPace: { start: '#FBC957', end: '#C98C00', solid: '#FAB219' },
12 exhausted: { start: '#E06A6A', end: '#A32C2C', solid: '#D03B3B' },
13 early: { start: '#D6DAE0', end: '#6B7280', solid: '#9AA1AC' },
14 unknown: { start: '#DDE0E4', end: '#8E949C', solid: '#A3A8B0' },
15}
16
17export type Identity = { light: Tri; dark: Tri }
18
19const tri = (s: string, e: string, so: string): Tri => ({ start: '#' + s, end: '#' + e, solid: '#' + so })
20
21const FIXED: Record<string, Identity> = {
22 seven_day: { light: tri('6BA3E8', '1B5AA8', '2A78D6'), dark: tri('74B0F0', '2A6DC4', '3987E5') },
23 five_hour: { light: tri('5FCFA4', '12805A', '1BAF7A'), dark: tri('4FC79A', '0F7A56', '199E70') },
24 weekly_fable: { light: tri('8B7DD8', '342A78', '4A3AA7'), dark: tri('B3ABF0', '6A5FC0', '9085E9') },
25}
26
27// Other keys hash by name into four slots (start-light, start-dark, end-light, end-dark, solid-light, solid-dark)
28const SLOTS: [string, string, string, string, string, string][] = [
29 ['F08A5C', 'E07A4A', 'C24E1C', 'B84518', 'EB6834', 'D95926'], // orange
30 ['F0A4C0', 'E58FB0', 'D05A85', 'C04A75', 'E87BA4', 'D55181'], // magenta
31 ['EDB84D', 'D9A030', 'C08000', 'A86E00', 'EDA100', 'C98500'], // yellow
32 ['EE7A79', 'F08585', 'C03332', 'D04A4A', 'E34948', 'E66767'], // red
33]
34
35export function identity(key: string): Identity {
36 const f = FIXED[key]
37 if (f) return f
38 let h = 0
39 for (const ch of key) h = (h * 31 + (ch.codePointAt(0) ?? 0)) & 0xffffff
40 const s = SLOTS[h % 4]
41 return { light: tri(s[0], s[2], s[4]), dark: tri(s[1], s[3], s[5]) }
42}
43
44/** Single color for native Text: the dark variant's solid, bright and legible on both light and dark backgrounds */
45export const identityText = (key: string) => identity(key).dark.solid
46export const statusText = (s: Status) => STATUS[s].solid
47hooks/svg.ts 421 lines1// SVG drawing: pace rings, the progress bar above the input box, usage trace (burn-up, legend drawn inside the chart).
2// Geometry and colors taken from the WeekToken macOS app (PaceRings / PaceBullet / BulletRenderer / BurnUpChart).
3// Light/dark: SVG adapts via prefers-color-scheme and declares color-scheme: light dark — a chart in the host sandbox frame
4// without it gets a white backdrop and light-mode drawing when the frame's scheme doesn't match the app (observed in dark app).
5// Neutral elements (track, grid, tick labels) use translucent gray readable on both light and dark backgrounds.
6// Each SVG scopes styles and gradients/filters with a unique id derived from its input: whether the host treats it as a standalone image
7// or inlines it into the same page, colors never leak between charts. Pure functions, output strings only.
8
9import { legendDates, windowLabel, type Overlay, type OverlayEntry, type Pace, type Series, type Status } from './pace.ts'
10import { STATUS, type Identity, type Tri } from './theme.ts'
11import { harmonizeHue, hexToOklch, oklchToHex } from './color.ts'
12import { getLang, L } from './i18n.ts'
13
14const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string))
15const f = (n: number) => (Math.round(n * 100) / 100).toString()
16const clamp = (x: number, lo: number, hi: number) => Math.min(Math.max(x, lo), hi)
17const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', sans-serif`
18
19/** Stable id derived from kind and input: same chart, same id; different charts never collide */
20function uidOf(kind: string, data: unknown): string {
21 const s = kind + JSON.stringify(data)
22 let h = 0x811c9dc5
23 for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) }
24 return `wt${kind}${(h >>> 0).toString(36)}`
25}
26
27/** Light and dark neutral palettes; identity's light/dark variants also set as CSS vars; all scoped to this chart only */
28function themeStyle(uid: string, id?: Identity): string {
29 const idVars = (t: 'light' | 'dark') => (id ? `--id-s:${id[t].start};--id-e:${id[t].end};--id:${id[t].solid};` : '')
30 return `<style>
31:root{color-scheme:light dark;background:transparent}
32#${uid}{color-scheme:light dark;--track:rgba(0,0,0,.07);--grid:rgba(0,0,0,.06);--vgrid:rgba(0,0,0,.06);--diag:rgba(0,0,0,.28);--ink2:rgba(60,60,67,.62);--ink3:rgba(60,60,67,.42);--tshadow:rgba(0,0,0,.22);--mark:rgba(0,0,0,.75);${idVars('light')}}
33@media (prefers-color-scheme: dark){#${uid}{--track:rgba(255,255,255,.08);--grid:rgba(255,255,255,.07);--vgrid:rgba(255,255,255,.11);--diag:rgba(255,255,255,.42);--ink2:rgba(235,235,245,.62);--ink3:rgba(235,235,245,.42);--tshadow:rgba(255,255,255,.34);--mark:rgba(255,255,255,.9);${idVars('dark')}}}
34#${uid} text{font-family:${FONT}}
35</style>`
36}
37
38const open = (uid: string, w: number, h: number) =>
39 `<svg xmlns="http://www.w3.org/2000/svg" id="${uid}" width="${f(w)}" height="${f(h)}" viewBox="0 0 ${f(w)} ${f(h)}" style="color-scheme:light dark;background:transparent">`
40
41/** Coordinates at progress p, clockwise from 12 o'clock */
42function polar(cx: number, cy: number, r: number, p: number): [number, number] {
43 const a = 2 * Math.PI * p - Math.PI / 2
44 return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
45}
46
47function arcPath(cx: number, cy: number, r: number, p0: number, p1: number): string {
48 const [x0, y0] = polar(cx, cy, r, p0)
49 const [x1, y1] = polar(cx, cy, r, p1)
50 const large = p1 - p0 > 0.5 ? 1 : 0
51 return `M${f(x0)} ${f(y0)}A${f(r)} ${f(r)} 0 ${large} 1 ${f(x1)} ${f(y1)}`
52}
53
54function hexToRgb(h: string): [number, number, number] {
55 const n = parseInt(h.slice(1), 16)
56 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
57}
58
59function mix(a: string, b: string, t: number): string {
60 const [r1, g1, b1] = hexToRgb(a)
61 const [r2, g2, b2] = hexToRgb(b)
62 const c = (x: number, y: number) => Math.round(x + (y - x) * t).toString(16).padStart(2, '0')
63 return `#${c(r1, r2)}${c(g1, g2)}${c(b1, b2)}`
64}
65
66export type RingsInput = {
67 used: number | null // U (may be absent)
68 elapsed: number | null // T
69 status: Status
70 id: Identity
71 center: { kind: 'rate'; text: string } | { kind: 'pct'; text: string } | { kind: 'none' }
72 label: string
73 title: string
74 /** Column of readings right of the ring: small label above, number below; the accent one uses the quota color. Empty array draws the ring only */
75 readings: { label: string; value: string; accent?: boolean }[]
76}
77
78/**
79 * Pace rings, drawn the same way as the band bar (quota's own color family only):
80 * · Outer ring = usage U: angular gradient in quota color; under pace, faint same-hue stripes from used to elapsed (headroom);
81 * over pace, the part past elapsed is one shade deeper + light stripes; when exhausted, that part uses the harmonized warm color.
82 * · Inner ring = time T: thin neutral gray ring. Both rings thin with tight spacing, leaving the center for numbers.
83 * · Center: number and status vertically centered as one block; font size based on the inner ring's inside diameter.
84 * · Column of readings on the right, with tiered font sizes (native Text has only one size).
85 */
86export function ringsSvg(x: RingsInput): string {
87 const u = uidOf('r', x)
88 const d = 156
89 const c = d / 2
90 const rw = 0.075 * d
91 const gap = 0.03 * d
92 const rwIn = 0.035 * d
93 const rO = (d - rw) / 2
94 const rI = rO - rw / 2 - gap - rwIn / 2
95 const Di = 2 * (rI - rwIn / 2) * 0.92
96 const U = x.used == null ? 0 : clamp(x.used, 0, 1)
97 const T = x.elapsed == null ? null : clamp(x.elapsed, 0, 1)
98 const over = T != null && U > T
99 const exhausted = x.status === 'exhausted'
100 // Stripes: 45°, 5px period, 2px line width (same as band bar)
101 const stripes = (id: string, back: string, backOp: number, line: string, lineOp: number) =>
102 `<pattern id="${id}" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
103 `<rect width="5" height="5" fill="${back}" fill-opacity="${backOp}"/><rect width="2" height="5" fill="${line}" fill-opacity="${lineOp}"/></pattern>`
104 const parts: string[] = []
105 parts.push(`<defs>${stripes(u + 'm', 'var(--id)', 0.1, 'var(--id)', 0.55)}${stripes(u + 'x', 'var(--deep)', 1, 'var(--id-s)', 0.35)}</defs>`)
106 // Inner ring: time
107 parts.push(`<circle cx="${f(c)}" cy="${f(c)}" r="${f(rI)}" fill="none" stroke="var(--track)" stroke-width="${f(rwIn)}"/>`)
108 if (T != null && T > 0.002) parts.push(`<path d="${arcPath(c, c, rI, 0, Math.min(T, 0.9999))}" fill="none" stroke="var(--time)" stroke-width="${f(rwIn)}" stroke-linecap="round"/>`)
109 // Outer ring: track, headroom, usage, overage
110 parts.push(`<circle cx="${f(c)}" cy="${f(c)}" r="${f(rO)}" fill="none" stroke="var(--track)" stroke-width="${f(rw)}"/>`)
111 if (T != null && T > U) parts.push(`<path d="${arcPath(c, c, rO, Math.max(0, U - 0.01), Math.min(T, 0.9999))}" fill="none" stroke="url(#${u}m)" stroke-width="${f(rw)}"/>`)
112 const upto = over ? (T as number) : U
113 if (upto > 0.002) {
114 // Angular gradient approximated by segments; one group each for light and dark colors, one shown per appearance
115 const n = Math.max(2, Math.ceil(upto * 72))
116 const grad = (t: Tri, cls: string) => {
117 const segs: string[] = []
118 for (let i = 0; i < n; i++) {
119 const p0 = (upto * i) / n
120 const p1 = (upto * (i + 1)) / n + (i < n - 1 ? 0.002 : 0)
121 segs.push(`<path d="${arcPath(c, c, rO, p0, Math.min(p1, 0.9999))}" fill="none" stroke="${mix(t.end, t.start, (i + 1) / n)}" stroke-width="${f(rw)}"/>`)
122 }
123 return `<g class="${cls}">${segs.join('')}</g>`
124 }
125 parts.push(grad(x.id.light, 'lt'), grad(x.id.dark, 'dk'))
126 const [sx, sy] = polar(c, c, rO, 0)
127 parts.push(`<circle cx="${f(sx)}" cy="${f(sy)}" r="${f(rw / 2)}" fill="var(--id-e)"/>`)
128 if (!over) {
129 const [ex, ey] = polar(c, c, rO, Math.min(upto, 0.9999))
130 parts.push(`<circle cx="${f(ex)}" cy="${f(ey)}" r="${f(rw / 2)}" fill="var(--id-s)"/>`)
131 }
132 }
133 if (over) {
134 const paint = exhausted ? 'var(--warm)' : `url(#${u}x)`
135 parts.push(`<path d="${arcPath(c, c, rO, T as number, Math.min(U, 0.9999))}" fill="none" stroke="${paint}" stroke-width="${f(rw)}"/>`)
136 if (U < 0.999) {
137 const [ex, ey] = polar(c, c, rO, U)
138 parts.push(`<circle cx="${f(ex)}" cy="${f(ey)}" r="${f(rw / 2)}" fill="${paint}"/>`)
139 }
140 }
141 // Center: number + status, vertically centered as one block
142 const em = 0.25 * Di
143 const lf = Math.max(10, 0.1 * Di)
144 const lead = 0.07 * Di
145 const nb = c - (0.72 * em + lead + 0.72 * lf) / 2 + 0.72 * em
146 if (x.center.kind === 'rate') {
147 parts.push(`<text x="${f(c)}" y="${f(nb)}" text-anchor="middle" font-size="${f(em)}" font-weight="600" fill="var(--ink)" style="font-variant-numeric:tabular-nums;letter-spacing:-.02em">${esc(x.center.text)}<tspan font-size="${f(em * 0.55)}" font-weight="500" fill="var(--ink3)" dx="1.5">×</tspan></text>`)
148 } else {
149 const t = x.center.kind === 'pct' ? x.center.text : '—'
150 parts.push(`<text x="${f(c)}" y="${f(nb)}" text-anchor="middle" font-size="${f(em)}" font-weight="600" fill="${x.center.kind === 'pct' ? 'var(--ink)' : 'var(--ink3)'}" style="font-variant-numeric:tabular-nums;letter-spacing:-.02em">${esc(t)}</text>`)
151 }
152 parts.push(`<text x="${f(c)}" y="${f(nb + lead + 0.72 * lf)}" text-anchor="middle" font-size="${f(lf)}" font-weight="500" fill="var(--ink2)">${esc(x.label)}</text>`)
153 // Column of readings on the right
154 const LS = 10
155 const VS = 22
156 const rx = d + 24
157 // Chinese labels too small at 10px, use 11px; width estimate runs narrow (% and bold digits are wider), so pad it
158 const lsOf = (t: string) => (/[\u2E80-\u9FFF]/.test(t) ? 11 : LS)
159 const colW = x.readings.reduce((a, r) => Math.max(a, textWidth(r.value, VS) * 1.15, textWidth(r.label, lsOf(r.label)) * 1.2), 0) + 6
160 const step = 46
161 const top = (d - (x.readings.length * step - 8)) / 2
162 x.readings.forEach((r, i) => {
163 const y = top + i * step
164 const col = r.accent ? (exhausted ? 'var(--warm)' : 'var(--id)') : 'var(--ink)'
165 parts.push(`<text x="${f(rx)}" y="${f(y + 9)}" font-size="${lsOf(r.label)}" font-weight="600" fill="var(--ink3)" style="letter-spacing:.09em">${esc(r.label)}</text>` +
166 `<text x="${f(rx)}" y="${f(y + 33)}" font-size="${VS}" font-weight="600" fill="${col}" style="font-variant-numeric:tabular-nums;letter-spacing:-.01em">${esc(r.value)}</text>`)
167 })
168 const W = x.readings.length ? Math.ceil(rx + colW) : d
169 const ol = overTones(x.id.light)
170 const od = overTones(x.id.dark)
171 const vars = `<style>#${u}{--ink:#1d1d1f;--time:#8E8E93;--deep:${ol.deep};--warm:${ol.warm}}#${u} .dk{display:none}` +
172 `@media (prefers-color-scheme: dark){#${u}{--ink:#f5f5f7;--deep:${od.deep};--warm:${od.warm}}#${u} .lt{display:none}#${u} .dk{display:inline}}</style>`
173 return `${open(u, W, d)}${themeStyle(u, x.id)}${vars}<title>${esc(x.title)}</title>${parts.join('')}</svg>`
174}
175
176/**
177 * Band progress bar, following Apple Fitness rings' "past the goal = one shade deeper", quota's own color family only:
178 * · Using slower than time: faint same-hue stripes behind the usage bar from used to elapsed, the remaining headroom;
179 * · Using faster than time: the part past elapsed turns one shade deeper (OKLCH lightness down 0.13), with light stripes in the bright-end hue;
180 * · Exhausted: that part is solid warm — persimmon hue rotated toward the quota color via Material 3 harmonize, lightness/chroma matched to it.
181 * Method: see docs/COLOR-METHOD-2026-10-04.md.
182 * A numeric width is fixed (the small collapsed bar); 'fluid' gives 100% width with all geometry in percent,
183 * stretched by the host to the band's remaining width: only lengthens, never distorts (corner radii unchanged).
184 */
185export function bandBarSvg(pace: Pace | null, used: number | null, status: Status, id: Identity, title: string, width: number | 'fluid' = 120): string {
186 const u = uidOf('n', [pace?.used, pace?.elapsed, used, status, id, title, width])
187 const fluid = width === 'fluid'
188 const h = 14
189 const bh = 8
190 const y = (h - bh) / 2
191 const r = bh / 2
192 const st = STATUS[status]
193 // Fixed width: pixels (with minimum width); fluid: percent
194 const W = (frac: number, min = 0) => (fluid ? `${f(clamp(frac, 0, 1) * 100)}%` : f(Math.max(min, (width as number) * clamp(frac, 0, 1))))
195 const X = (frac: number) => (fluid ? `${f(clamp(frac, 0, 1) * 100)}%` : f((width as number) * clamp(frac, 0, 1)))
196 // Stripes: 45°, 5px period, 2px line width
197 const stripes = (id: string, back: string, backOp: number, line: string, lineOp: number) =>
198 `<pattern id="${id}" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
199 `<rect width="5" height="5" fill="${back}" fill-opacity="${backOp}"/><rect width="2" height="5" fill="${line}" fill-opacity="${lineOp}"/></pattern>`
200 const exhausted = status === 'exhausted'
201 const parts: string[] = []
202 parts.push(`<defs><linearGradient id="${u}u" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--id-e)"/><stop offset="1" stop-color="var(--id-s)"/></linearGradient>` +
203 stripes(u + 'm', 'var(--id)', 0.1, 'var(--id)', 0.55) +
204 stripes(u + 'x', 'var(--deep)', 1, 'var(--id-s)', 0.35) +
205 `<clipPath id="${u}t"><rect y="${y}" width="${fluid ? '100%' : width}" height="${bh}" rx="${r}"/></clipPath></defs>`)
206 parts.push(`<rect y="${y}" width="${fluid ? '100%' : width}" height="${bh}" rx="${r}" fill="var(--track)"/>`)
207 if (pace) {
208 const T = clamp(pace.elapsed, 0, 1)
209 const U = clamp(pace.used, 0, 1)
210 // Headroom: from used to elapsed, behind the usage bar (its round cap hides the seam)
211 if (T > U) parts.push(`<g clip-path="url(#${u}t)"><rect y="${y}" width="${X(T)}" height="${bh}" fill="url(#${u}m)"/></g>`)
212 if (U > 0) {
213 parts.push(`<rect y="${y}" width="${W(U, bh)}" height="${bh}" rx="${r}" fill="url(#${u}u)"/>`)
214 // Overage: from elapsed to used, clipped to the usage bar's shape, round cap kept
215 if (U > T) {
216 parts.push(`<clipPath id="${u}c"><rect y="${y}" width="${W(U, bh)}" height="${bh}" rx="${r}"/></clipPath>`)
217 parts.push(`<g clip-path="url(#${u}c)"><rect x="${X(T)}" y="${y}" width="${X(U - T)}" height="${bh}" fill="${exhausted ? 'var(--warm)' : `url(#${u}x)`}"/></g>`)
218 }
219 }
220 } else if (used != null && used > 0) {
221 parts.push(`<rect y="${y}" width="${W(used, bh)}" height="${bh}" rx="${r}" fill="url(#${u}u)"/>`)
222 }
223 // Bar is thin, so the track is darker than in the panel to show the full length
224 // Fluid bar is a plain height-only image (no interactive frame, it flickers on panel redraw): root declares no width, all geometry in percent
225 const ol = overTones(id.light)
226 const od = overTones(id.dark)
227 const track = `<style>#${u}{--track:rgba(0,0,0,.13);--deep:${ol.deep};--warm:${ol.warm}}@media (prefers-color-scheme: dark){#${u}{--track:rgba(255,255,255,.16);--deep:${od.deep};--warm:${od.warm}}}</style>`
228 const head = fluid
229 ? `<svg xmlns="http://www.w3.org/2000/svg" id="${u}" width="100%" height="${h}" style="display:block;overflow:visible;color-scheme:light dark;background:transparent">`
230 : open(u, width as number, h)
231 return `${head}${themeStyle(u, id)}${track}<title>${esc(title)}</title>${parts.join('')}</svg>`
232}
233
234/**
235 * Credit line at the panel's bottom-right: one line of 10px translucent text (native Text can't set font size, and nothing is fainter than dimColor).
236 * textLength pins the width; a different font only changes spacing and never overflows the image.
237 */
238export function creditSvg(text: string): string {
239 const u = uidOf('c', text)
240 const size = 10
241 const w = Math.ceil(textWidth(text, size)) + 2
242 const h = 14
243 return `${open(u, w, h)}<style>:root{color-scheme:light dark;background:transparent}#${u} text{font-family:${FONT};fill:rgba(60,60,67,.42)}@media (prefers-color-scheme: dark){#${u} text{fill:rgba(235,235,245,.36)}}</style>` +
244 `<title>${esc(text)}</title><text x="${w - 1}" y="10.5" font-size="${size}" text-anchor="end" textLength="${w - 2}" lengthAdjust="spacingAndGlyphs">${esc(text)}</text></svg>`
245}
246
247/** OKLCH hue of persimmon orange: starting point for the exhausted warm color, then harmonized toward the quota color */
248const WARM_HUE = 32.68
249
250/** Two colors for the overage part: one shade deeper (over pace) and harmonized warm (exhausted), both derived from the quota color */
251function overTones(t: Tri): { deep: string; warm: string } {
252 const [L, C, H] = hexToOklch(t.solid)
253 return {
254 deep: oklchToHex(Math.max(0.3, L - 0.13), C * 1.05, H),
255 warm: oklchToHex(L - 0.02, Math.max(C, 0.15), harmonizeHue(WARM_HUE, H)),
256 }
257}
258
259export type BurnUpInput = {
260 width: number
261 overlay: Overlay
262 W: number
263 status: Status
264 id: Identity
265 title: string
266 /** Per-line inspection: drawn in an interactive frame; the hovered line thickens and shows its date and peak, others fade */
267 interactive?: boolean
268}
269
270const f1 = (n: number) => (Math.round(n * 10) / 10).toString()
271
272/** Join adjacent segments separated only by data gaps (not mid-window resets) into one */
273function joinGaps(s: Series): Series['segments'] {
274 const gapStarts = new Set(s.gaps.map(g => g.to))
275 const out: Series['segments'] = []
276 for (const seg of s.segments) {
277 if (out.length && gapStarts.has(seg[0])) out[out.length - 1] = [...out[out.length - 1], ...seg]
278 else out.push(seg)
279 }
280 return out
281}
282
283/** Estimate text width: CJK chars at 1em, others at 0.56em (for legend wrapping) */
284const textWidth = (s: string, size: number) => [...s].reduce((a, ch) => a + (/[\u2E80-\u9FFF\uFF00-\uFFEF]/.test(ch) ? size : size * 0.56), 0)
285
286/**
287 * Usage trace: x = time position in window, y = usage; dashed diagonal for ideal pace; past windows overlaid, opacity by recency; current window has projection and exhaustion line.
288 * Legend drawn at the bottom of the same chart, line styles matched one-to-one with labels; width set to the panel's usual width so text stays legible when scaled.
289 */
290export function burnUpSvg(x: BurnUpInput): string {
291 const u = uidOf('u', [x.width, x.W, x.status, x.id, x.title, getLang(), x.overlay.entries.map(e => [e.series.reset, e.series.segments.length, e.series.peak, e.recency]), !!x.interactive])
292 const Wd = x.width
293 const left = 32
294 const right = 8
295 const top = 8
296 const h = 150
297 const w = Wd - left - right
298 const X = (e: number) => left + w * clamp(e, 0, 1)
299 const Y = (v: number) => top + h * (1 - clamp(v, 0, 1))
300 const days = Math.max(1, Math.round(x.W / 86400))
301 const st = STATUS[x.status]
302 const TF = 10
303 const p: string[] = []
304 p.push(`<defs><linearGradient id="${u}r" x1="0" x2="1"><stop offset="0" stop-color="var(--id)" stop-opacity=".22"/><stop offset="1" stop-color="var(--id)"/></linearGradient></defs>`)
305 // Grid
306 for (let i = 1; i <= 3; i++) p.push(`<line x1="${left}" x2="${f(left + w)}" y1="${f(top + (h * i) / 4)}" y2="${f(top + (h * i) / 4)}" stroke="var(--grid)"/>`)
307 for (let i = 1; i < Math.max(2, days); i++) {
308 const gx = left + (w * i) / days
309 if (gx < left + w - 0.5) p.push(`<line x1="${f(gx)}" x2="${f(gx)}" y1="${top}" y2="${f(top + h)}" stroke="var(--vgrid)"/>`)
310 }
311 p.push(`<rect x="${left}" y="${top}" width="${f(w)}" height="${f(h)}" fill="none" stroke="var(--grid)"/>`)
312 // Ticks
313 for (const v of [0, 50, 100]) p.push(`<text x="${left - 5}" y="${f(Y(v / 100) + 3.5)}" text-anchor="end" font-size="${TF}" fill="var(--ink3)">${v}%</text>`)
314 const axisY = top + h + 14
315 if (days > 1) {
316 for (let dd = 0; dd <= days; dd++) p.push(`<text x="${f(clamp(left + (w * dd) / days, left + 12, left + w - 12))}" y="${f(axisY)}" text-anchor="middle" font-size="${TF}" fill="var(--ink3)">${L(`${dd}天`, `${dd}d`)}</text>`)
317 } else {
318 p.push(`<text x="${left}" y="${f(axisY)}" font-size="${TF}" fill="var(--ink3)">${L('开始', 'Start')}</text><text x="${f(left + w)}" y="${f(axisY)}" text-anchor="end" font-size="${TF}" fill="var(--ink3)">${L('重置', 'Reset')}</text>`)
319 }
320 // Ideal pace diagonal
321 p.push(`<line x1="${f(X(0))}" y1="${f(Y(0))}" x2="${f(X(1))}" y2="${f(Y(1))}" stroke="var(--diag)" stroke-width="1.2" stroke-dasharray="4 4"/>`)
322 // Keep at most first and last points per pixel column, coordinates to one decimal: source length stays bounded however many points
323 const poly = (pts: { elapsed: number; used: number }[]) => {
324 const kept: { x: number; y: number; col: number }[] = []
325 for (const q of pts) {
326 const pt = { x: X(q.elapsed), y: Y(q.used), col: Math.round(X(q.elapsed)) }
327 const n = kept.length
328 if (n >= 2 && kept[n - 1].col === pt.col && kept[n - 2].col === pt.col) kept[n - 1] = pt
329 else kept.push(pt)
330 }
331 return kept.map(k => `${f1(k.x)},${f1(k.y)}`).join(' ')
332 }
333 const entries = x.overlay.entries
334 // Past windows: opacity rises with recency; segments separated by "no data" are joined directly (with sparse sampling each point is its own segment,
335 // so drawing only multi-point segments would draw nothing); still broken at mid-window resets.
336 // With per-line inspection, one group per window: an invisible wide hit line (easy to target), the original line, a hover-only label
337 const hover = !!x.interactive
338 const history = entries.slice(0, -1)
339 const TF2 = 10.5
340 // Labels all drawn on the top layer (not covered by later lines), matched to their group by index
341 const labels: string[] = []
342 let gi = 0
343 const tag = (e: OverlayEntry, lp: { elapsed: number; used: number }) => {
344 const text = `${windowLabel(e.series.reset, x.W, e.isCurrent)} · ${L('峰值', 'peak')} ${Math.round(e.series.peak * 100)}%`
345 const pw = textWidth(text, TF2) + 16
346 const px = clamp(X(lp.elapsed) - pw - 6, left, Wd - right - pw)
347 const py = clamp(Y(lp.used) - 26, top, top + h - 18)
348 labels.push(`<g class="lbl l${gi}"><rect x="${f(px)}" y="${f(py)}" width="${f(pw)}" height="18" rx="9" fill="var(--tip-bg)" stroke="var(--tip-bd)"/>` +
349 `<text x="${f(px + pw / 2)}" y="${f(py + 12.5)}" text-anchor="middle" font-size="${TF2}" fill="var(--tip-fg)">${esc(text)}</text></g>`)
350 return `w w${gi++}`
351 }
352 const lastPoint = (runs: { elapsed: number; used: number }[][]) => { const r = runs[runs.length - 1]; return r[r.length - 1] }
353 // Hit lines only need to be easy to target (12px wide); one integer point per 4px is enough, adding only ~10% to source size
354 const coarse = (pts: { elapsed: number; used: number }[]) => {
355 const out: string[] = []
356 let col = NaN
357 pts.forEach((q, i) => {
358 const c = Math.round(X(q.elapsed) / 4)
359 if (c !== col || i === pts.length - 1) { out.push(`${Math.round(X(q.elapsed))},${Math.round(Y(q.used))}`); col = c }
360 })
361 return out.join(' ')
362 }
363 const hits = (runs: { elapsed: number; used: number }[][]) => runs.map(run => `<polyline class="hit" points="${coarse(run)}"/>`).join('')
364 history.forEach(e => {
365 const op = 0.4 + 0.52 * e.recency
366 const runs = joinGaps(e.series).filter(run => run.length >= 2).map(run => ({ run, pts: poly(run) }))
367 const lines = runs.map(({ pts }) => `<polyline class="ln" points="${pts}" fill="none" stroke="var(--id)" stroke-opacity="${f(op)}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`).join('')
368 if (!hover || !runs.length) { p.push(lines); return }
369 p.push(`<g class="${tag(e, lastPoint(runs.map(r => r.run)))}">${hits(runs.map(r => r.run))}${lines}</g>`)
370 })
371 const fo = x.overlay.focused
372 const cur = fo?.isCurrent ? fo.series : null
373 if (fo) {
374 const s = fo.series
375 for (const m of s.midResets) p.push(`<line x1="${f(X(m.before.elapsed))}" x2="${f(X(m.before.elapsed))}" y1="${top}" y2="${f(top + h)}" stroke="var(--ink3)" stroke-dasharray="1 3"/>`)
376 const gapLines = s.gaps.map(g => `<line class="gp" x1="${f(X(g.from.elapsed))}" y1="${f(Y(g.from.used))}" x2="${f(X(g.to.elapsed))}" y2="${f(Y(g.to.used))}" stroke="var(--id)" stroke-opacity=".3" stroke-width="1.5" stroke-dasharray="2 3"/>`).join('')
377 const segs = s.segments.filter(seg => seg.length >= 2).map(seg => ({ seg, pts: poly(seg) }))
378 const lines = segs.map(({ pts }) => `<polyline class="ln" points="${pts}" fill="none" stroke="var(--id)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`).join('')
379 // Hit lines reuse the "join gaps" path: still targetable when sampling is sparse and each segment has one point
380 const joined = joinGaps(s).filter(run => run.length >= 2)
381 if (hover && joined.length) p.push(`<g class="${tag(fo, lastPoint(joined))}">${hits(joined)}${gapLines}${lines}</g>`)
382 else p.push(gapLines, lines)
383 if (cur) {
384 if (cur.projection) p.push(`<line x1="${f(X(cur.projection.from.elapsed))}" y1="${f(Y(cur.projection.from.used))}" x2="${f(X(cur.projection.to.elapsed))}" y2="${f(Y(cur.projection.to.used))}" stroke="${st.solid}" stroke-opacity=".85" stroke-width="1.5" stroke-dasharray="5 3"/>`)
385 const lastSeg = cur.segments[cur.segments.length - 1]
386 const lp = lastSeg[lastSeg.length - 1]
387 p.push(`<circle cx="${f(X(lp.elapsed))}" cy="${f(Y(lp.used))}" r="3.5" fill="var(--id)"/>`)
388 if (cur.exhaustionAt != null) p.push(`<line x1="${f(X(cur.exhaustionAt))}" x2="${f(X(cur.exhaustionAt))}" y1="${top}" y2="${f(top + h)}" stroke="#D03B3B" stroke-opacity=".7" stroke-dasharray="2 2"/>`)
389 }
390 }
391 p.push(...labels)
392 // Legend: each item's line style matches its line in the chart
393 const items: { mark: string; text: string }[] = []
394 const dates = legendDates(x.overlay)
395 if (dates) items.push({ mark: `<rect y="2" width="16" height="4" rx="2" fill="url(#${u}r)"/>`, text: `${dates.from} → ${dates.to}` })
396 else items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="var(--id)" stroke-width="2.2" stroke-linecap="round"/>`, text: L('用量', 'Usage') })
397 items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="var(--diag)" stroke-width="1.2" stroke-dasharray="4 3"/>`, text: L('匀速线', 'Even pace') })
398 if (cur?.projection) items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="${st.solid}" stroke-opacity=".85" stroke-width="1.5" stroke-dasharray="5 3"/>`, text: L('按近期速度', 'Recent rate') })
399 if (cur?.exhaustionAt != null) items.push({ mark: `<line x1="8" y1="-1" x2="8" y2="9" stroke="#D03B3B" stroke-opacity=".7" stroke-dasharray="2 2"/>`, text: L('用完', 'Runs out') })
400 if (fo?.series.gaps.length) items.push({ mark: `<line x1="0" y1="4" x2="16" y2="4" stroke="var(--id)" stroke-opacity=".3" stroke-width="1.5" stroke-dasharray="2 3"/>`, text: L('无数据', 'No data') })
401 const LF = 11
402 let lx = left
403 let ly = axisY + 20
404 for (const it of items) {
405 const iw = 21 + textWidth(it.text, LF)
406 if (lx > left && lx + iw > Wd - right) { lx = left; ly += 17 }
407 p.push(`<g transform="translate(${f(lx)},${f(ly - 8)})">${it.mark}</g><text x="${f(lx + 21)}" y="${f(ly)}" font-size="${LF}" fill="var(--ink2)">${esc(it.text)}</text>`)
408 lx += iw + 12
409 }
410 const H = ly + 5
411 // Per-line inspection styles: hovered group thickens and shows its label, other groups fade; labels don't block the pointer
412 const hoverStyle = hover
413 ? `<style>#${u}{--tip-bg:rgba(255,255,255,.96);--tip-fg:#1d1d1f;--tip-bd:rgba(0,0,0,.14)}` +
414 `@media (prefers-color-scheme: dark){#${u}{--tip-bg:rgba(30,30,32,.94);--tip-fg:#f5f5f7;--tip-bd:rgba(255,255,255,.18)}}` +
415 `#${u} .w .hit{fill:none;stroke:transparent;stroke-width:12;pointer-events:stroke}` +
416 `#${u} .lbl{opacity:0;pointer-events:none}` + labels.map((_, i) => `#${u}:has(.w${i}:hover) .l${i}`).join(',') + `{opacity:1}` +
417 `#${u} .w:hover .ln{stroke-opacity:1;stroke-width:2.6}#${u} .w:hover .gp{stroke-opacity:.85}#${u}:has(.w:hover) .w:not(:hover) .ln{stroke-opacity:.12}</style>`
418 : ''
419 return `${open(u, Wd, H)}${themeStyle(u, x.id)}${hoverStyle}<title>${esc(x.title)}</title>${p.join('')}</svg>`
420}
421hooks/color.ts 34 lines1// Color utils: sRGB ↔ OKLCH (Björn Ottosson's reference formulas), plus Material 3 hue harmonization. Pure functions.
2// Used for the band's overrun segment ("same hue, one shade darker") and the exhausted "harmonized warm color", both derived from the quota's own color.
3
4const toLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
5const toSrgb = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
6export function hexToOklch(hex: string): [number, number, number] {
7 const n = parseInt(hex.slice(1), 16)
8 const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map(v => toLin(v / 255))
9 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
10 const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
11 const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
12 const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
13 const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
14 const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
15 return [L, Math.hypot(A, B), ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360]
16}
17export function oklchToHex(L: number, C: number, H: number): string {
18 // Out of gamut: reduce chroma, keep lightness and hue
19 for (let c = C; c >= 0; c -= 0.002) {
20 const A = c * Math.cos((H * Math.PI) / 180), B = c * Math.sin((H * Math.PI) / 180)
21 const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3
22 const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3
23 const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3
24 const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]
25 if (rgb.every(v => v >= -0.0005 && v <= 1.0005)) return '#' + rgb.map(v => Math.round(Math.min(1, Math.max(0, toSrgb(Math.max(0, v)))) * 255).toString(16).padStart(2, '0')).join('')
26 }
27 return '#000000'
28}
29/** Material 3 harmonize: rotate hue toward the reference color, at most 15° (the smaller of half the angle between them and 15°); lightness and chroma unchanged */
30export function harmonizeHue(h: number, toward: number): number {
31 const diff = ((toward - h + 540) % 360) - 180
32 return (h + Math.sign(diff) * Math.min(Math.abs(diff) * 0.5, 15) + 360) % 360
33}
34types/index.d.ts 66 lines1/** One quota in an observation: usage 0–100, reset time (ms), server display name */
2export type Obs = { u: number; r?: number; n?: string }
3/** One sample: time (ms) + per-quota observations */
4export type Sample = { t: number; w: Record<string, Obs> }
5
6/** Pane view state: selected quota, tab, burn-up range and page offset */
7export type PaneView = {
8 key: string | null
9 tab: 'pace' | 'burnup'
10 range: 'current' | 'month' | 'all'
11 offset: number
12 /** Per-line inspection for "Last month / All": the chart becomes hoverable boxes; the hovered line shows its dates and peak */
13 explore?: boolean
14}
15
16/** Band above the input box: shown / hidden (takes no row; restore from the pane) */
17export type BandMode = 'open' | 'hidden'
18
19/**
20 * Everything the band draws, rounded to display precision (whole percent). Written only when it changes, so only then does the band redraw:
21 * a new sample or passing time that leaves the display unchanged causes no redraw.
22 */
23export type BandShow = {
24 key: string
25 /** Display name from the server (per-model quotas) */
26 n?: string
27 /** All switchable quotas; the arrows step through them in this order */
28 keys: string[]
29 used: number | null
30 elapsed: number | null
31 /** The last observation's window is past its reset time: that usage is void and the band shows "reset" */
32 ended?: boolean
33 status: 'unknown' | 'early' | 'comfortable' | 'onPace' | 'overPace' | 'exhausted'
34}
35
36/** Time of the latest reply across local sessions: any = any model, byModel = per model family (fable / opus …) */
37export type Activity = { any?: number; byModel: Record<string, number> }
38
39/** UI language: Chinese / English */
40export type Lang = 'zh' | 'en'
41
42/** Manual refresh in progress (the refresh button shows "Refreshing…" from this); at is the start time. Older versions stored a receipt with text; no busy means idle */
43export type Note = { busy?: boolean; at: number; text?: string } | null
44
45declare module 'claude-code' {
46 interface PluginState {
47 weektoken: {
48 samples: Sample[]
49 view: PaneView
50 note: Note
51 band: BandMode
52 /** Quota shown in the band: remembered once picked with the band's prev/next arrows; null = auto-show the tightest */
53 bandKey: string | null
54 bandShow: BandShow | null
55 /** The band is asking "Hide the band?" */
56 confirmHide: boolean
57 /** Whether the pane is open (band button reads "Close" or "Details") */
58 paneOpen: boolean
59 lang: Lang
60 activity: Activity
61 /** Loaded version (from its own plugin.json), drawn after the pane's credit line */
62 version: string
63 }
64 }
65}
66