左ペインに「すべてのプロジェクト」を追加し、全プロジェクトのタスクを1つの 一覧で扱えるようにした。あわせて右ペインに日別集計タブを設けた。 - /api/all: 全プロジェクトのタスクを開始日時順に返す (CSVはプロジェクト列付き) - /api/daily: ターンをローカル日付で集計し、活動のない日も0で埋めて返す (プロジェクト別の内訳・期間指定 days= / 全期間・横持ちCSV) - 日別集計タブ: 作業時間の棒グラフ (横断時はプロジェクト別の積み上げ、 上位7件+その他)、14/30/90日・全期間の切替、日別テーブル、SVG手書きで 外部ライブラリなし - 再読込で横断モードの選択が先頭プロジェクトに戻る問題を修正 (未選択と横断モードを state.project の undefined / null で区別) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
510 lines
21 KiB
JavaScript
510 lines
21 KiB
JavaScript
'use strict';
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
// project: null = プロジェクト横断モード, undefined = 未選択 / view: turns=やりとり, daily=日別集計
|
||
const state = {
|
||
project: undefined,
|
||
projects: [],
|
||
sessionProject: null,
|
||
session: null,
|
||
sessions: [],
|
||
detail: null,
|
||
relative: false,
|
||
view: 'turns',
|
||
days: 30,
|
||
daily: null,
|
||
};
|
||
|
||
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca'];
|
||
const OTHER_COLOR = '#565f73';
|
||
const DOW = ['月', '火', '水', '木', '金', '土', '日'];
|
||
|
||
// ---------------------------------------------------------------- 表示ヘルパ
|
||
|
||
function fmtDur(ms) {
|
||
if (ms === null || ms === undefined) return '–';
|
||
if (ms < 0) return '-' + fmtDur(-ms);
|
||
const s = ms / 1000;
|
||
if (s < 1) return `${ms}ms`;
|
||
if (s < 60) return `${s.toFixed(1)}秒`;
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return `${m}分${String(Math.round(s - m * 60)).padStart(2, '0')}秒`;
|
||
const h = Math.floor(m / 60);
|
||
return `${h}時間${String(m - h * 60).padStart(2, '0')}分`;
|
||
}
|
||
|
||
function fmtTime(iso) {
|
||
if (!iso) return '–';
|
||
const d = new Date(iso);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')[0]}`;
|
||
}
|
||
|
||
function fmtDayTime(iso) {
|
||
if (!iso) return '–';
|
||
const d = new Date(iso);
|
||
const day = `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`;
|
||
return `<span class="d">${day}</span> ${fmtTime(iso)}`;
|
||
}
|
||
|
||
function sameDay(a, b) {
|
||
if (!a || !b) return false;
|
||
const x = new Date(a);
|
||
const y = new Date(b);
|
||
return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate();
|
||
}
|
||
|
||
function fmtDateTime(iso) {
|
||
if (!iso) return '–';
|
||
const d = new Date(iso);
|
||
return d.toLocaleString('ja-JP', { hour12: false, year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (n < 1024) return `${n}B`;
|
||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`;
|
||
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
||
}
|
||
|
||
function ago(iso) {
|
||
if (!iso) return '';
|
||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||
if (diff < 3600) return `${Math.max(1, Math.round(diff / 60))}分前`;
|
||
if (diff < 86400) return `${Math.round(diff / 3600)}時間前`;
|
||
return `${Math.round(diff / 86400)}日前`;
|
||
}
|
||
|
||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
|
||
async function api(path) {
|
||
const res = await fetch(path);
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
return data;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- プロジェクト
|
||
|
||
async function loadProjects() {
|
||
const data = await api('/api/projects');
|
||
state.projects = data.projects;
|
||
$('#root').textContent = data.root;
|
||
const ul = $('#project-list');
|
||
ul.innerHTML = '';
|
||
|
||
const all = document.createElement('li');
|
||
all.dataset.id = '';
|
||
all.className = 'all';
|
||
const totalSessions = data.projects.reduce((a, p) => a + p.sessions, 0);
|
||
const totalBytes = data.projects.reduce((a, p) => a + p.bytes, 0);
|
||
all.innerHTML = `<div class="p-path">すべてのプロジェクト</div>
|
||
<div class="p-meta">${totalSessions} タスク · ${fmtBytes(totalBytes)} · ${data.projects.length} プロジェクト</div>`;
|
||
all.onclick = () => selectProject(null);
|
||
ul.appendChild(all);
|
||
|
||
data.projects.forEach((p) => {
|
||
const li = document.createElement('li');
|
||
li.dataset.id = p.id;
|
||
li.innerHTML = `<div class="p-path">${esc(p.path)}</div>
|
||
<div class="p-meta">${p.sessions} タスク · ${fmtBytes(p.bytes)} · ${ago(p.lastActivity)}</div>`;
|
||
li.onclick = () => selectProject(p.id);
|
||
ul.appendChild(li);
|
||
});
|
||
|
||
// 再読込では選択を保つ (null = 横断モードもそのまま)
|
||
if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : null;
|
||
else if (state.project && !data.projects.some((p) => p.id === state.project)) state.project = null;
|
||
selectProject(state.project);
|
||
}
|
||
|
||
async function selectProject(id) {
|
||
state.project = id;
|
||
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (id ?? '')));
|
||
$('#csv-sessions').href = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv';
|
||
const tbody = $('#session-table tbody');
|
||
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
|
||
if (state.view === 'daily') loadDaily();
|
||
try {
|
||
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
|
||
state.sessions = data.sessions;
|
||
renderSessions();
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function renderSessions() {
|
||
const q = $('#filter').value.trim().toLowerCase();
|
||
const rows = state.sessions.filter(
|
||
(s) => !q || (s.title + ' ' + s.firstPrompt + ' ' + s.lastPrompt + ' ' + (s.projectPath || '')).toLowerCase().includes(q)
|
||
);
|
||
$('#sessions-title').textContent = `タスク ${rows.length}/${state.sessions.length}`;
|
||
const tbody = $('#session-table tbody');
|
||
tbody.innerHTML = '';
|
||
if (!rows.length) {
|
||
tbody.innerHTML = '<tr><td class="empty">該当なし</td></tr>';
|
||
return;
|
||
}
|
||
const cross = !state.project;
|
||
rows.forEach((s) => {
|
||
const tr = document.createElement('tr');
|
||
tr.dataset.id = s.id;
|
||
tr.className = s.id === state.session ? 'sel' : '';
|
||
tr.innerHTML = `
|
||
<td>
|
||
${cross ? `<div class="s-proj" title="${esc(s.projectPath || '')}">${esc(shortPath(s.projectPath))}</div>` : ''}
|
||
<div class="s-title">${esc(s.title)}</div>
|
||
<div class="s-meta">
|
||
<span class="s-when">${fmtDateTime(s.start)}</span>
|
||
<span>${s.turnCount} 往復</span>
|
||
<span>全体 ${fmtDur(s.wallMs)}</span>
|
||
<span>作業 ${fmtDur(s.activeMs)}</span>
|
||
${s.gitBranch ? `<span>⑂ ${esc(s.gitBranch)}</span>` : ''}
|
||
</div>
|
||
</td>`;
|
||
tr.onclick = () => selectSession(s.project || state.project, s.id);
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function shortPath(path) {
|
||
if (!path) return '(不明)';
|
||
const parts = String(path).split(/[\\/]/).filter(Boolean);
|
||
return parts.length > 2 ? '…' + parts.slice(-2).join('\\') : path;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 詳細
|
||
|
||
async function selectSession(project, id) {
|
||
state.sessionProject = project;
|
||
state.session = id;
|
||
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>';
|
||
try {
|
||
state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`);
|
||
renderDetail();
|
||
} catch (e) {
|
||
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderDetail() {
|
||
const d = state.detail;
|
||
if (!d) {
|
||
$('#detail-title').textContent = 'やりとりの時刻';
|
||
$('#detail-body').innerHTML = '<p class="empty">左のタスクを選んでください。</p>';
|
||
return;
|
||
}
|
||
$('#detail-title').textContent = d.title;
|
||
|
||
const maxSpan = Math.max(1, ...d.turns.map((t) => (t.idleMs || 0) + (t.durMs || 0)));
|
||
const scale = (ms) => {
|
||
const v = Math.max(0, ms || 0);
|
||
return v ? Math.max(2, Math.round((v / maxSpan) * 95)) : 0;
|
||
};
|
||
const t0 = d.turns.length ? new Date(d.turns[0].tsIso).getTime() : 0;
|
||
|
||
const head = `
|
||
<div class="sum">
|
||
<div><span class="k">開始</span><span class="v">${fmtDateTime(d.start)}</span></div>
|
||
<div><span class="k">終了</span><span class="v">${fmtDateTime(d.end)}</span></div>
|
||
<div><span class="k">全体</span><span class="v">${fmtDur(d.wallMs)}</span></div>
|
||
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(d.activeMs)}</span></div>
|
||
<div><span class="k">往復</span><span class="v">${d.turnCount}</span></div>
|
||
<div><span class="k">応答メッセージ</span><span class="v">${d.assistantMsgs}</span></div>
|
||
<div><span class="k">ツール呼び出し</span><span class="v">${d.toolCalls}</span></div>
|
||
<div><span class="k">サブエージェント</span><span class="v">${d.sidechainMsgs}</span></div>
|
||
<div><span class="k">ログ</span><span class="v">${fmtBytes(d.bytes)} · ${esc(d.id.slice(0, 8))}</span></div>
|
||
</div>`;
|
||
|
||
const rows = d.turns
|
||
.map((t) => {
|
||
const rel = (iso) => '+' + fmtDur(new Date(iso).getTime() - t0);
|
||
const when = state.relative ? rel(t.tsIso) : fmtDayTime(t.tsIso);
|
||
// 完了時刻は日をまたいだ時だけ日付を出す
|
||
const done = !t.endIso
|
||
? '–'
|
||
: state.relative
|
||
? rel(t.endIso)
|
||
: sameDay(t.endIso, t.tsIso)
|
||
? fmtTime(t.endIso)
|
||
: fmtDayTime(t.endIso);
|
||
const tools = t.toolList.map(([n, c]) => (c > 1 ? `${n}×${c}` : n)).join(', ');
|
||
return `
|
||
<tr data-i="${t.i}">
|
||
<td class="n">${t.i}</td>
|
||
<td class="time" title="${esc(fmtDateTime(t.tsIso))}">${when}${t.kind !== 'prompt' ? ` <span class="kind ${t.kind}">${t.kind}</span>` : ''}</td>
|
||
<td class="num">${t.idleMs !== null && t.idleMs < 0
|
||
? `<span class="ovl" title="前のターンの処理中に送信 (${fmtDur(-t.idleMs)} 重複)">並行</span>`
|
||
: fmtDur(t.idleMs)}</td>
|
||
<td class="num">${fmtDur(t.waitMs)}</td>
|
||
<td class="num">${fmtDur(t.durMs)}</td>
|
||
<td class="time done" title="${esc(fmtDateTime(t.endIso))}">${done}</td>
|
||
<td><div class="bar">
|
||
<i class="b-idle" style="width:${scale(t.idleMs)}px" title="空き ${fmtDur(t.idleMs)}"></i>
|
||
<i class="b-wait" style="width:${scale(t.waitMs)}px" title="初回応答まで ${fmtDur(t.waitMs)}"></i>
|
||
<i class="b-work" style="width:${scale((t.durMs || 0) - (t.waitMs || 0))}px" title="応答中 ${fmtDur((t.durMs || 0) - (t.waitMs || 0))}"></i>
|
||
</div></td>
|
||
<td class="n small">${t.assistant}<span class="u">msg</span>${t.toolCalls ? ` ${t.toolCalls}<span class="u">tool</span>` : ''}</td>
|
||
<td class="msg">
|
||
<div class="txt">${esc(t.prompt)}${t.truncated ? ' …' : ''}</div>
|
||
${tools ? `<div class="tools">${esc(tools)}</div>` : ''}
|
||
${t.interrupted ? '<div class="int">⚠ 中断あり</div>' : ''}
|
||
</td>
|
||
</tr>`;
|
||
})
|
||
.join('');
|
||
|
||
$('#detail-body').innerHTML =
|
||
head +
|
||
(d.turns.length
|
||
? `<table id="turns">
|
||
<thead><tr>
|
||
<th class="n">#</th><th>${state.relative ? '経過' : '発言時刻'}</th>
|
||
<th class="n">空き</th><th class="n">初回応答</th><th class="n">応答時間</th>
|
||
<th>${state.relative ? '完了(経過)' : '完了時刻'}</th><th>タイムライン</th><th class="n">内訳</th><th>やりとり</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`
|
||
: '<p class="empty">やりとりが記録されていません。</p>');
|
||
|
||
document.querySelectorAll('#turns .msg').forEach((td) => {
|
||
td.onclick = () => td.parentElement.classList.toggle('open');
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 日別集計
|
||
|
||
function syncTabs() {
|
||
$('#tab-turns').classList.toggle('sel', state.view === 'turns');
|
||
$('#tab-daily').classList.toggle('sel', state.view === 'daily');
|
||
}
|
||
|
||
function dailyQuery(extra) {
|
||
const q = new URLSearchParams({ days: String(state.days), ...extra });
|
||
if (state.project) q.set('project', state.project);
|
||
return q.toString();
|
||
}
|
||
|
||
function updateCsv() {
|
||
const link = $('#csv-turns');
|
||
if (state.view === 'daily') {
|
||
link.href = '/api/daily?' + dailyQuery({ 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 出力';
|
||
} else {
|
||
link.removeAttribute('href');
|
||
}
|
||
}
|
||
|
||
function setView(view) {
|
||
state.view = view;
|
||
syncTabs();
|
||
updateCsv();
|
||
if (view === 'daily') loadDaily();
|
||
else renderDetail();
|
||
}
|
||
|
||
async function loadDaily() {
|
||
$('#detail-title').textContent = '日別集計';
|
||
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
|
||
try {
|
||
state.daily = await api('/api/daily?' + dailyQuery());
|
||
renderDaily();
|
||
} catch (e) {
|
||
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
const STEPS = [5, 10, 15, 30, 60, 120, 180, 360, 720, 1440, 2880].map((m) => m * 60000);
|
||
|
||
function niceStep(maxMs) {
|
||
return STEPS.find((s) => maxMs / s <= 5) || STEPS[STEPS.length - 1];
|
||
}
|
||
|
||
function fmtAxis(ms) {
|
||
if (!ms) return '0';
|
||
const m = Math.round(ms / 60000);
|
||
if (m < 60) return `${m}分`;
|
||
const h = Math.floor(m / 60);
|
||
return h + '時間' + (m % 60 ? `${m % 60}分` : '');
|
||
}
|
||
|
||
function projectPathOf(id) {
|
||
const hit = state.projects.find((p) => p.id === id) || (state.daily && state.daily.projects.find((p) => p.id === id));
|
||
return hit ? hit.path : id;
|
||
}
|
||
|
||
function renderDaily() {
|
||
const d = state.daily;
|
||
if (!d) return;
|
||
const cross = !state.project;
|
||
const rows = d.days;
|
||
const t = d.totals;
|
||
$('#detail-title').textContent = '日別集計 — ' + (cross ? '全プロジェクト' : shortPath(projectPathOf(state.project)));
|
||
|
||
const legend = d.projects.slice(0, PALETTE.length);
|
||
const colorOf = new Map(legend.map((p, i) => [p.id, PALETTE[i]]));
|
||
const otherMs = d.projects.slice(PALETTE.length).reduce((a, p) => a + p.activeMs, 0);
|
||
const tip = (r) => {
|
||
const lines = [`${r.date}(${DOW[r.dow]})`, `作業 ${fmtDur(r.activeMs)} · ${r.turns} 往復 · ${r.sessions} タスク`];
|
||
if (cross) {
|
||
Object.entries(r.byProject)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5)
|
||
.forEach(([id, v]) => lines.push(` ${shortPath(projectPathOf(id))} ${fmtDur(v)}`));
|
||
}
|
||
return lines.join('\n');
|
||
};
|
||
|
||
const W = Math.max(380, $('#detail-body').clientWidth - 30);
|
||
const H = 258;
|
||
const padL = 64;
|
||
const padR = 12;
|
||
const padT = 12;
|
||
const padB = 38;
|
||
const plotW = W - padL - padR;
|
||
const plotH = H - padT - padB;
|
||
const n = Math.max(1, rows.length);
|
||
const slot = plotW / n;
|
||
const bw = Math.max(1, Math.min(30, slot * 0.78));
|
||
const maxMs = Math.max(1, ...rows.map((r) => r.activeMs));
|
||
const step = niceStep(maxMs);
|
||
const topMs = Math.max(step, Math.ceil(maxMs / step) * step);
|
||
const yOf = (ms) => padT + plotH - (ms / topMs) * plotH;
|
||
const every = Math.max(1, Math.ceil(n / 14));
|
||
|
||
const svg = [];
|
||
for (let v = 0; v <= topMs; v += step) {
|
||
const y = yOf(v).toFixed(1);
|
||
svg.push(`<line class="grid" x1="${padL}" y1="${y}" x2="${padL + plotW}" y2="${y}"/>`);
|
||
svg.push(`<text class="ax" x="${padL - 8}" y="${(yOf(v) + 3.5).toFixed(1)}" text-anchor="end">${fmtAxis(v)}</text>`);
|
||
}
|
||
rows.forEach((r, i) => {
|
||
const x = padL + i * slot;
|
||
if (r.dow >= 5) svg.push(`<rect class="wk" x="${x.toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"/>`);
|
||
});
|
||
rows.forEach((r, i) => {
|
||
const segs = [];
|
||
if (cross) {
|
||
legend.forEach((p) => {
|
||
if (r.byProject[p.id]) segs.push([colorOf.get(p.id), r.byProject[p.id]]);
|
||
});
|
||
const rest = Object.keys(r.byProject)
|
||
.filter((id) => !colorOf.has(id))
|
||
.reduce((a, id) => a + r.byProject[id], 0);
|
||
if (rest) segs.push([OTHER_COLOR, rest]);
|
||
} else if (r.activeMs) {
|
||
segs.push([PALETTE[1], r.activeMs]);
|
||
}
|
||
const bx = padL + i * slot + (slot - bw) / 2;
|
||
let acc = 0;
|
||
segs.forEach(([color, v]) => {
|
||
const y1 = yOf(acc + v);
|
||
const y0 = yOf(acc);
|
||
acc += v;
|
||
svg.push(`<rect x="${bx.toFixed(1)}" y="${y1.toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(1, y0 - y1).toFixed(1)}" fill="${color}"/>`);
|
||
});
|
||
});
|
||
svg.push(`<line class="axis" x1="${padL}" y1="${yOf(0).toFixed(1)}" x2="${padL + plotW}" y2="${yOf(0).toFixed(1)}"/>`);
|
||
rows.forEach((r, i) => {
|
||
const cx = padL + i * slot + slot / 2;
|
||
if ((n - 1 - i) % every === 0) {
|
||
const label = r.date.slice(5).replace('-', '/');
|
||
svg.push(`<text class="ax" x="${cx.toFixed(1)}" y="${padT + plotH + 15}" text-anchor="middle">${label}</text>`);
|
||
svg.push(`<text class="ax${r.dow >= 5 ? ' we' : ''}" x="${cx.toFixed(1)}" y="${padT + plotH + 27}" text-anchor="middle">${DOW[r.dow]}</text>`);
|
||
}
|
||
// 空の日もツールチップを出すため、スロット全体に当たり判定を重ねる
|
||
svg.push(`<rect class="hit" x="${(padL + i * slot).toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"><title>${esc(tip(r))}</title></rect>`);
|
||
});
|
||
|
||
const ranges = [14, 30, 90, 0]
|
||
.map((v) => `<button class="rng${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
|
||
.join('');
|
||
|
||
const active = rows.filter((r) => r.activeMs || r.turns).reverse();
|
||
const table = active.length
|
||
? `<table id="daily-table">
|
||
<thead><tr><th>日付</th><th class="n">作業時間</th><th class="n">往復</th><th class="n">タスク</th>${cross ? '<th>プロジェクト</th>' : ''}</tr></thead>
|
||
<tbody>${active
|
||
.map(
|
||
(r) => `<tr>
|
||
<td class="time${r.dow >= 5 ? ' we' : ''}">${r.date.slice(5).replace('-', '/')} <span class="d">${DOW[r.dow]}</span></td>
|
||
<td class="num">${fmtDur(r.activeMs)}</td>
|
||
<td class="num">${r.turns}</td>
|
||
<td class="num">${r.sessions}</td>
|
||
${cross
|
||
? `<td class="pj">${Object.entries(r.byProject)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(
|
||
([id, v]) =>
|
||
`<span class="pj-i" title="${esc(projectPathOf(id))}"><i style="background:${colorOf.get(id) || OTHER_COLOR}"></i>${esc(shortPath(projectPathOf(id)))} ${fmtDur(v)}</span>`
|
||
)
|
||
.join('')}</td>`
|
||
: ''}
|
||
</tr>`
|
||
)
|
||
.join('')}</tbody></table>`
|
||
: '<p class="empty">この期間の記録はありません。</p>';
|
||
|
||
$('#detail-body').innerHTML = `
|
||
<div class="sum">
|
||
<div><span class="k">期間</span><span class="v">${rows.length ? `${rows[0].date} → ${rows[rows.length - 1].date}` : '–'}</span></div>
|
||
<div><span class="k">稼働日</span><span class="v">${t.activeDays} / ${rows.length} 日</span></div>
|
||
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(t.activeMs)}</span></div>
|
||
<div><span class="k">1稼働日あたり</span><span class="v">${t.activeDays ? fmtDur(Math.round(t.activeMs / t.activeDays)) : '–'}</span></div>
|
||
<div><span class="k">往復</span><span class="v">${t.turns}</span></div>
|
||
<div><span class="k">タスク</span><span class="v">${t.sessions}</span></div>
|
||
<div><span class="k">最も長い日</span><span class="v">${t.busiestDate ? `${t.busiestDate.slice(5).replace('-', '/')} ${fmtDur(t.busiestMs)}` : '–'}</span></div>
|
||
</div>
|
||
<div class="chart-bar">
|
||
<span class="rngs">${ranges}</span>
|
||
<span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span>
|
||
</div>
|
||
<div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></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>${fmtDur(p.activeMs)}</b></span>`)
|
||
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>`
|
||
: ''}
|
||
${table}`;
|
||
|
||
document.querySelectorAll('#detail-body .rng').forEach((btn) => {
|
||
btn.onclick = () => {
|
||
state.days = Number(btn.dataset.days);
|
||
updateCsv();
|
||
loadDaily();
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 起動
|
||
|
||
$('#filter').oninput = renderSessions;
|
||
$('#relative').onchange = (e) => {
|
||
state.relative = e.target.checked;
|
||
if (state.view === 'turns') renderDetail();
|
||
};
|
||
$('#tab-turns').onclick = () => setView('turns');
|
||
$('#tab-daily').onclick = () => setView('daily');
|
||
$('#reload').onclick = async () => {
|
||
await fetch('/api/reload');
|
||
const keep = { project: state.sessionProject, session: state.session, view: state.view };
|
||
await loadProjects();
|
||
if (keep.view === 'turns' && keep.session) selectSession(keep.project, keep.session).catch(() => {});
|
||
};
|
||
|
||
let resizeTimer = null;
|
||
window.addEventListener('resize', () => {
|
||
if (state.view !== 'daily') return;
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => state.daily && renderDaily(), 150);
|
||
});
|
||
|
||
loadProjects().catch((e) => {
|
||
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
|
||
});
|