'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, dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う dailyTo: '', dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ) 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 ? `${rows}
#${state.relative ? '経過' : '発言時刻'} 空き初回応答応答時間 ${state.relative ? '完了(経過)' : '完了時刻'}タイムライン内訳やりとり
` : '

やりとりが記録されていません。

'); 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 ganttQuery(extra) { const q = new URLSearchParams({ days: String(state.ganttDays), ...extra }); if (state.project) q.set('project', state.project); return q.toString(); } const customRange = () => Boolean(state.dailyFrom || state.dailyTo); function dailyQuery(extra) { const q = new URLSearchParams(extra || {}); if (state.project) q.set('project', state.project); if (customRange()) { if (state.dailyFrom) q.set('from', state.dailyFrom); if (state.dailyTo) q.set('to', state.dailyTo); } else { q.set('days', String(state.days)); } // 除外は横断モードだけ (単一プロジェクト表示では自分自身を消してしまうので送らない) if (!state.project && state.dailyExclude.length) q.set('exclude', state.dailyExclude.join(',')); return q.toString(); } function updateCsv() { 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 custom = customRange(); const ranges = [14, 30, 90, 0] .map((v) => ``) .join(''); const dates = ` 〜 ${custom ? '' : ''} `; const active = rows.filter((r) => r.activeMs || r.turns).reverse(); const table = active.length ? `${cross ? '' : ''}${active .map( (r) => ` ${cross ? `` : ''} ` ) .join('')}
日付作業時間往復タスクプロジェクト
${r.date.slice(5).replace('-', '/')} ${DOW[r.dow]} ${fmtDur(r.activeMs)} ${r.turns} ${r.sessions}${Object.entries(r.byProject) .sort((a, b) => b[1] - a[1]) .map( ([id, v]) => `${esc(shortPath(projectPathOf(id)))} ${fmtDur(v)}` ) .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} ${dates} 棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。
${svg.join('')}
${cross ? `
${legend .map((p, i) => `${esc(shortPath(p.path))} ${fmtDur(p.activeMs)}`) .join('')}${otherMs ? `その他 ${d.projects.length - legend.length} 件 ${fmtDur(otherMs)}` : ''}
` : ''} ${cross && state.dailyExclude.length ? `
除外中${state.dailyExclude .map((id) => `✕ ${esc(shortPath(projectPathOf(id)))}`) .join('')}
` : ''} ${table}`; wireRanges('days', loadDaily); wireDaily(); } /** 日別集計の期間指定・プロジェクト除外を配線する。 */ function wireDaily() { const setRange = (from, to) => { state.dailyFrom = from; state.dailyTo = to; updateCsv(); loadDaily(); }; const from = $('#d-from'); const to = $('#d-to'); if (from) from.onchange = () => setRange(from.value, state.dailyTo); if (to) to.onchange = () => setRange(state.dailyFrom, to.value); const clear = $('#d-clear'); if (clear) clear.onclick = () => setRange('', ''); document.querySelectorAll('#detail-body .legend [data-ex]').forEach((el) => { el.onclick = () => { const id = el.dataset.ex; const at = state.dailyExclude.indexOf(id); if (at >= 0) state.dailyExclude.splice(at, 1); else state.dailyExclude.push(id); updateCsv(); loadDaily(); }; }); const exClear = $('#ex-clear'); if (exClear) exClear.onclick = () => { state.dailyExclude = []; updateCsv(); loadDaily(); }; } // ---------------------------------------------------------------- ガント 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 `
${labelHead}
${axis}
${labels}
${svg}
`; } 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)}`, // 表示範囲より前から続いているタスクは、数字が範囲内だけのものだと分かるようにする `${s.clipped ? '表示範囲内' : '全体'} ${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); if (key === 'days') state.dailyFrom = state.dailyTo = ''; // 日数指定と期間指定は排他 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', ''); } // ---------------------------------------------------------------- Redmine 起票 // 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。 // サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。 const rm = { cfg: null, // /api/redmine/config (キーは含まれない) meta: null, // プロジェクト / トラッカー / ステータス settings: false, // 接続設定を開いているか source: 'tasks', // tasks=chktime のタスク / git=git 履歴 mode: 'day', // git のまとめ方: day / type / all repo: '', from: '', to: '', tasks: new Map(), // 起票対象のタスク: キー -> {project, id} drafts: [], off: new Set(), // 下書きのうち外したもの (index) job: null, timer: null, busy: false, }; async function post(path, payload) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (data.error) throw new Error(data.error); return data; } /** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */ const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id }); const taskKey = (t) => `${t.project}/${t.id}`; function rmMsg(text, kind) { const el = $('#rm-msg'); el.className = kind === 'err' ? 'err-inline' : 'note'; el.textContent = text || ''; } function rmConn() { const el = $('#rm-conn'); if (!rm.cfg || !rm.cfg.url) { el.className = 'fstate wait'; el.textContent = '未設定'; } else if (!rm.cfg.hasKey) { el.className = 'fstate wait'; el.textContent = 'キー未設定'; } else { el.className = 'fstate live'; el.textContent = rm.cfg.url; } } async function openRedmine() { $('#rm-back').classList.remove('hidden'); rmMsg(''); try { rm.cfg = await api('/api/redmine/config'); rm.settings = !rm.cfg.url || !rm.cfg.hasKey; if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : ''); if (!rm.tasks.size && state.session) { const t = { project: state.sessionProject, id: state.session }; rm.tasks.set(taskKey(t), t); } renderRedmine(); if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta(); } catch (e) { $('#rm-body').innerHTML = `

