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(``); + 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; @@ -558,7 +703,6 @@ function renderGantt() { 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); @@ -570,6 +714,7 @@ function renderGantt() {
最長タスク${longest ? fmtDur(longest.wallMs) : '–'}
+ ${unitButtons()} ${ranges} 薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。
`; @@ -577,59 +722,23 @@ function renderGantt() { if (!rows.length) { $('#detail-body').innerHTML = head + '

この期間のタスクはありません。

'; 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(``); - axis.push( - `${label}` - ); - } - - // 背景 (土日と日境界) → バー → 当たり判定 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(``); - } - if (t > t0) svg.push(``); - }); - 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(``); - } - } + const axis = axisSvg(sc); + // 背景 (土日と日境界) → バー → 当たり判定 + const svg = gridSvg(sc, H); rows.forEach((s, i) => { if (i % 2) svg.push(``); }); @@ -646,10 +755,7 @@ function renderGantt() { svg.push(``); }); }); - if (g.now * 1000 > t0 && g.now * 1000 <= t1) { - const x = xOf(g.now * 1000).toFixed(1); - svg.push(``); - } + svg.push(...nowLine(sc, H, g.now * 1000)); rows.forEach((s, i) => { const tip = [ s.title, @@ -675,16 +781,15 @@ function renderGantt() { .join(''); $('#detail-body').innerHTML = `${head} -
-
-
タスク ${rows.length} 件(開始順)
- ${axis.join('')} -
-
-
${labels}
- ${svg.join('')} -
-
+ ${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} タスク`) @@ -692,13 +797,127 @@ function renderGantt() { : ''}`; 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').forEach((btn) => { + document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => { btn.onclick = () => { state[key] = Number(btn.dataset.days); updateCsv(); @@ -710,7 +929,6 @@ function wireRanges(key, reload) { // ---------------------------------------------------------------- 自動追従 const FOLLOW_KEY = 'chktime.follow'; -const pad2 = (n) => String(n).padStart(2, '0'); function followStatus(kind, text) { const el = $('#follow-state'); @@ -720,7 +938,7 @@ function followStatus(kind, text) { function nowHms() { const d = new Date(); - return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; + return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`; } /** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */ @@ -819,7 +1037,7 @@ window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (state.view === 'daily' && state.daily) renderDaily(); - else if (state.view === 'gantt' && state.gantt) renderGantt(); + else if (state.view === 'gantt') renderGantt(); }, 150); }); diff --git a/web/style.css b/web/style.css index 41db165..a954765 100644 --- a/web/style.css +++ b/web/style.css @@ -275,3 +275,10 @@ table { width: 100%; border-collapse: collapse; } .gantt svg rect.stripe { fill: #ffffff; opacity: .02; } .gantt svg rect.g-hit { fill: transparent; cursor: pointer; } .gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; } + +/* ガント (ターン単位) */ +.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; } +.gantt svg rect.t-wait { fill: var(--user); } +.gantt svg rect.t-work { fill: var(--work); } +.gantt svg rect.t-none { fill: var(--idle); } +#turns tr.hi td { background: rgba(122, 162, 247, .18); }