Case study · 2026-05-08

OL Companion

Compagnon de saison pour l'Olympique Lyonnais : classement Ligue 1, calendrier, brackets de coupe, live-match avec shot map, carte L1 bicolore. React 18 + TanStack côté front, NestJS 11 + crons côté back, push live via SSE depuis 365scores. 100% du code écrit en pair-programming avec Claude Code (zéro ligne tapée à la main).

Pourquoi cette app

Suivre l'OL en Ligue 1 sans jongler entre dix sites : un seul écran qui agrège classement, forme, prochains matchs, calendrier, brackets de coupe, news communautaires et — le plus tendu — un live-match avec shot map quand l'OL joue. L'app est déployée sur le NAS Synology et tourne en LAN ; elle sert aussi de bac à sable pour la stack React 18 + Vite + TanStack Router/Query + Tailwind côté front, et NestJS 11 + crons @nestjs/schedule côté back.

Toute la base de code (frontend + backend + Docker + nginx) a été écrite en pair-programming avec Claude Code. Sylvain n'a tapé aucune ligne directement : direction produit + revues + arbitrages, exécution déléguée à l'agent.

Architecture

Trois sources de données externes, agrégées côté backend, mises en cache JSON local, poussées en live au frontend via SSE :

Architecture
Loading diagram…
Frontend React (4202) ↔ Backend NestJS (3002) ↔ 3 sources externes. SSE @Sse() pousse les invalidations TanStack Query.

Front (port 4202) et back (port 3002) tournent côte-à-côte sur le NAS Synology dans deux containers Docker du même docker-compose.yml. nginx proxifie /api/ vers le backend (même réseau Docker), ce qui permet à la SSE de passer sans proxy_buffering en plus.

Pattern phare · SSE → TanStack Query invalidation

Le morceau de bravoure de l'app, repris ailleurs depuis (cf sse_invalidation_pattern.md). Côté backend, un EventBusService global expose un Subject<...> RxJS. Chaque cron qui poll 365scores diffe sa réponse via une signature courte ([statusGroup, scores, gameTime, events.length].join('|')) et n'émet sur le bus que si la signature change. Pas de spam, pas de re-render à vide.

Un @Sse() controller merge le flux du bus avec un heartbeat 30s (sinon proxies/load-balancers ferment la connexion idle). Côté front, un seul hook useEventStream() monté dans l'AppShell ouvre un EventSource et appelle qc.invalidateQueries(...) selon le type reçu. Les query keys sont structurées en arbre (['live-match']['live-match', 'stats', gameId, matchupId]), donc une seule invalidation root rafraîchit toute la branche.

Filet de sécurité : les hooks live gardent un refetchInterval: 30_000 conditionnel (uniquement quand status === 'live'), au cas où la SSE tombe. Reconnexion automatique de EventSource gère le reste — pas de logique retry à coder.

Données · 365scores reverse-engineered

365scores n'a pas d'API publique. Les endpoints utilisés (data.365scores.com/web/standings/, webws.365scores.com/web/game/, /web/games/results/, /web/games/fixtures/) ont été inspectés au devtools puis stabilisés derrière SCORES365_HEADERS — sans X-Domain: fr, Referer ligue-1-35 et User-Agent réaliste, c'est HTTP 403.

IDs critiques : Ligue 1 = compétition 35, OL = competitorId=465 côté 365scores et teamId=523 côté football-data.org. Le backend remappe 465 → 523 en sortie pour rester compatible avec l'historique frontend.

Standings — défense en profondeur. 365scores publie déjà l'ordre LFP officiel (différence générale → buts marqués → face-à-face), mais standings.service.ts re-trie localement après fetch, en cas de changement de format upstream. Pas de H2H local reconstruit depuis football-data : déjà essayé, ne match jamais ligue1.com.

Round-complete check sur les snapshots season-rankings.json : la mise à jour ne se fait que si MIN(played) === MAX(played) (journée complète pour toutes les équipes), sinon on capture des positions instables mid-journée.

Pour les logos clubs, l'app combine deux sources : Wikipedia FR via /api/wiki-image?q= (avec mapping statique ID → full wiki name pour les noms ambigus type "Reims" / "Brest" qui retournent la ville plutôt que le club), et le CDN 365scores (imagecache.365scores.com/.../Competitors/<id>) pour les popups de la carte L1.

Identité visuelle · rouge & bleu OL, vert sémantique

La règle palette est verrouillée par le CLAUDE.md du projet pour ne pas dériver entre deux sessions. Les couleurs identité OL (rouge #dc2626 / bleu marine #1e3a8a) structurent toute la décoration ; le vert n'apparaît que pour les usages sémantiques universels — forme W (vert) / N (jaune) / L (rouge), goal-difference positif, badge "Victoire", positions 1-3 LdC dans le tableau.

Conséquence concrète : sur le PositionTracker (Recharts), la ligne d'évolution est bleu OL (pas vert), et le dot du point courant est rouge OL. Sur la carte Ligue 1, les marqueurs des autres clubs sont bleus, OL est rouge plein. Seule exception : le thème /fcnoobz (Football Manager save perso) active body.theme-fcnoobz qui override en lime + bleu électrique cyberpunk.

Et deux features où Claude Code a livré pile ce qu'il fallait :

  • Brackets de coupe (CdF + EL) qui continuent après élimination OLbracket.service.ts fetch les matchs des autres équipes via /results/ + /fixtures/ (l'endpoint racine /web/games/?competitions=X renvoie 0 — piège vérifié). CdF affichée dès stageNum 6 (1/4), EL dès stageNum 3 (1/8). Suivre le parcours adverse même quand l'OL est sorti reste utile : c'est volontaire.
  • Carte Ligue 1 avec markers bicolores leg-aller/leg-retour — Leaflet + react-leaflet + tuiles OSM, 18 clubs hardcodés (ligue1-clubs-coords.ts). Chaque marqueur est un cercle SVG dont la moitié gauche représente le match aller et la moitié droite le retour. Couleur de chaque moitié = résultat OL côté concerné. Source = full-season history 365scores. Collision Paris (PSG + Paris FC) gérée par offset visuel +20px y sur le second.

Stack & crédits

  • Frontend · React 18 · Vite 5 · TanStack Router 1.78 · TanStack Query 5.59 · Tailwind 3.4 · Recharts 2.13 · Leaflet + react-leaflet · Lucide.
  • Backend · NestJS 11 · @nestjs/schedule v5 · RxJS (SSE via @Sse()) · Anthropic SDK 0.91 · Jest (>35 tests verts) · stockage JSON local.
  • Sources · 365scores (classement, forme, brackets, shot chart) · football-data.org (calendrier officiel) · Wikipedia FR via /api/wiki-image · CDN 365scores pour logos clubs.
  • Infra · Docker multi-stage (node:20-alpinenginx:alpine) sur NAS Synology, port 4202 / 3002.

Crédits IA

Collab à trois — humain (direction produit, revues, arbitrages) + Claude Code (code frontend, backend, infra Docker, debugging) + ChatGPT (logos OL Companion et FC Noobz, propositions de design). Sylvain a écrit zéro ligne de la base de code à la main.

Snapshot 2026-05-08 · 4 sprints livrés sur main entre 05-02 et 05-08.