テーマ
Design: 候補者ランキング画面
要件は requirements.md、ビジュアルは ui-design.md 参照。データ層の契約は ../data-model/design.md を唯一の正として参照する。
Overview
Purpose: 候補者を得票数の降順でランキング表示する閲覧専用画面を、data-model で定義された 3 系統(microCMS / Neon / Vercel ENV)から合成して描画する。1〜3 位を視覚的に強調し、得票数の相対値を視覚化する。投票導線は提供しない(投票は候補者詳細画面から)。
Users: 一般来訪ユーザー(認証なし)。トップページから「ランキングを見る」遷移、または直接 URL アクセス。
Impact: 過去 design(Prisma 単独の candidateService.getAllRanked() + /api/images/[id])を撤回し、候補者は microCMS、得票は Neon JOIN 集計から合成する構成に置き換える。
Goals
- Server Component で 3 系統合成 + rank 確定 + maxVotes 算出を行い、Client は表示専用に徹する
- 投票導線(
useVoteFlow/ Confetti / 購入モーダル)を 持たない - 1〜3 位/4 位以降のビジュアル区分のためのデータ(
rank)のみをcomponents/層に渡す(具体表現はui-design.md) - 得票率(
share = voteCount / maxVotes)を Server から渡し、Client では計算しない
Non-Goals
- 投票フロー本体(
voting委譲) - 候補者カード UI(home / candidate-detail のもの)
- 投票による楽観的順位変動アニメ(本画面は投票導線が無いため不要)
- 順位による具体ビジュアル(色・トロフィー・余白)(
ui-design.md委譲)
Boundary Commitments
This Spec Owns
app/ranking/page.tsx(Server Component)の合成手順<RankingPageClient>(Client、状態を持たない軽量ラッパ)の責務範囲<RankingList>/<RankingItem>の Props 契約- 並び順: 得票降順、同票時は ID 昇順タイブレーク
share = voteCount / maxVotesの算出位置(Server)- ホーム画面への戻り導線(
<BackToHomeLink>)
Out of Boundary
- microCMS / Neon / Vercel ENV のスキーマ(
data-model) - 投票フロー(
voting) - 候補者詳細画面(
candidate-detail) - 順位ごとのビジュアル表現(
ui-design.md)
Allowed Dependencies
features/cms/services/candidateService.getCandidatesfeatures/candidates/services/voteAggregationService.getVoteCountsByCandidateIdslib/voting-period.getVotingPeriod(必要に応じて期間表示)next/image(microCMS CDN)、next/link
Revalidation Triggers
data-modelのCandidate/voteAggregationServiceシグネチャ変更- 並び順タイブレーク規則の変更
RankedCandidate型の変更
Architecture
コンポーネント階層
mermaid
graph TB
subgraph Server[Server Component]
Page[app/ranking/page.tsx]
end
subgraph Client[Client/Component Layer]
PageClient[<RankingPageClient>]
BackLink[<BackToHomeLink>]
Header[<RankingHeader>]
List[<RankingList>]
Item[<RankingItem>]
Icon[<RankIcon>]
Bar[<VoteShareBar>]
end
subgraph DataModel[data-model spec]
CandSvc[features/cms/services/candidateService]
VoteAgg[features/candidates/services/voteAggregationService]
end
Page -->|getCandidates| CandSvc
Page -->|getVoteCountsByCandidateIds| VoteAgg
Page -->|props| PageClient
PageClient --> BackLink
PageClient --> Header
PageClient --> List
List --> Item
Item --> Icon
Item --> BarArchitecture Pattern & Boundary Map
- Pattern: 「Server で全データ確定 + Client は presentation のみ」
- Boundary Map:
- Server: 候補者取得 + 得票集計 + 降順ソート + rank 付与 + maxVotes 算出 + share 計算
- Client: 受け取った確定値を render するのみ
- Steering 整合:
structure.mdの features/components 分離。本画面は features フックを使わない最も薄い構造で済む
File Structure Plan
Directory Structure
app/
└── ranking/
├── page.tsx # Server Component: 合成 + rank + maxVotes
└── _components/
└── RankingPageClient.tsx # Client ラッパ(状態を持たない)
components/
└── public/
├── RankingHeader.tsx # 見出し領域
├── RankingList.tsx # 一覧ラッパ(items を render)
├── RankingItem.tsx # 個別行(候補者・順位・得票・share・詳細リンク)
├── RankIcon.tsx # 1-3 / 4+ のアイコン出し分け
├── VoteShareBar.tsx # 得票率バー(0-1)
└── BackToHomeLink.tsx # ホーム導線
features/candidates/
└── utils/
└── rankCandidates.ts # 純関数: voteCount 降順 + id 昇順タイブレークModified Files
- なし(新規ファイルのみ)
Out-of-scope(再導入禁止)
features/candidates/services/candidateService.getAllRanked()(Prisma 直叩き版)useVoteFlow/useReorderAnimation/<PurchaseFlowContainer>等の投票導線app/api/images/[id]/**参照
System Flows
Flow 1: ランキング描画
mermaid
sequenceDiagram
participant Browser
participant Page as app/ranking/page.tsx (Server)
participant CandSvc as candidateService
participant VoteAgg as voteAggregationService
Browser->>Page: GET /ranking
Page->>CandSvc: getCandidates()
CandSvc-->>Page: Candidate[](公開済みのみ)
Page->>VoteAgg: getVoteCountsByCandidateIds(ids)
VoteAgg-->>Page: Map<candidateId, count>
Page-->>Page: merge Candidate + voteCount, 降順ソート + ID タイブレーク, rank 付与
Page-->>Page: maxVotes = ranked[0]?.voteCount ?? 0
Page-->>Page: items.map → { ...c, share: maxVotes>0 ? c.voteCount/maxVotes : 0 }
Page-->>Browser: HTML(<RankingPageClient items maxVotes />)Key Decision:
rank/shareを Server で全て確定し、Client は数値を表示するだけ- 同票時は ID 昇順タイブレークでページ間の順位値の揺れを排除(
requirements.mdFeature 5)
Requirements Traceability
| Req | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | 表示時に降順ソートして表示 | app/ranking/page.tsx + <RankingList> | Server で降順ソート | Flow 1 |
| 1.2 | ホーム戻り導線をページ上部に表示 | <BackToHomeLink> | <Link href="/"> | — |
| 2.1 | 各順位行に順位・画像・名前・得票・詳細導線を含める | <RankingItem> | props | Flow 1 |
| 3.1 | 1/2/3/4+ で視覚強度を変える | <RankIcon> + <RankingItem> | rank props で出し分け | — |
| 3.2 | 具体表現は ui-design.md | (本 spec 外) | — | — |
| 4.1 | 候補者の詳細表示要求で詳細ページへ遷移 | <RankingItem> | <Link href={\/candidate/${id}`}>` | — |
| 5.1 | 同得票時は ID 昇順で順位確定 | features/candidates/utils/rankCandidates.ts | sort 比較関数 | Flow 1 |
Components and Interfaces
Summary
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
app/ranking/page.tsx | Server | 3 系統合成 + rank + maxVotes + share 算出 | 1.1, 5.1, Flow 1 | data-model services(P0) | Service |
<RankingPageClient> | Client | 子配布のみ | 1.1, 1.2 | — | UI |
<RankingHeader> | Component | 見出し領域 | 1.1 | — | UI |
<RankingList> | Component | 一覧ラッパ | 1.1 | <RankingItem> | UI |
<RankingItem> | Component | 個別行(画像・順位・得票・share・詳細リンク) | 2.1, 3.1, 4.1 | next/image, next/link | UI |
<RankIcon> | Component | 1-3 / 4+ のアイコン | 3.1 | — | UI |
<VoteShareBar> | Component | 得票率バー(0-1) | (補助) | — | UI |
<BackToHomeLink> | Component | ホーム戻り導線 | 1.2 | next/link | UI |
rankCandidates | Util | 純関数: 降順 + ID 昇順タイブレーク + rank/share 付与 | 5.1 | (純関数) | Service |
Server Component: app/ranking/page.tsx
| Field | Detail |
|---|---|
| Intent | 3 系統を合成し、降順ソート + rank + share を確定して Client に渡す |
| Requirements | 1.1, 5.1, Flow 1 |
Contracts: Service [x]
Algorithm
typescript
// app/ranking/page.tsx
import { getCandidates } from '@/features/cms/services/candidateService';
import { getVoteCountsByCandidateIds } from '@/features/candidates/services/voteAggregationService';
import { rankCandidates } from '@/features/candidates/utils/rankCandidates';
import { RankingPageClient } from './_components/RankingPageClient';
export const metadata = {
title: 'ランキング | Miss World Japan 2026',
};
export default async function RankingPage() {
const candidates = await getCandidates();
const voteCounts = await getVoteCountsByCandidateIds(candidates.map((c) => c.id));
// rankCandidates の戻り値順序の契約:
// - 配列は voteCount 降順 + id 昇順タイブレークでソート済み
// - rank は 1 始まり、同票でも一意(タイブレーク後の安定順)
// - 本画面はそのまま降順表示するので追加ソート不要
// - home spec のように id 昇順表示が必要な呼び出し側は、rank マップを抽出してから並べ直す
const items: RankedCandidate[] = rankCandidates(
candidates.map((c) => ({ ...c, voteCount: voteCounts.get(c.id) ?? 0 })),
);
const maxVotes = items[0]?.voteCount ?? 0;
const itemsWithShare: RankedCandidateWithShare[] = items.map((c) => ({
...c,
share: maxVotes > 0 ? c.voteCount / maxVotes : 0,
}));
return <RankingPageClient items={itemsWithShare} maxVotes={maxVotes} />;
}Implementation Notes
- Integration:
rankCandidatesは純関数としてfeatures/candidates/utils/に置き、homeと共有可能(将来 home 側でも採用検討) - Validation:
voteCounts.get(id) ?? 0で未集計候補は 0 票扱い - Risks: candidates 件数が 0 の場合は空配列(
<RankingList>は空状態を許容)
Util: rankCandidates
typescript
// features/candidates/utils/rankCandidates.ts
export type CandidateWithVotes = Readonly<Candidate & { voteCount: number }>;
export type RankedCandidate = Readonly<CandidateWithVotes & { rank: number }>;
export type RankedCandidateWithShare = Readonly<RankedCandidate & { share: number }>;
export function rankCandidates(items: ReadonlyArray<CandidateWithVotes>): RankedCandidate[] {
return [...items]
.sort((a, b) => b.voteCount - a.voteCount || a.id.localeCompare(b.id))
.map((c, i) => ({ ...c, rank: i + 1 }));
}- Preconditions:
itemsは voteCount フィールドを持つ - Postconditions(契約):
- 戻り値は voteCount 降順 + ID 昇順タイブレーク で並んでいる(配列順序が確定値)
rankは 1 始まり、配列 index + 1 と必ず一致- 入力が空配列なら戻り値も空配列
- Invariants: 同票時は ID 昇順で安定(要件 5.1)
- 使用ガイド:
- 本 spec(ランキング画面)はそのまま描画(降順表示)
home/candidate-detail等で id 昇順や単体取得が必要な場合は、戻り値からMap<id, rank>を作って 呼び出し側で並べ直す(関数本体は変更しない)
Component: <RankingPageClient>
typescript
export type RankingPageClientProps = Readonly<{
items: ReadonlyArray<RankedCandidate & { share: number }>;
maxVotes: number;
}>;責務: Server から受け取った props を子配布するのみ。状態を持たない。
Component: <RankingItem>
typescript
export type RankingItemProps = Readonly<{
candidate: Candidate;
rank: number;
share: number; // 0-1
voteCount: number;
}>;表示要素:
- 順位(
rank)—<RankIcon rank={rank} />で 1-3 と 4+ を視覚分離 - 候補者画像(
candidate.images[0]?.urlをnext/imageに直渡し) - 候補者名(
candidate.displayName) - 得票数(
voteCount.toLocaleString('ja-JP')+ 「票」) <VoteShareBar share={share} />で得票率バー- 行全体を
<Link href={\/candidate/${candidate.id}`}>` でラップ(詳細画面遷移)
Implementation Notes
- 投票ボタン・購入モーダルは 持たない(本 spec の Out of Boundary)
- ロジックは持たない(計算結果のみ受け取って描画)
Data Models
本 spec はデータ層に新規モデルを追加しない。data-model の Candidate 型と voteAggregationService の戻り値型を組み合わせる。
typescript
// features/candidates/utils/rankCandidates.ts(本 spec 所有)
export type CandidateWithVotes = Readonly<Candidate & { voteCount: number }>;
export type RankedCandidate = Readonly<CandidateWithVotes & { rank: number }>;
export type RankedCandidateWithShare = Readonly<RankedCandidate & { share: number }>;上記 3 型は 本 spec が単独所有。
home/candidate-detail等は再定義せず import して使用する。
Error Handling
| シナリオ | 検出箇所 | 対応 |
|---|---|---|
| 候補者取得失敗 | Server Component | throw → app/error.tsx で fallback |
| 集計失敗 | Server Component | throw → app/error.tsx で fallback |
| 候補者 0 件 | Server Component | <RankingList> が空状態を描画(エラー扱いしない) |
Testing Strategy
Unit
rankCandidates: 通常ケース・同票タイブレーク・全員 0 票・空配列(要件 1.1, 5.1)<RankingItem>のスナップショット: rank 1/2/3/4+ で<RankIcon>が切り替わること(要件 3.1)
Integration
app/ranking/page.tsx: モックされたサービスから受け取ったデータで rank と share が正しく付与されること(要件 1.1, 5.1)<RankingItem>の<Link>先が/candidate/[id]であること(要件 4.1)
E2E(Playwright)
/rankingを開く → 候補者が voteCount 降順で並ぶことを DOM で検証(要件 1.1)- 行クリック →
/candidate/[id]に遷移(要件 4.1) /への戻り導線を確認(要件 1.2)
Security Considerations
- 閲覧専用画面のため、書き込み Action は無し
- microCMS API は読み取り専用キー
- 公開ステータスは microCMS 側で制御
Performance
- キャッシュ:
getCandidatesは data-model 側でunstable_cache(TTL は data-model 委譲)。集計はキャッシュなし - 画像: microCMS CDN を
next/image直渡し - 再レンダリング: 本画面は状態を持たないため
React.memo化の必要は薄い
Migration Strategy
features/candidates/services/candidateService.getAllRanked()の Prisma 直叩き呼び出しを削除し、getCandidates + getVoteCountsByCandidateIds + rankCandidatesの合成に置き換えuseVoteFlow/useReorderAnimationの参照を削除<RankingItem>から投票ボタンと購入モーダル参照を削除- 画像 src を microCMS CDN URL に切替
Supporting References
requirements.md— 機能要件ui-design.md— 順位ごとのビジュアル表現../data-model/design.md—Candidate/voteAggregationService../home/design.md— 同等の合成パターンを採用../candidate-detail/design.md— 詳細遷移先steering/structure.md— features / components 責務分離