タスクを時間軸に並べるガントタブを追加

薄いバーで開始〜終了の全体、濃い部分で応答が動いていた区間を重ねて描く。
放置していた時間や、セッションを並行して動かしていた時間が見える。

- /api/timeline: セッションごとに全体の範囲と応答中の区間 (spans) を返す
  (隙間 1 秒以内のターンは結合、期間指定 days= / 全期間、CSV も可)
- ガントタブ: 1日/3日/7日/14日/30日/全期間の切替、1〜3日では時刻目盛りに
  自動で切替、土日の帯・日境界線・現在時刻の点線、横断時はプロジェクト別の色
- 行のホバーで内訳、クリックでそのタスクのやりとりを開く
- ラベル列とバーの行を22pxで揃え、時間軸ヘッダは縦スクロール中も固定
- ペイン幅に応じてラベル幅を詰め、横スクロールを出さない

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 10:49:42 +09:00
co-authored by Claude Opus 5
parent 2fd0ed1bef
commit 0205404797
5 changed files with 373 additions and 8 deletions
+12 -2
View File
@@ -35,7 +35,7 @@ python chktime.py
- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。先頭の **▦ すべてのプロジェクト** を選ぶと横断モードになります。 - **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。先頭の **▦ すべてのプロジェクト** を選ぶと横断モードになります。
- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。横断モードでは全プロジェクトのタスクを開始日時の新しい順に並べ、各行にプロジェクトを表示します(検索はパスにも効きます)。 - **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。横断モードでは全プロジェクトのタスクを開始日時の新しい順に並べ、各行にプロジェクトを表示します(検索はパスにも効きます)。
- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計** タブで表示を切り替えます。 - **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計 / ガント** タブで表示を切り替えます。
### 右ペインの列 ### 右ペインの列
@@ -62,7 +62,17 @@ python chktime.py
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。 - 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。 - 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
`CSV` ボタンでタスク一覧・ターン一覧・日別集計をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 ### ガントタブ
タスク(セッション)を 1 行ずつ、時間軸に並べたガントチャートです。
- **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。
- 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。
- 横断モードでは行の色がプロジェクトを表します(凡例は作業時間上位 7 件)。
- 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。
- 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
## 解析の仕様(ざっくり) ## 解析の仕様(ざっくり)
+103
View File
@@ -496,6 +496,91 @@ class Store:
}, },
} }
def timeline(self, project_id=None, days=7):
"""ガント表示用。セッションを1本のバーとして、内側に応答中の区間を返す。
spans は各ターンの [発言時刻, 応答完了] を、隙間 1 秒以内なら繋げたもの。
"""
paths = {p["id"]: p["path"] for p in self.projects()}
if project_id:
if project_id not in paths:
raise FileNotFoundError(project_id)
targets = [project_id]
else:
targets = list(paths)
now = datetime.now().timestamp()
t0 = None
if days and days > 0:
midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
t0 = (midnight - timedelta(days=days - 1)).timestamp()
rows = []
per_project = {}
for pid in targets:
for path in self.session_files(pid):
session = self.session(path)
start, end = session["startEpoch"], session["endEpoch"]
if start is None:
continue
if t0 is not None and (end if end is not None else start) < t0:
continue
spans = []
active = 0
for turn in session["turns"]:
a = turn["ts"]
if a is None:
continue
b = turn["endTs"] if turn["endTs"] is not None else a
if t0 is not None and b < t0:
continue
active += turn["durMs"] or 0
if spans and a - spans[-1][1] <= 1:
spans[-1][1] = max(spans[-1][1], b)
else:
spans.append([a, b])
rows.append(
{
"id": session["id"],
"project": pid,
"projectPath": session.get("cwd") or paths.get(pid, pid),
"title": session["title"],
"start": session["start"],
"end": session["end"],
"startEpoch": round(start, 1),
"endEpoch": round(end if end is not None else start, 1),
"wallMs": session["wallMs"],
"activeMs": active,
"turnCount": session["turnCount"],
"toolCalls": session["toolCalls"],
"spans": [[round(a, 1), round(b, 1)] for a, b in spans],
}
)
total = per_project.setdefault(pid, {"activeMs": 0, "sessions": 0})
total["activeMs"] += active
total["sessions"] += 1
rows.sort(key=lambda s: s["startEpoch"])
if t0 is None:
t0 = min((s["startEpoch"] for s in rows), default=now)
t1 = max([now] + [s["endEpoch"] for s in rows])
projects = sorted(
(
{"id": pid, "path": paths.get(pid, pid), "activeMs": v["activeMs"], "sessions": v["sessions"]}
for pid, v in per_project.items()
),
key=lambda p: -p["activeMs"],
)
return {
"scope": project_id or "",
"rangeDays": days if days and days > 0 else 0,
"from": round(t0, 1),
"to": round(t1, 1),
"now": round(now, 1),
"sessions": rows,
"projects": projects,
}
def clear(self): def clear(self):
with self._lock: with self._lock:
self._cache.clear() self._cache.clear()
@@ -589,6 +674,18 @@ def turns_csv(session):
return buf.getvalue() return buf.getvalue()
def timeline_csv(data):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
writer.writerow(["project", "session_id", "title", "start", "end", "wall", "active", "turns", "tool_calls"])
for s in data["sessions"]:
writer.writerow(
[s["projectPath"], s["id"], s["title"], s["start"] or "", s["end"] or "",
fmt_dur(s["wallMs"]), fmt_dur(s["activeMs"]), s["turnCount"], s["toolCalls"]]
)
return buf.getvalue()
class Server(ThreadingHTTPServer): class Server(ThreadingHTTPServer):
# Windows では SO_REUSEADDR が「使用中のポートにも bind できる」意味になり、 # Windows では SO_REUSEADDR が「使用中のポートにも bind できる」意味になり、
# 二重起動が検出できなくなるため無効にする。 # 二重起動が検出できなくなるため無効にする。
@@ -637,6 +734,12 @@ class Handler(BaseHTTPRequestHandler):
if query.get("format", [""])[0] == "csv": if query.get("format", [""])[0] == "csv":
return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv") return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv")
return self.send_json(data) return self.send_json(data)
if route == "/api/timeline":
project = query.get("project", [""])[0]
data = store.timeline(project or None, parse_days(query.get("days", ["7"])[0]))
if query.get("format", [""])[0] == "csv":
return self.send_text(timeline_csv(data), "text/csv", f"{project or 'all'}-timeline.csv")
return self.send_json(data)
if route == "/api/session": if route == "/api/session":
project = query.get("project", [""])[0] project = query.get("project", [""])[0]
session_id = query.get("id", [""])[0] session_id = query.get("id", [""])[0]
+209 -6
View File
@@ -13,6 +13,8 @@ const state = {
view: 'turns', view: 'turns',
days: 30, days: 30,
daily: null, daily: null,
ganttDays: 7,
gantt: null,
}; };
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca']; const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca'];
@@ -124,6 +126,7 @@ async function selectProject(id) {
const tbody = $('#session-table tbody'); const tbody = $('#session-table tbody');
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>'; tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
if (state.view === 'daily') loadDaily(); if (state.view === 'daily') loadDaily();
else if (state.view === 'gantt') loadGantt();
try { try {
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all'); const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions; state.sessions = data.sessions;
@@ -279,19 +282,26 @@ function renderDetail() {
function syncTabs() { function syncTabs() {
$('#tab-turns').classList.toggle('sel', state.view === 'turns'); $('#tab-turns').classList.toggle('sel', state.view === 'turns');
$('#tab-daily').classList.toggle('sel', state.view === 'daily'); $('#tab-daily').classList.toggle('sel', state.view === 'daily');
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
} }
function dailyQuery(extra) { function scopeQuery(days, extra) {
const q = new URLSearchParams({ days: String(state.days), ...extra }); const q = new URLSearchParams({ days: String(days), ...extra });
if (state.project) q.set('project', state.project); if (state.project) q.set('project', state.project);
return q.toString(); return q.toString();
} }
const dailyQuery = (extra) => scopeQuery(state.days, extra);
const ganttQuery = (extra) => scopeQuery(state.ganttDays, extra);
function updateCsv() { function updateCsv() {
const link = $('#csv-turns'); const link = $('#csv-turns');
if (state.view === 'daily') { if (state.view === 'daily') {
link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
link.title = '日別集計を CSV 出力'; link.title = '日別集計を CSV 出力';
} else if (state.view === 'gantt') {
link.href = '/api/timeline?' + ganttQuery({ format: 'csv' });
link.title = 'ガントのタスク一覧を CSV 出力';
} else if (state.session) { } else if (state.session) {
link.href = `/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}&format=csv`; link.href = `/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}&format=csv`;
link.title = 'ターン一覧を CSV 出力'; link.title = 'ターン一覧を CSV 出力';
@@ -305,6 +315,7 @@ function setView(view) {
syncTabs(); syncTabs();
updateCsv(); updateCsv();
if (view === 'daily') loadDaily(); if (view === 'daily') loadDaily();
else if (view === 'gantt') loadGantt();
else renderDetail(); else renderDetail();
} }
@@ -472,11 +483,199 @@ function renderDaily() {
: ''} : ''}
${table}`; ${table}`;
wireRanges('days', loadDaily);
}
// ---------------------------------------------------------------- ガント
async function loadGantt() {
$('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
try {
state.gantt = await api('/api/timeline?' + ganttQuery());
renderGantt();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
const HOUR = 3600000;
const DAY = 86400000;
const TICK_STEPS = [HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY, 28 * DAY];
function tickStep(span, plotW) {
const maxTicks = Math.max(3, Math.floor(plotW / 66));
return TICK_STEPS.find((s) => span / s <= maxTicks) || TICK_STEPS[TICK_STEPS.length - 1];
}
function midnightsIn(t0, t1) {
const out = [];
const d = new Date(t0);
d.setHours(0, 0, 0, 0);
for (let t = d.getTime(); t <= t1; t += DAY) out.push(t);
return out;
}
function renderGantt() {
const g = state.gantt;
if (!g) return;
const cross = !state.project;
const rows = g.sessions;
$('#detail-title').textContent = 'ガント — ' + (cross ? '全プロジェクト' : shortPath(projectPathOf(state.project)));
const legend = g.projects.slice(0, PALETTE.length);
const colorOf = new Map(legend.map((p, i) => [p.id, PALETTE[i]]));
const otherMs = g.projects.slice(PALETTE.length).reduce((a, p) => a + p.activeMs, 0);
const colorFor = (s) => (cross ? colorOf.get(s.project) || OTHER_COLOR : PALETTE[1]);
const ranges = [1, 3, 7, 14, 30, 0]
.map((v) => `<button class="rng${v === state.ganttDays ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
.join('');
const p2 = (n) => String(n).padStart(2, '0');
const totalActive = rows.reduce((a, s) => a + s.activeMs, 0);
const longest = rows.reduce((a, s) => (a && a.wallMs >= s.wallMs ? a : s), null);
const head = `
<div class="sum flat">
<div><span class="k">期間</span><span class="v">${fmtDateTime(new Date(g.from * 1000).toISOString())} → ${fmtDateTime(new Date(g.to * 1000).toISOString())}</span></div>
<div><span class="k">タスク</span><span class="v">${rows.length}</span></div>
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(totalActive)}</span></div>
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
</div>
<div class="chart-bar">
<span class="rngs">${ranges}</span>
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
</div>`;
if (!rows.length) {
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
wireRanges('ganttDays', loadGantt);
return;
}
const ROW_H = 22;
const boxW = $('#detail-body').clientWidth || 700;
// ラベル幅は横幅に追従させる (狭いペインでも横スクロールを出さない)
const labW = Math.max(130, Math.min(cross ? 250 : 220, Math.round(boxW * 0.34)));
const plotW = Math.max(180, boxW - labW - 28);
const t0 = g.from * 1000;
const tEnd = Math.max(g.to * 1000, t0 + 60000);
const t1 = tEnd + (tEnd - t0) * 0.01; // 右端に少し余白 (現在線と最後のバーを見やすく)
const xOf = (t) => ((Math.min(Math.max(t, t0), t1) - t0) / (t1 - t0)) * plotW;
const step = tickStep(t1 - t0, plotW);
const subDay = step < DAY;
// 軸 (上部に固定表示)
const axis = [];
const first = new Date(t0);
first.setSeconds(0, 0);
first.setMinutes(0);
if (!subDay) first.setHours(0, 0, 0, 0);
for (let t = first.getTime(); t <= t1; t += step) {
if (t < t0) continue;
const d = new Date(t);
const x = xOf(t);
const midnight = d.getHours() === 0 && d.getMinutes() === 0;
const label = !subDay || midnight ? `${p2(d.getMonth() + 1)}/${p2(d.getDate())}` : `${p2(d.getHours())}:${p2(d.getMinutes())}`;
axis.push(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
axis.push(
`<text class="ax${midnight || !subDay ? ' hi' : ''}" x="${Math.min(plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > plotW - 20 ? 'end' : 'middle'}">${label}</text>`
);
}
// 背景 (土日と日境界) → バー → 当たり判定
const H = rows.length * ROW_H;
const svg = [];
midnightsIn(t0, t1).forEach((t) => {
const d = new Date(t);
const x = xOf(t);
const xEnd = xOf(t + DAY);
if (d.getDay() === 0 || d.getDay() === 6) {
svg.push(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
}
if (t > t0) svg.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
});
if (subDay) {
for (let t = first.getTime(); t <= t1; t += step) {
if (t < t0) continue;
const d = new Date(t);
if (d.getHours() === 0 && d.getMinutes() === 0) continue;
svg.push(`<line class="grid" x1="${xOf(t).toFixed(1)}" y1="0" x2="${xOf(t).toFixed(1)}" y2="${H}"/>`);
}
}
rows.forEach((s, i) => {
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
});
rows.forEach((s, i) => {
const color = colorFor(s);
const y = i * ROW_H + 6;
const x = xOf(s.startEpoch * 1000);
const w = Math.max(1.5, xOf(s.endEpoch * 1000) - x);
svg.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="10" rx="2" fill="${color}" fill-opacity="0.24"/>`);
s.spans.forEach(([a, b]) => {
// 最小幅を確保しつつ、全体バーの内側に収める
const sx = Math.min(xOf(a * 1000), x + w - 0.8);
const sw = Math.min(Math.max(0.8, xOf(b * 1000) - sx), x + w - sx);
svg.push(`<rect x="${sx.toFixed(1)}" y="${y}" width="${sw.toFixed(1)}" height="10" rx="1" fill="${color}"/>`);
});
});
if (g.now * 1000 > t0 && g.now * 1000 <= t1) {
const x = xOf(g.now * 1000).toFixed(1);
svg.push(`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`);
}
rows.forEach((s, i) => {
const tip = [
s.title,
cross ? s.projectPath : '',
`${fmtDateTime(s.start)} → ${fmtDateTime(s.end)}`,
`全体 ${fmtDur(s.wallMs)} · 作業 ${fmtDur(s.activeMs)} · ${s.turnCount} 往復`,
]
.filter(Boolean)
.join('\n');
svg.push(
`<rect class="g-hit" data-id="${esc(s.id)}" data-project="${esc(s.project)}" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"><title>${esc(tip)}</title></rect>`
);
});
const labels = rows
.map(
(s) => `<div class="g-lab-row${s.id === state.session ? ' sel' : ''}" data-id="${esc(s.id)}" data-project="${esc(s.project)}" title="${esc(s.title)}">
<i style="background:${colorFor(s)}"></i>
<span class="g-when">${fmtDateTime(s.start).slice(3)}</span>
<span class="g-title">${esc(s.title)}</span>
</div>`
)
.join('');
$('#detail-body').innerHTML = `${head}
<div class="gantt" style="--labw:${labW}px">
<div class="g-head">
<div class="g-lab-h">タスク ${rows.length} 件(開始順)</div>
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis.join('')}</svg>
</div>
<div class="g-body">
<div class="g-labels">${labels}</div>
<svg class="g-plot" width="${plotW}" height="${H}" viewBox="0 0 ${plotW} ${H}">${svg.join('')}</svg>
</div>
</div>
${cross
? `<div class="legend">${legend
.map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${p.sessions} タスク</b></span>`)
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${g.projects.length - legend.length} 件</span>` : ''}</div>`
: ''}`;
wireRanges('ganttDays', loadGantt);
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
});
}
function wireRanges(key, reload) {
document.querySelectorAll('#detail-body .rng').forEach((btn) => { document.querySelectorAll('#detail-body .rng').forEach((btn) => {
btn.onclick = () => { btn.onclick = () => {
state.days = Number(btn.dataset.days); state[key] = Number(btn.dataset.days);
updateCsv(); updateCsv();
loadDaily(); reload();
}; };
}); });
} }
@@ -490,6 +689,7 @@ $('#relative').onchange = (e) => {
}; };
$('#tab-turns').onclick = () => setView('turns'); $('#tab-turns').onclick = () => setView('turns');
$('#tab-daily').onclick = () => setView('daily'); $('#tab-daily').onclick = () => setView('daily');
$('#tab-gantt').onclick = () => setView('gantt');
$('#reload').onclick = async () => { $('#reload').onclick = async () => {
await fetch('/api/reload'); await fetch('/api/reload');
const keep = { project: state.sessionProject, session: state.session, view: state.view }; const keep = { project: state.sessionProject, session: state.session, view: state.view };
@@ -499,9 +699,12 @@ $('#reload').onclick = async () => {
let resizeTimer = null; let resizeTimer = null;
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (state.view !== 'daily') return; if (state.view === 'turns') return;
clearTimeout(resizeTimer); clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => state.daily && renderDaily(), 150); resizeTimer = setTimeout(() => {
if (state.view === 'daily' && state.daily) renderDaily();
else if (state.view === 'gantt' && state.gantt) renderGantt();
}, 150);
}); });
loadProjects().catch((e) => { loadProjects().catch((e) => {
+1
View File
@@ -36,6 +36,7 @@
<span class="tabs"> <span class="tabs">
<button class="tab sel" id="tab-turns">やりとり</button> <button class="tab sel" id="tab-turns">やりとり</button>
<button class="tab" id="tab-daily">日別集計</button> <button class="tab" id="tab-daily">日別集計</button>
<button class="tab" id="tab-gantt">ガント</button>
</span> </span>
<a id="csv-turns" class="btn" href="#" download>CSV</a> <a id="csv-turns" class="btn" href="#" download>CSV</a>
</div> </div>
+48
View File
@@ -213,3 +213,51 @@ table { width: 100%; border-collapse: collapse; }
#daily-table .d { color: var(--dim); font-size: 11px; } #daily-table .d { color: var(--dim); font-size: 11px; }
#daily-table .pj { display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 11px; color: var(--dim); } #daily-table .pj { display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 11px; color: var(--dim); }
.pj-i { display: flex; align-items: center; gap: 4px; white-space: nowrap; } .pj-i { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
/* ガント */
.sum.flat { position: static; }
.gantt { padding: 4px 14px 0; }
.gantt .g-head {
display: flex;
align-items: flex-end;
position: sticky;
top: 0;
z-index: 2;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.gantt .g-lab-h {
width: var(--labw);
flex: none;
color: var(--dim);
font-size: 10.5px;
padding-bottom: 5px;
}
.gantt .g-body { display: flex; }
.gantt .g-labels { width: var(--labw); flex: none; }
.gantt .g-lab-row {
height: 22px;
display: flex;
align-items: center;
gap: 6px;
padding-right: 8px;
font-size: 11px;
cursor: pointer;
overflow: hidden;
}
.gantt .g-lab-row:nth-child(even) { background: rgba(255, 255, 255, .02); }
.gantt .g-lab-row:hover { background: var(--pane2); }
.gantt .g-lab-row.sel { box-shadow: inset 3px 0 var(--accent); }
.gantt .g-lab-row i { width: 8px; height: 8px; border-radius: 2px; flex: none; }
.gantt .g-when { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
.gantt .g-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gantt svg text.ax { fill: var(--dim); font-size: 10px; }
.gantt svg text.ax.hi { fill: var(--fg); }
.gantt svg line.grid { stroke: var(--line); stroke-width: 1; }
.gantt svg line.day { stroke: var(--line); stroke-width: 1; }
.gantt svg line.now { stroke: var(--warn); stroke-width: 1; stroke-dasharray: 3 3; }
.gantt svg rect.wk { fill: #ffffff; opacity: .028; }
.gantt svg rect.stripe { fill: #ffffff; opacity: .02; }
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }