Newer
Older
client_dwatch / docs / handoff / 指示書AA_一覧にページ送りを付ける.md

指示書AA — 一覧6画面にページ送りを付ける(201件目以降が画面から見えない問題を直す)

項目 内容
実行環境 ★Cursor 内の Claude が実装します★(.claude/commands/d-watch.md の役割分担表を参照)
実行状態 ★★完了(2026-09-11。D-120)★★ — ★詳細は文末の「実行状態」表★
実行順 ★★指示書Y → 指示書W → 指示書Z の後に実行すること★★(詳細は下の「実行順」節)。★★特に指示書Z とは同時に走らせないこと★★(components/features/detail-log/ が真正面から衝突します)
対象 ★★一覧6画面が per_page=200(PAGINATION.maxPerPage)で全件を要求して全部並べているだけで、★ページ送りUIが1つも無い★。★★201件目以降は画面から一生見えません★★★。★本番想定は約360行/日(docs/spec.md:45)なので、★当日1日を「全件」で見ただけで切れます★★
★着手前確認★ ★★なし★★ — ★「ページ送りを作る」「2ページ目以降ができたらその数だけ表示選択できるようにする」「3ページ目を押したら有りません、みたいなのはだめです」はユーザー回答(2026-09-11)で確定済み★。★★段階0 から通しで実行してよい★★
取得する決定番号 ★★**着手直前に `grep -nE "^### D-(0\ 1)[0-9][0-9]" docs/decisions.md \ tail -3で最終番号を実測すること**★★。★★^### D-0だけでは3桁台(D-100 以降)を拾えない★★。★2026-09-07 にD-068` の二重採番が実際に起きています★。★★見込み番号はここに書きません — 指示書W と指示書Z がそれぞれ番号を取る見込みなので、必ず自分で実測してから採番すること★★
前提 D-050(段階ごとに検証してからコミット)/ D-053(グループ・会社の絞り込みは未実装)/ D-054(summary は対応状況フィルタの前に計算する。★本書の禁止事項8 の根拠★)/ D-092 項3-3(db.cleanLogs() の全件取得がボトルネックになりうる。★本書の「性能について」節★)/ CLAUDE.md §5(モックが画面の正)§6-2(期待値表→テスト→実装)§7(閾値のハードコード禁止 / git add -A 禁止)§9.1〜§9.6
段階数 ★8段階★(段階0〜段階7)。★コミットは段階1 / 2 / 3 / 4 / 5 / 7 の6つ★
DB接続 ★★しません★★。★本書は fixtures(USE_FIXTURES=true)だけで完結します★
サーバ変更 ★★ありません★★。★API 6本は page / per_page を受け取り meta: { total, page, per_page } を返す実装が既に入っています★

★★冒頭に読むべきこと — 何が起きているか★★

1. ★一覧6画面すべてが同じ作りになっている(★監督が実測済み。着手時に自分でも確認すること★)★

画面 ファイル 現在の件数表示 レスポンス型
清掃詳細ログ F-02 components/features/detail-log/DetailLogScreen.tsx ★有り★ {total}件(表示 N件) ListResponse<CleanLogItem>
アラート(事前チェック)F-03 components/features/alerts/AlertsScreen.tsx ★無し★ SummaryListResponse<PrecheckItem, PrecheckSummary>
D-Secログ F-06 components/features/ds-logs/DsLogScreen.tsx ★無し★ SummaryListResponse<DsDevice, DsDeviceSummary>
D-Secアラート F-07 components/features/ds-alerts/DsAlertsScreen.tsx ★無し★ SummaryListResponse<DsAlert, DsAlertSummary>
施設ロボットマスタ F-08 components/features/facilities/FacilitiesScreen.tsx ★有り★ 全{n}施設 ListResponse<Facility>
スケジュール管理 F-09 components/features/schedules/SchedulesScreen.tsx ★無し★ ListResponse<Schedule>

★★6画面とも params.set("per_page", String(PAGINATION.maxPerPage)); で 常に200件を要求し、返ってきた分を全部並べているだけ★★です。

★★着手時に自分で実測すること★★(★行番号は変わる前提。grep で取ること★):

grep -rn 'params.set("per_page"' components/features/
grep -rn 'body.meta' components/features/

2. ★★何が壊れているのか★★

  • ★★per_page の上限は 200(PAGINATION.maxPerPage)★★。★★201件目以降は画面に出す手段が存在しない★★
  • ★本番想定は 約360行/日(docs/spec.md:45「データ量: 約360行/日、年間約13万行、保持2年」)★ → ★★清掃詳細ログで当日1日を「全件」にしただけで切れます★★
  • ★日付範囲の上限は31日(MAX_DATE_RANGE_DAYS)★ → ★★指定できる範囲(31日 ≒ 11,000行)のほうが、表示できる範囲(200行)より広い★★。 ★ユーザーは「31日ぶん見られる」と思って操作しますが、実際には先頭200件しか見ていません★
  • ★★しかも切れたことが画面のどこにも出ません★★。★清掃詳細ログだけは {total}件(表示 N件) で気づけますが、 残り5画面は総件数すら出していないので★★黙って消えます★★

3. ★★モックには前例が無い(★UIを新規に作ることをユーザーが承認済み★)★★

  • ★d-watch-mock.jsx にページ送りのUIは1つもありません★
  • ★前例と言えるのはレポート画面(F-04)の「他{n}件を表示 →」「全{n}施設を表示 →」だけ★ (lib/i18n.ts に対訳あり。★あれは limit を増やす方式で、ページ送りではありません★)
  • ★★CLAUDE.md §5 は「モック(d-watch-mock.jsx)が画面レイアウト・文言・遷移の正」と定めています★★ → ★★本書はその例外です★★。★★ユーザーの明示の承認(2026-09-11「ページ送りは作成してください」)に基づき、 モックに無いUIを新規に作ります★★。★★この経緯を docs/decisions.md に必ず残すこと(段階7)★★

4. ★★ユーザーが確定させた要件(★2026-09-11。原文★)★★

★ページ送りは作成してください★ ★2ページ目以降ができたらその数だけ表示選択できるようにしてください★ ★3ページ目を押したら有りません、みたいなのはだめです★

★★= 実際に存在するページだけを選択肢に出す。存在しないページ番号を1つも描画しない。★★ ★★これが本書の合否基準です★★(段階1 の不変条件テストで機械的に固定します)。


★★採る方針(★理由ごと理解してから実装すること★)★★

# 決めたこと 理由
★1★ ★★ページ番号方式(1 2 3 … の番号ボタン)★★ ★ユーザー要件「2ページ目以降ができたらその数だけ表示選択できるように」=何ページあるか見えて、直接飛べること★。★「もっと見る」方式ではページ数が見えないので要件を満たさない★
★2★ ★★共通部品を1つ作り、6画面すべてに入れる★★ ★★API側が6本とも page / per_page / meta に対応済み★★。★2画面だけに入れると作りが不揃いになり、次に触る人がどちらを真似ればいいか分からなくなる★
★3★ ★★1ページあたりの件数は PAGINATION.defaultPerPage(現在 50)から読む★★ CLAUDE.md §7(★閾値のハードコード禁止★)。★★50 を画面に直書きしないこと★★
★4★ ★★総ページ数 = Math.ceil(meta.total / per_page)★★。★総ページ数が1以下ならボタンを出さない★ ★1ページしかないのにボタンを出すと、押せないUIが並ぶだけ★
★5★ ★★ページ数が多いときは先頭・末尾・現在の前後を出し、間を「…」で省略する★★ ★223ページ分のボタンは並べられない(年間13万行 ÷ 50 = 2,600ページ)★
★6★ ★★「…」は button にしない★★。★★描画する数字は必ず実在するページ★★ ★★ユーザー要件「3ページ目を押したら有りません、みたいなのはだめです」★★
★7★ ★★件数表示を6画面すべてに出す。形は「全{total}件中 {from}〜{to}件」に統一★★ ★現状バラバラ(清掃詳細ログ {total}件(表示 N件) / 施設 全{n}施設 / 残り4画面は無し)★。★モックの「{n}件 / 全{n}件」とも食い違っている★ → ★★1つに揃える★★
★8★ ★★meta.total を使う。summary.total を絶対に使わない★★ ★アラート / D-Secログ / D-Secアラートの3本は summary をフィルタ前に計算している(D-054)★。★★app/api/prechecks/route.ts のコメントが「meta.total と summary.total は意味が違う」と明記しています★★
★9★ ★★CSV は全件のまま。画面に注記を出す★★ ★app/api/clean-logs/export/route.ts の冒頭コメント「page / per_page は無視する」が契約★。★★変えないこと★★
★10★ ★★ページ番号を URL に持たせない(React の state だけで持つ)★★ ★既存6画面はフィルタも検索もURLに持っていない(全部 useState)★。★URL同期を入れると useSearchParams の Suspense 境界が要り、6画面の構造を変えることになる★ → ★★本書の範囲を超える。申し送り①に回す★★

