SLOPSHOPPER

usage-meter

Shows context, 5-hour, weekly and per-model weekly limit usage above the prompt

newbandnetworktimer
★ 17v0.2.0no licenseupdated 2026-10-05HolyGrail/claude-mods/plugins/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ctx █████░░░░░ 49% 5h ███░░░░░░░ 31% NaNm (NaN:NaN) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx █████░░░░░ 49% 5h ███░░░░░░░ 31% NaNm (NaN:NaN) ⟨Claude Code's own drawing⟩
README

usage-meter

プロンプト入力欄の上の帯に、コンテキストの使用率と、5 時間制限と週間制限の使用率を常時表示する Claude Code の mod です。 Fable のようにモデル別の週間制限があるアカウントでは、その使用率も並べます。

ctx ██░░░░░░░░ 20%   5h ██████┃░░░ 62% 2h13m (23:13)   7d ┃░░░░░░░░░ 5% 4d18h   Fable ┃░░░░░░░░░ 3%

表示

  • ゲージ:使用率の塗りに、ウィンドウの経過位置を示す縦線を重ねる。Desktop アプリでは SVG、ターミナルでは文字のバーで描く。ターミナルでは、すべてのメーターがバー付きで 1 行に収まらないときにバーを省く。Desktop アプリでは、メーターが 4 本以上になるとバーを短くし、それでも収まらない幅ではメーター単位で次の行へ送る
  • 色:経過時間の割合から使用率を引いた差で決める。差が +10 以上なら緑、−15 未満か使用率 90% 以上なら赤、その間は黄
  • 残り時間:リセットまでの残り時間を添える。5 時間制限には、リセット時刻を JST の 24 時間表記で続ける
  • コンテキスト:時間の軸がないので、使用率だけで色を決める(50% と 80% が境目)
  • モデル別の週間制限:モデル名をラベルにして、週間制限と同じ規則で描く。リセットが週間制限と同時なら残り時間を省き、ずれているときだけ添える

使い方

公式ドキュメントでは、mod には Claude Code v2.1.287 以降が必要とされています。 実際には v2.1.286 でも動作を確認しており、v2.1.284 では mod が読み込まれませんでした。

Desktop アプリは、本体に同梱された Claude Code で動きます。 帯が表示されるのは、Claude Code v2.1.286 以降を同梱した本体(2.19675.0 以降)で新しく始めたセッションです。 本体を更新する前から続いているセッションは、再開しても帯が表示されないことがありました。 その場合は、新しいセッションを開いてください。

インストール

Claude Code のプロンプトで、次の 3 つを順に実行します。

/plugin marketplace add HolyGrail/claude-mods
/plugin install usage-meter@claude-mods
/reload-plugins

更新の手順と注意点は、リポジトリの README にまとめてあります。

インストールせずに試す

clone したリポジトリのルートで、次のように起動します。

claude --plugin-dir ./plugins/usage-meter

手元のコードを常に読み込むなら、~/.claude/settings.json の env に CLAUDE_CODE_PLUGIN_DIRS としてこのディレクトリの絶対パスを書きます。

制約

5 時間制限と週間制限はアカウント単位の値なので、読み取った値を $.store でマシン上のセッション全体に共有しています。 まだ値を受け取っていないセッションも、ほかのセッションが保存した最新の値を表示し、別のセッションで使った分も 1 分以内に反映されます。

$.store には atomic な更新がないため、各セッションは自分専用のキーにだけ書き込み、ほかのセッションの値を上書きしません。 それでも、キーの削除と書き込みがミリ秒単位で重なったときの競合は防げません。 たとえば、8 日以上測定していなかったセッションが測定した瞬間に、別のセッションがそのキーを古いものとして削除すると、その測定値は共有されません。 その場合も、各セッションの表示は次の測定で正しい値に戻ります。

mods API にはアカウントを識別する手段がないため、共有する値はアカウントを区別しません。 同じマシンで別のアカウントに切り替えると、新しいセッションが最初の応答を受け取るまで、前のアカウントの値が表示されます。

モデル別の週間制限

