日別集計に期間の任意指定とプロジェクト除外を追加

/api/daily に from / to / exclude を追加。from/to があれば days より優先し、
その範囲を 0 埋めで返す。exclude のプロジェクトは集計対象から外す
(横断モードのみ送信)。CSV も同じ条件で出力される。

UI は日数ボタンの隣に日付入力を置き、凡例のクリックで除外・「除外中」から
クリックで復帰できるようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 10:30:55 +09:00
co-authored by Claude Opus 5
parent 0c312afaa5
commit 8fd54757e6
4 changed files with 140 additions and 21 deletions
+46 -15
View File
@@ -25,7 +25,7 @@ import socket
import threading
import time
import webbrowser
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
@@ -495,10 +495,12 @@ class Store:
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
return out
def daily_stats(self, project_id=None, days=30):
def daily_stats(self, project_id=None, days=30, start=None, end=None, exclude=()):
"""ターンをローカル日付で集計する。project_id=None なら全プロジェクト。
days>0 なら「今日から遡って days 日」、それ以外は全期間。
期間は start/end (date) を渡せばその範囲、無ければ days>0 で「今日から
遡って days 日」、days<=0 なら全期間。exclude に入れたプロジェクトは
集計に含めない (横断モード用)。
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
"""
paths = {p["id"]: p["path"] for p in self.projects()}
@@ -507,10 +509,16 @@ class Store:
raise FileNotFoundError(project_id)
targets = [project_id]
else:
targets = list(paths)
targets = [pid for pid in paths if pid not in exclude]
today = datetime.now().date()
cutoff = today - timedelta(days=days - 1) if days and days > 0 else None
if start or end:
lo, hi = start, end or today
if lo and hi < lo:
lo, hi = hi, lo
else:
lo = today - timedelta(days=days - 1) if days and days > 0 else None
hi = None # 上限なし (今日 かデータの末尾まで)
per_day = {}
per_project = {}
@@ -522,7 +530,7 @@ class Store:
if turn["ts"] is None:
continue
day = datetime.fromtimestamp(turn["ts"]).date()
if cutoff and day < cutoff:
if (lo and day < lo) or (hi and day > hi):
continue
dur = turn["durMs"] or 0
counted = 1 if turn["kind"] in ("prompt", "command") else 0
@@ -538,16 +546,16 @@ class Store:
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 で埋める
first = lo or min(per_day)
last = hi or max(max(per_day), today)
elif lo or hi:
first, last = (lo or hi), (hi or today) # データなしでも指定範囲は 0 で埋める
else:
start = end = None
first = last = None
rows = []
cur = start
while cur and cur <= end:
cur = first
while cur and cur <= last:
bucket = per_day.get(cur)
rows.append(
{
@@ -577,7 +585,11 @@ class Store:
busiest = max(rows, key=lambda r: r["activeMs"], default=None)
return {
"scope": project_id or "",
"rangeDays": days if days and days > 0 else 0,
"rangeDays": 0 if (start or end) else (days if days and days > 0 else 0),
"custom": bool(start or end),
"from": first.isoformat() if first else None,
"to": last.isoformat() if last else None,
"excluded": sorted(exclude),
"days": rows,
"projects": projects,
"totals": {
@@ -794,6 +806,19 @@ def parse_days(value):
return max(0, min(days, 3650))
def parse_date(value):
"""?from= / ?to= の解釈。'YYYY-MM-DD' 以外は None (指定なし扱い)。"""
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
return None
def parse_ids(value):
"""?exclude= の解釈。カンマ区切りのプロジェクト ID。"""
return {part for part in (value or "").split(",") if part}
def sessions_csv(sessions, with_project=False):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
@@ -890,7 +915,13 @@ class Handler(BaseHTTPRequestHandler):
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]))
data = store.daily_stats(
project or None,
parse_days(query.get("days", ["30"])[0]),
parse_date(query.get("from", [""])[0]),
parse_date(query.get("to", [""])[0]),
parse_ids(query.get("exclude", [""])[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)