ガントにターン単位の表示を追加

1セッション内のターンを絶対時刻の共通軸に並べる表示を、ガントタブの
タスク/ターン切替として追加。やりとりタブのタイムライン列は行ごとに
正規化されていて長さを比較できないため、実時間で並べられるようにした。

- 軸・背景・枠組みをタスク単位の描画と共通化 (timeScale/axisSvg/gridSvg/ganttFrame)
- 目盛りを秒単位まで拡張し、開始位置を step の単位で切り捨てるよう修正
- 行クリックでやりとりタブの該当ターンへ移動 (一時ハイライト)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:26:49 +09:00
co-authored by Claude Opus 5
parent 0d9faa54bb
commit 4692043106
3 changed files with 310 additions and 72 deletions
+14 -1
View File
@@ -65,7 +65,11 @@ python chktime.py
### ガントタブ ### ガントタブ
タスク(セッション)を 1 行ずつ、時間軸に並べたガントチャートです。 時間軸に並べたガントチャートです。左上の **タスク / ターン** で並べる単位を切り替えます。
#### タスク単位
タスク(セッション)を 1 行ずつ並べます。
- **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。 - **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。
- 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。 - 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。
@@ -73,6 +77,15 @@ python chktime.py
- 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。 - 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。
- 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。 - 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。
#### ターン単位
選択中のタスクのターンを 1 行ずつ、**絶対時刻の共通軸**に並べます。やりとりタブのタイムライン列は行ごとに正規化されているため長さを比べられませんが、こちらは横位置と長さがそのまま実時間です。
- **青** = 初回応答までの待ち、**緑** = 応答中(ツール実行・サブエージェント含む)、**灰** = 応答が返らなかったターン。行と行の空白がそのまま放置していた時間です。
- 表示範囲はそのタスクの開始〜終了に自動で合わせます(目盛りは秒〜日単位で自動選択)。バーが横に重なっていれば、前の応答中に次を送った(並行)ターンです。
- 行にカーソルを当てると発言内容・時刻・初回応答/応答時間/空き、クリックすると「やりとり」タブの該当行に移動します。
- タスクを選ばずに開いた場合は、中央のタスク一覧から選ぶと表示されます。ターン単位を表示している間は、タスクを選び直してもガントのまま切り替わります。
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 `CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
+289 -71
View File
@@ -14,6 +14,7 @@ const state = {
days: 30, days: 30,
daily: null, daily: null,
ganttDays: 7, ganttDays: 7,
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
gantt: null, gantt: null,
follow: false, follow: false,
es: null, es: null,
@@ -81,6 +82,8 @@ function ago(iso) {
return `${Math.round(diff / 86400)}日前`; 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
async function api(path) { async function api(path) {
@@ -195,22 +198,27 @@ function shortPath(path) {
// ---------------------------------------------------------------- 詳細 // ---------------------------------------------------------------- 詳細
async function selectSession(project, id) { async function selectSession(project, id) {
// ターン単位のガントを見ている間は、タスクを選び直してもそのまま並べ替える
const stayGantt = state.view === 'gantt' && state.ganttUnit === 'turn';
state.sessionProject = project; state.sessionProject = project;
state.session = id; state.session = id;
state.view = 'turns'; if (!stayGantt) state.view = 'turns';
syncTabs(); syncTabs();
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id)); document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
updateCsv(); updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>'; $('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
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() { async function reloadDetail() {
if (!state.session) return renderDetail(); if (!state.session) return renderDetail();
try { try {
state.detail = await api( state.detail = await fetchDetail();
`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`
);
renderDetail(); renderDetail();
} catch (e) { } catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`; $('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
@@ -322,7 +330,7 @@ function updateCsv() {
if (state.view === 'daily') { if (state.view === 'daily') {
link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
link.title = '日別集計を 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.href = '/api/timeline?' + ganttQuery({ format: 'csv' });
link.title = 'ガントのタスク一覧を CSV 出力'; link.title = 'ガントのタスク一覧を CSV 出力';
} else if (state.session) { } else if (state.session) {
@@ -519,22 +527,58 @@ async function loadGantt({ silent = false } = {}) {
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>'; $('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
} }
try { try {
state.gantt = await api('/api/timeline?' + ganttQuery()); if (state.ganttUnit === 'turn') {
renderGantt(); if (state.session) state.detail = await fetchDetail();
renderGanttTurns();
} else {
state.gantt = await api('/api/timeline?' + ganttQuery());
renderGanttTasks();
}
} catch (e) { } catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`; $('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
} }
} }
function renderGantt() {
if (state.ganttUnit === 'turn') renderGanttTurns();
else renderGanttTasks();
}
const MIN = 60000;
const HOUR = 3600000; const HOUR = 3600000;
const DAY = 86400000; 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) { function tickStep(span, plotW) {
const maxTicks = Math.max(3, Math.floor(plotW / 66)); const maxTicks = Math.max(3, Math.floor(plotW / 66));
return TICK_STEPS.find((s) => span / s <= maxTicks) || TICK_STEPS[TICK_STEPS.length - 1]; 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) { function midnightsIn(t0, t1) {
const out = []; const out = [];
const d = new Date(t0); const d = new Date(t0);
@@ -543,7 +587,108 @@ function midnightsIn(t0, t1) {
return out; 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(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
out.push(
`<text class="ax${midnight || !sc.subDay ? ' hi' : ''}" x="${Math.min(sc.plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > sc.plotW - 20 ? 'end' : 'middle'}">${label}</text>`
);
}
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(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
}
if (t > sc.t0) out.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
});
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(`<line class="grid" x1="${sc.xOf(t).toFixed(1)}" y1="0" x2="${sc.xOf(t).toFixed(1)}" y2="${H}"/>`);
}
}
return out;
}
function nowLine(sc, H, nowMs) {
if (!(nowMs > sc.t0 && nowMs <= sc.t1)) return [];
const x = sc.xOf(nowMs).toFixed(1);
return [`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`];
}
/** ラベル列 + プロットの枠組み。tasks / turns で共通。 */
function ganttFrame({ labW, plotW, H, labelHead, labels, axis, svg }) {
return `
<div class="gantt" style="--labw:${labW}px">
<div class="g-head">
<div class="g-lab-h">${labelHead}</div>
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis}</svg>
</div>
<div class="g-body">
<div class="g-labels">${labels}</div>
<svg class="g-plot" width="${plotW}" height="${H}" viewBox="0 0 ${plotW} ${H}">${svg}</svg>
</div>
</div>`;
}
const ROW_H = 22;
/** 表示単位の切り替えボタン (タスク / ターン)。 */
function unitButtons() {
return `<span class="rngs">${[['task', 'タスク'], ['turn', 'ターン']]
.map(([v, label]) => `<button class="rng unit${v === state.ganttUnit ? ' sel' : ''}" data-unit="${v}">${label}</button>`)
.join('')}</span>`;
}
function wireUnits() {
document.querySelectorAll('#detail-body .unit').forEach((btn) => {
btn.onclick = () => {
state.ganttUnit = btn.dataset.unit;
updateCsv();
loadGantt();
};
});
}
function renderGanttTasks() {
const g = state.gantt; const g = state.gantt;
if (!g) return; if (!g) return;
const cross = !state.project; const cross = !state.project;
@@ -558,7 +703,6 @@ function renderGantt() {
const ranges = [1, 3, 7, 14, 30, 0] const ranges = [1, 3, 7, 14, 30, 0]
.map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`) .map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join(''); .join('');
const p2 = (n) => String(n).padStart(2, '0');
const totalActive = rows.reduce((a, s) => a + s.activeMs, 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 longest = rows.reduce((a, s) => (a && a.wallMs >= s.wallMs ? a : s), null);
@@ -570,6 +714,7 @@ function renderGantt() {
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div> <div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
</div> </div>
<div class="chart-bar"> <div class="chart-bar">
${unitButtons()}
<span class="rngs">${ranges}</span> <span class="rngs">${ranges}</span>
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span> <span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
</div>`; </div>`;
@@ -577,59 +722,23 @@ function renderGantt() {
if (!rows.length) { if (!rows.length) {
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>'; $('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
wireRanges('ganttDays', loadGantt); wireRanges('ganttDays', loadGantt);
wireUnits();
return; return;
} }
const ROW_H = 22;
const boxW = $('#detail-body').clientWidth || 700; const boxW = $('#detail-body').clientWidth || 700;
// ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない) // ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない)
const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34))); const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34)));
const plotW = Math.max(180, boxW - labW - 28); const plotW = Math.max(180, boxW - labW - 28);
const t0 = g.from * 1000; const t0 = g.from * 1000;
const tEnd = Math.max(g.to * 1000, t0 + 60000); const tEnd = Math.max(g.to * 1000, t0 + 60000);
const t1 = tEnd + (tEnd - t0) * 0.01; // 右端に少し余白 (現在線と最後のバーを見やすく) const sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW); // 右端に少し余白
const xOf = (t) => ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * plotW; const xOf = sc.xOf;
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(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
axis.push(
`<text class="ax${midnight || !subDay ? ' hi' : ''}" x="${Math.min(plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > plotW - 20 ? 'end' : 'middle'}">${label}</text>`
);
}
// 背景 (土日と日境界) → バー → 当たり判定
const H = rows.length * ROW_H; const H = rows.length * ROW_H;
const svg = []; const axis = axisSvg(sc);
midnightsIn(t0, t1).forEach((t) => { // 背景 (土日と日境界) → バー → 当たり判定
const d = new Date(t); const svg = gridSvg(sc, H);
const x = xOf(t);
const xEnd = xOf(t + DAY);
if (d.getDay() === 0 || d.getDay() === 6) {
svg.push(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
}
if (t > t0) svg.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
});
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(`<line class="grid" x1="${xOf(t).toFixed(1)}" y1="0" x2="${xOf(t).toFixed(1)}" y2="${H}"/>`);
}
}
rows.forEach((s, i) => { rows.forEach((s, i) => {
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`); if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
}); });
@@ -646,10 +755,7 @@ function renderGantt() {
svg.push(`<rect x="${sx.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="10" rx="1" fill="${color}"/>`); svg.push(`<rect x="${sx.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="10" rx="1" fill="${color}"/>`);
}); });
}); });
if (g.now * 1000 > t0 && g.now * 1000 <= t1) { svg.push(...nowLine(sc, H, g.now * 1000));
const x = xOf(g.now * 1000).toFixed(1);
svg.push(`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`);
}
rows.forEach((s, i) => { rows.forEach((s, i) => {
const tip = [ const tip = [
s.title, s.title,
@@ -675,16 +781,15 @@ function renderGantt() {
.join(''); .join('');
$('#detail-body').innerHTML = `${head} $('#detail-body').innerHTML = `${head}
<div class="gantt" style="--labw:${labW}px"> ${ganttFrame({
<div class="g-head"> labW,
<div class="g-lab-h">タスク ${rows.length} 件(開始順)</div> plotW,
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis.join('')}</svg> H,
</div> labelHead: `タスク ${rows.length} 件(開始順)`,
<div class="g-body"> labels,
<div class="g-labels">${labels}</div> axis: axis.join(''),
<svg class="g-plot" width="${plotW}" height="${H}" viewBox="0 0 ${plotW} ${H}">${svg.join('')}</svg> svg: svg.join(''),
</div> })}
</div>
${cross ${cross
? `<div class="legend">${legend ? `<div class="legend">${legend
.map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${p.sessions} タスク</b></span>`) .map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${p.sessions} タスク</b></span>`)
@@ -692,13 +797,127 @@ function renderGantt() {
: ''}`; : ''}`;
wireRanges('ganttDays', loadGantt); wireRanges('ganttDays', loadGantt);
wireUnits();
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => { document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => selectSession(el.dataset.project, el.dataset.id); el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
}); });
} }
// ---------------------------------------------------------------- ターン単位のガント
/** 選択中タスクのターンを、絶対時刻の共通軸に並べる。 */
function renderGanttTurns() {
const d = state.detail;
const head = (extra) => `
<div class="sum flat">
<div><span class="k">タスク</span><span class="v" title="${d ? esc(d.title) : ''}">${d ? esc(clip(d.title, 46)) : '–'}</span></div>
<div><span class="k">開始</span><span class="v">${d ? fmtDateTime(d.start) : '–'}</span></div>
<div><span class="k">全体</span><span class="v">${d ? fmtDur(d.wallMs) : '–'}</span></div>
<div><span class="k">作業時間合計</span><span class="v">${d ? fmtDur(d.activeMs) : '–'}</span></div>
<div><span class="k">往復</span><span class="v">${d ? d.turnCount : '–'}</span></div>
${extra}
</div>
<div class="chart-bar">
${unitButtons()}
<span class="note">1行=1ターン。青=初回応答待ち、緑=応答中、行間の空白=放置していた時間。行をクリックするとやりとりに移動します。</span>
</div>`;
$('#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('') + `<p class="empty">${d ? 'やりとりが記録されていません。' : '中央のタスク一覧から 1 つ選んでください。'}</p>`;
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(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
});
const seg = (cls, a, b, y) => {
const x = xOf(a);
const w = Math.max(1, xOf(b) - x);
return `<rect class="${cls}" x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="10" rx="1"/>`;
};
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(
`<rect class="g-hit" data-i="${t.i}" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"><title>${esc(tip)}</title></rect>`
);
});
const labels = turns
.map(
(t) => `<div class="g-lab-row" data-i="${t.i}" title="${esc(t.summary)}">
<span class="g-no">#${t.i}</span>
<span class="g-when">${fmtTime(t.tsIso)}</span>
<span class="g-title">${t.interrupted ? '⚠ ' : ''}${esc(t.summary)}</span>
</div>`
)
.join('');
const overlaps = turns.filter((t) => t.idleMs !== null && t.idleMs < 0).length;
$('#detail-body').innerHTML =
head(overlaps ? `<div><span class="k">並行送信</span><span class="v">${overlaps}</span></div>` : '') +
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) { function wireRanges(key, reload) {
document.querySelectorAll('#detail-body .rng').forEach((btn) => { document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => {
btn.onclick = () => { btn.onclick = () => {
state[key] = Number(btn.dataset.days); state[key] = Number(btn.dataset.days);
updateCsv(); updateCsv();
@@ -710,7 +929,6 @@ function wireRanges(key, reload) {
// ---------------------------------------------------------------- 自動追従 // ---------------------------------------------------------------- 自動追従
const FOLLOW_KEY = 'chktime.follow'; const FOLLOW_KEY = 'chktime.follow';
const pad2 = (n) => String(n).padStart(2, '0');
function followStatus(kind, text) { function followStatus(kind, text) {
const el = $('#follow-state'); const el = $('#follow-state');
@@ -720,7 +938,7 @@ function followStatus(kind, text) {
function nowHms() { function nowHms() {
const d = new Date(); 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); clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => { resizeTimer = setTimeout(() => {
if (state.view === 'daily' && state.daily) renderDaily(); if (state.view === 'daily' && state.daily) renderDaily();
else if (state.view === 'gantt' && state.gantt) renderGantt(); else if (state.view === 'gantt') renderGantt();
}, 150); }, 150);
}); });
+7
View File
@@ -275,3 +275,10 @@ table { width: 100%; border-collapse: collapse; }
.gantt svg rect.stripe { fill: #ffffff; opacity: .02; } .gantt svg rect.stripe { fill: #ffffff; opacity: .02; }
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; } .gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; } .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); }