diff --git a/README.md b/README.md index 046de4b..5469f40 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ python chktime.py ## 画面 -- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。 -- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。 -- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。 +- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。先頭の **▦ すべてのプロジェクト** を選ぶと横断モードになります。 +- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。横断モードでは全プロジェクトのタスクを開始日時の新しい順に並べ、各行にプロジェクトを表示します(検索はパスにも効きます)。 +- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。ヘッダの **やりとり / 日別集計** タブで表示を切り替えます。 ### 右ペインの列 @@ -53,7 +53,17 @@ python chktime.py 上部サマリの **作業時間合計** は各ターンの応答時間の合計(=実際に Claude が動いていた時間)、 **全体** は最初から最後までの経過時間です。放置時間が長いと両者は大きく離れます。 -`CSV` ボタンでタスク一覧・ターン一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 +### 日別集計タブ + +ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。 + +- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。 +- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。 +- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。 +- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。 + +`CSV` ボタンでタスク一覧・ターン一覧・日別集計をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 +横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 ## 解析の仕様(ざっくり) diff --git a/chktime.py b/chktime.py index b404e69..a0b6bcc 100644 --- a/chktime.py +++ b/chktime.py @@ -22,7 +22,7 @@ import os import re import threading import webbrowser -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -389,6 +389,113 @@ class Store: return self.session(path) raise FileNotFoundError(session_id) + def all_sessions(self): + """全プロジェクトのタスクを1つのリストにまとめる (開始が新しい順)。""" + out = [] + for project in self.projects(): + for path in self.session_files(project["id"]): + summary = summary_of(self.session(path)) + summary["project"] = project["id"] + summary["projectPath"] = summary.get("cwd") or project["path"] + out.append(summary) + out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True) + return out + + def daily_stats(self, project_id=None, days=30): + """ターンをローカル日付で集計する。project_id=None なら全プロジェクト。 + + days>0 なら「今日から遡って days 日」、それ以外は全期間。 + 活動のない日も 0 の行として埋めるので、そのまま横軸に使える。 + """ + 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) + + today = datetime.now().date() + cutoff = today - timedelta(days=days - 1) if days and days > 0 else None + + per_day = {} + per_project = {} + seen = set() + for pid in targets: + for path in self.session_files(pid): + session = self.session(path) + for turn in session["turns"]: + if turn["ts"] is None: + continue + day = datetime.fromtimestamp(turn["ts"]).date() + if cutoff and day < cutoff: + continue + dur = turn["durMs"] or 0 + counted = 1 if turn["kind"] in ("prompt", "command") else 0 + bucket = per_day.setdefault(day, {"activeMs": 0, "turns": 0, "sessions": set(), "byProject": {}}) + bucket["activeMs"] += dur + bucket["turns"] += counted + bucket["sessions"].add(session["id"]) + bucket["byProject"][pid] = bucket["byProject"].get(pid, 0) + dur + total = per_project.setdefault(pid, {"activeMs": 0, "turns": 0, "sessions": set()}) + total["activeMs"] += dur + total["turns"] += counted + total["sessions"].add(session["id"]) + seen.add(session["id"]) + + if per_day: + start = cutoff or min(per_day) + end = max(max(per_day), today) + elif cutoff: + start, end = cutoff, today # データなしでも指定範囲は 0 で埋める + else: + start = end = None + + rows = [] + cur = start + while cur and cur <= end: + bucket = per_day.get(cur) + rows.append( + { + "date": cur.isoformat(), + "dow": cur.weekday(), + "activeMs": bucket["activeMs"] if bucket else 0, + "turns": bucket["turns"] if bucket else 0, + "sessions": len(bucket["sessions"]) if bucket else 0, + "byProject": bucket["byProject"] if bucket else {}, + } + ) + cur += timedelta(days=1) + + projects = sorted( + ( + { + "id": pid, + "path": paths.get(pid, pid), + "activeMs": v["activeMs"], + "turns": v["turns"], + "sessions": len(v["sessions"]), + } + for pid, v in per_project.items() + ), + key=lambda p: -p["activeMs"], + ) + busiest = max(rows, key=lambda r: r["activeMs"], default=None) + return { + "scope": project_id or "", + "rangeDays": days if days and days > 0 else 0, + "days": rows, + "projects": projects, + "totals": { + "activeMs": sum(r["activeMs"] for r in rows), + "turns": sum(r["turns"] for r in rows), + "sessions": len(seen), + "activeDays": sum(1 for r in rows if r["activeMs"] or r["turns"]), + "busiestDate": busiest["date"] if busiest and busiest["activeMs"] else None, + "busiestMs": busiest["activeMs"] if busiest else 0, + }, + } + def clear(self): with self._lock: self._cache.clear() @@ -433,14 +540,39 @@ def fmt_dur(msec): return f"{hours}h{minutes:02d}m" -def sessions_csv(sessions): +def parse_days(value): + """?days= の解釈。'all' / 0 / 不正値は全期間 (0) とみなす。""" + try: + days = int(value) + except (TypeError, ValueError): + return 0 + return max(0, min(days, 3650)) + + +def sessions_csv(sessions, with_project=False): buf = io.StringIO(newline="") writer = csv.writer(buf) - writer.writerow(["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"]) + head = ["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"] + writer.writerow((["project"] if with_project else []) + head) for s in sessions: writer.writerow( - [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), - fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""] + ([s.get("projectPath") or s.get("project") or ""] if with_project else []) + + [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), + fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""] + ) + return buf.getvalue() + + +def daily_csv(data): + buf = io.StringIO(newline="") + writer = csv.writer(buf) + projects = data["projects"] + writer.writerow(["date", "dow", "active", "active_ms", "turns", "sessions"] + [p["path"] for p in projects]) + labels = "月火水木金土日" + for row in data["days"]: + writer.writerow( + [row["date"], labels[row["dow"]], fmt_dur(row["activeMs"]), row["activeMs"], row["turns"], row["sessions"]] + + [fmt_dur(row["byProject"].get(p["id"])) for p in projects] ) return buf.getvalue() @@ -494,6 +626,17 @@ class Handler(BaseHTTPRequestHandler): if query.get("format", [""])[0] == "csv": return self.send_text(sessions_csv(sessions), "text/csv", f"{project}-sessions.csv") return self.send_json({"project": project, "sessions": sessions}) + if route == "/api/all": + sessions = store.all_sessions() + if query.get("format", [""])[0] == "csv": + return self.send_text(sessions_csv(sessions, True), "text/csv", "all-sessions.csv") + return self.send_json({"project": "", "sessions": sessions}) + if route == "/api/daily": + project = query.get("project", [""])[0] + data = store.daily_stats(project or None, parse_days(query.get("days", ["30"])[0])) + if query.get("format", [""])[0] == "csv": + return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv") + return self.send_json(data) if route == "/api/session": project = query.get("project", [""])[0] session_id = query.get("id", [""])[0] diff --git a/web/app.js b/web/app.js index db02161..eb12bc0 100644 --- a/web/app.js +++ b/web/app.js @@ -1,7 +1,23 @@ 'use strict'; const $ = (sel) => document.querySelector(sel); -const state = { project: null, session: null, sessions: [], detail: null, relative: false }; +// project: null = プロジェクト横断モード, undefined = 未選択 / view: turns=やりとり, daily=日別集計 +const state = { + project: undefined, + projects: [], + sessionProject: null, + session: null, + sessions: [], + detail: null, + relative: false, + view: 'turns', + days: 30, + daily: null, +}; + +const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca']; +const OTHER_COLOR = '#565f73'; +const DOW = ['月', '火', '水', '木', '金', '土', '日']; // ---------------------------------------------------------------- 表示ヘルパ @@ -71,9 +87,21 @@ async function api(path) { async function loadProjects() { const data = await api('/api/projects'); + state.projects = data.projects; $('#root').textContent = data.root; const ul = $('#project-list'); ul.innerHTML = ''; + + const all = document.createElement('li'); + all.dataset.id = ''; + all.className = 'all'; + const totalSessions = data.projects.reduce((a, p) => a + p.sessions, 0); + const totalBytes = data.projects.reduce((a, p) => a + p.bytes, 0); + all.innerHTML = `
すべてのプロジェクト
+
${totalSessions} タスク · ${fmtBytes(totalBytes)} · ${data.projects.length} プロジェクト
`; + all.onclick = () => selectProject(null); + ul.appendChild(all); + data.projects.forEach((p) => { const li = document.createElement('li'); li.dataset.id = p.id; @@ -82,17 +110,22 @@ async function loadProjects() { li.onclick = () => selectProject(p.id); ul.appendChild(li); }); - if (data.projects.length) selectProject(state.project && data.projects.some((p) => p.id === state.project) ? state.project : data.projects[0].id); + + // 再読込では選択を保つ (null = 横断モードもそのまま) + if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : null; + else if (state.project && !data.projects.some((p) => p.id === state.project)) state.project = null; + selectProject(state.project); } async function selectProject(id) { state.project = id; - document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === id)); - $('#csv-sessions').href = `/api/sessions?project=${encodeURIComponent(id)}&format=csv`; + document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (id ?? ''))); + $('#csv-sessions').href = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv'; const tbody = $('#session-table tbody'); tbody.innerHTML = '解析中…'; + if (state.view === 'daily') loadDaily(); try { - const data = await api(`/api/sessions?project=${encodeURIComponent(id)}`); + const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all'); state.sessions = data.sessions; renderSessions(); } catch (e) { @@ -103,7 +136,7 @@ async function selectProject(id) { function renderSessions() { const q = $('#filter').value.trim().toLowerCase(); const rows = state.sessions.filter( - (s) => !q || (s.title + ' ' + s.firstPrompt + ' ' + s.lastPrompt).toLowerCase().includes(q) + (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'); @@ -112,12 +145,14 @@ function renderSessions() { tbody.innerHTML = '該当なし'; 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 = ` + ${cross ? `
${esc(shortPath(s.projectPath))}
` : ''}
${esc(s.title)}
${fmtDateTime(s.start)} @@ -127,20 +162,29 @@ function renderSessions() { ${s.gitBranch ? `⑂ ${esc(s.gitBranch)}` : ''}
`; - tr.onclick = () => selectSession(s.id); + 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(id) { +async function selectSession(project, id) { + state.sessionProject = project; state.session = id; + state.view = 'turns'; + syncTabs(); document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id)); - $('#csv-turns').href = `/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}&format=csv`; + updateCsv(); $('#detail-body').innerHTML = '

読み込み中…

'; try { - state.detail = await api(`/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}`); + state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`); renderDetail(); } catch (e) { $('#detail-body').innerHTML = `

${esc(e.message)}

`; @@ -149,7 +193,11 @@ async function selectSession(id) { function renderDetail() { const d = state.detail; - if (!d) return; + if (!d) { + $('#detail-title').textContent = 'やりとりの時刻'; + $('#detail-body').innerHTML = '

左のタスクを選んでください。

'; + return; + } $('#detail-title').textContent = d.title; const maxSpan = Math.max(1, ...d.turns.map((t) => (t.idleMs || 0) + (t.durMs || 0))); @@ -226,20 +274,236 @@ function renderDetail() { }); } +// ---------------------------------------------------------------- 日別集計 + +function syncTabs() { + $('#tab-turns').classList.toggle('sel', state.view === 'turns'); + $('#tab-daily').classList.toggle('sel', state.view === 'daily'); +} + +function dailyQuery(extra) { + const q = new URLSearchParams({ days: String(state.days), ...extra }); + if (state.project) q.set('project', state.project); + return q.toString(); +} + +function updateCsv() { + const link = $('#csv-turns'); + if (state.view === 'daily') { + link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); + link.title = '日別集計を CSV 出力'; + } else if (state.session) { + link.href = `/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}&format=csv`; + link.title = 'ターン一覧を CSV 出力'; + } else { + link.removeAttribute('href'); + } +} + +function setView(view) { + state.view = view; + syncTabs(); + updateCsv(); + if (view === 'daily') loadDaily(); + else renderDetail(); +} + +async function loadDaily() { + $('#detail-title').textContent = '日別集計'; + $('#detail-body').innerHTML = '

集計中…

'; + try { + state.daily = await api('/api/daily?' + dailyQuery()); + renderDaily(); + } catch (e) { + $('#detail-body').innerHTML = `

${esc(e.message)}

`; + } +} + +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(``); + svg.push(`${fmtAxis(v)}`); + } + rows.forEach((r, i) => { + const x = padL + i * slot; + if (r.dow >= 5) svg.push(``); + }); + 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(``); + }); + }); + svg.push(``); + 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(`${label}`); + svg.push(`${DOW[r.dow]}`); + } + // 空の日もツールチップを出すため、スロット全体に当たり判定を重ねる + svg.push(`${esc(tip(r))}`); + }); + + const ranges = [14, 30, 90, 0] + .map((v) => ``) + .join(''); + + const active = rows.filter((r) => r.activeMs || r.turns).reverse(); + const table = active.length + ? ` + ${cross ? '' : ''} + ${active + .map( + (r) => ` + + + + + ${cross + ? `` + : ''} + ` + ) + .join('')}
日付作業時間往復タスクプロジェクト
${r.date.slice(5).replace('-', '/')} ${DOW[r.dow]}${fmtDur(r.activeMs)}${r.turns}${r.sessions}${Object.entries(r.byProject) + .sort((a, b) => b[1] - a[1]) + .map( + ([id, v]) => + `${esc(shortPath(projectPathOf(id)))} ${fmtDur(v)}` + ) + .join('')}
` + : '

この期間の記録はありません。

'; + + $('#detail-body').innerHTML = ` +
+
期間${rows.length ? `${rows[0].date} → ${rows[rows.length - 1].date}` : '–'}
+
稼働日${t.activeDays} / ${rows.length} 日
+
作業時間合計${fmtDur(t.activeMs)}
+
1稼働日あたり${t.activeDays ? fmtDur(Math.round(t.activeMs / t.activeDays)) : '–'}
+
往復${t.turns}
+
タスク${t.sessions}
+
最も長い日${t.busiestDate ? `${t.busiestDate.slice(5).replace('-', '/')} ${fmtDur(t.busiestMs)}` : '–'}
+
+
+ ${ranges} + 棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。 +
+
${svg.join('')}
+ ${cross + ? `
${legend + .map((p, i) => `${esc(shortPath(p.path))} ${fmtDur(p.activeMs)}`) + .join('')}${otherMs ? `その他 ${d.projects.length - legend.length} 件 ${fmtDur(otherMs)}` : ''}
` + : ''} + ${table}`; + + document.querySelectorAll('#detail-body .rng').forEach((btn) => { + btn.onclick = () => { + state.days = Number(btn.dataset.days); + updateCsv(); + loadDaily(); + }; + }); +} + // ---------------------------------------------------------------- 起動 $('#filter').oninput = renderSessions; $('#relative').onchange = (e) => { state.relative = e.target.checked; - renderDetail(); + if (state.view === 'turns') renderDetail(); }; +$('#tab-turns').onclick = () => setView('turns'); +$('#tab-daily').onclick = () => setView('daily'); $('#reload').onclick = async () => { await fetch('/api/reload'); - const keepSession = state.session; + const keep = { project: state.sessionProject, session: state.session, view: state.view }; await loadProjects(); - if (keepSession) selectSession(keepSession).catch(() => {}); + if (keep.view === 'turns' && keep.session) selectSession(keep.project, keep.session).catch(() => {}); }; +let resizeTimer = null; +window.addEventListener('resize', () => { + if (state.view !== 'daily') return; + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => state.daily && renderDaily(), 150); +}); + loadProjects().catch((e) => { $('#project-list').innerHTML = `
  • ${esc(e.message)}
  • `; }); diff --git a/web/index.html b/web/index.html index 98aa384..71e73dd 100644 --- a/web/index.html +++ b/web/index.html @@ -33,6 +33,10 @@
    やりとりの時刻 + + + + CSV
    diff --git a/web/style.css b/web/style.css index ab477ac..71d0178 100644 --- a/web/style.css +++ b/web/style.css @@ -72,6 +72,7 @@ main { flex: 1; display: flex; min-height: 0; } border-bottom: 1px solid var(--line); } .pane-head > span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#detail-title { text-transform: none; } /* タイトルやパスをそのまま出す */ #filter { flex: 1 1 120px; min-width: 80px; @@ -162,3 +163,53 @@ table { width: 100%; border-collapse: collapse; } .bar .b-idle { background: var(--idle); } .bar .b-wait { background: var(--user); } .bar .b-work { background: var(--work); } + +/* プロジェクト横断モード */ +#project-list li.all { border-bottom: 2px solid var(--line); } +#project-list li.all .p-path::before { content: "▦ "; color: var(--dim); } +.s-proj { color: var(--dim); font-size: 10.5px; margin-bottom: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* 日別集計 */ +.tabs { display: flex; gap: 4px; flex: none; } +.tab { padding: 2px 9px; font-size: 11px; letter-spacing: normal; text-transform: none; } +.tab.sel { color: var(--accent); border-color: var(--accent); } + +.chart-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px 0; + flex-wrap: wrap; +} +.rngs { display: flex; gap: 4px; } +.rng { font-size: 11px; padding: 2px 8px; } +.rng.sel { color: var(--accent); border-color: var(--accent); } +.chart-bar .note { color: var(--dim); font-size: 10.5px; } + +.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); } +.chart svg line.grid { stroke: var(--line); stroke-width: 1; } +.chart svg line.axis { stroke: var(--dim); stroke-width: 1; opacity: .5; } +.chart svg rect.wk { fill: #ffffff; opacity: .028; } +.chart svg rect.hit { fill: transparent; } +.chart svg rect.hit:hover { fill: #ffffff; opacity: .06; } + +.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 8px 14px 2px; font-size: 11px; color: var(--dim); } +.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; } + +#daily-table { margin-top: 8px; } +#daily-table th { + position: sticky; top: 0; background: var(--pane); border-bottom: 1px solid var(--line); + color: var(--dim); font-weight: 500; font-size: 11px; text-align: left; padding: 6px 10px; white-space: nowrap; z-index: 1; +} +#daily-table td { padding: 5px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } +#daily-table tr:hover td { background: var(--pane2); } +#daily-table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +#daily-table .time { font-variant-numeric: tabular-nums; white-space: nowrap; } +#daily-table .time.we { color: var(--warn); } +#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); } +.pj-i { display: flex; align-items: center; gap: 4px; white-space: nowrap; }