Newer
Older
client_dwatch / docs / handoff / 指示書W_連続問題施設を実DBから作る.md

指示書W — 連続問題施設(continuousProblems)を実DBから作る

項目 内容
実行環境 ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照)
実行状態 ★完了(D-116。2026-09-11)★
実行順 ★指示書V(D-110)完了後★。★指示書M / P / R / S / T / U / V が作った lib/db/clean-log-mapping.ts / lib/db/report-mapping.ts / lib/db/index.ts の上に積む作業★です。★★prisma/schema.prisma と lib/fixtures/ は1文字も変えません★★が、★★d-watch-api.yaml / lib/types/index.ts / lib/i18n.ts / 画面(components/features/reports/ReportsScreen.tsx)は変えます★★。★触るコードが重なる未実行の指示書は着手直前に自分で確認すること★
対象 ★prismaSource.continuousProblems()(lib/db/index.ts:1115-1117 の notImplemented("continuousProblems"))の実装1本★。★★これが片付くと監視側の DataSource は全部埋まります★★(inspections / inspectionDetail は点検記録で後回し。★D-111 項6★)
取得する決定番号 ★**着手直前に `grep -nE "^### D-(0\ 1)[0-9][0-9]" docs/decisions.md \ tail -3で最終番号を実測すること**★。★★^### D-0だけでは3桁台(D-100 以降)を拾えない★★。★2026-09-07 にD-068の二重採番が実際に起きています★。★作成時点の最終は **D-112**(docs/decisions.md:6706)なので、本書の実行記録は **D-113** の見込み★。★それでも必ず自分で実測してから採番すること★。★★作成時点で **D-111とD-112が未コミット**(git diff docs/decisions.mdに出る。合計256行)★★ — ★★git add docs/decisions.md` は2件ごと巻き込みます★★(段階9-3 参照)
前提 ★D-112(docs/decisions.md:6706。★TeamWorks 第7回の回答受領★)★ / ★★D-111(docs/decisions.md:6584。★summary は事実だけを組み立てる。原因も助言も書かない★。★★ただし同 項2 の決定4「混在は出発失敗を優先する」は本書で C案に差し替わりました(後述「ユーザー確定」)★★)★★ / ★D-026(:122。★「連続」はカレンダー日ではなくスケジュール存在日の連続★)★ / ★D-102(:5139。★項4 が「continuousProblems は対応するサマリが無い。詳細テーブルから算出する必要がある(第6歩)」と明記している★)★ / ★D-103(:5222。consecutive_problem_days はA案 = computeConsecutiveProblemDays() を流用する / 規則を2箇所に書かない)★ / ★D-106(:5830。★1週間分の予定がまとめて登録される=未来の本清掃行が常時存在する★)★ / ★D-107(:5993。★isFutureSchedule() は必須★)★ / ★D-109(:6328。★連続日数は施設単位・同一日は OR で1日に畳む・70.0 は問題あり★)★ / ★D-110(:6447。指示書V の実行記録)★ / D-042(:226。書き込み許可4カラム)/ D-050(:308。段階ごとに検証してからコミット)/ D-056(:353。schema.prisma は生成物)/ D-072 項4(:864。年次は P1 では読まない)/ D-077(:1847。エラー値を変換しない)/ D-089 項3・D-104(★「その他」「不明」という語を使わない★)/ D-071(月跨ぎ週は月境界で分割する)
段階数 ★10段階★(段階0〜段階9)。★コミットは段階1 / 2 / 3 / 4 / 5 / 6 / 7 / 9 の8つ★
★着手前確認★ ★★なし★★ — ★判定の分かれ目(summary の文面3種 / 混在ランの書き方(C案)/ △と× を畳むこと / 確定期間も詳細から算出すること / i18n はテンプレート方式 / L1 もデータ層は返すこと / ロボット0台の施設を除外しないこと / 連続0日は行を作らないこと)は ★★すべて確定済み★★(ユーザー確定 2026-09-11 + 監督判断)。★★ユーザーの回答を待たずに段階0 から通しで実行してよい★★
着手前の基準 ★着手直前に自分で npm test / npx tsc --noEmit / npm run lint を実行し、その実測値を基準にすること★。★作成時点の実測(2026-09-11。★鵜呑みにしない★): npm test = 45ファイル / 928テスト(全パス) / tsc = 0 / lint = 0 / db.cleanLogs() = 259件 / db.prechecks() = 27件★
作成 2026-09-11(作成時点の決定事項最終番号 D-112(docs/decisions.md:6706)/ docs/handoff.md の最新は 追記33(docs/handoff.md:2774)。★着手時に実測すること★)

実行後はこの表と、文書末尾の「実行状態」表を更新してください。

★この指示書を読んでいるあなたへ★ — .claude/commands/d-watch.md の「実装は Cursor 側」は 2つの環境の役割分担です。Cursor の中で動いている Claude は実装担当です。 ★調査や改訂ではなく実装を行ってください。★ ★★うまくいかなければ段階の切れ目(コミット単位)で止めて構いません★★ — ★段階1(API契約)→ 段階2〜5(純粋関数を1本ずつ)→ 段階6(配線)→ 段階7(画面)★ の順に、 ★どこで止めても単体で意味が通る形★に並べてあります。 ★★ただし段階6 まで行かないと画面は 500 のままです★★(=現状と同じ。壊れはしません)。


★★冒頭に読むべきこと — 作るのは1本、決まっているのは文面3種★★

prismaSource.continuousProblems()(lib/db/index.ts:1115)は ★★監視側の DataSource で最後に残った 未実装1本★★です。★対応するサマリテーブルが無い★(D-102 項4)ので、 ★★確定期間(先週・先月)も当期(今週・今月)も、どちらも詳細テーブルから算出します★★。

★★返すもの★★(ContinuousProblem。lib/types/index.ts:258):

フィールド 作り方
facility_id / facility_name ★facilities(deleted_at: null)から引く。★robots を join しない★(後述 決定5)★
consecutive_problem_days ★computeConsecutiveProblemDays() + consecutiveProblemDaysAtPeriodEnd()★(★既存関数。別実装しない★)
escalation_level ★judgeEscalationLevel()★(★0日は null を返す=行を作らない★)
last_problem_on ★期間内でその施設の最終エントリの日付★(★consecutiveProblemDaysAtPeriodEnd() の内部から切り出す★)
summary ★★下の3種のテンプレート文字列のいずれか★★(★数値を埋めずに {n} {m} のまま返す★)
★launch_failed_days(新設・任意項目)★ ★ランのうち出発失敗(未実施)があった日数★。★summary の {m} に画面が差し込む★

★★summary の3種(これが仕様のすべて)★★:

# 条件 ★戻り値(テンプレートのまま)★
★1★ ★ランに出発失敗の日が1日も無い★ ★"{n}日連続で完了率が△以下"★
★2★ ★ランの全日が出発失敗★ ★"{n}日連続で出発失敗"★
★3★ ★混在(C案)★ ★"{n}日連続で△以下(うち{m}日は出発失敗)"★

★★ユーザーが確定させたこと(2026-09-11)★★

★★この節は確定済みです。★★ ★迷ったらこの節に戻ること★。★★本書の他の記述とこの節が食い違っていたら、この節が正★★。

★確定1 — summary は事実だけを組み立てる(D-111)★

★対処の助言も原因の推定も書かない★。 ★fixtures の summary(lib/fixtures/reports.ts:339-437。「マップ再作成またはオンサイト訪問を検討」等)は 再現しません★ — ★★USE_FIXTURES の true / false で文言が違うのは想定内★★(D-111 項5。 前例は status_1h の日本語 ↔ Standby.(D-098 / D-105))。 ★★見た目の差分をもって「実装がおかしい」と判断しないこと★★。

★確定2 — 混在ランは ★★C案★★(本日確定。★D-111 項2 の決定4 を差し替える★)★

出発失敗の日と完了率低下の日が混ざった連続では:

{n}日連続で△以下(うち{m}日は出発失敗)

★★却下した2案と理由(記録に残すこと)★★:

案 内容 ★却下理由★
★(a)★ ★「{n}日連続で出発失敗」(=D-111 項2 の決定4「出発失敗を優先する」)★ ★★直近に清掃を実施していても「出発失敗」と書くことになり事実と食い違う★★(=確定1「事実だけを組み立てる」に反する)
★(b)★ ★「{n}日連続で完了率が△以下」★ ★★出発失敗を隠してしまう★★

★★実データに2件実在します★★: ★施設5(2日中1日が出発失敗)★ / ★施設6(4日中3日が出発失敗)★。 ★★つまり「混在は起きない」という前提で書いてはいけません★★。

★確定3 — × と △ は書き分けない(★「△以下」に畳む★)★

★1つの連続の中で △ と × が混ざるため★ — ★★実測: 施設9 は5日連続で△のあと、最終日だけ 9.9% で ×★★。 ★モックの副題(d-watch-mock.jsx:713「2日以上連続で△以下または出発失敗の施設」)とも一致します★。

★★帰結★★: ★judgeGrade() は今回使わなくてよくなる可能性が高い★(△と× を分けないため)。 ★使うかどうかは実装者判断★。★使う場合も丸める前の生値で呼ぶこと★(CLAUDE.md §4.3)。


