diff --git a/README.md b/README.md index 1936bc4..4f48397 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,11 @@ python chktime.py ### ガントタブ -タスク(セッション)を 1 行ずつ、時間軸に並べたガントチャートです。 +時間軸に並べたガントチャートです。左上の **タスク / ターン** で並べる単位を切り替えます。 + +#### タスク単位 + +タスク(セッション)を 1 行ずつ並べます。 - **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。 - 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。 @@ -73,6 +77,15 @@ python chktime.py - 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。 - 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。 +#### ターン単位 + +選択中のタスクのターンを 1 行ずつ、**絶対時刻の共通軸**に並べます。やりとりタブのタイムライン列は行ごとに正規化されているため長さを比べられませんが、こちらは横位置と長さがそのまま実時間です。 + +- **青** = 初回応答までの待ち、**緑** = 応答中(ツール実行・サブエージェント含む)、**灰** = 応答が返らなかったターン。行と行の空白がそのまま放置していた時間です。 +- 表示範囲はそのタスクの開始〜終了に自動で合わせます(目盛りは秒〜日単位で自動選択)。バーが横に重なっていれば、前の応答中に次を送った(並行)ターンです。 +- 行にカーソルを当てると発言内容・時刻・初回応答/応答時間/空き、クリックすると「やりとり」タブの該当行に移動します。 +- タスクを選ばずに開いた場合は、中央のタスク一覧から選ぶと表示されます。ターン単位を表示している間は、タスクを選び直してもガントのまま切り替わります。 + `CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 diff --git a/web/app.js b/web/app.js index 450ca96..bdefb99 100644 --- a/web/app.js +++ b/web/app.js @@ -14,6 +14,7 @@ const state = { days: 30, daily: null, ganttDays: 7, + ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる gantt: null, follow: false, es: null, @@ -81,6 +82,8 @@ function ago(iso) { 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) { @@ -195,22 +198,27 @@ function shortPath(path) { // ---------------------------------------------------------------- 詳細 async function selectSession(project, id) { + // ターン単位のガントを見ている間は、タスクを選び直してもそのまま並べ替える + const stayGantt = state.view === 'gantt' && state.ganttUnit === 'turn'; state.sessionProject = project; state.session = id; - state.view = 'turns'; + 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 = '
読み込み中…
'; - await reloadDetail(); + 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 api( - `/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}` - ); + state.detail = await fetchDetail(); renderDetail(); } catch (e) { $('#detail-body').innerHTML = `${esc(e.message)}
`; @@ -322,7 +330,7 @@ function updateCsv() { if (state.view === 'daily') { link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.title = '日別集計を CSV 出力'; - } else if (state.view === 'gantt') { + } else if (state.view === 'gantt' && state.ganttUnit === 'task') { link.href = '/api/timeline?' + ganttQuery({ format: 'csv' }); link.title = 'ガントのタスク一覧を CSV 出力'; } else if (state.session) { @@ -519,22 +527,58 @@ async function loadGantt({ silent = false } = {}) { $('#detail-body').innerHTML = '集計中…
'; } try { - state.gantt = await api('/api/timeline?' + ganttQuery()); - renderGantt(); + 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 = [HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 28 * DAY]; +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); @@ -543,7 +587,108 @@ function midnightsIn(t0, t1) { return out; } -function renderGantt() { +/** 時間軸のスケール。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(`この期間のタスクはありません。
'; wireRanges('ganttDays', loadGantt); + wireUnits(); 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 sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW); // 右端に少し余白 + const xOf = sc.xOf; - // 軸 (上部に固定表示) - 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(`${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(`