Shows context, 5-hour and weekly limit usage in the band above the prompt

プロンプト入力欄の上の帯に、コンテキストの使用率と、5 時間制限と週間制限の使用率を表示する mod です。
ctx ██░░░░░░░░ 19% 5h ████░░░░░░ 43% 2h34m 7d █░░░░░░░░░ 9% 1d21h 14:05
HH:MM は、表示している利用制限の値を測った時刻ですデスクトップアプリでは、5h と 7d にアプリの使用量の表示と同じ値を出します。 5h と 7d の値は、このマシンのセッション間で共有します。別のセッションで使った分も、1 分ほどで反映されます。 ctx の値は、セッションごとのものです。
5h と 7d の使用率を、あとで集計できるようにファイルに書き残せます。 既定ではオフで、オンにしたときだけ書きます。
| 設定 | 既定 | 内容 |
|---|---|---|
log | false | true にすると記録を書く |
logDir | ~/.claude/usage-band/ | 記録を書くフォルダ(~ はホームフォルダ。絶対パスも使える) |
設定は、usage-band の設定の「使用率を記録する」(log)と「記録の置き場」(logDir)で変えます。
記録は日ごとのファイル usage-YYYYMMDD.jsonl(日付はこのマシンの日付)に、1 行に 1 つの JSON で書き足します。
{"ts":"2026-10-06T03:12:45.120Z","src":"card","five_hour":{"used":43,"resets_at":"2026-10-06T05:59:59.852Z"},"seven_day":{"used":9,"resets_at":"2026-10-07T23:59:59.852Z"}}
ts: 行を書いた時刻src: 値を読んだ元。card はデスクトップアプリの使用量の表示、response は Claude Code が受け取った応答five_hour、seven_day: 使用率(used、%)とリセットの時刻(resets_at)。値は読んだままです。利用制限の種類が増えると、その種類の名前(例 spend_limit)の項目も並びますClaude Code v2.1.286 以降で動作を確認しています。 インストール方法は、リポジトリの README を見てください。
hooks/register.js 563 lines1// Shows context, 5-hour limit and weekly limit usage in the band above the prompt.
2// On a narrow band the parts give way in a fixed order (fitBand): the clock first, then the
3// reset countdowns, then the bars shorten, then the bars go; the labels and percentages stay.
4// Every meter keeps its width (flexShrink 0), so none of them overlaps another or wraps.
5// The context figure is this session's own. The rate-limit figures are the account's. Where the
6// desktop app's own usage card answers (its ccd_session_mgmt server's get_usage), the 5h and weekly
7// figures come from it, so the band shows what the app shows; elsewhere (the terminal) they come
8// from the session's last API response, which lags the card by a few minutes. The newest reading
9// any session made is shared through $.store, and every session shows the newer of its own and
10// the stored.
11// With the `log` option on, each reading a session publishes to the store is also appended to
12// a daily JSON Lines file under `logDir`, so the readings can be aggregated later.
13
14// This session's context, from $.session.usage() or session.measure; never shared
15let context = null
16// This session's own rate-limit readings, { rateLimits, measuredAt, src }, one per source:
17// measuredAt is $.clock.now() ms when the figures arrived here (neither source gives a time of
18// its own), src is 'card' (the app's usage card) or 'response' (the last API response)
19let fromCard = null
20let fromResponse = null
21// Whether the last read of the card answered with figures; while it does, the card's reading is
22// this session's own and the response's is not used
23let cardOk = false
24// When the last read of the card started, for the throttle
25let cardAt = null
26// The read of the card in flight, which a second caller waits on instead of reading again
27let cardPending = null
28// A read of the card put off by the throttle, kept so a final session.end can stop it
29let cardLater = null
30// The reading drawn: own, or the stored one when that is newer
31let shown = null
32// Refreshes the countdowns; kept so a later session.start or session.end can stop it
33let ticker = null
34// register()'s options, defaults filled in
35let config = null
36// The last reading appended to the log, so a reading stored again (after another session's older
37// write landed last) is not logged twice
38let logged = null
39// Log appends run one after another, so two in one session never read the same old file
40let logQueue = Promise.resolve()
41// Whether this session has shown its one toast about the log
42let logWarned = false
43
44// session.end reasons after which the session is gone; /clear, /resume and logout keep it running
45const FINAL_REASONS = ['prompt_input_exit', 'other']
46const TICK_MS = 60_000
47// The $.store key holding the newest reading any session made
48const STORE_KEY = 'rateLimits'
49// Two resetsAt this close are the same window; the next window resets at least 5 hours later
50const SAME_WINDOW_MS = 3_600_000
51// Two resetsAt this close are the same figures: the card's carry milliseconds that may move
52// between reads
53const SAME_RESET_MS = 60_000
54
55// The desktop app's usage card, and the windows of it the band shows, by label
56const CARD = { server: 'ccd_session_mgmt', tool: 'get_usage' }
57// The same tool as the model calls it
58const CARD_TOOL = 'mcp__ccd_session_mgmt__get_usage'
59const CARD_KINDS = { '5-hour limit': 'five_hour', 'Weekly · all models': 'seven_day' }
60// The card is read at most once in this many milliseconds; a read asked for sooner runs when
61// they have passed
62const CARD_GAP_MS = 10_000
63
64const LABELS = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
65
66const DEFAULTS = { log: false, logDir: '~/.claude/usage-band/' }
67
68// Usage at or above these percentages turns the meter yellow, then red
69const WARNING_AT = 50
70const ERROR_AT = 80
71
72// The text bar's cells, at full length and shortened
73const BAR_CELLS = { full: 10, short: 5 }
74const METER_GAP = 3
75// The band's last columns, which the terminal may draw over
76const BAND_RESERVED_COLUMNS = 2
77// The SVG bar's size in pixels, at full length and shortened
78const SVG_BAR = { full: 96, short: 48, height: 10 }
79// Pixels taken as one cell, to size the SVG bar in cells (an estimate: the desktop app does not
80// report its font's advance)
81const PX_PER_CELL = 8
82const SVG_COLORS = { success: '#4caf50', warning: '#e0a526', error: '#e5534b', track: 'rgba(128,128,128,0.3)' }
83
84// How the band may give way, widest first
85const STEPS = [
86 { bar: 'full', hasResets: true, hasClock: true },
87 { bar: 'full', hasResets: true, hasClock: false },
88 { bar: 'full', hasResets: false, hasClock: false },
89 { bar: 'short', hasResets: false, hasClock: false },
90 { bar: null, hasResets: false, hasClock: false },
91]
92
93export function register(on, options) {
94 config = configOf(options)
95 // Fires again on an enable or a worker respawn, which may keep this module's variables
96 on('session.start', async ($, e, next) => {
97 ticker?.cancel()
98 await readUsage($)
99 await readCard($)
100 await sync($)
101 // Counts the resets down, polls this session's figures and picks up the stored ones
102 ticker = $.clock.every(TICK_MS, () => void refresh($))
103 $.ui.invalidate('ui.render')
104 return next(e)
105 })
106
107 // The desktop app (or another client) joined the session: read the figures again
108 on('session.attach', async ($, e, next) => {
109 await refresh($)
110 return next(e)
111 })
112
113 on('session.end', async ($, e, next) => {
114 if (FINAL_REASONS.includes(e.reason)) {
115 ticker?.cancel()
116 cardLater?.cancel()
117 cardLater = null
118 }
119 return next(e)
120 })
121
122 // session.measure reports a changed context only after the next turn, so read it now
123 on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
124 await readUsage($)
125 await readCard($)
126 await sync($)
127 $.ui.invalidate('ui.render')
128 return next(e)
129 })
130
131 // Fires after each turn, and when a rate-limit window moves a whole point
132 on('session.measure', async ($, e, next) => {
133 context = e.context
134 fromResponse = taken(fromResponse, e.rateLimits, await $.clock.now(), 'response')
135 // The response moved, so the account's usage did: the card has likely moved too
136 if (e.changed?.includes('rateLimits')) await readCard($)
137 await sync($)
138 $.ui.invalidate('ui.render')
139 return next(e)
140 })
141
142 // The model asked the app for its usage: the band takes the same answer. The call itself is
143 // passed on and its result returned as is.
144 on('tool.call', { tool: CARD_TOOL }, async ($, e, next) => {
145 const answer = await next(e)
146 await takeCardAnswer($, answer)
147 return answer
148 })
149
150 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151 const elements = $.ui.resolve(e)
152 const now = await $.clock.now()
153 const meters = [{ label: 'ctx', used: context?.percent, resetsAt: null }]
154 for (const limit of shown?.rateLimits ?? []) {
155 const resetsAt = limit.resetsAt == null ? null : Date.parse(limit.resetsAt)
156 // A window that reset since the last measurement starts again from zero
157 const reset = resetsAt != null && resetsAt <= now
158 meters.push({
159 label: LABELS[limit.kind] ?? limit.kind,
160 used: reset ? 0 : limit.percentUsed,
161 resetsAt: reset ? null : resetsAt,
162 })
163 }
164 for (const m of meters) {
165 m.percent = percentText(m.used)
166 m.reset = m.resetsAt == null ? null : untilReset(m.resetsAt - now)
167 }
168 const stamp = shown && shown.rateLimits.length > 0 ? clockText(shown.measuredAt) : null
169 const fit = fitBand(meters, stamp, e.surface, widthOf(e))
170
171 const children = meters.map((m) => meter(elements, e.surface, fit, m))
172 // When the shown rate-limit figures were measured, by whichever session
173 if (stamp && fit.hasClock) {
174 const time = elements.Text({ dimColor: true, wrap: 'truncate-end', children: [stamp] })
175 children.push(elements.Box({ key: 'rate-time', flexDirection: 'row', flexShrink: 0, alignItems: 'center', children: [time] }))
176 }
177 const line = elements.Box({ flexDirection: 'row', flexWrap: 'nowrap', columnGap: METER_GAP, children })
178 // Keep what later mods draw in the band
179 const rest = await next(e)
180 if (!rest) return line
181 return elements.Box({ flexDirection: 'column', children: [line, rest] })
182 })
183}
184
185async function readUsage($) {
186 const usage = await $.session.usage()
187 context = usage.context
188 fromResponse = taken(fromResponse, usage.rateLimits, await $.clock.now(), 'response')
189}
190
191// Polls this session's figures, syncs with the store and redraws; a failed read keeps the last
192// figures
193async function refresh($) {
194 try {
195 await readUsage($)
196 } catch {}
197 await readCard($)
198 await sync($)
199 $.ui.invalidate('ui.render')
200}
201
202// Reads the app's usage card into fromCard, at most once per CARD_GAP_MS: a read asked for sooner
203// is put off until then (one at a time), and a caller during a read waits on that read. Never
204// throws; a card that does not answer with figures (no such server, as in the terminal, an
205// error, or a status other than ok) leaves cardOk false, so the response's figures are used.
206function readCard($) {
207 if (cardPending) return cardPending
208 cardPending = (async () => {
209 try {
210 const now = await $.clock.now()
211 if (cardAt != null && now - cardAt < CARD_GAP_MS) {
212 cardLater ??= $.clock.after(cardAt + CARD_GAP_MS - now, () => {
213 cardLater = null
214 void refreshCard($)
215 })
216 return
217 }
218 cardAt = now
219 const limits = await cardLimits($)
220 cardOk = limits != null
221 if (cardOk) fromCard = taken(fromCard, limits, await $.clock.now(), 'card')
222 } catch {
223 cardOk = false
224 }
225 })().finally(() => {
226 cardPending = null
227 })
228 return cardPending
229}
230
231// The read the throttle put off: reads the card, syncs and redraws
232async function refreshCard($) {
233 await readCard($)
234 await sync($)
235 $.ui.invalidate('ui.render')
236}
237
238// The model called the card's tool itself: takes what it answered as a read of the card, so the
239// band shows the figures Claude reports. Silent on anything it cannot use.
240async function takeCardAnswer($, answer) {
241 try {
242 if (!answer || answer.isError || answer.deny != null) return
243 const text = typeof answer.text === 'string' ? answer.text : firstText(answer.result?.content ?? answer.result)
244 const limits = cardLimitsOf(text)
245 if (!limits) return
246 const now = await $.clock.now()
247 cardAt = now
248 cardOk = true
249 fromCard = taken(fromCard, limits, now, 'card')
250 await sync($)
251 $.ui.invalidate('ui.render')
252 } catch {}
253}
254
255// The card's 5h and weekly windows as rate limits, read with $.mcp.call; null when the card
256// cannot be read or has neither window
257async function cardLimits($) {
258 let result
259 try {
260 result = await $.mcp.call(CARD.server, CARD.tool, {})
261 } catch {
262 return null
263 }
264 if (!result || result.isError) return null
265 return cardLimitsOf(firstText(result.content))
266}
267
268// The text of the first text block among MCP content blocks; undefined when there is none
269function firstText(blocks) {
270 if (!Array.isArray(blocks)) return undefined
271 return blocks.find((b) => b?.type === 'text' && typeof b.text === 'string')?.text
272}
273
274// The card's 5h and weekly windows from get_usage's text, percentUsed and resetsAt as given; null
275// when the text is not the card's JSON, its status is not ok, or it has neither window
276function cardLimitsOf(text) {
277 if (typeof text !== 'string') return null
278 let data
279 try {
280 data = JSON.parse(text)
281 } catch {
282 return null
283 }
284 const plan = data?.plan
285 if (plan?.status !== 'ok' || !Array.isArray(plan.windows)) return null
286 const limits = []
287 for (const w of plan.windows) {
288 const kind = Object.hasOwn(CARD_KINDS, w?.label) ? CARD_KINDS[w.label] : null
289 if (!kind || typeof w.percentUsed !== 'number' || limits.some((l) => l.kind === kind)) continue
290 const limit = { kind, percentUsed: w.percentUsed }
291 if (typeof w.resetsAt === 'string') limit.resetsAt = w.resetsAt
292 limits.push(limit)
293 }
294 return limits.length > 0 ? limits : null
295}
296
297// `limits` as a new reading from `src`, or `prev` when they are empty or the same figures, so
298// measuredAt says how old the figures are, not when they were last asked for
299function taken(prev, limits, now, src) {
300 if (!Array.isArray(limits) || limits.length === 0) return prev
301 if (prev && sameFigures(prev.rateLimits, limits)) return prev
302 return { rateLimits: limits, measuredAt: now, src }
303}
304
305// Whether two lists of windows say the same: the same kinds and percentages in the same order,
306// and resets within SAME_RESET_MS of each other
307function sameFigures(a, b) {
308 if (a.length !== b.length) return false
309 return a.every((x, i) => {
310 const y = b[i]
311 if (x.kind !== y.kind || x.percentUsed !== y.percentUsed) return false
312 if (x.resetsAt == null || y.resetsAt == null) return x.resetsAt == null && y.resetsAt == null
313 return Math.abs(Date.parse(x.resetsAt) - Date.parse(y.resetsAt)) < SAME_RESET_MS
314 })
315}
316
317// This session's own reading: the card's while the card answers, else the response's
318function ownReading() {
319 return cardOk && fromCard ? fromCard : fromResponse
320}
321
322// Publishes this session's reading when it is newer than the stored one, and shows the newer of
323// the two; while the card answers, shows the card's reading whatever is stored, since the band
324// is to match the app and a stored response reading may run ahead of the card. Read, compare, write: two sessions syncing at once may both write, and the later
325// write wins even if it is the older reading. That loses at most a moment's difference, and the
326// session holding the newer reading writes it again on its next sync (within a minute).
327async function sync($) {
328 const own = ownReading()
329 let stored = null
330 try {
331 stored = asReading(await $.store.get(STORE_KEY))
332 } catch {}
333 if (own && newer(own, stored) === own) {
334 try {
335 await $.store.set(STORE_KEY, own)
336 } catch {}
337 if (config.log && own !== logged) {
338 const reading = own
339 logged = reading
340 logQueue = logQueue.then(() => appendLog($, reading))
341 await logQueue
342 }
343 }
344 shown = cardOk && fromCard ? fromCard : newer(own, stored)
345}
346
347// register()'s options; a value of the wrong type keeps the default
348function configOf(options) {
349 const logDir = options?.logDir
350 return {
351 log: typeof options?.log === 'boolean' ? options.log : DEFAULTS.log,
352 logDir: typeof logDir === 'string' && logDir.trim() !== '' ? logDir.trim() : DEFAULTS.logDir,
353 }
354}
355
356// Appends one line for `reading` to the day's file. $.fs has no append, so the day's file is
357// read whole and written back. Never throws: a failure shows one toast per session at most.
358// Two sessions appending at the same moment could lose a line; only the session holding a newer
359// reading writes, so that takes two new readings within one read and write.
360async function appendLog($, reading) {
361 try {
362 const now = await $.clock.now()
363 const dir = await logDirPath($)
364 if (dir == null) {
365 warnLog($, 'usage-band: ホームフォルダが分からないため、使用率の記録を書けません。usage-band の設定「記録の置き場」(logDir)にフォルダを指定してください')
366 return
367 }
368 const path = dir + '/' + logFileName(now)
369 let text = ''
370 // A file that exists but cannot be read is left alone rather than overwritten
371 if (await $.fs.exists(path)) text = await $.fs.read(path)
372 if (text !== '' && !text.endsWith('\n')) text += '\n'
373 await $.fs.write(path, text + logLine(reading, now) + '\n')
374 } catch (error) {
375 warnLog($, 'usage-band: 使用率の記録を書けませんでした(' + String(error?.message ?? error) + ')')
376 }
377}
378
379function warnLog($, text) {
380 if (logWarned) return
381 logWarned = true
382 $.ui.toast(text)
383}
384
385// logDir as an absolute path with forward slashes and no trailing slash; a leading `~` is the
386// home folder (USERPROFILE on Windows, else HOME). null when `~` is used and neither is set.
387async function logDirPath($) {
388 let dir = config.logDir.replace(/\\/g, '/')
389 if (dir === '~' || dir.startsWith('~/')) {
390 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
391 if (!home) return null
392 dir = home.replace(/\\/g, '/').replace(/\/+$/, '') + dir.slice(1)
393 }
394 return dir.length > 1 ? dir.replace(/\/+$/, '') : dir
395}
396
397// usage-YYYYMMDD.jsonl, by the local date
398function logFileName(ms) {
399 const d = new Date(ms)
400 const pad = (n) => String(n).padStart(2, '0')
401 return 'usage-' + d.getFullYear() + pad(d.getMonth() + 1) + pad(d.getDate()) + '.jsonl'
402}
403
404// One JSON object: ts (when written), src (where the figures came from: 'card' or 'response')
405// and, per window kind, the raw percentage and reset time
406function logLine(reading, now) {
407 const line = { ts: new Date(now).toISOString(), src: reading.src ?? 'response' }
408 for (const limit of reading.rateLimits) {
409 line[limit.kind] = { used: limit.percentUsed, resets_at: limit.resetsAt ?? null }
410 }
411 return JSON.stringify(line)
412}
413
414// A stored value as a reading, or null when it is not one (unset, or written by something else)
415function asReading(value) {
416 if (!value || !Array.isArray(value.rateLimits) || typeof value.measuredAt !== 'number') return null
417 return value
418}
419
420// The newer of two readings; b when they tie. The figures decide first: a percentage never falls
421// within a window, so a reading behind the other is older whatever its time says (a stale reading
422// stamped late, such as the one a re-fired session.start takes in an idle session). The time
423// decides otherwise.
424function newer(a, b) {
425 if (!a) return b
426 if (!b) return a
427 const aBehind = isBehind(a.rateLimits, b.rateLimits)
428 const bBehind = isBehind(b.rateLimits, a.rateLimits)
429 if (aBehind !== bBehind) return aBehind ? b : a
430 return a.measuredAt > b.measuredAt ? a : b
431}
432
433// Whether some window in a is an earlier window than in b, or the same window used less
434function isBehind(a, b) {
435 return a.some((x) => {
436 const y = b.find((l) => l.kind === x.kind)
437 if (!y || x.resetsAt == null || y.resetsAt == null) return false
438 const gap = Date.parse(y.resetsAt) - Date.parse(x.resetsAt)
439 if (gap > SAME_WINDOW_MS) return true
440 return Math.abs(gap) <= SAME_WINDOW_MS && x.percentUsed < y.percentUsed
441 })
442}
443
444function statusOf(used) {
445 if (used >= ERROR_AT) return 'error'
446 if (used >= WARNING_AT) return 'warning'
447 return 'success'
448}
449
450function percentText(used) {
451 return typeof used === 'number' ? Math.round(used) + '%' : '—'
452}
453
454// The cells across the band: the site's own width, else what the surface measured; null when
455// neither is known
456function widthOf(e) {
457 const n = e.props?.bodyColumns
458 if (typeof n === 'number' && n > 0) return n
459 const v = e.viewport?.columns
460 return typeof v === 'number' && v > 0 ? v : null
461}
462
463// Character cells a text takes: two for a wide (CJK, full-width) character, one otherwise
464function cells(text) {
465 let n = 0
466 for (const ch of String(text)) {
467 const c = ch.codePointAt(0)
468 n += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
469 }
470 return n
471}
472
473// The cells a bar takes at a length ('full', 'short')
474function barCells(surface, length) {
475 return surface === 'desktop' ? Math.ceil(SVG_BAR[length] / PX_PER_CELL) : BAR_CELLS[length]
476}
477
478// The first step of STEPS whose parts fit on one line in `columns`: everything when the width
479// is unknown, the narrowest step when nothing fits
480function fitBand(meters, stamp, surface, columns) {
481 if (columns == null) return STEPS[0]
482 const room = columns - BAND_RESERVED_COLUMNS
483 for (const step of STEPS) {
484 let width = METER_GAP * (meters.length - 1)
485 for (const m of meters) {
486 width += cells(m.label) + 1 + cells(m.percent)
487 if (step.bar) width += 1 + barCells(surface, step.bar)
488 if (step.hasResets && m.reset) width += 1 + cells(m.reset)
489 }
490 if (step.hasClock && stamp) width += METER_GAP + cells(stamp)
491 if (width <= room) return step
492 }
493 return STEPS[STEPS.length - 1]
494}
495
496// Local time as HH:MM
497function clockText(ms) {
498 const d = new Date(ms)
499 return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
500}
501
502// One meter: the label, the bar as `fit` says, and the percentage with its countdown when `fit`
503// keeps it. The Box does not shrink, so its parts never wrap or slide under the next meter.
504function meter({ Box, Text, Svg }, surface, fit, { label, used, percent, reset }) {
505 const known = typeof used === 'number'
506 const status = known ? statusOf(used) : null
507 const value = fit.hasResets && reset ? percent + ' ' + reset : percent
508 const children = [Text({ wrap: 'truncate-end', children: [label] })]
509 if (fit.bar && surface === 'desktop') {
510 const width = SVG_BAR[fit.bar]
511 children.push(
512 Svg({
513 source: svgBar(known ? used : 0, status, width),
514 alt: label + ' ' + percent + (reset ? ' ' + reset : ''),
515 width,
516 height: SVG_BAR.height,
517 }),
518 )
519 } else if (fit.bar) {
520 children.push(textBar(Text, known ? used : 0, status, BAR_CELLS[fit.bar]))
521 }
522 children.push(Text({ wrap: 'truncate-end', ...(known ? { color: status } : { dimColor: true }), children: [value] }))
523 return Box({ key: 'meter-' + label, flexDirection: 'row', flexShrink: 0, columnGap: 1, alignItems: 'center', children })
524}
525
526// Used cells in the status color, the rest dim, nested in one Text so they stay on one line
527function textBar(Text, used, status, total) {
528 const filled = Math.round((clamp(used) / 100) * total)
529 return Text({
530 wrap: 'truncate-end',
531 children: [
532 Text({ ...(status ? { color: status } : { dimColor: true }), children: ['█'.repeat(filled)] }),
533 Text({ dimColor: true, children: ['░'.repeat(total - filled)] }),
534 ],
535 })
536}
537
538function svgBar(used, status, width) {
539 const { height } = SVG_BAR
540 const fill = Math.round((clamp(used) / 100) * width)
541 return [
542 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
543 `<clipPath id="c"><rect width="${width}" height="${height}" rx="${height / 2}"/></clipPath>`,
544 `<g clip-path="url(#c)">`,
545 `<rect width="${width}" height="${height}" fill="${SVG_COLORS.track}"/>`,
546 fill > 0 ? `<rect width="${fill}" height="${height}" fill="${SVG_COLORS[status ?? 'success']}"/>` : '',
547 '</g></svg>',
548 ].join('')
549}
550
551function clamp(percent) {
552 return Math.min(Math.max(percent, 0), 100)
553}
554
555function untilReset(ms) {
556 const minutes = Math.max(0, Math.ceil(ms / 60_000))
557 const days = Math.floor(minutes / 1440)
558 const hours = Math.floor((minutes % 1440) / 60)
559 if (days > 0) return days + 'd' + hours + 'h'
560 if (hours > 0) return hours + 'h' + (minutes % 60) + 'm'
561 return (minutes % 60) + 'm'
562}
563