${esc(e.message)}

`; } } function closeRedmine() { $('#rm-back').classList.add('hidden'); clearInterval(rm.timer); rm.timer = null; } async function loadRedmineMeta() { try { rmMsg('Redmine に接続しています…'); rm.meta = await api('/api/redmine/meta'); rmMsg(''); } catch (e) { rm.meta = null; rmMsg(e.message, 'err'); } renderRedmine(); } function optionList(items, selected, label) { return items .map((it) => ``) .join(''); } function renderRedmine() { rmConn(); const cfg = rm.cfg || {}; const meta = rm.meta; const settings = `

接続

${rm.settings ? `
設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。 自己署名証明書のサーバでは「証明書を検証する」を外してください。
` : ''}
`; const target = meta ? `

起票先

` : cfg.hasKey ? '

Redmine の情報を取得できていません。接続設定を確認してください。

' : ''; const tab = (v, label) => ``; const picker = rm.source === 'tasks' ? `
一覧に出ている ${state.sessions.length} 件から選びます
${ state.sessions.length ? state.sessions .map((s) => { const t = taskOf(s); return `
`; }) .join('') : '
タスクがありません
' }
` : `
〜
分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。
`; const source = `

取得元 ${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}

${picker}
`; const drafts = rm.drafts.length ? `

下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)

${rm.drafts .map( (d, i) => `
${esc(d.subject)} ${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''} ${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''}
${esc(d.description)}
` ) .join('')}
` : ''; $('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : ''); wireRedmine(); syncRun(); } function renderJob() { const j = rm.job; const pct = j.total ? Math.round((j.done / j.total) * 100) : 0; const ok = j.results.filter((r) => r.ok).length; const ng = j.results.length - ok; return `

起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}

