Newer
Older
client_dwatch / docs / handoff / 指示書AF_連続日数の表示上限を設定できるようにする.md

指示書AF — 連続日数の表示上限を設定できるようにする(システム管理画面から変更可能にする)

項目 内容
実行環境 ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照)
実行状態 ★★完了(2026-09-13。段階0〜段階8 をすべて実行)★★ — ★決定番号 D-130(docs/decisions.md:8943)★ / ★コミット8つ: 68a5c14 4d063f2 9eb8a69 4854fc7 07f32f3 205b4a8 27b83cd + 段階8★ / ★引き継ぎ: docs/handoff.md 追記44★ / ★git push はしていない★
位置づけ ★ユーザー指示(2026-09-13)★ ①「★(連続日数の表示)上限は設定してください★」/②「★ただしシステム管理画面で変更できるようにしてください★」/③ その後の確認で ★A案で確定★「★連続日数の上限だけ先に編集可能にする。作法を確定させてから他へ広げる★」
実行順 ★★段階0 → 段階8 を順番どおり★★(★段階どうしに依存があります。飛ばさないこと★)
対象 ★本書に全部書いてあります★(★docs/handoff/指示書AC_絞り込みをSQLに降ろす.md との関係だけは §C を読むこと★)
★着手前確認★ ★★なし★★ — ★①②③ はすべてユーザー指示(2026-09-13)で確定済み★。★★ユーザーの回答を待たずに段階0 から実行してよい★★。
★ただし「実装者判断で決めた」ことが5件あります★(→ ★§B の表★)。★数値(既定値30 / 下限 / 上限365)を変えたくなったら lib/config/ の1行を直すだけで済むように作ってあります★
取得する決定番号 ★★**着手直前に `grep -nE "^### D-(0\ 1)[0-9][0-9]" docs/decisions.md \ tail -3で最終番号を実測すること**★★。★★^### D-0だけでは3桁台(D-100 以降)を拾えない★★。★2026-09-07 にD-068` の二重採番が実際に起きています★。★★本書には見込み番号を書きません★★。★1本(本書全体で1件)で足ります★
段階数 ★★9段階(段階0〜段階8)/ コミットは8つ★★(★段階0 はコミットなし★)
着手前の基準 ★★着手直前に自分で npm test / npx tsc --noEmit / npm run lint を実行し、その実測値を基準にすること★★。★作成時点の実測(2026-09-13。★★鵜呑みにしない★★): npm test = 48ファイル / 1137テスト(全パス) / tsc = 0 / lint = 0 / db.cleanLogs() = 538件 / db.prechecks() = 58件★
作成 2026-09-13(作成時点の決定事項最終番号 D-120(docs/decisions.md:7762)/ HEAD = f854150 / ブランチ = develop。★着手時に実測すること★)
★実行時の実測★ ★AF の着手時 HEAD = 3bec01b★ / ★段階8 の直前に実測した決定事項最終番号 = D-129(docs/decisions.md:8756)→ ★採番 D-130★ / ★完了時の検証: npx tsc --noEmit = 0 / npm run lint = 0 / npm test = 53ファイル・1362テスト 全パス★(★段階7 はUI変更なのでテスト件数は増減していない★)

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

★この指示書を読んでいるあなたへ★ — .claude/commands/d-watch.md の「実装は Cursor 側」は 2つの環境の役割分担です。Cursor の中で動いている Claude は実装担当です。 ★調査や改訂ではなく実装を行ってください。★ ★★途中で止めてよい指示書です★★ — ★段階4 / 段階7 の終わりに「★ここで止めてもよい★」と明記してあります★。


A. ★★冒頭に読むべきこと — 本書は何で、何ではないか★★

A-1. ★本書がやること(1行)★

★★連続日数に「表示上限」を1つ導入し、その1つだけをシステム管理画面から編集できるようにする★★。

A-2. ★★本書は「設定画面をまるごと編集可能にする指示書」ではありません★★

★ユーザー指示③(A案)は ★★「連続日数の上限だけ先に編集可能にする。作法を確定させてから他へ広げる」★★ です★。

設定 本書での扱い
★連続日数の表示上限(新規)★ ★★編集可能にする★★
ESCALATION_THRESHOLDS(L2=2日 / L3=3日) ★★触らない★★(★編集可能にしない★)
GRADE_THRESHOLDS(90 / 70 / 10) ★★触らない★★
PRECHECK_MINUTES_BEFORE(60 / 30) ★★触らない★★
アドバイス文言の編集機能(/api/settings/advice) ★★既存のまま。触らない★★(★作法の見本として読むだけ★)

★★「ついでに他の閾値も編集可能にしておきました」をやらないこと★★ — ★A案の目的は 「★作法を1件で確定させる★」ことです。同時に4件やると、どれが正しい作法か後から分かりません★。 ★他へ広げる話は §H(申し送り)に書いてあります★。

A-3. ★★保存の仕組みは既にあります。新しく作らないこと★★

★★監督実測(2026-09-13)★★:

既にあるもの 場所 本書での使い方
汎用の JSON 設定ストア lib/settings/json-store.ts(createJsonStore()。★アトミック差し替え / 破損時 .bak 退避 / プロトタイプ汚染対策まで入っている★) ★★そのまま使う★★
ストアの利用例①(アドバイス文言) lib/settings/advice.ts ★★真似る★★
ストアの利用例②(D-Secログの備考) lib/settings/ds-device-notes.ts ★★真似る★★
GET + PATCH の API 作法 app/api/settings/advice/route.ts ★★真似る★★
保存中 / 失敗の画面表示の作法 components/features/ds-logs/DsLogScreen.tsx の commitNote()(:234) ★★真似る★★

★★❌ 新しい保存の仕組み(別のファイル形式・DB・localStorage・Cookie・メモリキャッシュ)を作らないこと★★。 ★★❌ メインDBに保存しないこと★★(CLAUDE.md §3 / §7。★DFAが書けるのは robot_error_reports / control_error_histories の4カラムだけで、設定値はそのどれでもない★)。

A-4. ★★画面だけが「参照のみ」です★★

components/features/system/SystemScreen.tsx:118-121(★作成時点の実測。鵜呑みにしない★):

<div className={styles.readonlyNotice}>
  <strong className={styles.readonlyStrong}>{t("この画面は現在 参照のみ です。")}</strong>
  {t("設定値の編集はP1後半で対応します。")}
</div>

★★本書の段階7 でこの文言が事実と食い違うようになります★★(1項目だけ編集できるようになるため)。 ★★文言を直すことは段階7 の必須項目です★★(→ 段階7-3)。


B. ★★本書で新しく決めたこと(実装者判断。★数値は後から1行で変えられます★)★★

# 決めたこと 値 理由 どこ
1 ★表示上限の既定値★ ★★30★★ → ★§B-1★ 段階2
2 ★設定できる下限★ ★★ESCALATION_THRESHOLDS.level3Days(=現在3)★★ → ★§B-2★ 段階3
3 ★設定できる上限★ ★★365★★ → ★§B-3★ 段階3
4 ★API のパス★ ★★/settings/display-limits★★ → ★§B-4★ 段階1
5 ★上限に達したときの launch_failed_days★ ★★返さない(省略する)★★ → ★§B-5★ 段階1 / 段階5

B-1. ★既定値を 30 にした理由★

  • ★★R4(3日以上連続でオンサイト訪問検討)より十分に大きい★★(CLAUDE.md §4.5)。 ★★10倍★★なので、エスカレーションの判断が上限に触れて歪むことはない
  • ★「1か月ぶん」として運用担当が直感的に理解できる★。★月次レポートの粒度(CLAUDE.md §4.7)と揃う★
  • ★★連続日数はスケジュール存在日で数える(D-026)★★ので、毎日清掃なら約1か月、 週3回清掃なら約10週にあたる。★どちらの運用でも「長期化している」と読める長さ★
  • ★30日を超えた連続は、日数の正確さより「訪問したか」が問題になる段階★。 ★31日と45日の区別が意思決定を変えないので、頭打ちにしても運用上の損失が無い★
  • ★指示書AC 段階6(連続日数のクエリを有界にする)で読み込み量の上限としても使うため、 現実的な読み込み量に収まる値であること★(→ §C)

★★この数値を変えたくなったら lib/config/index.ts の1行を直すだけです★★。 ★★画面からも変えられます(それが本書の目的)★★。

B-2. ★下限を L3 の日数にした理由★

★★上限が L3 を下回ると、エスカレーションが出ているのに日数が頭打ちになり意味を成さなくなります★★ (ユーザー指示 2026-09-13)。 ★例: 上限を 2 にすると、5日連続の施設が「2日以上」と表示されるのに バッジは Lv3 になる★ ——★★「2日なのに L3」という、説明のつかない画面になります★★。

