SLOPSHOPPER

usage-log

ターンが終わるたびに使用率などを記録し、7日枠の目安(ペース表)との差を入力欄の下に出す

newpaneguardcommandprocesstimer
v0.3.1MITupdated 2026-10-04tanuu5/usage-log/plugins/usage-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-log
│ ┃ 週間ペース ✕ › fix the failing auth test and add an audit log call │ ┃ 7日枠の値がまだ届いていません(最初の応答のあ │ ┃ とに出ます)。 ⏺ 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 │ │ › /usage-log │ ⎿ usage-log: usage-log ?(/plugins/usage-log) │ ⎿ usage-log: 記録先: /Users/dev/.claude/usage-log/turns.jsonl │ ⎿ usage-log: まだ記録がありません(ターンが終わると 1 行目が書かれます)。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 週間ペース
7日枠の値がまだ届いていません(最初の応答のあとに出ます)。
README

usage-log

Claude Code の利用枠(5 時間枠・7 日枠)の使用率を、ターンが終わるたびに記録する mod。自分で決めた週の目安と比べて、使いすぎか、まだ余裕があるかも分かります。

Claude Code Desktop mod Made with Claude Opus 5.5 (XHIGH) License: MIT

<img src="docs/screenshots/band.png" width="660" alt="入力欄の上の帯の右側。コンテキスト 39%、5時間枠 11%、7日枠 68% の 3 行が並び、7日枠の行には目安の位置を示す縦線と、赤字で「目安61%(土 0:00)超過7」が出ている">

Claude Code × Claude Opus 5.5(XHIGH) で作りました。

Claude Code の週の利用枠を、気づいたら木曜で使い切っていた、ということを避けたくて作りました。ターンが終わるたびに、その時刻と 5 時間枠・7 日枠の使用率を 1 行ずつファイルに残し、あとから「何曜日の時点で、平均どれくらい使っているか」を集計できます。土日を厚めにするなど、自分で決めた週の配分(目安)を設定すると、今の使用率との差を入力欄の上の帯(別の mod clawd-dance)に出せます。

記録するもの

記録先は ~/.claude/usage-log/turns.jsonl です。本体のターンが終わるたびに 1 行(JSON)を書き足します。同時に開いている複数のセッションが、同じファイルに書き足します。

項目中身
時刻ts(UTC)、local(時差つきのローカル時刻)、dow(曜日、0 = 日曜)、hour
利用枠limits.five_hour と limits.seven_day:ターンの終わりの使用率 pct、始まりの使用率 startPct、リセットの時刻 resetsAt
どこで・何でsession(セッション ID)、turn(そのセッションで何回目の発言か)、cwd(作業フォルダ)、model、effort(推論強度)、surfaces(デスクトップかターミナルか)、cc(Claude Code の版)、mod(この mod の版)
どれだけdurationMs(ターンの長さ)、reason(正常終了・中断・エラー)、context(コンテキストの使用量)、costUsd・turnCostUsd(API 料金に換算した額。セッションの累計と、そのターンの分)、tokens(本体のトークン数)、subTokens(サブエージェントの分)
何をしたかsteps(API 呼び出しの回数)、tools・toolCounts(ツールの使用回数と内訳)、subagents(サブエージェントの数)、promptChars・answerChars(発言と返答の文字数)
  • 発言や返答の本文は残しません。文字数だけです。
  • 記録はこの Mac の中だけに置かれ、どこにも送りません。
  • サブエージェントが使った分は、そのとき進んでいた本体のターンの行にまとめます。
{
  "v": 1,
  "ts": "2026-10-02T14:26:45.472Z",
  "local": "2026-10-02T23:26:45+09:00",
  "dow": 5,
  "hour": 23,
  "session": "(セッション ID)",
  "turn": 9,
  "cwd": "/path/to/my-app",
  "model": "claude-opus-5-5",
  "effort": "xhigh",
  "surfaces": ["desktop"],
  "cc": "2.1.286",
  "mod": "0.3.0",
  "reason": "answer",
  "durationMs": 63732,
  "limits": {
    "five_hour": { "pct": 12, "startPct": 12, "resetsAt": "2026-10-02T18:20:00.000Z" },
    "seven_day": { "pct": 68, "startPct": 68, "resetsAt": "2026-10-05T11:00:00.000Z" }
  },
  "context": { "tokens": 407366, "window": 1000000, "pct": 41 },
  "costUsd": 14.4573,
  "turnCostUsd": 0.7459,
  "tokens": { "input": 12, "output": 5594, "cacheRead": 2405540, "cacheWrite": 19107 },
  "subTokens": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
  "steps": 6,
  "subSteps": 0,
  "tools": 7,
  "subTools": 0,
  "toolCounts": { "Bash": 5, "ToolSearch": 1, "mcp__ccd_session_mgmt__get_session": 1 },
  "subagents": 0,
  "promptChars": 149,
  "answerChars": 1110
}

週の目安と比べる

7 日枠について、「各曜日の 0:00 の時点で、累計で何 % まで使ってよいか」を目安として決めておきます。既定は次の表で、土日を厚めにした配分です(/config で変えられます)。

時点火 0:00水 0:00木 0:00金 0:00土 0:00日 0:00月 0:00
累計の目安12%25%37%49%61%80%98%
  • 枠のリセットの時刻は、Claude Code が受け取った値から自動で読みます。リセットの時点を 0%(始まり)と 100%(終わり)とし、その間に表の時点を並べます。
  • 今の使用率を「次の時点の目安」と比べます。たとえば金曜の夜に 68% なら、土 0:00 の目安 61% に対して 7 超過です。
  • 結果は ~/.claude/usage-log/pace.json に書き出します。clawd-dance(v0.12.0 以降)を一緒に入れていると、帯の 7 日枠の行に目安の位置(┃)と差が出ます。超過は赤、余裕は緑です(上の画像)。
  • /usage-log と打つと、今週のグラフ(目安と実際の使用率の推移)と、曜日ごとの「配分・目安・実際・使った量・差」の表をパネルで開きます。記録先の場所と行数、直近の値も表示します。

<img src="docs/screenshots/pane.png" width="560" alt="「週間ペース」のパネル。上に「7日枠 68% 目安は 土 0:00 までに 61% → 7% 超過」、その下に月曜 20:00 から次の月曜 20:00 までのグラフ(灰色の点線が目安、青が実際、赤い点が今)、曜日ごとの配分・目安(累計)・実際(累計)・使った・差の表が並ぶ"><br> <sub>/usage-log で開くパネル。灰色の点線が目安、青が実際の使用率、赤い点が今です。青の細い点線は記録を始める前の区間で、実際の推移は分かりません(この画像は記録を始めた日のもの)。</sub>

