# 指示書Y — DB の DATETIME を「日本時間の壁時計値」として読む（画面の時刻が9時間進む不具合を直す）

| 項目 | 内容 |
|---|---|
| 実行環境 | ★**Cursor 内の Claude が実装します**★（`.claude/commands/d-watch.md` の役割分担表を参照） |
| 実行状態 | ★**完了（D-115。2026-09-11）**★ |
| 実行順 | ★★**指示書X（`docs/handoff/指示書X_precheckを分数そのものとして読む.md`）の完了後に実行すること**★★。★★**指示書W（`docs/handoff/指示書W_連続問題施設を実DBから作る.md`）より先に実行することを推奨**★★（★理由は下の「実行順」節★）。★★**3本を同時に走らせないこと**★★ |
| 対象 | ★★DB の DATETIME に入っているのは**日本時間の壁時計値**だと TeamWorks 第8回 A2 で確定した★★。★ところが Prisma のアダプタがそれを **UTC** とみなして `Date` にし、その上に `toJstIso()` が**さらに9時間足している**★。★★結果、画面の時刻が9時間進み、深夜清掃が「まだ未来」と判定されて画面から消える★★ |
| 取得する決定番号 | ★★**着手直前に `grep -nE "^### D-(0\|1)[0-9][0-9]" docs/decisions.md \| tail -3` で最終番号を実測すること**★★。★★`^### D-0` だけでは3桁台（D-100 以降）を拾えない★★。★2026-09-07 に `D-068` の二重採番が実際に起きています★。★★本書作成時点では **指示書X が D-113 を取り、指示書W がさらに1つ取る見込み**です。★★見込み番号は書きません — 必ず自分で実測してから採番すること★★★ |
| 前提 | ★★**D-106 項8**（`docs/decisions.md:5830` 付近の「別件として切り出したもの — タイムゾーン」。★行番号は着手時に実測すること★。★★本書の出所そのもの★★。★アダプタが `new Date(値 + "Z")` で読むこと / 設定で変えられないこと / `isFutureSchedule()` にも効くことが既に整理されています★）★★ / ★**D-109**（`:6328`。当期の分母から未来を外す。★`isFutureSchedule()` を導入した決定★）★ / ★**D-110**（`:6447`。指示書V の実行記録）★ / ★**D-111**（`:6584`。指示書W の前提）★ / ★**D-112**（`:6706`。TeamWorks 第7回回答の受領）★ / ★★**指示書X が段階6 で追記する記録**（★★着手時に `grep -nE "^### D-" docs/decisions.md \| tail -3` で実測して読むこと★★。★`precheck` が分数そのものになり、`dwatch_local` が0911版DDLから作り直されている★）★★ / **D-042**（`:226`。書き込み許可4カラム）/ **D-050**（`:308`。段階ごとに検証してからコミット）/ **D-056**（`:353`。`schema.prisma` は生成物）/ **D-100**（MariaDB 10.11 とローカル MySQL 8.4 の差）/ **D-101 項2**（`ALLOW_DIRECT_ACCESS`）/ **D-108 項8**（`next dev` は `.env.local` を共有する） |
| 段階数 | ★**7段階**★（段階0〜段階6）。★コミットは段階1 / 2 / 3 / 4 / 6 の**5つ**★ |
| ★着手前確認★ | ★★**なし**★★ — ★DATETIME が日本時間であることは TeamWorks 第8回 A2（2026-09-11 受領）で**明言**されており、二重ずれの機序も監督が実測済みです★。★★ユーザーの回答を待たずに段階0 から通しで実行してよい★★。★★ただし TIMESTAMP 列の扱いだけは**実測で切り分けること**（段階0-4 / 段階3）★★ — ★推測で DATETIME と同じ扱いにしないこと★ |
| 着手前の基準 | ★★**着手直前に自分で `npm test` / `npx tsc --noEmit` / `npm run lint` を実行し、その実測値を基準にすること**★★。★作成時点の参考値（★鵜呑みにしない。指示書X 完了後は変わっています★）: `npm test` = **45ファイル / 941テスト** / `tsc` = **0** / `lint` = **0**★ |
| 作成 | 2026-09-11。★作成時点の決定事項最終番号は **D-113**（指示書X）/ `docs/handoff.md` の最新は **追記34**★。★★着手時に実測すること★★ |

> 実行後はこの表と、文書末尾の「実行状態」表を更新してください。

> ★**この指示書を読んでいるあなたへ**★ — `.claude/commands/d-watch.md` の「実装は Cursor 側」は
> **2つの環境の役割分担**です。**Cursor の中で動いている Claude は実装担当**です。
> ★調査や改訂ではなく実装を行ってください。★
> ★★**うまくいかなければ段階の切れ目（コミット単位）で止めて構いません**★★ —
> ★段階1（変換関数と設定値）→ 段階2（DATETIME 列の読み出し）→ 段階3（TIMESTAMP 列の切り分け）→
> 段階4（シードと再シード）の順に、★どこで止めても `tsc` / `lint` / `test` が緑になる形★に並べてあります★。
> ★★ただし段階2 と段階3 の間はローカルDBの表示が一時的におかしくなります★★（★調査5 に予告してあります★）。
> ★★段階2 または段階3 で止めるなら、その状態を作業メモに残すこと★★（★段階4 の再シードで解消します★）。

---

## ★★冒頭に読むべきこと — 何が起きているか★★

### 1. ★TeamWorks 第8回 A2 の回答（★2026-09-11 受領。原文★）★

```
Q2.DATETIMEのタイムゾーン
A2.JSTです.
mysql> select @@time_zone;        → SYSTEM
      select @@system_time_zone;  → JST
システムが生成する日付時刻はJSTで生成, PUDUから返却される値はUNIX時間なので, JSTで補正して格納する, としています.
control_reports.reported_at, security_status.reported_at, security_status_histories.reported を
timestamp型にしたのは, これらのカラムはオペレータが値を指定し格納する, もしくは
例えば管理画面等でこれらの値を表示する, ということがないためです.
※当方で用意した管理画面では, という話なので, 必ずしもそうではないかもしれません.
```

★★これで「DBの DATETIME には**日本時間の壁時計値**が入る」ことが確定しました。★★

### 2. ★★二重にずれる機序（★これが本書の全部です★）★★

★DBの `clean_timetables.start_at` に `2026-09-11 02:00:00` と入っているとします。★
★これは「日本時間の 2026-09-11 午前2時」を意味します（A2）★。

| # | 誰が | 何をするか | その時点の値 |
|---|---|---|---|
| ★1★ | ★DB★ | ★日本時間の壁時計値を持っている★ | `2026-09-11 02:00:00`（★JSTの2時★） |
| ★2★ | ★`@prisma/adapter-mariadb`★ | ★★`new Date(\`${value}Z\`)` — **UTC とみなして** `Date` にする**★★（`node_modules/@prisma/adapter-mariadb/dist/index.js` の `mapRow()`。★実測して行番号を確かめること★） | ★`Date` = UTC 2026-09-11T02:00Z ＝ ★**JSTでは11:00**★★（★★+9時間ずれた★★） |
| ★3★ | ★`toJstIso()`（`lib/format/date.ts:36`）★ | ★★`getTime() + 9h` を UTC getter で読む★★ | ★`"2026-09-11T11:00:00+09:00"`★（★★合っている。`Date` が既にずれているだけ★★） |
| ★4★ | ★画面★ | ★`formatHm()` が先頭から切り出す★ | ★★`11:00`★★（★★DBの `02:00` が画面で `11:00`★★） |

★★手順2 は設定で変えられません★★ —
★★接続文字列の `timezone` パラメータも効きません★★（★アダプタが `dateStrings: true` でドライバの変換を
迂回し、自前で `new Date(値 + "Z")` しているため。★★実測して自分の目で確かめること★★）。

### 3. ★★表示だけの問題ではない（★ここが本書の重さです★）★★

★`isFutureSchedule()`（`lib/db/clean-log-mapping.ts:263`。★行番号は実測すること★）は
この `Date` を**実時刻**（`new Date()`）と比較します★:

```ts
export function isFutureSchedule(startAt: Date, now: Date = new Date()): boolean {
  return startAt.getTime() > now.getTime();
}
```

★★DBの `02:00`（深夜2時の清掃）は、`Date` としては UTC 02:00 ＝ **JSTの11:00** になっています★★。
★★DFA 運用担当が翌朝8時に画面を開くと、`11:00 > 08:00` なので「まだ未来の予定」と判定され、
`cleanLogs()` と `prechecks()` から**行ごと消えます**★★。

★★これは `CLAUDE.md` §1 の「夜間に自動実行された清掃の結果を、翌朝DFA運用担当が確認する」という
**運用そのもの**を直撃します★★。

### 4. ★★なぜ今までローカルで表面化していないのか★★

★★シードが「9時間引いた値」を書いているからです★★。

- ★`scripts/seed-local-db.ts` は fixtures の ISO文字列（`2026-09-11T02:00:00+09:00`）を
  `new Date()` に通します → ★真の瞬間（UTC 2026-09-10T17:00Z）★★
- ★アダプタの書き込み側 `formatDateTime()` は ★**`getUTC*` で文字列を組み立てます**★
  （★同じファイル。★実測して確かめること★）→ ★DBには `2026-09-10 17:00:00` と入る★
- ★読むときは `new Date("2026-09-10 17:00:00Z")` → ★真の瞬間に戻る★ → ★`toJstIso()` で `02:00+09:00`★

★★往復で辻褄が合っているだけで、DBの中身は本番と**違う形**です★★。
★★だから段階4（シードの作り直し）が要ります★★ — ★★ローカルDBを本番と同じ形にしないと、
「正しく直った」ことを実測で確認できません★★。

---

## ★★採る方針（★理由ごと理解してから実装すること★）★★

### ★決定: 読み出しの境界で1回だけ補正する★

| # | やること |
|---|---|
| ★1★ | ★★`lib/format/date.ts` に `fromDbJst(date: Date): Date`（`getTime() - 9h`）を**新設**する★★ — ★「DBの壁時計値を UTC のフィールドに持っている `Date`」から「真の瞬間を指す `Date`」に直す★ |
| ★2★ | ★★Prisma から出た日時に `fromDbJst()` を通してから既存の処理に渡す★★ |
| ★3★ | ★★`toJstIso()` は**変更しない**★★ — ★既存の4テストがそのまま通る★。★★「`Date` を JST の ISO 文字列にする」という責務は正しく、壊れているのは**入力の `Date`** のほう★★ |
| ★4★ | ★★`isFutureSchedule()` も**変更しない**★★ — ★渡す `Date` が正しくなるので**自動的に直ります**★ |
| ★5★ | ★★9時間を**ハードコードしない**★★ — ★`lib/config/` の設定値にする（`CLAUDE.md` §7）★ |
| ★6★ | ★★書き込み側（シード）には逆変換 `toDbJst()` を用意する★★（★段階1。理由は下の「なぜ `toDbJst()` も要るのか」★） |

### ★★なぜ `toDbJst()` も要るのか★★

★アダプタの書き込み側は `getUTC*` で文字列を作ります★。
★★つまり「DBに書きたい壁時計値」を **UTC のフィールドに載せた `Date`** を渡す必要があります★★ —
★これは `fromDbJst()` のちょうど逆です★。

```
読み: DBの壁時計 → (アダプタ) → UTCフィールドに壁時計を載せたDate → fromDbJst() → 真の瞬間
書き: 真の瞬間 → toDbJst() → UTCフィールドに壁時計を載せたDate → (アダプタ) → DBの壁時計
```

★★`toDbJst()` の消費者は当面 `scripts/seed-local-db.ts` **だけ**です★★
（★アプリからのDB書き込みはまだ1つもありません。★実測: `grep -rn "prisma\.[a-zA-Z]*\.update" --include=*.ts app lib/db` が0件★）。
★★ただし将来 4カラム（`status` / `comment` / `responded_at` / `resolved_at`）の更新を実装するときに
必ず要ります★★ → ★申し送り②★。

