プロジェクト横断のタスク一覧と日別集計グラフを追加
左ペインに「すべてのプロジェクト」を追加し、全プロジェクトのタスクを1つの 一覧で扱えるようにした。あわせて右ペインに日別集計タブを設けた。 - /api/all: 全プロジェクトのタスクを開始日時順に返す (CSVはプロジェクト列付き) - /api/daily: ターンをローカル日付で集計し、活動のない日も0で埋めて返す (プロジェクト別の内訳・期間指定 days= / 全期間・横持ちCSV) - 日別集計タブ: 作業時間の棒グラフ (横断時はプロジェクト別の積み上げ、 上位7件+その他)、14/30/90日・全期間の切替、日別テーブル、SVG手書きで 外部ライブラリなし - 再読込で横断モードの選択が先頭プロジェクトに戻る問題を修正 (未選択と横断モードを state.project の undefined / null で区別) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+148
-5
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user