テーマ
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.tsにCandidateWithVotes/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]?.urlをnext/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
- モック化した
candidateServiceとvoteAggregationServiceから得た値で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.tsのincludeにcomponents/**/*.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 を検査する。inlinestyleへの ref + setAttribute 系ワークアラウンドは SSR/CSR 非対称を生むため禁止 (Task 2.4)。