Newer
Older
client_dwatch / docs / handoff / 指示書AP_レポート画面のエラー表示を直す.md

指示書AP — レポート画面のエラー表示を直す

★★2026-09-17 — この指示書は「保留」です。実行しないでください★★

★作成時の前提「422 のときエラーの説明が出ない」は、監督の誤りでした★。 ★再現して確かめたところ、message が通常の文字列なら .blockError に正しく表示されます★ (追記58 の訂正ブロック)。

★残る本当の問題は2つで、どちらも今は起きません★:

  • ★message が空文字のときだけ無言になる★(?? が "" を弾かない)。 ★同じ穴が9画面11箇所★。★ただし現在の4本のAPIは固定の日本語定数を返し、空文字を返さない★
  • ★4本のAPIの500文言が画面の既定文言と同一で、原因が区別できない★

★本文の調査部分(原因の特定・11箇所の一覧・文言の揃え先)は有効★なので、 ★将来この穴を塞ぐときの下敷きとして残します★。★段階0(再現)は監督が実施済み★。

作成: 2026-09-17 / ブランチ: develop(HEAD 79bcf20)/ 前提: 監督の実機確認(2026-09-17)

記号の根拠: ls docs/handoff/指示書*.md の実測で、使用済みは D〜Z と AA〜AO(AD は欠番)。 ★最後が AO(指示書AO_ダッシュボードと詳細ログの列を直す.md)なので AP を採った★。


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

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

■ 最初に読むもの(この順で、全部)
  1. CLAUDE.md(全文。特に §0 ルールの優先順位 / §6 作業ルール /
     §7 禁止事項 / §9.3 コーディング規約 / §9.4 判定ロジック / §9.6 フロント)
  2. docs/handoff/指示書AP_レポート画面のエラー表示を直す.md(全文)
  3. lib/reports/block-visibility.ts(★今回の主役。209行。全文★)
  4. lib/reports/__tests__/block-visibility.test.ts(★261行。全文★。
     ★既存の期待値表を壊さないことが今回の要件そのもの★)
  5. components/features/reports/ReportsScreen.tsx の :297-307(blockError)/
     :445-453(loading と allFailed)/ :455-512(KPIブロック)/
     :566-568 / :661-662 / :711-718(残り3ブロック)
  6. lib/api/client.ts(★ApiFetchError が何を持って来るか★。153行)
  7. lib/clean-area/visibility.ts(★lib/ に純粋関数を切り出す手本。置き場所も docstring も倣う★)

■ やること
  指示書AP の 段階0 から 段階5 までを、★番号順に★実行してください。
  ★段階0(再現)で1行もコードを変えないこと★ — ★実測で症状を確かめてから直します★。
  ★段階1・段階2 は「期待値表をテストに落としてから実装する」順序を崩さないでください★
    (CLAUDE.md §6-2 / §9.4)。
  ★段階4(再試行ボタン)はユーザーが「置く」と答えた場合だけ実行します★ —
    ★§2 の確認事項に回答が無ければ、段階3 まででいったん止めて報告してください★。

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

■ コミットの決まり
  ・git add -A は使わないでください。必ずファイルパスを明示してください
  ・コミットメッセージは日本語。「なぜそうしたか」を1〜2行入れてください
  ・push はしないでください

