Newer
Older
client_dwatch / docs / handoff / 指示書BC_画面の古い応答・失敗表示・設定の保存を直す_20261019.md

指示書BC — 画面の古い応答・失敗の表示・表示設定の保存を直す(画面側だけ)

日付: 2026-10-19 / 担当: Claude(★ユーザー決定 2026-10-19★) / 基点: develop 2fc3bbb ★+ 指示書BB(D-182)のコミット★ ★依存: 本書は指示書BB(docs/handoff/指示書BB_データ層の止まる経路と削除済みの扱いを揃える_20261019.md)のコミットが develop に入ってから始める★(段階0-1)。BB が型を nullable にし(serial_number / model_name / last_reported_at)、AreaCompletionItem.result_pending を足すので、本書の画面はその上で書く(BB §8 の B1〜B5 → 本書 §段階2-8)。 前提: 指示書BB §2(U2・A1・A3・D6・D11)と §8(B1〜B7)/D-165(編集モード)/D-181(編集モードの3画面の見た目)/D-162(固定幅)/D-171(備考の文字の頭)/D-077(生値を t() に通さない)/D-173(結果待ち)/指示書BA(本書の書式と G1〜G4 の元)/ CLAUDE.md §0-3(日本語フォント)・§4.3(判定は生値)・§6・§7・§9.6(モックが画面の正・ダーク/ライトと JP/EN の器を壊さない)/ node_modules/next/dist/docs/01-app/02-guides/preventing-flash-before-hydration.md(Next.js 16.3.3 の公式ガイド。段階3 の根拠)

★行番号はすべて 2026-10-19 に develop 2fc3bbb で確かめたもの★。同じファイルを上から直すと後ろの行はずれるので、★2つ目以降の場所は引用した文言・関数名で探し直すこと★。 ★同じファイルを複数の段階で触る★(DetailLogScreen.tsx・AlertsScreen.tsx・DsLogScreen.tsx・ReportsScreen.tsx・CleanLogTable.tsx・lib/i18n.ts)。★前の段階のコミットの後の行で探し直す★。

★本書は「書いてあることを全部やる」前提で、直す場所を1つずつ挙げてある★。表に無いものを見つけたら §2 のルールで直すか §7 に足し、作業報告に書く。

★範囲は画面側だけ★(components/**・app/(dashboard)/**・app/login/**・lib/i18n.ts・components/theme/**。例外は §9 に挙げた app/layout.tsx と、テストのために置く lib/ の純粋関数4本)。 ★データ層・API・型・YAML の直しは指示書BB★ — ★指示書BB_* は本書の担当ではない。触らない★。★本書は API・型を変えない★が、★BB が変えた型(null がありうる項目・result_pending)を画面で出す作業は本書がやる★(§段階2-8)。

★git add -A・git add . は禁止★(CLAUDE.md §7)。★ファイルパスを1つずつ書く★。★push しない★(§11)。


0. 何をするか

# やること 種類 段階
1 ★U4 6画面が古い応答を捨てていない★(清掃詳細ログ・アラート・D-Sec ログ・施設マスタ・スケジュール・レポート)→ ★let cancelled + 片付け★(レポートは ★結果にクエリを持たせる★形) 不具合 段階1
2 レポートの「全N施設を表示」「他N件を表示」で画面全体が「読み込み中…」に入れ替わりスクロールが飛ぶ → ★期間・分類が変わらないときは前の内容を薄く残す★(#1 と同じ書き換えで直す) 不具合 段階1
3 アドバイスの取得に失敗すると「該当するアドバイスはありません」と出て「エリア別詳細を見る →」も消える → ★「アドバイスの取得に失敗しました」・ボタンは常に出す★。★2行を素早く開くと読み込み中の表示が消える競合★も直す 不具合 段階2
4 清掃詳細ログの CSV ボタンが、期間の誤り・読み込み中でも押せる → ★押せなくする★ 不具合 段階2
5 アラートの期間が「開始 > 終了」でも黙って0件 →★エラー一覧と同じ検証と「指定なし(全期間)」★(サーバーの 422 は指示書BB) 不具合 段階2
6 清掃面積の粒度タブで画面全体が「読み込み中…」に入れ替わりスクロールが飛ぶ → ★数値カードと内訳の枠を残し、表だけ薄くする★ 不具合 段階2
7 レポートの「全件表示」は上限 200 で黙って切れる(施設別パフォーマンス・連続問題施設)→ ★切れたら注記を出す★ 不具合 段階2
8 施設マスタでロボット0台の施設のロボット列が空白 → ★「-」★ 見た目 段階2
9 「読み込み中…」・取得失敗の文言が t() を通っていない画面(清掃詳細ログ・D-Sec ログ・施設マスタ・スケジュール・レポート) 不具合 段階2
10 ★指示書BB §8 B1〜B4★: 有効なロボットがいない清掃エリアの serial_number / model_name と、心拍の無い制御盤の last_reported_at が ★null★ になる(BB D6・A1)→ ★画面は「-」★(清掃詳細ログ・アラート・スケジュール・D-Sec ログ) 機能 段階2
10b ★指示書BB §8 B5★: エリア別完了率の items[].result_pending(BB D11)→ ★モーダルで「結果待ち」★(完了率「-」・判定の札を出さない・問題エリアの赤字にしない。詳細ログの結果待ちと同じ見せ方) 機能 段階2
11 ★U1 ダーク/ライトと JP/EN を再読み込み・再ログインのあとも保つ★(localStorage。★描画前のインラインスクリプト+useLayoutEffect★) 機能 段階3
12 ★U3 D-Sec ログの備考を、他の3画面と同じ編集モードにする★(押して入力・フォーカスが外れたら保存 → 編集 / 保存(N件)/ キャンセル・まとめて保存) 機能 段階4
13 レポートの「エラー種別の内訳」の副題が過去の期間でも「今週/今月/今年の…」 → ★「この期間の…」★ 文言 段階5
14 レポートの「2日以上連続で△以下…」が t() を通らず日数も直書き → ★ESCALATION_THRESHOLDS.level2Days から作り t()★ 文言 段階5
15 システム管理の説明文に「{n}」がそのまま出る → ★今の上限値を入れる★ 不具合 段階5
16 レポートの KPI カードが 差 < 0.5 で「▲0%」「▼0%」を色付きで出す → ★丸めて0なら「→」・色なし★(施設別の表の「変化」と同じ) 見た目 段階5
17 a11y の基本: 押せる数値カード(div)・押せる表の行がキーボードで操作できない/エリア別モーダルの ✕ に名前が無く Esc で閉じない/ラベルの無い入力欄 a11y 段階5
18 ログイン画面が 200 以外をすべて「トークンが違います」と出す → ★401 / 403 / 422 / 429 / 5xx を分けて t()★ 不具合 段階5
19 ★決定 D-183 を追記★し、docs/code-map.md・docs/spec.md を追従させる 資料 段階6

★変えないもの★: API・データ層・型(app/api/**・lib/db/**・lib/types/**・d-watch-api.yaml)/判定・集計(lib/judge/**・lib/reports/**・lib/clean-logs/**)/ ★清掃詳細ログの凡例「(閾値はシステム設定で変更可能)」(CleanLogTable.tsx:212。★ユーザー決定 U2: 残す★。§7-1)/lib/prechecks/edit-drafts.ts(3画面の下書き。★D-Sec ログには使わない★ — 段階4-1)/ app/globals.css(★色の変数を足さない・フォントのスタックを変えない★)/d-watch-mock.jsx/components/layout/**。


1. 背景

  • 2026-10-19 に監督が画面側を一通り見直した(監督のコード読み。2fc3bbb)。上の #1〜#18 が出た。
  • ★ユーザー決定(2026-10-19。確定)★:
    • ★U1★ 表示設定(ダーク/ライト・JP/EN)は再読み込み・再ログインのあとも保つ。components/theme/ThemeProvider.tsx:25-26 を localStorage(try/catch。★使えない環境でも正しく描く★)で保つ。★ハイドレーションの不一致とちらつきを避ける方式を決める★ → 段階3(本書の判断と根拠を書いた。§10 Q1)
    • ★U2★ 凡例「(閾値はシステム設定で変更可能)」は残す(★あとで閾値を編集できるようにする★)。★今は直さない★。§7-1 に将来の機能として記録する(今のシステム管理画面は閾値を表示だけ)
    • ★U3★ D-Sec ログの備考を、他の3画面(エラー一覧・アラート・D-Sec アラート)と同じ編集モードにする(DsLogScreen.tsx:96-125 のセルを押して入力・blur で保存をやめる)。保存は既存の PATCH /api/ds-devices/{serial_number}/note(★保存先はメインDBではなく data/settings/ds-device-notes.json★。app/api/ds-devices/[serial_number]/note/route.ts:7-10)→ 段階4
    • ★U4★ 6画面の古い応答を捨てる(DetailLogScreen.tsx:181・AlertsScreen.tsx:205・DsLogScreen.tsx:213・FacilitiesScreen.tsx:74・SchedulesScreen.tsx:76・ReportsScreen.tsx:206)→ ★エラー一覧(ErrorsScreen.tsx:230-278)の形★ → 段階1
  • ★実装はユーザー決定で Claude★(2026-10-19)。★それでも本書は場所を全部 file:line で挙げる★(監督のレビューで突き合わせるため)。
  • ★テストの件数★: 2fc3bbb は ★130ファイル / 3,083件★(指示書BB の作成者の実測)。★本書は BB の後に始める★ので、★基準は「BB 後の件数」★(BB はテストを足し、weekStart・cleanErrorLabel のテストを消す。BB §段階7-3。★本書の作成時点で値は未定★)。★段階0 で自分で測って控える★。本書で ★4ファイル・40件増える見込み★(★BB 後 +4ファイル / +40件★。段階3 で +13・段階4 で +12・段階5 で +15)。
  • ★画面の部品のテスト(.test.tsx)はこのリポジトリに無い★(vitest.config.ts の include は lib/**/*.test.ts と app/**/*.test.ts だけ)。★だから、テストで守りたい判断は lib/ の純粋関数に出す★(段階3〜5 の4本)。それ以外は ★段階7 の画面確認★ で確かめる。

2. ★共通ルール(指示書BA と同じ。今後のすべての指示書に当てはまる)★

# ルール 本書での当てはめ
G1 ★API の挙動が変わったら、d-watch-api.yaml の ★影響を受けるパス・スキーマを全部★ 直す★ ★本書は API を変えない★(画面側だけ)。D-Sec ログの備考は既存の PATCH /ds-devices/{serial_number}/note(d-watch-api.yaml:1418-1450)を1行ずつ呼ぶだけ。★YAML に差分が出たら止まって報告★
G2 ★古い挙動を書いた docstring・コメントを残さない★。挙動を変えた関数だけでなく、★その関数・部品を説明している別の場所★も直す 本書の表の「コメントも直す」を全部やる。特に D-Sec ログの旧作り(commitNote()・NoteCell・「フォーカスが外れたら保存」)を引いている AlertsScreen.tsx:277-278・AlertsScreen.tsx:584・SystemScreen.tsx:177・lib/i18n.ts:146-147・docs/code-map.md(3.9・:925・:2155・:3613)と、表示設定を「保存されない」と書いた docs/code-map.md:3617-3618・KpiCard.tsx:41
G3 ★テストは「守る挙動が壊れたら落ちる」ように書く★。★書いたら一度わざと壊して赤になるのを確かめ、戻す★(壊した変更はコミットしない) 段階3(U1-T/L/S)・段階4(N/S)・段階5(K・M)の期待値表。★U4 はテストの代わりに、直す前に古い応答の上書きを画面で再現しておく★(段階0-7。直した後に同じ手順で消えたことを確かめる=赤→緑)
G4 ★決定の記録(docs/decisions.md)に、コミット・テスト件数の前後・画面確認の結果(出所つき。CLAUDE.md §6-6)・指示書と違う判断を書く★ 段階6 の D-183 の「実装の記録」

3. 落とし穴

# 罠 どう避けるか
3-1 ★git add -A・git add .★ 禁止。ファイルパスを1つずつ書く。各コミットの前に git diff --cached --name-status で中身を目で確かめる
3-2 ★他のセッションの未コミットの変更を巻き込む・上書きする★ ★2fc3bbb の時点で docs/decisions.md(D-179 の「4回目」の追記)と docs/handoff/指示書AY_*.md に他のセッションの未コミットの変更がある★。★触らない・含めない★。★docs/decisions.md は段階6 で追記するので、そのとき git diff -- docs/decisions.md に自分のもの以外があれば ★編集しない★(§段階6-1)★ — ★2026-10-19 に、別のセッションのコミットが作業ツリーの未コミットの追記ごと上書きして消した事故が実際に起きている★(docs/decisions.md の D-179 4回目の追記の末尾「記録の事故」)
3-3 ★指示書AY(制御盤のログ)と DsLogScreen.tsx が重なる★ 指示書AY(未コミットで見直し中)は DsLogScreen.tsx に ★「ログ」列とモーダル★ を足す計画で、★備考は blur で確定する前提★(AY 4-21「備考の編集中に『ログ』ボタンを押す」・:67・:316)を書いている。★本書の段階4 はその前提を消す★。★段階0-5 で AY の実装が入ったか確かめ、入っていればその上に重ねる(§段階4-7)・入っていなければ作業報告に「AY の 4-21 と DsLog の行番号は BC の後に引き直しが要る」と書く★
3-4 ★react-hooks/set-state-in-effect★ 既存の画面は // eslint-disable-next-line react-hooks/set-state-in-effect -- <理由> で抑止している(例 AlertsScreen.tsx:235)。★取得を effect の中に移すときは、抑止コメントも移す★(新しい行に足す)。★結果にクエリを持たせる形(レポート・清掃面積)は effect の中で同期的に state を変えないので抑止は要らない★(ErrorsScreen.tsx:231-233 のコメントと同じ)。★抑止が要らないのに残すと「使われていない抑止」の警告になる★ — npm run lint の結果どおりに足し引きする
3-5 ★font-family を宣言する★ ★宣言しない★(CLAUDE.md §0-3)。★textarea だけは font-family: inherit を書く★(globals.css の inherit の対象外。DsLogScreen.module.css:237-239 の既存の注意書き)
3-6 ★新しい色を作る★ ★既存の CSS 変数(--sf-h・--ac・--bd・--ng・--tx-m 等)だけ★。★#fff を変数に置き換えるのもやらない★(受け皿の変数が無く、足すには globals.css を触る。§7-6)
3-7 ★生値を t() に通す★ ★シリアル・機種・施設名・エラー種別は t() に通さない★(D-077)。★BB の null は画面で「-」★(記号なので言語によらない。t() に通さない。§段階2-8・§10 Q7)
3-7b ★null を見落として空白・"null" を出す★(BB で `string \ null` になった項目) ★tsc が指す場所だけでなく、JSX の {x}(null は何も描かず空白になる)も探す★: `grep -rn "serial_number\ model_name\ last_reported_at" components` の全行を §段階2-8 の表と突き合わせる
3-7c ★BB より先に始める★ ★禁止★(段階0-1)。BB の型の変更が入る前に本書の画面を書くと、BB のコミットで tsc が落ちるか、null の行が空白になる
3-8 ★EN を勝手に作る★ 本書で辞書に足すのは §段階2-9・§段階5-8 の表だけ。★EN は暫定★(既存の ★EN は暫定★ の書き方。lib/i18n.ts:441・:456 のコメントと同じ)。§10 Q6
3-9 ★表示設定をハイドレーションの前に state へ入れる★(useState(() => localStorage…)) ★禁止★。サーバーは既定(dark / ja)で描くので、クライアントの最初の描画で値が違うと ★全画面でハイドレーションの不一致★ になる(文字・色の style)。★state は既定のまま始め、useLayoutEffect で読み込む★(段階3)
3-10 ★D-Sec ログの画面確認で本物の備考ファイルを書き換える★ :3100 の next start も data/settings/ds-device-notes.json(process.cwd() 基準。lib/settings/ds-device-notes.ts:54)に書く。★画面確認の前にファイルを控え、終わったら戻す★(段階7-5。/data は .gitignore:50 で追跡外だが、ユーザーの備考が入っている)
3-11 ★push する★ ★監督のレビューが終わるまで push しない★

