Documentation technique
Architecture, API, schémas de données et outils de débogage pour Hockey Builder.
Stack technique
| Couche | Technologie | Version | Rôle |
|---|---|---|---|
| Runtime | Node.js | ≥ 18 | Exécute le serveur local |
| Serveur HTTP | Express | 5.x | API REST + fichiers statiques |
| CORS | cors | 2.x | Autorise les requêtes cross-origin |
| Upload | multer | 2.x | Traitement multipart (images Studio) |
| Frontend | Vanilla JS / HTML / CSS | — | Pas de framework, pas de build |
| Persistance | Fichiers JSON | — | store.json, config/, data/ |
| API externe | nationalleague.ch/api | — | Données matchs, joueurs, classement |
Toute la persistance repose sur des fichiers JSON locaux. Avantage : portabilité totale et débogage trivial (ouvre les fichiers dans un éditeur). Inconvénient : pas de transactions — un crash pendant l'écriture peut corrompre un fichier.
Structure des fichiers
Flux de données
/* Flux principal */ NL API (nationalleague.ch) │ GET /api/nl/games|players|teams|ranking-q ▼ server.js (proxy Express) │ proxifie + cache en mémoire ▼ app.js (frontend) │ normalise, affiche dans l'UI │ opérateur édite / sélectionne │ ─── POST /api/export/──▶ server.js │ │ writeJSON(configuredPath) │ ▼ │ kDrive (fichier JSON) │ │ │ ▼ │ XPression (DataLink polling) /* Persistance locale */ app.js ─── POST /api/store ──▶ server.js ──▶ data/store.json app.js ─── POST /api/config/settings ──▶ config/settings.json
Proxy API National League
Le serveur proxifie les 4 endpoints NL car l'API SIHF ne supporte pas CORS en direct. Les URLs de base :
NL_BASE = 'https://nationalleague.ch/api' NL_BASE_WWW = 'https://www.nationalleague.ch/api'
État client (state)
L'objet state global dans app.js est la source de vérité du frontend :
const state = { date: '2026-03-07', // date courante (ISO) store: { // persisté dans data/store.json guests: [], // invités Studio guestSlots: ['','',''], // 3 slots envoi Studio commentators: [], commentatorSlots: ['',''], // 2 slots envoi Commentateur createdPlayers: [], crosspromos: [], playoffSeries: [], program: { date:'', entries:[] }, tabsComments: {}, // { "fichier.png": "note" } sponsorsComments: {}, lastSelectedDate: '' }, standings: [], // calculé depuis /api/nl/teams + Q xml todaysGames: [], // calculé depuis rawGames pour state.date images: [], // depuis /api/images/list sponsors: [], // depuis /api/sponsors/list rawGames: [], // brut API matchs rawPlayers: [], // brut API joueurs teamsMapping: [], // depuis /api/config/teams settings: {}, // depuis /api/config/settings standingsManual: false, scheduleManual: false };
Initialisation serveur
const app = require('express')(); app.use(cors()); app.use(express.json({ limit: '30mb' })); // 30mb pour les PNG base64 app.use(express.static('./public')); // sert index.html + assets // Chemins internes (toujours relatifs au dossier du projet) const CONFIG_DIR = path.join(__dirname, 'config'); const DATA_DIR = path.join(__dirname, 'data'); const STORE_PATH = path.join(DATA_DIR, 'store.json'); // resolvePath : chemin absolu si absolu, sinon relatif au projet function resolvePath(p) { return path.isAbsolute(p) ? p : path.join(__dirname, p); }
Les fichiers de config et les JSON d'export peuvent être soit relatifs (dans le dossier du projet) soit absolus (ex: /Users/…/kDrive/…). resolvePath() gère les deux cas.
Référence API complète
Proxy NL
| Méthode | Endpoint | Description |
|---|---|---|
| GET | /api/nl/games | Matchs (proxifié depuis nationalleague.ch) |
| GET | /api/nl/players | Joueurs de toutes les équipes |
| GET | /api/nl/teams | Classement + stats équipes |
| GET | /api/nl/ranking-q | Qualifications (données XML Keytoq) |
Configuration
| Méthode | Endpoint | Body / Réponse |
|---|---|---|
| GET | /api/config/teams | Retourne { mapping: [{apiName, shortName, code}] } |
| POST | /api/config/teams | Sauvegarde le mapping équipes |
| GET | /api/config/settings | Retourne tous les paramètres |
| POST | /api/config/settings | Sauvegarde les paramètres |
| GET | /api/store | Retourne l'état complet du store |
| POST | /api/store | Remplace le store (body = objet store complet) |
Exports JSON Xpression
| Méthode | Endpoint | Fichier cible |
|---|---|---|
| POST | /api/export/standings | nl_standings.json |
| POST | /api/export/schedule | nl_schedule.json |
| POST | /api/export/program | nl_program.json |
| POST | /api/export/lowerthird-studio | nl_lowerthird_studio.json |
| POST | /api/export/lowerthird-player | nl_lowerthird_joueur.json |
| POST | /api/export/lowerthird-commentator | nl_lowerthird_commentateur.json |
| POST | /api/export/lowerthird-alerte | nl_lowerthird_alerte.json |
| POST | /api/export/crosspromo | nl_crosspromo.json |
| POST | /api/export/playoff | nl_playoff.json |
| POST | /api/export/tabs | nl_tabs.json |
| POST | /api/export/sponsors | nl_sponsors.json |
Images TABS & Sponsors
| Méthode | Endpoint | Description |
|---|---|---|
| GET | /api/images/list | Liste ordonnée des PNG TABS |
| POST | /api/images/import | Import PNG base64, validation PNG sig + 1920×1080 |
| DEL | /api/images/:filename | Supprime un PNG du dossier TABS |
| POST | /api/images/reorder | Body: { order: ["a.png","b.png"] } — écrit dans tabs_order.json |
| GET | /api/images/thumb/:filename | Stream PNG depuis le dossier TABS (cache-control: no-cache) |
| Même pattern pour /api/sponsors/… | ||
Schémas JSON exportés
nl_standings.json
{
"date": "07.03.2026",
"teams": [
{ "rank":1, "team":"Zurich", "played":50, "diff":45, "points":98, "q":1 },
…
]
}
Le champ q indique la qualification : 1=qualifié playoff, 0=neutre, -1=relégation/playout. La liste contient toujours 14 équipes.
nl_schedule.json
{
"date": "07.03.2026",
"games": [ // toujours 7 entrées minimum (vides si moins de matchs)
{ "match":"Bienne – Zurich", "home":"Bienne", "away":"Zurich",
"score":"3:2", "overtime":"" },
{ "match":"", "home":"", "away":"", "score":"", "overtime":"" }, …
]
}
nl_lowerthird_studio.json
{
"type": "studio",
"items": [
{ "name": "Roland Guex", "role": "Présentateur" },
{ "name": "Jessica Dafflon", "role": "Présentatrice" }
]
}
nl_crosspromo.json
{
"label": "Quart de finale",
"homeTeam": "Lausanne", "awayTeam": "Zoug",
"homeShort": "LHC", // "empty" si hasPhoto=0
"awayShort": "EVZ", // "empty" si hasPhoto=0
"line1": "Quart de finale",
"line2": "Lausanne - Zoug",
"line3": "Vendredi 7 mars à 19h25 sur MySports UN",
"hasPhoto": 1, // 0 ou 1
"freeTV": 0 // 0 ou 1
}
nl_tabs.json / nl_sponsors.json
{
"updatedAt": "2026-03-07T19:25:00.000Z",
"filename": "classement_semaine_12.png",
"comment": "Classement après la semaine 12" // peut être ""
}
Démarrage client
// app.js — point d'entrée (DOMContentLoaded) async function init() { loadStore(); // GET /api/store → state.store loadConfig(); // GET /api/config/teams + settings + channels setupDateNav(); // boutons ← →, affiche label de date await loadGames(); // GET /api/nl/games → state.rawGames → todaysGames await loadStandingsAPI(); await loadPlayersAPI(); // bind tous les onglets bindStandings(); bindSchedule(); bindLowerthirdStudio(); bindLowerthirdCommentator(); bindLowerthirdPlayer(); bindCrosspromo(); bindImages(); bindSponsors(); bindProgram(); bindPlayoff(); bindStudio(); bindSettings(); }
Modules JS (app.js)
app.js est un fichier monolithique structuré en sections. Chaque section = un onglet :
| Ligne ~ | Section | Fonctions clés |
|---|---|---|
| 1 | État global, API endpoints, helpers | state, API, postJSON, getJSON, saveStore |
| 115 | Team mapping helpers | shortTeamName(), apiAliases(), teamCode() |
| 145 | Init + date navigation | init(), setupDateNav() |
| 170 | Load config & store | loadStore(), loadConfig() |
| 300 | Classement | loadStandingsAPI(), renderStandingsTable(), exportStandings() |
| 550 | Calendrier | loadGames(), renderScheduleTable(), applyScheduleRowEdit() |
| 800 | Lower Thirds — Studio | bindLowerthirdStudio(), renderGuestSelects(), sendGuestSelection() |
| 1000 | Lower Thirds — Commentateur | bindLowerthirdCommentator() |
| 1150 | Lower Thirds — Joueur | bindLowerthirdPlayer(), playerToPayload() |
| 1400 | Crosspromo | buildCrosspromoPayload(), renderCrosspromoList(), sendSelectedCrosspromo() |
| 1700 | Images TABS | importImageFile(), renderImagesList(), sendTabsImage() |
| 1990 | Sponsors | bindSponsors(), sendSponsorImage() |
| 2200 | Programme | bindProgram(), buildProgramJSON() |
| 2330 | Playoff | bindPlayoff(), buildPlayoffPayload() |
| 3200 | Studio compositing | bindStudio(), publishStudio() |
| 3550 | Réglages | bindSettings(), saveAllSettings(), renderTeamsMappingTable() |
Auto-save & store
// Sauvegarde automatique après chaque modification UI async function saveStore() { await postJSON('/api/store', state.store); // flashAutosave() affiche le point vert en haut à droite } // server.js — écriture atomique function writeJSON(filePath, data) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); }
writeFileSync écrit directement. Si Node crash pendant l'écriture, le fichier peut être corrompu. Pour récupérer un store.json corrompu : supprime-le (l'app recrée un store vide) ou remplace-le par un backup.
🔧 Testeur d'API
Ces outils interrogent le serveur en direct. Ils ne fonctionnent que si le serveur tourne (http://localhost:3001).
🔧 Inspecteur store.json
Lit et permet d'inspecter ou modifier le store directement.
Cela efface toutes les données : invités, crosspromos, joueurs, playoff, notes… Utilise uniquement pour repartir de zéro.
🔧 Logs & monitoring
Surveiller les requêtes en temps réel
Dans le Terminal où tourne node server.js, ajoute un middleware de logging. Ouvre server.js et ajoute juste après app.use(cors()) :
// Middleware de logging — à retirer en production app.use((req, res, next) => { console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); if (req.method === 'POST' && req.body) { console.log(' Body keys:', Object.keys(req.body)); } next(); });
Inspecter les fichiers JSON en direct
Pour surveiller un fichier JSON en temps réel depuis le Terminal :
# Surveille nl_crosspromo.json et affiche à chaque modification fswatch ~/kDrive/.../DATA/nl_crosspromo.json | \ xargs -I{} cat ~/kDrive/.../DATA/nl_crosspromo.json # Ou plus simple avec watch (brew install watch) watch -n 1 cat ~/kDrive/.../DATA/nl_tabs.json
Vérifier les fichiers de config
# Valider que les JSON ne sont pas corrompus
node -e "require('./config/settings.json'); console.log('settings.json OK')"
node -e "require('./data/store.json'); console.log('store.json OK')"
node -e "require('./config/teams.json'); console.log('teams.json OK')"
Tester le serveur depuis le Terminal
# Vérifier que le serveur répond curl -s http://localhost:3001/ | head -5 # Lire le store curl -s http://localhost:3001/api/store | python3 -m json.tool | head -30 # Forcer un export classement curl -s -X POST http://localhost:3001/api/export/standings \ -H "Content-Type: application/json" \ -d '{"date":"07.03.2026","teams":[]}' | python3 -m json.tool
🔧 Erreurs communes & résolutions
Les dépendances ne sont pas installées.
cd "Hockey Builder" npm install
Le chemin d'export configuré dans config/settings.json n'existe pas ou le kDrive est démonté.
- Vérifie que le kDrive est monté :
ls ~/kDrive/ - Vérifie le chemin dans Réglages → Chemins d'export
- Le serveur crée les dossiers manquants automatiquement (
mkdirSync recursive), mais uniquement si le disque est accessible.
Si JSON.parse() échoue sur store.json, loadStore() retourne un store vide par défaut (comportement de readJSON(path, defaultValue)). L'app repart de zéro.
Pour vérifier :
node -e "JSON.parse(require('fs').readFileSync('./data/store.json','utf-8'))"
# Si erreur → fichier corrompu, supprime-le ou remplace par {}
L'API de la National League peut être instable. Le serveur retourne l'erreur HTTP telle quelle. Comportement côté client :
- Classement : affiche API indisponible, utilise les données en cache (
state.standings) - Calendrier : idem avec
state.rawGames
Pour tester directement depuis Terminal :
curl -I https://nationalleague.ch/api/v1/games curl -I https://www.nationalleague.ch/api/v1/players
Le serveur valide deux choses :
- Magic bytes PNG : octets 0-7 =
89 50 4E 47 0D 0A 1A 0A - Dimensions : lues dans le chunk IHDR aux offsets 16-23 du fichier
// server.js — validation (lignes ~460-490) const PNG_SIG = Buffer.from([0x89,0x50,0x4E,0x47,...]); const width = buffer.readUInt32BE(16); // doit être 1920 const height = buffer.readUInt32BE(20); // doit être 1080
Pour convertir un PNG à la bonne taille avec sips (macOS) :
sips -z 1080 1920 mon_image.png
# Trouver quel processus utilise le port 3001 lsof -ti:3001 # Tuer le processus kill $(lsof -ti:3001) # Ou changer le port dans config/settings.json { ..., "port": 3002 }
Exemple : ajouter "season" au classement.
- Dans
app.js, trouvefunction buildStandingsJSON() - Ajoute la clé dans l'objet retourné :
season: "2025-2026" - Redémarre le serveur (Ctrl+C →
node server.js) - Clique "Envoyer le classement" — le nouveau champ apparaît dans le JSON
Le server.js écrit le body de la requête POST tel quel, sans transformation. Toute la logique de construction du payload est dans app.js.