Files
chktime/docs/superpowers/plans/2026-10-12-n8n-worklog-import.md
T
yoshikazuandClaude Opus 5.5 e16189153c n8n の work_log へ作業区間を送る機能の実装計画を追加
設計書のチケット検索 API を /api/redmine/search に改名
(/api/redmine/issues は起票の POST が使っているため)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:40:00 +09:00

1712 lines
92 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# chktime → n8n work_log 取り込み Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** chktime の画面で Claude のセッションから作った作業区間を選び、n8n の `work_log` に二重登録なしで送れるようにする。
**Architecture:** n8n に受け口のワークフロー `work-tracker--import` (Header Auth 付き Webhook 2 本) を作り、`work_log` / `work_log_deleted` に `source` 列を足す。chktime は区間の計算・重なりの判定・n8n への送信を `chktime.py` の新しい節「n8n 連携」に持ち、画面は新しいファイル `web/n8n.js` に置いて右ペインのタブ「n8n へ送る」から開く。時刻はブラウザの値を信用せず、送信時にサーバーが `source` から区間を計算し直す。
**Tech Stack:** Python 3 標準ライブラリのみ (unittest, urllib, http.server)、素の JavaScript、n8n (Webhook / Code / Data table / IF / Respond to Webhook ノード)、n8n-api MCP (`mcp__n8n-api__*`)。
**Spec:** `docs/superpowers/specs/2026-10-12-n8n-worklog-import-design.md`
## Global Constraints
- chktime は **標準ライブラリのみ**。外部パッケージも CDN も使わない。ブラウザ側もフレームワークなし。
- 外部通信は **Redmine 連携と n8n への送信だけ**。
- 合言葉 (`token`) はリポジトリ・ログ・画面・コマンド出力に出さない。`n8n.json` は `.gitignore` に入れる。`/api/n8n/config` は `hasToken` だけ返す。
- 合言葉は利用者が決めて入力する。実装者 (Claude) は作らず、入力もしない。
- 会話ログ (`~/.claude/projects/**/*.jsonl`) は読み取り専用。テストは一時フォルダに作った jsonl で行う。
- 検証用サーバーは `python chktime.py --port 8799 --no-browser`。常用の 8787 と `stop.cmd` には触れない (`stop.cmd` は全 chktime を落とす)。
- `.bat` / `.cmd` は ASCII のみ。コメント・コミットメッセージ・UI 文言は日本語。
- 区間: 空き **30 分未満でつなぐ / 30 分以上で切る**、**5 分未満は外す (5 分ちょうどは残す)**。設定 `gapMinutes` (1〜240) / `minMinutes` (0〜240)。
- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。
- n8n 受け口: 期間 **最大 62 日**、1 回 **最大 200 件**、区間 **24 時間以内**、`task` **500 文字以内 (空不可)**、`memo` **2000 文字以内**、`source` **500 文字以内で `chktime:` 始まり**。
- 重なり: `[開始, 終了)` が少しでも重なれば重なり。接しているだけは重ならない。同じ `source` は相手から除く。進行中の work_log 行は終了 = 今。
- n8n の本番データに触れるテストは **2000-01-01 付けのダミー**だけで行い、終わったら Data table を直接 (MCP の `deleteRows`) 消す。work-tracker--delete の Webhook は使わない。
- 既存のコミットされていない `CLAUDE.md` の変更 (ナレッジ保管庫の節) は利用者のもの。自分の変更だけを `git add -p` でステージする。
- コミットメッセージの末尾に `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`。
## Review Focus
- 前のターンの応答中に次の発言を送った (並行) セッション: 区間は切れずにつながるべき → Task 3 `test_parallel_prompt_keeps_block`
- 最後のターンが中断されて応答が無いまま放置されたセッション: 進行中ではなく送れる区間になるべき → Task 3 `test_interrupted_last_turn_is_not_in_progress`
- タスク名が空白だけで送られた: n8n に送らず「タスク名が空です」を返すべき → Task 6 `test_send_rejects_blank_task`
- n8n に届かない (停止・合言葉違い): 区間の一覧は返り、送信済みの印は付かず `n8nError` に理由が入るべき → Task 6 `test_blocks_when_n8n_down`
- 日をまたぐ区間: 開始日の 1 件として、開始日を含む期間だけに出るべき → Task 4 `test_midnight_block_belongs_to_start_date`
## File Structure
| ファイル | 役割 |
|---|---|
| `chktime.py` (変更) | 新しい節「n8n 連携」: 定数、`N8nError`、`block_source`、`make_blocks`、`default_task_name`、`collect_blocks`、`mark_blocks`、`N8n`、`N8nHolder`、`n8n_range`、`n8n_blocks`、`n8n_send`。Redmine 節に `Redmine.search`。HTTP 節にルート追加。`main()` に `--n8n-config` |
| `tests/test_n8n.py` (新規) | 上記の unittest (純粋関数・偽の n8n サーバー・一時フォルダの jsonl) |
| `web/n8n.js` (新規) | 「n8n へ送る」タブの描画と操作 |
| `web/index.html` (変更) | タブボタンと `<script src="n8n.js">` |
| `web/app.js` (変更) | `syncTabs` / `setView` / `selectProject` / `refreshFollow` / `updateCsv` / resize に n8n の分岐 |
| `web/style.css` (変更) | `#n8n-table` などの最小のスタイル |
| `.gitignore` / `README.md` / `CLAUDE.md` (変更) | `n8n.json`、使い方、通信の決まりと API 表 |
| n8n: `work_log` / `work_log_deleted` | `source` (string) 列を追加 |
| n8n: `work-tracker--import` (新規) | Webhook 2 本 |
| n8n: `work-tracker--delete` (変更) | `Backup Row` が `source` もコピー |
テストの実行: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
---
### Task 1: n8n のデータテーブルに source 列を足し、削除時のコピーにも含める
**Files:**
- n8n データテーブル `work_log` (id `l41sKVXXWr0QFMBE`)、`work_log_deleted` (id `6Pwm6IdAkotvx8rD`)
- n8n ワークフロー `work-tracker--delete` (id `1z73HfzmdqHkX2TD`) のノード `Backup Row`
- 一時スクリプト: スクラッチパッド `add_columns.js` (リポジトリには入れない)
**Interfaces:**
- Produces: 両テーブルの `source` (string) 列。Task 2 の受け口と Task 4 以降の chktime が使う。
列の追加は公開 API ではできず、n8n-mcp の `addColumn` が `N8N_MCP_ACCESS_TOKEN` を必要とする。登録済みの `n8n-api` サーバーにはこの値を渡していないので、一時スクリプトで n8n-mcp を直接起動して呼ぶ (値は表示しない)。
- [ ] **Step 1: 列が無いことを確かめる**
`mcp__n8n-api__n8n_manage_datatable` を `action: "getTable"`、`tableId: "l41sKVXXWr0QFMBE"` で呼び、`columns` に `source` が無いことを確認する。`6Pwm6IdAkotvx8rD` も同様。
- [ ] **Step 2: 一時スクリプトを書く** (スクラッチパッドに `add_columns.js`)
```js
// n8n-mcp を直接起動して、2 つのデータテーブルに source 列を足す。トークンは表示しない。
const { spawn } = require('child_process');
const fs = require('fs');
const base = 'C:/Users/debug/.claude/mcp';
const apiKey = fs.readFileSync(`${base}/n8n-api/api-key.txt`, 'utf8').split(/\r?\n/).map((s) => s.trim()).find((s) => s && !s.startsWith('#'));
const mcpLine = fs.readFileSync(`${base}/n8n/headers.txt`, 'utf8').split(/\r?\n/).find((s) => /^Authorization:/i.test(s));
const mcpToken = mcpLine.replace(/^Authorization:\s*Bearer\s+/i, '').trim();
const p = spawn(process.execPath, [`${base}/n8n-api/node_modules/n8n-mcp/dist/mcp/stdio-wrapper.js`], {
env: { ...process.env, MCP_MODE: 'stdio', LOG_LEVEL: 'error', DISABLE_CONSOLE_OUTPUT: 'true',
N8N_API_URL: 'https://n8n.yfox.jp', N8N_API_KEY: apiKey, N8N_MCP_ACCESS_TOKEN: mcpToken,
N8N_MCP_TELEMETRY_DISABLED: 'true', WEBHOOK_SECURITY_MODE: 'permissive', NODE_TLS_REJECT_UNAUTHORIZED: '0' },
});
const tables = ['l41sKVXXWr0QFMBE', '6Pwm6IdAkotvx8rD'];
let buf = '';
p.stdout.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
try { const m = JSON.parse(line); if (m.id >= 10) console.log(m.id, JSON.stringify(m.result || m.error).slice(0, 400)); } catch (e) { /* 他の行は無視 */ }
}
});
const send = (o) => p.stdin.write(JSON.stringify(o) + '\n');
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'add-columns', version: '0' } } });
setTimeout(() => {
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
tables.forEach((tableId, k) => send({ jsonrpc: '2.0', id: 10 + k, method: 'tools/call',
params: { name: 'n8n_manage_datatable', arguments: { action: 'addColumn', tableId, column: { name: 'source', type: 'string' } } } }));
}, 5000);
setTimeout(() => { p.kill(); process.exit(0); }, 40000);
```
- [ ] **Step 3: 実行する**
Run: `node "<スクラッチパッド>/add_columns.js" 2>&1 | grep -v -E "trace-warnings|NODE_TLS"`
Expected: `10` と `11` の行が出て、どちらにもエラーが無い。
失敗した (instance-level MCP が列の追加に対応していない等) 場合: 利用者に、n8n の画面の Data tables → `work_log` と `work_log_deleted` で、列 `source` (String) を足してもらう。
- [ ] **Step 4: 列ができたことを確かめる**
Step 1 と同じ `getTable` で、両方の `columns` に `{"name":"source","type":"string"}` があること。
- [ ] **Step 5: 削除時のコピーに source を足す**
先に `mcp__n8n-api__n8n_get_workflow` (`id: "1z73HfzmdqHkX2TD"`, `mode: "filtered"`, `nodeNames: ["Backup Row"]`) で現在の `columns.value` を確認し、`mcp__n8n-api__n8n_update_partial_workflow` で:
```json
{"id":"1z73HfzmdqHkX2TD","operations":[{"type":"updateNode","nodeName":"Backup Row","updates":{"parameters.columns":{"mappingMode":"defineBelow","value":{"orig_id":"={{ $json.id }}","date":"={{ $json.date }}","start_time":"={{ $json.start_time }}","end_time":"={{ $json.end_time }}","task":"={{ $json.task }}","memo":"={{ $json.memo }}","duration_min":"={{ $json.duration_min }}","status":"={{ $json.status }}","orig_created_at":"={{ $json.createdAt }}","deleted_at":"={{ $now.toISO() }}","source":"={{ $json.source }}"},"matchingColumns":[],"schema":[]}}}]}
```
- [ ] **Step 6: 削除のコピーを確かめる**
1. `n8n_manage_datatable` `insertRows` で `work_log` にダミー
`{"date":"2000-01-01","start_time":"2000-01-01T00:00:00.000Z","end_time":"2000-01-01T01:00:00.000Z","task":"[削除テスト用のダミー] Claude","memo":"","duration_min":60,"status":"done","source":"chktime:test:946684800000"}` を `returnType: "id"` で入れ、id を控える。
2. `curl -sk -X POST -H "Content-Type: application/json" -d '{"ids":[<id>]}' https://n8n.yfox.jp/webhook/work-delete`
Expected: `{"ok":true,"deleted_count":1,...}`
3. `getRows` (`tableId: "6Pwm6IdAkotvx8rD"`, filter `orig_id eq <id>`) で `source` が `chktime:test:946684800000` であること。
4. `deleteRows` で `work_log_deleted` のそのダミーを消す (filter `orig_id eq <id>`)。
(この確認だけは削除 Webhook 自体の検証なので Webhook を使う。対象は自分で入れたダミーだけ。)
---
### Task 2: n8n に受け口のワークフロー work-tracker--import を作る
**Files:**
- n8n ワークフロー `work-tracker--import` (新規)
- `.gitignore` (変更)
- 一時スクリプト: スクラッチパッド `wt_call.py` (リポジトリには入れない)
**Interfaces:**
- Consumes: Task 1 の `source` 列。
- Produces (chktime の Task 5・6 が使う):
- `GET /webhook/wt-import-rows?from=YYYY-MM-DD&to=YYYY-MM-DD` (ヘッダー `X-WT-Token`) →
`{"ok":true,"rows":[{"id":int,"date":str,"start_time":str,"end_time":str|null,"task":str,"status":str,"source":str}],"deletedSources":[str]}`
不正な期間は `{"ok":false,"error":str}`。合言葉違いは HTTP 403。
- `POST /webhook/wt-import` 本文 `{"rows":[{"source","date","start_time","end_time","task","memo"}]}` →
`{"ok":bool,"inserted":[{"source":str,"id":int}],"skipped":[{"source":str,"reason":str}],"errors":[{"source":str,"error":str}]}`
- [ ] **Step 1: .gitignore に n8n.json を足してコミットする**
`.gitignore` の末尾に追加:
```
# n8n 連携の設定 (合言葉が入る)
n8n.json
```
```bash
cd /c/debug/chktime && git add .gitignore && git commit -m "n8n 連携の設定ファイルを git 管理外にする
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
- [ ] **Step 2: 利用者に合言葉の登録を頼む (ここで止まって待つ)**
利用者に次を依頼し、終わったと言われるまで先に進まない:
1. n8n の画面で Credentials → Create → **Header Auth**。名前 `wt-import token`、Name `X-WT-Token`、Value は自分で決めた合言葉 (推測されにくい長い文字列)。
2. 同じ合言葉で chktime の設定ファイルを作る (自分のターミナルで):
```powershell
Set-Content -Encoding utf8NoBOM C:\debug\chktime\n8n.json '{"url":"https://n8n.yfox.jp","token":"<合言葉>","verifySsl":false,"gapMinutes":30,"minMinutes":5}'
```
- [ ] **Step 3: 認証情報の id を取る**
`mcp__n8n-api__n8n_manage_credentials` を `action: "list"` で呼び、名前 `wt-import token` (type `httpHeaderAuth`) の `id` を控える (以下 `<CRED_ID>`)。値は読まない。
`git status --short` に `n8n.json` が出ない (無視されている) ことも確かめる。
- [ ] **Step 4: 呼び出し用の一時スクリプトを書く** (スクラッチパッドに `wt_call.py`。合言葉は表示しない)
```python
"""wt-import の受け口を呼ぶ検証用スクリプト。合言葉は n8n.json から読み、表示しない。
使い方:
python -I wt_call.py rows 2000-01-01 2000-01-01
python -I wt_call.py import '<rows の JSON>'
python -I wt_call.py rows 2000-01-01 2000-01-01 --bad-token
"""
import json, ssl, sys, urllib.error, urllib.request
cfg = json.load(open(r"C:\debug\chktime\n8n.json", encoding="utf-8"))
token = "wrong-token" if "--bad-token" in sys.argv else cfg["token"]
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
args = [a for a in sys.argv[1:] if a != "--bad-token"]
if args[0] == "rows":
req = urllib.request.Request(f"{cfg['url']}/webhook/wt-import-rows?from={args[1]}&to={args[2]}")
else:
req = urllib.request.Request(f"{cfg['url']}/webhook/wt-import", data=json.dumps({"rows": json.loads(args[1])}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-WT-Token", token)
try:
with urllib.request.urlopen(req, context=ctx, timeout=60) as res:
print(res.status, res.read().decode())
except urllib.error.HTTPError as e:
print(e.code, e.read().decode()[:300])
```
- [ ] **Step 5: ワークフローを作る**
`mcp__n8n-api__n8n_create_workflow` を次で呼ぶ (`<CRED_ID>` は Step 3 の値に置き換える):
```json
{
"name": "work-tracker--import",
"settings": {"executionOrder": "v1", "timezone": "Asia/Tokyo", "callerPolicy": "workflowsFromSameOwner"},
"nodes": [
{"id":"wr","name":"Webhook Rows","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[0,0],"webhookId":"wt-import-rows",
"parameters":{"httpMethod":"GET","path":"wt-import-rows","authentication":"headerAuth","responseMode":"responseNode","options":{}},
"credentials":{"httpHeaderAuth":{"id":"<CRED_ID>","name":"wt-import token"}}},
{"id":"cr","name":"Check Rows Range","type":"n8n-nodes-base.code","typeVersion":2,"position":[220,0],
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// 期間を確かめる。日をまたぐ記録との重なりを見るため、取得は from の前日から。\nconst q = $input.first().json.query || {};\nconst re = /^\\d{4}-\\d{2}-\\d{2}$/;\nconst f = String(q.from || ''), t = String(q.to || '');\nlet err = '';\nif (!re.test(f) || !re.test(t) || isNaN(Date.parse(f)) || isNaN(Date.parse(t))) err = 'from / to を YYYY-MM-DD で指定してください';\nelse if (f > t) err = 'from が to より後です';\nelse if ((Date.parse(t) - Date.parse(f)) / 86400000 + 1 > 62) err = '期間は 62 日以内にしてください';\nconst prev = err ? '' : new Date(Date.parse(f) - 86400000).toISOString().slice(0, 10);\nreturn [{ json: { ok: !err, error: err, from: prev, to: t } }];"}},
{"id":"ro","name":"Rows OK?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[440,0],
"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"loose","version":2},"conditions":[{"id":"r1","leftValue":"={{ $json.ok }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}}},
{"id":"gr","name":"Get Rows","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[660,-80],"alwaysOutputData":true,
"parameters":{"resource":"row","operation":"get","dataTableId":{"__rl":true,"value":"l41sKVXXWr0QFMBE","mode":"id","cachedResultName":"work_log"},"matchType":"allConditions","filters":{"conditions":[{"keyName":"date","condition":"gte","keyValue":"={{ $json.from }}"},{"keyName":"date","condition":"lte","keyValue":"={{ $json.to }}"}]},"returnAll":true,"orderBy":true,"orderByColumn":"start_time","orderByDirection":"ASC"}},
{"id":"gd","name":"Get Deleted","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[880,-80],"alwaysOutputData":true,"executeOnce":true,
"parameters":{"resource":"row","operation":"get","dataTableId":{"__rl":true,"value":"6Pwm6IdAkotvx8rD","mode":"id","cachedResultName":"work_log_deleted"},"matchType":"allConditions","filters":{"conditions":[{"keyName":"date","condition":"gte","keyValue":"={{ $('Check Rows Range').first().json.from }}"},{"keyName":"date","condition":"lte","keyValue":"={{ $('Check Rows Range').first().json.to }}"}]},"returnAll":true,"orderBy":false}},
{"id":"br","name":"Build Rows Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[1100,-80],
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"const rows = $('Get Rows').all().map(i => i.json).filter(r => r && r.id).map(r => ({ id: r.id, date: r.date, start_time: r.start_time, end_time: r.end_time, task: r.task || '', status: r.status || '', source: r.source || '' }));\nconst deletedSources = [...new Set($input.all().map(i => i.json && i.json.source).filter(Boolean))];\nreturn [{ json: { ok: true, rows, deletedSources } }];"}},
{"id":"rr","name":"Respond Rows","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.4,"position":[1320,-80],
"parameters":{"respondWith":"json","responseBody":"={{ $json }}","options":{}}},
{"id":"ri","name":"Respond Rows Invalid","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.4,"position":[660,120],
"parameters":{"respondWith":"json","responseBody":"={{ { ok: false, error: $json.error } }}","options":{}}},
{"id":"wi","name":"Webhook Import","type":"n8n-nodes-base.webhook","typeVersion":2,"position":[0,400],"webhookId":"wt-import",
"parameters":{"httpMethod":"POST","path":"wt-import","authentication":"headerAuth","responseMode":"responseNode","options":{}},
"credentials":{"httpHeaderAuth":{"id":"<CRED_ID>","name":"wt-import token"}}},
{"id":"vi","name":"Validate Import","type":"n8n-nodes-base.code","typeVersion":2,"position":[220,400],
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// 受け取った区間を 1 行ずつ確かめる。通ったものだけ valid に入れる。\nconst body = $input.first().json.body || {};\nconst rows = Array.isArray(body.rows) ? body.rows : null;\nconst out = (valid, errors) => { const d = valid.map(v => v.date).sort(); return [{ json: { valid, errors, minDate: d[0] || '', maxDate: d[d.length - 1] || '' } }]; };\nif (!rows) return out([], [{ source: '', error: 'rows がありません' }]);\nif (rows.length > 200) return out([], [{ source: '', error: '一度に送れるのは 200 件までです' }]);\nconst isoRe = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$/;\nconst valid = [], errors = [];\nfor (const r of rows) {\n const source = String((r && r.source) || '');\n const fail = (m) => errors.push({ source, error: m });\n if (!source.startsWith('chktime:') || source.length > 500) { fail('source が不正です'); continue; }\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(String(r.date || ''))) { fail('date が不正です'); continue; }\n const s = Date.parse(r.start_time), e = Date.parse(r.end_time);\n if (!isoRe.test(String(r.start_time || '')) || !isoRe.test(String(r.end_time || '')) || isNaN(s) || isNaN(e)) { fail('start_time / end_time が不正です'); continue; }\n if (e <= s) { fail('end_time が start_time 以前です'); continue; }\n if (e - s > 24 * 3600000) { fail('区間が 24 時間を超えています'); continue; }\n const task = String(r.task == null ? '' : r.task).trim();\n if (!task || task.length > 500) { fail('task が空か 500 文字を超えています'); continue; }\n const memo = String(r.memo == null ? '' : r.memo);\n if (memo.length > 2000) { fail('memo が 2000 文字を超えています'); continue; }\n valid.push({ source, date: r.date, start_time: new Date(s).toISOString(), end_time: new Date(e).toISOString(), task, memo, status: 'done', duration_min: Math.round((e - s) / 60000) });\n}\nreturn out(valid, errors);"}},
{"id":"ge","name":"Get Existing","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[440,400],"alwaysOutputData":true,
"parameters":{"resource":"row","operation":"get","dataTableId":{"__rl":true,"value":"l41sKVXXWr0QFMBE","mode":"id","cachedResultName":"work_log"},"matchType":"allConditions","filters":{"conditions":[{"keyName":"date","condition":"gte","keyValue":"={{ $json.minDate }}"},{"keyName":"date","condition":"lte","keyValue":"={{ $json.maxDate }}"},{"keyName":"source","condition":"isNotEmpty"}]},"returnAll":true,"orderBy":false}},
{"id":"pi","name":"Plan Insert","type":"n8n-nodes-base.code","typeVersion":2,"position":[660,400],
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// すでに work_log にある source と、同じ送信の中の重複を除く。1 件も無ければ _none を返す。\nconst existing = new Set($input.all().map(i => i.json && i.json.source).filter(Boolean));\nconst seen = new Set();\nconst rows = [];\nfor (const v of $('Validate Import').first().json.valid) {\n if (existing.has(v.source) || seen.has(v.source)) continue;\n seen.add(v.source);\n rows.push({ json: v });\n}\nreturn rows.length ? rows : [{ json: { _none: true } }];"}},
{"id":"ai","name":"Any To Insert?","type":"n8n-nodes-base.if","typeVersion":2.2,"position":[880,400],
"parameters":{"conditions":{"options":{"caseSensitive":true,"leftValue":"","typeValidation":"loose","version":2},"conditions":[{"id":"a1","leftValue":"={{ !!$json.source }}","rightValue":true,"operator":{"type":"boolean","operation":"true","singleValue":true}}],"combinator":"and"},"options":{}}},
{"id":"ir","name":"Insert Rows","type":"n8n-nodes-base.dataTable","typeVersion":1.1,"position":[1100,320],
"parameters":{"resource":"row","operation":"insert","dataTableId":{"__rl":true,"value":"l41sKVXXWr0QFMBE","mode":"id","cachedResultName":"work_log"},"columns":{"mappingMode":"defineBelow","value":{"date":"={{ $json.date }}","start_time":"={{ $json.start_time }}","end_time":"={{ $json.end_time }}","task":"={{ $json.task }}","memo":"={{ $json.memo }}","duration_min":"={{ $json.duration_min }}","status":"={{ $json.status }}","source":"={{ $json.source }}"},"matchingColumns":[],"schema":[]},"options":{}}},
{"id":"bi","name":"Build Import Result","type":"n8n-nodes-base.code","typeVersion":2,"position":[1320,400],
"parameters":{"language":"javaScript","mode":"runOnceForAllItems","jsCode":"// Insert Rows (追加した行) か Any To Insert? の false 側 (_none) のどちらか一方から来る。\nconst v = $('Validate Import').first().json;\nconst existing = new Set($('Get Existing').all().map(i => i.json && i.json.source).filter(Boolean));\nconst inserted = $input.all().map(i => i.json).filter(r => r && r.source && r.id).map(r => ({ source: r.source, id: r.id }));\nconst done = new Set(inserted.map(r => r.source));\nconst skipped = [];\nconst seen = new Set();\nfor (const row of v.valid) {\n if (done.has(row.source) && !seen.has(row.source)) { seen.add(row.source); continue; }\n skipped.push({ source: row.source, reason: existing.has(row.source) ? 'すでに登録されています' : '同じ送信の中で重複しています' });\n}\nreturn [{ json: { ok: v.errors.length === 0, inserted, skipped, errors: v.errors } }];"}},
{"id":"rp","name":"Respond Import","type":"n8n-nodes-base.respondToWebhook","typeVersion":1.4,"position":[1540,400],
"parameters":{"respondWith":"json","responseBody":"={{ $json }}","options":{}}}
],
"connections": {
"Webhook Rows": {"main": [[{"node":"Check Rows Range","type":"main","index":0}]]},
"Check Rows Range": {"main": [[{"node":"Rows OK?","type":"main","index":0}]]},
"Rows OK?": {"main": [[{"node":"Get Rows","type":"main","index":0}],[{"node":"Respond Rows Invalid","type":"main","index":0}]]},
"Get Rows": {"main": [[{"node":"Get Deleted","type":"main","index":0}]]},
"Get Deleted": {"main": [[{"node":"Build Rows Result","type":"main","index":0}]]},
"Build Rows Result": {"main": [[{"node":"Respond Rows","type":"main","index":0}]]},
"Webhook Import": {"main": [[{"node":"Validate Import","type":"main","index":0}]]},
"Validate Import": {"main": [[{"node":"Get Existing","type":"main","index":0}]]},
"Get Existing": {"main": [[{"node":"Plan Insert","type":"main","index":0}]]},
"Plan Insert": {"main": [[{"node":"Any To Insert?","type":"main","index":0}]]},
"Any To Insert?": {"main": [[{"node":"Insert Rows","type":"main","index":0}],[{"node":"Build Import Result","type":"main","index":0}]]},
"Insert Rows": {"main": [[{"node":"Build Import Result","type":"main","index":0}]]},
"Build Import Result": {"main": [[{"node":"Respond Import","type":"main","index":0}]]}
}
}
```
返ってきたワークフロー id を控え、`mcp__n8n-api__n8n_validate_workflow` でエラー 0 を確かめてから、`n8n_update_partial_workflow` の `{"type":"activateWorkflow"}` で有効にする。
- [ ] **Step 6: 受け口を確かめる (ダミーだけを使う)**
`S=<スクラッチパッド>/wt_call.py`、ダミー区間 `D='[{"source":"chktime:test:946684800000","date":"2000-01-01","start_time":"2000-01-01T00:00:00.000Z","end_time":"2000-01-01T01:00:00.000Z","task":"[取り込みテスト] Claude","memo":"テスト後に削除する"}]'`
| 実行 | Expected |
|---|---|
| `python -I $S rows 2000-01-01 2000-01-01 --bad-token` | `403` |
| `python -I $S rows 2000-01-02 2000-01-01` | `200 {"ok":false,"error":"from が to より後です"}` |
| `python -I $S rows 2000-01-01 2000-03-31` | `200 {"ok":false,"error":"期間は 62 日以内にしてください"}` |
| `python -I $S import "$D"` | `200`、`inserted` に `chktime:test:946684800000` と id が 1 件 |
| `python -I $S import "$D"` (2 回目) | `200`、`inserted` は空、`skipped` に `すでに登録されています` |
| `python -I $S import '[{"source":"x","date":"2000-01-01","start_time":"2000-01-01T00:00:00Z","end_time":"2000-01-01T01:00:00Z","task":"t"}]'` | `200`、`ok:false`、`errors` に `source が不正です` |
| `python -I $S import '[{"source":"chktime:test:1","date":"2000-01-01","start_time":"2000-01-01T02:00:00Z","end_time":"2000-01-01T01:00:00Z","task":"t"}]'` | `errors` に `end_time が start_time 以前です` |
| `python -I $S import '[{"source":"chktime:test:2","date":"2000-01-01","start_time":"2000-01-01T00:00:00Z","end_time":"2000-01-01T01:00:00Z","task":" "}]'` | `errors` に `task が空か 500 文字を超えています` |
| `python -I $S rows 2000-01-01 2000-01-01` | `rows` にダミー 1 件 (`source` 付き)、`deletedSources` は `[]` |
`inserted` に id が無い場合 (Insert Rows が行を返さない場合) は、`Build Import Result` で `$('Plan Insert').all()` と `$input.all()` を並び順で対応させて id を取るように直し、もう一度確かめる。
- [ ] **Step 7: ダミーを消す**
`n8n_manage_datatable` `deleteRows` (`tableId: "l41sKVXXWr0QFMBE"`, filter `source eq chktime:test:946684800000` かつ `date eq 2000-01-01`) で消し、`rows 2000-01-01 2000-01-01` が空になることを確かめる。
---
### Task 3: 区間の計算 make_blocks
**Files:**
- Modify: `chktime.py` (HTTP 節の直前、`to_fields` の後に新しい節を足す)
- Create: `tests/test_n8n.py`
**Interfaces:**
- Produces:
- `N8N_CONFIG: Path`、`N8N_GAP_MIN = 30`、`N8N_MIN_MIN = 5`、`N8N_MAX_DAYS = 62`
- `class N8nError(Exception)`
- `block_source(session_id: str, start_epoch: float) -> str`
- `make_blocks(session: dict, gap_min=30, min_min=5, now: float|None=None) -> list[dict]`
各 dict: `source, sessionId, date (YYYY-MM-DD ローカル), start, end (ISO UTC), startEpoch, endEpoch (float), minutes (int), turns (int), inProgress (bool)`
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py`)
```python
"""n8n 連携 (作業区間の計算・重なり・送信) のテスト。標準ライブラリの unittest だけを使う。
python -m unittest discover -s tests -v
"""
import json
import sys
import tempfile
import threading
import time
import unittest
from datetime import date, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import chktime # noqa: E402
MIN = 60.0
BASE = datetime(2026, 1, 5, 9, 0).timestamp() # ローカル時刻 2026-01-05 09:00
def turn(start_min, end_min, kind="prompt", reply=True, interrupted=False):
"""BASE からの分で 1 ターンを作る。reply=False なら応答なし。"""
ts = BASE + start_min * MIN
end = BASE + end_min * MIN
return {"ts": ts, "endTs": end if reply else ts, "firstReplyTs": ts + 1 if reply else None,
"kind": kind, "interrupted": interrupted}
def session(*turns, sid="s1", mtime=None):
last_end = max(t["endTs"] for t in turns) if turns else BASE
return {"id": sid, "turns": list(turns), "mtime": mtime if mtime is not None else last_end}
LATER = BASE + 24 * 3600 # 「今」: 最後のログから十分経っている
class MakeBlocksTest(unittest.TestCase):
def test_gap_29_minutes_keeps_block(self):
blocks = chktime.make_blocks(session(turn(0, 10), turn(39, 50)), now=LATER)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0]["minutes"], 50)
self.assertEqual(blocks[0]["turns"], 2)
def test_gap_30_minutes_splits_block(self):
blocks = chktime.make_blocks(session(turn(0, 10), turn(40, 50)), now=LATER)
self.assertEqual([b["minutes"] for b in blocks], [10, 10])
def test_parallel_prompt_keeps_block(self):
# 前の応答 (0〜20 分) の途中の 5 分に次の発言
blocks = chktime.make_blocks(session(turn(0, 20), turn(5, 30)), now=LATER)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0]["minutes"], 30)
def test_short_block_dropped_but_exactly_min_kept(self):
short = chktime.make_blocks(session(turn(0, 4.9)), now=LATER)
exact = chktime.make_blocks(session(turn(0, 5)), now=LATER)
self.assertEqual(short, [])
self.assertEqual(len(exact), 1)
def test_last_block_without_reply_is_in_progress(self):
blocks = chktime.make_blocks(session(turn(0, 10), turn(60, 60, reply=False)), now=LATER)
self.assertEqual([b["inProgress"] for b in blocks], [False, True])
def test_recent_log_marks_last_block_in_progress(self):
s = session(turn(0, 10))
blocks = chktime.make_blocks(s, now=s["mtime"] + 29 * MIN)
self.assertTrue(blocks[0]["inProgress"])
blocks = chktime.make_blocks(s, now=s["mtime"] + 30 * MIN)
self.assertFalse(blocks[0]["inProgress"])
def test_interrupted_last_turn_is_not_in_progress(self):
blocks = chktime.make_blocks(session(turn(0, 10, reply=False, interrupted=True), turn(1, 10)), now=LATER)
self.assertFalse(blocks[0]["inProgress"])
only = chktime.make_blocks(session(turn(0, 10), turn(11, 11, reply=False, interrupted=True)), now=LATER)
self.assertFalse(only[-1]["inProgress"])
def test_source_is_stable(self):
a = chktime.make_blocks(session(turn(0, 10)), now=LATER)
b = chktime.make_blocks(session(turn(0, 10)), now=LATER + 999)
self.assertEqual(a[0]["source"], b[0]["source"])
self.assertEqual(a[0]["source"], f"chktime:s1:{int(round(BASE * 1000))}")
def test_custom_gap_and_min(self):
blocks = chktime.make_blocks(session(turn(0, 3), turn(14, 17)), gap_min=10, min_min=3, now=LATER)
self.assertEqual([b["minutes"] for b in blocks], [3, 3])
def test_date_is_local_start_date(self):
blocks = chktime.make_blocks(session(turn(0, 10)), now=LATER)
self.assertEqual(blocks[0]["date"], "2026-01-05")
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: 失敗を確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: 全テストが ERROR (`AttributeError: module 'chktime' has no attribute 'make_blocks'`)。
- [ ] **Step 3: 実装する** (`chktime.py` の `to_fields` 関数の後、`# ---------------------------------------------------------------- HTTP` の前)
```python
# ---------------------------------------------------------------- n8n 連携 (work_log への取り込み)
N8N_CONFIG = APP_DIR / "n8n.json"
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
class N8nError(Exception):
"""n8n 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
def block_source(session_id, start_epoch):
"""作業区間の識別子。同じログからは毎回同じ値になる (二重登録を防ぐ鍵)。"""
return f"chktime:{session_id}:{int(round(start_epoch * 1000))}"
def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
"""1 セッションのターンを作業区間に分ける。
前のターンの応答完了から次の発言までの空きが gap_min 分未満ならつなぎ、
gap_min 分以上なら区間を切る。min_min 分未満の区間は捨てる (進行中は残す)。
最後の区間は、応答が返っていない / ログの更新から gap_min 分経っていない
なら進行中 (送れない) とする。
"""
now = time.time() if now is None else now
gap = gap_min * 60.0
groups = []
for turn in session["turns"]:
ts = turn["ts"]
if ts is None:
continue
end = turn["endTs"] if turn["endTs"] is not None else ts
if groups and ts - groups[-1]["end"] < gap:
group = groups[-1]
group["end"] = max(group["end"], end)
group["turns"] += 1
group["last"] = turn
else:
groups.append({"start": ts, "end": end, "turns": 1, "last": turn})
out = []
for index, group in enumerate(groups):
last = group["last"]
waiting = last["firstReplyTs"] is None and not last["interrupted"]
recent = now - (session.get("mtime") or group["end"]) < gap
in_progress = index == len(groups) - 1 and (waiting or recent)
minutes = (group["end"] - group["start"]) / 60.0
if minutes < min_min and not in_progress:
continue
out.append(
{
"source": block_source(session["id"], group["start"]),
"sessionId": session["id"],
"date": local_date(group["start"]),
"start": iso(group["start"]),
"end": iso(group["end"]),
"startEpoch": group["start"],
"endEpoch": group["end"],
"minutes": int(round(minutes)),
"turns": group["turns"],
"inProgress": in_progress,
}
)
return out
```
注意: `test_interrupted_last_turn_is_not_in_progress` の 2 つ目は、中断のターンが前のターンに含まれる形 (応答の途中で中断) を想定している。chktime のパーサーは中断を新しいターンにせず直前のターンの `interrupted` を立てるので、テストの `turn(11, 11, reply=False, interrupted=True)` は「応答が無いまま中断されたターン」を表す。
- [ ] **Step 4: 通ることを確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: `MakeBlocksTest` の 10 件が PASS。
- [ ] **Step 5: コミット**
```bash
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: セッションを作業区間に分ける make_blocks を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: 期間の区間を集める collect_blocks と重なりの判定 mark_blocks
**Files:**
- Modify: `chktime.py` (Task 3 の節に追記)
- Modify: `tests/test_n8n.py`
**Interfaces:**
- Consumes: `make_blocks`、`Store` (`projects()` / `session_files(id)` / `session(path)`)、`parse_ts`、`iso`、`one_line`
- Produces:
- `default_task_name(folder: str|None, title: str) -> str` … `[<フォルダ名>] <タイトル>`
- `collect_blocks(store, project_id: str, start: date, end: date, gap_min, min_min, now=None) -> list[dict]`
区間 dict に `project, projectPath, title, taskName` を足したもの。開始の古い順。
- `mark_blocks(blocks, rows: list[dict], deleted_sources: list[str], now=None) -> blocks`
各区間に `sent: bool, deleted: bool, overlaps: list[{kind:'row'|'block', id?, source, task, start, end}]` を足す。
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py` の `if __name__` の前に追加)
```python
def jsonl_line(obj):
return json.dumps(obj, ensure_ascii=False) + "\n"
def write_session(root, project_dir, sid, events, cwd):
"""events: [(分, 'user'|'assistant'|'sidechain', 本文)] を jsonl にする。"""
folder = Path(root) / project_dir
folder.mkdir(parents=True, exist_ok=True)
path = folder / f"{sid}.jsonl"
with path.open("w", encoding="utf-8") as fh:
for minute, kind, text in events:
ts = chktime.iso(BASE + minute * MIN)
if kind == "assistant":
fh.write(jsonl_line({"type": "assistant", "timestamp": ts, "sessionId": sid, "cwd": cwd,
"message": {"model": "m", "content": [{"type": "text", "text": text}]}}))
else:
fh.write(jsonl_line({"type": "user", "timestamp": ts, "sessionId": sid, "cwd": cwd,
"isSidechain": kind == "sidechain", "message": {"role": "user", "content": text}}))
end = BASE + max(m for m, _, _ in events) * MIN
import os
os.utime(path, (end, end))
return path
class CollectAndMarkTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.store = chktime.Store(self.root)
def tearDown(self):
self.tmp.cleanup()
def test_collect_blocks_names_and_range(self):
write_session(self.root, "C--debug-demo", "s1",
[(0, "user", "設計して"), (10, "assistant", "ok"), (60, "user", "続き"), (70, "assistant", "ok")],
r"C:\debug\demo")
blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER)
self.assertEqual(len(blocks), 2)
self.assertEqual(blocks[0]["taskName"], "[demo] 設計して")
self.assertEqual(blocks[0]["project"], "C--debug-demo")
none = chktime.collect_blocks(self.store, "", date(2026, 1, 6), date(2026, 1, 6), 30, 5, now=LATER)
self.assertEqual(none, [])
def test_sidechain_does_not_start_turn(self):
write_session(self.root, "C--debug-demo", "s2",
[(0, "user", "調べて"), (10, "assistant", "ok"), (45, "sidechain", "sub"), (80, "user", "次"), (90, "assistant", "ok")],
r"C:\debug\demo")
blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER)
# サブエージェントの行は直前のターンを 45 分まで伸ばすだけ。80 分の発言はその 35 分後なので別の区間。
self.assertEqual([b["minutes"] for b in blocks], [45, 10])
def test_midnight_block_belongs_to_start_date(self):
start = datetime(2026, 1, 5, 23, 50).timestamp()
minutes = (start - BASE) / MIN
write_session(self.root, "C--debug-demo", "s3",
[(minutes, "user", "夜"), (minutes + 20, "assistant", "ok")], r"C:\debug\demo")
day1 = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER + 86400)
day2 = chktime.collect_blocks(self.store, "", date(2026, 1, 6), date(2026, 1, 6), 30, 5, now=LATER + 86400)
self.assertEqual([b["date"] for b in day1], ["2026-01-05"])
self.assertEqual(day2, [])
def test_mark_blocks(self):
b1 = {"source": "chktime:a:1", "startEpoch": BASE, "endEpoch": BASE + 600, "start": "", "end": "", "taskName": "A"}
b2 = {"source": "chktime:b:2", "startEpoch": BASE + 600, "endEpoch": BASE + 1200, "start": "", "end": "", "taskName": "B"}
b3 = {"source": "chktime:c:3", "startEpoch": BASE + 1199, "endEpoch": BASE + 1800, "start": "", "end": "", "taskName": "C"}
rows = [
{"id": 1, "source": "chktime:a:1", "start_time": chktime.iso(BASE), "end_time": chktime.iso(BASE + 600), "task": "A"},
{"id": 2, "source": "", "start_time": chktime.iso(BASE + 1700), "end_time": None, "task": "手で記録"},
]
chktime.mark_blocks([b1, b2, b3], rows, ["chktime:b:2"], now=BASE + 3600)
self.assertTrue(b1["sent"])
self.assertEqual(b1["overlaps"], []) # 自分の行は除く。b2 とは接しているだけ
self.assertFalse(b2["sent"])
self.assertTrue(b2["deleted"])
self.assertEqual([o["source"] for o in b2["overlaps"]], ["chktime:c:3"]) # 1 秒重なる
self.assertEqual(sorted(o["kind"] for o in b3["overlaps"]), ["block", "row"]) # 進行中の行 (終了 = 今) とも重なる
```
- [ ] **Step 2: 失敗を確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: `CollectAndMarkTest` の 4 件が ERROR (`no attribute 'collect_blocks'` / `'mark_blocks'`)、`MakeBlocksTest` は PASS のまま。
- [ ] **Step 3: 実装する** (Task 3 の `make_blocks` の後に追加)
```python
def default_task_name(folder, title):
"""タスク名の初期値: [作業フォルダ名] セッションのタイトル。"""
name = Path(str(folder).replace("\\", "/")).name if folder else ""
title = one_line(title, 200)
return f"[{name}] {title}" if name else title
def collect_blocks(store, project_id, start, end, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
"""期間 [start, end] (date, ローカル) に始まる作業区間を集める。project_id が空なら全プロジェクト。"""
lo = datetime.combine(start, datetime.min.time()).timestamp()
hi = datetime.combine(end + timedelta(days=1), datetime.min.time()).timestamp()
projects = store.projects()
if project_id:
projects = [p for p in projects if p["id"] == project_id]
if not projects:
raise FileNotFoundError(project_id)
out = []
for project in projects:
for path in store.session_files(project["id"]):
if path.stat().st_mtime < lo:
continue # 期間より前に書き終わったログは開かない
session = store.session(path)
if session["startEpoch"] is None or session["startEpoch"] >= hi:
continue
folder = session.get("cwd") or project["path"]
for block in make_blocks(session, gap_min, min_min, now):
if not lo <= block["startEpoch"] < hi:
continue
block.update(
{
"project": project["id"],
"projectPath": folder,
"title": session["title"],
"taskName": default_task_name(folder, session["title"]),
}
)
out.append(block)
out.sort(key=lambda b: b["startEpoch"])
return out
def mark_blocks(blocks, rows, deleted_sources, now=None):
"""n8n の work_log の行と突き合わせて、送信済み / 削除済み / 重なりを付ける。
重なりは [開始, 終了) が少しでも重なること (接しているだけは重ならない)。
相手は work_log の行 (進行中は終了 = now) と一覧の他の区間。同じ source は除く。
"""
now = time.time() if now is None else now
sent = {r.get("source") for r in rows if r.get("source")}
deleted = set(deleted_sources or ())
spans = []
for row in rows:
start = parse_ts(row.get("start_time"))
if start is None:
continue
end = parse_ts(row.get("end_time")) or now
spans.append((start, end, {"kind": "row", "id": row.get("id"), "source": row.get("source") or "",
"task": row.get("task") or "", "start": iso(start), "end": iso(end)}))
for block in blocks:
spans.append((block["startEpoch"], block["endEpoch"], {"kind": "block", "source": block["source"],
"task": block.get("taskName") or "",
"start": block["start"], "end": block["end"]}))
for block in blocks:
block["sent"] = block["source"] in sent
block["deleted"] = not block["sent"] and block["source"] in deleted
block["overlaps"] = [
info for start, end, info in spans
if info["source"] != block["source"] and start < block["endEpoch"] and block["startEpoch"] < end
]
return blocks
```
- [ ] **Step 4: 通ることを確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: 14 件すべて PASS。
- [ ] **Step 5: コミット**
```bash
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: 期間の作業区間を集め、送信済み・重なりを判定する
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: n8n クライアントと設定 (N8n / N8nHolder)
**Files:**
- Modify: `chktime.py` (n8n 節に追記)
- Modify: `tests/test_n8n.py`
**Interfaces:**
- Consumes: Task 2 の受け口の形、`load_redmine_config` (JSON 設定の読み込み。名前は Redmine 用だが中身は汎用)
- Produces:
- `class N8n(url, token, verify=False)`: `.call(path, method="GET", payload=None) -> dict`、`.rows(start: date, end: date) -> dict`、`.import_rows(rows: list[dict]) -> dict`。失敗は `N8nError` (403 は `合言葉が違います (HTTP 403)`)。応答が `{"ok":false,"error":...}` なら `N8nError(error)`。
- `class N8nHolder(path)`: `.client: N8n`、`.settings() -> (gap_min:int, min_min:int)`、`.public() -> {url, hasToken, verifySsl, gapMinutes, minMinutes, configPath}`、`.update(patch) -> public()`
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py` に追加)
```python
class FakeN8n:
"""wt-import の受け口を真似る偽のサーバー。受け取った要求を記録する。"""
TOKEN = "test-token"
def __init__(self):
self.requests = []
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
fake.requests.append(("GET", self.path, self.headers.get("X-WT-Token")))
if self.headers.get("X-WT-Token") != FakeN8n.TOKEN:
return self.reply(403, {"message": "Authorization data is wrong!"})
if "from=2099" in self.path:
return self.reply(200, {"ok": False, "error": "from が to より後です"})
self.reply(200, {"ok": True, "rows": [{"id": 9, "source": "chktime:s1:1", "start_time": None}], "deletedSources": []})
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(length))
fake.requests.append(("POST", self.path, body))
if self.headers.get("X-WT-Token") != FakeN8n.TOKEN:
return self.reply(403, {})
inserted = [{"source": r["source"], "id": 100 + i} for i, r in enumerate(body["rows"])]
self.reply(200, {"ok": True, "inserted": inserted, "skipped": [], "errors": []})
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
class N8nClientTest(unittest.TestCase):
def setUp(self):
self.fake = FakeN8n()
def tearDown(self):
self.fake.close()
def test_rows_sends_token_and_range(self):
client = chktime.N8n(self.fake.url, FakeN8n.TOKEN)
data = client.rows(date(2026, 1, 1), date(2026, 1, 7))
self.assertEqual(data["rows"][0]["id"], 9)
method, path, token = self.fake.requests[-1]
self.assertEqual(path, "/webhook/wt-import-rows?from=2026-01-01&to=2026-01-07")
self.assertEqual(token, FakeN8n.TOKEN)
def test_wrong_token_is_403_message(self):
client = chktime.N8n(self.fake.url, "wrong")
with self.assertRaisesRegex(chktime.N8nError, "合言葉が違います"):
client.rows(date(2026, 1, 1), date(2026, 1, 1))
def test_error_payload_raises(self):
client = chktime.N8n(self.fake.url, FakeN8n.TOKEN)
with self.assertRaisesRegex(chktime.N8nError, "from が to より後です"):
client.rows(date(2099, 1, 2), date(2099, 1, 1))
def test_unreachable(self):
client = chktime.N8n("http://127.0.0.1:9", FakeN8n.TOKEN)
with self.assertRaisesRegex(chktime.N8nError, "接続できません"):
client.rows(date(2026, 1, 1), date(2026, 1, 1))
def test_missing_settings(self):
with self.assertRaisesRegex(chktime.N8nError, "URL が未設定"):
chktime.N8n("", "x").rows(date(2026, 1, 1), date(2026, 1, 1))
with self.assertRaisesRegex(chktime.N8nError, "合言葉が未設定"):
chktime.N8n(self.fake.url, "").rows(date(2026, 1, 1), date(2026, 1, 1))
class N8nHolderTest(unittest.TestCase):
def test_public_hides_token_and_update_keeps_it(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "n8n.json"
holder = chktime.N8nHolder(path)
self.assertEqual(holder.settings(), (30, 5))
pub = holder.update({"url": "https://n8n.example/", "token": "secret", "gapMinutes": 20, "minMinutes": 3})
self.assertTrue(pub["hasToken"])
self.assertNotIn("secret", json.dumps(pub))
self.assertEqual(holder.settings(), (20, 3))
holder.update({"token": ""}) # 空なら既存を残す
self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["token"], "secret")
self.assertEqual(holder.client.url, "https://n8n.example")
def test_rejects_bad_minutes(self):
with tempfile.TemporaryDirectory() as tmp:
holder = chktime.N8nHolder(Path(tmp) / "n8n.json")
for patch in ({"gapMinutes": 0}, {"gapMinutes": 241}, {"minMinutes": -1}, {"gapMinutes": "abc"}):
with self.assertRaises(chktime.N8nError):
holder.update(patch)
```
- [ ] **Step 2: 失敗を確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: 新しい 7 件が ERROR (`no attribute 'N8n'` / `'N8nHolder'`)、既存の 14 件は PASS。
- [ ] **Step 3: 実装する** (`mark_blocks` の後に追加)
```python
class N8n:
"""n8n の work-tracker--import を呼ぶ最小のクライアント (標準ライブラリのみ)。
LAN からは n8n.yfox.jp の証明書名が一致しないので、verify=False なら検証を外す。
合言葉は X-WT-Token ヘッダーで送り、ログにも例外の文言にも出さない。
"""
def __init__(self, url=None, token=None, verify=False):
self.url = (url or "").rstrip("/")
self.token = token or ""
self.ctx = None
if not verify:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def call(self, path, method="GET", payload=None):
if not self.url:
raise N8nError("n8n の URL が未設定です")
if not self.token:
raise N8nError("合言葉が未設定です")
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-WT-Token", self.token)
req.add_header("Accept", "application/json")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
if exc.code == 403:
raise N8nError("合言葉が違います (HTTP 403)") from None
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
raise N8nError(f"HTTP {exc.code}: {detail or exc.reason}") from None
except urllib.error.URLError as exc:
raise N8nError(f"接続できません: {exc.reason}") from None
except OSError as exc:
raise N8nError(f"接続できません: {exc}") from None
try:
data = json.loads(raw or b"{}")
except ValueError:
raise N8nError("JSON として読めない応答が返りました") from None
if isinstance(data, dict) and data.get("ok") is False and data.get("error"):
raise N8nError(str(data["error"]))
return data if isinstance(data, dict) else {}
def rows(self, start, end):
return self.call(f"/webhook/wt-import-rows?from={start.isoformat()}&to={end.isoformat()}")
def import_rows(self, rows):
return self.call("/webhook/wt-import", "POST", {"rows": rows})
class N8nHolder:
"""n8n 連携の設定ファイルと、そこから作ったクライアントを保持する。合言葉は UI へ返さない。"""
LIMITS = {"gapMinutes": (1, 240), "minMinutes": (0, 240)}
def __init__(self, path):
self.path = Path(path)
self.lock = threading.Lock()
self.cfg = load_redmine_config(self.path) # 中身は汎用の JSON 読み込み
self.client = self._build()
def _build(self):
return N8n(self.cfg.get("url"), self.cfg.get("token"), self.cfg.get("verifySsl", False))
def settings(self):
with self.lock:
return int(self.cfg.get("gapMinutes", N8N_GAP_MIN)), int(self.cfg.get("minMinutes", N8N_MIN_MIN))
def public(self):
with self.lock:
cfg = self.cfg
return {
"url": cfg.get("url", ""),
"hasToken": bool(cfg.get("token")),
"verifySsl": bool(cfg.get("verifySsl", False)),
"gapMinutes": int(cfg.get("gapMinutes", N8N_GAP_MIN)),
"minMinutes": int(cfg.get("minMinutes", N8N_MIN_MIN)),
"configPath": str(self.path),
}
def update(self, patch):
"""URL・合言葉・区間の設定を保存する。token が空文字なら既存の合言葉を残す。"""
with self.lock:
cfg = dict(self.cfg)
if "url" in patch:
cfg["url"] = str(patch["url"] or "").strip()
if "verifySsl" in patch:
cfg["verifySsl"] = bool(patch["verifySsl"])
for key, (low, high) in self.LIMITS.items():
if key in patch:
try:
value = int(patch[key])
except (TypeError, ValueError):
raise N8nError(f"{key} は整数で指定してください") from None
if not low <= value <= high:
raise N8nError(f"{key} は {low}〜{high} で指定してください")
cfg[key] = value
if patch.get("clearToken"):
cfg.pop("token", None)
elif patch.get("token"):
cfg["token"] = str(patch["token"]).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 N8nError(f"設定を保存できません: {exc}") from None
return self.public()
```
- [ ] **Step 4: 通ることを確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: 21 件すべて PASS。
- [ ] **Step 5: コミット**
```bash
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: 受け口を呼ぶクライアントと設定の保持を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: API (/api/n8n/config・blocks・send、/api/redmine/search)
**Files:**
- Modify: `chktime.py` (n8n 節に `n8n_range` / `n8n_blocks` / `n8n_send`、`Redmine.search`、`Handler`、`main`、import)
- Modify: `tests/test_n8n.py`
**Interfaces:**
- Consumes: `collect_blocks`、`mark_blocks`、`make_blocks`、`N8nHolder`、`Store.one(project, session_id)`
- Produces:
- `n8n_range(query: dict) -> (date, date)` (既定は今日までの 7 日。62 日超・逆順は `N8nError`)
- `n8n_blocks(store, holder, query, now=None) -> {from, to, gapMinutes, minMinutes, blocks, n8nError}`
- `n8n_send(store, holder, payload, now=None) -> {ok, inserted, skipped, errors}`
`payload = {"items":[{"project","source","task","memo"}]}`
- `Redmine.search(text: str, limit=30) -> list[{id, subject, project}]`
- ルート: `GET /api/n8n/config`、`POST /api/n8n/config`、`GET /api/n8n/blocks?from=&to=&project=`、`POST /api/n8n/send`、`GET /api/redmine/search?q=`
- [ ] **Step 1: 失敗するテストを書く** (`tests/test_n8n.py` に追加)
```python
class ApiFunctionsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name) / "projects"
self.store = chktime.Store(self.root)
write_session(self.root, "C--debug-demo", "s1",
[(0, "user", "設計して"), (10, "assistant", "ok")], r"C:\debug\demo")
self.fake = FakeN8n()
self.holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json")
self.holder.update({"url": self.fake.url, "token": FakeN8n.TOKEN})
self.query = {"from": ["2026-01-05"], "to": ["2026-01-05"], "project": [""]}
def tearDown(self):
self.fake.close()
self.tmp.cleanup()
def source(self):
return f"chktime:s1:{int(round(BASE * 1000))}"
def test_range_defaults_and_limits(self):
start, end = chktime.n8n_range({})
self.assertEqual((end - start).days, 6)
with self.assertRaises(chktime.N8nError):
chktime.n8n_range({"from": ["2026-01-05"], "to": ["2026-01-04"]})
with self.assertRaises(chktime.N8nError):
chktime.n8n_range({"from": ["2026-01-01"], "to": ["2026-03-31"]})
def test_blocks_marks_with_n8n_rows(self):
data = chktime.n8n_blocks(self.store, self.holder, self.query, now=LATER)
self.assertIsNone(data["n8nError"])
self.assertEqual(len(data["blocks"]), 1)
self.assertFalse(data["blocks"][0]["sent"])
def test_blocks_when_n8n_down(self):
self.holder.update({"url": "http://127.0.0.1:9"})
data = chktime.n8n_blocks(self.store, self.holder, self.query, now=LATER)
self.assertIn("接続できません", data["n8nError"])
self.assertEqual(len(data["blocks"]), 1)
self.assertFalse(data["blocks"][0]["sent"])
def test_send_recomputes_times_on_server(self):
item = {"project": "C--debug-demo", "source": self.source(), "task": "[demo] #12 設計", "memo": "m",
"start_time": "1999-01-01T00:00:00Z"} # ブラウザの時刻は使わない
result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER)
self.assertTrue(result["ok"])
method, path, body = self.fake.requests[-1]
row = body["rows"][0]
self.assertEqual(path, "/webhook/wt-import")
self.assertEqual(row["start_time"], chktime.iso(BASE))
self.assertEqual(row["end_time"], chktime.iso(BASE + 10 * MIN))
self.assertEqual(row["date"], "2026-01-05")
self.assertEqual(row["task"], "[demo] #12 設計")
def test_send_rejects_blank_task(self):
item = {"project": "C--debug-demo", "source": self.source(), "task": " "}
result = chktime.n8n_send(self.store, self.holder, {"items": [item]}, now=LATER)
self.assertFalse(result["ok"])
self.assertEqual(result["errors"][0]["error"], "タスク名が空です")
self.assertEqual([r for r in self.fake.requests if r[0] == "POST"], [])
def test_send_rejects_unknown_and_in_progress(self):
bad = {"project": "C--debug-demo", "source": "chktime:s1:123", "task": "x"}
gone = {"project": "C--debug-demo", "source": "chktime:nope:1", "task": "x"}
result = chktime.n8n_send(self.store, self.holder, {"items": [bad, gone]}, now=LATER)
self.assertEqual([e["error"] for e in result["errors"]],
["区間が見つかりません (ログか設定が変わりました)", "セッションが見つかりません"])
live = {"project": "C--debug-demo", "source": self.source(), "task": "x"}
result = chktime.n8n_send(self.store, self.holder, {"items": [live]}, now=BASE + 11 * MIN)
self.assertEqual(result["errors"][0]["error"], "進行中の区間は送れません")
def test_send_requires_items(self):
with self.assertRaises(chktime.N8nError):
chktime.n8n_send(self.store, self.holder, {"items": []}, now=LATER)
```
- [ ] **Step 2: 失敗を確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: `ApiFunctionsTest` の 7 件が ERROR (`no attribute 'n8n_range'` など)。
- [ ] **Step 3: 関数を実装する** (`N8nHolder` の後に追加)
```python
SOURCE_RE = re.compile(r"^chktime:([^:]+):(\d+)$")
def n8n_range(query):
"""?from= / ?to= の解釈。既定は今日までの 7 日。逆順と N8N_MAX_DAYS 日超はエラー。"""
end = parse_date(query.get("to", [""])[0]) or date.today()
start = parse_date(query.get("from", [""])[0]) or end - timedelta(days=6)
if start > end:
raise N8nError("開始日が終了日より後です")
if (end - start).days + 1 > N8N_MAX_DAYS:
raise N8nError(f"期間は {N8N_MAX_DAYS} 日以内にしてください")
return start, end
def n8n_blocks(store, holder, query, now=None):
"""期間の作業区間に、n8n 側の送信済み・削除済み・重なりを付けて返す。
n8n に届かなくても区間は返し、n8nError に理由を入れる (送信済みは分からないので UI は送らせない)。
"""
start, end = n8n_range(query)
gap, minimum = holder.settings()
blocks = collect_blocks(store, query.get("project", [""])[0], start, end, gap, minimum, now)
rows, deleted, error = [], [], None
try:
data = holder.client.rows(start, end)
rows = data.get("rows") or []
deleted = data.get("deletedSources") or []
except N8nError as exc:
error = str(exc)
mark_blocks(blocks, rows, deleted, now)
return {"from": start.isoformat(), "to": end.isoformat(), "gapMinutes": gap, "minMinutes": minimum,
"blocks": blocks, "n8nError": error}
def n8n_send(store, holder, payload, now=None):
"""選ばれた区間を n8n の work_log に送る。時刻はブラウザの値を使わずログから計算し直す。"""
items = payload.get("items") or []
if not items:
raise N8nError("送る区間がありません")
if len(items) > 200:
raise N8nError("一度に送れるのは 200 件までです")
gap, minimum = holder.settings()
rows, errors, cache = [], [], {}
for item in items:
source = str(item.get("source") or "")
task = " ".join(str(item.get("task") or "").split())
match = SOURCE_RE.match(source)
if not match:
errors.append({"source": source, "error": "source が不正です"})
continue
if not task:
errors.append({"source": source, "error": "タスク名が空です"})
continue
key = (str(item.get("project") or ""), match.group(1))
if key not in cache:
try:
cache[key] = make_blocks(store.one(*key), gap, minimum, now)
except FileNotFoundError:
cache[key] = None
if cache[key] is None:
errors.append({"source": source, "error": "セッションが見つかりません"})
continue
block = next((b for b in cache[key] if b["source"] == source), None)
if block is None:
errors.append({"source": source, "error": "区間が見つかりません (ログか設定が変わりました)"})
continue
if block["inProgress"]:
errors.append({"source": source, "error": "進行中の区間は送れません"})
continue
rows.append({"source": source, "date": block["date"], "start_time": block["start"],
"end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")})
result = {"ok": False, "inserted": [], "skipped": [], "errors": errors}
if rows:
data = holder.client.import_rows(rows)
result["inserted"] = data.get("inserted") or []
result["skipped"] = data.get("skipped") or []
result["errors"] = errors + (data.get("errors") or [])
result["ok"] = not result["errors"]
return result
```
- [ ] **Step 4: Redmine の検索を足す** (`class Redmine` の `create` の後。先頭の import を `from urllib.parse import parse_qs, quote, urlparse` に変える)
```python
def search(self, text, limit=30):
"""未完了チケットを件名で探す (n8n へ送るときのチケット選択用)。数字だけなら番号で引く。"""
text = (text or "").strip()
if text.isdigit():
try:
issue = self.call(f"/issues/{text}.json").get("issue")
except RedmineError:
issue = None
items = [issue] if issue else []
else:
path = f"/issues.json?status_id=open&sort=updated_on:desc&limit={int(limit)}"
if text:
path += "&f[]=subject&op[subject]=~&v[subject][]=" + quote(text)
items = self.call(path).get("issues") or []
return [{"id": i.get("id"), "subject": i.get("subject") or "",
"project": (i.get("project") or {}).get("name") or ""} for i in items]
```
- [ ] **Step 5: ルートと起動オプションを足す**
`Handler.do_GET` と `do_POST` の例外処理に `N8nError` を足す:
```python
except (RedmineError, N8nError) as exc:
self.send_json({"error": str(exc)}, 400)
```
(`do_POST` は `except (RedmineError, N8nError, ValueError) as exc:`)
`Handler.api` の `if route == "/api/redmine/config":` の前に:
```python
if route == "/api/n8n/config":
return self.send_json(self.server.n8n.public())
if route == "/api/n8n/blocks":
return self.send_json(n8n_blocks(store, self.server.n8n, query))
if route == "/api/redmine/search":
return self.send_json({"issues": self.server.redmine.client.search(query.get("q", [""])[0])})
```
`Handler.api_post` の先頭 (`redmine = ...` の後) に:
```python
if route == "/api/n8n/config":
return self.send_json(self.server.n8n.update(payload))
if route == "/api/n8n/send":
return self.send_json(n8n_send(self.server.store, self.server.n8n, payload))
```
`main()` に:
```python
parser.add_argument("--n8n-config", default=str(N8N_CONFIG), help="n8n 連携の設定ファイル")
```
と、`server.redmine = RedmineHolder(args.redmine_config)` の次の行に:
```python
server.n8n = N8nHolder(args.n8n_config)
```
- [ ] **Step 6: 通ることを確かめる**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: 28 件すべて PASS。
- [ ] **Step 7: 検証用サーバーで API を確かめる**
1. 一時設定を作る (合言葉は入れない): スクラッチパッドに `n8n-test.json` = `{"url":"http://127.0.0.1:9","gapMinutes":30,"minMinutes":5}`
2. `cd /c/debug/chktime && python chktime.py --port 8799 --no-browser --n8n-config "<スクラッチパッド>/n8n-test.json"` をバックグラウンドで起動
3. `curl -s http://127.0.0.1:8799/api/n8n/config` → `hasToken: false`、`token` のキーが無い
4. `curl -s "http://127.0.0.1:8799/api/n8n/blocks?from=$(date +%F)&to=$(date +%F)"` → `blocks` 配列と `n8nError: "合言葉が未設定です"`
5. `curl -s "http://127.0.0.1:8799/api/n8n/blocks?from=2026-01-05&to=2026-01-01"` → HTTP 400 `{"error":"開始日が終了日より後です"}`
6. 8799 番のプロセスだけを止める (`stop.cmd` は使わない。`netstat -ano | grep 8799` の PID を `taskkill //PID <pid> //F`)
- [ ] **Step 8: コミット**
```bash
cd /c/debug/chktime && git add chktime.py tests/test_n8n.py && git commit -m "n8n 連携: 区間の一覧・送信・設定の API と Redmine のチケット検索を追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: 「n8n へ送る」タブの画面
**Files:**
- Create: `web/n8n.js`
- Modify: `web/index.html`、`web/app.js` (`syncTabs`、`setView`、`selectProject`、`updateCsv`、`refreshFollow`、resize、タブの onclick)、`web/style.css`
**Interfaces:**
- Consumes: app.js の `$`、`state`、`api(path)`、`post(path, payload)`、`esc`、`fmtTime(iso)`、`shortPath(path)`、`setView(view)`。API は Task 6。
- Produces: グローバル関数 `loadN8n({silent})` と `renderN8n()` (app.js から呼ぶ)。
- [ ] **Step 1: index.html にタブとスクリプトを足す**
`<button class="tab" id="tab-gantt">ガント</button>` の次の行に:
```html
<button class="tab" id="tab-n8n" title="Claude の作業区間を n8n の work_log に送る">n8n へ送る</button>
```
`<script src="app.js"></script>` の次の行に:
```html
<script src="n8n.js"></script>
```
- [ ] **Step 2: app.js に分岐を足す**
`syncTabs` の最後に:
```js
$('#tab-n8n').classList.toggle('sel', state.view === 'n8n');
```
`updateCsv` の `if (state.view === 'daily') {` の前に:
```js
if (state.view === 'n8n') {
link.removeAttribute('href');
return;
}
```
`setView` の `else if (view === 'gantt') loadGantt();` の次に:
```js
else if (view === 'n8n') loadN8n();
```
`selectProject` の `else if (state.view === 'gantt') loadGantt();` の次に:
```js
else if (state.view === 'n8n') loadN8n();
```
`refreshFollow` の `else if (state.view === 'gantt') await loadGantt({ silent: true });` の次に (編集中の入力を消さないよう、自動追従では読み直さない):
```js
else if (state.view === 'n8n') { /* 自動追従では読み直さない */ }
```
resize の `if (state.view === 'turns') return;` を:
```js
if (state.view === 'turns' || state.view === 'n8n') return;
```
タブの onclick (`$('#tab-gantt').onclick = ...` の次) に:
```js
$('#tab-n8n').onclick = () => setView('n8n');
```
- [ ] **Step 3: web/n8n.js を書く**
```js
'use strict';
// 「n8n へ送る」タブ: Claude のセッションから作った作業区間を選び、n8n の work_log に送る。
// app.js の後に読み込む ($ / state / api / post / esc / fmtTime / shortPath を使う)。
const N8N_NAMES_KEY = 'chktime.n8n.names'; // セッション ID -> {task, memo} (ブラウザに覚える)
const n8 = {
cfg: null, // /api/n8n/config (合言葉は含まれない)
from: '',
to: '',
data: null, // /api/n8n/blocks
checked: new Set(), // 送る区間の source
edits: new Map(), // source -> {task, memo}
sameSession: true, // 同じセッションの他の区間にも入れる
settings: false,
busy: false,
result: null, // 直前の送信結果
pick: null, // チケットを選んでいる区間の source
issues: [],
pickMsg: '',
};
function n8LoadNames() {
try {
return JSON.parse(localStorage.getItem(N8N_NAMES_KEY) || '{}') || {};
} catch (e) {
return {};
}
}
function n8SaveName(sessionId, value) {
try {
const all = n8LoadNames();
all[sessionId] = value;
localStorage.setItem(N8N_NAMES_KEY, JSON.stringify(all));
} catch (e) {
/* 保存できなくても動く */
}
}
const n8CanSend = (b) => !b.sent && !b.inProgress;
const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length;
function n8Query() {
const q = new URLSearchParams();
if (state.project) q.set('project', state.project);
if (n8.from) q.set('from', n8.from);
if (n8.to) q.set('to', n8.to);
return q.toString();
}
async function loadN8n({ silent = false } = {}) {
$('#detail-title').textContent = 'n8n へ送る';
if (!silent) $('#detail-body').innerHTML = '<p class="loading">区間を計算中…</p>';
try {
n8.cfg = await api('/api/n8n/config');
if (!n8.cfg.url || !n8.cfg.hasToken) n8.settings = true;
n8.data = await api('/api/n8n/blocks?' + n8Query());
n8.from = n8.data.from;
n8.to = n8.data.to;
const names = n8LoadNames();
n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source));
for (const b of n8.data.blocks) {
if (n8.edits.has(b.source)) continue;
const saved = names[b.sessionId] || {};
n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' });
}
renderN8n();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
function n8Status(b) {
const marks = [];
if (b.sent) marks.push('<span class="n8-mark sent">送信済み</span>');
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
if ((b.overlaps || []).length) {
const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n');
marks.push(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
}
return marks.join(' ');
}
function n8Settings() {
const c = n8.cfg || {};
return `<div class="n8-settings">
<h4>接続設定 <span class="note">${esc(c.configPath || '')}</span></h4>
<label>URL <input id="n8-url" value="${esc(c.url || '')}" placeholder="https://n8n.yfox.jp"></label>
<label>合言葉 <input id="n8-token" type="password" placeholder="${c.hasToken ? '設定済み (変えるときだけ入力)' : '未設定'}"></label>
<label class="chk"><input type="checkbox" id="n8-verify" ${c.verifySsl ? 'checked' : ''}> 証明書を検証する</label>
<label>区切る空き (分) <input id="n8-gap" type="number" min="1" max="240" value="${c.gapMinutes ?? 30}"></label>
<label>最短 (分) <input id="n8-min" type="number" min="0" max="240" value="${c.minMinutes ?? 5}"></label>
<button id="n8-save">保存</button>
</div>`;
}
function n8ResultHtml() {
const r = n8.result;
if (!r) return '';
const errs = (r.errors || []).map((e) => `<li>${esc(e.source)}: ${esc(e.error)}</li>`).join('');
const skips = (r.skipped || []).map((s) => `<li>${esc(s.source)}: ${esc(s.reason)}</li>`).join('');
return `<div class="n8-result ${r.ok ? 'ok' : 'ng'}">
追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件
${skips ? `<ul>${skips}</ul>` : ''}${errs ? `<ul class="err-inline">${errs}</ul>` : ''}
</div>`;
}
function n8PickHtml() {
if (!n8.pick) return '';
const rows = n8.issues
.map((i) => `<li><button class="n8-issue" data-id="${i.id}">#${i.id}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
.join('');
return `<div class="n8-pick">
<h4>Redmine のチケットを選ぶ</h4>
<input id="n8-q" placeholder="件名の一部、または番号"> <button id="n8-find">検索</button> <button id="n8-pick-close">閉じる</button>
<span class="note">${esc(n8.pickMsg)}</span>
<ul>${rows}</ul>
</div>`;
}
function renderN8n() {
const d = n8.data;
if (!d) return;
const blocks = d.blocks;
const on = blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b));
const rows = blocks
.map((b) => {
const e = n8.edits.get(b.source) || { task: b.taskName, memo: '' };
return `<tr data-src="${esc(b.source)}" class="${b.sent ? 'n8-sent' : ''}">
<td><input type="checkbox" class="n8-on" ${n8.checked.has(b.source) && n8CanSend(b) ? 'checked' : ''} ${n8CanSend(b) ? '' : 'disabled'}></td>
<td class="time">${esc(b.date)}</td>
<td class="time">${fmtTime(b.start)}–${fmtTime(b.end)}</td>
<td class="num">${b.minutes} 分</td>
<td class="n8-pj" title="${esc(b.projectPath)}">${esc(shortPath(b.projectPath))}</td>
<td><input class="n8-task" value="${esc(e.task)}"> <input class="n8-memo" value="${esc(e.memo)}" placeholder="メモ"></td>
<td><button class="n8-choose">選ぶ</button></td>
<td>${n8Status(b)}</td>
</tr>`;
})
.join('');
$('#detail-body').innerHTML = `<div class="n8">
<div class="n8-bar">
<label>期間 <input type="date" id="n8-from" value="${esc(n8.from)}"> – <input type="date" id="n8-to" value="${esc(n8.to)}"></label>
<button id="n8-reload">読み直す</button>
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
<span class="spacer"></span>
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
<button id="n8-send" class="go" ${d.n8nError || n8.busy || !on.length ? 'disabled' : ''}>選んだ ${on.length} 件を送る</button>
</div>
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
${n8.settings ? n8Settings() : ''}
${n8ResultHtml()}
${n8PickHtml()}
<p class="note">区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件</p>
<table id="n8n-table"><thead><tr><th>送る</th><th>日付</th><th>時刻</th><th>時間</th><th>プロジェクト</th><th>タスク名 / メモ</th><th>チケット</th><th>状態</th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
</div>`;
n8Wire();
}
function n8Block(source) {
return n8.data.blocks.find((b) => b.source === source);
}
/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */
function n8SetEdit(source, patch) {
const b = n8Block(source);
const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b];
for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch });
n8SaveName(b.sessionId, n8.edits.get(source));
}
function n8Wire() {
$('#n8-reload').onclick = () => {
n8.from = $('#n8-from').value;
n8.to = $('#n8-to').value;
loadN8n();
};
$('#n8-same').onchange = (e) => {
n8.sameSession = e.target.checked;
};
$('#n8-toggle').onclick = () => {
n8.settings = !n8.settings;
renderN8n();
};
$('#n8-send').onclick = n8Send;
if (n8.settings) $('#n8-save').onclick = n8SaveSettings;
if (n8.pick) {
$('#n8-find').onclick = n8Find;
$('#n8-q').onkeydown = (e) => {
if (e.key === 'Enter') n8Find();
};
$('#n8-pick-close').onclick = () => {
n8.pick = null;
renderN8n();
};
document.querySelectorAll('.n8-issue').forEach((btn) => {
btn.onclick = () => {
const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id);
n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` });
n8.pick = null;
renderN8n();
};
});
}
document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => {
const source = tr.dataset.src;
tr.querySelector('.n8-on').onchange = (e) => {
if (e.target.checked) n8.checked.add(source);
else n8.checked.delete(source);
renderN8n();
};
tr.querySelector('.n8-task').onchange = (e) => {
n8SetEdit(source, { task: e.target.value });
renderN8n();
};
tr.querySelector('.n8-memo').onchange = (e) => {
n8SetEdit(source, { memo: e.target.value });
};
tr.querySelector('.n8-choose').onclick = () => {
n8.pick = source;
n8.issues = [];
n8.pickMsg = '';
renderN8n();
};
});
}
async function n8Find() {
n8.pickMsg = '検索中…';
const q = $('#n8-q').value;
renderN8n();
try {
n8.issues = (await api('/api/redmine/search?q=' + encodeURIComponent(q))).issues;
n8.pickMsg = n8.issues.length ? '' : '見つかりません';
} catch (e) {
n8.pickMsg = e.message;
}
renderN8n();
$('#n8-q').value = q;
}
async function n8SaveSettings() {
const patch = {
url: $('#n8-url').value,
verifySsl: $('#n8-verify').checked,
gapMinutes: Number($('#n8-gap').value),
minMinutes: Number($('#n8-min').value),
};
const token = $('#n8-token').value;
if (token) patch.token = token;
try {
n8.cfg = await post('/api/n8n/config', patch);
n8.settings = false;
loadN8n();
} catch (e) {
alert('保存できません: ' + e.message);
}
}
async function n8Send() {
const items = n8.data.blocks
.filter((b) => n8.checked.has(b.source) && n8CanSend(b))
.map((b) => ({ project: b.project, source: b.source, ...(n8.edits.get(b.source) || { task: b.taskName, memo: '' }) }));
if (!items.length) return;
n8.busy = true;
renderN8n();
try {
n8.result = await post('/api/n8n/send', { items });
} catch (e) {
n8.result = { ok: false, inserted: [], skipped: [], errors: [{ source: '', error: e.message }] };
}
n8.busy = false;
await loadN8n({ silent: true });
}
```
- [ ] **Step 4: スタイルを足す** (`web/style.css` の末尾)
```css
/* n8n へ送る */
.n8 { padding: 8px 12px; }
.n8-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
.n8-bar .spacer { flex: 1; }
.n8 .go { border-color: var(--work); color: var(--work); }
.n8 .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; }
.n8 .note { color: var(--dim); font-size: 11px; }
.n8 .err-inline { color: #f7768e; font-size: 11px; }
.n8-settings, .n8-pick, .n8-result { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 8px 0; }
.n8-settings label { display: inline-flex; gap: 4px; align-items: center; margin-right: 12px; }
.n8-result.ok { border-color: var(--work); }
.n8-result.ng { border-color: #f7768e; }
.n8-pick ul { list-style: none; padding: 0; margin: 6px 0 0; max-height: 200px; overflow: auto; }
#n8n-table th { text-align: left; font-size: 11px; color: var(--dim); padding: 4px 6px; border-bottom: 1px solid var(--line); }
#n8n-table td { padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; }
#n8n-table .time, #n8n-table .num { white-space: nowrap; font-variant-numeric: tabular-nums; }
#n8n-table .num { text-align: right; }
#n8n-table .n8-task { width: 22em; }
#n8n-table .n8-memo { width: 10em; }
#n8n-table tr.n8-sent td { color: var(--dim); }
.n8-mark { display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 10.5px; border: 1px solid var(--line); }
.n8-mark.sent { color: var(--work); border-color: var(--work); }
.n8-mark.warn { color: var(--warn); border-color: var(--warn); }
```
- [ ] **Step 5: 検証用サーバーで画面を確かめる**
1. `n8n.json` (利用者が Task 2 で作ったもの) を使い、`python chktime.py --port 8799 --no-browser` をバックグラウンドで起動。
2. 組み込みブラウザで `http://127.0.0.1:8799/` を開き、「n8n へ送る」タブを押す。
3. 確かめること (`read_page` / `javascript_tool` で):
- 直近 7 日の区間の表が出て、件数の表示と行数が合う
- 送信済み・進行中の行のチェックが無効、重なりのある行は最初チェックが外れている
- タスク名を変えると、「同じセッションの区間にも入れる」が on のとき同じセッションの他の行も変わる。ページを読み直しても残る
- 「接続設定」で合言葉の欄が空で、placeholder が「設定済み…」になっている (値は出ない)
- 「選ぶ」→ 件名で検索 → 結果をクリックすると、タスク名が `[プロジェクト] #番号 題名` になる
- 期間を逆にして「読み直す」と、エラーの文言が出る
- **「送る」は押さない** (実データを送るのは Task 8)
4. 8799 番のプロセスだけを止める。
- [ ] **Step 6: コミット**
```bash
cd /c/debug/chktime && git add web/n8n.js web/index.html web/app.js web/style.css && git commit -m "n8n 連携: 作業区間を選んで送る「n8n へ送る」タブを追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: 実際の区間を 1 件送る確認と文書
**Files:**
- Modify: `README.md`、`CLAUDE.md` (自分の変更だけステージする)
- 保管庫: `C:\debug\obsidian\10_プロジェクト\chktime\ナレッジ\`、`C:\debug\obsidian\00_共通ナレッジ\運用・手順\n8n(n8n.yfox.jp)のMCP接続.md`
**Interfaces:**
- Consumes: Task 1〜7 のすべて。
- [ ] **Step 1: 送る区間を利用者に選んでもらう (ここで止まって待つ)**
8799 の画面で一覧を見せ、本当に `work_log` に入れてよい区間を 1 件選んでもらう。タスク名も利用者に決めてもらう。
- [ ] **Step 2: 送って確かめる**
1. 選ばれた 1 件だけチェックして「送る」→ 結果が `追加 1 件`。
2. もう一度「読み直す」→ その行が「送信済み」になり、チェックできない。
3. `curl -sk https://n8n.yfox.jp/webhook/home | grep -o '<タスク名の一部>'` でダッシュボードに出ること。
4. 日報: `curl -sk -X POST -H "Content-Type: application/json" -d '{"date":"<その区間の日付>","tasks":[]}' https://n8n.yfox.jp/webhook/work-report-final | grep -o '<タスク名の一部>'`
5. 同じ区間をもう一度送れないこと (チェックが無効) を画面で確かめる。
- [ ] **Step 3: README.md に使い方を足す**
「Redmine へ起票」の節の後に、次の節を足す:
```markdown
## n8n へ送る
右ペインの **n8n へ送る** タブで、Claude のセッションから作った「作業区間」を
n8n の work-tracker (`work_log`) に送れます。手で作業開始・終了を記録しなくても済むようにするための機能です。
- **作業区間**: 同じセッションの中で、応答が終わってから次の発言までの空きが 30 分未満ならつなぎ、
30 分以上で区切ります。5 分未満の区間は出しません (どちらも接続設定で変更可)。
まだ応答中・ログ更新から 30 分以内の最後の区間は「進行中」で、送れません。
- **タスク名**: 初期値は `[作業フォルダ名] セッションのタイトル`。「選ぶ」で Redmine のチケットを検索して選ぶと
`[Redmine のプロジェクト名] #番号 題名` になり、work-tracker の Redmine 工数同期とガントの実績に載ります。
決めた名前はセッションごとにブラウザが覚え、同じセッションの他の区間にも入ります (切り替え可)。
- **重なり**: すでに work_log にある記録や、一覧の他の区間と時間が重なる区間には印が付き、最初は送らない状態です。
- **二重登録の防止**: 区間ごとの `source` (`chktime:<セッションID>:<開始時刻>`) を work_log に保存し、
n8n 側で同じ `source` は追加しません。送信済みの区間は選べません。
ただし 2 つの画面から同時に送ると二重に入ることがあります (確認と追加が一度の処理ではないため)。
- **設定**: `n8n.json` (git 管理外。`--n8n-config` で場所を変更可)。
`{"url": "https://n8n.yfox.jp", "token": "<合言葉>", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}`
合言葉は n8n の Header Auth 認証情報 (`X-WT-Token`) と同じ値。画面とログには出しません。
- 送り先は n8n のワークフロー `work-tracker--import` (`/webhook/wt-import-rows` と `/webhook/wt-import`)。
```
起動オプションの表に `| --n8n-config | n8n.json | n8n 連携の設定ファイル |` を足し、冒頭の「外部への通信は…」の文を「外部への通信は Redmine へ起票と n8n へ送るを使ったときだけです」に変える。
- [ ] **Step 4: CLAUDE.md を直す (自分の変更だけステージ)**
- 「外部通信は Redmine 連携だけ」の行を次に変える:
`- **外部通信は Redmine 連携と n8n への送信だけ**。それ以外はローカルの jsonl を読むだけで、どこにも接続しない。`
- 構成の表に `web/n8n.js 「n8n へ送る」タブ` と `n8n.json n8n 連携の設定(自動生成・git 管理外)` と `tests/ unittest (python -m unittest discover -s tests -v)` を足す。
- 「n8n 連携」の表を API の節に足す:
```markdown
n8n 連携(work_log への取り込み):
| ルート | 内容 |
| --- | --- |
| `GET/POST /api/n8n/config` | 接続設定の取得(合言葉は返さない)/ 保存 |
| `GET /api/n8n/blocks?from=&to=&project=` | 作業区間の一覧。n8n の送信済み・削除済み・重なりを付ける |
| `POST /api/n8n/send` | 選んだ区間を送る。時刻はサーバーがログから計算し直す |
| `GET /api/redmine/search?q=` | チケット選択用の検索(未完了のみ) |
```
ステージは `git add -p CLAUDE.md` で、今回足した行の hunk だけ `y` にする (利用者の「ナレッジ保管庫」の節は `n`)。対話できない環境では、`git diff CLAUDE.md > <スクラッチパッド>/claude.diff` から自分の hunk だけのパッチを作って `git apply --cached` する。
- [ ] **Step 5: テストを全部流してコミット**
Run: `cd /c/debug/chktime && python -m unittest discover -s tests -v`
Expected: 28 件すべて PASS。
```bash
cd /c/debug/chktime && git add README.md && git commit -m "n8n へ送る機能の使い方と通信の決まりを文書に追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(`CLAUDE.md` は Step 4 でステージ済みの分だけが一緒に入る。`git show --stat HEAD` で確認)
- [ ] **Step 6: 保管庫に記録する**
- `10_プロジェクト\chktime\ナレッジ\n8nへ作業区間を送る仕組み.md` (ナレッジのテンプレート): 区間の作り方、`source` の形、n8n の受け口 2 本と合言葉 (Header Auth `X-WT-Token`)、同時送信の注意、テストの流し方。
- chktime の `_index.md` のナレッジに 1 行追加。chktime の `CLAUDE.md` の「このプロジェクトの記録」表にも 1 行 (利用者の未コミットの節なので、追記は利用者に伝えるだけにして、ファイルの編集はしない)。
- `00_共通ナレッジ\運用・手順\n8n(n8n.yfox.jp)のMCP接続.md` の注意点に: データテーブルの列追加は公開 API ではできず、n8n-mcp の `addColumn` に `N8N_MCP_ACCESS_TOKEN` が要ること (Task 1 で実際にどうなったかを書く)。
- [ ] **Step 7: 仕上げ**
`superpowers:finishing-a-development-branch` に従って、ブランチの扱い (Gitea への push と PR) を利用者と決める。