Skip to content

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 — 共通フック