diff --git a/README.md b/README.md index 5469f40..e23db56 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ python chktime.py - **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。先頭の **▦ すべてのプロジェクト** を選ぶと横断モードになります。 - **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。横断モードでは全プロジェクトのタスクを開始日時の新しい順に並べ、各行にプロジェクトを表示します(検索はパスにも効きます)。 -- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計** タブで表示を切り替えます。 +- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計 / ガント** タブで表示を切り替えます。 ### 右ペインの列 @@ -62,7 +62,17 @@ python chktime.py - 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。 - 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。 -`CSV` ボタンでタスク一覧・ターン一覧・日別集計をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 +### ガントタブ + +タスク(セッション)を 1 行ずつ、時間軸に並べたガントチャートです。 + +- **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。 +- 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。 +- 横断モードでは行の色がプロジェクトを表します(凡例は作業時間上位 7 件)。 +- 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。 +- 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。 + +`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 ## 解析の仕様(ざっくり) diff --git a/chktime.py b/chktime.py index a0b6bcc..14e72ad 100644 --- a/chktime.py +++ b/chktime.py @@ -496,6 +496,91 @@ class Store: }, } + def timeline(self, project_id=None, days=7): + """ガント表示用。セッションを1本のバーとして、内側に応答中の区間を返す。 + + spans は各ターンの [発言時刻, 応答完了] を、隙間 1 秒以内なら繋げたもの。 + """ + paths = {p["id"]: p["path"] for p in self.projects()} + if project_id: + if project_id not in paths: + raise FileNotFoundError(project_id) + targets = [project_id] + else: + targets = list(paths) + + now = datetime.now().timestamp() + t0 = None + if days and days > 0: + midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + t0 = (midnight - timedelta(days=days - 1)).timestamp() + + rows = [] + per_project = {} + for pid in targets: + for path in self.session_files(pid): + session = self.session(path) + start, end = session["startEpoch"], session["endEpoch"] + if start is None: + continue + if t0 is not None and (end if end is not None else start) < t0: + continue + spans = [] + active = 0 + for turn in session["turns"]: + a = turn["ts"] + if a is None: + continue + b = turn["endTs"] if turn["endTs"] is not None else a + if t0 is not None and b < t0: + continue + active += turn["durMs"] or 0 + if spans and a - spans[-1][1] <= 1: + spans[-1][1] = max(spans[-1][1], b) + else: + spans.append([a, b]) + rows.append( + { + "id": session["id"], + "project": pid, + "projectPath": session.get("cwd") or paths.get(pid, pid), + "title": session["title"], + "start": session["start"], + "end": session["end"], + "startEpoch": round(start, 1), + "endEpoch": round(end if end is not None else start, 1), + "wallMs": session["wallMs"], + "activeMs": active, + "turnCount": session["turnCount"], + "toolCalls": session["toolCalls"], + "spans": [[round(a, 1), round(b, 1)] for a, b in spans], + } + ) + total = per_project.setdefault(pid, {"activeMs": 0, "sessions": 0}) + total["activeMs"] += active + total["sessions"] += 1 + + rows.sort(key=lambda s: s["startEpoch"]) + if t0 is None: + t0 = min((s["startEpoch"] for s in rows), default=now) + t1 = max([now] + [s["endEpoch"] for s in rows]) + projects = sorted( + ( + {"id": pid, "path": paths.get(pid, pid), "activeMs": v["activeMs"], "sessions": v["sessions"]} + for pid, v in per_project.items() + ), + key=lambda p: -p["activeMs"], + ) + return { + "scope": project_id or "", + "rangeDays": days if days and days > 0 else 0, + "from": round(t0, 1), + "to": round(t1, 1), + "now": round(now, 1), + "sessions": rows, + "projects": projects, + } + def clear(self): with self._lock: self._cache.clear() @@ -589,6 +674,18 @@ def turns_csv(session): return buf.getvalue() +def timeline_csv(data): + buf = io.StringIO(newline="") + writer = csv.writer(buf) + writer.writerow(["project", "session_id", "title", "start", "end", "wall", "active", "turns", "tool_calls"]) + for s in data["sessions"]: + writer.writerow( + [s["projectPath"], s["id"], s["title"], s["start"] or "", s["end"] or "", + fmt_dur(s["wallMs"]), fmt_dur(s["activeMs"]), s["turnCount"], s["toolCalls"]] + ) + return buf.getvalue() + + class Server(ThreadingHTTPServer): # Windows では SO_REUSEADDR が「使用中のポートにも bind できる」意味になり、 # 二重起動が検出できなくなるため無効にする。 @@ -637,6 +734,12 @@ class Handler(BaseHTTPRequestHandler): if query.get("format", [""])[0] == "csv": return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv") return self.send_json(data) + if route == "/api/timeline": + project = query.get("project", [""])[0] + data = store.timeline(project or None, parse_days(query.get("days", ["7"])[0])) + if query.get("format", [""])[0] == "csv": + return self.send_text(timeline_csv(data), "text/csv", f"{project or 'all'}-timeline.csv") + return self.send_json(data) if route == "/api/session": project = query.get("project", [""])[0] session_id = query.get("id", [""])[0] diff --git a/web/app.js b/web/app.js index eb12bc0..4a675f6 100644 --- a/web/app.js +++ b/web/app.js @@ -13,6 +13,8 @@ const state = { view: 'turns', days: 30, daily: null, + ganttDays: 7, + gantt: null, }; const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca']; @@ -124,6 +126,7 @@ async function selectProject(id) { const tbody = $('#session-table tbody'); tbody.innerHTML = '
集計中…
'; + try { + state.gantt = await api('/api/timeline?' + ganttQuery()); + renderGantt(); + } catch (e) { + $('#detail-body').innerHTML = `${esc(e.message)}
`; + } +} + +const HOUR = 3600000; +const DAY = 86400000; +const TICK_STEPS = [HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 28 * DAY]; + +function tickStep(span, plotW) { + const maxTicks = Math.max(3, Math.floor(plotW / 66)); + return TICK_STEPS.find((s) => span / s <= maxTicks) || TICK_STEPS[TICK_STEPS.length - 1]; +} + +function midnightsIn(t0, t1) { + const out = []; + const d = new Date(t0); + d.setHours(0, 0, 0, 0); + for (let t = d.getTime(); t <= t1; t += DAY) out.push(t); + return out; +} + +function renderGantt() { + const g = state.gantt; + if (!g) return; + const cross = !state.project; + const rows = g.sessions; + $('#detail-title').textContent = 'ガント — ' + (cross ? '全プロジェクト' : shortPath(projectPathOf(state.project))); + + const legend = g.projects.slice(0, PALETTE.length); + const colorOf = new Map(legend.map((p, i) => [p.id, PALETTE[i]])); + const otherMs = g.projects.slice(PALETTE.length).reduce((a, p) => a + p.activeMs, 0); + const colorFor = (s) => (cross ? colorOf.get(s.project) || OTHER_COLOR : PALETTE[1]); + + const ranges = [1, 3, 7, 14, 30, 0] + .map((v) => ``) + .join(''); + const p2 = (n) => String(n).padStart(2, '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 head = ` +この期間のタスクはありません。
'; + wireRanges('ganttDays', loadGantt); + return; + } + + const ROW_H = 22; + const boxW = $('#detail-body').clientWidth || 700; + // ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない) + const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34))); + const plotW = Math.max(180, boxW - labW - 28); + const t0 = g.from * 1000; + const tEnd = Math.max(g.to * 1000, t0 + 60000); + const t1 = tEnd + (tEnd - t0) * 0.01; // 右端に少し余白 (現在線と最後のバーを見やすく) + const xOf = (t) => ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * plotW; + 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(`