★★下限は ESCALATION_THRESHOLDS.level3Days から導くこと★★。 ★★3 と直書きしないこと★★(CLAUDE.md §7 閾値のハードコード禁止)。 ★L3 の日数が将来変われば、下限も自動で追従します★。

B-3. ★上限(上限値の上限)を 365 にした理由★

  • ★★上限を設けないと、指示書AC 段階6 で「全期間走査に戻る」抜け穴になります★★ (★画面から 999999 を保存すれば、事実上上限なしと同じ★)
  • ★実データの保持は2年(指示書AC の調査)。365 はその半分・1年ぶん★
  • ★1年を超える連続を1日単位で表示する運用上の意味が無い★

★★Number.MAX_SAFE_INTEGER や Infinity を許さないこと★★(→ 段階3 の期待値表)。

B-4. ★API のパスを /settings/display-limits にした理由★

  • ★/settings/thresholds にしない★ — ★★「閾値」という名前にすると、 編集対象外の GRADE_THRESHOLDS / ESCALATION_THRESHOLDS まで ここから編集できると誤解されます★★(A案の趣旨に反する)
  • ★/settings/consecutive-days-max にしない★ — ★1設定=1エンドポイントにすると、 他の表示上限が増えるたびにエンドポイントが増える★
  • ★★display-limits(表示上限)=「判定には使わない、表示のための上限」という区切り★★。 ★本書で足すキーは consecutive_days_max の1つだけ★

B-5. ★上限に達したとき launch_failed_days を返さない理由★

★★これは「返さないほうが正しい」ケースです★★。

  • ContinuousProblem.summary の混在テンプレートは ★{n}日連続で△以下(うち{m}日は出発失敗)★(lib/db/report-mapping.ts:53)
  • d-watch-api.yaml:1400 が ★★0 <= launch_failed_days <= consecutive_problem_days★★ と定めている
  • ★★上限で頭打ちにすると n が小さくなるので、この不等式が壊れます★★ (例: 実際45日連続・うち40日が出発失敗 → n=30 にすると m=40 > n=30)
  • ★★m を min(m, n) に丸めるのは捏造★★ — ★「直近30日のうち何日が出発失敗か」は、日付を持っていない場所では計算できません★
  • ★launch_failed_days は ★もともと任意項目★(d-watch-api.yaml:1403 「fixtures を変更しないための措置」)なので、★省略しても契約違反になりません★

★★→ 上限に達したときは {m} を持たない専用テンプレートを使い、launch_failed_days を省略します★★ (→ 段階5)。★画面(ReportsScreen.tsx:406-408)は既に「launch_failed_days が undefined なら {m} を置換しない」と書かれているので、★★画面側の分岐は増えません★★。


C. ★★指示書AC との関係(★必ず読むこと★)★★

C-1. ★★本書は指示書AC の着手前確認 A-1 の答えです★★

docs/handoff/指示書AC_絞り込みをSQLに降ろす.md:83 の ★着手前確認 A-1★:

★★連続日数の表示に上限(たとえば「30日」で頭打ち。それ以上は 30日以上 と出す)を設けてよいですか?★★

★★答え: よい★★(ユーザー指示 2026-09-13)。 ★★→ 指示書AC 段階6(:719)は「実施する」に確定します★★。

C-2. ★★実行順は AF → AC を推奨。★同時に走らせないこと★★★

理由
★★同時に走らせない★★ ★指示書AC は lib/db/index.ts / lib/db/report-mapping.ts / lib/*/query.ts / app/api/**/route.ts を大きく書き換えます★。★本書も同じファイルを触ります★。★同じ時間帯に同じ木を触ると、どちらの検証結果も信用できなくなります★
★★AF を先にする理由★★ ★AC 段階6 は「上限値」が無いと書けません★。★★先に AF で consecutive_days_max を確定させると、AC 段階6 はそれを読むだけになります★★
★★AC の段階0〜5 は AF を待たずに着手してよい★★ ★AC 自身がそう書いています(:104「回答を待たずに進めてよい範囲: 段階0〜段階5」)★。★★ただし「待たずに着手してよい」であって「同時に走らせてよい」ではありません★★

C-3. ★★指示書AC 段階6 への申し送り(★3件。★★どれも外すと壊れます★★)★★

★★本書の段階8 で docs/decisions.md に必ず書き残すこと★★。

# 申し送り 外すとどうなるか
★1★ ★★AC 段階6 のクエリ上限は、lib/config/ の既定値ではなく loadConsecutiveDaysMax()(保存された値)から導くこと★★ ★運用担当が画面で上限を 90 に上げても、クエリが 30日ぶんしか読まないので ★★90 に上げた意味が無くなる★★。★例外もテスト失敗も出ない★
★2★ ★★「上限 +1日ぶん」読むこと★★(★ぴったり上限ぶんしか読まない実装にしないこと★) ★★capped が永久に false になり、「30日以上」が二度と画面に出なくなる★★。★本書の capConsecutiveDays() は「★上限を超えたとき★」に capped=true にするため、raw が上限を1つも超えられないと成立しない★
★3★ ★★上限は「スケジュール存在日」の数。カレンダー日の遡り日数と同じ数にしないこと★★(D-026) ★週3回清掃の施設では、カレンダー30日を遡っても清掃日は約13日しかない★。★★連続が途中で切れて、実際より短い日数が表示される★★(AC 自身も :729 で同じ警告をしている)

D. ★★コミットと検証の規律(★全段階に効きます★)★★

  1. ★★段階ごとに1コミット★★ — ★段階をまたいだ1コミットを作らない★
  2. ★★各段階の終わりに npx tsc --noEmit / npm run lint / npm test の3つが通ること★★
  3. ★★git add -A 禁止★★(CLAUDE.md §7。★過去に別作業の途中ファイルを巻き込む事故あり★)— ★★必ずファイルパスを明示して git add すること★★
  4. ★★docs/decisions.md をコミットするときは git diff docs/decisions.md を先に見ること★★ — ★未コミットの追記が同居していると巻き込みます★(★指示書W で実際に起きた★)
  5. ★★docs/handoff/ 配下の他ファイルに触らないこと★★ — ★★別のエージェントが docs/handoff/指示書AC_絞り込みをSQLに降ろす.md を改訂中です★★。 ★本書が触ってよいのは docs/handoff.md(段階8)と本書自身だけ★
  6. ★★テスト件数の増減を毎回確認すること★★ — ★本書は段階2 / 段階3 / 段階5 / 段階6 でテストが増えます★。 ★★「増えるはずの段階で増えていない」「増えないはずの段階で増えた」はどちらも手が滑っています★★

