Newer
Older
client_dwatch / docs / handoff / 指示書AI_要レスキュー店舗と詳細ログの期間上限.md

指示書AI — 要レスキュー店舗(3条件)と、詳細ログの期間上限

作成: 2026-09-15 / ブランチ: develop / 前提: D-134(docs/decisions.md の最終決定番号。★着手時に grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3 で実測し直すこと★)


★Cursor への指示文(ここから下をそのまま貼ってください)★

D-Watch の実装をお願いします。ブランチは develop です。

■ 最初に読むもの(この順で、全部)
  1. CLAUDE.md(全文。特に §4.1 判定基準 / §4.3 端数処理 / §4.5 エスカレーション /
     §7 禁止事項 / §9 実装規約)
  2. docs/handoff/指示書AI_要レスキュー店舗と詳細ログの期間上限.md(全文)
  3. 指示書AI が名前を挙げている既存ファイル(lib/db/clean-log-mapping.ts の
     ConsecutiveInput 周辺、lib/db/consecutive-source.ts、lib/judge/escalation.ts、
     lib/judge/kpi.ts、lib/config/index.ts、components/features/dashboard/DashboardScreen.tsx)

■ やること
  指示書AI の 段階1 から 段階9 までを、番号順に実行してください。
  段階を飛ばさないでください。特に 段階3(判定の純粋関数)は
  「期待値表をテストに落としてから実装する」順序を崩さないでください。

■ 段階ごとに必ずやること
  1. npx tsc --noEmit    → 0 であること
  2. npm run lint        → 0 であること
  3. npm test            → 全パス
  4. コミットする(★段階ごとに1コミット★。まとめない)

■ コミットの決まり
  ・git add -A は使わないでください。必ずファイルパスを明示してください
    (過去に別作業の途中ファイルを巻き込む事故が起きています)
  ・コミットメッセージは日本語。何を変えたかではなく「なぜそうしたか」を1〜2行入れてください
  ・push はしないでください