★★監督が決めたこと(★理由ごと記録に残すこと★)★★

★決定1 — API契約を先に直す(CLAUDE.md §6 作業ルール1)★

★★現在の d-watch-api.yaml:1324-1329 は summary を「別物」として定義しています★★:

summary:
  type: string
  example: "スケジュール存在日ベースで3日連続。カレンダー上は5日間"
  description: >-
    連続判定の内容を説明する文。完了率の数値ではなく、
    D-026(スケジュール存在日ベースの連続)の説明として画面に出す

★これは「D-026 の説明を画面に出す」という定義★で、★★確定1・確定2 のA案/C案とは中身が違います★★。 ★★description と example を先に書き換えてから実装に入ること★★(=段階1)。 ★実装だけ変えて YAML と乖離させない★(CLAUDE.md §6-1 / §7)。

★決定2 — 確定期間(先週・先月)も詳細テーブルから算出する★

  • ★★連続問題施設に対応するサマリテーブルは存在しません★★ — ★D-102 項4 自身が 「対応するサマリが無い / 詳細テーブルから算出する必要がある(第6歩)」と明記している★
  • ★★D-102 に反しません★★ — ★D-102 が禁じているのは「サマリがあるのに再計算すること」★
  • ★★当期だけにしてはいけない理由★★: ★先週・先月に切り替えたとき、このカードだけ常に「該当なし」になる★。 ★★同じ画面の施設別パフォーマンス表は確定期間の行を返す★★ので、 ★同じ期間で「施設別は問題を示しているのに連続問題施設は空」という不整合★になる
  • ★★帰結(実装が楽になる点)★★: ★確定期間と当期で処理を分ける必要がありません★。 ★★todayJst() / isCurrentPeriod() を使った当期の場合分けを書かないこと★★ — ★期間キーはデータ(清掃ログの日付)から列挙するだけ★(段階4)

★決定3 — i18n はテンプレート方式(方式A)★

  • ★★サーバは「{n}日連続で完了率が△以下」のようなテンプレート文字列のまま返す★★。 ★画面側で数値を差し込む★
  • ★前例★: lib/i18n.ts:100("他{n}件を表示 →")/ :163("{n}分前")/ :175("{n}日以上連続")。 ★lib/i18n.ts:85-99 のコメントが「方式A(プレースホルダ + replace)★推奨★」と明記している★
  • ★★完成した日本語文を返すと、EN 切替でこのカードだけ和文が残ります★★ (ReportsScreen.tsx:394 は t() を通していないので、★そこも直す★)
  • ★★{m} を使う C案 は replace が2回★★。★プレースホルダ名を分けてあるので String.replace で足ります★ (String.replace は文字列指定だと最初の1件しか置換しないが、{n} も {m} も各テンプレートに1回しか出ない)

★★起草時に補った点(★実装者はこの形で作ること★)★★: ★{m} に入れる数値の出どころが ContinuousProblem に無かった★ため、 ★★launch_failed_days(任意項目)を API契約と型に足します★★。

  • ★★必ず「任意項目」にすること★★ — ★必須にすると lib/fixtures/reports.ts:339-437 の12件が 型エラーになり、★★lib/fixtures/ を変更しない★★(禁止事項)を守れなくなる★
  • ★★prismaSource は必ず数値を入れる★★(0 を含む)。★任意なのは fixtures を凍結しているためだけ★
  • ★画面は launch_failed_days が undefined のとき {m} の置換を行わない★ (★?? 0 で 0 を捏造しない★。fixtures の summary にはそもそも {m} が無いので置換不要)

★決定4 — 連続1日(L1)の行もデータ層は返す★

  • ★「2日以上」の絞り込みは ★lib/reports/query.ts:236-242 の filterContinuousProblems() に実装済み★ (閾値は ESCALATION_THRESHOLDS.level2Days。★ハードコードされていない★)
  • ★★データ層でも絞ると規則が2箇所になる★★(D-103 が禁じた状態)。★片方だけ直したときに食い違う★
  • ★★lib/reports/query.ts と app/api/reports/continuous-problems/route.ts は1文字も変えないこと★★

★決定5 — ロボット0台の施設を除外しない★

  • ★facilityPerformance()(lib/db/index.ts:883-898)は ★ロボット0台の施設を行ごと除外している★ が、 ★★あちらは FacilityPerformance.serial_number を出すため★★(lib/types/index.ts:249)
  • ★★ContinuousProblem に serial_number は無い★★(lib/types/index.ts:258-265)ので ★join 自体が不要★
  • ★★除外すると、機体を撤去・交換中の施設のエスカレーションが画面から消えます★★(★fail-silent★)。 ★連続問題施設は「現場に行くか」を判断するカード★なので、機体が無い施設こそ見えている必要がある
  • ★★prisma.facility.findMany({ where: { deleted_at: null } }) に include: { robots: ... } を書かないこと★★

★決定6 — 連続0日の施設は行を作らない★

  • ★judgeEscalationLevel()(lib/judge/escalation.ts:52-59)は ★0日で null を返す★
  • ★ContinuousProblem.escalation_level は ★non-null★(EscalationLevel)
  • ★★as EscalationLevel でのキャストや ?? "L1" での握り潰しを禁止★★ — ★0日は「問題が続いていない」であって L1(初回)ではない★。 ★★null が返ったら行を作らない(continue)★★

★必ず共有させる既存関数(★別実装したら D-109 の問題が再発します★)★

関数 場所 役割
★computeConsecutiveProblemDays()★ lib/db/clean-log-mapping.ts:366 ★行id → 連続日数(施設単位・同一日OR畳み込み済み)★
★foldProblemDaysByDate()★ lib/db/clean-log-mapping.ts:322 ★同一日 OR 畳み込み(D-109 決定2)★
★consecutiveProblemDaysAtPeriodEnd()★ lib/db/report-mapping.ts:282 ★期間内最終エントリの連続日数(A案。D-103)★
judgeEscalationLevel() lib/judge/escalation.ts:52 L1〜L3。★0日は null★
latestHistoryByTimetableId() lib/db/clean-log-mapping.ts:214 履歴を1件に絞る規則(D-107 落とし穴2)
completionRateFromHistory() lib/db/clean-log-mapping.ts:184 ★未実施は null・丸めない生値★
isProblemCompletion() lib/db/clean-log-mapping.ts:281 ★△以下 or 未実施(70.0 は問題あり。D-109 決定3)★
★isFutureSchedule()★ lib/db/clean-log-mapping.ts:263 ★★外すと未来7日ぶんが全部「出発失敗」になり、全施設が常時L3になります★★
weekOfMonth() / periodRange() lib/format/period.ts:126 / :157 ★月跨ぎ週の帰属を知っているのは weekOfMonth() だけ(D-071)★
toJstIso() lib/format/date.ts ★既存の使い方を1文字も変えない★(タイムゾーンは別件。D-106 項8)

★judgeGrade()★ は ★確定3(△以下に畳む)★ のため今回は不要になる可能性が高い。★実装者判断★。


★新規に要るもの(★調査で判明。4本の純粋関数★)★

# 何 なぜ既存で足りないか ★置き場所★
★1★ ★期間内の「最終エントリ」を返す関数★ ★consecutiveProblemDaysAtPeriodEnd()(lib/db/report-mapping.ts:282-297)が内部で選んでいるが、★日数しか返さない★。last_problem_on に日付が要る★ ★lib/db/report-mapping.ts★(★切り出す。★同関数のシグネチャは変えない★)
★2★ ★ランの範囲(日付配列)を返す関数★ ★foldProblemDaysByDate() の戻りから「末尾 n 日」を採る★ ★lib/db/report-mapping.ts(内部で foldProblemDaysByDate() を呼ぶ)★
★3★ ★ラン内の「出発失敗の日数」を返す関数★ ★★foldProblemDaysByDate() は {date, isProblem} しか返さず、未実施 / × / △ の別を落としている★★ ★同上★
★4★ ★期間キーの列挙★ ★facilityPerformance() はサマリ行から期間キーを列挙している(lib/db/index.ts:906-913)。★詳細行から列挙するヘルパは無い★ ★lib/db/report-mapping.ts★
★5★ ★1期間ぶんの ContinuousProblem[] を組み立てる関数★ ★★DB に触らない形にしておかないと「連続0日は行を作らない」等をテストできない★★(禁止事項「テストを DB 依存にしない」) ★同上★

★★型を拡張してはいけないもの★★

  • ★★ScheduleDayResult(lib/judge/escalation.ts:19-24)と foldProblemDaysByDate() の戻り型を 拡張しないこと★★ — ★lib/db/__tests__/clean-log-mapping.test.ts:620-700 が toEqual で 完全一致比較しており、フィールドを1つ足すだけで全部落ちます★。 ★★区別が要るなら、畳み込みの結果とは別に「元の行」を見ればよい★★(新関数2・3 はそうしてある)
  • ★★consecutiveProblemDaysAtPeriodEnd() のシグネチャを変えないこと★★ — ★lib/db/__tests__/report-mapping.test.ts:400-440 が依存している★。 ★★中身を新関数1 に置き換えるのは可(引数と戻り値が同じなら既存テストはそのまま通る)★★

