diff --git a/README.md b/README.md index 046de4b..5469f40 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ python chktime.py ## 画面 -- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。 -- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。 -- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。 +- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。先頭の **▦ すべてのプロジェクト** を選ぶと横断モードになります。 +- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。横断モードでは全プロジェクトのタスクを開始日時の新しい順に並べ、各行にプロジェクトを表示します(検索はパスにも効きます)。 +- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計** タブで表示を切り替えます。 ### 右ペインの列 @@ -53,7 +53,17 @@ python chktime.py 上部サマリの **作業時間合計** は各ターンの応答時間の合計(=実際に Claude が動いていた時間)、 **全体** は最初から最後までの経過時間です。放置時間が長いと両者は大きく離れます。 -`CSV` ボタンでタスク一覧・ターン一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 +### 日別集計タブ + +ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。 + +- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。 +- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。 +- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。 +- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。 + +`CSV` ボタンでタスク一覧・ターン一覧・日別集計をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 +横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 ## 解析の仕様(ざっくり) diff --git a/chktime.py b/chktime.py index b404e69..a0b6bcc 100644 --- a/chktime.py +++ b/chktime.py @@ -22,7 +22,7 @@ import os import re import threading import webbrowser -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -389,6 +389,113 @@ class Store: return self.session(path) raise FileNotFoundError(session_id) + def all_sessions(self): + """全プロジェクトのタスクを1つのリストにまとめる (開始が新しい順)。""" + out = [] + for project in self.projects(): + for path in self.session_files(project["id"]): + summary = summary_of(self.session(path)) + summary["project"] = project["id"] + summary["projectPath"] = summary.get("cwd") or project["path"] + out.append(summary) + out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True) + return out + + def daily_stats(self, project_id=None, days=30): + """ターンをローカル日付で集計する。project_id=None なら全プロジェクト。 + + days>0 なら「今日から遡って days 日」、それ以外は全期間。 + 活動のない日も 0 の行として埋めるので、そのまま横軸に使える。 + """ + 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) + + today = datetime.now().date() + cutoff = today - timedelta(days=days - 1) if days and days > 0 else None + + per_day = {} + per_project = {} + seen = set() + for pid in targets: + for path in self.session_files(pid): + session = self.session(path) + for turn in session["turns"]: + if turn["ts"] is None: + continue + day = datetime.fromtimestamp(turn["ts"]).date() + if cutoff and day < cutoff: + continue + dur = turn["durMs"] or 0 + counted = 1 if turn["kind"] in ("prompt", "command") else 0 + bucket = per_day.setdefault(day, {"activeMs": 0, "turns": 0, "sessions": set(), "byProject": {}}) + bucket["activeMs"] += dur + bucket["turns"] += counted + bucket["sessions"].add(session["id"]) + bucket["byProject"][pid] = bucket["byProject"].get(pid, 0) + dur + total = per_project.setdefault(pid, {"activeMs": 0, "turns": 0, "sessions": set()}) + total["activeMs"] += dur + total["turns"] += counted + total["sessions"].add(session["id"]) + seen.add(session["id"]) + + if per_day: + start = cutoff or min(per_day) + end = max(max(per_day), today) + elif cutoff: + start, end = cutoff, today # データなしでも指定範囲は 0 で埋める + else: + start = end = None + + rows = [] + cur = start + while cur and cur <= end: + bucket = per_day.get(cur) + rows.append( + { + "date": cur.isoformat(), + "dow": cur.weekday(), + "activeMs": bucket["activeMs"] if bucket else 0, + "turns": bucket["turns"] if bucket else 0, + "sessions": len(bucket["sessions"]) if bucket else 0, + "byProject": bucket["byProject"] if bucket else {}, + } + ) + cur += timedelta(days=1) + + projects = sorted( + ( + { + "id": pid, + "path": paths.get(pid, pid), + "activeMs": v["activeMs"], + "turns": v["turns"], + "sessions": len(v["sessions"]), + } + for pid, v in per_project.items() + ), + key=lambda p: -p["activeMs"], + ) + busiest = max(rows, key=lambda r: r["activeMs"], default=None) + return { + "scope": project_id or "", + "rangeDays": days if days and days > 0 else 0, + "days": rows, + "projects": projects, + "totals": { + "activeMs": sum(r["activeMs"] for r in rows), + "turns": sum(r["turns"] for r in rows), + "sessions": len(seen), + "activeDays": sum(1 for r in rows if r["activeMs"] or r["turns"]), + "busiestDate": busiest["date"] if busiest and busiest["activeMs"] else None, + "busiestMs": busiest["activeMs"] if busiest else 0, + }, + } + def clear(self): with self._lock: self._cache.clear() @@ -433,14 +540,39 @@ def fmt_dur(msec): return f"{hours}h{minutes:02d}m" -def sessions_csv(sessions): +def parse_days(value): + """?days= の解釈。'all' / 0 / 不正値は全期間 (0) とみなす。""" + try: + days = int(value) + except (TypeError, ValueError): + return 0 + return max(0, min(days, 3650)) + + +def sessions_csv(sessions, with_project=False): buf = io.StringIO(newline="") writer = csv.writer(buf) - writer.writerow(["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"]) + head = ["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"] + writer.writerow((["project"] if with_project else []) + head) for s in sessions: writer.writerow( - [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), - fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""] + ([s.get("projectPath") or s.get("project") or ""] if with_project else []) + + [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), + fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""] + ) + return buf.getvalue() + + +def daily_csv(data): + buf = io.StringIO(newline="") + writer = csv.writer(buf) + projects = data["projects"] + writer.writerow(["date", "dow", "active", "active_ms", "turns", "sessions"] + [p["path"] for p in projects]) + labels = "月火水木金土日" + for row in data["days"]: + writer.writerow( + [row["date"], labels[row["dow"]], fmt_dur(row["activeMs"]), row["activeMs"], row["turns"], row["sessions"]] + + [fmt_dur(row["byProject"].get(p["id"])) for p in projects] ) return buf.getvalue() @@ -494,6 +626,17 @@ class Handler(BaseHTTPRequestHandler): if query.get("format", [""])[0] == "csv": return self.send_text(sessions_csv(sessions), "text/csv", f"{project}-sessions.csv") return self.send_json({"project": project, "sessions": sessions}) + if route == "/api/all": + sessions = store.all_sessions() + if query.get("format", [""])[0] == "csv": + return self.send_text(sessions_csv(sessions, True), "text/csv", "all-sessions.csv") + return self.send_json({"project": "", "sessions": sessions}) + if route == "/api/daily": + project = query.get("project", [""])[0] + data = store.daily_stats(project or None, parse_days(query.get("days", ["30"])[0])) + 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/session": project = query.get("project", [""])[0] session_id = query.get("id", [""])[0] diff --git a/web/app.js b/web/app.js index db02161..eb12bc0 100644 --- a/web/app.js +++ b/web/app.js @@ -1,7 +1,23 @@ 'use strict'; const $ = (sel) => document.querySelector(sel); -const state = { project: null, session: null, sessions: [], detail: null, relative: false }; +// project: null = プロジェクト横断モード, undefined = 未選択 / view: turns=やりとり, daily=日別集計 +const state = { + project: undefined, + projects: [], + sessionProject: null, + session: null, + sessions: [], + detail: null, + relative: false, + view: 'turns', + days: 30, + daily: null, +}; + +const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca']; +const OTHER_COLOR = '#565f73'; +const DOW = ['月', '火', '水', '木', '金', '土', '日']; // ---------------------------------------------------------------- 表示ヘルパ @@ -71,9 +87,21 @@ async function api(path) { async function loadProjects() { const data = await api('/api/projects'); + state.projects = data.projects; $('#root').textContent = data.root; const ul = $('#project-list'); ul.innerHTML = ''; + + const all = document.createElement('li'); + all.dataset.id = ''; + all.className = 'all'; + const totalSessions = data.projects.reduce((a, p) => a + p.sessions, 0); + const totalBytes = data.projects.reduce((a, p) => a + p.bytes, 0); + all.innerHTML = `
読み込み中…
'; try { - state.detail = await api(`/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}`); + state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`); renderDetail(); } catch (e) { $('#detail-body').innerHTML = `${esc(e.message)}
`; @@ -149,7 +193,11 @@ async function selectSession(id) { function renderDetail() { const d = state.detail; - if (!d) return; + if (!d) { + $('#detail-title').textContent = 'やりとりの時刻'; + $('#detail-body').innerHTML = '左のタスクを選んでください。
'; + return; + } $('#detail-title').textContent = d.title; const maxSpan = Math.max(1, ...d.turns.map((t) => (t.idleMs || 0) + (t.durMs || 0))); @@ -226,20 +274,236 @@ function renderDetail() { }); } +// ---------------------------------------------------------------- 日別集計 + +function syncTabs() { + $('#tab-turns').classList.toggle('sel', state.view === 'turns'); + $('#tab-daily').classList.toggle('sel', state.view === 'daily'); +} + +function dailyQuery(extra) { + const q = new URLSearchParams({ days: String(state.days), ...extra }); + if (state.project) q.set('project', state.project); + return q.toString(); +} + +function updateCsv() { + const link = $('#csv-turns'); + if (state.view === 'daily') { + link.href = '/api/daily?' + dailyQuery({ 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 出力'; + } else { + link.removeAttribute('href'); + } +} + +function setView(view) { + state.view = view; + syncTabs(); + updateCsv(); + if (view === 'daily') loadDaily(); + else renderDetail(); +} + +async function loadDaily() { + $('#detail-title').textContent = '日別集計'; + $('#detail-body').innerHTML = '集計中…
'; + try { + state.daily = await api('/api/daily?' + dailyQuery()); + renderDaily(); + } catch (e) { + $('#detail-body').innerHTML = `${esc(e.message)}
`; + } +} + +const STEPS = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880].map((m) => m * 60000); + +function niceStep(maxMs) { + return STEPS.find((s) => maxMs / s <= 5) || STEPS[STEPS.length - 1]; +} + +function fmtAxis(ms) { + if (!ms) return '0'; + const m = Math.round(ms / 60000); + if (m < 60) return `${m}分`; + const h = Math.floor(m / 60); + return h + '時間' + (m % 60 ? `${m % 60}分` : ''); +} + +function projectPathOf(id) { + const hit = state.projects.find((p) => p.id === id) || (state.daily && state.daily.projects.find((p) => p.id === id)); + return hit ? hit.path : id; +} + +function renderDaily() { + const d = state.daily; + if (!d) return; + const cross = !state.project; + const rows = d.days; + const t = d.totals; + $('#detail-title').textContent = '日別集計 — ' + (cross ? '全プロジェクト' : shortPath(projectPathOf(state.project))); + + const legend = d.projects.slice(0, PALETTE.length); + const colorOf = new Map(legend.map((p, i) => [p.id, PALETTE[i]])); + const otherMs = d.projects.slice(PALETTE.length).reduce((a, p) => a + p.activeMs, 0); + const tip = (r) => { + const lines = [`${r.date}(${DOW[r.dow]})`, `作業 ${fmtDur(r.activeMs)} · ${r.turns} 往復 · ${r.sessions} タスク`]; + if (cross) { + Object.entries(r.byProject) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .forEach(([id, v]) => lines.push(` ${shortPath(projectPathOf(id))} ${fmtDur(v)}`)); + } + return lines.join('\n'); + }; + + const W = Math.max(380, $('#detail-body').clientWidth - 30); + const H = 258; + const padL = 64; + const padR = 12; + const padT = 12; + const padB = 38; + const plotW = W - padL - padR; + const plotH = H - padT - padB; + const n = Math.max(1, rows.length); + const slot = plotW / n; + const bw = Math.max(1, Math.min(30, slot * 0.78)); + const maxMs = Math.max(1, ...rows.map((r) => r.activeMs)); + const step = niceStep(maxMs); + const topMs = Math.max(step, Math.ceil(maxMs / step) * step); + const yOf = (ms) => padT + plotH - (ms / topMs) * plotH; + const every = Math.max(1, Math.ceil(n / 14)); + + const svg = []; + for (let v = 0; v <= topMs; v += step) { + const y = yOf(v).toFixed(1); + svg.push(`| 日付 | 作業時間 | 往復 | タスク | ${cross ? 'プロジェクト | ' : ''}
|---|---|---|---|---|
| ${r.date.slice(5).replace('-', '/')} ${DOW[r.dow]} | +${fmtDur(r.activeMs)} | +${r.turns} | +${r.sessions} | + ${cross + ? `${Object.entries(r.byProject) + .sort((a, b) => b[1] - a[1]) + .map( + ([id, v]) => + `${esc(shortPath(projectPathOf(id)))} ${fmtDur(v)}` + ) + .join('')} | ` + : ''} +
この期間の記録はありません。
'; + + $('#detail-body').innerHTML = ` +