■ 絶対に触らないファイル
  d-watch-mock.jsx / lib/judge/** / lib/db/** / prisma/** / app/api/** /
  d-watch-api.yaml / README.md / docs/code-map.md / .claude/worktrees/** /
  docs/handoff/指示書*.md(★本書を含め、既存の指示書を1文字も書き換えない★)

■ 絶対にやらないこと
  ・★API ルート(app/api/reports/**)を変えること★ — ★今回はサーバ側を1行も変えません★
  ・★エラー文言をサーバ側で組み立て直すこと★(画面の仕事です)
  ・★例外オブジェクトやスタックトレースを画面に出すこと★(§6-3)
  ・★components/ に判定・整形ロジックを書くこと★(★テストが走りません★。落とし穴1)
  ・★他3ブロックが生きる挙動を壊すこと★(★これが今の良い性質★。§5)
  ・★生SQL($queryRaw / $executeRaw / *Unsafe / Prisma.raw)★ — ESLint が落とします
  ・★新しい CSS に font-family を書くこと★(CLAUDE.md §0-3)
  ・★本番DB(133.125.238.144)に接続すること★(★読み取りも不要★)
  ・仕様が不明なところを推測で実装すること(★止まって聞いてください★。CLAUDE.md §0-1)

■ 動作確認
  ★dev server はポート3000です★。ログインが要ります。
  ★段階0 の再現と段階3・段階4 の目視は必須です★ — できなければ
  「できなかった」と正直に書いてください。★推測で「動いているはず」と書かないこと★。

■ 終わったら
  ・段階ごとのコミットハッシュ
  ・tsc / lint / test の結果(★テストファイル数と本数も★。着手前は 97ファイル / 2,161テスト)
  ・★段階0 で実測した「注入した本文」と「実際に描画された内容」★
  ・指示書どおりにできなかったところと、その理由
  ・判断に迷って自分で決めたところ(あれば全部)

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

★監督が実機で見つけた症状★(2026-09-17。ブラウザで fetch を差し替えて /api/reports/kpi に 422 を注入):

# 観測 評価
1 4本とも呼ばれる ★正常★
2 ★残り3ブロックは正常に描画される★ ★★良い挙動。壊さないこと★★
3 ★KPIブロックは見出し「稼働率・出発成功率・清掃完了率・エラー発生率」だけが残り、なぜ数字が出ないのかが画面に出ない★ ★★直す★★
4 他3ブロックが正常なので「壊れている」と気づきにくい 3 の帰結

★なぜ直すか★: このプロジェクトは ★fail-loud★(黙って間違った値を出すより、出せないことを見せる)を 方針にしています(docs/decisions.md D-107 §3 :6052「★C を採った理由 — fail-silent より fail-loud★」/ D-053 :333-337「★固定の選択肢だけがあって何も起きないUIは、不具合と区別できない★」)。 ★無言の空ブロックはこれに真正面から反します★。

0-1. ★★「エラー表示が無い」わけではありません — ある。だが空になりうる★★

★誤解しないでください★。ReportsScreen.tsx には ★既にブロック単位のエラー表示があります★ (指示書AL 段階2 / コミット 877993c)。★ゼロから作る仕事ではありません★。

// components/features/reports/ReportsScreen.tsx:302-307
const blockError = (view: ReportBlockView) =>
  view.kind === "error" ? (
    <div className={styles.blockError}>
      {view.message ?? t("データの取得に失敗しました")}
    </div>
  ) : null;

★★問題は ?? です★★。view.message は ★ApiFetchError.serverMessage をそのまま持って来た値★で、 lib/api/client.ts:86 の判定が ★typeof message === "string" だけ★なので ★★空文字 "" も「読めた文言」として通ります★★。

"" ?? t("データの取得に失敗しました")   →   ""   ← ★既定文言に落ちない★

結果、<div className={styles.blockError}></div> が描画されます。 .blockError は padding: 20px を持つ(ReportsScreen.module.css:346-351)ので、 ★★カードの見出しの下に、高さだけある空白が残ります★★ — ★監督が見たものと一致します★。

0-2. ★★自分で確かめた範囲では、空になる経路はこれ1つだけでした★★

★2026-09-17 に develop HEAD 79bcf20 のコードを開いて追った結果です★:

経路 何が出るか
kind === "select-customer"(customer_ids が数値配列の422) :471-510 の誘導が出る。★空にならない★
kind === "error" / message === null t("データの取得に失敗しました") が出る。★空にならない★
kind === "error" / message が普通の文字列 その文言が出る。★空にならない★
★kind === "error" / message === ""(または空白のみ)★ ★★空の div が出る★★

★★だから段階0 で必ず再現してください★★ — ★監督が注入した本文が分からないと、 直したものが症状に対応しているか確かめられません★。 ★再現できない/別の経路だと分かったら、その時点で止めて報告してください★(CLAUDE.md §0-1)。

0-3. ★★空にならなくても「理由の説明が出ていない」問題は残ります★★

★これは経路に関係なく成立する、もう1つの欠陥です★:

app/api/reports/kpi/route.ts:87                 message: "データの取得に失敗しました"(500)
app/api/reports/facility-performance/route.ts:81  同上
app/api/reports/continuous-problems/route.ts:74   同上
app/api/reports/error-breakdown/route.ts:71       同上
components/features/reports/ReportsScreen.tsx:305  t("データの取得に失敗しました")(画面の既定文言)

★★サーバの500の文言と、画面の既定文言が同一の文字列です★★。つまり:

  • ★500 なのか、通信が切れたのか、入力が悪いのかが、画面からは一切区別できません★
  • ★運用担当がやるべき行動(待つ/条件を変える/DFAに連絡する)が決まりません★

★本指示書は「空にしない」と「原因の種類を言う」の両方を直します★。


1. ★先に知っておくこと(実測)★

★どれも 2026-09-17 に自分で開いて確かめた事実です。★ ★★行番号は着手時にズレている可能性があります。必ず自分で開いて確認してください。★★

1-1. ★4ブロックの「いまのエラー処理」★

ブロック 判定 画面 いま出るもの
KPI block-visibility.ts:157-173 kpiView() ReportsScreen.tsx:458-512 ★見出し(:462 「KPI」/ :463-465 「稼働率・…」)★ + blockError()(:467)+ 422誘導(:471-510)
連続問題施設 block-visibility.ts:151-155 plainView() ReportsScreen.tsx:559-568 見出し(:561)+ blockError()(:568)
エラー種別の内訳 同上 ReportsScreen.tsx:651-662 見出し(:653)+ blockError()(:662)
施設別パフォーマンス 同上 ReportsScreen.tsx:705-718 見出し(:707-708)+ blockError()(:718)。★失敗時は「{n}施設」を出さない(:711)★
★4本とも失敗★ block-visibility.ts:191 allFailed ReportsScreen.tsx:449-453 ★画面全体を1行★(blocks.message ?? t("データの取得に失敗しました"))

★★つまり「どのブロックが失敗したか」はすでに見出しで分かります★★(:461 のコメントが その意図を書いています)。★足りないのは「なぜ」です★。★見出しを増やさないでください★。

1-2. ★エラー形式は API契約どおり★

d-watch-api.yaml:1550-1566   Error スキーマ = { error: { code, message, details? } }
lib/types/index.ts:31-45     ErrorCode(validation_failed / not_found / internal_error /
                             unauthorized / forbidden / invalid_token)
lib/types/index.ts:78-82     ApiErrorDetails(3つの形の union)
lib/api/client.ts:28-63      ApiFetchError(status / code / serverMessage / details)

★★YAML は1文字も変えません★★ — ★今回はサーバ側を変えないので、契約は動きません★ (CLAUDE.md §6-1 の「先に YAML を直す」は今回は該当しません)。★着手時に自分で確認すること★。

1-3. ★この画面に来るステータスの実測★

lib/reports/query.ts:143/151/159/166/179/190   fail(422, "validation_failed", "…の値が不正です", …)
                                                (period / year / month / week / status)
app/api/reports/kpi/route.ts:103-110            422 + details.customer_ids(★対象顧客が定まらない★)
app/api/reports/*/route.ts                      500 + "データの取得に失敗しました"
lib/api/client.ts:117-122                       ★status: 0★(fetch 自体が失敗 = 通信断)
lib/api/client.ts:144-150                       200系でも JSON が壊れていれば serverMessage: null
d-watch-api.yaml:1516-1536                      401 unauthorized / 403 forbidden(proxy が返す)

★★401 / 403 は「画面を開いたままセッションが切れた」ときに実際に来ます★★ (d-watch-api.yaml:1517-1520「★画面(HTMLナビゲーション)は /login へリダイレクトされるため、 この401はAPIルートのみ★」)。★入力の誤りと同じ扱いにしないこと★。

1-4. ★他画面の文言と見せ方(★揃える先はあります★)★

★7画面が同じ既定文言を使っています★:

components/features/dashboard/DashboardScreen.tsx:92-95     (e instanceof ApiFetchError ? e.serverMessage : null) ?? "データの取得に失敗しました"
components/features/dashboard/DashboardScreen.tsx:187-190    同上(エリアモーダル)
components/features/detail-log/DetailLogScreen.tsx:197-200   同上
components/features/detail-log/DetailLogScreen.tsx:286-289   同上(エリアモーダル)
components/features/alerts/AlertsScreen.tsx:117-120          同上
components/features/ds-alerts/DsAlertsScreen.tsx:114-119     同上
components/features/ds-logs/DsLogScreen.tsx:228-231          同上
components/features/facilities/FacilitiesScreen.tsx:85-88    同上
components/features/schedules/SchedulesScreen.tsx:87-90      同上
components/features/clean-area/CleanAreaScreen.tsx:283-287   failure.message ?? t("データの取得に失敗しました")
components/features/reports/ReportsScreen.tsx:305/451        view.message ?? t("データの取得に失敗しました")

★★揃える先 = データの取得に失敗しました★★。★このキーは辞書にもあります★ (lib/i18n.ts:336 データの取得に失敗しました: "Failed to load data")。

★★だから「既定文言を置き換える」のではなく、「原因の種類を1行足す」形にします★★ — ★9画面の文言を今回いっせいに変えるのは範囲外です★(§8)。