### ★★却下した案（★同じ案を再提案しないために、理由ごと残します★）★★

| # | 案 | ★却下した理由★ |
|---|---|---|
| ★1★ | ★`toJstIso()` から `+9h` を外す★ | ★★表示は直るが `isFutureSchedule()` の比較が直らない★★ — ★`Date` は依然として真の瞬間より9時間先のままで、深夜清掃が消える問題は残る★。★★「表示だけ直って判定が壊れたまま」は本書がいちばん避けたい結末です★★ |
| ★2★ | ★接続文字列に `?timezone=Asia/Tokyo` を付ける★ | ★★効かない★★ — ★アダプタが `dateStrings: true` でドライバの変換を迂回し、自前で `new Date(値 + "Z")` している★。★DATETIME は1ミリ秒も変わらない★。★★おまけに TIMESTAMP 列だけがセッション TZ の変更で**逆に狂う**可能性がある★★ |
| ★3★ | ★プロセスの `TZ` 環境変数を設定する★ | ★★効かない★★ — ★アダプタも `toJstIso()` も `getUTC*` しか使っていない★。★`TZ` は `getFullYear()` 等のローカル getter にしか効かない★ |
| ★4★ | ★DB を UTC 格納に変える★ | ★★TeamWorks の領域。DFA からは実施不可★★（`CLAUDE.md` §2 / §7） |
| ★5★ | ★アダプタを差し替える / パッチを当てる★ | ★★依存を増やす・アップグレードで壊れる★★。★★アプリ側で1関数を通すだけで済む話に、外部依存の改変を持ち込まない★★ |

---

## ★★DATETIME と TIMESTAMP は挙動が違う（★推測で同じ扱いにしないこと★）★★

★★ここが本書でいちばん踏みやすい落とし穴です。★★

| 型 | サーバ側の挙動 | 本書での扱い |
|---|---|---|
| ★**DATETIME**★ | ★サーバはタイムゾーン変換を**一切しない**。書いた文字列がそのまま入り、そのまま返る★ | ★★補正が要る（確定）★★ |
| ★**TIMESTAMP**★ | ★★サーバが**セッションの `time_zone`** で解釈して UTC で内部保存し、読み出し時に**セッションの `time_zone`** に戻す★★ | ★★**実測で切り分けること**（段階0-4 / 段階3）★★ |

★★重要な帰結★★:

- ★★明示的に書いた TIMESTAMP は「書いた壁時計値がそのまま返る」★★（★書きも読みも同じセッション TZ なので往復する★）
  → ★この場合は DATETIME と**同じ補正**でよい★
- ★★サーバが生成した TIMESTAMP（`DEFAULT CURRENT_TIMESTAMP` の `created_at` 等）は
  「そのサーバのセッション TZ での現在時刻」になる★★
  → ★★本番（`@@time_zone = SYSTEM` / `@@system_time_zone = JST`）なら JST の壁時計値なので補正が要る★★
  → ★★ローカルの MySQL 8.4 のシステム TZ が JST でなければ、ローカルだけ**別のずれ方**をする★★

★★だから段階0 で `@@time_zone` と `@@system_time_zone` を**必ず実測すること**★★。
★★ローカル検証環境（MySQL 8.4）のシステム TZ が JST であることも実測すること★★ — ★違えば結果が変わります★。

### ★`toJstIso()` を通している TIMESTAMP 列（★実測 2026-09-11。着手時に取り直すこと★）★

| # | 列 | 型 | 経路 | 備考 |
|---|---|---|---|---|
| ★1★ | ★`control_reports.reported_at`★ | ★★`@db.Timestamp(0)`★★（`prisma/schema.prisma` の `model ControlReport`） | ★`dsDevices()` の `last_reported_at`（`lib/db/index.ts:593`）★ | ★★シードが明示的に書いている列なので往復する★★ → ★DATETIME と同じ補正でよい見込み。★実測で確かめること★★ |
| ★2★ | ★`robot_error_reports.created_at`★ | ★★`@db.Timestamp(0)`★★ | ★★`detectedAtFromError()`（`lib/db/precheck-mapping.ts:158`）の `errored_at ?? created_at` **フォールバック**★★ → `lib/db/index.ts:485` / `lib/db/report-mapping.ts:384` | ★★1つの関数が DATETIME と TIMESTAMP を**混ぜて返している**★★。★★ここが本書で最も注意が要る箇所★★（下記） |

★★#2 の注意★★:
- ★`detectedAtFromError()` は `errored_at`（★DATETIME★）が null のときだけ `created_at`（★TIMESTAMP★）を返します★
- ★★ローカルのシードは `errored_at` を必ず入れている（`scripts/seed-local-db.ts:865` 付近。★実測すること★）ので、
  **ローカルではフォールバックが1度も発火しません**★★
- ★★＝ ローカルの実測では #2 の正しさを検証できません★★ → ★★申し送り③に残すこと★★
- ★★2つの型で補正が違うと判明した場合★★は、★★`detectedAtFromError()` の中で列ごとに補正するのではなく、
  **実装を止めて報告すること**★★（★関数の責務が変わる大きな設計判断であり、推測で決めない。`CLAUDE.md` §7★）

### ★参考: 画面に出ていない TIMESTAMP 列★

- ★`security_statuses.reported_at` / `security_status_histories.reported_at`★ …
  ★★A2 が名指ししている列ですが、★現状のアプリコードが `toJstIso()` に通しているかを**実測で確かめること**★★★
  （`grep -rn "security_status" --include=*.ts lib/db`）。★通していなければ本書の範囲外★
- ★各テーブルの `created_at` / `updated_at` / `deleted_at`★ … ★★画面に出していない★★。★本書では触らない★

---

## ★調査済みの事実（★現物で裏を取ってから使うこと。行番号は必ず実測すること★）★

### 1. ★★`toJstIso()` の呼び出し全箇所（★2026-09-11 実測。★着手時に必ず取り直すこと★）★★

```
grep -rn "toJstIso(" --include=*.ts --include=*.tsx . | grep -v node_modules | grep -v ".claude/worktrees"
```

| # | 場所（★行番号は実測すること★） | 元の列 | 型 | 段階 |
|---|---|---|---|---|
| ★1★ | `lib/db/index.ts:338`（`cleanLogs()` の `scheduled_at`） | `clean_timetables.start_at` | ★DATETIME★ | ★段階2★ |
| ★2★ | `lib/db/index.ts:478`（`prechecks()` の `scheduled_at`） | `clean_timetables.start_at` | ★DATETIME★ | ★段階2★ |
| ★3★ | `lib/db/index.ts:485`（`prechecks()` の `detected_at`） | ★`detectedAtFromError()` = `errored_at` ?? `created_at`★ | ★★DATETIME + TIMESTAMP の混在★★ | ★段階2 / 段階3★ |
| ★4★ | `lib/db/index.ts:488`（`responded_at`） | `robot_error_reports.responded_at` | ★DATETIME★ | ★段階2★ |
| ★5★ | `lib/db/index.ts:489`（`resolved_at`） | `robot_error_reports.resolved_at` | ★DATETIME★ | ★段階2★ |
| ★6★ | `lib/db/index.ts:524`（`schedules()` 系の `scheduledAt`） | `clean_timetables.start_at` | ★DATETIME★ | ★段階2★ |
| ★7★ | ★`lib/db/index.ts:593`（`dsDevices()` の `last_reported_at`）★ | ★`control_reports.reported_at`★ | ★★TIMESTAMP★★ | ★★段階3★★ |
| ★8★ | `lib/db/index.ts:635`（`dsAlerts()` の `detected_at`） | `control_error_histories.occurred_at` | ★DATETIME★ | ★段階2★ |
| ★9★ | `lib/db/index.ts:638`（`responded_at`） | `control_error_histories.responded_at` | ★DATETIME★ | ★段階2★ |
| ★10★ | `lib/db/index.ts:639`（`resolved_at`） | `control_error_histories.resolved_at` | ★DATETIME★ | ★段階2★ |
| ★11★ | `lib/db/index.ts:951`（連続問題施設の `scheduled_at`） | `clean_timetables.start_at` | ★DATETIME★ | ★段階2★ |
| ★12★ | ★`lib/db/report-mapping.ts:197`（`toJstIso(row.start_at).slice(0, 10)`）★ | `clean_timetables.start_at` | ★DATETIME★ | ★段階2★ |
| ★13★ | ★`lib/db/report-mapping.ts:384`（`toJstIso(detectedAtFromError(report)).slice(0, 10)`）★ | ★混在（#3 と同じ）★ | ★★DATETIME + TIMESTAMP★★ | ★段階2 / 段階3★ |

★★監督の調査時点では「10箇所・実質12コール」と数えていました★★ — ★★本書作成時の実測は13箇所です★★。
★★どちらも鵜呑みにせず、着手時に自分で数えること★★（★指示書X の完了で行番号は動いています★）。

★`lib/format/__tests__/date.test.ts` / `lib/db/__tests__/report-mapping.test.ts` のヒットはテストです★ — ★★段階2 で見直すこと★★。

### 2. ★★`isFutureSchedule()` の呼び出し全箇所（★実測して取り直すこと★）★★

```
grep -rn "isFutureSchedule(" --include=*.ts . | grep -v node_modules | grep -v ".claude/worktrees"
```

| # | 場所（★行番号は実測すること★） | 渡している値 | 型 |
|---|---|---|---|
| ★1★ | `lib/db/index.ts:292`（`cleanLogs()` の本清掃行の絞り込み） | `row.start_at` | ★DATETIME★ |
| ★2★ | `lib/db/index.ts:507`（`prechecks()`） | `row.start_at` | ★DATETIME★ |
| ★3★ | `lib/db/index.ts:947`（連続問題施設） | `row.start_at` | ★DATETIME★ |
| ★4★ | `lib/db/report-mapping.ts:204`（当期の分母から未来を外す。D-109） | `row.start_at` | ★DATETIME★ |

★★4箇所すべてが `clean_timetables.start_at`（DATETIME）です★★ → ★★段階2 で片付きます★★。

### 3. ★★日付の絞り込みが「文字列の先頭10文字」になっている箇所★★

★★これらは `scheduled_at`（＝ `toJstIso()` の**出力**）を見ているので、段階2 で**自動的に直ります**★★。
★★手を入れないこと★★ — ★入れると二重補正になります★。

| # | 場所（★実測すること★） | 何をしているか |
|---|---|---|
| ★1★ | `lib/prechecks/query.ts:98`（`filterPrechecksByDate()`） | `item.scheduled_at.slice(0, 10)` を `dateFrom` / `dateTo` と比較 |
| ★2★ | `app/api/dashboard/route.ts:75` | `row.scheduled_at.slice(0, 10) === date` |
| ★3★ | `lib/db/clean-log-mapping.ts:327`（`foldProblemDaysByDate()`） | 連続日数の日付バケット |
| ★4★ | `lib/db/clean-log-mapping.ts:384` | 同上 |
| ★5★ | `lib/db/report-mapping.ts:291` | 期間集計の日付バケット |
| ★6★ | ★`lib/db/report-mapping.ts:197` / `:384`★ | ★★`toJstIso(...)` を**直接**呼んでから `slice(0, 10)` している★★ → ★★これは段階2 で直す（調査1 の #12・#13）★★ |

★★9時間ずれると日付バケットが翌日に移ります★★ —
★★実測で深夜清掃は 01:30〜03:00 に集中しているので、**全部が翌日扱いになっていました**★★。

### 4. ★★`app/api/dashboard/route.ts` の既定日はずれない（★だから食い違っていた★）★★

- ★`todayJst()`（`lib/format/date.ts:19`）は `Intl.DateTimeFormat` で `Asia/Tokyo` を指定しており、★**本物のJST**★★
- ★★＝ 既定表示の「今日」は正しく、行の日付だけが翌日にずれていた★★
- ★★段階2 のあと、この食い違いが消えることを段階5 で確認すること★★

### 5. ★★週・月の帰属（`CLAUDE.md` §4.7）★★