画像は、実際のアプリの画面を切り出したものです。

{
  "v": 1,
  "sevenDay": { "pct": 68, "resetsAt": "2026-10-05T11:00:00.000Z" },
  "fiveHour": { "pct": 13, "resetsAt": "2026-10-02T18:20:00.000Z" },
  "windowEnd": 1791198000000,
  "checkpoints": [
    { "at": 1790593200000, "pct": 0, "label": "月 20:00" },
    { "at": 1790607600000, "pct": 12, "label": "火 0:00" },
    { "at": 1791198000000, "pct": 100, "label": "月 20:00" }
  ],
  "updatedAt": "2026-10-02T14:32:06.779Z"
}

sevenDay と fiveHour は、どのセッションのものも含めていちばん新しい値です。checkpoints は今の 7 日枠の目安の時点(時刻はミリ秒、上の例は途中を省略)。値が変わったときだけ書き直します。

記録を集計する

リポジトリの analyze.py(Python 3 の標準ライブラリだけ)で、記録を表にします。

python3 analyze.py

記録ファイルの場所を変えたときはパスを渡します。目安表は --pace "火=12, 水=25, 木=37, 金=49, 土=61, 日=80, 月=98" のように渡せます(省略すると上の既定の表)。

リセットのチケットなどで枠の途中に使用率が戻ったとき(同じ枠の中で 5 ポイント以上下がったとき、または次の枠が前倒しで始まったとき)は、途中のリセットとみなします。その枠のリセット後の時点は「各曜日 0:00 の累計」の平均に入れず、表の下に注記します。増分はリセット後 0 から数え直します。記録ファイル自体は書き換えません。

出力の例です。記録がまだ少ないため、3 週間分のダミーデータで実行しました。

■ 7日枠:各曜日 0:00 の時点の累計(平均)と目安
枠の切り替わり: 毎週月曜 20:00(記録にある枠 4 個)

時点                  平均 %  枠の数  今の枠 %  目安 %  平均−目安
--------------------  ------  ------  --------  ------  ---------
火 0:00               3.2     4       3.0       12      -8.8
水 0:00               16.1    4       17.6      25      -8.9
木 0:00               29.6    4       29.5      37      -7.4
金 0:00               42.5    4       41.8      49      -6.5
土 0:00               56.1    3       -         61      -4.9
日 0:00               75.2    3       -         80      -4.8
月 0:00               97.4    3       -         98      -0.6
月 20:00(リセット)  100.0   3       -         100     +0.0

■ 曜日ごとの消費
曜日  1日に使った7日枠 %(平均)  日数
----  --------------------------  ----
日    22.2                        3
月    5.1                         4
火    13.0                        4
水    13.5                        4
木    12.8                        4
金    12.4                        4
土    19.4                        3

このあとに、5 時間枠ごとの最高値、時間帯ごと・モデルと推論強度ごと・作業フォルダごとの消費が続きます。

  • 使用率の増え方は、全セッションの記録を時刻順に並べ、直前の記録からどれだけ増えたかで数えます。同時に動くセッションの分を二重に数えないためです。
  • 記録を始める前の時点は、値が分からないので平均に入れません。
  • 同じ mod が二重に読み込まれて同じターンが 2 行になっていても、1 行として数えます。

気をつけること

  • 利用枠の値は、Claude のサブスクリプション(Pro / Max)でログインしているときだけ届きます。API キーで使っているときは、limits が空のまま記録されます。
  • 使用率はアカウント全体の値です。claude.ai やスマホのアプリで使った分も、次に記録される行の増え方に入ります。
  • 使用率は整数で届くので、1 ターンでの増え方はたいてい 0 か 1 です。日単位・週単位の集計に向いています。
  • 枠の値は API の応答と一緒に届くため、新しいセッションは最初の応答まで値を持ちません。その間の目安の表示には、記録ファイルの最後の値(直前に別のセッションで記録した値)を使います。
  • mod を入れた直後や書き換えた直後のターンは、記録されないことがあります(読み込みの切り替えと重なるため)。

インストール

動作環境:Claude Code の mod の仕組み(function hooks)が入った版。公式の要件は v2.1.287 以上です。作者は macOS 版のデスクトップアプリ 2.19675.0(中に入っている Claude Code は 2.1.286)で確かめました。ターミナル版の Claude Code でも記録はできるつくりですが、作者は試していません。

ターミナルで次の 2 行を実行します。

claude plugin marketplace add tanuu5/usage-log
claude plugin install usage-log@usage-log

入れたあと、新しいセッションから記録が始まります。外すときは /plugin の画面で無効にするか、claude plugin uninstall usage-log@usage-log を実行します。記録ファイルは外しても残るので、要らなければ ~/.claude/usage-log/ を消してください。

設定を変える

/config の一覧に、次の 2 つが並びます。

項目既定内容
記録ファイルの場所~/.claude/usage-log/turns.jsonl~ はホームフォルダ。pace.json はこのファイルと同じフォルダに書きます
7日枠の目安(各曜日 0:00 の累計 %)火=12, 水=25, 木=37, 金=49, 土=61, 日=80, 月=98曜日=累計% をカンマで区切って並べます。読めない書き方のときは既定の表を使います

記録ファイルの場所を変えたときは、pace.json の場所も変わります。clawd-dance は ~/.claude/usage-log/pace.json だけを読むので、帯に目安を出したいときは既定の場所のままにしてください。

この mod がすること・しないこと

mod は Claude Code の中で、あなたの権限のまま動きます。入れる前に中身を確かめてください。

  • フックするイベント:session.start(コマンドの登録、1 分ごとの確認の開始)、command.run(/usage-log)、turn.start・turn.step・tool.call・turn.complete(ターンの始まりの値、API 呼び出し・ツールの回数、モデルと推論強度を数え、終わりに 1 行書く)、session.measure(利用枠が動いたことを知る)、ui.close・ui.render(このパネルだけ)
  • turn.step と tool.call は数えるだけです。モデルへの依頼やツールの許可・入力は変えません。
  • 実行するコマンド:/bin/date +%z(時差を知る)、/bin/sh -c 'mkdir -p … && cat >> …'(記録ファイルへの書き足し)、tail・wc(記録ファイルの末尾と行数を読む)
  • 読み書きするファイル:記録ファイルと pace.json だけ。環境変数は HOME を読むだけです。
  • ネットワーク通信、別モデルへの問い合わせ、会話の本文の保存はしません。

