Files
chktime/web/app.js
T
yoshikazuandClaude Opus 5 2fd0ed1bef プロジェクト横断のタスク一覧と日別集計グラフを追加
左ペインに「すべてのプロジェクト」を追加し、全プロジェクトのタスクを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>
2026-07-30 10:08:59 +09:00

510 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const $ = (sel) => document.querySelector(sel);
// 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 = ['月', '火', '水', '木', '金', '土', '日'];
// ---------------------------------------------------------------- 表示ヘルパ
function fmtDur(ms) {
if (ms === null || ms === undefined) return '–';
if (ms < 0) return '-' + fmtDur(-ms);
const s = ms / 1000;
if (s < 1) return `${ms}ms`;
if (s < 60) return `${s.toFixed(1)}秒`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}分${String(Math.round(s - m * 60)).padStart(2, '0')}秒`;
const h = Math.floor(m / 60);
return `${h}時間${String(m - h * 60).padStart(2, '0')}分`;
}
function fmtTime(iso) {
if (!iso) return '–';
const d = new Date(iso);
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')[0]}`;
}
function fmtDayTime(iso) {
if (!iso) return '–';
const d = new Date(iso);
const day = `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
return `<span class="d">${day}</span> ${fmtTime(iso)}`;
}
function sameDay(a, b) {
if (!a || !b) return false;
const x = new Date(a);
const y = new Date(b);
return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate();
}
function fmtDateTime(iso) {
if (!iso) return '–';
const d = new Date(iso);
return d.toLocaleString('ja-JP', { hour12: false, year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function fmtBytes(n) {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`;
return `${(n / 1024 / 1024).toFixed(1)}MB`;
}
function ago(iso) {
if (!iso) return '';
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 3600) return `${Math.max(1, Math.round(diff / 60))}分前`;
if (diff < 86400) return `${Math.round(diff / 3600)}時間前`;
return `${Math.round(diff / 86400)}日前`;
}
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
async function api(path) {
const res = await fetch(path);
const data = await res.json();
if (data.error) throw new Error(data.error);
return data;
}
// ---------------------------------------------------------------- プロジェクト
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;
li.innerHTML = `<div class="p-path">${esc(p.path)}</div>
<div class="p-meta">${p.sessions} タスク · ${fmtBytes(p.bytes)} · ${ago(p.lastActivity)}</div>`;
li.onclick = () => selectProject(p.id);
ul.appendChild(li);
});
// 再読込では選択を保つ (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 = 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(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions;
renderSessions();
} catch (e) {
tbody.innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
}
}
function renderSessions() {
const q = $('#filter').value.trim().toLowerCase();
const rows = state.sessions.filter(
(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');
tbody.innerHTML = '';
if (!rows.length) {
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>
<span>${s.turnCount} 往復</span>
<span>全体 ${fmtDur(s.wallMs)}</span>
<span>作業 ${fmtDur(s.activeMs)}</span>
${s.gitBranch ? `<span>⑂ ${esc(s.gitBranch)}</span>` : ''}
</div>
</td>`;
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(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));
updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
try {
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>`;
}
}
function renderDetail() {
const d = state.detail;
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)));
const scale = (ms) => {
const v = Math.max(0, ms || 0);
return v ? Math.max(2, Math.round((v / maxSpan) * 95)) : 0;
};
const t0 = d.turns.length ? new Date(d.turns[0].tsIso).getTime() : 0;
const head = `
<div class="sum">
<div><span class="k">開始</span><span class="v">${fmtDateTime(d.start)}</span></div>
<div><span class="k">終了</span><span class="v">${fmtDateTime(d.end)}</span></div>
<div><span class="k">全体</span><span class="v">${fmtDur(d.wallMs)}</span></div>
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(d.activeMs)}</span></div>
<div><span class="k">往復</span><span class="v">${d.turnCount}</span></div>
<div><span class="k">応答メッセージ</span><span class="v">${d.assistantMsgs}</span></div>
<div><span class="k">ツール呼び出し</span><span class="v">${d.toolCalls}</span></div>
<div><span class="k">サブエージェント</span><span class="v">${d.sidechainMsgs}</span></div>
<div><span class="k">ログ</span><span class="v">${fmtBytes(d.bytes)} · ${esc(d.id.slice(0, 8))}</span></div>
</div>`;
const rows = d.turns
.map((t) => {
const rel = (iso) => '+' + fmtDur(new Date(iso).getTime() - t0);
const when = state.relative ? rel(t.tsIso) : fmtDayTime(t.tsIso);
// 完了時刻は日をまたいだ時だけ日付を出す
const done = !t.endIso
? '–'
: state.relative
? rel(t.endIso)
: sameDay(t.endIso, t.tsIso)
? fmtTime(t.endIso)
: fmtDayTime(t.endIso);
const tools = t.toolList.map(([n, c]) => (c > 1 ? `${n}×${c}` : n)).join(', ');
return `
<tr data-i="${t.i}">
<td class="n">${t.i}</td>
<td class="time" title="${esc(fmtDateTime(t.tsIso))}">${when}${t.kind !== 'prompt' ? ` <span class="kind ${t.kind}">${t.kind}</span>` : ''}</td>
<td class="num">${t.idleMs !== null && t.idleMs < 0
? `<span class="ovl" title="前のターンの処理中に送信 (${fmtDur(-t.idleMs)} 重複)">並行</span>`
: fmtDur(t.idleMs)}</td>
<td class="num">${fmtDur(t.waitMs)}</td>
<td class="num">${fmtDur(t.durMs)}</td>
<td class="time done" title="${esc(fmtDateTime(t.endIso))}">${done}</td>
<td><div class="bar">
<i class="b-idle" style="width:${scale(t.idleMs)}px" title="空き ${fmtDur(t.idleMs)}"></i>
<i class="b-wait" style="width:${scale(t.waitMs)}px" title="初回応答まで ${fmtDur(t.waitMs)}"></i>
<i class="b-work" style="width:${scale((t.durMs || 0) - (t.waitMs || 0))}px" title="応答中 ${fmtDur((t.durMs || 0) - (t.waitMs || 0))}"></i>
</div></td>
<td class="n small">${t.assistant}<span class="u">msg</span>${t.toolCalls ? ` ${t.toolCalls}<span class="u">tool</span>` : ''}</td>
<td class="msg">
<div class="txt">${esc(t.prompt)}${t.truncated ? ' …' : ''}</div>
${tools ? `<div class="tools">${esc(tools)}</div>` : ''}
${t.interrupted ? '<div class="int">⚠ 中断あり</div>' : ''}
</td>
</tr>`;
})
.join('');
$('#detail-body').innerHTML =
head +
(d.turns.length
? `<table id="turns">
<thead><tr>
<th class="n">#</th><th>${state.relative ? '経過' : '発言時刻'}</th>
<th class="n">空き</th><th class="n">初回応答</th><th class="n">応答時間</th>
<th>${state.relative ? '完了(経過)' : '完了時刻'}</th><th>タイムライン</th><th class="n">内訳</th><th>やりとり</th>
</tr></thead><tbody>${rows}</tbody></table>`
: '<p class="empty">やりとりが記録されていません。</p>');
document.querySelectorAll('#turns .msg').forEach((td) => {
td.onclick = () => td.parentElement.classList.toggle('open');
});
}
// ---------------------------------------------------------------- 日別集計
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;
if (state.view === 'turns') renderDetail();
};
$('#tab-turns').onclick = () => setView('turns');
$('#tab-daily').onclick = () => setView('daily');
$('#reload').onclick = async () => {
await fetch('/api/reload');
const keep = { project: state.sessionProject, session: state.session, view: state.view };
await loadProjects();
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>`;
});