1524 lines
59 KiB
JavaScript
1524 lines
59 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,
|
||
dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う
|
||
dailyTo: '',
|
||
dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ)
|
||
daily: null,
|
||
ganttDays: 7,
|
||
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
|
||
gantt: null,
|
||
follow: false,
|
||
es: null,
|
||
rev: null,
|
||
followTimer: null,
|
||
refreshing: false,
|
||
};
|
||
|
||
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 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) {
|
||
const res = await fetch(path);
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
return data;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- プロジェクト
|
||
|
||
async function loadProjects({ select = true } = {}) {
|
||
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;
|
||
markProject();
|
||
if (select) selectProject(state.project);
|
||
}
|
||
|
||
function markProject() {
|
||
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (state.project ?? '')));
|
||
$('#csv-sessions').href = state.project
|
||
? `/api/sessions?project=${encodeURIComponent(state.project)}&format=csv`
|
||
: '/api/all?format=csv';
|
||
}
|
||
|
||
async function selectProject(id) {
|
||
state.project = id;
|
||
markProject();
|
||
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
|
||
if (state.view === 'daily') loadDaily();
|
||
else if (state.view === 'gantt') loadGantt();
|
||
else if (state.view === 'n8n') loadN8n();
|
||
await reloadSessions();
|
||
}
|
||
|
||
async function reloadSessions() {
|
||
const id = state.project;
|
||
try {
|
||
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
|
||
state.sessions = data.sessions;
|
||
renderSessions();
|
||
} catch (e) {
|
||
$('#session-table 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) {
|
||
// ターン単位のガントを見ている間は、タスクを選び直してもそのまま並べ替える
|
||
const stayGantt = state.view === 'gantt' && state.ganttUnit === 'turn';
|
||
state.sessionProject = project;
|
||
state.session = id;
|
||
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>';
|
||
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 fetchDetail();
|
||
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');
|
||
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
|
||
$('#tab-n8n').classList.toggle('sel', state.view === 'n8n');
|
||
}
|
||
|
||
function ganttQuery(extra) {
|
||
const q = new URLSearchParams({ days: String(state.ganttDays), ...extra });
|
||
if (state.project) q.set('project', state.project);
|
||
return q.toString();
|
||
}
|
||
|
||
const customRange = () => Boolean(state.dailyFrom || state.dailyTo);
|
||
|
||
function dailyQuery(extra) {
|
||
const q = new URLSearchParams(extra || {});
|
||
if (state.project) q.set('project', state.project);
|
||
if (customRange()) {
|
||
if (state.dailyFrom) q.set('from', state.dailyFrom);
|
||
if (state.dailyTo) q.set('to', state.dailyTo);
|
||
} else {
|
||
q.set('days', String(state.days));
|
||
}
|
||
// 除外は横断モードだけ (単一プロジェクト表示では自分自身を消してしまうので送らない)
|
||
if (!state.project && state.dailyExclude.length) q.set('exclude', state.dailyExclude.join(','));
|
||
return q.toString();
|
||
}
|
||
|
||
function updateCsv() {
|
||
const link = $('#csv-turns');
|
||
if (state.view === 'n8n') {
|
||
link.removeAttribute('href');
|
||
return;
|
||
}
|
||
if (state.view === 'daily') {
|
||
link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
|
||
link.title = '日別集計を CSV 出力';
|
||
} else if (state.view === 'gantt' && state.ganttUnit === 'task') {
|
||
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 出力';
|
||
} else {
|
||
link.removeAttribute('href');
|
||
}
|
||
}
|
||
|
||
function setView(view) {
|
||
state.view = view;
|
||
syncTabs();
|
||
updateCsv();
|
||
if (view === 'daily') loadDaily();
|
||
else if (view === 'gantt') loadGantt();
|
||
else if (view === 'n8n') loadN8n();
|
||
else renderDetail();
|
||
}
|
||
|
||
async function loadDaily({ silent = false } = {}) {
|
||
if (!silent) {
|
||
$('#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 custom = customRange();
|
||
const ranges = [14, 30, 90, 0]
|
||
.map((v) => `<button class="rng${!custom && v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
|
||
.join('');
|
||
const dates = `<span class="dates">
|
||
<input type="date" id="d-from" value="${state.dailyFrom}" max="${state.dailyTo}" title="開始日">
|
||
<span class="tilde">〜</span>
|
||
<input type="date" id="d-to" value="${state.dailyTo}" min="${state.dailyFrom}" title="終了日">
|
||
${custom ? '<button class="rng sel" id="d-clear" title="期間指定を解除">✕</button>' : ''}
|
||
</span>`;
|
||
|
||
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>
|
||
${dates}
|
||
<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 act" data-ex="${esc(p.id)}" title="${esc(p.path)}\nクリックで集計から外す"><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>`
|
||
: ''}
|
||
${cross && state.dailyExclude.length
|
||
? `<div class="legend excl"><span class="cap">除外中</span>${state.dailyExclude
|
||
.map((id) => `<span class="lg off" data-ex="${esc(id)}" title="${esc(projectPathOf(id))}\nクリックで戻す">✕ ${esc(shortPath(projectPathOf(id)))}</span>`)
|
||
.join('')}<button class="rng" id="ex-clear">すべて戻す</button></div>`
|
||
: ''}
|
||
${table}`;
|
||
|
||
wireRanges('days', loadDaily);
|
||
wireDaily();
|
||
}
|
||
|
||
/** 日別集計の期間指定・プロジェクト除外を配線する。 */
|
||
function wireDaily() {
|
||
const setRange = (from, to) => {
|
||
state.dailyFrom = from;
|
||
state.dailyTo = to;
|
||
updateCsv();
|
||
loadDaily();
|
||
};
|
||
const from = $('#d-from');
|
||
const to = $('#d-to');
|
||
if (from) from.onchange = () => setRange(from.value, state.dailyTo);
|
||
if (to) to.onchange = () => setRange(state.dailyFrom, to.value);
|
||
const clear = $('#d-clear');
|
||
if (clear) clear.onclick = () => setRange('', '');
|
||
|
||
document.querySelectorAll('#detail-body .legend [data-ex]').forEach((el) => {
|
||
el.onclick = () => {
|
||
const id = el.dataset.ex;
|
||
const at = state.dailyExclude.indexOf(id);
|
||
if (at >= 0) state.dailyExclude.splice(at, 1);
|
||
else state.dailyExclude.push(id);
|
||
updateCsv();
|
||
loadDaily();
|
||
};
|
||
});
|
||
const exClear = $('#ex-clear');
|
||
if (exClear)
|
||
exClear.onclick = () => {
|
||
state.dailyExclude = [];
|
||
updateCsv();
|
||
loadDaily();
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------- ガント
|
||
|
||
async function loadGantt({ silent = false } = {}) {
|
||
if (!silent) {
|
||
$('#detail-title').textContent = 'ガント';
|
||
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
|
||
}
|
||
try {
|
||
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 = [
|
||
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);
|
||
d.setHours(0, 0, 0, 0);
|
||
for (let t = d.getTime(); t <= t1; t += DAY) out.push(t);
|
||
return out;
|
||
}
|
||
|
||
/** 時間軸のスケール。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;
|
||
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 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">
|
||
${unitButtons()}
|
||
<span class="rngs">${ranges}</span>
|
||
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
|
||
</div>`;
|
||
|
||
if (!rows.length) {
|
||
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
|
||
wireRanges('ganttDays', loadGantt);
|
||
wireUnits();
|
||
return;
|
||
}
|
||
|
||
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 sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW); // 右端に少し余白
|
||
const xOf = sc.xOf;
|
||
|
||
const H = rows.length * ROW_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}"/>`);
|
||
});
|
||
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}"/>`);
|
||
});
|
||
});
|
||
svg.push(...nowLine(sc, H, g.now * 1000));
|
||
rows.forEach((s, i) => {
|
||
const tip = [
|
||
s.title,
|
||
cross ? s.projectPath : '',
|
||
`${fmtDateTime(s.start)} → ${fmtDateTime(s.end)}`,
|
||
// 表示範囲より前から続いているタスクは、数字が範囲内だけのものだと分かるようにする
|
||
`${s.clipped ? '表示範囲内' : '全体'} ${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}
|
||
${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>`)
|
||
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${g.projects.length - legend.length} 件</span>` : ''}</div>`
|
||
: ''}`;
|
||
|
||
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[data-days]').forEach((btn) => {
|
||
btn.onclick = () => {
|
||
state[key] = Number(btn.dataset.days);
|
||
if (key === 'days') state.dailyFrom = state.dailyTo = ''; // 日数指定と期間指定は排他
|
||
updateCsv();
|
||
reload();
|
||
};
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 自動追従
|
||
|
||
const FOLLOW_KEY = 'chktime.follow';
|
||
|
||
function followStatus(kind, text) {
|
||
const el = $('#follow-state');
|
||
el.className = 'fstate ' + kind;
|
||
el.textContent = text ?? '';
|
||
}
|
||
|
||
function nowHms() {
|
||
const d = new Date();
|
||
return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
|
||
}
|
||
|
||
/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */
|
||
async function refreshFollow() {
|
||
if (state.refreshing) return;
|
||
state.refreshing = true;
|
||
const box = $('#detail-body');
|
||
const list = $('#sessions .scroll');
|
||
const boxTop = box.scrollTop;
|
||
const listTop = list.scrollTop;
|
||
const atBottom = box.scrollHeight - box.clientHeight - boxTop < 40;
|
||
try {
|
||
await loadProjects({ select: false });
|
||
await reloadSessions();
|
||
if (state.view === 'daily') await loadDaily({ silent: true });
|
||
else if (state.view === 'gantt') await loadGantt({ silent: true });
|
||
else if (state.view === 'n8n') { /* 編集中の入力を消さないよう、自動追従では読み直さない */ }
|
||
else await reloadDetail();
|
||
list.scrollTop = listTop;
|
||
// やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚)
|
||
box.scrollTop = atBottom && state.view === 'turns' ? box.scrollHeight : boxTop;
|
||
followStatus('live', `更新 ${nowHms()}`);
|
||
} catch (e) {
|
||
followStatus('err', esc(e.message).slice(0, 40));
|
||
} finally {
|
||
state.refreshing = false;
|
||
}
|
||
}
|
||
|
||
function scheduleRefresh() {
|
||
// 書き込み中は数秒おきに飛んでくるので、まとめてから読み直す
|
||
followStatus('busy', '更新あり…');
|
||
clearTimeout(state.followTimer);
|
||
state.followTimer = setTimeout(refreshFollow, 1200);
|
||
}
|
||
|
||
function revOf(event) {
|
||
try {
|
||
return JSON.parse(event.data).rev;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function startFollow() {
|
||
if (state.es) return;
|
||
state.follow = true;
|
||
followStatus('wait', '接続中…');
|
||
const es = new EventSource('/api/watch');
|
||
es.onopen = () => followStatus('live', '追従中');
|
||
es.addEventListener('hello', (e) => {
|
||
const rev = revOf(e);
|
||
// OFF の間や切断中の変更を取り込む (初回接続時は読み込み済みなので不要)
|
||
const stale = state.rev !== null && rev !== state.rev;
|
||
state.rev = rev;
|
||
followStatus('live', '追従中');
|
||
if (stale) scheduleRefresh();
|
||
});
|
||
es.addEventListener('change', (e) => {
|
||
state.rev = revOf(e);
|
||
scheduleRefresh();
|
||
});
|
||
es.onerror = () => followStatus('err', '切断(再接続待ち)');
|
||
state.es = es;
|
||
}
|
||
|
||
function stopFollow() {
|
||
state.follow = false;
|
||
clearTimeout(state.followTimer);
|
||
if (state.es) {
|
||
state.es.close();
|
||
state.es = null;
|
||
}
|
||
followStatus('off', '');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Redmine 起票
|
||
|
||
// 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。
|
||
// サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。
|
||
const rm = {
|
||
cfg: null, // /api/redmine/config (キーは含まれない)
|
||
meta: null, // プロジェクト / トラッカー / ステータス
|
||
settings: false, // 接続設定を開いているか
|
||
source: 'tasks', // tasks=chktime のタスク / git=git 履歴
|
||
mode: 'day', // git のまとめ方: day / type / all
|
||
repo: '',
|
||
from: '',
|
||
to: '',
|
||
tasks: new Map(), // 起票対象のタスク: キー -> {project, id}
|
||
drafts: [],
|
||
off: new Set(), // 下書きのうち外したもの (index)
|
||
job: null,
|
||
timer: null,
|
||
busy: false,
|
||
};
|
||
|
||
async function post(path, payload) {
|
||
const res = await fetch(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) throw new Error(data.error);
|
||
return data;
|
||
}
|
||
|
||
/** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */
|
||
const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id });
|
||
const taskKey = (t) => `${t.project}/${t.id}`;
|
||
|
||
function rmMsg(text, kind) {
|
||
const el = $('#rm-msg');
|
||
el.className = kind === 'err' ? 'err-inline' : 'note';
|
||
el.textContent = text || '';
|
||
}
|
||
|
||
function rmConn() {
|
||
const el = $('#rm-conn');
|
||
if (!rm.cfg || !rm.cfg.url) {
|
||
el.className = 'fstate wait';
|
||
el.textContent = '未設定';
|
||
} else if (!rm.cfg.hasKey) {
|
||
el.className = 'fstate wait';
|
||
el.textContent = 'キー未設定';
|
||
} else {
|
||
el.className = 'fstate live';
|
||
el.textContent = rm.cfg.url;
|
||
}
|
||
}
|
||
|
||
async function openRedmine() {
|
||
$('#rm-back').classList.remove('hidden');
|
||
rmMsg('');
|
||
try {
|
||
rm.cfg = await api('/api/redmine/config');
|
||
rm.settings = !rm.cfg.url || !rm.cfg.hasKey;
|
||
if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : '');
|
||
if (!rm.tasks.size && state.session) {
|
||
const t = { project: state.sessionProject, id: state.session };
|
||
rm.tasks.set(taskKey(t), t);
|
||
}
|
||
renderRedmine();
|
||
if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta();
|
||
} catch (e) {
|
||
$('#rm-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function closeRedmine() {
|
||
$('#rm-back').classList.add('hidden');
|
||
clearInterval(rm.timer);
|
||
rm.timer = null;
|
||
}
|
||
|
||
async function loadRedmineMeta() {
|
||
try {
|
||
rmMsg('Redmine に接続しています…');
|
||
rm.meta = await api('/api/redmine/meta');
|
||
rmMsg('');
|
||
} catch (e) {
|
||
rm.meta = null;
|
||
rmMsg(e.message, 'err');
|
||
}
|
||
renderRedmine();
|
||
}
|
||
|
||
function optionList(items, selected, label) {
|
||
return items
|
||
.map((it) => `<option value="${it.id}"${String(it.id) === String(selected ?? '') ? ' selected' : ''}>${esc(label(it))}</option>`)
|
||
.join('');
|
||
}
|
||
|
||
function renderRedmine() {
|
||
rmConn();
|
||
const cfg = rm.cfg || {};
|
||
const meta = rm.meta;
|
||
|
||
const settings = `
|
||
<div class="rm-sec">
|
||
<h4>接続 <button class="rng" id="rm-toggle">${rm.settings ? '隠す' : '設定'}</button></h4>
|
||
${rm.settings
|
||
? `<div class="rm-row"><label>URL</label>
|
||
<input type="text" id="rm-url" value="${esc(cfg.url || '')}" placeholder="https://192.168.3.150"></div>
|
||
<div class="rm-row"><label>APIキー</label>
|
||
<input type="password" id="rm-key" placeholder="${cfg.hasKey ? '保存済み(変更するときだけ入力)' : '個人設定 → APIアクセスキー'}"></div>
|
||
<div class="rm-row">
|
||
<label class="chk"><input type="checkbox" id="rm-verify"${cfg.verifySsl ? ' checked' : ''}> 証明書を検証する</label>
|
||
<label>間隔(秒)</label><input type="text" id="rm-int" value="${esc(cfg.interval ?? 7)}" style="flex:0 0 60px">
|
||
<button id="rm-save">保存</button>
|
||
</div>
|
||
<div class="note">設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。
|
||
自己署名証明書のサーバでは「証明書を検証する」を外してください。</div>`
|
||
: ''}
|
||
</div>`;
|
||
|
||
const target = meta
|
||
? `<div class="rm-sec">
|
||
<h4>起票先</h4>
|
||
<div class="rm-row">
|
||
<label>プロジェクト</label>
|
||
<select id="rm-proj"><option value="">選んでください</option>${optionList(meta.projects, cfg.projectId, (p) => p.name)}</select>
|
||
<label>トラッカー</label>
|
||
<select id="rm-tracker">${optionList(meta.trackers, cfg.trackerId, (t) => t.name)}</select>
|
||
<label>ステータス</label>
|
||
<select id="rm-status">${optionList(meta.statuses, cfg.statusId, (s) => s.name)}</select>
|
||
</div>
|
||
<div class="rm-row">
|
||
<label class="chk"><input type="checkbox" id="rm-assign"${cfg.assignedToId ? ' checked' : ''}> 自分(${esc(meta.user.name || meta.user.login || '')})を担当にする</label>
|
||
<label class="chk"><input type="checkbox" id="rm-done"> 進捗を 100% にする</label>
|
||
</div>
|
||
</div>`
|
||
: cfg.hasKey
|
||
? '<div class="rm-sec"><p class="note">Redmine の情報を取得できていません。接続設定を確認してください。</p></div>'
|
||
: '';
|
||
|
||
const tab = (v, label) => `<button class="rng${rm.source === v ? ' sel' : ''}" data-src="${v}">${label}</button>`;
|
||
const picker =
|
||
rm.source === 'tasks'
|
||
? `<div class="rm-row">
|
||
<span class="note">一覧に出ている ${state.sessions.length} 件から選びます</span>
|
||
<button class="rng" id="rm-all">全選択</button><button class="rng" id="rm-none">全解除</button>
|
||
</div>
|
||
<div class="rm-list">${
|
||
state.sessions.length
|
||
? state.sessions
|
||
.map((s) => {
|
||
const t = taskOf(s);
|
||
return `<div class="rm-item"><label class="h">
|
||
<input type="checkbox" class="rm-task" data-project="${esc(t.project)}" data-id="${esc(t.id)}"${rm.tasks.has(taskKey(t)) ? ' checked' : ''}>
|
||
<span class="t"><span class="subject">${esc(s.title)}</span>
|
||
<span class="meta">${fmtDateTime(s.start)} · ${s.turnCount} 往復 · 作業 ${fmtDur(s.activeMs)}</span></span>
|
||
</label></div>`;
|
||
})
|
||
.join('')
|
||
: '<div class="rm-item"><div class="h"><span class="note">タスクがありません</span></div></div>'
|
||
}</div>`
|
||
: `<div class="rm-row">
|
||
<label>リポジトリ</label><input type="text" id="rm-repo" value="${esc(rm.repo)}" placeholder="C:\\debug\\chktime">
|
||
</div>
|
||
<div class="rm-row">
|
||
<label>期間</label><input type="date" id="rm-from" value="${rm.from}"><span class="note">〜</span><input type="date" id="rm-to" value="${rm.to}">
|
||
<label>まとめ方</label>
|
||
<select id="rm-mode">
|
||
<option value="day"${rm.mode === 'day' ? ' selected' : ''}>日ごと</option>
|
||
<option value="type"${rm.mode === 'type' ? ' selected' : ''}>日 × 種別 (feat/fix/docs…)</option>
|
||
<option value="all"${rm.mode === 'all' ? ' selected' : ''}>期間まとめて 1 件</option>
|
||
</select>
|
||
</div>
|
||
<div class="note">分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。</div>`;
|
||
|
||
const source = `
|
||
<div class="rm-sec">
|
||
<h4>取得元 <span class="rngs" style="display:inline-flex;margin-left:6px">${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}</span></h4>
|
||
${picker}
|
||
<div class="rm-row" style="margin-top:8px"><button id="rm-draft">下書きを作る</button></div>
|
||
</div>`;
|
||
|
||
const drafts = rm.drafts.length
|
||
? `<div class="rm-sec">
|
||
<h4>下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)</h4>
|
||
<div class="rm-list">${rm.drafts
|
||
.map(
|
||
(d, i) => `<div class="rm-item${rm.off.has(i) ? ' off' : ''}" data-i="${i}">
|
||
<div class="h">
|
||
<input type="checkbox" class="rm-pick" data-i="${i}"${rm.off.has(i) ? '' : ' checked'}>
|
||
<span class="t">
|
||
<span class="subject">${esc(d.subject)}</span>
|
||
<span class="meta">${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''}
|
||
${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''}</span>
|
||
</span>
|
||
</div>
|
||
<div class="body">${esc(d.description)}</div>
|
||
</div>`
|
||
)
|
||
.join('')}</div>
|
||
</div>`
|
||
: '';
|
||
|
||
$('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : '');
|
||
wireRedmine();
|
||
syncRun();
|
||
}
|
||
|
||
function renderJob() {
|
||
const j = rm.job;
|
||
const pct = j.total ? Math.round((j.done / j.total) * 100) : 0;
|
||
const ok = j.results.filter((r) => r.ok).length;
|
||
const ng = j.results.length - ok;
|
||
return `<div class="rm-sec">
|
||
<h4>起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}</h4>
|
||
<div class="rm-prog"><i style="width:${pct}%"></i></div>
|
||
<div class="note">成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}</div>
|
||
<div class="rm-list" style="margin-top:6px">${j.results
|
||
.map(
|
||
(r) => `<div class="rm-res">
|
||
<span class="id${r.ok ? '' : ' ng'}">${r.ok ? '#' + r.issueId : '失敗'}</span>
|
||
<span class="s">${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}</span>
|
||
</div>`
|
||
)
|
||
.join('')}</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */
|
||
function wireRedmine() {
|
||
const on = (sel, ev, fn) => {
|
||
const el = $(sel);
|
||
if (el) el[ev] = fn;
|
||
};
|
||
on('#rm-toggle', 'onclick', () => {
|
||
rm.settings = !rm.settings;
|
||
renderRedmine();
|
||
});
|
||
on('#rm-save', 'onclick', async () => {
|
||
try {
|
||
rmMsg('保存しています…');
|
||
rm.cfg = await post('/api/redmine/config', {
|
||
url: $('#rm-url').value.trim(),
|
||
key: $('#rm-key').value.trim(),
|
||
verifySsl: $('#rm-verify').checked,
|
||
interval: Number($('#rm-int').value) || 7,
|
||
});
|
||
rm.settings = false;
|
||
rm.meta = null;
|
||
rmMsg('保存しました');
|
||
await loadRedmineMeta();
|
||
} catch (e) {
|
||
rmMsg(e.message, 'err');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => {
|
||
btn.onclick = () => {
|
||
rm.source = btn.dataset.src;
|
||
renderRedmine();
|
||
};
|
||
});
|
||
document.querySelectorAll('#rm-body .rm-task').forEach((box) => {
|
||
box.onchange = () => {
|
||
const t = { project: box.dataset.project, id: box.dataset.id };
|
||
if (box.checked) rm.tasks.set(taskKey(t), t);
|
||
else rm.tasks.delete(taskKey(t));
|
||
};
|
||
});
|
||
const pickAll = (want) => {
|
||
state.sessions.forEach((s) => {
|
||
const t = taskOf(s);
|
||
if (want) rm.tasks.set(taskKey(t), t);
|
||
else rm.tasks.delete(taskKey(t));
|
||
});
|
||
renderRedmine();
|
||
};
|
||
on('#rm-all', 'onclick', () => pickAll(true));
|
||
on('#rm-none', 'onclick', () => pickAll(false));
|
||
on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim()));
|
||
on('#rm-from', 'onchange', (e) => (rm.from = e.target.value));
|
||
on('#rm-to', 'onchange', (e) => (rm.to = e.target.value));
|
||
on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value));
|
||
on('#rm-draft', 'onclick', makeDrafts);
|
||
|
||
document.querySelectorAll('#rm-body .rm-pick').forEach((box) => {
|
||
box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように
|
||
box.onchange = () => {
|
||
const i = Number(box.dataset.i);
|
||
if (box.checked) rm.off.delete(i);
|
||
else rm.off.add(i);
|
||
renderRedmine();
|
||
};
|
||
});
|
||
document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => {
|
||
head.onclick = (e) => {
|
||
if (e.target.classList.contains('rm-pick')) return;
|
||
head.parentElement.classList.toggle('open');
|
||
};
|
||
});
|
||
|
||
['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun));
|
||
}
|
||
|
||
/** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */
|
||
function syncRun() {
|
||
const proj = $('#rm-proj');
|
||
$('#rm-run').disabled =
|
||
rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value);
|
||
}
|
||
|
||
async function makeDrafts() {
|
||
const payload =
|
||
rm.source === 'tasks'
|
||
? {
|
||
source: 'tasks',
|
||
ids: [...rm.tasks.values()],
|
||
}
|
||
: { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode };
|
||
try {
|
||
rmMsg('下書きを作っています…');
|
||
const data = await post('/api/redmine/draft', payload);
|
||
rm.drafts = data.drafts;
|
||
rm.off = new Set();
|
||
rm.job = null;
|
||
rmMsg(`${rm.drafts.length} 件の下書きを作りました`);
|
||
} catch (e) {
|
||
rm.drafts = [];
|
||
rmMsg(e.message, 'err');
|
||
}
|
||
renderRedmine();
|
||
}
|
||
|
||
async function runRedmine() {
|
||
const drafts = rm.drafts.filter((_, i) => !rm.off.has(i));
|
||
if (!drafts.length) return;
|
||
const defaults = {
|
||
projectId: Number($('#rm-proj').value) || null,
|
||
trackerId: Number($('#rm-tracker').value) || null,
|
||
statusId: Number($('#rm-status').value) || null,
|
||
assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null,
|
||
doneRatio: $('#rm-done').checked ? 100 : null,
|
||
};
|
||
const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1));
|
||
if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return;
|
||
try {
|
||
rm.busy = true;
|
||
rmMsg('起票しています…');
|
||
// 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す
|
||
post('/api/redmine/config', {
|
||
projectId: defaults.projectId, trackerId: defaults.trackerId,
|
||
statusId: defaults.statusId, assignedToId: defaults.assignedToId,
|
||
}).catch(() => {});
|
||
rm.job = await post('/api/redmine/issues', { drafts, defaults });
|
||
renderRedmine();
|
||
clearInterval(rm.timer);
|
||
rm.timer = setInterval(pollJob, 1000);
|
||
} catch (e) {
|
||
rm.busy = false;
|
||
rmMsg(e.message, 'err');
|
||
renderRedmine();
|
||
}
|
||
}
|
||
|
||
async function pollJob() {
|
||
if (!rm.job) return;
|
||
try {
|
||
rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`);
|
||
} catch {
|
||
return;
|
||
}
|
||
if (rm.job.state === 'done') {
|
||
clearInterval(rm.timer);
|
||
rm.timer = null;
|
||
rm.busy = false;
|
||
const ng = rm.job.results.filter((r) => !r.ok).length;
|
||
rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : '');
|
||
}
|
||
renderRedmine();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 起動
|
||
|
||
$('#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');
|
||
$('#tab-gantt').onclick = () => setView('gantt');
|
||
$('#tab-n8n').onclick = () => setView('n8n');
|
||
$('#redmine').onclick = openRedmine;
|
||
$('#rm-close').onclick = closeRedmine;
|
||
$('#rm-run').onclick = runRedmine;
|
||
$('#rm-back').onclick = (e) => {
|
||
if (e.target.id === 'rm-back') closeRedmine();
|
||
};
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine();
|
||
});
|
||
$('#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 === 'turns' || state.view === 'n8n') return;
|
||
clearTimeout(resizeTimer);
|
||
resizeTimer = setTimeout(() => {
|
||
if (state.view === 'daily' && state.daily) renderDaily();
|
||
else if (state.view === 'gantt') renderGantt();
|
||
}, 150);
|
||
});
|
||
|
||
$('#follow').checked = localStorage.getItem(FOLLOW_KEY) !== '0'; // 既定は ON
|
||
$('#follow').onchange = (e) => {
|
||
localStorage.setItem(FOLLOW_KEY, e.target.checked ? '1' : '0');
|
||
if (e.target.checked) startFollow();
|
||
else stopFollow();
|
||
};
|
||
|
||
loadProjects()
|
||
.then(() => {
|
||
if ($('#follow').checked) startFollow();
|
||
})
|
||
.catch((e) => {
|
||
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
|
||
});
|