Skip to content

Implementation Plan

本 spec は data-model/tasks.mdcandidate-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-ranking spec 所有の 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 を実装し、useOptimisticRankedCandidate[] を管理する
    • 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/imagesrccandidate.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)
    • getVotingPeriodInvalidVotingPeriodError を投げた場合に <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.tsxHomePageClient を 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 単独では実装しない。