Compare commits

...
5 Commits
Author SHA1 Message Date
yoshikazuandClaude Opus 5 afae15b30c CLAUDE.md を追加
構成・守ること (標準ライブラリのみ / ログは読み取り専用 / .cmd は ASCII)・
検証時のポート・API 一覧・解析の前提を README から起こした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:31:37 +09:00
yoshikazuandClaude Opus 5 8fd54757e6 日別集計に期間の任意指定とプロジェクト除外を追加
/api/daily に from / to / exclude を追加。from/to があれば days より優先し、
その範囲を 0 埋めで返す。exclude のプロジェクトは集計対象から外す
(横断モードのみ送信)。CSV も同じ条件で出力される。

UI は日数ボタンの隣に日付入力を置き、凡例のクリックで除外・「除外中」から
クリックで復帰できるようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:30:55 +09:00
yoshikazuandClaude Opus 5 0c312afaa5 SSE の切断を 5 秒以内に検出してスレッドを片付ける
これまではタブを閉じた接続に気付くのが次の ping (最大20秒後) の書き込み
失敗時だった。受信側の EOF を select で見て、待ち受けの合間に確認する。
ping は経過時間で送るようにしたので、間隔 (20秒) は変わらない。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:48:37 +09:00
yoshikazuandClaude Opus 5 efc7180317 自動追従で追記分だけを解析するようにした
解析を SessionParser に切り出し、前回読んだ位置から先だけを読み足せる
ようにした。書きかけの行は消費せず次回に持ち越す。先頭と解析済み末尾の
バイト列で「追記の続きか」を判定し、違えば頭から読み直す。

Store はファイルごとに Entry (パーサ + ロック) を持つ。追記解析は状態を
持つので、同じファイルを複数スレッドで同時に解析しないようにしている。

実測 (追記1行あたり): 1.4MB 13.1ms -> 7.8ms、14MB 59.0ms -> 8.2ms。
残りはファイル open の実費で、ログサイズによらず一定になった。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:46:34 +09:00
yoshikazuandClaude Opus 5 4692043106 ガントにターン単位の表示を追加
1セッション内のターンを絶対時刻の共通軸に並べる表示を、ガントタブの
タスク/ターン切替として追加。やりとりタブのタイムライン列は行ごとに
正規化されていて長さを比較できないため、実時間で並べられるようにした。

