Claude Codeセッションのタイムライン可視化スクリプトを開発した
「昨日、自分がどのプロジェクトで何時間コードを書いていたのか」を把握する手段がなかった。Claude Codeのセッション履歴は ~/.claude/history.jsonl に蓄積されているが、JSONLの行を眺めても時間の流れが掴めない。ガントチャート風のSVGに変換するスクリプトを書き、最終的にmake-diaryコマンドに組み込んで毎日の日記にPNG画像として埋め込むところまで到達した。
データソースの構造を把握する
Claude Codeのセッション履歴は2層構造になっている。
~/.claude/history.jsonl: ユーザーのプロンプトごとに1行。sessionId、projectパス、timestampを含む~/.claude/projects/*/セッションID.jsonl: セッション本体。type: "user"とtype: "assistant"の両方のメッセージが記録されている
最初は history.jsonl だけを解析するスクリプト(generate-timeline-svg.mjs)を書いた。ユーザーのプロンプト送信時刻だけを拾い、プロジェクト別にガントチャート風のバーを描画する。動いたが、Claude Codeがバックグラウンドで10分作業していても、ユーザーが次のプロンプトを打つまでバーが途切れてしまう。実態と乖離していた。
full版: assistant応答も含めて活動時間を捉える
ユーザーのプロンプトだけでなく、assistantの応答タイムスタンプも拾う generate-timeline-full.mjs を作った。セッション本体のJSONLファイルを直接読み、type === "user" || type === "assistant" のタイムスタンプを全て収集する。
処理の流れはこうなる。
history.jsonlから対象日のエントリを抽出し、sessionId→ プロジェクト名のマッピングを構築~/.claude/projects/配下を走査して各セッションのJSONLファイルを探す- 各JSONLから user + assistant 両方のタイムスタンプを収集
- タイムスタンプの配列をブロック(連続区間)に分割
- ブロックをSVGのrect要素として描画
セッションファイルが見つからない場合は history.jsonl のデータにフォールバックする。
if ((obj.type === "user" || obj.type === "assistant") && obj.timestamp) {
const ts = new Date(obj.timestamp).getTime();
if (formatDate(toJST(ts)) === targetDate) {
timestamps.push(ts);
}
}
full版に切り替えたところ、バーの長さが体感に近づいた。Claude Codeが長時間コードを書いている間もバーが伸びるので、「このセッションでは30分作業していた」という情報が読み取れるようになった。
セッション別サブ行表示
同じプロジェクトで複数セッションを開くことがある。全セッションを1本のバーにまとめると、朝と夜に別のセッションで作業していたのか、1つのセッションで通しでやっていたのかが区別できない。
プロジェクト行の中にセッションごとのサブ行を設け、各セッションを個別のバーで描画する構造にした。行の高さはセッション数に応じて動的に伸縮する。
const projectRowHeights = projectNames.map((name) => {
const sc = projectSessions[name].length;
return PROJECT_PADDING * 2 + sc * SESSION_BAR_HEIGHT + (sc - 1) * SESSION_GAP;
});
左端にはセッション番号(1, 2, 3...)をグレーで小さく表示した。ホバーするとツールチップで開始・終了時刻とメッセージ数が出る。
GAP_THRESHOLDの比較検証: 5分/10分/15分/20分
タイムラインの「ブロック」をどう分割するかが、見た目に大きく影響する。2つのタイムスタンプの間隔がGAP_THRESHOLD以上ならば別ブロックとして分割し、それ未満なら同一ブロックに統合する。
4つの値で比較した。
| GAP_THRESHOLD | 結果 |
|---|---|
| 5分 | バーが細切れになりすぎる。1分おきにプロンプトを打っていても、6分空くだけで別ブロックに分かれてしまい、ガントチャートがモザイク状になった |
| 10分 | ちょうどよい。コーヒーを入れに行く程度の離席は吸収し、別の作業に移ったタイミングで切れる |
| 15分 | 別のプロジェクトに移動して戻ってきた場合でも1本のバーに統合されてしまう。「あ、ここで中断したのか」が見えなくなった |
| 20分 | 最初のプロトタイプで使っていた値。昼休みを挟んでも1ブロックに統合されることがあり、不正確 |
10分に決定した。実際の運用で1週間分のタイムラインを生成して見比べた結果、体感との一致度が最も高かった。ブロック末尾には5分のパディング(TAIL_PADDING_MS)を加える。最後のメッセージから5分間は「まだ作業していた」とみなす補正で、バーの末端が不自然に短くならないようにした。
時間軸の動的スケーリング
初期版では START_HOUR = 6、END_HOUR = 26(翌朝2時)で固定していた。早朝に作業しない日はグラフの左半分が空白になり、深夜まで作業しない日は右半分が空白になる。
データの最初と最後の活動時刻から動的に計算するように変えた。
const START_HOUR = firstJST.getUTCHours();
const lastHour = lastJST.getUTCHours() + (lastJST.getUTCMinutes() > 0 ? 1 : 0);
const END_HOUR = Math.max(lastHour, START_HOUR + MIN_HOUR_SPAN);
MIN_HOUR_SPAN = 4 を設けて、活動が1時間に集中している日でもグラフが横に潰れないようにした。
プロジェクト名のラベル対応表
history.jsonl のプロジェクトパスはフルパスで記録されている。末尾のディレクトリ名を抽出して mdx-playground のような英語名を得るが、タイムラインに並べるとどれがどれだか一目でわからない。
日本語+略称のラベル対応表を作った。
const PROJECT_LABELS = {
"mdx-playground": "ログ管理",
"eurekapu-nuxt4": "アプリ_eurekapu",
"Edinet-api": "アプリ_Edinet",
"chrome-extension-x": "Chrome拡張X",
"tax-lp": "コンテンツ制作",
// ...26プロジェクト分
};
対応表に存在しないプロジェクトは英語名がそのまま表示される。新しいリポジトリを作ったら、ここに1行追加すればよい。
Playwrightヘッドレスによる SVG → PNG 変換
SVGはブラウザで開けば見られるが、Markdownの記事に埋め込むにはPNGが必要になる。別スクリプト(svg-to-png.mjs)として切り出す案もあったが、タイムライン生成の最後にそのまま変換する方が運用が楽だった。
Playwrightのchromiumをヘッドレスで起動し、SVGを含むHTMLをレンダリングしてスクリーンショットを撮る。
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
viewport: { width: TOTAL_WIDTH, height: TOTAL_HEIGHT }
});
await page.setContent(html, { waitUntil: "load" });
await page.screenshot({ path: pngPath, fullPage: true });
await browser.close();
Playwrightが入っていない環境では try/catch で握りつぶしてSVGのみ出力する。PNG変換は任意のオプション扱い。
make-diaryコマンドへの統合
タイムラインPNGを手動でコピーして日記に貼り付けるのは1日で飽きた。make-diaryコマンドの実行手順に /generate-timeline を組み込み、統合日記(diary-YYYY-MM-DD.md)にPNG画像を自動埋め込むようにした。
日記の冒頭に「今日のタイムライン」セクションが入り、その日どのプロジェクトにどれだけ時間を使ったかが一目でわかる。画像パスは相対パスで ./timeline-YYYY-MM-DD.png を参照する。
note-title-generatorスキルの作成
同日に、note記事タイトル提案スキル(note-title-generator)も作成した。note編集部の「有料記事500件を分析して見えた、noteで購入されやすいタイトル設計の基本」をリファレンスとして読み込み、キーワードやクリエイター名を入力するとタイトル案を3つ返す。タイムラインとは無関係だが、同じセッションで一気に作ったのでここに記録しておく。
成果物と所感
最終的に以下のファイルが揃った。
| ファイル | 役割 |
|---|---|
scripts/generate-timeline-svg.mjs | プロトタイプ。history.jsonlのみ解析 |
scripts/generate-timeline-full.mjs | 本番版。セッション本体のJSONLも解析 |
scripts/svg-to-png.mjs | 汎用SVG→PNG変換(単体実行用) |
scripts/list-sessions.mjs | デバッグ用。セッション一覧を表示 |
.claude/commands/generate-timeline.md | スラッシュコマンド定義 |
GAP_THRESHOLDの値を4パターン試して最適解を探るプロセスが、このスクリプト開発で一番時間を使った部分だった。数値を変えてSVGを出力し、過去1週間分のタイムラインを見比べて「この日は昼に1時間離席したはずなのにバーが繋がっている」「ここは5分の休憩だったのに切れている」と体感と照合する作業を繰り返した。最終的に10分に落ち着いたが、この検証工程がなければ20分のまま使い続けていただろう。