diff --git a/src/app/journal/journal-view.tsx b/src/app/journal/journal-view.tsx
index 56486ab..4225390 100644
--- a/src/app/journal/journal-view.tsx
+++ b/src/app/journal/journal-view.tsx
@@ -6,7 +6,7 @@ import { createClient } from "@/lib/supabase/client";
type Entry = {
id: number;
target_id: string;
- judge_id: string;
+ judge_id: string | null;
delta: number;
reason: string | null;
created_at: string;
@@ -55,7 +55,8 @@ export function JournalView({
return (
{entries.map((entry) => {
- const judgePseudo = pseudoById[entry.judge_id] ?? "Un Archonte";
+ const judgePseudo =
+ entry.judge_id === null ? "Le Tribunal" : (pseudoById[entry.judge_id] ?? "Un Archonte");
const targetPseudo = pseudoById[entry.target_id] ?? "un Citoyen";
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
return (
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 7ee5f39..65b4ea7 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -484,3 +484,170 @@ end $$;
--
-- (Récupérer l'uuid via Authentication → Users, ou :
-- select id, pseudo from public.profiles;)
+
+-- 11. La Course du Char --------------------------------------------------------
+-- Mini-jeu : course de char en vue du dessus, une piste générée aléatoirement
+-- par jour (calculée côté client à partir de la date Europe/Paris, jamais
+-- stockée ici), chacun rejoue autant qu'il veut, seul le meilleur temps du
+-- jour compte. Les points du jour sont attribués automatiquement (pas
+-- d'Archonte impliqué) par une tâche planifiée pg_cron — seul mécanisme de
+-- points de tout le projet qui n'attend pas de décision d'un juge.
+
+-- Un point attribué automatiquement n'a pas de juge : judge_id devient
+-- optionnel (le Crieur affiche alors "Le Tribunal" à la place d'un pseudo).
+alter table public.points_log alter column judge_id drop not null;
+
+create table if not exists public.chariot_runs (
+ user_id uuid not null references public.profiles (id) on delete cascade,
+ race_date date not null,
+ best_time_ms integer not null check (best_time_ms between 2000 and 300000),
+ ghost_path jsonb not null default '[]'::jsonb,
+ updated_at timestamptz not null default now(),
+ primary key (user_id, race_date)
+);
+
+create index if not exists chariot_runs_race_date_idx
+ on public.chariot_runs (race_date, best_time_ms);
+
+alter table public.chariot_runs enable row level security;
+
+-- Même modèle que points_log : verrouillée en écriture, seule la RPC
+-- submit_chariot_run() (SECURITY DEFINER) peut écrire.
+revoke insert, update, delete on public.chariot_runs from authenticated, anon;
+grant select on public.chariot_runs to authenticated;
+
+drop policy if exists "chariot_runs readable by authenticated" on public.chariot_runs;
+create policy "chariot_runs readable by authenticated"
+ on public.chariot_runs for select
+ to authenticated
+ using (true);
+
+-- Seul point d'entrée pour soumettre un temps : la date du jour est calculée
+-- côté serveur (jamais fournie par le client, pour ne pas pouvoir soumettre
+-- "pour" un autre jour), le temps est borné grossièrement (anti-triche
+-- minimal, suffisant pour un jeu entre amis — pas de vérification fine par
+-- piste), et on n'écrase le temps existant que s'il est strictement meilleur.
+create or replace function public.submit_chariot_run(p_time_ms integer, p_ghost_path jsonb default '[]'::jsonb)
+returns public.chariot_runs
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_race_date date;
+ v_row public.chariot_runs;
+begin
+ if auth.uid() is null then
+ raise exception 'authentication required';
+ end if;
+
+ if p_time_ms is null or p_time_ms < 2000 or p_time_ms > 300000 then
+ raise exception 'invalid time_ms';
+ end if;
+
+ v_race_date := (now() at time zone 'Europe/Paris')::date;
+
+ insert into public.chariot_runs (user_id, race_date, best_time_ms, ghost_path, updated_at)
+ values (auth.uid(), v_race_date, p_time_ms, coalesce(p_ghost_path, '[]'::jsonb), now())
+ on conflict (user_id, race_date) do update
+ set best_time_ms = excluded.best_time_ms,
+ ghost_path = excluded.ghost_path,
+ updated_at = now()
+ where excluded.best_time_ms < public.chariot_runs.best_time_ms;
+
+ -- On resélectionne toujours plutôt que de dépendre du RETURNING de
+ -- l'upsert : si la clause WHERE bloque la mise à jour (temps pas
+ -- meilleur), l'INSERT ne retourne aucune ligne. On renvoie dans tous les
+ -- cas le meilleur temps courant du joueur, qu'il vienne d'être battu ou non.
+ select * into v_row from public.chariot_runs
+ where user_id = auth.uid() and race_date = v_race_date;
+
+ return v_row;
+end;
+$$;
+
+grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated;
+
+do $$
+begin
+ alter publication supabase_realtime add table public.chariot_runs;
+exception
+ when duplicate_object then null;
+end $$;
+
+-- Marqueur interne : quelles journées ont déjà été clôturées (idempotence de
+-- la tâche planifiée, qui tourne toutes les 15 minutes plutôt qu'une seule
+-- fois à minuit pile — le changement d'heure CET/CEST décalerait un horaire
+-- UTC fixe). Table strictement interne : aucune policy, jamais lue/écrite
+-- par le client.
+create table if not exists public.chariot_race_closes (
+ race_date date primary key,
+ closed_at timestamptz not null default now()
+);
+alter table public.chariot_race_closes enable row level security;
+revoke all on public.chariot_race_closes from authenticated, anon;
+
+-- Clôture de la journée précédente (Europe/Paris) : classe chariot_runs,
+-- attribue les gloires du top 3 (ex-aequo inclus au même rang), journalise
+-- dans points_log avec judge_id = null, puis marque la date comme close.
+-- No-op si déjà clôturée. Volontairement pas de grant execute à
+-- authenticated : uniquement appelée par pg_cron ou depuis le SQL Editor.
+create or replace function public.close_daily_chariot_race()
+returns void
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1);
+ r record;
+ v_points int;
+begin
+ if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then
+ return;
+ end if;
+
+ for r in
+ with ranked as (
+ select user_id, best_time_ms,
+ rank() over (order by best_time_ms asc) as rnk
+ from public.chariot_runs
+ where race_date = v_target_date
+ )
+ select * from ranked where rnk <= 3
+ loop
+ v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end;
+ update public.profiles set points = points + v_points where id = r.user_id;
+ insert into public.points_log (target_id, judge_id, delta, reason)
+ values (r.user_id, null, v_points,
+ 'Course du Char — ' || to_char(v_target_date, 'DD/MM') || ' (rang ' || r.rnk || ')');
+ end loop;
+
+ insert into public.chariot_race_closes (race_date) values (v_target_date);
+end;
+$$;
+
+-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
+-- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce
+-- fichier, et pas garanti self-service selon le plan/la région du projet.
+-- Les deux blocs ci-dessous n'échouent jamais bruyamment si l'extension n'est
+-- pas encore activée (schéma "cron" inexistant) : le reste de ce script,
+-- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer.
+do $$
+begin
+ perform cron.unschedule('close-daily-chariot-race');
+exception
+ when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
+end $$;
+
+do $$
+begin
+ perform cron.schedule(
+ 'close-daily-chariot-race',
+ '*/15 * * * *',
+ $cron$select public.close_daily_chariot_race();$cron$
+ );
+exception
+ when others then
+ raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier la clôture automatique de la Course du Char.';
+end $$;