タスクを時間軸に並べるガントタブを追加
薄いバーで開始〜終了の全体、濃い部分で応答が動いていた区間を重ねて描く。 放置していた時間や、セッションを並行して動かしていた時間が見える。 - /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:
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user