■ 絶対に触らないファイル
  README.md / docs/code-map.md /
  docs/handoff/管理画面_設計検討_20260910.md / .claude/worktrees/** /
  d-watch-mock.jsx / lib/dashboard/aggregate.ts / lib/judge/advice.ts / lib/db/prisma.ts

■ 絶対にやらないこと
  ・生SQL($queryRaw / $executeRaw / *Unsafe / Prisma.raw)— ESLint が落とします
  ・閾値のハードコード — 必ず lib/config/index.ts に置いてください
  ・メインDBのスキーマ変更・マイグレーション作成
  ・表示用に丸めた値で判定すること
  ・新しい .module.css に font-family を書くこと(日本語フォントが落ちて漢字が中華字形になります)
  ・仕様が不明なところを推測で実装すること

■ 迷ったら
  推測で埋めず、手を止めて「ここが決まっていません」と報告してください。
  指示書AI の §13(実装者へ — 特に踏みやすい落とし穴)に、
  例外もテスト失敗も出ないまま壊れる箇所を10個挙げてあります。着手前に読んでください。

■ 動作確認
  dev server は npm run dev で立ててください(.env.local は既にテストDBを向いています)。
  ★テストDBのデータは 2026-09-13 で終わっています★。今日の日付で開くと全部0件になるので、
  /api/dashboard?date=2026-09-13 と ?date=2026-09-12 で確かめてください。
  期待される結果は指示書AI の §12 に書いてあります。

■ 終わったら
  次を報告してください。
  ・段階ごとのコミットハッシュ
  ・tsc / lint / test の結果(テスト本数も)
  ・指示書どおりにできなかったところと、その理由
  ・判断に迷って自分で決めたところ(あれば全部)

0. この指示書の位置づけ

ユーザーから新要件が4件来ました(2026-09-15)。★そのうち、いま着手できる2件だけ★を扱います。

要件 この指示書で 理由
★要レスキュー店舗★ ★やる(3条件)★ 独立している
★詳細ログの期間上限を2年に★ ★やる★ 設定値1本と検証の変更で済む
★CSVの上限と、出せないことの表示★ ★やる★ ★上を広げた結果として要る(ユーザー指示 2026-09-15)★
分類(ホールディングス/屋号/店舗)の復活 ★やらない★ ★TeamWorks への確認待ち★(TeamWorks確認事項_第9回_20260915.md の 6章・7章)
総清掃面積の新画面 ★やらない★ ★分類の絞り込みに乗るため、上が先★

★★着手前に必ず CLAUDE.md を全文読むこと。★★ 特に §4.1(判定基準)/ §4.3(端数処理)/ §4.5(エスカレーション)/ §7(禁止事項)/ §9(実装規約)。


1. ★ユーザーが決めたこと(原文と、その帰結)★

1-1. 要レスキューの4条件 → ★3条件にする★

ユーザー原文(2026-09-15):

・ダッシュボードの変更 要レスキュー店舗をダッシュボード上の詳細ログの上に表示 定義は出発失敗3日異常、連続問題レベル3、完了率急落前日比OO%、未実施3日連続 当該日の清掃詳細ログの情報を元に表示

★「出発失敗」と「未実施」は、現在のコードでは同じ判定です★ (notExecutedFromHistory()、lib/db/clean-log-mapping.ts:152-157)。

ユーザーに確認したところ「別物」との回答でしたが、★実データを調べた結果、別物にできませんでした★。

  • clean_status_histories.started_at は、テーブル定義書に ★「清掃スケジュールの清掃開始日時を格納する」★ と書かれている(= clean_timetables.start_at の写し)
  • 実測: 本清掃538行すべてで TIMESTAMPDIFF(MINUTE, t.start_at, h.started_at) = 0。 ★30分以上の遅延は0件★
  • 実開始時刻が入りそうな clean_task_reports は ★0行★
  • robot_instruct_histories / section_robot_statuses も ★0行★
  • clean_statuses.clean_status_trans(1=清掃指示 で止まる=出発失敗を表せる唯一の列)は ★履歴テーブルに存在しない★ため、過去を遡れない

★したがって、猶予30分の計算をしようとしても材料がありません。★ ★D-091(「予定+猶予30分を当方で計算しない」)を覆すかどうか以前の問題です。★

★ユーザー決定(2026-09-15): 3条件で先に実装する。★ TeamWorks から実開始時刻の所在について回答が来たら、4つ目の条件として足す (照会済み: TeamWorks確認事項_第9回_20260915.md 11章)。

確定した3条件:

# 条件 判定
★R-1★ ★未実施が3日以上連続★(=出発失敗と同義) completion_rate === null が3スケジュール日以上連続
★R-2★ ★連続問題レベル3★ escalation_level === "L3"
★R-3★ ★完了率が前日比20%以上の急落★ 後述

1-2. 急落の閾値と数え方

ユーザー原文:

数値は一旦20%を閾値にしてください、ただし変更は可能にしてください

差(ポイント)か比(%)かを確認したところ:

比で

★確定:★

(前回の完了率 − 当日の完了率) ÷ 前回の完了率 ≧ 0.20

★= 完了率が前回の8割を切ったら「急落」。★

★20 は設定値。ハードコード禁止(CLAUDE.md §7)。★

1-3. 監督が決めたこと(★実装前に読むこと。覆すなら理由を残すこと★)

ユーザーに個別の確認を取らず、既存の規則との整合から監督が決めた事項です。

# 決定 根拠
★A★ ★「前日」はカレンダー前日ではなく、前回のスケジュール存在日★ CLAUDE.md §4.5 / D-026 が「連続はスケジュール存在日の連続」と定めている。急落だけカレンダー日にすると規則が二本立てになる。★実データに1件だけ差の出る例がある — 施設7が 09-07 → 09-09(09-08 は予定なし)。カレンダー前日だと算出不可、前回スケジュール日だと 94.7% → 72.5% で急落として拾う★
★B★ ★前回の完了率が 0% のときは急落としない★ 0 で割れない。下がりようもない
★C★ ★前回が未実施(null)のときは急落判定から外す★ 比べる相手が無い。未実施は R-1 で拾う
★D★ ★当日が未実施(null)のときも急落判定から外す★ 同上。★「未実施を 0% とみなして急落にする」ことはしない★ — completionRateFromHistory() が未実施を 0 ではなく null にしているのは意図的(lib/db/clean-log-mapping.ts:168-173)
★E★ ★完了率が上がったときは拾わない★ 「急落」なので低下方向のみ。絶対値で見ない

1-4. 施設に複数ロボットがあるときの完了率

ユーザー確認済み(「それでOK」):

★面積で加重する。★

施設の1日の完了率 = Σclean_area ÷ Σtask_area × 100

★CLAUDE.md §4.2 が「清掃完了率 = Σ清掃面積 ÷ Σ清掃対象面積」と面積ベースを定めているため。★ ★★単純平均にしないこと★★ — 1000㎡の区画と100㎡の区画を同じ重みで扱うことになる。

★★注意: これは既存の completion_rate(1行=1清掃エリアごとの率)とは別の数です。★★ ★混同を防ぐため、名前を変えること★(後述 §5 の facilityDayRate)。

1-5. 当該日に予定が無い施設の扱い

ユーザー原文:

出す、がダッシュボードの割合計算には含めない

★確定:★

  • ★当該日に清掃予定が無い施設も、要レスキューには出す★ (各施設の「当該日以前で最も新しいスケジュール存在日」の状態で判定する)
  • ★★ダッシュボードの数値カード(成功 / 失敗 / 未実施 / 成功率)は一切変更しない★★ — lib/dashboard/aggregate.ts と app/api/dashboard/route.ts の summary に手を入れない

★この指定は重要です。★ 実データに例があります:

★施設7「メガバリューマート緑野店」は 2026-09-12 時点で問題4日連続(L3)だが、2026-09-13 には予定が無い★

★「当該日のログだけ」を母集団にすると、一番出すべき施設が消えます。★

1-6. 詳細ログの期間

ユーザー原文:

・詳細ログは2年分見れるように 31日なのを2年をデフォに、変更可能はそのまま

確認したところ:

上限だけ、未指定で開いた場合や、店舗指定のみの場合は上限無し、絞り込むときの上限を2年

★確定:★

状況 上限
date_from と date_to の両方が指定されている ★2年★(今の31日を置き換え)
日付が指定されていない ★上限なし★
店舗(施設名)だけ指定されている ★上限なし★(=日付が無いので上記と同じ)
画面を開いたときの既定 ★今日1日のまま★(変更しない)

★★「デフォ」を画面の初期表示のことだと解釈しないこと★★ — 現在の画面の既定は DetailLogScreen.tsx:43-44 の useState(todayJst()) で ★今日1日★ です。31日は既定ではなく上限です。


2. ★触ってはいけないもの★

対象 理由
README.md ★別作業が進行中★
docs/code-map.md ★同上★
docs/handoff/管理画面_設計検討_20260910.md ★同上★
.claude/worktrees/** ★別ブランチの作業コピー★
d-watch-mock.jsx ★モックは原型として保存する★(D-120 / D-121 の前例。§8 参照)
lib/dashboard/aggregate.ts ★§1-5。数値カードを変えない★
lib/judge/advice.ts ★R1〜R4 のアドバイス生成。要レスキューとは別物★
lib/db/prisma.ts ★着手時点で未コミットの変更が乗っている★。触らない

★★禁止★★(CLAUDE.md §7 と、これまでの決定):

  • ❌ ★git add -A★ — 必ずファイルパスを明示する
  • ❌ ★生SQL★($queryRaw / $executeRaw / *Unsafe / Prisma.raw)— ESLint が error で落とす(D-122)
  • ❌ ★閾値のハードコード★ — 必ず lib/config/index.ts に置く
  • ❌ ★メインDBのスキーマ変更・マイグレーション作成★
  • ❌ ★表示用に丸めた値で判定すること★
  • ❌ ★モックに残っている実在の住所・電話番号をそのまま使うこと★
  • ❌ ★仕様が不明な箇所を推測で実装すること★ — 止まって確認する

3. 段階1 — 設定値を足す

対象: lib/config/index.ts

ESCALATION_THRESHOLDS(:20-25)の直後に足す。

/**
 * 完了率の「急落」判定(ユーザー指示 2026-09-15 / 指示書AI)
 *
 * ★比(%)で見る。差(ポイント)ではない★ — ユーザー確定(2026-09-15「比で」)。
 *   (前回の完了率 − 当日の完了率) ÷ 前回の完了率 ≧ dropRatio
 *   = 完了率が前回の (1 - dropRatio) 倍を下回ったら急落。
 *
 * ★差で見ると、元から完了率が低い施設を拾えない★ —
 *   前日30% → 当日18% は「12ポイント低下」だが「40%低下」。
 *   ユーザーは後者を拾う側を選んだ。
 *
 * ★「前回」はカレンダー前日ではなく、前回のスケジュール存在日★
 *   (D-026 / `CLAUDE.md` §4.5 の「連続」の数え方に揃えた。指示書AI 決定A)。
 */