E. ★禁止事項(★本書の全段階に効きます★)★

  1. ❌ ★★git add -A の使用★★ — ★ファイルパスを明示する★(CLAUDE.md §7)
  2. ❌ ★★メインDBのスキーマ変更・マイグレーション作成★★(TeamWorks の領域。D-056)
  3. ❌ ★★robot_error_reports / control_error_histories の status / comment / responded_at / resolved_at ★以外★への DB 書き込み★★(D-042)。 ★★本書はアプリコードから DB に1バイトも書きません★★
  4. ❌ ★★設定値のハードコード★★ — ★lib/config/ から読む★(CLAUDE.md §7)。 ★★30 / 3 / 365 を画面・API・判定のどこにも直書きしないこと★★
  5. ❌ ★★新しい保存の仕組みを作ること★★ — ★lib/settings/json-store.ts を使う★(→ §A-3)
  6. ❌ ★★他の閾値を編集可能にすること★★ — ★★今回は1つだけ★★(→ §A-2)
  7. ❌ ★★lib/fixtures/ の既存の値を変更すること★★ — ★★連続日数の長い fixture を「テストのために」足さないこと★★。 ★件数(cleanLogs() 538 / prechecks() 58)が変わり、既存テストの前提が崩れます★。 ★★上限の検証は純粋関数の単体テストで行います★★(→ 段階2)
  8. ❌ ★★検証を画面側だけにすること★★ — ★★サーバ側で必ず検証する★★(→ 段階3)。 ★<input type="number" min max> は開発者ツールで外せます★
  9. ❌ ★★consecutive_problem_days を判定に使う箇所で上限値を使うこと★★ — ★★L1〜L3 と R1〜R4 の判定は上限をかける前の生値で行う★★(CLAUDE.md §4.3 の思想。 「表示用に丸めた値で判定すること」の禁止と同じ)
  10. ❌ ★★一時スクリプトをリポジトリに残すこと★★ — ★一時ファイルは作業用ディレクトリに置き、終わったら消すこと★。 ★★成功時にも必ず process.exit(0) を書くこと★★(★Prisma の接続が残ってハングした事故あり★)
  11. ❌ ★★DBへの書き込み・スキーマ変更を伴う調査★★ — ★★調査は SELECT のみ★★
  12. ❌ ★★次のファイルに触ること★★: README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / ★.claude/worktrees/**★ / ★docs/handoff/ 配下の本書以外のファイル(docs/handoff.md は除く)★
  13. ❌ ★★仕様が不明な箇所を推測で実装すること★★(CLAUDE.md §7)— ★止めて確認を求める★

段階0 — ★★実測(★読むだけ・コミットなし★)★★

★★行番号を信じないこと★★。★次を自分で実測して控えること★:

npx tsc --noEmit
npm run lint
npm test

grep -n "readonlyNotice\|この画面は現在" components/features/system/SystemScreen.tsx
grep -n "consecutive_problem_days" components/features/reports/ReportsScreen.tsx
grep -n "SUMMARY_TEMPLATE\|continuousProblemSummary\|continuousProblemsInPeriod" lib/db/report-mapping.ts
grep -n "filterContinuousProblems\|sortContinuousProblems" lib/reports/query.ts
grep -n "日以上連続\|保存中\|保存に失敗" lib/i18n.ts
grep -rn "consecutive_problem_days" --include=*.ts app lib | grep -v "^lib/generated/" | grep -v "__tests__"
grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3

★作成時点の実測(2026-09-13。★鵜呑みにしない★)★

場所 内容
components/features/system/SystemScreen.tsx:118-121 ★「この画面は現在 参照のみ です。」「設定値の編集はP1後半で対応します。」★
components/features/reports/ReportsScreen.tsx:386 ★バッジ★ {item.consecutive_problem_days}日(★日 は t() を通していない=EN でも「5日」と出る★)
components/features/reports/ReportsScreen.tsx:400-410 ★summary★ t(item.summary).replace("{n}", …) → launch_failed_days !== undefined なら {m} も置換
lib/db/report-mapping.ts:51-53 SUMMARY_TEMPLATE_COMPLETION / _LAUNCH_FAILED / _MIXED
lib/db/report-mapping.ts:452 continuousProblemSummary(consecutiveDays, launchFailedDays)
lib/db/report-mapping.ts:526 continuousProblemsInPeriod(...)(★summary と launch_failed_days を組み立てている★)
lib/reports/query.ts:236 filterContinuousProblems()(★>= ESCALATION_THRESHOLDS.level2Days で絞る★)
lib/reports/query.ts:245 sortContinuousProblems()(★consecutive_problem_days の降順★)
lib/i18n.ts:186 ★"{n}日以上連続": "{n}+ consecutive days" — 既にある★
lib/i18n.ts:121 / :123 "保存中…" / 保存に失敗しました — ★既にある★
lib/i18n.ts:166-168 "この画面は現在 参照のみ です。" / "設定値の編集はP1後半で対応します。"
d-watch-api.yaml:654-698 /settings/advice の GET / PATCH(★真似る形★)
d-watch-api.yaml:1151 / :1336 / :1360 CleanLogItem / FacilityPerformance / ContinuousProblem
d-watch-api.yaml:354 /clean-logs/{id}/advice のレスポンス(★インライン定義★)
テストファイル ★48ファイル★(vitest.config.ts の include は ★lib/**/*.test.ts と app/**/*.test.ts のみ★。★★components/** のテストは拾われない★★)
★API ルートの単体テスト★ ★★1件も無い★★(→ ★検証は純粋関数に寄せること。段階3 / 段階5★)

★★consecutive_problem_days が API に出る場所(★4つ★)★★

# エンドポイント 型 ★画面に出るか★
1 GET /reports/continuous-problems ContinuousProblem ★★出る(ReportsScreen.tsx:386 / :404)★★
2 GET /clean-logs CleanLogItem ★出ない★(DetailLogScreen.tsx:197 が state に持つだけ)
3 GET /clean-logs/{id}/advice インライン ★出ない★
4 GET /reports/facility-performance FacilityPerformance ★出ない★

★★①だけ直せばよい、とはしません★★ — ★理由は段階6 に書いてあります★。

★「{n}日以上連続」は使えるか(★ユーザーからの宿題★)★

用途 既存キー "{n}日以上連続" を使えるか 本書の結論
★システム管理画面の L2 / L3 の説明★ ★★使っている(SystemScreen.tsx:180)★★ ★★そのまま。触らない★★
★連続問題施設のバッジ★ ★使えるが長い★(Lv3 / 30日以上連続 になり、EN は Lv3 / 30+ consecutive days。★小さなバッジに収まらない★) ★★新キー "{n}日以上" を足す★★(→ 段階5)
★連続問題施設の summary 文★ ★★使えない★★(★summary は「{n}日連続で出発失敗」のような文で、「{n}日以上連続」は文の一部にならない★) ★★上限用のテンプレートを3本足す★★(→ 段階5)

段階1 — ★★d-watch-api.yaml を先に直す★★(コミット1)

★★API契約を変えたくなったら、先に d-watch-api.yaml を直す★★(CLAUDE.md §6-1)。 ★★この段階ではコードを1行も書きません★★。

1-1. ★新しいエンドポイント /settings/display-limits★

★/settings/advice(:654-698)の直後★に、★同じ書式で★追加すること。

  /settings/display-limits:
    get:
      tags: [settings]
      summary: 表示上限の一覧(システム管理>運用設定)
      description: |
        既定値に運用設定での編集内容を重ねたものを返す。
        保存先はDFA側の設定ファイル(data/settings/display-limits.json)。メインDBには保存しない。
        判定(◎○△×・L1〜L3)には使わない。表示のための上限のみを扱う。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/DisplayLimitSetting" }
    patch:
      tags: [settings]
      summary: 表示上限の更新
      description: |
        reset=true を指定すると、そのキーの編集を取り消して既定値に戻す。
        value は整数のみ。文字列("30")・小数・NaN・Infinity は 422。
        範囲外(min 未満 / max 超過)も 422。min / max は GET のレスポンスに含まれる。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key:
                  type: string
                  enum: [consecutive_days_max]
                value: { type: integer, example: 30 }
                reset: { type: boolean, description: trueなら既定値に戻す }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DisplayLimitSetting" }
        "422": { $ref: "#/components/responses/ValidationError" }

★★"422": { $ref: "#/components/responses/ValidationError" } を必ず付けること★★ (★ValidationError は ★YAML の既存レスポンス部品★(d-watch-api.yaml の components.responses)です。 ★★コードに ValidationError というクラスは存在しません★★ — 実測済み。 ★コード側の作法は errorResponse(422, "validation_failed", …)(app/api/ds-devices/[serial_number]/note/route.ts:34-41)★)。

1-2. ★新しいスキーマ DisplayLimitSetting★

★AdviceSetting(:1452)の近くに追加すること★。

    DisplayLimitSetting:
      type: object
      description: |
        表示上限1件。GET /settings/display-limits の items。
        min / max はサーバ側の検証範囲そのもの。画面の入力欄はこの値を使う
        (画面に範囲を直書きすると、サーバの検証とずれる)。
      properties:
        key:
          type: string
          enum: [consecutive_days_max]
          description: consecutive_days_max = 連続日数の表示上限(スケジュール存在日ベース)
        value: { type: integer, example: 30, description: 現在有効な値 }
        default: { type: integer, example: 30, description: lib/config の既定値 }
        min: { type: integer, example: 3, description: 設定できる下限(L3の日数。これを下回らせない) }
        max: { type: integer, example: 365, description: 設定できる上限 }
        customized: { type: boolean, description: 既定値から変更されているか }

1-3. ★consecutive_problem_days_capped を4箇所に足す★

★★CleanLogItem(:1151)/ FacilityPerformance(:1336)/ ContinuousProblem(:1360)/ /clean-logs/{id}/advice のインライン定義(:354)の4箇所すべて★★:

        consecutive_problem_days_capped:
          type: boolean
          example: false
          description: >-
            consecutive_problem_days が表示上限(GET /settings/display-limits の
            consecutive_days_max)で頭打ちになっているか。true のとき実際の連続日数は
            この値より大きい(「{n}日以上」と表示する)。上限ちょうどのときは false。
            escalation_level は上限をかける前の生値で判定する(上限は表示のためだけ)

★★既存の consecutive_problem_days の description も直すこと★★:

            ... 表示上限で頭打ちになる(consecutive_problem_days_capped を参照)

1-4. ★ContinuousProblem の summary と launch_failed_days★

  • ★summary の description に ★上限用の3種★ を追記する★: {n}日以上連続で完了率が△以下 / {n}日以上連続で出発失敗 / {n}日以上連続で△以下(出発失敗を含む) ★★上限用の混在テンプレートには {m} が無いことを明記すること★★
  • ★launch_failed_days の description に追記★: ★★consecutive_problem_days_capped が true のときは省略する★★(→ 理由は §B-5)

この段階の完了条件

  • ★/settings/display-limits の GET / PATCH が YAML にある★
  • ★DisplayLimitSetting スキーマがある★
  • ★consecutive_problem_days_capped が★4箇所★にある★
  • ★summary / launch_failed_days の description を直した★
  • ★★git diff --stat が d-watch-api.yaml の1ファイルだけ★★
  • tsc 0 / lint 0 / npm test が★着手前と同じ件数★で全パス(★YAML だけなので増減しない★)
  • コミット(★ファイルパスを明示★)

段階2 — ★★既定値と純粋関数★★(コミット2)

2-1. ★lib/config/index.ts に定数を足す★

★ESCALATION_THRESHOLDS(:20-25)のすぐ下に置くこと★(★関係が近いので離さない★)。

/**
 * 連続日数の「表示上限」(ユーザー指示 2026-09-13 / 指示書AF)
 *
 * ★これは表示のための上限であって、判定の閾値ではない★
 *   L1〜L3(`lib/judge/escalation.ts`)と R1〜R4(`lib/judge/advice.ts`)は
 *   ★上限をかける前の生値★で判定する(`CLAUDE.md` §4.3 の「表示用に丸めた値で判定しない」と同じ思想)。
 *
 * ★`defaultMax` はシステム管理画面から変更できる★(保存先は
 *   `data/settings/display-limits.json`。`lib/settings/display-limits.ts`)。
 *   ★保存が無い・壊れている・範囲外のときはこの既定値に倒れる★。
 *
 * ★既定値を 30 にした理由★:
 *   R4(3日以上でオンサイト訪問。`CLAUDE.md` §4.5)の10倍で、エスカレーションの判断が
 *   上限に触れて歪まない。「1か月ぶん」として運用担当が直感的に読める長さで、
 *   月次レポートの粒度(§4.7)とも揃う。30日を超えた連続は、日数の正確さより
 *   「訪問したか」が問題になる段階であり、31日と45日の区別が意思決定を変えない。
 *
 * ★`min` を `level3Days` から導く理由★:
 *   上限が L3 を下回ると、「2日以上」と表示されているのにバッジが `Lv3` という
 *   説明のつかない画面になる。★`3` と直書きしないこと★(§7 ハードコード禁止)。
 *
 * ★`max` を 365 にした理由★:
 *   上限が無いと、画面から巨大な値を保存することで全期間走査に戻る抜け穴になる
 *   (指示書AC 段階6 がこの値をクエリの読み込み範囲に使う)。実データの保持は2年で、
 *   365 はその半分・1年ぶん。1年を超える連続を1日単位で表示する運用上の意味が無い。
 *
 * ★単位は「スケジュール存在日」★(D-026)。★カレンダー日ではない★。
 */
