'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, }; 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 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() { 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 = `
読み込み中…
'; try { state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`); 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 = `| # | ${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 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() { $('#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(`| 日付 | 作業時間 | 往復 | タスク | ${cross ? 'プロジェクト | ' : ''}
|---|---|---|---|---|
| ${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('')} | ` : ''}
この期間の記録はありません。
'; $('#detail-body').innerHTML = `集計中…
'; try { state.gantt = await api('/api/timeline?' + ganttQuery()); renderGantt(); } catch (e) { $('#detail-body').innerHTML = `${esc(e.message)}
`; } } 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) => ``) .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 = `この期間のタスクはありません。
'; 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(`