export const COMPLETION_DROP = {
  /** 前回比でこの割合以上下がったら「急落」(0.2 = 20%) */
  dropRatio: 0.2,
} as const;

★★0.2 と書くこと。20 と書いて後で100で割らないこと★★ — 単位が二重になると、次に値を変える人が どちらの単位か分からなくなる。

検証

npx tsc --noEmit

4. 段階2 — 連続日数の入力に面積を足す

4-1. なぜ要るか

急落判定(R-3)は ★施設×日の面積加重完了率★ を必要とします(§1-4)。 現在の ConsecutiveInput(lib/db/clean-log-mapping.ts:286-301)は completion_rate しか持たず、 ★面積を持っていません★。

★★R-1(未実施の連続)と R-2(L3)は、既存の completion_rate だけで判定できます★★ — completion_rate === null が「未実施=出発失敗」と同値であることは、 lib/db/clean-log-mapping.ts:176-180 の不変条件として既に書かれています。★新しい列は要りません。★

面積が要るのは R-3 だけです。

4-2. ★この変更のコストを理解してから着手すること★

loadConsecutiveInputs() の docstring(lib/db/consecutive-source.ts の select の直上)には こう書かれています。

★★select の列を増やさないこと★★ — ここに列を足すとそのまま本番 (保持2年・約26万行)の転送量になります。

★この指示書は、その禁を意図的に破ります。★ 根拠:

  • 足すのは clean_area / task_area の2列(いずれも double、8バイト)
  • 本番想定 26万行 × 16バイト = ★約4MB★
  • ★R-3 は施設×日の面積加重を必要とし、率の再合成では代用できない★ (率の平均は面積加重にならない)

★★docstring を書き換えること★★ — 「増やすな」とだけ書いてあると、次の人が 「なぜ2列だけ増えているのか」を判断できません。★増やしてよい条件と、増やした理由を書く★。

4-3. 変更する箇所

(1) lib/db/consecutive-source.ts

  • ConsecutiveHistoryRow に clean_area: number / task_area: number を足す
  • 履歴の select に clean_area: true, task_area: true を足す (★clean_timetables 側の select は増やさない★)
  • consecutiveInputsFromRows() の .map() で ConsecutiveInput に詰める。 ★履歴が無い行は clean_area: 0 / task_area: 0★(?? 0)

(2) lib/db/clean-log-mapping.ts

ConsecutiveInput に足す:

  /**
   * 面積の生値(急落判定の面積加重に使う。指示書AI 段階2)。
   *
   * ★履歴が無い行・未実施の行は 0★(実データでは `clean_error = 1` の行が
   *   `task_area = 0` / `clean_area = 0` になっている)。
   * ★★`completion_rate` を面積から再計算しないこと★★ — `completion_rate` は
   *   1行(=1清掃エリア)の率で、未実施は `null`。この2つは別の数です。
   */
  clean_area: number;
  task_area: number;

(3) fixtures 側

lib/fixtures/ から ConsecutiveInput を組み立てている箇所があれば同じ2列を足す。 ★無ければ何もしない(探すこと)★。

4-4. 既存テストへの影響

ConsecutiveInput を組み立てているテストが落ちます。★必須の2列なので全部直す必要があります★。

★★clean_area / task_area を optional(?)にして逃げないこと★★ — optional にすると、詰め忘れた呼び出し側が undefined のまま通り、 ★面積加重が静かに 0/0 になって率が null に化けます(例外もテスト失敗も出ません)★。

検証

npx tsc --noEmit
npm run lint
npm test

★この段階ではテストが落ちたままでも構いません★(次の段階で期待値を足すため)。 ★ただし「どのテストがなぜ落ちているか」を把握してから次に進むこと。★


5. 段階3 — 判定を純粋関数にする(★テストが先★)

5-1. 置き場所

★新規ファイル lib/judge/rescue.ts★ と lib/judge/__tests__/rescue.test.ts。

★lib/judge/ に置く理由★: CLAUDE.md §9.1 が「判定ロジック(◎○△×、L1〜L3、KPI計算、丸め)」を lib/judge/ に置くと定めており、要レスキューはその仲間です。 ★DBアクセスを混ぜないこと★(§9.4「判定関数は純粋関数にする」)。

