Compare commits
5
Commits
0d9faa54bb
...
afae15b30c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afae15b30c | ||
|
|
8fd54757e6 | ||
|
|
0c312afaa5 | ||
|
|
efc7180317 | ||
|
|
4692043106 |
@@ -0,0 +1,70 @@
|
||||
# chktime
|
||||
|
||||
Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`)を解析して、
|
||||
タスクごとのやりとりの時刻を表示するローカル Web アプリ。使い方は [README.md](README.md) に書いてある。
|
||||
|
||||
## 構成
|
||||
|
||||
```
|
||||
chktime.py サーバ + jsonl 解析(これ1本で完結)
|
||||
web/index.html 画面の骨組み
|
||||
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
|
||||
web/style.css スタイル(ダークテーマ固定)
|
||||
run.bat コンソール表示で起動
|
||||
stop.cmd 起動中の chktime を終了
|
||||
```
|
||||
|
||||
`chktime.py` の中身は上から「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ HTTP」の順。
|
||||
|
||||
## 守ること
|
||||
|
||||
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
|
||||
- **外部通信しない**。読むのはローカルの jsonl だけ。
|
||||
- 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。
|
||||
- `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。
|
||||
- コメント・コミットメッセージ・UI の文言は日本語。
|
||||
- 機能を足したら README.md も更新する。
|
||||
|
||||
## 動かす
|
||||
|
||||
```bash
|
||||
python chktime.py --port 8799 --no-browser
|
||||
```
|
||||
|
||||
ユーザーは 8787 でデスクトップショートカットから常用しているので、**検証は 8799 など別ポート**で立てる。
|
||||
停止は `stop.cmd`(ただし `chktime.py` を実行中の python/pythonw を**すべて**終了するので、
|
||||
検証用だけを止めたいときはコマンドラインでポートを見て個別に kill する)。
|
||||
|
||||
## API
|
||||
|
||||
| ルート | 内容 |
|
||||
| --- | --- |
|
||||
| `/api/projects` | プロジェクト一覧 |
|
||||
| `/api/sessions?project=` | そのプロジェクトのタスク一覧 |
|
||||
| `/api/all` | 全プロジェクトのタスク一覧(横断モード) |
|
||||
| `/api/session?project=&id=` | 1タスクのターン一覧 |
|
||||
| `/api/daily?project=&days=&from=&to=&exclude=` | 日別集計 |
|
||||
| `/api/timeline?project=&days=` | ガント(タスク単位)。ターン単位は `/api/session` から描く |
|
||||
| `/api/watch` | SSE。ログが変わったら `change` を流す |
|
||||
| `/api/rev` `/api/reload` | 現在の版 / キャッシュ破棄 |
|
||||
|
||||
いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。
|
||||
|
||||
## 解析の前提
|
||||
|
||||
- ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。
|
||||
- 再開時に書かれるメタ行(attachment / system / mode / queue-operation)は**ターンの所要時間に数えない**。
|
||||
セッション全体の開始・終了時刻には反映する。
|
||||
- `tool_result` を含む行は巨大なので JSON パースせず、正規表現で `timestamp` だけ拾う。
|
||||
- 解析結果は `(mtime, size)` でキャッシュし、追記されただけのファイルは**増えたぶんだけ**読み足す
|
||||
(`SessionParser.read()`)。書きかけの行は消費せず次回に回す。先頭と解析済み末尾のバイト列が
|
||||
変わっていたら書き換えとみなして頭から読み直す。
|
||||
- 追記解析は状態を持つので、同じファイルを複数スレッドで同時に解析しない(`Entry.lock`)。
|
||||
|
||||
## 画面
|
||||
|
||||
- 3ペイン(プロジェクト / タスク一覧 / 詳細)。右ペインは **やりとり・日別集計・ガント**のタブ。
|
||||
- 状態はすべて `app.js` 冒頭の `state` オブジェクト。再描画は `render*()` が `#detail-body` を作り直す方式。
|
||||
- 自動追従(SSE)で作り直すときも、選択・スクロール位置・絞り込み・タブは保つ(`refreshFollow()`)。
|
||||
- ガントの軸・背景・枠組みは `timeScale` / `axisSvg` / `gridSvg` / `ganttFrame` をタスク単位と
|
||||
ターン単位で共有している。
|
||||
@@ -59,13 +59,19 @@ python chktime.py
|
||||
ターンをローカル日付で集計し、日ごとの **作業時間 / 往復数 / タスク数** を棒グラフと表で出します。
|
||||
|
||||
- 表示範囲は **14日 / 30日 / 90日 / 全期間**。活動のない日も 0 の列として並ぶので、間隔がそのまま日付軸になります。
|
||||
- 右の**日付入力**で期間を直接指定できます(片方だけでも可。開始のみ→その日から今日まで、終了のみ→その日まで全部)。指定中は日数ボタンの選択が外れ、**✕** で解除すると日数指定に戻ります。
|
||||
- 横断モードでは**凡例のプロジェクトをクリックすると集計から外せます**。外したものは下の「除外中」に並び、クリックで戻せます(「すべて戻す」でまとめて解除)。除外は棒グラフ・表・合計・CSV すべてに効きます。
|
||||
- 横断モードでは棒を**プロジェクト別の積み上げ**にします(作業時間上位 7 件に色、残りは「その他」)。単一プロジェクト選択時は 1 色。
|
||||
- 棒にカーソルを当てると、その日の内訳(プロジェクト別の作業時間)が出ます。土日は背景を薄く敷いています。
|
||||
- 棒の高さは応答時間の合計なので、**セッションを並行して動かした日は 24 時間を超えることがあります**。
|
||||
|
||||
### ガントタブ
|
||||
|
||||
タスク(セッション)を 1 行ずつ、時間軸に並べたガントチャートです。
|
||||
時間軸に並べたガントチャートです。左上の **タスク / ターン** で並べる単位を切り替えます。
|
||||
|
||||
#### タスク単位
|
||||
|
||||
タスク(セッション)を 1 行ずつ並べます。
|
||||
|
||||
- **薄いバー** = 開始から終了までの全体、**濃い部分** = 実際に応答が動いていた区間(各ターンの発言〜応答完了。1 秒以内の隙間は繋げています)。放置していた時間がそのまま隙間として見えます。
|
||||
- 表示範囲は **1日 / 3日 / 7日 / 14日 / 30日 / 全期間**。1〜3日なら軸が時刻目盛りに切り替わります。土日の帯・日境界の線・現在時刻の点線付き。
|
||||
@@ -73,6 +79,15 @@ python chktime.py
|
||||
- 行にカーソルを当てるとタイトル・プロジェクト・開始→終了・全体/作業/往復数、クリックするとそのタスクの「やりとり」を開きます。
|
||||
- 同じ時間帯に複数のバーが重なっていれば、セッションを並行して動かしていた時間です。
|
||||
|
||||
#### ターン単位
|
||||
|
||||
選択中のタスクのターンを 1 行ずつ、**絶対時刻の共通軸**に並べます。やりとりタブのタイムライン列は行ごとに正規化されているため長さを比べられませんが、こちらは横位置と長さがそのまま実時間です。
|
||||
|
||||
- **青** = 初回応答までの待ち、**緑** = 応答中(ツール実行・サブエージェント含む)、**灰** = 応答が返らなかったターン。行と行の空白がそのまま放置していた時間です。
|
||||
- 表示範囲はそのタスクの開始〜終了に自動で合わせます(目盛りは秒〜日単位で自動選択)。バーが横に重なっていれば、前の応答中に次を送った(並行)ターンです。
|
||||
- 行にカーソルを当てると発言内容・時刻・初回応答/応答時間/空き、クリックすると「やりとり」タブの該当行に移動します。
|
||||
- タスクを選ばずに開いた場合は、中央のタスク一覧から選ぶと表示されます。ターン単位を表示している間は、タスクを選び直してもガントのまま切り替わります。
|
||||
|
||||
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
|
||||
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
|
||||
|
||||
@@ -82,6 +97,7 @@ python chktime.py
|
||||
動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。
|
||||
|
||||
- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。
|
||||
- タブを閉じた接続は 5 秒以内に検出して片付けます(20 秒ごとの ping を待ちません)。
|
||||
- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。
|
||||
- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。
|
||||
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
|
||||
@@ -94,6 +110,7 @@ python chktime.py
|
||||
- `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。
|
||||
- セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。
|
||||
- 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。
|
||||
- 追記されただけのファイルは**増えたぶんだけ**読み足します(書きかけの行は次回に持ち越し)。先頭と解析済み末尾のバイト列が変わっていた場合=ログが書き換えられた場合は頭から読み直します。
|
||||
|
||||
## ファイル
|
||||
|
||||
|
||||
+311
-172
@@ -20,10 +20,12 @@ import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -38,6 +40,9 @@ TAG_RE = re.compile(r"<([a-z-]+)>(.*?)</\1>", re.S)
|
||||
PROMPT_LIMIT = 4000
|
||||
SUMMARY_LIMIT = 200
|
||||
|
||||
WATCH_POLL = 5.0 # SSE: 切断を見に行く間隔(秒)
|
||||
PING_INTERVAL = 20.0 # SSE: 接続維持のコメントを送る間隔(秒)
|
||||
|
||||
# ---------------------------------------------------------------- 解析ロジック
|
||||
|
||||
|
||||
@@ -138,166 +143,232 @@ def new_turn(index, kind, text, ts):
|
||||
}
|
||||
|
||||
|
||||
def parse_file(path: Path):
|
||||
"""1セッションの jsonl を解析して dict を返す。"""
|
||||
turns = []
|
||||
titles = {}
|
||||
meta = {}
|
||||
counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0}
|
||||
start = end = None
|
||||
pending_prompt = None # turn 開始待ち (queue-operation より後の user 本体を採用)
|
||||
TAIL_BYTES = 1024 # 追記の続きかを確かめるために覚えておく末尾のバイト数
|
||||
|
||||
with path.open("rb") as fh:
|
||||
for raw in fh:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
# tool_result 行は巨大なので JSON パースせず時刻だけ拾う
|
||||
if b'"tool_result"' in raw and b'"type":"user"' in raw:
|
||||
match = TS_RE.search(raw)
|
||||
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
||||
counts["toolResult"] += 1
|
||||
if ts is not None:
|
||||
start = ts if start is None else min(start, ts)
|
||||
end = ts if end is None else max(end, ts)
|
||||
if turns:
|
||||
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
|
||||
continue
|
||||
class SessionParser:
|
||||
"""1セッションの jsonl を解析する。追記されたぶんだけ読み足せる。
|
||||
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
continue
|
||||
read() は「改行で終わっている行」までしか消費しないので、書きかけの行が
|
||||
あっても次回の read() で読み直される。ログは追記されるだけという前提だが、
|
||||
書き換えられた場合は resumable() が False を返すので作り直せばよい。
|
||||
"""
|
||||
|
||||
rtype = obj.get("type")
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.turns = []
|
||||
self.titles = {}
|
||||
self.meta = {}
|
||||
self.counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0}
|
||||
self.start = None
|
||||
self.end = None
|
||||
self.pos = 0 # 解析済みのバイト位置
|
||||
self.head = b"" # 先頭 TAIL_BYTES バイト
|
||||
self.tail = b"" # pos の直前 TAIL_BYTES バイト
|
||||
|
||||
if rtype == "custom-title":
|
||||
titles["custom"] = obj.get("customTitle")
|
||||
continue
|
||||
if rtype == "ai-title":
|
||||
titles["ai"] = obj.get("aiTitle")
|
||||
continue
|
||||
if rtype == "last-prompt":
|
||||
titles["last"] = obj.get("lastPrompt")
|
||||
continue
|
||||
# -- 追記の読み取り ------------------------------------------------
|
||||
|
||||
ts = parse_ts(obj.get("timestamp"))
|
||||
def read(self, size=None):
|
||||
"""未読部分を読んで解析する。前回の続きでなければ False を返す。
|
||||
|
||||
追記されるだけのログを前提に、先頭と解析済み末尾のバイト列だけを見て
|
||||
「続き」かを判定する。ファイル open は Windows では数 ms かかるので、
|
||||
確認と読み取りは 1 回の open で済ませる。
|
||||
"""
|
||||
if size is not None and size < self.pos:
|
||||
return False # 縮んだ = 書き換えられた
|
||||
with self.path.open("rb") as fh:
|
||||
if self.pos:
|
||||
if fh.read(len(self.head)) != self.head:
|
||||
return False
|
||||
fh.seek(self.pos - len(self.tail))
|
||||
if fh.read(len(self.tail)) != self.tail:
|
||||
return False
|
||||
else:
|
||||
fh.seek(0)
|
||||
chunk = fh.read()
|
||||
if not chunk:
|
||||
return True
|
||||
lines = chunk.split(b"\n")
|
||||
partial = lines.pop() # 改行で終わっていなければ書きかけの行
|
||||
used = len(chunk) - len(partial)
|
||||
for raw in lines:
|
||||
self.feed(raw)
|
||||
if used:
|
||||
if len(self.head) < TAIL_BYTES:
|
||||
self.head = (self.head + chunk[:used])[:TAIL_BYTES]
|
||||
self.tail = (self.tail + chunk[:used])[-TAIL_BYTES:]
|
||||
self.pos += used
|
||||
return True
|
||||
|
||||
# -- 1行の解析 ----------------------------------------------------
|
||||
|
||||
def bump(self, ts):
|
||||
self.start = ts if self.start is None else min(self.start, ts)
|
||||
self.end = ts if self.end is None else max(self.end, ts)
|
||||
|
||||
def extend_last(self, ts):
|
||||
"""直前のターンの終端を伸ばす (サブエージェント・ツール結果・中断)。"""
|
||||
if self.turns and ts is not None:
|
||||
last = self.turns[-1]
|
||||
last["endTs"] = max(last["endTs"] or ts, ts)
|
||||
|
||||
def feed(self, raw):
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return
|
||||
|
||||
# tool_result 行は巨大なので JSON パースせず時刻だけ拾う
|
||||
if b'"tool_result"' in raw and b'"type":"user"' in raw:
|
||||
match = TS_RE.search(raw)
|
||||
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
||||
self.counts["toolResult"] += 1
|
||||
if ts is not None:
|
||||
start = ts if start is None else min(start, ts)
|
||||
end = ts if end is None else max(end, ts)
|
||||
self.bump(ts)
|
||||
self.extend_last(ts)
|
||||
return
|
||||
|
||||
for key in ("cwd", "gitBranch", "version", "slug", "sessionId"):
|
||||
if not meta.get(key) and obj.get(key):
|
||||
meta[key] = obj[key]
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return
|
||||
|
||||
if rtype == "user":
|
||||
counts["user"] += 1
|
||||
parsed = classify_user(obj)
|
||||
if not parsed:
|
||||
if obj.get("isSidechain"):
|
||||
counts["sidechain"] += 1
|
||||
if turns and ts is not None:
|
||||
turns[-1]["sidechain"] += 1
|
||||
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
|
||||
continue
|
||||
kind, text = parsed
|
||||
if kind == "interrupt":
|
||||
if turns:
|
||||
turns[-1]["interrupted"] = True
|
||||
if ts is not None:
|
||||
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
|
||||
continue
|
||||
pending_prompt = None
|
||||
turns.append(new_turn(len(turns) + 1, kind, text, ts))
|
||||
continue
|
||||
rtype = obj.get("type")
|
||||
|
||||
if rtype == "assistant":
|
||||
if rtype == "custom-title":
|
||||
self.titles["custom"] = obj.get("customTitle")
|
||||
return
|
||||
if rtype == "ai-title":
|
||||
self.titles["ai"] = obj.get("aiTitle")
|
||||
return
|
||||
if rtype == "last-prompt":
|
||||
self.titles["last"] = obj.get("lastPrompt")
|
||||
return
|
||||
|
||||
ts = parse_ts(obj.get("timestamp"))
|
||||
if ts is not None:
|
||||
self.bump(ts)
|
||||
|
||||
for key in ("cwd", "gitBranch", "version", "slug", "sessionId"):
|
||||
if not self.meta.get(key) and obj.get(key):
|
||||
self.meta[key] = obj[key]
|
||||
|
||||
if rtype == "user":
|
||||
self.counts["user"] += 1
|
||||
parsed = classify_user(obj)
|
||||
if not parsed:
|
||||
if obj.get("isSidechain"):
|
||||
counts["sidechain"] += 1
|
||||
if turns and ts is not None:
|
||||
turns[-1]["sidechain"] += 1
|
||||
turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts)
|
||||
continue
|
||||
counts["assistant"] += 1
|
||||
if turns:
|
||||
turn = turns[-1]
|
||||
turn["assistant"] += 1
|
||||
if ts is not None:
|
||||
if turn["firstReplyTs"] is None:
|
||||
turn["firstReplyTs"] = ts
|
||||
turn["endTs"] = max(turn["endTs"] or ts, ts)
|
||||
model = (obj.get("message") or {}).get("model")
|
||||
if model and model not in turn["models"]:
|
||||
turn["models"].append(model)
|
||||
content = (obj.get("message") or {}).get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
name = block.get("name") or "?"
|
||||
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
||||
turn["toolCalls"] += 1
|
||||
continue
|
||||
self.counts["sidechain"] += 1
|
||||
if self.turns and ts is not None:
|
||||
self.turns[-1]["sidechain"] += 1
|
||||
self.extend_last(ts)
|
||||
return
|
||||
kind, text = parsed
|
||||
if kind == "interrupt":
|
||||
if self.turns:
|
||||
self.turns[-1]["interrupted"] = True
|
||||
self.extend_last(ts)
|
||||
return
|
||||
self.turns.append(new_turn(len(self.turns) + 1, kind, text, ts))
|
||||
return
|
||||
|
||||
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
||||
# 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。
|
||||
if rtype == "assistant":
|
||||
if obj.get("isSidechain"):
|
||||
self.counts["sidechain"] += 1
|
||||
if self.turns and ts is not None:
|
||||
self.turns[-1]["sidechain"] += 1
|
||||
self.extend_last(ts)
|
||||
return
|
||||
self.counts["assistant"] += 1
|
||||
if not self.turns:
|
||||
return
|
||||
turn = self.turns[-1]
|
||||
turn["assistant"] += 1
|
||||
if ts is not None:
|
||||
if turn["firstReplyTs"] is None:
|
||||
turn["firstReplyTs"] = ts
|
||||
turn["endTs"] = max(turn["endTs"] or ts, ts)
|
||||
model = (obj.get("message") or {}).get("model")
|
||||
if model and model not in turn["models"]:
|
||||
turn["models"].append(model)
|
||||
content = (obj.get("message") or {}).get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||
name = block.get("name") or "?"
|
||||
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
||||
turn["toolCalls"] += 1
|
||||
return
|
||||
|
||||
_ = pending_prompt # 予約
|
||||
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
||||
# 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。
|
||||
|
||||
# ターンごとの派生値
|
||||
prev_end = None
|
||||
active = 0.0
|
||||
tool_total = 0
|
||||
for turn in turns:
|
||||
ts = turn["ts"]
|
||||
turn["waitMs"] = ms(turn["firstReplyTs"], ts)
|
||||
turn["durMs"] = ms(turn["endTs"], ts)
|
||||
turn["idleMs"] = ms(ts, prev_end)
|
||||
turn["tsIso"] = iso(ts)
|
||||
turn["endIso"] = iso(turn["endTs"])
|
||||
turn["summary"] = one_line(turn["prompt"], SUMMARY_LIMIT)
|
||||
turn["toolList"] = sorted(turn["tools"].items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
tool_total += turn["toolCalls"]
|
||||
if turn["durMs"]:
|
||||
active += turn["durMs"]
|
||||
prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end
|
||||
# -- 結果 ----------------------------------------------------------
|
||||
|
||||
first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "")
|
||||
title = (
|
||||
titles.get("custom")
|
||||
or titles.get("ai")
|
||||
or one_line(first_prompt, 120)
|
||||
or titles.get("last")
|
||||
or meta.get("slug")
|
||||
or path.stem
|
||||
)
|
||||
def result(self, stat=None):
|
||||
"""現在までの解析結果を dict にする。派生値はここで計算し直す。"""
|
||||
stat = stat or self.path.stat()
|
||||
turns = self.turns
|
||||
prev_end = None
|
||||
active = 0.0
|
||||
tool_total = 0
|
||||
for turn in turns:
|
||||
ts = turn["ts"]
|
||||
turn["waitMs"] = ms(turn["firstReplyTs"], ts)
|
||||
turn["durMs"] = ms(turn["endTs"], ts)
|
||||
turn["idleMs"] = ms(ts, prev_end)
|
||||
turn["tsIso"] = iso(ts)
|
||||
turn["endIso"] = iso(turn["endTs"])
|
||||
turn["summary"] = one_line(turn["prompt"], SUMMARY_LIMIT)
|
||||
turn["toolList"] = sorted(turn["tools"].items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
tool_total += turn["toolCalls"]
|
||||
if turn["durMs"]:
|
||||
active += turn["durMs"]
|
||||
prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end
|
||||
|
||||
stat = path.stat()
|
||||
return {
|
||||
"id": path.stem,
|
||||
"file": str(path),
|
||||
"title": title,
|
||||
"titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"),
|
||||
"cwd": meta.get("cwd"),
|
||||
"gitBranch": meta.get("gitBranch"),
|
||||
"version": meta.get("version"),
|
||||
"slug": meta.get("slug"),
|
||||
"start": iso(start),
|
||||
"end": iso(end),
|
||||
"startEpoch": start,
|
||||
"endEpoch": end,
|
||||
"wallMs": ms(end, start),
|
||||
"activeMs": int(active) if active else 0,
|
||||
"turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")),
|
||||
"userMsgs": counts["user"],
|
||||
"assistantMsgs": counts["assistant"],
|
||||
"toolResults": counts["toolResult"],
|
||||
"sidechainMsgs": counts["sidechain"],
|
||||
"toolCalls": tool_total,
|
||||
"bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"turns": turns,
|
||||
}
|
||||
titles = self.titles
|
||||
first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "")
|
||||
title = (
|
||||
titles.get("custom")
|
||||
or titles.get("ai")
|
||||
or one_line(first_prompt, 120)
|
||||
or titles.get("last")
|
||||
or self.meta.get("slug")
|
||||
or self.path.stem
|
||||
)
|
||||
|
||||
return {
|
||||
"id": self.path.stem,
|
||||
"file": str(self.path),
|
||||
"title": title,
|
||||
"titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"),
|
||||
"cwd": self.meta.get("cwd"),
|
||||
"gitBranch": self.meta.get("gitBranch"),
|
||||
"version": self.meta.get("version"),
|
||||
"slug": self.meta.get("slug"),
|
||||
"start": iso(self.start),
|
||||
"end": iso(self.end),
|
||||
"startEpoch": self.start,
|
||||
"endEpoch": self.end,
|
||||
"wallMs": ms(self.end, self.start),
|
||||
"activeMs": int(active) if active else 0,
|
||||
"turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")),
|
||||
"userMsgs": self.counts["user"],
|
||||
"assistantMsgs": self.counts["assistant"],
|
||||
"toolResults": self.counts["toolResult"],
|
||||
"sidechainMsgs": self.counts["sidechain"],
|
||||
"toolCalls": tool_total,
|
||||
"bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"turns": turns,
|
||||
}
|
||||
|
||||
|
||||
def parse_file(path: Path):
|
||||
"""1セッションの jsonl を頭から解析して dict を返す。"""
|
||||
parser = SessionParser(path)
|
||||
parser.read()
|
||||
return parser.result()
|
||||
|
||||
|
||||
def ms(later, earlier):
|
||||
@@ -328,8 +399,24 @@ def summary_of(session):
|
||||
# ---------------------------------------------------------------- ストア
|
||||
|
||||
|
||||
class Entry:
|
||||
"""1ファイルぶんのキャッシュ。パーサを持ち回して追記分だけ読み足す。"""
|
||||
|
||||
__slots__ = ("lock", "parser", "token", "data")
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.lock = threading.Lock() # 同じファイルを同時に解析しない
|
||||
self.parser = SessionParser(path)
|
||||
self.token = None # (mtime, size)
|
||||
self.data = None
|
||||
|
||||
|
||||
class Store:
|
||||
"""jsonl の解析結果を (mtime, size) でキャッシュする。"""
|
||||
"""jsonl の解析結果を (mtime, size) でキャッシュする。
|
||||
|
||||
変化したファイルは、末尾に追記されただけなら追加分だけを解析する。
|
||||
書き換えられていた場合 (縮んだ / 途中が変わった) は頭から読み直す。
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
@@ -368,16 +455,22 @@ class Store:
|
||||
return sorted(directory.glob("*.jsonl"))
|
||||
|
||||
def session(self, path: Path):
|
||||
stat = path.stat()
|
||||
key = str(path)
|
||||
with self._lock:
|
||||
hit = self._cache.get(key)
|
||||
if hit and hit[0] == (stat.st_mtime, stat.st_size):
|
||||
return hit[1]
|
||||
parsed = parse_file(path)
|
||||
with self._lock:
|
||||
self._cache[key] = ((stat.st_mtime, stat.st_size), parsed)
|
||||
return parsed
|
||||
entry = self._cache.get(key)
|
||||
if entry is None:
|
||||
entry = self._cache[key] = Entry(path)
|
||||
with entry.lock:
|
||||
stat = path.stat()
|
||||
token = (stat.st_mtime, stat.st_size)
|
||||
if entry.token == token and entry.data is not None:
|
||||
return entry.data
|
||||
if not entry.parser.read(stat.st_size):
|
||||
entry.parser = SessionParser(path) # 書き換えられていた: 頭から
|
||||
entry.parser.read()
|
||||
entry.data = entry.parser.result(stat)
|
||||
entry.token = token
|
||||
return entry.data
|
||||
|
||||
def sessions(self, project_id):
|
||||
out = [summary_of(self.session(p)) for p in self.session_files(project_id)]
|
||||
@@ -402,10 +495,12 @@ class Store:
|
||||
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
|
||||
return out
|
||||
|
||||
def daily_stats(self, project_id=None, days=30):
|
||||
def daily_stats(self, project_id=None, days=30, start=None, end=None, exclude=()):
|
||||
"""ターンをローカル日付で集計する。project_id=None なら全プロジェクト。
|
||||
|
||||
days>0 なら「今日から遡って days 日」、それ以外は全期間。
|
||||
期間は start/end (date) を渡せばその範囲、無ければ days>0 で「今日から
|
||||
遡って days 日」、days<=0 なら全期間。exclude に入れたプロジェクトは
|
||||
集計に含めない (横断モード用)。
|
||||
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
|
||||
"""
|
||||
paths = {p["id"]: p["path"] for p in self.projects()}
|
||||
@@ -414,10 +509,16 @@ class Store:
|
||||
raise FileNotFoundError(project_id)
|
||||
targets = [project_id]
|
||||
else:
|
||||
targets = list(paths)
|
||||
targets = [pid for pid in paths if pid not in exclude]
|
||||
|
||||
today = datetime.now().date()
|
||||
cutoff = today - timedelta(days=days - 1) if days and days > 0 else None
|
||||
if start or end:
|
||||
lo, hi = start, end or today
|
||||
if lo and hi < lo:
|
||||
lo, hi = hi, lo
|
||||
else:
|
||||
lo = today - timedelta(days=days - 1) if days and days > 0 else None
|
||||
hi = None # 上限なし (今日 かデータの末尾まで)
|
||||
|
||||
per_day = {}
|
||||
per_project = {}
|
||||
@@ -429,7 +530,7 @@ class Store:
|
||||
if turn["ts"] is None:
|
||||
continue
|
||||
day = datetime.fromtimestamp(turn["ts"]).date()
|
||||
if cutoff and day < cutoff:
|
||||
if (lo and day < lo) or (hi and day > hi):
|
||||
continue
|
||||
dur = turn["durMs"] or 0
|
||||
counted = 1 if turn["kind"] in ("prompt", "command") else 0
|
||||
@@ -445,16 +546,16 @@ class Store:
|
||||
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 で埋める
|
||||
first = lo or min(per_day)
|
||||
last = hi or max(max(per_day), today)
|
||||
elif lo or hi:
|
||||
first, last = (lo or hi), (hi or today) # データなしでも指定範囲は 0 で埋める
|
||||
else:
|
||||
start = end = None
|
||||
first = last = None
|
||||
|
||||
rows = []
|
||||
cur = start
|
||||
while cur and cur <= end:
|
||||
cur = first
|
||||
while cur and cur <= last:
|
||||
bucket = per_day.get(cur)
|
||||
rows.append(
|
||||
{
|
||||
@@ -484,7 +585,11 @@ class Store:
|
||||
busiest = max(rows, key=lambda r: r["activeMs"], default=None)
|
||||
return {
|
||||
"scope": project_id or "",
|
||||
"rangeDays": days if days and days > 0 else 0,
|
||||
"rangeDays": 0 if (start or end) else (days if days and days > 0 else 0),
|
||||
"custom": bool(start or end),
|
||||
"from": first.isoformat() if first else None,
|
||||
"to": last.isoformat() if last else None,
|
||||
"excluded": sorted(exclude),
|
||||
"days": rows,
|
||||
"projects": projects,
|
||||
"totals": {
|
||||
@@ -701,6 +806,19 @@ def parse_days(value):
|
||||
return max(0, min(days, 3650))
|
||||
|
||||
|
||||
def parse_date(value):
|
||||
"""?from= / ?to= の解釈。'YYYY-MM-DD' 以外は None (指定なし扱い)。"""
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_ids(value):
|
||||
"""?exclude= の解釈。カンマ区切りのプロジェクト ID。"""
|
||||
return {part for part in (value or "").split(",") if part}
|
||||
|
||||
|
||||
def sessions_csv(sessions, with_project=False):
|
||||
buf = io.StringIO(newline="")
|
||||
writer = csv.writer(buf)
|
||||
@@ -797,7 +915,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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]))
|
||||
data = store.daily_stats(
|
||||
project or None,
|
||||
parse_days(query.get("days", ["30"])[0]),
|
||||
parse_date(query.get("from", [""])[0]),
|
||||
parse_date(query.get("to", [""])[0]),
|
||||
parse_ids(query.get("exclude", [""])[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)
|
||||
@@ -824,7 +948,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise FileNotFoundError(route)
|
||||
|
||||
def watch(self):
|
||||
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。"""
|
||||
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。
|
||||
|
||||
タブを閉じた接続はソケットの EOF で気付いて抜ける。書き込みが失敗する
|
||||
まで (ping 最大20秒ぶん) スレッドを抱えたままにしないため。
|
||||
"""
|
||||
watcher = self.server.watcher
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
@@ -832,18 +960,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.end_headers()
|
||||
rev = watcher.rev
|
||||
last_ping = time.monotonic()
|
||||
try:
|
||||
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
|
||||
while True:
|
||||
current = watcher.wait_change(rev, 20)
|
||||
while not self.disconnected():
|
||||
current = watcher.wait_change(rev, WATCH_POLL)
|
||||
if current != rev:
|
||||
rev = current
|
||||
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
|
||||
else:
|
||||
last_ping = time.monotonic()
|
||||
elif time.monotonic() - last_ping >= PING_INTERVAL:
|
||||
self.stream(": ping\n\n") # 接続維持
|
||||
last_ping = time.monotonic()
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
|
||||
return # タブを閉じた等
|
||||
|
||||
def disconnected(self):
|
||||
"""クライアントが切断済みかを見る (受信側の EOF で判定)。"""
|
||||
try:
|
||||
ready, _, _ = select.select([self.connection], [], [], 0)
|
||||
return bool(ready) and not self.connection.recv(1, socket.MSG_PEEK)
|
||||
except (OSError, ValueError):
|
||||
return True
|
||||
|
||||
def stream(self, text):
|
||||
self.wfile.write(text.encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
+360
-77
@@ -12,8 +12,12 @@ const state = {
|
||||
relative: false,
|
||||
view: 'turns',
|
||||
days: 30,
|
||||
dailyFrom: '', // 期間の任意指定 (YYYY-MM-DD)。空なら days を使う
|
||||
dailyTo: '',
|
||||
dailyExclude: [], // 集計から外すプロジェクト ID (横断モードのみ)
|
||||
daily: null,
|
||||
ganttDays: 7,
|
||||
ganttUnit: 'task', // task=タスク(セッション)を並べる / turn=選択中タスクのターンを並べる
|
||||
gantt: null,
|
||||
follow: false,
|
||||
es: null,
|
||||
@@ -81,6 +85,8 @@ function ago(iso) {
|
||||
return `${Math.round(diff / 86400)}日前`;
|
||||
}
|
||||
|
||||
const clip = (s, n) => (String(s ?? '').length > n ? String(s).slice(0, n) + '…' : String(s ?? ''));
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
async function api(path) {
|
||||
@@ -195,22 +201,27 @@ function shortPath(path) {
|
||||
// ---------------------------------------------------------------- 詳細
|
||||
|
||||
async function selectSession(project, id) {
|
||||
// ターン単位のガントを見ている間は、タスクを選び直してもそのまま並べ替える
|
||||
const stayGantt = state.view === 'gantt' && state.ganttUnit === 'turn';
|
||||
state.sessionProject = project;
|
||||
state.session = id;
|
||||
state.view = 'turns';
|
||||
if (!stayGantt) state.view = 'turns';
|
||||
syncTabs();
|
||||
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
|
||||
updateCsv();
|
||||
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
|
||||
await reloadDetail();
|
||||
if (stayGantt) await loadGantt({ silent: true });
|
||||
else await reloadDetail();
|
||||
}
|
||||
|
||||
function fetchDetail() {
|
||||
return api(`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`);
|
||||
}
|
||||
|
||||
async function reloadDetail() {
|
||||
if (!state.session) return renderDetail();
|
||||
try {
|
||||
state.detail = await api(
|
||||
`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`
|
||||
);
|
||||
state.detail = await fetchDetail();
|
||||
renderDetail();
|
||||
} catch (e) {
|
||||
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||||
@@ -308,21 +319,34 @@ function syncTabs() {
|
||||
$('#tab-gantt').classList.toggle('sel', state.view === 'gantt');
|
||||
}
|
||||
|
||||
function scopeQuery(days, extra) {
|
||||
const q = new URLSearchParams({ days: String(days), ...extra });
|
||||
function ganttQuery(extra) {
|
||||
const q = new URLSearchParams({ days: String(state.ganttDays), ...extra });
|
||||
if (state.project) q.set('project', state.project);
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
const dailyQuery = (extra) => scopeQuery(state.days, extra);
|
||||
const ganttQuery = (extra) => scopeQuery(state.ganttDays, extra);
|
||||
const customRange = () => Boolean(state.dailyFrom || state.dailyTo);
|
||||
|
||||
function dailyQuery(extra) {
|
||||
const q = new URLSearchParams(extra || {});
|
||||
if (state.project) q.set('project', state.project);
|
||||
if (customRange()) {
|
||||
if (state.dailyFrom) q.set('from', state.dailyFrom);
|
||||
if (state.dailyTo) q.set('to', state.dailyTo);
|
||||
} else {
|
||||
q.set('days', String(state.days));
|
||||
}
|
||||
// 除外は横断モードだけ (単一プロジェクト表示では自分自身を消してしまうので送らない)
|
||||
if (!state.project && state.dailyExclude.length) q.set('exclude', state.dailyExclude.join(','));
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
function updateCsv() {
|
||||
const link = $('#csv-turns');
|
||||
if (state.view === 'daily') {
|
||||
link.href = '/api/daily?' + dailyQuery({ format: 'csv' });
|
||||
link.title = '日別集計を CSV 出力';
|
||||
} else if (state.view === 'gantt') {
|
||||
} else if (state.view === 'gantt' && state.ganttUnit === 'task') {
|
||||
link.href = '/api/timeline?' + ganttQuery({ format: 'csv' });
|
||||
link.title = 'ガントのタスク一覧を CSV 出力';
|
||||
} else if (state.session) {
|
||||
@@ -457,9 +481,16 @@ function renderDaily() {
|
||||
svg.push(`<rect class="hit" x="${(padL + i * slot).toFixed(1)}" y="${padT}" width="${slot.toFixed(1)}" height="${plotH}"><title>${esc(tip(r))}</title></rect>`);
|
||||
});
|
||||
|
||||
const custom = customRange();
|
||||
const ranges = [14, 30, 90, 0]
|
||||
.map((v) => `<button class="rng${v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
|
||||
.map((v) => `<button class="rng${!custom && v === state.days ? ' sel' : ''}" data-days="${v}">${v ? v + '日' : '全期間'}</button>`)
|
||||
.join('');
|
||||
const dates = `<span class="dates">
|
||||
<input type="date" id="d-from" value="${state.dailyFrom}" max="${state.dailyTo}" title="開始日">
|
||||
<span class="tilde">〜</span>
|
||||
<input type="date" id="d-to" value="${state.dailyTo}" min="${state.dailyFrom}" title="終了日">
|
||||
${custom ? '<button class="rng sel" id="d-clear" title="期間指定を解除">✕</button>' : ''}
|
||||
</span>`;
|
||||
|
||||
const active = rows.filter((r) => r.activeMs || r.turns).reverse();
|
||||
const table = active.length
|
||||
@@ -498,17 +529,58 @@ function renderDaily() {
|
||||
</div>
|
||||
<div class="chart-bar">
|
||||
<span class="rngs">${ranges}</span>
|
||||
${dates}
|
||||
<span class="note">棒=その日の応答時間の合計。セッションを並行して動かした日は 24 時間を超えることがあります。</span>
|
||||
</div>
|
||||
<div class="chart"><svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${svg.join('')}</svg></div>
|
||||
${cross
|
||||
? `<div class="legend">${legend
|
||||
.map((p, i) => `<span class="lg" title="${esc(p.path)}"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`)
|
||||
.map((p, i) => `<span class="lg act" data-ex="${esc(p.id)}" title="${esc(p.path)}\nクリックで集計から外す"><i style="background:${PALETTE[i]}"></i>${esc(shortPath(p.path))} <b>${fmtDur(p.activeMs)}</b></span>`)
|
||||
.join('')}${otherMs ? `<span class="lg"><i style="background:${OTHER_COLOR}"></i>その他 ${d.projects.length - legend.length} 件 <b>${fmtDur(otherMs)}</b></span>` : ''}</div>`
|
||||
: ''}
|
||||
${cross && state.dailyExclude.length
|
||||
? `<div class="legend excl"><span class="cap">除外中</span>${state.dailyExclude
|
||||
.map((id) => `<span class="lg off" data-ex="${esc(id)}" title="${esc(projectPathOf(id))}\nクリックで戻す">✕ ${esc(shortPath(projectPathOf(id)))}</span>`)
|
||||
.join('')}<button class="rng" id="ex-clear">すべて戻す</button></div>`
|
||||
: ''}
|
||||
${table}`;
|
||||
|
||||
wireRanges('days', loadDaily);
|
||||
wireDaily();
|
||||
}
|
||||
|
||||
/** 日別集計の期間指定・プロジェクト除外を配線する。 */
|
||||
function wireDaily() {
|
||||
const setRange = (from, to) => {
|
||||
state.dailyFrom = from;
|
||||
state.dailyTo = to;
|
||||
updateCsv();
|
||||
loadDaily();
|
||||
};
|
||||
const from = $('#d-from');
|
||||
const to = $('#d-to');
|
||||
if (from) from.onchange = () => setRange(from.value, state.dailyTo);
|
||||
if (to) to.onchange = () => setRange(state.dailyFrom, to.value);
|
||||
const clear = $('#d-clear');
|
||||
if (clear) clear.onclick = () => setRange('', '');
|
||||
|
||||
document.querySelectorAll('#detail-body .legend [data-ex]').forEach((el) => {
|
||||
el.onclick = () => {
|
||||
const id = el.dataset.ex;
|
||||
const at = state.dailyExclude.indexOf(id);
|
||||
if (at >= 0) state.dailyExclude.splice(at, 1);
|
||||
else state.dailyExclude.push(id);
|
||||
updateCsv();
|
||||
loadDaily();
|
||||
};
|
||||
});
|
||||
const exClear = $('#ex-clear');
|
||||
if (exClear)
|
||||
exClear.onclick = () => {
|
||||
state.dailyExclude = [];
|
||||
updateCsv();
|
||||
loadDaily();
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ガント
|
||||
@@ -519,22 +591,58 @@ async function loadGantt({ silent = false } = {}) {
|
||||
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
|
||||
}
|
||||
try {
|
||||
state.gantt = await api('/api/timeline?' + ganttQuery());
|
||||
renderGantt();
|
||||
if (state.ganttUnit === 'turn') {
|
||||
if (state.session) state.detail = await fetchDetail();
|
||||
renderGanttTurns();
|
||||
} else {
|
||||
state.gantt = await api('/api/timeline?' + ganttQuery());
|
||||
renderGanttTasks();
|
||||
}
|
||||
} catch (e) {
|
||||
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGantt() {
|
||||
if (state.ganttUnit === 'turn') renderGanttTurns();
|
||||
else renderGanttTasks();
|
||||
}
|
||||
|
||||
const MIN = 60000;
|
||||
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];
|
||||
const TICK_STEPS = [
|
||||
1000, 2000, 5000, 10000, 15000, 30000, MIN, 2 * MIN, 5 * MIN, 10 * MIN, 15 * MIN, 30 * MIN,
|
||||
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];
|
||||
}
|
||||
|
||||
/** 目盛りの開始位置。step の単位で切り捨てる (ローカル時刻基準)。 */
|
||||
function floorTo(t, step) {
|
||||
const d = new Date(t);
|
||||
d.setMilliseconds(0);
|
||||
if (step < MIN) {
|
||||
d.setSeconds(d.getSeconds() - (d.getSeconds() % (step / 1000)));
|
||||
return d.getTime();
|
||||
}
|
||||
d.setSeconds(0);
|
||||
if (step < HOUR) {
|
||||
d.setMinutes(d.getMinutes() - (d.getMinutes() % (step / MIN)));
|
||||
return d.getTime();
|
||||
}
|
||||
d.setMinutes(0);
|
||||
if (step < DAY) {
|
||||
d.setHours(d.getHours() - (d.getHours() % (step / HOUR)));
|
||||
return d.getTime();
|
||||
}
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function midnightsIn(t0, t1) {
|
||||
const out = [];
|
||||
const d = new Date(t0);
|
||||
@@ -543,7 +651,108 @@ function midnightsIn(t0, t1) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderGantt() {
|
||||
/** 時間軸のスケール。tasks / turns で共通。 */
|
||||
function timeScale(t0, t1, plotW) {
|
||||
const span = Math.max(1, t1 - t0);
|
||||
const step = tickStep(span, plotW);
|
||||
return {
|
||||
t0,
|
||||
t1,
|
||||
plotW,
|
||||
step,
|
||||
subDay: step < DAY,
|
||||
subHour: step < HOUR,
|
||||
xOf: (t) => ((Math.min(Math.max(t, t0), t1) - t0) / span) * plotW,
|
||||
};
|
||||
}
|
||||
|
||||
const p2 = (n) => String(n).padStart(2, '0');
|
||||
|
||||
/** 上部に固定表示する時刻軸 (高さ 26px の svg 中身)。 */
|
||||
function axisSvg(sc) {
|
||||
const out = [];
|
||||
for (let t = floorTo(sc.t0, sc.step); t <= sc.t1; t += sc.step) {
|
||||
if (t < sc.t0) continue;
|
||||
const d = new Date(t);
|
||||
const x = sc.xOf(t);
|
||||
const midnight = d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0;
|
||||
const label = !sc.subDay || midnight
|
||||
? `${p2(d.getMonth() + 1)}/${p2(d.getDate())}`
|
||||
: sc.subHour
|
||||
? `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
|
||||
: `${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
||||
out.push(`<line class="grid" x1="${x.toFixed(1)}" y1="18" x2="${x.toFixed(1)}" y2="24"/>`);
|
||||
out.push(
|
||||
`<text class="ax${midnight || !sc.subDay ? ' hi' : ''}" x="${Math.min(sc.plotW - 2, Math.max(2, x)).toFixed(1)}" y="13" text-anchor="${x < 20 ? 'start' : x > sc.plotW - 20 ? 'end' : 'middle'}">${label}</text>`
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 背景 (土日の帯・日境界・細かい目盛り線)。バーより先に描く。 */
|
||||
function gridSvg(sc, H) {
|
||||
const out = [];
|
||||
midnightsIn(sc.t0, sc.t1).forEach((t) => {
|
||||
const d = new Date(t);
|
||||
const x = sc.xOf(t);
|
||||
const xEnd = sc.xOf(t + DAY);
|
||||
if (d.getDay() === 0 || d.getDay() === 6) {
|
||||
out.push(`<rect class="wk" x="${x.toFixed(1)}" y="0" width="${(xEnd - x).toFixed(1)}" height="${H}"/>`);
|
||||
}
|
||||
if (t > sc.t0) out.push(`<line class="day" x1="${x.toFixed(1)}" y1="0" x2="${x.toFixed(1)}" y2="${H}"/>`);
|
||||
});
|
||||
if (sc.subDay) {
|
||||
for (let t = floorTo(sc.t0, sc.step); t <= sc.t1; t += sc.step) {
|
||||
if (t < sc.t0) continue;
|
||||
const d = new Date(t);
|
||||
if (d.getHours() === 0 && d.getMinutes() === 0 && d.getSeconds() === 0) continue;
|
||||
out.push(`<line class="grid" x1="${sc.xOf(t).toFixed(1)}" y1="0" x2="${sc.xOf(t).toFixed(1)}" y2="${H}"/>`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nowLine(sc, H, nowMs) {
|
||||
if (!(nowMs > sc.t0 && nowMs <= sc.t1)) return [];
|
||||
const x = sc.xOf(nowMs).toFixed(1);
|
||||
return [`<line class="now" x1="${x}" y1="0" x2="${x}" y2="${H}"/>`];
|
||||
}
|
||||
|
||||
/** ラベル列 + プロットの枠組み。tasks / turns で共通。 */
|
||||
function ganttFrame({ labW, plotW, H, labelHead, labels, axis, svg }) {
|
||||
return `
|
||||
<div class="gantt" style="--labw:${labW}px">
|
||||
<div class="g-head">
|
||||
<div class="g-lab-h">${labelHead}</div>
|
||||
<svg width="${plotW}" height="26" viewBox="0 0 ${plotW} 26">${axis}</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}</svg>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const ROW_H = 22;
|
||||
|
||||
/** 表示単位の切り替えボタン (タスク / ターン)。 */
|
||||
function unitButtons() {
|
||||
return `<span class="rngs">${[['task', 'タスク'], ['turn', 'ターン']]
|
||||
.map(([v, label]) => `<button class="rng unit${v === state.ganttUnit ? ' sel' : ''}" data-unit="${v}">${label}</button>`)
|
||||
.join('')}</span>`;
|
||||
}
|
||||
|
||||
function wireUnits() {
|
||||
document.querySelectorAll('#detail-body .unit').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
state.ganttUnit = btn.dataset.unit;
|
||||
updateCsv();
|
||||
loadGantt();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderGanttTasks() {
|
||||
const g = state.gantt;
|
||||
if (!g) return;
|
||||
const cross = !state.project;
|
||||
@@ -558,7 +767,6 @@ function renderGantt() {
|
||||
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);
|
||||
|
||||
@@ -570,6 +778,7 @@ function renderGantt() {
|
||||
<div><span class="k">最長タスク</span><span class="v">${longest ? fmtDur(longest.wallMs) : '–'}</span></div>
|
||||
</div>
|
||||
<div class="chart-bar">
|
||||
${unitButtons()}
|
||||
<span class="rngs">${ranges}</span>
|
||||
<span class="note">薄いバー=開始から終了まで、濃い部分=応答が動いていた区間。行をクリックするとやりとりを開きます。</span>
|
||||
</div>`;
|
||||
@@ -577,59 +786,23 @@ function renderGantt() {
|
||||
if (!rows.length) {
|
||||
$('#detail-body').innerHTML = head + '<p class="empty">この期間のタスクはありません。</p>';
|
||||
wireRanges('ganttDays', loadGantt);
|
||||
wireUnits();
|
||||
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 sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW); // 右端に少し余白
|
||||
const xOf = sc.xOf;
|
||||
|
||||
// 軸 (上部に固定表示)
|
||||
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}"/>`);
|
||||
}
|
||||
}
|
||||
const axis = axisSvg(sc);
|
||||
// 背景 (土日と日境界) → バー → 当たり判定
|
||||
const svg = gridSvg(sc, H);
|
||||
rows.forEach((s, i) => {
|
||||
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
|
||||
});
|
||||
@@ -646,10 +819,7 @@ function renderGantt() {
|
||||
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}"/>`);
|
||||
}
|
||||
svg.push(...nowLine(sc, H, g.now * 1000));
|
||||
rows.forEach((s, i) => {
|
||||
const tip = [
|
||||
s.title,
|
||||
@@ -675,16 +845,15 @@ function renderGantt() {
|
||||
.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>
|
||||
${ganttFrame({
|
||||
labW,
|
||||
plotW,
|
||||
H,
|
||||
labelHead: `タスク ${rows.length} 件(開始順)`,
|
||||
labels,
|
||||
axis: axis.join(''),
|
||||
svg: svg.join(''),
|
||||
})}
|
||||
${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>`)
|
||||
@@ -692,15 +861,130 @@ function renderGantt() {
|
||||
: ''}`;
|
||||
|
||||
wireRanges('ganttDays', loadGantt);
|
||||
wireUnits();
|
||||
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
|
||||
el.onclick = () => selectSession(el.dataset.project, el.dataset.id);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- ターン単位のガント
|
||||
|
||||
/** 選択中タスクのターンを、絶対時刻の共通軸に並べる。 */
|
||||
function renderGanttTurns() {
|
||||
const d = state.detail;
|
||||
const head = (extra) => `
|
||||
<div class="sum flat">
|
||||
<div><span class="k">タスク</span><span class="v" title="${d ? esc(d.title) : ''}">${d ? esc(clip(d.title, 46)) : '–'}</span></div>
|
||||
<div><span class="k">開始</span><span class="v">${d ? fmtDateTime(d.start) : '–'}</span></div>
|
||||
<div><span class="k">全体</span><span class="v">${d ? fmtDur(d.wallMs) : '–'}</span></div>
|
||||
<div><span class="k">作業時間合計</span><span class="v">${d ? fmtDur(d.activeMs) : '–'}</span></div>
|
||||
<div><span class="k">往復</span><span class="v">${d ? d.turnCount : '–'}</span></div>
|
||||
${extra}
|
||||
</div>
|
||||
<div class="chart-bar">
|
||||
${unitButtons()}
|
||||
<span class="note">1行=1ターン。青=初回応答待ち、緑=応答中、行間の空白=放置していた時間。行をクリックするとやりとりに移動します。</span>
|
||||
</div>`;
|
||||
|
||||
$('#detail-title').textContent = 'ガント(ターン)' + (d ? ' — ' + d.title : '');
|
||||
|
||||
const turns = d ? d.turns.filter((t) => t.ts !== null && t.ts !== undefined) : [];
|
||||
if (!turns.length) {
|
||||
$('#detail-body').innerHTML =
|
||||
head('') + `<p class="empty">${d ? 'やりとりが記録されていません。' : '中央のタスク一覧から 1 つ選んでください。'}</p>`;
|
||||
wireUnits();
|
||||
return;
|
||||
}
|
||||
|
||||
const boxW = $('#detail-body').clientWidth || 700;
|
||||
const labW = Math.max(130, Math.min(240, Math.round(boxW * 0.34)));
|
||||
const plotW = Math.max(180, boxW - labW - 28);
|
||||
const t0 = Math.min(...turns.map((t) => t.ts)) * 1000;
|
||||
// 一瞬で終わったタスクでも目盛りが出るよう、最小 10 秒ぶんの幅を確保する
|
||||
const tEnd = Math.max(t0 + 10000, ...turns.map((t) => (t.endTs ?? t.ts) * 1000));
|
||||
const sc = timeScale(t0, tEnd + (tEnd - t0) * 0.01, plotW);
|
||||
const xOf = sc.xOf;
|
||||
|
||||
const H = turns.length * ROW_H;
|
||||
const axis = axisSvg(sc);
|
||||
const svg = gridSvg(sc, H);
|
||||
turns.forEach((t, i) => {
|
||||
if (i % 2) svg.push(`<rect class="stripe" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"/>`);
|
||||
});
|
||||
const seg = (cls, a, b, y) => {
|
||||
const x = xOf(a);
|
||||
const w = Math.max(1, xOf(b) - x);
|
||||
return `<rect class="${cls}" x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="10" rx="1"/>`;
|
||||
};
|
||||
turns.forEach((t, i) => {
|
||||
const y = i * ROW_H + 6;
|
||||
const a = t.ts * 1000;
|
||||
const end = (t.endTs ?? t.ts) * 1000;
|
||||
const reply = t.firstReplyTs ? t.firstReplyTs * 1000 : null;
|
||||
if (!reply) {
|
||||
svg.push(seg('t-none', a, end, y)); // 応答なし (中断など)
|
||||
} else {
|
||||
svg.push(seg('t-wait', a, reply, y));
|
||||
if (end > reply) svg.push(seg('t-work', reply, end, y));
|
||||
}
|
||||
});
|
||||
svg.push(...nowLine(sc, H, Date.now()));
|
||||
turns.forEach((t, i) => {
|
||||
const tip = [
|
||||
`#${t.i}${t.kind !== 'prompt' ? ` [${t.kind}]` : ''}${t.interrupted ? ' ⚠中断' : ''} ${t.summary}`,
|
||||
`発言 ${fmtDateTime(t.tsIso)} → 完了 ${t.endIso ? fmtTime(t.endIso) : '–'}`,
|
||||
`初回応答 ${fmtDur(t.waitMs)} · 応答時間 ${fmtDur(t.durMs)} · 空き ${t.idleMs !== null && t.idleMs < 0 ? '並行' : fmtDur(t.idleMs)}`,
|
||||
`${t.assistant} msg · ${t.toolCalls} tool`,
|
||||
].join('\n');
|
||||
svg.push(
|
||||
`<rect class="g-hit" data-i="${t.i}" x="0" y="${i * ROW_H}" width="${plotW}" height="${ROW_H}"><title>${esc(tip)}</title></rect>`
|
||||
);
|
||||
});
|
||||
|
||||
const labels = turns
|
||||
.map(
|
||||
(t) => `<div class="g-lab-row" data-i="${t.i}" title="${esc(t.summary)}">
|
||||
<span class="g-no">#${t.i}</span>
|
||||
<span class="g-when">${fmtTime(t.tsIso)}</span>
|
||||
<span class="g-title">${t.interrupted ? '⚠ ' : ''}${esc(t.summary)}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
|
||||
const overlaps = turns.filter((t) => t.idleMs !== null && t.idleMs < 0).length;
|
||||
$('#detail-body').innerHTML =
|
||||
head(overlaps ? `<div><span class="k">並行送信</span><span class="v">${overlaps}</span></div>` : '') +
|
||||
ganttFrame({
|
||||
labW,
|
||||
plotW,
|
||||
H,
|
||||
labelHead: `ターン ${turns.length} 件`,
|
||||
labels,
|
||||
axis: axis.join(''),
|
||||
svg: svg.join(''),
|
||||
});
|
||||
|
||||
wireUnits();
|
||||
document.querySelectorAll('#detail-body .g-hit, #detail-body .g-lab-row').forEach((el) => {
|
||||
el.onclick = () => jumpToTurn(Number(el.dataset.i));
|
||||
});
|
||||
}
|
||||
|
||||
/** ターンのガントから「やりとり」タブの該当行へ移動する。 */
|
||||
function jumpToTurn(i) {
|
||||
setView('turns');
|
||||
const tr = document.querySelector(`#turns tr[data-i="${i}"]`);
|
||||
if (!tr) return;
|
||||
tr.scrollIntoView({ block: 'center' });
|
||||
tr.classList.add('hi');
|
||||
setTimeout(() => tr.classList.remove('hi'), 1800);
|
||||
}
|
||||
|
||||
function wireRanges(key, reload) {
|
||||
document.querySelectorAll('#detail-body .rng').forEach((btn) => {
|
||||
document.querySelectorAll('#detail-body .rng[data-days]').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
state[key] = Number(btn.dataset.days);
|
||||
if (key === 'days') state.dailyFrom = state.dailyTo = ''; // 日数指定と期間指定は排他
|
||||
updateCsv();
|
||||
reload();
|
||||
};
|
||||
@@ -710,7 +994,6 @@ function wireRanges(key, reload) {
|
||||
// ---------------------------------------------------------------- 自動追従
|
||||
|
||||
const FOLLOW_KEY = 'chktime.follow';
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
|
||||
function followStatus(kind, text) {
|
||||
const el = $('#follow-state');
|
||||
@@ -720,7 +1003,7 @@ function followStatus(kind, text) {
|
||||
|
||||
function nowHms() {
|
||||
const d = new Date();
|
||||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
return `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */
|
||||
@@ -819,7 +1102,7 @@ window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (state.view === 'daily' && state.daily) renderDaily();
|
||||
else if (state.view === 'gantt' && state.gantt) renderGantt();
|
||||
else if (state.view === 'gantt') renderGantt();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
|
||||
@@ -200,6 +200,21 @@ table { width: 100%; border-collapse: collapse; }
|
||||
.rng.sel { color: var(--accent); border-color: var(--accent); }
|
||||
.chart-bar .note { color: var(--dim); font-size: 10.5px; }
|
||||
|
||||
/* 期間の任意指定 */
|
||||
.dates { display: flex; align-items: center; gap: 4px; }
|
||||
.dates input[type="date"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
color: var(--fg);
|
||||
padding: 1px 6px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.dates input[type="date"]:hover { border-color: var(--accent); }
|
||||
.dates .tilde { color: var(--dim); font-size: 11px; }
|
||||
|
||||
.chart { padding: 4px 14px 0; overflow-x: auto; }
|
||||
.chart svg text.ax { fill: var(--dim); font-size: 10px; }
|
||||
.chart svg text.ax.we { fill: var(--warn); }
|
||||
@@ -213,6 +228,12 @@ table { width: 100%; border-collapse: collapse; }
|
||||
.legend .lg { display: flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.legend .lg i, .pj-i i { display: inline-block; width: 9px; height: 9px; border-radius: 2px; flex: none; }
|
||||
.legend .lg b { color: var(--fg); font-weight: 500; }
|
||||
.legend .lg.act { cursor: pointer; }
|
||||
.legend .lg.act:hover { color: var(--fg); }
|
||||
.legend.excl { padding-top: 2px; }
|
||||
.legend.excl .cap { font-size: 10.5px; }
|
||||
.legend .lg.off { cursor: pointer; opacity: .6; text-decoration: line-through; }
|
||||
.legend .lg.off:hover { opacity: 1; color: var(--accent); }
|
||||
|
||||
#daily-table { margin-top: 8px; }
|
||||
#daily-table th {
|
||||
@@ -275,3 +296,10 @@ table { width: 100%; border-collapse: collapse; }
|
||||
.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; }
|
||||
|
||||
/* ガント (ターン単位) */
|
||||
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.gantt svg rect.t-wait { fill: var(--user); }
|
||||
.gantt svg rect.t-work { fill: var(--work); }
|
||||
.gantt svg rect.t-none { fill: var(--idle); }
|
||||
#turns tr.hi td { background: rgba(122, 162, 247, .18); }
|
||||
|
||||
Reference in New Issue
Block a user