★★却下した案(★同じ案を再提案しないために、理由ごと残します★)★★

案 却下理由
★PAGINATION.maxPerPage を 200 から 10000 に上げるだけ★ ★★問題を先送りするだけ★★。★年間13万行では結局切れるし、1万行を1画面に描くとブラウザが固まる★
★無限スクロール★ ★★ユーザー要件「その数だけ表示選択できるように」を満たさない★★(何ページあるか見えない・特定のページに戻れない)。★翌朝確認型の運用では「昨日見た位置に戻る」が要る★
★「もっと見る」で追記していく方式(レポート画面と同じ)★ ★同上。★★総ページ数が見えない★★★。★レポート画面のあれは「上位10件+全部」の2状態であって、ページ送りではない★
★サーバ側にカーソルページングを足す★ ★★d-watch-api.yaml の変更が要る★★。★★page / per_page / meta で既に足りている★★ので、契約を変える理由が無い
★ページ送りと同時に SQL 絞り込みも入れる★ ★★下の「性能について」節を読むこと。同時にやると壊れます★★

★★性能について — 必ず読むこと★★

★★ページ送りを付けても、DB からは毎リクエスト全件読みます★★。

  • ★DataSource は引数を取らない契約になっており、prismaSource.cleanLogs() は全期間・全件の findMany()★
  • ★コード自身が警告を書いています★(★着手時に実測すること★):

    grep -rn "13万" lib/db/
    # → lib/db/index.ts / lib/db/clean-log-mapping.ts の2箇所に
    #   「年間13万行(docs/spec.md:45)の実データではここがボトルネックになりうる」
  • ★絞り込み・並べ替え・ページ切り出しはすべてAPI ルートの中で JavaScript の配列操作として走っています★ (filterCleanLogs() → sorted.slice(start, start + perPage))

★★つまり本書で解決するのは「見えない行が見えるようになる」ことだけで、★性能は1ミリも改善しません★★★。 ★★むしろページを繰るたびに全件取得が走るので、体感は悪化する方向です★★。

★★これは別課題(SQL に絞り込みを降ろす改修)として切り離します★★。

★★ページ送りと性能改善を同時にやらないこと★★ — ★連続日数の計算(consecutive_problem_days)が全期間を走査して数えているため、 「期間で絞って取る」改修を入れると★★連続日数が同時に壊れます★★★。 ★2つが同じコミットに入ると、どちらが壊したのか切り分けられなくなります★。 ★本書は lib/db/ に1行も触りません★(禁止事項5)。


★調査済みの事実(★現物で裏を取ってから使うこと。行番号は必ず実測すること★)★

1. ★設定値★

// lib/config/index.ts(★着手時に grep -n "PAGINATION" lib/config/index.ts で位置を取ること★)
export const PAGINATION = {
  defaultPerPage: 50,
  maxPerPage: 200,
} as const;

★★本書で maxVisiblePages を1つ足します(段階1)★★。★それ以外は変えません★。

2. ★API 6本はすべて対応済み★

API ページ切り出しの場所 meta
/api/clean-logs filtered.slice(start, start + perPage) 有り
/api/prechecks sorted.slice(...) 有り(★summary と併記。コメントで違いを明記済み★)
/api/ds-devices sorted.slice(...) 有り(同上)
/api/ds-alerts sorted.slice(...) 有り(同上)
/api/facilities paginateFacilities()(lib/facilities/query.ts) 有り
/api/schedules sorted.slice(...) 有り

★★6本とも parse*Query() が★page = Math.max(1, toInt(searchParams.get("page"), 1))★ ★perPage = Math.min(PAGINATION.maxPerPage, Math.max(1, toInt(..., PAGINATION.defaultPerPage)))★ という同一の形で実装済みです★★。★★サーバは1行も変えません★★。

★確認コマンド★:

grep -rn 'searchParams.get("page")' lib/
grep -rn "meta: { total, page, per_page: perPage }" app/api/

3. ★★meta.total と summary.total は意味が違う(★踏むと総ページ数がずれます★)★★

app/api/prechecks/route.ts の既存コメント(★原文。着手時に実測すること★):

★meta.total = 一覧(対応状況フィルタ後)の総件数。ページング計算用★ ★summary.total = 日付範囲で絞った当日の全件(対応状況フィルタの影響を受けない)★ ★status=all のときだけ一致する。status=open 等で絞ると meta.total < summary.total になりうる★

★★/api/ds-devices / /api/ds-alerts にも同趣旨のコメントがあります★★(D-054)。

★★アラート / D-Secログ / D-Secアラートの3画面は、現在 body.summary しか読んでおらず body.meta を捨てています★★ → ★★段階4 で meta.total を持つ state を新設すること★★。★summary.total を流用しないこと★。

4. ★i18n の作法(★方式A: プレースホルダ + replace★)★

lib/i18n.ts に既存の解説コメントがあります(★原文。着手時に実測すること★):

★方式A(プレースホルダ + replace)★推奨★★ ★t("他{n}件を表示 →").replace("{n}", String(n))★ ★語順が言語ごとに違っても対訳側で吸収できるため、こちらを推す★

★★本書も方式A を使います★★。★★方式B(前後を分割して連結)は使わないこと★★。

★既存の前例★: "他{n}件を表示 →" / "全{n}施設を表示 →" / "全{n}施設" / "{n}分前" / "{n}日以上連続" / "第{n}回チェック"

★★注意: 本書のキーはプレースホルダが3つ({total} / {from} / {to})で、既存の {n} 1つより多い★★。 ★.replace() を3回つなげること★。★★replaceAll は不要(各プレースホルダは1回しか出ない)★★。

5. ★★テストの実行環境(★ここを間違えるとテストが1つも走りません★)★★

// vitest.config.ts
test: {
  environment: "node",
  include: ["lib/**/*.test.ts", "app/**/*.test.ts"],
}