★注意点も揃っています★:

  • ★サーバが返した文言は t() に通さない★(ReportsScreen.tsx:300 のコメント 「★生成済みの文をキーにしない★」)。★この作法を維持すること★
  • ★★同じ ?? の穴(空文字が既定文言に落ちない)が11箇所すべてにあります★★ — ★今回直すのはレポート画面だけ★。★残りは申し送りに書きます★(段階5 / §8)

1-5. ★「押しても何も起きないUI」についての既存の立場★

docs/decisions.md D-053 :333-337   固定の選択肢だけがあって何も起きないUIは、不具合と区別できない
lib/i18n.ts:321-322                ★disabled だけにして理由を出さないことはしない★ —
                                   押せないボタンは、理由が書いていないと不具合と区別がつかない

★★D-053 は「何も起きないUIを置くな」であって「ボタンを置くな」ではありません★★。 ★段階4(再試行)の判断はここを踏まえて行います★(§7-3)。

1-6. ★テストの現況(着手前の実測。2026-09-17)★

$ npx vitest run
 Test Files  97 passed (97)
      Tests  2161 passed (2161)
vitest.config.ts:14   include: ["lib/**/*.test.ts", "app/**/*.test.ts"]

★★components/** は走りません★★(落とし穴1)。★判定・整形は必ず lib/ に置きます★。

1-7. ★既存テストがこの挙動を固定しています(★消さないこと★)★

lib/reports/__tests__/block-visibility.test.ts(261行):

行 テスト 今回との関係
:53 #2-1 4本すべて成功 そのまま
:68 ★#2-2 KPIだけ422 → 残り3ブロックは出る★ ★★守るべき性質そのもの★★
:89 #2-3 KPIだけ500 → 残り3ブロックは出る ★同上★。★:97-100 の toEqual を直す★
:107 #2-4 4本とも失敗 → 画面全体を1行 そのまま
:120 #2-5 施設別だけ失敗 → 残り3ブロックは出る ★同上★。★:128-131 の toEqual を直す★
:138 KPI以外の422は誘導にしない そのまま
:149 KPIの422でも customer_ids が無ければ普通のエラー ★:164 の toEqual を直す★
:167 候補0社でも誘導にする そのまま
:180 4本とも失敗+文言が読めなければ message は null そのまま
:197-221 customerIdCandidates そのまま
:223-259 toFetchOutcome そのまま

★★toEqual({ kind: "error", message: … }) が3箇所(:97-100 / :128-131 / :164)★★。 ★cause を足すとこの3箇所が落ちます★。★落ちるのは正常です★ — ★直してください★。 ★★ただし「落ちたから期待値を合わせる」だけで終わらせないこと★★(§4-5)。


2. ★★着手前にユーザーの確定が要ること(3件)★★

★★Cursor はここに回答が書き込まれるまで、段階3 以降に進まないでください★★。 ★段階0〜段階2 は回答が無くても進められます★(画面に出る文言を含まないため)。

★★回答欄★★(監督が埋めてください):

★確認1★ — 原因ごとの文言(★これが本件の中身★)

★4種類に分けて、それぞれ何と出すかを決めます★。★下は提案です★。

原因 来る状況(実測) ★提案する既定文言★
★input★ 422。lib/reports/query.ts の fail(422,…)(period / year / month / week / status が不正) ★「指定した期間・絞り込みでは表示できません」★
★auth★ 401 / 403(d-watch-api.yaml:1516-1536。★開いたままセッションが切れた★) ★「認証エラーです。ログインし直してください」★
★server★ 500(4本すべて)、および 400/404 等の想定外 ★「サーバー側の問題で取得できませんでした」★
★network★ status: 0(lib/api/client.ts:117-122。★通信断・VPN切れ★) ★「サーバーに接続できませんでした(通信エラー)」★

★★実例★★(監督の注入で実際に起きるもの):

今:   [KPI] 稼働率・出発成功率・清掃完了率・エラー発生率
      (空白)

提案: [KPI] 稼働率・出発成功率・清掃完了率・エラー発生率
      指定した期間・絞り込みでは表示できません
      weekの値が不正です                      ← ★サーバが返した文言(あれば2行目)★
500 のとき
今:   データの取得に失敗しました              ← ★サーバの文言と画面の既定文言が同一★
提案: サーバー側の問題で取得できませんでした    ← ★1行だけ(2行目は重複なので出さない)★
  • ★提案どおりでよい★
  • ★文言を変える → ( )
  • ★4分類ではなく422と500の2分類でよい★(★その場合 auth / network は server にまとめます★)

★確認2★ — サーバが返した文言を画面に出し続けるか

★★現状は出しています★★(ReportsScreen.tsx:305)。★★内部情報は漏れていません★★ — 実測:

  • 4本の500はすべて ★固定の日本語定数★("データの取得に失敗しました")。 ★例外の中身は logApiError() でサーバ側のログにだけ出ています★ (app/api/reports/kpi/route.ts:91 等。★例外は err フィールドでログへ、body は定数★)
  • 422 は lib/reports/query.ts の ★固定文言+パラメータ名★("weekの値が不正です" など)
  • KPI の422 は TARGET_CUSTOMER_REQUIRED_MESSAGE(lib/reports/target-customer.ts:65-66。 ★「顧客またはグループを指定してください。複数の顧客のデータを合算した数値は出せません。」★)
  • ★スタックトレース・SQL・ホスト名・テーブル名が message に入る経路は1件もありません★

★★ただし将来の漏れ口はあります★★ — apiFetch() は ★{ error: { message } } の形なら何でも画面に出します★(lib/api/client.ts:86)。 ★新しいルートが e.message を message に入れた瞬間に漏れます★。

  • ★出し続ける(提案。ただし★既定文言と重複するときは2行目を出さない★)★
  • ★出さない(既定文言だけにする)★

★確認3★ — 再試行ボタンを置くか(段階4)

★置くなら「押せば実際に取り直す」ものにします★(fetchAll() は useCallback なので ★ボタンから呼べます★。ReportsScreen.tsx:182-231)。

★★ただし input(422)のときは置きません★★ — ★同じ条件で押しても必ず同じ422になるので、 それこそ「押しても何も起きないUI」(D-053)になります★。 ★入力を直すのはセレクトと検索ボタンの仕事です★。

原因 ボタン
input ★置かない★(条件を変えるまで結果は変わらない)
auth ★置かない★(ログインし直しが要る。★ボタンでは解決しない★)
★server★ ★置く★(一過性のことがある)
★network★ ★置く★(復帰したら取れる)
  • ★上のとおり置く★
  • ★置かない(段階4 を飛ばす)★
  • ★4ブロックぜんぶに置く★

★確認4(i18n。★和文が増えることの是非★)★

★★新しく増える辞書キーは4〜5個です★★(原因4種+再試行を置くなら「再読み込み」)。

★衝突の実測(2026-09-17。lib/i18n.ts は367行)★:

$ grep -c "再読み込み" lib/i18n.ts        → 0
$ grep -c "再試行" lib/i18n.ts            → 0
$ grep -c "認証エラー" lib/i18n.ts        → 0
$ grep -c "サーバー側の問題で" lib/i18n.ts → 0

★D-143 項3 のとおり英語を配布するかは未定です★。それでも ★対訳を書いて足すことを提案します★:

  • ★理由1★: lib/i18n.ts は ★キーが日本語原文の1階層辞書★で、 ★足すコスト=1行、足さないコスト=EN表示で和文が出る★。★同じ塊の既存5キー (:329-336)はすべて対訳を持っています★。★ここだけ抜けると不揃いになります★
  • ★理由2★: ★対訳を足しても日本語表示は1文字も変わりません★(translate() は lang === "en" のときだけ差し替える。lib/i18n.ts:364-367)
  • ★理由3(懸念)★: ★英語を配布しないなら、増えた対訳は誰にも読まれません★。 ★辞書が367行から372行前後になります★

  • ★対訳も書いて足す(提案)★

  • ★和文キーだけ足して対訳は書かない★

3. 段階0 — ★★再現する(コードを1行も変えない)★★

3-1. やること

  1. npm run dev(ポート3000)でログインし、レポート画面を開く
  2. ★監督と同じやり方★でブラウザから /api/reports/kpi に422を注入する
  3. ★★注入したレスポンス本文そのものを記録する★★
  4. ★何が描画されたかを記録する★(★開発者ツールで DOM を見る★。 ★.blockError の要素が存在するか/中身が空かを見ること★ — ★見た目だけで判断しない★)
  5. ★残り3ブロックが描画されていることを確認する★

3-2. ★見分け方★

DOM どの経路か §0-2 の表
.blockError が★存在し、中身が空★ ★message === ""★ ★想定どおり★
.blockError が存在し「データの取得に失敗しました」 message === null ★空ではない。監督の観測と違う★
.kpiNotice が出ている select-customer(customer_ids 付き422) ★空ではない。監督の観測と違う★
★どれも無い★ ★想定外★ ★★止まって報告★★

3-3. ★★想定と違ったら止まること★★

★★「たぶんこれだろう」で段階1 に進まないでください★★(CLAUDE.md §0-1 / §7)。 ★どの経路だったかが、段階5 で docs/decisions.md に書く事実そのものです★。

★なお、どの経路であっても段階1〜3 の修正は有効です★ — ★空文字も null も想定外の例外も、 同じ1つの関数を通って必ず何かの文言になるようにするから★です。 ★それでも「何を直したか」を正確に書くために、再現は先にやってください★。

3-4. 完了条件

  • ★注入した本文を1文字も省略せずに記録した★
  • ★描画された DOM を記録した★
  • ★残り3ブロックが出ていることを確認した★
  • ★コードを1行も変えていない★(git status が clean)

★★コミットはありません★★(報告に書くだけ)。


4. 段階1 — ★失敗の原因分類と文言を純粋関数にする★(★テストが先★)

4-1. 置き場所

★新規 lib/reports/block-failure.ts★ と lib/reports/__tests__/block-failure.test.ts。

★手本は lib/clean-area/visibility.ts★(51行。★components/ に置かない理由が docstring の 冒頭に書いてある★)。★同じ形で書いてください★。

★★components/ に書かないこと★★ — vitest.config.ts:14 の include は ["lib/**/*.test.ts", "app/**/*.test.ts"] で、★components/** は走りません★(落とし穴1)。