★vitest.config.ts の include は lib/** と app/** だけで、components/** のテストは走りません★ (D-120 項2)。★判定を画面側に書くと、テストが1本も走りません。★

5-2. ★期待値表を先に作ること★

CLAUDE.md §6-2 / §9.4:

判定ロジックには必ずテストを書く。★期待値表を先に作り、それをテストで固定する★

★★実装より先にこの表をテストに落とすこと。★★

表1: R-1(未実施が3日以上連続)

「スケジュール存在日」を古い順に並べたときの、当該日時点の連続数。

# 入力(★は未実施) 期待
1-1 [○] ★false★(1日)
1-2 [★] ★false★(1日)
1-3 [★, ★] ★false★(2日)
1-4 ★[★, ★, ★]★ ★★true★★(3日)
1-5 [★, ★, ★, ★] ★true★(4日以上も拾う。「3日以上」)
1-6 [★, ○, ★, ★] ★false★(途中で切れて2日)
1-7 [★, ★, ★, ○] ★false★(★当該日が正常なら、過去に3日連続があっても拾わない★)
1-8 [](行なし) ★false★
1-9 ★予定のない日を挟む★ [★(9/7), ★(9/9), ★(9/10)](9/8 は予定なし) ★★true★★(★カレンダー日ではなくスケジュール存在日で数える。D-026★)
1-10 同一日に2台、片方だけ未実施 ★その日は「未実施の日」とする(OR)★。D-109 の A案に揃える
1-11 同一日に同一ロボットが2回清掃、片方だけ未実施 ★同上(1日に畳む)★

表2: R-2(L3)

★既存の judgeEscalationLevel()(lib/judge/escalation.ts:52-59)をそのまま使う。★ ★★新しく数え直さないこと★★(D-109 の再発防止「連続日数の算出は1本に集約する」)。

# 連続問題日数 期待
2-1 2 ★false★(L2)
2-2 3 ★★true★★(L3)
2-3 10 ★true★
2-4 0 ★false★

表3: 面積加重の施設日完了率(facilityDayRate)

# 入力(clean_area / task_area の組) 期待
3-1 [(900, 1000)] ★90★
3-2 [(900, 1000), (100, 1000)] ★50★(= 1000/2000)★単純平均も50なので、これだけでは加重を確かめられない★
3-3 ★[(900, 1000), (10, 100)]★ ★★82.7…★★(= 910/1100)。★単純平均なら 50 になる★ — ★この1本で加重を固定する★
3-4 [(0, 1000)] ★0★(0%で実施した。★null ではない★)
3-5 [(0, 0)](未実施のみ) ★★null★★(0除算を捏造しない)
3-6 [] ★null★
3-7 [(0, 0), (900, 1000)](未実施1台+正常1台) ★90★(= 900/1000。★未実施行は分母に 0 を足すだけ★)
3-8 [(500, 0)](分母0だが分子あり) ★★null★★(★∞ や NaN を返さない★)

★lib/judge/kpi.ts:36 の completionRate() をそのまま使えば 3-5 / 3-6 / 3-8 は自動的に満たされます★ (内部の rate() が分母0で null を返す)。★自前で割り算を書かないこと。★

表4: R-3(急落)— ★dropRatio = 0.2 のとき★

# 前回 → 当日 低下 期待 根拠
4-1 100 → 80 ちょうど20% ★★true★★ ★境界は「以上」★
4-2 100 → 80.1 19.9% ★false★
4-3 100 → 79.9 20.1% ★true★
4-4 30 → 18 40% ★true★ ★差なら12ポイントで拾えない。比を選んだ意味がここに出る★
4-5 80 → 60 25% ★true★
4-6 60 → 80 ★上昇★ ★★false★★ ★決定E★
4-7 ★0 → 0★ — ★★false★★ ★決定B。0で割らない★
4-8 ★0 → 50★ — ★false★ ★決定B★
4-9 ★null → 50★(前回が未実施) — ★★false★★ ★決定C★
4-10 ★50 → null★(当日が未実施) — ★★false★★ ★決定D。R-1 で拾う★
4-11 ★null → null★ — ★false★
4-12 前回が存在しない(初日) — ★★false★★ 比べる相手が無い
4-13 ★予定のない日を挟む★ 94.7(9/7) → 72.5(9/9) 23.4% ★★true★★ ★決定A。9/8 に予定が無くても 9/7 と比べる★

★★4-1 の境界を必ずテストに入れること★★ — CLAUDE.md §9.4 は 「90.0 / 90.1 / 70.0 / 70.1」のように★境界の両側を必ず含める★と定めている。

表5: 3条件の合成

# R-1 R-2 R-3 期待
5-1 false false false ★要レスキューでない★
5-2 true false false ★要レスキュー★(理由: 未実施3日以上)
5-3 false true false ★要レスキュー★(理由: L3)
5-4 false false true ★要レスキュー★(理由: 急落)
5-5 ★true★ ★true★ ★true★ ★要レスキュー。★★理由は3つとも返す★★(1つに絞らない)★

★★理由を配列で返すこと★★ — 「なぜレスキューが要るのか」が運用担当の判断材料そのものです。 ★1つに丸めると、現場に何を伝えるかが決まりません。★

5-3. 返す形

/** 要レスキューの理由(複数該当したら全部入る) */
export type RescueReason = "not_executed_streak" | "escalation_l3" | "completion_drop";

export interface RescueJudgement {
  /** 3条件のいずれかに当たったか */
  required: boolean;
  /** 当たった理由(★空配列なら required は false★) */
  reasons: RescueReason[];
  /** 表示用の付帯情報。★判定には使わない★ */
  not_executed_days: number;
  consecutive_problem_days: number;
  /** 面積加重。未実施・データなしは null */
  completion_rate: number | null;
  previous_completion_rate: number | null;
  /** 低下の比(0.25 = 25%低下)。算出できないときは null */
  drop_ratio: number | null;
}

★★文言(日本語)をこの層で作らないこと★★ — D-111 の決定です。

連続問題施設の summary は★事実だけ★を組み立てる(★原因も助言も書かない★)。 テンプレート文字列のまま返し、数値はフロントが差し込む(EN切替で和文が残るのを避けるため)

★RescueReason は識別子(enum)で返し、日本語は画面側の t() で作ること。★


6. 段階4 — データを取る

対象: lib/db/index.ts

6-1. ★入力は loadConsecutiveInputs() から取ること★

★★新しい全期間クエリを書かないこと。★★

lib/db/clean-log-mapping.ts の computeConsecutiveProblemDays() の docstring に明記されています:

★入力を作る責務は loadConsecutiveInputs()(lib/db/consecutive-source.ts)の1本に切り出してある★ — ★★cleanLogs() / facilityPerformance() / continuousProblems() はその1本を呼ぶこと★★ (D-109 / D-103 の再発防止。連続日数を2箇所で別々に計算しない)

★要レスキューも、この1本を通します。★ ★4本目の呼び出し元になります。★

6-2. ★遡り幅に注意★

