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 = '解析中…'; if (state.view === 'daily') loadDaily(); + else if (state.view === 'gantt') loadGantt(); try { const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all'); state.sessions = data.sessions; @@ -279,19 +282,26 @@ function renderDetail() { function syncTabs() { $('#tab-turns').classList.toggle('sel', state.view === 'turns'); $('#tab-daily').classList.toggle('sel', state.view === 'daily'); + $('#tab-gantt').classList.toggle('sel', state.view === 'gantt'); } -function dailyQuery(extra) { - const q = new URLSearchParams({ days: String(state.days), ...extra }); +function scopeQuery(days, extra) { + const q = new URLSearchParams({ days: String(days), ...extra }); if (state.project) q.set('project', state.project); return q.toString(); } +const dailyQuery = (extra) => scopeQuery(state.days, extra); +const ganttQuery = (extra) => scopeQuery(state.ganttDays, extra); + function updateCsv() { const link = $('#csv-turns'); if (state.view === 'daily') { link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.title = '日別集計を CSV 出力'; + } else if (state.view === 'gantt') { + link.href = '/api/timeline?' + ganttQuery({ format: 'csv' }); + link.title = 'ガントのタスク一覧を CSV 出力'; } else if (state.session) { link.href = `/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}&format=csv`; link.title = 'ターン一覧を CSV 出力'; @@ -305,6 +315,7 @@ function setView(view) { syncTabs(); updateCsv(); if (view === 'daily') loadDaily(); + else if (view === 'gantt') loadGantt(); else renderDetail(); } @@ -472,11 +483,199 @@ function renderDaily() { : ''} ${table}`; + wireRanges('days', loadDaily); +} + +// ---------------------------------------------------------------- ガント + +async function loadGantt() { + $('#detail-title').textContent = 'ガント'; + $('#detail-body').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 = ` +
+
期間${fmtDateTime(new Date(g.from * 1000).toISOString())} → ${fmtDateTime(new Date(g.to * 1000).toISOString())}
+
タスク${rows.length}
+
作業時間合計${fmtDur(totalActive)}
+
最長タスク${longest ? fmtDur(longest.wallMs) : '–'}
+
+
+ ${ranges} + 薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。 +
`; + + if (!rows.length) { + $('#detail-body').innerHTML = 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(``); + axis.push( + `${label}` + ); + } + + // 背景 (土日と日境界) → バー → 当たり判定 + const H = rows.length * ROW_H; + const svg = []; + midnightsIn(t0, t1).forEach((t) => { + const d = new Date(t); + const x = xOf(t); + const xEnd = xOf(t + DAY); + if (d.getDay() === 0 || d.getDay() === 6) { + svg.push(``); + } + if (t > t0) svg.push(``); + }); + 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(``); + } + } + rows.forEach((s, i) => { + if (i % 2) svg.push(``); + }); + rows.forEach((s, i) => { + const color = colorFor(s); + const y = i * ROW_H + 6; + const x = xOf(s.startEpoch * 1000); + const w = Math.max(1.5, xOf(s.endEpoch * 1000) - x); + svg.push(``); + s.spans.forEach(([a, b]) => { + // 最小幅を確保しつつ、全体バーの内側に収める + const sx = Math.min(xOf(a * 1000), x + w - 0.8); + const sw = Math.min(Math.max(0.8, xOf(b * 1000) - sx), x + w - sx); + svg.push(``); + }); + }); + if (g.now * 1000 > t0 && g.now * 1000 <= t1) { + const x = xOf(g.now * 1000).toFixed(1); + svg.push(``); + } + rows.forEach((s, i) => { + const tip = [ + s.title, + cross ? s.projectPath : '', + `${fmtDateTime(s.start)} → ${fmtDateTime(s.end)}`, + `全体 ${fmtDur(s.wallMs)} · 作業 ${fmtDur(s.activeMs)} · ${s.turnCount} 往復`, + ] + .filter(Boolean) + .join('\n'); + svg.push( + `${esc(tip)}` + ); + }); + + const labels = rows + .map( + (s) => `
+ + ${fmtDateTime(s.start).slice(3)} + ${esc(s.title)} +
` + ) + .join(''); + + $('#detail-body').innerHTML = `${head} +
+
+
タスク ${rows.length} 件(開始順)
+ ${axis.join('')} +
+
+
${labels}
+ ${svg.join('')} +
+
+ ${cross + ? `
${legend + .map((p, i) => `${esc(shortPath(p.path))} ${p.sessions} タスク`) + .join('')}${otherMs ? `その他 ${g.projects.length - legend.length} 件` : ''}
` + : ''}`; + + wireRanges('ganttDays', loadGantt); + document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => { + el.onclick = () => selectSession(el.dataset.project, el.dataset.id); + }); +} + +function wireRanges(key, reload) { document.querySelectorAll('#detail-body .rng').forEach((btn) => { btn.onclick = () => { - state.days = Number(btn.dataset.days); + state[key] = Number(btn.dataset.days); updateCsv(); - loadDaily(); + reload(); }; }); } @@ -490,6 +689,7 @@ $('#relative').onchange = (e) => { }; $('#tab-turns').onclick = () => setView('turns'); $('#tab-daily').onclick = () => setView('daily'); +$('#tab-gantt').onclick = () => setView('gantt'); $('#reload').onclick = async () => { await fetch('/api/reload'); const keep = { project: state.sessionProject, session: state.session, view: state.view }; @@ -499,9 +699,12 @@ $('#reload').onclick = async () => { let resizeTimer = null; window.addEventListener('resize', () => { - if (state.view !== 'daily') return; + if (state.view === 'turns') return; clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => state.daily && renderDaily(), 150); + resizeTimer = setTimeout(() => { + if (state.view === 'daily' && state.daily) renderDaily(); + else if (state.view === 'gantt' && state.gantt) renderGantt(); + }, 150); }); loadProjects().catch((e) => { diff --git a/web/index.html b/web/index.html index 71e73dd..70b74c4 100644 --- a/web/index.html +++ b/web/index.html @@ -36,6 +36,7 @@ + CSV diff --git a/web/style.css b/web/style.css index 71d0178..855fc0b 100644 --- a/web/style.css +++ b/web/style.css @@ -213,3 +213,51 @@ table { width: 100%; border-collapse: collapse; } #daily-table .d { color: var(--dim); font-size: 11px; } #daily-table .pj { display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 11px; color: var(--dim); } .pj-i { display: flex; align-items: center; gap: 4px; white-space: nowrap; } + +/* ガント */ +.sum.flat { position: static; } +.gantt { padding: 4px 14px 0; } +.gantt .g-head { + display: flex; + align-items: flex-end; + position: sticky; + top: 0; + z-index: 2; + background: var(--bg); + border-bottom: 1px solid var(--line); +} +.gantt .g-lab-h { + width: var(--labw); + flex: none; + color: var(--dim); + font-size: 10.5px; + padding-bottom: 5px; +} +.gantt .g-body { display: flex; } +.gantt .g-labels { width: var(--labw); flex: none; } +.gantt .g-lab-row { + height: 22px; + display: flex; + align-items: center; + gap: 6px; + padding-right: 8px; + font-size: 11px; + cursor: pointer; + overflow: hidden; +} +.gantt .g-lab-row:nth-child(even) { background: rgba(255, 255, 255, .02); } +.gantt .g-lab-row:hover { background: var(--pane2); } +.gantt .g-lab-row.sel { box-shadow: inset 3px 0 var(--accent); } +.gantt .g-lab-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; } +.gantt .g-when { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; } +.gantt .g-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.gantt svg text.ax { fill: var(--dim); font-size: 10px; } +.gantt svg text.ax.hi { fill: var(--fg); } +.gantt svg line.grid { stroke: var(--line); stroke-width: 1; } +.gantt svg line.day { stroke: var(--line); stroke-width: 1; } +.gantt svg line.now { stroke: var(--warn); stroke-width: 1; stroke-dasharray: 3 3; } +.gantt svg rect.wk { fill: #ffffff; opacity: .028; } +.gantt svg rect.stripe { fill: #ffffff; opacity: .02; } +.gantt svg rect.g-hit { fill: transparent; cursor: pointer; } +.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }