Remplace la Course du Char par Le Vol d'Icare
Build and deploy / deploy (push) Successful in 37s

Après retour d'expérience, la course de char (vue du dessus, piste
générée par jour) laisse place à un Flappy Bird grec plus simple et
plus lisible sur téléphone : Icare vole entre des colonnes de temple,
touche l'écran pour battre des ailes, échec net au premier contact
(score remis à zéro).

Record personnel all-time (plus de piste quotidienne ni de fantômes) :
les scores se figent dès que la date du Tribunal est atteinte, puis les
gloires du top 3 sont attribuées automatiquement via une tâche
planifiée pg_cron, comme pour l'ancienne Course du Char.

schema.sql nettoie explicitement l'ancienne Course du Char (tables,
RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle
avait déjà été appliquée en prod.
This commit is contained in:
Valentin ROBIN
2026-07-29 19:14:52 +02:00
parent 6af56d2ade
commit 33d2476ef6
22 changed files with 636 additions and 1054 deletions
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Avatar } from "@/components/avatar";
import { computeRanksBy } from "@/lib/ranking";
import { createClient } from "@/lib/supabase/client";
import { IcarusGame } from "./icarus-game";
import type { IcarusScoreEntry } from "./page";
export function IcareView({
initialLeaderboard,
initialOwnBestScore,
isFrozen,
}: {
initialLeaderboard: IcarusScoreEntry[];
initialOwnBestScore: number | null;
isFrozen: boolean;
}) {
const [leaderboard, setLeaderboard] = useState(initialLeaderboard);
const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore);
const refetch = useCallback(async () => {
const supabase = createClient();
const { data: scores } = await supabase
.from("icarus_scores")
.select("user_id, best_score")
.order("best_score", { ascending: false });
const userIds = (scores ?? []).map((row) => row.user_id);
const { data: profiles } =
userIds.length > 0
? await supabase.from("profiles").select("id, pseudo, avatar_url").in("id", userIds)
: { data: [] as { id: string; pseudo: string; avatar_url: string | null }[] };
const profileById = new Map((profiles ?? []).map((profile) => [profile.id, profile]));
setLeaderboard(
(scores ?? []).map((row) => ({
id: row.user_id,
pseudo: profileById.get(row.user_id)?.pseudo ?? "Un Citoyen",
avatar_url: profileById.get(row.user_id)?.avatar_url ?? null,
best_score: row.best_score,
})),
);
}, []);
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("icarus-scores")
.on("postgres_changes", { event: "*", schema: "public", table: "icarus_scores" }, () => {
refetch();
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [refetch]);
async function handleFinish(score: number) {
const supabase = createClient();
const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score });
if (!error && data) {
setOwnBestScore(data.best_score);
refetch();
}
}
const ranked = useMemo(() => computeRanksBy(leaderboard, (a, b) => b.best_score - a.best_score), [leaderboard]);
return (
<div className="flex flex-col gap-6">
{isFrozen && (
<p className="marble-surface rounded-lg border border-gold/40 px-4 py-2 text-center text-sm text-text-marble">
Les scores sont figés depuis le début du Tribunal.
</p>
)}
{ranked.length > 0 && (
<ol className="flex flex-col gap-1.5">
{ranked.map((entry) => (
<li
key={entry.id}
className="marble-surface flex items-center gap-3 rounded-lg border border-gold/25 px-3 py-2 shadow-sm"
>
<span className="w-5 shrink-0 text-center font-heading text-sm text-text-mut">{entry.rank}</span>
<Avatar pseudo={entry.pseudo} avatarUrl={entry.avatar_url} size="xs" />
<span className="flex-1 truncate text-sm font-medium text-text-marble">{entry.pseudo}</span>
<span className="font-heading text-sm font-semibold text-gold-bright">{entry.best_score}</span>
</li>
))}
</ol>
)}
{ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-text-marble">
Ton record : <span className="text-gold-bright">{ownBestScore}</span>
</p>
)}
<IcarusGame onFinish={handleFinish} />
<p className="text-center text-xs text-text-mut">
{isFrozen
? "Les gloires du podium ont été attribuées automatiquement au début du Tribunal."
: "Au début du Tribunal, les scores seront figés et le podium recevra des gloires automatiquement — aucun Archonte n'a besoin d'intervenir."}
</p>
</div>
);
}