成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}
${j.results .map( (r) => `
${r.ok ? '#' + r.issueId : '失敗'} ${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}
` ) .join('')}
`; } /** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */ function wireRedmine() { const on = (sel, ev, fn) => { const el = $(sel); if (el) el[ev] = fn; }; on('#rm-toggle', 'onclick', () => { rm.settings = !rm.settings; renderRedmine(); }); on('#rm-save', 'onclick', async () => { try { rmMsg('保存しています…'); rm.cfg = await post('/api/redmine/config', { url: $('#rm-url').value.trim(), key: $('#rm-key').value.trim(), verifySsl: $('#rm-verify').checked, interval: Number($('#rm-int').value) || 7, }); rm.settings = false; rm.meta = null; rmMsg('保存しました'); await loadRedmineMeta(); } catch (e) { rmMsg(e.message, 'err'); } }); document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => { btn.onclick = () => { rm.source = btn.dataset.src; renderRedmine(); }; }); document.querySelectorAll('#rm-body .rm-task').forEach((box) => { box.onchange = () => { const t = { project: box.dataset.project, id: box.dataset.id }; if (box.checked) rm.tasks.set(taskKey(t), t); else rm.tasks.delete(taskKey(t)); }; }); const pickAll = (want) => { state.sessions.forEach((s) => { const t = taskOf(s); if (want) rm.tasks.set(taskKey(t), t); else rm.tasks.delete(taskKey(t)); }); renderRedmine(); }; on('#rm-all', 'onclick', () => pickAll(true)); on('#rm-none', 'onclick', () => pickAll(false)); on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim())); on('#rm-from', 'onchange', (e) => (rm.from = e.target.value)); on('#rm-to', 'onchange', (e) => (rm.to = e.target.value)); on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value)); on('#rm-draft', 'onclick', makeDrafts); document.querySelectorAll('#rm-body .rm-pick').forEach((box) => { box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように box.onchange = () => { const i = Number(box.dataset.i); if (box.checked) rm.off.delete(i); else rm.off.add(i); renderRedmine(); }; }); document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => { head.onclick = (e) => { if (e.target.classList.contains('rm-pick')) return; head.parentElement.classList.toggle('open'); }; }); ['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun)); } /** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */ function syncRun() { const proj = $('#rm-proj'); $('#rm-run').disabled = rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value); } async function makeDrafts() { const payload = rm.source === 'tasks' ? { source: 'tasks', ids: [...rm.tasks.values()], } : { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode }; try { rmMsg('下書きを作っています…'); const data = await post('/api/redmine/draft', payload); rm.drafts = data.drafts; rm.off = new Set(); rm.job = null; rmMsg(`${rm.drafts.length} 件の下書きを作りました`); } catch (e) { rm.drafts = []; rmMsg(e.message, 'err'); } renderRedmine(); } async function runRedmine() { const drafts = rm.drafts.filter((_, i) => !rm.off.has(i)); if (!drafts.length) return; const defaults = { projectId: Number($('#rm-proj').value) || null, trackerId: Number($('#rm-tracker').value) || null, statusId: Number($('#rm-status').value) || null, assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null, doneRatio: $('#rm-done').checked ? 100 : null, }; const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1)); if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return; try { rm.busy = true; rmMsg('起票しています…'); // 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す post('/api/redmine/config', { projectId: defaults.projectId, trackerId: defaults.trackerId, statusId: defaults.statusId, assignedToId: defaults.assignedToId, }).catch(() => {}); rm.job = await post('/api/redmine/issues', { drafts, defaults }); renderRedmine(); clearInterval(rm.timer); rm.timer = setInterval(pollJob, 1000); } catch (e) { rm.busy = false; rmMsg(e.message, 'err'); renderRedmine(); } } async function pollJob() { if (!rm.job) return; try { rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`); } catch { return; } if (rm.job.state === 'done') { clearInterval(rm.timer); rm.timer = null; rm.busy = false; const ng = rm.job.results.filter((r) => !r.ok).length; rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : ''); } renderRedmine(); } // ---------------------------------------------------------------- 起動 $('#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'); $('#redmine').onclick = openRedmine; $('#rm-close').onclick = closeRedmine; $('#rm-run').onclick = runRedmine; $('#rm-back').onclick = (e) => { if (e.target.id === 'rm-back') closeRedmine(); }; document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine(); }); $('#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)}
  • `; });