制作について

企画・ディレクション:たぬ / 開発:Claude Code(Claude Opus 5.5・推論レベル XHIGH)

「毎セッション、対話が終わったら、その時刻と週間制限・時間制限の何 % かを記録したい。n 曜日の時点で平均どれくらい使うのかを知りたい」という一言から作りました。記録の仕組みはすぐに動いたので、次に土日を厚めにした目安表を足し、今の使用率との差を見えるようにしました。最初は入力欄の下、dev-dashboard のボタンの横に小さなチップとして置きました。しかし、その欄は収まらない分を「…」で隠してしまい、ボタンと並ぶとどちらかが見えなくなりました。そこで、すでに使用率を出していた clawd-dance の帯に、ファイル(pace.json)経由で目安を渡す形にしています。記録の分析(analyze.py)も、目安表と同じ「各曜日 0:00 の時点」で並べて、目安と見比べられるようにしました。

更新履歴

  • 2026-10-02:公開(v0.3.1)

開発

.claude-plugin/marketplace.json        マーケットプレイスの定義
plugins/usage-log/
  .claude-plugin/plugin.json           mod の定義(/config の 2 項目)
  hooks/hooks.json                     読み込むモジュール
  hooks/register.tsx                   本体(記録、pace.json、パネル)
  hooks/pace.ts                        目安の計算とグラフの SVG($ を使わない関数)
  tests/pace.test.tsx                  テスト(claude plugin test)
analyze.py                             記録の集計

手元の版を直接読み込むには、claude --plugin-dir ./plugins/usage-log で起動します。起動フラグを渡せないデスクトップアプリでは、~/.claude/settings.json の env に CLAUDE_CODE_PLUGIN_DIRS として、このフォルダの絶対パスを書きます(複数の mod を並べるときは : で区切ります)。

claude plugin validate ./plugins/usage-log
claude plugin test ./plugins/usage-log

型を確かめるには、mod を一度読み込ませて型定義(.claude-plugin/types/)を書き出させてから、npx -p typescript tsc -p plugins/usage-log/tsconfig.json を実行します。

クレジット・ライセンス

  • MIT License(LICENSE)© 2026 たぬ
  • この mod は Anthropic とは関係のない個人の作品です。
  • MIT License の対象はこのリポジトリのコードと文章です。「Claude」の名前や商標の使用を許諾するものではありません。