★区別の取り方(材料の作り方)★

事象 ★判定★
★出発失敗(未実施)★ ★★completion_rate === null★★(lib/db/clean-log-mapping.ts:176-181 の不変条件: departed === false ⟺ completion_rate === null)
★完了率が△以下★ ★それ以外で isProblemCompletion() が true★(★× と △ を分けない。確定3★)
★同じ日に「未実施の行」と「実施した行」が混在★ ★★その日は「出発失敗の日」として1日ぶんだけ数える★★(OR。★D-109 決定2 の畳み込みと同じ規則にそろえる★。★同じ日を2回数えない★)
  • ★★丸める前の生値で判定すること★★(CLAUDE.md §4.3)。 ★Math.round(9.9) = 10 で × が △ に化ける★ — ★今回は△と× を畳むので文面は変わらないが、 ★★70.0 前後では判定がひっくり返る★★(isProblemCompletion() は <= 70。70.4 を丸めた 70 と 70.6 を丸めた 71 で結果が変わる)。★丸めた値を判定に渡さないこと★
  • ★★m ≤ n が必ず成り立つこと★★(ランの日だけを数えるため)。★ランの外の未実施日を数えない★

★禁止事項(この指示書の全段階に効きます)★

# ★してはいけないこと★ 理由
★1★ ★★git add -A を使うこと★★ CLAUDE.md §7。★過去に別作業の途中ファイルを巻き込む事故が起きている★。★★必ずファイルパスを明示すること★★
★2★ ★★README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / `.claude/worktrees/` に触ること**★★ ★★すべて別作業のもの★★。★本指示書のコミットに含めない・編集しない・削除しない★
★3★ ★★メインDBのスキーマ変更(prisma migrate / db push / db pull / prisma/schema.prisma の手書き)★★ CLAUDE.md §7 / D-056。★★本書はスキーマ変更を1つも必要としません★★(★シードにも1行も足しません★)
★4★ ★★書き込み許可4カラム(status / comment / responded_at / resolved_at)以外への DB 書き込み★★ D-042。★★本書は読むだけです。検証でも DB に1行も書きません★★(★指示書V 段階6-2 のような一時 INSERT はしない★)
★5★ ★★閾値(90/70/10、猶予30分、L2=2日/L3=3日)をハードコードすること★★ CLAUDE.md §7。★lib/config/ から読む★。★★summary の文面に「2日以上」等の閾値を焼き込まないこと★★
★6★ ★★表示用に丸めた値で判定すること★★ CLAUDE.md §4.3
★7★ ★★isFutureSchedule() を外すこと・猶予を足すこと・境界(>)を変えること★★ D-107 設計4 / D-110。★★外すと未来7日ぶんが全部「出発失敗」になり、全施設が常時L3になります★★
★8★ ★★ScheduleDayResult / foldProblemDaysByDate() の戻り型を変えること★★ ★lib/db/__tests__/clean-log-mapping.test.ts:620-700 が toEqual で完全一致比較している★
★9★ ★★consecutiveProblemDaysAtPeriodEnd() のシグネチャを変えること★★ ★lib/db/__tests__/report-mapping.test.ts:400-440 が依存している★
★10★ ★★lib/fixtures/ を1行でも変更すること★★ CLAUDE.md §9.5 / D-111 項5。★fixtures の summary は人間が書いた文章で、実装では再現しない★
★11★ ★lib/reports/query.ts / app/api/reports/continuous-problems/route.ts を変更すること★ ★★「2日以上」の絞り込み・並べ替え・meta は既に実装済みで正しい★★(決定4)。★データ層でも絞ると規則が2箇所になる(D-103)★
★12★ ★lib/judge/ を変更すること★ ★countConsecutiveProblemDays() / judgeEscalationLevel() / judgeGrade() / lib/judge/advice.ts は完成している★
★13★ ★prismaSource の他メソッド(cleanLogs / prechecks / reportKpis / facilityPerformance / errorBreakdown / D-Sec 系)の挙動を変えること★ ★範囲外★。★★段階2 のリファクタは「内部の切り出し」だけで、facilityPerformance() の値が1つでも変わったら実装が違います★★(段階8 で突き合わせる)
★14★ ★エラー種別を7分類に変換すること・「その他」「不明」という語を使うこと★ D-077 / D-089 項3 / D-104 項2。★★summary に原因(オフライン / ブラシエラー等)を書かないこと★★(確定1)
★15★ ★summary に対処の助言を書くこと★ D-111 項2 の決定2・決定3。★★「現場へ連絡」「オンサイト訪問を検討」は書かない★★(アドバイス R1〜R4 と二重になる)
★16★ ★summary を「完成した日本語文」で返すこと★ 決定3。★★EN 切替でこのカードだけ和文が残ります★★
★17★ ★テストを DB 依存にすること★ ★遅く・不安定になり、DB が空の環境で落ちる★。★テストは純粋関数と手書きの入力だけ★
★18★ ★.env.local をコミットすること / DB の認証情報を文書やコードに書くこと★ CLAUDE.md §9.3。★検証後は USE_FIXTURES=true に戻す★
★19★ ★★DBに触る一時スクリプトに process.exit(0) を書かないこと★★ ★★D-111 項9。main().catch(...) だけだと Prisma の接続が残り、★実際に4時間39分ハングした事故★が起きている★★
★20★ ★npm run dev を Bash で起動すること★ ★Browser pane の preview_start を使う★。★無ければ next build && next start -p <別ポート> か curl で代替し、その旨を記録する★(★dwatch_local と .env.local は複数セッションで共有。D-108 項8★)

作業の順序(★崩さないこと★)

★段階0(前提の確認。★読むだけ・コミットなし★) → 段階1(★API契約★: d-watch-api.yaml → lib/types/index.ts) → 段階2(純粋関数①: 期間内最終エントリの切り出し) → 段階3(純粋関数②③④: ランの範囲・出発失敗日数・summary テンプレート) → 段階4(純粋関数⑤: 期間キーの列挙) → 段階5(純粋関数⑥: 1期間ぶんの ContinuousProblem[] 組み立て) → 段階6(★配線★: prismaSource.continuousProblems()) → 段階7(画面 + i18n) → 段階8(実測での検証。★コミットなし★) → 段階9(記録と push)★

★★段階1・2・3・4・5・6・7・9 でそれぞれ1コミット(計8コミット)★★。 ★段階ごとに npx tsc --noEmit / npm run lint / npm test を実行し、通ってからコミットすること★ (D-050。docs/decisions.md:308)。

★★期待値表 → テスト → 実装 の順序を崩さないこと★★(CLAUDE.md §6-2 / §9.4)。 ★★各段階の X-1(期待値表)を先に書き、X-2(テスト)で固定し、落ちることを確認してから X-3(実装)★★。 ★特に段階3 は、実装を先に書くと「自分の実装がそのまま期待値になる」ため、表を先に埋めること★。

★★なぜこの順番なのか★★

  • ★段階1 が先頭なのは CLAUDE.md §6 作業ルール1「API契約を変えたくなったら先に YAML を直す」★
  • ★段階2〜5 で純粋関数を1本ずつ積むのは、★★段階6 で配線したときに「どの関数が間違っていたか」を テストで切り分けられるようにするため★★。★段階6 は DB を読むのでテストが書けない★
  • ★段階7(画面)を段階6 の後に置くのは、★★実データが出るようになってから見た目を直したいため★★
  • ★段階8(実測)を最後に置くのは、★★期待値(4件・0件・施設5=2日 等)が全段階の積み上げでしか出ないため★★

段階0 — 前提の確認(★読むだけ・コミットなし★)

読むもの

# 対象 見る点
1 ★CLAUDE.md 全文★ ★★§4.3(丸め)/ §4.5(R1〜R4・L1〜L3)/ §6(API契約が先・期待値表が先)/ §7(禁止事項)/ §9.4(判定ロジックの作法)★★
2 ★docs/decisions.md の D-111(:6584)★ ★★summary の生成規則(A案)★★。★★ただし 項2 の決定4「混在は出発失敗を優先」は本書の確定2(C案)で差し替わる★★。★項9(一時スクリプトの process.exit(0))も読む★
3 ★D-026(:122)/ D-102(:5139。★項4★)/ D-103(:5222)/ D-106(:5830)/ D-107(:5993)/ ★D-109(:6328)★ / D-110(:6447) ★スケジュール存在日の連続 / サマリが無いこと / A案 / 1週間分の予定 / isFutureSchedule() / 施設単位と同一日OR / 指示書V の実行記録★。★行番号は自分で取り直すこと★
4 ★docs/handoff.md の末尾(★追記33★。:2774)★ ★指示書V の完了内容と申し送り★。★「次にやること」1番が本書★
5 ★docs/handoff/指示書V_連続日数を施設単位に揃え当期の分母から未来を外す.md★ ★段階の切り方・期待値表の書き方・記録の作法★
6 ★lib/db/clean-log-mapping.ts 全文(392行)★ ★isFutureSchedule()(:263)/ isProblemCompletion()(:281)/ ConsecutiveInput(:286)/ foldProblemDaysByDate()(:322)/ computeConsecutiveProblemDays()(:366)★
7 ★lib/db/report-mapping.ts 全文(389行)★ ★FacilityConsecutiveEntry(:261)/ consecutiveProblemDaysAtPeriodEnd()(:282)/ periodMapKey()(:31)★
8 ★lib/db/index.ts:874-1117★ ★facilityPerformance() の組み立て方(★これが本書のひな形★)★ / ★continuousProblems()(:1115)★ / ★施設マスタの引き方(:883-898。★本書は robots を join しない★)★
9 ★lib/judge/escalation.ts 全文★ ★★countConsecutiveProblemDays() が日付を見ていないこと★★ / ★judgeEscalationLevel() が0日で null を返すこと★。★★どちらも変更しない★★
10 ★lib/reports/query.ts:232-254 / :303-308★ ★「2日以上」の絞り込みと並べ替えが既に実装済みであること★。★★変更しない★★
11 ★app/api/reports/continuous-problems/route.ts 全文★ ★meta.total の意味(2日以上の絞り込み後・limit 適用前)★。★★変更しない★★
12 ★components/features/reports/ReportsScreen.tsx:358-418 / ReportsScreen.module.css:251-257★ ★カードの描画(:394 が t() を通していない)★ / ★.contSummary に省略記号の指定が無いこと(申し送り②)★
13 ★lib/i18n.ts:85-101 / :160-176★ ★方式A の前例と書き方★
14 ★lib/db/__tests__/clean-log-mapping.test.ts:600-700 / lib/db/__tests__/report-mapping.test.ts:380-450★ ★既存テストの書き方(ヘルパー)に揃えるため★ / ★★変えてはいけない toEqual 比較の場所を自分の目で確認する★★

