Newer
Older
client_dwatch / docs / handoff / 指示書Y_DATETIMEを日本時間として読む.md

指示書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())と比較します★:

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)★★:

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 に設定値を足す★

/**
 * 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つ足す★

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() で包むだけです★★。

// 例(★行番号・変数名は実測すること★)
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)
実測値 tsc0 / lint0 / npm test 45ファイル965テスト / DBの生値とAPI scheduled_at の桁が一致することを確認(正しく直った証明)。詳細は docs/decisions.md の D-115 / docs/handoff.md 追記35 を参照