テーマ
Implementation Plan
本 spec は
data-model/tasks.mdとcandidate-ranking/tasks.md(rankCandidates純関数)、voting/tasks.md(useVoteFlow/useVotingPeriodStatus)の完了を前提とする。本 spec はこれら他 spec 所有のファイルを 再実装せず、import するのみとする。
[ ] 1. Foundation: 画面層型・フック
[x] 1.1 RankedCandidate 型の re-export
features/candidates/types.tsを実装し、candidate-rankingspec 所有のRankedCandidateを import して再 export する- 本 spec で型を再定義しないこと(
import type { RankedCandidate } from '@/features/candidates/utils/rankCandidates'を経由) - Requirements: 2.2, 4.1
- Boundary: features/candidates/types.ts
[x] 1.2 useCandidateListWithRank フック
features/candidates/hooks/useCandidateListWithRank.tsを実装し、useOptimisticでRankedCandidate[]を管理するOptimisticVoteMutation = { candidateId: string; delta: number }を Readonly で export する- reducer は
voteCountのみを更新し、rankフィールドを書き換えないこと - 戻り値の配列が引数
initialと同じ ID 昇順を維持することをテストで確認できる構造にする - Requirements: 3.2, 4.2
- Boundary: features/candidates/hooks/useCandidateListWithRank.ts
- Depends: 1.1
[x] 1.3 (P) useScrollToTop フック
features/candidates/hooks/useScrollToTop.tsを実装し、スクロール位置が一定値を超えた時にtrueを返す- クリーンアップで
removeEventListenerを呼ぶこと - Requirements: —(UX 補助)
- Boundary: features/candidates/hooks/useScrollToTop.ts
[ ] 2. Core: UI コンポーネント
[x] 2.1 (P) HomeHero コンポーネント
components/public/HomeHero.tsxを実装し、props{ votingPeriod: { startsAt: Date; endsAt: Date } }を受け取る- サイトタイトル「Miss World Japan 2026」、サブコピー「多様な個性が響き合い、可能性を解き放つ」を固定文言で表示する
- 投票期間を
Intl.DateTimeFormat('ja-JP', ...)で整形して表示する(具体的な日付や年をハードコードしない) <Link href="/ranking">でランキング画面遷移ボタンを設置する- Requirements: 1.2
- Boundary: components/public/HomeHero.tsx
[x] 2.2 (P) CandidateCard コンポーネント
components/public/CandidateCard.tsxを実装し、props{ candidate: RankedCandidate; voteLabel: '投票する' | '投票期間外'; voteDisabled: boolean; onPaidVote: (c: Candidate) => void; isPriority: boolean }を受け取る- 画像:
next/imageのsrcにcandidate.images[0].url(microCMS CDN URL)を直接渡す sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"を固定で指定isPriority=trueのときpriority属性を付与する- 表示要素: 候補者画像 /
rank/displayName/voteCount.toLocaleString('ja-JP')+ 「票」 / 投票ボタン - 詳細遷移は
<Link href={\/candidate/${candidate.id}`}>` で実装 - 投票ボタン押下で
onPaidVote(candidate)を発火する。voteDisabled=trueのとき disabled とする React.memoでラップする- Requirements: 2.2, 5.1
- Boundary: components/public/CandidateCard.tsx
- Depends: 1.1
[x] 2.3 CandidateGrid コンポーネント
components/public/CandidateGrid.tsxを実装し、props{ candidates: RankedCandidate[]; voteLabel; voteDisabled; onPaidVote }を受け取る- props 順をそのまま render 順として保持する(再ソートしない)
- 先頭 4 件のみ
isPriority=trueを<CandidateCard>に渡す(LCP 対策) - 0 件の場合は空状態 UI(
ui-design.md文言)を描画する - Requirements: 2.1, 3.1
- Boundary: components/public/CandidateGrid.tsx
- Depends: 2.2
[x] 2.4 (P) Confetti コンポーネント
components/public/Confetti.tsxを実装し、props{ show: boolean }を受け取るmotionを使った投票成功演出をshow=trueのとき表示するuseEffectのクリーンアップでclearTimeoutを呼びリークを防ぐ- Requirements: 2.2(投票導線の演出)
- Boundary: components/public/Confetti.tsx
[ ] 3. Integration: Home 画面
[x] 3.1 HomePageClient
app/_components/HomePageClient.tsxを実装し、props{ candidates: RankedCandidate[]; votingPeriod: { startsAt: Date; endsAt: Date }; creditPackages: CreditPackage[] }を受け取る'use client'を含めるuseCandidateListWithRank(candidates)/useVoteFlow()(voting spec 所有) /useVotingPeriodStatus(votingPeriod)(voting spec 所有) /useScrollToTop()を呼ぶuseState<'purchase' | null>のようなモーダル個別 state を 持たない(useVoteFlowが一元管理)- 子に
<HomeHero votingPeriod>/<CandidateGrid candidates voteLabel voteDisabled onPaidVote>/<PurchaseFlowContainer ...>(voting spec 所有) /<Confetti show>を配置する useVoteFlow.handlePaidVoteConfirmed経由でaddOptimisticVoteを呼ぶ配線を実装する- Requirements: 2.1, 3.2, 4.2
- _Boundary: app/components/HomePageClient.tsx
- Depends: 1.2, 1.3, 2.1, 2.3, 2.4
[x] 3.2 Server Component: Home ページ
app/page.tsxを Server Component として実装する('use client'を含めない)metadata = { title: 'Miss World Japan 2026', description: '多様な個性が響き合い、可能性を解き放つ' }を export する- 取得順序: 先頭で
getVotingPeriod()を 同期呼び出し(throw を error.tsx に伝播)。次にPromise.all([getCandidates(), getActiveCreditPackages()])、続けてgetVoteCountsByCandidateIds(candidateIds)を呼ぶ withVotes = candidates.map(c => ({ ...c, voteCount: voteCounts.get(c.id) ?? 0 }))を構築するrankCandidates(withVotes)(candidate-ranking spec 所有)を呼んで rank マップを取得し、withVotesを id 昇順に並べ直したうえでrankを付与する<HomePageClient candidates votingPeriod creditPackages />を返す- 公開済み候補者のみ集計対象に渡すこと(
getCandidates()の戻り値 ID をそのままgetVoteCountsByCandidateIdsに渡す) /を Dev Server で開いて DOM 順序が id 昇順、各カードの rank が voteCount 降順 + id タイブレークに一致することを確認する- Requirements: 1.1, 1.2, 1.3, 3.1, 4.1, 4.3
- Boundary: app/page.tsx
- Depends: 3.1
[x] 3.3 error.tsx フォールバック
app/error.tsxを Client Component として実装し、InvalidVotingPeriodErrorを含む取得失敗時に「開催情報を取得できませんでした」等のメッセージ(文言はui-design.md)を表示するreset()ボタンを設置する(Next.js 標準の error boundary 仕様)<HomePageClient>/<HomeHero>/<CandidateGrid>が一切描画されないことを統合テストで確認する- Requirements: 1.4
- Boundary: app/error.tsx
- Depends: 3.2
[ ] 4. 検証
[x] 4.1 (P) useCandidateListWithRank ユニットテスト
addOptimisticVote({ candidateId, delta: 5 })で対象候補者のvoteCountのみが +5 され、他は変化しないことを確認するrankフィールドが mutation 前後で完全一致することを確認する(rank 不変の検証)- 並び順(id 昇順)が mutation 前後で変わらないことを確認する
- Requirements: 3.2, 4.2
- Boundary: features/candidates/hooks/useCandidateListWithRank.ts
- Depends: 1.2
[x] 4.2 (P) コンポーネントテスト
<HomeHero>: 固定文言・整形された期間文字列・/rankingリンクが描画されることをスナップショットで確認する<CandidateCard>: voteLabel が「投票する」「投票期間外」のそれぞれで投票ボタンの disabled 状態が切り替わることを確認する<CandidateGrid>: 先頭 4 件のみisPriorityプロップが付与されること、空配列で空状態 UI が出ることを確認する- Requirements: 1.2, 2.1, 2.2
- Boundary: components/public/**
- Depends: 2.1, 2.2, 2.3
[x] 4.3 統合テスト: ページ合成とエラー伝播
- モック化した
candidateService/voteAggregationService/creditPackageService/lib/voting-periodから得た値でapp/page.tsxが rank と id 昇順表示を確定することを確認する - 同得票時の rank が id 昇順タイブレークで決定することを確認する(要件 4.3)
getVotingPeriodがInvalidVotingPeriodErrorを投げた場合に<HomePageClient>が 呼ばれないこと とapp/error.tsxの fallback が描画されることを確認する(要件 1.4)- Requirements: 1.4, 4.1, 4.3
- Boundary: app/page.tsx + app/error.tsx
- Depends: 3.2, 3.3
- モック化した
[ ] 4.4 E2E (Playwright)
/を開き、候補者カードが id 昇順で並ぶことを DOM で確認- カードクリックで
/candidate/[id]に遷移すること、ランキングボタンで/rankingに遷移することを確認 - カードの投票ボタン押下 → 購入モーダル表示 → Stripe テストキーで決済成功 → Confetti と voteCount 増加(rank 不変)を確認する
- 投票期間外シナリオ(Vercel ENV を操作したテスト環境)で投票ボタンが「投票期間外」表示・disabled になることを確認する
- Requirements: 1.1, 2.1, 2.2, 3.1, 4.1, 5.1
- Boundary: app/**
- Depends: 3.2, 3.3
- Blocked: Playwright および Stripe テストキーを用いた E2E 基盤は voting spec Task 7.3「E2E(Playwright + Stripe テストキー)」が単独所有する shared platform に依存する。voting/7.3 が完了して E2E ハーネス(playwright.config / e2e ディレクトリ / Stripe テストキー注入手順)が確立した後に本タスクを再開すること。home spec 内で playwright 依存を独自追加するのは boundary 違反となるため不可。
Implementation Notes
- (4.3)
app/page.test.tsxでHomePageClientを import するとPurchaseFlowContainer → usePurchaseFlow → @/app/actions/purchase → @/lib/prismaの経路で Prisma client がロードされ、.prisma/client/default未生成環境では起動時に suite が失敗する。テストでvi.mock("@/app/actions/purchase", ...)を入れて連鎖を切ること。 - (4.2) jsdom テストで
Intl.DateTimeFormat('ja-JP', ...)を呼ぶ場合、CI/local TZ がAsia/Tokyoでないと表示日が +9h ずれる。HomeHero スナップショットは同じ formatter で生成した期待値と比較する self-consistent パターンで耐性を持たせている。本番表示と完全一致させたい場合は CI のTZ=Asia/Tokyo固定を検討。 - (4.4 Blocked) E2E は voting spec Task 7.3 が所有する Playwright + Stripe テストキー基盤に依存。home spec 単独では実装しない。