From bf7b3acf5463ef201fd8aa57fbc1e7a051dc820f Mon Sep 17 00:00:00 2001
From: tmba
Date: Tue, 2 Jun 2026 22:54:08 +0900
Subject: [PATCH 01/12] docs(checkin): design spec for data-layer propagation
(useApiResource)
Migrate checkin's 5 hand-rolled GET fetch state machines to the shared
useApiResource hook (from #43), and consolidate the 4 near-identical
full-screen error screens into a checkin-local CheckinErrorScreen.
Loading skeletons stay content-aware/page-local; signage stays as-is
(polling). Auth untouched.
Co-Authored-By: Claude Opus 4.8
---
...2-checkin-data-layer-propagation-design.md | 112 ++++++++++++++++++
1 file changed, 112 insertions(+)
create mode 100644 docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md
diff --git a/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md b/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md
new file mode 100644
index 0000000..aab7d0d
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md
@@ -0,0 +1,112 @@
+# checkin データ層伝播 設計(useApiResource 展開)
+
+作成日: 2026-06-02 / 対象: `apps/checkin`(共有: `packages/ui` の `useApiResource` を利用)/ Next.js 16 + React 19
+
+## 0. 背景とゴール
+
+admin データ層モダナイゼーション(`docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md` の SP1)で `packages/ui` に `useApiResource` / `DataError` / `EmptyState` を導入した。本サブプロジェクトは、その **ロジック層(`useApiResource`)** を checkin の読み取り取得に展開し、5 箇所の手書き fetch ステートマシン(`useEffect` + `apiFetch` + `type State` + cancellation)の重複を解消する。
+
+**プレゼンテーションは checkin 独自の全画面キオスク UX(motion 付き)を維持** し、admin 向け inline プリミティブ(`DataError` / `EmptyState`)は持ち込まない(ユーザー判断: 「ロジック共通化+checkin 独自 UI」)。
+
+### 実コード精査で判明した重要点(設計の根拠)
+
+1. **loading スケルトンは各ページのレイアウトを写した content-aware skeleton**(first-time / history / reception / guideline で形が異なる Card+Skeleton グリッド、manual は 3 件のリスト skeleton)。1 コンポーネントに無理に統合すると content-aware なローディング UX が退化する → **ページ固有のスケルトンは維持**。
+2. **empty 状態は文脈ごとに形が異なる**(first-time: inline Alert / history: 中央 icon 円+太字 / manual: dashed-border テキスト)。低重複なので **原則維持**。
+3. **真の高価値統合は 2 つ**: (a) `useApiResource` による fetch ロジック共通化(5 箇所)、(b) ほぼ同一の **全画面 ErrorScreen 4 箇所**(first-time / history / reception / guideline)を checkin-local 共有コンポーネント 1 つへ。
+4. **`cache: 'no-store'` は不要になる**。API(`apps/api/src`)は Cache-Control 系ヘッダを一切送っておらず、freshness 情報がないためブラウザは毎回再検証する。admin の `useApiResource`(ライブな `/api/sessions` ダッシュボード)が client 側 `no-store` 無しで既に正しく動作していることがこれを裏付ける。よって checkin の明示的 `cache:'no-store'` は移行時に **削除して良い**(フックに cache オプションを足さない=minimum first)。
+5. **`useApiResource` のキャンセルは `cancelled` フラグ**(AbortController ではない)。manual 検索を移行すると in-flight リクエストのネットワーク中断は無くなるが、最新クエリ優先の **UX は同一**(古いレスポンスは破棄)。
+
+## 1. スコープ
+
+### 1.1 `useApiResource` へ移行する GET 5 箇所
+
+| # | ファイル | path | enabled | reload | 移行の要点 |
+|---|---------|------|---------|--------|-----------|
+| 1 | `app/first-time/page.tsx` | `'/checkin/pre-registered'` | 常時 (true) | retry ボタン → `reload()` | クリーン移行。`type State` / `useCallback(loadParticipants)` を撤去し `state.kind` で分岐。`state.kind==='ok'` の `data.participants` を使う。 |
+| 2 | `app/history/page.tsx` | `'/checkin/history/today'` | 常時 | retry → `reload()` | GET のみ移行。**60s タイマー(`nowMs` 再計算)は別 `useEffect` として残す**。bulk-checkout(POST)と `lastResult`/`error`/`isSubmitting` は現状維持。`cache:'no-store'` は削除。 |
+| 3 | `app/reception/participants/[id]/page.tsx` | `` `/checkin/participants/${participantId}` `` | `!!participantId` | (なし) | **複合ステート分離**: fetch は `useApiResource`(loading\|ok\|error)。`submitting`/`result`(attendance POST 後)は別 `useState` に切り出す。`cache:'no-store'` 削除。 |
+| 4 | `app/manual/page.tsx` | `` debouncedQuery ? `/checkin/participants/search?${new URLSearchParams({ q: debouncedQuery })}` : null `` | (path=null で idle) | (なし) | debounce(300ms) はページに残し、結果の path を `useApiResource` に渡す。**AbortController を撤去** しフックの cancelled-flag に委譲(UX 同一)。空クエリ → path=null → idle。 |
+| 5 | `app/guideline/page.tsx` | `'/checkin/pre-registered'` | `!!preRegistrationId` | retry → `reload()` | list 取得後にページ側で `participants.find(p => p.preRegistrationId === preRegistrationId)` を実行。見つからなければ **派生エラー**(ErrorScreen 表示)。`preRegistrationId` 無し(enabled=false→idle)は ErrorScreen「登録する人を選んでください。」を表示。`activating`/`result`(activate POST)は別 `useState`。 |
+
+共通方針:
+- `useApiResource` は hooks ルールに従い **無条件で呼ぶ**。条件分岐(param ゲート)は `enabled` で表現。
+- エラーメッセージは `useApiResource` が `apiErrorMessage`(`body.message ?? body.error ?? HTTP `)で生成。現行 `readErrorMessage`(`body.message ?? HTTP `)とほぼ等価で表示文言は維持される。
+- 各ページの fetch 用 `type State` / fetch 用 `useEffect` / `useCallback(load)` を撤去する(重複解消の検証ポイント)。
+
+### 1.2 共有 ErrorScreen(checkin-local 新規)
+
+`apps/checkin/src/components/screen-error.tsx`:
+
+```tsx
+import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
+import { IconAlertCircle } from '@tabler/icons-react';
+import type { ReactNode } from 'react';
+
+// checkin の全画面エラー(キオスク用)。bg-rose-50 の全画面 + destructive Alert。
+// ページ固有のボタンを actions に、任意の補足(ID 行 / 詳細カード)を footer に渡す。
+export function CheckinErrorScreen({
+ title,
+ message,
+ actions,
+ footer,
+}: {
+ title: string; // 例: 一覧を表示できません / 履歴を表示できません
+ message: string; // 取得エラーメッセージ
+ actions: ReactNode; // ページ固有のボタン群(Home / 再読み込み / 選び直す 等、h-16 text-xl)
+ footer?: ReactNode; // 任意: reception の ID 行 / guideline の ParticipantDetails 等
+}) {
+ return (
+
+
+
+ {title}
+ {message}
+
+ {footer}
+ {actions}
+
+ );
+}
+```
+
+- first-time / history / reception / guideline の全画面 ErrorScreen 4 コピー(各 ~20-25 行)を本コンポーネントへ集約。各ページは固有のボタンを `actions` に、固有の補足(ID 行 / 詳細カード)を `footer` に渡す。
+- guideline の現行 `ErrorScreen` は `onRetry` の有無でボタンが変わる(再読み込み or ホーム)。この分岐は **呼び出し側** で `actions` を組み立てて表現する(コンポーネントは分岐を持たない)。
+- **manual の inline error**(`SearchResults` 内の小さな destructive Alert、全画面でない)は対象外=現状維持。
+- import パス(`@tecnova/ui/components/alert` 等)は実装時に既存ページの import 文で確認して合わせる。
+
+### 1.3 loading / empty は原則ページ固有のまま(過度な統合をしない)
+
+- content-aware スケルトンと多様な empty 状態は各ページに残す(§0 の根拠)。
+- 例外(任意・実装時判断): `PageShell` + `Card` の足場が複数ページで共通なため、薄いラッパに切り出す価値があれば検討して良いが、**スケルトンの中身はページに残す**。統合自体を目的化しない(minimum first)。
+
+## 2. 非ゴール / 不変条件
+
+- POST/mutation(`/checkin/history/check-out-bulk` / `/checkin/activate` / attendance)は変更しない(`useApiResource` は読み取り専用)。
+- **signage は対象外**。4 つのフック(health 30s / playlist 5min / previous-summary 1h / live counts 20s)はすべて polling + silent-degrade で `useApiResource` 非対応。現状が正しい。
+- admin の inline `DataError` / `EmptyState` は checkin に持ち込まない。
+- エンドポイント・debounce(300ms)・ルーティング・motion・キオスク UX・表示文言は不変。
+- `useApiResource` のシグネチャは変更しない(cache オプション等を足さない)。
+
+## 3. 実装上の注意(落とし穴)
+
+- **複合ステートの分離(#3 reception, #5 guideline)**: fetch 状態(`useApiResource`)と mutation/workflow 状態(`submitting`/`result`/`activating`)を別管理にする。`state.kind === 'ok'` で取得完了データを得て、その後のユーザー操作は独立した `useState` で進める。`result`(ScanResponse)表示中はデータ/エラー画面を出さない、という現行の画面遷移を保つ。
+- **派生状態(#5 guideline, #4 manual)**: `useApiResource` の状態に加え、ページ側で派生を計算する(guideline: list から find → not-found を派生エラーに; manual: 空クエリ → path=null → idle)。
+- **enabled=false の表示**: guideline で `preRegistrationId` 無し → idle。現行は error「登録する人を選んでください。」を出すので、idle のときに同文言の ErrorScreen を出す分岐をページに置く。
+- **reload の配線**: 現行 retry ボタンの `onClick={() => void loadParticipants()}` を `onClick={reload}` に置換。
+
+## 4. デリバリ・検証
+
+- **依存**: PR #43(`useApiResource` 等)は **`develop` へマージ済み**(`develop` @ `39d969b`)。本ブランチ `refactor/checkin-data-layer` は最新 `develop` から分岐済みで、`useApiResource` を直接利用できる。**実装はすぐ着手可能**。
+- **ブランチ/PR**: 単一 `refactor/checkin-data-layer` → `develop` に 1 本の PR(**スタックしない**。#40-43 のスタック誤マージ=兄弟ブランチ同士でマージされ develop に届かなかった反省を踏まえる)。
+- **コミット**: 論理単位で分割(spec → 共有 `CheckinErrorScreen` 追加 → 各ページ移行を数コミット)。
+- **検証**:
+ - `pnpm --filter checkin --filter @tecnova/ui type-check` green。
+ - `pnpm biome check apps/checkin/src` green。
+ - Playwright(`/api/me` + 各 GET をモック)で 5 フローの load / error / retry / empty、manual の idle/検索/空、reception/guideline の取得後の操作(POST)が従来どおり動くことを確認。特に `reload()` / 画面遷移後に **最新データが返る** こと(no-store 削除の影響確認)。
+ - 各ページから手書き fetch ステートマシン(`type State` の fetch 部分 / fetch 用 `useEffect`)が消えていること。
+
+## 5. 参照
+
+- 親設計: `docs/superpowers/specs/2026-06-02-admin-data-layer-modernization-design.md`
+- フック: `packages/ui/src/hooks/use-api-resource.ts`(#43 で develop に追加済み)/ クライアント: `packages/ui/src/lib/api-client.ts`
+- 認証は触らない方針(admin/API は同一親ドメインの同サイト兄弟 / SP3〔サーバー取得〕は意図的に見送り)。
From 8fa0dd10d5d11bee3c6ee29631650cedef67a206 Mon Sep 17 00:00:00 2001
From: tmba
Date: Tue, 2 Jun 2026 23:41:05 +0900
Subject: [PATCH 02/12] docs(checkin): reconcile spec with background-reload
(A3) decision
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The shared useApiResource gains a backward-compatible reload({ background })
(stale-while-revalidate); history's 更新 and admin's refresh/mutation
refetch opt in to avoid the skeleton flash.
Co-Authored-By: Claude Opus 4.8
---
...06-02-checkin-data-layer-propagation-design.md | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md b/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md
index aab7d0d..e2fce83 100644
--- a/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md
+++ b/docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md
@@ -15,6 +15,7 @@ admin データ層モダナイゼーション(`docs/superpowers/specs/2026-06-
3. **真の高価値統合は 2 つ**: (a) `useApiResource` による fetch ロジック共通化(5 箇所)、(b) ほぼ同一の **全画面 ErrorScreen 4 箇所**(first-time / history / reception / guideline)を checkin-local 共有コンポーネント 1 つへ。
4. **`cache: 'no-store'` は不要になる**。API(`apps/api/src`)は Cache-Control 系ヘッダを一切送っておらず、freshness 情報がないためブラウザは毎回再検証する。admin の `useApiResource`(ライブな `/api/sessions` ダッシュボード)が client 側 `no-store` 無しで既に正しく動作していることがこれを裏付ける。よって checkin の明示的 `cache:'no-store'` は移行時に **削除して良い**(フックに cache オプションを足さない=minimum first)。
5. **`useApiResource` のキャンセルは `cancelled` フラグ**(AbortController ではない)。manual 検索を移行すると in-flight リクエストのネットワーク中断は無くなるが、最新クエリ優先の **UX は同一**(古いレスポンスは破棄)。
+6. **(A3 決定)共有フックに background reload を追加する**。history の「更新」ボタンは現在バックグラウンド更新(スケルトンを出さずデータ差し替え)で、`reload()` をそのまま使うとローディングに戻りスケルトンが一瞬出る。これを避けるため `useApiResource` に **後方互換の `reload({ background: true })`**(stale-while-revalidate)を追加する。既存呼び出し(引数なし)は挙動不変。ついでに admin の更新/ミューテーション後再取得のちらつきも解消する。
## 1. スコープ
@@ -23,7 +24,7 @@ admin データ層モダナイゼーション(`docs/superpowers/specs/2026-06-
| # | ファイル | path | enabled | reload | 移行の要点 |
|---|---------|------|---------|--------|-----------|
| 1 | `app/first-time/page.tsx` | `'/checkin/pre-registered'` | 常時 (true) | retry ボタン → `reload()` | クリーン移行。`type State` / `useCallback(loadParticipants)` を撤去し `state.kind` で分岐。`state.kind==='ok'` の `data.participants` を使う。 |
-| 2 | `app/history/page.tsx` | `'/checkin/history/today'` | 常時 | retry → `reload()` | GET のみ移行。**60s タイマー(`nowMs` 再計算)は別 `useEffect` として残す**。bulk-checkout(POST)と `lastResult`/`error`/`isSubmitting` は現状維持。`cache:'no-store'` は削除。 |
+| 2 | `app/history/page.tsx` | `'/checkin/history/today'` | 常時 | error画面 retry → `reload()`;更新ボタン/POST後 → `reload({ background: true })` | GET のみ移行。**60s タイマー(`nowMs` 再計算)は別 `useEffect` として残す**。bulk-checkout(POST)と `lastResult`/`error`/`isSubmitting` は現状維持。`cache:'no-store'` は削除。更新ボタンと POST 後の再取得は background reload でちらつき回避(§0.6)。 |
| 3 | `app/reception/participants/[id]/page.tsx` | `` `/checkin/participants/${participantId}` `` | `!!participantId` | (なし) | **複合ステート分離**: fetch は `useApiResource`(loading\|ok\|error)。`submitting`/`result`(attendance POST 後)は別 `useState` に切り出す。`cache:'no-store'` 削除。 |
| 4 | `app/manual/page.tsx` | `` debouncedQuery ? `/checkin/participants/search?${new URLSearchParams({ q: debouncedQuery })}` : null `` | (path=null で idle) | (なし) | debounce(300ms) はページに残し、結果の path を `useApiResource` に渡す。**AbortController を撤去** しフックの cancelled-flag に委譲(UX 同一)。空クエリ → path=null → idle。 |
| 5 | `app/guideline/page.tsx` | `'/checkin/pre-registered'` | `!!preRegistrationId` | retry → `reload()` | list 取得後にページ側で `participants.find(p => p.preRegistrationId === preRegistrationId)` を実行。見つからなければ **派生エラー**(ErrorScreen 表示)。`preRegistrationId` 無し(enabled=false→idle)は ErrorScreen「登録する人を選んでください。」を表示。`activating`/`result`(activate POST)は別 `useState`。 |
@@ -84,8 +85,9 @@ export function CheckinErrorScreen({
- POST/mutation(`/checkin/history/check-out-bulk` / `/checkin/activate` / attendance)は変更しない(`useApiResource` は読み取り専用)。
- **signage は対象外**。4 つのフック(health 30s / playlist 5min / previous-summary 1h / live counts 20s)はすべて polling + silent-degrade で `useApiResource` 非対応。現状が正しい。
- admin の inline `DataError` / `EmptyState` は checkin に持ち込まない。
-- エンドポイント・debounce(300ms)・ルーティング・motion・キオスク UX・表示文言は不変。
-- `useApiResource` のシグネチャは変更しない(cache オプション等を足さない)。
+- エンドポイント・debounce(300ms)・ルーティング・motion・キオスク UX・表示文言は不変(history 更新の成功時も従来どおりちらつき無し。背景更新の失敗時のみ全画面エラーになる点は §0.6 / 受容)。
+- `useApiResource` の変更は **後方互換の `reload({ background })` 追加のみ**(A3、§0.6)。cache オプションは足さない。既存呼び出しは引数なしのため挙動不変。
+- **(A3 のオプトイン)admin の `reload()` 呼び出し 6 箇所**(dashboard 更新ボタン + mentors/pre-registrations のミューテーション後再取得)を `background: true` に更新し、admin 側のちらつきも解消する。初回取得・path 駆動の再取得(検索/フィルタ/ページング/日付)は変更しない。
## 3. 実装上の注意(落とし穴)
@@ -98,10 +100,11 @@ export function CheckinErrorScreen({
- **依存**: PR #43(`useApiResource` 等)は **`develop` へマージ済み**(`develop` @ `39d969b`)。本ブランチ `refactor/checkin-data-layer` は最新 `develop` から分岐済みで、`useApiResource` を直接利用できる。**実装はすぐ着手可能**。
- **ブランチ/PR**: 単一 `refactor/checkin-data-layer` → `develop` に 1 本の PR(**スタックしない**。#40-43 のスタック誤マージ=兄弟ブランチ同士でマージされ develop に届かなかった反省を踏まえる)。
-- **コミット**: 論理単位で分割(spec → 共有 `CheckinErrorScreen` 追加 → 各ページ移行を数コミット)。
+- **コミット**: 論理単位で分割(フック background reload 追加 → 共有 `CheckinErrorScreen` 追加 → 各ページ移行を数コミット → admin の reload 更新)。
- **検証**:
- - `pnpm --filter checkin --filter @tecnova/ui type-check` green。
- - `pnpm biome check apps/checkin/src` green。
+ - `pnpm --filter @tecnova/ui --filter checkin --filter admin type-check` green。
+ - `pnpm biome check packages/ui/src apps/checkin/src apps/admin/src` green。
+ - history の更新ボタン・admin の更新/ミューテーション後再取得が **スケルトンを出さず** 差し替わること(background reload)。既存呼び出し(引数なし)の挙動が不変であること。
- Playwright(`/api/me` + 各 GET をモック)で 5 フローの load / error / retry / empty、manual の idle/検索/空、reception/guideline の取得後の操作(POST)が従来どおり動くことを確認。特に `reload()` / 画面遷移後に **最新データが返る** こと(no-store 削除の影響確認)。
- 各ページから手書き fetch ステートマシン(`type State` の fetch 部分 / fetch 用 `useEffect`)が消えていること。
From 907a8b1e2b04d73a6498f76991febd239ad37af7 Mon Sep 17 00:00:00 2001
From: tmba
Date: Tue, 2 Jun 2026 23:41:05 +0900
Subject: [PATCH 03/12] docs(checkin): implementation plan for data-layer
propagation
Co-Authored-By: Claude Opus 4.8
---
...26-06-02-checkin-data-layer-propagation.md | 713 ++++++++++++++++++
1 file changed, 713 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-02-checkin-data-layer-propagation.md
diff --git a/docs/superpowers/plans/2026-06-02-checkin-data-layer-propagation.md b/docs/superpowers/plans/2026-06-02-checkin-data-layer-propagation.md
new file mode 100644
index 0000000..9760504
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-02-checkin-data-layer-propagation.md
@@ -0,0 +1,713 @@
+# checkin Data-Layer Propagation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Migrate checkin's 5 hand-rolled GET fetch state machines to the shared `useApiResource` hook and consolidate its 4 full-screen error copies into one checkin-local `CheckinErrorScreen`, while adding a backward-compatible background (stale-while-revalidate) reload to the shared hook.
+
+**Architecture:** `packages/ui`'s read-only `useApiResource` becomes the single fetch primitive for checkin's read GETs. A new `reload({ background: true })` mode keeps current data visible during refetch (no skeleton flash) — used by checkin's history 更新 button and applied to admin's refresh/mutation refetch buttons. Presentation stays checkin-local: a new `CheckinErrorScreen` owns the full-screen kiosk error layout; loading skeletons and empty states stay page-local and unchanged. Auth, endpoints, debounce, motion, and display text are untouched. signage is out of scope (polling architecture).
+
+**Tech Stack:** Next.js 16 / React 19, TypeScript (strict), Biome, `@tecnova/ui` shared package (`useApiResource`, `apiJson`/`apiErrorMessage`), motion/react, Tabler icons, pnpm workspaces.
+
+**Spec:** `docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md`
+
+**Branch:** `refactor/checkin-data-layer` (already created off `develop` @ `39d969b`, which includes `useApiResource` via #43). Single PR back to `develop` — **not stacked**.
+
+---
+
+## File Structure
+
+| File | Responsibility | Change |
+|------|---------------|--------|
+| `packages/ui/src/hooks/use-api-resource.ts` | Shared read-only fetch hook | Add `reload({ background })` (SWR) — backward compatible |
+| `apps/checkin/src/components/screen-error.tsx` | checkin full-screen kiosk error layout | **Create** |
+| `apps/checkin/src/app/first-time/page.tsx` | Pre-registered list for initial registration | Migrate GET |
+| `apps/checkin/src/app/history/page.tsx` | Today's check-in/out history | Migrate GET (background 更新) |
+| `apps/checkin/src/app/reception/participants/[id]/page.tsx` | Participant profile + attendance | Migrate GET, split POST state |
+| `apps/checkin/src/app/manual/page.tsx` | Debounced name search | Migrate GET, drop AbortController |
+| `apps/checkin/src/app/guideline/page.tsx` | Guideline flow + activate | Migrate GET, derive item |
+| `apps/admin/src/app/(authed)/page.tsx` | Dashboard | 更新 button → background reload |
+| `apps/admin/src/app/(authed)/mentors/page.tsx` | Mentors admin | post-mutation reload → background |
+| `apps/admin/src/app/(authed)/pre-registrations/page.tsx` | Pre-reg admin | post-mutation reload → background |
+
+**Conventions (all tasks):** call `useApiResource` unconditionally (hooks rule); gate with `enabled`. Drop any `cache: 'no-store'` from migrated GETs (API sends no cache headers; the hook revalidates). Keep all POST/mutation logic and state. Only **full-screen** error blocks move to `CheckinErrorScreen`; inline Alerts stay. Keep skeletons, empty states, motion, and Japanese text byte-for-byte. Verified import paths: hook = `@tecnova/ui/hooks/use-api-resource`; local components = `@/components/...`.
+
+---
+
+### Task 1: Add background (stale-while-revalidate) reload to `useApiResource`
+
+**Files:**
+- Modify: `packages/ui/src/hooks/use-api-resource.ts`
+
+- [ ] **Step 1: Replace the hook file body**
+
+The only changes vs. the current file: add `useRef` to the React import, add a `ReloadOptions` type, widen `reload`'s signature, and skip the `loading` flip when a background reload is requested and data is already present. Write the full file:
+
+```ts
+'use client';
+
+import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+// 取得状態。idle = まだ取得していない(path=null / enabled=false)。
+export type ResourceState =
+ | { kind: 'idle' }
+ | { kind: 'loading' }
+ | { kind: 'ok'; data: T }
+ | { kind: 'error'; message: string };
+
+export interface ReloadOptions {
+ // true のとき、既にデータがある場合は loading に戻さず裏で再取得する
+ // (stale-while-revalidate)。更新ボタンのちらつき回避用。初回取得・path 変更は
+ // 従来どおり loading を表示する。
+ background?: boolean;
+}
+
+export interface UseApiResourceResult {
+ state: ResourceState;
+ reload: (opts?: ReloadOptions) => void;
+}
+
+export interface UseApiResourceOptions {
+ // false のあいだは取得せず idle のままにする(ロール待ち等の条件付き取得用)。
+ enabled?: boolean;
+}
+
+// path から JSON を取得し loading|ok|error|idle を返す読み取り専用フック。
+// - path が null か enabled=false のとき idle(取得しない)。
+// - path が変わると自動で再取得する(クエリ文字列を path に含めて表現する)。
+// - reload() で手動再取得。reload({ background: true }) は表示中のデータを
+// 保持したまま裏で再取得する(ちらつき回避)。
+// アンマウントやパラメータ変更時に古いレスポンスで setState しないよう
+// cancelled フラグでガードする。ミューテーションは扱わない。
+export const useApiResource = (
+ path: string | null,
+ options?: UseApiResourceOptions,
+): UseApiResourceResult => {
+ const enabled = options?.enabled ?? true;
+ // 取得予定なら最初から loading で初期化し、idle の一瞬のちらつきを避ける。
+ const [state, setState] = useState>(() =>
+ path && enabled ? { kind: 'loading' } : { kind: 'idle' },
+ );
+ const [reloadKey, setReloadKey] = useState(0);
+ // 直近の reload がバックグラウンド要求だったかを次の effect 実行に伝える。
+ const backgroundReloadRef = useRef(false);
+ // effect の依存に state を入れずに最新値を参照するための ref。
+ const stateRef = useRef(state);
+ stateRef.current = state;
+
+ const reload = useCallback((opts?: ReloadOptions) => {
+ backgroundReloadRef.current = opts?.background ?? false;
+ setReloadKey((k) => k + 1);
+ }, []);
+
+ // reloadKey は本文では参照しないが、reload() による手動再取得のトリガーとして
+ // 依存配列に必要(path/enabled が同じでも再フェッチさせる)。
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey is an intentional refetch trigger
+ useEffect(() => {
+ if (!path || !enabled) {
+ setState({ kind: 'idle' });
+ return;
+ }
+ const background = backgroundReloadRef.current;
+ backgroundReloadRef.current = false;
+ let cancelled = false;
+ // バックグラウンド再取得かつ既にデータ表示中なら loading に戻さず、
+ // 現在のデータを表示したまま裏で更新する。それ以外(初回・path 変更・
+ // エラーからの再取得)は従来どおり loading を表示する。
+ if (!(background && stateRef.current.kind === 'ok')) {
+ setState({ kind: 'loading' });
+ }
+ void (async () => {
+ try {
+ const data = await apiJson(path);
+ if (!cancelled) setState({ kind: 'ok', data });
+ } catch (e) {
+ if (!cancelled) setState({ kind: 'error', message: apiErrorMessage(e) });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [path, enabled, reloadKey]);
+
+ return { state, reload };
+};
+```
+
+- [ ] **Step 2: Verify ui + admin still type-check (existing callers unaffected)**
+
+Run: `pnpm --filter @tecnova/ui --filter admin type-check`
+Expected: PASS. `reload(opts?: ReloadOptions)` has zero required params, so it remains assignable to existing `() => void` callbacks (`onCreated={reload}`, etc.) — no admin change needed yet.
+
+- [ ] **Step 3: Verify Biome**
+
+Run: `pnpm biome check packages/ui/src/hooks/use-api-resource.ts`
+Expected: PASS (the `useExhaustiveDependencies` ignore comment is retained).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add packages/ui/src/hooks/use-api-resource.ts
+git commit -m "$(printf 'feat(ui): add background (stale-while-revalidate) reload to useApiResource\n\nreload({ background: true }) keeps current data visible during refetch\ninstead of flipping to loading, for flicker-free refresh buttons.\nBackward compatible: default is the existing loading behavior.\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 2: Create `CheckinErrorScreen`
+
+**Files:**
+- Create: `apps/checkin/src/components/screen-error.tsx`
+
+- [ ] **Step 1: Write the component**
+
+Confirm the exact `Alert` import path by matching an existing checkin page (e.g. `manual/page.tsx` imports `Alert, AlertTitle, AlertDescription` from `@tecnova/ui/components/alert`). Write:
+
+```tsx
+import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
+import { IconAlertCircle } from '@tabler/icons-react';
+import type { ReactNode } from 'react';
+
+// checkin(iPad キオスク)共通の全画面エラー。bg-rose-50 の全画面 +
+// destructive Alert。ページ固有のボタンを actions に、任意の補足
+// (ID 行 / 詳細カード等)を footer に渡す。inline の小さなエラーは対象外。
+export function CheckinErrorScreen({
+ title,
+ message,
+ actions,
+ footer,
+}: {
+ title: string;
+ message: string;
+ actions: ReactNode;
+ footer?: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+ {message}
+
+ {footer}
+ {actions}
+
+ );
+}
+```
+
+- [ ] **Step 2: Verify type-check + Biome**
+
+Run: `pnpm --filter checkin type-check && pnpm biome check apps/checkin/src/components/screen-error.tsx`
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/checkin/src/components/screen-error.tsx
+git commit -m "$(printf 'feat(checkin): add shared CheckinErrorScreen for full-screen kiosk errors\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 3: Migrate `first-time/page.tsx` GET to `useApiResource`
+
+**Files:**
+- Modify: `apps/checkin/src/app/first-time/page.tsx`
+
+- [ ] **Step 1: Update imports**
+
+- Remove `import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';` (both become unused).
+- Add `import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';`.
+- Change `import { useCallback, useEffect, useMemo, useState } from 'react';` → `import { useMemo, useState } from 'react';` (`useCallback`/`useEffect` become unused).
+- Add `import { CheckinErrorScreen } from '@/components/screen-error';` in the `@/components/*` group.
+- Keep `Alert`/`AlertTitle`/`AlertDescription`/`IconAlertCircle`/`IconRefresh` — still used by the two inline Alerts and the inline 更新 button.
+
+- [ ] **Step 2: Remove the fetch state machine**
+
+Delete: the `type State` union (fetch-only, lines ~42–45); the `const [state, setState] = useState(...)`; the `loadParticipants` `useCallback`; and the `useEffect(() => { void loadParticipants(); }, [loadParticipants])`.
+
+- [ ] **Step 3: Add the hook + rewrite derived reads**
+
+At the top of `FirstTimePage`, replace the removed state with:
+```tsx
+ const { state, reload } = useApiResource('/checkin/pre-registered');
+ const [query, setQuery] = useState('');
+```
+Rewrite `filteredItems` to read from the `ok` data:
+```tsx
+ const filteredItems = useMemo(() => {
+ if (state.kind !== 'ok') return [];
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) return state.data.participants;
+ return state.data.participants.filter((item) => {
+ const values = [
+ item.fullName,
+ item.nickname,
+ item.grade,
+ item.registeredAt,
+ formatJapaneseDate(item.registeredAt),
+ ];
+ return values.some((value) => value.toLowerCase().includes(normalizedQuery));
+ });
+ }, [query, state]);
+```
+In the data UI, change `state.items.length` → `state.data.participants.length` (the count Badge and the empty-list check), and the inline 更新 button `onClick={() => void loadParticipants()}` → `onClick={reload}`.
+
+- [ ] **Step 4: Replace the full-screen error branch with `CheckinErrorScreen`**
+
+The loading branch (`state.kind === 'loading'`) and both inline Alerts stay unchanged. Replace the error block:
+```tsx
+ if (state.kind === 'error') {
+ return (
+
+
+
+ >
+ }
+ />
+ );
+ }
+```
+
+- [ ] **Step 5: Verify**
+
+Run: `pnpm --filter checkin type-check && pnpm biome check apps/checkin/src/app/first-time/page.tsx`
+Expected: PASS. Manually (`/first-time`): skeleton → list; search filters; empty-list and empty-search Alerts still appear; kill the API → `CheckinErrorScreen` (一覧を表示できません); 再読み込み recovers.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/checkin/src/app/first-time/page.tsx
+git commit -m "$(printf 'refactor(checkin): migrate first-time page to useApiResource\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 4: Migrate `history/page.tsx` GET to `useApiResource` (background 更新)
+
+**Files:**
+- Modify: `apps/checkin/src/app/history/page.tsx`
+
+- [ ] **Step 1: Update imports**
+
+- Add `import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';`.
+- Add `import { CheckinErrorScreen } from '@/components/screen-error';`.
+- Drop `useCallback` from the `react` import (keep `useEffect`, `useMemo`, `useState`).
+- Keep `apiFetch, readErrorMessage` (still used by `postHistoryBulkCheckOut`), and `Alert`/`AlertTitle`/`AlertDescription`/`IconAlertCircle`/`IconHome`/`IconRefresh` (success/mutation-error Alerts + actions).
+
+- [ ] **Step 2: Remove fetch helper, fetch state, fetch effect, old ErrorScreen**
+
+Delete: `fetchTodayHistory` (incl. its `cache: 'no-store'`); the `function ErrorScreen({ message, onRetry })` definition; `const [data, setData] = useState<...>(null)` and `const [isLoading, setIsLoading] = useState(true)`; the `loadSessions` `useCallback`; the `useEffect(() => { void loadSessions(); }, [loadSessions])`; and the old early returns `if (isLoading) return ` and `if (error && !data) return `.
+**Do NOT remove** `nowMs` state, the 60s `setInterval` effect, or any mutation state (`isSubmitting`/`error`/`lastResult`).
+
+- [ ] **Step 3: Add hook + a derived `data` + selection-prune effect**
+
+Replace the removed fetch state with the hook and a single derived `data` so the existing `data?.…` derivations keep compiling:
+```tsx
+ const { state, reload } = useApiResource('/checkin/history/today');
+ // ...keep isSubmitting / error / query / selectedIds / lastResult / nowMs as-is...
+
+ // useApiResource が ok のときだけ実データ。それ以外は null で描画ロジックを共通化。
+ const data = state.kind === 'ok' ? state.data : null;
+```
+Keep the 60s timer effect unchanged. Keep `const sessions = data?.sessions ?? [];` and all `useMemo` derivations. The old `loadSessions` pruned `selectedIds` after each refetch — reproduce that with an effect after `presentIdSet` is defined:
+```tsx
+ // 取得結果が変わったら、もう滞在中でない参加者を選択から外す
+ // (旧 loadSessions の selectedIds 絞り込みを再取得後も維持)。
+ useEffect(() => {
+ setSelectedIds((ids) => ids.filter((id) => presentIdSet.has(id)));
+ }, [presentIdSet]);
+```
+
+- [ ] **Step 4: Background 更新 + render branches**
+
+In `checkoutParticipants`, change the post-success refetch `loadSessions({ showLoading: false })` → `reload({ background: true })` (keeps the table visible; no flash). Change the in-card 更新 button `onClick={() => void loadSessions({ showLoading: false })}` → `onClick={() => reload({ background: true })}`. Add the new branches (place after `checkoutParticipants`, before the `summary`/`eventLabel` derivation):
+```tsx
+ if (state.kind === 'idle' || state.kind === 'loading') {
+ return ;
+ }
+
+ if (state.kind === 'error') {
+ return (
+
+
+
+ >
+ }
+ />
+ );
+ }
+```
+Keep `const summary = data?.summary ?? {...}` and `const eventLabel = data?.event ? ... : '今日'` as optional-chained (TS narrows `state`, not `data`). The success Alert, inline mutation-error Alert, empty-state block, table, and motion stay unchanged.
+
+- [ ] **Step 5: Verify**
+
+Run: `pnpm --filter checkin type-check && pnpm biome check apps/checkin/src/app/history/page.tsx`
+Expected: PASS. Manually (`/history`): skeleton → table; stay-duration updates ~each minute (timer intact); tap 更新 → **no skeleton flash**, data refreshes in place (background reload); bulk checkout → success Alert + list refresh, no flash; kill API + reload → `CheckinErrorScreen` (履歴を表示できません), 再読み込み recovers. (Note: a failed background 更新 surfaces the full error screen — accepted per spec.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/checkin/src/app/history/page.tsx
+git commit -m "$(printf 'refactor(checkin): migrate history page to useApiResource (background refresh)\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 5: Migrate `reception/participants/[id]/page.tsx` GET, split POST state
+
+**Files:**
+- Modify: `apps/checkin/src/app/reception/participants/[id]/page.tsx`
+
+- [ ] **Step 1: Update imports**
+
+- Remove `import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';` (only the old `ErrorScreen` used them).
+- Remove `IconAlertCircle` from the `@tabler/icons-react` import (only the old `ErrorScreen` used it). Keep `IconHome`/`IconArrowBack`/`IconLogin2`/`IconLogout2`.
+- Add `import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';`.
+- Add `import { CheckinErrorScreen } from '@/components/screen-error';`.
+- Keep `apiFetch, readErrorMessage` (used by `postAttendance`). Drop `useEffect` from the React import (the fetch effect is removed; `useCallback` stays — used by `measureTileGrid`).
+
+- [ ] **Step 2: Remove fetch machinery + old ErrorScreen; replace state union**
+
+Delete: the `fetchParticipantProfile` helper (incl. `cache: 'no-store'`); the `function ErrorScreen({ message, participantId })`; the `const [state, setState] = useState(...)`; the `loadProfile` `useCallback`; the fetch `useEffect`; and the four old `state.kind`-based render branches. Replace the `type State` union with a POST-only `Action`:
+```tsx
+// 取得は useApiResource に委譲。ここでは出退場 POST の進行状態だけを持つ。
+type Action =
+ | { kind: 'idle' }
+ | { kind: 'submitting' }
+ | { kind: 'result'; data: ScanResponse }
+ | { kind: 'error'; message: string };
+```
+
+- [ ] **Step 3: Add hook + action state + derived values**
+
+```tsx
+ const params = useParams<{ id: string }>();
+ const participantId = String(params.id ?? '');
+ const prefersReduced = useReducedMotion();
+
+ // 5桁ID以外はそもそも取得しない(取得前のローカル検証エラー)。
+ const isValidId = PARTICIPANT_ID_PATTERN.test(participantId);
+ const { state } = useApiResource(
+ `/checkin/participants/${participantId}`,
+ { enabled: isValidId },
+ );
+
+ const [action, setAction] = useState({ kind: 'idle' });
+
+ const profile = state.kind === 'ok' ? state.data : null;
+ const isSubmitting = action.kind === 'submitting';
+```
+(No retry button on this page → do **not** destructure `reload`; that avoids an unused-var lint.)
+
+- [ ] **Step 4: Rewrite `submitAttendance` to use `action`**
+
+```tsx
+ const submitAttendance = async () => {
+ if (!profile) return;
+ setAction({ kind: 'submitting' });
+ try {
+ const data = await postAttendance(profile.participant.id);
+ setAction({ kind: 'result', data });
+ } catch (e) {
+ setAction({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
+ }
+ };
+```
+
+- [ ] **Step 5: Rewrite render branches (order matters)**
+
+POST `result` and POST `error` and invalid-ID must be checked BEFORE fetch state. Build the shared `footer`/`actions` once (the `ID {participantId}` line as `footer`; ホームに戻る + 入力し直す as `actions`). Branch order:
+1. `if (action.kind === 'result') { ... }` — render the existing `ResultSummaryCard` exactly as before, reading `action.data` (was `state.data`).
+2. `if (!isValidId) { return ID {participantId}
} actions={<>…ホームに戻る…入力し直す…>} /> }`.
+3. `if (action.kind === 'error') { return }`.
+4. `if (state.kind === 'loading' || state.kind === 'idle') { return ; }`.
+5. `if (state.kind === 'error') { return }`.
+6. `if (!profile) return null;`
+
+The shared `actions` JSX (identical in branches 2/3/5):
+```tsx
+<>
+
+
+>
+```
+All JSX below `if (!profile) return null;` (heatmap, history table, motion, `measureTileGrid`) stays unchanged.
+
+- [ ] **Step 6: Verify**
+
+Run: `pnpm --filter checkin type-check && pnpm biome check "apps/checkin/src/app/reception/participants/[id]/page.tsx"`
+Expected: PASS. Manually: valid id → skeleton → profile; check-in/out → `ResultSummaryCard`, no flash back to profile/error; `/reception/participants/abc` → error (5桁の参加者IDを入力してください) with `ID abc` footer + both buttons; valid-format non-existent id → API error in the same screen.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add "apps/checkin/src/app/reception/participants/[id]/page.tsx"
+git commit -m "$(printf 'refactor(checkin): migrate reception detail page to useApiResource\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 6: Migrate `manual/page.tsx` debounced search (drop AbortController)
+
+**Files:**
+- Modify: `apps/checkin/src/app/manual/page.tsx`
+
+- [ ] **Step 1: Update imports**
+
+- Remove `import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';` (only `searchParticipants` used them).
+- Add `import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource';` (mixed value/type import, matching admin).
+- Keep `useEffect`/`useState` (debounce effect stays), `Alert`/`AlertTitle`/`AlertDescription`/`IconAlertCircle` (inline error Alert stays). Do **not** import `CheckinErrorScreen` — this page has no full-screen error.
+
+- [ ] **Step 2: Remove `SearchState`, `searchParticipants`, fetch state + AbortController effect**
+
+Delete the `type SearchState` union; the `searchParticipants` helper (incl. `cache: 'no-store'` + `signal`); the `const [state, setState] = useState({ kind: 'idle' })`; and the entire second `useEffect` (the AbortController fetch). **Keep** the FIRST `useEffect` (300ms debounce).
+
+- [ ] **Step 3: Add derived path + hook in `NameSearchPanel`**
+
+```tsx
+ // 入力のたびに API を叩かないよう 300ms デバウンス(既存のまま)。
+ // debouncedQuery が空なら path=null → フックは idle のまま。
+ // path が変わるとフックが自動で再取得し、古いレスポンスは cancelled フラグで破棄。
+ const searchPath = debouncedQuery
+ ? `/checkin/participants/search?${new URLSearchParams({ q: debouncedQuery }).toString()}`
+ : null;
+ const { state } = useApiResource(searchPath);
+```
+Pass `state={state}` to `` (unchanged prop name).
+
+- [ ] **Step 4: Update `SearchResults` to read `state.data.participants`**
+
+Change the prop type `state: SearchState` → `state: ResourceState`. The idle/loading/error inline UI stays byte-identical. In the `ok`/empty/list branches, change `state.results` → `state.data.participants` (3 reads: the empty-length check, the `{…}件の候補` count, and the `.map`).
+
+- [ ] **Step 5: Verify**
+
+Run: `pnpm --filter checkin type-check && pnpm biome check apps/checkin/src/app/manual/page.tsx`
+Then: `grep -n "apiFetch\|readErrorMessage\|AbortController\|no-store\|SearchState" apps/checkin/src/app/manual/page.tsx` → expect ZERO hits.
+Manually (`/manual` → 名前で探す): idle hint; type → 3 skeletons → results or empty message; fast typing shows no stale flash; kill API → inline destructive Alert (検索に失敗しました), NOT full-screen; tapping a result navigates and disables rows.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/checkin/src/app/manual/page.tsx
+git commit -m "$(printf 'refactor(checkin): migrate manual search to useApiResource\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 7: Migrate `guideline/page.tsx` GET, derive item, split mutation state
+
+**Files:**
+- Modify: `apps/checkin/src/app/guideline/page.tsx`
+
+- [ ] **Step 1: Update imports**
+
+- Add `import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';` and `import { CheckinErrorScreen } from '@/components/screen-error';`.
+- Change `import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';` → `import { apiFetch } from '@tecnova/ui/lib/api-client';` (`apiFetch` stays for activate POST; `readErrorMessage` removed).
+- Remove `useCallback` from the React import (only `loadTarget` used it); keep `useEffect` (arrow-key handler in `GuidelineSlideView`).
+- Remove `Alert, AlertDescription, AlertTitle` (line ~36) and `IconAlertCircle` (icon import) — only the deleted inline `ErrorScreen` used them; confirm no other usage with grep before deleting.
+
+- [ ] **Step 2: Remove old `ErrorScreen`, `loadTarget`, its effect, and fetch render branches; replace state union**
+
+Delete: the `function ErrorScreen({ title, message, item, onRetry })`; the `loadTarget` `useCallback`; the `useEffect(() => { void loadTarget(); }, [loadTarget])`; the old `if (state.kind === 'loading')`, `if (state.kind === 'error')`, and `if (!slide)` branches. Replace the `type State` with a mutation-only union:
+```tsx
+// 取得(pre-registered 一覧)は useApiResource。ここはアクティベート POST の
+// ワークフロー状態のみ(取得状態とは分離)。
+type MutationState =
+ | { kind: 'idle' }
+ | { kind: 'activating'; item: PreRegisteredParticipant }
+ | { kind: 'result'; data: ActivateResponse; registeredAt: string }
+ | { kind: 'error'; message: string; item: PreRegisteredParticipant };
+```
+
+- [ ] **Step 3: Rewrite `GuidelinePageContent` (hook + derived item + activate)**
+
+```tsx
+function GuidelinePageContent() {
+ const searchParams = useSearchParams();
+ const preRegistrationId = searchParams.get('preRegistrationId') ?? '';
+ const [mutation, setMutation] = useState({ kind: 'idle' });
+ const [slideIndex, setSlideIndex] = useState(0);
+ const [agreed, setAgreed] = useState(false);
+ const [direction, setDirection] = useState(1);
+
+ const { state, reload } = useApiResource('/checkin/pre-registered', {
+ enabled: !!preRegistrationId,
+ });
+
+ const item = useMemo(
+ () =>
+ state.kind === 'ok'
+ ? (state.data.participants.find(
+ (participant) => participant.preRegistrationId === preRegistrationId,
+ ) ?? null)
+ : null,
+ [state, preRegistrationId],
+ );
+
+ const goPrev = () => { setDirection(-1); setSlideIndex((i) => Math.max(0, i - 1)); };
+ const goNext = () => { setDirection(1); setSlideIndex((i) => Math.min(GUIDELINE_SLIDES.length - 1, i + 1)); };
+
+ const activate = async () => {
+ if (!item) return;
+ setMutation({ kind: 'activating', item });
+ try {
+ const r = await apiFetch('/checkin/activate', {
+ method: 'POST',
+ body: { preRegistrationId: item.preRegistrationId },
+ });
+ const body = (await r.json()) as ActivateResponse | { error: string; message: string };
+ if (!r.ok) {
+ const msg = 'message' in body ? body.message : `HTTP ${r.status}`;
+ throw new Error(msg);
+ }
+ setMutation({ kind: 'result', data: body as ActivateResponse, registeredAt: item.registeredAt });
+ } catch (e) {
+ setMutation({ kind: 'error', message: e instanceof Error ? e.message : String(e), item });
+ }
+ };
+
+ const slide = useMemo(() => GUIDELINE_SLIDES[slideIndex], [slideIndex]);
+ // ...render branches below...
+}
+```
+(Verify the activate POST body/response shape against the current code; keep it identical — only the `item` source changed from `state.item` to the derived `item`.)
+
+- [ ] **Step 4: Render branches (mutation first, then fetch)**
+
+Order: `activating` → `result` (existing `ResultSummaryCard`, unchanged) → mutation `error` → `!preRegistrationId` (idle) → fetch `loading`/`idle` → fetch `error` → not-found (`!item`) → `!slide` → `GuidelineSlideView`. Five of these are `CheckinErrorScreen`. Per the resolved decision, the **mutation-error** and **fetch/not-found/no-slide** retry buttons all use 再読み込み wired to `reload` (preserves the original, where `onRetry={loadTarget}` re-fetched the list). The `!preRegistrationId` idle case uses ホームに戻る (no retry — matches original idle path). `actions` use the existing 選び直す(`/first-time`) + (再読み込み `onClick={reload}` | ホームに戻る `/`). The mutation-error `footer` is ``. Messages:
+- mutation error: title `登録できませんでした`, message `mutation.message`, footer ParticipantDetails, actions 選び直す + 再読み込み(`reload`).
+- `!preRegistrationId`: title `ガイドラインを表示できません`, message `登録する人を選んでください。`, actions 選び直す + ホームに戻る.
+- fetch error: title `ガイドラインを表示できません`, message `state.message`, actions 選び直す + 再読み込み(`reload`).
+- not-found (`state.kind==='ok' && !item`): message `この事前登録はすでに登録済み、または一覧にありません。`, actions 選び直す + 再読み込み(`reload`).
+- `!slide`: message `ガイドラインを表示できません。`, actions 選び直す + 再読み込み(`reload`).
+
+Finally render ` void activate()} />` (was `state.item`). `LoadingScreen` stays for `state.kind === 'loading' || 'idle'`.
+
+- [ ] **Step 5: Verify**
+
+Run: `pnpm --filter checkin type-check && pnpm biome check apps/checkin/src/app/guideline/page.tsx`
+Then `grep -n "IconAlertCircle\|AlertTitle\|AlertDescription\|` → skeleton → slides → 同意 → activate → 登録できました; `` → not-found + working 再読み込み; kill API → fetch error + 再読み込み.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/checkin/src/app/guideline/page.tsx
+git commit -m "$(printf 'refactor(checkin): migrate guideline page to useApiResource\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 8: Apply background reload to admin refresh/mutation refetch
+
+**Files:**
+- Modify: `apps/admin/src/app/(authed)/page.tsx` (1 site)
+- Modify: `apps/admin/src/app/(authed)/mentors/page.tsx` (3 sites)
+- Modify: `apps/admin/src/app/(authed)/pre-registrations/page.tsx` (3 sites)
+
+All admin `reload()` calls are manual-refresh or post-mutation refetches (none are path-driven), so all opt into `background: true` to remove the skeleton flash.
+
+- [ ] **Step 1: Dashboard refresh button**
+
+In `apps/admin/src/app/(authed)/page.tsx`, change the 更新 button (line ~99):
+```tsx
+onClick={() => sessions.reload({ background: true })}
+```
+
+- [ ] **Step 2: Mentors post-mutation refetch**
+
+In `apps/admin/src/app/(authed)/mentors/page.tsx`, wrap the three `reload` callbacks:
+- ` reload({ background: true })} />`
+- both ` reload({ background: true })} ... />` (card + row variants).
+
+- [ ] **Step 3: Pre-registrations post-mutation refetch**
+
+In `apps/admin/src/app/(authed)/pre-registrations/page.tsx`, wrap the three callbacks:
+- ` reload({ background: true })} />`
+- both `onDeleted={() => reload({ background: true })}`.
+
+- [ ] **Step 4: Verify**
+
+Run: `pnpm --filter admin type-check && pnpm biome check apps/admin/src/app/\(authed\)/page.tsx apps/admin/src/app/\(authed\)/mentors/page.tsx apps/admin/src/app/\(authed\)/pre-registrations/page.tsx`
+Expected: PASS. Manually (admin): dashboard 更新 refreshes **without** a skeleton flash; creating/updating a mentor and creating/deleting a pre-registration update the list in place without a flash; the initial loads and any error states are unchanged.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add "apps/admin/src/app/(authed)/page.tsx" "apps/admin/src/app/(authed)/mentors/page.tsx" "apps/admin/src/app/(authed)/pre-registrations/page.tsx"
+git commit -m "$(printf 'refactor(admin): use background reload for refresh and post-mutation refetch\n\nCo-Authored-By: Claude Opus 4.8 ')"
+```
+
+---
+
+### Task 9: Full verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Type-check all affected workspaces**
+
+Run: `pnpm --filter @tecnova/ui --filter checkin --filter admin type-check`
+Expected: PASS for ui/checkin/admin. (If a stale `.next/dev/types/validator.ts` error appears for checkin from a running dev server, stop the dev server and re-run — that artifact is environmental, not source.)
+
+- [ ] **Step 2: Biome across touched source**
+
+Run: `pnpm biome check packages/ui/src apps/checkin/src apps/admin/src`
+Expected: PASS.
+
+- [ ] **Step 3: Confirm duplication removed**
+
+Run: `grep -rn "type State" apps/checkin/src/app` — expect only mutation/`MutationState`/`Action` unions remain (no fetch-only `loading|list|error` / `loading|ready|error` machines). Run `grep -rn "AbortController\|no-store" apps/checkin/src/app` → expect ZERO hits.
+
+- [ ] **Step 4: Playwright smoke (checkin + admin)**
+
+With `pnpm --filter checkin dev` (:3000) and `pnpm --filter admin dev` (:3001) and `/api/me` + endpoints mocked (route-mock with CORS headers, as in prior sessions), confirm for each checkin page: load → ok, forced error → `CheckinErrorScreen` (or inline for manual), retry/reload recovers, empty states render. Confirm history 更新 and admin refresh/mutation reloads show **no skeleton flash**. Verify `reload()`/navigation return fresh data (no stale cache).
+
+- [ ] **Step 5: Push and open PR**
+
+```bash
+git push -u origin refactor/checkin-data-layer
+gh pr create --base develop --head refactor/checkin-data-layer \
+ --title "refactor(checkin): propagate useApiResource + add background reload" \
+ --body "$(printf '## 概要\ncheckin の 5 つの GET を共有 useApiResource に統合し、全画面エラーを CheckinErrorScreen に集約。共有フックに background(SWR) reload を追加し、history 更新と admin の更新/ミューテーション再取得のちらつきを解消。signage は対象外、認証は不変。\n\n設計: docs/superpowers/specs/2026-06-02-checkin-data-layer-propagation-design.md\n計画: docs/superpowers/plans/2026-06-02-checkin-data-layer-propagation.md\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)')"
+```
+
+---
+
+## Notes on resolved decisions
+
+- **history 更新**: uses `reload({ background: true })` — no skeleton flash on success. A failed background refresh surfaces the full `CheckinErrorScreen` (consistent with admin's reload-on-error behavior); accepted per spec.
+- **guideline activate-error**: retry button is 再読み込み wired to `reload` (preserves the original `onRetry={loadTarget}` semantics — re-fetches the list, not the POST).
+- **reception POST failure**: routes to the full-screen error (`action.kind === 'error'`), preserving the original behavior.
+- **admin**: all `reload()` callers opt into `background: true` (the opted-in flash fix). Initial loads and path-driven refetches (search/filter/pagination/date) are unchanged.
From 66a27f499f44bf87a88f92f5d700f1f879e83984 Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:03:36 +0900
Subject: [PATCH 04/12] feat(ui): add background (stale-while-revalidate)
reload to useApiResource
reload({ background: true }) keeps current data visible during refetch
instead of flipping to loading, for flicker-free refresh buttons.
Backward compatible: default is the existing loading behavior.
Co-Authored-By: Claude Opus 4.8
---
packages/ui/src/hooks/use-api-resource.ts | 38 +++++++++++++++++------
1 file changed, 28 insertions(+), 10 deletions(-)
diff --git a/packages/ui/src/hooks/use-api-resource.ts b/packages/ui/src/hooks/use-api-resource.ts
index 5c7ad0e..e99f7aa 100644
--- a/packages/ui/src/hooks/use-api-resource.ts
+++ b/packages/ui/src/hooks/use-api-resource.ts
@@ -1,7 +1,7 @@
'use client';
import { apiErrorMessage, apiJson } from '@tecnova/ui/lib/api-client';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
// 取得状態。idle = まだ取得していない(path=null / enabled=false)。
export type ResourceState =
@@ -10,9 +10,16 @@ export type ResourceState =
| { kind: 'ok'; data: T }
| { kind: 'error'; message: string };
+export interface ReloadOptions {
+ // true のとき、既にデータがある場合は loading に戻さず裏で再取得する
+ // (stale-while-revalidate)。更新ボタンのちらつき回避用。初回取得・path 変更は
+ // 従来どおり loading を表示する。
+ background?: boolean;
+}
+
export interface UseApiResourceResult {
state: ResourceState;
- reload: () => void;
+ reload: (opts?: ReloadOptions) => void;
}
export interface UseApiResourceOptions {
@@ -21,14 +28,12 @@ export interface UseApiResourceOptions {
}
// path から JSON を取得し loading|ok|error|idle を返す読み取り専用フック。
-// admin の各画面で重複していた取得+状態機械を 1 箇所に集約する。
// - path が null か enabled=false のとき idle(取得しない)。
-// - path が変わると自動で再取得する(クエリ文字列を path に含めて
-// 検索・フィルタ・ページング・日付変更を表現する)。
-// - reload() で手動再取得(更新ボタン・ミューテーション後の再読込)。
+// - path が変わると自動で再取得する(クエリ文字列を path に含めて表現する)。
+// - reload() で手動再取得。reload({ background: true }) は表示中のデータを
+// 保持したまま裏で再取得する(ちらつき回避)。
// アンマウントやパラメータ変更時に古いレスポンスで setState しないよう
-// cancelled フラグでガードする(participant-detail-sheet の実装を踏襲)。
-// ミューテーション(POST/PATCH/DELETE)は扱わない。
+// cancelled フラグでガードする。ミューテーションは扱わない。
export const useApiResource = (
path: string | null,
options?: UseApiResourceOptions,
@@ -39,8 +44,14 @@ export const useApiResource = (
path && enabled ? { kind: 'loading' } : { kind: 'idle' },
);
const [reloadKey, setReloadKey] = useState(0);
+ // 直近の reload がバックグラウンド要求だったかを次の effect 実行に伝える。
+ const backgroundReloadRef = useRef(false);
+ // effect の依存に state を入れずに最新値を参照するための ref。
+ const stateRef = useRef(state);
+ stateRef.current = state;
- const reload = useCallback(() => {
+ const reload = useCallback((opts?: ReloadOptions) => {
+ backgroundReloadRef.current = opts?.background ?? false;
setReloadKey((k) => k + 1);
}, []);
@@ -52,8 +63,15 @@ export const useApiResource = (
setState({ kind: 'idle' });
return;
}
+ const background = backgroundReloadRef.current;
+ backgroundReloadRef.current = false;
let cancelled = false;
- setState({ kind: 'loading' });
+ // バックグラウンド再取得かつ既にデータ表示中なら loading に戻さず、
+ // 現在のデータを表示したまま裏で更新する。それ以外(初回・path 変更・
+ // エラーからの再取得)は従来どおり loading を表示する。
+ if (!(background && stateRef.current.kind === 'ok')) {
+ setState({ kind: 'loading' });
+ }
void (async () => {
try {
const data = await apiJson(path);
From 2cd233be2a638235243756b65261ee7ccf477395 Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:07:29 +0900
Subject: [PATCH 05/12] feat(checkin): add shared CheckinErrorScreen for
full-screen kiosk errors
Co-Authored-By: Claude Opus 4.8
---
apps/checkin/src/components/screen-error.tsx | 30 ++++++++++++++++++++
1 file changed, 30 insertions(+)
create mode 100644 apps/checkin/src/components/screen-error.tsx
diff --git a/apps/checkin/src/components/screen-error.tsx b/apps/checkin/src/components/screen-error.tsx
new file mode 100644
index 0000000..3d63240
--- /dev/null
+++ b/apps/checkin/src/components/screen-error.tsx
@@ -0,0 +1,30 @@
+import { IconAlertCircle } from '@tabler/icons-react';
+import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
+import type { ReactNode } from 'react';
+
+// checkin(iPad キオスク)共通の全画面エラー。bg-rose-50 の全画面 +
+// destructive Alert。ページ固有のボタンを actions に、任意の補足
+// (ID 行 / 詳細カード等)を footer に渡す。inline の小さなエラーは対象外。
+export function CheckinErrorScreen({
+ title,
+ message,
+ actions,
+ footer,
+}: {
+ title: string;
+ message: string;
+ actions: ReactNode;
+ footer?: ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+ {message}
+
+ {footer}
+ {actions}
+
+ );
+}
From 2955fa674d0809dd55e3e8477975324d8298cd50 Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:12:14 +0900
Subject: [PATCH 06/12] refactor(checkin): migrate first-time page to
useApiResource
Co-Authored-By: Claude Opus 4.8
---
apps/checkin/src/app/first-time/page.tsx | 85 +++++++++---------------
1 file changed, 30 insertions(+), 55 deletions(-)
diff --git a/apps/checkin/src/app/first-time/page.tsx b/apps/checkin/src/app/first-time/page.tsx
index 6354c30..d195fb3 100644
--- a/apps/checkin/src/app/first-time/page.tsx
+++ b/apps/checkin/src/app/first-time/page.tsx
@@ -28,22 +28,18 @@ import { Card, CardContent, CardDescription, CardHeader } from '@tecnova/ui/comp
import { Input } from '@tecnova/ui/components/input';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table';
-import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
+import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';
import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import { AnimatedNumber } from '@/components/animated-number';
import { PageShell } from '@/components/page-shell';
import { PanelHeader } from '@/components/panel-header';
import { Reveal } from '@/components/reveal';
+import { CheckinErrorScreen } from '@/components/screen-error';
import { formatJapaneseDate } from '@/lib/format';
import { listItemTransition } from '@/lib/motion';
-type State =
- | { kind: 'loading' }
- | { kind: 'list'; items: PreRegisteredListResponse['participants'] }
- | { kind: 'error'; message: string };
-
type PreRegisteredParticipant = PreRegisteredListResponse['participants'][number];
function ParticipantDetails({ item }: { item: PreRegisteredParticipant }) {
@@ -124,33 +120,14 @@ function RegistrationSteps() {
export default function FirstTimePage() {
const prefersReduced = useReducedMotion();
- const [state, setState] = useState({ kind: 'loading' });
+ const { state, reload } = useApiResource('/checkin/pre-registered');
const [query, setQuery] = useState('');
- const loadParticipants = useCallback(async () => {
- setState({ kind: 'loading' });
- try {
- const r = await apiFetch('/checkin/pre-registered');
- if (!r.ok) throw new Error(await readErrorMessage(r));
- const data = (await r.json()) as PreRegisteredListResponse;
- setState({ kind: 'list', items: data.participants });
- } catch (e) {
- setState({
- kind: 'error',
- message: e instanceof Error ? e.message : String(e),
- });
- }
- }, []);
-
- useEffect(() => {
- void loadParticipants();
- }, [loadParticipants]);
-
const filteredItems = useMemo(() => {
- if (state.kind !== 'list') return [];
+ if (state.kind !== 'ok') return [];
const normalizedQuery = query.trim().toLowerCase();
- if (!normalizedQuery) return state.items;
- return state.items.filter((item) => {
+ if (!normalizedQuery) return state.data.participants;
+ return state.data.participants.filter((item) => {
const values = [
item.fullName,
item.nickname,
@@ -162,7 +139,7 @@ export default function FirstTimePage() {
});
}, [query, state]);
- if (state.kind === 'loading') {
+ if (state.kind === 'idle' || state.kind === 'loading') {
return (
@@ -189,27 +166,21 @@ export default function FirstTimePage() {
if (state.kind === 'error') {
return (
-
-
-
- 一覧を表示できません
- {state.message}
-
-
-
-
-
-
+
+
+
+ >
+ }
+ />
);
}
@@ -233,14 +204,18 @@ export default function FirstTimePage() {
style={{ height: 'auto' }}
className="w-fit px-4 py-2 text-base"
>
- 未登録
+ 未登録{' '}
+
人
- {state.items.length === 0 ? (
+ {state.data.participants.length === 0 ? (
今、登録できる人はいません
@@ -282,7 +257,7 @@ export default function FirstTimePage() {
type="button"
variant="outline"
size="lg"
- onClick={() => void loadParticipants()}
+ onClick={() => reload()}
className="h-14 text-lg"
>
From ccd4b0bd73e1870353f4244887c48fc15eddeb41 Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:17:50 +0900
Subject: [PATCH 07/12] refactor(checkin): migrate history page to
useApiResource (background refresh)
Co-Authored-By: Claude Opus 4.8
---
apps/checkin/src/app/history/page.tsx | 113 ++++++++++----------------
1 file changed, 43 insertions(+), 70 deletions(-)
diff --git a/apps/checkin/src/app/history/page.tsx b/apps/checkin/src/app/history/page.tsx
index 94f5ba3..3eb0a9a 100644
--- a/apps/checkin/src/app/history/page.tsx
+++ b/apps/checkin/src/app/history/page.tsx
@@ -42,15 +42,17 @@ import {
TableRow,
} from '@tecnova/ui/components/table';
import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge';
+import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';
import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { AnimatedNumber } from '@/components/animated-number';
import { LiveDot } from '@/components/live-dot';
import { PageShell } from '@/components/page-shell';
import { PanelHeader } from '@/components/panel-header';
import { Reveal } from '@/components/reveal';
+import { CheckinErrorScreen } from '@/components/screen-error';
import { StatTile } from '@/components/stat-tile';
import {
formatDuration,
@@ -61,14 +63,6 @@ import {
import { listItemTransition } from '@/lib/motion';
import { participantProfilePath } from '@/lib/participant-id';
-const fetchTodayHistory = async (): Promise => {
- const response = await apiFetch('/checkin/history/today', { cache: 'no-store' });
- if (!response.ok) {
- throw new Error(await readErrorMessage(response));
- }
- return (await response.json()) as TodaySessionsResponse;
-};
-
const postHistoryBulkCheckOut = async (
participantIds: string[],
): Promise => {
@@ -108,36 +102,6 @@ function LoadingScreen() {
);
}
-function ErrorScreen({ message, onRetry }: { message: string; onRetry: () => void }) {
- return (
-
-
-
- 履歴を表示できません
- {message}
-
-
-
-
-
-
- );
-}
-
function CheckoutDialog({
buttonLabel,
count,
@@ -185,8 +149,7 @@ function CheckoutDialog({
export default function HistoryPage() {
const prefersReduced = useReducedMotion();
- const [data, setData] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
+ const { state, reload } = useApiResource('/checkin/history/today');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [query, setQuery] = useState('');
@@ -194,30 +157,8 @@ export default function HistoryPage() {
const [lastResult, setLastResult] = useState(null);
const [nowMs, setNowMs] = useState(() => Date.now());
- const loadSessions = useCallback(
- async ({ showLoading = true }: { showLoading?: boolean } = {}) => {
- if (showLoading) setIsLoading(true);
- setError(null);
- try {
- const result = await fetchTodayHistory();
- setData(result);
- setSelectedIds((ids) =>
- ids.filter((id) =>
- result.sessions.some((session) => session.isPresent && session.participantId === id),
- ),
- );
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- } finally {
- if (showLoading) setIsLoading(false);
- }
- },
- [],
- );
-
- useEffect(() => {
- void loadSessions();
- }, [loadSessions]);
+ // useApiResource が ok のときだけ実データ。それ以外は null で描画ロジックを共通化。
+ const data = state.kind === 'ok' ? state.data : null;
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 60_000);
@@ -230,6 +171,13 @@ export default function HistoryPage() {
[sessions],
);
const presentIdSet = useMemo(() => new Set(presentIds), [presentIds]);
+
+ // 取得結果が変わったら、もう滞在中でない参加者を選択から外す
+ // (旧 loadSessions の selectedIds 絞り込みを再取得後も維持)。
+ useEffect(() => {
+ setSelectedIds((ids) => ids.filter((id) => presentIdSet.has(id)));
+ }, [presentIdSet]);
+
const selectedPresentIds = useMemo(
() => selectedIds.filter((id) => presentIdSet.has(id)),
[presentIdSet, selectedIds],
@@ -290,7 +238,7 @@ export default function HistoryPage() {
const result = await postHistoryBulkCheckOut(targetIds);
setLastResult(result);
setSelectedIds([]);
- await loadSessions({ showLoading: false });
+ reload({ background: true });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -298,12 +246,37 @@ export default function HistoryPage() {
}
};
- if (isLoading) {
+ if (state.kind === 'idle' || state.kind === 'loading') {
return ;
}
- if (error && !data) {
- return void loadSessions()} />;
+ if (state.kind === 'error') {
+ return (
+
+
+
+ >
+ }
+ />
+ );
}
const summary = data?.summary ?? { totalCheckedIn: 0, currentlyPresent: 0, checkedOut: 0 };
@@ -333,7 +306,7 @@ export default function HistoryPage() {
variant="outline"
size="lg"
disabled={isSubmitting}
- onClick={() => void loadSessions({ showLoading: false })}
+ onClick={() => reload({ background: true })}
className="h-14 text-lg"
>
From bf213841dd3aed5d6905049f81d6cad7e7bfa462 Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:23:30 +0900
Subject: [PATCH 08/12] refactor(checkin): migrate reception detail page to
useApiResource
Co-Authored-By: Claude Opus 4.8
---
.../app/reception/participants/[id]/page.tsx | 155 +++++++++---------
1 file changed, 78 insertions(+), 77 deletions(-)
diff --git a/apps/checkin/src/app/reception/participants/[id]/page.tsx b/apps/checkin/src/app/reception/participants/[id]/page.tsx
index 9682c81..95c4765 100644
--- a/apps/checkin/src/app/reception/participants/[id]/page.tsx
+++ b/apps/checkin/src/app/reception/participants/[id]/page.tsx
@@ -1,7 +1,6 @@
'use client';
import {
- IconAlertCircle,
IconArrowBack,
IconAward,
IconCalendarEvent,
@@ -20,7 +19,6 @@ import {
} from '@tabler/icons-react';
import type { ParticipantProfileResponse, ScanResponse } from '@tecnova/shared/schemas';
import { TERM_LABELS } from '@tecnova/shared/venue-schedule';
-import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Badge } from '@tecnova/ui/components/badge';
import { Button } from '@tecnova/ui/components/button';
import { Card, CardContent, CardHeader, CardTitle } from '@tecnova/ui/components/card';
@@ -34,14 +32,16 @@ import {
TableRow,
} from '@tecnova/ui/components/table';
import { TermBadge, UncountedBadge } from '@tecnova/ui/components/term-badge';
+import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';
import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
import { motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
-import { useCallback, useEffect, useMemo, useState, ViewTransition } from 'react';
+import { useCallback, useMemo, useState, ViewTransition } from 'react';
import { AnimatedNumber } from '@/components/animated-number';
import { PanelHeader } from '@/components/panel-header';
import { ResultSummaryCard } from '@/components/result-summary-card';
+import { CheckinErrorScreen } from '@/components/screen-error';
import {
formatDuration,
formatJapaneseDateFromIso,
@@ -50,25 +50,13 @@ import {
} from '@/lib/format';
import { PARTICIPANT_ID_PATTERN } from '@/lib/participant-id';
-type State =
- | { kind: 'loading' }
- | { kind: 'ready'; profile: ParticipantProfileResponse }
- | { kind: 'submitting'; profile: ParticipantProfileResponse }
+// 取得は useApiResource に委譲。ここでは出退場 POST の進行状態だけを持つ。
+type Action =
+ | { kind: 'idle' }
+ | { kind: 'submitting' }
| { kind: 'result'; data: ScanResponse }
| { kind: 'error'; message: string };
-const fetchParticipantProfile = async (
- participantId: string,
-): Promise => {
- const response = await apiFetch(`/checkin/participants/${participantId}`, {
- cache: 'no-store',
- });
- if (!response.ok) {
- throw new Error(await readErrorMessage(response));
- }
- return (await response.json()) as ParticipantProfileResponse;
-};
-
const postAttendance = async (participantId: string): Promise => {
const response = await apiFetch(`/checkin/participants/${participantId}/attendance`, {
method: 'POST',
@@ -167,59 +155,22 @@ function LoadingScreen() {
);
}
-function ErrorScreen({ message, participantId }: { message: string; participantId: string }) {
- return (
-
-
-
- 参加者を表示できません
- {message}
-
-
-
-
-
- ID {participantId}
-
- );
-}
-
export default function ReceptionParticipantPage() {
const params = useParams<{ id: string }>();
const participantId = String(params.id ?? '');
- const [state, setState] = useState({ kind: 'loading' });
const prefersReduced = useReducedMotion();
- const loadProfile = useCallback(async () => {
- if (!PARTICIPANT_ID_PATTERN.test(participantId)) {
- setState({ kind: 'error', message: '5桁の参加者IDを入力してください' });
- return;
- }
- setState({ kind: 'loading' });
- try {
- const profile = await fetchParticipantProfile(participantId);
- setState({ kind: 'ready', profile });
- } catch (e) {
- setState({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
- }
- }, [participantId]);
+ // 5桁ID以外はそもそも取得しない(取得前のローカル検証エラー)。
+ const isValidId = PARTICIPANT_ID_PATTERN.test(participantId);
+ const { state } = useApiResource(
+ `/checkin/participants/${participantId}`,
+ { enabled: isValidId },
+ );
- useEffect(() => {
- void loadProfile();
- }, [loadProfile]);
+ const [action, setAction] = useState({ kind: 'idle' });
- const profile = state.kind === 'ready' || state.kind === 'submitting' ? state.profile : null;
- const isSubmitting = state.kind === 'submitting';
+ const profile = state.kind === 'ok' ? state.data : null;
+ const isSubmitting = action.kind === 'submitting';
const nextAction = profile?.current.nextAction ?? 'check_in';
const isCheckIn = nextAction === 'check_in';
@@ -313,25 +264,38 @@ export default function ReceptionParticipantPage() {
const submitAttendance = async () => {
if (!profile) return;
- setState({ kind: 'submitting', profile });
+ setAction({ kind: 'submitting' });
try {
const data = await postAttendance(profile.participant.id);
- setState({ kind: 'result', data });
+ setAction({ kind: 'result', data });
} catch (e) {
- setState({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
+ setAction({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
}
};
- if (state.kind === 'loading') {
- return ;
- }
-
- if (state.kind === 'error') {
- return ;
- }
+ // エラー画面で共有するボタン群と ID 行。invalid-ID / POST error / fetch error で使い回す。
+ const errorActions = (
+ <>
+
+
+ >
+ );
+ const errorIdFooter = (
+ ID {participantId}
+ );
- if (state.kind === 'result') {
- const data = state.data;
+ if (action.kind === 'result') {
+ const data = action.data;
const didCheckIn = data.action === 'check_in';
const resultRows =
data.action === 'check_in'
@@ -367,6 +331,43 @@ export default function ReceptionParticipantPage() {
);
}
+ if (!isValidId) {
+ return (
+
+ );
+ }
+
+ if (action.kind === 'error') {
+ return (
+
+ );
+ }
+
+ if (state.kind === 'loading' || state.kind === 'idle') {
+ return ;
+ }
+
+ if (state.kind === 'error') {
+ return (
+
+ );
+ }
+
if (!profile) return null;
return (
From 2f45ff9b0fe6e06c8a1cfd3ada6753f62ae57379 Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:28:05 +0900
Subject: [PATCH 09/12] refactor(checkin): migrate manual search to
useApiResource
Co-Authored-By: Claude Opus 4.8
---
apps/checkin/src/app/manual/page.tsx | 59 ++++++----------------------
1 file changed, 11 insertions(+), 48 deletions(-)
diff --git a/apps/checkin/src/app/manual/page.tsx b/apps/checkin/src/app/manual/page.tsx
index b78c74e..e1bd69a 100644
--- a/apps/checkin/src/app/manual/page.tsx
+++ b/apps/checkin/src/app/manual/page.tsx
@@ -15,7 +15,7 @@ import { Button } from '@tecnova/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter } from '@tecnova/ui/components/card';
import { Input } from '@tecnova/ui/components/input';
import { Skeleton } from '@tecnova/ui/components/skeleton';
-import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
+import { type ResourceState, useApiResource } from '@tecnova/ui/hooks/use-api-resource';
import { cn } from '@tecnova/ui/lib/utils';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { useRouter } from 'next/navigation';
@@ -115,32 +115,10 @@ function IdEntryPanel() {
);
}
-type SearchState =
- | { kind: 'idle' }
- | { kind: 'loading' }
- | { kind: 'ok'; results: ParticipantSearchResponse['participants'] }
- | { kind: 'error'; message: string };
-
-const searchParticipants = async (
- query: string,
- signal: AbortSignal,
-): Promise => {
- const params = new URLSearchParams({ q: query });
- const response = await apiFetch(`/checkin/participants/search?${params.toString()}`, {
- cache: 'no-store',
- signal,
- });
- if (!response.ok) {
- throw new Error(await readErrorMessage(response));
- }
- return (await response.json()) as ParticipantSearchResponse;
-};
-
function NameSearchPanel() {
const router = useRouter();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
- const [state, setState] = useState({ kind: 'idle' });
const [navigatingId, setNavigatingId] = useState(null);
// 入力のたびに API を叩かないよう 300ms デバウンス。
@@ -149,27 +127,12 @@ function NameSearchPanel() {
return () => clearTimeout(id);
}, [query]);
- useEffect(() => {
- if (!debouncedQuery) {
- setState({ kind: 'idle' });
- return;
- }
- const controller = new AbortController();
- setState({ kind: 'loading' });
- void (async () => {
- try {
- const data = await searchParticipants(debouncedQuery, controller.signal);
- setState({ kind: 'ok', results: data.participants });
- } catch (e) {
- if (e instanceof DOMException && e.name === 'AbortError') return;
- setState({
- kind: 'error',
- message: e instanceof Error ? e.message : String(e),
- });
- }
- })();
- return () => controller.abort();
- }, [debouncedQuery]);
+ // debouncedQuery が空なら path=null → フックは idle のまま。
+ // path が変わるとフックが自動で再取得し、古いレスポンスは cancelled フラグで破棄。
+ const searchPath = debouncedQuery
+ ? `/checkin/participants/search?${new URLSearchParams({ q: debouncedQuery }).toString()}`
+ : null;
+ const { state } = useApiResource(searchPath);
const handleSelect = (participantId: string) => {
if (navigatingId) return;
@@ -213,7 +176,7 @@ function SearchResults({
onSelect,
}: {
query: string;
- state: SearchState;
+ state: ResourceState;
navigatingId: string | null;
onSelect: (id: string) => void;
}) {
@@ -248,7 +211,7 @@ function SearchResults({
);
}
- if (state.results.length === 0) {
+ if (state.data.participants.length === 0) {
return (
「{query}」に一致する参加者が見つかりませんでした
@@ -259,10 +222,10 @@ function SearchResults({
return (
- {state.results.length}件の候補(タップして開く)
+ {state.data.participants.length}件の候補(タップして開く)
- {state.results.map((participant, index) => (
+ {state.data.participants.map((participant, index) => (
Date: Wed, 3 Jun 2026 00:33:46 +0900
Subject: [PATCH 10/12] refactor(checkin): migrate guideline page to
useApiResource
Co-Authored-By: Claude Opus 4.8
---
apps/checkin/src/app/guideline/page.tsx | 253 ++++++++++++------------
1 file changed, 128 insertions(+), 125 deletions(-)
diff --git a/apps/checkin/src/app/guideline/page.tsx b/apps/checkin/src/app/guideline/page.tsx
index 95557dd..732cfbe 100644
--- a/apps/checkin/src/app/guideline/page.tsx
+++ b/apps/checkin/src/app/guideline/page.tsx
@@ -1,7 +1,6 @@
'use client';
import {
- IconAlertCircle,
IconArrowLeft,
IconBook,
IconBottle,
@@ -33,31 +32,33 @@ import type {
PreRegisteredListResponse,
PreRegisteredParticipant,
} from '@tecnova/shared/schemas';
-import { Alert, AlertDescription, AlertTitle } from '@tecnova/ui/components/alert';
import { Badge } from '@tecnova/ui/components/badge';
import { Button } from '@tecnova/ui/components/button';
import { Card, CardContent } from '@tecnova/ui/components/card';
import { Checkbox } from '@tecnova/ui/components/checkbox';
import { Skeleton } from '@tecnova/ui/components/skeleton';
import { Table, TableBody, TableCell, TableRow } from '@tecnova/ui/components/table';
-import { apiFetch, readErrorMessage } from '@tecnova/ui/lib/api-client';
+import { useApiResource } from '@tecnova/ui/hooks/use-api-resource';
+import { apiFetch } from '@tecnova/ui/lib/api-client';
import { cn } from '@tecnova/ui/lib/utils';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
-import { type ReactNode, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { type ReactNode, Suspense, useEffect, useMemo, useState } from 'react';
import { PageShell } from '@/components/page-shell';
import { PanelHeader } from '@/components/panel-header';
import { ResultSummaryCard } from '@/components/result-summary-card';
+import { CheckinErrorScreen } from '@/components/screen-error';
import { formatJapaneseDate, formatJapaneseDateTime } from '@/lib/format';
import { popAnimate, popInitial, popTransition } from '@/lib/motion';
-type State =
- | { kind: 'loading' }
- | { kind: 'ready'; item: PreRegisteredParticipant }
+// 取得(pre-registered 一覧)は useApiResource。ここはアクティベート POST の
+// ワークフロー状態のみ(取得状態とは分離)。
+type MutationState =
+ | { kind: 'idle' }
| { kind: 'activating'; item: PreRegisteredParticipant }
| { kind: 'result'; data: ActivateResponse; registeredAt: string }
- | { kind: 'error'; message: string; item?: PreRegisteredParticipant };
+ | { kind: 'error'; message: string; item: PreRegisteredParticipant };
type GuidelineTone = 'emerald' | 'sky' | 'amber' | 'rose' | 'slate';
@@ -396,60 +397,6 @@ function LoadingScreen() {
);
}
-function ErrorScreen({
- title = 'ガイドラインを表示できません',
- message,
- item,
- onRetry,
-}: {
- title?: string;
- message: string;
- item?: PreRegisteredParticipant;
- onRetry?: () => void;
-}) {
- return (
-
-
-
- {title}
- {message}
-
- {item ? (
-
- ) : null}
-
-
- {onRetry ? (
-
- ) : (
-
- )}
-
-
- );
-}
-
function ActivatingScreen({ item }: { item: PreRegisteredParticipant }) {
return (
@@ -666,11 +613,25 @@ function GuidelineSlideView({
function GuidelinePageContent() {
const searchParams = useSearchParams();
const preRegistrationId = searchParams.get('preRegistrationId') ?? '';
- const [state, setState] = useState({ kind: 'loading' });
+ const [mutation, setMutation] = useState({ kind: 'idle' });
const [slideIndex, setSlideIndex] = useState(0);
const [agreed, setAgreed] = useState(false);
const [direction, setDirection] = useState(1);
+ const { state, reload } = useApiResource('/checkin/pre-registered', {
+ enabled: !!preRegistrationId,
+ });
+
+ const item = useMemo(
+ () =>
+ state.kind === 'ok'
+ ? (state.data.participants.find(
+ (participant) => participant.preRegistrationId === preRegistrationId,
+ ) ?? null)
+ : null,
+ [state, preRegistrationId],
+ );
+
const goPrev = () => {
setDirection(-1);
setSlideIndex((index) => Math.max(0, index - 1));
@@ -680,42 +641,9 @@ function GuidelinePageContent() {
setSlideIndex((index) => Math.min(GUIDELINE_SLIDES.length - 1, index + 1));
};
- const loadTarget = useCallback(async () => {
- if (!preRegistrationId) {
- setState({ kind: 'error', message: '登録する人を選んでください。' });
- return;
- }
-
- setState({ kind: 'loading' });
- setSlideIndex(0);
- setAgreed(false);
- try {
- const r = await apiFetch('/checkin/pre-registered');
- if (!r.ok) throw new Error(await readErrorMessage(r));
- const data = (await r.json()) as PreRegisteredListResponse;
- const item = data.participants.find(
- (participant) => participant.preRegistrationId === preRegistrationId,
- );
- if (!item) {
- throw new Error('この事前登録はすでに登録済み、または一覧にありません。');
- }
- setState({ kind: 'ready', item });
- } catch (e) {
- setState({
- kind: 'error',
- message: e instanceof Error ? e.message : String(e),
- });
- }
- }, [preRegistrationId]);
-
- useEffect(() => {
- void loadTarget();
- }, [loadTarget]);
-
const activate = async () => {
- if (state.kind !== 'ready') return;
- const { item } = state;
- setState({ kind: 'activating', item });
+ if (!item) return;
+ setMutation({ kind: 'activating', item });
try {
const r = await apiFetch('/checkin/activate', {
method: 'POST',
@@ -726,13 +654,13 @@ function GuidelinePageContent() {
const msg = 'message' in body ? body.message : `HTTP ${r.status}`;
throw new Error(msg);
}
- setState({
+ setMutation({
kind: 'result',
data: body as ActivateResponse,
registeredAt: item.registeredAt,
});
} catch (e) {
- setState({
+ setMutation({
kind: 'error',
message: e instanceof Error ? e.message : String(e),
item,
@@ -742,38 +670,45 @@ function GuidelinePageContent() {
const slide = useMemo(() => GUIDELINE_SLIDES[slideIndex], [slideIndex]);
- if (state.kind === 'loading') {
- return ;
- }
-
- if (state.kind === 'error') {
- return (
- void loadTarget()}
- />
- );
- }
+ // ページ固有のエラー画面ボタン。選び直す(/first-time)+ 再読み込み(一覧再取得)。
+ const retryActions = (
+ <>
+
+
+ >
+ );
- if (state.kind === 'activating') {
- return ;
+ if (mutation.kind === 'activating') {
+ return ;
}
- if (state.kind === 'result') {
+ if (mutation.kind === 'result') {
return (
}
rows={[
- { label: 'ID', value: state.data.participantId, valueClassName: 'tabular-nums' },
- { label: '氏名', value: state.data.fullName },
- { label: 'ニックネーム', value: state.data.nickname },
- { label: '学年', value: state.data.grade },
- { label: '初回チェックイン', value: formatJapaneseDateTime(state.data.checkedInAt) },
- { label: '事前登録日', value: formatJapaneseDate(state.registeredAt) },
+ { label: 'ID', value: mutation.data.participantId, valueClassName: 'tabular-nums' },
+ { label: '氏名', value: mutation.data.fullName },
+ { label: 'ニックネーム', value: mutation.data.nickname },
+ { label: '学年', value: mutation.data.grade },
+ { label: '初回チェックイン', value: formatJapaneseDateTime(mutation.data.checkedInAt) },
+ { label: '事前登録日', value: formatJapaneseDate(mutation.registeredAt) },
]}
note="表示されたIDでカードを作ってください"
footer={
@@ -788,15 +723,83 @@ function GuidelinePageContent() {
);
}
+ if (mutation.kind === 'error') {
+ return (
+
+
+
+ }
+ actions={retryActions}
+ />
+ );
+ }
+
+ if (!preRegistrationId) {
+ return (
+
+
+
+ >
+ }
+ />
+ );
+ }
+
+ if (state.kind === 'loading' || state.kind === 'idle') {
+ return ;
+ }
+
+ if (state.kind === 'error') {
+ return (
+
+ );
+ }
+
+ if (!item) {
+ return (
+
+ );
+ }
+
if (!slide) {
return (
- void loadTarget()} />
+
);
}
return (
Date: Wed, 3 Jun 2026 00:39:23 +0900
Subject: [PATCH 11/12] refactor(admin): use background reload for refresh and
post-mutation refetch
Co-Authored-By: Claude Opus 4.8
---
apps/admin/src/app/(authed)/mentors/page.tsx | 16 +++++++++++++---
apps/admin/src/app/(authed)/page.tsx | 2 +-
.../src/app/(authed)/pre-registrations/page.tsx | 6 +++---
3 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/apps/admin/src/app/(authed)/mentors/page.tsx b/apps/admin/src/app/(authed)/mentors/page.tsx
index 426478e..4fa49e3 100644
--- a/apps/admin/src/app/(authed)/mentors/page.tsx
+++ b/apps/admin/src/app/(authed)/mentors/page.tsx
@@ -78,7 +78,7 @@ export default function MentorsPage() {
-
+ reload({ background: true })} />
{/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。
@@ -110,7 +110,12 @@ export default function MentorsPage() {
保存すれば再取得で両者が同期するため許容するトレードオフ。 */}
{state.data.mentors.map((m) => (
-
+ reload({ background: true })}
+ variant="card"
+ />
))}
@@ -130,7 +135,12 @@ export default function MentorsPage() {
{state.data.mentors.map((m) => (
-
+ reload({ background: true })}
+ variant="row"
+ />
))}
diff --git a/apps/admin/src/app/(authed)/page.tsx b/apps/admin/src/app/(authed)/page.tsx
index f80880a..602cf01 100644
--- a/apps/admin/src/app/(authed)/page.tsx
+++ b/apps/admin/src/app/(authed)/page.tsx
@@ -96,7 +96,7 @@ export default function DashboardPage() {
type="button"
variant="outline"
size="sm"
- onClick={() => sessions.reload()}
+ onClick={() => sessions.reload({ background: true })}
disabled={sessions.state.kind === 'loading'}
>
diff --git a/apps/admin/src/app/(authed)/pre-registrations/page.tsx b/apps/admin/src/app/(authed)/pre-registrations/page.tsx
index 6c5a995..c552c0a 100644
--- a/apps/admin/src/app/(authed)/pre-registrations/page.tsx
+++ b/apps/admin/src/app/(authed)/pre-registrations/page.tsx
@@ -101,7 +101,7 @@ export default function PreRegistrationsPage() {
-
+ reload({ background: true })} />
{/* データ領域。Reveal を常時マウントして入場は一度だけ(再フェッチで再生されない)。
@@ -133,7 +133,7 @@ export default function PreRegistrationsPage() {
reload({ background: true })}
variant="card"
/>
))}
@@ -157,7 +157,7 @@ export default function PreRegistrationsPage() {
reload({ background: true })}
variant="row"
/>
))}
From 412b60594c9772e2112e4f6a92eb741cc15cae7e Mon Sep 17 00:00:00 2001
From: tmba
Date: Wed, 3 Jun 2026 00:54:47 +0900
Subject: [PATCH 12/12] fix(checkin): remove redundant selectedIds prune effect
(render loop)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The prune useEffect depended on presentIdSet, which is recreated every
render while state != ok (sessions = data?.sessions ?? [] makes a fresh
[] each render), so the effect ran every render and setSelectedIds
(ids.filter always returns a new array) re-rendered forever — "Maximum
update depth exceeded" on the loading screen.
The effect was also redundant: selectedPresentIds already filters
selectedIds by presentIdSet at use-time, and every consumer (count,
checkbox state, checkout) reads the present-filtered value, so stale
stored selections are inert. Deleting the effect fixes the loop with no
behavioral change.
Co-Authored-By: Claude Opus 4.8
---
apps/checkin/src/app/history/page.tsx | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/apps/checkin/src/app/history/page.tsx b/apps/checkin/src/app/history/page.tsx
index 3eb0a9a..a78688a 100644
--- a/apps/checkin/src/app/history/page.tsx
+++ b/apps/checkin/src/app/history/page.tsx
@@ -172,12 +172,11 @@ export default function HistoryPage() {
);
const presentIdSet = useMemo(() => new Set(presentIds), [presentIds]);
- // 取得結果が変わったら、もう滞在中でない参加者を選択から外す
- // (旧 loadSessions の selectedIds 絞り込みを再取得後も維持)。
- useEffect(() => {
- setSelectedIds((ids) => ids.filter((id) => presentIdSet.has(id)));
- }, [presentIdSet]);
-
+ // 選択は保存値(selectedIds)を prune せず、使用時に present で絞り込む。
+ // 再取得で滞在中でなくなった選択は selectedPresentIds 以降(カウント・チェック状態・
+ // チェックアウト対象)すべてで除外されるため、保存値を間引く effect は不要。
+ // (presentIdSet を deps にした setSelectedIds は、データ到着前の loading 中に
+ // presentIdSet が毎レンダー再生成されるため無限ループになる)
const selectedPresentIds = useMemo(
() => selectedIds.filter((id) => presentIdSet.has(id)),
[presentIdSet, selectedIds],