Files
chktime/web/app.js
T
yoshikazuandClaude Opus 5 0d9faa54bb ログ更新の自動追従を追加 (ON/OFF 切替つき)
ヘッダの「自動追従」で有効/無効を切り替える。状態は localStorage に保持し、
既定は有効。無効時は従来どおり再読込ボタンで更新する。

- Watcher スレッド: --interval 秒ごとに jsonl を stat し、ファイル数・合計
  サイズ・最新 mtime の組が変わったら rev を進めて待機中の SSE を起こす
- /api/watch (SSE) で change を通知、20 秒ごとに ping で接続維持。
  /api/rev で現在の版も取れる
- ブラウザ側は通知を 1.2 秒まとめてから表示中のものだけ黙って作り直す。
  プロジェクト/タスクの選択・スクロール位置・絞り込み・タブは維持し、
  やりとりを末尾まで見ていた場合は追記行に追従する
- 日別集計・ガントの更新は placeholder を出さない silent 経路を追加
- 後から有効にした時に、無効だった間の変更を版の比較で取り込む

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:21:05 +09:00

840 lines
33 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,
ganttDays: 7,
gantt: null,
follow: false,
es: null,
rev: null,
followTimer: null,
refreshing: false,
};
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({ select = true } = {}) {
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;
markProject();
if (select) selectProject(state.project);
}
function markProject() {
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (state.project ?? '')));
$('#csv-sessions').href = state.project
? `/api/sessions?project=${encodeURIComponent(state.project)}&format=csv`
: '/api/all?format=csv';
}
async function selectProject(id) {
state.project = id;
markProject();
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
if (state.view === 'daily') loadDaily();
else if (state.view === 'gantt') loadGantt();
await reloadSessions();
}
async function reloadSessions() {
const id = state.project;
try {
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions;
renderSessions();
} catch (e) {
$('#session-table 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>';
await reloadDetail();
}
async function reloadDetail() {
if (!state.session) return renderDetail();
try {
state.detail = await api(
`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`
);
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');
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
}
function scopeQuery(days, extra) {
const q = new URLSearchParams({ days: String(days), ...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);
function updateCsv() {
const link = $('#csv-turns');
if (state.view === 'daily') {
link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
link.title = '日別集計を CSV 出力';
} else if (state.view === 'gantt') {
link.href = '/api/timeline?' + ganttQuery({ 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 if (view === 'gantt') loadGantt();
else renderDetail();
}
async function loadDaily({ silent = false } = {}) {
if (!silent) {
$('#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}`;
wireRanges('days', loadDaily);
}
// ---------------------------------------------------------------- ガント
async function loadGantt({ silent = false } = {}) {
if (!silent) {
$('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try {
state.gantt = await api('/api/timeline?' + ganttQuery());
renderGantt();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
const HOUR = 3600000;
const DAY = 86400000;
const TICK_STEPS = [HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 28 * DAY];
function tickStep(span, plotW) {
const maxTicks = Math.max(3, Math.floor(plotW / 66));
return TICK_STEPS.find((s) => span / s <= maxTicks) || TICK_STEPS[TICK_STEPS.length - 1];
}
function midnightsIn(t0, t1) {
const out = [];
const d = new Date(t0);
d.setHours(0, 0, 0, 0);
for (let t = d.getTime(); t <= t1; t += DAY) out.push(t);
return out;
}
function renderGantt() {
const g = state.gantt;
if (!g) return;
const cross = !state.project;
const rows = g.sessions;
$('#detail-title').textContent = 'ガント — ' + (cross ? '全プロジェクト' : shortPath(projectPathOf(state.project)));
const legend = g.projects.slice(0, PALETTE.length);
const colorOf = new Map(legend.map((p, i) => [p.id, PALETTE[i]]));
const otherMs = g.projects.slice(PALETTE.length).reduce((a, p) => a + p.activeMs, 0);
const colorFor = (s) => (cross ? colorOf.get(s.project) || OTHER_COLOR : PALETTE[1]);
const ranges = [1, 3, 7, 14, 30, 0]
.map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join('');
const p2 = (n) => String(n).padStart(2, '0');
const totalActive = rows.reduce((a, s) => a + s.activeMs, 0);
const longest = rows.reduce((a, s) => (a && a.wallMs >= s.wallMs ? a : s), null);
const head = `
<div class="sum flat">
<div><span class="k">期間</span><span class="v">${fmtDateTime(new Date(g.from * 1000).toISOString())} → ${fmtDateTime(new Date(g.to * 1000).toISOString())}</span></div>
<div><span class="k">タスク</span><span class="v">${rows.length}</span></div>
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(totalActive)}</span></div>
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
</div>
<div class="chart-bar">
<span class="rngs">${ranges}</span>
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
</div>`;
if (!rows.length) {
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
wireRanges('ganttDays', loadGantt);
return;
}
const ROW_H = 22;
const boxW = $('#detail-body').clientWidth || 700;
// ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない)
const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34)));
const plotW = Math.max(180, boxW - labW - 28);
const t0 = g.from * 1000;
const tEnd = Math.max(g.to * 1000, t0 + 60000);
const t1 = tEnd + (tEnd - t0) * 0.01; // 右端に少し余白 (現在線と最後のバーを見やすく)
const xOf = (t) => ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * plotW;
const step = tickStep(t1 - t0, plotW);
const subDay = step < DAY;
// 軸 (上部に固定表示)
const axis = [];
const first = new Date(t0);
first.setSeconds(0, 0);
first.setMinutes(0);
if (!subDay) first.setHours(0, 0, 0, 0);
for (let t = first.getTime(); t <= t1; t += step) {
if (t < t0) continue;
const d = new Date(t);
const x = xOf(t);
const midnight = d.getHours() === 0 && d.getMinutes() === 0;
const label = !subDay || midnight ? `${p2(d.getMonth() + 1)}/${p2(d.getDate())}` : `${p2(d.getHours())}:${p2(d.getMinutes())}`;
axis.push(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
axis.push(
`<text class="ax${midnight || !subDay ? ' hi' : ''}" x="${Math.min(plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > plotW - 20 ? 'end' : 'middle'}">${label}</text>`
);
}
// 背景 (土日と日境界) → バー → 当たり判定
const H = rows.length * ROW_H;
const svg = [];
midnightsIn(t0, t1).forEach((t) => {
const d = new Date(t);
const x = xOf(t);
const xEnd = xOf(t + DAY);
if (d.getDay() === 0 || d.getDay() === 6) {
svg.push(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
}
if (t > t0) svg.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
});
if (subDay) {
for (let t = first.getTime(); t <= t1; t += step) {
if (t < t0) continue;
const d = new Date(t);
if (d.getHours() === 0 && d.getMinutes() === 0) continue;
svg.push(`<line class="grid" x1="${xOf(t).toFixed(1)}" y1="0" x2="${xOf(t).toFixed(1)}" y2="${H}"/>`);
}
}
rows.forEach((s, i) => {
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
});
rows.forEach((s, i) => {
const color = colorFor(s);
const y = i * ROW_H + 6;
const x = xOf(s.startEpoch * 1000);
const w = Math.max(1.5, xOf(s.endEpoch * 1000) - x);
svg.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="10" rx="2" fill="${color}" fill-opacity="0.24"/>`);
s.spans.forEach(([a, b]) => {
// 最小幅を確保しつつ、全体バーの内側に収める
const sx = Math.min(xOf(a * 1000), x + w - 0.8);
const sw = Math.min(Math.max(0.8, xOf(b * 1000) - sx), x + w - sx);
svg.push(`<rect x="${sx.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="10" rx="1" fill="${color}"/>`);
});
});
if (g.now * 1000 > t0 && g.now * 1000 <= t1) {
const x = xOf(g.now * 1000).toFixed(1);
svg.push(`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`);
}
rows.forEach((s, i) => {
const tip = [
s.title,
cross ? s.projectPath : '',
`${fmtDateTime(s.start)} → ${fmtDateTime(s.end)}`,
`全体 ${fmtDur(s.wallMs)} · 作業 ${fmtDur(s.activeMs)} · ${s.turnCount} 往復`,
]
.filter(Boolean)
.join('\n');
svg.push(
`<rect class="g-hit" data-id="${esc(s.id)}" data-project="${esc(s.project)}" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"><title>${esc(tip)}</title></rect>`
);
});
const labels = rows
.map(
(s) => `<div class="g-lab-row${s.id === state.session ? ' sel' : ''}" data-id="${esc(s.id)}" data-project="${esc(s.project)}" title="${esc(s.title)}">
<i style="background:${colorFor(s)}"></i>
<span class="g-when">${fmtDateTime(s.start).slice(3)}</span>
<span class="g-title">${esc(s.title)}</span>
</div>`
)
.join('');
$('#detail-body').innerHTML = `${head}
<div class="gantt" style="--labw:${labW}px">
<div class="g-head">
<div class="g-lab-h">タスク ${rows.length} 件(開始順)</div>
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis.join('')}</svg>
</div>
<div class="g-body">
<div class="g-labels">${labels}</div>
<svg class="g-plot" width="${plotW}" height="${H}" viewBox="0 0 ${plotW} ${H}">${svg.join('')}</svg>
</div>
</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>${p.sessions} タスク</b></span>`)
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${g.projects.length - legend.length} 件</span>` : ''}</div>`
: ''}`;
wireRanges('ganttDays', loadGantt);
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
});
}
function wireRanges(key, reload) {
document.querySelectorAll('#detail-body .rng').forEach((btn) => {
btn.onclick = () => {
state[key] = Number(btn.dataset.days);
updateCsv();
reload();
};
});
}
// ---------------------------------------------------------------- 自動追従
const FOLLOW_KEY = 'chktime.follow';
const pad2 = (n) => String(n).padStart(2, '0');
function followStatus(kind, text) {
const el = $('#follow-state');
el.className = 'fstate ' + kind;
el.textContent = text ?? '';
}
function nowHms() {
const d = new Date();
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */
async function refreshFollow() {
if (state.refreshing) return;
state.refreshing = true;
const box = $('#detail-body');
const list = $('#sessions .scroll');
const boxTop = box.scrollTop;
const listTop = list.scrollTop;
const atBottom = box.scrollHeight - box.clientHeight - boxTop < 40;
try {
await loadProjects({ select: false });
await reloadSessions();
if (state.view === 'daily') await loadDaily({ silent: true });
else if (state.view === 'gantt') await loadGantt({ silent: true });
else await reloadDetail();
list.scrollTop = listTop;
// やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚)
box.scrollTop = atBottom && state.view === 'turns' ? box.scrollHeight : boxTop;
followStatus('live', `更新 ${nowHms()}`);
} catch (e) {
followStatus('err', esc(e.message).slice(0, 40));
} finally {
state.refreshing = false;
}
}
function scheduleRefresh() {
// 書き込み中は数秒おきに飛んでくるので、まとめてから読み直す
followStatus('busy', '更新あり…');
clearTimeout(state.followTimer);
state.followTimer = setTimeout(refreshFollow, 1200);
}
function revOf(event) {
try {
return JSON.parse(event.data).rev;
} catch {
return null;
}
}
function startFollow() {
if (state.es) return;
state.follow = true;
followStatus('wait', '接続中…');
const es = new EventSource('/api/watch');
es.onopen = () => followStatus('live', '追従中');
es.addEventListener('hello', (e) => {
const rev = revOf(e);
// OFF の間や切断中の変更を取り込む (初回接続時は読み込み済みなので不要)
const stale = state.rev !== null && rev !== state.rev;
state.rev = rev;
followStatus('live', '追従中');
if (stale) scheduleRefresh();
});
es.addEventListener('change', (e) => {
state.rev = revOf(e);
scheduleRefresh();
});
es.onerror = () => followStatus('err', '切断(再接続待ち)');
state.es = es;
}
function stopFollow() {
state.follow = false;
clearTimeout(state.followTimer);
if (state.es) {
state.es.close();
state.es = null;
}
followStatus('off', '');
}
// ---------------------------------------------------------------- 起動
$('#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');
$('#tab-gantt').onclick = () => setView('gantt');
$('#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 === 'turns') return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (state.view === 'daily' && state.daily) renderDaily();
else if (state.view === 'gantt' && state.gantt) renderGantt();
}, 150);
});
$('#follow').checked = localStorage.getItem(FOLLOW_KEY) !== '0'; // 既定は ON
$('#follow').onchange = (e) => {
localStorage.setItem(FOLLOW_KEY, e.target.checked ? '1' : '0');
if (e.target.checked) startFollow();
else stopFollow();
};
loadProjects()
.then(() => {
if ($('#follow').checked) startFollow();
})
.catch((e) => {
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
});