- ★★月境界で1日ずれると「月」まで変わります★★ — ★8月31日 23:30 の清掃が9月1日扱いになると、月次サマリの帰属が変わる★
- ★`lib/format/period.ts` の `weekOfMonth()` は ★**YYYY-MM-DD 文字列**を受け取る★ので、
  ★★入力の日付文字列が正しくなれば自動的に直ります★★。★★`period.ts` は1行も変更しないこと★★
- ★★段階1 の期待値表に「月をまたぐケース（月末の深夜）」を必ず入れること★★

### 6. ★★ずれない場所（★直そうとしないこと★）★★

| # | 場所 | なぜずれないか |
|---|---|---|
| ★1★ | ★`robotStatusFromErrors()`（`lib/db/clean-log-mapping.ts:57`）の `errored_at` による並べ替え★ | ★★DB 由来の値どうしの**相対比較**★★。★両方が同じだけずれているので順序は変わらない★ |
| ★2★ | ★`lib/db/clean-log-mapping.ts:221` の `current.ended_at < history.ended_at`★ | ★同上（履歴を1件に絞る比較）★ |
| ★3★ | ★`lib/format/period.ts` 全体★ | ★★`Date` を「UTC上の日数計算機」としてしか使っておらず、DBの値を受け取らない★★ |
| ★4★ | ★`todayJst()`★ | ★`Intl` で `Asia/Tokyo` 指定（調査4）★ |
| ★5★ | ★`lib/fixtures/` 全体★ | ★★ISO文字列（`+09:00` 付き）を直接持っており、DBを経由しない★★。★★`USE_FIXTURES=true` の挙動は1つも変わらないのが正しい★★ |
| ★6★ | ★`lib/judge/` 全体★ | ★日時を扱わない★ |

### 7. ★★シードの日時の入り口（★段階4 で直す場所★。実測すること★）★★

★シードが `Date` を作っている「根っこ」は6箇所だけです★ —
★★そこを直せば、`addMinutes()` で派生している値（`end_at` / `precheck` 行の `start_at` /
`errored_at` / `responded_at` / `resolved_at` / `started_at` / `ended_at`）は**すべて自動的に追従します**★★。

| # | 場所（★行番号は実測すること★） | 現在のコード | 派生先 |
|---|---|---|---|
| ★1★ | `scripts/seed-local-db.ts:687` | `const latestReportedAt = new Date(device.last_reported_at);` | ★`control_reports.reported_at` 2箇所（`:697` / `:709`）★ |
| ★2★ | `:730` | `occurred_at: new Date(alert.detected_at)` | ★—★ |
| ★3★ | `:731` | `responded_at: alert.responded_at ? new Date(alert.responded_at) : null` | ★—★ |
| ★4★ | `:732` | `resolved_at: alert.resolved_at ? new Date(alert.resolved_at) : null` | ★—★ |
| ★5★ | `:784` | `const startAt = new Date(log.scheduled_at);` | ★★`clean_timetables.start_at` / `end_at` / 事前チェック行 / `robot_error_reports` の3列 / `clean_statuses` / `clean_status_histories`★★ |
| ★6★ | `:976` | `const startAt = new Date(at(item.daysBack, item.time));` | ★施設7の検証データ（指示書V 段階6）★ |

★★`addMinutes()`（`:363`）には手を付けないこと★★ — ★ミリ秒の足し算であり、補正とは無関係★。

### 8. ★落とし穴（★踏むとテストが通ったまま壊れます★）★

| # | 落とし穴 |
|---|---|
| ★1★ | ★★`toJstIso()` を書き換えること★★ — ★既存4テストが落ちます★。★★壊れているのは `toJstIso()` ではなく**入力の `Date`**★★。★方針3★ |
| ★2★ | ★★`isFutureSchedule()` を書き換えること★★ — ★★渡す `Date` が正しくなれば自動的に直ります★★。★中で補正すると、正しい `Date` を渡すテスト（`lib/db/__tests__/clean-log-mapping.test.ts:117-148`）が壊れます★ |
| ★3★ | ★★`9` / `9 * 60 * 60 * 1000` / `32400000` をコードに直書きすること★★ — ★`CLAUDE.md` §7★。★★`lib/config/` の設定値から読むこと★★ |
| ★4★ | ★★`jstIsoFromDb()` を「UTCフィールドをそのまま文字列に組み立てる」実装で書くこと★★ — ★★答えは合いますが、9時間が式から消えて設定値が効かなくなります★★。★必ず `toJstIso(fromDbJst(date))` の合成として書くこと★ |
| ★5★ | ★★調査3 の「文字列の先頭10文字」箇所に補正を足すこと★★ — ★★二重補正になります★★。★あそこが見ているのは補正済みの出力★ |
| ★6★ | ★★DATETIME と TIMESTAMP に**推測で**同じ補正を掛けること★★ — ★★段階0 で実測してから決めること★★ |
| ★7★ | ★★`detectedAtFromError()` の中で列ごとに補正を分けること★★ — ★2つの型で補正が違うと判明したら**止めて報告する**（上記「#2 の注意」）★ |
| ★8★ | ★★`lib/fixtures/` を1行でも変えること★★ — ★fixtures は ISO文字列を直接持っており、DBを経由しません★。★★本書の変更の影響を受けないのが正しい★★ |
| ★9★ | ★★シードの本ループに行を差し込むこと・行の順序を変えること★★ — ★`historyIndex` / `errorReportCount` の通番の剰余で `clean_error` と対応状況を決定的に散らしています★。★★本書は**値だけ**を変えます★★ |
| ★10★ | ★★シードで乱数を使うこと・id を暗黙採番にすること★★ — ★冪等が壊れます★。★★2回流して同じ結果になることを実測すること★★ |
| ★11★ | ★★`prisma/schema.prisma` を書き換えること★★ — ★★本書はスキーマを1文字も変えません★★（`CLAUDE.md` §7 / D-056）。★`db pull` も実行しません★ |
| ★12★ | ★★接続文字列に `timezone` を足すこと・`TZ` 環境変数を設定すること★★ — ★★却下案2・3★★。★効かないうえ TIMESTAMP を狂わせます★ |
| ★13★ | ★★`lib/format/date.ts` の `formatMd()` / `formatHm()` / `todayJst()` / `inclusiveDayCount()` を変えること★★ — ★★範囲外。1文字も変えない★★ |
| ★14★ | ★★一時スクリプトを `main().catch(...)` だけで終わらせること★★ — ★★成功時にも必ず `process.exit(0)` を書くこと★★（★Prisma の接続が残り、★★実際に4時間39分ハングした事故が起きています★★） |

---

## ★禁止事項（この指示書の全段階に効きます）★

| # | ★してはいけないこと★ | 理由 |
|---|---|---|
| ★1★ | ★★**`git add -A` を使うこと**★★ | `CLAUDE.md` §7。★過去に別作業の途中ファイルを巻き込む事故が起きている★。★★必ずファイルパスを明示すること★★ |
| ★2★ | ★★**`README.md` / `docs/code-map.md` / `docs/handoff/管理画面_設計検討_20260910.md` / `docs/handoff/指示書W_連続問題施設を実DBから作る.md` / `docs/handoff/指示書X_precheckを分数そのものとして読む.md` / `.claude/worktrees/**` に触ること**★★ | ★★すべて別作業のもの★★。★★読むのは自由。編集・削除・コミットへの同梱は禁止★★ |
| ★3★ | ★★**メインDBのスキーマ変更（`prisma migrate` / `db push` / `prisma/schema.prisma` の手書き）**★★ | `CLAUDE.md` §7 / D-056 / D-059。★★本書はスキーマ変更を1つも作りません。`db pull` も実行しません★★ |
| ★4★ | ★★**書き込み許可4カラム（`status` / `comment` / `responded_at` / `resolved_at`）以外への DB 書き込み**★★ | D-042。★段階4 の投入は `scripts/seed-local-db.ts`（ローカル検証用シード）からであり、アプリの実行時書き込みではない★。★★メインDB / `dwatch_ddl` に接続しないこと★★ |
| ★5★ | ★★**9時間（`9` / `540` / `32400000` 等）をコードやテストに直書きすること**★★ | `CLAUDE.md` §7 / 落とし穴3。★★`lib/config/` の設定値から読む★★ |
| ★6★ | ★★**`lib/fixtures/` を1行でも変更すること**★★ | `CLAUDE.md` §9.5 / 落とし穴8 |
| ★7★ | ★★**DATETIME と TIMESTAMP を推測で同じ扱いにすること**★★ | ★★段階0 で実測して切り分けること★★（落とし穴6）。`CLAUDE.md` §7「仕様が不明な箇所を推測で実装すること」の禁止 |
| ★8★ | ★★**`toJstIso()` / `isFutureSchedule()` / `todayJst()` / `formatMd()` / `formatHm()` / `inclusiveDayCount()` / `lib/format/period.ts` を変更すること**★★ | 方針3・4 / 落とし穴1・2・13 |
| ★9★ | ★★**接続文字列に `timezone` を足すこと / `TZ` 環境変数を設定すること / アダプタにパッチを当てること**★★ | ★★却下案2・3・5★★ |
| ★10★ | ★★**`d-watch-api.yaml` / `lib/types/` を変えること**★★ | ★★変える必要がありません★★ — ★API の値は今も ISO8601（JST, +09:00）で、★**形式は変わらず、中身の数字が正しくなるだけ**★★。★変えたくなったら、それは本書の解釈が間違っているサインなので**止めて確認を求めること**★ |
| ★11★ | ★★**テストを DB 依存にすること**★★ | ★遅く・不安定になり、DB が空の環境で落ちる★。★★テストは純粋関数と手書きの `Date` だけ★★ |
| ★12★ | ★★**`npm run dev` を Bash で起動すること**★★ | ★Browser pane の `preview_start` を使う★。★無ければ curl で代替し、その旨を記録する★ |
| ★13★ | ★★**`.env.local` をコミットすること / DB の認証情報を文書やコードに書くこと**★★ | `CLAUDE.md` §9.3。★検証後は `USE_FIXTURES=true` に戻す★ |
| ★14★ | ★★**一時スクリプトを `main().catch(...)` だけで終わらせること / 一時スクリプトをコミットすること**★★ | 落とし穴14 |
| ★15★ | ★★**`docs/decisions.md` / `docs/handoff.md` を段階6 より前に編集すること**★★ | ★★別担当（指示書W）が番号を取る可能性があります★★。★段階6 でも「自分の追記だけを足す」こと★ |

---

## 作業の順序（★崩さないこと★）

★段階0（前提の確認と実測。★読むだけ・コミットなし★。★★DBの生値と画面の値を控える★★）
→ 段階1（★設定値と `fromDbJst()` / `toDbJst()` / `jstIsoFromDb()` を新設。期待値表 → テスト → 実装★）
→ 段階2（★★DATETIME 列の読み出しに補正を通す★★）
→ 段階3（★★TIMESTAMP 列を実測で切り分けて決着させる★★）
→ 段階4（★シードを「日本時間の壁時計値」で書くように直し、再シードする★）
→ 段階5（実測での検証。★コミットなし★）
→ 段階6（記録と push）★

★★段階1・2・3・4・6 でそれぞれ1コミット（計5コミット）★★。
★**段階ごとに `npx tsc --noEmit` / `npm run lint` / `npm test` を実行し、
通ってからコミットすること**★（D-050）。

> ★★**期待値表 → テスト → 実装 の順序を崩さないこと**★★（`CLAUDE.md` §6-2 / §9.4）。
> ★★段階1 と段階2 は「テストを先に書き、**落ちることを確認してから**実装する」こと★★。

> ★★**なぜこの順番なのか**★★
> - ★段階0 が先頭なのは、★★DBの生値と画面の値を控えないと「9時間ぶん戻った」ことを示せない★★から
> - ★段階1 を分けるのは、★★変換関数は純粋関数でテストが完結する★★から。★ここだけで止めても何も壊れない★
> - ★段階2 と段階3 を分けるのは、★★DATETIME（確定）と TIMESTAMP（要実測）は**別の問題**★★だから。
>   ★1コミットにまとめると、段階5 の実測でどちらが効いたか分からなくなる★
> - ★段階4 を最後の実装段階に置くのは、★★コードが正しくなってからシードを直さないと、
>   「往復で辻褄が合っているだけ」の状態と区別がつかない★★から