実測して控えるもの(★段階8 の突き合わせに使う★)

npx tsc --noEmit
npm run lint
npm test

★作成時点の実測(2026-09-11。★鵜呑みにしない★)★: tsc = 0 / lint = 0 / npm test = ★45ファイル・928テスト★

USE_FIXTURES=false DATABASE_URL="mysql://root@localhost:3306/dwatch_local" npx tsx -e "
import { db } from './lib/db';
const logs = await db.cleanLogs();
const pre = await db.prechecks();
console.log('cleanLogs', logs.length, 'prechecks', pre.length);
const fp = await db.facilityPerformance();
console.log(JSON.stringify(fp.map(r => [r.period, r.year, r.month, r.week,
  r.item.facility_id, r.item.consecutive_problem_days, r.item.status])));
process.exit(0);            // ★★必ず書くこと(D-111 項9)★★
"

★作成時点の実測★: cleanLogs = ★259件★ / prechecks = ★27件★。 ★★facilityPerformance() の出力は丸ごとファイルに控えておくこと★★ (★段階2 のリファクタで1つでも変わったら実装が違う★)。

  • ★.env.local の DATABASE_URL が dwatch_local を指していることを確認する★。 ★値そのものを画面・ログ・文書に出さないこと★(CLAUDE.md §9.3)
  • ★.env.local に ALLOW_DIRECT_ACCESS=true があることを確認する★(D-101 項2)
  • ★★決定番号を実測する★★(grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3)。 ★最終が D-112 なら本書の記録は D-113★

段階1 — ★API契約と型★(1コミット)

1-1. d-watch-api.yaml の ContinuousProblem(:1300-1329)を直す

★★summary の description と example を差し替え、launch_failed_days を足す★★。 ★他のフィールド(facility_id / facility_name / consecutive_problem_days / escalation_level / last_problem_on)と、スキーマ冒頭の description(「L1(初回)は返さない(2日以上のみ)」)は 変えないこと★ — ★★API の応答としては今も L1 を返さない(絞り込みはルートが行う)ので、記述は正しい★★。

★書き換え後の形(★このとおりに★。既存の記法・インデントに合わせる)★:

        summary:
          type: string
          example: "{n}日連続で△以下(うち{m}日は出発失敗)"
          description: >-
            連続の内容を事実だけで述べたテンプレート文字列(D-111)。原因の推定も対処の助言も含まない。
            画面が {n} に consecutive_problem_days を、{m} に launch_failed_days を差し込む(方式A)。
            サーバは数値を埋めずにテンプレートのまま返す(完成した日本語文を返すと EN 切替で和文が残るため)。
            返す文字列は次の3種のいずれか:
            出発失敗の日が無いとき「{n}日連続で完了率が△以下」/
            全日が出発失敗のとき「{n}日連続で出発失敗」/
            混在するとき「{n}日連続で△以下(うち{m}日は出発失敗)」。
            △ と × は書き分けず「△以下」に畳む(1つの連続の中で混ざるため。モック:713 の副題と同じ粒度)
        launch_failed_days:
          type: integer
          example: 3
          description: >-
            連続の中で出発失敗(未実施)があった日の数。0 <= launch_failed_days <= consecutive_problem_days。
            summary の {m} に差し込む。同じ日に未実施の行と実施済みの行が混在する場合は、その日を
            出発失敗の日として1日だけ数える(D-109決定2 の同一日OR畳み込みと同じ規則)。
            ★任意項目★: fixtures(手書きの完成文)を変更しないための措置であり、
            実DB経由(prismaSource)では常に数値が入る

1-2. lib/types/index.ts:258-265 の ContinuousProblem に足す

  /**
   * 連続の中で出発失敗(未実施)があった日の数(summary の {m})。
   * ★任意項目にしてあるのは `lib/fixtures/reports.ts` の手書き12件を変更しないため★
   *   (`CLAUDE.md` §9.5 / D-111 項5)。★`prismaSource` は常に数値を入れる(0 を含む)★。
   */
  launch_failed_days?: number;

★summary のコメントも「テンプレート文字列である」ことが分かるように直すこと★ (★型を読んだ人が完成文を入れないように★)。

この段階の完了条件

  • ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス(★件数は段階0 と同じ★)★
  • ★★lib/fixtures/ に差分が無いこと★★(=任意項目にできている)
  • ★git diff --stat が d-watch-api.yaml と lib/types/index.ts の2ファイルだけ★
  • ★★コミット(ファイルパスを明示)★★
    • 例: git commit -m "docs/feat: 指示書W 段階1 ContinuousProblem の summary 契約を事実ベースに直す"

段階2 — ★純粋関数①: 期間内の最終エントリを切り出す★(1コミット)

2-1. ★★期待値表(★これをそのままテストにすること。実装より先に書く★)★★

★対象★: lastEntryInPeriod(entries, facilityId, range) → FacilityConsecutiveEntry | undefined

# 入力 ★期待★
★1★ entries = [] ★undefined★
★2★ その施設の行が1件も無い(別施設の行だけ) ★undefined★
★3★ その施設の行はあるが全部範囲外(from の前日 / to の翌日) ★undefined★
★4★ 範囲内に3件(日付バラバラ) ★scheduled_at が最大の1件★
★5★ ★同じ日に2件(時刻違い)★ ★★時刻が遅いほう★★(scheduled_at の文字列比較。既存実装と同じ)
★6★ ★境界: from ちょうどの行だけ★ ★その行(範囲に含む)★
★7★ ★境界: to ちょうどの行だけ★ ★その行(範囲に含む)★
★8★ 範囲内と範囲外が混在(範囲外のほうが新しい) ★★範囲内の最新★★(範囲外を拾わない)

★★加えて、consecutiveProblemDaysAtPeriodEnd() の既存テスト (lib/db/__tests__/report-mapping.test.ts:400-440)が1文字も変えずに通ること★★。

2-2. テストを書く

  • ★lib/db/__tests__/report-mapping.test.ts に追記する★(新規ファイルを作らない)
  • ★★実装より先に書き、落ちることを確認する★★

2-3. 実装

  • ★lib/db/report-mapping.ts の consecutiveProblemDaysAtPeriodEnd()(:282-297)の ★ループ部分をそのまま lastEntryInPeriod() に切り出す★
  • ★consecutiveProblemDaysAtPeriodEnd() は新関数を呼ぶだけにする★:
export function consecutiveProblemDaysAtPeriodEnd(
  entries: readonly FacilityConsecutiveEntry[],
  consecutiveById: ReadonlyMap<number, number>,
  facilityId: number,
  range: { from: string; to: string }
): number {
  const latest = lastEntryInPeriod(entries, facilityId, range);
  if (!latest) return 0;
  return consecutiveById.get(latest.id) ?? 0;
}
  • ★★選び方の規則を2箇所に書かないこと★★(D-103)— ★★continuousProblems() 側で 「最終エントリを自分で探す」コードを書かない★★。必ずこの関数を呼ぶ
  • ★docstring に「last_problem_on の算出にも使う」ことを書く★

この段階の完了条件

  • ★tsc = 0 / lint = 0 / npm test 全パス(★テスト数は増える★)★
  • ★★consecutiveProblemDaysAtPeriodEnd() のシグネチャが変わっていない★★
  • ★git diff --stat が lib/db/report-mapping.ts と lib/db/__tests__/report-mapping.test.ts の2ファイル★
  • ★★コミット★★ — 例: git commit -m "refactor: 指示書W 段階2 期間内の最終エントリを切り出す"

段階3 — ★純粋関数②③④: ランの範囲・出発失敗日数・summary★(1コミット)

3-0. ★★この段階の肝 — ランは期間をまたぐ★★

