テーマ
Design: ランキング画面
要件は requirements.md、ビジュアルは ui-design.md 参照。
アーキテクチャ
app/ranking/page.tsx (Server)
├─ candidateService.getAll() → ソート済み RankedCandidate[]
└─ <RankingPageClient candidates>
│
├─ useVoteAction(candidateId) (各行)
├─ useVoteFlow()
└─ render:
├─ <BackToHomeLink>
├─ <RankingHeader>
├─ <RankingList items={ranked}>
│ └─ <RankingItem candidate rank maxVotes ... />
└─ Modals + Confetti (home画面と同じ)使用フック
| フック | 役割 |
|---|---|
useCandidateRanking(candidates) | votes降順ソート + rank付与 |
useVoteAction(candidateId) | 投票実行(各行) |
useVoteFlow() | モーダル制御 |
useReorderAnimation() | 順位変動時の FLIP アニメ用 (motion layout) |
使用コンポーネント
| コンポーネント | Props |
|---|---|
<RankingList> | items: RankedCandidate[], maxVotes |
<RankingItem> | candidate, rank, maxVotes, voteState, onFreeVote, onPaidVote |
<RankIcon> | rank (1〜3で Trophy/Medal、4以降で Award) |
<VoteShareBar> | votes, maxVotes, rank |
データフロー
Server: candidates = candidateService.getAll()
→ sorted = sortByVotes(candidates)
→ ranked = sorted.map((c, i) => ({ ...c, rank: i + 1 }))
→ maxVotes = ranked[0]?.votes ?? 1
→ <RankingPageClient items={ranked} maxVotes={maxVotes} />
Client (投票実行時):
→ useVoteAction.execute()
→ setItems(items => sortAndReRank(items.map(c => c.id === id ? { ...c, votes: c.votes + n } : c)))
→ motion `layout` props で並び替えアニメソートと順位付け
ts
// features/candidates/hooks/useCandidateRanking.ts
export function useCandidateRanking(candidates: Candidate[]): RankedCandidate[] {
return useMemo(
() => [...candidates]
.sort((a, b) => b.votes - a.votes || a.id - b.id) // 同点はID昇順
.map((c, i) => ({ ...c, rank: i + 1 })),
[candidates]
);
}並び替えアニメーション
motion の layout props で FLIP アニメを実現:
tsx
<motion.div layout transition={{ type: 'spring', damping: 25 }}>
<RankingItem ... />
</motion.div>エッジケース
| シナリオ | 対応 |
|---|---|
| 同得票 | ID昇順で安定ソート |
| 0票 | バー幅 0%、テキスト「0票」 |
| 候補者数 < 3 | 存在分のみランク装飾を適用 |
関連
requirements.md/ui-design.md../home/design.md— 共通フック