プロジェクト横断のタスク一覧と日別集計グラフを追加

左ペインに「すべてのプロジェクト」を追加し、全プロジェクトのタスクを1つの
一覧で扱えるようにした。あわせて右ペインに日別集計タブを設けた。

- /api/all: 全プロジェクトのタスクを開始日時順に返す (CSVはプロジェクト列付き)
- /api/daily: ターンをローカル日付で集計し、活動のない日も0で埋めて返す
  (プロジェクト別の内訳・期間指定 days= / 全期間・横持ちCSV)
- 日別集計タブ: 作業時間の棒グラフ (横断時はプロジェクト別の積み上げ、
  上位7件+その他)、14/30/90日・全期間の切替、日別テーブル、SVG手書きで
  外部ライブラリなし
- 再読込で横断モードの選択が先頭プロジェクトに戻る問題を修正
  (未選択と横断モードを state.project の undefined / null で区別)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 10:08:59 +09:00
co-authored by Claude Opus 5
parent cf17cf3fa0
commit 2fd0ed1bef
5 changed files with 495 additions and 23 deletions
+14 -4
View File
@@ -33,9 +33,9 @@ python chktime.py
## 画面
- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。
- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。
- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。
- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。先頭の **▦ すべてのプロジェクト** を選ぶと横断モードになります。
- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。横断モードでは全プロジェクトのタスクを開始日時の新しい順に並べ、各行にプロジェクトを表示します(検索はパスにも効きます)。
- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計** タブで表示を切り替えます。
### 右ペインの列
@@ -53,7 +53,17 @@ python chktime.py
上部サマリの **作業時間合計** は各ターンの応答時間の合計(=実際に Claude が動いていた時間)、
**全体** は最初から最後までの経過時間です。放置時間が長いと両者は大きく離れます。
`CSV` ボタンでタスク一覧・ターン一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
### 日別集計タブ
ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。
- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
`CSV` ボタンでタスク一覧・ターン一覧・日別集計をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
## 解析の仕様(ざっくり)
+147 -4
View File
@@ -22,7 +22,7 @@ import os
import re
import threading
import webbrowser
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
@@ -389,6 +389,113 @@ class Store:
return self.session(path)
raise FileNotFoundError(session_id)
def all_sessions(self):
"""全プロジェクトのタスクを1つのリストにまとめる (開始が新しい順)。"""
out = []
for project in self.projects():
for path in self.session_files(project["id"]):
summary = summary_of(self.session(path))
summary["project"] = project["id"]
summary["projectPath"] = summary.get("cwd") or project["path"]
out.append(summary)
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
return out
def daily_stats(self, project_id=None, days=30):
"""ターンをローカル日付で集計する。project_id=None なら全プロジェクト。
days>0 なら「今日から遡って days 日」、それ以外は全期間。
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
"""
paths = {p["id"]: p["path"] for p in self.projects()}
if project_id:
if project_id not in paths:
raise FileNotFoundError(project_id)
targets = [project_id]
else:
targets = list(paths)
today = datetime.now().date()
cutoff = today - timedelta(days=days - 1) if days and days > 0 else None
per_day = {}
per_project = {}
seen = set()
for pid in targets:
for path in self.session_files(pid):
session = self.session(path)
for turn in session["turns"]:
if turn["ts"] is None:
continue
day = datetime.fromtimestamp(turn["ts"]).date()
if cutoff and day < cutoff:
continue
dur = turn["durMs"] or 0
counted = 1 if turn["kind"] in ("prompt", "command") else 0
bucket = per_day.setdefault(day, {"activeMs": 0, "turns": 0, "sessions": set(), "byProject": {}})
bucket["activeMs"] += dur
bucket["turns"] += counted
bucket["sessions"].add(session["id"])
bucket["byProject"][pid] = bucket["byProject"].get(pid, 0) + dur
total = per_project.setdefault(pid, {"activeMs": 0, "turns": 0, "sessions": set()})
total["activeMs"] += dur
total["turns"] += counted
total["sessions"].add(session["id"])
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 で埋める
else:
start = end = None
rows = []
cur = start
while cur and cur <= end:
bucket = per_day.get(cur)
rows.append(
{
"date": cur.isoformat(),
"dow": cur.weekday(),
"activeMs": bucket["activeMs"] if bucket else 0,
"turns": bucket["turns"] if bucket else 0,
"sessions": len(bucket["sessions"]) if bucket else 0,
"byProject": bucket["byProject"] if bucket else {},
}
)
cur += timedelta(days=1)
projects = sorted(
(
{
"id": pid,
"path": paths.get(pid, pid),
"activeMs": v["activeMs"],
"turns": v["turns"],
"sessions": len(v["sessions"]),
}
for pid, v in per_project.items()
),
key=lambda p: -p["activeMs"],
)
busiest = max(rows, key=lambda r: r["activeMs"], default=None)
return {
"scope": project_id or "",
"rangeDays": days if days and days > 0 else 0,
"days": rows,
"projects": projects,
"totals": {
"activeMs": sum(r["activeMs"] for r in rows),
"turns": sum(r["turns"] for r in rows),
"sessions": len(seen),
"activeDays": sum(1 for r in rows if r["activeMs"] or r["turns"]),
"busiestDate": busiest["date"] if busiest and busiest["activeMs"] else None,
"busiestMs": busiest["activeMs"] if busiest else 0,
},
}
def clear(self):
with self._lock:
self._cache.clear()
@@ -433,18 +540,43 @@ def fmt_dur(msec):
return f"{hours}h{minutes:02d}m"
def sessions_csv(sessions):
def parse_days(value):
"""?days= の解釈。'all' / 0 / 不正値は全期間 (0) とみなす。"""
try:
days = int(value)
except (TypeError, ValueError):
return 0
return max(0, min(days, 3650))
def sessions_csv(sessions, with_project=False):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
writer.writerow(["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"])
head = ["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"]
writer.writerow((["project"] if with_project else []) + head)
for s in sessions:
writer.writerow(
[s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]),
([s.get("projectPath") or s.get("project") or ""] if with_project else [])
+ [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]),
fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""]
)
return buf.getvalue()
def daily_csv(data):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
projects = data["projects"]
writer.writerow(["date", "dow", "active", "active_ms", "turns", "sessions"] + [p["path"] for p in projects])
labels = "月火水木金土日"
for row in data["days"]:
writer.writerow(
[row["date"], labels[row["dow"]], fmt_dur(row["activeMs"]), row["activeMs"], row["turns"], row["sessions"]]
+ [fmt_dur(row["byProject"].get(p["id"])) for p in projects]
)
return buf.getvalue()
def turns_csv(session):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
@@ -494,6 +626,17 @@ class Handler(BaseHTTPRequestHandler):
if query.get("format", [""])[0] == "csv":
return self.send_text(sessions_csv(sessions), "text/csv", f"{project}-sessions.csv")
return self.send_json({"project": project, "sessions": sessions})
if route == "/api/all":
sessions = store.all_sessions()
if query.get("format", [""])[0] == "csv":
return self.send_text(sessions_csv(sessions, True), "text/csv", "all-sessions.csv")
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]))
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)
if route == "/api/session":
project = query.get("project", [""])[0]
session_id = query.get("id", [""])[0]
+278 -14
View File
@@ -1,7 +1,23 @@
'use strict';
const $ = (sel) => document.querySelector(sel);
const state = { project: null, session: null, sessions: [], detail: null, relative: false };
// project: null = プロジェクト横断モード, undefined = 未選択 / view: turns=やりとり, daily=日別集計
const state = {
project: undefined,
projects: [],
sessionProject: null,
session: null,
sessions: [],
detail: null,
relative: false,
view: 'turns',
days: 30,
daily: null,
};
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca'];
const OTHER_COLOR = '#565f73';
const DOW = ['月', '火', '水', '木', '金', '土', '日'];
// ---------------------------------------------------------------- 表示ヘルパ
@@ -71,9 +87,21 @@ async function api(path) {
async function loadProjects() {
const data = await api('/api/projects');
state.projects = data.projects;
$('#root').textContent = data.root;
const ul = $('#project-list');
ul.innerHTML = '';
const all = document.createElement('li');
all.dataset.id = '';
all.className = 'all';
const totalSessions = data.projects.reduce((a, p) => a + p.sessions, 0);
const totalBytes = data.projects.reduce((a, p) => a + p.bytes, 0);
all.innerHTML = `<div class="p-path">すべてのプロジェクト</div>
<div class="p-meta">${totalSessions} タスク · ${fmtBytes(totalBytes)} · ${data.projects.length} プロジェクト</div>`;
all.onclick = () => selectProject(null);
ul.appendChild(all);
data.projects.forEach((p) => {
const li = document.createElement('li');
li.dataset.id = p.id;
@@ -82,17 +110,22 @@ async function loadProjects() {
li.onclick = () => selectProject(p.id);
ul.appendChild(li);
});
if (data.projects.length) selectProject(state.project && data.projects.some((p) => p.id === state.project) ? state.project : data.projects[0].id);
// 再読込では選択を保つ (null = 横断モードもそのまま)
if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : null;
else if (state.project && !data.projects.some((p) => p.id === state.project)) state.project = null;
selectProject(state.project);
}
async function selectProject(id) {
state.project = id;
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === id));
$('#csv-sessions').href = `/api/sessions?project=${encodeURIComponent(id)}&format=csv`;
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (id ?? '')));
$('#csv-sessions').href = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv';
const tbody = $('#session-table tbody');
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
if (state.view === 'daily') loadDaily();
try {
const data = await api(`/api/sessions?project=${encodeURIComponent(id)}`);
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions;
renderSessions();
} catch (e) {
@@ -103,7 +136,7 @@ async function selectProject(id) {
function renderSessions() {
const q = $('#filter').value.trim().toLowerCase();
const rows = state.sessions.filter(
(s) => !q || (s.title + ' ' + s.firstPrompt + ' ' + s.lastPrompt).toLowerCase().includes(q)
(s) => !q || (s.title + ' ' + s.firstPrompt + ' ' + s.lastPrompt + ' ' + (s.projectPath || '')).toLowerCase().includes(q)
);
$('#sessions-title').textContent = `タスク ${rows.length}/${state.sessions.length}`;
const tbody = $('#session-table tbody');
@@ -112,12 +145,14 @@ function renderSessions() {
tbody.innerHTML = '<tr><td class="empty">該当なし</td></tr>';
return;
}
const cross = !state.project;
rows.forEach((s) => {
const tr = document.createElement('tr');
tr.dataset.id = s.id;
tr.className = s.id === state.session ? 'sel' : '';
tr.innerHTML = `
<td>
${cross ? `<div class="s-proj" title="${esc(s.projectPath || '')}">${esc(shortPath(s.projectPath))}</div>` : ''}
<div class="s-title">${esc(s.title)}</div>
<div class="s-meta">
<span class="s-when">${fmtDateTime(s.start)}</span>
@@ -127,20 +162,29 @@ function renderSessions() {
${s.gitBranch ? `<span>⑂ ${esc(s.gitBranch)}</span>` : ''}
</div>
</td>`;
tr.onclick = () => selectSession(s.id);
tr.onclick = () => selectSession(s.project || state.project, s.id);
tbody.appendChild(tr);
});
}
function shortPath(path) {
if (!path) return '(不明)';
const parts = String(path).split(/[\\/]/).filter(Boolean);
return parts.length > 2 ? '…' + parts.slice(-2).join('\\') : path;
}
// ---------------------------------------------------------------- 詳細
async function selectSession(id) {
async function selectSession(project, id) {
state.sessionProject = project;
state.session = id;
state.view = 'turns';
syncTabs();
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
$('#csv-turns').href = `/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}&format=csv`;
updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
try {
state.detail = await api(`/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}`);
state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`);
renderDetail();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
@@ -149,7 +193,11 @@ async function selectSession(id) {
function renderDetail() {
const d = state.detail;
if (!d) return;
if (!d) {
$('#detail-title').textContent = 'やりとりの時刻';
$('#detail-body').innerHTML = '<p class="empty">左のタスクを選んでください。</p>';
return;
}
$('#detail-title').textContent = d.title;
const maxSpan = Math.max(1, ...d.turns.map((t) => (t.idleMs || 0) + (t.durMs || 0)));
@@ -226,20 +274,236 @@ function renderDetail() {
});
}
// ---------------------------------------------------------------- 日別集計
function syncTabs() {
$('#tab-turns').classList.toggle('sel', state.view === 'turns');
$('#tab-daily').classList.toggle('sel', state.view === 'daily');
}
function dailyQuery(extra) {
const q = new URLSearchParams({ days: String(state.days), ...extra });
if (state.project) q.set('project', state.project);
return q.toString();
}
function updateCsv() {
const link = $('#csv-turns');
if (state.view === 'daily') {
link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
link.title = '日別集計を CSV 出力';
} else if (state.session) {
link.href = `/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}&format=csv`;
link.title = 'ターン一覧を CSV 出力';
} else {
link.removeAttribute('href');
}
}
function setView(view) {
state.view = view;
syncTabs();
updateCsv();
if (view === 'daily') loadDaily();
else renderDetail();
}
async function loadDaily() {
$('#detail-title').textContent = '日別集計';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
try {
state.daily = await api('/api/daily?' + dailyQuery());
renderDaily();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
const STEPS = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880].map((m) => m * 60000);
function niceStep(maxMs) {
return STEPS.find((s) => maxMs / s <= 5) || STEPS[STEPS.length - 1];
}
function fmtAxis(ms) {
if (!ms) return '0';
const m = Math.round(ms / 60000);
if (m < 60) return `${m}分`;
const h = Math.floor(m / 60);
return h + '時間' + (m % 60 ? `${m % 60}分` : '');
}
function projectPathOf(id) {
const hit = state.projects.find((p) => p.id === id) || (state.daily && state.daily.projects.find((p) => p.id === id));
return hit ? hit.path : id;
}
function renderDaily() {
const d = state.daily;
if (!d) return;
const cross = !state.project;
const rows = d.days;
const t = d.totals;
$('#detail-title').textContent = '日別集計 — ' + (cross ? '全プロジェクト' : shortPath(projectPathOf(state.project)));
const legend = d.projects.slice(0, PALETTE.length);
const colorOf = new Map(legend.map((p, i) => [p.id, PALETTE[i]]));
const otherMs = d.projects.slice(PALETTE.length).reduce((a, p) => a + p.activeMs, 0);
const tip = (r) => {
const lines = [`${r.date}(${DOW[r.dow]})`, `作業 ${fmtDur(r.activeMs)} · ${r.turns} 往復 · ${r.sessions} タスク`];
if (cross) {
Object.entries(r.byProject)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.forEach(([id, v]) => lines.push(` ${shortPath(projectPathOf(id))} ${fmtDur(v)}`));
}
return lines.join('\n');
};
const W = Math.max(380, $('#detail-body').clientWidth - 30);
const H = 258;
const padL = 64;
const padR = 12;
const padT = 12;
const padB = 38;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const n = Math.max(1, rows.length);
const slot = plotW / n;
const bw = Math.max(1, Math.min(30, slot * 0.78));
const maxMs = Math.max(1, ...rows.map((r) => r.activeMs));
const step = niceStep(maxMs);
const topMs = Math.max(step, Math.ceil(maxMs / step) * step);
const yOf = (ms) => padT + plotH - (ms / topMs) * plotH;
const every = Math.max(1, Math.ceil(n / 14));
const svg = [];
for (let v = 0; v <= topMs; v += step) {
const y = yOf(v).toFixed(1);
svg.push(`<line class="grid" x1="${padL}" y1="${y}" x2="${padL + plotW}" y2="${y}"/>`);
svg.push(`<text class="ax" x="${padL - 8}" y="${(yOf(v) + 3.5).toFixed(1)}" text-anchor="end">${fmtAxis(v)}</text>`);
}
rows.forEach((r, i) => {
const x = padL + i * slot;
if (r.dow >= 5) svg.push(`<rect class="wk" x="${x.toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"/>`);
});
rows.forEach((r, i) => {
const segs = [];
if (cross) {
legend.forEach((p) => {
if (r.byProject[p.id]) segs.push([colorOf.get(p.id), r.byProject[p.id]]);
});
const rest = Object.keys(r.byProject)
.filter((id) => !colorOf.has(id))
.reduce((a, id) => a + r.byProject[id], 0);
if (rest) segs.push([OTHER_COLOR, rest]);
} else if (r.activeMs) {
segs.push([PALETTE[1], r.activeMs]);
}
const bx = padL + i * slot + (slot - bw) / 2;
let acc = 0;
segs.forEach(([color, v]) => {
const y1 = yOf(acc + v);
const y0 = yOf(acc);
acc += v;
svg.push(`<rect x="${bx.toFixed(1)}" y="${y1.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(1, y0 - y1).toFixed(1)}" fill="${color}"/>`);
});
});
svg.push(`<line class="axis" x1="${padL}" y1="${yOf(0).toFixed(1)}" x2="${padL + plotW}" y2="${yOf(0).toFixed(1)}"/>`);
rows.forEach((r, i) => {
const cx = padL + i * slot + slot / 2;
if ((n - 1 - i) % every === 0) {
const label = r.date.slice(5).replace('-', '/');
svg.push(`<text class="ax" x="${cx.toFixed(1)}" y="${padT + plotH + 15}" text-anchor="middle">${label}</text>`);
svg.push(`<text class="ax${r.dow >= 5 ? ' we' : ''}" x="${cx.toFixed(1)}" y="${padT + plotH + 27}" text-anchor="middle">${DOW[r.dow]}</text>`);
}
// 空の日もツールチップを出すため、スロット全体に当たり判定を重ねる
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 ranges = [14, 30, 90, 0]
.map((v) => `<button class="rng${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join('');
const active = rows.filter((r) => r.activeMs || r.turns).reverse();
const table = active.length
? `<table id="daily-table">
<thead><tr><th>日付</th><th class="n">作業時間</th><th class="n">往復</th><th class="n">タスク</th>${cross ? '<th>プロジェクト</th>' : ''}</tr></thead>
<tbody>${active
.map(
(r) => `<tr>
<td class="time${r.dow >= 5 ? ' we' : ''}">${r.date.slice(5).replace('-', '/')} <span class="d">${DOW[r.dow]}</span></td>
<td class="num">${fmtDur(r.activeMs)}</td>
<td class="num">${r.turns}</td>
<td class="num">${r.sessions}</td>
${cross
? `<td class="pj">${Object.entries(r.byProject)
.sort((a, b) => b[1] - a[1])
.map(
([id, v]) =>
`<span class="pj-i" title="${esc(projectPathOf(id))}"><i style="background:${colorOf.get(id) || OTHER_COLOR}"></i>${esc(shortPath(projectPathOf(id)))} ${fmtDur(v)}</span>`
)
.join('')}</td>`
: ''}
</tr>`
)
.join('')}</tbody></table>`
: '<p class="empty">この期間の記録はありません。</p>';
$('#detail-body').innerHTML = `
<div class="sum">
<div><span class="k">期間</span><span class="v">${rows.length ? `${rows[0].date} → ${rows[rows.length - 1].date}` : '–'}</span></div>
<div><span class="k">稼働日</span><span class="v">${t.activeDays} / ${rows.length} 日</span></div>
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(t.activeMs)}</span></div>
<div><span class="k">1稼働日あたり</span><span class="v">${t.activeDays ? fmtDur(Math.round(t.activeMs / t.activeDays)) : '–'}</span></div>
<div><span class="k">往復</span><span class="v">${t.turns}</span></div>
<div><span class="k">タスク</span><span class="v">${t.sessions}</span></div>
<div><span class="k">最も長い日</span><span class="v">${t.busiestDate ? `${t.busiestDate.slice(5).replace('-', '/')} ${fmtDur(t.busiestMs)}` : '–'}</span></div>
</div>
<div class="chart-bar">
<span class="rngs">${ranges}</span>
<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>`)
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>`
: ''}
${table}`;
document.querySelectorAll('#detail-body .rng').forEach((btn) => {
btn.onclick = () => {
state.days = Number(btn.dataset.days);
updateCsv();
loadDaily();
};
});
}
// ---------------------------------------------------------------- 起動
$('#filter').oninput = renderSessions;
$('#relative').onchange = (e) => {
state.relative = e.target.checked;
renderDetail();
if (state.view === 'turns') renderDetail();
};
$('#tab-turns').onclick = () => setView('turns');
$('#tab-daily').onclick = () => setView('daily');
$('#reload').onclick = async () => {
await fetch('/api/reload');
const keepSession = state.session;
const keep = { project: state.sessionProject, session: state.session, view: state.view };
await loadProjects();
if (keepSession) selectSession(keepSession).catch(() => {});
if (keep.view === 'turns' && keep.session) selectSession(keep.project, keep.session).catch(() => {});
};
let resizeTimer = null;
window.addEventListener('resize', () => {
if (state.view !== 'daily') return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => state.daily && renderDaily(), 150);
});
loadProjects().catch((e) => {
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
});
+4
View File
@@ -33,6 +33,10 @@
<section id="detail" class="pane">
<div class="pane-head">
<span id="detail-title">やりとりの時刻</span>
<span class="tabs">
<button class="tab sel" id="tab-turns">やりとり</button>
<button class="tab" id="tab-daily">日別集計</button>
</span>
<a id="csv-turns" class="btn" href="#" download>CSV</a>
</div>
<div class="scroll" id="detail-body">
+51
View File
@@ -72,6 +72,7 @@ main { flex: 1; display: flex; min-height: 0; }
border-bottom: 1px solid var(--line);
}
.pane-head > span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#detail-title { text-transform: none; } /* タイトルやパスをそのまま出す */
#filter {
flex: 1 1 120px;
min-width: 80px;
@@ -162,3 +163,53 @@ table { width: 100%; border-collapse: collapse; }
.bar .b-idle { background: var(--idle); }
.bar .b-wait { background: var(--user); }
.bar .b-work { background: var(--work); }
/* プロジェクト横断モード */
#project-list li.all { border-bottom: 2px solid var(--line); }
#project-list li.all .p-path::before { content: "▦ "; color: var(--dim); }
.s-proj { color: var(--dim); font-size: 10.5px; margin-bottom: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 日別集計 */
.tabs { display: flex; gap: 4px; flex: none; }
.tab { padding: 2px 9px; font-size: 11px; letter-spacing: normal; text-transform: none; }
.tab.sel { color: var(--accent); border-color: var(--accent); }
.chart-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px 0;
flex-wrap: wrap;
}
.rngs { display: flex; gap: 4px; }
.rng { font-size: 11px; padding: 2px 8px; }
.rng.sel { color: var(--accent); border-color: var(--accent); }
.chart-bar .note { color: var(--dim); font-size: 10.5px; }
.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); }
.chart svg line.grid { stroke: var(--line); stroke-width: 1; }
.chart svg line.axis { stroke: var(--dim); stroke-width: 1; opacity: .5; }
.chart svg rect.wk { fill: #ffffff; opacity: .028; }
.chart svg rect.hit { fill: transparent; }
.chart svg rect.hit:hover { fill: #ffffff; opacity: .06; }
.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 8px 14px 2px; font-size: 11px; color: var(--dim); }
.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; }
#daily-table { margin-top: 8px; }
#daily-table th {
position: sticky; top: 0; background: var(--pane); border-bottom: 1px solid var(--line);
color: var(--dim); font-weight: 500; font-size: 11px; text-align: left; padding: 6px 10px; white-space: nowrap; z-index: 1;
}
#daily-table td { padding: 5px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
#daily-table tr:hover td { background: var(--pane2); }
#daily-table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
#daily-table .time { font-variant-numeric: tabular-nums; white-space: nowrap; }
#daily-table .time.we { color: var(--warn); }
#daily-table .d { color: var(--dim); font-size: 11px; }
#daily-table .pj { display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 11px; color: var(--dim); }
.pj-i { display: flex; align-items: center; gap: 4px; white-space: nowrap; }