'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,
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
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 `${day} ${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 clip = (s, n) => (String(s ?? '').length > n ? String(s).slice(0, n) + '…' : String(s ?? ''));
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 = `
すべてのプロジェクト
${totalSessions} タスク · ${fmtBytes(totalBytes)} · ${data.projects.length} プロジェクト
`;
all.onclick = () => selectProject(null);
ul.appendChild(all);
data.projects.forEach((p) => {
const li = document.createElement('li');
li.dataset.id = p.id;
li.innerHTML = `${esc(p.path)}
${p.sessions} タスク · ${fmtBytes(p.bytes)} · ${ago(p.lastActivity)}
`;
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 = '| 解析中… |
';
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 = `| ${esc(e.message)} |
`;
}
}
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 = '| 該当なし |
';
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 = `
${cross ? ` ${esc(shortPath(s.projectPath))} ` : ''}
${esc(s.title)}
${fmtDateTime(s.start)}
${s.turnCount} 往復
全体 ${fmtDur(s.wallMs)}
作業 ${fmtDur(s.activeMs)}
${s.gitBranch ? `⑂ ${esc(s.gitBranch)}` : ''}
| `;
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) {
// ターン単位のガントを見ている間は、タスクを選び直してもそのまま並べ替える
const stayGantt = state.view === 'gantt' && state.ganttUnit === 'turn';
state.sessionProject = project;
state.session = id;
if (!stayGantt) state.view = 'turns';
syncTabs();
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
updateCsv();
$('#detail-body').innerHTML = '読み込み中…
';
if (stayGantt) await loadGantt({ silent: true });
else await reloadDetail();
}
function fetchDetail() {
return api(`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`);
}
async function reloadDetail() {
if (!state.session) return renderDetail();
try {
state.detail = await fetchDetail();
renderDetail();
} catch (e) {
$('#detail-body').innerHTML = `${esc(e.message)}
`;
}
}
function renderDetail() {
const d = state.detail;
if (!d) {
$('#detail-title').textContent = 'やりとりの時刻';
$('#detail-body').innerHTML = '左のタスクを選んでください。
';
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 = `
開始${fmtDateTime(d.start)}
終了${fmtDateTime(d.end)}
全体${fmtDur(d.wallMs)}
作業時間合計${fmtDur(d.activeMs)}
往復${d.turnCount}
応答メッセージ${d.assistantMsgs}
ツール呼び出し${d.toolCalls}
サブエージェント${d.sidechainMsgs}
ログ${fmtBytes(d.bytes)} · ${esc(d.id.slice(0, 8))}
`;
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 `
| ${t.i} |
${when}${t.kind !== 'prompt' ? ` ${t.kind}` : ''} |
${t.idleMs !== null && t.idleMs < 0
? `並行`
: fmtDur(t.idleMs)} |
${fmtDur(t.waitMs)} |
${fmtDur(t.durMs)} |
${done} |
|
${t.assistant}msg${t.toolCalls ? ` ${t.toolCalls}tool` : ''} |
${esc(t.prompt)}${t.truncated ? ' …' : ''}
${tools ? `${esc(tools)} ` : ''}
${t.interrupted ? '⚠ 中断あり ' : ''}
|
`;
})
.join('');
$('#detail-body').innerHTML =
head +
(d.turns.length
? `
| # | ${state.relative ? '経過' : '発言時刻'} |
空き | 初回応答 | 応答時間 |
${state.relative ? '完了(経過)' : '完了時刻'} | タイムライン | 内訳 | やりとり |
${rows}
`
: 'やりとりが記録されていません。
');
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' && state.ganttUnit === 'task') {
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 = '集計中…
';
}
try {
state.daily = await api('/api/daily?' + dailyQuery());
renderDaily();
} catch (e) {
$('#detail-body').innerHTML = `${esc(e.message)}
`;
}
}
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(``);
svg.push(`${fmtAxis(v)}`);
}
rows.forEach((r, i) => {
const x = padL + i * slot;
if (r.dow >= 5) svg.push(``);
});
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(``);
});
});
svg.push(``);
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(`${label}`);
svg.push(`${DOW[r.dow]}`);
}
// 空の日もツールチップを出すため、スロット全体に当たり判定を重ねる
svg.push(`${esc(tip(r))}`);
});
const ranges = [14, 30, 90, 0]
.map((v) => ``)
.join('');
const active = rows.filter((r) => r.activeMs || r.turns).reverse();
const table = active.length
? `
| 日付 | 作業時間 | 往復 | タスク | ${cross ? 'プロジェクト | ' : ''}
${active
.map(
(r) => `
| ${r.date.slice(5).replace('-', '/')} ${DOW[r.dow]} |
${fmtDur(r.activeMs)} |
${r.turns} |
${r.sessions} |
${cross
? `${Object.entries(r.byProject)
.sort((a, b) => b[1] - a[1])
.map(
([id, v]) =>
`${esc(shortPath(projectPathOf(id)))} ${fmtDur(v)}`
)
.join('')} | `
: ''}
`
)
.join('')}
`
: 'この期間の記録はありません。
';
$('#detail-body').innerHTML = `
期間${rows.length ? `${rows[0].date} → ${rows[rows.length - 1].date}` : '–'}
稼働日${t.activeDays} / ${rows.length} 日
作業時間合計${fmtDur(t.activeMs)}
1稼働日あたり${t.activeDays ? fmtDur(Math.round(t.activeMs / t.activeDays)) : '–'}
往復${t.turns}
タスク${t.sessions}
最も長い日${t.busiestDate ? `${t.busiestDate.slice(5).replace('-', '/')} ${fmtDur(t.busiestMs)}` : '–'}
${ranges}
棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。
${cross
? `${legend
.map((p, i) => `${esc(shortPath(p.path))} ${fmtDur(p.activeMs)}`)
.join('')}${otherMs ? `その他 ${d.projects.length - legend.length} 件 ${fmtDur(otherMs)}` : ''}
`
: ''}
${table}`;
wireRanges('days', loadDaily);
}
// ---------------------------------------------------------------- ガント
async function loadGantt({ silent = false } = {}) {
if (!silent) {
$('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '集計中…
';
}
try {
if (state.ganttUnit === 'turn') {
if (state.session) state.detail = await fetchDetail();
renderGanttTurns();
} else {
state.gantt = await api('/api/timeline?' + ganttQuery());
renderGanttTasks();
}
} catch (e) {
$('#detail-body').innerHTML = `${esc(e.message)}
`;
}
}
function renderGantt() {
if (state.ganttUnit === 'turn') renderGanttTurns();
else renderGanttTasks();
}
const MIN = 60000;
const HOUR = 3600000;
const DAY = 86400000;
const TICK_STEPS = [
1000, 2000, 5000, 10000, 15000, 30000, MIN, 2 * MIN, 5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN,
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];
}
/** 目盛りの開始位置。step の単位で切り捨てる (ローカル時刻基準)。 */
function floorTo(t, step) {
const d = new Date(t);
d.setMilliseconds(0);
if (step < MIN) {
d.setSeconds(d.getSeconds() - (d.getSeconds() % (step / 1000)));
return d.getTime();
}
d.setSeconds(0);
if (step < HOUR) {
d.setMinutes(d.getMinutes() - (d.getMinutes() % (step / MIN)));
return d.getTime();
}
d.setMinutes(0);
if (step < DAY) {
d.setHours(d.getHours() - (d.getHours() % (step / HOUR)));
return d.getTime();
}
d.setHours(0, 0, 0, 0);
return d.getTime();
}
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;
}
/** 時間軸のスケール。tasks / turns で共通。 */
function timeScale(t0, t1, plotW) {
const span = Math.max(1, t1 - t0);
const step = tickStep(span, plotW);
return {
t0,
t1,
plotW,
step,
subDay: step < DAY,
subHour: step < HOUR,
xOf: (t) => ((Math.min(Math.max(t, t0), t1) - t0) / span) * plotW,
};
}
const p2 = (n) => String(n).padStart(2, '0');
/** 上部に固定表示する時刻軸 (高さ 26px の svg 中身)。 */
function axisSvg(sc) {
const out = [];
for (let t = floorTo(sc.t0, sc.step); t <= sc.t1; t += sc.step) {
if (t < sc.t0) continue;
const d = new Date(t);
const x = sc.xOf(t);
const midnight = d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0;
const label = !sc.subDay || midnight
? `${p2(d.getMonth() + 1)}/${p2(d.getDate())}`
: sc.subHour
? `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
: `${p2(d.getHours())}:${p2(d.getMinutes())}`;
out.push(``);
out.push(
`${label}`
);
}
return out;
}
/** 背景 (土日の帯・日境界・細かい目盛り線)。バーより先に描く。 */
function gridSvg(sc, H) {
const out = [];
midnightsIn(sc.t0, sc.t1).forEach((t) => {
const d = new Date(t);
const x = sc.xOf(t);
const xEnd = sc.xOf(t + DAY);
if (d.getDay() === 0 || d.getDay() === 6) {
out.push(``);
}
if (t > sc.t0) out.push(``);
});
if (sc.subDay) {
for (let t = floorTo(sc.t0, sc.step); t <= sc.t1; t += sc.step) {
if (t < sc.t0) continue;
const d = new Date(t);
if (d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0) continue;
out.push(``);
}
}
return out;
}
function nowLine(sc, H, nowMs) {
if (!(nowMs > sc.t0 && nowMs <= sc.t1)) return [];
const x = sc.xOf(nowMs).toFixed(1);
return [``];
}
/** ラベル列 + プロットの枠組み。tasks / turns で共通。 */
function ganttFrame({ labW, plotW, H, labelHead, labels, axis, svg }) {
return `
`;
}
const ROW_H = 22;
/** 表示単位の切り替えボタン (タスク / ターン)。 */
function unitButtons() {
return `${[['task', 'タスク'], ['turn', 'ターン']]
.map(([v, label]) => ``)
.join('')}`;
}
function wireUnits() {
document.querySelectorAll('#detail-body .unit').forEach((btn) => {
btn.onclick = () => {
state.ganttUnit = btn.dataset.unit;
updateCsv();
loadGantt();
};
});
}
function renderGanttTasks() {
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) => ``)
.join('');
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 = `
期間${fmtDateTime(new Date(g.from * 1000).toISOString())} → ${fmtDateTime(new Date(g.to * 1000).toISOString())}
タスク${rows.length}
作業時間合計${fmtDur(totalActive)}
最長タスク${longest ? fmtDur(longest.wallMs) : '–'}
${unitButtons()}
${ranges}
薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。
`;
if (!rows.length) {
$('#detail-body').innerHTML = head + 'この期間のタスクはありません。
';
wireRanges('ganttDays', loadGantt);
wireUnits();
return;
}
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 sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW); // 右端に少し余白
const xOf = sc.xOf;
const H = rows.length * ROW_H;
const axis = axisSvg(sc);
// 背景 (土日と日境界) → バー → 当たり判定
const svg = gridSvg(sc, H);
rows.forEach((s, i) => {
if (i % 2) svg.push(``);
});
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(``);
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(``);
});
});
svg.push(...nowLine(sc, H, g.now * 1000));
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(
`${esc(tip)}`
);
});
const labels = rows
.map(
(s) => `
${fmtDateTime(s.start).slice(3)}
${esc(s.title)}
`
)
.join('');
$('#detail-body').innerHTML = `${head}
${ganttFrame({
labW,
plotW,
H,
labelHead: `タスク ${rows.length} 件(開始順)`,
labels,
axis: axis.join(''),
svg: svg.join(''),
})}
${cross
? `${legend
.map((p, i) => `${esc(shortPath(p.path))} ${p.sessions} タスク`)
.join('')}${otherMs ? `その他 ${g.projects.length - legend.length} 件` : ''}
`
: ''}`;
wireRanges('ganttDays', loadGantt);
wireUnits();
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
});
}
// ---------------------------------------------------------------- ターン単位のガント
/** 選択中タスクのターンを、絶対時刻の共通軸に並べる。 */
function renderGanttTurns() {
const d = state.detail;
const head = (extra) => `
タスク${d ? esc(clip(d.title, 46)) : '–'}
開始${d ? fmtDateTime(d.start) : '–'}
全体${d ? fmtDur(d.wallMs) : '–'}
作業時間合計${d ? fmtDur(d.activeMs) : '–'}
往復${d ? d.turnCount : '–'}
${extra}
${unitButtons()}
1行=1ターン。青=初回応答待ち、緑=応答中、行間の空白=放置していた時間。行をクリックするとやりとりに移動します。
`;
$('#detail-title').textContent = 'ガント(ターン)' + (d ? ' — ' + d.title : '');
const turns = d ? d.turns.filter((t) => t.ts !== null && t.ts !== undefined) : [];
if (!turns.length) {
$('#detail-body').innerHTML =
head('') + `${d ? 'やりとりが記録されていません。' : '中央のタスク一覧から 1 つ選んでください。'}
`;
wireUnits();
return;
}
const boxW = $('#detail-body').clientWidth || 700;
const labW = Math.max(130, Math.min(240, Math.round(boxW * 0.34)));
const plotW = Math.max(180, boxW - labW - 28);
const t0 = Math.min(...turns.map((t) => t.ts)) * 1000;
// 一瞬で終わったタスクでも目盛りが出るよう、最小 10 秒ぶんの幅を確保する
const tEnd = Math.max(t0 + 10000, ...turns.map((t) => (t.endTs ?? t.ts) * 1000));
const sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW);
const xOf = sc.xOf;
const H = turns.length * ROW_H;
const axis = axisSvg(sc);
const svg = gridSvg(sc, H);
turns.forEach((t, i) => {
if (i % 2) svg.push(``);
});
const seg = (cls, a, b, y) => {
const x = xOf(a);
const w = Math.max(1, xOf(b) - x);
return ``;
};
turns.forEach((t, i) => {
const y = i * ROW_H + 6;
const a = t.ts * 1000;
const end = (t.endTs ?? t.ts) * 1000;
const reply = t.firstReplyTs ? t.firstReplyTs * 1000 : null;
if (!reply) {
svg.push(seg('t-none', a, end, y)); // 応答なし (中断など)
} else {
svg.push(seg('t-wait', a, reply, y));
if (end > reply) svg.push(seg('t-work', reply, end, y));
}
});
svg.push(...nowLine(sc, H, Date.now()));
turns.forEach((t, i) => {
const tip = [
`#${t.i}${t.kind !== 'prompt' ? ` [${t.kind}]` : ''}${t.interrupted ? ' ⚠中断' : ''} ${t.summary}`,
`発言 ${fmtDateTime(t.tsIso)} → 完了 ${t.endIso ? fmtTime(t.endIso) : '–'}`,
`初回応答 ${fmtDur(t.waitMs)} · 応答時間 ${fmtDur(t.durMs)} · 空き ${t.idleMs !== null && t.idleMs < 0 ? '並行' : fmtDur(t.idleMs)}`,
`${t.assistant} msg · ${t.toolCalls} tool`,
].join('\n');
svg.push(
`${esc(tip)}`
);
});
const labels = turns
.map(
(t) => `
#${t.i}
${fmtTime(t.tsIso)}
${t.interrupted ? '⚠ ' : ''}${esc(t.summary)}
`
)
.join('');
const overlaps = turns.filter((t) => t.idleMs !== null && t.idleMs < 0).length;
$('#detail-body').innerHTML =
head(overlaps ? `並行送信${overlaps}
` : '') +
ganttFrame({
labW,
plotW,
H,
labelHead: `ターン ${turns.length} 件`,
labels,
axis: axis.join(''),
svg: svg.join(''),
});
wireUnits();
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => jumpToTurn(Number(el.dataset.i));
});
}
/** ターンのガントから「やりとり」タブの該当行へ移動する。 */
function jumpToTurn(i) {
setView('turns');
const tr = document.querySelector(`#turns tr[data-i="${i}"]`);
if (!tr) return;
tr.scrollIntoView({ block: 'center' });
tr.classList.add('hi');
setTimeout(() => tr.classList.remove('hi'), 1800);
}
function wireRanges(key, reload) {
document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => {
btn.onclick = () => {
state[key] = Number(btn.dataset.days);
updateCsv();
reload();
};
});
}
// ---------------------------------------------------------------- 自動追従
const FOLLOW_KEY = 'chktime.follow';
function followStatus(kind, text) {
const el = $('#follow-state');
el.className = 'fstate ' + kind;
el.textContent = text ?? '';
}
function nowHms() {
const d = new Date();
return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(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') 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 = `${esc(e.message)}`;
});