Newer
Older
client_dwatch / lib / pagination / page-items.ts
import { PAGINATION } from "@/lib/config";

/**
 * 一覧のページ送り — 表示に必要な数だけを組み立てる純粋関数(指示書AA 段階1)
 *
 * ★ここに置く理由★: `vitest.config.ts` の `include` は `lib/**` と `app/**` だけで、
 *   `components/**` のテストは走らない。★判断をコンポーネントに置くとテストで固定できない★ため、
 *   「何ページあるか」「どの番号を描くか」の判断はすべてこのファイルに寄せる。
 *   `components/ui/Pagination.tsx` は、ここが返したものを描くだけにする。
 *
 * ★満たすべき要件(ユーザー確定。2026-09-11)★
 *   「2ページ目以降ができたらその数だけ表示選択できるようにしてください」
 *   「3ページ目を押したら有りません、みたいなのはだめです」
 *   = ★`buildPageItems()` が返す数字は1つ残らず `1..totalPages` に入ること★。
 *   これは `__tests__/page-items.test.ts` の不変条件テスト(総ページ数 1..300 の総当たり)で
 *   機械的に固定してある。
 *
 * ★閾値は `PAGINATION` から読む★(`CLAUDE.md` §7)。`7` / `50` / `200` を直書きしない。
 */

/**
 * 描画する1要素。
 *
 * ★`kind: "ellipsis"` に `page` を持たせないこと★ — 持たせると
 * 「…を押したら何ページに飛ぶ?」という存在しない問いが生まれる。型で押せなくしておく。
 * `key` は React の key 用("gap-left" / "gap-right")。
 */
export type PageItem =
  | { kind: "page"; page: number }
  | { kind: "ellipsis"; key: string };

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

/**
 * ページ番号を「実在するページ」に丸める。
 *
 * ★`clamp()` だけでは `NaN` を素通りさせる★ — `Math.min(max, Math.max(min, NaN))` は `NaN` で、
 * そのまま比較に使うと全部 false になり、どの枝にも入らないまま中央の枝へ落ちる。
 * ★`totalPageCount()` / `buildPageItems()` の `totalPages` 側には既に `Number.isFinite` の
 * ガードがあるのに、`page` 側だけ無かった★(指示書AE 段階3-1 ②)。
 *
 * ★いまは到達しない★ — 呼び出し元(`components/ui/Pagination.tsx`)の `page` は
 * `useState(1)` の整数 state で、★URL クエリから読む経路は存在しない★。
 * ★★ただしページ番号を URL に持たせた瞬間に穴が開く★★ので、入口で閉じておく。
 *
 * ★丸め方★: `NaN` → 先頭ページ / 非整数は `Math.trunc`(2.9 → 2。実在しない `2.9` を描かない)/
 * `Infinity` → 最終ページ・`-Infinity` → 先頭ページ(`Math.trunc` が素通しした後 `clamp` が拾う)。
 */
function normalizePage(page: number, totalPages: number): number {
  if (Number.isNaN(page)) return 1;
  return clamp(Math.trunc(page), 1, totalPages);
}

/** `from`..`to`(両端含む)の連番を PageItem の配列にする */
function pages(from: number, to: number): PageItem[] {
  const out: PageItem[] = [];
  for (let n = from; n <= to; n++) out.push({ kind: "page", page: n });
  return out;
}

/**
 * 総ページ数。
 *
 * ★0件は 0 ページ★(1ページではない。呼び出し側が「部品を出さない」判断に使う)。
 * ★`perPage` が0以下のときは 0★ — `Math.ceil(total / 0)` は `Infinity` になり、
 * そのまま使うとループが終わらなくなる(指示書AA 落とし穴1)。
 */
export function totalPageCount(total: number, perPage: number): number {
  if (!Number.isFinite(total) || !Number.isFinite(perPage)) return 0;
  if (total <= 0 || perPage <= 0) return 0;
  return Math.ceil(total / perPage);
}

/**
 * 「全{total}件中 {from}〜{to}件」の from / to。
 *
 * ★0件のとき `from` を 1 にしないこと★ — 素直に `(page-1)*perPage+1` と書くと
 * 「全0件中 1〜0件」が出る(指示書AA 落とし穴2)。
 * ★`to` は `items.length` から作らないこと★ — ローディング中や取得失敗で `items` が
 * 空になると件数だけ 0 に見える(落とし穴3)。
 *
 * 範囲外の `page` は実在するページに丸める(0以下は先頭、超過は最終ページ)。
 * ★`NaN` / 非整数 / `Infinity` も `normalizePage()` が拾う★。
 */
export function pageRange(
  total: number,
  page: number,
  perPage: number
): { from: number; to: number } {
  const totalPages = totalPageCount(total, perPage);
  if (totalPages === 0) return { from: 0, to: 0 };
  const cur = normalizePage(page, totalPages);
  const from = (cur - 1) * perPage + 1;
  return { from, to: Math.min(cur * perPage, total) };
}

/**
 * 描画するページ番号の並び。
 *
 * ★アルゴリズム★(`W = PAGINATION.maxVisiblePages`。★すべて W から導き、数値を直書きしない★)
 *
 *   totalPages <= 0        → [](0件。部品ごと出さない)
 *   totalPages <= W        → 1..totalPages を全部出す(省略なし)
 *   現在ページが先頭寄り   → [1 .. W-2] … [totalPages]
 *   現在ページが末尾寄り   → [1] … [totalPages-(W-3) .. totalPages]
 *   それ以外(中央)       → [1] … [cur-side .. cur+side] … [totalPages]
 *
 * どの枝でも要素数はちょうど W になる(★ボタン列の幅がガタつかない★)。
 * また「…」が隠すページ数は必ず2以上になる — ★1ページだけを「…」で隠さない★
 * (数字1つぶんの幅に「…」を出すのは、押せる番号が減るだけで損。落とし穴4)。
 */
export function buildPageItems(page: number, totalPages: number): PageItem[] {
  if (!Number.isFinite(totalPages) || totalPages <= 0) return [];

  const w = PAGINATION.maxVisiblePages;
  const half = (w + 1) / 2;
  const cur = normalizePage(page, totalPages);

  if (totalPages <= w) return pages(1, totalPages);

  const gapLeft: PageItem = { kind: "ellipsis", key: "gap-left" };
  const gapRight: PageItem = { kind: "ellipsis", key: "gap-right" };

  // 先頭寄り: 1..(W-2) を並べ、間を1つ飛ばして最終ページ
  if (cur <= half) {
    return [...pages(1, w - 2), gapRight, { kind: "page", page: totalPages }];
  }

  // 末尾寄り: 先頭ページのあと、末尾側の (W-2) ページを並べる
  if (cur >= totalPages - (half - 1)) {
    return [
      { kind: "page", page: 1 },
      gapLeft,
      ...pages(totalPages - (w - 3), totalPages),
    ];
  }

  // 中央: 先頭 … 現在ページの前後 … 末尾
  const side = (w - 5) / 2;
  return [
    { kind: "page", page: 1 },
    gapLeft,
    ...pages(cur - side, cur + side),
    gapRight,
    { kind: "page", page: totalPages },
  ];
}