★★problemRunDates() には「その施設の全期間の行」を渡すこと★★。 ★期間内の行だけで畳むと、★★前月から続いている連続でランが途中で切れ、{m} が過小になります★★★ (consecutive_problem_days は全期間走査で出しているので、★日数だけ 4 なのにランの日付が 2 件★という 食い違いになる)。★★期待値表 (a)-9 でこれを固定します★★。

3-1. ★★期待値表(★これをそのままテストにすること。実装より先に書く★)★★

(a) ★problemRunDates(facilityRows, endDate, consecutiveDays) → string[]★

★入力の facilityRows は ★その施設ぶんの ConsecutiveInput[](全期間)★。 戻りは ★日付昇順の YYYY-MM-DD 配列★。

# 入力 ★期待★
★1★ facilityRows = [] / consecutiveDays = 0 ★[]★
★2★ consecutiveDays = 0(最終日が問題なし) ★[]★
★3★ 1日だけ・問題あり / n=1 ★["2026-09-10"]★
★4★ 09-08 / 09-09 / 09-10 が全部問題 / endDate="2026-09-10" / n=3 ★["2026-09-08","2026-09-09","2026-09-10"]★
★5★ ★スケジュール非存在日を挟む★(09-01 / 09-03 / 09-05 の3日だけ行があり全部問題)/ n=3 ★★["2026-09-01","2026-09-03","2026-09-05"]★★(★カレンダー上は5日。D-026★)
★6★ ★同一日に2行(1台が問題・1台が正常)★ ★★その日は1日ぶんだけ★★(OR。D-109 決定2)
★7★ ランの前に問題なしの日がある(09-01 正常 / 09-02・09-03 問題)/ n=2 ★["2026-09-02","2026-09-03"]★(★正常日を含めない★)
★8★ endDate が行に存在しない ★[]★(★呼び出し側が同じ行から取った日付を渡す契約。例外にしない★)
★9★ ★★n が行の日数より大きい★★(行は2日ぶんしか無いのに n=4) ★★slice が負にならず、先頭から endDate まで(2件)★★(Math.max(0, ...))
★10★ endDate より後の日にも行がある ★★endDate より後を含めない★★

(b) ★launchFailedDaysInRun(facilityRows, runDates) → number★

# 入力 ★期待★
★1★ runDates = [] ★0★
★2★ ラン3日・全日 completion_rate === null ★3★
★3★ ラン3日・null が1日も無い(40 / 55 / 62) ★0★
★4★ ★混在(3日中1日が null)★ ★1★
★5★ ★★同じ日に「null の行」と「完了率60の行」★★ ★★1(その日を1日として数える。2回数えない)★★
★6★ ランの外の日に null がある ★★数えない★★
★7★ ★完了率ちょうど 70.0 の日★ ★★0(問題日ではあるが出発失敗ではない)★★
★8★ ラン2日・両方 null(= m === n) ★2★

(c) ★continuousProblemSummary(consecutiveDays, launchFailedDays) → string★

★★戻り値は「数値を埋めていないテンプレート」★★({n} / {m} の文字がそのまま残る)。

# n m ★戻り★
★1★ 1 0 ★"{n}日連続で完了率が△以下"★
★2★ 1 1 ★"{n}日連続で出発失敗"★
★3★ 2 0 ★"{n}日連続で完了率が△以下"★
★4★ ★2★ ★1★ ★★"{n}日連続で△以下(うち{m}日は出発失敗)"★★(★実データの施設5★)
★5★ 2 2 ★"{n}日連続で出発失敗"★
★6★ ★4★ ★3★ ★★"{n}日連続で△以下(うち{m}日は出発失敗)"★★(★実データの施設6★)
★7★ 6 0 ★"{n}日連続で完了率が△以下"★(★実データの施設9★)
★8★ 4 4 ★"{n}日連続で出発失敗"★
  • ★★「完了率が△以下」と「△以下」の書き分けは意図的★★ — ★混在の文には出発失敗の日が 含まれるので「完了率が」を付けると事実と食い違う★。★勝手にそろえないこと★
  • ★★文字列は lib/i18n.ts に足すキーと1文字も違ってはいけない★★(全角括弧・「うち」・「は」を含む)。 ★★違うと translate() が未登録キーとして日本語のまま返す(fail-silent。D-111 項8)★★
  • ★前提: n >= 1 かつ 0 <= m <= n★(★n <= 0 では呼ばれない。呼び出し側が行を作らない★)

3-2. テストを書く

  • ★lib/db/__tests__/report-mapping.test.ts に追記★。★実装より先に書き、落ちることを確認する★

3-3. 実装

  • ★★3本とも lib/db/report-mapping.ts に置く★★(★lib/db/clean-log-mapping.ts は触らない★ — 戻り型を変えないため。禁止事項8)
  • ★problemRunDates() は内部で foldProblemDaysByDate() を呼ぶ★(★独自に遡って数えない★。 ★★数え方の規則が2箇所になると D-109 の問題が再発する★★)
  • ★テンプレート文字列は ★モジュール先頭の定数3本★ にして、テストからも参照できるように export する★ (★文字列リテラルを2箇所に書かない★)
  • ★★閾値は登場しません★★(n と m の大小比較だけ)。★「2日以上」等をここに書かないこと★

この段階の完了条件

  • ★tsc = 0 / lint = 0 / npm test 全パス★
  • ★★(a) 10ケース・(b) 8ケース・(c) 8ケースがすべてテストになっている★★
  • ★git diff --stat が lib/db/report-mapping.ts と lib/db/__tests__/report-mapping.test.ts の2ファイル★
  • ★★コミット★★ — 例: git commit -m "feat: 指示書W 段階3 連続の内訳とsummaryテンプレートを作る"

段階4 — ★純粋関数⑤: 期間キーの列挙★(1コミット)

4-1. ★★期待値表★★

★対象★: reportPeriodKeysFromDates(dates) → ReportPeriodKey[] (★入力は YYYY-MM-DD の並び。重複していてよい★)

# 入力 ★期待★
★1★ [] ★[]★
★2★ ["2026-09-10"] ★月次 {period:"monthly", year:2026, month:9, week:0} と 週次 {period:"weekly", year:2026, month:9, week: weekOfMonth("2026-09-10").week} の2件★(★★週番号を手計算で書かず weekOfMonth() を呼んで期待値にすること★★)
★3★ 同じ週の2日 ★2件(重複しない)★
★4★ 同じ月・別の週の2日 ★月次1件 + 週次2件 = 3件★
★5★ ★★月跨ぎ(["2026-08-31","2026-09-01"])★★ ★★月次2件 + 週次2件 = 4件★★(★D-071。同じ日曜〜土曜でも月境界で別の週になる★)
★6★ 年跨ぎ(["2025-12-31","2026-01-01"]) ★年が分かれて4件★
★7★ 並び順 ★★year → month → week の昇順(week:0 の月次が先頭)★★。★決定的であること★
★8★ ★年次(month:0, week:0)★ ★★作らない★★(P1では読まない。D-072 項4)

4-2. テストを書く → 4-3. 実装

  • ★lib/db/report-mapping.ts に置く★。★重複判定は既存の periodMapKey()(:31)を使う★
  • ★★週番号は weekOfMonth() に必ず通すこと★★ — ★★月跨ぎ週の帰属を知っているのは weekOfMonth() だけ★★(D-071 / CLAUDE.md §4.7)。★自分で「1日から数えて7日ごと」と書かない★
  • ★todayJst() を呼ばないこと★(★当期の特別扱いをしない。決定2★)

この段階の完了条件

  • ★tsc = 0 / lint = 0 / npm test 全パス★ / ★8ケースがテストになっている★
  • ★★コミット★★ — 例: git commit -m "feat: 指示書W 段階4 清掃ログの日付から期間キーを列挙する"

段階5 — ★純粋関数⑥: 1期間ぶんの ContinuousProblem[] を組み立てる★(1コミット)

5-0. ★なぜ純粋関数に切り出すのか★

★★「連続0日は行を作らない」「ロボット0台の施設も出す」「L1 も返す」は、DB を読む continuousProblems() の中に書くとテストできません★★(禁止事項17)。 ★★組み立てを純粋関数にして、段階6 は「DBから材料を集めてこの関数を呼ぶだけ」にすること★★。

5-1. ★★期待値表★★

★対象★:

