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 ( +
+ + + {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 ( +
+ + + {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 ( +
+ + + {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 ( -
- - -
- - -
-
+ + + + + } + /> ); } @@ -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 ? (