タスクを時間軸に並べるガントタブを追加
薄いバーで開始〜終了の全体、濃い部分で応答が動いていた区間を重ねて描く。 放置していた時間や、セッションを並行して動かしていた時間が見える。 - /api/timeline: セッションごとに全体の範囲と応答中の区間 (spans) を返す (隙間 1 秒以内のターンは結合、期間指定 days= / 全期間、CSV も可) - ガントタブ: 1日/3日/7日/14日/30日/全期間の切替、1〜3日では時刻目盛りに 自動で切替、土日の帯・日境界線・現在時刻の点線、横断時はプロジェクト別の色 - 行のホバーで内訳、クリックでそのタスクのやりとりを開く - ラベル列とバーの行を22pxで揃え、時間軸ヘッダは縦スクロール中も固定 - ペイン幅に応じてラベル幅を詰め、横スクロールを出さない Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+103
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user