Writes your Claude usage limits and credits to ~/.claude/usage-reporter/usage.json so other tools can read them

A Claude Code mod that writes your Claude usage limits and credits to a file, so menu bar apps, status lines, scripts, and other mods can read them without each one asking Anthropic.
It writes ~/.claude/usage-reporter/usage.json:
{
"version": 1,
"at": "2026-10-03T19:51:50.920Z",
"windows": [
{ "kind": "session", "percent": 7, "resetsAt": "2026-10-04T00:50:00.000Z", "at": "2026-10-03T19:51:50.920Z" },
{ "kind": "weekly", "percent": 25, "resetsAt": "2026-10-04T23:00:00.000Z", "at": "2026-10-03T19:51:50.920Z" },
{ "kind": "weekly", "label": "Fable", "percent": 48, "resetsAt": "2026-10-04T22:59:59.000Z", "at": "2026-10-03T19:49:51.598Z" }
],
"credits": { "enabled": false, "used": 0, "limit": null, "currency": "USD", "at": "2026-10-03T19:51:50.920Z" },
"cloudSessionCredits": { "used": 0, "limit": 250, "currency": "USD", "resetsAt": "2026-11-05T07:59:00.000Z", "at": "2026-10-03T19:51:50.920Z" },
"projectSetupCredit": { "used": 17.993769, "limit": 100, "currency": "USD", "expiresAt": "2026-10-05T17:16:23.346Z", "at": "2026-10-04T18:37:30.984Z" },
"grants": [
{ "id": "extra_usage", "label": "Extra usage", "used": 0, "limit": 100, "currency": "USD", "at": "2026-10-04T18:37:30.984Z" },
{ "id": "iguana_necktie", "label": "Cloud sessions", "used": 1.864015, "limit": 250, "currency": "USD", "endsAt": "2026-11-05T07:59:00.000Z", "ends": "expiry", "at": "2026-10-04T18:37:30.984Z" },
{ "id": "harbor_lantern", "label": "Project setup", "used": 17.993769, "limit": 100, "currency": "USD", "endsAt": "2026-10-05T17:16:23.346Z", "ends": "expiry", "at": "2026-10-04T18:37:30.984Z" }
],
"weeklyBreakdown": {
"windowStartedAt": "2026-09-27T23:00:00.902Z",
"rows": [
{ "key": "claude_code", "label": "Claude Code", "percent": 100 },
{ "key": "chat", "label": "Chats", "percent": 0 },
{ "key": "cowork", "label": "Cowork", "percent": 0 },
{ "key": "other", "label": "Other", "percent": 0 }
],
"at": "2026-10-03T19:51:50.920Z"
},
"raw": {}
}
Requires a Claude Code version with mods (Anthropic supports mods on 2.1.287 and later; the mod has also run on 2.1.251) and a Claude subscription login.
git clone https://github.com/tksunw/usage-reporter ~/.claude/mods/usage-reporter
Then point Claude Code at that folder's parent in ~/.claude/settings.json, if it does not already:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods"
}
}
Claude Code loads every folder under ~/.claude/mods as a mod, which is why it lives there and not in ~/.claude/skills: pointing CLAUDE_CODE_PLUGIN_DIRS at the skills folder would try to load every skill as a mod.
Start a new Claude Code session. The file appears after the session starts. To remove the mod, delete ~/.claude/mods/usage-reporter; the last report stays in ~/.claude/usage-reporter/ until you delete that too.
To try it for one session without installing: claude --plugin-dir /path/to/usage-reporter.
Pull the latest version into the folder you cloned:
git -C ~/.claude/mods/usage-reporter pull
The next Claude Code session you start runs the new version.
The tksunw marketplace lists this mod, so Claude Code can fetch and update it for you:
claude plugin marketplace add tksunw/claude-plugins
claude plugin install usage-reporter@tksunw
Turn on auto-update for the tksunw marketplace in /plugin (third-party marketplaces default to off), or run claude plugin marketplace update tksunw by hand. Updates are keyed on version in .claude-plugin/plugin.json, so a release without a version bump is not picked up. Use one install method, not both: remove the clone from ~/.claude/mods before installing this way.
Format version 1. A reader should check version and stop if it is not one it knows.
| Field | Meaning |
|---|---|
version | 1 |
at | When the file was last written, ISO 8601 UTC |
windows[] | One entry per usage window. Empty on an Enterprise login, which has no windows, only a spend budget in credits (and in grants when spending is enabled) |
windows[].kind | session (the 5-hour window) or weekly (the 7-day window) |
windows[].label | Present on a weekly window scoped to one model family, for example Fable. Absent on the all-models windows |
windows[].percent | Percent of the window used, 0 to 100 |
windows[].resetsAt | When the window resets, ISO 8601 UTC. Can be absent |
windows[].at | When this window's figure was read. Scoped windows can be older than the others |
credits | Usage credits (extra usage). Absent when Anthropic's response carries no credit figures |
credits.enabled | Whether credits are turned on |
credits.used | Credits spent, a number in major units of currency (dollars, not cents) |
credits.limit | The spend limit, in the same units. null when no limit is set. Absent when a limit is set in a shape the mod does not know |
credits.currency | ISO 4217 code, for example USD. Can be absent |
credits.at | When the credit figures were read. Can be older than the file's at |
cloudSessionCredits | The credit grant for cloud sessions. Absent when Anthropic's response does not carry it |
cloudSessionCredits.used | Dollars spent |
cloudSessionCredits.limit | Dollars granted. Can be absent |
cloudSessionCredits.currency | USD |
cloudSessionCredits.resetsAt | When the credit expires, ISO 8601 UTC. Despite the name it is an expiry, as Claude's usage page shows it; the name stays for existing readers. Can be absent |
cloudSessionCredits.at | When the figures were read. Can be older than the file's at |
projectSetupCredit | The one-time Claude Projects setup credit, shown in Claude Desktop as "Project setup credit". Absent when Anthropic's response does not carry it, which includes before it is granted |
projectSetupCredit.used | Dollars spent |
projectSetupCredit.limit | Dollars granted. Can be absent |
projectSetupCredit.currency | USD |
projectSetupCredit.expiresAt | When the credit expires, ISO 8601 UTC. It does not reset. Can be absent |
projectSetupCredit.at | When the figures were read. Can be older than the file's at |
grants[] | Every dollar credit in one list, for readers that want to show them all without knowing each kind: extra usage when it is turned on, then every grant in Anthropic's response, including ones this mod has never seen. Absent when there are none. Build on this rather than the three fields above |
grants[].id | Where it came from: extra_usage, or Anthropic's codename for the grant (iguana_necktie, harbor_lantern). Stable; use it to tell grants apart |
grants[].label | A name to show, for example Cloud sessions. For a grant the mod does not know, the codename itself |
grants[].used | Dollars spent |
grants[].limit | Dollars available. null when no limit is set. Can be absent |
grants[].currency | ISO 4217 code, for example USD |
grants[].endsAt | When the grant resets or expires, ISO 8601 UTC. Can be absent |
grants[].ends | reset or expiry, saying which endsAt is. Absent when not known |
grants[].at | When the figures were read. Can be older than the file's at |
weeklyBreakdown | The weekly window's usage split by surface, account-wide (claude.ai chat included). Absent when Anthropic's response does not carry it |
weeklyBreakdown.windowStartedAt | When the weekly window began, ISO 8601 UTC. Can be absent |
weeklyBreakdown.rows[] | One entry per surface, in Anthropic's order. Keys not listed here are passed through; show them rather than dropping them |
weeklyBreakdown.rows[].key | Surface id. Seen: claude_code, chat, cowork, other |
weeklyBreakdown.rows[].label | Anthropic's display name, for example Chats. Can be absent |
weeklyBreakdown.rows[].percent | See below. Not the percent of the weekly limit |
weeklyBreakdown.at | When the figures were read. Can be older than the file's at |
raw | Anthropic's last usage response, unparsed, for debugging. Its shape is theirs and changes without notice. Do not build on it |
What weeklyBreakdown.rows[].percent measures is not settled. Every reading so far had one non-zero row, claude_code at 100, while the weekly window stood at 20% and later 43%. So it is not the weekly percent, and it fits "share of this week's usage, rows summing to 100", but no reading with two non-zero rows has confirmed that. Treat it as a relative share until one does.
A window whose resetsAt has passed has rolled over; treat it as empty until the next report.
Reading it from a shell:
jq -r '.windows[] | "\(.kind) \(.label // "all") \(.percent)%"' ~/.claude/usage-reporter/usage.json
Only while a Claude Code session is running. Nothing runs on a timer.
credits, cloudSessionCredits, projectSetupCredit, grants, and weeklyBreakdown come only from the endpoint, so they carry over unchanged until the next call.So session and weekly follow each turn, and model-scoped windows and credits update at most every five minutes. Usage from claude.ai chat or Claude Desktop shows up at the next Claude Code turn.
$.session.authorize(), gets an opaque handle, and passes the handle to $.http.fetch. Claude Code attaches the credential on its side.GET https://api.anthropic.com/api/oauth/usage, made by Claude Code. This is the call behind /usage.~/.claude/usage-reporter/usage.json and reads it back to merge. The file holds percentages, reset times, credit figures, the per-surface split, and raw, Anthropic's last response as given. No token, no prompts. The mod cannot set the file's mode, so it gets your default permissions; on a Mac with other accounts that can reach ~/.claude, they can read your usage and credit figures.HOME, else USERPROFILE.claude plugin validate . prints the same list from the source. The whole mod is hooks/register.ts.
cloudSessionCredits is read from a key Anthropic names by codename (iguana_necktie), matched to the credit by its amount. If they rename it, the field goes absent until the mod is updated.grants treats any top-level object in Anthropic's response with a numeric used_dollars as a grant, other than the usage windows. A new grant appears there under its codename without a mod update, but with no friendly label or ends until the mod learns it.projectSetupCredit is read from harbor_lantern, another codename, matched to Claude Desktop's "Project setup credit" bar by its limit, spend, and expiry. After the credit expires the key goes back to null, so projectSetupCredit and its grant drop out of the file (seen 2026-10-05).limits[] with null five_hour and seven_day) while the status line has no windows either. The file then has an empty windows[] and the budget in credits, and in grants when spending is enabled, refreshed at most every five minutes. Any other response without windows is treated as a shape the mod cannot read, and the last report's windows stay. The response carries no reset date for the budget, so none is written; Claude's settings page shows it resetting at the start of each month.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, Claude Code refuses the call and you get session and weekly only.claude plugin validate .
claude plugin test .
CI runs both on every push to main and every pull request, against the latest Claude Code.
MIT. See LICENSE.
hooks/register.ts 266 lines1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3// Claude Code makes the usage call with its own login, and this mod writes the answer to a file
4// other tools read. The credential never reaches the mod.
5const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'
6const FILE = '.claude/usage-reporter/usage.json'
7const FLOOR_MS = 5 * 60_000 // the endpoint rate-limits; one call per five minutes across all sessions
8const BACKOFF_MS = 10 * 60_000 // after a 429
9
10/** One usage window. A weekly window with a `label` is scoped to that model family. */
11type Window = { kind: 'session' | 'weekly'; label?: string; percent: number; resetsAt?: string; at: string }
12
13/**
14 * Usage credits, in major units of `currency` (dollars, not cents). `limit` is null when no limit
15 * is set, and absent when one is set in a shape this mod does not know.
16 */
17type Credits = { enabled: boolean; used: number; limit?: number | null; currency?: string; at: string }
18
19/** Cloud session credits, in dollars. `resetsAt` is when the credit expires (Claude's usage page says "Expires"); the name predates that. */
20type CloudCredits = { used: number; limit?: number; currency: 'USD'; resetsAt?: string; at: string }
21
22/** The one-time Claude Projects setup credit, in dollars. It expires at `expiresAt`; it does not reset. */
23type SetupCredit = { used: number; limit?: number; currency: 'USD'; expiresAt?: string; at: string }
24
25/**
26 * One dollar credit or grant. `id` is the key it came from (`extra_usage`, or Anthropic's codename),
27 * `label` a name for people, the codename itself when the mod does not know it. `limit` is null when
28 * no limit is set. `ends` says whether `endsAt` is a reset or an expiry, and is absent when not known.
29 */
30type Grant = { id: string; label: string; used: number; limit?: number | null; currency: string; endsAt?: string; ends?: 'reset' | 'expiry'; at: string }
31
32/** Codenamed grants the mod knows. Any other top-level object with a numeric `used_dollars` still becomes a grant. */
33const KNOWN_GRANTS: Record<string, { label: string; ends?: Grant['ends'] }> = {
34 iguana_necktie: { label: 'Cloud sessions', ends: 'expiry' },
35 harbor_lantern: { label: 'Project setup', ends: 'expiry' },
36}
37
38/** The weekly window's usage by surface (Claude Code, chat, ...). Rows pass through as given, unknown keys included. */
39type Breakdown = { windowStartedAt?: string; rows: { key: string; label?: string; percent: number }[]; at: string }
40
41/** The file, format version 1. `raw` is Anthropic's last response, unparsed; its shape is theirs. */
42type Report = {
43 version: 1
44 at: string
45 windows: Window[]
46 credits?: Credits
47 cloudSessionCredits?: CloudCredits
48 projectSetupCredit?: SetupCredit
49 grants?: Grant[]
50 weeklyBreakdown?: Breakdown
51 raw?: unknown
52}
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 const started = await next(e)
57 await report($).catch(() => {})
58 return started
59 })
60
61 on('session.measure', async ($, e, next) => {
62 if (e.changed.includes('rateLimits')) await report($, e.rateLimits).catch(() => {})
63 return next(e)
64 })
65
66 // A quiet stretch moves no whole point, so nothing above writes and readers see an old file. Once the
67 // floor has lapsed, a main-conversation turn reports anyway, which stamps the file current.
68 on('turn.complete', async ($, e, next) => {
69 const done = await next(e)
70 if (!e.agentId && (await $.clock.now()) >= Number((await $.store.get('nextFetchAt')) ?? 0)) await report($).catch(() => {})
71 return done
72 })
73}
74
75async function report($: EngineInterface, rateLimits?: readonly SessionRateLimit[]) {
76 // HOME, else USERPROFILE for Windows; status-enhanced resolves it the same way.
77 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
78 if (!home) return
79 const path = `${home}/${FILE}`
80 const now = await $.clock.now()
81 const at = new Date(now).toISOString()
82 const write = (rest: Omit<Report, 'version' | 'at'>) => $.fs.write(path, JSON.stringify({ version: 1, at, ...rest } satisfies Report))
83
84 if (now >= Number((await $.store.get('nextFetchAt')) ?? 0)) {
85 // Claim the slot before the call so a second session starting now skips it.
86 await $.store.set('nextFetchAt', now + FLOOR_MS)
87 try {
88 const auth = await $.session.authorize()
89 if (auth?.kind === 'bearer') {
90 const res = await $.http.fetch(ENDPOINT, { headers: { 'anthropic-beta': 'oauth-2025-04-20' }, auth: auth.handle })
91 if (res.ok) {
92 const raw: unknown = JSON.parse(res.text)
93 const credits = fromCredits(raw, at)
94 const grants = fromGrants(raw, credits, at)
95 const windows = fromUsage(raw, at)
96 // An Enterprise login has no windows, only a spend budget, and is written with `windows: []`.
97 // It is recognized by its shape, not by an empty status line, which a session also has before
98 // its first turn. Any other response without windows is a shape this mod does not read, and
99 // the merge below keeps the last report's windows instead.
100 const enterprise =
101 windows.length === 0 && isWindowless(raw) && fromRateLimits(rateLimits ?? (await $.session.usage()).rateLimits, at).length === 0
102 if (windows.length > 0 || enterprise)
103 return write({
104 windows,
105 credits,
106 cloudSessionCredits: fromCloudCredits(raw, at),
107 projectSetupCredit: fromSetupCredit(raw, at),
108 grants,
109 weeklyBreakdown: fromBreakdown(raw, at),
110 raw,
111 })
112 }
113 if (res.status === 429) await $.store.set('nextFetchAt', now + BACKOFF_MS)
114 }
115 } catch {
116 // Refused or offline: fall through to the figures the session already has.
117 }
118 }
119
120 // Inside the floor, or the call failed: the session and weekly percent Claude Code already holds
121 // for its status line, merged into the last report so the model-scoped windows and credits stay.
122 // With none (an Enterprise login), the last report stands as is.
123 const latest = fromRateLimits(rateLimits ?? (await $.session.usage()).rateLimits, at)
124 if (latest.length === 0) return
125 const last = await $.fs.read(path).then(text => JSON.parse(text) as Partial<Report>, () => ({}) as Partial<Report>)
126 const { windows = [], credits, cloudSessionCredits, projectSetupCredit, grants, weeklyBreakdown } = last.version === 1 ? last : ({} as Partial<Report>)
127 await write({ windows: merge(windows, latest), credits, cloudSessionCredits, projectSetupCredit, grants, weeklyBreakdown, raw: last.raw })
128}
129
130/** The usage response: `limits[]` when present, else the older `five_hour` / `seven_day` objects. */
131function fromUsage(raw: any, at: string): Window[] {
132 const windows: Window[] = []
133 for (const limit of Array.isArray(raw?.limits) ? raw.limits : []) {
134 if (typeof limit?.percent !== 'number') continue
135 const base = { percent: limit.percent, resetsAt: iso(limit.resets_at), at }
136 if (limit.kind === 'session') windows.push({ kind: 'session', ...base })
137 else if (limit.kind === 'weekly_all') windows.push({ kind: 'weekly', ...base })
138 else if (limit.kind === 'weekly_scoped') {
139 const scope = limit.scope
140 windows.push({ kind: 'weekly', label: scope?.model?.display_name ?? scope?.display_name ?? scope?.name ?? 'Scoped', ...base })
141 }
142 }
143 if (windows.some(w => w.label === undefined)) return windows
144
145 const older = (key: string, kind: Window['kind'], label?: string): Window[] =>
146 typeof raw?.[key]?.utilization === 'number' ? [{ kind, label, percent: raw[key].utilization, resetsAt: iso(raw[key].resets_at), at }] : []
147 return [...older('five_hour', 'session'), ...older('seven_day', 'weekly'), ...older('seven_day_opus', 'weekly', 'Opus'), ...older('seven_day_sonnet', 'weekly', 'Sonnet')]
148}
149
150/**
151 * A response that says outright there are no windows: an empty `limits[]` and null `five_hour` and
152 * `seven_day`, as an Enterprise login returns. A missing key is not enough; that may be a renamed one.
153 */
154function isWindowless(raw: any): boolean {
155 return Array.isArray(raw?.limits) && raw.limits.length === 0 && raw.five_hour === null && raw.seven_day === null
156}
157
158/** Credits from the usage response: `spend` when it parses, else `extra_usage`. Undefined when neither does. */
159function fromCredits(raw: any, at: string): Credits | undefined {
160 const spend = raw?.spend
161 const used = major(spend?.used)
162 if (used !== undefined) {
163 // A set limit mirrors `used`. A bare number is unseen; it would be read as minor units at `used`'s exponent.
164 const limit = spend.limit === null ? null : major(spend.limit, spend.used.exponent)
165 return { enabled: spend.enabled === true, used, limit, currency: text(spend.used.currency), at }
166 }
167
168 // `used_credits` and `monthly_limit` are minor units (cents) at `decimal_places`, 2 when absent.
169 // Seen for both: `monthly_limit` 10000 beside a `spend.limit` of 100.00, and a non-zero
170 // `used_credits` equal to `spend.used.amount_minor`.
171 const extra = raw?.extra_usage
172 const places = extra?.decimal_places ?? 2
173 const spent = major(extra?.used_credits, places)
174 if (spent === undefined) return undefined
175 const limit = extra.monthly_limit === null ? null : major(extra.monthly_limit, places)
176 return { enabled: extra.is_enabled === true, used: spent, limit, currency: text(extra.currency), at }
177}
178
179/**
180 * Cloud session credits, from `iguana_necktie`. That key is Anthropic's codename, matched to the
181 * credit by its amount. When they rename it this field goes absent, and the fix is the key here.
182 */
183function fromCloudCredits(raw: any, at: string): CloudCredits | undefined {
184 const grant = raw?.iguana_necktie
185 if (typeof grant?.used_dollars !== 'number') return undefined
186 const limit = typeof grant.limit_dollars === 'number' ? grant.limit_dollars : undefined
187 return { used: grant.used_dollars, limit, currency: 'USD', resetsAt: iso(grant.resets_at), at }
188}
189
190/**
191 * The Projects setup credit, from `harbor_lantern`, another codename. Matched on 2026-10-04 to the
192 * "Project setup credit" bar in Claude Desktop by its $100 limit, used amount, and expiry time.
193 */
194function fromSetupCredit(raw: any, at: string): SetupCredit | undefined {
195 const grant = raw?.harbor_lantern
196 if (typeof grant?.used_dollars !== 'number') return undefined
197 const limit = typeof grant.limit_dollars === 'number' ? grant.limit_dollars : undefined
198 return { used: grant.used_dollars, limit, currency: 'USD', expiresAt: iso(grant.resets_at), at }
199}
200
201/**
202 * Every dollar credit in one list: extra usage when it is on, then each top-level object in the
203 * response with a numeric `used_dollars`, other than the usage windows (`five_hour`, `seven_day*`).
204 * Undefined when there are none.
205 */
206function fromGrants(raw: any, credits: Credits | undefined, at: string): Grant[] | undefined {
207 const grants: Grant[] = []
208 if (credits?.enabled) grants.push({ id: 'extra_usage', label: 'Extra usage', used: credits.used, limit: credits.limit, currency: credits.currency ?? 'USD', at })
209 for (const [id, grant] of Object.entries<any>(raw && typeof raw === 'object' ? raw : {})) {
210 if (id.startsWith('five_hour') || id.startsWith('seven_day') || typeof grant?.used_dollars !== 'number') continue
211 const known = KNOWN_GRANTS[id]
212 const limit = typeof grant.limit_dollars === 'number' ? grant.limit_dollars : undefined
213 grants.push({ id, label: known?.label ?? id, used: grant.used_dollars, limit, currency: 'USD', endsAt: iso(grant.resets_at), ends: known?.ends, at })
214 }
215 return grants.length > 0 ? grants : undefined
216}
217
218/** The weekly breakdown, from `seven_day_breakdown`. Rows without a string key and a numeric percent are dropped. */
219function fromBreakdown(raw: any, at: string): Breakdown | undefined {
220 const breakdown = raw?.seven_day_breakdown
221 const rows = (Array.isArray(breakdown?.rows) ? breakdown.rows : [])
222 .filter((row: any) => typeof row?.key === 'string' && typeof row.percent === 'number')
223 .map((row: any) => ({ key: row.key, label: text(row.display_name), percent: row.percent }))
224 return rows.length > 0 ? { windowStartedAt: iso(breakdown.window_started_at), rows, at } : undefined
225}
226
227/** A money figure in major units: `{ amount_minor, exponent }`, or a bare number of minor units at `exponent`. */
228function major(value: any, exponent?: unknown): number | undefined {
229 const minor = typeof value === 'number' ? value : value?.amount_minor
230 const places = value?.exponent ?? exponent
231 return typeof minor === 'number' && typeof places === 'number' ? minor / 10 ** places : undefined
232}
233
234const text = (value: unknown) => (typeof value === 'string' ? value : undefined)
235
236/** The status line's figures: `five_hour` and `seven_day`, no model-scoped windows. */
237function fromRateLimits(rateLimits: readonly SessionRateLimit[], at: string): Window[] {
238 const kinds: Record<string, Window['kind']> = { five_hour: 'session', seven_day: 'weekly' }
239 return rateLimits.flatMap(limit => {
240 const kind = kinds[limit.kind]
241 return kind ? [{ kind, percent: limit.percentUsed, resetsAt: iso(limit.resetsAt), at }] : []
242 })
243}
244
245/**
246 * Lays newer windows over the previous ones. The status line trails the endpoint by a point, so
247 * inside one window (same reset time) a lower reading never replaces a higher one.
248 */
249function merge(previous: Window[], latest: Window[]): Window[] {
250 const key = (w: Window) => `${w.kind}/${w.label ?? ''}`
251 const merged = previous.map(old => {
252 const next = latest.find(w => key(w) === key(old))
253 if (!next) return old
254 const isSameWindow = old.resetsAt !== undefined && next.resetsAt !== undefined && Math.abs(Date.parse(old.resetsAt) - Date.parse(next.resetsAt)) < 60_000
255 return isSameWindow && next.percent < old.percent ? old : next
256 })
257 return [...merged, ...latest.filter(w => !previous.some(old => key(old) === key(w)))]
258}
259
260/** A timestamp as ISO 8601 UTC, or as given when it does not parse. */
261function iso(value: unknown): string | undefined {
262 if (typeof value !== 'string') return undefined
263 const time = Date.parse(value)
264 return Number.isNaN(time) ? value : new Date(time).toISOString()
265}
266