diff --git a/README.md b/README.md index 2550b5b..cb55e75 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ python chktime.py ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。 - 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。 +- 右の**日付入力**で期間を直接指定できます(片方だけでも可。開始のみ→その日から今日まで、終了のみ→その日まで全部)。指定中は日数ボタンの選択が外れ、**✕** で解除すると日数指定に戻ります。 +- 横断モードでは**凡例のプロジェクトをクリックすると集計から外せます**。外したものは下の「除外中」に並び、クリックで戻せます(「すべて戻す」でまとめて解除)。除外は棒グラフ・表・合計・CSV すべてに効きます。 - 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。 - 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。 - 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。 diff --git a/chktime.py b/chktime.py index 8b52599..81e90b7 100644 --- a/chktime.py +++ b/chktime.py @@ -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) diff --git a/web/app.js b/web/app.js index bdefb99..e446ff7 100644 --- a/web/app.js +++ b/web/app.js @@ -12,6 +12,9 @@ const state = { relative: false, view: 'turns', days: 30, + dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う + dailyTo: '', + dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ) daily: null, ganttDays: 7, ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる @@ -316,14 +319,27 @@ function syncTabs() { $('#tab-gantt').classList.toggle('sel', state.view === 'gantt'); } -function scopeQuery(days, extra) { - const q = new URLSearchParams({ days: String(days), ...extra }); +function ganttQuery(extra) { + const q = new URLSearchParams({ days: String(state.ganttDays), ...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); +const customRange = () => Boolean(state.dailyFrom || state.dailyTo); + +function dailyQuery(extra) { + const q = new URLSearchParams(extra || {}); + if (state.project) q.set('project', state.project); + if (customRange()) { + if (state.dailyFrom) q.set('from', state.dailyFrom); + if (state.dailyTo) q.set('to', state.dailyTo); + } else { + q.set('days', String(state.days)); + } + // 除外は横断モードだけ (単一プロジェクト表示では自分自身を消してしまうので送らない) + if (!state.project && state.dailyExclude.length) q.set('exclude', state.dailyExclude.join(',')); + return q.toString(); +} function updateCsv() { const link = $('#csv-turns'); @@ -465,9 +481,16 @@ function renderDaily() { svg.push(`${esc(tip(r))}`); }); + const custom = customRange(); const ranges = [14, 30, 90, 0] - .map((v) => ``) + .map((v) => ``) .join(''); + const dates = ` + + 〜 + + ${custom ? '' : ''} + `; const active = rows.filter((r) => r.activeMs || r.turns).reverse(); const table = active.length @@ -506,17 +529,58 @@ function renderDaily() {
${ranges} + ${dates} 棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。
${svg.join('')}
${cross ? `
${legend - .map((p, i) => `${esc(shortPath(p.path))} ${fmtDur(p.activeMs)}`) + .map((p, i) => `${esc(shortPath(p.path))} ${fmtDur(p.activeMs)}`) .join('')}${otherMs ? `その他 ${d.projects.length - legend.length} 件 ${fmtDur(otherMs)}` : ''}
` : ''} + ${cross && state.dailyExclude.length + ? `
除外中${state.dailyExclude + .map((id) => `✕ ${esc(shortPath(projectPathOf(id)))}`) + .join('')}
` + : ''} ${table}`; wireRanges('days', loadDaily); + wireDaily(); +} + +/** 日別集計の期間指定・プロジェクト除外を配線する。 */ +function wireDaily() { + const setRange = (from, to) => { + state.dailyFrom = from; + state.dailyTo = to; + updateCsv(); + loadDaily(); + }; + const from = $('#d-from'); + const to = $('#d-to'); + if (from) from.onchange = () => setRange(from.value, state.dailyTo); + if (to) to.onchange = () => setRange(state.dailyFrom, to.value); + const clear = $('#d-clear'); + if (clear) clear.onclick = () => setRange('', ''); + + document.querySelectorAll('#detail-body .legend [data-ex]').forEach((el) => { + el.onclick = () => { + const id = el.dataset.ex; + const at = state.dailyExclude.indexOf(id); + if (at >= 0) state.dailyExclude.splice(at, 1); + else state.dailyExclude.push(id); + updateCsv(); + loadDaily(); + }; + }); + const exClear = $('#ex-clear'); + if (exClear) + exClear.onclick = () => { + state.dailyExclude = []; + updateCsv(); + loadDaily(); + }; } // ---------------------------------------------------------------- ガント @@ -920,6 +984,7 @@ function wireRanges(key, reload) { document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => { btn.onclick = () => { state[key] = Number(btn.dataset.days); + if (key === 'days') state.dailyFrom = state.dailyTo = ''; // 日数指定と期間指定は排他 updateCsv(); reload(); }; diff --git a/web/style.css b/web/style.css index a954765..e00951b 100644 --- a/web/style.css +++ b/web/style.css @@ -200,6 +200,21 @@ table { width: 100%; border-collapse: collapse; } .rng.sel { color: var(--accent); border-color: var(--accent); } .chart-bar .note { color: var(--dim); font-size: 10.5px; } +/* 期間の任意指定 */ +.dates { display: flex; align-items: center; gap: 4px; } +.dates input[type="date"] { + background: var(--bg); + border: 1px solid var(--line); + border-radius: 5px; + color: var(--fg); + padding: 1px 6px; + font: inherit; + font-size: 11px; + color-scheme: dark; +} +.dates input[type="date"]:hover { border-color: var(--accent); } +.dates .tilde { color: var(--dim); font-size: 11px; } + .chart { padding: 4px 14px 0; overflow-x: auto; } .chart svg text.ax { fill: var(--dim); font-size: 10px; } .chart svg text.ax.we { fill: var(--warn); } @@ -213,6 +228,12 @@ table { width: 100%; border-collapse: collapse; } .legend .lg { display: flex; align-items: center; gap: 5px; white-space: nowrap; } .legend .lg i, .pj-i i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex: none; } .legend .lg b { color: var(--fg); font-weight: 500; } +.legend .lg.act { cursor: pointer; } +.legend .lg.act:hover { color: var(--fg); } +.legend.excl { padding-top: 2px; } +.legend.excl .cap { font-size: 10.5px; } +.legend .lg.off { cursor: pointer; opacity: .6; text-decoration: line-through; } +.legend .lg.off:hover { opacity: 1; color: var(--accent); } #daily-table { margin-top: 8px; } #daily-table th {