export const CONSECUTIVE_DAYS_DISPLAY = {
  /** 既定の表示上限。★これを超えたら「{n}日以上」と表示する★ */
  defaultMax: 30,
  /** 設定できる下限。★L3 の日数を下回らせない★ */
  minMax: ESCALATION_THRESHOLDS.level3Days,
  /** 設定できる上限 */
  maxMax: 365,
} as const;

★プロパティ名は実装者が決めてよい★(minMax が読みづらければ lowerBound / upperBound 等でよい)。 ★★変えてはいけないのは「3 を直書きしないこと」だけです★★。

2-2. ★lib/format/consecutive-days.ts(新規)★

★★lib/judge/ ではなく lib/format/ に置くこと★★ — ★これは判定ではなく表示整形です★(CLAUDE.md §9.1: lib/judge/ は「◎○△×、L1〜L3、KPI計算、丸め」)。

import { CONSECUTIVE_DAYS_DISPLAY } from "@/lib/config";

export interface CappedConsecutiveDays {
  /** 表示に使う日数。`rawDays` が上限以下ならそのまま */
  days: number;
  /** ★上限を「超えた」か★。★上限ちょうどは false★ */
  capped: boolean;
}

/**
 * 連続日数に表示上限をかける。★純粋関数★(設定の読み込みを混ぜない)。
 *
 * ★`capped` は「超えた」ときだけ true★ — 上限ちょうど(`rawDays === maxDays`)は
 *   「ちょうど30日」であって「30日以上」ではないので false。
 *   ★★指示書AC 段階6 でクエリを有界にするときは「上限+1日ぶん」読むこと★★。
 *   ぴったり上限ぶんしか読まないと `rawDays` が上限を超えられず、
 *   ★`capped` が永久に false になって「30日以上」が画面から消える★。
 */
export function capConsecutiveDays(
  rawDays: number,
  maxDays: number = CONSECUTIVE_DAYS_DISPLAY.defaultMax
): CappedConsecutiveDays {
  if (rawDays > maxDays) return { days: maxDays, capped: true };
  return { days: rawDays, capped: false };
}

2-3. ★★期待値表(★テストより先に表を書くこと★。CLAUDE.md §9.4)★★

lib/format/__tests__/consecutive-days.test.ts

# rawDays maxDays 期待 days 期待 capped 何の境界か
1 29 30 29 false ★上限−1★
2 30 30 30 ★★false★★ ★★上限ちょうど(「以上」にしない)★★
3 31 30 30 ★★true★★ ★★上限+1★★
4 100 30 30 true 十分大きい
5 3 ★3(下限)★ 3 false ★★下限の境界★★
6 4 ★3(下限)★ 3 true ★★下限でも「以上」が出る★★
7 0 30 0 false ★問題なし★
8 1 30 1 false ★L1★
9 2 30 2 false ★L2★
10 引数省略 (既定 30) — — ★既定値が使われること★

★★もう1本、判定が壊れないことを固定するテストを書くこと★★:

# 検証 期待
11 ★maxDays を下限(=ESCALATION_THRESHOLDS.level3Days)にしても judgeEscalationLevel(rawDays) が L3 を返し続ける★(rawDays = 5 / maxDays = 3) ★★days=3, capped=true でも レベルは L3★★
12 ★CONSECUTIVE_DAYS_DISPLAY.minMax >= ESCALATION_THRESHOLDS.level3Days★ ★★真★★(★設定同士の関係をテストで固定する★)

★★テストに 3 / 30 / 365 を直書きしてよいのは「期待値」としてだけです★★。 ★入力側(maxDays に渡す下限)は ESCALATION_THRESHOLDS.level3Days から取ること★。

この段階の完了条件

  • lib/config/index.ts に定数(★理由をコメントに書いた★)
  • lib/format/consecutive-days.ts(★純粋関数。設定の読み込みを混ぜていない★)
  • lib/format/__tests__/consecutive-days.test.ts(★上の12行を全部入れた★)
  • ★★テストファイルが 48 → 49 に増えている★★
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

段階3 — ★★設定ストアと検証★★(コミット3)

3-1. ★lib/settings/display-limits.ts(新規)★

★★lib/settings/advice.ts を見本にすること★★(★構造をそっくり真似る★):

真似る要素 advice.ts の該当
保存先パスを★呼び出し時に組み立てる★関数 defaultAdviceStorePath()(:44)
破損通知を logEvent に流す関数 reportCorruptStore()(:57)
ストア生成関数(★テストからパスを差し替えられる★) createAdviceStore()(:73)
let + 遅延生成のモジュール内インスタンス adviceStoreInstance(:92)
★テスト専用の差し替え関数★ __setAdviceStoreForTesting()(:103)
/** ファイルに入るのは編集されたキーだけ。未編集のキーは存在しない */
export interface DisplayLimitOverrides {
  consecutive_days_max?: number;
}

★保存先: data/settings/display-limits.json★

公開する関数(★4本★)

関数 用途
validateConsecutiveDaysMax(value: unknown) ★★純粋関数。検証の正★★(→ 3-2)
loadConsecutiveDaysMax(): Promise<number> ★★4つのAPIルートが使う★★。★保存が無い/壊れている/範囲外なら既定値★
loadDisplayLimitSettings(): Promise<DisplayLimitSetting[]> ★GET が使う★(value / default / min / max / customized)
saveConsecutiveDaysMax(value: number) / resetConsecutiveDaysMax() ★PATCH が使う★

3-2. ★★検証は純粋関数に閉じ込めること(★API ルートの単体テストが1件も無いため★)★★

export type ValidationOutcome =
  | { ok: true; value: number }
  | { ok: false; reason: string };

/**
 * 表示上限として保存してよい値か判定する。★純粋関数★(ファイルを読まない)。
 *
 * ★ここが検証の唯一の正★ — PATCH ルートも、保存済みの値の読み込み時も、
 *   両方ここを通す。★画面側の `<input min max>` は補助であって検証ではない★
 *   (開発者ツールで外せる。`CLAUDE.md` §7 / 本書 禁止事項8)。
 */
