プロジェクト横断のタスク一覧と日別集計グラフを追加
左ペインに「すべてのプロジェクト」を追加し、全プロジェクトのタスクを1つの 一覧で扱えるようにした。あわせて右ペインに日別集計タブを設けた。 - /api/all: 全プロジェクトのタスクを開始日時順に返す (CSVはプロジェクト列付き) - /api/daily: ターンをローカル日付で集計し、活動のない日も0で埋めて返す (プロジェクト別の内訳・期間指定 days= / 全期間・横持ちCSV) - 日別集計タブ: 作業時間の棒グラフ (横断時はプロジェクト別の積み上げ、 上位7件+その他)、14/30/90日・全期間の切替、日別テーブル、SVG手書きで 外部ライブラリなし - 再読込で横断モードの選択が先頭プロジェクトに戻る問題を修正 (未選択と横断モードを state.project の undefined / null で区別) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 はプロジェクトごとの列を持つ横持ち形式です。
|
||||
|
||||
## 解析の仕様(ざっくり)
|
||||
|
||||
|
||||
+148
-5
@@ -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]
|
||||
|
||||
+278
-14
@@ -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 = `<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;
|
||||
@@ -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 = '<tr><td class="loading">解析中…</td></tr>';
|
||||
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 = '<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>
|
||||
@@ -127,20 +162,29 @@ function renderSessions() {
|
||||
${s.gitBranch ? `<span>⑂ ${esc(s.gitBranch)}</span>` : ''}
|
||||
</div>
|
||||
</td>`;
|
||||
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 = '<p class="loading">読み込み中…</p>';
|
||||
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 = `<p class="err">${esc(e.message)}</p>`;
|
||||
@@ -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 = '<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)));
|
||||
@@ -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 = '<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 ranges = [14, 30, 90, 0]
|
||||
.map((v) => `<button class="rng${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
|
||||
.join('');
|
||||
|
||||
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>
|
||||
<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>`)
|
||||
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>`
|
||||
: ''}
|
||||
${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 = `<li class="err">${esc(e.message)}</li>`;
|
||||
});
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
<section id="detail" class="pane">
|
||||
<div class="pane-head">
|
||||
<span id="detail-title">やりとりの時刻</span>
|
||||
<span class="tabs">
|
||||
<button class="tab sel" id="tab-turns">やりとり</button>
|
||||
<button class="tab" id="tab-daily">日別集計</button>
|
||||
</span>
|
||||
<a id="csv-turns" class="btn" href="#" download>CSV</a>
|
||||
</div>
|
||||
<div class="scroll" id="detail-body">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user