Skip to content

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.getCandidates
  • features/candidates/services/voteAggregationService.getVoteCountsByCandidateIds
  • lib/voting-period.getVotingPeriod(必要に応じて期間表示)
  • next/image(microCMS CDN)、next/link

Revalidation Triggers

  • data-modelCandidate / 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 --> Bar

Architecture 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.md Feature 5)

Requirements Traceability

ReqSummaryComponentsInterfacesFlows
1.1表示時に降順ソートして表示app/ranking/page.tsx + <RankingList>Server で降順ソートFlow 1
1.2ホーム戻り導線をページ上部に表示<BackToHomeLink><Link href="/">
2.1各順位行に順位・画像・名前・得票・詳細導線を含める<RankingItem>propsFlow 1
3.11/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.tssort 比較関数Flow 1

Components and Interfaces

Summary

ComponentDomain/LayerIntentReq CoverageKey DependenciesContracts
app/ranking/page.tsxServer3 系統合成 + rank + maxVotes + share 算出1.1, 5.1, Flow 1data-model services(P0)Service
<RankingPageClient>Client子配布のみ1.1, 1.2UI
<RankingHeader>Component見出し領域1.1UI
<RankingList>Component一覧ラッパ1.1<RankingItem>UI
<RankingItem>Component個別行(画像・順位・得票・share・詳細リンク)2.1, 3.1, 4.1next/image, next/linkUI
<RankIcon>Component1-3 / 4+ のアイコン3.1UI
<VoteShareBar>Component得票率バー(0-1)(補助)UI
<BackToHomeLink>Componentホーム戻り導線1.2next/linkUI
rankCandidatesUtil純関数: 降順 + ID 昇順タイブレーク + rank/share 付与5.1(純関数)Service

Server Component: app/ranking/page.tsx

FieldDetail
Intent3 系統を合成し、降順ソート + rank + share を確定して Client に渡す
Requirements1.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]?.urlnext/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-modelCandidate 型と 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 Componentthrow → app/error.tsx で fallback
集計失敗Server Componentthrow → 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

  1. features/candidates/services/candidateService.getAllRanked() の Prisma 直叩き呼び出しを削除し、getCandidates + getVoteCountsByCandidateIds + rankCandidates の合成に置き換え
  2. useVoteFlow / useReorderAnimation の参照を削除
  3. <RankingItem> から投票ボタンと購入モーダル参照を削除
  4. 画像 src を microCMS CDN URL に切替

Supporting References

  • requirements.md — 機能要件
  • ui-design.md — 順位ごとのビジュアル表現
  • ../data-model/design.mdCandidate / voteAggregationService
  • ../home/design.md — 同等の合成パターンを採用
  • ../candidate-detail/design.md — 詳細遷移先
  • steering/structure.md — features / components 責務分離