タスクを時間軸に並べるガントタブを追加
薄いバーで開始〜終了の全体、濃い部分で応答が動いていた区間を重ねて描く。 放置していた時間や、セッションを並行して動かしていた時間が見える。 - /api/timeline: セッションごとに全体の範囲と応答中の区間 (spans) を返す (隙間 1 秒以内のターンは結合、期間指定 days= / 全期間、CSV も可) - ガントタブ: 1日/3日/7日/14日/30日/全期間の切替、1〜3日では時刻目盛りに 自動で切替、土日の帯・日境界線・現在時刻の点線、横断時はプロジェクト別の色 - 行のホバーで内訳、クリックでそのタスクのやりとりを開く - ラベル列とバーの行を22pxで揃え、時間軸ヘッダは縦スクロール中も固定 - ペイン幅に応じてラベル幅を詰め、横スクロールを出さない Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+209
-6
@@ -13,6 +13,8 @@ const state = {
|
||||
view: 'turns',
|
||||
days: 30,
|
||||
daily: null,
|
||||
ganttDays: 7,
|
||||
gantt: null,
|
||||
};
|
||||
|
||||
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca'];
|
||||
@@ -124,6 +126,7 @@ async function selectProject(id) {
|
||||
const tbody = $('#session-table tbody');
|
||||
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
|
||||
if (state.view === 'daily') loadDaily();
|
||||
else if (state.view === 'gantt') loadGantt();
|
||||
try {
|
||||
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
|
||||
state.sessions = data.sessions;
|
||||
@@ -279,19 +282,26 @@ function renderDetail() {
|
||||
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 dailyQuery(extra) {
|
||||
const q = new URLSearchParams({ days: String(state.days), ...extra });
|
||||
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 出力';
|
||||
@@ -305,6 +315,7 @@ function setView(view) {
|
||||
syncTabs();
|
||||
updateCsv();
|
||||
if (view === 'daily') loadDaily();
|
||||
else if (view === 'gantt') loadGantt();
|
||||
else renderDetail();
|
||||
}
|
||||
|
||||
@@ -472,11 +483,199 @@ function renderDaily() {
|
||||
: ''}
|
||||
${table}`;
|
||||
|
||||
wireRanges('days', loadDaily);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ガント
|
||||
|
||||
async function loadGantt() {
|
||||
$('#detail-title').textContent = 'ガント';
|
||||
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
|
||||
try {
|
||||
state.gantt = await api('/api/timeline?' + ganttQuery());
|
||||
renderGantt();
|
||||
} catch (e) {
|
||||
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
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) => `<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);
|
||||
|
||||
const head = `
|
||||
<div class="sum flat">
|
||||
<div><span class="k">期間</span><span class="v">${fmtDateTime(new Date(g.from * 1000).toISOString())} → ${fmtDateTime(new Date(g.to * 1000).toISOString())}</span></div>
|
||||
<div><span class="k">タスク</span><span class="v">${rows.length}</span></div>
|
||||
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(totalActive)}</span></div>
|
||||
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
|
||||
</div>
|
||||
<div class="chart-bar">
|
||||
<span class="rngs">${ranges}</span>
|
||||
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
|
||||
</div>`;
|
||||
|
||||
if (!rows.length) {
|
||||
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
|
||||
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(`<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}"/>`);
|
||||
}
|
||||
}
|
||||
rows.forEach((s, i) => {
|
||||
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
|
||||
});
|
||||
rows.forEach((s, i) => {
|
||||
const color = colorFor(s);
|
||||
const y = i * ROW_H + 6;
|
||||
const x = xOf(s.startEpoch * 1000);
|
||||
const w = Math.max(1.5, xOf(s.endEpoch * 1000) - x);
|
||||
svg.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="10" rx="2" fill="${color}" fill-opacity="0.24"/>`);
|
||||
s.spans.forEach(([a, b]) => {
|
||||
// 最小幅を確保しつつ、全体バーの内側に収める
|
||||
const sx = Math.min(xOf(a * 1000), x + w - 0.8);
|
||||
const sw = Math.min(Math.max(0.8, xOf(b * 1000) - sx), x + w - sx);
|
||||
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}"/>`);
|
||||
}
|
||||
rows.forEach((s, i) => {
|
||||
const tip = [
|
||||
s.title,
|
||||
cross ? s.projectPath : '',
|
||||
`${fmtDateTime(s.start)} → ${fmtDateTime(s.end)}`,
|
||||
`全体 ${fmtDur(s.wallMs)} · 作業 ${fmtDur(s.activeMs)} · ${s.turnCount} 往復`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
svg.push(
|
||||
`<rect class="g-hit" data-id="${esc(s.id)}" data-project="${esc(s.project)}" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"><title>${esc(tip)}</title></rect>`
|
||||
);
|
||||
});
|
||||
|
||||
const labels = rows
|
||||
.map(
|
||||
(s) => `<div class="g-lab-row${s.id === state.session ? ' sel' : ''}" data-id="${esc(s.id)}" data-project="${esc(s.project)}" title="${esc(s.title)}">
|
||||
<i style="background:${colorFor(s)}"></i>
|
||||
<span class="g-when">${fmtDateTime(s.start).slice(3)}</span>
|
||||
<span class="g-title">${esc(s.title)}</span>
|
||||
</div>`
|
||||
)
|
||||
.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>
|
||||
${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>`)
|
||||
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${g.projects.length - legend.length} 件</span>` : ''}</div>`
|
||||
: ''}`;
|
||||
|
||||
wireRanges('ganttDays', loadGantt);
|
||||
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
|
||||
el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
|
||||
});
|
||||
}
|
||||
|
||||
function wireRanges(key, reload) {
|
||||
document.querySelectorAll('#detail-body .rng').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
state.days = Number(btn.dataset.days);
|
||||
state[key] = Number(btn.dataset.days);
|
||||
updateCsv();
|
||||
loadDaily();
|
||||
reload();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -490,6 +689,7 @@ $('#relative').onchange = (e) => {
|
||||
};
|
||||
$('#tab-turns').onclick = () => setView('turns');
|
||||
$('#tab-daily').onclick = () => setView('daily');
|
||||
$('#tab-gantt').onclick = () => setView('gantt');
|
||||
$('#reload').onclick = async () => {
|
||||
await fetch('/api/reload');
|
||||
const keep = { project: state.sessionProject, session: state.session, view: state.view };
|
||||
@@ -499,9 +699,12 @@ $('#reload').onclick = async () => {
|
||||
|
||||
let resizeTimer = null;
|
||||
window.addEventListener('resize', () => {
|
||||
if (state.view !== 'daily') return;
|
||||
if (state.view === 'turns') return;
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => state.daily && renderDaily(), 150);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (state.view === 'daily' && state.daily) renderDaily();
|
||||
else if (state.view === 'gantt' && state.gantt) renderGantt();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
loadProjects().catch((e) => {
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<span class="tabs">
|
||||
<button class="tab sel" id="tab-turns">やりとり</button>
|
||||
<button class="tab" id="tab-daily">日別集計</button>
|
||||
<button class="tab" id="tab-gantt">ガント</button>
|
||||
</span>
|
||||
<a id="csv-turns" class="btn" href="#" download>CSV</a>
|
||||
</div>
|
||||
|
||||
@@ -213,3 +213,51 @@ table { width: 100%; border-collapse: collapse; }
|
||||
#daily-table .d { color: var(--dim); font-size: 11px; }
|
||||
#daily-table .pj { display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 11px; color: var(--dim); }
|
||||
.pj-i { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
|
||||
/* ガント */
|
||||
.sum.flat { position: static; }
|
||||
.gantt { padding: 4px 14px 0; }
|
||||
.gantt .g-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.gantt .g-lab-h {
|
||||
width: var(--labw);
|
||||
flex: none;
|
||||
color: var(--dim);
|
||||
font-size: 10.5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.gantt .g-body { display: flex; }
|
||||
.gantt .g-labels { width: var(--labw); flex: none; }
|
||||
.gantt .g-lab-row {
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-right: 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gantt .g-lab-row:nth-child(even) { background: rgba(255, 255, 255, .02); }
|
||||
.gantt .g-lab-row:hover { background: var(--pane2); }
|
||||
.gantt .g-lab-row.sel { box-shadow: inset 3px 0 var(--accent); }
|
||||
.gantt .g-lab-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
|
||||
.gantt .g-when { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.gantt .g-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.gantt svg text.ax { fill: var(--dim); font-size: 10px; }
|
||||
.gantt svg text.ax.hi { fill: var(--fg); }
|
||||
.gantt svg line.grid { stroke: var(--line); stroke-width: 1; }
|
||||
.gantt svg line.day { stroke: var(--line); stroke-width: 1; }
|
||||
.gantt svg line.now { stroke: var(--warn); stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
.gantt svg rect.wk { fill: #ffffff; opacity: .028; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user