Files
chktime/web/app.js
T
yoshikazuandClaude Opus 5 a458509014 Redmine へチケットを起票するボタンを追加
chktime のタスクと git 履歴から下書きを作り、まとめて Redmine へ登録できる
ようにした。外部通信はこの機能を使ったときだけで、使わなければ従来どおり
ローカルの jsonl を読むだけ。

- Redmine の REST API を叩く最小クライアント (標準ライブラリのみ)
- 下書きは「タスクから」(1タスク=1チケット) と「git 履歴から」
  (日ごと / 日 x 種別 / 期間一括) の 2 系統
- 起票は件数 x 間隔ぶん時間がかかるのでジョブにし、進捗だけ見に行く
- 作成時の status_id は Redmine 側で無視されるため、作成後に付け直す
- 連続アクセスに 403 を返すサーバがあるので 1 件ごとに間隔をあける (既定 7 秒)
- 自己署名証明書が普通なので、既定は証明書の検証なし
- API キーは redmine.json に置き、git 管理外にする

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:55:46 +09:00

1515 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();
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');
}
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 === '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 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 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');
$('#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') 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>`;
});