export function validateConsecutiveDaysMax(value: unknown): ValidationOutcome

★★弾く条件(★全部弾くこと★)★★:

  1. typeof value !== "number"(★文字列 "30" も弾く★)
  2. !Number.isFinite(value)(★NaN / Infinity / -Infinity★)
  3. !Number.isInteger(value)(★小数★)
  4. value < CONSECUTIVE_DAYS_DISPLAY.minMax
  5. value > CONSECUTIVE_DAYS_DISPLAY.maxMax

★Number.isInteger(1e9) は true★ です。★巨大値は条件5 で弾かれます★(★条件3 では弾けません★)。

3-3. ★★保存が壊れているときは既定値へ倒れること(★例外にしない★)★★

loadConsecutiveDaysMax() は ★次の4つすべてで既定値を返すこと★:

状態 挙動
★ファイルが無い(一度も保存していない)★ ★★既定値★★(json-store.ts:54 が ENOENT を空で返す)
★JSON として壊れている★ ★★既定値★★(json-store.ts が .bak に退避して空を返す。★settings.store_corrupt が1行出る★)
★JSON は読めるが consecutive_days_max が無い★ ★★既定値★★
★★値が入っているが不正(文字列 / 小数 / 範囲外)★★ ★★既定値★★(★★validateConsecutiveDaysMax() を読み込み時にも通す★★)

★★4つ目を忘れないこと★★ — ★手で JSON を編集された場合や、将来 min / max を狭めた場合に、 保存済みの値が範囲外になります★。★そのとき例外を投げるとシステム管理画面が開かなくなります★。

★不正な値を読み飛ばしたときはログを1行残すこと★

★黙って既定値に倒れると、運用担当は「保存したのに効かない」としか分かりません★。

  • lib/logger/events.ts に ★新しいイベント型を1つ足す★(例: settings.value_invalid)。 ★LogEvent のユニオン(:334)に追加するのを忘れないこと★
  • ★★フィールド名を決める前に lib/logger/redact.ts の FORBIDDEN_PII_FIELDS / FORBIDDEN_CREDENTIAL_FIELDS を見ること★★(★該当する名前を使うと非本番で例外になります★)
  • ★載せてよいもの★: ファイルパス / キー名 / ★不正だった数値そのもの★ / 理由の分類 (★数値1つは自由記述ではないので SettingsStoreCorruptLogEvent の「本文を載せない」規律に触れません★)
  • ★★settings.store_corrupt を流用しないこと★★ — ★あれは「.bak へ退避した」という事実の記録で、 backup_path が必須です。退避していないのに退避したと書かないこと★

3-4. ★★期待値表(★テストより先に★)★★

lib/settings/__tests__/display-limits.test.ts (★lib/settings/__tests__/advice.test.ts の作法をそのまま使うこと★: fs.mkdtemp で一時ディレクトリ / afterEach で削除 / @/lib/logger は vi.mock / ★実ファイル data/settings/display-limits.json を絶対に汚さないこと★)

検証(validateConsecutiveDaysMax)

# 入力 期待 何の境界か
1 30 ★ok★ 既定値そのもの
2 ESCALATION_THRESHOLDS.level3Days(=3) ★★ok★★ ★★下限ちょうど★★
3 ESCALATION_THRESHOLDS.level3Days - 1(=2) ★★422(ng)★★ ★★下限−1★★
4 365 ★★ok★★ ★★上限ちょうど★★
5 364 ok 上限−1
6 366 ★★ng★★ ★★上限+1★★
7 3.5 ★★ng★★ ★★整数でない★★
8 -1 ★★ng★★ ★★負数★★
9 0 ng 0
10 1e9 ★★ng★★ ★★巨大値★★
11 Number.MAX_SAFE_INTEGER ng 巨大値
12 NaN ★★ng★★ —
13 Infinity ★★ng★★ —
14 "30"(文字列) ★★ng★★ ★★型★★
15 null / undefined ng 型
16 true ng 型
17 [30] / { value: 30 } ng 型

読み込み(loadConsecutiveDaysMax)

# ストアの状態 期待
18 ★ファイルが存在しない★ ★★既定値(30)★★
19 {"consecutive_days_max": 45} 45
20 ★★壊れた JSON(途中で切れている)★★ ★★既定値★★ + ★.bak が作られる★ + ★settings.store_corrupt が warn で1行★
21 ★[](配列)★ ★★既定値★★(json-store.ts:72 がオブジェクト以外を破損として扱う)
22 {}(キーが無い) ★★既定値★★
23 ★★{"consecutive_days_max": -5}(範囲外)★★ ★★既定値★★ + ★不正値のログが1行★
24 ★★{"consecutive_days_max": "45"}(文字列)★★ ★★既定値★★
25 ★{"consecutive_days_max": 45, "unknown_key": 1}★ 45(★知らないキーは無視する。例外にしない★)

保存(saveConsecutiveDaysMax / resetConsecutiveDaysMax)

# 操作 期待
26 save(45) → load() 45
27 save(45) → reset() → load() ★★既定値★★
28 save(45) → ファイルの中身 ★{"consecutive_days_max": 45}★(★他のキーを勝手に足さない★)
29 ★save() に不正値を渡した場合★ ★★例外 or 失敗を返す(★ファイルに書かない★)★★

この段階の完了条件

  • lib/settings/display-limits.ts(★json-store.ts を使っている。新しい保存の仕組みを作っていない★)
  • ★検証が純粋関数として切り出され、読み込み時にも通っている★
  • lib/logger/events.ts に新イベント(★LogEvent のユニオンにも追加した★)
  • lib/settings/__tests__/display-limits.test.ts(★上の29行★)
  • ★★テストファイルが 49 → 50 に増えている★★
  • ★data/settings/display-limits.json が★リポジトリに入っていない★(/data は .gitignore。★git status で確認★)★
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

段階4 — ★★API ルート★★(コミット4)

4-1. app/api/settings/display-limits/route.ts(新規)

★★app/api/settings/advice/route.ts をそのまま骨格にすること★★:

  • withRequestId(request, async () => { … }) で包む
  • errorBody() / errorResponse() のヘルパをローカルに持つ(★同じ形★)
  • GET は ★{ items: DisplayLimitSetting[] }★ を返す
  • PATCH は
    1. request.json() の失敗 → 422 validation_failed「JSONの形式が不正です」
    2. key が未知 → 422
    3. reset === true → resetConsecutiveDaysMax() → 更新後の DisplayLimitSetting を返す
    4. validateConsecutiveDaysMax(body.value) が ok:false → ★★422★★ (★★details に field 別のメッセージを入れること★★。 ★ds-devices/[serial_number]/note/route.ts:74 と同じ形: { value: ["…"] }★)
    5. 保存 → 更新後の DisplayLimitSetting を返す
    6. ★例外は logApiError() に流して 500★(★err.message を本文に載せないこと★)

4-2. ★★型を lib/types/index.ts に足す★★

★★YAML のスキーマ名と揃えること★★(CLAUDE.md §9.2):

export type DisplayLimitKey = "consecutive_days_max";

export interface DisplayLimitSetting {
  key: DisplayLimitKey;
  value: number;
  default: number;
  min: number;
  max: number;
  customized: boolean;
}

★default は予約語ではないのでプロパティ名に使えます★(★YAML に合わせること。default_value に改名しない★)。

4-3. ★★Next.js のキャッシュを確認すること★★

★★CLAUDE.md 末尾の警告(「This is NOT the Next.js you know」)が効く箇所です★★。

  • ★GET /api/settings/display-limits はクエリパラメータを持たない★ ので、 ★★ビルド時に静的化される可能性があります★★(★静的化されると、保存しても画面に反映されません★)
  • ★★node_modules/next/dist/docs/ の Route Handler / キャッシュの章を読んで確認すること★★ (★このリポジトリの next から解決すること★)
  • ★必要なら export const dynamic = "force-dynamic"; を足す★。 ★★足す場合は「なぜ必要か」をコメントに書くこと★★(★おまじないを増やさない★)
  • ★★確認手順★★: npm run build の出力で、このルートが ★ƒ (Dynamic)★ になっているか見ること (★○ (Static) なら効いていない★)

4-4. ★★手で動かして確認すること★★

npm run dev
# 操作 期待
1 GET /api/settings/display-limits items 1件。value === default === 30 / customized: false
2 PATCH に {"key":"consecutive_days_max","value":45} 200。value: 45 / customized: true
3 ★もう一度 GET★ ★★value: 45★★(★★キャッシュされていないこと★★)
4 PATCH に {"key":"consecutive_days_max","value":2} ★★422★★ + details
5 PATCH に {"key":"consecutive_days_max","value":"45"} ★★422★★
6 PATCH に {"key":"consecutive_days_max","reset":true} 200。value: 30 / customized: false
7 PATCH に {"key":"unknown"} 422
8 ★data/settings/display-limits.json の中身★ ★{"consecutive_days_max": 45}(reset 後は {})★