> ★★**段階の並びは実装者が判断して入れ替えてよい**★★（★上は推奨順です★）。
> ★★ただし1つだけ動かせない制約があります★★ —
> ★★**段階0 でDBの生値と画面の値を控えるまで、コードにもシードにも手を付けないこと**★★。
> ★控え損ねると「直った」ことを示す証拠が作れません★。

---

## ★★実行順 — 指示書X の完了後、指示書W より先に★★

| # | 指示書 | 順序 |
|---|---|---|
| ★1★ | ★`docs/handoff/指示書X_precheckを分数そのものとして読む.md`★ | ★★本書より**先**（★着手時に完了しているはず。★`docs/decisions.md` と `docs/handoff.md` の末尾で確認すること★）★★ |
| ★2★ | ★★**本書（指示書Y）**★★ | ★★指示書X の完了後★★ |
| ★3★ | ★`docs/handoff/指示書W_連続問題施設を実DBから作る.md`★ | ★★本書の**あと**を推奨★★ |

★★本書を指示書W より先に実行することを推奨する理由★★:
★★時刻が9時間ずれたまま他の機能を検証すると、何が正しいのか分からなくなるからです★★。
★指示書W は「連続問題施設」を扱いますが、★★連続日数は日付バケットで数えており、
9時間ずれると深夜清掃が全部翌日に移ります★★★ → ★ずれた土台の上で連続日数を検証しても意味がありません★。

★★**3本を同時に走らせないこと**★★ —
★`docs/decisions.md` / `docs/handoff.md` / `dwatch_local` を複数が触ります★。
★指示書W / 指示書X は ★**読むだけ。編集しないこと**★★（禁止事項2）。

---

## 段階0 — 前提の確認と実測（★読むだけ・コミットなし★）

### 0-1. 読むもの

| # | 対象 | 見る点 |
|---|---|---|
| ★1★ | ★`CLAUDE.md` 全文★ | ★★§1（**翌朝確認型**の運用。★本書が守ろうとしているものそのもの★）★★ / ★★§4.7（週・月の帰属。★1日ずれると月まで変わる★）★★ / §7（禁止事項。★閾値のハードコード禁止・推測で実装しない★）/ ★★§9.3（**日時は ISO 8601（JST, +09:00）**）★★ / §9.4 |
| ★2★ | ★★`docs/decisions.md` の **D-106 項8**（★見出しを `grep -n "^### D-106" docs/decisions.md` で実測★）★★ | ★★本書の出所★★。★アダプタが `new Date(値 + "Z")` で読むこと / 設定で変えられないこと / `isFutureSchedule()` にも効くことが既に整理されている★。★★内容が本書 §「二重にずれる機序」と食い違っていたら実装を止めて確認を求めること★★ |
| ★3★ | ★`docs/handoff/TeamWorks確認事項_第8回_20260911.md` の「2. DATETIME に入っているのは…」節★ | ★★当方が何を聞いたか（Q2-1〜Q2-4）★★。★★特に Q2-4（TIMESTAMP と `time_zone`）★★ |
| ★4★ | ★**D-109**（`:6328`）/ **D-110**（`:6447`）★ | ★`isFutureSchedule()` を導入した経緯（当期の分母から未来を外す）★。★★本書はこの判定を「正しい `Date` で動くようにする」だけで、規則は変えない★★ |
| ★5★ | ★★**指示書X の実行記録**（★`grep -nE "^### D-" docs/decisions.md \| tail -3` で番号を実測してから読む★）★★ | ★★`precheck` が分数そのものになったこと / `dwatch_local` が0911版DDLから作り直されたこと★★。★★段階4 のシードはこの上に積みます★★ |
| ★6★ | ★`docs/handoff/指示書X_precheckを分数そのものとして読む.md` 全文★ | ★★段階の切り方・期待値表の書き方・記録の作法★★（★本書はこれに揃えてあります★）。★★**読むだけ。編集しないこと**★★ |
| ★7★ | ★`docs/handoff.md` の末尾（★最新の追記。番号を実測すること★）★ | ★直前の完了内容と申し送り★ |
| ★8★ | ★`lib/format/date.ts` 全文（57行）★ | ★`toJstIso()`（`:36`）の実装と docstring★ / ★`todayJst()`（`:19`）が `Intl` で本物のJSTを出していること★ |
| ★9★ | ★`lib/format/__tests__/date.test.ts` 全文（27行）★ | ★★既存4テストが**そのまま通り続ける**こと★★を確認する（方針3） |
| ★10★ | ★`lib/format/period.ts` 全文★ | ★★`Date` を「UTC上の日数計算機」としてしか使っていない＝本書の影響を受けないこと★★（調査6 #3） |
| ★11★ | ★`lib/db/index.ts` の `toJstIso` / `isFutureSchedule` 呼び出し全箇所★ | ★調査1・調査2 の表を自分で作り直す★ |
| ★12★ | ★`lib/db/clean-log-mapping.ts` の `isFutureSchedule()`（`:263` 付近）★ | ★★1行の関数であること★★。★★変えないこと★★ |
| ★13★ | ★`lib/db/precheck-mapping.ts` の `detectedAtFromError()`（`:158` 付近）★ | ★★`errored_at`（DATETIME） ?? `created_at`（TIMESTAMP）という**型の混在**★★ |
| ★14★ | ★`lib/db/report-mapping.ts:195-210` / `:380-390`★ | ★`toJstIso(...).slice(0, 10)` と `isFutureSchedule()` が同じ行に効いていること★ |
| ★15★ | ★`scripts/seed-local-db.ts` の調査7 の6箇所★ | ★★`Date` を作っている「根っこ」がどこか★★ |
| ★16★ | ★`lib/config/index.ts` 全文★ | ★設定値の書き方（docstring の作法・`as const` の使い方）★ |
| ★17★ | ★★`node_modules/@prisma/adapter-mariadb/dist/index.js` の `mapRow()` と `formatDateTime()`★★ | ★★読み側の ``new Date(`${value}Z`)`` と書き側の `getUTC*` を**自分の目で確認すること**★★。★`dateStrings: true` の指定も探すこと★ |

### 0-2. ★★DBの生値と画面の値を1組ずつ控える（★必須。あとで「9時間ぶん戻った」ことを示す証拠になります★）★★

```
"/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe" -u root --default-character-set=utf8mb4 dwatch_local -e "
SELECT id, start_at, end_at, precheck FROM clean_timetables WHERE parent_id = 0 ORDER BY id LIMIT 3;
SELECT id, errored_at, responded_at, resolved_at, created_at FROM robot_error_reports ORDER BY id LIMIT 3;
SELECT id, reported_at, created_at FROM control_reports ORDER BY id LIMIT 3;
SELECT id, occurred_at FROM control_error_histories ORDER BY id LIMIT 3;"
```

★★控える形（★この表をそのまま埋めること★）★★:

| # | 何を | 着手前の値 | ★段階4 後の期待★ |
|---|---|---|---|
| ★1★ | ★`clean_timetables.start_at` の**DB生値**（id を明記）★ | ★（実測。★`2026-09-10 17:00:00` のような「9時間引かれた」値のはず★）★ | ★★`2026-09-11 02:00:00` のような**深夜の時刻**★★ |
| ★2★ | ★同じ行の API の `scheduled_at`★ | ★（実測。★`...T02:00:00+09:00` のはず — 往復で辻褄が合っているため★）★ | ★★**同じ**（`...T02:00:00+09:00`）★★ |
| ★3★ | ★同じ行の画面の時刻（`/detail-log`）★ | ★（実測）★ | ★★**同じ**★★ |
| ★4★ | ★`control_reports.reported_at` の**DB生値**★ | ★（実測）★ | ★★9時間進んだ値★★ |
| ★5★ | ★`robot_error_reports.created_at` の**DB生値**（★サーバ生成★）★ | ★（実測）★ | ★★変わらない（サーバが生成するため）★★ |

★★#1 と #2 が「DB生値と API の値が9時間ずれている」ことが、いま起きている問題の証拠です★★。
★★段階4 のあとは #1 と #2 の**時刻の桁が一致する**のが正解です★★。

### 0-3. ★★API とローカルDBの現状を控える（★段階5 の突き合わせに使う★）★★

★★一時スクリプトの雛形（★成功時にも `process.exit(0)` を必ず書くこと★。禁止事項14）★★:

```ts
import { db } from "../lib/db";

async function main() {
  const logs = await db.cleanLogs();
  console.log("cleanLogs", logs.length);
  const byDate: Record<string, number> = {};
  const byHour: Record<string, number> = {};
  for (const l of logs) {
    byDate[l.scheduled_at.slice(0, 10)] = (byDate[l.scheduled_at.slice(0, 10)] ?? 0) + 1;
    byHour[l.scheduled_at.slice(11, 13)] = (byHour[l.scheduled_at.slice(11, 13)] ?? 0) + 1;
  }
  console.log("byHour", JSON.stringify(byHour));
  console.log("dates", Object.keys(byDate).sort().slice(0, 3), Object.keys(byDate).sort().slice(-3));
  const pre = await db.prechecks();
  console.log("prechecks", pre.length);
  const devices = await db.dsDevices();
  console.log("dsDevices", devices.length, devices[0]?.last_reported_at);
}

// ★★成功時にも必ず exit する（`main().catch(...)` だけだと Prisma の接続が残ってハングする）★★
main()
  .then(() => process.exit(0))
  .catch((e) => { console.error(e); process.exit(1); });
```

```
USE_FIXTURES=false DATABASE_URL="mysql://root@localhost:3306/dwatch_local" npx tsx ./scripts/tmp-measure.ts
```

★★控えること★★:

| # | 項目 | 着手前 | ★段階4 後の期待★ |
|---|---|---|---|
| ★1★ | `db.cleanLogs()` の件数 | ★（実測）★ | ★★**変わらない**★★ |
| ★2★ | `db.prechecks()` の件数 | ★（実測）★ | ★★**変わらない**★★ |
| ★3★ | ★`scheduled_at` の**時刻の分布**（`byHour`）★ | ★（実測。★`01` / `02` などのはず — 往復で辻褄が合っているため★）★ | ★★**変わらない**★★ |
| ★4★ | ★`scheduled_at` の**日付の範囲**★ | ★（実測）★ | ★★**変わらない**★★ |
| ★5★ | ★`dsDevices()[0].last_reported_at`★ | ★（実測）★ | ★★**変わらない**★★ |

> ★★ここが本書のいちばん分かりにくいところです★★ —
> ★★ローカルは「シードが9時間引いて書き、コードが9時間足して読む」ので、**着手前から API の値は正しく見えます**★★。
> ★★＝ 段階4 まで通しでやると、API の値は**着手前と同じ**に戻ります★★。
> ★★変わるのは「DBの生値」（0-2 の #1・#4）だけです★★ — ★これが「本番と同じ形になった」ことの証明です★。
> ★★逆に言えば、段階2・3 だけで止めると API の値が9時間戻ってしまい、一時的におかしく見えます★★（調査5 / 下記 0-5）。

### 0-4. ★★TIMESTAMP の実測（★段階3 の判断材料。必ずやること★）★★

```
"/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe" -u root -e "
SELECT @@time_zone, @@system_time_zone, NOW(), UTC_TIMESTAMP(), CURRENT_TIMESTAMP();"
```

★★控えること★★:

| # | 項目 | 実測値 | ★意味★ |
|---|---|---|---|
| ★1★ | `@@time_zone` | ★（実測。`SYSTEM` のはず）★ | ★セッションの TZ★ |
| ★2★ | ★★`@@system_time_zone`★★ | ★（実測）★ | ★★これが `JST`（または `+09:00` / `Tokyo Standard Time`）なら**本番と同じ**★★。★違えばローカルだけ別のずれ方をする★ |
| ★3★ | `NOW()` と `UTC_TIMESTAMP()` の差 | ★（実測）★ | ★★9時間なら JST★★ |
| ★4★ | ★`robot_error_reports.created_at`（サーバ生成）の生値★ | ★（0-2 で実測済み）★ | ★★#3 が9時間なら、この値は「シードを流した時刻の JST 壁時計」になっているはず★★ |

★★判断表（★段階3 でこれに従って決めること★）★★:

| ケース | 判断 |
|---|---|
| ★★`@@system_time_zone` が JST 相当★★ | ★★ローカルは本番と同じ条件★★ → ★★TIMESTAMP も DATETIME と同じ補正でよい★★。★実測で裏を取ったうえで段階3 を実施する★ |
| ★★JST 相当でない★★ | ★★**明示的に書いた TIMESTAMP**（`control_reports.reported_at`）は往復するので、それでも同じ補正でよい★★。★★ただし**サーバ生成の TIMESTAMP**（`created_at`）はローカルだけ別のずれ方をする★★ → ★★コードで特別扱いせず、**ローカル固有の事情として記録する**こと★★（★本番は JST なのでコードは正しい★）。★★段階6 に必ず書くこと★★ |

★★どちらのケースでも「本番の TIMESTAMP の挙動は本番接続後に再確認が要る」ことは変わりません★★ → ★申し送り①★。

### 0-5. ★★段階2・3 の間はローカルDBの表示が一時的におかしくなる（★想定内★）★★

★段階2 まで実装した時点で、コードは「DBの生値をそのまま JST として読む」ようになります★。
★ところがローカルDBにはまだ「9時間引かれた値」しか入っていません★。★★その間だけ★★:

- ★★`/detail-log` の時刻が **9時間戻って** `17:00`（前日）のように見える★★
- ★★日付も1日前にずれるので、`/dashboard` の当日件数が **0件**になりうる★★
- ★★`isFutureSchedule()` の除外が変わるので件数が動きうる★★

★★これは「壊れた」のではなく「本番と違う形のデータを、本番と同じ規則で正しく読んだ」結果です★★。
★★段階4 の再シードで解消します★★。

★`tsc` / `lint` / `test` はこの間もすべて緑のままです★
（★テストは純粋関数と手書きの `Date` だけで書かれており、DBを見ないため★）。

★★段階2 または段階3 のコミットで止める場合★★は、
★★ローカルDBがまだ古い形であることをコミットメッセージか作業メモに残すこと★★。

### 0-6. 接続先の確認とこの段階の完了条件

- ★`.env.local` の `DATABASE_URL` が `dwatch_local` を指していることを確認する★。
  ★**値そのものを画面・ログ・文書に出さないこと**★（`CLAUDE.md` §9.3）
- ★`.env.local` に `ALLOW_DIRECT_ACCESS=true` があることを確認する★（D-101 項2。★無いと段階5 で詰まる★）
- ★★着手前の基準値を自分で実測して控えた★★（`npm test` のファイル数・テスト数 / `tsc` / `lint`）
- ★★0-2 / 0-3 / 0-4 をすべて実測して控えた★★
- ★★調査1・調査2 の呼び出し箇所を自分で数え直した★★
- ★★一時スクリプトは**コミットしないこと**★★（★検証後に削除する。`git status` に残さない★）
- ★★決定番号を実測した★★（`grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3`）。
  ★★指示書W が番号を取っていないか確認し、★空いている次の番号★を段階6 で使うこと★★

---

## 段階1 — ★設定値と変換関数を新設する★（1コミット）

### 1-1. ★★期待値表（★これをそのままテストにすること。実装より先に書く★）★★

> ★★表記の約束★★: 「★DBの生値★」は `clean_timetables.start_at` に入っている文字列。
> ★アダプタはこれを `new Date(値 + "Z")` にするので、テストの入力は
> ★★`new Date("<DBの生値>Z")`（＝ 末尾に `Z` を付けた ISO 文字列）★★ で書くこと★。
> ★★実際のアダプタの挙動と同じ形なので、これがいちばん誤解のない書き方です★★。

#### ★`fromDbJst(date: Date): Date`★

| # | ★DBの生値★ | 入力 `Date` | ★期待（真の瞬間）★ | 何の確認か |
|---|---|---|---|---|
| ★1★ | `2026-09-11 02:00:00` | `new Date("2026-09-11T02:00:00Z")` | ★`new Date("2026-09-11T02:00:00+09:00")`（＝ UTC `2026-09-10T17:00:00Z`）★ | ★基本形（深夜清掃）★ |
| ★2★ | ★`2026-09-11 00:30:00`★ | `new Date("2026-09-11T00:30:00Z")` | ★★`new Date("2026-09-11T00:30:00+09:00")`（＝ UTC `2026-09-10T15:30:00Z`）★★ | ★★日をまたぐ（JST 00:30）★★ |
| ★3★ | ★`2026-09-11 23:30:00`★ | `new Date("2026-09-11T23:30:00Z")` | ★★`new Date("2026-09-11T23:30:00+09:00")`（＝ UTC `2026-09-11T14:30:00Z`）★★ | ★★日をまたぐ（JST 23:30）★★ |
| ★4★ | ★`2026-08-31 23:30:00`★ | `new Date("2026-08-31T23:30:00Z")` | ★`new Date("2026-08-31T23:30:00+09:00")`★ | ★★月をまたぐ（月末の深夜）★★ |
| ★5★ | ★`2026-09-01 00:30:00`★ | `new Date("2026-09-01T00:30:00Z")` | ★★`new Date("2026-09-01T00:30:00+09:00")`（＝ UTC では **8月31日** 15:30）★★ | ★★月境界。★UTC の日付で月を判定してはいけないことの証拠★★★ |
| ★6★ | ★`2024-02-29 02:00:00`★ | `new Date("2024-02-29T02:00:00Z")` | ★`new Date("2024-02-29T02:00:00+09:00")`（＝ UTC `2024-02-28T17:00:00Z`）★ | ★★うるう年★★ |
| ★7★ | ★`2027-01-01 00:30:00`★ | `new Date("2027-01-01T00:30:00Z")` | ★★`new Date("2027-01-01T00:30:00+09:00")`（＝ UTC `2026-12-31T15:30:00Z`）★★ | ★★年またぎ★★ |
| ★8★ | `2026-09-11 02:00:00.500` | `new Date("2026-09-11T02:00:00.500Z")` | ★ミリ秒が保たれる★ | ★情報を落とさない★ |

#### ★`toDbJst(date: Date): Date`★

| # | 入力（真の瞬間） | ★期待★ | 何の確認か |
|---|---|---|---|
| ★1★ | `new Date("2026-09-11T02:00:00+09:00")` | ★`new Date("2026-09-11T02:00:00Z")`（★アダプタが `getUTC*` で読むと `2026-09-11 02:00:00`★）★ | ★基本形★ |
| ★2★ | ★任意の `Date`★ | ★★`fromDbJst(toDbJst(d)).getTime() === d.getTime()`★★ | ★★往復（★上の #1〜#8 の入力を総当たりで通すこと★）★★ |
| ★3★ | ★任意の `Date`★ | ★★`toDbJst(fromDbJst(d)).getTime() === d.getTime()`★★ | ★★逆向きの往復★★ |

#### ★`jstIsoFromDb(date: Date): string`（★`toJstIso(fromDbJst(date))` の合成★）★

| # | ★DBの生値★ | ★期待★ | 何の確認か |
|---|---|---|---|
| ★1★ | `2026-09-11 02:00:00` | ★★`"2026-09-11T02:00:00+09:00"`★★ | ★★**DBの生値の桁がそのまま出る**★★（★★これが本書の判定基準です★★） |
| ★2★ | ★`2026-09-11 00:30:00`★ | ★`"2026-09-11T00:30:00+09:00"`★ | ★★日付が前日に落ちない★★ |
| ★3★ | ★`2026-08-31 23:30:00`★ | ★`"2026-08-31T23:30:00+09:00"`★ | ★★月が変わらない★★ |
| ★4★ | ★`2026-09-01 00:30:00`★ | ★`"2026-09-01T00:30:00+09:00"`★ | ★★月が変わらない（逆方向）★★ |
| ★5★ | ★`2024-02-29 02:00:00`★ | ★`"2024-02-29T02:00:00+09:00"`★ | ★うるう年★ |
| ★6★ | ★`2027-01-01 00:30:00`★ | ★`"2027-01-01T00:30:00+09:00"`★ | ★年またぎ★ |
| ★7★ | ★プロパティ★ | ★★任意の `"YYYY-MM-DD HH:MM:SS"` 文字列 `s` について `jstIsoFromDb(new Date(s.replace(" ", "T") + "Z")) === s.replace(" ", "T") + "+09:00"`★★ | ★★「DBの生値と表示が一致する」ことの一般化★★（★#1〜#6 を含む形で1本書くこと★） |

#### ★`toJstIso()` の既存4テスト★

- ★★`lib/format/__tests__/date.test.ts` の既存4テストは**1文字も変えず、そのまま通ること**★★（方針3）

### 1-2. 実装

#### ★`lib/config/index.ts` に設定値を足す★

```ts
/**
 * DB の DATETIME 列に入っている「壁時計値」のタイムゾーン（分）
 *
 * ★TeamWorks 第8回 A2（2026-09-11 受領）★: 「JSTです」
 *   `@@time_zone = SYSTEM` / `@@system_time_zone = JST`。
 *   システムが生成する日付時刻は JST で生成し、PUDU から返る UNIX 時間も JST に補正して格納する。
 *
 * ★なぜ設定値にするのか★: `CLAUDE.md` §7（閾値のハードコード禁止）。
 *   DB のタイムゾーンが変われば、直す場所はここ1箇所になる。
 *
 * ★消費者★: `lib/format/date.ts` の `fromDbJst()` / `toDbJst()` のみ。
 *   ★表示整形（`toJstIso()`）は「JST の ISO 文字列を作る」別の責務なので、この値を使わない★
 */
export const DB_DATETIME_OFFSET_MINUTES = 9 * 60;
```

★★`toJstIso()` の中の `9 * 60 * 60 * 1000` は**そのまま残すこと**★★（方針3 / 禁止事項8）。
★★2つの `9` が別の場所にあるのは意図的です★★ — ★片方は「DBのTZ」、もう片方は「出力形式が `+09:00` であること」★。
★★これは申し送り④に残すこと★★。

#### ★`lib/format/date.ts` に3つ足す★