loadConsecutiveInputs() は「表示期間 + 遡りぶん」を読みます。要レスキューは:

  • R-1(3日連続)には★3スケジュール日ぶん★
  • R-2(L3)には★既存の遡り幅がそのまま要る★
  • R-3(前回との比較)には★1スケジュール日ぶん★

★既存の遡り幅(表示上限+1 ×7 カレンダー日、既定なら217日)で足ります。★ ★★遡り幅を要レスキュー用に別途縮めないこと★★ — R-2 が L3 を取りこぼします。

6-3. ★当該日に予定が無い施設の扱い(§1-5)★

各施設について、「当該日以前で最も新しいスケジュール存在日」を求め、
その日の状態で3条件を判定する。

★★「当該日のスケジュール存在日だけ」で絞らないこと★★ — 施設7の例(09-12 に L3、09-13 は予定なし)が 消えます。

★実装の目安★: loadConsecutiveInputs() の返す ConsecutiveInput[] を施設ごとに分け、 scheduled_at の日付が当該日以下のものだけを残し、その中の最終日を「評価日」とする。

6-4. DataSource への追加

interface DataSource(lib/db/index.ts:151-262)に足し、 ★fixtureSource と prismaSource の両方に実装すること★。

★★fixtures 側を notImplemented にしないこと★★ — ダッシュボードは USE_FIXTURES=true でも開きます。 ★fixtures で画面が落ちると、実装した本人が最初に踏みます。★

★fixtures 側は lib/fixtures/ の既存データから同じ判定を通すこと★(CLAUDE.md §9.5: 「判定ロジックは切替の外側に置く」)。★fixtures 用に別の判定を書かない。★

6-5. 並び順と件数

★契約に明記すること★(D-052 / D-055 の前例: ソート順は契約に書く)。

  • 並び: ★理由の重さ順ではなく、連続問題日数の降順 → 施設ID昇順★ (★安定した順序にすること★。同値のときに順番が揺れると、毎朝見る人が混乱する)
  • 件数: ★上限を設ける★。既存の詳細ログ(コンパクト版)が5件なので★同じ5件★に揃える。 ★★lib/config/index.ts に置くこと★★(ハードコード禁止)

★★訂正(2026-09-15。指示書AJ 段階1 で撤回)★★

★上の「並び=連続問題日数の降順」「件数=上限5件」の2つは、指示書AJ で撤回しました。★ この組み合わせで ★急落だけで該当する施設が構造的に一覧から落ちていました★ (急落は「昨日まで正常 → 今日落ちた」なので連続日数が1〜2にしかならず、 連続日数の降順で並べると必ず L3 の後ろに来る。実データで7件中2件が黙って消えていた)。 ★これは監督(指示書AI の作成者)の指定の誤りであり、実装者に落ち度はありません。★

現在の正:

  • 並び = ★reasons の件数の降順 → consecutive_problem_days の降順 → facility_id の昇順★
  • 件数 = ★API に上限を設けない★。初期表示5件+「もっと表示」は★画面側の責務★ (RESCUE_INITIAL_VISIBLE。旧 RESCUE_LIST_LIMIT の改名)

詳細は docs/handoff/指示書AJ_要レスキューの見せ方とサイドバーのスクロール.md §2 / §4。


7. 段階5 — API契約と、ルート

7-1. ★YAML を先に直すこと★

CLAUDE.md §6-1:

★API契約を変えたくなったら、先に d-watch-api.yaml を直す。★ 実装だけ変えてYAMLと乖離させない

7-2. どこに生やすか

★GET /dashboard のレスポンスに rescue_required を足す。★ 新規エンドポイントは作らない。

★理由★: security_banner と同じ「ダッシュボード専用の配列」で、形が揃う。 画面の往復も増えない。

★★注意★★: d-watch-api.yaml:170-172 に

コンパクト版清掃詳細ログは本APIには含めない。 GET /clean-logs?result=problem&date_from=当日 を再利用すること

とあります。★これは「既存の一覧APIで足りるものを二重に作るな」という趣旨★であり、 要レスキューは CleanLogItem だけでは組み立てられない(前日比も未実施連続日数も入っていない)ので 抵触しません。★ただし、その旨を description に書き添えること★ — 次に読む人が同じ疑問を持ちます。

7-3. スキーマ案

                  rescue_required:
                    type: array
                    description: |
                      要レスキュー店舗。指定日時点で3条件のいずれかに当たる施設。
                      ★指定日に清掃予定が無い施設も含む★ — 各施設の「指定日以前で最も新しい
                      スケジュール存在日」の状態で判定する(指示書AI)。
                      ★summary の数値カード(success/failed/not_executed/success_rate)には
                      影響しない★ — 予定の無い施設を分母に入れないため(ユーザー指示 2026-09-15)。
                      並びは consecutive_problem_days の降順、同値なら facility_id の昇順。
                      件数は RESCUE_LIST_LIMIT 件まで。
                    items:
                      type: object
                      properties:
                        facility_id:   { type: integer, example: 7 }
                        facility_name: { type: string,  example: "メガバリューマート緑野店" }
                        evaluated_on:  { type: string, format: date, example: "2026-09-12" }
                        reasons:
                          type: array
                          description: |
                            当たった条件。★複数該当したら全部入る★。
                            文言はクライアント側で作る(D-111。EN切替で和文が残るのを避けるため)。
                          items:
                            type: string
                            enum: [not_executed_streak, escalation_l3, completion_drop]
                        not_executed_days:        { type: integer, example: 3 }
                        consecutive_problem_days: { type: integer, example: 4 }
                        completion_rate:          { type: number, nullable: true, example: 8.5 }
                        previous_completion_rate: { type: number, nullable: true, example: 73.4 }
                        drop_ratio:               { type: number, nullable: true, example: 0.884 }
                      required: [facility_id, facility_name, evaluated_on, reasons]

★completion_rate は丸めない生値を返す★(CLAUDE.md §9.3「数値の丸めは表示層でのみ行う」)。

7-4. ルート

app/api/dashboard/route.ts の body(:109-113)に1キー足す。

