Skip to content

Implementation Plan

本 spec は data-model/tasks.md 完了を前提とする(candidateService.getCandidates / voteAggregationService.getVoteCountsByCandidateIds が利用可能)。rankCandidates 純関数は本 spec が単独所有し、home / candidate-detail 等が import して再定義しないこと。

  • [ ] 1. Foundation: ランキング順位確定ユーティリティ

  • [x] 1.1 rankCandidates 純関数を実装

    • features/candidates/utils/rankCandidates.tsCandidateWithVotes / RankedCandidate / RankedCandidateWithShare の Readonly 型を export する
    • rankCandidates(items) を実装し、voteCount 降順 + id 昇順タイブレークでソート後、rank を 1 始まりで付与した新配列を返す
    • 入力配列をミューテートしないこと([...items].sort(...) で複製)
    • 入力が空配列なら戻り値も空配列となる
    • home / candidate-detail 等が import { rankCandidates } from '@/features/candidates/utils/rankCandidates' で参照可能になることを確認する
    • Requirements: 5.1
    • Boundary: features/candidates/utils/rankCandidates.ts
  • [ ] 2. Core: UI コンポーネント層

  • [x] 2.1 (P) 順位アイコンコンポーネント

    • components/public/RankIcon.tsx を実装し、props { rank: number } を受け取って 1 / 2 / 3 / 4+ の 4 区分でアイコンを出し分ける
    • 具体ビジュアル(色・トロフィー・サイズ)は ui-design.md の指定に従う
    • rank=1/2/3/4 のスナップショットで異なる DOM が出力されることを確認する
    • Requirements: 3.1, 3.2
    • Boundary: components/public/RankIcon.tsx
  • [x] 2.2 (P) 得票率バーコンポーネント

    • components/public/VoteShareBar.tsx を実装し、props { share: number }(0〜1)を受け取ってバー幅を share * 100% で表示する
    • share=0 で空バー、share=1 で満タンバーが表示される
    • 計算ロジックを持たず、受け取った値をそのまま反映する
    • Requirements: 2.1
    • Boundary: components/public/VoteShareBar.tsx
  • [x] 2.3 (P) ホーム戻り導線コンポーネント

    • components/public/BackToHomeLink.tsx を実装し、<Link href="/"> で候補者一覧画面(ホーム)への遷移を提供する
    • ページ上部に配置されることを RankingPageClient 側で配置する前提の Props 設計とする
    • Requirements: 1.2
    • Boundary: components/public/BackToHomeLink.tsx
  • [x] 2.4 (P) ランキング見出し領域

    • components/public/RankingHeader.tsx を実装し、見出し(「現在のランキング」等、文言は ui-design.md に従う)を表示する
    • 投票期間表示など補助情報は本タスクに含めず、純粋な見出しコンポーネントとする
    • Requirements: 1.1
    • Boundary: components/public/RankingHeader.tsx
  • [x] 2.5 個別順位行コンポーネント

    • components/public/RankingItem.tsx を実装し、props { candidate: Candidate; rank: number; share: number; voteCount: number } を受け取る
    • 順位(<RankIcon rank={rank} />)、候補者画像(candidate.images[0]?.urlnext/image に直渡し)、候補者名、得票数(toLocaleString('ja-JP') + 「票」)、<VoteShareBar share={share} /> を表示する
    • 行全体を <Link href={\/candidate/${candidate.id}`}>` でラップする
    • 投票ボタン・購入モーダルを 含めない(本 spec の Out of Boundary)
    • rank=1/2/3/10 のスナップショットテストで DOM 差分が出ることを確認する
    • Requirements: 2.1, 3.1, 4.1
    • Boundary: components/public/RankingItem.tsx
    • Depends: 2.1, 2.2
  • [x] 2.6 ランキング一覧ラッパ

    • components/public/RankingList.tsx を実装し、props { items: ReadonlyArray<RankedCandidateWithShare> } を受け取って <RankingItem> を順次描画する
    • 空配列のとき空状態 UI(「候補者が登録されていません」等、文言は ui-design.md)を描画する
    • props 順をそのまま render 順として保持する(再ソートしない)
    • Requirements: 1.1
    • Boundary: components/public/RankingList.tsx
    • Depends: 2.5
  • [ ] 3. Integration: ランキングページ

  • [x] 3.1 Client ラッパ

    • app/ranking/_components/RankingPageClient.tsx を実装し、props { items, maxVotes } を受け取って <BackToHomeLink> / <RankingHeader> / <RankingList> を配置する
    • 状態を持たないことを確認する(useState / useEffect を使わない)
    • 'use client' を含めない(純粋な配布コンポーネント)か、必要なら 'use client' を付けて軽量化する設計判断を本タスク内でコメントとして記す
    • Requirements: 1.1, 1.2
    • _Boundary: app/ranking/components/RankingPageClient.tsx
    • Depends: 2.3, 2.4, 2.6
  • [x] 3.2 Server Component: ランキングページ

    • app/ranking/page.tsx を Server Component として実装する
    • getCandidates()getVoteCountsByCandidateIds(ids) を呼び、候補者に voteCount を結合する
    • rankCandidates(...)RankedCandidate[] 型で受け、maxVotes = items[0]?.voteCount ?? 0 を計算する
    • items.map(c => ({ ...c, share: maxVotes > 0 ? c.voteCount / maxVotes : 0 }))RankedCandidateWithShare[] を生成する
    • <RankingPageClient items={itemsWithShare} maxVotes={maxVotes} /> を返す
    • metadata = { title: 'ランキング | Miss World Japan 2026' } を export する
    • /ranking を Dev Server で開いて DOM 順序が voteCount 降順 + 同票時 ID 昇順になることを確認する
    • Requirements: 1.1, 5.1
    • Boundary: app/ranking/page.tsx
    • Depends: 1.1, 3.1
  • [ ] 4. 検証

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

    • 通常ケース(5 候補がバラバラの voteCount を持つ)で降順 + rank 1〜5 が付与されることを確認する
    • 同票タイブレーク(2 候補が同 voteCount、ID が "b" < "c")で b が先に来ることを確認する
    • 全員 0 票で ID 昇順の rank 付与となることを確認する
    • 空配列入力で空配列が返ることを確認する
    • 入力配列がミューテートされないことを確認する
    • Requirements: 5.1
    • Boundary: features/candidates/utils/rankCandidates.ts
    • Depends: 1.1
  • [x] 4.2 (P) コンポーネントのスナップショットテスト

    • <RankingItem> を rank=1/2/3/4/10、voteCount=0 / 100 / 1234567、share=0/0.5/1 のパターンで描画し、<RankIcon> と数値表示が期待通り切り替わることを確認する
    • <RankingList> が空配列で空状態 UI を出し、3 件で 3 行を描画することを確認する
    • Requirements: 2.1, 3.1
    • Boundary: components/public/**
    • Depends: 2.5, 2.6
  • [ ] 4.3 ランキングページ統合テスト + E2E

    • モック化した candidateServicevoteAggregationService から得た値で app/ranking/page.tsx が正しい rank / share を渡すことを統合テストで確認する
    • Playwright E2E: /ranking を開き、DOM 順序が voteCount 降順 + 同票時 ID 昇順になっていることを検証する
    • E2E: 行クリックで /candidate/[id] へ遷移すること、<BackToHomeLink> クリックで / に戻ることを検証する
    • Requirements: 1.1, 1.2, 4.1, 5.1
    • Boundary: app/ranking/**
    • Depends: 3.2
    • Blocked: 統合テスト bullet は Task 3.2 の app/ranking/page.test.tsx (7 tests; mocked candidateService + voteAggregationService から rank / share の正しい伝搬を検証) で実質充足済み。E2E bullet (Playwright) は autonomous 実行環境では完遂不可: (1) @playwright/test が依存に未追加、(2) ブラウザバイナリ未取得、(3) DATABASE_URL / MICROCMS_API_KEY_READONLY 未設定で dev server / DB が起動できない。CI またはローカルで Playwright をインストール後、tests/e2e/ranking.spec.ts 等を追加して再開。

Implementation Notes

  • コンポーネントテスト (components/public/**) は // @vitest-environment jsdom を test ファイル冒頭に記述する必要がある (vitest.config.ts の global env は node)。vitest.config.tsincludecomponents/**/*.test.ts(x) を追加済み (Task 2.1)。
  • jsdom は inline style={{ color: '#XXXXXX' }}rgb(...) に正規化するため、hex リテラルをテストで直接アサートしたい場合は CSS カスタムプロパティ (--rank-icon-color 等) 経由で値を設定し、SVG/要素側で color: var(--xxx) を消費する設計にする (Task 2.1)。
  • jsdom は font-size: clamp(...) を React の style prop 経由で受け付けないため、clamp(...) 等の現代 CSS 値を当てたい場合は Tailwind arbitrary class (text-[clamp(2rem,4vw,3rem)]) を使い、テストも className を検査する。inline style への ref + setAttribute 系ワークアラウンドは SSR/CSR 非対称を生むため禁止 (Task 2.4)。