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

/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
+2
View File
@@ -59,6 +59,8 @@ python chktime.py
ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。
- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。
- 右の**日付入力**で期間を直接指定できます(片方だけでも可。開始のみ→その日から今日まで、終了のみ→その日まで全部)。指定中は日数ボタンの選択が外れ、**✕** で解除すると日数指定に戻ります。
- 横断モードでは**凡例のプロジェクトをクリックすると集計から外せます**。外したものは下の「除外中」に並び、クリックで戻せます(「すべて戻す」でまとめて解除)。除外は棒グラフ・表・合計・CSV すべてに効きます。
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
+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)
+71 -6
View File
@@ -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(`<rect class="hit" x="${(padL + i * slot).toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"><title>${esc(tip(r))}</title></rect>`);
});
const custom = customRange();
const ranges = [14, 30, 90, 0]
.map((v) => `<button class="rng${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.map((v) => `<button class="rng${!custom && v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join('');
const dates = `<span class="dates">
<input type="date" id="d-from" value="${state.dailyFrom}" max="${state.dailyTo}" title="開始日">
<span class="tilde">〜</span>
<input type="date" id="d-to" value="${state.dailyTo}" min="${state.dailyFrom}" title="終了日">
${custom ? '<button class="rng sel" id="d-clear" title="期間指定を解除">✕</button>' : ''}
</span>`;
const active = rows.filter((r) => r.activeMs || r.turns).reverse();
const table = active.length
@@ -506,17 +529,58 @@ function renderDaily() {
</div>
<div class="chart-bar">
<span class="rngs">${ranges}</span>
${dates}
<span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span>
</div>
<div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></div>
${cross
? `<div class="legend">${legend
.map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`)
.map((p, i) => `<span class="lg act" data-ex="${esc(p.id)}" title="${esc(p.path)}\nクリックで集計から外す"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`)
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>`
: ''}
${cross && state.dailyExclude.length
? `<div class="legend excl"><span class="cap">除外中</span>${state.dailyExclude
.map((id) => `<span class="lg off" data-ex="${esc(id)}" title="${esc(projectPathOf(id))}\nクリックで戻す">✕ ${esc(shortPath(projectPathOf(id)))}</span>`)
.join('')}<button class="rng" id="ex-clear">すべて戻す</button></div>`
: ''}
${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();
};
+21
View File
@@ -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 {