```ts
import { DB_DATETIME_OFFSET_MINUTES } from "@/lib/config";

/**
 * Prisma（`@prisma/adapter-mariadb`）から出てきた `Date` を「真の瞬間」に直す。
 *
 * ★なぜ要るのか★:
 *   DB の DATETIME には ★日本時間の壁時計値★ が入っている（TeamWorks 第8回 A2。D-1XX）。
 *   ところがアダプタは ``new Date(`${value}Z`)`` で ★UTC とみなして★ `Date` にする
 *   （`node_modules/@prisma/adapter-mariadb/dist/index.js` の `mapRow()`）。
 *   ★この挙動は設定で変更できない★ — 接続文字列の `timezone` も効かない
 *   （アダプタが `dateStrings: true` でドライバの変換を迂回しているため）。
 *   結果、返ってくる `Date` は ★真の瞬間より9時間先★ を指している。
 *
 * ★補正しないと何が起きるか★:
 *   表示が9時間進むだけでなく、`isFutureSchedule()`（`lib/db/clean-log-mapping.ts`）の
 *   実時刻との比較が狂い、★深夜2時の清掃が翌朝の確認時刻でも「まだ未来」と判定されて
 *   画面から消える★（`CLAUDE.md` §1 の「翌朝確認型」の運用を直撃する）。
 *
 * ★使い方★: Prisma から出た日時は、既存の処理に渡す前に必ずこれを通す。
 *   `toJstIso()` と `isFutureSchedule()` は変更しない（入力が正しくなれば直る）。
 */
export function fromDbJst(date: Date): Date {
  return new Date(date.getTime() - DB_DATETIME_OFFSET_MINUTES * 60_000);
}

/**
 * `fromDbJst()` の逆。「真の瞬間」を、DB に書きたい壁時計値として渡せる `Date` にする。
 *
 * ★なぜ要るのか★: アダプタの書き込み側（`formatDateTime()`）は `getUTC*` で文字列を組み立てる。
 *   つまり「DB に書きたい壁時計値を UTC のフィールドに載せた `Date`」を渡す必要がある。
 *
 * ★消費者★: `scripts/seed-local-db.ts` のみ（★アプリからの DB 書き込みはまだ無い★）。
 *   ★書き込み許可4カラム（`status` / `comment` / `responded_at` / `resolved_at`。D-042）を
 *   実装するときは、日時2カラムに必ずこれを通すこと★
 */
export function toDbJst(date: Date): Date {
  return new Date(date.getTime() + DB_DATETIME_OFFSET_MINUTES * 60_000);
}

/**
 * Prisma から出た日時 → ISO8601（JST, +09:00）文字列。
 *
 * ★`toJstIso(fromDbJst(date))` の合成★ — ★★この2段を1つの式に潰さないこと★★。
 *   潰すと「DB のタイムゾーン」という設定値が式から消え、`lib/config` を変えても効かなくなる。
 *
 * ★性質★: 戻り値の日時の桁は ★DB の生値そのもの★ になる。
 *   （DB が `2026-09-11 02:00:00` なら `"2026-09-11T02:00:00+09:00"`）
 *   ★これが「直った」ことの判定基準★ — DB の生値と画面の表示が一致すること。
 */
export function jstIsoFromDb(date: Date): string {
  return toJstIso(fromDbJst(date));
}
```

★★注意★★: `lib/format/date.ts` は現在 import を1つも持っていません★。
★`@/lib/config` を import しても循環参照は起きません（`lib/config/index.ts` は何も import していない。★実測して確かめること★）★。
★★`scripts/seed-local-db.ts` は相対 import ですが、`tsx` は tsconfig の paths を解決するので `@/` でも動きます★★
（★既に `lib/db/clean-log-mapping.ts` 等が `@/lib/config` を使っており、シードから間接的に読まれています。★実測で確かめること★）。

### この段階の完了条件

- ★期待値表（`fromDbJst` 8ケース / `toDbJst` 3ケース / `jstIsoFromDb` 7ケース）がテストになっており、全部通る★
- ★★`lib/format/__tests__/date.test.ts` の既存4テストが1文字も変わっていない★★
- ★★`toJstIso()` / `todayJst()` / `formatMd()` / `formatHm()` / `inclusiveDayCount()` に差分が無い★★
- ★★`9` / `540` / `32400000` が `lib/format/date.ts` の新設3関数に直書きされていない★★
- ★`npx tsc --noEmit` = 0 / `npm run lint` = 0 / `npm test` 全パス（★テスト数が増えている★）★
- ★`git diff --stat` が ★`lib/config/index.ts` / `lib/format/date.ts` / `lib/format/__tests__/date.test.ts` の3ファイルだけ★★
- ★★コミット（ファイルパスを明示。`git add -A` 禁止）★★
  - 例: `git commit -m "feat: 指示書Y 段階1 DBのDATETIMEを日本時間として読む変換関数を足す"`

---

## 段階2 — ★DATETIME 列の読み出しに補正を通す★（1コミット）

### 2-1. ★やること★

| # | 対象 | 直し方 |
|---|---|---|
| ★1★ | ★調査1 の **DATETIME 列**（#1・#2・#4・#5・#6・#8・#9・#10・#11・#12）★ | ★★`toJstIso(x)` → `jstIsoFromDb(x)`★★ |
| ★2★ | ★調査1 の **混在**（#3・#13。`detectedAtFromError()`）★ | ★★段階3 の実測で決着させるが、★**DATETIME 側が主経路（ローカルは常に `errored_at`）なので、段階2 で `jstIsoFromDb()` に置き換えてよい**★★★。★★段階3 で TIMESTAMP の挙動を確かめ、違っていたら止めて報告する★★ |
| ★3★ | ★調査1 の **TIMESTAMP**（#7。`dsDevices()` の `last_reported_at`）★ | ★★段階2 では**触らない**（`toJstIso` のまま残す）★★ → ★★段階3 で決着させる★★ |
| ★4★ | ★調査2 の4箇所★ | ★★`isFutureSchedule(row.start_at, now)` → `isFutureSchedule(fromDbJst(row.start_at), now)`★★ |

★★`isFutureSchedule()` 自体は1文字も変えないこと★★（方針4 / 落とし穴2）。

### 2-2. ★★期待値表（★実装より先にテストを書くこと★）★★

#### ★`isFutureSchedule()` の境界（★純粋関数のテスト。DBを見ない★）★

★前提: `now` = ★JST 2026-09-11 08:00（＝ `new Date("2026-09-11T08:00:00+09:00")`）★ ＝「翌朝の確認時刻」★

| # | ★DBの生値★ | ★補正前に渡していた `Date`★ | ★補正前の結果★ | ★★補正後に渡す `Date`★★ | ★★補正後の結果★★ |
|---|---|---|---|---|---|
| ★1★ | ★`2026-09-11 02:00:00`（深夜清掃）★ | `new Date("2026-09-11T02:00:00Z")` | ★★`true`（＝ 未来 → **行が消える**。★これがバグ★）★★ | ★`fromDbJst(...)`★ | ★★`false`（＝ 過去 → **行が残る**）★★ ← ★★これが直った証拠★★ |
| ★2★ | ★`2026-09-11 01:30:00`★ | — | ★`true`★ | ★`fromDbJst(...)`★ | ★★`false`★★ |
| ★3★ | ★`2026-09-11 23:00:00`（当日夜の予定）★ | — | ★`true`★ | ★`fromDbJst(...)`★ | ★★`true`（★正しく未来★）★★ |
| ★4★ | ★`2026-09-11 08:00:00`（★ちょうど `now`★）★ | — | — | ★`fromDbJst(...)`★ | ★★`false`（`>` なので同時刻は未来でない）★★ |
| ★5★ | ★`2026-09-11 08:00:01`★ | — | — | ★`fromDbJst(...)`★ | ★★`true`★★ |
| ★6★ | ★`2026-09-10 23:59:59`（前夜）★ | — | ★`true`★ | ★`fromDbJst(...)`★ | ★★`false`★★ |

★★#1 は必ずテストに入れること★★ — ★★これが本書の存在理由です★★。
★★テスト名に「★深夜2時の清掃が翌朝8時の確認で画面から消えないこと★」と日本語で書くこと★★
（★次に触る人が「なぜこのテストがあるか」を1行で分かるように★）。

#### ★`jstIsoFromDb()` を通した経路の確認★

- ★★`lib/db/report-mapping.ts` の `jstIsoFromDb(row.start_at).slice(0, 10)` が
  「DBの生値の日付部分」と一致すること★★（★手書きの `Date` を使った純粋関数テスト。★DBを見ない★）
  - ★DBの生値 `2026-08-31 23:30:00` → ★★`"2026-08-31"`★★（★★`"2026-09-01"` にならないこと★★）
  - ★DBの生値 `2026-09-01 00:30:00` → ★★`"2026-09-01"`★★（★★`"2026-08-31"` にならないこと★★）
- ★★`lib/db/__tests__/report-mapping.test.ts:115-117` 付近のコメント★★（★`toJstIso()` の挙動を説明している★）
  ★★は**書き換えること**★★ — ★★入力の作り方が変わるので、古い説明が残ると次の人が混乱します★★

### 2-3. ★実装のときに気をつけること★

- ★★import を整理すること★★ — `lib/db/index.ts` / `lib/db/report-mapping.ts` の
  `import { toJstIso } from "@/lib/format/date"` は ★`jstIsoFromDb` / `fromDbJst`★ に入れ替わります
- ★★調査3 の「文字列の先頭10文字」箇所（`lib/prechecks/query.ts` / `app/api/dashboard/route.ts` /
  `lib/db/clean-log-mapping.ts` / `lib/db/report-mapping.ts:291`）には**手を付けないこと**★★（落とし穴5）
- ★★`lib/db/index.ts:262` / `:482` の `errored_at`（`RobotErrorLike` に渡している値）には
  **補正を掛けないこと**★★ — ★★あそこは**相対比較（並べ替え）にしか使っていない**★★（調査6 #1）。
  ★掛けても結果は変わりませんが、「補正が要る場所」を増やすと次の人が混乱します★

### この段階の完了条件

- ★★`grep -rn "toJstIso(" --include=*.ts lib/db` の**残りが調査1 の #7（TIMESTAMP）だけ**★★
  （★★他はすべて `jstIsoFromDb(` になっている★★）
- ★★`grep -rn "isFutureSchedule(" --include=*.ts lib/db | grep -v __tests__` の**すべてに `fromDbJst(` が付いている**★★
- ★★`lib/db/clean-log-mapping.ts` の `isFutureSchedule()` の**中身に差分が無い**★★
- ★★`lib/format/date.ts` に差分が無い（段階1 で完成済み）★★
- ★2-2 の期待値表がテストになっており、全部通る★
- ★`npx tsc --noEmit` = 0 / `npm run lint` = 0 / `npm test` 全パス（★テスト数が増えている★）★
- ★★ローカルDBの画面が一時的におかしくなること（0-5）を確認し、作業メモに控えた★★
- ★`git diff --stat` が ★`lib/db/index.ts` / `lib/db/report-mapping.ts` とそのテストだけ★★
- ★★コミット（ファイルパスを明示）★★
  - 例: `git commit -m "fix: 指示書Y 段階2 DATETIME列を日本時間として読む(深夜清掃が消える不具合)"`

---

## 段階3 — ★★TIMESTAMP 列を実測で切り分けて決着させる★★（1コミット）

### 3-1. ★実測（★段階0-4 の続き。ここで判断する★）★

★★段階0-4 で控えた `@@time_zone` / `@@system_time_zone` を判断表に当てはめること★★。

★さらに、`control_reports.reported_at` の**往復**を実測すること★:

```
"/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe" -u root --default-character-set=utf8mb4 dwatch_local -e "
SELECT id, reported_at FROM control_reports ORDER BY id LIMIT 3;"
```

★★これと、`db.dsDevices()[0].last_reported_at`（0-3 で控えた値）を突き合わせること★★:

| ケース | 意味 | ★段階3 でやること★ |
|---|---|---|
| ★★DB生値 と `last_reported_at` の桁が**9時間ずれている**★★ | ★★DATETIME と**同じ**挙動★★（★往復している★） | ★★`toJstIso` → `jstIsoFromDb` に置き換える★★ |
| ★★桁が**一致している**★★ | ★★DATETIME と**違う**挙動★★（★サーバが変換している★） | ★★**実装を止めて報告すること**★★（★推測で決めない。`CLAUDE.md` §7★） |

### 3-2. ★実装（★上の実測が「同じ挙動」だった場合のみ★）★

| # | 対象 | 直し方 |
|---|---|---|
| ★1★ | ★`lib/db/index.ts` の `dsDevices()` の `last_reported_at`（調査1 #7）★ | ★★`toJstIso(latestReport.reported_at)` → `jstIsoFromDb(latestReport.reported_at)`★★ |
| ★2★ | ★`lib/db/precheck-mapping.ts` の `detectedAtFromError()` の docstring★ | ★★「`errored_at` は DATETIME、`created_at` は TIMESTAMP。★本番はどちらも JST 壁時計値なので同じ補正でよい★。★ローカルでは `errored_at` が必ず入るためフォールバックは発火しない＝**ローカルでは検証できない**★」と明記する★★ |

★★`detectedAtFromError()` の**コードは変えないこと**★★ — ★docstring だけ★。
★★2つの型で補正が違うと判明した場合は、実装せずに報告すること★★（落とし穴7）。

### 3-3. ★★段階0-4 の実測が「ローカルは JST ではない」だった場合★★