★★components/** は include に入っておらず、environment は node(jsdom ではない)で、 React Testing Library も入っていません★★ →

  • ★★ページ番号の並びを組み立てるロジックは lib/pagination/ の純粋関数に置くこと★★。 ★そこならテストが走ります★
  • ★★コンポーネントのテストを書こうとして jsdom / @testing-library を package.json に足さないこと★★(禁止事項10)。 ★依存追加は本書の範囲外です★

6. ★空表示は6画面とも既にある(★0件のときページ送りを出さない根拠★)★

画面 空表示
清掃詳細ログ CleanLogTable の中に「該当するログはありません」
アラート 「該当するアラートはありません」
D-Secログ 「該当するデバイスはありません」
D-Secアラート 「該当するアラートはありません」
施設 「該当する施設はありません」
スケジュール 「該当するスケジュールはありません」

★★だから total === 0 のときは部品ごと出さない(件数表示もページ送りも出さない)★★。 ★「全0件中 0〜0件」と空表示が二重に出るのを避けます★。

7. ★フィルタを変えたらページを1に戻す(★前例あり★)★

components/features/reports/ReportsScreen.tsx は★期間変更・検索実行のたびに setPerfShowAll(false) を呼んでいます★ (★3箇所。着手時に grep -n "setPerfShowAll(false)" components/features/reports/ReportsScreen.tsx で実測すること★)。

★★同じ発想です★★ — ★★フィルタを変えたのにページ3に留まっていると、件数が減ったときに 存在しないページに居座って空表示になります★★。★これはユーザーが名指しで禁じた状態です★。

★★清掃詳細ログ / D-Secログ / 施設 / スケジュールの検索欄は debounce(300ms)があります★★ → ★★debounce の反映時にもページを戻すこと★★(段階3 の 3-4 に具体的な書き方を示します)。

8. ★落とし穴(★踏むとテストが通ったまま壊れます★)★

# 落とし穴
★1★ ★★Math.ceil(0 / 50) は 0、Math.ceil(total / 0) は Infinity★★。★★perPage が0以下のときは総ページ数を0にすること★★(防御。純粋関数のテストに入れる)
★2★ ★★total === 0 のとき from を 1 にしないこと★★。★(1-1)*50+1 = 1 になるので、素直に書くと「全0件中 1〜0件」が出ます★
★3★ ★★to は Math.min(page * perPage, total)★★。★items.length を使わないこと★(★ローディング中や失敗時に items が空になり、件数だけ0に見える★)
★4★ ★★「…」が1ページだけを隠す形にしないこと★★(1 … 3 4 5 の「…」が 2 だけを隠している状態)。★数字1つぶんの幅に「…」を出すのは、押せる番号が減るだけで損★。★段階1 の不変条件テストで機械的に禁じます★
★5★ ★★フィルタ変更時に setPage(1) を useEffect で後追いすると、古いページのままフェッチが1回余計に飛びます★★。★★state を変えるその場で一緒に戻すこと★★(3-4 参照)
★6★ ★★page を queryString の useMemo の依存に入れ忘れると、ボタンを押しても取り直しません★★(★useCallback の依存は queryString(★DetailLogScreen.tsx だけは [queryString, rangeDays] の2本。rangeDays は日付範囲の検証に要るので消さないこと★)なので、queryString が変わらないと fetch* も変わらない★)
★7★ ★★清掃詳細ログの CSV リンクは queryString をそのまま href に入れています★★。★page が混ざりますが、export は page / per_page を無視する契約なので動作は変わりません★。★★リンクを分岐させないこと(余計な差分になります)★★
★8★ ★★d-watch-api.yaml を見て「page が無い」と思わないこと★★ — ★着手時に `grep -n "per_page\ page" d-watch-api.yaml \ head -30` で実測すること★。★★もし本当に無ければ、それは別の不整合なので、本書を止めて確認を求めること(推測で YAML を書き換えないこと)★★
★9★ ★★components/ui/ の既存部品(Pill / FilterButton / GradeBadge / StatCard / ProgressBar)はどれも useTheme() を使っていません★★。★色は CSS 変数(var(--ac) / var(--bd) / var(--tx-m))か props の color★。★★本書の部品は文言のために useTheme() を使います(理由は 2-1 に明記)★★

★禁止事項(この指示書の全段階に効きます)★

# ★してはいけないこと★ 理由
★1★ ★★git add -A を使うこと★★ CLAUDE.md §7。★過去に別作業の途中ファイルを巻き込む事故が起きている★。★★必ずファイルパスを明示すること★★
★2★ ★★README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / 他の指示書 / `.claude/worktrees/` に触ること**★★ ★★すべて別作業のもの★★。★★読むのは自由。編集・削除・コミットへの同梱は禁止★★
★3★ ★★件数・ページ数を直書きすること(50 / 200 / 7 / Math.ceil(total / 50) 等)★★ CLAUDE.md §7。★★lib/config/index.ts の PAGINATION から読むこと★★
★4★ ★★summary.total を総ページ数の計算に使うこと★★ D-054。★summary はフィルタ前の集計で、一覧の件数とは別物★。★★総ページ数がずれます★★
★5★ ★★存在しないページ番号を描画すること★★ ★★ユーザーが名指しで禁じた状態★★。★段階1 の不変条件テストで固定します★
★6★ ★★lib/fixtures/ を1行でも変更すること★★ CLAUDE.md §9.5。★★「2ページ目が出ないから件数を増やす」は禁止★★(検証方法は段階6 に書いてあります)
★7★ ★★d-watch-api.yaml / `app/api//lib//query.ts/lib/db/*/lib/types/index.ts` を変更すること★★ ★★変える必要がありません★★ — ★API は6本とも対応済み★。★★変えたくなったら、それは本書の設計が違うサインなので、止めて確認を求めること★★
★8★ ★★レポート画面(`components/features/reports/)とダッシュボード(components/features/dashboard/`)に手を入れること★★ ★レポートは limit 方式で別の作り(申し送り②)★。★ダッシュボードは per_page=5 の意図的な上位5件★
★9★ ★★点検記録(F-05)を対象に含めること★★ ★画面もAPIも未実装。★★後回しと決定済み★★★
★10★ ★★package.json に依存を足すこと(jsdom / @testing-library / ページャライブラリ等)★★ ★★純粋関数+CSS Module で足ります★★。★依存追加は本書の範囲外★
★11★ ★★docs/decisions.md / docs/handoff.md を段階7 より前に編集すること★★ ★★別担当(指示書W / 指示書Z)が番号を取る可能性があります★★。★段階7 でも「自分の追記だけを足す」こと★
★12★ ★★PAGINATION.defaultPerPage を 50 以外にしたままコミットすること★★ ★段階6 の検証で一時的に小さくすることを許可しますが、★★戻し忘れると本番の1ページ件数が変わります★★★

作業の順序(★崩さないこと★)

★段階0(前提の確認と実測。★読むだけ・コミットなし★) → 段階1(★lib/pagination/ の純粋関数。★★期待値表 → テスト → 実装★★) → 段階2(★components/ui/Pagination.tsx と i18n。★画面にはまだ入れない★) → 段階3(★清掃詳細ログ F-02 に入れて作法を確定させる★) → 段階4(★summary 系3画面 F-03 / F-06 / F-07★) → 段階5(★ListResponse 系2画面 F-08 / F-09★) → 段階6(★6画面の実測検証。★コミットなし★) → 段階7(記録と push)★

★★段階1・2・3・4・5・7 でそれぞれ1コミット(計6コミット)★★。 ★★段階ごとに npx tsc --noEmit / npm run lint / npm test を実行し、通ってからコミットすること★★(D-050)。

★★期待値表 → テスト → 実装 の順序を崩さないこと★★(CLAUDE.md §6-2 / §9.4)。 ★★段階1 は「期待値表を書く → テストを書く → 落ちることを確認する → 実装する」★★。

★★なぜこの順番なのか★★

  • ★段階1 を独立させるのは、★★ページ番号の並びは純粋関数でテストが完結する★★から。 ★ここだけで止めても何も壊れない★
  • ★段階2 を独立させるのは、★★部品を作った時点では画面の見た目が1ピクセルも変わらない★★から。 ★レビューで「部品の設計」と「画面への適用」を分けて見られる★
  • ★段階3 を1画面だけにするのは、★★最初の1画面で作法(state の持ち方・ページを1に戻す場所・置き場所)を確定させる★★ため。 ★★6画面を一度に書くと、間違った作法が6箇所に増えます★★
  • ★段階4 と段階5 を分けるのは、★★summary 系3画面は meta を捨てているので新しい state が要る★★から (★ListResponse 系2画面とは作業が違う★)

★★段階の並びは実装者が判断して入れ替えてよい★★(★上は推奨順です★)。 ★★ただし動かせない制約が2つあります★★ — ★★① 段階1(純粋関数とテスト)を段階2 より先にやること★★ ★★② 段階3(1画面目)を段階4・5(横展開)より先にやること★★


★★実行順 — 指示書Y・W・Z の後★★

# 指示書 順序 ★衝突するファイル★
★1★ docs/handoff/指示書Y_DATETIMEを日本時間として読む.md ★本書より先★ lib/db/ / lib/format/ / scripts/ — ★★本書とは重ならない★★
★2★ docs/handoff/指示書W_連続問題施設を実DBから作る.md ★本書より先★ ★★lib/i18n.ts★★ / d-watch-api.yaml / lib/types/index.ts / components/features/reports/ReportsScreen.tsx — ★★lib/i18n.ts が真正面から衝突します★★
★3★ docs/handoff/指示書Z_エリア別完了率を実装する.md ★本書より先★ ★★components/features/detail-log/★★ — ★★本書の段階3 と同じディレクトリ★★
★4★ ★★本書(指示書AA)★★ ★★最後★★ —

★★着手時にやること★★:

# 3本の実行状態を実測する(本書作成時点の記録: Y=完了 / W=未着手 / Z=起草中)
grep -n "実行状態" -A 3 docs/handoff/指示書Y_*.md | head -20
grep -n "実行状態" -A 3 docs/handoff/指示書W_*.md | head -20
ls docs/handoff/ | grep "指示書Z"
git status --short   # ★他の担当の未コミット差分が無いことを確認する★

★★3本のいずれかが未完了なら、本書に着手しないこと★★。 ★★特に指示書Z と同時に走らせると components/features/detail-log/ で確実に衝突します★★。

★★指示書Y / W / Z は ★読むだけ。編集しないこと★★(禁止事項2)。


段階0 — 前提の確認と実測(★読むだけ・コミットなし★)

0-1. 読むもの

# 対象 何を見るか
1 CLAUDE.md §5 / §6 / §7 / §9.1〜§9.6 ★モックが正であること(★本書はその例外★)/期待値表→テスト→実装/ハードコード禁止/ディレクトリ構成★
2 lib/config/index.ts の PAGINATION ★defaultPerPage / maxPerPage の現在値を実測すること★
3 vitest.config.ts ★★include が lib/** と app/** だけであることを自分の目で確認すること★★
4 lib/i18n.ts の「数値が文中に入るラベル」節 ★方式A の作法★
5 components/ui/ の5部品と .module.css ★★命名・props の形・CSS 変数の使い方を真似ること★★
6 app/api/prechecks/route.ts の meta / summary コメント ★禁止事項4 の根拠★
7 app/api/clean-logs/export/route.ts の冒頭コメント ★「page / per_page は無視する」★
8 6画面のファイル ★★state の持ち方・debounce・フィルタのUIを1つずつ数え上げること(0-2)★★

0-2. ★★各画面の「ページを1に戻すべき操作」を数え上げる(★必須。ここを漏らすと落とし穴5 を踏みます★)★★

★★着手時に自分で数えること★★(★下は監督の実測。★★変わっている前提で確認すること★★★):

画面 ページを1に戻すべき操作
清掃詳細ログ F-02 dateFrom / dateTo の変更 / 判定フィルタ3ボタン(problem / all / critical)/ ★★facilityName・model・serial のdebounce 反映時★★
アラート F-03 dateFrom / dateTo / 対応状況3ボタン(open / all / resolved)
D-Secログ F-06 ★★q のdebounce 反映時★★ / ステータスフィルタ
D-Secアラート F-07 種別フィルタ / 対応状況フィルタ
施設 F-08 ★★q のdebounce 反映時★★
スケジュール F-09 ★★q のdebounce 反映時★★

★★ソート操作のUIは現状どの画面にもありません★★(★並べ替えはサーバ固定★)。 ★★将来ソートUIが付いたら、そこでも setPage(1) すること★★ — ★段階7 の申し送りに書くこと★。

0-3. ★★現状の件数を控える(★あとで「合っている」ことを示す証拠になります★)★★

# ★fixtures の件数を実測する(★数字を推測しないこと★)★
npx tsx -e "import('./lib/fixtures/index.ts').then(m=>console.log(Object.entries(m).map(([k,v])=>[k,Array.isArray(v)?v.length:typeof v])))"

★上が動かない場合は、各画面を開いて総件数を目で控えること★。 ★★控えるもの★★: 6画面それぞれの「フィルタ既定状態での総件数」。 ★★段階6 で「全{total}件中 …」の total がこの数と一致すること★★を確認します。

この段階の完了条件

  • ★実行順の3本(Y / W / Z)が完了していることを確認した★
  • ★git status --short がクリーン★
  • ★0-2 の「ページを1に戻すべき操作」を自分で数え上げ、手元に控えた★
  • ★0-3 の件数を控えた★
  • ★★vitest.config.ts の include が lib/** と app/** だけであることを確認した★★
  • ★コミット無し★

段階1 — ★ページ番号の並びを組み立てる純粋関数★(1コミット)

1-1. ★★置き場所★★

ファイル 内容
lib/pagination/page-items.ts ★★純粋関数3本と型★★
lib/pagination/__tests__/page-items.test.ts ★★期待値表そのままのテスト★★
lib/config/index.ts ★PAGINATION に maxVisiblePages を1つ足すだけ★

★★lib/types/index.ts に型を置かないこと★★ — ★あそこは d-watch-api.yaml のスキーマ名に揃える場所(CLAUDE.md §9.2)で、 PageItem は API 契約の型ではありません★。

★★import は @/lib/pagination/page-items と直接書くこと★★(★lib/clean-logs/query.ts 等と同じ作法。barrel は作らない★)。

1-2. ★設定値の追加★

// lib/config/index.ts の PAGINATION に足す
export const PAGINATION = {
  defaultPerPage: 50,
  maxPerPage: 200,
  /**
   * ★ページ番号ボタンを省略せずに全部並べる上限★
   *
   * これを超えたら先頭・末尾・現在の前後だけを出し、間を「…」で省略する。
   * ★★奇数かつ 7 以上であること★★(`buildPageItems()` が左右対称に切り出すため。
   * テストで固定している)。
   *
   * ★なぜ設定値にするのか★: `CLAUDE.md` §7(閾値のハードコード禁止)。
   *   画面幅の都合で並べる数を変えたくなったとき、直す場所をここ1箇所にする。
   *
   * ★消費者★: `lib/pagination/page-items.ts` の `buildPageItems()` のみ。
   */
  maxVisiblePages: 7,
} as const;

1-3. ★★期待値表(★これをそのままテストにすること。実装より先に書く★)★★

(A) totalPageCount(total: number, perPage: number): number

# total perPage ★期待★ 意図
A-1 0 50 ★0★ ★★0件は0ページ(1ページではない)★★
A-2 1 50 1 最小
A-3 50 50 ★1★ ★★ちょうど per_page の倍数★★
A-4 51 50 ★2★ ★★端数あり(+1件で2ページ)★★
A-5 100 50 ★2★ ★★ちょうど倍数★★
A-6 101 50 ★3★ ★端数あり★
A-7 33 50 1 ★fixtures 相当(ローカルでは1ページ)★
A-8 360 50 ★8★ ★★本番の1日ぶん(docs/spec.md:45)★★
A-9 11150 50 ★223★ ★大量ページ★
A-10 10 5 2 perPage が変わっても効く
A-11 10 0 ★0★ ★★防御。Infinity を返さないこと(落とし穴1)★★
A-12 10 -1 ★0★ ★防御★
A-13 -1 50 ★0★ ★防御★

(B) pageRange(total, page, perPage): { from: number; to: number }

# total page perPage ★期待★ 意図
B-1 0 1 50 ★{from:0, to:0}★ ★★0件で from:1 にしないこと(落とし穴2)★★
B-2 1 1 50 {1, 1} 最小
B-3 50 1 50 {1, 50} ちょうど1ページ
B-4 51 1 50 {1, 50} 1ページ目
B-5 51 2 50 ★{51, 51}★ ★★端数の最終ページ★★
B-6 100 2 50 {51, 100} ちょうど倍数の最終ページ
B-7 360 8 50 ★{351, 360}★ 本番相当
B-8 51 ★3★ 50 ★{51, 51}★ ★★存在しないページを渡されたら最終ページに丸める★★
B-9 51 ★0★ 50 ★{1, 50}★ ★0以下は1ページ目に丸める★

(C) buildPageItems(page: number, totalPages: number): PageItem[]

★★表記★★: 数字は { kind: "page", page: n }、… は { kind: "ellipsis" }。 ★★現行 maxVisiblePages = 7 での期待値です★★。

# page totalPages ★期待★ 意図
C-1 1 ★0★ ★[]★ ★★0件★★
C-2 1 1 [1] ★1ページ(★部品側では出さない。1-5 参照★)★
C-3 1 2 [1, 2] ★★2ページ(ユーザー要件の最小ケース)★★
C-4 2 2 [1, 2] 現在ページが末尾
C-5 1 7 [1,2,3,4,5,6,7] ★★省略しない上限ちょうど★★
C-6 4 7 [1,2,3,4,5,6,7] 現在ページが中央
C-7 1 8 ★[1,2,3,4,5, …, 8]★ ★★省略が始まる最初のケース★★
C-8 4 8 [1,2,3,4,5, …, 8] 先頭寄りの境界
C-9 ★5★ 8 ★[1, …, 4,5,6,7,8]★ ★★末尾寄りに切り替わる境界★★
C-10 8 8 [1, …, 4,5,6,7,8] 現在ページが末尾
C-11 1 9 [1,2,3,4,5, …, 9] —
C-12 ★5★ 9 ★[1, …, 4,5,6, …, 9]★ ★★中央(「…」が2つ出る最小ケース)★★
C-13 9 9 [1, …, 5,6,7,8,9] —
C-14 1 ★223★ ★[1,2,3,4,5, …, 223]★ ★★大量ページ・先頭★★
C-15 4 223 [1,2,3,4,5, …, 223] 先頭寄りの境界
C-16 5 223 ★[1, …, 4,5,6, …, 223]★ 中央に切り替わる境界
C-17 ★112★ 223 ★[1, …, 111,112,113, …, 223]★ ★★大量ページ・中央★★
C-18 219 223 [1, …, 218,219,220, …, 223] 末尾寄りに切り替わる直前
C-19 ★220★ 223 ★[1, …, 219,220,221,222,223]★ ★末尾寄りの境界★
C-20 ★223★ 223 ★[1, …, 219,220,221,222,223]★ ★★大量ページ・末尾★★
C-21 ★0★ 5 [1,2,3,4,5](page=1 扱い) ★範囲外を丸める★
C-22 ★99★ 5 [1,2,3,4,5](page=5 扱い) ★範囲外を丸める★

(D) ★★不変条件テスト(★これがユーザー要件の合否そのものです★)★★

★★for (totalPages = 1; totalPages <= 300; totalPages++) × for (page = 1; page <= totalPages; page++) を 総当たりして、以下をすべて満たすこと★★:

# 不変条件
★D-1★ ★★kind === "page" の値がすべて 1 <= n <= totalPages に入る★★ ← ★★「3ページ目を押したら有りません」の禁止★★
D-2 ★必ず 1 を含む★
D-3 ★必ず totalPages を含む★
D-4 ★必ず page(現在ページ)自身を含む★
D-5 ★数字は狭義単調増加(重複なし・逆転なし)★
★D-6★ ★★「…」が飛ばすページ数は必ず2以上★★(★1ページだけを「…」で隠さない。落とし穴4★)
D-7 ★「…」が連続しない★
D-8 ★先頭と末尾は必ず数字(「…」で始まらない・終わらない)★
D-9 ★要素数は totalPages <= maxVisiblePages なら totalPages、それ以外は常に maxVisiblePages★(★幅がガタつかない★)
D-10 ★PAGINATION.maxVisiblePages が奇数かつ7以上★(★設定値そのものへのアサーション★)

1-4. ★実装(★アルゴリズムの定義★)★

W    = PAGINATION.maxVisiblePages   // 7
half = (W + 1) / 2                  // 4

buildPageItems(page, totalPages):
  if totalPages <= 0: return []
  cur = clamp(page, 1, totalPages)

  if totalPages <= W:
      return [1 .. totalPages]

  if cur <= half:
      // 先頭寄り: 1..(W-2), …, totalPages
      return [1 .. W-2] + [ELLIPSIS] + [totalPages]

  if cur >= totalPages - (half - 1):
      // 末尾寄り: 1, …, (totalPages-(W-3))..totalPages
      return [1] + [ELLIPSIS] + [totalPages-(W-3) .. totalPages]

  // 中央: 1, …, cur-1, cur, cur+1, …, totalPages
  side = (W - 5) / 2                // 1
  return [1] + [ELLIPSIS] + [cur-side .. cur+side] + [ELLIPSIS] + [totalPages]

★★7 / 4 / 5 / 2 を関数の中に直書きしないこと★★ — ★★すべて W から導くこと★★(禁止事項3)。

型:

export type PageItem =
  | { kind: "page"; page: number }
  | { kind: "ellipsis"; key: string };   // key は React の key 用("gap-left" / "gap-right" 等)

★★kind: "ellipsis" に page を持たせないこと★★ — ★持たせると「…を押したら何ページに飛ぶ?」という 存在しない問いが生まれます★。★★型で押せなくしておくのが一番確実です★★。

この段階の完了条件

  • ★★期待値表(A 13件 + B 9件 + C 22件 + D 10条件)を先に書き、テストにした★★
  • ★★テストを実装前に走らせて落ちることを確認した★★
  • ★実装後、npm test が全パス★
  • ★lib/pagination/page-items.ts に 7 / 50 / 200 の直書きが無い★
  • ★npx tsc --noEmit = 0 / npm run lint = 0★
  • ★コミット(★ファイルパスを明示すること★):
    `git add lib/config/index.ts lib/pagination/page-items.ts lib/pagination/__tests__/page-items.test.ts`★

段階2 — ★共通部品 Pagination と i18n★(1コミット)

★★この段階では画面の見た目は1ピクセルも変わりません★★(★部品を作るだけ★)。

2-1. ★部品の設計★

ファイル 内容
components/ui/Pagination.tsx 部品本体
components/ui/Pagination.module.css ★CSS Module(★既存5部品と同じ作法★)★
"use client";

interface PaginationProps {
  /** ★★`meta.total` を渡すこと。`summary.total` を渡さないこと★★(D-054 / 禁止事項4) */
  total: number;
  /** 現在ページ(1始まり) */
  page: number;
  /** ★1ページあたりの件数。`PAGINATION.defaultPerPage` を渡す。直書き禁止★ */
  perPage: number;
  onPageChange: (page: number) => void;
}

export function Pagination({ total, page, perPage, onPageChange }: PaginationProps) { … }

★★描画の規則★★:

条件 描画
total === 0 ★★null(何も出さない)★★ — ★各画面の空表示に任せる(調査6)★
★総ページ数が1★ ★★件数表示だけ出す。ページ送りのボタンは出さない★★
★総ページ数が2以上★ ★件数表示 + 「前へ」+ 番号 + 「…」+「次へ」★

★★細部★★:

  • ★件数表示は左、ボタンは右★(display:flex; justify-content:space-between; align-items:center;)
  • ★★「…」は <span aria-hidden="true"> で出す。<button> にしないこと★★(禁止事項5)
  • ★現在ページのボタンは disabled かつ aria-current="page"★(★押しても何も起きない・スクリーンリーダーに現在地が伝わる★)
  • ★「前へ」は1ページ目で disabled、「次へ」は最終ページで disabled★
  • ★★onPageChange に渡す値は必ず 1 <= n <= totalPages★★(★部品の中で clamp すること★)
  • ★色は CSS 変数(var(--ac) / var(--bd) / var(--tx-m) / var(--tx-d))★。 ★★THEME_TOKENS を import しないこと★★(★components/ui/ の既存部品はどれも使っていない。落とし穴9★)
  • ★★useTheme() は t の取得のためだけに使う★★。 ★★理由: 件数表示の文言を6画面に散らすと .replace() の連鎖が6箇所に増える★★。 ★★components/ui/ で useTheme() を使う最初の部品になるので、その旨を docstring に1行書くこと★★
  • ★"use client" を付けること★(★useTheme() を使うため★)

2-2. ★i18n に足すキー(★方式A★)★

// lib/i18n.ts の EN に足す
// ---- 一覧のページ送り(指示書AA)----
// ★プレースホルダが3つある(既存の {n} 1つより多い)。replace を3回つなげる★
//   t("全{total}件中 {from}〜{to}件")
//     .replace("{total}", String(total))
//     .replace("{from}", String(from))
//     .replace("{to}", String(to))
"全{total}件中 {from}〜{to}件": "{from}–{to} of {total}",
前へ: "Prev",
次へ: "Next",
// ★CSVは表示中のページではなく全件(app/api/clean-logs/export/route.ts が page/per_page を無視する契約)★
"CSVは絞り込み条件に一致する全件を出力します(表示中のページだけではありません)":
  "CSV exports all rows matching the filters, not just the current page",

★★前へ / 次へ が既存キーと衝突しないことを grep -n "前へ\|次へ" lib/i18n.ts で確認すること★★ (★監督の実測では0件★)。

2-3. ★★やってはいけないこと★★

  • ★★件数表示の文言を各画面で組み立てること★★(★部品の中1箇所に閉じ込める★)
  • ★★page / perPage を部品の内部 state にすること★★(★★親が持つ。部品は表示と通知だけ★★)
  • ★★部品の中で fetch すること★★(★★純粋な表示部品にすること★★)

この段階の完了条件

  • ★components/ui/Pagination.tsx / .module.css を作った★
  • ★total === 0 で null を返す★
  • ★総ページ数1でボタンが出ない★
  • ★「…」が <button> になっていない★
  • ★lib/i18n.ts に4キーを足した(★既存キーを1つも消していない★)★
  • ★部品に 50 / 200 / 7 の直書きが無い★
  • ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス★
  • ★コミット: git add components/ui/Pagination.tsx components/ui/Pagination.module.css lib/i18n.ts★

段階3 — ★清掃詳細ログ F-02 に入れる(★ここで作法を確定させる★)★(1コミット)

★★対象: components/features/detail-log/DetailLogScreen.tsx★★ (★★指示書Z が同じディレクトリを触ります。指示書Z の完了を確認してから着手すること★★)

3-1. per_page と page を送る

- params.set("per_page", String(PAGINATION.maxPerPage));
+ params.set("per_page", String(PAGINATION.defaultPerPage));
+ params.set("page", String(page));

★★page を useMemo の依存配列に入れること★★(★★落とし穴6。入れ忘れるとボタンを押しても取り直しません★★)。

3-2. state を1つ足す

const [page, setPage] = useState(1);

★★total は既に useState(0) で持っています★★(setTotal(body.meta.total))。★そのまま使うこと★。

3-3. ★★存在しないページに取り残されたときの保険★★

fetchLogs() の成功時に:

setItems(body.items);
setTotal(body.meta.total);
// ★★フィルタ外の要因(データ更新など)で総ページ数が減り、存在しないページに
//   取り残された場合の保険。最終ページに寄せて取り直す★★
const pages = totalPageCount(body.meta.total, body.meta.per_page);
if (pages > 0 && body.meta.page > pages) setPage(pages);

★★pages > 0 の判定を落とさないこと★★(★0件のときに setPage(0) して無限ループになります★)。

3-4. ★★ページを1に戻す(★落とし穴5 を踏まない書き方★)★★

★★useEffect で後追いしないこと★★。★★state を変えるその場で一緒に戻すこと★★。

★debounce のあるもの(施設名・機種・シリアル)★ — ★★タイムアウトのコールバックの中で一緒に戻す★★:

useEffect(() => {
  const id = window.setTimeout(() => {
    setDebouncedFacility(facilityName);
    setPage(1);          // ★★debounce の反映と同時に戻す(React が1回の再描画にまとめる)★★
  }, 300);
  return () => window.clearTimeout(id);
}, [facilityName]);

★debounce の無いもの(日付・判定フィルタ)★ — ★ハンドラの中で一緒に戻す★:

onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
onClick={() => { setGradeFilter("problem"); setPage(1); }}

★★0-2 で数え上げた操作を1つ残らず潰すこと★★。

3-5. フッタを差し替える

- <div className={styles.footer}>
-   {total}件
-   {items.length < total ? `(表示 ${items.length}件)` : ""}
- </div>
+ <Pagination
+   total={total}
+   page={page}
+   perPage={PAGINATION.defaultPerPage}
+   onPageChange={setPage}
+ />

★★items.length を件数表示に使わないこと★★(落とし穴3)。

3-6. ★CSV の注記★

★CSVボタンの近く(toolbarRight の下、または CSV ボタンの直後)に小さく出すこと★:

<span className={styles.csvNote}>
  {t("CSVは絞り込み条件に一致する全件を出力します(表示中のページだけではありません)")}
</span>

★★CSVリンクの href は queryString のまま変えないこと★★(落とし穴7)。

この段階の完了条件

  • ★★清掃詳細ログで2ページ目に移動でき、内容が変わる★★(★検証方法は段階6-1★)
  • ★フィルタ・検索を変えると1ページ目に戻る(★debounce 経由も含む★)★
  • ★件数表示が「全{total}件中 {from}〜{to}件」になっている★
  • ★CSVの注記が出ている★
  • ★PAGINATION.maxPerPage の参照がこのファイルから消えている★
  • ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス★
  • ★コミット(★components/features/detail-log/ 配下で自分が触ったファイルだけを明示★)★

段階4 — ★summary 系3画面(F-03 / F-06 / F-07)★(1コミット)

★★対象★★: components/features/alerts/AlertsScreen.tsx / components/features/ds-logs/DsLogScreen.tsx / components/features/ds-alerts/DsAlertsScreen.tsx

★★この3画面は現在 body.summary しか読まず、body.meta を捨てています★★ → ★★total の state を新設すること★★。

const [total, setTotal] = useState(0);
…
setItems(body.items);
setSummary(body.summary);
setTotal(body.meta.total);      // ★★summary.total ではない★★(禁止事項4)

★★setTotal(body.summary.total) と書いたら間違いです★★ — ★★summary は対応状況フィルタの前に 計算されているので(D-054)、status=open で絞ると summary.total > meta.total になり、 存在しないページのボタンが出ます★★(★★ユーザーが名指しで禁じた状態★★)。

★あとは段階3 と同じ★:

  • per_page を defaultPerPage に、page を送る(★useMemo の依存に page を入れる★)
  • 3-3 の保険
  • 0-2 で数え上げた操作すべてで setPage(1)
  • 一覧の末尾(★空表示の外側★)に <Pagination …/> を置く

★★D-Secログ(F-06)だけの注意★★: ★備考のインライン編集があり、commitNote() が setItems() でその行だけ差し替えています★ (★全件再取得を避けるため。既存コメント参照★)。 ★★備考の保存で page を触らないこと★★(★保存のたびに1ページ目に飛ばされたら使い物になりません★)。

★★また、DsLogScreen.tsx の docstring に 「一覧は最大 PAGINATION.maxPerPage 件並ぶため」という古くなる記述があります★★ → ★★PAGINATION.defaultPerPage 件に直すこと★★(★★grep で実測して直すこと。行番号は変わります★★)。

この段階の完了条件

  • ★3画面とも body.meta.total を読んでいる★
  • ★★grep -rn "summary.total" components/features/ にページ送り由来の参照が1件も無い★★
    (★`StatCard` の表示に使っているものは残ってよい★)
  • ★D-Secログの備考保存でページが飛ばない★
  • ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス★
  • ★コミット(3ファイル + 必要なら CSS を明示)★

段階5 — ★ListResponse 系2画面(F-08 / F-09)★(1コミット)

★★対象★★: components/features/facilities/FacilitiesScreen.tsx / components/features/schedules/SchedulesScreen.tsx

5-1. 施設ロボットマスタ F-08

★total の state は既にあります★(setTotal(body.meta.total))。

- <div className={styles.footer}>
-   {t("全{n}施設").replace("{n}", String(total))}
- </div>
+ <Pagination total={total} page={page} perPage={PAGINATION.defaultPerPage} onPageChange={setPage} />

★★全{n}施設 が使われなくなります★★ →

grep -rn '全{n}施設"' --include=*.tsx --include=*.ts . | grep -v node_modules | grep -v "\.claude/"

★★上が lib/i18n.ts の定義行だけになったら、キー "全{n}施設": "{n} facilities total" と その直上の解説コメント(t("全{n}施設").replace(...) の例が書かれた3行)を削除すること★★。 ★★"全{n}施設を表示 →" は別のキー(レポート画面が使用中)なので残すこと★★。

5-2. スケジュール管理 F-09

★★total の state がありません★★ → ★新設して body.meta.total を入れること★。 ★既存コメント「fixtures は13件だが、ページャの無い画面なので上限で一度に取り切る」を★★書き換えること★★ (★★もうページャがあります★★)。

この段階の完了条件

  • ★2画面ともページ送りと件数表示が出る★
  • ★施設の 全{n}施設 を「全{total}件中 …」に置き換え、未使用になったキーを削除した★
  • ★★"全{n}施設を表示 →" を消していない★★
  • ★スケジュールの古いコメントを直した★
  • ★★grep -rn "PAGINATION.maxPerPage" components/features/ が
    `components/features/reports/ReportsScreen.tsx` の ★★2件(JSDoc の `:38` とコード `:42` の `SHOW_ALL_LIMIT`)だけ★★ になっている
    (★1件ではない。実測済み。★★コメント行を消しにいかないこと★★ — reports に差分を出すと禁止事項8 と完了条件16 に違反する)
  • ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス★
  • ★コミット(2ファイル + lib/i18n.ts を明示)★

段階6 — ★6画面の実測検証(★コミットなし★)★

6-1. ★★ローカルでは2ページ目が出ない(★対処を必ず読むこと★)★★

★★lib/fixtures/ の件数は数十件(監督の実測では清掃詳細ログで33件)しかなく、 defaultPerPage = 50 ではどの画面も1ページで収まります★★。

★★対処(★この順で試すこと★)★★:

# 方法 注意
★1★ ★★lib/config/index.ts の defaultPerPage を一時的に 5 にして6画面を確認する★★ ★★確認後に必ず 50 に戻し、git diff lib/config/index.ts が★maxVisiblePages の追加ぶんだけ★になることを確認すること★★(禁止事項12)
2 ★API を直接叩いて meta を確認する★ ★curl "http://localhost:3000/api/clean-logs?date_from=…&date_to=…&result=all&per_page=5&page=2"★。★認証・IP制限の回避方法は docs/handoff.md / D-101 項2(ALLOW_DIRECT_ACCESS)を参照★
3 ★大量ページの見た目は段階1 のテストで固定済み★ ★★223ページの並びを画面で作る必要はありません★★

★★禁止★★: ★★lib/fixtures/ に行を足して件数を増やすこと★★(禁止事項6)。

6-2. ★★6画面の検証手順(★1画面ずつ、全部やること★)★★

★各画面について、以下を順に確認して結果を控えること★:

# 確認
★1★ ★画面を開くと件数表示が出る(「全{total}件中 1〜N件」)★
★2★ ★★total が 0-3 で控えた件数と一致する★★
★3★ ★defaultPerPage を小さくするとページ送りのボタンが出る★
★4★ ★★描画されているページ番号がすべて実在する(最大の番号を押しても空にならない)★★
★5★ ★2ページ目を押すと内容が変わり、件数表示が「51〜100件」のように追随する★
★6★ ★最終ページの to が total と一致する★
★7★ ★★フィルタ/検索を変えると1ページ目に戻る★★(★2ページ目に居る状態で試すこと★)
★8★ ★★debounce のある検索欄でも戻る★★(★文字を打って300ms待つ★)
★9★ ★0件になる条件で絞ると、★★ページ送りも件数表示も消え、空表示だけが出る★★★
★10★ ★1ページに収まるとき、★★件数表示は出るがボタンは出ない★★★
★11★ ★ダーク/ライト両方で崩れない★
★12★ ★EN に切り替えて「{from}–{to} of {total}」「Prev」「Next」が出る★

★★画面ごとの追加確認★★:

画面 追加で見ること
清掃詳細ログ F-02 ★CSVの注記が出ている★ / ★★CSVを実際に落として、表示中のページではなく全件入っていること★★ / ★行展開(アドバイス)がページ移動後も動く★
アラート F-03 ★★StatCard の3枚(未解消/解消済み/合計)がページ移動で変わらない★★(★summary はページに依存しない★)
D-Secログ F-06 ★★備考を編集して保存してもページが飛ばない★★ / ★StatCard がページ移動で変わらない★
D-Secアラート F-07 ★StatCard がページ移動で変わらない★
施設 F-08 ★★全{n}施設 が消え、新しい形になっている★★
スケジュール F-09 ★件数表示が新規に出ている★

6-3. ★回帰していないこと★

# 確認
1 ★★レポート画面(F-04)に差分が無い★★(git diff --stat components/features/reports/)
2 ★★ダッシュボード(F-01)に差分が無い★★
3 ★git diff --stat に lib/fixtures/ / app/api/ / lib/db/ / d-watch-api.yaml / prisma/schema.prisma / lib/types/index.ts が出てこない★
4 ★npm test のテスト数が着手前より増えている★

この段階の完了条件

  • ★6画面 × 12項目の確認をすべて実施し、結果を控えた★
  • ★★defaultPerPage が 50 に戻っている★★
  • ★6-3 の回帰確認がすべて通った★
  • ★コミット無し★

段階7 — 記録と push(1コミット)

7-1. docs/decisions.md に追記(★★着手直前に実測した空き番号を使うこと★★)

★★書くこと★★:

# 内容
★1★ ★★モックに無いUIを新規に作った経緯と、それが CLAUDE.md §5 の例外であること★★。★★ユーザーの明示の承認(2026-09-11)を原文で引用すること★★
★2★ ★「実在するページだけを描画する」を不変条件テストで固定したこと(段階1 の D-1〜D-10)★
3 ★省略の規則(maxVisiblePages = 7、先頭寄り/中央/末尾寄りの3分岐)と、なぜ幅を固定したか★
4 ★件数表示を「全{total}件中 {from}〜{to}件」に統一し、{total}件(表示 N件) と 全{n}施設 を廃止したこと★
★5★ ★★meta.total を使い summary.total を使わない理由(D-054 の帰結)★★
★6★ ★★ページ送りを付けても DB からは全件読む。性能は改善しないこと。別課題として切り離したこと★★
★7★ ★却下した4案(maxPerPage を上げる / 無限スクロール / もっと見る / カーソルページング)と理由★
8 ★対象外にしたもの(レポート / ダッシュボード / 点検記録)と理由★

7-2. docs/handoff.md に追記(★番号は実測すること★)

★★grep -n "^## 20" docs/handoff.md | tail -3 で最終の追記番号を実測してから足すこと★★。

7-3. 本書の実行状態を更新

★冒頭表の「実行状態」と文末の「実行状態」表を★★完了★★に更新し、 ★実行日 / 決定番号 / コミットハッシュ / 実測値(tsc / lint / テスト数)★を書くこと。

この段階の完了条件

  • ★docs/decisions.md に実測した番号で追記した(★7-1 の8点すべて★)★
  • ★docs/handoff.md に追記した★
  • ★本書の実行状態を更新した★
  • ★コミット: git add docs/decisions.md docs/handoff.md docs/handoff/指示書AA_一覧にページ送りを付ける.md★
  • ★push★

★★申し送り★★

★★以下は本指示書では直しません。★★ ★決定事項に列挙して残すこと★。

# 論点 状態
★★①★★ ★★DB から全件読む構造は変わらない★★ — ★DataSource が引数を取らない契約で、prismaSource.cleanLogs() は全期間・全件 findMany()★。★コード自身が「年間13万行でボトルネックになりうる」と警告している(lib/db/index.ts / lib/db/clean-log-mapping.ts)★。★★ページを繰るたびに全件取得が走るので、体感はむしろ悪化する方向★★ ★★別課題(SQL に絞り込みを降ろす改修)として切り出すこと★★。★★ページ送りと同時にやらないこと★★ — ★★連続日数の計算が全期間を走査しているため、同時に壊れます★★。★★合わせて「ページ番号を URL に持たせるか(useSearchParams + Suspense 境界)」も未決★★
★★②★★ ★★レポート画面の「全{n}施設を表示 →」が200件で切れる潜在バグ★★ — ★components/features/reports/ReportsScreen.tsx の SHOW_ALL_LIMIT = PAGINATION.maxPerPage(=200)★。★★施設が350ある顧客では「全350施設を表示 →」を押しても200件しか出ません★★。★件数表示は totalCount(=350)を出すので、★★数字と中身が食い違います★★★ ★★本書では直さない★★(禁止事項8)。★★別指示書で「レポート画面もページ送りに寄せる」か「SHOW_ALL_LIMIT を撤廃してサーバ側で全件返す」かを決めること★★。★★P1のドン・キホーテ1社で施設が何件になるかを先に確認すること★★(★200を超えないなら急がない★)
★★③★★ ★★CSV と画面の件数が食い違う★★ — ★CSV は絞り込み条件に一致する全件、画面は1ページぶん★。★本書では画面に注記を出すだけ(段階3-6)★ ★★export に件数上限が無いことも未確認★★ — ★★年間13万行を1本のCSVに吐く挙動を、実データが入る前に一度確かめること★★
★★④★★ ★★fixtures は数十件しかなく、ローカルでは2ページ目が出ない★★(★清掃詳細ログで33件★) ★★検証は defaultPerPage を一時的に 5 にして行い、必ず 50 に戻すこと★★(段階6-1)。★★lib/fixtures/ に行を足さないこと★★(禁止事項6)。★★大量ページの見た目は段階1 の期待値表(223ページ)で固定済み★★
⑤ ★1ページあたりの件数を切り替えるUI(20/50/100)は作っていない★ ★要望が出たら perPage を state 化して Pagination に選択UIを足す★。★★そのとき PAGINATION.maxPerPage(200)を超えないこと(サーバがクランプする)★★
⑥ ★ソートUIが付いたときの setPage(1)★ — ★現状どの画面にもソートUIが無い(並べ替えはサーバ固定)★ ★★将来ソートUIを足す人は、そこでも setPage(1) すること★★
⑦ ★点検記録(F-05)は画面もAPIも未実装★ ★実装するときは最初から Pagination を入れること★

完了条件(全体)

# 条件
★1★ ★lib/pagination/page-items.ts に totalPageCount() / pageRange() / buildPageItems() があり、PageItem の ellipsis が page を持っていない★
★2★ ★★段階1 の期待値表(A 13 + B 9 + C 22 + D 10)がすべてテストになっている★★
★3★ ★★不変条件テストが totalPages = 1..300 × 全ページを総当たりし、★存在しないページ番号が1つも描画されない★ことを固定している★★
★4★ ★★「…」が飛ばすページ数が必ず2以上であることがテストで固定されている★★
★5★ ★PAGINATION.maxVisiblePages が追加され、奇数かつ7以上であることがテストで固定されている★
★6★ ★components/ui/Pagination.tsx があり、total === 0 で null、総ページ数1でボタンを出さない★
★7★ ★★「…」が <button> になっていない★★
★8★ ★★6画面すべてに件数表示(「全{total}件中 {from}〜{to}件」)とページ送りが入っている★★
★9★ ★★grep -rn "PAGINATION.maxPerPage" components/features/ が reports/ReportsScreen.tsx の★2件(JSDoc の :38 とコード :42)だけ★(★1件ではない。実測済み。コメント行を消しにいかないこと★)★★
★10★ ★★6画面すべてが body.meta.total を使っており、summary.total を総ページ数に使っていない★★
★11★ ★★フィルタ・検索(debounce 経由を含む)を変えると6画面すべてが1ページ目に戻る★★
★12★ ★清掃詳細ログにCSVの注記が出ており、CSVの中身は全件のまま★
★13★ ★lib/i18n.ts に4キーを足し、未使用になった "全{n}施設" を削除した(★"全{n}施設を表示 →" は残っている★)★
★14★ ★★50 / 200 / 7 がコンポーネントにも純粋関数にも直書きされていない★★
★15★ ★★lib/fixtures/ / app/api/** / lib/db/** / lib/*/query.ts / d-watch-api.yaml / lib/types/index.ts / prisma/schema.prisma に差分が無い★★
★16★ ★★components/features/reports/** / components/features/dashboard/** に差分が無い★★
★17★ ★★package.json / package-lock.json に差分が無い★★
★18★ ★★lib/config/index.ts の差分が maxVisiblePages の追加だけ(defaultPerPage が 50 に戻っている)★★
★19★ ★npx tsc --noEmit = 0 / npm run lint = 0 / npm test 全パス(★テスト数は着手前より増えている★)★
★20★ ★README.md / docs/code-map.md / docs/handoff/管理画面_設計検討_20260910.md / 他の指示書 / .claude/worktrees/** に触っていない★
★21★ ★★docs/decisions.md(★実測した空き番号★)と docs/handoff.md に記録した。★モック例外の承認経緯と、性能が改善しないことを明記した★★★
★22★ ★6コミット(段階1 / 2 / 3 / 4 / 5 / 7)で push した★

実行状態

項目 内容
状態 ★★完了★★(★段階0〜7 をすべて実行。★段階0〜3 と段階4〜7 は別のエージェントが分担★★)
実行日 2026-09-11
決定番号 ★★D-120★★(docs/decisions.md:7762)/ docs/handoff.md ★追記40★(:3363)
コミット 段階1 fd4ca5b / 段階2 fcdcb07 / 段階3 9e9704c / ★段階4 78fbfac★ / ★段階5 77693c4★ / 段階7(記録)。★★push は未実施(監督確認待ち)★★
実測値 ★テスト 48ファイル / 1137件 全パス★(★段階1 で +48件。段階4・5 は画面コードのみでテスト追加なし★)/ npx tsc --noEmit = ★0★ / npx eslint . = ★0★ / grep -rn "PAGINATION.maxPerPage" components/features/ = ★reports/ReportsScreen.tsx の2件(:38 JSDoc / :42 コード)だけ★
★段階6 について★ ★★ブラウザでの目視は行っていない★★(★dev server がアクセストークンのログインを要求し、認証情報の入力は行わない方針のため★)。★代わりに APIルートの GET を直接呼び、6画面ぶんの meta と「描画される最大のページ番号を押しても空にならないこと」を実測した★。★★目視が要る項目は docs/handoff.md 追記40 項6 に列挙してある★★
★defaultPerPage について★ ★★lib/config/index.ts は本書の対象外ファイルなので、段階6-1 の一時変更は行っていない★★(defaultPerPage は 50 のまま)。★代わりにクエリの per_page=5 で同等の確認を行った★