export function continuousProblemsInPeriod(
  entries: readonly ConsecutiveInput[],            // ★過去の行のみ・全施設・全期間★
  consecutiveById: ReadonlyMap<number, number>,    // computeConsecutiveProblemDays() の戻り
  facilityNameById: ReadonlyMap<number, string>,   // ★ロボット0台の施設も入っている★
  range: { from: string; to: string }
): ContinuousProblem[]
# 入力 ★期待★
★1★ entries = [] ★[]★
★2★ ★期間内にその施設の清掃ログが無い★ ★★行を作らない★★
★3★ ★連続0日(期間内の最終日が問題なし)★ ★★行を作らない★★(★escalation_level を as で作らない・?? "L1" にしない★)
★4★ ★n=1(L1)★ ★★行を作る★★・escalation_level="L1"(★絞り込みは lib/reports/query.ts の仕事。決定4★)
★5★ n=2 / n=3 / n=6 ★"L2" / "L3" / "L3"★(★judgeEscalationLevel() 経由。閾値を直書きしない★)
★6★ ★★facilityNameById にロボット0台の施設が入っている★★ ★★その施設の行が出る★★(決定5)
★7★ ★entries にあるが facilityNameById に無い施設 id★ ★★行を作らない★★(★名前を空文字や String(id) で捏造しない★。facilityPerformance() の buildItem() が null を返すのと同じ作法)
★8★ ★混在ラン(3日中1日が未実施)★ ★summary = 混在テンプレート / launch_failed_days = 1★
★9★ ★全日が出発失敗★ ★summary = 出発失敗テンプレート / launch_failed_days = n★
★10★ ★全日が完了率低下★ ★summary = 完了率テンプレート / launch_failed_days = 0★
★11★ 通常ケース ★last_problem_on = ★期間内最終エントリの日付(YYYY-MM-DD)★★
★12★ ★完了率ちょうど 70.0 が最終日★ ★★問題あり(行が出る)・launch_failed_days に数えない★★(D-109 決定3)
★13★ ★同一日に複数台(1台だけ問題)★ ★★その日は1日★★(D-109 決定2)
★14★ ★★ランが期間の前から続いている(前月から連続4日、期間内は2日)★★ ★★consecutive_problem_days = 4 / ランの日付も期間外の2日を含む / {m} も4日ぶんで数える★★(★これが「全期間の行を渡す」理由★)
★15★ 複数施設 ★★戻りの順序が決定的★★(facilityNameById の並び順で作る。★並べ替えは lib/reports/query.ts の仕事★)

5-2. テストを書く → 5-3. 実装

★組み立ての順序(★この順で★)★:

  1. facilityNameById を回す(★entries を回さない★ — 名前の無い施設を作らないため)
  2. days = consecutiveProblemDaysAtPeriodEnd(entries, consecutiveById, facilityId, range) / ★days <= 0 なら continue★
  3. level = judgeEscalationLevel(days) / ★level === null なら continue★(決定6。★キャスト禁止★)
  4. latest = lastEntryInPeriod(entries, facilityId, range) / ★無ければ continue★ (★2 で 0 を弾いているので通常は起きない。それでも ! や as を使わない★)
  5. facilityRows = entries.filter(e => e.facility_id === facilityId)(★全期間★)
  6. runDates = problemRunDates(facilityRows, latest.scheduled_at.slice(0, 10), days)
  7. m = launchFailedDaysInRun(facilityRows, runDates)
  8. summary = continuousProblemSummary(days, m)
  9. push({ facility_id, facility_name, consecutive_problem_days: days, escalation_level: level, last_problem_on: latest.scheduled_at.slice(0, 10), summary, launch_failed_days: m })

この段階の完了条件

  • ★tsc = 0 / lint = 0 / npm test 全パス★ / ★15ケースがテストになっている★
  • ★★as / !(non-null assertion)/ ?? "L1" を1つも使っていない★★
  • ★★コミット★★ — 例: git commit -m "feat: 指示書W 段階5 1期間ぶんの連続問題施設を組み立てる"

段階6 — ★配線: prismaSource.continuousProblems()★(1コミット)

6-1. 実装(★facilityPerformance() をひな形にする★)

★lib/db/index.ts:1115-1117 の notImplemented("continuousProblems") を置き換える★:

async continuousProblems() {
  const { getPrisma } = await import("./prisma");
  const prisma = await getPrisma();

  // ★robots を join しない(ContinuousProblem に serial_number は無い。決定5)★
  //   ロボット0台の施設を除外すると、機体を撤去・交換中の施設の
  //   エスカレーションが画面から消える(fail-silent)
  const facilityRows = await prisma.facility.findMany({ where: { deleted_at: null } });
  const facilityNameById = new Map<number, string>(
    facilityRows.map((f) => [Number(f.id), f.facility_name])
  );

  const [timetables, histories] = await Promise.all([
    prisma.cleanTimetable.findMany(),
    prisma.cleanStatusHistory.findMany(),
  ]);
  const mainRows = timetables.filter((r) => r.precheck === CLEAN_TIMETABLE_PRECHECK.main);
  const historyByTimetableId = latestHistoryByTimetableId(histories);

  // ★`now` は1回だけ作って使い回す(行ごとに作ると境界の行の扱いがぶれる)★
  // ★★`isFutureSchedule()` を外さないこと — 外すと未来7日ぶんが全部「出発失敗」になり
  //   全施設が常時L3になる(D-106 / D-107)★★
  const now = new Date();
  const entries: ConsecutiveInput[] = mainRows
    .filter((row) => !isFutureSchedule(row.start_at, now))
    .map((row) => ({ /* cleanLogs() / facilityPerformance() と同じ作り方 */ }));
  const consecutiveById = computeConsecutiveProblemDays(entries);

  // ★確定期間も当期も詳細から算出する(対応するサマリが無い。D-102 項4 / 決定2)★
  //   期間キーはデータの日付から列挙する。todayJst() による当期の場合分けは要らない
  const rows: ContinuousProblemRow[] = [];
  for (const key of reportPeriodKeysFromDates(entries.map((e) => e.scheduled_at.slice(0, 10)))) {
    const range = periodRange(key.period, key.year, key.month, key.week);
    if (!range) {
      // ★自分が weekOfMonth() で作ったキーなので解けないのは実装の誤り(fail-loud)★
      //   既存2箇所(reportKpis / facilityPerformance)と同じ作法にそろえる
      throw new Error(`[db] continuousProblems: 期間範囲を算出できませんでした(${JSON.stringify(key)})`);
    }
    for (const item of continuousProblemsInPeriod(entries, consecutiveById, facilityNameById, range)) {
      rows.push({ ...key, item });
    }
  }
  return rows;
}

★★注意★★:

  • ★ConsecutiveInput の作り方は ★facilityPerformance()(lib/db/index.ts:938-947)と1文字同じ★ にすること★ — ★★2画面で連続日数が食い違わないため(D-103)★★
  • ★prisma.robotErrorReport は読まない★(★summary にエラー種別を出さないため。D-077 / 確定1★)
  • ★★lib/db/index.ts の import に新関数4本を足す★★(lint の未使用/不足で気づける)
  • ★notImplemented() が他で使われているなら残す。使われなくなったら未使用で lint が落ちるので消す★

6-2. ★この段階でやらないこと★

  • ★★「2日以上」で絞らない★★(決定4。lib/reports/query.ts の仕事)
  • ★★並べ替えない★★(sortContinuousProblems() の仕事)
  • ★★当期の特別扱いをしない★★(決定2)

この段階の完了条件

  • ★tsc = 0 / lint = 0 / npm test 全パス(★この段階でテストは増えない★)★
  • ★★USE_FIXTURES=false で db.continuousProblems() が例外を投げずに配列を返す★★(★件数の検証は段階8★)
  • ★git diff --stat が lib/db/index.ts の1ファイルだけ★
  • ★★コミット★★ — 例: git commit -m "feat: 指示書W 段階6 continuousProblems()を実DBから作る"

段階7 — ★画面と i18n★(1コミット)

7-1. lib/i18n.ts に3キーを足す

★★段階3 の定数と1文字も違わないこと★★(★translate() は未登録キーを日本語のまま返す。 fail-silent で気づけない。D-111 項8★)。

キー(日本語原文) ★EN★
"{n}日連続で完了率が△以下" "Completion rate △ or below for {n} consecutive days"
"{n}日連続で出発失敗" "Launch failed for {n} consecutive days"
"{n}日連続で△以下(うち{m}日は出発失敗)" "△ or below for {n} consecutive days ({m} with launch failure)"

★英文は実装者判断で改善してよいが、★★{n} / {m} を必ず1回ずつ含めること★★★。 ★既存の "{n}日以上連続": "{n}+ consecutive days"(lib/i18n.ts:175)の語彙に寄せる★。

7-2. ReportsScreen.tsx:391-394 を直す

★現状(★t() を通していない=EN で和文が残る★)★:

<span className={styles.contSummary}>{item.summary}</span>

★直したあと(例。★{m} は値があるときだけ置換する★)★:

// ★サーバはテンプレート文字列を返す(決定3。方式A)。数値はここで差し込む★
//   `String.replace` は文字列指定だと最初の1件だけ置換する。各テンプレートに
//   `{n}` `{m}` は1回ずつしか出ないのでこれで足りる。
//   ★`launch_failed_days` は任意項目(fixtures を凍結しているため)。
//     値が無いときは置換しない(0 を捏造しない)★
const summaryText = (() => {
  const base = t(item.summary).replace("{n}", String(item.consecutive_problem_days));
  return item.launch_failed_days === undefined
    ? base
    : base.replace("{m}", String(item.launch_failed_days));
})();
  • ★★:391-393 の既存コメント(「モックの『完了率 {n}%』ではなく summary を出す★(指示書F 0-1 の判断)」)も 書き換えること★★ — ★★D-026 の説明を出す、という理由はもう成り立たない(D-111 / 本書)★★
  • ★★fixtures の summary は完成文でプレースホルダを含まない★★ので、replace は何もしない。 ★EN 表示では和文のまま出るが、それは想定内★(D-111 項5)
  • ★★ReportsScreen.tsx:364 の副題(「2日以上連続で△以下または出発失敗の施設」)は本書では直しません★★ (★申し送り③★)。★ついでに直さないこと(差分が増えてレビューしづらくなる)★

