日別集計に期間の任意指定とプロジェクト除外を追加
/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 の列として並ぶので、間隔がそのまま日付軸になります。
|
- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。
|
||||||
|
- 右の**日付入力**で期間を直接指定できます(片方だけでも可。開始のみ→その日から今日まで、終了のみ→その日まで全部)。指定中は日数ボタンの選択が外れ、**✕** で解除すると日数指定に戻ります。
|
||||||
|
- 横断モードでは**凡例のプロジェクトをクリックすると集計から外せます**。外したものは下の「除外中」に並び、クリックで戻せます(「すべて戻す」でまとめて解除)。除外は棒グラフ・表・合計・CSV すべてに効きます。
|
||||||
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
|
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
|
||||||
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
|
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
|
||||||
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
|
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
|
||||||
|
|||||||
+46
-15
@@ -25,7 +25,7 @@ import socket
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
@@ -495,10 +495,12 @@ class Store:
|
|||||||
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
|
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
|
||||||
return out
|
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 なら全プロジェクト。
|
"""ターンをローカル日付で集計する。project_id=None なら全プロジェクト。
|
||||||
|
|
||||||
days>0 なら「今日から遡って days 日」、それ以外は全期間。
|
期間は start/end (date) を渡せばその範囲、無ければ days>0 で「今日から
|
||||||
|
遡って days 日」、days<=0 なら全期間。exclude に入れたプロジェクトは
|
||||||
|
集計に含めない (横断モード用)。
|
||||||
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
|
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
|
||||||
"""
|
"""
|
||||||
paths = {p["id"]: p["path"] for p in self.projects()}
|
paths = {p["id"]: p["path"] for p in self.projects()}
|
||||||
@@ -507,10 +509,16 @@ class Store:
|
|||||||
raise FileNotFoundError(project_id)
|
raise FileNotFoundError(project_id)
|
||||||
targets = [project_id]
|
targets = [project_id]
|
||||||
else:
|
else:
|
||||||
targets = list(paths)
|
targets = [pid for pid in paths if pid not in exclude]
|
||||||
|
|
||||||
today = datetime.now().date()
|
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_day = {}
|
||||||
per_project = {}
|
per_project = {}
|
||||||
@@ -522,7 +530,7 @@ class Store:
|
|||||||
if turn["ts"] is None:
|
if turn["ts"] is None:
|
||||||
continue
|
continue
|
||||||
day = datetime.fromtimestamp(turn["ts"]).date()
|
day = datetime.fromtimestamp(turn["ts"]).date()
|
||||||
if cutoff and day < cutoff:
|
if (lo and day < lo) or (hi and day > hi):
|
||||||
continue
|
continue
|
||||||
dur = turn["durMs"] or 0
|
dur = turn["durMs"] or 0
|
||||||
counted = 1 if turn["kind"] in ("prompt", "command") else 0
|
counted = 1 if turn["kind"] in ("prompt", "command") else 0
|
||||||
@@ -538,16 +546,16 @@ class Store:
|
|||||||
seen.add(session["id"])
|
seen.add(session["id"])
|
||||||
|
|
||||||
if per_day:
|
if per_day:
|
||||||
start = cutoff or min(per_day)
|
first = lo or min(per_day)
|
||||||
end = max(max(per_day), today)
|
last = hi or max(max(per_day), today)
|
||||||
elif cutoff:
|
elif lo or hi:
|
||||||
start, end = cutoff, today # データなしでも指定範囲は 0 で埋める
|
first, last = (lo or hi), (hi or today) # データなしでも指定範囲は 0 で埋める
|
||||||
else:
|
else:
|
||||||
start = end = None
|
first = last = None
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
cur = start
|
cur = first
|
||||||
while cur and cur <= end:
|
while cur and cur <= last:
|
||||||
bucket = per_day.get(cur)
|
bucket = per_day.get(cur)
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
@@ -577,7 +585,11 @@ class Store:
|
|||||||
busiest = max(rows, key=lambda r: r["activeMs"], default=None)
|
busiest = max(rows, key=lambda r: r["activeMs"], default=None)
|
||||||
return {
|
return {
|
||||||
"scope": project_id or "",
|
"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,
|
"days": rows,
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
"totals": {
|
"totals": {
|
||||||
@@ -794,6 +806,19 @@ def parse_days(value):
|
|||||||
return max(0, min(days, 3650))
|
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):
|
def sessions_csv(sessions, with_project=False):
|
||||||
buf = io.StringIO(newline="")
|
buf = io.StringIO(newline="")
|
||||||
writer = csv.writer(buf)
|
writer = csv.writer(buf)
|
||||||
@@ -890,7 +915,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.send_json({"project": "", "sessions": sessions})
|
return self.send_json({"project": "", "sessions": sessions})
|
||||||
if route == "/api/daily":
|
if route == "/api/daily":
|
||||||
project = query.get("project", [""])[0]
|
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":
|
if query.get("format", [""])[0] == "csv":
|
||||||
return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv")
|
return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv")
|
||||||
return self.send_json(data)
|
return self.send_json(data)
|
||||||
|
|||||||
+71
-6
@@ -12,6 +12,9 @@ const state = {
|
|||||||
relative: false,
|
relative: false,
|
||||||
view: 'turns',
|
view: 'turns',
|
||||||
days: 30,
|
days: 30,
|
||||||
|
dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う
|
||||||
|
dailyTo: '',
|
||||||
|
dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ)
|
||||||
daily: null,
|
daily: null,
|
||||||
ganttDays: 7,
|
ganttDays: 7,
|
||||||
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
|
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
|
||||||
@@ -316,14 +319,27 @@ function syncTabs() {
|
|||||||
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
|
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeQuery(days, extra) {
|
function ganttQuery(extra) {
|
||||||
const q = new URLSearchParams({ days: String(days), ...extra });
|
const q = new URLSearchParams({ days: String(state.ganttDays), ...extra });
|
||||||
if (state.project) q.set('project', state.project);
|
if (state.project) q.set('project', state.project);
|
||||||
return q.toString();
|
return q.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dailyQuery = (extra) => scopeQuery(state.days, extra);
|
const customRange = () => Boolean(state.dailyFrom || state.dailyTo);
|
||||||
const ganttQuery = (extra) => scopeQuery(state.ganttDays, extra);
|
|
||||||
|
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() {
|
function updateCsv() {
|
||||||
const link = $('#csv-turns');
|
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>`);
|
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]
|
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('');
|
.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 active = rows.filter((r) => r.activeMs || r.turns).reverse();
|
||||||
const table = active.length
|
const table = active.length
|
||||||
@@ -506,17 +529,58 @@ function renderDaily() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="chart-bar">
|
<div class="chart-bar">
|
||||||
<span class="rngs">${ranges}</span>
|
<span class="rngs">${ranges}</span>
|
||||||
|
${dates}
|
||||||
<span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span>
|
<span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></div>
|
<div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></div>
|
||||||
${cross
|
${cross
|
||||||
? `<div class="legend">${legend
|
? `<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>`
|
.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}`;
|
${table}`;
|
||||||
|
|
||||||
wireRanges('days', loadDaily);
|
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) => {
|
document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => {
|
||||||
btn.onclick = () => {
|
btn.onclick = () => {
|
||||||
state[key] = Number(btn.dataset.days);
|
state[key] = Number(btn.dataset.days);
|
||||||
|
if (key === 'days') state.dailyFrom = state.dailyTo = ''; // 日数指定と期間指定は排他
|
||||||
updateCsv();
|
updateCsv();
|
||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -200,6 +200,21 @@ table { width: 100%; border-collapse: collapse; }
|
|||||||
.rng.sel { color: var(--accent); border-color: var(--accent); }
|
.rng.sel { color: var(--accent); border-color: var(--accent); }
|
||||||
.chart-bar .note { color: var(--dim); font-size: 10.5px; }
|
.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 { padding: 4px 14px 0; overflow-x: auto; }
|
||||||
.chart svg text.ax { fill: var(--dim); font-size: 10px; }
|
.chart svg text.ax { fill: var(--dim); font-size: 10px; }
|
||||||
.chart svg text.ax.we { fill: var(--warn); }
|
.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 { 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 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 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 { margin-top: 8px; }
|
||||||
#daily-table th {
|
#daily-table th {
|
||||||
|
|||||||
Reference in New Issue
Block a user