Redmine へチケットを起票するボタンを追加 #1

Merged
yoshikazu merged 1 commits from redmine-issue-button into main 2026-09-02 06:03:04 +00:00
7 changed files with 1021 additions and 5 deletions
+3
View File
@@ -5,3 +5,6 @@ __pycache__/
# Claude Code のローカル設定 # Claude Code のローカル設定
.claude/settings.local.json .claude/settings.local.json
# Redmine 連携の設定 (API アクセスキーが入る)
redmine.json
+32 -3
View File
@@ -6,20 +6,25 @@ Claude Code の会話ログ(`~/.claude/projects/<プロジェクト>/<セッ
## 構成 ## 構成
``` ```
chktime.py サーバ + jsonl 解析(これ1本で完結) chktime.py サーバ + jsonl 解析 + Redmine 連携(これ1本で完結)
web/index.html 画面の骨組み web/index.html 画面の骨組み
web/app.js 表示ロジック(フレームワークなし・素の DOM 操作) web/app.js 表示ロジック(フレームワークなし・素の DOM 操作)
web/style.css スタイル(ダークテーマ固定) web/style.css スタイル(ダークテーマ固定)
run.bat コンソール表示で起動 run.bat コンソール表示で起動
stop.cmd 起動中の chktime を終了 stop.cmd 起動中の chktime を終了
redmine.json Redmine 連携の設定(自動生成・git 管理外)
``` ```
`chktime.py` の中身は上から「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ HTTP」の順。 `chktime.py` の中身は上から
「解析ロジック → Store(キャッシュ)/ Watcher(更新検出)→ Redmine 連携 → HTTP」の順。
## 守ること ## 守ること
- **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。 - **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
- **外部通信しない**。読むのはローカルの jsonl だけ。 - **外部通信は Redmine 連携だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。
新しい通信先を増やすときは、この行を書き換えるところから始める。
- **API キーをリポジトリに入れない**。`redmine.json` は `.gitignore` 済み。ログにも出さない
(`/api/redmine/config` はキーの有無だけ返す)。
- 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。 - 会話ログは**読み取り専用**。検証で書き換えるときはスクラッチパッドにコピーしてから行う。
- `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。 - `.bat` / `.cmd` は cmd の既定コードページで読まれるので**中身は ASCII のみ**(日本語を書かない)。
- コメント・コミットメッセージ・UI の文言は日本語。 - コメント・コミットメッセージ・UI の文言は日本語。
@@ -50,6 +55,16 @@ python chktime.py --port 8799 --no-browser
いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。 いずれも `&format=csv` を付けると CSV を返す(Excel 用に BOM 付き)。
Redmine 連携(POST を使う唯一の系統):
| ルート | 内容 |
| --- | --- |
| `GET/POST /api/redmine/config` | 接続設定の取得(キーは返さない)/ 保存 |
| `GET /api/redmine/meta` | プロジェクト・トラッカー・ステータス・自分の情報 |
| `POST /api/redmine/draft` | 下書きを作る(`source=tasks` / `source=git`) |
| `POST /api/redmine/issues` | 起票をジョブとして開始。返り値の `id` で進捗を見る |
| `GET /api/redmine/job?id=` | 起票ジョブの進捗と結果 |
## 解析の前提 ## 解析の前提
- ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。 - ターンの起点は**人間の発言だけ**。ツール実行結果・システム挿入・サブエージェント(`isSidechain`)は起点にしない。
@@ -70,3 +85,17 @@ python chktime.py --port 8799 --no-browser
ターン単位で共有している。 ターン単位で共有している。
- `/api/timeline` は表示範囲より前から続くセッションの全体時間・往復数・ツール数も範囲内に切り詰め、 - `/api/timeline` は表示範囲より前から続くセッションの全体時間・往復数・ツール数も範囲内に切り詰め、
`clipped` を立てて返す(作業時間だけ切り詰めると数字が食い違うため)。 `clipped` を立てて返す(作業時間だけ切り詰めると数字が食い違うため)。
- Redmine 起票のダイアログは `rm` オブジェクトが状態を持ち、`renderRedmine()` が中身を作り直す。
HTML 属性に NUL は入れられない(パーサが置換する)ので、タスクの識別は `data-project` /
`data-id` の 2 属性で持つ。
## Redmine 連携の落とし穴
実際に踏んだもの。同じところで転ばないように。
- **作成時の `status_id` は無視される**(既定の New で入る)。状態を指定したいなら作成後に
`PUT /issues/<id>.json` で付け直す(`Redmine.create()` がやっている)。
- **連続アクセスに 403 を返すサーバがある**。7〜8 秒あければ通る。`Redmine.throttle()` が
クライアント単位で間隔を保つので、クライアントは作り直さず使い回す。
- **自己署名証明書**が普通なので、既定は検証なし(`verifySsl: false`)。
- 起票は件数×間隔ぶん時間がかかるので HTTP を掴んだままにしない。`Jobs` に投げて進捗を取りに来てもらう。
+39 -2
View File
@@ -4,7 +4,10 @@ Claude Code の **タスク(セッション)ごとのチャットのやり
`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(Claude Code の会話ログ)を解析して、 `~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(Claude Code の会話ログ)を解析して、
「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。 「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。
外部通信なし・追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。 追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。
外部への通信は **[Redmine へ起票](#redmine-へ起票)** を使ったときだけです。それ以外は
ローカルの jsonl を読むだけで、どこにも接続しません。
## 起動 ## 起動
@@ -30,6 +33,7 @@ python chktime.py
| `--host` | 127.0.0.1 | 待ち受けアドレス | | `--host` | 127.0.0.1 | 待ち受けアドレス |
| `--dir` | `~/.claude/projects` | ログの置き場所 | | `--dir` | `~/.claude/projects` | ログの置き場所 |
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 | | `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
| `--redmine-config` | `redmine.json` | Redmine 連携の設定ファイル |
| `--no-browser` | – | ブラウザを自動で開かない | | `--no-browser` | – | ブラウザを自動で開かない |
## 画面 ## 画面
@@ -105,6 +109,38 @@ python chktime.py
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。 - オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
- オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。 - オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。
## Redmine へ起票
ヘッダの **Redmine へ起票** で、作業の記録を Redmine のチケットにまとめて登録できます。
**このアプリで唯一、外部へ通信する機能**です。使わなければ一切接続しません。
### 準備
ダイアログ右上の **設定** で、Redmine の URL と API アクセスキーを入れて保存します。
キーは Redmine の「個人設定 → API アクセスキー」で表示できます。
- 設定は `redmine.json`(`--redmine-config` で変更可)に保存されます。**キーが平文で入るので git 管理外**です。
- 社内 Redmine のような自己署名証明書のサーバでは「証明書を検証する」を**外して**ください。
- **間隔(秒)** は 1 件ごとに空ける待ち時間です。連続アクセスに 403 を返すサーバがあるため既定 7 秒。速いサーバなら短くできます。
### 起票する
**下書きを作る → 中身を確かめる → 起票する** の 3 段です。取得元は 2 つあります。
| 取得元 | 内容 |
| --- | --- |
| **タスクから** | 中央の一覧に出ているタスクから選び、1 タスク = 1 チケットにします。本文は期間・全体/作業時間・往復数・ツール呼び出し数と、やりとりの一覧(発言時刻・要約・応答時間、最大 60 件)。開始日/期日はタスクの開始・終了日 |
| **git 履歴から** | 指定したリポジトリのコミットをまとめてチケットにします。本文はコミットの一覧(日付・短縮ハッシュ・件名、最大 80 件) |
git 履歴のまとめ方は **日ごと / 日 × 種別(`feat:` `fix:` `docs:` …)/ 期間まとめて 1 件** から選びます。
分類は**件名の prefix と日付だけを見た機械的なもの**で、中身の判断はしません。期間は空にすると全履歴です。
下書きは件名をクリックすると本文を確認でき、チェックを外せば除外できます。
起票先のプロジェクト・トラッカー・ステータス・担当・進捗 100% はダイアログ上部で指定します(次回も同じ値で開きます)。
- 起票は 1 件ずつ間隔を空けて流すので、**数十件だと数分かかります**。進捗と結果(チケット番号/失敗理由)はダイアログに出ます。実行中もダイアログは閉じられます。
- Redmine は**作成時の状態指定を無視する**ため、状態を指定した場合は作成直後に更新をもう 1 回投げて付け直します(1 件あたり 2 回アクセスします)。
## 解析の仕様(ざっくり) ## 解析の仕様(ざっくり)
- ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。 - ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。
@@ -117,8 +153,9 @@ python chktime.py
## ファイル ## ファイル
``` ```
chktime.py サーバ + jsonl 解析 chktime.py サーバ + jsonl 解析 + Redmine 連携
web/index.html 画面 web/index.html 画面
web/app.js 表示ロジック web/app.js 表示ロジック
web/style.css スタイル web/style.css スタイル
redmine.json Redmine 連携の設定(APIキーを含む。git 管理外・自動生成)
``` ```
+446
View File
@@ -22,8 +22,12 @@ import os
import re import re
import select import select
import socket import socket
import ssl
import subprocess
import threading import threading
import time import time
import urllib.error
import urllib.request
import webbrowser import webbrowser
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -797,6 +801,396 @@ def decode_project_dir(name):
return name.replace("-", "/") return name.replace("-", "/")
# ---------------------------------------------------------------- Redmine 連携
REDMINE_CONFIG = APP_DIR / "redmine.json"
REDMINE_INTERVAL = 7.0 # 連続で叩くと 403 を返すサーバがあるので、呼び出しの間隔をあける
TURN_LIMIT = 60 # チケット本文に載せるやりとりの上限
COMMIT_LIMIT = 80 # チケット本文に載せるコミットの上限
GIT_SEP = "\x1f"
GIT_FMT = GIT_SEP.join(["%H", "%h", "%ad", "%an", "%s"])
COMMIT_TYPE_RE = re.compile(r"^(\w+)(?:\([^)]*\))?!?:\s")
TYPE_LABEL = {
"feat": "機能追加", "fix": "不具合修正", "docs": "書類", "test": "テスト",
"perf": "高速化", "refactor": "整理", "build": "ビルド", "chore": "雑務",
"release": "リリース", "style": "整形", "ci": "CI", "legal": "法務", "wip": "作業中",
}
class RedmineError(Exception):
"""Redmine 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
def fmt_local(epoch, time_only=False):
if epoch is None:
return "–"
stamp = datetime.fromtimestamp(epoch)
return stamp.strftime("%H:%M:%S") if time_only else stamp.strftime("%Y-%m-%d %H:%M")
def local_date(epoch):
return datetime.fromtimestamp(epoch).date().isoformat() if epoch else None
class Redmine:
"""Redmine の REST API を叩く最小のクライアント (標準ライブラリのみ)。
社内 Redmine は自己署名証明書のことが多いので、verify=False なら検証を外す。
また連続アクセスに 403 を返す設定のサーバがあるため、呼び出しの間隔をあける。
"""
def __init__(self, url=None, key=None, verify=False, interval=REDMINE_INTERVAL):
self.url = (url or "").rstrip("/")
self.key = key or ""
self.interval = max(0.0, float(interval or 0))
self.ctx = None
if not verify:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
self._last = 0.0
self._lock = threading.Lock()
def throttle(self):
"""前回の呼び出しから interval 秒あくまで待つ。"""
with self._lock:
gap = self.interval - (time.monotonic() - self._last)
if gap > 0:
time.sleep(gap)
self._last = time.monotonic()
def call(self, path, method="GET", payload=None):
if not self.url:
raise RedmineError("Redmine の URL が未設定です")
if not self.key:
raise RedmineError("API アクセスキーが未設定です")
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req = urllib.request.Request(self.url + path, data=body, method=method)
req.add_header("X-Redmine-API-Key", self.key)
req.add_header("Accept", "application/json")
if body is not None:
req.add_header("Content-Type", "application/json")
self.throttle()
try:
with urllib.request.urlopen(req, timeout=30, context=self.ctx) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
raise RedmineError(f"HTTP {exc.code}: {detail or exc.reason}") from None
except urllib.error.URLError as exc:
raise RedmineError(f"接続できません: {exc.reason}") from None
except OSError as exc:
raise RedmineError(f"接続できません: {exc}") from None
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise RedmineError("JSON として読めない応答が返りました") from None
def projects(self):
out = []
offset = 0
while True:
data = self.call(f"/projects.json?limit=100&offset={offset}")
items = data.get("projects") or []
out += [{"id": p.get("id"), "identifier": p.get("identifier"), "name": p.get("name")} for p in items]
offset += len(items)
if not items or offset >= (data.get("total_count") or 0):
return out
def enums(self):
"""トラッカー / ステータス / 自分の情報。起票フォームの選択肢に使う。"""
trackers = self.call("/trackers.json").get("trackers") or []
statuses = self.call("/issue_statuses.json").get("issue_statuses") or []
me = (self.call("/users/current.json").get("user")) or {}
return {
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers],
"statuses": [{"id": s.get("id"), "name": s.get("name"), "closed": bool(s.get("is_closed"))} for s in statuses],
"user": {"id": me.get("id"), "login": me.get("login"),
"name": " ".join(filter(None, [me.get("lastname"), me.get("firstname")])).strip()},
}
def create(self, fields):
"""チケットを作る。
Redmine は作成時の status_id を既定値で上書きするので、指定があれば
作成後に PUT で付け直す。
"""
fields = dict(fields)
status = fields.pop("status_id", None)
data = self.call("/issues.json", "POST", {"issue": fields})
issue_id = (data.get("issue") or {}).get("id")
if issue_id and status:
self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}})
return issue_id
def load_redmine_config(path):
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
class RedmineHolder:
"""設定ファイルと、そこから作ったクライアントを保持する。
呼び出し間隔の記憶をなくさないよう、設定が変わったときだけ作り直す。
API キーは UI へ返さない (有無だけ返す)。
"""
def __init__(self, path):
self.path = Path(path)
self.lock = threading.Lock()
self.cfg = load_redmine_config(self.path)
self.client = self._build()
def _build(self):
return Redmine(self.cfg.get("url"), self.cfg.get("key"),
self.cfg.get("verifySsl", False), self.cfg.get("interval", REDMINE_INTERVAL))
def public(self):
with self.lock:
cfg = self.cfg
return {
"url": cfg.get("url", ""),
"hasKey": bool(cfg.get("key")),
"verifySsl": bool(cfg.get("verifySsl", False)),
"interval": cfg.get("interval", REDMINE_INTERVAL),
"projectId": cfg.get("projectId"),
"trackerId": cfg.get("trackerId"),
"statusId": cfg.get("statusId"),
"assignedToId": cfg.get("assignedToId"),
"configPath": str(self.path),
}
def update(self, patch):
"""URL・キー・既定値を保存する。key が空文字なら既存のキーを残す。"""
with self.lock:
cfg = dict(self.cfg)
for key in ("url", "verifySsl", "interval", "projectId", "trackerId", "statusId", "assignedToId"):
if key in patch:
cfg[key] = patch[key]
if patch.get("clearKey"):
cfg.pop("key", None)
elif patch.get("key"):
cfg["key"] = str(patch["key"]).strip()
self.cfg = cfg
self.client = self._build()
try:
self.path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as exc:
raise RedmineError(f"設定を保存できません: {exc}") from None
return self.public()
class Jobs:
"""起票をバックグラウンドで流し、UI からは進捗だけ見えるようにする。
1件ごとに間隔をあけるので数十件で数分かかる。HTTP を掴んだままにしないため、
開始だけ返して /api/redmine/job で進捗を取りに来てもらう。
"""
def __init__(self):
self._jobs = {}
self._lock = threading.Lock()
self._seq = 0
def start(self, client, drafts):
with self._lock:
self._seq += 1
job = {"id": f"j{self._seq}", "total": len(drafts), "done": 0, "state": "running", "results": []}
self._jobs[job["id"]] = job
threading.Thread(target=self._run, args=(job, client, drafts), daemon=True).start()
return job
def _run(self, job, client, drafts):
for draft in drafts:
subject = draft.get("subject") or "(件名なし)"
try:
issue_id = client.create(draft)
job["results"].append({"subject": subject, "issueId": issue_id, "ok": True})
except RedmineError as exc:
job["results"].append({"subject": subject, "error": str(exc), "ok": False})
job["done"] += 1
job["state"] = "done"
def get(self, job_id):
with self._lock:
job = self._jobs.get(job_id)
if job is None:
raise FileNotFoundError(job_id)
return job
# -- 下書きの組み立て -------------------------------------------------
def issue_from_session(session, project_path=None):
"""chktime のタスク 1 件をチケットの下書きにする。"""
start, end = session.get("startEpoch"), session.get("endEpoch")
head = [
f"* 期間: {fmt_local(start)} → {fmt_local(end)}",
f"* 全体: {fmt_dur(session['wallMs'])} / 作業時間: {fmt_dur(session['activeMs'])}",
f"* 往復: {session['turnCount']} · 応答メッセージ: {session['assistantMsgs']} · ツール呼び出し: {session['toolCalls']}",
]
if project_path:
head.append(f"* 作業ディレクトリ: {project_path}")
if session.get("gitBranch"):
head.append(f"* ブランチ: {session['gitBranch']}")
turns = [t for t in session.get("turns", []) if t.get("kind") in ("prompt", "command")]
rows = ["h3. やりとり", ""]
for turn in turns[:TURN_LIMIT]:
rows.append(f"* {fmt_local(turn['ts'], True)} #{turn['i']} "
f"{one_line(turn['prompt'], 120)} — {fmt_dur(turn['durMs'])}")
if len(turns) > TURN_LIMIT:
rows.append(f"* … 他 {len(turns) - TURN_LIMIT} 件")
if not turns:
rows.append("* (記録なし)")
return {
"subject": one_line(session.get("title") or session["id"], 240),
"description": "\n".join(head + [""] + rows + ["", "---", f"chktime から起票(タスク {session['id']})"]),
"start_date": local_date(start),
"due_date": local_date(end),
"source": {"kind": "task", "id": session["id"]},
}
def git_log(repo, since=None, until=None, limit=2000):
"""git 履歴を読む。リポジトリでなければ RedmineError。"""
path = Path(repo).expanduser()
if not path.is_dir():
raise RedmineError(f"フォルダが見つかりません: {repo}")
cmd = ["git", "-C", str(path), "log", "--no-merges", "--date=short", f"--pretty=format:{GIT_FMT}", f"-n{limit}"]
if since:
cmd.append(f"--since={since} 00:00:00")
if until:
cmd.append(f"--until={until} 23:59:59")
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60)
except FileNotFoundError:
raise RedmineError("git が見つかりません (PATH を確認してください)") from None
except (OSError, subprocess.SubprocessError) as exc:
raise RedmineError(f"git を実行できません: {exc}") from None
if proc.returncode:
detail = one_line(proc.stderr.decode("utf-8", "replace"), 200)
raise RedmineError(detail or f"git log に失敗しました: {path}")
out = []
for line in proc.stdout.decode("utf-8", "replace").splitlines():
parts = line.split(GIT_SEP)
if len(parts) == 5:
out.append({"hash": parts[0], "short": parts[1], "date": parts[2], "author": parts[3], "subject": parts[4]})
out.reverse() # 古い順
return out
def commit_type(subject):
match = COMMIT_TYPE_RE.match(subject or "")
return match.group(1).lower() if match else ""
def group_commits(commits, mode):
"""コミットを起票の単位にまとめる。mode: day=日ごと / type=日×種別 / all=一括。
分類は機械的に行う (件名の prefix と日付だけを見る)。
"""
groups = {}
order = []
for commit in commits:
if mode == "all":
key = ("all",)
elif mode == "type":
key = (commit["date"], commit_type(commit["subject"]))
else:
key = (commit["date"],)
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(commit)
out = []
for key in order:
items = groups[key]
dates = [c["date"] for c in items]
start, due = min(dates), max(dates)
if mode == "all":
span = start if start == due else f"{start} 〜 {due}"
subject = f"{span} の作業({len(items)} コミット)"
elif mode == "type":
kind = key[1]
label = TYPE_LABEL.get(kind, kind or "その他")
subject = f"{key[0]} {label}({len(items)} コミット)"
else:
subject = f"{key[0]} の作業({len(items)} コミット)"
out.append({"subject": subject, "start": start, "due": due, "commits": items})
return out
def issue_from_commits(group, repo):
"""コミットのまとまり 1 つをチケットの下書きにする。"""
items = group["commits"]
rows = ["h3. 対応したコミット", ""]
for commit in items[:COMMIT_LIMIT]:
rows.append(f"* {commit['date']} {commit['short']} {commit['subject']}")
if len(items) > COMMIT_LIMIT:
rows.append(f"* … 他 {len(items) - COMMIT_LIMIT} 件")
return {
"subject": one_line(group["subject"], 240),
"description": "\n".join(rows + ["", "---", f"git 履歴から起票(リポジトリ: {repo})"]),
"start_date": group["start"],
"due_date": group["due"],
"source": {"kind": "git", "id": items[0]["short"], "count": len(items)},
}
def build_drafts(store, payload):
"""UI から来た指定で下書きの一覧を作る。source: tasks / git。"""
source = payload.get("source")
if source == "tasks":
project = payload.get("project") or ""
ids = payload.get("ids") or []
if not ids:
raise RedmineError("タスクが選ばれていません")
paths = {p["id"]: p["path"] for p in store.projects()}
drafts = []
for item in ids:
pid, sid = (item.get("project") or project, item.get("id")) if isinstance(item, dict) else (project, item)
session = store.one(pid, sid)
drafts.append(issue_from_session(session, session.get("cwd") or paths.get(pid)))
return drafts
if source == "git":
repo = payload.get("repo") or ""
commits = git_log(repo, payload.get("from"), payload.get("to"))
if not commits:
raise RedmineError("その期間のコミットがありません")
return [issue_from_commits(g, repo) for g in group_commits(commits, payload.get("mode") or "day")]
raise RedmineError(f"不明な取得元です: {source}")
def to_fields(draft, defaults):
"""下書き + 既定値を Redmine の issue フィールドにする。"""
fields = {
"project_id": defaults.get("projectId"),
"subject": draft.get("subject") or "(件名なし)",
"description": draft.get("description") or "",
}
if not fields["project_id"]:
raise RedmineError("起票先のプロジェクトが選ばれていません")
for key, name in (("trackerId", "tracker_id"), ("statusId", "status_id"), ("assignedToId", "assigned_to_id")):
if defaults.get(key):
fields[name] = defaults[key]
for key in ("start_date", "due_date"):
if draft.get(key):
fields[key] = draft[key]
if defaults.get("doneRatio") is not None:
fields["done_ratio"] = defaults["doneRatio"]
return fields
# ---------------------------------------------------------------- HTTP # ---------------------------------------------------------------- HTTP
@@ -912,9 +1306,49 @@ class Handler(BaseHTTPRequestHandler):
return self.static(route) return self.static(route)
except FileNotFoundError as exc: except FileNotFoundError as exc:
self.send_json({"error": f"not found: {exc}"}, 404) self.send_json({"error": f"not found: {exc}"}, 404)
except RedmineError as exc:
self.send_json({"error": str(exc)}, 400)
except Exception as exc: # noqa: BLE001 - UI にエラーを返す except Exception as exc: # noqa: BLE001 - UI にエラーを返す
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500) self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def do_POST(self):
route = urlparse(self.path).path
try:
if route.startswith("/api/"):
return self.api_post(route, self.read_json())
raise FileNotFoundError(route)
except FileNotFoundError as exc:
self.send_json({"error": f"not found: {exc}"}, 404)
except (RedmineError, ValueError) as exc:
self.send_json({"error": str(exc)}, 400)
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def read_json(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
data = json.loads(self.rfile.read(length))
except ValueError:
raise ValueError("JSON として読めません") from None
return data if isinstance(data, dict) else {}
def api_post(self, route, payload):
redmine = self.server.redmine
if route == "/api/redmine/config":
return self.send_json(redmine.update(payload))
if route == "/api/redmine/draft":
return self.send_json({"drafts": build_drafts(self.server.store, payload)})
if route == "/api/redmine/issues":
drafts = payload.get("drafts") or []
if not drafts:
raise RedmineError("起票するものがありません")
defaults = payload.get("defaults") or {}
fields = [to_fields(d, defaults) for d in drafts]
return self.send_json(self.server.jobs.start(redmine.client, fields))
raise FileNotFoundError(route)
def api(self, route, query): def api(self, route, query):
store = self.server.store store = self.server.store
if route == "/api/projects": if route == "/api/projects":
@@ -955,6 +1389,15 @@ class Handler(BaseHTTPRequestHandler):
if query.get("format", [""])[0] == "csv": if query.get("format", [""])[0] == "csv":
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv") return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
return self.send_json(session) return self.send_json(session)
if route == "/api/redmine/config":
return self.send_json(self.server.redmine.public())
if route == "/api/redmine/meta":
client = self.server.redmine.client
data = client.enums()
data["projects"] = client.projects()
return self.send_json(data)
if route == "/api/redmine/job":
return self.send_json(self.server.jobs.get(query.get("id", [""])[0]))
if route == "/api/reload": if route == "/api/reload":
store.clear() store.clear()
return self.send_json({"ok": True}) return self.send_json({"ok": True})
@@ -1046,6 +1489,7 @@ def main():
parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ") parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)") parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
parser.add_argument("--redmine-config", default=str(REDMINE_CONFIG), help="Redmine 連携の設定ファイル")
parser.add_argument("--no-browser", action="store_true") parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -1065,6 +1509,8 @@ def main():
webbrowser.open(url) webbrowser.open(url)
return return
server.store = Store(root) server.store = Store(root)
server.redmine = RedmineHolder(args.redmine_config)
server.jobs = Jobs()
server.watcher = Watcher(root, args.interval) server.watcher = Watcher(root, args.interval)
server.watcher.start() server.watcher.start()
print(f"chktime: {root}") print(f"chktime: {root}")
+391
View File
@@ -1080,6 +1080,388 @@ function stopFollow() {
followStatus('off', ''); followStatus('off', '');
} }
// ---------------------------------------------------------------- Redmine 起票
// 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。
// サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。
const rm = {
cfg: null, // /api/redmine/config (キーは含まれない)
meta: null, // プロジェクト / トラッカー / ステータス
settings: false, // 接続設定を開いているか
source: 'tasks', // tasks=chktime のタスク / git=git 履歴
mode: 'day', // git のまとめ方: day / type / all
repo: '',
from: '',
to: '',
tasks: new Map(), // 起票対象のタスク: キー -> {project, id}
drafts: [],
off: new Set(), // 下書きのうち外したもの (index)
job: null,
timer: null,
busy: false,
};
async function post(path, payload) {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.error) throw new Error(data.error);
return data;
}
/** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */
const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id });
const taskKey = (t) => `${t.project}/${t.id}`;
function rmMsg(text, kind) {
const el = $('#rm-msg');
el.className = kind === 'err' ? 'err-inline' : 'note';
el.textContent = text || '';
}
function rmConn() {
const el = $('#rm-conn');
if (!rm.cfg || !rm.cfg.url) {
el.className = 'fstate wait';
el.textContent = '未設定';
} else if (!rm.cfg.hasKey) {
el.className = 'fstate wait';
el.textContent = 'キー未設定';
} else {
el.className = 'fstate live';
el.textContent = rm.cfg.url;
}
}
async function openRedmine() {
$('#rm-back').classList.remove('hidden');
rmMsg('');
try {
rm.cfg = await api('/api/redmine/config');
rm.settings = !rm.cfg.url || !rm.cfg.hasKey;
if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : '');
if (!rm.tasks.size && state.session) {
const t = { project: state.sessionProject, id: state.session };
rm.tasks.set(taskKey(t), t);
}
renderRedmine();
if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta();
} catch (e) {
$('#rm-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
function closeRedmine() {
$('#rm-back').classList.add('hidden');
clearInterval(rm.timer);
rm.timer = null;
}
async function loadRedmineMeta() {
try {
rmMsg('Redmine に接続しています…');
rm.meta = await api('/api/redmine/meta');
rmMsg('');
} catch (e) {
rm.meta = null;
rmMsg(e.message, 'err');
}
renderRedmine();
}
function optionList(items, selected, label) {
return items
.map((it) => `<option value="${it.id}"${String(it.id) === String(selected ?? '') ? ' selected' : ''}>${esc(label(it))}</option>`)
.join('');
}
function renderRedmine() {
rmConn();
const cfg = rm.cfg || {};
const meta = rm.meta;
const settings = `
<div class="rm-sec">
<h4>接続 <button class="rng" id="rm-toggle">${rm.settings ? '隠す' : '設定'}</button></h4>
${rm.settings
? `<div class="rm-row"><label>URL</label>
<input type="text" id="rm-url" value="${esc(cfg.url || '')}" placeholder="https://192.168.3.150"></div>
<div class="rm-row"><label>APIキー</label>
<input type="password" id="rm-key" placeholder="${cfg.hasKey ? '保存済み(変更するときだけ入力)' : '個人設定 → APIアクセスキー'}"></div>
<div class="rm-row">
<label class="chk"><input type="checkbox" id="rm-verify"${cfg.verifySsl ? ' checked' : ''}> 証明書を検証する</label>
<label>間隔(秒)</label><input type="text" id="rm-int" value="${esc(cfg.interval ?? 7)}" style="flex:0 0 60px">
<button id="rm-save">保存</button>
</div>
<div class="note">設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。
自己署名証明書のサーバでは「証明書を検証する」を外してください。</div>`
: ''}
</div>`;
const target = meta
? `<div class="rm-sec">
<h4>起票先</h4>
<div class="rm-row">
<label>プロジェクト</label>
<select id="rm-proj"><option value="">選んでください</option>${optionList(meta.projects, cfg.projectId, (p) => p.name)}</select>
<label>トラッカー</label>
<select id="rm-tracker">${optionList(meta.trackers, cfg.trackerId, (t) => t.name)}</select>
<label>ステータス</label>
<select id="rm-status">${optionList(meta.statuses, cfg.statusId, (s) => s.name)}</select>
</div>
<div class="rm-row">
<label class="chk"><input type="checkbox" id="rm-assign"${cfg.assignedToId ? ' checked' : ''}> 自分(${esc(meta.user.name || meta.user.login || '')})を担当にする</label>
<label class="chk"><input type="checkbox" id="rm-done"> 進捗を 100% にする</label>
</div>
</div>`
: cfg.hasKey
? '<div class="rm-sec"><p class="note">Redmine の情報を取得できていません。接続設定を確認してください。</p></div>'
: '';
const tab = (v, label) => `<button class="rng${rm.source === v ? ' sel' : ''}" data-src="${v}">${label}</button>`;
const picker =
rm.source === 'tasks'
? `<div class="rm-row">
<span class="note">一覧に出ている ${state.sessions.length} 件から選びます</span>
<button class="rng" id="rm-all">全選択</button><button class="rng" id="rm-none">全解除</button>
</div>
<div class="rm-list">${
state.sessions.length
? state.sessions
.map((s) => {
const t = taskOf(s);
return `<div class="rm-item"><label class="h">
<input type="checkbox" class="rm-task" data-project="${esc(t.project)}" data-id="${esc(t.id)}"${rm.tasks.has(taskKey(t)) ? ' checked' : ''}>
<span class="t"><span class="subject">${esc(s.title)}</span>
<span class="meta">${fmtDateTime(s.start)} · ${s.turnCount} 往復 · 作業 ${fmtDur(s.activeMs)}</span></span>
</label></div>`;
})
.join('')
: '<div class="rm-item"><div class="h"><span class="note">タスクがありません</span></div></div>'
}</div>`
: `<div class="rm-row">
<label>リポジトリ</label><input type="text" id="rm-repo" value="${esc(rm.repo)}" placeholder="C:\\debug\\chktime">
</div>
<div class="rm-row">
<label>期間</label><input type="date" id="rm-from" value="${rm.from}"><span class="note">〜</span><input type="date" id="rm-to" value="${rm.to}">
<label>まとめ方</label>
<select id="rm-mode">
<option value="day"${rm.mode === 'day' ? ' selected' : ''}>日ごと</option>
<option value="type"${rm.mode === 'type' ? ' selected' : ''}>日 × 種別 (feat/fix/docs…)</option>
<option value="all"${rm.mode === 'all' ? ' selected' : ''}>期間まとめて 1 件</option>
</select>
</div>
<div class="note">分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。</div>`;
const source = `
<div class="rm-sec">
<h4>取得元 <span class="rngs" style="display:inline-flex;margin-left:6px">${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}</span></h4>
${picker}
<div class="rm-row" style="margin-top:8px"><button id="rm-draft">下書きを作る</button></div>
</div>`;
const drafts = rm.drafts.length
? `<div class="rm-sec">
<h4>下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)</h4>
<div class="rm-list">${rm.drafts
.map(
(d, i) => `<div class="rm-item${rm.off.has(i) ? ' off' : ''}" data-i="${i}">
<div class="h">
<input type="checkbox" class="rm-pick" data-i="${i}"${rm.off.has(i) ? '' : ' checked'}>
<span class="t">
<span class="subject">${esc(d.subject)}</span>
<span class="meta">${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''}
${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''}</span>
</span>
</div>
<div class="body">${esc(d.description)}</div>
</div>`
)
.join('')}</div>
</div>`
: '';
$('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : '');
wireRedmine();
syncRun();
}
function renderJob() {
const j = rm.job;
const pct = j.total ? Math.round((j.done / j.total) * 100) : 0;
const ok = j.results.filter((r) => r.ok).length;
const ng = j.results.length - ok;
return `<div class="rm-sec">
<h4>起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}</h4>
<div class="rm-prog"><i style="width:${pct}%"></i></div>
<div class="note">成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}</div>
<div class="rm-list" style="margin-top:6px">${j.results
.map(
(r) => `<div class="rm-res">
<span class="id${r.ok ? '' : ' ng'}">${r.ok ? '#' + r.issueId : '失敗'}</span>
<span class="s">${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}</span>
</div>`
)
.join('')}</div>
</div>`;
}
/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */
function wireRedmine() {
const on = (sel, ev, fn) => {
const el = $(sel);
if (el) el[ev] = fn;
};
on('#rm-toggle', 'onclick', () => {
rm.settings = !rm.settings;
renderRedmine();
});
on('#rm-save', 'onclick', async () => {
try {
rmMsg('保存しています…');
rm.cfg = await post('/api/redmine/config', {
url: $('#rm-url').value.trim(),
key: $('#rm-key').value.trim(),
verifySsl: $('#rm-verify').checked,
interval: Number($('#rm-int').value) || 7,
});
rm.settings = false;
rm.meta = null;
rmMsg('保存しました');
await loadRedmineMeta();
} catch (e) {
rmMsg(e.message, 'err');
}
});
document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => {
btn.onclick = () => {
rm.source = btn.dataset.src;
renderRedmine();
};
});
document.querySelectorAll('#rm-body .rm-task').forEach((box) => {
box.onchange = () => {
const t = { project: box.dataset.project, id: box.dataset.id };
if (box.checked) rm.tasks.set(taskKey(t), t);
else rm.tasks.delete(taskKey(t));
};
});
const pickAll = (want) => {
state.sessions.forEach((s) => {
const t = taskOf(s);
if (want) rm.tasks.set(taskKey(t), t);
else rm.tasks.delete(taskKey(t));
});
renderRedmine();
};
on('#rm-all', 'onclick', () => pickAll(true));
on('#rm-none', 'onclick', () => pickAll(false));
on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim()));
on('#rm-from', 'onchange', (e) => (rm.from = e.target.value));
on('#rm-to', 'onchange', (e) => (rm.to = e.target.value));
on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value));
on('#rm-draft', 'onclick', makeDrafts);
document.querySelectorAll('#rm-body .rm-pick').forEach((box) => {
box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように
box.onchange = () => {
const i = Number(box.dataset.i);
if (box.checked) rm.off.delete(i);
else rm.off.add(i);
renderRedmine();
};
});
document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => {
head.onclick = (e) => {
if (e.target.classList.contains('rm-pick')) return;
head.parentElement.classList.toggle('open');
};
});
['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun));
}
/** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */
function syncRun() {
const proj = $('#rm-proj');
$('#rm-run').disabled =
rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value);
}
async function makeDrafts() {
const payload =
rm.source === 'tasks'
? {
source: 'tasks',
ids: [...rm.tasks.values()],
}
: { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode };
try {
rmMsg('下書きを作っています…');
const data = await post('/api/redmine/draft', payload);
rm.drafts = data.drafts;
rm.off = new Set();
rm.job = null;
rmMsg(`${rm.drafts.length} 件の下書きを作りました`);
} catch (e) {
rm.drafts = [];
rmMsg(e.message, 'err');
}
renderRedmine();
}
async function runRedmine() {
const drafts = rm.drafts.filter((_, i) => !rm.off.has(i));
if (!drafts.length) return;
const defaults = {
projectId: Number($('#rm-proj').value) || null,
trackerId: Number($('#rm-tracker').value) || null,
statusId: Number($('#rm-status').value) || null,
assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null,
doneRatio: $('#rm-done').checked ? 100 : null,
};
const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1));
if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return;
try {
rm.busy = true;
rmMsg('起票しています…');
// 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す
post('/api/redmine/config', {
projectId: defaults.projectId, trackerId: defaults.trackerId,
statusId: defaults.statusId, assignedToId: defaults.assignedToId,
}).catch(() => {});
rm.job = await post('/api/redmine/issues', { drafts, defaults });
renderRedmine();
clearInterval(rm.timer);
rm.timer = setInterval(pollJob, 1000);
} catch (e) {
rm.busy = false;
rmMsg(e.message, 'err');
renderRedmine();
}
}
async function pollJob() {
if (!rm.job) return;
try {
rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`);
} catch {
return;
}
if (rm.job.state === 'done') {
clearInterval(rm.timer);
rm.timer = null;
rm.busy = false;
const ng = rm.job.results.filter((r) => !r.ok).length;
rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : '');
}
renderRedmine();
}
// ---------------------------------------------------------------- 起動 // ---------------------------------------------------------------- 起動
$('#filter').oninput = renderSessions; $('#filter').oninput = renderSessions;
@@ -1090,6 +1472,15 @@ $('#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'); $('#tab-gantt').onclick = () => setView('gantt');
$('#redmine').onclick = openRedmine;
$('#rm-close').onclick = closeRedmine;
$('#rm-run').onclick = runRedmine;
$('#rm-back').onclick = (e) => {
if (e.target.id === 'rm-back') closeRedmine();
};
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine();
});
$('#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 };
+18
View File
@@ -14,9 +14,27 @@
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label> <label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label> <label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
<span id="follow-state" class="fstate"></span> <span id="follow-state" class="fstate"></span>
<button id="redmine" title="Redmine にチケットを起票する">Redmine へ起票</button>
<button id="reload" title="キャッシュを破棄して再読込">再読込</button> <button id="reload" title="キャッシュを破棄して再読込">再読込</button>
</header> </header>
<div id="rm-back" class="modal-back hidden">
<div class="modal">
<div class="modal-head">
<span>Redmine へ起票</span>
<span class="spacer"></span>
<span id="rm-conn" class="fstate"></span>
<button id="rm-close" title="閉じる">✕</button>
</div>
<div class="modal-body" id="rm-body"></div>
<div class="modal-foot">
<span id="rm-msg" class="note"></span>
<span class="spacer"></span>
<button id="rm-run" class="go">起票する</button>
</div>
</div>
</div>
<main> <main>
<nav id="projects" class="pane"> <nav id="projects" class="pane">
<div class="pane-head">プロジェクト</div> <div class="pane-head">プロジェクト</div>
+92
View File
@@ -297,6 +297,98 @@ table { width: 100%; border-collapse: collapse; }
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; } .gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; } .gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }
/* Redmine 起票ダイアログ */
.hidden { display: none !important; }
.modal-back {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .55);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.modal {
display: flex;
flex-direction: column;
width: min(880px, 94vw);
height: min(760px, 92vh);
background: var(--pane);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.modal-head, .modal-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--pane2);
border-bottom: 1px solid var(--line);
}
.modal-foot { border-bottom: none; border-top: 1px solid var(--line); }
.modal-head .spacer, .modal-foot .spacer { flex: 1; }
.modal-body { flex: 1; overflow: auto; padding: 12px 14px 20px; }
.modal .note { color: var(--dim); font-size: 11px; }
.modal .err-inline { color: #f7768e; font-size: 11px; }
.modal .go { border-color: var(--work); color: var(--work); }
.modal .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; }
.rm-sec { margin-bottom: 14px; }
.rm-sec > h4 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 500;
letter-spacing: .08em;
color: var(--dim);
text-transform: uppercase;
}
.rm-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.rm-row label { color: var(--dim); font-size: 11px; }
.modal input[type="text"], .modal input[type="password"], .modal input[type="date"], .modal select {
background: var(--bg);
border: 1px solid var(--line);
border-radius: 5px;
color: var(--fg);
padding: 3px 8px;
font: inherit;
font-size: 12px;
color-scheme: dark;
}
.modal input[type="text"], .modal input[type="password"] { flex: 1 1 220px; min-width: 140px; }
.modal input:focus, .modal select:focus { outline: none; border-color: var(--accent); }
.rm-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.rm-item { border-bottom: 1px solid var(--line); }
.rm-item:last-child { border-bottom: none; }
.rm-item > .h { display: flex; align-items: flex-start; gap: 8px; padding: 6px 10px; cursor: pointer; }
.rm-item > .h:hover { background: var(--pane2); }
.rm-item .t { flex: 1; min-width: 0; }
.rm-item .subject { font-size: 12.5px; word-break: break-word; }
.rm-item .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
.rm-item .body {
display: none;
white-space: pre-wrap;
word-break: break-word;
font-size: 11px;
color: var(--dim);
background: var(--bg);
border-top: 1px solid var(--line);
padding: 8px 10px;
max-height: 260px;
overflow: auto;
}
.rm-item.open .body { display: block; }
.rm-item.off .subject, .rm-item.off .meta { opacity: .45; text-decoration: line-through; }
.rm-res { display: flex; gap: 8px; padding: 4px 10px; border-bottom: 1px solid var(--line); font-size: 12px; }
.rm-res:last-child { border-bottom: none; }
.rm-res .id { color: var(--work); font-variant-numeric: tabular-nums; white-space: nowrap; }
.rm-res .id.ng { color: #f7768e; }
.rm-res .s { flex: 1; min-width: 0; word-break: break-word; }
.rm-prog { height: 6px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; margin: 8px 0; }
.rm-prog i { display: block; height: 100%; background: var(--work); transition: width .2s; }
/* ガント (ターン単位) */ /* ガント (ターン単位) */
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; } .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-wait { fill: var(--user); }