7-3. 確認

  • ★JP / EN を切り替えて、★★このカードの文が両方とも言語に追従すること★★を目視する★
  • ★★EN でこのカードだけ和文が残っていたら、キーが1文字違う★★(段階3 の定数と突き合わせる)

この段階の完了条件

  • ★tsc = 0 / lint = 0 / npm test 全パス★
  • ★git diff --stat が lib/i18n.ts と components/features/reports/ReportsScreen.tsx の2ファイル★
  • ★★コミット★★ — 例: git commit -m "feat: 指示書W 段階7 連続問題施設のsummaryをi18n対応にする"

段階8 — ★実測での検証(★コミットなし・DBに1行も書かない★)★

8-1. ★データ層の実測★

★★.env.local を書き換えず、コマンドラインで env を渡すこと★★(★戻し忘れと他セッションへの影響を防ぐ★。 D-108 項8)。★★一時スクリプトには成功時にも必ず process.exit(0) を書くこと★★(★D-111 項9。 main().catch(...) だけだと Prisma の接続が残り、★実際に4時間39分ハングした★★)。

USE_FIXTURES=false DATABASE_URL="mysql://root@localhost:3306/dwatch_local" npx tsx -e "
import { db } from './lib/db';
import { ESCALATION_THRESHOLDS } from './lib/config';
const rows = await db.continuousProblems();
const two = rows.filter(r => r.item.consecutive_problem_days >= ESCALATION_THRESHOLDS.level2Days);
console.log('全行', rows.length, '2日以上', two.length);
console.log(JSON.stringify(rows.map(r => [r.period, r.year, r.month, r.week,
  r.item.facility_id, r.item.consecutive_problem_days, r.item.escalation_level,
  r.item.last_problem_on, r.item.launch_failed_days, r.item.summary])));
const logs = await db.cleanLogs();
const pre = await db.prechecks();
console.log('cleanLogs', logs.length, 'prechecks', pre.length);
const fp = await db.facilityPerformance();
console.log(JSON.stringify(fp.map(r => [r.period, r.year, r.month, r.week,
  r.item.facility_id, r.item.consecutive_problem_days, r.item.status])));
process.exit(0);            // ★★必ず書くこと★★
"

★npx tsx でエイリアス(@/)が解決できない場合★は ★dev サーバ+curl に切り替えてよい★ (/api/reports/continuous-problems?period=weekly&year=...)。★★その場合も下の期待値をすべて実測すること★★。

★★期待値(★作成時点の実測。★着手直前に段階0 で自分が控えた値を基準にすること★★)★★:

# 項目 ★期待★
★1★ ★2日以上の件数★ ★★当週 4件 / 当月 4件 / 確定6期間はすべて0件★★
★2★ ★施設5★ ★★2日 / L2 / launch_failed_days = 1 / summary = "{n}日連続で△以下(うち{m}日は出発失敗)"★★
★3★ ★施設6★ ★★4日 / L3 / launch_failed_days = 3 / summary = 混在テンプレート★★
★4★ ★施設7★ ★4日 / L3★。★launch_failed_days は ★0 か 4 のどちらか★(★混在は施設5・6 の2件だけ★)。★実測して記録すること★
★5★ ★施設9★ ★★6日 / L3 / launch_failed_days = 0 / summary = "{n}日連続で完了率が△以下"★★(★5日連続で△のあと最終日だけ 9.9% で ×。★★「△以下」に畳むので1本の連続になる★★)
★6★ ★L1(1日)★ ★★施設8・施設10 が escalation_level: "L1" で行として返る★★(★データ層は L1 も返す。決定4★)
★7★ ★確定期間の行★ ★★期間キー自体は存在する(8月ぶんの週次・月次)が、連続0日なので ContinuousProblem が0件★★
★8★ ★cleanLogs() / prechecks()★ ★★259 / 27(★1件も変わっていないこと★)★★
★9★ ★★facilityPerformance() の全出力★★ ★★段階0 で控えた内容と完全一致★★(★段階2 のリファクタが値を変えていないこと★)
★10★ ★summary に数値が埋まっていないこと★ ★★戻り値に {n} {m} の文字がそのまま残っている★★(★埋まっていたら決定3 違反★)
★11★ ★last_problem_on★ ★YYYY-MM-DD(時刻を含まない)★。★その日が問題日であること(施設5 なら 70.0 の日)★
★12★ ★launch_failed_days <= consecutive_problem_days★ ★★全行で成立★★

8-2. ★画面(回帰していないこと)★

画面 期待
★/reports★ ★★連続問題施設カードが 500 → 200、当週で 4件★★(★limit 既定10 なので「他N件を表示」は出ない★)。★KPI・施設別・エラー内訳が回帰していないこと★
★/reports(先週・先月に切替)★ ★★「該当施設なし」(0件)が出て、エラーにならない★★
★/detail-log★ ★200 / 259件★
★/dashboard / /alerts★ ★200 / 9 / 9 / 27件★
/facilities / /schedules / /ds-logs / /ds-alerts ★200。回帰していないこと★
  • ★★404 が出たら Edge Runtime バンドル破壊の回帰を疑うこと★★(D-086)
  • ★★next dev は .env.local を共有する別セッションに影響します(D-108 項8)★★ — ★USE_FIXTURES=false npm run start -- -p <別ポート> のように環境変数を都度渡す★

8-3. ★USE_FIXTURES=true での確認★

  • ★★fixtures 側の12件が1件も変わっていないこと★★(★summary は手書きの完成文のまま★)
  • ★★launch_failed_days が無くても画面が壊れないこと★★(★{m} の置換をスキップする分岐★)
  • ★EN 切替で fixtures の summary が和文のまま出るのは★★正常★★(D-111 項5)