★block-visibility.ts に混ぜないこと★ — あちらは ★「どのブロックを出すか」★ の決定表で、 こちらは ★「出せないときに何と書くか」★ です。★混ぜると1ファイルが2つの決定表を持ちます★ (lib/dashboard/rescue-visibility.ts と lib/clean-area/visibility.ts を分けてあるのと同じ考え方)。

4-2. 何を作るか

/** 失敗の原因。★画面の文言はこの4つでしか分岐しない★ */
export type ReportBlockFailureCause = "input" | "auth" | "server" | "network";

/** HTTPステータスから原因を決める。★`ApiFetchError.status` をそのまま渡す★ */
export function reportBlockFailureCause(status: number): ReportBlockFailureCause;

/** 原因ごとの既定文言。★`t()` のキーになるので日本語原文★ */
export const REPORT_BLOCK_FAILURE_TEXT: Record<ReportBlockFailureCause, string>;

export interface ReportBlockFailureText {
  /** ★必ず非空★。画面は `t()` に通して出す */
  defaultText: string;
  /** サーバが返した文言。★`t()` に通さない★。出すものが無ければ null */
  serverText: string | null;
}

/** 画面に出す1〜2行を決める。★これが「空にしない」ことの保証★ */
export function reportBlockFailureText(
  cause: ReportBlockFailureCause,
  serverMessage: string | null
): ReportBlockFailureText;

/** 再試行ボタンを出すか(★段階4。確認3★) */
export function shouldOfferRetry(cause: ReportBlockFailureCause): boolean;

4-3. ★分類の規則(★ここが決定表★)★

status === 0                 → "network"    (lib/api/client.ts:117-122)
status === 401 || 403        → "auth"       (d-watch-api.yaml:1516-1536)
status === 422               → "input"      (lib/reports/query.ts の fail(422,…) と kpi の422)
それ以外(400 / 404 / 500 / 想定外) → "server"

★★422 だけを input にすること★★。★「400番台なら入力の問題」としないこと★ — ★401 / 403 が「指定した期間・絞り込みでは表示できません」と出ると、 運用担当は条件をいじり続けて永遠に直りません★。