★★認証(lib/proxy/)を通す必要があります★★ — ★他のAPIを手で叩いたときと同じ手順で★。

この段階の完了条件

  • app/api/settings/display-limits/route.ts(GET / PATCH)
  • lib/types/index.ts に DisplayLimitSetting(★YAML と名前が一致★)
  • ★キャッシュされないことを npm run build の出力で確認した★
  • ★4-4 の8項目を手で確認した★
  • tsc 0 / lint 0 / npm test 全パス(★テスト件数は増えない。ルートのテストは無い★)
  • コミット

★★ここで止めてもよい★★

★「保存できる仕組み」はここで完成しています★。★ただし★まだ画面には何の変化もありません★。


段階5 — ★★連続問題施設に上限を効かせる★★(コミット5)

5-1. ★★上限用の summary テンプレートを3本足す★★

lib/db/report-mapping.ts(★:51-53 の既存3本のすぐ下★):

/** ★上限で頭打ちになったときのテンプレート(指示書AF)★ */
export const SUMMARY_TEMPLATE_CAPPED_COMPLETION = "{n}日以上連続で完了率が△以下";
export const SUMMARY_TEMPLATE_CAPPED_LAUNCH_FAILED = "{n}日以上連続で出発失敗";
/** ★★`{m}` を持たない★★ — 上限で切った窓の中の内訳は計算できないため(指示書AF §B-5) */
export const SUMMARY_TEMPLATE_CAPPED_MIXED = "{n}日以上連続で△以下(出発失敗を含む)";

★★既存3本の上のコメント(:43-50)と同じ注意書きを付けること★★ — ★★lib/i18n.ts のキーと1文字も違わないこと★★(★違うと translate() が未登録キーとして日本語のまま返る★)。

5-2. ★★上限をかける純粋関数★★

★lib/db/report-mapping.ts に置くこと★(★テンプレート文字列の正がこのファイルだから★。 ★lib/reports/query.ts に置くとテンプレートの定義場所が2ファイルに割れる★):

/**
 * `ContinuousProblem[]` に表示上限をかける。★純粋関数★。
 *
 * ★呼ぶ場所は「絞り込み → 並べ替え → 切り出し」の**後**★ —
 *   `sortContinuousProblems()` は `consecutive_problem_days` の降順で並べるので、
 *   ★先に頭打ちにすると上限に達した施設どうしの順序が潰れます★。
 *
 * ★上限に達した行は `launch_failed_days` を**落とす**★(指示書AF §B-5):
 *   `0 <= launch_failed_days <= consecutive_problem_days` が壊れるため。
 *   `min()` で丸めるのは「直近n日のうち何日が出発失敗か」の捏造になる。
 */
export function capContinuousProblems(
  items: readonly ContinuousProblem[],
  maxDays: number
): ContinuousProblem[]

★やること★:

  1. capConsecutiveDays(item.consecutive_problem_days, maxDays) を呼ぶ
  2. capped === false なら ★★そのまま返す(オブジェクトを作り直さない)★★+consecutive_problem_days_capped: false
  3. capped === true なら
    • consecutive_problem_days を days に
    • consecutive_problem_days_capped: true
    • summary を ★上限用テンプレートに差し替える★ (★元のテンプレートが _COMPLETION → _CAPPED_COMPLETION、_LAUNCH_FAILED → _CAPPED_LAUNCH_FAILED、_MIXED → _CAPPED_MIXED★)
    • ★★launch_failed_days を落とす★★
  4. ★escalation_level は触らない★(★生値で判定済み★)

5-3. ★lib/i18n.ts に対訳を足す★

★:186("{n}日以上連続")の近く★に:

  // ★連続問題施設のバッジ(指示書AF)★
  "{n}日": "{n} days",
  "{n}日以上": "{n}+ days",
  // ★連続問題施設の summary が上限で頭打ちになったとき(指示書AF)★
  //   `lib/db/report-mapping.ts` の SUMMARY_TEMPLATE_CAPPED_* と★1文字も違わないこと★
  "{n}日以上連続で完了率が△以下": "Completion rate △ or below for {n}+ consecutive days",
  "{n}日以上連続で出発失敗": "Launch failed for {n}+ consecutive days",
  "{n}日以上連続で△以下(出発失敗を含む)":
    "△ or below for {n}+ consecutive days (including launch failures)",

★★既存の "{n}日以上連続"(:186)を書き換えないこと★★ — ★システム管理画面が使っています★。

5-4. ★ルートを繋ぐ★

app/api/reports/continuous-problems/route.ts:

const sorted = sortContinuousProblems(filtered);
const total = sorted.length;
const { limit } = parsed.query;
const items = capContinuousProblems(sorted.slice(0, limit), maxDays);   // ★最後にかける★
  • maxDays は ★await loadConsecutiveDaysMax()★ で取る
  • ★★try の中で取ること★★(★ファイル読み込みは失敗しうる★)。 ★★ただし「設定が読めない」だけで一覧を 500 にしないこと★★ — ★loadConsecutiveDaysMax() は内部で既定値に倒れる設計なので(段階3-3)、 通常は例外になりません。★念のため catch するなら既定値で続行すること★★
  • ★★meta.total は上限をかける前と同じ★★(★件数は変わらない★)

★★なぜルート層でかけるのか(★lib/db/ の中でかけない理由★)★★ USE_FIXTURES の ★両方で同じ挙動になる★ ようにするためです(CLAUDE.md §9.5)。 lib/db/index.ts の prismaSource の中でかけると、★fixtures 経由では上限が効かず、 実DB経由でだけ効く★という差が出ます。 ★★fixtures の連続日数は最大 6 日(lib/fixtures/reports.ts:384)なので、 上限を下限(3)に設定すると fixtures だけで「3日以上」の表示を確認できます★★ (→ 段階7 の確認手順。★fixtures の値は変えません★)。

5-5. ★画面(バッジ)★

components/features/reports/ReportsScreen.tsx:386:

{/* ★上限で頭打ちのときは「{n}日以上」(指示書AF)★ */}
{item.consecutive_problem_days_capped
  ? t("{n}日以上").replace("{n}", String(item.consecutive_problem_days))
  : t("{n}日").replace("{n}", String(item.consecutive_problem_days))}

★★summary(:400-410)は1行も変えないこと★★ — ★サーバが上限用テンプレートを返し、launch_failed_days が undefined になるので、 既存のコードのまま正しく動きます★(★:406-408 が既に undefined を分岐している★)。 ★★動かないと思ったら、まず i18n のキーが1文字違っていないか疑うこと★★。

5-6. ★期待値表(lib/db/__tests__/report-mapping.test.ts に追記)★

# 入力 consecutive_problem_days / launch_failed_days / summary maxDays 期待
1 29 / 0 / _COMPLETION 30 ★そのまま★。capped:false。launch_failed_days: 0 ★残る★
2 ★30 / 0 / _COMPLETION★ 30 ★★そのまま(上限ちょうどは capped:false)★★
3 ★31 / 0 / _COMPLETION★ 30 30 / capped:true / ★_CAPPED_COMPLETION★ / ★★launch_failed_days が無い★★
4 31 / 31 / _LAUNCH_FAILED 30 30 / capped:true / ★_CAPPED_LAUNCH_FAILED★
5 ★31 / 12 / _MIXED★ 30 30 / capped:true / ★★_CAPPED_MIXED({m} が無い)★★ / ★launch_failed_days が無い★
6 5 / 2 / _MIXED ★3(下限)★ 3 / capped:true / _CAPPED_MIXED
7 ★escalation_level: "L3" の行を上限で切る★ 3 ★★escalation_level が L3 のまま★★
8 ★並べ替え済みの配列(45 / 31 / 30 / 5)★ 30 ★★順序が変わらない★★(30 / 30 / 30 / 5)

★★i18n の整合テストを1本入れること★★:

# 検証 期待
9 ★SUMMARY_TEMPLATE_CAPPED_* の3本すべてが lib/i18n.ts のキーとして存在する★ ★★真★★(★既存3本にも同種のテストがあれば、そこに足すこと★)

この段階の完了条件

  • 上限用テンプレート3本(★i18n のキーと1文字も違わない★)
  • capContinuousProblems()(★純粋関数★)+ テスト9行
  • lib/i18n.ts に5キー(★既存の "{n}日以上連続" を書き換えていない★)
  • ルートで ★絞り込み → 並べ替え → 切り出しの後★ に適用
  • ReportsScreen.tsx はバッジの1箇所だけ(★summary の行を触っていない★)
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

段階6 — ★★残り3経路にも同じ上限を効かせる★★(コミット6)

