日別集計に期間の任意指定とプロジェクト除外を追加
/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:
@@ -59,6 +59,8 @@ python chktime.py
|
||||
ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。
|
||||
|
||||
- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。
|
||||
- 右の**日付入力**で期間を直接指定できます(片方だけでも可。開始のみ→その日から今日まで、終了のみ→その日まで全部)。指定中は日数ボタンの選択が外れ、**✕** で解除すると日数指定に戻ります。
|
||||
- 横断モードでは**凡例のプロジェクトをクリックすると集計から外せます**。外したものは下の「除外中」に並び、クリックで戻せます(「すべて戻す」でまとめて解除)。除外は棒グラフ・表・合計・CSV すべてに効きます。
|
||||
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
|
||||
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
|
||||
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
|
||||
|
||||
+46
-15
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user