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(`