# 指示書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`）。★ゼロから作る仕事ではありません★。

```tsx
// 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. 何を作るか

```ts
/** 失敗の原因。★画面の文言はこの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. 型の変更

```ts
// 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[] };
```

```ts
// 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()` の作り直し

```tsx
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`）も同じ関数を通す

```tsx
{!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つ足します★。

```css
/* ★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本ずつ出す」の塊の末尾★
（`データの取得に失敗しました` の直後）に足します。★画面の関心ごとに辞書をまとめるため★。

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

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

```bash
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. やること

```tsx
{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. ★★採番の前に必ず実測すること★★

```bash
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`） |
| ★スマートフォン幅のレイアウト★ | 別課題 |