★★summary / schedule_progress / security_banner の中身を変えないこと。★★

★型★: lib/types/index.ts の DashboardResponse に足す。★YAML のスキーマ名と揃える★(§9.2)。


8. 段階6 — ダッシュボードに出す

8-1. 入れる位置

components/features/dashboard/DashboardScreen.tsx:

:97-125  数値カード4枚のグリッド(</div> が :125)
           ★★ここに入れる★★
:127     <div className={styles.card}>   ← 清掃詳細ログ(コンパクト版)

★:125 の </div> と :127 の <div> の間★。ユーザー指定(「詳細ログの上に表示」)どおり。

8-2. ★モックに対応する画面がありません★

実測: d-watch-mock.jsx に「要レスキュー」「レスキュー」のヒット ★0件★。

CLAUDE.md §5 は「モックが画面レイアウト・文言・遷移の正」と定めています。★前例が2件あります★:

決定 何をしたか
★D-120★ モックに無いページ送りUIを新規に作った。「本決定は §5 の例外」と明記し、★ユーザーの原文を決定に転記★
★D-121★ モックと違う色にした。同上+★コード側にも理由コメントを残した★

★同じ作法を踏襲すること:★

  1. ★d-watch-mock.jsx は1文字も書き換えない★
  2. docs/decisions.md に ★「§5 の例外。ユーザー指示(2026-09-15)に基づく」★ と記録し、 ★指示の原文を引用する★
  3. ★DashboardScreen.tsx の該当ブロックの直上に、理由コメントを残す★ — ★★書かないと、次にモックと突き合わせた人が「余計なものがある」と判断して消します★★

8-3. 表示の作法

  • 色は ★app/globals.css の CSS変数★を参照する。★JS 側で色を持たない★ (ReportsScreen.module.css:1-5 の作法)。★新しい色を発明しない★
  • ★font-family を新しい .module.css で宣言しないこと★ — 宣言すると和文フォントが落ちて ★中国語フォントにフォールバックし、漢字が中華字形になります★(CLAUDE.md §0-3)
  • 文言は useTheme() の t() を通す。★JP/EN の器を壊さない★(§9.6)
  • ★reasons から日本語を作るのはここ★。段階3 の純粋関数は識別子だけを返す(D-111)
  • ★0件のときは、ブロックごと出さない★(「要レスキューの店舗はありません」と出すか、 ブロックを隠すかは実装者判断。★ただしどちらかに決めて、理由をコメントに書くこと★)

9. 段階7 — 詳細ログの期間上限を2年にする

★★段階6 までとは別のコミットにすること。★★ D-120 項6 の教訓: ★2つを同じコミットに入れると、どちらが壊したのか切り分けられない★。

9-1. 変える箇所

# ファイル:行 現状 変更
1 lib/config/index.ts:266 MAX_DATE_RANGE_DAYS = 31 ★731★(2年。うるう年を含みうるので 730 ではなく 731)
2 lib/clean-logs/query.ts:86-92 超過で422 ★そのまま。定数を読んでいるので自動的に追従する★
3 components/features/detail-log/DetailLogScreen.tsx:129-132 画面側の先回り検証 ★そのまま★
4 components/features/detail-log/DetailLogScreen.tsx:43-44 useState(todayJst()) ★★変更しない★★(既定は今日1日のまま。§1-6)
5 d-watch-api.yaml:253 「from-toは最大31日間、超過は422」 ★文言を2年に★
6 d-watch-api.yaml:1251 example: "date_from〜date_toは31日以内で指定してください" ★同上★

★★定数名を変えないこと★★(MAX_DATE_RANGE_DAYS)。★値だけ変える。★

9-2. ★「日付未指定なら上限なし」は既にそうなっています★

lib/clean-logs/query.ts:78-94 の検証は ★date_from と date_to の両方があるときだけ★走ります。 片方でも無ければ範囲の検証をしません。★したがって「未指定なら上限なし」は追加実装が不要です。★

★確認だけすること★ — テストで固定されているかを見て、無ければ足す:

# 入力 期待
9-1 両方指定・731日 ★200★
9-2 両方指定・732日 ★422★
9-3 ★date_from のみ★ ★200(上限の検証をしない)★
9-4 ★date_to のみ★ ★200★
9-5 ★両方なし・facility_name のみ★ ★200★

9-3. ★既存テストの「31日」を直す★

MAX_DATE_RANGE_DAYS を直接 import しているテストは追従しますが、 ★31 や "31日以内" を直書きしているテストは落ちます★。全部探して直すこと。

grep -rn "31日\|MAX_DATE_RANGE_DAYS" --include=*.ts --include=*.tsx --include=*.yaml .

9-4. ★★この変更で起きること(申し送りに必ず書くこと)★★

★索引が入るまで、2年を選んだときは重くなります。★ 実測に基づく見込み:

現状(31日) 2年(外挿。施設200件)
主クエリが読む行 約1万1千行 ★約25万行★
clean_status_histories の走査 1塊 ★254塊 × 全表走査 ≒ 6,430万行★
CSVエクスポート 55KB / 0.7秒 ★約28MB / 約6分(下限)★

★★CSVは nginx の既定タイムアウト(60秒)を超えるため、2年分は落とせません(504)。★★ ★これは索引だけでは直りません★ — LIMIT がSQLに降りていないこと、 ORDER BY start_at DESC, id ASC の向きが混在していて filesort が消えないことが原因です。

★★この問題は段階8 で「押す前に分かる」形にします。★★

★★依頼済み★★: 索引7件(TeamWorks確認事項_第9回_20260915.md 1章)。 ★★別課題★★: LIMIT の押し下げと並び順の変更(D-131 決定D-4 の前提が崩れたため。 ★同時にやらないこと★ — D-131 申し送り⑤が「ページ番号をURLに持たせる件と同時にやるな」と 書いているのと同じ理由で、切り分けができなくなる)。


10. 段階8 — ★CSVに上限を設け、出せないことを押す前に伝える★

★★ユーザー指示(2026-09-15。原文)★★:

あとCSVで出力できる期間の限界が来たらこの期間だとCSV出力できませんとわかるようにしてください