段階0 — 着手前

  1. ★指示書BB のコミットが develop に入っていること(依存)★。入っていなければ ★始めない★(監督に報告して待つ。3-7c)。確かめ方:
    git log --oneline 2fc3bbb..HEAD | grep "指示書BB"
    grep -n "serial_number: string | null\|model_name: string | null\|last_reported_at: string | null\|result_pending: boolean" lib/types/index.ts
    grep -n "formatDateTime(item.last_reported_at)" components/features/ds-logs/DsLogScreen.tsx
    • 1行目: BB の段階1〜6 のコミットが出る(★少なくとも段階1(型の nullable・DsLogScreen.tsx の1行)と段階3(result_pending)★)
    • 2行目: CleanLogItem.model_name / serial_number・PrecheckItem.serial_number・Schedule.serial_number・DsDevice.last_reported_at が string | null、AreaCompletionItem と AreaCompletionRow に result_pending: boolean(BB 1-2・3-1)
    • 3行目: BB が入れた {item.last_reported_at ? formatDateTime(item.last_reported_at) : "-"} の1行(BB 1-2 の末尾)
    • ★BB のコミットの後は、本書の行番号(2fc3bbb 基準)がずれている★ — ★引用の文言で探し直す★(特に DsLogScreen.tsx と、BB が触った lib/ を引く箇所)。
    • ★RobotSummary(施設マスタのロボット)が BB で nullable になっていないこと★も確かめる(grep -n "interface RobotSummary" -A 8 lib/types/index.ts)。★BB は施設マスタのロボットを変えない★(実在のロボットの一覧で、清掃エリアの「有効なロボットがいない」とは別。BB 表2 R9 は施設別パフォーマンスの除外だけ)。もし nullable になっていたら §段階2-8 の 2-8e をやる
  2. ★develop の本体で作業する★(.claude/worktrees/ の中ではない)。git log --oneline -1 が BB のコミットの後であること。
  3. git status --short を控える。★2026-10-19 の本書作成時点で、他のセッションの次の2行があった★ — ★触らない・含めない★(3-2):
    • M docs/decisions.md(D-179 の「4回目」の追記。★本書の D-183 とは別★)
    • M docs/handoff/指示書AY_制御盤のログをD-Secログ画面から取り出す_20261019.md
    • 本書(未追跡)は ★コミットに含めない★(監督がコミットする)。それ以外の見知らぬ行があれば誰のものか確かめてから進む。
  4. npx tsc --noEmit -p . → npm run lint → npm test。★テストのファイル数と件数を控える★(★これが「BB 後の件数」で、本書の基準★。2fc3bbb の 130 / 3,083 に BB の増減を足したもの)。
  5. ★指示書AY の D-Sec ログの変更が入ったか確かめる★(3-3):
    git log --oneline 2fc3bbb..HEAD -- components/features/ds-logs/
    ls components/features/ds-logs/
    grep -n "colLogs\|DeviceLogsModal" components/features/ds-logs/DsLogScreen.tsx
    • ★何も出ない★ → AY は未実装。本書のとおり進め、作業報告に「AY の 4-21(blur の前提)・DsLogScreen.tsx の行番号(:67・:187・:258・:438-452)は BC の後に引き直しが要る」と書く(★AY の文書は触らない★)。
    • ★出る★ → AY が先に入った。★段階4 は AY の「ログ」列とモーダルを残して重ねる★(§段階4-7)。★競合の危険★: 同じ <colgroup>・<thead>・<tbody> の行と DsLogScreen.module.css の表の最小幅(:82-88)を両方が書き換える。★AY の列を消さない・最小幅の式を壊さない★。
    • ★AY の実装が作業中(未コミットの差分が components/features/ds-logs/ にある)なら★ ★段階4 に入らず止まって監督に報告★(同じファイルを2つのセッションが同時に書くと片方が消える)。
  6. ★D 番号を確かめる★: grep -n "^### D-18[2-9]" docs/decisions.md。
    • ★D-182 は BB★(BB の段階6-5。docs/decisions.md に他人の差分があると BB も追記を監督に渡すので、まだ無いことがある)。★本書は D-183★(埋まっていたら次の空き。★D-182 は使わない★)。
    • ★BB §8 の B1〜B7 を読み、§段階2-8 の表と突き合わせる★(BB の実装の記録に「指示書と違う判断」があれば、それも。違えば作業報告に書く)。
  7. ★古い応答の上書きを、直す前に再現しておく(G3 の代わり)★ — §段階7-5 の手順で :3100 を立て、★/alerts と /reports の2画面だけ★ 次を行い、★直す前は誤った表示になること★を控える(スクリーンショット1枚ずつ):
    // 最初の1回だけ、URL に pattern を含む要求を ms ミリ秒遅らせる(ページを再読み込みすれば元に戻る)
    (() => {
      const orig = window.fetch.bind(window);
      let armed = true;
      window.fetch = (input, init) => {
        const url = typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
        if (armed && url.includes(window.__delayPattern)) {
          armed = false;
          return new Promise((resolve) => setTimeout(() => resolve(orig(input, init)), window.__delayMs));
        }
        return orig(input, init);
      };
    })();
    • /alerts: window.__delayPattern = "status=all"; window.__delayMs = 3000; → スクリプト → 「全件」を押す → ★すぐ★「解消済み」を押す → 4秒待つ。★直す前の期待(誤り)★: 「解消済み」が押された状態なのに、一覧と数値カードが「全件」の結果になる。
    • /reports: window.__delayPattern = "period=weekly"; window.__delayMs = 3000; → スクリプト → 「月次」を押す → ★すぐ★「週次」を押す…ではなく、★最初に「週次」の要求が遅れるよう★ ‹(前の期間)を押してから ★すぐ★「月次」を押す → 4秒待つ。★直す前の期待(誤り)★: 「月次」のラベルなのに、施設別パフォーマンスの施設数・数値が週次のものになる(比べ方: await fetch("/api/reports/facility-performance?" + <月次のクエリ>).then(r => r.json()) の meta.total と、画面の「{n}施設」)。
    • ★アプリ内ブラウザが使えなければ、この再現は飛ばし、作業報告にそう書く★(ほかの手段で代わりにしない)。