★★/reports/* が入力の誤りで返すのは 422 だけ★★という実測に依っています (lib/reports/query.ts:143/151/159/166/179/190 はすべて fail(422, …))。 ★この前提を docstring に書くこと★ — ★将来 400 を返すルートができたら見直しが要ります★。

4-4. ★reportBlockFailureText() の規則★

1. serverMessage を trim する
2. trim 後が空文字なら null 扱いにする          ← ★★今回の直接の原因★★
3. trim 後が defaultText と同じなら null 扱いにする ← ★500 の「データの取得に失敗しました」の重複を消す★
4. defaultText は REPORT_BLOCK_FAILURE_TEXT[cause](★必ず非空★)

★★2 を必ず入れること★★ — ?? は ★"" を既定文言に落としません★。 ★|| に変えて済ませないこと★ — ★|| は将来 serverMessage に 0 や NaN 由来の値が 入ったときに別の事故を作ります★し、★空白だけの文言(" ")は || でも通ります★。 ★trim してから明示的に判定すること★。

★★3 を必ず入れること★★ — ★入れないと500のとき「サーバー側の問題で取得できませんでした」と 「データの取得に失敗しました」が2行並びます★。★同じことを2回言う画面にしないでください★。

4-5. ★期待値表(★先にテストへ★)★

★★reportBlockFailureCause()★★

# 入力 期待 なぜ入れるか
★1-1★ 0 ★"network"★ ★ApiFetchError のネットワーク失敗(client.ts:117-122)★
1-2 401 "auth"
1-3 403 "auth"
★1-4★ ★422★ ★"input"★ ★本件の本体★
★1-5★ ★400★ ★"server"★ ★★400番台をまとめて input にしていないことの担保★★
1-6 404 "server" 同上
1-7 500 "server"
1-8 503 "server"
★1-9★ ★200★ ★"server"★ ★想定外(成功なのに失敗として来た)を input に落とさない★
★1-10★ ★-1★ ★"server"★ ★負値でも例外を投げないこと★

★★1-5 を必ず入れること★★ — ★status >= 400 && status < 500 と書いた瞬間に 401/403/404 が input に化けます★。★例外も tsc エラーも出ません★。

★★reportBlockFailureText()★★

# 入力 期待 なぜ入れるか
★2-1★ ("server", "") ★serverText === null★ ★★今回の直接の原因★★
★2-2★ ("server", " ") ★serverText === null★ ★空白だけの文言★
★2-3★ ("server", "\n\t") ★serverText === null★ ★改行だけの文言★
2-4 ("server", null) serverText === null
★2-5★ ("server", "データの取得に失敗しました") ★serverText === null★ ★★既定文言との重複を消す(§4-4 の3)★★
★2-6★ ("input", "weekの値が不正です") ★serverText === "weekの値が不正です"★ ★422の実物(lib/reports/query.ts:179)★
★2-7★ ("input", " weekの値が不正です ") ★"weekの値が不正です"★ ★trim した結果を返す(前後の空白を画面に持ち込まない)★
★2-8★ ★4種すべて★ ★defaultText が空文字でない★ ★★「必ず何か出る」ことの担保。これが本件の要★★
★2-9★ ★4種すべて★ ★defaultText が REPORT_BLOCK_FAILURE_TEXT と一致★ ★画面側で言い換えていないことの担保★
2-10 ("auth", TARGET_CUSTOMER_REQUIRED_MESSAGE) serverText はそのまま 原因が違えば重複判定に当たらない

★★2-8 を必ず入れること★★ — ★★これが「見出しだけ残って中身が空」を二度と起こさない唯一の固定点です★★。 ★4種を配列で回して expect(text.defaultText.trim()).not.toBe("") を確かめてください★。 ★Object.keys() で回すこと★ — ★1つずつ書くと、原因を足したときに漏れます★。

★★shouldOfferRetry()★★(★確認3 が「置かない」なら段階4 ごと不要ですが、関数とテストは書いておきます★)

# 入力 期待 なぜ
3-1 "input" ★false★ ★同じ条件では必ず同じ422(D-053)★
3-2 "auth" ★false★ ★ログインし直しが要る★
3-3 "server" true
3-4 "network" true

4-6. 完了条件

  • lib/reports/block-failure.ts と lib/reports/__tests__/block-failure.test.ts がある
  • ★テストを実装より先に書いた★(CLAUDE.md §6-2 / §9.4)
  • ★REPORT_BLOCK_FAILURE_TEXT の4つの値がすべて非空★(2-8 で固定)
  • ★components/ に1行も書いていない★
  • ★ReportsScreen.tsx をまだ変えていない★(★段階3 でつなぎます★)
  • npx tsc --noEmit → ★0★
  • npm run lint → ★0★
  • npm test → 全パス。★★98ファイル(+1)/ 2,185前後(+20〜26)★★

コミットメッセージ案

feat(reports): ブロック失敗の原因分類と文言を純粋関数にする

サーバ文言が空文字だと ?? が既定文言に落ちず、ブロックが見出しだけで
中身が空になっていた。trim して空なら既定文言へ落ちることを期待値表で固定した。
422だけを入力の誤りとし、401/403 を巻き込まないことも併せて固定している
(指示書AP 段階1 / D-107 §3 の fail-loud)。

5. 段階2 — ★ReportBlockView に cause を持たせる★

5-1. 触るファイル

ファイル やること
lib/reports/block-visibility.ts ★error の variant に cause を足す★・★ReportBlocksView にも cause を足す★
lib/reports/__tests__/block-visibility.test.ts ★toEqual 3箇所を直す★ + ★新テスト★

5-2. 型の変更

// lib/reports/block-visibility.ts:111-126
export type ReportBlockView =
  | { kind: "ready" }
  | { kind: "error"; ★cause: ReportBlockFailureCause★; message: string | null }
  | { kind: "select-customer"; message: string | null; candidateCustomerIds: number[] };
// lib/reports/block-visibility.ts:135-148
export interface ReportBlocksView {
  allFailed: boolean;
  message: string | null;
  ★/** `allFailed` のときに出す1行の原因。★成功が1本でもあれば null★ */★
  ★cause: ReportBlockFailureCause | null;★
  kpi: ReportBlockView;
  …
}

★plainView()(:151-155)と kpiView()(:157-173)で cause: reportBlockFailureCause(outcome.failure.status) を入れます★。

5-3. ★★select-customer には cause を足さないこと★★

★422 の誘導は「エラー」ではなく「指定が足りない」★です (app/api/reports/kpi/route.ts:98「★500 ではなく 422 — サーバの不具合ではなく『指定が足りない』 という話★」)。★既に専用の見せ方(:471-510)を持っているので、原因分類は要りません★。 ★足すと、同じことを2つの仕組みで表すことになります★。

5-4. ★ReportBlocksView.cause の決め方(★message と揃わないことがある★)★

message : ★最初に見つかった「文言が読めた失敗」★の文言(:193-199。★既存の規則を変えないこと★)
cause   : ★最初の失敗★の原因

★★この2つは別の本から来ることがあります★★ — 例: KPIが status:0(文言なし)で、 施設別が500(文言あり)のとき、cause = "network" / message = "データの取得に失敗しました"。