この段階の完了条件

  • ★8-1 の12項目・8-2 の回帰確認・8-3 をすべて実測した★
  • ★★DB に1行も書いていないことを確認した★★(★本書に INSERT / UPDATE は1つもありません★)
  • ★★.env.local が着手前と同じ(USE_FIXTURES=true)であることを確認した★★
  • ★git status に lib/ docs/ components/ d-watch-api.yaml 以外の差分が無いことを確認した★ (★README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / .claude/worktrees/** が★着手前と同じ状態のまま★であること。禁止事項2)

段階9 — 記録と push(1コミット)

9-1. docs/decisions.md に追記(★D-113 の見込み。★着手直前に実測した番号を使うこと★)

★見出しの形式は既存に揃える★: ### D-113 \[確定]` 指示書W(連続問題施設を実DBから作る)を実行した`

★最低限入れる項目★:

# 項目
1 ★★continuousProblems() を実装し、監視側の DataSource が全部埋まったこと★★(★残るは点検記録2本(D-111 項6)と Pudu Cloud 待ちの GET /clean-logs/{id}/areas(501)★)
2 ★★確定2(C案): 混在ランは「{n}日連続で△以下(うち{m}日は出発失敗)」★★。★★D-111 項2 の決定4(出発失敗を優先)を差し替えたことを明記する★★(★却下理由: 直近に清掃を実施していても「出発失敗」と書くことになり事実と食い違う / (b) は出発失敗を隠す★)。★実データに2件実在(施設5・施設6)★
3 ★★確定3: △ と × は書き分けず「△以下」に畳む★★(★施設9 は5日連続で△のあと最終日だけ 9.9% で ×★。★モック d-watch-mock.jsx:713 の副題と同じ粒度★)
4 ★決定1: API契約を先に直した★(d-watch-api.yaml の summary の description / example が D-026 の説明になっていた)。★launch_failed_days を任意項目として足した理由(fixtures を凍結するため)★
5 ★★決定2: 確定期間も詳細テーブルから算出した★★(★対応するサマリが無い(D-102 項4)★ / ★D-102 に反しない(禁じているのは「サマリがあるのに再計算すること」)★ / ★当期だけにすると先週・先月でこのカードだけ常に空になり、施設別パフォーマンス表と不整合になる★)
6 ★決定3: i18n はテンプレート方式(方式A)★。★サーバはテンプレートのまま返し、画面が {n} {m} を差し込む★。★ReportsScreen.tsx:394 が t() を通していなかったことも直した★
7 ★決定4: L1 もデータ層は返す★(★「2日以上」は lib/reports/query.ts:236 に実装済み。データ層でも絞ると規則が2箇所になる(D-103)★)
8 ★★決定5: ロボット0台の施設を除外しない★★(★facilityPerformance() が除外しているのは serial_number を出すためで、ContinuousProblem には無い★ / ★除外すると機体を撤去・交換中の施設のエスカレーションが消える(fail-silent)★)
9 ★決定6: 連続0日は行を作らない★(★judgeEscalationLevel() が null を返す。as キャストや ?? "L1" で握り潰さない★)
10 ★★共有した既存関数と、新設した6本★★(★lastEntryInPeriod() / problemRunDates() / launchFailedDaysInRun() / continuousProblemSummary() / reportPeriodKeysFromDates() / continuousProblemsInPeriod()。名前は実装に合わせる★)
11 ★★型を拡張しなかった理由★★(★ScheduleDayResult と foldProblemDaysByDate() の戻り型は toEqual 比較のテストが依存している / consecutiveProblemDaysAtPeriodEnd() のシグネチャも同様★)
12 ★★ランは期間をまたぐので、ランの抽出には「その施設の全期間の行」を渡すこと★★(★踏むと {m} が過小になる★)
13 ★段階8 の実測値(★2日以上が当週4件・当月4件・確定6期間0件 / 施設5=2日(m=1) / 施設6=4日(m=3) / 施設7=4日(m=実測値) / 施設9=6日(m=0) / L1 は施設8・施設10 / cleanLogs 259 / prechecks 27 / テスト数★)
14 ★申し送り(下記「申し送り」節をそのまま移す)★

9-2. docs/handoff.md に追記(★追記34★)

  • ★見出しは ## 2026-09-1X(追記34)★(★番号は実測すること★。★別担当が先に使っていたら35★)
  • ★冒頭の「最新の追記」ブロック(docs/handoff.md:6 付近)も更新すること★
  • ★入れる内容★:
    • ★★「連続問題施設の summary は事実だけ。テンプレート文字列({n} / {m})で返し、画面が数値を差し込む」★★
    • ★★「混在は C案。△ と × は畳む」★★
    • ★「確定期間も詳細から算出する(対応するサマリが無い)」★
    • ★「ロボット0台の施設も出す / L1 もデータ層は返す / 連続0日は行を作らない」★
    • ★次に触る人が踏みやすい落とし穴(★ランは期間をまたぐ★ / ★foldProblemDaysByDate() の戻り型を拡張すると toEqual のテストが全部落ちる★ / ★i18n のキーは1文字違うと fail-silent★)★
    • ★★「監視側の DataSource は全部埋まった。残りは点検記録2本」★★
    • ★申し送り★

9-3. push とこの段階の完了条件

  • ★★コミット(ファイルパスを明示。docs/decisions.md と docs/handoff.md のみ)★★
    • 例: git commit -m "docs: 指示書W完了(D-113)を記録する"
    • ★★作成時点で docs/decisions.md には別担当の D-111 が未コミットのまま入っています★★ — ★★git add docs/decisions.md は D-111 ごと巻き込みます★★。 ★着手時に D-111 が既にコミット済みか確認し、★★未コミットなら、その担当が先にコミットするのを待つか、 巻き込むことを承知のうえでコミットメッセージに明記すること★★★
    • ★★同様に README.md(作成時点で M)と docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md (作成時点で ??)は別作業のもの。コミットに含めないこと★★(禁止事項2)
    • ★★コミット前に git pull --rebase の要否を確認し、★競合したら自分の追記だけを残して番号を取り直すこと★★★
  • ★git push origin develop★
  • ★★本書の冒頭表と末尾「実行状態」表を更新すること★★
  • ★★README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / .claude/worktrees/** に差分が無いことを最後に確認すること★★

★★申し送り — 本書では直さない論点★★

★D-113 に列挙して残すこと★。

# 論点 状態
★①★ ★★全件取得をやめて SQL に絞り込みを降ろす件★★(★ユーザー提起 2026-09-11★)— 現状 cleanLogs() / facilityPerformance() / continuousProblems() はいずれも clean_timetables / clean_status_histories を全期間・全件読む(★DataSource が引数を取らない契約★。lib/db/index.ts:216-218 / D-090 項15)。★年間13万行(docs/spec.md:45)では重い(D-092 項3-3)★ ★★DataSource の契約変更なので全メソッドに波及する★★。★テストDBで実測してから専用の指示書で行う★。★★第7回で取り下げた索引の依頼は、これを実施するなら再依頼が要る★★
★②★ ★.contSummary(components/features/reports/ReportsScreen.module.css:251-257)に省略記号の指定が無く、長い文は折り返して行が伸びる★ — ★施設名(.contFacility)は nowrap で縮まない★ ★★C案の文は22文字前後で境界★★(例:「4日連続で△以下(うち3日は出発失敗)」)。★text-overflow: ellipsis を足すか、行の高さが伸びるのを許容するかの判断が要る★
★③★ ★ReportsScreen.tsx:364 の副題「2日以上連続で△以下または出発失敗の施設」が ★t() 未適用★ かつ ★「2」がハードコード★(ESCALATION_THRESHOLDS.level2Days から読んでいない)★ ★本書では直さない(差分を段階7 の1箇所に限るため)★。★★閾値を変えると副題だけ嘘になる★★
★④★ ★★当期は同じ全件スキャンが2〜3回重なる★★ — reportKpis() / facilityPerformance() / continuousProblems() がそれぞれ独立に全件を読む(★/reports を1回開くと3回★) ★①と一体で見直す★
★⑤★ ★periodRange() が解けないと一覧APIが 500★(★本書の continuousProblems() も既存2箇所と同じく throw する★) ★指示書V 申し送り⑤ と同じ。「その行だけ落とす」に変えるか要検討★
★⑥★ ★★consecutiveProblemDaysAtPeriodEnd() は「期末時点の連続」であって「期間中の最長連続」ではない★★(A案。D-103)— ★期間の途中で5日連続していても、期末の日が問題なしなら 0★ ★★施設別パフォーマンスと同じ規則なので意図した挙動★★。★確定期間が0件になる主因でもある★。定義を変えたくなったら D-103 から見直す
★⑦★ ★論理削除された施設(facilities.deleted_at != null)の清掃ログは、施設名が引けないので行が作られない(fail-silent)★ ★facilityPerformance() の buildItem() と同じ作法にそろえてある★。★方針として妥当かの確認が要る(指示書V 申し送り⑥ と同種)★

完了条件(全体)

# 条件
★1★ ★d-watch-api.yaml の summary の description / example が事実ベースの3種に書き換わり、launch_failed_days(任意項目)が足されている★
★2★ ★lib/types/index.ts が YAML に追従している(launch_failed_days?: number)★
★3★ ★★prismaSource.continuousProblems() が notImplemented でなくなり、USE_FIXTURES=false で 200 が返る★★
★4★ ★確定期間(先週・先月)も詳細テーブルから算出しており、期間キーがデータの日付から列挙されている(todayJst() による当期の場合分けが無い)★
★5★ ★summary が★テンプレート文字列★で返り、画面が {n} {m} を差し込んでいる。★lib/i18n.ts に3キーの EN 対訳がある★★
★6★ ★★混在ランが C案の文面になっている(実データの施設5・施設6 で確認)★★
★7★ ★△ と × を書き分けていない(施設9 が1本の連続として出る)★
★8★ ★L1 の行がデータ層から返り、API 応答には含まれない(絞り込みは lib/reports/query.ts のまま)★
★9★ ★ロボット0台の施設が除外されていない(prisma.facility.findMany に include: { robots: ... } が無い)★
★10★ ★連続0日の行が作られていない。★as / ! / ?? "L1" を使っていない★★
★11★ ★computeConsecutiveProblemDays() / foldProblemDaysByDate() / consecutiveProblemDaysAtPeriodEnd() / isFutureSchedule() / weekOfMonth() を共有しており、同等の処理を別実装していない★
★12★ ★★ScheduleDayResult / foldProblemDaysByDate() の戻り型と consecutiveProblemDaysAtPeriodEnd() のシグネチャが変わっていない★★
★13★ ★期待値表(段階2 の8 / 段階3 の 10+8+8 / 段階4 の8 / 段階5 の15)がすべてテストになっている★
★14★ ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス(★テスト数は着手前より増えている★)★
★15★ ★段階8 の実測: 2日以上が★当週4件・当月4件・確定6期間0件★ / 施設5=2日(m=1) / 施設6=4日(m=3) / 施設9=6日(m=0) / L1 は施設8・施設10★
★16★ ★★cleanLogs() = 259件 / prechecks() = 27件 / facilityPerformance() の全出力が段階0 と完全一致★★
★17★ ★prisma/ / lib/fixtures/ / lib/judge/ / lib/reports/query.ts / app/api/ に差分が無い★
★18★ ★README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / .claude/worktrees/** に触っていない★
★19★ ★docs/decisions.md(★D-113★)と docs/handoff.md(追記34)に記録した★
★20★ ★8コミット(段階1 / 2 / 3 / 4 / 5 / 6 / 7 / 9)で push した★
★21★ ★.env.local が USE_FIXTURES=true に戻っている★
★22★ ★★DB に1行も書いていない★★

実行状態

項目 内容
状態 ★完了★
実行日 2026-09-11
決定番号 ★D-116★(着手時点の最終番号はD-115。指示書Y完了後に実行したため見込みのD-113ではない)
コミット e24e177(1) / 985ce60(2) / e136256(3) / 1aba267(4) / f73b6b7(5) / 2974653(6) / b214449(7) / 本コミット(9)
実測値 tsc0 / lint0 / npm test 45ファイル1023テスト / 当週・当月とも2日以上4件(施設5=2日m1/施設6=4日m3/施設7=4日m0/施設9=6日m0) / L1が施設8・10 / facilityPerformance()全出力が段階0と完全一致。詳細は docs/decisions.md の D-116 / docs/handoff.md 追記36 を参照