モデル別の週間制限は、/usage 画面が読むのと同じ https://api.anthropic.com/api/oauth/usage から取得しています。 mods API が渡す値は 5 時間制限と週間制限だけで、モデル別の値を含まないためです。 認証には $.session.authorize() のハンドルを使うので、トークンが mod に渡ることはありません。

  • 問い合わせはマシン全体でおよそ 5 分に 1 回で、読み取った値は $.store でセッション全体に共有する。表示は最大 5 分遅れる
  • API キー、サードパーティのプロバイダー、ゲートウェイで使っているセッションは問い合わせない。応答にモデル別の制限がないアカウントでは、メーターを出さない
  • メーターが 4 本になるぶん、ターミナルでバーを保つのに必要な幅が広がる。冒頭の例の値なら 101 桁からで、それより狭いとすべてのメーターがバーを省く
  • 公開された API ではないので、応答の形が変わるとメーターは更新されなくなる。そのメーターはリセット時刻に消える

テスト

cd plugins/usage-meter
claude plugin test
Source 1 files
hooks/register.js 438 lines
1// Shows context, 5-hour limit, weekly limit and per-model weekly limit usage in the band above
2// the prompt.
3
4// The latest figures this session has, from $.session.usage() or session.measure
5let context = null
6let rateLimits = []
7// When rateLimits was last measured, in $.clock.now() milliseconds
8let measuredAt = 0
9// The timer that refreshes the band, kept so a later session.start can stop it
10let ticker = null
11// This session's own key in the store
12let ownKey = null
13// The per-model weekly limits as last read, by this session or another: { at, limits }
14let scoped = { at: 0, limits: [] }
15// When this session last set out to ask the usage endpoint, whatever came of it
16let scopedTriedAt = 0
17// Counts session.start, so an answer to a poll an earlier start began is left unused
18let scopedGeneration = 0
19
20// Rate limits are per account, so sessions share readings through $.store, and each shows the
21// newest one. $.store has no atomic update, so each session writes only its own key, and no
22// write can overwrite another session's reading.
23// The mods API has no account id, so after switching accounts on this machine a new
24// session shows the previous account's reading until its own first response.
25const KEY_PREFIX = 'reading:'
26// Readings older than the longest window say nothing current: they are never shown, and their
27// keys are deleted
28const STALE_MS = 8 * 24 * 3_600_000
29// The session.end reasons after which this module stops; /clear, /resume (which /branch reports)
30// and logout leave it running and measuring
31const FINAL_REASONS = ['prompt_input_exit', 'other']
32// How often to pick up other sessions' readings and refresh the countdowns and time markers
33const TICK_MS = 60_000
34
35// The per-model weekly limits (Fable's), which $.session.usage() and session.measure leave out,
36// come from the endpoint /usage reads. Every session shares one key: each write is a whole fresh
37// reading, so one landing over another loses nothing.
38const SCOPED_KEY = 'scoped'
39// When any session last asked the endpoint, so that others starting or ticking meanwhile wait,
40// and a failing endpoint is asked once a poll by the machine, not once by each session. Two
41// sessions that read it in the same moment may still both ask: $.store has no atomic update.
42const SCOPED_TRIED_KEY = 'scoped-tried'
43const SCOPED_URL = 'https://api.anthropic.com/api/oauth/usage'
44// How old the shared reading gets before a session asks the endpoint again, and how long a
45// session waits after an attempt that brought nothing
46const SCOPED_POLL_MS = 5 * 60_000
47// A per-model limit that resets within this long of the weekly limit leaves its countdown out
48const SAME_RESET_MS = 60_000
49
50const HOUR_MS = 3_600_000
51const WINDOWS = {
52  // showsClock adds the reset time of day, in JST
53  five_hour: { label: '5h', ms: 5 * HOUR_MS, showsClock: true },
54  seven_day: { label: '7d', ms: 7 * 24 * HOUR_MS },
55  // Labeled with the model's name
56  weekly_scoped: { ms: 7 * 24 * HOUR_MS },
57  spend_limit: { label: '$' },
58}
59
60// Pace thresholds: margin is the elapsed share of the window minus the used share
61const GREEN_MIN_MARGIN = 10
62const RED_BELOW_MARGIN = -15
63// Usage this low stays green early in a window, when the margin is still small
64const GREEN_MAX_USED = 10
65// Usage this high is red whatever the pace
66const RED_MIN_USED = 90
67
68// JST has no daylight saving time, so a fixed offset gives its clock
69const JST_OFFSET_MS = 9 * HOUR_MS
70
71const BAR_CELLS = 10
72// Columns between two meters, and the band's last column, which the terminal may draw over
73const METER_GAP = 3
74const BAND_RESERVED_COLUMNS = 2
75const SVG_BAR = { width: 96, height: 10 }
76// With more meters than this, the Desktop app's bars narrow so the line still fits its band
77const SVG_WIDE_MAX_METERS = 3
78const SVG_NARROW_WIDTH = 72
79const SVG_COLORS = { success: '#4caf50', warning: '#e0a526', error: '#e5534b', track: 'rgba(128,128,128,0.3)', marker: '#5b9bff' }
80// The terminal draws the time marker in this color
81const MARKER_COLOR = 'cyan'
82
83export function register(on) {
84  // Fires again on an enable or a worker respawn, which may keep this module's variables
85  on('session.start', async ($, e, next) => {
86    ticker?.cancel()
87    rateLimits = []
88    measuredAt = 0
89    scoped = { at: 0, limits: [] }
90    scopedTriedAt = 0
91    scopedGeneration += 1
92    ownKey = KEY_PREFIX + (await $.session.id())
93    const usage = await $.session.usage()
94    context = usage.context
95    if (usage.rateLimits.length > 0) await publishSnapshot($, usage.rateLimits)
96    // Also clears keys ended sessions left, which short runs that never tick would not
97    await refresh($)
98    await adoptScoped($)
99    // Not awaited: the session does not wait on the network to start
100    void pollScoped($).then(() => $.ui.invalidate('ui.render'))
101    ticker = $.clock.every(TICK_MS, async () => {
102      await refresh($)
103      await adoptScoped($)
104      await pollScoped($)
105      $.ui.invalidate('ui.render')
106    })
107    $.ui.invalidate('ui.render')
108    return next(e)
109  })
110
111  // Keeps the store from growing by a key per session. A session whose reading is not the newest
112  // removes its own key. One that holds the newest marks it ended, so the others may delete it once
113  // a newer reading exists: an ended session never writes again, so that delete can't lose a write.
114  on('session.end', async ($, e, next) => {
115    if (!FINAL_REASONS.includes(e.reason) || !ownKey) return next(e)
116    ticker?.cancel()
117    await releaseKey($)
118    return next(e)
119  })
120
121  // /clear, /resume, /branch (fork) and compaction change the context, which session.measure
122  // reports only after the next turn. All but compaction also switch to another session id, so
123  // this module hands its key over as an ended session would, and writes under the new id.
124  on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
125    const key = KEY_PREFIX + (await $.session.id())
126    if (ownKey && key !== ownKey) {
127      await releaseKey($)
128      ownKey = key
129    }
130    context = (await $.session.usage()).context
131    $.ui.invalidate('ui.render')
132    return next(e)
133  })
134
135  // Fires after each turn, and when a rate-limit window moves a whole point
136  on('session.measure', async ($, e, next) => {
137    context = e.context
138    // A fresh measurement, including an empty one when the account's windows went away
139    if (e.changed.includes('rateLimits')) await remember($, e.rateLimits)
140    $.ui.invalidate('ui.render')
141    return next(e)
142  })
143
144  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
145    const elements = $.ui.resolve(e)
146    const now = await $.clock.now()
147    const meters = [{ label: 'ctx', used: context?.percent, elapsed: null, resetsAt: null }]
148    for (const limit of rateLimits) {
149      meters.push(readLimit(limit, now))
150    }
151    const weekly = meters.find((m) => m.kind === 'seven_day')
152    for (const limit of scoped.limits) {
153      // Only the endpoint refreshes this reading, and it may be failing: past the reset, the
154      // meter goes until an answer comes, where 0% could stand for days over real usage
155      if (limit.resetsAt != null && Date.parse(limit.resetsAt) <= now) continue
156      const m = readLimit(limit, now)
157      // The weekly meter beside it already counts down to the same reset
158      if (weekly?.resetsAt != null && m.resetsAt != null && Math.abs(m.resetsAt - weekly.resetsAt) < SAME_RESET_MS) {
159        m.resetsAt = null
160      }
161      meters.push(m)
162    }
163    for (const m of meters) m.value = valueText(m, now)
164    const gauge = e.surface === 'desktop' ? 'svg' : barsFit(meters, e.props.bodyColumns ?? 0) ? 'text' : 'none'
165
166    const barWidth = meters.length > SVG_WIDE_MAX_METERS ? SVG_NARROW_WIDTH : SVG_BAR.width
167    const line = elements.Box({
168      flexDirection: 'row',
169      columnGap: METER_GAP,
170      // In a narrow Desktop window a whole meter moves to the next row, rather than its text
171      // breaking or running off the band
172      ...(gauge === 'svg' && { flexWrap: 'wrap' }),
173      children: meters.map((m) => meter(elements, gauge, m, barWidth)),
174    })
175    // Keep what the mods after this one draw in the band
176    const rest = await next(e)
177    if (!rest) return line
178    return elements.Box({ flexDirection: 'column', children: [line, rest] })
179  })
180}
181
182async function remember($, limits) {
183  // Take the time first, so a refresh that runs meanwhile can't pair old limits with it
184  const now = await $.clock.now()
185  rateLimits = limits
186  measuredAt = now
187  await $.store.set(ownKey, { at: now, limits })
188}
189
190// Takes the newest reading any session saved, unless this session's own is newer still, and
191// deletes the keys no session needs: ended sessions' older readings, and any reading too old
192async function refresh($) {
193  const { entries, newest } = await scan($)
194  if (newest && newest.reading.at >= measuredAt) {
195    rateLimits = newest.reading.limits
196    measuredAt = newest.reading.at
197  } else if (!newest && measuredAt < (await $.clock.now()) - STALE_MS) {
198    // This session's own reading has gone stale too, as in one left idle for days
199    rateLimits = []
200    measuredAt = 0
201  }
202  await prune($, entries, newest?.key)
203}
204
205// Stops using this session's key: keeps its reading marked ended if it is the newest, so the
206// others may delete it once a newer one exists, and removes it otherwise, since only the newest
207// reading is ever shown
208async function releaseKey($) {
209  const { entries, newest } = await scan($)
210  if (newest?.key === ownKey) await $.store.set(ownKey, { ...newest.reading, ended: true })
211  else await $.store.delete(ownKey)
212  await prune($, entries, newest?.key)
213}
214
215async function prune($, entries, newestKey) {
216  const cutoff = (await $.clock.now()) - STALE_MS
217  for (const { key, reading } of entries) {
218    if (key === ownKey || key === newestKey) continue
219    if (!isReading(reading) || reading.ended === true || reading.at < cutoff) await $.store.delete(key)
220  }
221}
222
223// usage() at startup may answer this session's last reading, which can be older than the shared
224// one in ways a merge can't tell apart (a window that went away, a spend limit that went down),
225// so a shared reading always wins and the snapshot is saved only when there is none
226async function publishSnapshot($, snapshot) {
227  const { newest } = await scan($)
228  if (newest) {
229    rateLimits = newest.reading.limits
230    measuredAt = newest.reading.at
231    return
232  }
233  await remember($, snapshot)
234}
235
236// Every session's key and what it holds, and the newest reading among them. At an equal time the
237// later key wins, so every session picks the same one.
238async function scan($) {
239  const entries = []
240  let newest = null
241  const cutoff = (await $.clock.now()) - STALE_MS
242  for (const key of await $.store.keys()) {
243    if (!key.startsWith(KEY_PREFIX)) continue
244    const reading = await $.store.get(key)
245    entries.push({ key, reading })
246    if (!isReading(reading) || reading.at < cutoff) continue
247    if (!newest || reading.at > newest.reading.at || (reading.at === newest.reading.at && key > newest.key)) {
248      newest = { key, reading }
249    }
250  }
251  return { entries, newest }
252}
253
254// Takes the per-model reading the sessions share, unless this session's own is newer, and drops
255// one too old to say anything current
256async function adoptScoped($) {
257  const shared = await $.store.get(SCOPED_KEY)
258  if (isReading(shared) && shared.at >= scoped.at) scoped = shared
259  if (scoped.at < (await $.clock.now()) - STALE_MS) scoped = { at: 0, limits: [] }
260}
261
262// Asks the usage endpoint once the shared reading is due, and shares what it answers. A session
263// with no first-party login, a refused request or an answer in another shape leaves the reading
264// as it was.
265async function pollScoped($) {
266  const now = await $.clock.now()
267  if (now - scoped.at < SCOPED_POLL_MS || now - scopedTriedAt < SCOPED_POLL_MS) return
268  scopedTriedAt = now
269  const generation = scopedGeneration
270  try {
271    const auth = await $.session.authorize()
272    if (!auth) return
273    const tried = await $.store.get(SCOPED_TRIED_KEY)
274    if (typeof tried === 'number' && tried <= now && now - tried < SCOPED_POLL_MS) {
275      // Wait out the rest of that session's five minutes, not five of this one's own
276      scopedTriedAt = tried
277      return
278    }
279    await $.store.set(SCOPED_TRIED_KEY, now)
280    const res = await $.http.fetch(SCOPED_URL, { auth: auth.handle })
281    const limits = res.ok ? scopedLimits(JSON.parse(res.text)) : null
282    if (!limits || generation !== scopedGeneration) return
283    scoped = { at: await $.clock.now(), limits }
284    await $.store.set(SCOPED_KEY, scoped)
285  } catch {
286    // The next attempt is a poll away
287  }
288}
289
290// The weekly limits the answer scopes to a model, as limits readLimit takes: an empty list for an
291// account with none, null when the answer has no list of limits at all
292function scopedLimits(usage) {
293  if (!Array.isArray(usage?.limits)) return null
294  const limits = []
295  for (const limit of usage.limits) {
296    const label = limit?.scope?.model?.display_name
297    if (limit?.kind !== 'weekly_scoped' || typeof label !== 'string' || typeof limit.percent !== 'number') continue
298    const resetsAtMs = typeof limit.resets_at === 'string' ? Date.parse(limit.resets_at) : NaN
299    limits.push({
300      kind: 'weekly_scoped',
301      label,
302      percentUsed: limit.percent,
303      ...(Number.isFinite(resetsAtMs) && { resetsAt: new Date(resetsAtMs).toISOString() }),
304    })
305  }
306  return limits
307}
308
309function isReading(value) {
310  return value != null && typeof value.at === 'number' && Array.isArray(value.limits)
311}
312
313function readLimit(limit, now) {
314  const window = WINDOWS[limit.kind]
315  const label = limit.label ?? window?.label ?? limit.kind
316  const resetsAtMs = limit.resetsAt == null ? null : Date.parse(limit.resetsAt)
317  // A window that has reset since the last reading starts again from zero
318  if (resetsAtMs != null && resetsAtMs <= now) {
319    return { kind: limit.kind, label, used: 0, elapsed: window?.ms ? 0 : null, resetsAt: null }
320  }
321  const elapsed = window?.ms && resetsAtMs != null ? clamp(100 - ((resetsAtMs - now) / window.ms) * 100) : null
322  return { kind: limit.kind, label, used: limit.percentUsed, elapsed, resetsAt: resetsAtMs, showsClock: window?.showsClock === true }
323}
324
325// Green, yellow or red by how far usage runs ahead of the time gone in its window
326function statusOf(used, elapsed) {
327  if (used >= RED_MIN_USED) return 'error'
328  if (elapsed == null) return used >= 80 ? 'error' : used >= 50 ? 'warning' : 'success'
329  const margin = elapsed - used
330  if (margin < RED_BELOW_MARGIN) return 'error'
331  if (margin < GREEN_MIN_MARGIN && used >= GREEN_MAX_USED) return 'warning'
332  return 'success'
333}
334
335function valueText({ used, resetsAt, showsClock }, now) {
336  let value = typeof used === 'number' ? Math.round(used) + '%' : '—'
337  if (resetsAt != null) value += ' ' + untilReset(resetsAt - now)
338  if (resetsAt != null && showsClock) value += ' (' + jstClock(resetsAt) + ')'
339  return value
340}
341
342// Whether every meter fits on one line with its text bar; every character drawn is one cell wide
343function barsFit(meters, columns) {
344  const width = meters.reduce((sum, m) => sum + [...m.label].length + 1 + BAR_CELLS + 1 + [...m.value].length, 0)
345  return width + METER_GAP * (meters.length - 1) <= columns - BAND_RESERVED_COLUMNS
346}
347
348function meter({ Box, Text, Svg }, gauge, { label, used, elapsed, value }, barWidth) {
349  const known = typeof used === 'number'
350  const status = known ? statusOf(used, elapsed) : null
351  const style = known ? { color: status } : { dimColor: true }
352
353  const children = [Text({ children: [label] })]
354  if (gauge === 'svg') {
355    children.push(
356      Svg({
357        source: svgBar(known ? used : 0, elapsed, status, barWidth),
358        alt: label + ' ' + value + (elapsed == null ? '' : ', ' + Math.round(elapsed) + '% of the window gone'),
359        width: barWidth,
360        height: SVG_BAR.height,
361      }),
362    )
363  } else if (gauge === 'text') {
364    children.push(...textBar(Text, known ? used : 0, elapsed, status))
365  }
366  children.push(Text({ ...style, children: [value] }))
367  return Box({
368    key: 'meter-' + label,
369    flexDirection: 'row',
370    columnGap: 1,
371    alignItems: 'center',
372    ...(gauge === 'svg' && { flexShrink: 0 }),
373    children,
374  })
375}
376
377// The bar as runs of cells: used cells in the status color, the rest dim, and the time marker
378function textBar(Text, used, elapsed, status) {
379  const filled = Math.round((clamp(used) / 100) * BAR_CELLS)
380  const marker = elapsed == null ? -1 : Math.min(BAR_CELLS - 1, Math.floor((elapsed / 100) * BAR_CELLS))
381  const markerStyle = { color: MARKER_COLOR, bold: true }
382  const usedStyle = status ? { color: status } : { dimColor: true }
383  const restStyle = { dimColor: true }
384  const cells = []
385  for (let i = 0; i < BAR_CELLS; i++) {
386    if (i === marker) cells.push({ char: '┃', style: markerStyle })
387    else if (i < filled) cells.push({ char: '█', style: usedStyle })
388    else cells.push({ char: '░', style: restStyle })
389  }
390  const runs = []
391  for (const cell of cells) {
392    const last = runs.at(-1)
393    if (last && last.style === cell.style) last.text += cell.char
394    else runs.push({ text: cell.char, style: cell.style })
395  }
396  // One Text per run, nested in a Text so the runs stay on one line with no gaps
397  return [Text({ children: runs.map((run) => Text({ ...run.style, children: [run.text] })) })]
398}
399
400function svgBar(used, elapsed, status, width) {
401  const { height } = SVG_BAR
402  const r = height / 2
403  const fill = Math.round((clamp(used) / 100) * width)
404  const parts = [
405    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
406    `<clipPath id="c"><rect width="${width}" height="${height}" rx="${r}"/></clipPath>`,
407    `<g clip-path="url(#c)">`,
408    `<rect width="${width}" height="${height}" fill="${SVG_COLORS.track}"/>`,
409  ]
410  if (fill > 0) parts.push(`<rect width="${fill}" height="${height}" fill="${SVG_COLORS[status ?? 'success']}"/>`)
411  parts.push('</g>')
412  if (elapsed != null) {
413    const x = Math.min(width - 2, Math.max(0, Math.round((elapsed / 100) * width) - 1))
414    parts.push(`<rect x="${x}" width="2" height="${height}" fill="${SVG_COLORS.marker}"/>`)
415  }
416  parts.push('</svg>')
417  return parts.join('')
418}
419
420function clamp(percent) {
421  return Math.min(Math.max(percent, 0), 100)
422}
423
424// The time of day as HH:MM, 24-hour, in JST
425function jstClock(ms) {
426  const jst = new Date(ms + JST_OFFSET_MS)
427  return String(jst.getUTCHours()).padStart(2, '0') + ':' + String(jst.getUTCMinutes()).padStart(2, '0')
428}
429
430function untilReset(ms) {
431  const minutes = Math.max(0, Math.ceil(ms / 60_000))
432  const days = Math.floor(minutes / 1440)
433  const hours = Math.floor((minutes % 1440) / 60)
434  if (days > 0) return days + 'd' + hours + 'h'
435  if (hours > 0) return hours + 'h' + (minutes % 60) + 'm'
436  return (minutes % 60) + 'm'
437}
438