★段階7 で期間の上限を2年に広げた結果、CSVは現実的に落とせない大きさになりえます。★ ★「押してから6分待たされて 504 が返る」のが最悪の形です。★ ★押す前に分かるようにします。★

10-1. ★上限は「日数」ではなく「行数」で決めること★

★★日数で決めないこと。★★ 理由:

絞り込み 2年ぶんの行数(外挿) 落とせるか
★1店舗だけ★ ★約1,300行★ ★余裕で落とせる★
全店舗(施設200件) ★約25万行★ ★落とせない★

★同じ「2年」でも、絞り込み次第で200倍違います。★ ★日数で切ると、1店舗の2年ぶん(軽い)まで巻き添えで禁止することになります。★

★★ユーザーは「店舗指定のみなら上限なし」と明示しています(§1-6)。★★ ★行数で決めれば、その指定と自然に両立します。★

10-2. 設定値

lib/config/index.ts の MAX_DATE_RANGE_DAYS の直後に足す。

/**
 * CSVエクスポートの行数上限(ユーザー指示 2026-09-15 / 指示書AI 段階8)
 *
 * ★日数ではなく行数で決めている★ — 同じ2年でも、1店舗なら約1,300行、
 *   全店舗(施設200件)なら約25万行で、200倍違うため(指示書AI 10-1)。
 *
 * ★★この値は暫定です。実データと索引が入ったら測り直すこと★★ —
 *   現在の根拠は「31日・502行で0.73秒」という実測1点からの外挿にすぎない。
 *   ★DB側は塊ごとの全表走査になるので、行数に対して二乗で効く★
 *   (`clean_status_histories.clean_timetable_id` に索引が無いため。第9回 1-2 で依頼中)。
 *   20,000行なら 20塊 × 20,000行 = 40万行の走査で収まる見込み。
 *
 * ★CSVの大きさの目安★: 1行あたり 111 バイト(実測。BOM・CRLF・10列)。
 *   20,000行 ≒ 2.2MB。
 */
export const MAX_CSV_ROWS = 20_000;

10-3. サーバ側 — app/api/clean-logs/export/route.ts

★絞り込みを適用したあと、CSVを組み立てる前に行数を見る。★

  • 行数が MAX_CSV_ROWS 以下 → これまでどおりCSVを返す
  • 超える → ★422★ を返す({ error: { code, message, details } }。CLAUDE.md §9.3)
message: "この期間はCSVで出力できません。対象が{n}件で、上限の{max}件を超えています。期間を短くするか、店舗で絞り込んでください。"
details: { total: n, max: MAX_CSV_ROWS }

★★500 ではなく 422 にすること★★ — 利用者の指定が上限を超えているという話で、 サーバの不具合ではありません。★details に実数を入れること★ — 「あと何件減らせばよいか」が分かります。

★★CSVを組み立ててから長さで判断しないこと★★ — 25万行ぶんの文字列を作ってから捨てるのでは、 ★メモリを使い切る危険が残ったまま★です。判断は組み立ての前。

★d-watch-api.yaml の /clean-logs/export に 422 の宣言を足すこと★(先にYAML。§6-1)。

10-4. 画面側 — ★★押す前に分かるようにする(ここが本題)★★

components/features/detail-log/DetailLogScreen.tsx

★★画面は既に件数を持っています。★★ 一覧を取ったときの meta.total です。 ★CSVのために問い合わせを増やす必要はありません。★

  • meta.total > MAX_CSV_ROWS のとき:
    • ★CSVエクスポートのボタンを押せなくする★(disabled)
    • ★ボタンの近くに理由を出す★: 「★この期間だとCSV出力できません★({n}件/上限{max}件)。期間を短くするか、店舗で絞り込んでください。」
  • 上限以内のとき: これまでどおり

★★理由を出さずに disabled だけにしないこと★★ — ★押せないボタンは、理由が書いていないと不具合と区別がつきません★(D-053 がUIを削除した理由と同じ)。

★★文言は t() を通すこと★★(JP/EN の器。§9.6)。★数値は画面側で差し込む★(D-111)。

★★サーバ側の422 を消さないこと★★ — 画面で止めても、APIを直接叩く経路が残ります。 ★画面は「親切」、サーバは「歯止め」で、役割が違います。★

10-5. 期待値表

# 状況 サーバ 画面
8-1 0件 ★200(ヘッダ行だけのCSV)★ ボタン有効
8-2 19,999件 ★200★ ボタン有効
8-3 ★ちょうど 20,000件★ ★★200★★ ★ボタン有効★(★「上限」は含む★)
8-4 ★20,001件★ ★★422★★ ★★ボタン無効+理由の表示★★
8-5 1店舗・2年・1,300件 ★200★ ★ボタン有効★(★期間が長くても行数が少なければ出せる★)
8-6 日付未指定・全店舗 ★件数次第★ ★同上★

★★8-3 と 8-4 の両方を必ずテストに入れること★★ — 境界の片側だけだと、 > と >= の取り違えが通ってしまいます(CLAUDE.md §9.4)。

10-6. ★この段階も独立したコミットにすること★

段階6(ダッシュボード)・段階7(期間上限)・段階8(CSV上限)は、★3つとも別のコミット★。


11. 段階9 — 記録

11-1. docs/decisions.md

★★採番の前に必ず実測すること★★:

grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3

★★docs/decisions.md は番号順に並んでいません。★★ 末尾を見て最大値を判断しないこと。

書くこと:

  • 要レスキューの3条件と、★4つ目を落とした理由(実開始時刻がDBに無い)★
  • ★急落を「比」にした理由と、20% の出所(ユーザー原文を引用)★
  • ★監督が決めた5件(決定A〜E)とその根拠★
  • ★面積加重にした理由★
  • ★モックに無いUIを作ったこと(★CLAUDE.md §5 の例外★。D-120 / D-121 と同じ扱い)。ユーザー原文を転記★
  • ★select の列を増やした理由と、そのコスト(約4MB)★
  • 期間上限を2年にしたこと。★既定は今日1日のままであること★
  • ★CSVの上限を日数ではなく行数にした理由(同じ2年でも絞り込み次第で200倍違う)★と、 ★MAX_CSV_ROWS = 20_000 が実測1点からの外挿にすぎない暫定値であること★
  • ★★やらなかったこと★★: 分類の復活・総清掃面積(TeamWorks 待ち)