段階1 — 古い応答を捨てる(U4・#1・#2)

★考え方★: エラー一覧(ErrorsScreen.tsx:230-278)と同じく、★effect の中で取得し、片付け(cleanup)で cancelled = true にして、遅れて届いた古い応答で state を変えない★。 ★清掃詳細ログのエリア別モーダル(DetailLogScreen.tsx:275-306)と清掃面積(CleanAreaScreen.tsx:122-146)は既にこの形★ — 写す元にしてよい。

1-1. 5画面は let cancelled の形にする(state の持ち方は変えない)

★5画面とも同じ書き換え★: useCallback の取得関数(fetchXxx)と、それを呼ぶだけの useEffect を ★1つの useEffect にまとめる★。

  // ★一覧の取得。取得中に絞り込みを変えたら、遅れて届いた古い応答は捨てる★(片付けで cancelled にする。
  //   捨てないと前の条件の結果が新しい条件の表示を上書きする。エラー一覧と同じ考え方。指示書BC 段階1・D-183)
  useEffect(() => {
    let cancelled = false;
    // eslint-disable-next-line react-hooks/set-state-in-effect -- <既存の理由をそのまま>
    setLoading(true);
    setError(null);
    apiFetch<…>(`/api/…?${queryString}`).then(
      (body) => {
        if (cancelled) return;
        // …今の try の中身(setItems / setTotal / ★存在しないページの保険★ など)をそのまま…
      },
      (e: unknown) => {
        if (cancelled) return;
        // …今の catch の中身をそのまま…
      }
    ).finally(() => {
      if (!cancelled) setLoading(false);
    });
    return () => {
      cancelled = true;
    };
  }, [queryString]);
# ファイル:行(今) 今 直す 古い応答で何が起きるか(直す理由)
1-1a components/features/detail-log/DetailLogScreen.tsx:181-220(fetchLogs と effect) useCallback + useEffect(() => { void fetchLogs(); }, [fetchLogs]) 上の形に。★rangeError !== null の分岐(:182-188)は effect の先頭に残す★(同期的に setError(rangeError) 等をして return () => { cancelled = true; } ではなく ★return;★ でよい — 前の effect の片付けは React が先に呼ぶ)。依存は [queryString, rangeError]。★抑止コメントは :218 の理由「fetch 起点の一覧更新」を使う★ 施設名を打って待つ(取得A)→ 判定ボタンを押す(取得B)。A が遅いと ★B の後に A が届き、ボタンは「×のみ」なのに表は施設名だけで絞った結果★になる。★期間を誤って入れた(rangeError)直後に、前の取得が届いて ★誤りの表示を表で上書きする★ ことも起きる
1-1b 同 :149-150(コメント) `fetchLogs` も変わらないので、ボタンを押しても取り直さない 取得の effect も走らないので、ボタンを押しても取り直さない (G2) —
1-1c 同 :4(import) useCallback, useEffect, useMemo, useState ★useCallback を消す★(ほかに使っていない。grep -n useCallback で確かめる) —
1-1d components/features/alerts/AlertsScreen.tsx:205-237 同上 上の形に。依存は [queryString](★段階2-3 で rangeError を足す★)。抑止の理由は :235 のまま 「全件」→ すぐ「解消済み」。「全件」の応答が遅いと ★「解消済み」なのに全件の一覧・数値カード★。★そのまま「編集」に入ると、絞り込みと違う行を編集できてしまう★
1-1e 同 :201-202(コメント) `fetchAlerts` も変わらないので、ボタンを押しても取り直さない 取得の effect も走らないので、ボタンを押しても取り直さない —
1-1f 同 :277(saveEdits の docstring) ・★`fetchAlerts()` を呼ばないこと★ — 取り直すとローディング表示に入れ替わって位置を見失う ・★保存の後に一覧を取り直さないこと★ — 取り直すとローディング表示に入れ替わって位置を見失う(★次の行 :278 は段階4-6 で直す★) —
1-1g components/features/ds-logs/DsLogScreen.tsx:213-243(★BB の1行の変更(:465)の後。行番号は引用で探し直す★) 同上 上の形に。依存は [queryString]。抑止の理由は :241 のまま。★:254 の ★保存後に `fetchDevices()` を呼ばないこと★ は段階4 で関数ごと消える★ 検索語を打って待つ → 「異常」を押す。前の応答が遅いと ★「異常」なのに正常の制御盤も並ぶ★
1-1h components/features/facilities/FacilitiesScreen.tsx:74-99 同上 上の形に。依存は [queryString]。抑止の理由は :97 のまま。:3 の import から useCallback を消す 「ドン」で止める(0.3秒 → 取得A)→ 「ドンキ」まで打つ(取得B)。A が遅いと ★検索欄は「ドンキ」なのに「ドン」の結果★
1-1i components/features/schedules/SchedulesScreen.tsx:76-101 同上 上の形に。依存は [queryString]。抑止の理由は :99 のまま。:3 の import から useCallback を消す 1-1h と同じ(検索語)
  • ★state の名前・loading の出し方・失敗の文言・「存在しないページの保険」(pages > 0 && body.meta.page > pages)は変えない★(3-8 の「ロジックまで直してしまう」と同じ注意)。
  • ★.finally() の中も cancelled を見る★ — 見ないと、古い取得の finally が新しい取得の読み込み中を消す(今の作りでも起きている)。

1-2. レポートは「結果にクエリを持たせる」形にする(#1・#2)

★今★: fetchAll(ReportsScreen.tsx:206-255)は4本の API を取り、★先頭で setLoading(true)★ する。loading の間は :511 で画面全体が「読み込み中…」になり、:521 の本体が消える。 「全N施設を表示 →」(FacilityPerformanceTable.tsx:243 → ReportsScreen.tsx:795 setPerfShowAll(true))・「他N件を表示 →」(ReportsScreen.tsx:699 setContShowAll(true))は limit が変わるだけなのに4本を取り直し、★画面全体が消えてスクロールが先頭に飛ぶ★。古い応答も捨てていない(期間を素早く変えると前の期間の数字が新しいラベルの下に出る — 段階0-7)。

  1. :127-143 の7つの state(kpi … errorBreakdown・loading・blocks)を ★1つの結果にまとめる★(ErrorsScreen.tsx:122-131 の FetchResult と同じ考え方):
/**
 * 4本の取得結果。★どのクエリの結果かを持つ★(指示書BC 段階1・D-183)—
 *   ・読み込み中は「手元の結果のクエリ ≠ 今のクエリ」で判定する(effect の中で同期的に loading を立てない)
 *   ・★期間・分類(`baseQuery`)が変わったときだけ画面全体を「読み込み中…」にする★(数字の意味が変わるため。今と同じ)
 *   ・★`limit`(全件表示)や施設の絞り込みだけが変わったときは、前の内容を薄く残す★(スクロールが飛ばないように)
 */
interface ReportsResult {
  baseQuery: string;
  facilityQuery: string;
  continuousQuery: string;
  blocks: ReportBlocksView;
  kpi: ReportKpi | null;
  facilityPerformance: FacilityPerformance[];
  facilityMeta: Meta | null;
  continuousProblems: ContinuousProblem[];
  continuousMeta: Meta | null;
  errorBreakdown: ErrorBreakdownItem[];
}
  1. fetchAll と effect(:206-260)を、★let cancelled を持つ1つの effect★ にする。中身(Promise.allSettled の4本・toFetchOutcome()・resolveReportBlocks()・★失敗した本は空に戻す★)は今のまま。最後に ★if (cancelled) return; の後で setResult({ baseQuery, facilityQuery, continuousQuery, blocks, … })★ を1回だけ呼ぶ。依存は [baseQuery, facilityQuery, continuousQuery]。★effect の中で同期的に state を変えないので、:258 の抑止コメントは消す★(3-4)。
  2. 描画で使う値を結果から出す:
  // ★期間・分類が変わった(または初回)→ 画面全体を読み込み中★(今の :511 と同じ見た目)
  const loading = result === null || result.baseQuery !== baseQuery;
  // ★施設別パフォーマンス / 連続問題施設だけ取り直し中★ → 前の内容を薄く残す
  const perfRefreshing = !loading && result.facilityQuery !== facilityQuery;
  const contRefreshing = !loading && result.continuousQuery !== continuousQuery;
  const blocks = result?.blocks ?? null;
  // kpi / facilityPerformance / … も result から(loading のときは使わない)
  1. ★取り直し中のカードを薄くする★: 連続問題施設のカード(:625 の <div className={styles.card}>)と施設別パフォーマンスのカード(:775)に、それぞれ aria-busy={contRefreshing} / aria-busy={perfRefreshing} と、取り直し中だけ styles.cardRefreshing を足す。 components/features/reports/ReportsScreen.module.css の .card(:192)の後に足す:
    /* ★取り直し中のカード(全件表示・施設の絞り込みだけが変わったとき)★ — 前の内容を薄く残す。
       画面全体を「読み込み中…」に入れ替えるとスクロールが先頭に飛ぶため(指示書BC 段階1・D-183) */
    .cardRefreshing {
      opacity: 0.6;
    }
  2. :326-329 の contRemaining は result の値から出す(continuousMeta / continuousProblems を result?.… に)。
  3. ★変えないもの★: changePeriod / navigatePeriod / handleSearch(:263-324)・blockError・ブロックの出し分け(blocks.kpi.kind 等)・exportQuery(CSV)・resolveReportBlocks()(lib/reports/block-visibility.ts)。

  4. ★承知のうえのこと★: 「全件表示」を押すと4本とも取り直す(KPI とエラー内訳も)。★取得の本数を減らす(変わったブロックだけ取る)のは本書ではやらない★ — resolveReportBlocks() が4本の結果をまとめて見る作りで、分けると決定表(allFailed 等)の扱いが変わるため(§10 Q4)。

1-3. 確かめてコミット

  • npx tsc --noEmit -p .・npm run lint・npm test(件数は段階0 と同じ)
  • 段階0-7 の2画面の再現を、同じ手順でもう一度 → ★誤った表示にならない★(最後に押した条件の結果だけが出る)。結果を控える(段階7 でまとめて確認してもよい)
  • コミット(7ファイル):
    git add -- components/features/detail-log/DetailLogScreen.tsx components/features/alerts/AlertsScreen.tsx \
      components/features/ds-logs/DsLogScreen.tsx components/features/facilities/FacilitiesScreen.tsx \
      components/features/schedules/SchedulesScreen.tsx components/features/reports/ReportsScreen.tsx \
      components/features/reports/ReportsScreen.module.css
    git diff --cached --name-status   # この7行だけ
    メッセージの例: fix: 6画面で取得中に絞り込みを変えたときの古い応答を捨て、レポートの全件表示で画面全体が読み込み中に入れ替わらないようにする(指示書BC 段階1・U4・D-183)

段階2 — 失敗・空の表示、CSV、アラートの期間、清掃面積の粒度タブ

2-1. アドバイスの取得失敗と、読み込み中の競合(#3)

★今★: 清掃詳細ログ(DetailLogScreen.tsx:222-254)とダッシュボード(DashboardScreen.tsx:111-143)の toggleRow() は、取得に失敗すると adviceById に ★advices: [] の代わりの値★ を入れる(:235-250 / :124-139)。 CleanLogTable.tsx:325-329 はそれを「該当するアドバイスはありません」と出し、★「エリア別詳細を見る →」(:350-359)は advices.length > 0 の中にあるので消える★(エリア別の取得はアドバイスと無関係なのに)。 ★競合★: 行Aを開いて取得中に行Bを開くと、A の finally { setAdviceLoadingId(null) }(:252 / :141)が ★B の読み込み中を消す★(B は取得中なのに何も出ない時間ができる)。

# ファイル:行(今) 今 直す
2-1a components/features/detail-log/DetailLogScreen.tsx:100-103(state) adviceById / adviceLoadingId 次の行に足す: /** アドバイスの取得に失敗した行(★adviceByIdには入れない★ — 入れると次に開いたとき取り直さない) */
const [adviceFailedIds, setAdviceFailedIds] = useState<ReadonlySet<number>>(() => new Set());
2-1b 同 :222-254(toggleRow) 失敗で代わりの値を入れる・finally で無条件に null ★次の形にする★(下のコード)。★代わりの値(:236-250 のコメントごと)は消す★
2-1c 同 :531-540(<CleanLogTable …>) — adviceFailedIds={adviceFailedIds} を足す
2-1d components/features/dashboard/DashboardScreen.tsx:62-65 / :111-143 / :311-321 同上 2-1a〜2-1c と同じ
2-1e components/features/detail-log/CleanLogTable.tsx(props の型と引数。:103-113 の上の CleanLogTableProps) — /** アドバイスの取得に失敗した行(★「該当なし」と区別して出す★。指示書BC 2-1) */ adviceFailedIds: ReadonlySet<number>; を足し、引数にも足す
2-1f 同 :322-361(開いた行の中身) 読み込み中 → advice.advices.length === 0 → advices.length > 0(ボタンはこの中) ★次の形にする★(下のコード)
  async function toggleRow(row: CleanLogItem) {
    if (expandedId === row.id) {
      setExpandedId(null);
      return;
    }
    setExpandedId(row.id);
    if (adviceById[row.id]) return;
    // ★前に失敗した行は取り直す★(失敗の表示を消してから)
    setAdviceFailedIds((prev) => {
      if (!prev.has(row.id)) return prev;
      const next = new Set(prev);
      next.delete(row.id);
      return next;
    });
    setAdviceLoadingId(row.id);
    try {
      const body = await apiFetch<CleanLogAdviceResponse>(`/api/clean-logs/${row.id}/advice`);
      setAdviceById((prev) => ({ ...prev, [row.id]: body }));
    } catch {
      // ★失敗は「該当なし」と区別して出す★(行は開いたまま。エリア別詳細のボタンは出る。指示書BC 2-1)
      setAdviceFailedIds((prev) => new Set(prev).add(row.id));
    } finally {
      // ★自分の行の読み込み中だけを消す★ — 先に開いた行の取得が後から終わっても、
      //   後から開いた行の「読み込み中…」を消さない(指示書BC 2-1)
      setAdviceLoadingId((prev) => (prev === row.id ? null : prev));
    }
  }
                    <div className={`${styles.adviceWrap} ${head.wrap}`}>
                      {adviceLoadingId === r.id && !advice && !adviceFailedIds.has(r.id) && (
                        <div className={styles.adviceText}>{t("読み込み中…")}</div>
                      )}
                      {!advice && adviceFailedIds.has(r.id) && (
                        <div className={styles.adviceText}>{t("アドバイスの取得に失敗しました")}</div>
                      )}
                      {advice && advice.advices.length === 0 && (
                        <div className={styles.adviceText}>{t("該当するアドバイスはありません")}</div>
                      )}
                      {advice && advice.advices.length > 0 && (
                        <>
                          …見出し(head.text)とカード(今の :332-349 のまま)…
                        </>
                      )}
                      {/* ★エリア別詳細はアドバイスの有無・取得の成否によらず出す★(取得は別の API。指示書BC 2-1) */}
                      <button type="button" className={styles.areaBtn} onClick={(e) => { e.stopPropagation(); onOpenAreas(r); }}>
                        {t("エリア別詳細を見る →")}
                      </button>
                    </div>
  • ★head(:239-240 adviceHead(maxLevel))は今のまま★(失敗のときは max_level が無いので 0 の見た目)。

2-2. 清掃詳細ログの CSV ボタン(#4)

★今★: csvBlocked(DetailLogScreen.tsx:179)は件数の上限だけを見る。★期間を誤って入れたとき(rangeError)や読み込み中も <a download> のまま★(:496-504)で、押すとサーバーの 422 の画面になる/前の条件の件数で判断している。

  1. :179 の次に足す:
    /**
     * ★今は CSV を出せない状態か★(期間の誤り・読み込み中。指示書BC 2-2)—
     *   期間が誤っていると API は 422 を返し、読み込み中は件数(`total`)が前の条件のもの。
     *   ★理由の文言は足さない★(期間の誤りは表の場所に出ている。読み込みはすぐ終わる)
     */
    const csvUnavailable = rangeError !== null || loading;
  2. :487 の {csvBlocked ? ( → {csvBlocked || csvUnavailable ? (。:482-486 のコメントの末尾に ★期間の誤り・読み込み中も同じ <button disabled>(csvUnavailable)★ を1行足す。
  3. ★csv-note(:512-525)の文言は変えない★(csvBlocked のときだけ理由に差し替わる今のまま)。

2-3. アラートの期間の検証と「指定なし(全期間)」(#5)

★今★: AlertsScreen.tsx:401-423 の日付は検証が無く、開始 > 終了でも取得して黙って0件。両方空でも「全期間」と分からない。aria-label も無い。 ★エラー一覧(ErrorsScreen.tsx:51-56・:226-228・:236-237・:414-443)と同じにする★(★サーバーの 422 は指示書BB★)。

  1. import(AlertsScreen.tsx の先頭)に import { dateRangeError } from "@/lib/clean-logs/detail-log-date-range"; を足す(エラー一覧と同じ関数)。
  2. コンポーネントの外(EMPTY_SUMMARY の近く)に、エラー一覧 :51-56 と同じ定数とコメントを写す:
    /**
     * ★期間の日数の上限は設けない★(`/prechecks` は上限なし。指示書AW §11 Q13)。
     *   `dateRangeError()` に無限大を渡して「開始 > 終了」だけを検証する(エラー一覧と同じ。指示書BC 2-3)
     */
    const NO_MAX_DATE_RANGE_DAYS = Number.POSITIVE_INFINITY;
  3. queryString(:190-203)の後に足す: const rangeError = dateRangeError(dateFrom, dateTo, NO_MAX_DATE_RANGE_DAYS); と const dateRangeUnset = !dateFrom && !dateTo;
  4. 段階1 の取得の effect の先頭に、★rangeError !== null なら取得せず★ setError(rangeError); setItems([]); setSummary(EMPTY_SUMMARY); setTotal(0); setLoading(false); return;(★今の取得失敗と同じ空に戻す★)。依存に rangeError を足す。
  5. 日付(:400-424)を直す:
    • <div className={styles.dateGroup}> の直後に {dateRangeUnset && <span className={styles.dateSep}>{t("指定なし(全期間)")}</span>}(エラー一覧 :414-416 のコメントごと写す)
    • 2つの <input type="date"> に aria-label={t("開始日")} / aria-label={t("終了日")} と placeholder={t("指定なし(全期間)")} を足す
    • ★区切りの「〜」(:412)は変えない★(§7-3。BA 7-2 と同じ)
  6. ★BB §8 B6★: BB の後は、API を直接叩いたときも /prechecks が 422 を返す(BB 3-5・表8 P1・P2)。★画面は先に止めるので普段は届かない★が、届いたら取得失敗の経路で同じ場所に出る(★追加の作業は無い★)。表示は今の {!loading && error && <div className={styles.error}>{t(error)}</div>}(:523)がそのまま出す(文言はサーバーと同じ「date_fromはdate_to以前の日付を指定してください」。★EN の訳は足さない★ — エラー一覧と同じ。§7-5)。
  7. ★編集ボタン(:380 disabled={loading || editableCount === 0})は変えない★(期間の誤りでは items が空なので押せない)。

2-4. 清掃面積の粒度タブで画面全体が入れ替わる(#6)

★今★: 粒度タブ(CleanAreaScreen.tsx:368-379 → changeScope() :161-166)を押すと query(:108-120)が変わり、effect(:122-146)が setLoading(true) する。:302 で画面全体が「読み込み中…」になり、:310 の数値カード・注記・内訳が消える(★数値カードは粒度で変わらないのに★)。表の近くまでスクロールしていると ★先頭に飛ぶ★。

  1. query(:108-120)を2つに分ける:
    /** ★粒度以外の条件★(検索ボタンで確定する: 期間・施設名・分類)。これが変わったときだけ画面全体を読み込み中にする */
    const filterQuery = useMemo(() => { …今の :109-119 から `group_by` の行(:110)を除いたもの… }, [dateFrom, dateTo, facilityName, confirmedClassification]);
    const query = useMemo(() => {
      const params = new URLSearchParams(filterQuery);
      params.set("group_by", groupBy);
      return params.toString();
    }, [filterQuery, groupBy]);
  2. data / failure / loading(:104-106)を ★1つの結果★ にまとめる:
    /** 取得の結果。★どの条件(粒度を含む)の結果かを持つ★(指示書BC 2-4。エラー一覧の FetchResult と同じ考え方) */
    interface CleanAreaResult {
      filterQuery: string;
      groupBy: CleanAreaScope;
      data: CleanAreaResponse | null;
      failure: FetchFailure | null;
    }
    const [result, setResult] = useState<CleanAreaResult | null>(null);
  3. effect(:122-146)は cancelled を残し、★成功・失敗とも最後に setResult({ filterQuery, groupBy, data, failure }) を1回だけ★。★setLoading(true) / setFailure(null) を effect の中で同期的に呼ばない★ → :124 の抑止コメントは消す(3-4)。依存は [query, filterQuery, groupBy]。
  4. 描画で使う値:
    // ★検索の条件が変わった(または初回)→ 画面全体を読み込み中(今と同じ)★
    const loadingAll = result === null || result.filterQuery !== filterQuery;
    // ★粒度だけが変わった → 数値カード・注記・タブは残し、表だけ薄くする★(スクロールが飛ばないように)
    const loadingRows = !loadingAll && result.groupBy !== groupBy;
    const data = loadingAll ? null : result.data;
    const failure = loadingAll ? null : result.failure;
    • :302 の loading → loadingAll、:304 / :310 の !loading → !loadingAll
    • ★表の見出しと行の名前は、表示中のデータの粒度(result.groupBy)で出す★: :406 の t(SCOPE_LABEL[groupBy]) → t(SCOPE_LABEL[result.groupBy])(★タブの選択(:373)は groupBy のまま★ — 押したタブがすぐ選ばれて見える)
    • 表の外枠(:391 <div className={styles.tableWrap}>)に aria-busy={loadingRows} と、loadingRows のときだけ styles.tableLoading を足す。件数(:382-384)は loadingRows のあいだ t("読み込み中…") にする
    • components/features/clean-area/CleanAreaScreen.module.css の .tableWrap(:200)の後に足す:
      /* ★粒度を切り替えて取り直している間の表★ — 前の行を薄く残す(画面全体を入れ替えるとスクロールが飛ぶ。指示書BC 2-4) */
      .tableLoading {
        opacity: 0.5;
      }
  5. ★変えないもの★: handleSearch・changeScope(expanded を畳む)・cleanAreaVisibility()・失敗の表示(粒度の取り直しが失敗したら、今と同じく画面全体のエラー)。

2-5. レポートの「全件表示」が上限で切れたら注記を出す(#7)

★今★: 全件表示の limit は SHOW_ALL_LIMIT = PAGINATION.maxPerPage(200。ReportsScreen.tsx:55)。★総数がそれを超えると黙って 200 件で切れる★。施設別パフォーマンスは「全N施設を表示 →」を押すと、全件表示中は「上位10件に戻す」だけになり(FacilityPerformanceTable.tsx:136-137)、切れたことが分からない。

# ファイル:行(今) 直す
2-5a components/features/reports/FacilityPerformanceTable.tsx:136-137 の後 /** ★全件表示でも表示の上限(200)で切れているか★(指示書BC 2-5)— 切れたら注記を出す(黙って切らない) */
const truncated = showAll && totalCount > items.length;
2-5b 同 :248-258(showCollapse のフッタ) <div className={styles.footer}> の中、ボタンの前に {truncated && (<span className={styles.truncatedNote}>{t("全{n}施設のうち上位{m}施設を表示しています(表示の上限)").replace("{n}", String(totalCount)).replace("{m}", String(items.length))}</span>)}
2-5c components/features/reports/FacilityPerformanceTable.module.css の .footer(:161)の後 .truncatedNote { font-size: 12px; color: var(--tx-m); margin-right: 12px; }(コメント /* 全件表示が上限で切れたときの注記(指示書BC 2-5) */)
2-5d components/features/reports/ReportsScreen.tsx:694-707(連続問題施設の「他N件」) 直後に: {contShowAll && continuousMeta && continuousMeta.total > continuousProblems.length && (<div className={styles.cardFooter}><span className={styles.truncatedNote}>{t("全{n}件のうち{m}件を表示しています(表示の上限)").replace("{n}", String(continuousMeta.total)).replace("{m}", String(continuousProblems.length))}</span></div>)}(★段階1 で result から出すようにした名前で書く★)
2-5e components/features/reports/ReportsScreen.module.css の .cardFooter(:224)の後 2-5c と同じ .truncatedNote

2-6. 施設マスタのロボット0台(#8)

components/features/facilities/FacilitiesScreen.tsx:182-205 の <td className={styles.tdRobots}> の中を {facility.robots.length === 0 ? "-" : facility.robots.map(…今のまま…)} にする。直前にコメント {/* ★ロボットが0台なら「-」★(ほかの列の空欄と同じ。orDash()と同じ記号。指示書BC 2-6) */}。

2-7. 「読み込み中…」と取得失敗の文言を t() に通す(#9)

★指示書BA 1-5(アラート)と同じ直し★。読み込み中…(lib/i18n.ts:75)・データの取得に失敗しました は辞書にある。

# ファイル:行(今) 直す
2-7a components/features/detail-log/DetailLogScreen.tsx:528-529 {t("読み込み中…")}・直前に {/* サーバーの文言は辞書に無ければ原文のまま */} を足して {t(error)}
2-7b components/features/ds-logs/DsLogScreen.tsx:422-423 同上
2-7c components/features/facilities/FacilitiesScreen.tsx:113-114 同上
2-7d components/features/schedules/SchedulesScreen.tsx:115-116 同上
2-7e components/features/reports/ReportsScreen.tsx:511 {t("読み込み中…")}(★:517 と blockError(:336-341)の view.message は t() に通さない★ — 今のコメント :334 のとおり)
2-7f components/features/detail-log/CleanLogTable.tsx:227 {t("該当するログはありません")}(2-9 で辞書に足す)

2-8. 指示書BB の null と result_pending を画面で出す(#10・#10b。BB §8 B1〜B5)

★指示書BB の決定(BB §2-2 D6・D9・D11。BB §8 の表)★:

  • ★U2★: 有効なロボットがいない清掃エリアでは、API が ★serial_number / model_name に null★ を返す(「-」の文字列は API に入れない。CSV は空のセル — BB がやる)。★画面は「-」★
  • ★A1★: 心拍がまだ無い制御盤は ★last_reported_at: null★。★画面は「-」★(★BB が DsLogScreen.tsx のその1行だけ先に直している★ — BB 1-2 の末尾)
  • ★A3★: エリア別完了率の各行に ★result_pending: boolean★。★grade は「×」・completion_rate は null のまま★(別の旗)。★problem_count と「問題エリアのみ」からは結果待ちが外れる★(サーバー側。画面は数え直さない)
  • ★「-」は記号なので t() に通さない★(言語によらない。§10 Q7 承認済み)。★BB は施設マスタのロボット(RobotSummary)を変えない★(段階0-1 で確かめる)
# ファイル:行(2fc3bbb。★BB の後は引用で探し直す★) 今 直す BB
2-8a components/features/detail-log/CleanLogTable.tsx:248-249 {r.model_name} / {r.serial_number}(null だと空白) {r.model_name ?? "-"} / {r.serial_number ?? "-"}。直前にコメント {/* ★有効なロボットがいない清掃エリアは null → 「-」★(指示書BB U2・D6。t() に通さない) */}。★縮小表示(ダッシュボード)はこの2列を出さないので変える所は無い★ B1
2-8b components/features/alerts/AlertsScreen.tsx:559 {item.serial_number} {item.serial_number ?? "-"}(同じコメント) B2
2-8c components/features/schedules/SchedulesScreen.tsx:158 {item.serial_number} {item.serial_number ?? "-"}(同じコメント) B3
2-8d components/features/ds-logs/DsLogScreen.tsx(2fc3bbb の :465。★BB が {item.last_reported_at ? formatDateTime(item.last_reported_at) : "-"} に直した行★) BB の形 ★変えない★(見た目はこのままでよい)。★段階4 で行を書き直すときもこの形を残す★(formatDateTime(item.last_reported_at) を grep して確かめる)。直前にコメント {/* ★心拍がまだ無い制御盤は null → 「-」★(指示書BB A1) */} だけ足す B4
2-8e ★条件つき★ components/features/facilities/FacilitiesScreen.tsx:185-202 robot.model を出し、isSupportedModel(robot.maker, robot.model) が false なら「未対応」の札 ★段階0-1 で RobotSummary.model / maker が nullable になっていたときだけ★: {robot.model ?? "-"}、札は robot.model !== null && robot.maker !== null && !isSupportedModel(robot.maker, robot.model) のときだけ(★null に「未対応」を付けない★)。★nullable でなければ何もしない★(型が string のままなら null は来ない。作業報告に「確かめた・該当なし」と書く) (BB は変えない)
2-8f components/features/detail-log/AreaModal.tsx:128-151(行) problem = isProblemGrade(item.grade)(結果待ちの「×」も赤字)・<GradeBadge grade={item.grade} /> ★次の形★(下のコード) B5
2-8g components/features/detail-log/AreaModal.module.css(.areaNameProblem :103 の後) — .pending を足す(下の CSS) B5
              {data.items.map((item) => {
                // ★結果待ち(item.result_pending。指示書BB A3 / D-173)★ — レポートがまだ届いていないだけかもしれない。
                //   ★判定(grade は "×" のまま)を見せない・問題エリアの赤字にしない★(清掃詳細ログの結果待ちと同じ見せ方:
                //   完了率「-」・判定の札の代わりに「結果待ち」。`cleanLogRowView()` と同じ考え方)。
                // ★APIが返した `grade` / `result_pending` を見るだけ(閾値でここで判定し直さない)★
                const pending = item.result_pending;
                const problem = !pending && isProblemGrade(item.grade);
                return (
                  <div key={item.section_id} className={styles.row}>
                    …清掃エリア名(今の :133-138 のまま。`problem` が上の式になる)…
                    <div className={styles.bar}>
                      {/* ★未実施・結果待ち(null)は 0 幅★ */}
                      <ProgressBar percent={item.completion_rate ?? 0} tone={barTone(item.grade)} />
                    </div>
                    {/* ★丸めるのは表示のときだけ(`CLAUDE.md` §4.3)。未実施・結果待ちは `-`★。結果待ちは色を付けない */}
                    <div className={styles.rate} data-tone={pending ? undefined : barTone(item.grade)}>
                      {displayRate(item.completion_rate)}
                    </div>
                    {pending ? (
                      <span className={styles.pending}>{t(CLEAN_RESULT_PENDING_LABEL)}</span>
                    ) : (
                      <GradeBadge grade={item.grade} />
                    )}
                  </div>
                );
              })}
/* ★結果待ちのエリア(指示書BB A3 / D-173)★ — 判定の札の代わりに「結果待ち」。色は控えめな文字色だけ(✓✗ の色を使わない。
   清掃詳細ログの結果待ちと同じ)。★幅は GradeBadge と同じにする★(`components/ui/GradeBadge.module.css` の幅を確かめて合わせ、
   行の右端を揃える。EN の "Pending" も収まること) */
.pending {
  font-size: 11px;
  color: var(--tx-m);
  white-space: nowrap;
  text-align: center;
}
  • CLEAN_RESULT_PENDING_LABEL(lib/format/clean-result.ts:73。「結果待ち」。辞書 lib/i18n.ts:230 に 結果待ち: "Pending" がある)を import する。★文字列を直書きしない★。
  • ★件数の行(:109-118「問題エリア({n} / {m}エリア)」)は変えない★ — problem_count がサーバー側で結果待ちを除くようになる(BB 表6 A2)。
  • ★「問題エリアのみ」が0件のときの文言(:123-125「問題エリアはありません(全エリア◎○)」)は変えない★が、★結果待ちのエリアがあると「全エリア◎○」が事実と違う★ → §10 Q10(★モック d-watch-mock.jsx:1949 の文言なので、変えるなら決定が要る★)。

2-8h. エラー一覧(参考。変えない)

  • ErrorReportItem.serial_number(ErrorsScreen.tsx:581)は ★BB で変わらない★(robot_error_reports の行の生値)。★変えない★。

2-9. 辞書に足す(段階2 の分。lib/i18n.ts)

lib/i18n.ts の最後の区画(本清掃は成功(:457)の後)に足す(★重複キーは lint が落とす — 足す前に grep -nF で無いことを確かめる★。2026-10-19 に5つとも無いことを確かめた):

  // ---- 画面の失敗・空の表示(指示書BC 段階2。D-183)----
  // ★EN は暫定★
  アドバイスの取得に失敗しました: "Failed to load advice",
  該当するアドバイスはありません: "No advice for this run",
  該当するログはありません: "No matching logs",
  "全{n}施設のうち上位{m}施設を表示しています(表示の上限)": "Showing the top {m} of {n} facilities (display limit)",
  "全{n}件のうち{m}件を表示しています(表示の上限)": "Showing {m} of {n} (display limit)",

2-10. 確かめてコミット

  • npx tsc --noEmit -p .・npm run lint・npm test(件数は段階0 と同じ)
  • コミット(16ファイル):
    git add -- components/features/detail-log/DetailLogScreen.tsx components/features/detail-log/CleanLogTable.tsx \
      components/features/detail-log/AreaModal.tsx components/features/detail-log/AreaModal.module.css \
      components/features/dashboard/DashboardScreen.tsx components/features/alerts/AlertsScreen.tsx \
      components/features/clean-area/CleanAreaScreen.tsx components/features/clean-area/CleanAreaScreen.module.css \
      components/features/reports/FacilityPerformanceTable.tsx components/features/reports/FacilityPerformanceTable.module.css \
      components/features/reports/ReportsScreen.tsx components/features/reports/ReportsScreen.module.css \
      components/features/facilities/FacilitiesScreen.tsx components/features/ds-logs/DsLogScreen.tsx \
      components/features/schedules/SchedulesScreen.tsx lib/i18n.ts
    git diff --cached --name-status
    (★DsLogScreen.tsx は 2-7 と 2-8d のコメントだけ・SchedulesScreen.tsx は 2-7 と 2-8c だけ★。2-8e をやらなかったら FacilitiesScreen.tsx は 2-6・2-7 だけ。合わせて16行になる) メッセージの例: fix: アドバイスの取得失敗を「該当なし」と区別しエリア別詳細を常に出す・CSV とアラートの期間の誤りを扱う・清掃面積の粒度切替とレポートの上限切れを分かるようにし、指示書BB の null を「-」・結果待ちのエリアを「結果待ち」で出す(指示書BC 段階2・D-183)

段階3 — 表示設定を保つ(U1・#11)

3-0. ★方式の判断★(§10 Q1。ユーザーの確認が要るのは「残るちらつき」の扱いだけ)

★前提(2026-10-19 確認)★:

  • app/layout.tsx:23-27 は <html lang="ja" data-theme="dark"> で描く。色の大部分は CSS 変数で、app/globals.css:1-40 が [data-theme] で切り替える。
  • ★ただし23ファイルが useTheme() の theme / lang を JS で使う★(THEME_TOKENS[theme] で style に色を入れる・t() で文字を出す。例 DsLogScreen.tsx:135-136・CleanLogTable.tsx:114)。★サーバーはいつも既定(dark / ja)で描く★。
  • CSP は無い(next.config.ts・proxy.ts にヘッダが無い)ので、インラインスクリプトは今は動く。
案 中身 再読み込みの直後に見えるもの 判断
A useEffect で読むだけ ★毎回、画面全体が一瞬ダーク・日本語で描かれてから切り替わる★(ライト・EN の人) ★採らない★(Next のガイドも「見えるちらつき」と書いている)
★B★ ★<head> のインラインスクリプトで <html> の data-theme / lang を描く前に入れる + ThemeProvider は既定で始めて useLayoutEffect で読み込む★ ★背景・枠・文字色など CSS 変数の部分は最初からライト★。style で色を入れている部分(札・状態の色)と t() の文字は ★ハイドレーションの直後・描く前に★ 正しくなる。★ハイドレーションより前に描かれる場合(遅い回線)だけ★、その部分が一瞬ダーク・日本語 ★採る★(node_modules/next/dist/docs/01-app/02-guides/preventing-flash-before-hydration.md の「Themes」「Syncing with React state」「Re-applying attributes in development」「Why not useEffect?」の組み合わせ)
C cookie に保存し、ルートの layout で cookies() を読んでサーバーが描く ちらつき無し ★採らない★: ★ユーザー決定は localStorage★。ルートの layout で cookies() を読むと全ページが動的になる(ガイドの「Storing the theme in a cookie」)。§10 Q1 で残すちらつきが困るなら C を提案する
  • ★useState(() => localStorage…)(遅延初期化)は採らない★(3-9): ガイドの例(アコーディオン)は、★インラインスクリプトが同じ要素の DOM を直す★から成り立つ。本アプリは ★23ファイルの文字と style★ が値に依存し、スクリプトで全部は直せない → ★ハイドレーションの不一致になる★。

3-1. 純粋関数 — lib/ui-prefs.ts(新規)

/**
 * 表示設定(ダーク/ライト・JP/EN)の保存(ユーザー決定 2026-10-19 U1。指示書BC 段階3。D-183)。
 *
 * ★保存先はブラウザの localStorage(閲覧者ごとの便利機能)★。サーバー・DB には保存しない。
 * ★読み書きは必ず try/catch★(プライベートウィンドウ・ストレージの無効化で例外になる)。使えないときは既定で描く。
 * ★キーと許す値はこのファイルだけが知る★ — `<head>` のインラインスクリプト(`app/layout.tsx`)と
 *   `ThemeProvider` が同じものを使う(片方だけ変えると、描く前と後で値が食い違う)。
 *
 * ★期待値表は `lib/__tests__/ui-prefs.test.ts`★(T1〜T5 / L1〜L4 / S1〜S4)。
 */
import type { Lang } from "@/lib/i18n";
import type { ThemeName } from "@/lib/theme";

export const THEME_STORAGE_KEY = "dwatch.theme";
export const LANG_STORAGE_KEY = "dwatch.lang";

/** 保存されていた値をテーマとして読む。★"dark" / "light" 以外(null・大文字違い・空)は null★(既定で描く) */
export function parseSavedTheme(value: string | null): ThemeName | null {
  return value === "dark" || value === "light" ? value : null;
}

/** 保存されていた値を言語として読む。★"ja" / "en" 以外は null★ */
export function parseSavedLang(value: string | null): Lang | null {
  return value === "ja" || value === "en" ? value : null;
}

/**
 * `<head>` で描く前に動かすスクリプト(文字列)。`<html>` の `data-theme` と `lang` を保存値に合わせる。
 * ★許す値は parseSaved* と同じ★。★例外はすべて握りつぶす★(使えなければ既定のまま)。
 */
export const PREFS_INLINE_SCRIPT = `(function(){try{var d=document.documentElement;var t=localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});if(t==="dark"||t==="light")d.setAttribute("data-theme",t);var l=localStorage.getItem(${JSON.stringify(LANG_STORAGE_KEY)});if(l==="ja"||l==="en")d.setAttribute("lang",l);}catch(e){}})()`;
  • ★ThemeName は lib/theme.ts・Lang は lib/i18n.ts:1 の型★(どちらも型だけ import する)。

3-2. テスト — lib/__tests__/ui-prefs.test.ts(新規。★期待値表を先に★)

# 関数 入力 期待
T1 parseSavedTheme "dark" "dark"
T2 〃 "light" "light"
T3 〃 null null
T4 〃 "Light" null
T5 〃 "" null
L1 parseSavedLang "ja" "ja"
L2 〃 "en" "en"
L3 〃 null null
L4 〃 "jp" null
S1 PREFS_INLINE_SCRIPT 保存 light / en data-theme = light・lang = en
S2 〃 保存 blue / fr ★変えない★(dark / ja のまま)
S3 〃 getItem が例外を投げる ★例外が外に出ない★・変えない
S4 〃 何も保存されていない(getItem が null) 変えない
  • S1〜S4 は ★スクリプトを new Function("document", "localStorage", PREFS_INLINE_SCRIPT) で作り、偽の document(documentElement に setAttribute を持ち、属性を Map に記録する)と偽の localStorage を渡して呼ぶ★(vitest.config.ts の環境は node。DOM は使わない)。偽の <html> は最初 data-theme=dark・lang=ja。
  • ★壊して確かめる(G3)★: (1) parseSavedTheme を return value as ThemeName | null; に → ★T4・T5 が赤★ → 戻す。(2) スクリプトの try{ … }catch(e){} を外す → ★S3 が赤★ → 戻す。

3-3. components/theme/ThemeProvider.tsx

  1. :3-10 の import に useLayoutEffect を足し、import { LANG_STORAGE_KEY, THEME_STORAGE_KEY, parseSavedLang, parseSavedTheme } from "@/lib/ui-prefs"; を足す。
  2. :25-31 を次に置き換える:
  // ★既定(dark / ja)で始める★ — サーバーも既定で描くので、最初の描画を一致させる(ハイドレーションの不一致を避ける)。
  // ★保存値は下の useLayoutEffect で読む(描く前)★。`useState(() => localStorage…)` にしないこと(指示書BC 3-0・D-183)
  const [theme, setTheme] = useState<ThemeName>("dark");
  const [lang, setLang] = useState<Lang>("ja");
  /** 保存値を読み終えたか。★読み終える前は <html> にも localStorage にも書かない★(既定値で上書きしない) */
  const [prefsLoaded, setPrefsLoaded] = useState(false);

  // ★保存した表示設定を、ハイドレーションの直後・描く前に読む★(ユーザー決定 2026-10-19 U1)。
  //   <html> の data-theme / lang は <head> のスクリプト(app/layout.tsx の PREFS_INLINE_SCRIPT)が先に入れている。
  //   ここでは JS で色・文字を決めている部品(THEME_TOKENS[theme]・t())のために state を合わせる。
  //   開発時の Strict Mode の再マウントで React が <html> の属性を消すので、下の effect が入れ直す(ガイドの「Re-applying attributes」)
  useLayoutEffect(() => {
    let savedTheme: ThemeName | null = null;
    let savedLang: Lang | null = null;
    try {
      savedTheme = parseSavedTheme(window.localStorage.getItem(THEME_STORAGE_KEY));
      savedLang = parseSavedLang(window.localStorage.getItem(LANG_STORAGE_KEY));
    } catch {
      // ★使えない環境(プライベートウィンドウ等)は既定のまま★
    }
    // eslint-disable-next-line react-hooks/set-state-in-effect -- 保存した表示設定を描く前に反映する(D-183)
    if (savedTheme) setTheme(savedTheme);
    if (savedLang) setLang(savedLang);
    setPrefsLoaded(true);
  }, []);

  useLayoutEffect(() => {
    if (!prefsLoaded) return;
    document.documentElement.dataset.theme = theme;
    document.documentElement.lang = lang === "ja" ? "ja" : "en";
    try {
      window.localStorage.setItem(THEME_STORAGE_KEY, theme);
      window.localStorage.setItem(LANG_STORAGE_KEY, lang);
    } catch {
      // ★保存できなくても画面は切り替わる★(その閲覧中だけ有効)
    }
  }, [theme, lang, prefsLoaded]);
  • ★toggleTheme / toggleLang / t / value(:33-46)は変えない★。
  • ★抑止コメント★: lint が set-state-in-effect を出さなければ消す(3-4)。

3-4. app/layout.tsx

  1. import に import { PREFS_INLINE_SCRIPT } from "@/lib/ui-prefs"; を足す。
  2. :23-31 を次にする:
    <html
      lang="ja"
      data-theme="dark"
      className={`${geistSans.variable} ${geistMono.variable}`}
      /* ★<head> のスクリプトが data-theme / lang を描く前に書き換えるので、<html> の属性の食い違いは許す★
         (この要素の属性だけに効く。子には効かない。指示書BC 3-4・D-183) */
      suppressHydrationWarning
    >
      <head>
        {/* ★保存した表示設定(ダーク/ライト・JP/EN)を描く前に <html> に入れる★(ユーザー決定 2026-10-19 U1)。
            ★CSP を入れるときは nonce が要る★(今は CSP が無い。§7-7) */}
        <script dangerouslySetInnerHTML={{ __html: PREFS_INLINE_SCRIPT }} />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  • ★lang="ja" / data-theme="dark" は既定として残す★(スクリプトが動かない・保存が無いとき)。
  • 開発サーバーのコンソールに「script タグ」についての警告が出たら、ガイドの「Extracting a reusable component」の InlineScript(サーバーで text/javascript・クライアントで text/plain)の形にする(作業報告に書く)。

3-5. 確かめてコミット

  • npx tsc --noEmit -p .・npm run lint・npm test(★BB 後の件数 +1ファイル / +13件★の見込み)
  • コミット(4ファイル):
    git add -- lib/ui-prefs.ts lib/__tests__/ui-prefs.test.ts components/theme/ThemeProvider.tsx app/layout.tsx
    git diff --cached --name-status
    メッセージの例: feat: ダーク/ライトと JP/EN をブラウザに保存し、再読み込み・再ログインのあとも描く前に戻す(指示書BC 段階3・U1・D-183)

段階4 — D-Sec ログの備考を編集モードにする(U3・#12)

★今(2fc3bbb)★: DsLogScreen.tsx:65-132 の NoteCell が、備考のセルを押すと textarea に変わり(:117-124)、★フォーカスが外れたら1件ずつ PATCH する★(:114・commitNote() :258-296)。 ★直す★: エラー一覧(ErrorsScreen.tsx)と同じ ★編集モード★ — 通常モードは備考を表示だけ/見出しの右の「編集」→ 全行の備考が入力欄になる → 「保存(N件)」で ★変えた行だけを1行ずつ順に★ PATCH /「キャンセル」は下書きがあれば確認/下書きがあれば beforeunload/変えた行の目印/絞り込みとページ送りを無効にし案内を出す。

★この画面に対応状況は無い★(制御盤の稼働状況。書くのは備考だけ)。★120px の対応状況の列(D-181 項3)は当てはまらない★。

★順番(同じファイルを3つの指示書が触る)★:

  1. ★指示書BB が先★(段階0-1 の依存)。BB は DsLogScreen.tsx の ★最終通信の1行だけ★ を {item.last_reported_at ? formatDateTime(item.last_reported_at) : "-"} にする(BB 1-2 の末尾・BB §8 B4)。★本書はその上に重ねる★ — 着手前に grep -n "formatDateTime(item.last_reported_at)" components/features/ds-logs/DsLogScreen.tsx で BB の形を確かめ、★行を書き直すときもこの形を残す★(null を formatDateTime() に渡すと型エラー・実行時は例外)。
  2. ★指示書AY(制御盤のログ)とは、先に入ったほうの上に後のほうが重ねる★(段階0-5・§4-7)。★AY が作業中なら本段階に入らない★。

4-1. 下書きの純粋関数 — lib/ds-devices/note-drafts.ts(新規)

★lib/prechecks/edit-drafts.ts は使わない★: 行の型 EditableRow が id / resolution_status / note を要り(edit-drafts.ts:19)、送る本文も対応状況が必須(:100-111)。★制御盤の行(DsDevice lib/types/index.ts:1024-1031)は serial_number と note だけ★。★同じ規則(行の値に戻したら消す・null と "" は同じ・入力の Map を書き換えない・順に送る・1行の失敗で止めない)の小さい版を書く★。

/**
 * D-Sec ログ(/ds-logs)の備考の「編集モード」の下書き(純粋関数のみ。ユーザー決定 2026-10-19 U3。指示書BC 段階4。D-183)。
 *
 * ★規則は `lib/prechecks/edit-drafts.ts`(3画面の対応状況・備考)と同じ★ — ただし行に対応状況が無いので別に持つ:
 *   - ★下書きは「行の値と違う備考」だけを持つ★。行の値に戻したらエントリごと消す(変えた件数 = Map の件数)
 *   - ★行の null と "" を同じとみなす★(比較は素の文字列。トリムと「空 → 削除」はサーバー側の規則。`app/api/ds-devices/[serial_number]/note/route.ts`)
 *   - ★`setNoteDraft` は入力の Map を書き換えない★(React の state にそのまま入れるため)
 *   - ★保存は rows の並び順に1行ずつ★(並列にしない)。★1行の失敗で止めない★
 *
 * ★期待値表は `lib/ds-devices/__tests__/note-drafts.test.ts`★(N1〜N6c / S1〜S4)。
 */

/** 下書きの対象の行(`DsDevice` が満たす) */
export interface NoteRow {
  serial_number: string;
  note: string | null;
}

/** serial_number → 入力中の備考 */
export type NoteDrafts = ReadonlyMap<string, string>;

/** 画面に出す備考(下書きがあればそれ、無ければ行の値。★行の null は ""★) */
export function noteDraftValue(row: NoteRow, drafts: NoteDrafts): string {
  return drafts.get(row.serial_number) ?? row.note ?? "";
}

/** 備考の下書きを入れる。★新しい Map を返す★。★行の値(null は "")と同じならエントリを消す★ */
export function setNoteDraft(drafts: NoteDrafts, row: NoteRow, note: string): Map<string, string> {
  const next = new Map(drafts);
  if (note === (row.note ?? "")) next.delete(row.serial_number);
  else next.set(row.serial_number, note);
  return next;
}

export interface SaveNoteDraftsResult {
  /** 成功した行(serial_number → ★サーバーが返した備考★。空にしたら null) */
  updated: Map<string, string | null>;
  /** 失敗した行(serial_number → 投げられた例外そのまま) */
  failed: Map<string, unknown>;
  /** ★失敗した行の下書きだけ★ */
  remaining: Map<string, string>;
}

/** 下書きをまとめて保存する。★rows の並び順に、下書きが行の値と違う行だけを1行ずつ★ `send` する。★1行の失敗で止めない★ */
export async function saveNoteDrafts<T extends NoteRow>(
  rows: readonly T[],
  drafts: NoteDrafts,
  send: (row: T, note: string) => Promise<string | null>
): Promise<SaveNoteDraftsResult> {
  const updated = new Map<string, string | null>();
  const failed = new Map<string, unknown>();
  const remaining = new Map<string, string>();
  for (const row of rows) {
    const note = drafts.get(row.serial_number);
    if (note === undefined || note === (row.note ?? "")) continue;
    try {
      updated.set(row.serial_number, await send(row, note));
    } catch (error) {
      failed.set(row.serial_number, error);
      remaining.set(row.serial_number, note);
    }
  }
  return { updated, failed, remaining };
}

4-2. テスト — lib/ds-devices/__tests__/note-drafts.test.ts(新規。★期待値表を先に★)

行: A = { serial_number: "S1", note: "点検済み" }・B = { serial_number: "S2", note: null }・C = { serial_number: "S3", note: "x" }

# 関数 入力 期待 守るもの
N1 setNoteDraft 空・A・"工事中" { S1: "工事中" } 変えたら入る
N2 〃 { S1: "工事中" }・A・"点検済み" 空 ★行の値に戻したら消える★(件数が「保存(N件)」になる)
N3 〃 空・B・"" 空 ★null と "" は同じ★
N4 〃 空・B・"メモ" { S2: "メモ" } null の行にも書ける
N5 〃 入力の Map ★入力の Map の中身と件数が変わらない・戻り値は別の Map★ state にそのまま入れられる
N6a noteDraftValue A・{ S1: "工事中" } "工事中" 下書き優先
N6b 〃 B・空 "" null は ""
N6c 〃 A・空 "点検済み" 行の値
S1 saveNoteDrafts rows [A, B, C]・下書き { S3: "y", S1: "工事中" } ★send が S1 → S3 の順に2回★(rows の順。下書きの順ではない) 順に送る
S2 〃 rows [A, C]・下書き { S1: "工事中", S3: "y" }・S1 の send が例外 S3 も送る。updated = { S3: <返り値> }・failed に S1・remaining = { S1: "工事中" } ★1行の失敗で止めない・失敗の下書きは残す★
S3 〃 下書きが空 send を1回も呼ばない・3つとも空 送る必要が無ければ送らない
S4 〃 rows [A]・下書き { S1: "" }・send は null を返す send に "" が渡る・updated = { S1: null } ★空にすると削除(サーバーが null を返す)★
  • ★壊して確かめる(G3)★: (1) setNoteDraft の if (note === (row.note ?? "")) next.delete(…); else を外して常に set → ★N2・N3 が赤★ → 戻す。(2) saveNoteDrafts の catch で throw error; にする → ★S2 が赤★ → 戻す。

4-3. components/features/ds-logs/DsLogScreen.tsx

★写す元は ErrorsScreen.tsx(表の画面の編集モード)★。番号は 2fc3bbb の行(段階1・2 の後は引用で探し直す)。

# 場所(今) 直す
4-3a :3(import) useCallback を消す(startEditNote / commitNote が無くなる。grep -n useCallback で確かめる)。import { noteDraftValue, saveNoteDrafts, setNoteDraft, type NoteDrafts } from "@/lib/ds-devices/note-drafts"; を足す
4-3b :47-132(DsDeviceNoteResponse・NoteError・NoteCellProps・NoteCell) ★DsDeviceNoteResponse(:47-57)は残す★。★NoteError・NoteCellProps・NoteCell(:59-132)は消す★。代わりに次の2つを置く:
/** 表の列数(保存に失敗した行の colSpan)。★<colgroup> / <th> / <td> と揃えること★ */ const COLUMN_COUNT = 5;(★指示書AY の「ログ」列が入っていれば 6★。§4-7)
/** 1行を送る(PATCH /ds-devices/{serial_number}/note)。★返ってきた備考(トリム済み・空なら null)を返す★。失敗は例外のまま */
`async function sendDeviceNote(device: DsDevice, note: string): Promise<string \
null> { const saved = await apiFetch(`/api/ds-devices/${encodeURIComponent(device.serial_number)}/note`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note }) }); return saved.note; }<br>/* 行の失敗の文言。★サーバーの文言があればそれ★、無ければ既定(表示時に t() を通す) / function rowErrorMessage(e: unknown): string { return (e instanceof ApiFetchError ? e.serverMessage : null) ?? "保存に失敗しました"; }`
4-3c :180-189(editingSerial / savingSerial / noteError とその docstring) ★消して、ErrorsScreen.tsx:162-184 と同じ state と beforeunload の effect を置く★(editing / drafts: NoteDrafts / rowErrors: ReadonlyMap<string, string> / saving / draftCount)。docstring は /** ★備考の編集モード(ユーザー決定 2026-10-19 U3。D-183)★ — エラー一覧・アラート・D-Sec アラートと同じ操作。変えた内容は下書きに貯め、「保存」で変えた行だけを1行ずつ送る。★旧: セルを押して入力・フォーカスが外れたら1件ずつ保存(D-171 まで)は廃止★ */
4-3d :245-296(startEditNote / commitNote) ★消して★、ErrorsScreen.tsx:280-302 と同じ canStartEdit / filtersDisabled / startEdit / cancelEdit と、saveEdit を置く(下のコード)
4-3e :298-303(見出し) ErrorsScreen.tsx:362-407 と同じ形(左に <div> で見出しと副題・右に <div className={styles.headerRight}> で「編集」/「保存(N件)」「キャンセル」)。★件数(全{n}件)は足さない★(この画面には無い。ページ送りが件数を出す)
4-3f :311 / :420(<div className={styles.toolbar}> … </div>) ★<fieldset className={styles.toolbar} disabled={filtersDisabled}> … </fieldset> にする★。直前にコメント {/* ★編集中は絞り込みをまとめて無効にする★(<fieldset disabled> で中のセレクト・検索・ボタンが全部無効になる。下書きが消えないように。D-165 / D-183) */}
4-3g :420 と :422 の間 ErrorsScreen.tsx:518-528 と同じ案内({editing && (<div className={styles.editNotice}>…</div>)}。失敗件数は rowErrors.size)
4-3h :455-477(行) 下のコードの形にする(変えた行の目印 styles.rowChanged・備考のセル・保存に失敗した行の直後の1行)
4-3i :485-490(<Pagination>) ErrorsScreen.tsx:658-667 と同じく <fieldset className={styles.pagerFieldset} disabled={filtersDisabled}> で包む(コメントごと)
4-3j :161-162(page の docstring) ★備考の保存では触らないこと★ → ★備考の保存では触らないこと★(保存の後に一覧を取り直さない)(意味は同じ。commitNote を指していないので文言だけ確かめる)
  /** ★編集モードに入れるのは、表示中の一覧が今の絞り込みの結果と一致しているときだけ★(エラー一覧と同じ)—
   *  読み込み中や検索語の確定前(debounce の待ち)に入ると、編集中に一覧が取り直されて下書きの行が消える */
  const canStartEdit = !loading && error === null && items.length > 0 && q === debouncedQ;
  /** ★編集中・保存中は絞り込みとページ送りを無効にする★(下書きが消えないように) */
  const filtersDisabled = editing;

  /**
   * 保存。★変えた行だけを1行ずつ順に送る★(`saveNoteDrafts()`)。
   * ・★成功した行はサーバーが返した備考で差し替える★(トリム・「空 → 削除」をサーバーが行うため。一覧は取り直さない)
   * ・★失敗した行は下書きを残し、行の直後に文言を出す★。★全部成功なら通常モードに戻る★
   * ・変更が0件なら何も送らずに通常モードに戻る(「編集を終了」の意味)
   */
  const saveEdit = async () => {
    if (saving) return;
    if (draftCount === 0) {
      setRowErrors(new Map());
      setEditing(false);
      return;
    }
    setSaving(true);
    setRowErrors(new Map());
    try {
      const { updated, failed, remaining } = await saveNoteDrafts(items, drafts, sendDeviceNote);
      if (updated.size > 0) {
        setItems((prev) =>
          prev.map((d) => (updated.has(d.serial_number) ? { ...d, note: updated.get(d.serial_number) ?? null } : d))
        );
      }
      setDrafts(remaining);
      setRowErrors(new Map(Array.from(failed, ([serial, e]): [string, string] => [serial, rowErrorMessage(e)])));
      if (failed.size === 0) setEditing(false);
    } finally {
      setSaving(false);
    }
  };
                {items.map((item) => {
                  // ★変えた行の目印★(下書きがある行。保存に失敗した行も下書きが残るので目印が付いたまま。エラー一覧と同じ)
                  const changed = drafts.has(item.serial_number);
                  const rowError = rowErrors.get(item.serial_number);
                  return (
                    <Fragment key={item.serial_number}>
                      <tr className={changed ? `${styles.row} ${styles.rowChanged}` : styles.row}>
                        …シリアル・施設名・接続状態・最終通信の4つ(今の :457-465 のまま。★最終通信は BB の
                           `{item.last_reported_at ? formatDateTime(item.last_reported_at) : "-"}` と 2-8d のコメントを残す★)…
                        <td className={styles.tdNote}>
                          {editing ? (
                            /* ★入力欄は固定の大きさ★(2行ぶん・resize: none・幅は列いっぱい。エラー一覧と同じ)。
                               上限超過(422)を画面側で先に防ぐ。上限値の正は lib/config(YAML と同じ) */
                            <textarea
                              className={styles.noteInput}
                              aria-label={t("備考")}
                              value={noteDraftValue(item, drafts)}
                              rows={2}
                              maxLength={DEVICE_NOTE_MAX_LENGTH}
                              placeholder={t("備考を入力")}
                              disabled={saving}
                              onChange={(e) => setDrafts((prev) => setNoteDraft(prev, item, e.target.value))}
                            />
                          ) : (
                            /* ★通常モードは文字だけ★(1行・はみ出しは「…」・全文は title。押しても何も起きない。
                               ★空のときは何も出さない★ — エラー一覧・アラートと同じ。旧: 「-」のボタン) */
                            <div className={styles.noteText} title={item.note ?? undefined}>
                              {item.note ?? ""}
                            </div>
                          )}
                        </td>
                      </tr>
                      {/* ★保存に失敗した行は直後に文言を出す★(下書きは残っている)。サーバーの文言は辞書に無ければ原文のまま */}
                      {rowError !== undefined && (
                        <tr>
                          <td colSpan={COLUMN_COUNT} className={styles.rowError}>
                            {t(rowError)}
                          </td>
                        </tr>
                      )}
                    </Fragment>
                  );
                })}
  • Fragment を react から import する(ErrorsScreen.tsx:3 と同じ)。
  • ★DEVICE_NOTE_MAX_LENGTH(200。lib/config/index.ts:617)のまま★(エラー一覧の PRECHECK_NOTE_MAX_LENGTH 400 にしない — 保存先と上限が違う)。
  • ★表の <colgroup>(:438-444)と列の幅は変えない★(.colNote 228px に2行の入力欄が収まる)。:429-437 のコメントの「備考の編集(表示 ↔ textarea)でも幅が動かない」は「通常モード ↔ 編集モードでも幅が動かない」に(G2)。

4-4. components/features/ds-logs/DsLogScreen.module.css

★写す元★: 見出し・ボタン・絞り込み・案内・ページ送りは DsAlertsScreen.module.css(:12-31・:33-87・:96-106・:131-149・:151-176)、行の目印・入力欄・失敗の行は ErrorsScreen.module.css(:315-323・:447-475・:477-482)。★コメントごと写し、「(<元のファイル> と同じ。D-183)」を足す★。

# 場所(今) 直す
4-4a :5-19(.header・.title・.subtitle) DsAlertsScreen.module.css:12-31 と同じ(.header を flex・margin-bottom: 16px、.subtitle から margin-bottom: 16px を消す。★見た目の余白は今と同じ 16px★ — 4px と 16px はマージンの相殺で 16px だった。:16 のコメントの理由)
4-4b :5-19 の後 DsAlertsScreen.module.css:33-87(.headerRight から .saveButton:disabled, .cancelButton:disabled まで)を写す
4-4c :28-34(.toolbar) DsAlertsScreen.module.css:96-106(fieldset の枠・余白を消す形)にする
4-4d :66-69(.statusButtons)の後 DsAlertsScreen.module.css:131-149(.toolbar:disabled の不透明度・select の戻し・禁止カーソル。.pagerFieldset:disabled button を含む)と :151-176(.editNotice・.editNoticeError・.pagerFieldset・.pagerFieldset:disabled)を写す
4-4e :128-129(.colNote のコメント) /* 備考(見出し 約47px)。編集モードの入力欄(2行ぶんの固定の大きさ)が入る列(D-183)。 / 旧 .tdNote の min-width: 200px(編集で幅が揺れないための最小幅)より広めに取る */
4-4f :160-162(.row)の後 ErrorsScreen.module.css:315-323(.rowChanged と .rowChanged > td:first-child。コメントごと)を写す
4-4g :192-269(「備考」列のインライン編集の区画: .tdNote のコメント・.noteButton・.noteButton:hover・.noteInput・.noteSaving・.noteError) ★次の形にする★: 区画のコメント /* ---- 「備考」列(通常モードは文字だけ・編集モードは入力欄。D-183)---- *//.tdNote はそのまま(コメントの「編集に入って textarea に変わっても」→「編集モードで入力欄に変わっても」)/★.noteButton・.noteButton:hover・.noteSaving・.noteError は消す★/★.noteText を足す★(下)/★.noteInput は ErrorsScreen.module.css:447-475 の固定の大きさ(height: calc(2 * 1.5em + 12px); overflow-y: auto; resize: none;)にし、D-171 の左右 8px のはみ出し(width: calc(100% + 16px); margin: 0 -8px;)は残す★/.noteInput:focus・.noteInput:disabled はそのまま/★.rowError を足す★(ErrorsScreen.module.css:477-482)
/* 通常モードの備考(表示だけ。1行・はみ出しは「…」・全文は title)。
   ★文字の頭を見出しの「備考」と揃える★(D-171)— 枠 1px + padding 7px のぶん左右に 8px はみ出させ、
   編集モードの入力欄(.noteInput)と同じ位置から文字を始める。空でも高さを保つ(行の高さが揃う) */
.noteText {
  box-sizing: border-box;
  width: calc(100% + 16px);
  margin: 0 -8px;
  padding: 0 7px;
  border: 1px solid transparent;
  line-height: 1.5;
  min-height: calc(1.5em + 2px);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

4-5. 辞書(lib/i18n.ts)

  • :146 の区画の見出し // ---- D-Secログ(F-06)の「備考」インライン編集 ---- → // ---- D-Secログ(F-06)の「備考」(編集モード。D-183)----
  • ★:147 の クリックして備考を編集: "Click to edit note", を消す★(使う場所が無くなる。grep -rn "クリックして備考を編集" components lib でコメント以外に残っていないこと)
  • 備考を入力・保存中…(:148-149)は残す(他の画面も使う)

4-6. 旧作りを引いているコメント(G2)

# ファイル:行(今) 今 直す
4-6a components/features/alerts/AlertsScreen.tsx:278 (`DsLogScreen.tsx` の `commitNote()` と同じ理由)。 (エラー一覧の `saveEdit()`・D-Sec ログの `saveEdit()` と同じ理由)。
4-6b 同 :584 ★空のときは何も出さない★(「クリックして備考を編集」は出さない) */} ★空のときは何も出さない★ */}
4-6c components/features/system/SystemScreen.tsx:177 * 保存の共通部分(`DsLogScreen.tsx` の `commitNote()` に揃えてある)。 * 保存の共通部分(★成功はレスポンスの値で差し替える・保存中は無効・失敗は文言を出す★。編集モードの画面の `saveEdit()` と同じ作法)。

4-7. 指示書AY が先に入っていたとき(段階0-5)

  • ★「ログ」列(.colLogs・<th>・各行の「ログ」ボタン)とモーダル(DeviceLogsModal)は残す★。COLUMN_COUNT は ★6★。表の最小幅(DsLogScreen.module.css:82-88)は AY の値のまま。
  • ★編集モード中も「ログ」ボタンは押せる★(読むだけのモーダルで、一覧も下書きも変えない)。★ボタンは <fieldset> の外(表の中)なので無効にならない★ — そのままでよい。
  • ★AY の 4-21(備考の blur が先に起きる)の前提は消える★ → 作業報告に書く(AY の文書は触らない)。

4-8. 確かめてコミット

  • npx tsc --noEmit -p .・npm run lint・npm test(★BB 後の件数 +2ファイル / +25件★の見込み)
  • 次の grep が0件:
    grep -rn "NoteCell\|commitNote\|editingSerial\|savingSerial\|noteButton\|noteSaving\|クリックして備考を編集" components lib
  • コミット(7ファイル):
    git add -- lib/ds-devices/note-drafts.ts lib/ds-devices/__tests__/note-drafts.test.ts \
      components/features/ds-logs/DsLogScreen.tsx components/features/ds-logs/DsLogScreen.module.css \
      lib/i18n.ts components/features/alerts/AlertsScreen.tsx components/features/system/SystemScreen.tsx
    git diff --cached --name-status
    メッセージの例: feat: D-Sec ログの備考を他の3画面と同じ編集モードにする(編集/保存(N件)/キャンセル・まとめて保存。指示書BC 段階4・U3・D-183)

段階5 — 文言・a11y・細かい点(#13〜#18)

5-1. レポートの文言(#13・#14)

# ファイル:行(今) 今 直す
5-1a components/features/reports/ReportsScreen.tsx:628-630 2日以上連続で△以下または出発失敗の施設(直書き・t() なし) {t("{n}日以上連続で△以下または出発失敗の施設").replace("{n}", String(ESCALATION_THRESHOLDS.level2Days))}。ESCALATION_THRESHOLDS を @/lib/config から import(★API の絞り込み lib/reports/query.ts:428 と同じ値★)。直前にコメント {/* ★日数は lib/config の L2 の閾値から作る★(API の絞り込みと同じ値。ハードコード禁止 CLAUDE.md §7) */}
5-1b 同 :720-729(エラー種別の内訳の副題とコメント) period で「今週/今月/今年」+「のエラー発生件数(種別ごと)」(t() なし) {t("この期間のエラー発生件数(種別ごと)")}。コメントを {/* モック:748 は「今週」固定。期間を移動すると過去の期間になるので「この期間」と書く(期間は上の期間ナビに出ている)。★periodLabel() は日本語固定なので文に混ぜない★(指示書BC 5-1) */}
5-1c 同 :737 該当なし {t("該当なし")}
5-1d 同 :783 {facilityMeta ? facilityMeta.total : 0}施設 {t("{n}施設").replace("{n}", String(<段階1 の名前の facilityMeta>?.total ?? 0))}
5-1e 同 :469 / :481 aria-label="前の期間" / "次の期間" aria-label={t("前の期間")} / aria-label={t("次の期間")}

5-2. システム管理の「{n}」(#15)

components/features/system/SystemScreen.tsx:345-349 の t("連続問題施設などで、連続日数がこの日数を超えたとき「{n}日以上」と表示します") の後に .replace("{n}", limit !== null ? String(limit.value) : "N") を足す。直前にコメント {/* ★{n} は今の上限値★(保存すると変わる。読み込み中・失敗のときは「N」。指示書BC 5-2) */}。

5-3. KPI カードの ▲0% / ▼0%(#16)

★今★: KpiCard.tsx:82-94 は矢印を ★生値の符号★、数を丸めて出すので、|差| < 0.5 で「▲0%」「▼0%」が改善・悪化の色で出る(:41 のコメントはそれを意図として書いている)。 ★施設別の表の「変化」(lib/reports/facility-performance-labels.ts:29-32 の diffText()・FacilityPerformanceTable.tsx:94-97 の diffTone())は丸めた 0 を「→」・色なしにしている★ → ★カードも同じにする★。 ★◎○△× と L1〜L3 の判定(CLAUDE.md §4.3)ではない★(前期比の矢印と色は表示)。

  1. lib/format/kpi-delta.ts(新規):
    /**
     * レポートの KPI カードの前期比(矢印・数・色)。★丸めた数が 0 なら「→」・色なし★(指示書BC 5-3。D-183)—
     *   施設別パフォーマンスの「変化」(`diffText()` / `diffTone()`)と同じ考え方。旧: 生値の符号で「▼0%」を改善の色で出していた。
     * ★期待値表は `lib/format/__tests__/kpi-delta.test.ts`★(K1〜K8)。
     */
    export interface KpiDeltaView {
      arrow: "▲" | "▼" | "→";
      /** 表示する数(%。整数に丸めた絶対値) */
      percent: number;
      /** good = 改善 / bad = 悪化 / neutral = 横ばい */
      tone: "good" | "bad" | "neutral";
    }
    
    export function kpiDeltaView(diff: number, betterWhenHigher: boolean): KpiDeltaView {
      const percent = Math.round(Math.abs(diff));
      if (percent === 0) return { arrow: "→", percent: 0, tone: "neutral" };
      return {
        arrow: diff > 0 ? "▲" : "▼",
        percent,
        tone: diff > 0 === betterWhenHigher ? "good" : "bad",
      };
    }
  2. テスト lib/format/__tests__/kpi-delta.test.ts(新規。★期待値表を先に★):| # | diff | betterWhenHigher | 期待(arrow / percent / tone) | 守るもの | |---|---|---|---|---| | K1 | 0 | true | → / 0 / neutral | 横ばい | | K2 | 0.4 | true | → / 0 / neutral | ★#16(旧: ▲0 good)★ | | K3 | -0.4 | false | → / 0 / neutral | ★#16(旧: ▼0 good)★ | | K4 | 0.5 | true | ▲ / 1 / good | 丸めて1は矢印を出す | | K5 | -0.5 | true | ▼ / 1 / bad | 下がったら悪化 | | K6 | -2.6 | false | ▼ / 3 / good | ★エラー発生率は下がったら改善★ | | K7 | 3 | false | ▲ / 3 / bad | 同上の逆 | | K8 | -0.49 | true | → / 0 / neutral | 境界の手前 |
    • ★壊して確かめる(G3)★: if (percent === 0) … の行を消す(=旧の挙動)→ ★K2・K3・K8 が赤★ → 戻す。
  3. components/features/reports/KpiCard.tsx: deltaTone()(:35-46。型 DeltaTone ごと)を消し、kpiDeltaView を import。:82-94 の中を const view = kpiDeltaView(diff, betterWhenHigher); → className={\${styles.delta} ${styles[view.tone]}`}・{view.arrow}{view.percent}%。:87-88のコメントを{/ 矢印・数・色は kpiDeltaView()(丸めて0なら「→」・色なし。施設別の表の「変化」と同じ。指示書BC 5-3) /}に。★:41` の「例: エラー発生率が -0.4 なら表示は「▼0%」だが色は good(改善)。」は消す(G2)★。

5-4. a11y の基本(#17)

# ファイル:行(今) 今 直す
5-4a components/ui/StatCard.tsx:27-37 onClick があっても <div onClick> onClick があるときだけ role="button"・tabIndex={0}・onKeyDown(Enter と Space で onClick()。Space は e.preventDefault() でスクロールを止める)を足す。★<button> にしない★(中の <div> が button の中に置けない要素で、CSS も変わるため)。docstring に ★押せるカードはキーボードでも押せる(role="button"・Tab・Enter/Space。指示書BC 5-4)★ を1行
5-4b components/ui/StatCard.module.css:8-10(.clickable) cursor: pointer; 後に .clickable:focus-visible { outline: 2px solid var(--ac); outline-offset: 2px; } を足す
5-4c components/features/detail-log/CleanLogTable.tsx:243-246(開ける行の <tr>) onClick だけ hasAdvice のときだけ tabIndex={0}・aria-expanded={isExpanded}・`onKeyDown={(e) => { if (e.target !== e.currentTarget) return; if (e.key === "Enter" \ \ e.key === " ") { e.preventDefault(); onToggleRow(r); } }}を足す(★role` は変えない — 表の行のまま★)
5-4d components/features/detail-log/CleanLogTable.module.css:226-228(.rowClickable) — 後に .rowClickable:focus-visible { outline: 2px solid var(--ac); outline-offset: -2px; } を足す(★表の中なので内側に描く★)
5-4e components/features/detail-log/CleanLogTable.tsx:186 / :213-219 aria-label="判定基準"・凡例の ✕ に名前なし aria-label={t("判定基準")}・凡例の ✕ に aria-label={t("閉じる")}
5-4f components/features/detail-log/AreaModal.tsx:82-84(✕) 名前なし aria-label={t("閉じる")}
5-4g components/features/detail-log/AreaModal.tsx(:60-64 の後) Esc で閉じない 次を足す(useEffect を react から import):
// ★Esc で閉じる★(指示書BC 5-4。フォーカスの移動・背景のスクロールの停止は本書ではやらない — §7-8)
useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [onClose]);
5-4h components/features/reports/ReportsScreen.tsx:434-457 施設名・シリアル・ステータスの入力にラベルなし・placeholder は直書き 施設名: aria-label={t("施設名")}・placeholder={t("施設名")}/シリアル: aria-label={t("シリアルNo.")}・placeholder={t("シリアルNo.")}/ステータスの <select>: aria-label={t("ステータス")}
5-4i components/features/detail-log/DetailLogScreen.tsx:457-477 施設名・機種・シリアルにラベルなし・placeholder 直書き それぞれ aria-label={t("施設名")} / t("機種") / t("シリアル") と placeholder も t() に
5-4j 同 :322-345(日付2つ) ラベルなし aria-label={t("開始日")} / aria-label={t("終了日")}(エラー一覧と同じ)
5-4k components/features/ds-logs/DsLogScreen.tsx:382-387・components/features/facilities/FacilitiesScreen.tsx:105-110・components/features/schedules/SchedulesScreen.tsx:107-112(検索欄) ラベルなし aria-label に placeholder と同じ t(...) を足す
  • ★ダッシュボードの数値カード(DashboardScreen.tsx:246-280 の onClick={() => router.push("/detail-log")})は 5-4a で自動的にキーボードで押せるようになる★(呼び出し側は変えない)。

5-5. 清掃詳細ログ・D-Sec ログの文言(見つけたもの)

# ファイル:行(今) 今 直す
5-5a components/features/detail-log/DetailLogScreen.tsx:313 1行=1ロボット×1スケジュール実行(t() なし) {t("1行=1ロボット×1スケジュール実行")}
5-5b 同 :347 ← 戻る(t() なし) {t("← 戻る")}
5-5c components/features/ds-logs/DsLogScreen.tsx:301 t("D-Secログ")(★辞書のキーは "D-Sec ログ"(空白あり)で、当たらない★) ★キーを足す★(5-8)。画面の文言は変えない
5-5d components/features/ds-logs/DsLogScreen.tsx:384 t("施設名・シリアルで検索")(辞書に無い) ★キーを足す★(5-8)

5-6. ログイン画面の失敗の文言(#18)

★今★: app/login/page.tsx:36-44 は 200 以外をすべて「トークンが違います」(:44)。★IP で弾かれた(403。proxy.ts:66-73)・入力が空(422。app/api/auth/login/route.ts:38-40)・サーバーの失敗(5xx)・回数制限(429。★今は無いがリリース前に入れる予定 — CLAUDE.md §3 のセキュリティの一覧★)も同じ文言★。★画面の文言は t() を通っていない★(ログイン画面も ThemeProvider の中なので、段階3 の後は EN の人が EN で来る)。

  1. lib/auth/login-error-message.ts(新規):
    /**
     * ログインの失敗の文言(辞書のキー。画面で t() に通す)。★トークンの中身に関わることは言わない★
     * (違う・空・IP・回数・サーバー側の失敗の区別だけ。指示書BC 5-6。D-183)。
     * ★期待値表は `lib/auth/__tests__/login-error-message.test.ts`★(M1〜M7)。
     */
    export function loginErrorMessage(status: number): string {
      if (status === 401) return "トークンが違います";
      if (status === 422) return "トークンを入力してください";
      if (status === 403) return "このIPアドレスからは利用できません";
      if (status === 429) return "試行回数が多すぎます。しばらくしてから再度お試しください";
      return "ログインできませんでした。時間をおいて再度お試しください";
    }
  2. テスト(★期待値表を先に★。lib/auth/__tests__/ が無ければ作る):| # | status | 期待 | |---|---|---| | M1 | 401 | トークンが違います | | M2 | 403 | このIPアドレスからは利用できません | | M3 | 422 | トークンを入力してください | | M4 | 429 | 試行回数が多すぎます。しばらくしてから再度お試しください | | M5 | 500 | ログインできませんでした。時間をおいて再度お試しください | | M6 | 503 | 同上 | | M7 | 404 | 同上(想定外はサーバー側の失敗と同じ) |
    • ★壊して確かめる(G3)★: 関数を return "トークンが違います"; だけにする → ★M2〜M7 が赤★ → 戻す。
  3. app/login/page.tsx:
    • import に import { useTheme } from "@/components/theme/ThemeProvider"; と import { loginErrorMessage } from "@/lib/auth/login-error-message";。LoginForm の先頭で const { t } = useTheme();
    • :43-44 → // ★失敗の理由は状態コードで分ける。トークンの中身に関わることは言わない★(指示書BC 5-6) と setError(loginErrorMessage(response.status));
    • :73-75 の {error} → {t(error)}
    • :46 の文言はそのまま(t() は表示のとき)
    • 画面の文言を t() に通す: :55 DFAロボット監視システム(辞書にある)・:58 アクセストークン・:79 確認中… / ログイン・:82 社内利用のみ。接続元IPも制限されています。。★:54 の D-Watch は製品名なので通さない★
    • ★:136 の color: "#fff" は変えない★(§7-6)

5-7. 確かめてコミット(の前に辞書)

5-8. 辞書に足す(段階5 の分。lib/i18n.ts。段階2 の区画の後)

★足す前に grep -nF で無いことを確かめる★(2026-10-19 に、下の22個はどれも無いことを確かめた。閉じる・開始日・終了日・施設名・機種・シリアル・ステータス・DFAロボット監視システム は既にある):

  // ---- 文言・a11y(指示書BC 段階5。D-183)----
  // ★EN は暫定★
  "D-Secログ": "D-Sec Logs",
  "施設名・シリアルで検索": "Search by facility or serial",
  "1行=1ロボット×1スケジュール実行": "1 row = 1 robot × 1 scheduled run",
  "← 戻る": "← Back",
  "シリアルNo.": "Serial No.",
  前の期間: "Previous period",
  次の期間: "Next period",
  "{n}日以上連続で△以下または出発失敗の施設": "Facilities rated △ or worse, or failing to launch, for {n}+ consecutive days",
  "この期間のエラー発生件数(種別ごと)": "Errors in this period (by type)",
  該当なし: "None",
  "{n}施設": "{n} facilities",
  判定基準: "Grading criteria",
  // ログイン画面(ログイン画面も ThemeProvider の中。段階3 の後は保存した言語で出る)
  アクセストークン: "Access token",
  ログイン: "Sign in",
  "確認中…": "Checking…",
  "社内利用のみ。接続元IPも制限されています。": "Internal use only. Source IP addresses are restricted.",
  トークンが違います: "Invalid token",
  トークンを入力してください: "Please enter the token",
  このIPアドレスからは利用できません: "Access from this IP address is not allowed",
  "試行回数が多すぎます。しばらくしてから再度お試しください": "Too many attempts. Please try again later",
  "ログインできませんでした。時間をおいて再度お試しください": "Could not sign in. Please try again later",
  "通信に失敗しました。時間をおいて再度お試しください": "Network error. Please try again later",

(★このIPアドレスからは利用できません は API(proxy.ts:70)の文言と同じ★ — API の 403 を画面で出したときも EN になる)

  • npx tsc --noEmit -p .・npm run lint(★重複キーで落ちないこと★)・npm test(★BB 後の件数 +4ファイル / +40件★の見込み)
  • コミット(16ファイル程度。git diff --name-only で確かめてから1つずつ書く):
    git add -- lib/format/kpi-delta.ts lib/format/__tests__/kpi-delta.test.ts components/features/reports/KpiCard.tsx \
      lib/auth/login-error-message.ts lib/auth/__tests__/login-error-message.test.ts app/login/page.tsx \
      components/features/reports/ReportsScreen.tsx components/features/system/SystemScreen.tsx \
      components/ui/StatCard.tsx components/ui/StatCard.module.css \
      components/features/detail-log/CleanLogTable.tsx components/features/detail-log/CleanLogTable.module.css \
      components/features/detail-log/AreaModal.tsx components/features/detail-log/DetailLogScreen.tsx \
      components/features/ds-logs/DsLogScreen.tsx components/features/facilities/FacilitiesScreen.tsx \
      components/features/schedules/SchedulesScreen.tsx lib/i18n.ts
    git diff --cached --name-status
    メッセージの例: fix: レポートの文言・KPI の横ばい表示・システム管理の{n}・ログインの失敗の区別を直し、押せるカードと表の行をキーボードで操作できるようにする(指示書BC 段階5・D-183) (★2つに分けてもよい★: 文言(5-1・5-2・5-5・5-6・5-8)と、KPI・a11y(5-3・5-4)。分けたら作業報告に書く)

段階6 — 資料(D-183・code-map・spec)

6-1. ★docs/decisions.md に触る前に★(3-2)

git diff -- docs/decisions.md
grep -n "^### D-18[2-9]" docs/decisions.md
  • ★差分が空★ → 6-2 へ。
  • ★差分がある(他のセッションの未コミットの追記)★ → ★docs/decisions.md は編集しない★。6-3・6-4 だけコミットし、★D-183 の本文(6-2 の下書きに実際の値を入れたもの)を作業報告に貼って監督に渡す★(監督が他のセッションのコミットの後に追記する)。
  • ★D-182 が無くても D-183 を使う★(D-182 は指示書BB の予約)。★D-183 が埋まっていたら次の空き★(本書の「D-183」をすべて読み替え、作業報告に書く)。

6-2. D-183 を末尾に追記する(下書き)

★末尾の区切り(--- と空行)は実物を見て合わせる★。★D-181 以前の本文・「実装の記録」は変えない★。

### D-183 `[決定]` 画面の古い応答を捨て、失敗・空の表示を分け、表示設定を保存し、D-Sec ログの備考を編集モードにする

**日付**: 2026-10-19 / 実装: Claude(指示書BC。★ユーザー決定で Claude★)

★根拠★: 監督の画面側の見直し(2026-10-19・`2fc3bbb`)/ユーザー決定 2026-10-19 U1〜U4(下)/監督の決定 2026-10-19(指示書BC §10 Q1・Q2・Q3・Q6・Q7・Q9)。★データ層・API・型の直しは D-182(指示書BB)★で、★本書は D-182 の後に、その型(null・`result_pending`)を画面で出す★(BB §8 B1〜B5)。

1. ★U4 取得中に条件を変えたら、遅れて届いた古い応答は捨てる★(清掃詳細ログ・アラート・D-Sec ログ・施設マスタ・スケジュール・レポート)。
   5画面は effect の片付けで `cancelled` にする形、レポートは ★結果にクエリを持たせる★形(エラー一覧と同じ)。
   旧: 前の条件の結果が新しい条件の表示を上書きしえた(例: 「解消済み」を押したのに全件の一覧)
2. ★レポートは期間・分類が変わったときだけ画面全体を「読み込み中…」にする★。全件表示・施設の絞り込みだけなら前の内容を薄く残す。★清掃面積は粒度タブで表だけ薄くする★(数値カードは粒度で変わらない)。
   旧: 画面全体が入れ替わりスクロールが先頭に飛んだ。★全件表示で4本とも取り直すのは承知のうえ★
3. ★アドバイスの取得失敗は「アドバイスの取得に失敗しました」★(「該当なし」と区別)。★「エリア別詳細を見る →」はアドバイスの有無・成否によらず出す★。読み込み中の表示は自分の行だけを消す
4. ★清掃詳細ログの CSV は、期間の誤り・読み込み中は押せない★。★アラートの期間はエラー一覧と同じ検証と「指定なし(全期間)」★(サーバーの 422 は D-182)
5. ★レポートの全件表示が上限(200)で切れたら注記を出す★(施設別パフォーマンス・連続問題施設)
6. ★U1 ダーク/ライトと JP/EN はブラウザ(localStorage。キー `dwatch.theme` / `dwatch.lang`)に保存し、再読み込み・再ログインのあとも保つ★。
   ★`<head>` のインラインスクリプトで描く前に `<html>` の `data-theme` / `lang` を入れ、`ThemeProvider` は既定で始めて `useLayoutEffect` で読み込む★
   (Next.js 16 のガイド「preventing-flash-before-hydration」)。★`useState` の遅延初期化は使わない★(23ファイルの文字・色でハイドレーションの不一致になる)。
   ★残るもの★: ハイドレーションより前に描かれたとき(遅い回線)、JS で色・文字を決めている部分が一瞬ダーク・日本語。★CSP を入れるときは nonce が要る★。
   ストレージが使えないときは既定(ダーク・日本語)で描き、切り替えはその閲覧中だけ有効
7. ★U3 D-Sec ログの備考は、エラー一覧・アラート・D-Sec アラートと同じ編集モード★(編集 / 保存(N件)/ キャンセル・変えた行だけを1行ずつ順に保存・失敗した行は下書きと文言を残す・下書きがあれば離れる前に確認・編集中は絞り込みとページ送りを無効・変えた行の目印は表の形(背景+左端の帯))。
   保存先は今までどおり `data/settings/ds-device-notes.json`(`PATCH /ds-devices/{serial_number}/note`。API は変えない)。★通常モードの空の備考は何も出さない★(旧: 「-」)。
   下書きの規則は `lib/ds-devices/note-drafts.ts`(`lib/prechecks/edit-drafts.ts` と同じ規則。行に対応状況が無いので別に持つ)。★旧: セルを押して入力・フォーカスが外れたら1件ずつ保存(D-171 まで)は廃止★
8. ★U2 清掃詳細ログの凡例「(閾値はシステム設定で変更可能)」は残す★(ユーザー決定。あとで閾値を編集できるようにする。今のシステム管理画面は閾値を表示だけ)
9. ★レポートの KPI カードの前期比は、丸めた数が 0 なら「→」・色なし★(施設別の表の「変化」と同じ。`kpiDeltaView()`)。旧: 「▼0%」を改善の色で出していた
10. ★ログインの失敗は状態コードで分ける★(401 トークンが違う / 422 空 / 403 IP / 429 回数 / それ以外はサーバー側の失敗。`loginErrorMessage()`)。ログイン画面の文言も `t()` に通す
11. 文言・a11y: レポートの連続問題施設の説明は L2 の閾値から作る/エラー種別の内訳の副題は「この期間の」/システム管理の「{n}」に今の上限値/押せる数値カードと開ける表の行はキーボードで操作できる/エリア別モーダルの ✕ に名前・Esc で閉じる/ラベルの無い入力欄に `aria-label`/「読み込み中…」と取得失敗を `t()` に/施設マスタのロボット0台は「-」
12. (記録)★指示書BB の `null`(有効なロボットがいない清掃エリアの `serial_number` / `model_name`・心拍の無い制御盤の `last_reported_at`)は画面で「-」★(`t()` に通さない。記号で言語によらない)。★エリア別完了率の `result_pending` の行は、完了率「-」・判定の札の代わりに「結果待ち」・問題エリアの赤字にしない★(清掃詳細ログの結果待ちと同じ見せ方。BB §8 B1〜B5)

#### 実装の記録

- ★2026-10-19 指示書BC(Claude)★:
  - ★コミット★: <段階1>/<段階2>/<段階3>/<段階4>/<段階5(2つなら両方)>
  - ★テスト★: BC 前 <段階0 で測った数>(`2fc3bbb`)→ BC 後 <段階7 で測った数>(全件通過。U1-T/L/S 13件・N/S 12件・K 8件・M 7件)。それぞれ指示書BC のとおり壊して赤を確かめた
  - ★古い応答の再現(段階0-7)★: 直す前 <結果>/直した後 <結果>(当方の fixtures 由来。アプリ内ブラウザで fetch を遅らせた。できなかったならそう書く)
  - ★画面確認★: <段階7 の結果と出所(誰が・どこで・いつ。当方の fixtures 由来)。監督に任せたならそう書く>
  - ★指示書BB との関係★: <BB のどのコミットの上で始めたか。2-8e(施設マスタのロボット)が該当したか>
  - ★指示書AY との関係★: <AY が先に入っていたか。入っていなければ「AY の 4-21 と DsLog の行番号は引き直しが要る」>
  - ★指示書と違う判断★: <あれば。無ければ「無し」>

6-3. docs/code-map.md

★冒頭の改訂の履歴(:26 の「第4版改訂6」の行の次)に1項目足す★: - ★**第4版改訂7: 2026-10-19**(**D-183** / 指示書BC の実装 <段階5 のコミット> まで)★ — 画面の古い応答・失敗の表示・表示設定の保存・D-Sec ログの備考の編集モードの分だけ直しました。★直した箇所の行番号は <段階5 のコミット> で確かめました★。それ以外は上のとおりです

# 場所(今) 直す
6-3a :737(3.9「DsLogScreen.tsx(★495行。f2b91fc★)」)・:738(:217 / :271) 行数と <段階5 のコミット>・GET /api/ds-devices と PATCH …/note の行を引き直す(api/ds-devices?・sendDeviceNote)
6-3b :745-749(3.9 の固有の注意点: 「★この画面の備考は編集モードではなく、セルを押して直接入力する従来の作り★のまま」・「保存の契機はセルから離れたとき」・:261-266・:280-284・:286-288) ★編集モードの説明に書き直す★: ★備考は編集モード(エラー一覧と同じ操作。編集→保存(N件)・キャンセル。D-183)★。変えた行だけを1行ずつ順に送り(saveNoteDrafts()`lib/ds-devices/note-drafts.ts)、★返ってきた値で行を書き換えます★(サーバーが前後の空白を落とし「空文字 → 削除」を行うため)。★失敗した行は下書きと文言を残し、編集モードのままです★`
6-3c :750(DEVICE_NOTE_MAX_LENGTH(200字。:107)) :107 を引き直す(maxLength={DEVICE_NOTE_MAX_LENGTH})
6-3d :751(備考の文字の頭 :206-210 / :232-234) .noteText / .noteInput の行に引き直す(DsLogScreen.module.css)
6-3e :924・:925(API の表の画面側の行 DsLogScreen.tsx:217 / :271) 引き直す
6-3f :2155(DEVICE_NOTE_MAX_LENGTH の「入力欄(DsLogScreen.tsx:107)」) 引き直す
6-3g :3613(「D-S制御盤の備考を書く」) **D-Secログ画面で「編集」を押し、備考を入力して「保存(N件)」を押す。**★押すまでは送られません★(D-183)。200文字まで。data/settings/ds-device-notes.jsonに保存
6-3h :3617・:3618(「★保存されないのでリロードで日本語に戻ります★」「★これも保存されません★」) 画面右上の切り替え。★ブラウザに保存され、再読み込み・再ログインのあとも保たれます★(localStorage。D-183。ストレージが使えないブラウザでは保たれません) / 同上(★保存されます★)
6-3i :4093(ThemeProvider.tsx の役割) 末尾に 。★選んだ値は localStorage に保存し、のスクリプト(app/layout.tsx・lib/ui-prefs.ts)が描く前に戻す(D-183)★
6-3j AlertsScreen.tsx・DetailLogScreen.tsx・ReportsScreen.tsx・CleanAreaScreen.tsx・DashboardScreen.tsx・CleanLogTable.tsx・FacilitiesScreen.tsx・SchedulesScreen.tsx の行番号を引いている行 ★本書のコミットの後の HEAD で関数名・文言を grep して引き直す★(★下の行から直す★)。対象の行は `grep -n "AlertsScreen.tsx\ DetailLogScreen.tsx\ ReportsScreen.tsx\ CleanAreaScreen.tsx\ DashboardScreen.tsx\ CleanLogTable.tsx\ FacilitiesScreen.tsx\ SchedulesScreen.tsx\ DsLogScreen" docs/code-map.mdで出す(2026-10-19 は約100行)。★取得の関数名(fetchAlerts` 等)を引いている行は「取得の effect」に★
  • 最後に grep -n "commitNote\|NoteCell\|セルから離れたとき\|保存されません\|保存されないので" docs/code-map.md が0件。

6-4. docs/spec.md

# 場所(今) 直す
6-4a :104(F-06「一覧: シリアル / 施設名 / 接続状態 / 最終通信 / 備考」)の次 - 備考は編集モード(編集 → 保存(N件)/ キャンセル)で変えた行をまとめて保存(★即時保存はしない★。保存先はファイルdata/settings/ds-device-notes.json。D-183) を1行足す
6-4b :169(「共通レイアウト(…ダーク/ライト、JP/EN切替の器)」) 末尾の ) の前に 。★選んだ値はブラウザに保存し、再読み込み・再ログインのあとも保つ(D-183)★ を足す

6-5. 確かめてコミット

  • git diff --stat で docs/decisions.md(6-1 で触れたときだけ)・docs/code-map.md・docs/spec.md だけ
  • git diff -- docs/decisions.md が D-183 の追記だけ
  • ★D-183 の「実装の記録」は段階7 の後に埋める★(一度 <…> のままコミットしても、段階7 の後にまとめて1つでもよい。どちらにしたか作業報告に書く)
  • メッセージの例: docs: D-183(画面の古い応答・失敗の表示・表示設定の保存・D-Sec ログの備考の編集モード)を追記し、code-map・spec を追従させる(指示書BC)

段階7 — 全体の確認

  1. npx tsc --noEmit -p . — 0件
  2. npm run lint — 0件(★使われていない eslint-disable を残していない・新しい抑止は 3-4 の理由のあるものだけ★)
  3. npm test — ★全件通過★。件数を控える(期待: ★段階0-4 で測った BB 後の件数 +4ファイル / +40件★)
  4. ★USE_FIXTURES=true で npm run build★(PowerShell: $env:USE_FIXTURES='true'; npm run build)— 成功(★本体で★。ワークツリーではない)
  5. ★画面の確認★ — ★アプリ内ブラウザ(mcp__Claude_Browser__*)を使う。Edge(ヘッドレス・CDP を含む)は使わない★(ユーザー指示 2026-10-16)。★ユーザーの :3000 は止めない★。 手順は指示書AX 段階10-1 の 1〜3(本体で fixtures のビルド → :3100 で next start・テスト用のトークンはその場で生成し、★文書・コミット・報告に書かない★)。★幅 1440px★(resize_window で 1440×900。終わったら preset: "desktop" に戻す)。★JP と EN・ダークとライトの両方★。 ★始める前に data/settings/ds-device-notes.json を控える(スクラッチパッドにコピー)。終わったら戻す★(3-10)。 ★アプリ内ブラウザが使えないときは、画面の確認をせずに「監督に任せる」と作業報告に書く★(ほかの手段で代わりにしない)。| # | 画面 | 操作 | 期待 | |---|---|---|---| | 1 | /alerts・/reports | 段階0-7 と同じ(fetch を遅らせる) | ★最後に押した条件の結果だけが出る★(遅れて届いた前の応答で上書きされない) | | 1 | /detail-log | __delayPattern = "result=all" → 「全件」→ すぐ「×のみ」 | 表は「×のみ」の結果 | | 1 | /ds-logs・/facilities・/schedules | __delayPattern = "q=" → 検索欄に1文字 → 0.5秒待つ → もう1文字 | 表は2文字の検索の結果 | | 2 | /reports | 連続問題施設の「他N件を表示 →」・施設別の「全N施設を表示 →」 | ★画面全体が「読み込み中…」にならない★。押したカードだけ一瞬薄くなり(aria-busy="true")、スクロール位置(window.scrollY)が変わらない | | 2 | /reports | 「月次」を押す | 今までどおり画面全体が「読み込み中…」→ 月次の内容 | | 3 | /detail-log | __delayPattern = "/advice"・__delayMs = 3000 → 行Aを開く → すぐ行Bを開く | B に「読み込み中…」が出続け、B の取得が終わるまで消えない | | 3 | /detail-log・/dashboard | アドバイスの API を失敗させる(window.fetch を差し替えて /advice だけ Promise.reject(new TypeError("x")) を返す)→ 行を開く | ★「アドバイスの取得に失敗しました」★ と ★「エリア別詳細を見る →」★(押すとモーダルが開く)。もう一度閉じて開くと取り直す | | 4 | /detail-log | 開始日 > 終了日を入れる | 表の場所に期間の誤り・★CSV が押せない(button[disabled])★ | | 5 | /alerts | 両方空 → 開始日 > 終了日 | 空のとき「指定なし(全期間)」が出る。誤りのとき一覧の場所に「date_fromはdate_to以前の日付を指定してください」・数値カードは0・「編集」は押せない | | 6 | /clean-area | 表までスクロール → 「顧客」タブ | ★数値カードと注記が消えない★・表だけ薄くなり顧客の行に変わる・window.scrollY が変わらない | | 7 | /reports | (fixtures の施設数が 200 以下なら)コードで確かめた、と書いてよい | 総数 > 表示件数のとき注記 | | 8 | /facilities | ロボット0台の施設(fixtures にあれば) | ロボット列が「-」(無ければコードで確かめた、と書く) | | 9 | 清掃詳細ログ・D-Sec ログ・施設マスタ・スケジュール・レポート | EN で再読み込み(読み込み中は一瞬) | "Loading…"(見えなければコードの差分で確かめた、と書く) | | 10 | /detail-log・/alerts・/schedules・/ds-logs | (fixtures に該当の行が無ければ)window.fetch を差し替え、/api/clean-logs・/api/prechecks・/api/schedules の応答の1行目の serial_number(清掃ログは model_name も)を null、/api/ds-devices の1行目の last_reported_at を null にして返す | その列に ★「-」★(空白・"null" にならない)。EN でも「-」。★エラーにならない★ | | 10b | /detail-log | BB の fixtures に結果待ちのエリア(BB 3-1 の4)があればそれを開く。無ければ window.fetch を差し替えて /api/clean-logs/{id}/areas?filter=all の応答の1行に result_pending: true(grade: "×"・completion_rate: null)を差し込み、任意の行を開いて「エリア別詳細を見る →」→「全件」 | その行は ★完了率「-」・札の代わりに「結果待ち」/ "Pending"・清掃エリア名が赤字でない★。他の行は今どおり。行の右端が揃っている | | 11 | 任意の画面 | ライト・EN に切り替える → 再読み込み | ★ライト・EN のまま★。document.documentElement.dataset.theme === "light"・lang === "en"。★コンソールにハイドレーションのエラーが無い★(read_console_messages の onlyErrors) | | 11 | 任意の画面 | ログアウト → /login | ★ログイン画面もライト・EN★("Access token" / "Sign in")。ログインしてもライト・EN のまま | | 11 | 任意の画面 | localStorage.clear() → 再読み込み | 既定(ダーク・日本語) | | 12 | /ds-logs | 通常モード | 備考は文字だけ(押しても何も起きない・空なら何も出ない)。見出しの右に「編集」(88px)。★「編集」は検索欄に打ってすぐ(0.3秒以内)は押せない★ | | 12 | /ds-logs | 「編集」→ 1行の備考を変える | 全行が入力欄(2行ぶんの固定の大きさ・resize: none)。「保存(1件)」(120px)・「キャンセル」(100px)。変えた行は背景 --sf-h+左端の帯 --ac。絞り込み全体が薄く([class*="__toolbar"] の opacity が 0.5)・中の部品とページ送りが禁止カーソル・「編集中は絞り込みとページ送りを使えません」。★入力欄の文字の頭が見出し「備考」と揃う★(D-171) | | 12 | /ds-logs | 変えた行を元の文字に戻す | 「保存」(件数なし)・目印が消える | | 12 | /ds-logs | 変えたまま「キャンセル」 | 確認が出る。OK で元に戻り通常モード | | 12 | /ds-logs | 変えたまま再読み込み | ブラウザの「離れますか」の確認 | | 12 | /ds-logs | 2行変えて「保存(2件)」 | ★PATCH /api/ds-devices/<シリアル>/note が2回、表の順に★(read_network_requests)。通常モードに戻り、新しい備考が出る。再読み込みしても残る | | 12 | /ds-logs | 201文字を入れようとする | 200文字で止まる | | 13〜16 | /reports | 過去の期間に移る・EN | 副題「この期間のエラー発生件数(種別ごと)」/ "Errors in this period (by type)"。連続問題施設の説明が「2日以上…」(EN も)。施設数 "{n} facilities"。KPI の差が |0.5| 未満のカードは「→0%」で色なし(fixtures に無ければコードとテストで確かめた、と書く) | | 15 | /system | 連続日数の表示上限 | 説明文に「{n}」が無く、今の上限値が入っている。保存すると変わる | | 17 | /dashboard | Tab で数値カードへ → Enter | /detail-log に移る(フォーカスの枠が見える) | | 17 | /detail-log | Tab で開ける行へ → Enter / Space | 行が開く・閉じる(aria-expanded が変わる) | | 17 | /detail-log | 行を開く → 「エリア別詳細を見る →」→ Esc | モーダルが閉じる。✕ の aria-label が「閉じる」/ "Close" | | 18 | /login | 違うトークン | 「トークンが違います」/ "Invalid token"(403・429・5xx はコードとテストで確かめた、と書く) | | 全体 | 上の画面 | ダーク・ライト × JP・EN | 崩れ・横スクロールが無い。日本語が中華字形になっていない |
    • ★スクリーンショットを撮る★: D-Sec ログの通常・編集(JP・ダーク)、同じ編集(EN・ライト)、清掃詳細ログのアドバイス失敗、レポートの全件表示の直後、ログイン画面(EN・ライト)
    • 終わったら data/settings/ds-device-notes.json を戻し、自分で起動した :3100 の listen プロセスを止める
  6. 最後の grep(★結果を作業報告に貼る★):
    grep -rn "NoteCell\|commitNote\|editingSerial\|noteButton\|クリックして備考を編集" components lib
    grep -rn "fetchLogs\|fetchAlerts\|fetchDevices\|fetchFacilities\|fetchSchedules" components
    grep -n "PREFS_INLINE_SCRIPT\|suppressHydrationWarning" app/layout.tsx
    git diff <BB の最後のコミット> --stat -- d-watch-api.yaml app/api lib/db lib/types lib/judge lib/reports lib/clean-logs app/globals.css
    • 1行目・2行目: 0件
    • 3行目: 各1件以上
    • 4行目: 何も出ない(★BB の後に★ API・データ層・型・判定・色を変えていない。★2fc3bbb と比べると BB の差分が出るので、BB の最後のコミットと比べる★)
  7. D-183 の「実装の記録」を埋める(G4)。

7. 記録だけ(★直さない★。作業報告に「確認した・直していない」と書く)

# 内容 場所 なぜ直さないか
7-1 ★凡例「(閾値はシステム設定で変更可能)」が、今は事実と違う★(システム管理画面は閾値を表示だけ — SystemScreen.tsx:7・:91 の GRADE_THRESHOLDS の表示) components/features/detail-log/CleanLogTable.tsx:212 ★ユーザー決定 U2: 残す。あとで閾値を編集できるようにする(将来の機能)★。そのとき lib/config の GRADE_THRESHOLDS を設定ストアから読む形にし、判定(lib/judge)とこの凡例が同じ値を見るようにする
7-2 凡例の他の文言(「判定基準(タスク完了率):」「%以上」「%未満」)と KpiCard の「{前週}比」が t() を通っていない CleanLogTable.tsx:196-211・KpiCard.tsx:92 ★EN はあとで一括(指示書AZ R3 の方針)★。数と連結する文言でキーの設計が要る
7-3 期間の区切りの記号が違う(アラート「〜」・エラー一覧と清掃詳細ログ「~」) AlertsScreen.tsx:412 ほか BA 7-2 と同じ(文言の統一はユーザー判断)
7-4 サイドバーのテーマ切替の aria-label(「ライトモード」「ダークモード」)が t() を通っていない components/layout/AppShell.tsx:123 components/layout/** は本書の範囲外(触らない)
7-5 期間の誤りの文言「date_fromはdate_to以前の日付を指定してください」に EN が無い サーバー・lib/clean-logs/detail-log-date-range.ts:54 エラー一覧でも同じ。EN はあとで一括
7-6 ★#fff の直書き★(主ボタン・選択中のタブの文字) app/login/page.tsx:136・CleanAreaScreen.module.css:85 :189・ReportsScreen.module.css:90 :158 ★受け皿の CSS 変数(「主色の上の文字」)が無い★。足すには app/globals.css を触る(範囲外)。両テーマとも --pr の上の白で読める
7-7 ★CSP を入れるとインラインスクリプトが止まる★ app/layout.tsx(段階3) 今は CSP が無い。★リリース前のセキュリティの一覧(CLAUDE.md §3)に足すよう監督に伝える★(本書は CLAUDE.md を触らない)
7-8 エリア別モーダルのフォーカスの移動・背景のスクロールの停止 AreaModal.tsx 本書は Esc と ✕ の名前だけ(#17)。指示書AY の DeviceLogsModal が同じ課題を自前で持つ。共通部品は作らない方針
7-9 表示設定の既定を OS の設定(prefers-color-scheme)に合わせること ThemeProvider.tsx ユーザーの依頼に無い。既定はダークのまま
7-10 レポートの全件表示で4本とも取り直す ReportsScreen.tsx の取得 段階1-2 の「承知のうえ」。§10 Q4
7-11 ★エリア別モーダルの「問題エリアはありません(全エリア◎○)」が、結果待ちのエリアだけ残っているときに事実と違う★(BB A3 の後) AreaModal.tsx:123-125 モックの文言(d-watch-mock.jsx:1949)。変えるには決定が要る(§10 Q10)

9. 触ってよいもの・触ってはいけないもの

触ってよい

  • components/features/detail-log/DetailLogScreen.tsx・CleanLogTable.tsx・CleanLogTable.module.css・AreaModal.tsx・AreaModal.module.css(2-8f・2-8g)
  • components/features/alerts/AlertsScreen.tsx(段階1・2 と、段階4-6 のコメント)
  • components/features/ds-logs/DsLogScreen.tsx・DsLogScreen.module.css
  • components/features/facilities/FacilitiesScreen.tsx・components/features/schedules/SchedulesScreen.tsx
  • components/features/reports/ReportsScreen.tsx・ReportsScreen.module.css・FacilityPerformanceTable.tsx・FacilityPerformanceTable.module.css・KpiCard.tsx
  • components/features/clean-area/CleanAreaScreen.tsx・CleanAreaScreen.module.css
  • components/features/dashboard/DashboardScreen.tsx
  • components/features/system/SystemScreen.tsx(★段階4-6c のコメントと段階5-2 の1か所だけ★)
  • components/ui/StatCard.tsx・components/ui/StatCard.module.css
  • components/theme/ThemeProvider.tsx
  • ★app/layout.tsx★(★段階3-4 のスクリプトと suppressHydrationWarning だけ★。範囲の例外 — 描く前に動かすにはルートの layout しか置き場が無い)
  • app/login/page.tsx
  • lib/i18n.ts(★段階2-9・段階4-5・段階5-8 だけ★)
  • ★新規★: lib/ui-prefs.ts・lib/__tests__/ui-prefs.test.ts・lib/ds-devices/note-drafts.ts・lib/ds-devices/__tests__/note-drafts.test.ts・lib/format/kpi-delta.ts・lib/format/__tests__/kpi-delta.test.ts・lib/auth/login-error-message.ts・lib/auth/__tests__/login-error-message.test.ts(★画面の判断をテストするための純粋関数。データ層ではない★)
  • docs/decisions.md(★末尾への D-183 の追記だけ。6-1 の条件つき★)・docs/code-map.md(6-3)・docs/spec.md(6-4 の2行)

触ってはいけない

  • ★docs/handoff/指示書BB_*(作らない・触らない)・docs/handoff/指示書AY_*・docs/handoff/設計_制御盤ログの取り出し_*・docs/handoff/TeamWorks確認事項_*・docs/handoff/回答待ち一覧_*・docs/handoff/pdf/*★(他のセッションの作業)
  • 指示書BA・本書(指示書BC)
  • ★app/api/**・lib/db/**・lib/types/**・d-watch-api.yaml★(API を変えない。G1。データ層の直しは指示書BB)
  • lib/judge/**・lib/reports/**・lib/clean-logs/**・lib/prechecks/**・lib/settings/**・lib/ds-devices/query.ts・lib/config/**・lib/theme.ts・lib/auth/ の既存ファイル
  • components/layout/**・components/ui/ の StatCard 以外・本書に挙げていない components/features/**(errors/・ds-alerts/・inspections/ 等)
  • app/globals.css(★色の変数を足さない・フォントのスタックを変えない★)
  • d-watch-mock.jsx・CLAUDE.md・docs/handoff.md・docs/fixtures.md
  • ★docs/decisions.md の D-182 以前の本文・「実装の記録」と、他のセッションの未コミットの追記★
  • ★data/settings/*.json をコミットすること(追跡外。画面確認の後は戻す)★
  • ★認証情報(パスワード・接続文字列・生成したトークン)を文書・コード・テスト・コミット・作業報告に書くこと★
  • ★git add -A / git add .・push★

10. 確認事項(監督・ユーザーへ。★実装は本書のとおりに進めてよい★)

# 内容 本書の判断 変えるなら
Q1 ★U1 の方式★(段階3-0) ★監督承認(2026-10-19)★: ★案B★(<head> のインラインスクリプト+useLayoutEffect)。残るちらつき: ハイドレーションより前に描かれたとき(遅い回線)だけ、JS で色・文字を決めている部分(札の色・t() の文字)が一瞬ダーク・日本語。背景・枠・文字色は最初から正しい —
Q2 U3 の通常モードの空の備考 ★監督承認★: ★何も出さない★(エラー一覧・アラート・D-Sec アラートと同じ)。旧は「-」 —
Q3 U3 で「ログ」ボタン(指示書AY)を編集モード中も押せるか ★監督承認★: ★押せる★(読むだけで一覧も下書きも変えない) —
Q4 レポートの全件表示で4本とも取り直す ★本書の推奨どおり(承知のうえ)★(resolveReportBlocks() が4本をまとめて見るため) 変わった本だけ取るなら、結果を本ごとに持ち、ブロックの決定表(lib/reports/block-visibility.ts)の入力の作り方を変える(別の指示書)
Q5 清掃面積の粒度タブの取り直し中、表の見出しを前の粒度のまま出す ★本書の推奨どおり★: 前の粒度のまま(表示中のデータに合わせる)・タブだけ新しい粒度 —
Q6 ★新しく足す EN(27個)は暫定★ ★監督了承★: 段階2-9・段階5-8 の表。モックに対訳が無い文言 ユーザーが直すなら lib/i18n.ts の1行ずつ
Q7 ★指示書BB の null(serial_number / model_name / last_reported_at)★ ★監督承認★: ★画面は「-」・t() に通さない★(「-」は記号で言語によらない。BB D6 の「API は null・画面が「-」・CSV は空」と同じ)。★施設マスタのロボット(RobotSummary)は BB で変わらない★ — nullable になっていたときだけ 2-8e(「-」を出し「未対応」の札を付けない) —
Q8 ログインの 422(トークンが空) ★本書の推奨どおり★: 「トークンを入力してください」(入力欄は required なので普段は出ない) —
Q9 KPI カードの前期比(#16) ★監督承認★: ★施設別の表の「変化」に揃える(丸めて0は「→」・色なし)★。KpiCard.tsx:41 の「▼0% だが色は改善」という旧来の意図は取り消す —
Q10 ★新規★ エリア別モーダルの「問題エリアのみ」が0件のときの文言「問題エリアはありません(全エリア◎○)」(AreaModal.tsx:123-125。★モック d-watch-mock.jsx:1949 の文言★) ★本書では変えない★(モックが正)。★BB A3 の後は、結果待ちのエリアだけが残っているとき「全エリア◎○」が事実と違う★(応答に結果待ちの件数が無く、画面では見分けられない)。§7-11 に記録 変えるなら「問題エリアはありません」だけにする(新しいキーと EN が要る)か、BB に結果待ちの件数を返してもらう(API の変更。別の指示書)

11. コミットの計画

順 中身 段階
1 古い応答を捨てる(5画面)・レポートの結果にクエリを持たせる(7ファイル) 1
2 アドバイスの失敗・CSV・アラートの期間・清掃面積の粒度・上限の注記・ロボット0台・t()・★BB の null と結果待ちのエリア★(16ファイル) 2
3 表示設定の保存(4ファイル) 3
4 D-Sec ログの編集モード(7ファイル) 4
5 文言・KPI・a11y・ログイン(約18ファイル。2つに分けてもよい) 5
6 資料(D-183・code-map・spec。「実装の記録」を後で埋めるなら2つ) 6・7
  • ★git add はファイルパスを明示する★。各コミットの前に git diff --cached --name-status と git status --short(他のセッションの行を含めていないか)を確かめる
  • メッセージには ★何を変えたか★ と ★なぜ(指示書BC・どの項目か)★ を書く
  • ★push しない★。★監督がコミットを見てから、監督の指示で develop に push する★。PR の作成とマージは daichi のレビューの後(D-154)

12. 期待される結果

  • 6画面で、取得中に条件を変えても ★最後に選んだ条件の結果だけが出る★(遅れて届いた古い応答で上書きされない)
  • レポートの「全件表示」と施設の絞り込みで画面全体が入れ替わらず、スクロールが飛ばない。清掃面積の粒度タブで数値カードが消えない
  • アドバイスの取得に失敗すると「アドバイスの取得に失敗しました」と出て、「エリア別詳細を見る →」はいつでも押せる。2行を素早く開いても読み込み中の表示が正しい
  • 清掃詳細ログの CSV は期間の誤り・読み込み中に押せない。アラートの期間は誤りを出し、空なら「指定なし(全期間)」
  • レポートの全件表示が上限で切れたら注記が出る。施設マスタのロボット0台は「-」
  • ★BB の null★(有効なロボットがいない清掃エリアのシリアル・機種、心拍の無い制御盤の最終通信)は ★「-」★。★エリア別モーダルの結果待ちの行は「結果待ち」★(完了率「-」・赤字にしない)
  • ★ダーク/ライトと JP/EN が、再読み込み・ログアウト・再ログインのあとも保たれる★(ログイン画面も)。ハイドレーションのエラーが出ない。ストレージが使えなくても既定で描ける
  • ★D-Sec ログの備考は他の3画面と同じ編集モード★(編集 / 保存(N件)/ キャンセル・変えた行の目印・絞り込みとページ送りの無効・案内・離れる前の確認・失敗した行の文言)。保存先は今までどおりファイル
  • レポートの文言が期間・言語に合う(「この期間の…」・L2 の閾値の日数・EN)。KPI の横ばいは「→0%」。システム管理の説明文に「{n}」が出ない
  • 押せる数値カードと開ける表の行がキーボードで操作でき、エリア別モーダルは Esc で閉じる。ラベルの無い入力欄が無い
  • ログイン画面がトークン違い・IP・回数・サーバー側の失敗を分けて出す(EN も)
  • D-183 があり、docs/code-map.md・docs/spec.md が食い違わない
  • tsc 0件・lint 0件・npm test 全件通過(BB 後の件数 +4 / +40 の見込み)・USE_FIXTURES=true npm run build 成功・API・データ層・型・判定・色の変数に差分なし

13. 完了条件

  • 段階0: ★指示書BB のコミットが develop に入っていることを確かめた(型の nullable・result_pending・DsLogScreen.tsx の1行)★・他のセッションの2行を確かめた・★BB 後のテスト件数を控えた(本書の基準)★・指示書AY の DsLog の状態を確かめた・D 番号を確かめた・古い応答の上書きを直す前に再現した(または再現できなかったと書いた)
  • 段階1: 5画面を let cancelled の形に、レポートを結果にクエリを持たせる形にした。古いコメント(fetchXxx を引くもの)を直した。直した後に再現が消えた
  • 段階2: 2-1〜2-9 を表のとおり直した。★BB §8 の B1〜B5(null →「-」・結果待ちのエリア)を出した★。grep -rn "serial_number\|model_name\|last_reported_at" components の全行を確かめた(3-7b)。2-8e は該当したか書いた
  • 段階3: lib/ui-prefs.ts と T/L/S の13件を足し、★2通りに壊して赤を確かめて戻した★。ThemeProvider は既定で始め useLayoutEffect で読む。app/layout.tsx にスクリプトと suppressHydrationWarning
  • 段階4: lib/ds-devices/note-drafts.ts と N/S の12件を足し、★2通りに壊して赤を確かめて戻した★。D-Sec ログを編集モードにした。4-8 の grep が0件。旧作りを引くコメントを直した
  • 段階5: 5-1〜5-8 を直した。K の8件・M の7件を足し、それぞれ壊して赤を確かめて戻した
  • 段階6: D-183 を追記した(6-1 の条件・番号を確かめた。または監督に渡した)。code-map・spec を直し、行番号を引き直した
  • 段階7: tsc 0件・lint 0件・npm test 全件通過(件数)・USE_FIXTURES=true npm run build 成功・画面の確認(アプリ内ブラウザ。または「監督に任せる」と書いた)・備考のファイルを戻した・最後の grep
  • D-183 の「実装の記録」を埋めた(コミット・テスト件数の前後・再現の結果・画面確認と出所・AY との関係・逸脱)
  • §7 の 7-1〜7-10 は直していない
  • ★push していない★
  • 作業報告に: コミット一覧(hash とメッセージ)/テスト件数(BC 前・BC 後)/壊して確かめた結果(段階3・4・5)/古い応答の再現(直す前・後)/画面確認の結果(出所: 当方の fixtures 由来)/段階7-6 の grep の結果/指示書AY・BB との関係/本書と違う判断をしたところ/★CLAUDE.md §3 のリリース前の一覧に「CSP を入れるならインラインスクリプトに nonce」を足すよう監督への依頼★