Skip to content

Implementation Plan

本 spec は data-model/tasks.md(getCandidate / getCandidates / getVoteCountsByCandidateIds / getTopVotersByCandidateId / lib/voting-period)、candidate-ranking/tasks.md(rankCandidates 純関数 + RankedCandidate 型)、voting/tasks.md(useVoteFlow / useVotingPeriodStatus / <PurchaseFlowContainer>)の完了を前提とする。本 spec は他 spec 所有のファイルを 再実装せず、import するのみとする。 特に Vote.nickname は voting spec の Webhook ハンドラで normalizeNickname 適用済み値で保存されている前提で集計を扱うこと(応援者ランキングの同一支援者集計の正しさはこの不変条件に依存)。

  • [ ] 1. Foundation: フック

  • [x] 1.1 useImageSlider 純フック

    • features/candidates/hooks/useImageSlider.ts を実装し、props images: ReadonlyArray<{ url: string }> を受け取って { currentIndex, hasMultiple, goTo, next, prev } を返す
    • hasMultipleimages.length > 1 の真偽値
    • goTo(index) は範囲外 index を無視する(state 更新しない)
    • next() / prev() は循環せず、端で stop する(最後で next を呼んでも index が増えない、先頭で prev も同様)
    • Requirements: 5.2, 5.5
    • Boundary: features/candidates/hooks/useImageSlider.ts
  • [ ] 2. Core: UI コンポーネント

  • [x] 2.1 (P) DetailHeader コンポーネント

    • components/public/DetailHeader.tsx を実装し、props { candidate: RankedCandidate } を受け取る
    • 「現在 N 位」「候補者名」「N 票」(toLocaleString('ja-JP') で 3 桁区切り)を表示する
    • 候補者一覧(/)とランキング(/ranking)へ戻る導線(<Link> 2 つ)を含める
    • Requirements: 2.1, 3.1
    • Boundary: components/public/DetailHeader.tsx
  • [x] 2.2 CandidateImageSlider コンポーネント

    • components/public/CandidateImageSlider.tsx を実装し、props { images: ReadonlyArray<{ url: string }>; altPrefix: string } を受け取る
    • 内部で useImageSlider(images) を呼び、メイン画像 + サムネイル一覧を描画する
    • メイン画像は next/imagesrc に microCMS CDN URL を直渡し、最初の画像に priority 属性を付与する
    • サムネイルクリックで goTo(index) を呼ぶ。現在表示中のサムネイルに aria-current="true" と視覚的強調を付与する
    • hasMultiple === false のとき、サムネイル一覧と prev/next 操作 UI を 非表示 にする
    • alt 属性は ${altPrefix} (n/N) の形式で出力する
    • Requirements: 5.1, 5.2, 5.3, 5.4, 5.5
    • Boundary: components/public/CandidateImageSlider.tsx
    • Depends: 1.1
  • [x] 2.3 (P) CandidateInfo コンポーネント

    • components/public/CandidateInfo.tsx を実装し、props { candidate: Candidate } を受け取る
    • プロフィール表として出身地(candidate.region)・職業(candidate.occupation)・身長(candidate.height + 「cm」)・趣味/特技(candidate.hobbies)・資格/大会実績(candidate.certifications)を表示する
    • 趣味/特技および資格/大会実績はリスト要素(<ul><li>)で列挙する
    • PR メッセージ(candidate.message)を独立セクションとして表示し、white-space: pre-line 相当で改行を保持する
    • PR メッセージセクションが他プロフィール項目と視覚的に区別される構造(別 <section> 等)にする
    • Requirements: 6.1, 6.2, 7.1, 7.2
    • Boundary: components/public/CandidateInfo.tsx
  • [x] 2.4 (P) VoterRanking コンポーネント

    • components/public/VoterRanking.tsx を実装し、props { voters: ReadonlyArray<{ nickname: string; voteCount: number }> } を受け取る
    • 各 voter について順位(1-5、配列 index + 1)・ニックネーム・投票数(toLocaleString('ja-JP') + 「票」)を表示する
    • props 順をそのまま render 順として保持する(再ソートしない、service 側で voteCount 降順済みを信頼する)
    • voters が空配列のとき、空状態 UI(ui-design.md 文言)を描画する
    • Requirements: 8.1, 8.2, 8.4
    • Boundary: components/public/VoterRanking.tsx
  • [x] 2.5 (P) PaidVoteButton コンポーネント

    • components/public/PaidVoteButton.tsx を実装し、props { label: '投票する' | '投票期間外'; disabled: boolean; onClick: () => void } を受け取る
    • disabled=true のとき HTML disabled 属性 + aria-disabled="true" を付与し、onClick を発火させない
    • Requirements: 4.1
    • Boundary: components/public/PaidVoteButton.tsx
  • [ ] 3. Integration: 候補者詳細画面

  • [x] 3.1 CandidateDetailClient

    • app/candidate/[id]/_components/CandidateDetailClient.tsx を実装し、props { candidate: RankedCandidate; topVoters; creditPackages; votingPeriod } を受け取る
    • 'use client' を含める
    • useOptimistic(candidate, (current, mutation: { delta: number }) => ({ ...current, voteCount: current.voteCount + mutation.delta })) で楽観更新する
    • reducer は voteCount のみを更新し、rank を絶対に書き換えないこと
    • useVoteFlow()(voting spec) / useVotingPeriodStatus(votingPeriod)(voting spec) を呼ぶ
    • useState<'purchase' | null> のようなモーダル個別 state を 持たない
    • 子に <DetailHeader> / <CandidateImageSlider> / <CandidateInfo> / <VoterRanking> / <PaidVoteButton> / <PurchaseFlowContainer>(voting spec) / <Confetti>(home spec の Confetti を共有してよい) を配置する
    • <PaidVoteButton>label / disableduseVotingPeriodStatus の戻り値から渡す
    • useVoteFlow.handlePaidVoteConfirmed({ totalCredits }) から addOptimisticVote({ delta: totalCredits }) を呼ぶ配線を実装する
    • Requirements: 3.3, 4.1, 9.1
    • _Boundary: app/candidate/[id]/components/CandidateDetailClient.tsx
    • Depends: 2.1, 2.2, 2.3, 2.4, 2.5
  • [x] 3.2 Server Component: 候補者詳細ページ

    • app/candidate/[id]/page.tsx を Server Component として実装する
    • generateMetadata({ params }) を export し、getCandidate(id) で取得した候補者から title: \${displayName} | Miss World Japan 2026`description: message.slice(0, 120)openGraph.images: [images[0]?.url]を返す。存在しない場合は` を返す
    • default async function CandidateDetailPage({ params }): まず getCandidate(id) で単体取得し、null なら notFound() を呼ぶ
    • 続けて getVotingPeriod() を同期呼び出し(throw を error.tsx に伝播)
    • Promise.all([getCandidates(), getTopVotersByCandidateId(id, 5), getActiveCreditPackages()]) で並列取得
    • getVoteCountsByCandidateIds(allCandidates.map(c => c.id)) で集計を取得し、rankCandidates(allCandidates.map(c => ({ ...c, voteCount: voteCounts.get(c.id) ?? 0 }))) で順位確定する
    • target = ranked.find(c => c.id === id) を抽出し、見つからなければ整合性異常として notFound() を呼ぶ(microCMS と Neon の不整合への保険)
    • <CandidateDetailClient candidate={target} topVoters creditPackages votingPeriod /> を返す
    • getCandidategenerateMetadata 経由の getCandidatedata-model spec の Request Memoization 契約により dedupe される ことを Network タブで確認する(dedupe されない場合は data-model spec の Revalidation Trigger に該当)
    • 存在する ID を Dev Server で開いて DOM に名前・順位・得票数・プロフィール・PR・応援者ランキングが表示されることを確認する
    • 存在しない ID で 404 ページが出ることを確認する
    • Requirements: 1.1, 1.2, 1.3, 3.2, 3.4, 8.1
    • Boundary: app/candidate/[id]/page.tsx
    • Depends: 3.1
  • [ ] 4. 検証

  • [x] 4.1 (P) useImageSlider ユニットテスト

    • 初期 index が 0 であることを確認する
    • goTo(2) で index が 2 になることを確認する
    • 範囲外 index(-1 / images.length)で goTo を呼んでも state が変化しないことを確認する
    • next / prev の端での stop 動作を確認する
    • images.length=1hasMultiple=false となることを確認する
    • Requirements: 5.2, 5.5
    • Boundary: features/candidates/hooks/useImageSlider.ts
    • Depends: 1.1
  • [x] 4.2 (P) コンポーネントスナップショット/単体テスト

    • <CandidateInfo>: 改行を含む PR メッセージが段落構造を保持して描画されることを確認する(要件 7.1)
    • <CandidateInfo>: hobbies / certifications が複数項目のリスト要素として列挙されることを確認する(要件 6.2)
    • <VoterRanking>: 空配列で空状態 UI が描画されること、5 件で 5 行が描画されることを確認する(要件 8.1, 8.4)
    • <PaidVoteButton>: disabled=true で click ハンドラが呼ばれないことを確認する(要件 4.1)
    • <CandidateImageSlider>: images.length=1 でサムネイル一覧と prev/next 操作 UI が非描画になることを確認する(要件 5.5)
    • <CandidateImageSlider>: サムネイル click でメイン画像が切り替わり、aria-current が更新されることを確認する(要件 5.2, 5.4)
    • Requirements: 4.1, 5.2, 5.4, 5.5, 6.2, 7.1, 8.1, 8.4
    • Boundary: components/public/**
    • Depends: 2.1, 2.2, 2.3, 2.4, 2.5
  • [x] 4.3 統合テスト: page.tsx と generateMetadata

    • モック化した getCandidate / getCandidates / getVoteCountsByCandidateIds / getTopVotersByCandidateId / getActiveCreditPackages / getVotingPeriodpage.tsx を呼び、<CandidateDetailClient>rank 付き RankedCandidate が渡ることを確認する(要件 1.1, 3.2)
    • getCandidate(id)=nullnotFound() が呼ばれることを確認する(要件 1.2)
    • 存在する ID で getCandidates から target が抽出できない異常ケースで notFound() が呼ばれることを確認する(整合性保険)
    • generateMetadata が candidate.displayName をタイトルに、candidate.images[0].url を OGP に含めることを確認する(要件 1.3, 非機能-メタデータ)
    • <CandidateDetailClient> 統合: useVoteFlow.handlePaidVoteConfirmedvoteCount のみ更新、rank 不変を確認する(要件 3.3)
    • Requirements: 1.1, 1.2, 1.3, 3.2, 3.3
    • Boundary: app/candidate/[id]/**
    • Depends: 3.2
  • [ ] 4.4 E2E (Playwright) Blocked: Playwright が repo に未導入 (package.json に @playwright/test なし)。e2e 環境のセットアップは本 spec の範囲外で、別途インフラ spec での導入が必要

    • 存在する ID(/candidate/[id])を開く → 名前・順位・得票数・プロフィール・PR・応援者ランキングが DOM に出ることを確認
    • サムネイルクリック → メイン画像が切り替わることを確認(要件 5.2)
    • 投票ボタン押下 → 購入モーダル → Stripe テストキーで決済成功 → Confetti と voteCount 増加(rank 不変)を確認(要件 3.3, 9.1)
    • 投票期間外シナリオでボタンラベルが「投票期間外」かつ disabled になることを確認(要件 4.1)
    • 存在しない ID で 404 ページが表示されることを確認(要件 1.2)
    • 一覧戻り導線(<DetailHeader>/ リンク) / ランキング戻り導線(/ranking リンク)が機能することを確認(要件 2.1)
    • 同一ニックネームで複数回投票したテストデータで、応援者ランキングがニックネーム単位で集計されていること(同一文字列が 1 行に集約)を確認する(要件 8.3)
    • Requirements: 1.2, 2.1, 3.3, 4.1, 5.2, 8.3, 9.1
    • Boundary: app/candidate/[id]/**
    • Depends: 3.2

Implementation Notes

  • 3.1 CandidateDetailClient: React 19 useOptimistic は pending Action 完了後に base state へ revert する仕様のため、投票成功後も voteCount 増分を持続させる目的で useState<number>(0) の累積 delta を追加し、useOptimistic の base に合成。reducer は依然 voteCount のみ更新、rank 不変。サーバー再取得で candidate prop が変わると累積は新しい base に追従する。home spec の <Confetti> 未実装のため、useVoteFlow().showConfettirole="status" aria-live="polite" のフィードバック領域で暫定提供。home spec ship 時に差し替え予定。
  • 3.2 page.tsx: getCandidate は microcms-js-sdk 経由で 404 を null ではなく Error として throw する。design.md は if (!candidate) notFound() と書かれているが、実装は tryGetCandidate(id) で 404 のみ catch して null に変換する wrapper パターンに修正。他のエラーは error.tsx に伝播。data-model spec 側の契約更新が望ましい (Follow-up: /kiro-spec-design data-model)。
  • 3.2 page.tsx: getActiveCreditPackages の戻り値が ReadonlyArray<CreditPackage><CandidateDetailClient> の props 型 CreditPackage[] と非互換のため as CreditPackage[] キャスト。Client 側で破壊的変更は行わない (read-only 利用)。Props 型側の ReadonlyArray 化を将来検討。
  • 4.1 / 4.2 / 4.3: 検証フェーズで列挙された unit / snapshot / integration の各テスト項目は 1.1〜3.2 の実装ターン内で TDD として既に追加・GREEN 化済み。具体的な対応箇所は以下の通り。
    • 4.1: features/candidates/hooks/useImageSlider.test.ts (13 ケース) で初期値・hasMultiple・goTo 範囲外無視・next/prev の端 stop・空配列を網羅。
    • 4.2: components/public/{CandidateInfo,VoterRanking,PaidVoteButton,CandidateImageSlider}.test.tsx に該当ケースを実装済み。改行保持 (whitespace-pre-line)、リスト構造、空配列空状態、disabled 時 click 抑止、画像 1 枚での切替 UI 非描画、サムネイル click による aria-current 更新を確認。
    • 4.3: app/candidate/[id]/page.test.tsx (9 ケース) で getCandidate null → notFound()、target 抽出失敗時 notFound()、generateMetadata の title / OGP、<CandidateDetailClient> への RankedCandidate 受け渡しを検証。app/candidate/[id]/_components/CandidateDetailClient.test.tsxhandlePaidVoteConfirmed → voteCount のみ更新・rank 不変を検証。
  • 4.4 E2E (Playwright): 本リポジトリには Playwright が未導入 (package.json の devDependencies に @playwright/test なし、e2e/ ディレクトリも存在しない)。E2E テストインフラの導入は本 spec の境界外と判断し、専用の infra/e2e spec で対応する想定。手動検証は /kiro-validate-impl 段階でユーザー側 Dev Server 起動により実施。