11-2. docs/handoff.md に追記

  • ★TeamWorks への索引依頼7件が未回答であること★
  • ★★MAX_CSV_ROWS を実データで測り直すこと★★ — ★索引が入ると通る行数が変わる★。 ★暫定値のまま放置しない★
  • ★LIMIT の押し下げと並び順の変更が別課題として残ること★
  • ★実開始時刻の回答が来たら、4つ目の条件を足すこと★

12. 各段階の検証

★★段階ごとにコミットすること。★★ ★git add -A を使わず、ファイルパスを明示する。★

npx tsc --noEmit     # 0 であること
npm run lint         # 0 であること
npm test             # 全パス

★★「テストが通った」を安全の証明にしないこと★★ — この指示書で扱う3条件は、 ★ローカルのデータでは1件も発火しません★(実測: 最終データ日 2026-09-13 を当該日としたとき R-1 は0件。R-2 は4件、R-3 は4件)。 ★テストが通ることと、本番で正しく動くことは別です。★

実データでの確認(★dev server は preview_start で立てること。npm run dev を Bash で叩かない★)

.env.local は既にテストDB(133.125.238.144:13306 / clean_watch)を向いており、 USE_FIXTURES=false です。

★★テストDBのデータは 2026-09-13 で終わっています。★★ ★今日の日付で開くと全条件が0件になります。★ 確認するときは日付を指定すること:

/api/dashboard?date=2026-09-13   → R-2 が4件、R-3 が4件出るはず
/api/dashboard?date=2026-09-12   → R-1 が1件(施設6)、R-2 が3件出るはず

★★訂正(2026-09-15。指示書AJ 段階3)★★

★上の期待値には誤りがあります。★ ★元の数字は消していません★ — ★次に読む人が、この指示書を信じて実装の数を合わせにいくのを防ぐため★です。 ★★実装は正しく、誤っていたのは監督が書いたこの期待値のほうです。★★

上の記述 訂正
?date=2026-09-13 → ★「R-2 が4件」★ ★★誤り。正しくは5件★★ — ★施設7を数え漏らしていました★(09-13 に予定が無いが、09-12 評価で出る施設。§1-5 でわざわざ「当該日に予定が無い施設も出す」と指定したのは、まさにこの施設のためでした)
?date=2026-09-13 → 「R-3 が4件」 ★4件で正しい★。★ただし当時は上限5件だったため、API には2件しか出ていませんでした★(施設1・施設10 が上限で切られていた。指示書AJ §1-1)

★★実測(2026-09-15。上限撤廃後の develop)★★:

?date=2026-09-13  rescue_required 7件
  施設9  [escalation_l3, completion_drop] 連続6  施設6  [escalation_l3, completion_drop] 連続4
  施設7  [escalation_l3] 連続4(evaluated_on=2026-09-12)
  施設3  [escalation_l3] 連続3          施設5  [escalation_l3] 連続3
  施設10 [completion_drop] 連続2        施設1  [completion_drop] 連続1
  → R-2(escalation_l3)は★5件★、R-3(completion_drop)は★4件★、R-1 は 0 件

?date=2026-09-12  rescue_required 7件
  施設6 [not_executed_streak, escalation_l3] / 施設9 施設7 [escalation_l3]
  施設2 施設4 施設8 施設10 [completion_drop]
  → R-1 は★1件(施設6)★で記述どおり。R-2 は★3件★で記述どおり

★★施設7が 2026-09-13 の結果に含まれることを必ず確かめること★★ — ★09-13 に予定が無いのに出る★のが、§1-5 の指定どおりに実装できている証拠です。 ★出なければ、当該日で絞ってしまっています。★


13. ★実装者へ — 特に踏みやすい落とし穴★

# 落とし穴 どうなるか
★1★ ★completion_rate を面積から再計算する★ 1行の率(エリア単位)と施設日の率(面積加重)が混ざる。★数字が微妙にずれるだけで、例外もテスト失敗も出ない★
★2★ ★ConsecutiveInput の新しい2列を optional にする★ 詰め忘れが undefined のまま通り、★面積加重が 0/0 → null に化ける★
★3★ ★当該日で母集団を絞る★ ★予定の無い日のL3施設が消える★(施設7の例)
★4★ ★急落の閾値を 20 と書く★ 後で 0.2 と 20 のどちらの単位か分からなくなる。★0.2 で統一★
★5★ ★未実施を 0% とみなす★ 前日未実施の翌日が全部「大幅上昇」扱いになる。★null のまま扱う★
★6★ ★連続日数を要レスキュー用に数え直す★ D-109 が潰した「2箇所で別々に計算する」に戻る
★7★ ★遡り幅を縮める★ ★L3 が L1 に化ける★(例外もテスト失敗も出ない)
★8★ ★日本語を判定層で作る★ EN切替で和文が残る(D-111)
★9★ ★新しい .module.css に font-family を書く★ ★漢字が中華字形になる★
★10★ ★段階6 と段階7 を同じコミットにする★ 壊れたときに切り分けられない

14. やらないこと(★この指示書の範囲外★)

項目 なぜ
★分類(ホールディングス/屋号/店舗)の復活★ TeamWorks 確認待ち(第9回 6章・7章)。★/reports/kpi が2顧客で500を返す件も、これとセットでしか直せない★
★総清掃面積の新画面★ 分類の絞り込みに乗るため、上が先
★LIMIT の SQL 押し下げ★ D-131 決定D-4 の作り直し。★別課題★
★ORDER BY の向き揃え★ ★レスポンスの並び順が変わるのでユーザー確認が要る★
★全ルートへの 5xx 宣言★ 既に別課題として記録済み
★出発失敗を未実施と別に判定する★ ★データがDBに無い★(第9回 11章で照会中)
★点検記録2本★ ユーザーが保留中