6-1. ★★なぜやるのか(★画面に出ないのに直す理由★)★★

理由 中身
★1★ ★★D-109 の再発防止★★ — ★「同じ施設・同じ日なのに、清掃詳細ログとレポートで連続日数が違う」は

★実際に起きた不具合★です(D-109 項2 / D-103)。★契約の片方だけ頭打ちにすると同じ形の食い違いが復活します★ | | ★2★ | ★★指示書AC 段階6 が先回りで壊す★★ — ★AC 段階6 はクエリそのものを有界にします★。 ★そうすると /clean-logs の consecutive_problem_days も黙って頭打ちになります★。 ★★capped フラグが無いと、それが「上限のせい」なのか「本当に短い」のか判別できません★★ | | ★3★ | ★YAML(段階1)で4箇所に consecutive_problem_days_capped を書いた★。 ★★契約に書いて実装しないのは乖離★★(CLAUDE.md §6-1) |

6-2. ★やること★

# 経路 かける場所 純粋関数の置き場
1 GET /clean-logs ★filterCleanLogs() → ページ切り出しの後★ lib/clean-logs/query.ts(★テスト有り★)
2 GET /clean-logs/{id}/advice ★レスポンス組み立て時★ ★ルート内で capConsecutiveDays() を直接呼ぶ★
3 GET /reports/facility-performance ★絞り込み・並べ替えの後★ lib/reports/query.ts(★テスト有り★)

★★②で特に注意★★: app/api/clean-logs/[id]/advice/route.ts は ★:56 と :98 で log.consecutive_problem_days を判定に使っています★ (judgeAdvice の入力と judgeEscalationLevel())。 ★★この2箇所には上限をかけないこと★★(★禁止事項9★)。 ★上限をかけるのは ★:72 / :104 / :114 のレスポンスに載せる値★ だけです★。

★/clean-logs/export(CSV)は consecutive_problem_days を出力していません★(★実測済み★)。★触らない★。

6-3. ★期待値表★

★lib/clean-logs/__tests__/query.test.ts と lib/reports/__tests__/query.test.ts に追記★:

# 入力 maxDays 期待
1 consecutive_problem_days: 30 の行 30 そのまま / capped:false
2 consecutive_problem_days: 31 の行 30 30 / capped:true
3 ★escalation_level: "L3" の行★ 3 ★★L3 のまま★★
4 ★空配列★ 30 空配列(★例外にしない★)
5 ★consecutive_problem_days: 0 の行★ 30 0 / capped:false

★★judgeEscalationLevel() / judgeAdvice() に渡る値が生値のままであることを固定するテストを /clean-logs/{id}/advice 相当の入力で1本書くこと★★(★★禁止事項9 の機械化★★)。

この段階の完了条件

  • 3経路すべてで consecutive_problem_days_capped が返る
  • ★★判定(judgeAdvice / judgeEscalationLevel)に渡る値が生値のまま★★(★テストで固定した★)
  • lib/types/index.ts の3つの型に consecutive_problem_days_capped を足した(★YAML と一致★)
  • ★/clean-logs/export を触っていない★
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

段階7 — ★★システム管理画面から編集できるようにする★★(コミット7)

7-1. ★新しいセクションを足す★

components/features/system/SystemScreen.tsx の ★「アドバイス エスカレーション閾値」セクションの直後★(★関係が近い★)に、 ★★<section className={styles.block}> を1つ足すこと★★。

要素 内容
★見出し★ t("連続日数の表示上限")
★説明★ t("連続問題施設などで、連続日数がこの日数を超えたとき「{n}日以上」と表示します")
★バッジ★ customized === true のとき ★既存の styles.customizedBadge(編集済み)★
★入力欄★ <input type="number">。★min / max / step={1} は ★APIの返り値から★ 入れること★(★画面に数値を直書きしない★)
★単位★ t("日")
★保存ボタン★ t("保存")
★既定値に戻す★ ★customized === true のときだけ出す★。t("既定値に戻す")
★注記★ t("「連続」はカレンダー日ではなく…") ★既存キーを再利用できる★(lib/i18n.ts:203)

★★GET /api/settings/display-limits を初回マウントで1回取ること★★ (★fetchAdvice()(:86-106)と同じ形。★useCallback + useEffect + eslint-disable-next-line react-hooks/set-state-in-effect のコメント付き★)。

7-2. ★★保存の作法は DsLogScreen.tsx の commitNote()(:234)に揃えること★★

