尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Jeu de picks tennis personnel, mono-utilisateur. Next.js 16 (App Router) + Supabase + Tailwind v4.
À chaque tour d'un tournoi, on « pique » des joueurs. Barème : 5 pts par match gagné, net sets ×3 (plancher 0), net games sur les sets gagnés uniquement. Un forfait gagné vaut 5 pts secs, 10 pts pour un tournoi en 3 sets gagnants commencé le 2026-10-05 ou après (Shanghai 2026) — voir Où corriger le barème des picks. Un joueur ne peut être pické qu'une seule fois sur tout le tournoi — c'est la contrainte structurante du jeu. 12 picks par tournoi (2 par tour jusqu'aux quarts, puis 1 en demi et 1 en finale).
Le moteur (lib/) vient du projet tennis-picks : scoring, Elo par surface,
simulation Monte Carlo, affectation optimale (algorithme hongrois), parser du
JSON du bookmarklet. Il a été modifié et enrichi ici. Les six modules
d'origine (types, scoring, elo, optimizer, montecarlo, parser) ont
évolué, par exemple pour le barème forfait/abandon, le statut in_progress, le
support WTA, l'échelle Elo configurable, le mélange Elo/cotes et la
réconciliation des IDs joueurs. D'autres modules ont été ajoutés (voir
Structure). C'est désormais la copie de référence : un bug du
moteur se corrige ici, jamais dans tennis-picks/lib, qui est dépassé.
npm run dev # http://localhost:3000
Variables d'environnement (.env.local) :
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=... # publique : lecture seule, inlinée dans le bundle
SUPABASE_SERVICE_ROLE_KEY=... # SECRET : jamais préfixée NEXT_PUBLIC_
APP_PASSWORD=... # mot de passe unique d'accès à l'app
AUTH_SECRET=... # signature du cookie (sinon dérivé d'APP_PASSWORD)
Modèle complet dans .env.example.
L'app entière est privée derrière un mot de passe unique (APP_PASSWORD).
proxy.ts — le middleware.ts de Next 16 — redirige vers /login toute requête
sans cookie de session valide, et répond 401 sur /api/* et sur les Server
Actions, où une redirection HTML serait illisible.
Le cookie tn_session est httpOnly, SameSite=Lax, secure en production, et
signé en HMAC-SHA256 : il ne contient qu'une expiration, elle-même dans la charge
signée — donc non modifiable côté client. Rien n'est stocké en base.
Le proxy n'est pas la ligne de défense principale. La doc Next 16 est
explicite : une Server Action n'est pas une route, c'est un POST vers la route qui
l'héberge, qu'un changement de matcher peut sortir de la couverture du proxy sans
bruit. Chaque écriture revérifie donc la session elle-même via auth/garde.ts —
importerExtrait, validerPick, supprimerPick, POST /api/recompute.
npm run build && npm start
npm run verify:auth # 401 sans cookie, cookie signé, contournements
Toutes les tables tn_* et ta_* sont en lecture publique / écriture
service-role. Chaque migration qui crée une table y active la RLS, pose une
seule policy for select to anon, authenticated, et retire aux rôles publics
tout droit d'écriture. La clé publique (anon) sert à lire, rien d'autre.
Seule la service role, utilisée côté serveur uniquement, écrit.
Côté fonctions SQL :
tn_recompute_picks et tn_score_match ne sont pas appelables en RPC avec
la clé publique (0001, 0009, 0013) ;ta_elo_a_la_date (0010) l'est volontairement. Elle est en lecture seule
(stable, security invoker) et ne renvoie que ce que la table publique
ta_elo_historique montre déjà.Les 20 migrations de supabase/migrations/ (0001 → 0020) se jouent dans
l'ordre, une fois chacune : éditeur SQL Supabase, ou
SUPABASE_ACCESS_TOKEN=sbp_… node scripts/appliquer-migration.mjs <fichier.sql>.
La liste commentée, la date d'application de chacune et une requête de
vérification par migration sont dans MIGRATIONS.md.
Gestes à ne pas oublier :
ta_elo depuis /import/elo. La migration la vide,
et seul un import la remplit, slug compris ;tn_fantasy_historique que seul le
backfill réécrit ;# Depuis la clé publique : lecture OK, écritures et RPC refusées
npm run verify:rls
verify:rls contrôle les 15 tables du schéma (migrations 0001 à 0020). Toute
migration qui crée une table doit aussi l'ajouter à TABLES dans
scripts/verifier-rls.mjs, sans quoi le « Tout est conforme » ne dit rien d'elle.
Tant que 0001 n'est pas appliquée, les pages s'affichent vides : les lectures se font avec la clé publique, que la RLS filtre à 0 ligne.
/import — coller le JSON du bookmarklet ATP ou WTA (même format, au champ
tour près). parseExtract() + verifierExtraction(), puis upsert dans
tn_tournaments, tn_players, tn_matches (réimportable après chaque tour).
Le circuit du tournoi décide du rapport Elo interrogé : un tableau WTA n'est
jamais rapproché des joueurs ATP, et inversement./import/elo — mise à jour des Elo Tennis Abstract, par collage. Tennis
Abstract répond 403 aux requêtes venant des IP de datacenter (Vercel) :
le fetch serveur n'aboutit plus en production, alors que la page s'ouvre
normalement dans un navigateur. C'est donc le navigateur qui lit le rapport —
snippet public/extract-elo.js, servi par l'app (derrière le mot de passe) et
copiable en un clic depuis l'écran — et l'app qui reçoit le JSON par collage,
POST /api/elo/import. Un circuit à la fois, ou les deux en collant
[extraitAtp, extraitWta]. L'écriture en base est rigoureusement la même
que celle du fetch (mêmes lignes, même upsert par (ta_slug, tour), mêmes
caches invalidés) : seule la source change.
Chaque import laisse une trace datée. ta_elo est écrasée — c'est
l'état courant, celui que lisent les picks, le fantasy et la simulation —
mais le même rapport est aussi versé dans ta_elo_historique, sous la date
qu'il annonce (« Last update »). Sans cette archive, juger un match passé se
ferait sur l'Elo d'aujourd'hui, qui a déjà intégré son résultat. L'archive ne
remonte pas le temps : elle part de l'instantané courant et n'accumule que
vers l'avant, donc les tournois déjà en base n'auront jamais d'Elo antérieur.
POST /api/elo/refresh est conservé en repli — inutilisable depuis
Vercel, mais fonctionnel en local, et prêt à resservir si le filtre tombe. Le
bouton « Tenter le fetch serveur » est en bas de l'écran./tournoi/[id] — vue du tableau tour par tour (lecture seule)./tournoi/[id]/predictions?vue=tableau (onglet Prédictions → « Tableau
pronostiqué » ; l'ancienne URL /tournoi/[id]/bracket y redirige, l'onglet
« Bracket » désignant désormais le jeu, route /simulateur) — arbre pronostiqué depuis le tirage, du premier
tour au champion. Pronostic déterministe : à chaque match, le plus haut
Elo effectif sur la surface du tournoi (mélange 60/40, le même que
l'écran Picks et que la simulation) l'emporte et avance ; un exempté passe
sans match. À Elo strictement égal, le mieux classé ; à rang égal, un
départage stable par identifiant, pour que deux affichages ne donnent jamais
deux champions. Aucun Monte Carlo — un simple parcours de l'arbre, calculé à
chaque affichage (quelques millisecondes).
: seul alimente le
calcul, la suite est déduite. L'arbre est donc identique que le tournoi soit
à venir, en cours ou terminé — même parti pris que l'espérance Fantasy. Il
n'y a rien à invalider, et un tour intermédiaire manquant en base ne
l'empêche pas de se remplir.
Rendu pensé pour le téléphone : un tour à la fois (sélecteur de tours),
duels en liste verticale, filtre par moitié de tableau au-delà de 4 duels, et
la carte du champion prédit avec son parcours tour par tour en tête d'écran.
La logique vit dans , module pur sans I/O.Ces points s'écartent légèrement de l'énoncé, pour des raisons dictées par la base et par Next.js 16 :
Un joueur, plusieurs espaces d'identifiants. tn_players.id est l'ID de
la source qui l'a fait entrer en base. Les extractions ne partagent pas
toutes le même espace : à côté des ID officiels (J0DZ, KE17, 326160)
on trouve des identifiants Sportradar (SR:COMPETITOR:972327) et un second
espace numérique WTA. Une même personne peut donc arriver sous deux lignes,
chacune avec ses matchs et son Elo maison — et le rapprochement Tennis
Abstract ne s'accroche qu'à l'une d'elles, l'autre tombant en Elo « défaut ».
Corrigé au cas par cas (migration 0011), jamais automatiquement : deux lignes
de même nom sont parfois deux vraies personnes. Les deux « X. Wang »
(326160 et 326376) sont deux joueuses distinctes, avec chacune leurs
matchs ; les fusionner créerait le bug qu'on corrige. Le critère de fusion
n'est donc pas le nom mais la preuve : mêmes tournois impossibles à
cumuler, ou une ligne sans le moindre match face à une ligne peuplée.
Elo courant pour prédire, Elo archivé pour juger. ta_elo ne garde qu'un
instantané : le dernier import écrase le précédent. C'est ce qu'il faut pour
la production — prédire un match à venir avec l'Elo du jour n'est pas un
biais, c'est la seule chose à faire — mais pas pour les écrans de mesure. Sur
un match déjà joué, l'Elo d'aujourd'hui a intégré son résultat : le vainqueur
en est ressorti relevé, le perdant abaissé, si bien qu'a posteriori le favori
d'une affiche est en partie désigné par son résultat. Le biais est
orienté — le favori gagne trop souvent, l'Elo paraît meilleur qu'il ne l'est,
et la courbe plus raide qu'elle ne l'est.
D'où ta_elo_historique (migration 0010) : chaque import y ajoute un
instantané daté du rapport, et la fonction SQL ta_elo_a_la_date(tour, date)
rend, pour chaque joueur, sa dernière valeur strictement antérieure à une
date. Par joueur et non par rapport, parce qu'un rapport hebdomadaire ne
republie pas tout le monde — même règle que ta_elo, qui ne supprime jamais
un joueur absent du rapport de la semaine.
Deux frontières, tenues explicitement : la production n'appelle jamais
db/elo-historique.ts, et les écrans de mesure ne remplacent jamais un
Elo antérieur manquant par l'Elo courant — ni par l'Elo maison, recalculé sur
les matchs importés, qui rentrerait le look-ahead par la porte de derrière.
Un trou se signale (compté et affiché), il ne se remplit pas. Enfin
l'archive ne remonte pas le temps : Tennis Abstract ne publie que le rapport
de la semaine, donc l'évaluation propre ne portera que sur les tournois joués
après sa mise en place.
Séparation lecture / écriture des clés Supabase. Les lectures passent par la clé publique (), adossée à des policies RLS sur les 5 tables : ce client ne peut rien modifier, et reste donc utilisable jusque dans le navigateur. Les écritures (import, picks, cache de projections, ) passent par la (), qui contourne RLS et ne vit que côté serveur — le module porte , ce qui casse le build s'il est atteint depuis un Client Component. Aucune policy insert/update/delete n'existe : même volée, la clé publique ne permet aucune écriture.
madrid2026.json (127 matchs, 96 joueurs) permet de tester l'import et tous les
écrans sans extraction fraîche : le coller dans /import.
proxy.ts porte d'entrée : tout est privé sauf /login
auth/
session.ts jeton signé HMAC (importable depuis proxy.ts)
garde.ts contrôle de session dans les écritures
app/
login/ mot de passe unique → cookie de session
page.tsx liste des tournois
fantasy/ historique prédit / réalisé + bouton de reprise
calibration/ courbe Elo→proba confrontée aux matchs joués
calibration/echelle/ effet d'une autre échelle sur le jeu Fantasy
calibration/cotes/ blend Elo/cotes — mesure isolée, rien de branché
import/ import du JSON + action serveur
import/elo/ collage des Elo TA (snippet public/extract-elo.js)
face-a-face/ bilan entre deux joueurs + comparaison Elo
bilan/ bilan de saison (donnees.ts assemble, page affiche)
tournoi/[id]/ tableau, bracket, picks, fantasy, predictions, resultats
tournoi/[id]/BadgeSourceElo.tsx provenance d'un Elo — partagé picks/fantasy
tournoi/[id]/fantasy/scenario/ « et si » du Fantasy (action serveur en lecture seule)
api/recompute/route.ts POST → tn_recompute_picks()
api/elo/import/route.ts POST → Elo TA collés (méthode principale)
api/elo/refresh/route.ts POST → Elo TA par fetch (repli, 403 sur Vercel)
api/fantasy/backfill/route.ts POST → historique des tournois déjà en base
api/calibration/elo/route.ts POST → calibration Elo→proba, en JSON
api/cotes/refresh/route.ts POST → cotes The Odds API → cache tn_odds
EloRefreshButton.tsx bouton du repli par fetch (écran /import/elo)
db/
anon.ts client clé publique — LECTURES uniquement
server.ts client service-role (server-only) — ÉCRITURES
queries.ts point d'entrée, réexporte les quatre suivants :
types.ts types des lignes tn_*
lectures.ts lectures (clé anon)
tableau.ts joueurs en lice, tour courant, état des slots
moteur.ts reconstruction Match[]/Player, loadEngineData
elo.ts cascade TA → défaut + sources (étage « maison »
écrit mais jamais atteint, cf. plus haut)
elo-refresh.ts collage OU fetch TA → ta_elo (écrasée) ET
ta_elo_historique (instantané daté ajouté)
elo-historique.ts Elo tel qu'il était AVANT une date — lecture de
l'archive, pour les seuls écrans de mesure
fantasy-anterieur.ts équipe Fantasy rejouée sur l'Elo d'avant le
tirage (sans look-ahead, hors cache)
projections.ts simulation Monte Carlo + cache tn_projections
reference.ts score de référence : projections par tour puis
lib/reference.ts (mémoïsé par requête)
fantasy.ts point d'entrée : fantasy-cache.ts (espérances a
priori, cache tn_fantasy) + fantasy-historique.ts
(équipe, score réel, historique)
face-a-face.ts rencontres entre deux joueurs + Elo comparés
bilan-saison.ts lectures paginées du bilan de saison
calibration.ts mesure de la courbe Elo→proba (lecture seule)
comparaison-echelle.ts rejoue le Fantasy sous d'autres échelles Elo
supabase/migrations/ 0001 → 0020, cf. MIGRATIONS.md (seul contenu de
supabase/, dossier attendu par la CLI)
scripts/verifier-rls.mjs contrôle des accès avec la clé publique
scripts/verifier-auth.mjs contrôle de la protection par mot de passe
scripts/appliquer-migration.mjs joue un .sql via l'API Management Supabase
lib/ moteur issu de tennis-picks, modifié depuis
(copie de référence, cf. Principe)
Les plus gros modules sont découpés derrière un
point d'entrée qui réexporte tout (les imports
`@/lib/x` ne changent pas) : bracketSim →
bracketArbre + bracketScenarios ; montecarlo →
montecarloTournoi + montecarloBracket ; optimizer
→ optimizerProbabilites + optimizerAffectation ;
parser → parserExtraction + parserJoueurs +
parserControles ; calendrier (logique) +
calendrierDonnees (fiches des tournois)
lib/fantasy.ts AJOUT : paliers, multiplicateurs, équipe optimale,
score réel (réutilise optimizer/scoring/montecarlo
tels quels) — point d'entrée de fantasyRegles.ts
(bye, barèmes, paliers) + fantasyCalcul.ts
lib/bracket.ts AJOUT : arbre pronostiqué depuis le tirage
(déterministe, sans Monte Carlo ni résultats)
lib/cotes.ts AJOUT : dévigorisation, consensus des books,
scores de Brier et log-loss (mesure seule)
lib/reference.ts AJOUT : picks recommandés tour par tour sous
contrainte d'unicité, et leurs points réels
lib/picksRegles.ts AJOUT : règles datées du jeu des picks
(forfait gagné à 10 pts, pointsForfaitPour)
lib/faceAFace.ts AJOUT : bilan des rencontres entre deux joueurs
lib/bilanSaison.ts AJOUT : agrégats du bilan de saison
lib/cotesEvolution.ts AJOUT : séries de cotes avant chaque match
lib/fantasyScenario.ts AJOUT : probabilités surchargées, score projeté
Le barème d'un match est dans lib/scoring.ts (scoreMatch) et son miroir SQL
tn_score_match : les deux doivent rester identiques. Une règle qui change en
cours de saison n'y est pas écrite en dur : elle est datée dans
lib/picksRegles.ts (miroir SQL tn_points_forfait, migration 0025), sur le
modèle de compositionPour — c'est la start_date du tournoi qui choisit, et
une règle déjà jouée n'est jamais modifiée.
Aujourd'hui : un forfait gagné vaut 5 pts, 10 pts à partir du 2026-10-05
pour un tournoi en 3 sets gagnants (pointsForfaitPour). Abandons, perdant du
forfait et Grands Chelems masculins en 5 sets inchangés.
Périmètre : tn_recompute_picks (donc tn_picks.points) et le Score de
référence de l'onglet Résultats. Le Fantasy, le Bracket et les équipes figées
gardent le défaut de scoreMatch (5 pts). Après un changement de règle,
rejouer tn_recompute_picks sur les seuls tournois concernés.
Les valeurs Grand Chelem sont officielles ; celles des Masters 1000 ne le sont
pas encore et sont dérivées du barème Grand Chelem, ramené au nombre de tours
du tournoi (premier tour ×1, finale ×2, progression régulière). Pour les corriger,
une seule ligne à ajouter dans BAREMES_EXPLICITES (lib/fantasyRegles.ts,
réexporté par lib/fantasy.ts) :
export const BAREMES_EXPLICITES: Record<string, readonly number[]> = {
'GC:7': BAREME_GRAND_CHELEM,
'M1000:7': [1, 1.15, 1.3, 1.5, 1.7, 1.85, 2], // ← clé = famille:nombre de tours
};
Le cache tn_fantasy mémorise les multiplicateurs utilisés : changer le barème
le périme de lui-même, sans invalidation manuelle.
La constante de pVictoire vit dans ECHELLE_ELO (lib/elo.ts), une seule
ligne dont dépend tout le moteur — simulation Monte Carlo comme propagation
analytique, qui appellent pVictoire sans surcharge :
export const ECHELLE_ELO = 400; // ← hérité des échecs, jamais calibré tennis
pVictoire, simulerMatch, simulerTournoi et simulerDepuis acceptent un
paramètre echelle optionnel : il n'existe que pour les écrans de calibration,
qui rejouent le calcul sous d'autres valeurs sans rien changer à la
production. Omis, c'est toujours ECHELLE_ELO qui s'applique.
Après un changement, vider les caches dérivés (tn_projections, tn_fantasy) :
ils ont été calculés sous l'ancienne échelle. Un import d'Elo (/import/elo)
le fait déjà pour les deux.
winner_id ni les scores ne sont lusrounds[0]lib/bracket.ts/tournoi/[id]/picks — écran principal : le plan de picks réactif (ex-onglet
« Plan », /tournoi/[id]/plan y redirige ; lib/planEcran.ts, calcul dans le
navigateur) — un joueur proposé par pick restant, modes Prudent / Équilibré /
Audacieux / Contre les rivaux, grand tableau joueurs × tours. Validation →
tn_picks. (L'ancien écran par colonnes, PickBoard.tsx, n'est plus affiché.)/tournoi/[id]/fantasy — second jeu, à côté des picks et sans interaction
avec eux : une équipe composée une seule fois avant le coup d'envoi, puis
figée — un joueur par palier de classement (5 en Grand Chelem, 4 ailleurs).
Chaque joueur y marque sur tous ses matchs, au même barème, pondéré par un
multiplicateur croissant selon le tour. L'écran propose la composition
optimale, le détail tour par tour au clic, et le total de l'équipe.
Espérance a priori : la simulation part toujours du tirage, tableau
complet, et n'injecte aucun résultat réel — la question posée est « quelle
équipe fallait-il composer au vu du tirage », pas « que rapportera-t-elle
compte tenu de ce qui est joué ». Le résultat est donc identique que le
tournoi soit à venir, en cours ou terminé. Lit le même cache
tn_projections que les picks, sur le premier tour.
À côté de l'espérance, l'écran affiche le score réel de cette même équipe
figée — ce qu'elle a marqué sur les résultats importés, joueur par joueur,
tour par tour et en total. C'est une mesure, jamais un critère : la
composition ne tient aucun compte des résultats.
Le bye vaut une victoire 6/4 6/4 — 5 (victoire) + 6 (net sets) + 4 (net
games) = 15 points, multipliés par le coefficient du tour où il tombe.
Règle propre au Fantasy : aux picks, un bye reste à 0 point, et lib/scoring.ts
n'est pas touché. Comme l'exemption est acquise au tirage, elle entre en
espérance comme un gain certain (présence 100 %, aucune simulation), et
au même montant dans le score réel — sans quoi l'écart prédit/réalisé
s'ouvrirait sur une différence de convention./tournoi/[id]/fantasy/scenario — « et si » du Fantasy, en lecture seule.
Un curseur par match d'un tour constitué (probabilité du modèle de
production par défaut) ; « Simuler » rejoue le tournoi depuis ce tour
(simulerDepuis, 3 000 tirages, deux passes de même graine) et donne le
score final projeté de l'équipe DÉJÀ FIGÉE : points réels avant le tour +
espérance simulée ensuite (lib/fantasyScenario.ts). Rien n'est
enregistré, l'équipe ne change pas. À 0 % ou 100 %, l'issue est certaine./fantasy — historique prédit / réalisé, un tournoi par ligne. Écrit à
chaque import ; le bouton « Reprendre les tournois déjà en base » rattrape
l'existant (POST /api/fantasy/backfill, borné dans le temps, à recliquer
tant qu'il reste des tournois). La synthèse ne porte que sur les tournois
terminés — un tournoi en cours a un score tronqué qui tirerait la moyenne
vers le bas. Collecte seulement : aucun paramètre du modèle n'est dérivé
de ces chiffres, ni aujourd'hui ni automatiquement demain. Sur quelques
tournois, l'écart est dominé par le bruit ; s'y ajuster serait du
sur-apprentissage. On accumule pour pouvoir regarder, et décider à la main.
Deux colonnes de prédit, et la seconde est la bonne : la première rejoue
l'équipe avec les Elo d'aujourd'hui, qui ont déjà intégré les résultats du
tournoi (les joueurs allés loin en sont ressortis relevés, donc l'équipe
reconstituée est en partie choisie POUR avoir bien fini) ; la colonne « sans
look-ahead » repart du dernier relevé Elo antérieur au tirage
(db/fantasy-anterieur.ts). Un tournoi antérieur à l'archive Elo
affiche « — » et reste hors de la synthèse propre, plutôt que d'y entrer avec
un chiffre flatté./calibration — la courbe Elo → probabilité du moteur
(P = 1 / (1 + 10^(−Δ/400)), cf. pVictoire dans lib/elo.ts) confrontée à
tous les matchs terminés en base : fréquence réelle de victoire du favori par
tranche d'écart d'Elo, face à la probabilité prédite, plus la constante qui
minimise l'écart. Le favori est celui au plus haut Elo effectif, pas la
tête de série. Tranches sous 20 matchs signalées « non significatif » et
exclues de l'ajustement. Mesure seulement : lib/elo.ts n'est pas touché.
Réserve importante, rappelée sur la page : les Elo utilisés sont les Elo
actuels, qui intègrent le résultat des matchs testés — la fréquence de
victoire du favori en ressort surestimée et la constante ajustée, plus basse
que la vraie. Même sortie en JSON via POST /api/calibration/elo./calibration/echelle — le même choix, jugé sur ce qui compte pour l'app :
pour chaque échelle candidate (400, 370, 350, 305), on rejoue chaque
tournoi terminé (simulation, espérances, composition de l'équipe, score réel
de cette équipe) et on affiche l'écart prédit/réalisé, global et par
catégorie (GC / M1000 / autres). La colonne « pire catégorie » sert de
garde-fou : une échelle peut annuler l'écart global en compensant une
catégorie trop optimiste par une autre trop pessimiste. Déclenché au bouton
(une simulation par tournoi ET par échelle), POST /api/calibration/echelle.
Même réserve de circularité, et même corpus que /calibration : ce n'est
pas une seconde preuve, c'est la même vue autrement./calibration/cotes — le blend Elo / cotes, mesuré avant d'être branché.
Trois méthodes concourent sur les mêmes matchs : Elo seul, cotes seules, et
leur mélange 50/50 (blendAvecCotes, lib/elo.ts). Chacune est jugée au
score de Brier et à la log-loss, tous deux « plus bas = mieux » — les
deux sont affichés parce que la log-loss punit bien plus durement une
prédiction confiante et fausse. Rien n'est branché : ni les picks, ni le
fantasy, ni la simulation ne lisent ces cotes.
L'Elo comparé aux cotes est celui d'AVANT le match (dernier relevé
Tennis Abstract strictement antérieur, cf. db/elo-historique.ts) :
une cote est capturée avant la rencontre, l'Elo doit l'être aussi, sinon on
compare une prédiction à une rétrodiction. Les matchs sans Elo antérieur —
aucun relevé plus ancien, ou un joueur absent du relevé — sont affichés,
comptés et exclus de cette évaluation, jamais remplis avec l'Elo courant.
Celui-ci reste affiché en second tableau, « pour mémoire », pour que l'écart
entre les deux se lise.
Source : The Odds API v4, clé dans ODDS_API_KEY (variable d'environnement,
jamais dans le code). Les probabilités sont dévigorisées (1/cote puis
normalisation à somme 1, ce qui retire la marge du book) et agrégées par la
médiane des bookmakers — un book en retard sur une blessure déplacerait
une moyenne, pas une médiane. Le rapprochement des noms réutilise
lib/matching.ts ; une rencontre non appariée est affichée et exclue du
score, jamais ignorée en silence.
Quota et fenêtre de capture. Le palier gratuit plafonne à 500 requêtes par
mois : seul le bouton « Récupérer les cotes » appelle l'API (1 crédit :
un marché, une région), l'affichage ne lit que le cache tn_odds. Surtout,
ce palier ne sert que les rencontres à venir ou en cours — l'historique
est payant. Une cote doit donc être capturée AVANT que le match ne se joue ;
sur un tournoi déjà terminé sans capture préalable, il n'y a rien à
récupérer, et l'écran le dit.
Évolution avant chaque match. Chaque clic ajoute aussi une ligne par
rencontre dans tn_odds_historique (migration 0020), jamais écrasée : une
courbe par match trace la probabilité à chaque capture prise avant le coup
d'envoi (lib/cotesEvolution.ts). Aucune capture automatique : autant de
points que de clics./face-a-face — historique entre deux joueurs d'un même circuit : bilan
des rencontres dans les tournois IMPORTÉS (pas la carrière entière), par
surface, avec l'Elo effectif et P(victoire) par surface, et l'évolution de
l'Elo général relevé par relevé (ta_elo_historique)./bilan — bilan de saison généré à l'affichage : classement du groupe
(mêmes points que le Dashboard, via pointsStock.ts), mes picks les plus
rentables, Fantasy des tournois terminés, meilleures progressions et baisses
d'Elo des joueurs de nos tournois (sur la seule période couverte par
l'archive, affichée), joueurs les plus victorieux./tournoi/[id]/predictions — « bracket prédit » : pour chaque joueur encore en
lice, P(atteindre chaque tour restant) et P(titre), triées par probabilité de
titre décroissante. Lit le même cache tn_projections que l'écran picks — rien
n'est resimulé si les picks ont déjà été affichés./tournoi/[id]/resultats — points par pick (match / net sets / net games) et
total du tournoi. Bouton « Recalculer » → /api/recompute.
À côté du total, le score de référence : ce qu'auraient rapporté les picks
si l'on avait suivi l'app à la lettre. À chaque tour on prend la (les)
recommandation(s) de l'écran Picks — espérance de points maximale parmi les
survivants réels, deux par tour tant que le tableau a deux moitiés, une seule
en demies et en finale — sous la même contrainte d'unicité que le jeu :
un joueur déjà pris descend d'un cran dans l'ordre des espérances, et un tour
dont tous les survivants sont utilisés reste vide (« aucun pick possible »,
exactement comme dans le jeu réel). Le total est la somme de leurs points
réels ; un match indécis vaut 0, comme pour le score de l'utilisateur.
Détail tour par tour au clic, avec le pick réel en regard.
Lecture seule : ni les picks ni le score ne sont modifiés. Les espérances
viennent du même cache tn_projections que l'écran Picks (une entrée par
tour de départ), donc aucune divergence possible avec ce que l'app affichait.
Seuls les tours effectivement joués comptent, ce qui borne le calcul ; il est
malgré tout rendu en flux (<Suspense>) — sur un tableau de 128 au cache
froid, il faut une simulation par tour (une quinzaine de secondes) et le reste
de la page ne doit pas l'attendre.POST /api/recompute — appelle la fonction Supabase tn_recompute_picks().db/anon.tsfor select to anon using (true)tn_*tn_recompute_picksSUPABASE_SERVICE_ROLE_KEYdb/server.tsimport 'server-only'Espérances par simulation Monte Carlo plutôt que par propagation analytique,
qui s'effondrait aux tours tardifs (espérances → 0, slots de picks laissés
vides). L'écran picks simule à partir du tour affiché (simulerDepuis,
20 000 runs) : seuls les survivants réels de ce tour sont simulés — un joueur
déjà éliminé n'apparaît plus comme candidat. Le résultat est mis en cache dans
tn_projections, indexé par from_round (E[pts] + P d'avancer). L'import
invalide tout le cache du tournoi puis préchauffe le tour courant ; les autres
tours sont simulés à la demande au premier affichage. Voir
db/projections.ts.
Slots sans pick possible. À un tour donné, si tous les survivants d'une
moitié ont déjà été pickés, le slot ne peut littéralement pas être rempli —
état valide du jeu (un joueur ne sert qu'une fois), pas une erreur. Ces slots
sont détectés par etatsSlots() à partir des matchs réels (pas des
projections : la disponibilité est un fait du tableau, et il faut pouvoir la
calculer sur tous les tours sans lancer une simulation par tour). Ils sortent
du décompte « requis » du tour, qui devient donc complétable. Un tour dont les
joueurs ne sont pas encore connus n'est pas « impossible », seulement
indéterminé.
Le calcul des points est découplé de la validation des tours.
tn_recompute_picks scorait déjà chaque pick contre son match, sans notion de
tour complet — mais il n'était appelé que par le bouton « Recalculer » de
l'écran Résultats. Des picks dont le match était terminé restaient donc à
points = null. recalculerPoints() est désormais appelé à l'import (arrivée
des résultats) et à chaque validation/suppression de pick ; le bouton n'est
plus qu'un filet de sécurité.
P(titre) ajoutée au moteur. presence ne couvre que « jouer un tour » : la
finale y figure, la gagner non — la boucle de simulation s'arrête dès qu'il ne
reste qu'un joueur. simulerTournoi crédite désormais le dernier survivant de
chaque simulation (titres), seul ajout au moteur fourni ; aucune sortie
existante ne change. Vérifié : Σ P(titre) = 1, Σ P(finale) = 2, et
P(titre) ≤ P(finale) pour chaque joueur. La valeur est stockée dans
tn_projections sous le pseudo-tour TITRE, hors de tournament.rounds pour
rester invisible de l'optimiseur.
Référentiel des tournois (lib/calendrier.ts) pour la surface, la catégorie
et la date de début. Il remplace devinerSurface(slug, mois), dont le repli
calendaire recevait le mois de l'extraction et non celui du tournoi :
importer un tableau en juillet classait l'Australian Open sur gazon (15 des 22
tournois en base étaient faux). L'extraction du bookmarklet ne portant aucune
date, start_date — colonne déjà présente au schéma mais jamais remplie — est
reconstituée depuis la semaine ISO habituelle du tournoi. npm run backfill:tournois corrige les lignes existantes (aperçu par défaut,
--appliquer pour écrire), noms d'affichage compris, et liste les slugs
qu'il ne reconnaît pas.
Les clés du calendrier WTA sont les slugs réels de wtatennis.com, relevés
dans /sitemap/tournaments.xml — pas les noms courants. Le site emploie deux
formes d'URL, /tournaments/{slug}/draws pour les Grands Chelems et les
1000, /tournaments/{id}/{slug}/{annee}/draws pour le reste : d'où
canadian-open (et non montreal), china-open (et non beijing),
miami-open, madrid-open, cincinnati-open, wuhan-open. Surface,
catégorie et semaine viennent de l'API publique de la WTA, calendrier 2026 ;
les noms courants et les variantes d'une saison à l'autre restent acceptés
via ALIAS_WTA. Un slug de WTA 125 (madrid-125) n'hérite jamais de la
fiche du tournoi principal de la même ville : mieux vaut un tournoi non
reconnu, donc signalé, qu'un WTA 125 classé WTA 1000.
Identité du tournoi retrouvée dans l'URL source. Le bookmarklet WTA ne
renseigne ni tournament.slug, ni l'identifiant, ni l'année : ses imports
arrivaient en « Tournoi 2026 », sans surface ni catégorie, et — faute de clé
— une ligne de plus à chaque réimport. identiteDepuisUrl les relit dans
source_url (le champ explicite prime toujours). Le premier segment
numérique est l'identifiant, les suivants l'année : les identifiants WTA
récents (2014 Adélaïde, 2088 Abu Dhabi) ressemblent à des années, seule leur
position les distingue.
Un slug inconnu s'affiche brut et se signale. « wuhan-open 2026 » dans la liste des tournois dit quelle fiche ajouter ; « Tournoi 2026 » ne disait rien. L'import le remonte dans ses avertissements — surface, catégorie et date ne sont alors que des défauts — et le journalise côté serveur avec l'URL source.
Elo : source externe Tennis Abstract, avec repli — deux niveaux, pas trois.
ta_elo reçoit les rapports hebdomadaires de Tennis Abstract (~540 joueurs par
circuit, Elo global + dur/terre/gazon). resoudreElos (db/elo.ts) écrit
une cascade à trois étages — TA, puis Elo maison, puis défaut — mais l'étage
du milieu n'est jamais atteint : en pratique la cascade est
TA → défaut (1650).
La raison tient à ce qui alimente tn_players : l'import de tableau n'y écrit
que l'identité (id, circuit, nom, pays). Il n'écrit ni rank, ni les
colonnes elo_*, et rien d'autre ne les alimente — le code qui calculerait
les Elo maison n'est pas branché (voir plus bas). Sur la base actuelle :
rank est NULL sur les 365 lignes, 96 lignes portent encore un elo_*
hérité, et ces 96-là sont toutes appariées à Tennis Abstract, qui prime. Sur
1 404 résolutions (tous tournois confondus) : 1 388 TA, 0 maison, 12 défaut,
4 ambigus.
Conséquence à connaître. Un joueur non apparié dans Tennis Abstract ne
tombe pas seulement à 1650 : il se retrouve aussi sans rang, puisque le
rang affiché vient de ta_elo.atp_rank (p.rank ?? e.rangTa, cf.
rowsToPlayers). Or estEligible (lib/fantasy.ts) rejette un rang nul :
ces joueurs sont donc inéligibles à tous les paliers Fantasy. C'est ce qui
explique les équipes à 3 paliers pourvus sur 4 de Marrakech, Hong Kong et
Adélaïde. Aujourd'hui 16 joueurs sont dans ce cas sur l'ensemble des tournois.
Tennis Abstract ne publiant pas d'ID ATP, le rapprochement se fait par nom
normalisé (lib/matching.ts) ; les cas irréductibles se déclarent dans
ta_name_exceptions. L'écran Picks affiche l'Elo effectif de chaque joueur et
sa source, pour repérer d'un coup d'œil une mauvaise correspondance — un badge
« défaut » y signale exactement un joueur tombé au bout de cette cascade.
Le calcul d'Elo maison existe mais n'est pas branché. calculerElos,
majElo, nouvelEloRecord et facteurK (lib/elo.ts) forment une chaîne
complète pour recalculer des Elo match par match depuis les tournois importés,
avec un écart propre au bo5 (ecartFormat). L'app n'en appelle aucune —
seuls les scripts de recherche/ s'en servent : elles subsistent en
réserve, pour le jour où l'on voudrait un repli entre Tennis Abstract et le
défaut. Tant qu'elles ne le sont pas, l'étage « maison » de resoudreElos
reste du code atteignable mais jamais emprunté — il ne se déclencherait que
pour un joueur portant un elo_* hérité ET absent des rapports TA.
Les Elo arrivent par le presse-papier, comme les tableaux. Tennis Abstract
répond 403 aux requêtes venant des IP de datacenter : imiter les en-têtes d'un
navigateur n'y change rien, c'est l'IP qui est filtrée, et le fetch serveur est
donc mort en production. Plutôt que de chercher un contournement fragile
(proxy, cache tiers), on reprend le chemin qui marche déjà pour les tableaux :
un snippet lit la page dans le navigateur, l'app reçoit le JSON par
collage. Le parsing des Elo et l'upsert ne sont écrits qu'une fois
(ecrireRapport) — collage et fetch y convergent, si bien que le repli reste
vérifiable en local sans code parallèle à maintenir.
Homonymes : l'identité est le slug TA, pas le nom. « Andrej Martin » (SVK)
et « Andres Martin » (USA) se normalisent tous deux en a martin ; le rapport
Elo ne publie aucun pays, donc rien dans la source ne les départage.
ta_elo est donc unique sur (ta_slug, tour) — le slug vient de l'URL TA
(player.cgi?p=AndresMartin) — et les deux lignes coexistent. Le
rapprochement d'un nom ambigu ne choisit aucun des deux : le joueur passe
en source ambigu (badge violet), la valeur retombe sur le repli, et l'écran
Picks affiche les candidats avec l'insert prêt à coller dans
ta_name_exceptions (colonne ta_slug). Un Elo faux et silencieux est pire
qu'un signalement.
Un match en cours est un match pas encore joué — une seule liste le dit.
Le bookmarklet marque in_progress les rencontres en train de se jouer sur un
tableau en direct ; la contrainte tn_matches_status_check ne connaissait que
live, et l'upsert étant atomique, importer Montréal WTA en cours perdait les
128 lignes pour 4 matchs sur le court. in_progress est donc accepté au
schéma (migration 0009) à côté de live, sans réécriture à l'import : la
valeur stockée reste celle de la source.
Ce qu'il vaut : rien. Pas d'issue connue, donc pas de vainqueur, pas de
points (scoreMatch et tn_score_match le rendent à 0 comme un scheduled,
ses sets partiels compris), pas d'entrée dans le score réel — picks comme
fantasy — et un tournoi qui en contient un n'est pas terminé. La simulation le
rejoue depuis zéro : à Elo égal, une joueuse menant 6-0 4-0 reste à 50 % de
passer le tour, et le bracket, qui ne lit aucun résultat, ne bouge pas.
La liste des statuts sans issue était recopiée dans huit modules ; c'est
ce qui a fait mentir le code un fichier à la fois. Elle vit désormais dans
STATUTS_INDECIS / STATUTS_DECIDES (lib/types.ts) et nulle part ailleurs.
Par sécurité, l'import neutralise le vainqueur d'un match indécis (un
tableau en direct peut désigner la joueuse qui mène) et verifierExtraction
nomme tout statut hors vocabulaire plutôt que de laisser remonter une
erreur SQL brute.
best_of et surface déduits à l'import (devinerBestOf, devinerSurface),
stockés sur tn_tournaments et propagés au scoring comme à la simulation
(depuis 0025, best_of décide aussi si un forfait gagné vaut 10 pts aux
picks : jamais en 5 sets).
Le schéma ne stocke pas la tête de série (ni la moitié de tableau) sur
tn_players. Après import, les structures du moteur (Match[], Player) sont
reconstruites depuis la DB (db/queries.ts) : la moitié se déduit du
match de 1er tour ; la reconstruction est vérifiée identique à l'extraction en
mémoire. La tête de série n'étant pas persistée, l'UI affiche le rang ATP quand
il est connu.
Next.js 16 : params et searchParams sont des Promise (breaking change),
d'où le await dans les pages.