★message の規則を変えないこと★ — :140-142 のコメントが 「★Promise.all だったころの『最初に失敗した1本の文言を出す』と同じ見え方に寄せている★」と 書いており、★既存テスト :107-118(#2-4)がこれを固定しています★。

★代わりに、この食い違いをテストで固定してください★(下の 4-4)。 ★★reportBlockFailureText() が重複を消すので、画面には破綻が出ません★★。

5-5. ★既存テストの修正(3箇所)★

行 現在 直す
:97-100 toEqual({ kind: "error", message: "データの取得に失敗しました" }) ★cause: "server" を足す★
:128-131 toEqual({ kind: "error", message: "施設別が読めません" }) ★cause: "server" を足す★
:164 toEqual({ kind: "error", message: "weekの値が不正です" }) ★cause: "input" を足す★

★★toEqual を toMatchObject に弱めないこと★★ — ★toEqual だから 「余計なフィールドが増えていない」ことまで見えています★。 ★弱めると、次に誰かがフィールドを足したときに気づけません★。

5-6. ★足すテスト★

# 状況 期待
★4-1★ KPIだけ status: 0 ★kpi.cause === "network"★ / ★残り3本は ready★
4-2 KPIだけ 401 kpi.cause === "auth" / 残り3本は ready
★4-3★ ★KPIだけ422(customer_ids あり)★ ★kind === "select-customer"★(★cause を持たない★。§5-3)
★4-4★ KPIが status:0(文言なし)+ 残り3本が500(文言あり) ★allFailed === true★ / ★cause === "network"★ / ★message === "データの取得に失敗しました"★
4-5 4本とも500 cause === "server"
★4-6★ ★1本でも成功★ ★cause === null★

★★4-1 と 4-3 を必ず入れること★★ — ★★「残り3ブロックが生きる」という今の良い性質を、 原因分類を足したあとも固定するためです★★(監督の実測 #2)。

5-7. 完了条件

  • ★toEqual 3箇所が cause 付きで通る★(★toMatchObject に弱めていない★)
  • ★新テスト6本★
  • ★既存の11テスト(:53〜:195)が1つも削除・書き換えされていない★
    (★`cause` の追加以外に差分が無いこと★。`git diff` で確かめる)
  • ★select-customer に cause が無い★
  • npx tsc --noEmit → 0 / npm run lint → 0 / npm test → 全パス。
    ★★98ファイル / 2,191前後(段階1 から +6)★★

コミットメッセージ案

feat(reports): ブロックの失敗に原因の種類を持たせる

500 なのか通信断なのか入力の誤りなのかが画面から区別できず、運用担当が
とるべき行動(待つ/条件を変える/連絡する)を決められなかった。
残り3ブロックが生きる性質は既存テストのまま維持し、network と
select-customer の組み合わせを新しく固定した(指示書AP 段階2)。

6. 段階3 — ★画面につなぐ(★空にしない★)★

★★確認1・確認2・確認4 の回答が要ります★★(§2)。★無ければここで止まって報告してください★。

6-1. 触るファイル

ファイル やること
components/features/reports/ReportsScreen.tsx ★blockError()(:302-307)と全体エラー(:449-453)を reportBlockFailureText() 経由にする★
components/features/reports/ReportsScreen.module.css ★2行になるので .blockError に子要素のクラスを足す★
lib/i18n.ts ★原因4種の文言を足す★(★確認4 の回答どおり★)

6-2. blockError() の作り直し

const blockError = (view: ReportBlockView) => {
  if (view.kind !== "error") return null;
  // ★1〜2行の中身は lib/ の純粋関数が決める★ — ★ここに決定表を写さないこと★
  const text = reportBlockFailureText(view.cause, view.message);
  return (
    <div className={styles.blockError}>
      {/* ★既定文言は t() を通す★(辞書のキー) */}
      <div className={styles.blockErrorText}>{t(text.defaultText)}</div>
      {/* ★サーバが返した文言は t() を通さない★(:300 の作法。生成済みの文をキーにしない) */}
      {text.serverText && (
        <div className={styles.blockErrorDetail}>{text.serverText}</div>
      )}
    </div>
  );
};

★★text.defaultText は必ず非空★★(段階1 の 2-8 で固定済み)なので、 ★★.blockError が空になる経路がコード上から消えます★★。

6-3. 全体エラー(:449-453)も同じ関数を通す

{!loading && blocks?.allFailed && (() => {
  // ★cause は allFailed のとき必ず入る(段階2 の 4-6)★
  const text = reportBlockFailureText(blocks.cause ?? "server", blocks.message);
  return (
    <div className={styles.error}>
      <div>{t(text.defaultText)}</div>
      {text.serverText && <div className={styles.blockErrorDetail}>{text.serverText}</div>}
    </div>
  );
})()}

★?? "server" は型を通すための保険★です。★cause が null になるのは allFailed === false のときだけ★なので、★実際には使われません★。★その旨をコメントに書くこと★。

6-4. CSS(★font-family を書かないこと★)

ReportsScreen.module.css の .blockError(:346-351)はそのまま残し、 ★子要素のクラスを2つ足します★。

/* ★2行目(サーバが返した文言)は控えめにする★ — 1行目(原因)が主で、これは補足 */
.blockErrorDetail {
  margin-top: 6px;
  font-size: 11px;
  color: var(--tx-m);
}

★★font-family を書かないこと★★(CLAUDE.md §0-3。 ★このファイルの :341-344 にも同じ注意書きがあります★)。

6-5. i18n(★確認4 が「足す」の場合★)

lib/i18n.ts の ★:325-336 の「レポート画面を1本ずつ出す」の塊の末尾★ (データの取得に失敗しました の直後)に足します。★画面の関心ごとに辞書をまとめるため★。

// ---- ブロック失敗の原因(指示書AP 段階3 / §2 確認1)----
// ★既定文言 `データの取得に失敗しました`(上)は9画面が共有しているので動かさない★ —
//   ここに足すのは「原因の種類」を言う4行だけ(指示書AP §1-4)。
指定した期間・絞り込みでは表示できません: "…",
認証エラーです。ログインし直してください: "…",
サーバー側の問題で取得できませんでした: "…",
"サーバーに接続できませんでした(通信エラー)": "…",

★★足す前に必ず grep すること★★:

grep -n "指定した期間\|認証エラー\|サーバー側の問題\|接続できませんでした" lib/i18n.ts

★着手前の実測では 0件★です。★1件でも出たら止まって報告してください★ — lib/i18n.ts は ★キーが日本語原文の1階層辞書★なので、 ★同じ日本語を別の意味で使うと必ず衝突します★(未対応 → Unsupported の事故が実在。D-146)。

★★キーの文字列は REPORT_BLOCK_FAILURE_TEXT の値と1文字も違わないこと★★ — ★違うと EN で和文が出ますが、tsc も lint もテストも通ります★(落とし穴6)。

6-6. ★目視で確かめること(★必須★)★

★段階0 と同じ注入で、★★4つとも★★確かめてください★:

注入 期待
★422({"error":{"code":"validation_failed","message":""}})★ ★★「指定した期間・絞り込みでは表示できません」が出る(★空にならない★)★★
422(message: "weekの値が不正です") 1行目に原因、★2行目に「weekの値が不正です」★
500(message: "データの取得に失敗しました") ★1行だけ★(★2行にならない★。§4-4 の3)
★4本とも失敗★ ★画面全体が1行(★カードが4つ並ばない★)★

★★どの注入でも、残り3ブロックが正常に描画されていることを毎回確認すること★★ (★監督の実測 #2 の性質★)。

6-7. 完了条件

  • ★★空の .blockError が出る経路が無い★★(★message: "" の注入で文言が出る★)
  • ★500 で同じ文が2行にならない★
  • ★残り3ブロックが正常に描画される★(★4パターンすべてで確認★)
  • ★ReportsScreen.tsx に原因分岐(status === 422 等)を書いていない★
    (`grep -n "422\|=== 500\|status ===" components/features/reports/ReportsScreen.tsx` → ★0件★)
  • ★サーバの文言を t() に通していない★
  • ★新しいCSSに font-family が無い★
    (`grep -c "font-family" components/features/reports/ReportsScreen.module.css` → ★1(既存のコメント行のみ)★)
  • npx tsc --noEmit → 0 / npm run lint → 0 / npm test → 全パス
    (★★本数は段階2 のまま=98ファイル / 2,191前後★★)

★★テストは増えません★★ — ★画面のテストは存在しません★(vitest.config.ts:14)。 ★「増えないこと」自体が正しい★ので、増やそうとして components/ にテストを置かないでください。

コミットメッセージ案

fix(reports): ブロックが失敗したとき必ず理由を出す

サーバ文言が空文字だと見出しだけのカードが残り、なぜ数字が出ないのかが
画面に出ていなかった(監督の実機確認 2026-09-17)。原因の種類を1行目に必ず出し、
サーバの文言は重複しないときだけ2行目に添える。残り3ブロックが生きる挙動は
そのまま(指示書AP 段階3 / D-107 §3 の fail-loud / D-053)。

7. 段階4 — ★再試行の導線★(★確認3 が「置く」の場合のみ★)

7-1. 触るファイル

components/features/reports/ReportsScreen.tsx と ReportsScreen.module.css ★のみ★。

7-2. やること

{shouldOfferRetry(view.cause) && (
  <button
    type="button"
    className={styles.blockErrorRetry}
    onClick={() => void fetchAll()}
  >
    {t("再読み込み")}
  </button>
)}

★fetchAll は useCallback(:182-231)なので、そのまま呼べます★。 ★★blockError() は fetchAll を参照するので、定義位置に注意すること★★ (fetchAll は :182、blockError は :302 なので順序は満たしています)。

★★1ブロックだけ押しても4本とも取り直します★★ — ★これは意図どおりです★。 ★1本だけ取り直す仕組みを作らないこと★ — ★4本は同じ期間・同じ絞り込みで取るので、 1本だけ新しい状態にすると画面の中で期間が食い違います★。★その旨をコメントに書くこと★。

7-3. ★★D-053 との整合(★聞かれる前に書いておきます★)★★

D-053 は ★「固定の選択肢だけがあって何も起きないUIは、不具合と区別できない」★ため 絞り込み <select> を ★消し★ました(docs/decisions.md:333-337)。

★★このボタンは押すと実際に取り直します★★ — ★何も起きないUIではありません★。

★★さらに、shouldOfferRetry() が input / auth のときに false を返すのが要点です★★:

  • ★422 で再試行ボタンを出すと、押しても必ず同じ422になります★ → ★★それこそ D-053 が禁じた「押しても何も起きないUI」です★★
  • ★401 / 403 も同じ★。★ログインし直さないと直りません★

★★つまり「原因によって出し分ける」ことが、D-053 を守る条件そのものです★★。 ★「全部に置く」を選ばないこと★(★確認3 でユーザーがそう答えた場合を除く★)。

同じ趣旨が lib/i18n.ts:321-322 にも明文化されています:

★★disabled だけにして理由を出さないことはしない★★ — 押せないボタンは、理由が書いていないと不具合と区別がつかない(D-053 と同じ理由)

★★disabled のボタンを置かないこと★★ — ★出すか出さないかの2択です★。

7-4. ★全体エラー(allFailed)にも置くか★

★置いてください★ — ★4本とも失敗しているときこそ、通信断や一過性の500である見込みが高い★です。 ★同じ shouldOfferRetry(blocks.cause ?? "server") で判定します★。

7-5. 完了条件

  • ★500 / 通信断の注入でボタンが出る★
  • ★★422 / 401 の注入でボタンが出ない★★(★これが D-053 の条件★)
  • ★押すと4本とも取り直す★(開発者ツールのネットワークで4本を確認)
  • ★disabled のボタンを置いていない★
  • ★再読み込み の対訳を辞書に足した★(★確認4 が「足す」の場合★)。
    ★`grep -n "再読み込み" lib/i18n.ts` → 1件(追加した行だけ)★
  • ★新しいCSSに font-family が無い★
  • npx tsc --noEmit → 0 / npm run lint → 0 / npm test → 全パス(★本数は段階3 のまま★)

コミットメッセージ案

feat(reports): サーバ側・通信の失敗にだけ再読み込みを置く

一過性の500や通信断は押せば直るが、422(入力の誤り)と401/403で同じボタンを
出すと押しても必ず同じ結果になり、D-053 が禁じた「押しても何も起きないUI」に
なる。出し分けの判定は lib/reports/block-failure.ts に置いた(指示書AP 段階4)。

8. 段階5 — ★記録★

8-1. ★★採番の前に必ず実測すること★★

grep -n "^### D-" docs/decisions.md | tail -3

★★^### D-0 では3桁台を拾えません★★(★二重採番の事故歴があります★)。

★着手前の実測では最終番号は D-148(docs/decisions.md:12001)★です。 ★★着手時点で増えている可能性があるので、必ず自分で確認してください★★。

★採番の根拠を本文の冒頭に1行残すこと★:

★採番についての注記★: grep -n "^### D-" docs/decisions.md | tail -3 で最終番号が ★D-1xx★ であることを実測してから D-1yy を採った。

8-2. docs/decisions.md に書くこと

  • ★★段階0 で実測した事実★★ — ★注入した本文と、実際に描画された DOM★。 ★「たぶんこうだろう」ではなく、見たものを書く★(CLAUDE.md §6-6「出所を書けない数字は 『実測』と呼ばない」)
  • ★?? が空文字を既定文言に落とさないという欠陥★と、★それが11箇所すべてに同型で存在すること★ (§1-4。★今回直したのはレポート画面だけ★)
  • ★原因を4分類(input / auth / server / network)にしたこと★と、 ★★422 だけを input にした理由★★(/reports/* が入力の誤りで返すのは422だけ、という実測)
  • ★サーバの500の文言と画面の既定文言が同一の文字列だったこと★と、★重複を消す規則を入れたこと★
  • ★★サーバの文言を画面に出し続ける判断と、その時点での安全確認★★ (4本とも固定の日本語定数、例外は logApiError() でサーバ側ログのみ。§2 確認2)と、 ★★将来の漏れ口(apiFetch は {error:{message}} なら何でも出す)★★
  • ★再試行ボタンを原因で出し分けた理由★と、★D-053 との整合★(段階4 を実施した場合)
  • ★★指示書AL 段階2 で入れた「残り3本は出す」性質を、今回も壊していないこと★★ (★新テスト 4-1 / 4-3 で固定した★)
  • ★ユーザーが決めた文言(確認1)を原文のまま転記すること★

8-3. docs/handoff.md に追記すること

  • ★★同じ ?? の穴が他10箇所に残っている★★(§1-4 の一覧を転記)。 ★レポート画面以外はサーバが空文字を返さないかぎり顕在化しない★が、 ★★apiFetch は typeof message === "string" しか見ないので、いつでも起こりうる★★
  • ★★9画面の既定文言 データの取得に失敗しました は原因を区別していない★★ — ★レポート画面だけが原因を言うようになった★。★揃えるなら別途★
  • ★ReportsScreen.tsx:445 の 読み込み中… が t() を通っていない★ (★辞書には "読み込み中…": "Loading…" が lib/i18n.ts:73 にある★)。★既存の抜け★
  • ★d-watch-api.yaml:1550-1566 の Error.details は array of string しか許していない★が、 ★実装は { customer_ids: [1,2] }(数値配列)と { total, max }(整数)も返す★ (lib/types/index.ts:78-82 の union が正)。★★契約と実装のズレ。今回は触っていない★★
  • ★英語を配布すると決めた時点で、増えた対訳4〜5件も含めて EN 表示を通しで確認する必要がある★

8-4. 完了条件

  • ★採番を実測してから採ったことが本文に書いてある★
  • ★段階0 の実測(注入した本文・描画された DOM)が書いてある★
  • docs/handoff.md に5件の申し送りが入っている
  • ★git add にファイルパスを明示している★(★git add -A を使っていない★)

コミットメッセージ案

docs: レポート画面のエラー表示を直した経緯を記録する(D-1xx)

?? が空文字を既定文言に落とさず、ブロックが見出しだけになっていた。
同型の穴が他10箇所に残っていることと、サーバ文言を出し続ける判断の
安全確認を申し送りに入れた(指示書AP 段階5)。

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

# 落とし穴 どうなるか
★1★ ★判定・整形を components/ に書く★ ★★テストが走らない★★(vitest.config.ts:14 の include は lib/** と app/** のみ)。★落ちないのでゼロ件成功と区別がつかない★
★2★ ★?? を `\ \ ` に変えて済ませる★ ★空白だけの文言(" ")は `\ \ ` でも通る★。★★trim してから明示的に判定すること★★(§4-4)
★3★ ★status >= 400 && status < 500 を input にする★ ★★401 / 403 / 404 が「指定した期間・絞り込みでは表示できません」になる★★。★運用担当は条件をいじり続けて永遠に直らない★。★例外も tsc エラーも出ない★
★4★ ★select-customer にも cause を足す★ ★同じことを2つの仕組みで表す★(§5-3)。★422誘導は既に専用の見せ方を持っている★
★5★ ★既定文言との重複を消さない★ ★500 で「サーバー側の問題で取得できませんでした」と「データの取得に失敗しました」が2行並ぶ★
★6★ ★辞書のキーと REPORT_BLOCK_FAILURE_TEXT の値が1文字違う★ ★★EN で和文が出る★★。★tsc も lint もテストも通る★
★7★ ★サーバの文言を t() に通す★ ★生成済みの文をキーにすることになる★(ReportsScreen.tsx:300 の作法)。★辞書の対訳に誤って当たる★
★8★ ★toEqual を toMatchObject に弱める★ ★「余計なフィールドが増えていない」ことが見えなくなる★(§5-5)
★9★ ★既存テスト(:53〜:195)を消す/書き換える★ ★★「残り3ブロックが生きる」性質の唯一の固定点★★。★cause の追加以外の差分を作らないこと★
★10★ ★ReportsScreen.tsx に status === 422 を書く★ ★決定表が2箇所になる★。★片方だけ直すと静かにズレる★(app/api/reports/kpi/route.ts:70 と同じ趣旨)
★11★ ★422 にも再試行ボタンを出す★ ★★押しても必ず同じ422。D-053 が禁じた「押しても何も起きないUI」そのもの★★
★12★ ★disabled のボタンを置く★ ★理由が書いていない押せないボタンは不具合と区別がつかない★(lib/i18n.ts:321-322)
★13★ ★1ブロックだけ取り直す仕組みを作る★ ★画面の中で期間・絞り込みが食い違う★(§7-2)
★14★ ★app/api/reports/** を変える★ ★★今回はサーバ側を1行も変えません★★。★文言を組み立てるのは画面の仕事★
★15★ ★d-watch-api.yaml を直す★ ★契約は動きません★(§1-2)。★ズレは申し送りに書くだけ★(§8-3)
★16★ ★9画面の既定文言をいっせいに変える★ ★範囲外★(§10)。★レポート画面だけ直す★
★17★ ★新しいCSSに font-family を書く★ ★漢字が中華字形になる★(CLAUDE.md §0-3。★同じ注意が ReportsScreen.module.css:341-344 にある★)
★18★ ★段階0 を飛ばす★ ★★何を直したのか書けなくなる★★。★別の経路だった場合に気づけない★
★19★ ★決定番号を先に決める★ ★★二重採番の事故歴あり★★。★着手直前に `grep -n "^### D-" \ tail -3` で実測する★
★20★ ★git add -A★ ★★禁止★★(CLAUDE.md §7。★別作業の途中ファイルを巻き込む事故歴あり★)

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

項目 なぜ
★他9画面の ?? の穴を直す★ ★★同型だが、サーバが空文字を返さないかぎり顕在化しない★★。★9画面ぶんの目視確認が要るので別の指示書にする★。★申し送りに書く★(§8-3)
★9画面の既定文言に原因分類を入れる★ ★同上★。★レポート画面で形が固まってから横展開する★
★app/api/reports/** の500の文言を変える★ ★サーバ側は今回1行も変えません★。★画面側で区別がつくようにするのが本件★
★d-watch-api.yaml の Error.details を実装に合わせる★ ★契約の改訂は CLAUDE.md §6-1 の手順が要る★。★申し送りに書くだけ★(§8-3)
★ErrorCode で分岐する★ ★code は null になりうる★(lib/api/client.ts:88。★code が欠けても message は採る★契約)。★★status のほうが必ず入る★★
★エラーの自動再試行(指数バックオフ等)★ ★要件に無い★。★勝手に何度も叩かない★
★vitest.config.ts の include を広げて画面のテストを書けるようにする★ ★別課題★(指示書AO §12 と同じ判断)
★読み込み中… を t() に通す★ ★既存の抜け★。★申し送りに書く★(§8-3)
★エラー時にトースト/ダイアログを出す★ ★この画面にトーストの仕組みが無い★。★★ブロックの中に出すのが指示書AL の設計★★(block-visibility.ts:115)
★スマートフォン幅のレイアウト★ 別課題