Source 2 files
hooks/register.tsx 562 lines
1import type { EngineInterface, ModelUsage, PluginOptions, Register, TurnCompleteInput } from 'claude-code'
2
3import {
4  DEFAULT_PACE_TABLE,
5  DOW,
6  chartSvg,
7  checkpointLabel,
8  computePace,
9  daySegments,
10  fmt,
11  formatPaceTable,
12  localDow,
13  parsePaceTable,
14  untilText,
15} from './pace'
16import type { PaceEntry, PaceView, Point } from './pace'
17
18// 本体(メインループ)のターンが終わるたびに、そのときの利用枠(5時間枠・7日枠の使用率)などを
19// JSON Lines で 1 行ずつ追記する。1 行 = 本体の 1 ターン。
20// サブエージェントのステップ・ツール・トークンは、次に終わる本体のターンの行にまとめる。
21// 書き込みは `cat >>` の追記なので、同時に動いている複数のセッションが同じファイルに書き足せる。
22//
23// 7日枠の使用率と目安(ペース表)との差は、記録ファイルの隣の pace.json に書き出し、
24// 入力欄の上の帯(clawd-dance)が読んで出す。/usage-log で今週のグラフと曜日ごとの表のパネルを開く。
25// (入力欄の下の欄にチップを置く案は、幅が狭くダッシュボードのボタンと共存できなかったのでやめた)
26
27const SCHEMA_VERSION = 1
28const DEFAULT_LOG_FILE = '~/.claude/usage-log/turns.jsonl'
29const PANE = 'usage-pace'
30// パネルのために読む記録の行数(1 週間分より十分多く、4 MiB に収まる量)
31const TAIL_LINES = 3000
32const WEEK_MS = 7 * 24 * 60 * 60 * 1000
33
34type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
35
36// 前の行を書いてから今までに数えたもの
37type Tally = {
38  startedAt: number | undefined
39  limitsAtStart: Record<string, number>
40  costAtStart: number | undefined
41  promptChars: number | undefined
42  steps: number
43  subSteps: number
44  tools: number
45  subTools: number
46  toolCounts: Record<string, number>
47  subagents: string[]
48  subTokens: Tokens
49  model: string | undefined
50  effort: string | number | undefined
51}
52
53type Reading = { pct: number; resetsAt: string }
54
55type Recorder = {
56  logFileSetting: string
57  logFile: string | undefined
58  tzOffset: string | undefined
59  tally: Tally
60  paceTable: PaceEntry[]
61  paceTableError: string | undefined
62  isPaneOpen: boolean
63  // パネル用:記録ファイルから読んだ 7日枠の値(古い順)
64  history: (Point & { resetsAt: number })[]
65  // 最後に書いた pace.json の中身(同じなら書き直さない)
66  publishedPace: string | undefined
67  // 記録ファイルの最後の枠の値(どのセッションが書いたものでも)
68  logLatest: { at: number; limits: Record<string, Reading> } | undefined
69  // このセッションが最後に枠の値を受け取った時刻
70  ownAt: number | undefined
71  // 読み込まれている mod の版(plugin.json から)。記録の行と /usage-log に出す
72  modVersion: string | undefined
73}
74
75const zeroTokens = (): Tokens => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
76
77function freshTally(): Tally {
78  return {
79    startedAt: undefined,
80    limitsAtStart: {},
81    costAtStart: undefined,
82    promptChars: undefined,
83    steps: 0,
84    subSteps: 0,
85    tools: 0,
86    subTools: 0,
87    toolCounts: {},
88    subagents: [],
89    subTokens: zeroTokens(),
90    model: undefined,
91    effort: undefined,
92  }
93}
94
95function toTokens(usage: ModelUsage | undefined): Tokens {
96  if (usage === undefined) return zeroTokens()
97  return {
98    input: usage.input_tokens,
99    output: usage.output_tokens,
100    cacheRead: usage.cache_read_input_tokens,
101    cacheWrite: usage.cache_creation_input_tokens,
102  }
103}
104
105function addTokens(into: Tokens, usage: ModelUsage | undefined) {
106  const t = toTokens(usage)
107  into.input += t.input
108  into.output += t.output
109  into.cacheRead += t.cacheRead
110  into.cacheWrite += t.cacheWrite
111}
112
113function readLogFileSetting(options: PluginOptions): string {
114  const value = options.log_file
115  return typeof value === 'string' && value.trim() !== '' ? value.trim() : DEFAULT_LOG_FILE
116}
117
118async function resolveLogFile($: EngineInterface, rec: Recorder): Promise<string> {
119  if (rec.logFile !== undefined) return rec.logFile
120  let path = rec.logFileSetting
121  if (path === '~' || path.startsWith('~/')) {
122    const home = await $.env.get('HOME')
123    if (home === undefined) throw new Error('HOME が分からないため ~ を展開できない')
124    path = home + path.slice(1)
125  }
126  rec.logFile = path
127  return path
128}
129
130// サンドボックスの Date は時差を知らないので、`date +%z`(+0900)で一度だけ聞く
131async function resolveTzOffset($: EngineInterface, rec: Recorder): Promise<string> {
132  if (rec.tzOffset !== undefined) return rec.tzOffset
133  const { stdout } = await $.process.run(['/bin/date', '+%z'])
134  const m = /^([+-])(\d\d)(\d\d)$/.exec(stdout.trim())
135  rec.tzOffset = m ? `${m[1]}${m[2]}:${m[3]}` : '+00:00'
136  return rec.tzOffset
137}
138
139function offsetMinutes(offset: string): number {
140  const sign = offset.startsWith('-') ? -1 : 1
141  const [h = '0', m = '0'] = offset.slice(1).split(':')
142  return sign * (Number(h) * 60 + Number(m))
143}
144
145async function resolveModVersion($: EngineInterface, rec: Recorder): Promise<string> {
146  if (rec.modVersion !== undefined) return rec.modVersion
147  try {
148    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
149    rec.modVersion = manifest.version ?? '?'
150  } catch {
151    rec.modVersion = '?'
152  }
153  return rec.modVersion
154}
155
156async function snapshotStart($: EngineInterface, rec: Recorder, promptChars: number) {
157  const [now, usage] = await Promise.all([$.clock.now(), $.session.usage()])
158  rec.tally.startedAt = now
159  rec.tally.promptChars = promptChars
160  rec.tally.costAtStart = usage.cost?.usd
161  rec.tally.limitsAtStart = Object.fromEntries(usage.rateLimits.map(l => [l.kind, l.percentUsed]))
162  // 読み込み直しや再開の直後は、このセッションがまだ枠の値を持たない。記録ファイルの最後の値で補う
163  if (rec.tally.limitsAtStart.seven_day === undefined) {
164    const { seven, five } = await freshestReadings($, rec)
165    if (seven !== undefined) rec.tally.limitsAtStart.seven_day = seven.pct
166    if (five !== undefined && rec.tally.limitsAtStart.five_hour === undefined) rec.tally.limitsAtStart.five_hour = five.pct
167  }
168}
169
170async function writeRecord($: EngineInterface, rec: Recorder, e: TurnCompleteInput) {
171  const [now, usage, sessionId, turns, cwd, surfaces, version, sessionModel, tz, logFile, modVersion] = await Promise.all([
172    $.clock.now(),
173    $.session.usage(),
174    $.session.id(),
175    $.session.turns(),
176    $.session.cwd(),
177    $.session.surfaces(),
178    $.session.version(),
179    $.session.model(),
180    resolveTzOffset($, rec),
181    resolveLogFile($, rec),
182    resolveModVersion($, rec),
183  ])
184  const t = rec.tally
185
186  const local = new Date(now + offsetMinutes(tz) * 60_000)
187  const limits: Record<string, { pct: number; startPct: number | null; resetsAt: string | null }> = {}
188  for (const l of usage.rateLimits) {
189    limits[l.kind] = { pct: l.percentUsed, startPct: t.limitsAtStart[l.kind] ?? null, resetsAt: l.resetsAt ?? null }
190  }
191  const costUsd = usage.cost?.usd
192  const round4 = (n: number) => Math.round(n * 10_000) / 10_000
193
194  const row = {
195    v: SCHEMA_VERSION,
196    ts: new Date(now).toISOString(),
197    local: local.toISOString().replace(/\.\d+Z$/, tz),
198    dow: local.getUTCDay(), // 0 = 日曜
199    hour: local.getUTCHours(),
200    session: sessionId,
201    turn: turns,
202    cwd,
203    model: e.usage?.model ?? t.model ?? sessionModel,
204    effort: t.effort ?? null,
205    surfaces,
206    cc: version.version,
207    mod: modVersion,
208    reason: e.reason,
209    durationMs: e.durationMs,
210    limits,
211    context: { tokens: usage.context.tokens ?? null, window: usage.context.window, pct: usage.context.percent ?? null },
212    costUsd: costUsd === undefined ? null : round4(costUsd),
213    turnCostUsd: costUsd === undefined || t.costAtStart === undefined ? null : round4(costUsd - t.costAtStart),
214    tokens: toTokens(e.usage),
215    subTokens: t.subTokens,
216    steps: t.steps,
217    subSteps: t.subSteps,
218    tools: t.tools,
219    subTools: t.subTools,
220    toolCounts: t.toolCounts,
221    subagents: t.subagents.length,
222    promptChars: t.promptChars ?? null,
223    answerChars: e.answer.length,
224  }
225
226  const { exitCode, stderr } = await $.process.run(
227    ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', logFile],
228    { stdin: JSON.stringify(row) + '\n' },
229  )
230  if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
231}
232
233async function showSummary($: EngineInterface, rec: Recorder): Promise<string> {
234  const logFile = await resolveLogFile($, rec)
235  const loaded = `usage-log ${await resolveModVersion($, rec)}(${$.plugin.root})`
236  if (!(await $.fs.exists(logFile))) return `${loaded}\n記録先: ${logFile}\nまだ記録がありません(ターンが終わると 1 行目が書かれます)。`
237  const [count, last] = await Promise.all([
238    $.process.run(['/usr/bin/wc', '-l', logFile]),
239    $.process.run(['/usr/bin/tail', '-n', '1', logFile]),
240  ])
241  const lines = [loaded, `記録先: ${logFile}`, `記録数: ${count.stdout.trim().split(/\s+/)[0] ?? '?'} 行`]
242  try {
243    const row = JSON.parse(last.stdout) as {
244      local?: string
245      limits?: Record<string, { pct: number; resetsAt: string | null }>
246    }
247    const five = row.limits?.five_hour?.pct
248    const seven = row.limits?.seven_day?.pct
249    lines.push(
250      `直近: ${row.local ?? '?'} 5時間枠 ${five ?? '-'}% 7日枠 ${seven ?? '-'}%`,
251    )
252  } catch {
253    lines.push('直近の行を読めませんでした。')
254  }
255  return lines.join('\n')
256}
257
258// ---- 目安(ペース表)との差 ----
259
260function readPaceSetting(options: PluginOptions): { table: PaceEntry[]; error: string | undefined } {
261  const text = typeof options.pace_table === 'string' && options.pace_table.trim() !== '' ? options.pace_table : DEFAULT_PACE_TABLE
262  const table = parsePaceTable(text)
263  if (table) return { table, error: undefined }
264  return {
265    table: parsePaceTable(DEFAULT_PACE_TABLE) ?? [],
266    error: `目安表「${text}」を読めなかったので、既定の表(${DEFAULT_PACE_TABLE})を使っています。`,
267  }
268}
269
270// 記録ファイルの末尾から、最後に記録された枠の値(5時間枠・7日枠)を読む。
271// 枠の値は API の応答と一緒に届くので、新しいセッションは最初の応答まで値を持たない。
272// その間や、別のセッションのほうが新しい値を受け取っているときは、こちらを使う。
273async function refreshLogLatest($: EngineInterface, rec: Recorder) {
274  const logFile = await resolveLogFile($, rec)
275  if (!(await $.fs.exists(logFile))) return
276  const { stdout } = await $.process.run(['/usr/bin/tail', '-n', '20', logFile])
277  for (const line of stdout.split('\n').reverse()) {
278    try {
279      const row = JSON.parse(line) as { ts?: string; limits?: Record<string, { pct: number; resetsAt: string | null }> }
280      const at = Date.parse(row.ts ?? '')
281      if (!row.limits?.seven_day?.resetsAt || !Number.isFinite(at)) continue
282      const limits: Record<string, Reading> = {}
283      for (const [kind, l] of Object.entries(row.limits)) if (l.resetsAt) limits[kind] = { pct: l.pct, resetsAt: l.resetsAt }
284      rec.logLatest = { at, limits }
285      return
286    } catch {
287      // 空行や途中で切れた行は飛ばす
288    }
289  }
290}
291
292// このセッションの値と記録ファイルの値のうち、新しいほう。リセットを越えていたら新しい枠として扱う
293async function freshestReadings($: EngineInterface, rec: Recorder): Promise<{ now: number; seven?: Reading; five?: Reading }> {
294  const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()])
295  const own: Record<string, Reading> = {}
296  for (const l of usage.rateLimits) if (l.resetsAt) own[l.kind] = { pct: l.percentUsed, resetsAt: l.resetsAt }
297  if (own.seven_day === undefined && rec.logLatest === undefined) await refreshLogLatest($, rec).catch(() => undefined)
298  const logged = rec.logLatest
299  const useOwn = own.seven_day !== undefined && (logged === undefined || (rec.ownAt ?? 0) >= logged.at)
300  const source = useOwn ? own : (logged?.limits ?? {})
301  let seven = source.seven_day
302  if (seven !== undefined) {
303    let end = Date.parse(seven.resetsAt)
304    if (Number.isFinite(end) && end <= now) {
305      while (end <= now) end += WEEK_MS
306      seven = { pct: 0, resetsAt: new Date(end).toISOString() }
307    }
308  }
309  // 5時間枠は使い始めた時点から数えるので、切れた後の枠の時刻は分からない。切れていたら出さない
310  const five = source.five_hour !== undefined && Date.parse(source.five_hour.resetsAt) > now ? source.five_hour : undefined
311  return { now, seven, five }
312}
313
314async function currentPace($: EngineInterface, rec: Recorder): Promise<{ view: PaceView; now: number; offMin: number } | undefined> {
315  const [{ now, seven }, tz] = await Promise.all([freshestReadings($, rec), resolveTzOffset($, rec)])
316  if (seven === undefined) return undefined
317  const offMin = offsetMinutes(tz)
318  const view = computePace(seven.pct, seven.resetsAt, now, offMin, rec.paceTable)
319  return view === undefined ? undefined : { view, now, offMin }
320}
321
322// 帯(clawd-dance)など、ほかの mod が目安を出せるよう、記録ファイルの隣に pace.json を書く。
323// 中身:今の 5時間枠・7日枠の値と、今の 7日枠の目安の時点(時刻・累計 %・ラベル)
324async function publishPace($: EngineInterface, rec: Recorder) {
325  const [{ now, seven, five }, tz, logFile] = await Promise.all([
326    freshestReadings($, rec),
327    resolveTzOffset($, rec),
328    resolveLogFile($, rec),
329  ])
330  if (seven === undefined) return
331  const offMin = offsetMinutes(tz)
332  const view = computePace(seven.pct, seven.resetsAt, now, offMin, rec.paceTable)
333  if (view === undefined) return
334  const body = {
335    v: 1,
336    sevenDay: seven,
337    fiveHour: five ?? null,
338    windowEnd: view.windowEnd,
339    checkpoints: view.checkpoints.map(c => ({ at: c.at, pct: c.pct, label: checkpointLabel(c.at, offMin) })),
340  }
341  const signature = JSON.stringify(body)
342  if (signature === rec.publishedPace) return
343  const path = `${logFile.slice(0, logFile.lastIndexOf('/'))}/pace.json`
344  await $.fs.write(path, JSON.stringify({ ...body, updatedAt: new Date(now).toISOString() }, null, 2) + '\n')
345  rec.publishedPace = signature
346}
347
348// 値が動いた・時間がたった:pace.json を書き直し、パネルが開いていれば描き直す
349async function refreshOutputs($: EngineInterface, rec: Recorder) {
350  await publishPace($, rec).catch(err => $.ui.log(`usage-log: pace.json を書けませんでした(${err instanceof Error ? err.message : String(err)})`))
351  if (rec.isPaneOpen) $.ui.invalidate('ui.render')
352}
353
354// 1 分ごと:別のセッションが新しい値を記録していないか見る。0:00 を越えると目安も変わる
355async function tick($: EngineInterface, rec: Recorder) {
356  await refreshLogLatest($, rec).catch(() => undefined)
357  await refreshOutputs($, rec)
358}
359
360// パネル用に、記録ファイルの末尾から 7日枠の値を読む
361async function refreshHistory($: EngineInterface, rec: Recorder) {
362  const logFile = await resolveLogFile($, rec)
363  if (!(await $.fs.exists(logFile))) {
364    rec.history = []
365    return
366  }
367  const { stdout } = await $.process.run(['/usr/bin/tail', '-n', String(TAIL_LINES), logFile])
368  const history: Recorder['history'] = []
369  for (const line of stdout.split('\n')) {
370    if (line.trim() === '') continue
371    try {
372      const row = JSON.parse(line) as { ts?: string; limits?: Record<string, { pct: number; resetsAt: string | null }> }
373      const weekly = row.limits?.seven_day
374      const at = Date.parse(row.ts ?? '')
375      const resetsAt = Date.parse(weekly?.resetsAt ?? '')
376      if (weekly && Number.isFinite(at) && Number.isFinite(resetsAt)) history.push({ at, pct: weekly.pct, resetsAt })
377    } catch {
378      // 途中で切れた行などは飛ばす
379    }
380  }
381  history.sort((a, b) => a.at - b.at)
382  rec.history = history
383}
384
385async function openPane($: EngineInterface, rec: Recorder) {
386  rec.isPaneOpen = true
387  await refreshHistory($, rec).catch(() => undefined)
388  await $.ui.open({ id: PANE, title: '週間ペース' })
389  $.ui.invalidate('ui.render')
390}
391
392async function refreshPane($: EngineInterface, rec: Recorder) {
393  await refreshHistory($, rec).catch(() => undefined)
394  $.ui.invalidate('ui.render')
395}
396
397function summaryMarkdown(view: PaceView, now: number, offMin: number): string {
398  const until = checkpointLabel(view.next.at, offMin)
399  const verdict = view.margin < 0 ? `**${fmt(-view.margin)}% 超過**` : `**あと ${fmt(view.margin)}%** 使える`
400  return [
401    `**7日枠 ${fmt(view.current)}%** 目安は ${until} までに ${fmt(view.next.pct)}% → ${verdict}`,
402    '',
403    `今の区間(${DOW[localDow(now, offMin)]})の配分 ${fmt(view.allowance)}%・リセットは ${checkpointLabel(view.windowEnd, offMin)}(あと ${untilText(view.windowEnd - now)})`,
404  ].join('\n')
405}
406
407function tableMarkdown(view: PaceView, points: Point[], now: number, offMin: number): string {
408  const lines = ['| 日 | 配分 | 目安(累計) | 実際(累計) | 使った | 差 |', '|---|---:|---:|---:|---:|---:|']
409  for (const seg of daySegments(view, points, now, offMin)) {
410    const used = seg.startValue !== undefined && seg.endValue !== undefined ? seg.endValue - seg.startValue : undefined
411    let diff = '-'
412    if (seg.isNow && seg.endValue !== undefined) {
413      const left = seg.target - seg.endValue
414      diff = left < 0 ? `${fmt(-left)} 超過` : `あと ${fmt(left)}`
415    } else if (used !== undefined) {
416      const left = seg.allowance - used
417      diff = left < 0 ? `${fmt(-left)} 超過` : `${fmt(left)} 余り`
418    }
419    const label = seg.isNow ? `**${seg.label}(今)**` : seg.label
420    lines.push(
421      `| ${label} | ${fmt(seg.allowance)} | ${fmt(seg.target)} | ${seg.endValue === undefined ? '-' : fmt(seg.endValue)} | ${used === undefined ? '-' : fmt(used)} | ${diff} |`,
422    )
423  }
424  return lines.join('\n')
425}
426
427export const register: Register = (on, options) => {
428  const pace = readPaceSetting(options)
429  const rec: Recorder = {
430    logFileSetting: readLogFileSetting(options),
431    logFile: undefined,
432    tzOffset: undefined,
433    tally: freshTally(),
434    paceTable: pace.table,
435    paceTableError: pace.error,
436    isPaneOpen: false,
437    history: [],
438    publishedPace: undefined,
439    logLatest: undefined,
440    ownAt: undefined,
441    modVersion: undefined,
442  }
443
444  on('session.start', async ($, e, next) => {
445    await $.command.register({
446      name: 'usage-log',
447      description: '7日枠の週間ペースのパネルを開き、記録ファイルの場所・行数・直近の値を表示する',
448    })
449    await resolveTzOffset($, rec).catch(() => undefined)
450    if (rec.paceTableError !== undefined) $.ui.log(`usage-log: ${rec.paceTableError}`)
451    await refreshLogLatest($, rec).catch(() => undefined)
452    await refreshOutputs($, rec)
453    $.clock.every(60_000, () => void tick($, rec))
454    return next(e)
455  })
456
457  on('command.run', { command: 'usage-log' }, async $ => {
458    await refreshLogLatest($, rec).catch(() => undefined)
459    await refreshOutputs($, rec)
460    await openPane($, rec)
461    return { text: await showSummary($, rec) }
462  })
463
464  // turn.start は本体のターンでだけ来る(サブエージェントの実行は turn.start を出さない)
465  on('turn.start', async ($, e, next) => {
466    try {
467      await snapshotStart($, rec, e.text.length)
468    } catch {
469      // 記録の失敗でターンを止めない
470    }
471    return next(e)
472  })
473
474  on('turn.step', async function* ($, e, next) {
475    if (e.agentId === undefined) {
476      rec.tally.steps += 1
477      rec.tally.model = e.model
478      rec.tally.effort = e.effort
479    } else {
480      rec.tally.subSteps += 1
481    }
482    return yield* next(e)
483  })
484
485  on('tool.call', ($, e, next) => {
486    if (e.agentId === undefined) rec.tally.tools += 1
487    else rec.tally.subTools += 1
488    rec.tally.toolCounts[e.tool] = (rec.tally.toolCounts[e.tool] ?? 0) + 1
489    return next(e)
490  })
491
492  on('turn.complete', async ($, e, next) => {
493    const result = await next(e)
494    if (e.agentId !== undefined) {
495      addTokens(rec.tally.subTokens, e.usage)
496      if (!rec.tally.subagents.includes(e.agentId)) rec.tally.subagents.push(e.agentId)
497      return result
498    }
499    try {
500      await writeRecord($, rec, e)
501    } catch (err) {
502      $.ui.log(`usage-log: 記録に失敗しました(${err instanceof Error ? err.message : String(err)})`)
503    }
504    rec.tally = freshTally()
505    rec.ownAt = await $.clock.now()
506    await refreshLogLatest($, rec).catch(() => undefined)
507    if (rec.isPaneOpen) await refreshHistory($, rec).catch(() => undefined)
508    await refreshOutputs($, rec)
509    return result
510  })
511
512  // 利用枠が 1 ポイント動いたとき(別のセッションや claude.ai の分も含む)
513  on('session.measure', async ($, e, next) => {
514    if (e.changed.includes('rateLimits')) {
515      rec.ownAt = await $.clock.now()
516      await refreshOutputs($, rec)
517    }
518    return next(e)
519  })
520
521  on('ui.close', async ($, e, next) => {
522    const closed = await next(e)
523    if (e.id === PANE) rec.isPaneOpen = false
524    return closed
525  })
526
527  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
528    const pace = await currentPace($, rec).catch(() => undefined)
529    const { view, now, offMin } = pace ?? { view: undefined, now: 0, offMin: 0 }
530    const points = view === undefined ? [] : rec.history.filter(p => Math.abs(p.resetsAt - view.windowEnd) < 3_600_000)
531    const footer = `目安表:${formatPaceTable(rec.paceTable)}(/config で変更)`
532    const onRefresh = () => void refreshPane($, rec)
533    if (e.surface === 'desktop') {
534      const { Box, Text, Button, Svg, Markdown } = $.ui.resolve(e)
535      if (view === undefined) return <Text dimColor>7日枠の値がまだ届いていません(最初の応答のあとに出ます)。</Text>
536      return (
537        <Box flexDirection="column">
538          <Markdown text={summaryMarkdown(view, now, offMin)} />
539          <Svg source={chartSvg(view, points, now, offMin)} alt="今週の 7日枠の使用率(実線)と目安(点線)" isInteractive />
540          <Markdown text={tableMarkdown(view, points, now, offMin)} />
541          <Box flexDirection="row" justifyContent="space-between">
542            <Text dimColor>{footer}</Text>
543            <Button label="更新" hotkey="r" onPress={onRefresh} />
544          </Box>
545        </Box>
546      )
547    }
548    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
549    if (view === undefined) return <Text dimColor>7日枠の値がまだ届いていません(最初の応答のあとに出ます)。</Text>
550    return (
551      <Box flexDirection="column">
552        <Markdown text={summaryMarkdown(view, now, offMin)} />
553        <Markdown text={tableMarkdown(view, points, now, offMin)} />
554        <Box flexDirection="row" justifyContent="space-between">
555          <Text dimColor>{footer}</Text>
556          <Button label="更新" hotkey="r" onPress={onRefresh} />
557        </Box>
558      </Box>
559    )
560  })
561}
562
hooks/pace.ts 217 lines
1// 7日枠の「目安」(ペース表)の計算と、その絵(SVG)。$ を使わない関数だけを置く。
2//
3// 目安表は「各曜日の 0:00 の時点で、累計で何 % まで使ってよいか」。
4// 枠の始まり(リセット)は 0%、終わり(次のリセット)は 100% として、その間に表の時点を並べる。
5// 時刻はすべて UTC のミリ秒で持ち、曜日や 0:00 はローカルの時差(分)を足して求める。
6
7export const DOW = ['日', '月', '火', '水', '木', '金', '土'] as const
8export const DEFAULT_PACE_TABLE = '火=12, 水=25, 木=37, 金=49, 土=61, 日=80, 月=98'
9
10const MINUTE = 60_000
11const HOUR = 60 * MINUTE
12const DAY = 24 * HOUR
13const WEEK = 7 * DAY
14
15export type PaceEntry = { dow: number; pct: number }
16export type Checkpoint = { at: number; pct: number }
17export type Point = { at: number; pct: number }
18
19export type PaceView = {
20  current: number
21  windowStart: number
22  windowEnd: number
23  checkpoints: Checkpoint[]
24  prev: Checkpoint
25  next: Checkpoint
26  // 今の区間(たいてい今日)の配分
27  allowance: number
28  // 次の時点の目安まであと何 %(負なら超過)
29  margin: number
30}
31
32export type DaySegment = {
33  from: number
34  to: number
35  label: string
36  allowance: number
37  target: number
38  isNow: boolean
39  isFuture: boolean
40  startValue: number | undefined
41  endValue: number | undefined
42}
43
44// 「火=12, 水=25」「火曜:12%、水曜:25%」などを読む。読めなければ undefined
45export function parsePaceTable(text: string): PaceEntry[] | undefined {
46  const entries: PaceEntry[] = []
47  for (const raw of text.split(/[,、,\n]+/)) {
48    const part = raw.replace(/\s+/g, '')
49    if (part === '') continue
50    const m = /^([日月火水木金土])(?:曜日?)?[=:=:](\d+(?:\.\d+)?)[%%]?$/.exec(part)
51    if (!m || m[1] === undefined || m[2] === undefined) return undefined
52    entries.push({ dow: DOW.indexOf(m[1] as (typeof DOW)[number]), pct: Number(m[2]) })
53  }
54  return entries.length > 0 ? entries : undefined
55}
56
57export function formatPaceTable(table: PaceEntry[]): string {
58  return table.map(e => `${DOW[e.dow]}=${fmt(e.pct)}`).join(', ')
59}
60
61export function localDow(at: number, offMin: number): number {
62  return new Date(at + offMin * MINUTE).getUTCDay()
63}
64
65export function localHM(at: number, offMin: number): string {
66  const d = new Date(at + offMin * MINUTE)
67  return `${d.getUTCHours()}:${String(d.getUTCMinutes()).padStart(2, '0')}`
68}
69
70function isLocalMidnight(at: number, offMin: number): boolean {
71  return (at + offMin * MINUTE) % DAY === 0
72}
73
74// t より後で最初のローカル 0:00
75function nextLocalMidnight(t: number, offMin: number): number {
76  const off = offMin * MINUTE
77  return Math.floor((t + off) / DAY) * DAY + DAY - off
78}
79
80export function buildCheckpoints(windowEnd: number, offMin: number, table: PaceEntry[]): Checkpoint[] {
81  const start = windowEnd - WEEK
82  const cps: Checkpoint[] = [{ at: start, pct: 0 }]
83  for (let at = nextLocalMidnight(start, offMin); at < windowEnd; at += DAY) {
84    const entry = table.find(e => e.dow === localDow(at, offMin))
85    if (entry) cps.push({ at, pct: entry.pct })
86  }
87  cps.push({ at: windowEnd, pct: 100 })
88  return cps
89}
90
91export function computePace(
92  current: number,
93  resetsAt: string | undefined,
94  now: number,
95  offMin: number,
96  table: PaceEntry[],
97): PaceView | undefined {
98  const windowEnd = resetsAt === undefined ? NaN : Date.parse(resetsAt)
99  if (!Number.isFinite(windowEnd)) return undefined
100  const checkpoints = buildCheckpoints(windowEnd, offMin, table)
101  let i = checkpoints.findIndex(c => c.at > now)
102  if (i === -1) i = checkpoints.length - 1
103  if (i === 0) i = 1
104  const prev = checkpoints[i - 1] as Checkpoint
105  const next = checkpoints[i] as Checkpoint
106  return {
107    current,
108    windowStart: windowEnd - WEEK,
109    windowEnd,
110    checkpoints,
111    prev,
112    next,
113    allowance: next.pct - prev.pct,
114    margin: next.pct - current,
115  }
116}
117
118export function checkpointLabel(at: number, offMin: number): string {
119  return `${DOW[localDow(at, offMin)]} ${localHM(at, offMin)}`
120}
121
122// 時刻 t の時点で何 % だったか:t 以前で最後の記録。枠の始まりは 0。分からなければ undefined
123export function valueAt(points: Point[], t: number, windowStart: number): number | undefined {
124  if (t <= windowStart) return 0
125  let value: number | undefined
126  for (const p of points) {
127    if (p.at <= t) value = p.pct
128    else break
129  }
130  return value
131}
132
133export function daySegments(view: PaceView, points: Point[], now: number, offMin: number): DaySegment[] {
134  const cps = view.checkpoints
135  const out: DaySegment[] = []
136  for (let i = 0; i + 1 < cps.length; i++) {
137    const from = cps[i] as Checkpoint
138    const to = cps[i + 1] as Checkpoint
139    let label: string = DOW[localDow(from.at, offMin)] ?? ''
140    if (!isLocalMidnight(from.at, offMin)) label += `(${localHM(from.at, offMin)}〜)`
141    else if (!isLocalMidnight(to.at, offMin)) label += `(〜${localHM(to.at, offMin)})`
142    const isNow = from.at <= now && now < to.at
143    const isFuture = from.at > now
144    out.push({
145      from: from.at,
146      to: to.at,
147      label,
148      allowance: to.pct - from.pct,
149      target: to.pct,
150      isNow,
151      isFuture,
152      startValue: isFuture ? undefined : valueAt(points, from.at, view.windowStart),
153      endValue: isFuture ? undefined : isNow ? view.current : valueAt(points, to.at, view.windowStart),
154    })
155  }
156  return out
157}
158
159export function fmt(n: number): string {
160  return Number.isInteger(n) ? String(n) : n.toFixed(1)
161}
162
163export function untilText(ms: number): string {
164  const h = Math.max(0, Math.floor(ms / HOUR))
165  const d = Math.floor(h / 24)
166  return d > 0 ? `${d}日${h % 24}時間` : `${h}時間${Math.floor((ms % HOUR) / MINUTE)}分`
167}
168
169const OK = '#22a06b'
170const OVER = '#e5484d'
171const LINE = '#3b82f6'
172const MUTED = '#8a8a8a'
173
174// パネルの週のグラフ:目安の折れ線(点線)と、記録した実際の使用率
175export function chartSvg(view: PaceView, points: Point[], now: number, offMin: number): string {
176  const W = 640
177  const H = 260
178  const L = 34
179  const R = 12
180  const T = 12
181  const B = 26
182  const pw = W - L - R
183  const ph = H - T - B
184  const x = (at: number) => L + ((at - view.windowStart) / WEEK) * pw
185  const y = (pct: number) => T + (1 - Math.min(100, Math.max(0, pct)) / 100) * ph
186  const xy = (p: Point) => `${x(p.at).toFixed(1)},${y(p.pct).toFixed(1)}`
187  const parts: string[] = [
188    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui, sans-serif" font-size="11">`,
189  ]
190  for (const pct of [0, 25, 50, 75, 100]) {
191    parts.push(`<line x1="${L}" x2="${W - R}" y1="${y(pct)}" y2="${y(pct)}" stroke="${MUTED}" stroke-opacity="0.25"/>`)
192    parts.push(`<text x="${L - 6}" y="${y(pct) + 4}" text-anchor="end" fill="${MUTED}">${pct}</text>`)
193  }
194  const cps = view.checkpoints
195  for (let i = 0; i + 1 < cps.length; i++) {
196    const a = cps[i] as Checkpoint
197    const b = cps[i + 1] as Checkpoint
198    if (i > 0) parts.push(`<line x1="${x(a.at)}" x2="${x(a.at)}" y1="${T}" y2="${T + ph}" stroke="${MUTED}" stroke-opacity="0.25"/>`)
199    if (b.at - a.at >= 3 * HOUR) {
200      parts.push(`<text x="${((x(a.at) + x(b.at)) / 2).toFixed(1)}" y="${H - 8}" text-anchor="middle" fill="${MUTED}">${DOW[localDow(a.at, offMin)]}</text>`)
201    }
202  }
203  parts.push(`<polyline points="${cps.map(xy).join(' ')}" fill="none" stroke="${MUTED}" stroke-width="1.5" stroke-dasharray="4 3"/>`)
204
205  const inWindow = points.filter(p => p.at >= view.windowStart && p.at <= now)
206  const actual: Point[] = [...inWindow, { at: now, pct: view.current }]
207  const first = actual[0] as Point
208  // 記録が始まる前の区間は分からないので細い点線でつなぐ
209  parts.push(`<polyline points="${xy({ at: view.windowStart, pct: 0 })} ${xy(first)}" fill="none" stroke="${LINE}" stroke-width="1" stroke-dasharray="2 3"/>`)
210  parts.push(`<polyline points="${actual.map(xy).join(' ')}" fill="none" stroke="${LINE}" stroke-width="2" stroke-linejoin="round"/>`)
211  parts.push(`<line x1="${x(now)}" x2="${x(now)}" y1="${T}" y2="${T + ph}" stroke="${LINE}" stroke-opacity="0.35"/>`)
212  parts.push(`<circle cx="${x(view.next.at)}" cy="${y(view.next.pct)}" r="4" fill="none" stroke="${MUTED}" stroke-width="1.5"/>`)
213  parts.push(`<circle cx="${x(now)}" cy="${y(view.current)}" r="4.5" fill="${view.margin < 0 ? OVER : OK}"/>`)
214  parts.push('</svg>')
215  return parts.join('')
216}
217