日別集計に期間の任意指定とプロジェクト除外を追加

/api/daily に from / to / exclude を追加。from/to があれば days より優先し、
その範囲を 0 埋めで返す。exclude のプロジェクトは集計対象から外す
(横断モードのみ送信)。CSV も同じ条件で出力される。

UI は日数ボタンの隣に日付入力を置き、凡例のクリックで除外・「除外中」から
クリックで復帰できるようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 10:30:55 +09:00
co-authored by Claude Opus 5
parent 0c312afaa5
commit 8fd54757e6
4 changed files with 140 additions and 21 deletions
+71 -6
View File
@@ -12,6 +12,9 @@ const state = {
relative: false,
view: 'turns',
days: 30,
dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う
dailyTo: '',
dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ)
daily: null,
ganttDays: 7,
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
@@ -316,14 +319,27 @@ function syncTabs() {
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
}
function scopeQuery(days, extra) {
const q = new URLSearchParams({ days: String(days), ...extra });
function ganttQuery(extra) {
const q = new URLSearchParams({ days: String(state.ganttDays), ...extra });
if (state.project) q.set('project', state.project);
return q.toString();
}
const dailyQuery = (extra) => scopeQuery(state.days, extra);
const ganttQuery = (extra) => scopeQuery(state.ganttDays, extra);
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');
@@ -465,9 +481,16 @@ function renderDaily() {
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${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.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
@@ -506,17 +529,58 @@ function renderDaily() {
</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" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`)
.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();
};
}
// ---------------------------------------------------------------- ガント
@@ -920,6 +984,7 @@ 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();
};
+21
View File
@@ -200,6 +200,21 @@ table { width: 100%; border-collapse: collapse; }
.rng.sel { color: var(--accent); border-color: var(--accent); }
.chart-bar .note { color: var(--dim); font-size: 10.5px; }
/* 期間の任意指定 */
.dates { display: flex; align-items: center; gap: 4px; }
.dates input[type="date"] {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 5px;
color: var(--fg);
padding: 1px 6px;
font: inherit;
font-size: 11px;
color-scheme: dark;
}
.dates input[type="date"]:hover { border-color: var(--accent); }
.dates .tilde { color: var(--dim); font-size: 11px; }
.chart { padding: 4px 14px 0; overflow-x: auto; }
.chart svg text.ax { fill: var(--dim); font-size: 10px; }
.chart svg text.ax.we { fill: var(--warn); }
@@ -213,6 +228,12 @@ table { width: 100%; border-collapse: collapse; }
.legend .lg { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.legend .lg i, .pj-i i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex: none; }
.legend .lg b { color: var(--fg); font-weight: 500; }
.legend .lg.act { cursor: pointer; }
.legend .lg.act:hover { color: var(--fg); }
.legend.excl { padding-top: 2px; }
.legend.excl .cap { font-size: 10.5px; }
.legend .lg.off { cursor: pointer; opacity: .6; text-decoration: line-through; }
.legend .lg.off:hover { opacity: 1; color: var(--accent); }
#daily-table { margin-top: 8px; }
#daily-table th {