- 軸・背景・枠組みをタスク単位の描画と共通化 (timeScale/axisSvg/gridSvg/ganttFrame)
- 目盛りを秒単位まで拡張し、開始位置を step の単位で切り捨てるよう修正
- 行クリックでやりとりタブの該当ターンへ移動 (一時ハイライト)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 09:26:49 +09:00
5 changed files with 787 additions and 250 deletions
+70
View File
@@ -0,0 +1,70 @@
# chktime
Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`)を解析して、
タスクごとのやりとりの時刻を表示するローカル Web アプリ。使い方は [README.md](README.md) に書いてある。
## 構成
```
chktime.py サーバ + jsonl 解析(これ1本で完結)
web/index.html 画面の骨組み
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
web/style.css スタイル(ダークテーマ固定)
run.bat コンソール表示で起動
stop.cmd 起動中の chktime を終了
```
`chktime.py` の中身は上から「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ HTTP」の順。
## 守ること
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
- **外部通信しない**。読むのはローカルの jsonl だけ。
- 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。
- `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。
- コメント・コミットメッセージ・UI の文言は日本語。
- 機能を足したら README.md も更新する。
## 動かす
```bash
python chktime.py --port 8799 --no-browser
```
ユーザーは 8787 でデスクトップショートカットから常用しているので、**検証は 8799 など別ポート**で立てる。
停止は `stop.cmd`(ただし `chktime.py` を実行中の python/pythonw を**すべて**終了するので、
検証用だけを止めたいときはコマンドラインでポートを見て個別に kill する)。
## API
| ルート | 内容 |
| --- | --- |
| `/api/projects` | プロジェクト一覧 |
| `/api/sessions?project=` | そのプロジェクトのタスク一覧 |
| `/api/all` | 全プロジェクトのタスク一覧(横断モード) |
| `/api/session?project=&id=` | 1タスクのターン一覧 |
| `/api/daily?project=&days=&from=&to=&exclude=` | 日別集計 |
| `/api/timeline?project=&days=` | ガント(タスク単位)。ターン単位は `/api/session` から描く |
| `/api/watch` | SSE。ログが変わったら `change` を流す |
| `/api/rev` `/api/reload` | 現在の版 / キャッシュ破棄 |
いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。
## 解析の前提
- ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。
- 再開時に書かれるメタ行(attachment / system / mode / queue-operation)は**ターンの所要時間に数えない**。
セッション全体の開始・終了時刻には反映する。
- `tool_result` を含む行は巨大なので JSON パースせず、正規表現で `timestamp` だけ拾う。
- 解析結果は `(mtime, size)` でキャッシュし、追記されただけのファイルは**増えたぶんだけ**読み足す
(`SessionParser.read()`)。書きかけの行は消費せず次回に回す。先頭と解析済み末尾のバイト列が
変わっていたら書き換えとみなして頭から読み直す。
- 追記解析は状態を持つので、同じファイルを複数スレッドで同時に解析しない(`Entry.lock`)。
## 画面
- 3ペイン(プロジェクト / タスク一覧 / 詳細)。右ペインは **やりとり・日別集計・ガント**のタブ。
- 状態はすべて `app.js` 冒頭の `state` オブジェクト。再描画は `render*()` が `#detail-body` を作り直す方式。
- 自動追従(SSE)で作り直すときも、選択・スクロール位置・絞り込み・タブは保つ(`refreshFollow()`)。
- ガントの軸・背景・枠組みは `timeScale` / `axisSvg` / `gridSvg` / `ganttFrame` をタスク単位と
ターン単位で共有している。
+18 -1
View File
@@ -59,13 +59,19 @@ python chktime.py
ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。 ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。
- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。 - 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。
- 右の**日付入力**で期間を直接指定できます(片方だけでも可。開始のみ→その日から今日まで、終了のみ→その日まで全部)。指定中は日数ボタンの選択が外れ、**✕** で解除すると日数指定に戻ります。
- 横断モードでは**凡例のプロジェクトをクリックすると集計から外せます**。外したものは下の「除外中」に並び、クリックで戻せます(「すべて戻す」でまとめて解除)。除外は棒グラフ・表・合計・CSV すべてに効きます。
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。 - 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。 - 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。 - 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
### ガントタブ ### ガントタブ
タスク(セッション)を 1 行ずつ、時間軸に並べたガントチャートです。 時間軸に並べたガントチャートです。左上の **タスク / ターン** で並べる単位を切り替えます。
#### タスク単位
タスク(セッション)を 1 行ずつ並べます。
- **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。 - **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。
- 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。 - 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。
@@ -73,6 +79,15 @@ python chktime.py
- 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。 - 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。
- 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。 - 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。
#### ターン単位
選択中のタスクのターンを 1 行ずつ、**絶対時刻の共通軸**に並べます。やりとりタブのタイムライン列は行ごとに正規化されているため長さを比べられませんが、こちらは横位置と長さがそのまま実時間です。
- **青** = 初回応答までの待ち、**緑** = 応答中(ツール実行・サブエージェント含む)、**灰** = 応答が返らなかったターン。行と行の空白がそのまま放置していた時間です。
- 表示範囲はそのタスクの開始〜終了に自動で合わせます(目盛りは秒〜日単位で自動選択)。バーが横に重なっていれば、前の応答中に次を送った(並行)ターンです。
- 行にカーソルを当てると発言内容・時刻・初回応答/応答時間/空き、クリックすると「やりとり」タブの該当行に移動します。
- タスクを選ばずに開いた場合は、中央のタスク一覧から選ぶと表示されます。ターン単位を表示している間は、タスクを選び直してもガントのまま切り替わります。
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 `CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
@@ -82,6 +97,7 @@ python chktime.py
動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。 動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。
- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。 - サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。
- タブを閉じた接続は 5 秒以内に検出して片付けます(20 秒ごとの ping を待ちません)。
- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。 - 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。
- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。 - ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。 - オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
@@ -94,6 +110,7 @@ python chktime.py
- `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。 - `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。
- セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。 - セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。
- 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。 - 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。
- 追記されただけのファイルは**増えたぶんだけ**読み足します(書きかけの行は次回に持ち越し)。先頭と解析済み末尾のバイト列が変わっていた場合=ログが書き換えられた場合は頭から読み直します。
## ファイル ## ファイル
+238 -99
View File
@@ -20,10 +20,12 @@ import io
import json import json
import os import os
import re import re
import select
import socket
import threading import threading
import time import time
import webbrowser import webbrowser
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@@ -38,6 +40,9 @@ TAG_RE = re.compile(r"<([a-z-]+)>(.*?)</\1>", re.S)
PROMPT_LIMIT = 4000 PROMPT_LIMIT = 4000
SUMMARY_LIMIT = 200 SUMMARY_LIMIT = 200
WATCH_POLL = 5.0 # SSE: 切断を見に行く間隔(秒)
PING_INTERVAL = 20.0 # SSE: 接続維持のコメントを送る間隔(秒)
# ---------------------------------------------------------------- 解析ロジック # ---------------------------------------------------------------- 解析ロジック
@@ -138,90 +143,146 @@ def new_turn(index, kind, text, ts):
} }
def parse_file(path: Path): TAIL_BYTES = 1024 # 追記の続きかを確かめるために覚えておく末尾のバイト数
"""1セッションの jsonl を解析して dict を返す。"""
turns = []
titles = {}
meta = {}
counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0}
start = end = None
pending_prompt = None # turn 開始待ち (queue-operation より後の user 本体を採用)
with path.open("rb") as fh:
for raw in fh: class SessionParser:
"""1セッションの jsonl を解析する。追記されたぶんだけ読み足せる。
read() は「改行で終わっている行」までしか消費しないので、書きかけの行が
あっても次回の read() で読み直される。ログは追記されるだけという前提だが、
書き換えられた場合は resumable() が False を返すので作り直せばよい。
"""
def __init__(self, path: Path):
self.path = path
self.turns = []
self.titles = {}
self.meta = {}
self.counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0}
self.start = None
self.end = None
self.pos = 0 # 解析済みのバイト位置
self.head = b"" # 先頭 TAIL_BYTES バイト
self.tail = b"" # pos の直前 TAIL_BYTES バイト
# -- 追記の読み取り ------------------------------------------------
def read(self, size=None):
"""未読部分を読んで解析する。前回の続きでなければ False を返す。
追記されるだけのログを前提に、先頭と解析済み末尾のバイト列だけを見て
「続き」かを判定する。ファイル open は Windows では数 ms かかるので、
確認と読み取りは 1 回の open で済ませる。
"""
if size is not None and size < self.pos:
return False # 縮んだ = 書き換えられた
with self.path.open("rb") as fh:
if self.pos:
if fh.read(len(self.head)) != self.head:
return False
fh.seek(self.pos - len(self.tail))
if fh.read(len(self.tail)) != self.tail:
return False
else:
fh.seek(0)
chunk = fh.read()
if not chunk:
return True
lines = chunk.split(b"\n")
partial = lines.pop() # 改行で終わっていなければ書きかけの行
used = len(chunk) - len(partial)
for raw in lines:
self.feed(raw)
if used:
if len(self.head) < TAIL_BYTES:
self.head = (self.head + chunk[:used])[:TAIL_BYTES]
self.tail = (self.tail + chunk[:used])[-TAIL_BYTES:]
self.pos += used
return True
# -- 1行の解析 ----------------------------------------------------
def bump(self, ts):
self.start = ts if self.start is None else min(self.start, ts)
self.end = ts if self.end is None else max(self.end, ts)
def extend_last(self, ts):
"""直前のターンの終端を伸ばす (サブエージェント・ツール結果・中断)。"""
if self.turns and ts is not None:
last = self.turns[-1]
last["endTs"] = max(last["endTs"] or ts, ts)
def feed(self, raw):
raw = raw.strip() raw = raw.strip()
if not raw: if not raw:
continue return
# tool_result 行は巨大なので JSON パースせず時刻だけ拾う # tool_result 行は巨大なので JSON パースせず時刻だけ拾う
if b'"tool_result"' in raw and b'"type":"user"' in raw: if b'"tool_result"' in raw and b'"type":"user"' in raw:
match = TS_RE.search(raw) match = TS_RE.search(raw)
ts = parse_ts(match.group(1).decode("utf-8")) if match else None ts = parse_ts(match.group(1).decode("utf-8")) if match else None
counts["toolResult"] += 1 self.counts["toolResult"] += 1
if ts is not None: if ts is not None:
start = ts if start is None else min(start, ts) self.bump(ts)
end = ts if end is None else max(end, ts) self.extend_last(ts)
if turns: return
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
continue
try: try:
obj = json.loads(raw) obj = json.loads(raw)
except (ValueError, UnicodeDecodeError): except (ValueError, UnicodeDecodeError):
continue return
rtype = obj.get("type") rtype = obj.get("type")
if rtype == "custom-title": if rtype == "custom-title":
titles["custom"] = obj.get("customTitle") self.titles["custom"] = obj.get("customTitle")
continue return
if rtype == "ai-title": if rtype == "ai-title":
titles["ai"] = obj.get("aiTitle") self.titles["ai"] = obj.get("aiTitle")
continue return
if rtype == "last-prompt": if rtype == "last-prompt":
titles["last"] = obj.get("lastPrompt") self.titles["last"] = obj.get("lastPrompt")
continue return
ts = parse_ts(obj.get("timestamp")) ts = parse_ts(obj.get("timestamp"))
if ts is not None: if ts is not None:
start = ts if start is None else min(start, ts) self.bump(ts)
end = ts if end is None else max(end, ts)
for key in ("cwd", "gitBranch", "version", "slug", "sessionId"): for key in ("cwd", "gitBranch", "version", "slug", "sessionId"):
if not meta.get(key) and obj.get(key): if not self.meta.get(key) and obj.get(key):
meta[key] = obj[key] self.meta[key] = obj[key]
if rtype == "user": if rtype == "user":
counts["user"] += 1 self.counts["user"] += 1
parsed = classify_user(obj) parsed = classify_user(obj)
if not parsed: if not parsed:
if obj.get("isSidechain"): if obj.get("isSidechain"):
counts["sidechain"] += 1 self.counts["sidechain"] += 1
if turns and ts is not None: if self.turns and ts is not None:
turns[-1]["sidechain"] += 1 self.turns[-1]["sidechain"] += 1
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) self.extend_last(ts)
continue return
kind, text = parsed kind, text = parsed
if kind == "interrupt": if kind == "interrupt":
if turns: if self.turns:
turns[-1]["interrupted"] = True self.turns[-1]["interrupted"] = True
if ts is not None: self.extend_last(ts)
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) return
continue self.turns.append(new_turn(len(self.turns) + 1, kind, text, ts))
pending_prompt = None return
turns.append(new_turn(len(turns) + 1, kind, text, ts))
continue
if rtype == "assistant": if rtype == "assistant":
if obj.get("isSidechain"): if obj.get("isSidechain"):
counts["sidechain"] += 1 self.counts["sidechain"] += 1
if turns and ts is not None: if self.turns and ts is not None:
turns[-1]["sidechain"] += 1 self.turns[-1]["sidechain"] += 1
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) self.extend_last(ts)
continue return
counts["assistant"] += 1 self.counts["assistant"] += 1
if turns: if not self.turns:
turn = turns[-1] return
turn = self.turns[-1]
turn["assistant"] += 1 turn["assistant"] += 1
if ts is not None: if ts is not None:
if turn["firstReplyTs"] is None: if turn["firstReplyTs"] is None:
@@ -237,14 +298,17 @@ def parse_file(path: Path):
name = block.get("name") or "?" name = block.get("name") or "?"
turn["tools"][name] = turn["tools"].get(name, 0) + 1 turn["tools"][name] = turn["tools"].get(name, 0) + 1
turn["toolCalls"] += 1 turn["toolCalls"] += 1
continue return
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。 # その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
# 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。 # 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。
_ = pending_prompt # 予約 # -- 結果 ----------------------------------------------------------
# ターンごとの派生値 def result(self, stat=None):
"""現在までの解析結果を dict にする。派生値はここで計算し直す。"""
stat = stat or self.path.stat()
turns = self.turns
prev_end = None prev_end = None
active = 0.0 active = 0.0
tool_total = 0 tool_total = 0
@@ -262,37 +326,37 @@ def parse_file(path: Path):
active += turn["durMs"] active += turn["durMs"]
prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end
titles = self.titles
first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "") first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "")
title = ( title = (
titles.get("custom") titles.get("custom")
or titles.get("ai") or titles.get("ai")
or one_line(first_prompt, 120) or one_line(first_prompt, 120)
or titles.get("last") or titles.get("last")
or meta.get("slug") or self.meta.get("slug")
or path.stem or self.path.stem
) )
stat = path.stat()
return { return {
"id": path.stem, "id": self.path.stem,
"file": str(path), "file": str(self.path),
"title": title, "title": title,
"titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"), "titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"),
"cwd": meta.get("cwd"), "cwd": self.meta.get("cwd"),
"gitBranch": meta.get("gitBranch"), "gitBranch": self.meta.get("gitBranch"),
"version": meta.get("version"), "version": self.meta.get("version"),
"slug": meta.get("slug"), "slug": self.meta.get("slug"),
"start": iso(start), "start": iso(self.start),
"end": iso(end), "end": iso(self.end),
"startEpoch": start, "startEpoch": self.start,
"endEpoch": end, "endEpoch": self.end,
"wallMs": ms(end, start), "wallMs": ms(self.end, self.start),
"activeMs": int(active) if active else 0, "activeMs": int(active) if active else 0,
"turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")), "turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")),
"userMsgs": counts["user"], "userMsgs": self.counts["user"],
"assistantMsgs": counts["assistant"], "assistantMsgs": self.counts["assistant"],
"toolResults": counts["toolResult"], "toolResults": self.counts["toolResult"],
"sidechainMsgs": counts["sidechain"], "sidechainMsgs": self.counts["sidechain"],
"toolCalls": tool_total, "toolCalls": tool_total,
"bytes": stat.st_size, "bytes": stat.st_size,
"mtime": stat.st_mtime, "mtime": stat.st_mtime,
@@ -300,6 +364,13 @@ def parse_file(path: Path):
} }
def parse_file(path: Path):
"""1セッションの jsonl を頭から解析して dict を返す。"""
parser = SessionParser(path)
parser.read()
return parser.result()
def ms(later, earlier): def ms(later, earlier):
if later is None or earlier is None: if later is None or earlier is None:
return None return None
@@ -328,8 +399,24 @@ def summary_of(session):
# ---------------------------------------------------------------- ストア # ---------------------------------------------------------------- ストア
class Entry:
"""1ファイルぶんのキャッシュ。パーサを持ち回して追記分だけ読み足す。"""
__slots__ = ("lock", "parser", "token", "data")
def __init__(self, path: Path):
self.lock = threading.Lock() # 同じファイルを同時に解析しない
self.parser = SessionParser(path)
self.token = None # (mtime, size)
self.data = None
class Store: class Store:
"""jsonl の解析結果を (mtime, size) でキャッシュする。""" """jsonl の解析結果を (mtime, size) でキャッシュする。
変化したファイルは、末尾に追記されただけなら追加分だけを解析する。
書き換えられていた場合 (縮んだ / 途中が変わった) は頭から読み直す。
"""
def __init__(self, root: Path): def __init__(self, root: Path):
self.root = root self.root = root
@@ -368,16 +455,22 @@ class Store:
return sorted(directory.glob("*.jsonl")) return sorted(directory.glob("*.jsonl"))
def session(self, path: Path): def session(self, path: Path):
stat = path.stat()
key = str(path) key = str(path)
with self._lock: with self._lock:
hit = self._cache.get(key) entry = self._cache.get(key)
if hit and hit[0] == (stat.st_mtime, stat.st_size): if entry is None:
return hit[1] entry = self._cache[key] = Entry(path)
parsed = parse_file(path) with entry.lock:
with self._lock: stat = path.stat()
self._cache[key] = ((stat.st_mtime, stat.st_size), parsed) token = (stat.st_mtime, stat.st_size)
return parsed if entry.token == token and entry.data is not None:
return entry.data
if not entry.parser.read(stat.st_size):
entry.parser = SessionParser(path) # 書き換えられていた: 頭から
entry.parser.read()
entry.data = entry.parser.result(stat)
entry.token = token
return entry.data
def sessions(self, project_id): def sessions(self, project_id):
out = [summary_of(self.session(p)) for p in self.session_files(project_id)] out = [summary_of(self.session(p)) for p in self.session_files(project_id)]
@@ -402,10 +495,12 @@ class Store:
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True) out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
return out return out
def daily_stats(self, project_id=None, days=30): def daily_stats(self, project_id=None, days=30, start=None, end=None, exclude=()):
"""ターンをローカル日付で集計する。project_id=None なら全プロジェクト。 """ターンをローカル日付で集計する。project_id=None なら全プロジェクト。
days>0 なら「今日から遡って days 日」、それ以外は全期間。 期間は start/end (date) を渡せばその範囲、無ければ days>0 で「今日から
遡って days 日」、days<=0 なら全期間。exclude に入れたプロジェクトは
集計に含めない (横断モード用)。
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。 活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
""" """
paths = {p["id"]: p["path"] for p in self.projects()} paths = {p["id"]: p["path"] for p in self.projects()}
@@ -414,10 +509,16 @@ class Store:
raise FileNotFoundError(project_id) raise FileNotFoundError(project_id)
targets = [project_id] targets = [project_id]
else: else:
targets = list(paths) targets = [pid for pid in paths if pid not in exclude]
today = datetime.now().date() today = datetime.now().date()
cutoff = today - timedelta(days=days - 1) if days and days > 0 else None if start or end:
lo, hi = start, end or today
if lo and hi < lo:
lo, hi = hi, lo
else:
lo = today - timedelta(days=days - 1) if days and days > 0 else None
hi = None # 上限なし (今日 かデータの末尾まで)
per_day = {} per_day = {}
per_project = {} per_project = {}
@@ -429,7 +530,7 @@ class Store:
if turn["ts"] is None: if turn["ts"] is None:
continue continue
day = datetime.fromtimestamp(turn["ts"]).date() day = datetime.fromtimestamp(turn["ts"]).date()
if cutoff and day < cutoff: if (lo and day < lo) or (hi and day > hi):
continue continue
dur = turn["durMs"] or 0 dur = turn["durMs"] or 0
counted = 1 if turn["kind"] in ("prompt", "command") else 0 counted = 1 if turn["kind"] in ("prompt", "command") else 0
@@ -445,16 +546,16 @@ class Store:
seen.add(session["id"]) seen.add(session["id"])
if per_day: if per_day:
start = cutoff or min(per_day) first = lo or min(per_day)
end = max(max(per_day), today) last = hi or max(max(per_day), today)
elif cutoff: elif lo or hi:
start, end = cutoff, today # データなしでも指定範囲は 0 で埋める first, last = (lo or hi), (hi or today) # データなしでも指定範囲は 0 で埋める
else: else:
start = end = None first = last = None
rows = [] rows = []
cur = start cur = first
while cur and cur <= end: while cur and cur <= last:
bucket = per_day.get(cur) bucket = per_day.get(cur)
rows.append( rows.append(
{ {
@@ -484,7 +585,11 @@ class Store:
busiest = max(rows, key=lambda r: r["activeMs"], default=None) busiest = max(rows, key=lambda r: r["activeMs"], default=None)
return { return {
"scope": project_id or "", "scope": project_id or "",
"rangeDays": days if days and days > 0 else 0, "rangeDays": 0 if (start or end) else (days if days and days > 0 else 0),
"custom": bool(start or end),
"from": first.isoformat() if first else None,
"to": last.isoformat() if last else None,
"excluded": sorted(exclude),
"days": rows, "days": rows,
"projects": projects, "projects": projects,
"totals": { "totals": {
@@ -701,6 +806,19 @@ def parse_days(value):
return max(0, min(days, 3650)) return max(0, min(days, 3650))
def parse_date(value):
"""?from= / ?to= の解釈。'YYYY-MM-DD' 以外は None (指定なし扱い)。"""
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
return None
def parse_ids(value):
"""?exclude= の解釈。カンマ区切りのプロジェクト ID。"""
return {part for part in (value or "").split(",") if part}
def sessions_csv(sessions, with_project=False): def sessions_csv(sessions, with_project=False):
buf = io.StringIO(newline="") buf = io.StringIO(newline="")
writer = csv.writer(buf) writer = csv.writer(buf)
@@ -797,7 +915,13 @@ class Handler(BaseHTTPRequestHandler):
return self.send_json({"project": "", "sessions": sessions}) return self.send_json({"project": "", "sessions": sessions})
if route == "/api/daily": if route == "/api/daily":
project = query.get("project", [""])[0] project = query.get("project", [""])[0]
data = store.daily_stats(project or None, parse_days(query.get("days", ["30"])[0])) data = store.daily_stats(
project or None,
parse_days(query.get("days", ["30"])[0]),
parse_date(query.get("from", [""])[0]),
parse_date(query.get("to", [""])[0]),
parse_ids(query.get("exclude", [""])[0]),
)
if query.get("format", [""])[0] == "csv": if query.get("format", [""])[0] == "csv":
return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv") return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv")
return self.send_json(data) return self.send_json(data)
@@ -824,7 +948,11 @@ class Handler(BaseHTTPRequestHandler):
raise FileNotFoundError(route) raise FileNotFoundError(route)
def watch(self): def watch(self):
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。""" """SSE。ログに変更があるまでブロックし、あれば change イベントを流す。
タブを閉じた接続はソケットの EOF で気付いて抜ける。書き込みが失敗する
まで (ping 最大20秒ぶん) スレッドを抱えたままにしないため。
"""
watcher = self.server.watcher watcher = self.server.watcher
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8") self.send_header("Content-Type", "text/event-stream; charset=utf-8")
@@ -832,18 +960,29 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("X-Accel-Buffering", "no") self.send_header("X-Accel-Buffering", "no")
self.end_headers() self.end_headers()
rev = watcher.rev rev = watcher.rev
last_ping = time.monotonic()
try: try:
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n") self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
while True: while not self.disconnected():
current = watcher.wait_change(rev, 20) current = watcher.wait_change(rev, WATCH_POLL)
if current != rev: if current != rev:
rev = current rev = current
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n") self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
else: last_ping = time.monotonic()
elif time.monotonic() - last_ping >= PING_INTERVAL:
self.stream(": ping\n\n") # 接続維持 self.stream(": ping\n\n") # 接続維持
last_ping = time.monotonic()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError): except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
return # タブを閉じた等 return # タブを閉じた等
def disconnected(self):
"""クライアントが切断済みかを見る (受信側の EOF で判定)。"""
try:
ready, _, _ = select.select([self.connection], [], [], 0)
return bool(ready) and not self.connection.recv(1, socket.MSG_PEEK)
except (OSError, ValueError):
return True
def stream(self, text): def stream(self, text):
self.wfile.write(text.encode("utf-8")) self.wfile.write(text.encode("utf-8"))
self.wfile.flush() self.wfile.flush()
+359 -76
View File
@@ -12,8 +12,12 @@ const state = {
relative: false, relative: false,
view: 'turns', view: 'turns',
days: 30, days: 30,
dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う
dailyTo: '',
dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ)
daily: null, daily: null,
ganttDays: 7, ganttDays: 7,
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
gantt: null, gantt: null,
follow: false, follow: false,
es: null, es: null,
@@ -81,6 +85,8 @@ function ago(iso) {
return `${Math.round(diff / 86400)}日前`; return `${Math.round(diff / 86400)}日前`;
} }
const clip = (s, n) => (String(s ?? '').length > n ? String(s).slice(0, n) + '…' : String(s ?? ''));
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
async function api(path) { async function api(path) {
@@ -195,22 +201,27 @@ function shortPath(path) {
// ---------------------------------------------------------------- 詳細 // ---------------------------------------------------------------- 詳細
async function selectSession(project, id) { async function selectSession(project, id) {
// ターン単位のガントを見ている間は、タスクを選び直してもそのまま並べ替える
const stayGantt = state.view === 'gantt' && state.ganttUnit === 'turn';
state.sessionProject = project; state.sessionProject = project;
state.session = id; state.session = id;
state.view = 'turns'; if (!stayGantt) state.view = 'turns';
syncTabs(); syncTabs();
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id)); document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
updateCsv(); updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>'; $('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
await reloadDetail(); if (stayGantt) await loadGantt({ silent: true });
else await reloadDetail();
}
function fetchDetail() {
return api(`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`);
} }
async function reloadDetail() { async function reloadDetail() {
if (!state.session) return renderDetail(); if (!state.session) return renderDetail();
try { try {
state.detail = await api( state.detail = await fetchDetail();
`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`
);
renderDetail(); renderDetail();
} catch (e) { } catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`; $('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
@@ -308,21 +319,34 @@ function syncTabs() {
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt'); $('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
} }
function scopeQuery(days, extra) { function ganttQuery(extra) {
const q = new URLSearchParams({ days: String(days), ...extra }); const q = new URLSearchParams({ days: String(state.ganttDays), ...extra });
if (state.project) q.set('project', state.project); if (state.project) q.set('project', state.project);
return q.toString(); return q.toString();
} }
const dailyQuery = (extra) => scopeQuery(state.days, extra); const customRange = () => Boolean(state.dailyFrom || state.dailyTo);
const ganttQuery = (extra) => scopeQuery(state.ganttDays, extra);
function dailyQuery(extra) {
const q = new URLSearchParams(extra || {});
if (state.project) q.set('project', state.project);
if (customRange()) {
if (state.dailyFrom) q.set('from', state.dailyFrom);
if (state.dailyTo) q.set('to', state.dailyTo);
} else {
q.set('days', String(state.days));
}
// 除外は横断モードだけ (単一プロジェクト表示では自分自身を消してしまうので送らない)
if (!state.project && state.dailyExclude.length) q.set('exclude', state.dailyExclude.join(','));
return q.toString();
}
function updateCsv() { function updateCsv() {
const link = $('#csv-turns'); const link = $('#csv-turns');
if (state.view === 'daily') { if (state.view === 'daily') {
link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
link.title = '日別集計を CSV 出力'; link.title = '日別集計を CSV 出力';
} else if (state.view === 'gantt') { } else if (state.view === 'gantt' && state.ganttUnit === 'task') {
link.href = '/api/timeline?' + ganttQuery({ format: 'csv' }); link.href = '/api/timeline?' + ganttQuery({ format: 'csv' });
link.title = 'ガントのタスク一覧を CSV 出力'; link.title = 'ガントのタスク一覧を CSV 出力';
} else if (state.session) { } else if (state.session) {
@@ -457,9 +481,16 @@ function renderDaily() {
svg.push(`<rect class="hit" x="${(padL + i * slot).toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"><title>${esc(tip(r))}</title></rect>`); svg.push(`<rect class="hit" x="${(padL + i * slot).toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"><title>${esc(tip(r))}</title></rect>`);
}); });
const custom = customRange();
const ranges = [14, 30, 90, 0] const ranges = [14, 30, 90, 0]
.map((v) => `<button class="rng${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`) .map((v) => `<button class="rng${!custom && v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join(''); .join('');
const dates = `<span class="dates">
<input type="date" id="d-from" value="${state.dailyFrom}" max="${state.dailyTo}" title="開始日">
<span class="tilde">〜</span>
<input type="date" id="d-to" value="${state.dailyTo}" min="${state.dailyFrom}" title="終了日">
${custom ? '<button class="rng sel" id="d-clear" title="期間指定を解除">✕</button>' : ''}
</span>`;
const active = rows.filter((r) => r.activeMs || r.turns).reverse(); const active = rows.filter((r) => r.activeMs || r.turns).reverse();
const table = active.length const table = active.length
@@ -498,17 +529,58 @@ function renderDaily() {
</div> </div>
<div class="chart-bar"> <div class="chart-bar">
<span class="rngs">${ranges}</span> <span class="rngs">${ranges}</span>
${dates}
<span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span> <span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span>
</div> </div>
<div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></div> <div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></div>
${cross ${cross
? `<div class="legend">${legend ? `<div class="legend">${legend
.map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`) .map((p, i) => `<span class="lg act" data-ex="${esc(p.id)}" title="${esc(p.path)}\nクリックで集計から外す"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`)
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>` .join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>`
: ''} : ''}
${cross && state.dailyExclude.length
? `<div class="legend excl"><span class="cap">除外中</span>${state.dailyExclude
.map((id) => `<span class="lg off" data-ex="${esc(id)}" title="${esc(projectPathOf(id))}\nクリックで戻す">✕ ${esc(shortPath(projectPathOf(id)))}</span>`)
.join('')}<button class="rng" id="ex-clear">すべて戻す</button></div>`
: ''}
${table}`; ${table}`;
wireRanges('days', loadDaily); wireRanges('days', loadDaily);
wireDaily();
}
/** 日別集計の期間指定・プロジェクト除外を配線する。 */
function wireDaily() {
const setRange = (from, to) => {
state.dailyFrom = from;
state.dailyTo = to;
updateCsv();
loadDaily();
};
const from = $('#d-from');
const to = $('#d-to');
if (from) from.onchange = () => setRange(from.value, state.dailyTo);
if (to) to.onchange = () => setRange(state.dailyFrom, to.value);
const clear = $('#d-clear');
if (clear) clear.onclick = () => setRange('', '');
document.querySelectorAll('#detail-body .legend [data-ex]').forEach((el) => {
el.onclick = () => {
const id = el.dataset.ex;
const at = state.dailyExclude.indexOf(id);
if (at >= 0) state.dailyExclude.splice(at, 1);
else state.dailyExclude.push(id);
updateCsv();
loadDaily();
};
});
const exClear = $('#ex-clear');
if (exClear)
exClear.onclick = () => {
state.dailyExclude = [];
updateCsv();
loadDaily();
};
} }
// ---------------------------------------------------------------- ガント // ---------------------------------------------------------------- ガント
@@ -519,22 +591,58 @@ async function loadGantt({ silent = false } = {}) {
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>'; $('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
} }
try { try {
if (state.ganttUnit === 'turn') {
if (state.session) state.detail = await fetchDetail();
renderGanttTurns();
} else {
state.gantt = await api('/api/timeline?' + ganttQuery()); state.gantt = await api('/api/timeline?' + ganttQuery());
renderGantt(); renderGanttTasks();
}
} catch (e) { } catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`; $('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
} }
} }
function renderGantt() {
if (state.ganttUnit === 'turn') renderGanttTurns();
else renderGanttTasks();
}
const MIN = 60000;
const HOUR = 3600000; const HOUR = 3600000;
const DAY = 86400000; const DAY = 86400000;
const TICK_STEPS = [HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 28 * DAY]; const TICK_STEPS = [
1000, 2000, 5000, 10000, 15000, 30000, MIN, 2 * MIN, 5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN,
HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 28 * DAY,
];
function tickStep(span, plotW) { function tickStep(span, plotW) {
const maxTicks = Math.max(3, Math.floor(plotW / 66)); const maxTicks = Math.max(3, Math.floor(plotW / 66));
return TICK_STEPS.find((s) => span / s <= maxTicks) || TICK_STEPS[TICK_STEPS.length - 1]; return TICK_STEPS.find((s) => span / s <= maxTicks) || TICK_STEPS[TICK_STEPS.length - 1];
} }
/** 目盛りの開始位置。step の単位で切り捨てる (ローカル時刻基準)。 */
function floorTo(t, step) {
const d = new Date(t);
d.setMilliseconds(0);
if (step < MIN) {
d.setSeconds(d.getSeconds() - (d.getSeconds() % (step / 1000)));
return d.getTime();
}
d.setSeconds(0);
if (step < HOUR) {
d.setMinutes(d.getMinutes() - (d.getMinutes() % (step / MIN)));
return d.getTime();
}
d.setMinutes(0);
if (step < DAY) {
d.setHours(d.getHours() - (d.getHours() % (step / HOUR)));
return d.getTime();
}
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function midnightsIn(t0, t1) { function midnightsIn(t0, t1) {
const out = []; const out = [];
const d = new Date(t0); const d = new Date(t0);
@@ -543,7 +651,108 @@ function midnightsIn(t0, t1) {
return out; return out;
} }
function renderGantt() { /** 時間軸のスケール。tasks / turns で共通。 */
function timeScale(t0, t1, plotW) {
const span = Math.max(1, t1 - t0);
const step = tickStep(span, plotW);
return {
t0,
t1,
plotW,
step,
subDay: step < DAY,
subHour: step < HOUR,
xOf: (t) => ((Math.min(Math.max(t, t0), t1) - t0) / span) * plotW,
};
}
const p2 = (n) => String(n).padStart(2, '0');
/** 上部に固定表示する時刻軸 (高さ 26px の svg 中身)。 */
function axisSvg(sc) {
const out = [];
for (let t = floorTo(sc.t0, sc.step); t <= sc.t1; t += sc.step) {
if (t < sc.t0) continue;
const d = new Date(t);
const x = sc.xOf(t);
const midnight = d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0;
const label = !sc.subDay || midnight
? `${p2(d.getMonth() + 1)}/${p2(d.getDate())}`
: sc.subHour
? `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
: `${p2(d.getHours())}:${p2(d.getMinutes())}`;
out.push(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
out.push(
`<text class="ax${midnight || !sc.subDay ? ' hi' : ''}" x="${Math.min(sc.plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > sc.plotW - 20 ? 'end' : 'middle'}">${label}</text>`
);
}
return out;
}
/** 背景 (土日の帯・日境界・細かい目盛り線)。バーより先に描く。 */
function gridSvg(sc, H) {
const out = [];
midnightsIn(sc.t0, sc.t1).forEach((t) => {
const d = new Date(t);
const x = sc.xOf(t);
const xEnd = sc.xOf(t + DAY);
if (d.getDay() === 0 || d.getDay() === 6) {
out.push(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
}
if (t > sc.t0) out.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
});
if (sc.subDay) {
for (let t = floorTo(sc.t0, sc.step); t <= sc.t1; t += sc.step) {
if (t < sc.t0) continue;
const d = new Date(t);
if (d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0) continue;
out.push(`<line class="grid" x1="${sc.xOf(t).toFixed(1)}" y1="0" x2="${sc.xOf(t).toFixed(1)}" y2="${H}"/>`);
}
}
return out;
}
function nowLine(sc, H, nowMs) {
if (!(nowMs > sc.t0 && nowMs <= sc.t1)) return [];
const x = sc.xOf(nowMs).toFixed(1);
return [`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`];
}
/** ラベル列 + プロットの枠組み。tasks / turns で共通。 */
function ganttFrame({ labW, plotW, H, labelHead, labels, axis, svg }) {
return `
<div class="gantt" style="--labw:${labW}px">
<div class="g-head">
<div class="g-lab-h">${labelHead}</div>
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis}</svg>
</div>
<div class="g-body">
<div class="g-labels">${labels}</div>
<svg class="g-plot" width="${plotW}" height="${H}" viewBox="0 0 ${plotW} ${H}">${svg}</svg>
</div>
</div>`;
}
const ROW_H = 22;
/** 表示単位の切り替えボタン (タスク / ターン)。 */
function unitButtons() {
return `<span class="rngs">${[['task', 'タスク'], ['turn', 'ターン']]
.map(([v, label]) => `<button class="rng unit${v === state.ganttUnit ? ' sel' : ''}" data-unit="${v}">${label}</button>`)
.join('')}</span>`;
}
function wireUnits() {
document.querySelectorAll('#detail-body .unit').forEach((btn) => {
btn.onclick = () => {
state.ganttUnit = btn.dataset.unit;
updateCsv();
loadGantt();
};
});
}
function renderGanttTasks() {
const g = state.gantt; const g = state.gantt;
if (!g) return; if (!g) return;
const cross = !state.project; const cross = !state.project;
@@ -558,7 +767,6 @@ function renderGantt() {
const ranges = [1, 3, 7, 14, 30, 0] const ranges = [1, 3, 7, 14, 30, 0]
.map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`) .map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join(''); .join('');
const p2 = (n) => String(n).padStart(2, '0');
const totalActive = rows.reduce((a, s) => a + s.activeMs, 0); const totalActive = rows.reduce((a, s) => a + s.activeMs, 0);
const longest = rows.reduce((a, s) => (a && a.wallMs >= s.wallMs ? a : s), null); const longest = rows.reduce((a, s) => (a && a.wallMs >= s.wallMs ? a : s), null);
@@ -570,6 +778,7 @@ function renderGantt() {
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div> <div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
</div> </div>
<div class="chart-bar"> <div class="chart-bar">
${unitButtons()}
<span class="rngs">${ranges}</span> <span class="rngs">${ranges}</span>
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span> <span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
</div>`; </div>`;
@@ -577,59 +786,23 @@ function renderGantt() {
if (!rows.length) { if (!rows.length) {
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>'; $('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
wireRanges('ganttDays', loadGantt); wireRanges('ganttDays', loadGantt);
wireUnits();
return; return;
} }
const ROW_H = 22;
const boxW = $('#detail-body').clientWidth || 700; const boxW = $('#detail-body').clientWidth || 700;
// ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない) // ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない)
const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34))); const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34)));
const plotW = Math.max(180, boxW - labW - 28); const plotW = Math.max(180, boxW - labW - 28);
const t0 = g.from * 1000; const t0 = g.from * 1000;
const tEnd = Math.max(g.to * 1000, t0 + 60000); const tEnd = Math.max(g.to * 1000, t0 + 60000);
const t1 = tEnd + (tEnd - t0) * 0.01; // 右端に少し余白 (現在線と最後のバーを見やすく) const sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW); // 右端に少し余白
const xOf = (t) => ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * plotW; const xOf = sc.xOf;
const step = tickStep(t1 - t0, plotW);
const subDay = step < DAY;
// 軸 (上部に固定表示)
const axis = [];
const first = new Date(t0);
first.setSeconds(0, 0);
first.setMinutes(0);
if (!subDay) first.setHours(0, 0, 0, 0);
for (let t = first.getTime(); t <= t1; t += step) {
if (t < t0) continue;
const d = new Date(t);
const x = xOf(t);
const midnight = d.getHours() === 0 && d.getMinutes() === 0;
const label = !subDay || midnight ? `${p2(d.getMonth() + 1)}/${p2(d.getDate())}` : `${p2(d.getHours())}:${p2(d.getMinutes())}`;
axis.push(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
axis.push(
`<text class="ax${midnight || !subDay ? ' hi' : ''}" x="${Math.min(plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > plotW - 20 ? 'end' : 'middle'}">${label}</text>`
);
}
// 背景 (土日と日境界) → バー → 当たり判定
const H = rows.length * ROW_H; const H = rows.length * ROW_H;
const svg = []; const axis = axisSvg(sc);
midnightsIn(t0, t1).forEach((t) => { // 背景 (土日と日境界) → バー → 当たり判定
const d = new Date(t); const svg = gridSvg(sc, H);
const x = xOf(t);
const xEnd = xOf(t + DAY);
if (d.getDay() === 0 || d.getDay() === 6) {
svg.push(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
}
if (t > t0) svg.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
});
if (subDay) {
for (let t = first.getTime(); t <= t1; t += step) {
if (t < t0) continue;
const d = new Date(t);
if (d.getHours() === 0 && d.getMinutes() === 0) continue;
svg.push(`<line class="grid" x1="${xOf(t).toFixed(1)}" y1="0" x2="${xOf(t).toFixed(1)}" y2="${H}"/>`);
}
}
rows.forEach((s, i) => { rows.forEach((s, i) => {
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`); if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
}); });
@@ -646,10 +819,7 @@ function renderGantt() {
svg.push(`<rect x="${sx.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="10" rx="1" fill="${color}"/>`); svg.push(`<rect x="${sx.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="10" rx="1" fill="${color}"/>`);
}); });
}); });
if (g.now * 1000 > t0 && g.now * 1000 <= t1) { svg.push(...nowLine(sc, H, g.now * 1000));
const x = xOf(g.now * 1000).toFixed(1);
svg.push(`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`);
}
rows.forEach((s, i) => { rows.forEach((s, i) => {
const tip = [ const tip = [
s.title, s.title,
@@ -675,16 +845,15 @@ function renderGantt() {
.join(''); .join('');
$('#detail-body').innerHTML = `${head} $('#detail-body').innerHTML = `${head}
<div class="gantt" style="--labw:${labW}px"> ${ganttFrame({
<div class="g-head"> labW,
<div class="g-lab-h">タスク ${rows.length} 件(開始順)</div> plotW,
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis.join('')}</svg> H,
</div> labelHead: `タスク ${rows.length} 件(開始順)`,
<div class="g-body"> labels,
<div class="g-labels">${labels}</div> axis: axis.join(''),
<svg class="g-plot" width="${plotW}" height="${H}" viewBox="0 0 ${plotW} ${H}">${svg.join('')}</svg> svg: svg.join(''),
</div> })}
</div>
${cross ${cross
? `<div class="legend">${legend ? `<div class="legend">${legend
.map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${p.sessions} タスク</b></span>`) .map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${p.sessions} タスク</b></span>`)
@@ -692,15 +861,130 @@ function renderGantt() {
: ''}`; : ''}`;
wireRanges('ganttDays', loadGantt); wireRanges('ganttDays', loadGantt);
wireUnits();
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => { document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => selectSession(el.dataset.project, el.dataset.id); el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
}); });
} }
// ---------------------------------------------------------------- ターン単位のガント
/** 選択中タスクのターンを、絶対時刻の共通軸に並べる。 */
function renderGanttTurns() {
const d = state.detail;
const head = (extra) => `
<div class="sum flat">
<div><span class="k">タスク</span><span class="v" title="${d ? esc(d.title) : ''}">${d ? esc(clip(d.title, 46)) : '–'}</span></div>
<div><span class="k">開始</span><span class="v">${d ? fmtDateTime(d.start) : '–'}</span></div>
<div><span class="k">全体</span><span class="v">${d ? fmtDur(d.wallMs) : '–'}</span></div>
<div><span class="k">作業時間合計</span><span class="v">${d ? fmtDur(d.activeMs) : '–'}</span></div>
<div><span class="k">往復</span><span class="v">${d ? d.turnCount : '–'}</span></div>
${extra}
</div>
<div class="chart-bar">
${unitButtons()}
<span class="note">1行=1ターン。青=初回応答待ち、緑=応答中、行間の空白=放置していた時間。行をクリックするとやりとりに移動します。</span>
</div>`;
$('#detail-title').textContent = 'ガント(ターン)' + (d ? ' — ' + d.title : '');
const turns = d ? d.turns.filter((t) => t.ts !== null && t.ts !== undefined) : [];
if (!turns.length) {
$('#detail-body').innerHTML =
head('') + `<p class="empty">${d ? 'やりとりが記録されていません。' : '中央のタスク一覧から 1 つ選んでください。'}</p>`;
wireUnits();
return;
}
const boxW = $('#detail-body').clientWidth || 700;
const labW = Math.max(130, Math.min(240, Math.round(boxW * 0.34)));
const plotW = Math.max(180, boxW - labW - 28);
const t0 = Math.min(...turns.map((t) => t.ts)) * 1000;
// 一瞬で終わったタスクでも目盛りが出るよう、最小 10 秒ぶんの幅を確保する
const tEnd = Math.max(t0 + 10000, ...turns.map((t) => (t.endTs ?? t.ts) * 1000));
const sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW);
const xOf = sc.xOf;
const H = turns.length * ROW_H;
const axis = axisSvg(sc);
const svg = gridSvg(sc, H);
turns.forEach((t, i) => {
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
});
const seg = (cls, a, b, y) => {
const x = xOf(a);
const w = Math.max(1, xOf(b) - x);
return `<rect class="${cls}" x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="10" rx="1"/>`;
};
turns.forEach((t, i) => {
const y = i * ROW_H + 6;
const a = t.ts * 1000;
const end = (t.endTs ?? t.ts) * 1000;
const reply = t.firstReplyTs ? t.firstReplyTs * 1000 : null;
if (!reply) {
svg.push(seg('t-none', a, end, y)); // 応答なし (中断など)
} else {
svg.push(seg('t-wait', a, reply, y));
if (end > reply) svg.push(seg('t-work', reply, end, y));
}
});
svg.push(...nowLine(sc, H, Date.now()));
turns.forEach((t, i) => {
const tip = [
`#${t.i}${t.kind !== 'prompt' ? ` [${t.kind}]` : ''}${t.interrupted ? ' ⚠中断' : ''} ${t.summary}`,
`発言 ${fmtDateTime(t.tsIso)} → 完了 ${t.endIso ? fmtTime(t.endIso) : '–'}`,
`初回応答 ${fmtDur(t.waitMs)} · 応答時間 ${fmtDur(t.durMs)} · 空き ${t.idleMs !== null && t.idleMs < 0 ? '並行' : fmtDur(t.idleMs)}`,
`${t.assistant} msg · ${t.toolCalls} tool`,
].join('\n');
svg.push(
`<rect class="g-hit" data-i="${t.i}" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"><title>${esc(tip)}</title></rect>`
);
});
const labels = turns
.map(
(t) => `<div class="g-lab-row" data-i="${t.i}" title="${esc(t.summary)}">
<span class="g-no">#${t.i}</span>
<span class="g-when">${fmtTime(t.tsIso)}</span>
<span class="g-title">${t.interrupted ? '⚠ ' : ''}${esc(t.summary)}</span>
</div>`
)
.join('');
const overlaps = turns.filter((t) => t.idleMs !== null && t.idleMs < 0).length;
$('#detail-body').innerHTML =
head(overlaps ? `<div><span class="k">並行送信</span><span class="v">${overlaps}</span></div>` : '') +
ganttFrame({
labW,
plotW,
H,
labelHead: `ターン ${turns.length} 件`,
labels,
axis: axis.join(''),
svg: svg.join(''),
});
wireUnits();
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => jumpToTurn(Number(el.dataset.i));
});
}
/** ターンのガントから「やりとり」タブの該当行へ移動する。 */
function jumpToTurn(i) {
setView('turns');
const tr = document.querySelector(`#turns tr[data-i="${i}"]`);
if (!tr) return;
tr.scrollIntoView({ block: 'center' });
tr.classList.add('hi');
setTimeout(() => tr.classList.remove('hi'), 1800);
}
function wireRanges(key, reload) { function wireRanges(key, reload) {
document.querySelectorAll('#detail-body .rng').forEach((btn) => { document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => {
btn.onclick = () => { btn.onclick = () => {
state[key] = Number(btn.dataset.days); state[key] = Number(btn.dataset.days);
if (key === 'days') state.dailyFrom = state.dailyTo = ''; // 日数指定と期間指定は排他
updateCsv(); updateCsv();
reload(); reload();
}; };
@@ -710,7 +994,6 @@ function wireRanges(key, reload) {
// ---------------------------------------------------------------- 自動追従 // ---------------------------------------------------------------- 自動追従
const FOLLOW_KEY = 'chktime.follow'; const FOLLOW_KEY = 'chktime.follow';
const pad2 = (n) => String(n).padStart(2, '0');
function followStatus(kind, text) { function followStatus(kind, text) {
const el = $('#follow-state'); const el = $('#follow-state');
@@ -720,7 +1003,7 @@ function followStatus(kind, text) {
function nowHms() { function nowHms() {
const d = new Date(); const d = new Date();
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
} }
/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */ /** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */
@@ -819,7 +1102,7 @@ window.addEventListener('resize', () => {
clearTimeout(resizeTimer); clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { resizeTimer = setTimeout(() => {
if (state.view === 'daily' && state.daily) renderDaily(); if (state.view === 'daily' && state.daily) renderDaily();
else if (state.view === 'gantt' && state.gantt) renderGantt(); else if (state.view === 'gantt') renderGantt();
}, 150); }, 150);
}); });
+28
View File
@@ -200,6 +200,21 @@ table { width: 100%; border-collapse: collapse; }
.rng.sel { color: var(--accent); border-color: var(--accent); } .rng.sel { color: var(--accent); border-color: var(--accent); }
.chart-bar .note { color: var(--dim); font-size: 10.5px; } .chart-bar .note { color: var(--dim); font-size: 10.5px; }
/* 期間の任意指定 */
.dates { display: flex; align-items: center; gap: 4px; }
.dates input[type="date"] {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 5px;
color: var(--fg);
padding: 1px 6px;
font: inherit;
font-size: 11px;
color-scheme: dark;
}
.dates input[type="date"]:hover { border-color: var(--accent); }
.dates .tilde { color: var(--dim); font-size: 11px; }
.chart { padding: 4px 14px 0; overflow-x: auto; } .chart { padding: 4px 14px 0; overflow-x: auto; }
.chart svg text.ax { fill: var(--dim); font-size: 10px; } .chart svg text.ax { fill: var(--dim); font-size: 10px; }
.chart svg text.ax.we { fill: var(--warn); } .chart svg text.ax.we { fill: var(--warn); }
@@ -213,6 +228,12 @@ table { width: 100%; border-collapse: collapse; }
.legend .lg { display: flex; align-items: center; gap: 5px; white-space: nowrap; } .legend .lg { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.legend .lg i, .pj-i i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex: none; } .legend .lg i, .pj-i i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex: none; }
.legend .lg b { color: var(--fg); font-weight: 500; } .legend .lg b { color: var(--fg); font-weight: 500; }
.legend .lg.act { cursor: pointer; }
.legend .lg.act:hover { color: var(--fg); }
.legend.excl { padding-top: 2px; }
.legend.excl .cap { font-size: 10.5px; }
.legend .lg.off { cursor: pointer; opacity: .6; text-decoration: line-through; }
.legend .lg.off:hover { opacity: 1; color: var(--accent); }
#daily-table { margin-top: 8px; } #daily-table { margin-top: 8px; }
#daily-table th { #daily-table th {
@@ -275,3 +296,10 @@ table { width: 100%; border-collapse: collapse; }
.gantt svg rect.stripe { fill: #ffffff; opacity: .02; } .gantt svg rect.stripe { fill: #ffffff; opacity: .02; }
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; } .gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; } .gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }
/* ガント (ターン単位) */
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
.gantt svg rect.t-wait { fill: var(--user); }
.gantt svg rect.t-work { fill: var(--work); }
.gantt svg rect.t-none { fill: var(--idle); }
#turns tr.hi td { background: rgba(122, 162, 247, .18); }