★★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 を採った★。
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 で実測した「注入した本文」と「実際に描画された内容」★
・指示書どおりにできなかったところと、その理由
・判断に迷って自分で決めたところ(あれば全部)
★監督が実機で見つけた症状★(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は、不具合と区別できない★」)。 ★無言の空ブロックはこれに真正面から反します★。
★誤解しないでください★。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)ので、 ★★カードの見出しの下に、高さだけある空白が残ります★★ — ★監督が見たものと一致します★。
★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)。
★これは経路に関係なく成立する、もう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の文言と、画面の既定文言が同一の文字列です★★。つまり:
★本指示書は「空にしない」と「原因の種類を言う」の両方を直します★。
★どれも 2026-09-17 に自分で開いて確かめた事実です。★ ★★行番号は着手時にズレている可能性があります。必ず自分で開いて確認してください。★★
| ブロック | 判定 | 画面 | いま出るもの |
|---|---|---|---|
| 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 のコメントが その意図を書いています)。★足りないのは「なぜ」です★。★見出しを増やさないでください★。
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 を直す」は今回は該当しません)。★着手時に自分で確認すること★。
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ルートのみ★」)。★入力の誤りと同じ扱いにしないこと★。
★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)docs/decisions.md D-053 :333-337 固定の選択肢だけがあって何も起きないUIは、不具合と区別できない
lib/i18n.ts:321-322 ★disabled だけにして理由を出さないことはしない★ —
押せないボタンは、理由が書いていないと不具合と区別がつかない
★★D-053 は「何も起きないUIを置くな」であって「ボタンを置くな」ではありません★★。 ★段階4(再試行)の判断はここを踏まえて行います★(§7-3)。
$ 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/ に置きます★。
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)。
★★Cursor はここに回答が書き込まれるまで、段階3 以降に進まないでください★★。 ★段階0〜段階2 は回答が無くても進められます★(画面に出る文言を含まないため)。
★★回答欄★★(監督が埋めてください):
★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行目は重複なので出さない)★
★★現状は出しています★★(ReportsScreen.tsx:305)。★★内部情報は漏れていません★★ — 実測:
"データの取得に失敗しました")。 ★例外の中身は logApiError() でサーバ側のログにだけ出ています★ (app/api/reports/kpi/route.ts:91 等。★例外は err フィールドでログへ、body は定数★)lib/reports/query.ts の ★固定文言+パラメータ名★("weekの値が不正です" など)TARGET_CUSTOMER_REQUIRED_MESSAGE(lib/reports/target-customer.ts:65-66。 ★「顧客またはグループを指定してください。複数の顧客のデータを合算した数値は出せません。」★)message に入る経路は1件もありません★★★ただし将来の漏れ口はあります★★ — apiFetch() は ★{ error: { message } } の形なら何でも画面に出します★(lib/api/client.ts:86)。 ★新しいルートが e.message を message に入れた瞬間に漏れます★。
★置くなら「押せば実際に取り直す」ものにします★(fetchAll() は useCallback なので ★ボタンから呼べます★。ReportsScreen.tsx:182-231)。
★★ただし input(422)のときは置きません★★ — ★同じ条件で押しても必ず同じ422になるので、 それこそ「押しても何も起きないUI」(D-053)になります★。 ★入力を直すのはセレクトと検索ボタンの仕事です★。
| 原因 | ボタン |
|---|---|
| input | ★置かない★(条件を変えるまで結果は変わらない) |
| auth | ★置かない★(ログインし直しが要る。★ボタンでは解決しない★) |
| ★server★ | ★置く★(一過性のことがある) |
| ★network★ | ★置く★(復帰したら取れる) |
★★新しく増える辞書キーは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 のとおり英語を配布するかは未定です★。それでも ★対訳を書いて足すことを提案します★:
lib/i18n.ts は ★キーが日本語原文の1階層辞書★で、 ★足すコスト=1行、足さないコスト=EN表示で和文が出る★。★同じ塊の既存5キー (:329-336)はすべて対訳を持っています★。★ここだけ抜けると不揃いになります★translate() は lang === "en" のときだけ差し替える。lib/i18n.ts:364-367)★理由3(懸念)★: ★英語を配布しないなら、増えた対訳は誰にも読まれません★。 ★辞書が367行から372行前後になります★
★対訳も書いて足す(提案)★
npm run dev(ポート3000)でログインし、レポート画面を開く/api/reports/kpi に422を注入する.blockError の要素が存在するか/中身が空かを見ること★ — ★見た目だけで判断しない★)| DOM | どの経路か | §0-2 の表 |
|---|---|---|
.blockError が★存在し、中身が空★ |
★message === ""★ |
★想定どおり★ |
.blockError が存在し「データの取得に失敗しました」 |
message === null |
★空ではない。監督の観測と違う★ |
.kpiNotice が出ている |
select-customer(customer_ids 付き422) |
★空ではない。監督の観測と違う★ |
| ★どれも無い★ | ★想定外★ | ★★止まって報告★★ |
★★「たぶんこれだろう」で段階1 に進まないでください★★(CLAUDE.md §0-1 / §7)。 ★どの経路だったかが、段階5 で docs/decisions.md に書く事実そのものです★。
★なお、どの経路であっても段階1〜3 の修正は有効です★ — ★空文字も null も想定外の例外も、 同じ1つの関数を通って必ず何かの文言になるようにするから★です。 ★それでも「何を直したか」を正確に書くために、再現は先にやってください★。
git status が clean)★★コミットはありません★★(報告に書くだけ)。
★新規 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つでしか分岐しない★ */
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;
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 を返すルートができたら見直しが要ります★。
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回言う画面にしないでください★。
★★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 |
lib/reports/block-failure.ts と lib/reports/__tests__/block-failure.test.ts がある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)。
ReportBlockView に cause を持たせる★| ファイル | やること |
|---|---|
lib/reports/block-visibility.ts |
★error の variant に cause を足す★・★ReportBlocksView にも cause を足す★ |
lib/reports/__tests__/block-visibility.test.ts |
★toEqual 3箇所を直す★ + ★新テスト★ |
// 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) を入れます★。
select-customer には cause を足さないこと★★★422 の誘導は「エラー」ではなく「指定が足りない」★です (app/api/reports/kpi/route.ts:98「★500 ではなく 422 — サーバの不具合ではなく『指定が足りない』 という話★」)。★既に専用の見せ方(:471-510)を持っているので、原因分類は要りません★。 ★足すと、同じことを2つの仕組みで表すことになります★。
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() が重複を消すので、画面には破綻が出ません★★。
| 行 | 現在 | 直す |
|---|---|---|
: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 だから 「余計なフィールドが増えていない」ことまで見えています★。 ★弱めると、次に誰かがフィールドを足したときに気づけません★。
| # | 状況 | 期待 |
|---|---|---|
| ★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)。
toEqual 3箇所が cause 付きで通る★(★toMatchObject に弱めていない★):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)。
★★確認1・確認2・確認4 の回答が要ります★★(§2)。★無ければここで止まって報告してください★。
| ファイル | やること |
|---|---|
components/features/reports/ReportsScreen.tsx |
★blockError()(:302-307)と全体エラー(:449-453)を reportBlockFailureText() 経由にする★ |
components/features/reports/ReportsScreen.module.css |
★2行になるので .blockError に子要素のクラスを足す★ |
lib/i18n.ts |
★原因4種の文言を足す★(★確認4 の回答どおり★) |
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 が空になる経路がコード上から消えます★★。
: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 のときだけ★なので、★実際には使われません★。★その旨をコメントに書くこと★。
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 にも同じ注意書きがあります★)。
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)。
★段階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 の性質★)。
.blockError が出る経路が無い★★(★message: "" の注入で文言が出る★)ReportsScreen.tsx に原因分岐(status === 422 等)を書いていない★
(`grep -n "422\|=== 500\|status ===" components/features/reports/ReportsScreen.tsx` → ★0件★)
t() に通していない★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)。
components/features/reports/ReportsScreen.tsx と ReportsScreen.module.css ★のみ★。
{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本だけ新しい状態にすると画面の中で期間が食い違います★。★その旨をコメントに書くこと★。
D-053 は ★「固定の選択肢だけがあって何も起きないUIは、不具合と区別できない」★ため 絞り込み <select> を ★消し★ました(docs/decisions.md:333-337)。
★★このボタンは押すと実際に取り直します★★ — ★何も起きないUIではありません★。
★★さらに、shouldOfferRetry() が input / auth のときに false を返すのが要点です★★:
★★つまり「原因によって出し分ける」ことが、D-053 を守る条件そのものです★★。 ★「全部に置く」を選ばないこと★(★確認3 でユーザーがそう答えた場合を除く★)。
同じ趣旨が lib/i18n.ts:321-322 にも明文化されています:
★★
disabledだけにして理由を出さないことはしない★★ — 押せないボタンは、理由が書いていないと不具合と区別がつかない(D-053 と同じ理由)
★★disabled のボタンを置かないこと★★ — ★出すか出さないかの2択です★。
allFailed)にも置くか★★置いてください★ — ★4本とも失敗しているときこそ、通信断や一過性の500である見込みが高い★です。 ★同じ shouldOfferRetry(blocks.cause ?? "server") で判定します★。
disabled のボタンを置いていない★再読み込み の対訳を辞書に足した★(★確認4 が「足す」の場合★)。
★`grep -n "再読み込み" lib/i18n.ts` → 1件(追加した行だけ)★
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)。
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を採った。
docs/decisions.md に書くことCLAUDE.md §6-6「出所を書けない数字は 『実測』と呼ばない」)?? が空文字を既定文言に落とさないという欠陥★と、★それが11箇所すべてに同型で存在すること★ (§1-4。★今回直したのはレポート画面だけ★)/reports/* が入力の誤りで返すのは422だけ、という実測)logApiError() でサーバ側ログのみ。§2 確認2)と、 ★★将来の漏れ口(apiFetch は {error:{message}} なら何でも出す)★★docs/handoff.md に追記すること?? の穴が他10箇所に残っている★★(§1-4 の一覧を転記)。 ★レポート画面以外はサーバが空文字を返さないかぎり顕在化しない★が、 ★★apiFetch は typeof message === "string" しか見ないので、いつでも起こりうる★★データの取得に失敗しました は原因を区別していない★★ — ★レポート画面だけが原因を言うようになった★。★揃えるなら別途★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 が正)。★★契約と実装のズレ。今回は触っていない★★docs/handoff.md に5件の申し送りが入っているgit add にファイルパスを明示している★(★git add -A を使っていない★)コミットメッセージ案
docs: レポート画面のエラー表示を直した経緯を記録する(D-1xx) ?? が空文字を既定文言に落とさず、ブロックが見出しだけになっていた。 同型の穴が他10箇所に残っていることと、サーバ文言を出し続ける判断の 安全確認を申し送りに入れた(指示書AP 段階5)。
| # | 落とし穴 | どうなるか | ||||
|---|---|---|---|---|---|---|
| ★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。★別作業の途中ファイルを巻き込む事故歴あり★) |
| 項目 | なぜ |
|---|---|
★他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) |
| ★スマートフォン幅のレイアウト★ | 別課題 |