状態 作法 参照
★保存中★ ★入力欄とボタンを disabled★ + t("保存中…") を出す(★既存キー lib/i18n.ts:121★) DsLogScreen.tsx:108 / :121
★成功★ ★★レスポンスの値で state を差し替える★★(★送った値ではなく★) + t("保存しました") DsLogScreen.tsx:262-266
★失敗★ ★★(e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました"★★ を t() に通して出す。★入力値は消さない★ DsLogScreen.tsx:274-280
★変更が無い★ ★叩かない★(★無駄な PATCH を出さない★) DsLogScreen.tsx:240-244
★★再取得しない★★ ★★保存後に一覧を取り直さないこと★★(★DsLogScreen.tsx:229-233 の理由と同じ★) —

★★入力欄の値は文字列です★★ — ★Number(input.value) で数値にしてから送ること★。 ★★空文字は Number("") === 0 になります★★ ので、 ★★送る前に「空でないこと」を見ること★★(★★それでもサーバ検証は外さない★★。禁止事項8)。

7-3. ★★「参照のみ」の文言を直す(★必須★)★★

★★1項目だけ編集できるようになるので、現在の文言は事実と食い違います★★。

components/features/system/SystemScreen.tsx:118-121 を次の趣旨に直すこと:

<div className={styles.readonlyNotice}>
  <strong className={styles.readonlyStrong}>{t("この画面は一部の設定のみ編集できます。")}</strong>
  {t("編集できない設定には「編集不可」と表示します。")}
</div>
  • ★★lib/i18n.ts に新しいキーの対訳を足すこと★★:
    • "この画面は一部の設定のみ編集できます。": "Only some settings on this screen can be edited."
    • "編集できない設定には「編集不可」と表示します。": "Settings that cannot be edited are marked “Read-only”."
  • ★★古いキー2本(lib/i18n.ts:166-168)は、grep -rn で他に使っていないことを確認してから消すこと★★。 ★使われていたら残すこと★
  • ★SystemScreen.tsx の冒頭のコメント(:5-9 の「★P1 前半は「参照のみ」★」)も直すこと★ — ★★コメントだけ古いまま残すと、次の人が「参照のみのはずでは」と戻します★★

7-4. ★★編集できない設定との見分けをつける(★必須★)★★

★既存の styles.lockBadge(編集不可。SystemScreen.module.css:250 / SystemScreen.tsx:241)を再利用すること★。

★次の3セクションの見出しに 編集不可 バッジを付けること★:

セクション 現状
事前チェック設定 PRECHECK_MINUTES_BEFORE
完了率の判定閾値 GRADE_THRESHOLDS
アドバイス エスカレーション閾値 ESCALATION_THRESHOLDS

★★アドバイス文言セクションの「発火条件」に付いている 編集不可(:241)は触らないこと★★ (★あれは「セクションの中の一部だけ編集不可」という別の意味★)。

7-5. ★★手で動かして確認すること(★fixtures で確認できます★)★★

USE_FIXTURES=true npm run dev     # ★環境変数の指定方法はリポジトリの既存手順に合わせること★
# 操作 期待
1 システム管理画面を開く ★「一部の設定のみ編集できます」★ + ★他3セクションに 編集不可★
2 上限の入力欄に ★3★(下限)を入れて保存 ★保存中… → 保存しました★ + 編集済み バッジ
3 ★レポート画面(/reports)を開く★ ★★連続問題施設が「3日以上連続で…」になっている★★(★fixtures の 5日 / 6日 の施設★)
4 ★バッジ★ ★★Lv3 / 3日以上★★
5 ★3日ちょうどの施設★ ★★「3日連続で…」のまま(★「以上」が付かない★)★★
6 ★EN に切り替える★ ★★3+ consecutive days / Lv3 / 3+ days★★(★和文が残っていないこと★)
7 ★ダーク / ライト両方★ ★入力欄・ボタンが両方で読めること★
8 入力欄に ★2★ を入れて保存 ★★422 のメッセージが画面に出る★★(★入力値は消えない★)
9 入力欄に ★3.5★ / ★空★ を入れて保存 ★保存されない(★画面が壊れない★)★
10 ★「既定値に戻す」★ ★30 に戻り 編集済み が消える★
11 ★data/settings/display-limits.json を手で壊す★({ だけ書く)→ 画面を開く ★★既定値 30 で開く(★画面が落ちない★)★★ + .bak ができる
12 ★★USE_FIXTURES を切って(実DB経由で)同じことをする★★ ★★同じ挙動★★(★画面側は切替を意識しない。CLAUDE.md §9.5★)

この段階の完了条件

  • ★入力欄・保存ボタン・既定値に戻すボタンが動く★
  • ★保存中 / 成功 / 失敗の表示が commitNote() の作法に揃っている★
  • ★★「参照のみ」の文言を直した(コメントも直した)★★
  • ★他3セクションに 編集不可 バッジ★
  • ★新しい文言すべてに lib/i18n.ts の対訳がある★
  • ★★min / max を画面に直書きしていない(APIの返り値を使っている)★★
  • ★7-5 の12項目を手で確認した(★ライト・ダーク・JP・EN★)★
  • tsc 0 / lint 0 / npm test 全パス
  • コミット

★★ここで止めてもよい★★

★機能としてはここで完成です★。★段階8 は記録だけ★。


段階8 — ★★記録★★(コミット8)

  1. ★docs/decisions.md に決定エントリを1件書く★(★着手時に実測した番号★)
    • ★書くこと★:
      • ★★A案(連続日数の上限だけ先に編集可能にする)を採った経緯★★(ユーザー指示 2026-09-13)
      • ★既定値 30 / 下限 = L3 / 上限 365 の理由★(→ §B)
      • ★★上限に達したとき launch_failed_days を返さない理由★★(→ §B-5)
      • ★★上限をかけるのはルート層(データソースの外側)である理由★★(→ 段階5-4 の枠)
      • ★★capped は「超えた」ときだけ true(上限ちょうどは false)★★
      • ★★指示書AC 段階6 への申し送り3件★★(→ §C-3。★★これを落とさないこと★★)
      • ★設定の変更履歴は残さないという判断とその理由★(→ §H-3)
    • ★★追記直前に grep -nE "^### D-(0|1)[0-9][0-9]" docs/decisions.md | tail -3 で 最終番号を実測してから採番すること★★
    • ★★git diff docs/decisions.md を先に見ること★★(★未コミットの追記を巻き込まない★)
  2. ★docs/handoff.md に追記する★(★先頭の「最新の追記」も一緒に直すこと★)
  3. ★本書冒頭の表と末尾の「実行状態」表を更新する★

この段階の完了条件

  • docs/decisions.md に記録(★申し送り3件が入っている★)
  • docs/handoff.md に追記(★先頭も直っている★)
  • 本書の表2つを更新
  • コミット

F. ★★まとめ:期待値表(★段階をまたいで必ず含めるもの★)★★

★ユーザー指示で「必ず含める」と指定された項目★の所在:

必須項目 どこ
★上限ちょうど★ 段階2 表 #2 / 段階5 表 #2 / 段階3 表 #4
★上限−1★ 段階2 表 #1 / 段階3 表 #5
★上限+1★ 段階2 表 #3 / 段階5 表 #3 / 段階3 表 #6
★★下限の境界(L3 の日数)★★ 段階2 表 #5・#6・#11・#12 / 段階3 表 #2・#3
★整数でない値★ 段階3 表 #7
★負数★ 段階3 表 #8
★巨大値★ 段階3 表 #10・#11
★★保存が無いときに既定値が使われること★★ 段階3 表 #18 / 段階2 表 #10
★★保存が壊れているときに既定値へ倒れること★★ 段階3 表 #20・#21・#22・#23・#24

G. ★★よくある落とし穴★★

# 落とし穴 どうなるか
★1★ ★i18n のキーがテンプレート文字列と1文字違う★ ★★EN に切り替えても和文が残る。例外もテスト失敗も出ない★★(lib/i18n.ts:187-190 の既存コメントが同じ警告をしている)
★2★ ★上限を並べ替えの前にかける★ ★上限に達した施設どうしの順序が潰れる★
★3★ ★判定にも上限をかける★ ★★「3日以上」と出ているのに L3 が出ない★★(禁止事項9)
★4★ ★3 / 30 / 365 を画面や API に直書きする★ ★★サーバ検証と画面の範囲がずれる★★。★設定を変えても片方だけ古い値のまま★
★5★ ★min を 3 と直書きする★ ★L3 の日数を変えたときに追従しない★
★6★ ★Number.isInteger だけで巨大値を弾いたつもりになる★ ★★Number.isInteger(1e9) === true★★。★上限チェックが無いと通る★
★7★ ★GET が静的化される★ ★★保存しても画面に反映されない★★(→ 段階4-3)
★8★ ★launch_failed_days を min(m, n) に丸める★ ★★数字の捏造★★(→ §B-5)
★9★ ★fixtures に「45日連続」の行を足してテストする★ ★★件数(538 / 58)が変わり、既存テストの前提が崩れる★★(禁止事項7)
★10★ ★data/settings/display-limits.json をコミットする★ ★/data は .gitignore。★git add でパスを明示していれば起きない★★

H. ★★申し送り(★段階8 で docs/decisions.md に必ず書くこと★)★★

H-1. ★他の閾値も同じ作法で編集可能にできる★

★本書で確定した作法は次の5点です★。★他の設定へ広げるときはこれをなぞるだけ★:

  1. ★既定値と検証範囲を lib/config/ に置く★(★範囲は他の設定から導く★)
  2. ★lib/settings/json-store.ts の上に lib/settings/<name>.ts を1枚作る★
  3. ★★検証は純粋関数に閉じ込め、PATCH と読み込みの両方で通す★★
  4. ★/settings/<name> に GET + PATCH(★reset 付き★)★
  5. ★画面は DsLogScreen.commitNote() の作法(保存中 / 成功 / 失敗)★

★広げる候補★: GRADE_THRESHOLDS(90 / 70 / 10)/ ESCALATION_THRESHOLDS(L2 / L3)/ PRECHECK_MINUTES_BEFORE(60 / 30)/ LAUNCH_GRACE_MINUTES(30分)。

★★ただしこの4つは「判定」に使われます★★ — ★★表示上限とは重みが違います★★。 ★★閾値を変えると過去の判定結果と食い違う★★(★昨日は ◎ だった清掃が今日は ○ になる★)ので、 ★広げる前に「いつから有効か」「過去の表示をどうするか」をユーザーに確認すること★。 ★★本書の上限は「表示だけ・判定に影響しない」から先に出せた、という点が重要です★★。

H-2. ★指示書AC 段階6 への申し送り★

→ ★★§C-3 の3件をそのまま転記すること★★。

H-3. ★設定の変更履歴は残さない(★今回の判断★)★

  • ★★今回は残しません★★ — ★利用者は DFA 社内のみ・実質1人の運用(CLAUDE.md §1)★。 ★「誰がいつ変えたか」を追う要件が出ていない★
  • ★保存の事実は data/settings/display-limits.json の内容と mtime で分かる★
  • ★★履歴が要るようになったら json-store.ts に足すこと★★(★呼び出し側を変えずに済む★)。 ★★アドバイス文言(advice.json)も同じ状況なので、履歴は2つまとめて設計すること★★

H-4. ★連続問題施設バッジの EN 対訳(★本書で直した★)★

★ReportsScreen.tsx:386 の 日 は t() を通していませんでした★(★EN でも「5日」と出ていた★)。 ★本書の段階5-5 で "{n}日" / "{n}日以上" を通す形に直しています★。 ★★他にも t() を通していない日本語がレポート画面に残っている可能性があります★★ (★例: ReportsScreen.tsx:364 の「2日以上連続で△以下または出発失敗の施設」★)。 ★本書の範囲外。まとめて棚卸しすること★。


実行状態

段階 内容 状態 決定番号 コミット
段階0 実測 ★完了★ — ★コミットなし(読むだけ)★
段階1 d-watch-api.yaml ★完了★ — 68a5c14
段階2 既定値と純粋関数 ★完了★ ★D-130★ 4d063f2
段階3 設定ストアと検証 ★完了★ ★D-130★ 9eb8a69
段階4 API ルート ★完了★ — 4854fc7
段階5 連続問題施設に上限を効かせる ★完了★ ★D-130★ 07f32f3
段階6 残り3経路 ★完了★ ★D-130★ 205b4a8
段階7 システム管理画面 ★完了★ ★D-130★ 27b83cd
段階8 記録 ★完了★ ★D-130★ 4085fbd

★段階7-5 の手動確認12項目は、fixtures(USE_FIXTURES=true)と実DB(dwatch_local)の両方で実施した★ (★ライト/ダーク・JP/EN を含む★)。 ★★①だけ fixtures では確認できない★★ — ★fixtures の summary は手書きの自由文でテンプレートではないため、 上限用テンプレートへの差し替えが起きない(バッジの 3日以上 は正しく出る)★。 ★★実DB経由で 3日以上連続で△以下(出発失敗を含む) になることを実測して代替した★★(→ D-130 の11節)。