ガントにターン単位の表示を追加
1セッション内のターンを絶対時刻の共通軸に並べる表示を、ガントタブの タスク/ターン切替として追加。やりとりタブのタイムライン列は行ごとに 正規化されていて長さを比較できないため、実時間で並べられるようにした。 - 軸・背景・枠組みをタスク単位の描画と共通化 (timeScale/axisSvg/gridSvg/ganttFrame) - 目盛りを秒単位まで拡張し、開始位置を step の単位で切り捨てるよう修正 - 行クリックでやりとりタブの該当ターンへ移動 (一時ハイライト) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+289
-71
@@ -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 = '<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() {
|
||||
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 = `<p class="err">${esc(e.message)}</p>`;
|
||||
@@ -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 = '<p class="loading">集計中…</p>';
|
||||
}
|
||||
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 = `<p class="err">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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(`<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;
|
||||
if (!g) return;
|
||||
const cross = !state.project;
|
||||
@@ -558,7 +703,6 @@ function renderGantt() {
|
||||
const ranges = [1, 3, 7, 14, 30, 0]
|
||||
.map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
|
||||
.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() {
|
||||
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
|
||||
</div>
|
||||
<div class="chart-bar">
|
||||
${unitButtons()}
|
||||
<span class="rngs">${ranges}</span>
|
||||
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
|
||||
</div>`;
|
||||
@@ -577,59 +722,23 @@ function renderGantt() {
|
||||
if (!rows.length) {
|
||||
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
|
||||
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(`<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 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(`<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}"/>`);
|
||||
}
|
||||
}
|
||||
const axis = axisSvg(sc);
|
||||
// 背景 (土日と日境界) → バー → 当たり判定
|
||||
const svg = gridSvg(sc, H);
|
||||
rows.forEach((s, i) => {
|
||||
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}"/>`);
|
||||
});
|
||||
});
|
||||
if (g.now * 1000 > t0 && g.now * 1000 <= t1) {
|
||||
const x = xOf(g.now * 1000).toFixed(1);
|
||||
svg.push(`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`);
|
||||
}
|
||||
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}
|
||||
<div class="gantt" style="--labw:${labW}px">
|
||||
<div class="g-head">
|
||||
<div class="g-lab-h">タスク ${rows.length} 件(開始順)</div>
|
||||
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis.join('')}</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.join('')}</svg>
|
||||
</div>
|
||||
</div>
|
||||
${ganttFrame({
|
||||
labW,
|
||||
plotW,
|
||||
H,
|
||||
labelHead: `タスク ${rows.length} 件(開始順)`,
|
||||
labels,
|
||||
axis: axis.join(''),
|
||||
svg: svg.join(''),
|
||||
})}
|
||||
${cross
|
||||
? `<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>`)
|
||||
@@ -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) => `
|
||||
<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) {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user