- ★★コードは**そのまま**（本番は JST なので正しい）★★
- ★★「ローカルの `created_at` はローカル固有の理由でずれる」ことを docstring と段階6 の記録に書くこと★★
- ★★特別扱いのコードを書かないこと★★ — ★ローカルの都合を本番のコードに持ち込まない★

### この段階の完了条件

- ★★3-1 の実測結果を控えた（`@@time_zone` / `@@system_time_zone` / 往復の一致・不一致）★★
- ★★`grep -rn "toJstIso(" --include=*.ts lib/db` が ★**0件**★★★（★★`lib/format/date.ts` の定義と
  `lib/format/__tests__/date.test.ts` のヒットだけが残る★★）
- ★`detectedAtFromError()` の docstring に DATETIME / TIMESTAMP の別が書かれている★
- ★`npx tsc --noEmit` = 0 / `npm run lint` = 0 / `npm test` 全パス★
- ★★コミット（ファイルパスを明示）★★
  - 例: `git commit -m "fix: 指示書Y 段階3 TIMESTAMP列の扱いを実測で切り分ける"`

---

## 段階4 — ★シードを「日本時間の壁時計値」で書くようにし、再シードする★（1コミット）

### 4-1. ★★何を直すのか★★

★★調査7 の6箇所（`Date` の「根っこ」）を `toDbJst()` で包むだけです★★。

```ts
// 例（★行番号・変数名は実測すること★）
const startAt = toDbJst(new Date(log.scheduled_at));
```

| # | 場所（★実測すること★） | 直し方 |
|---|---|---|
| ★1★ | `scripts/seed-local-db.ts` の `latestReportedAt` | ★`toDbJst(new Date(device.last_reported_at))`★ |
| ★2★ | `occurred_at` | ★`toDbJst(new Date(alert.detected_at))`★ |
| ★3★ | `responded_at`（D-Sアラート） | ★`alert.responded_at ? toDbJst(new Date(alert.responded_at)) : null`★ |
| ★4★ | `resolved_at`（D-Sアラート） | ★`alert.resolved_at ? toDbJst(new Date(alert.resolved_at)) : null`★ |
| ★5★ | 清掃ログ本ループの `startAt` | ★`toDbJst(new Date(log.scheduled_at))`★ |
| ★6★ | 施設7検証データの `startAt` | ★`toDbJst(new Date(at(item.daysBack, item.time)))`★ |

★★`addMinutes()` で派生している値には**何もしないこと**★★ —
★根っこが直れば `end_at` / 事前チェック行の `start_at` / `errored_at` / `responded_at` /
`resolved_at` / `started_at` / `ended_at` は**すべて自動的に追従します**★。

★★ファイル冒頭の docstring に「★DBには日本時間の壁時計値を書く（TeamWorks 第8回 A2）。
アダプタが `getUTC*` で書くので `toDbJst()` を通す★」と明記すること★★。

### 4-2. ★★やってはいけないこと★★

- ★★行を足す・行の順序を変える・id の採番規則を変えること★★（落とし穴9）
- ★★乱数を使うこと★★（落とし穴10）
- ★★`lib/fixtures/` を1行でも変えること★★（禁止事項6）
- ★★`addMinutes()` を変えること★★
- ★★`created_at` / `updated_at` を明示的に書き込むようにすること★★（★サーバの既定値のままにする★）

### 4-3. 再シードと実測

```
npm run seed:local
```

★★再シード後に実測して控える★★:

```
"/c/Program Files/MySQL/MySQL Server 8.4/bin/mysql.exe" -u root --default-character-set=utf8mb4 dwatch_local -e "
SELECT id, start_at, end_at FROM clean_timetables WHERE parent_id = 0 ORDER BY id LIMIT 3;
SELECT COUNT(*) AS total FROM clean_timetables;
SELECT id, errored_at, created_at FROM robot_error_reports ORDER BY id LIMIT 3;
SELECT id, reported_at FROM control_reports ORDER BY id LIMIT 3;
SELECT clean_error, COUNT(*) FROM clean_status_histories GROUP BY clean_error ORDER BY clean_error;"
```

★★期待★★:

| # | 項目 | ★期待★ |
|---|---|---|
| ★1★ | ★`clean_timetables.start_at` の生値★ | ★★`2026-09-11 02:00:00` のような**深夜の時刻**★★（★★いまは `2026-09-10 17:00:00` のような値★★） |
| ★2★ | ★`clean_timetables` の総行数★ | ★★着手前と同じ★★ |
| ★3★ | ★`robot_error_reports` の件数★ | ★★着手前と同じ★★ |
| ★4★ | ★`control_reports.reported_at` の生値★ | ★★9時間進んだ値★★ |
| ★5★ | ★`clean_status_histories.clean_error` の分布★ | ★★着手前と同じ★★（★動いていたらシードの通番を壊している。落とし穴9★） |
| ★6★ | ★2回シードしても同じ★ | ★もう一度 `npm run seed:local` を流し、1〜5 が同じ値になること（★冪等★）★ |

### この段階の完了条件

- ★4-3 の6項目を実測した★
- ★★`lib/fixtures/` に差分が無い★★
- ★`npx tsc --noEmit` = 0 / `npm run lint` = 0 / `npm test` 全パス★
- ★`git diff --stat` が ★`scripts/seed-local-db.ts` だけ★★
- ★★コミット（ファイルパスを明示）★★
  - 例: `git commit -m "chore: 指示書Y 段階4 シードを日本時間の壁時計値で書くようにする"`

---

## 段階5 — ★実測での検証（★コミットなし★）★

### 5-1. ★★「直った」ことの判定基準（★これが本書の合否です★）★★

| # | ★判定基準★ | 確かめ方 |
|---|---|---|
| ★★1★★ | ★★**DBの生値と画面の表示が一致すること**★★ | ★0-2 で控えた行の `start_at` の生値（例 `2026-09-11 02:00:00`）と、`/detail-log` のその行の日付・時刻（`9/11` / `02:00`）が★**同じ桁**★であること★ |
| ★★2★★ | ★★**深夜2時の清掃が、翌朝の確認時刻に画面から消えないこと**★★ | ★`/detail-log` と `/alerts` の件数が0-3 で控えた値と同じであること★。★★時刻が 01:30〜03:00 の行が出ていること★★ |
| ★3★ | ★既定表示の「今日」と行の日付が食い違わないこと★ | ★`/dashboard` の当日件数が0でないこと（★調査4★）★ |
| ★4★ | ★API の値が着手前と同じであること★ | ★0-3 の #1〜#5 が★**全部一致**★すること（★★往復で辻褄が合っていた分がそのまま残る★★） |

### 5-2. ★データ層の検証★

★★`.env.local` を書き換えず、コマンドラインで env を渡すこと★★（★戻し忘れを防ぐ★）。
★★一時スクリプトには成功時にも必ず `process.exit(0)` を書くこと★★（禁止事項14。段階0 の雛形を使う）。

```
USE_FIXTURES=false DATABASE_URL="mysql://root@localhost:3306/dwatch_local" npx tsx ./scripts/tmp-measure.ts
```

| # | 項目 | ★期待★ |
|---|---|---|
| ★1★ | `db.cleanLogs()` の件数 | ★★着手前と同じ★★ |
| ★2★ | `db.prechecks()` の件数 | ★★着手前と同じ★★ |
| ★3★ | ★`scheduled_at` の時刻の分布（`byHour`）★ | ★★着手前と同じ★★ |
| ★4★ | ★`scheduled_at` の日付の範囲★ | ★★着手前と同じ★★ |
| ★5★ | ★`dsDevices()[0].last_reported_at`★ | ★★着手前と同じ★★ |
| ★6★ | ★★DBの `start_at` の生値と `scheduled_at` の桁★★ | ★★**一致していること**★★ ← ★★これが本書の証明★★ |

★★#1〜#5 のどれかが動いていたら、補正のどこかが片側だけ効いています★★。

### 5-3. ★画面（回帰していないこと）★

| 画面 | 期待 |
|---|---|
| ★`/detail-log`★ | ★200 / 件数が着手前と同じ★。★★時刻が DBの生値と一致★★。★★深夜の行が出ている★★ |
| ★`/alerts`★ | ★200 / 件数が着手前と同じ★。★`detected_at` の時刻が妥当（深夜帯）★ |
| ★`/dashboard`★ | ★200 / 当日件数が0でない★。★★既定日（`todayJst()`）と行の日付が食い違わない★★ |
| ★`/reports`★ | ★200（KPI・施設別・エラー内訳）★。★★月末・月初の帰属が変わっていないこと（`CLAUDE.md` §4.7）★★ |
| ★`/ds-logs` / `/ds-alerts`★ | ★200 / `last_reported_at` と `detected_at` が妥当★ |
| `/facilities` / `/schedules` / `/system` | ★200。回帰していないこと★ |

- ★★**404 が出たら Edge Runtime バンドル破壊の回帰を疑うこと**★★（D-086）。★500 = 未実装 / 404 = ビルドが壊れている★
- ★Browser pane（`preview_start`）が使えなければ curl で代替し、★目視できなかった項目を必ず記録する★★
- ★★`next dev` は `.env.local` を共有する別セッションに影響します（D-108 項8）★★ —
  ★`next build && next start -p <別ポート>` で自分専用のサーバーを起てるほうが安全★

### 5-4. ★`USE_FIXTURES=true` での確認★

- ★★fixtures 側の件数・日付・時刻が**1つも変わっていないこと**★★（禁止事項6）
  - ★fixtures は ISO文字列（`+09:00` 付き）を直接持っており、DBを経由しない★
  - ★★だから本書の変更の影響を受けないのが**正しい**★★（★変わったら fixtures を触ってしまっている★）

### この段階の完了条件

- ★5-1 の4項目・5-2 の6項目・5-3 の回帰確認・5-4 をすべて実測した★
- ★★一時スクリプトを削除した★★
- ★★`.env.local` が `USE_FIXTURES=true` のままであることを確認した★★
- ★`git status` に `lib/` `scripts/` `docs/` 以外の差分が無いことを確認した★
  （★`README.md` / `docs/code-map.md` / `docs/handoff/管理画面_設計検討_20260910.md` /
  `docs/handoff/指示書W_…` / `docs/handoff/指示書X_…` が ★**着手前と同じ状態のまま**★ であること。禁止事項2）

---

## 段階6 — 記録と push（1コミット）

### 6-1. `docs/decisions.md` に追記（★★着手直前に実測した空き番号を使うこと★★）

★★`grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3` で実測してから採番すること★★。
★★指示書W が先に番号を取っている可能性があります★★ — ★その番号を奪わないこと★。

★見出しの形式は既存に揃える★:
`### D-1XX \`[確定]\` DBの DATETIME を日本時間の壁時計値として読む（画面の時刻が9時間進む不具合）`

★最低限入れる項目★:

| # | 項目 |
|---|---|
| 1 | ★★TeamWorks 第8回 A2 の**原文を引用**する★★（★要約に置き換えないこと★） |
| 2 | ★★二重ずれの機序★★: ★アダプタが `new Date(値 + "Z")` で UTC とみなす → `toJstIso()` がさらに +9h → 画面が9時間進む★ |
| 3 | ★★表示だけの問題ではなかったこと★★: ★`isFutureSchedule()` が狂い、★★深夜2時の清掃が翌朝の確認時刻に画面から消えていた★★（`CLAUDE.md` §1 の運用を直撃）★ |
| 4 | ★★採った方針★★: ★読み出しの境界で1回だけ補正する（`fromDbJst()`）。`toJstIso()` と `isFutureSchedule()` は変えない★ |
| 5 | ★★却下した4案とその理由★★（★`toJstIso()` から +9h を外す / 接続文字列の `timezone` / `TZ` 環境変数 / DBをUTC格納に変える★） |
| 6 | ★★9時間を `lib/config` の `DB_DATETIME_OFFSET_MINUTES` に外出しした★★（`CLAUDE.md` §7） |
| 7 | ★★`jstIsoFromDb()` は `toJstIso(fromDbJst(...))` の合成であり、1つの式に潰さない理由★★ |
| 8 | ★★DATETIME と TIMESTAMP の挙動の違い★★と、★★段階0-4 / 段階3 の**実測結果**（`@@time_zone` / `@@system_time_zone` / 往復の一致・不一致）★★ |
| 9 | ★★`detectedAtFromError()` が DATETIME と TIMESTAMP を混ぜて返すこと、ローカルではフォールバックが発火しない＝検証できないこと★★ |
| 10 | ★★シードを「日本時間の壁時計値」で書くように直したこと★★。★`toDbJst()` の新設と、その消費者が現状シードだけであること★ |
| 11 | ★★DBの生値が `17:00`（前日）から `02:00` に変わったこと★★。★API の値は**変わらない**（往復で辻褄が合っていたため）こと★ |
| 12 | ★★日付バケット（`slice(0, 10)`）の箇所は**自動的に直る**ので触っていないこと★★ |
| 13 | ★★`todayJst()` はもともと本物の JST だったので、既定表示の「今日」と行の日付が食い違っていたこと★★ |
| 14 | ★段階5 の実測値（★件数・時刻分布・日付範囲が着手前と一致すること★ / ★DBの生値と表示が一致すること★ / テスト数）★ |
| 15 | ★★変えなかったもの★★: ★`toJstIso()` / `isFutureSchedule()` / `todayJst()` / `lib/format/period.ts` / `lib/fixtures/` / `d-watch-api.yaml` / `lib/types/` / `prisma/schema.prisma`★ |
| 16 | ★申し送り（下記「申し送り」節をそのまま移す）★ |

### 6-2. `docs/handoff.md` に追記（★番号は実測すること★）

- ★見出しは `## 2026-09-1X（追記NN）`★（★★指示書W が先に使っていたら次の番号★★）
- ★冒頭の「最新の追記」ブロック（`docs/handoff.md:6` 付近）も更新すること★
- ★入れる内容★:
  - ★★「DBの DATETIME には**日本時間の壁時計値**が入る（TeamWorks 第8回 A2）。Prisma のアダプタは
    それを UTC として読むので、★**読み出しの境界で `fromDbJst()` を通す**★」★★
  - ★★「`toJstIso()` と `isFutureSchedule()` は**変えていない**。入力の `Date` が正しくなれば直る」★★
  - ★★「Prisma から出た日時は ★**必ず `jstIsoFromDb()` / `fromDbJst()` を通す**★。生の `Date` を
    そのまま `toJstIso()` に渡さないこと」★★（★★次に `lib/db/` を触る人がいちばん踏みやすい★★）
  - ★★「9時間は `lib/config` の `DB_DATETIME_OFFSET_MINUTES`。直書きしない」★★
  - ★★「シードは `toDbJst()` を通して**日本時間の壁時計値**を書く。ローカルDBの `start_at` は
    `02:00` のような値になった（以前は `17:00`）」★★
  - ★★「DATETIME と TIMESTAMP は挙動が違う。★本番の TIMESTAMP は本番接続後に再確認が要る★」★★
  - ★★「書き込み許可4カラムの更新を実装するときは、日時2カラムに `toDbJst()` を通すこと」★★
  - ★申し送り★

### 6-3. push とこの段階の完了条件

- ★★コミット（ファイルパスを明示。`docs/decisions.md` と `docs/handoff.md` のみ）★★
  - 例: `git commit -m "docs: 指示書Y完了(D-1XX)を記録する"`
  - ★★着手時に**別担当の未コミットの追記**が `docs/decisions.md` に入っている可能性があります★★ —
    ★★`git add docs/decisions.md` はそれごと巻き込みます★★。
    ★`git status` / `git diff docs/decisions.md` を確認し、★★他人の追記があれば、その担当が先に
    コミットするのを待つか、巻き込むことを承知のうえでコミットメッセージに明記すること★★★
  - ★★コミット前に `git pull --rebase` の要否を確認し、★競合したら自分の追記だけを残して番号を取り直すこと★★★
- ★`git push origin develop`★
- ★★本書の冒頭表と末尾「実行状態」表を更新すること★★
- ★★`README.md` / `docs/code-map.md` / `docs/handoff/管理画面_設計検討_20260910.md` /
  `docs/handoff/指示書W_…` / `docs/handoff/指示書X_…` / `.claude/worktrees/**` に差分が無いことを最後に確認すること★★

---

## ★★申し送り★★

★★以下は本指示書では直しません。★★ ★決定事項に列挙して残すこと★。

| # | 論点 | 状態 |
|---|---|---|
| ★★①★★ | ★★**本番の TIMESTAMP 列の挙動は、本番接続後に再確認が要る**★★ — ★A2 は `@@system_time_zone = JST` と言っているが、★★DFA はまだ実DBに繋いでいない★★★。★対象: `control_reports.reported_at` / `security_statuses.reported_at` / `security_status_histories.reported` / 各テーブルの `created_at` / `updated_at`★ | ★★実DB接続時に `SELECT @@time_zone, @@system_time_zone, NOW(), UTC_TIMESTAMP();` を**最初に流すこと**★★。★あわせて `clean_timetables` の1行の `start_at` の生値と、それが指す実際の時刻を突き合わせること★ |
| ★★②★★ | ★★**ステージング（MariaDB 10.11）でも同じ確認をすること**★★ — ★ローカルは MySQL 8.4、本番は MariaDB 10.11（D-100）★。★★ローカルで通っても本番で通らない可能性がある★★ | ★★ステージング環境ができた時点で、段階0-4 と段階5-1 の実測を**そのまま1回流すこと**★★ |
| ★③★ | ★★`detectedAtFromError()` の `created_at` フォールバックは**ローカルでは検証できない**★★ — ★シードが `errored_at` を必ず入れるため発火しない★ | ★★実DB接続時に `SELECT COUNT(*) FROM robot_error_reports WHERE errored_at IS NULL` を流し、0でなければ `detected_at` の値を目で確かめること★★ |
| ★④★ | ★★`toJstIso()` の中の `9 * 60 * 60 * 1000` は設定値化していない★★ — ★あれは「DBのTZ」ではなく「出力形式が `+09:00` であること」を表しており、別の `9` です★ | ★★意図的に残した★★。★★ただし「9が2箇所にある」ことは次の人が混乱する種なので、
`toJstIso()` の docstring に「この9は出力形式の `+09:00` であって DB のTZではない」と1行足すかは**未決**★★（★本書では足していない★） |
| ★⑤★ | ★★書き込み許可4カラム（`status` / `comment` / `responded_at` / `resolved_at`。D-042）の更新はまだ**未実装**★★ | ★★実装するときは `responded_at` / `resolved_at` に必ず `toDbJst()` を通すこと★★。★通し忘れると DB に9時間ずれた値が入り、★★TeamWorks 側の集計まで巻き込んで壊れます★★★ |
| ★⑥★ | ★★`lib/format/date.ts` が `lib/config` を import するようになった★★ — ★これまで依存ゼロのファイルだった★ | ★★循環参照は起きない（`lib/config/index.ts` は何も import していない）★★。★将来 `lib/config` から `lib/format` を import したくなったら循環するので注意★ |
| ★⑦★ | ★★`docs/code-map.md` に `toJstIso()` の説明が残っている可能性★★ | ★★本書では触らない（禁止事項2。別作業の未追跡ファイル）★★。★その担当に伝えること★ |

---

## 完了条件（全体）

| # | 条件 |
|---|---|
| ★1★ | ★`lib/config/index.ts` に `DB_DATETIME_OFFSET_MINUTES` があり、docstring に TeamWorks 第8回 A2 の出所が書かれている★ |
| ★2★ | ★`lib/format/date.ts` に `fromDbJst()` / `toDbJst()` / `jstIsoFromDb()` があり、9時間が直書きされていない★ |
| ★3★ | ★★`jstIsoFromDb()` が `toJstIso(fromDbJst(...))` の**合成**として書かれている（1つの式に潰されていない）★★ |
| ★4★ | ★★`toJstIso()` / `isFutureSchedule()` / `todayJst()` / `formatMd()` / `formatHm()` / `inclusiveDayCount()` / `lib/format/period.ts` に差分が無い★★ |
| ★5★ | ★★`lib/format/__tests__/date.test.ts` の既存4テストが1文字も変わらず通る★★ |
| ★6★ | ★段階1 の期待値表（8 + 3 + 7 ケース）がすべてテストになっている★ |
| ★7★ | ★★日をまたぐケース（JST 00:30 / 23:30）・月をまたぐケース（月末の深夜）・うるう年・年またぎがテストに含まれている★★ |
| ★8★ | ★★`isFutureSchedule()` の境界（段階2 の6ケース）がテストになっており、★**深夜2時の清掃が翌朝8時に `false`（＝画面に残る）**★ が固定されている★★ |
| ★9★ | ★★`grep -rn "toJstIso(" --include=*.ts lib/db` が **0件**★★ |
| ★10★ | ★★`grep -rn "isFutureSchedule(" --include=*.ts lib/db \| grep -v __tests__` のすべてに `fromDbJst(` が付いている★★ |
| ★11★ | ★★調査3 の「文字列の先頭10文字」箇所に差分が無い（二重補正していない）★★ |
| ★12★ | ★★段階0-4 の TIMESTAMP 実測（`@@time_zone` / `@@system_time_zone` / 往復）を控え、段階3 の判断を記録した★★ |
| ★13★ | ★シードが `toDbJst()` を通しており、行数・順序・採番規則が変わっていない★ |
| ★14★ | ★★再シード後のDBの `start_at` が `02:00` のような**深夜の時刻**になっている（以前は `17:00`）★★ |
| ★15★ | ★★2回シードしても同じ結果（冪等）★★ |
| ★16★ | ★★`db.cleanLogs()` / `db.prechecks()` の件数・時刻分布・日付範囲が**着手前と同じ**★★ |
| ★17★ | ★★**DBの生値と画面の表示が一致する**（0-2 の #1 と #3）★★ |
| ★18★ | ★★**深夜2時の清掃が翌朝の確認時刻に画面から消えない**★★ |
| ★19★ | ★`/dashboard` の当日件数が0でない（既定日と行の日付が食い違わない）★ |
| ★20★ | ★`npx tsc --noEmit` = 0 / `npm run lint` = 0 / `npm test` 全パス（★テスト数は着手前より増えている★）★ |
| ★21★ | ★★`lib/fixtures/` / `d-watch-api.yaml` / `lib/types/` / `prisma/schema.prisma` に差分が無い★★ |
| ★22★ | ★`README.md` / `docs/code-map.md` / `docs/handoff/管理画面_設計検討_20260910.md` / `docs/handoff/指示書W_…` / `docs/handoff/指示書X_…` / `.claude/worktrees/**` に触っていない★ |
| ★23★ | ★★`docs/decisions.md`（★実測した空き番号★）と `docs/handoff.md` に記録した★★。★★却下した4案と、TIMESTAMP の実測結果を明記した★★ |
| ★24★ | ★5コミット（段階1 / 2 / 3 / 4 / 6）で push した★ |
| ★25★ | ★★一時スクリプトを削除し、`.env.local` が `USE_FIXTURES=true` に戻っている★★ |

---

## 実行状態

| 項目 | 内容 |
|---|---|
| 状態 | ★**完了**★ |
| 実行日 | 2026-09-11 |
| 決定番号 | ★**D-115**★（着手時点の最終番号はD-114。別担当がTeamWorks第8回回答受領として先に取得済み） |
| コミット | `166d366`（段階1）/ `e5a64e4`（段階2）/ `6f6b53b`（段階3）/ `ea70d3a`（段階4）/ 本コミット（段階6） |
| 実測値 | `tsc`0 / `lint`0 / `npm test` 45ファイル965テスト / DBの生値とAPI `scheduled_at` の桁が一致することを確認（正しく直った証明）。詳細は `docs/decisions.md` の **D-115** / `docs/handoff.md` **追記35** を参照 |
