Documentation technique

Architecture, API, schémas de données et outils de débogage pour Hockey Builder.


Stack technique

CoucheTechnologieVersionRôle
RuntimeNode.js≥ 18Exécute le serveur local
Serveur HTTPExpress5.xAPI REST + fichiers statiques
CORScors2.xAutorise les requêtes cross-origin
Uploadmulter2.xTraitement multipart (images Studio)
FrontendVanilla JS / HTML / CSSPas de framework, pas de build
PersistanceFichiers JSONstore.json, config/, data/
API externenationalleague.ch/apiDonnées matchs, joueurs, classement
Pas de base de données

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

Hockey Builder/ ├── server.js ← serveur Express, tous les endpoints ├── package.json ├── config/ ← configuration persistante (éditée via UI) │ ├── settings.json ← chemins export, port, labels playoff │ ├── teams.json ← mapping apiName → shortName + code │ └── channels.json ← mapping channelId → label chaîne ├── data/ ← état runtime (ne pas modifier à la main sauf urgence) │ ├── store.json ← tout l'état UI (crosspromos, invités, joueurs…) │ ├── studio_state.json ← configuration studio compositing │ ├── tabs_order.json ← ordre interne des images TABS │ ├── sponsors_order.json← ordre interne des images SPONSORS │ ├── TABS/ ← PNG tableaux (si chemin relatif) │ ├── SPONSORS/ ← PNG sponsors (si chemin relatif) │ └── output/ ← JSON exportés (si chemins relatifs) │ ├── nl_standings.json │ ├── nl_schedule.json │ ├── ├── public/ ← servi statiquement par Express │ ├── index.html ← SPA principale │ ├── app.js ← toute la logique frontend (~3600 lignes) │ ├── style.css ← styles (~1000 lignes, dark broadcast) │ ├── doc-utilisateur.html │ ├── doc-technique.html ← ce fichier │ └── studio/ │ ├── back.html ← overlay Millumin 1920×1080 │ └── front.html ← overlay Millumin 1152×216

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éthodeEndpointDescription
GET/api/nl/gamesMatchs (proxifié depuis nationalleague.ch)
GET/api/nl/playersJoueurs de toutes les équipes
GET/api/nl/teamsClassement + stats équipes
GET/api/nl/ranking-qQualifications (données XML Keytoq)

Configuration

MéthodeEndpointBody / Réponse
GET/api/config/teamsRetourne { mapping: [{apiName, shortName, code}] }
POST/api/config/teamsSauvegarde le mapping équipes
GET/api/config/settingsRetourne tous les paramètres
POST/api/config/settingsSauvegarde les paramètres
GET/api/storeRetourne l'état complet du store
POST/api/storeRemplace le store (body = objet store complet)

Exports JSON Xpression

MéthodeEndpointFichier cible
POST/api/export/standingsnl_standings.json
POST/api/export/schedulenl_schedule.json
POST/api/export/programnl_program.json
POST/api/export/lowerthird-studionl_lowerthird_studio.json
POST/api/export/lowerthird-playernl_lowerthird_joueur.json
POST/api/export/lowerthird-commentatornl_lowerthird_commentateur.json
POST/api/export/lowerthird-alertenl_lowerthird_alerte.json
POST/api/export/crosspromonl_crosspromo.json
POST/api/export/playoffnl_playoff.json
POST/api/export/tabsnl_tabs.json
POST/api/export/sponsorsnl_sponsors.json

Images TABS & Sponsors

MéthodeEndpointDescription
GET/api/images/listListe ordonnée des PNG TABS
POST/api/images/importImport PNG base64, validation PNG sig + 1920×1080
DEL/api/images/:filenameSupprime un PNG du dossier TABS
POST/api/images/reorderBody: { order: ["a.png","b.png"] } — écrit dans tabs_order.json
GET/api/images/thumb/:filenameStream 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 ~SectionFonctions clés
1État global, API endpoints, helpersstate, API, postJSON, getJSON, saveStore
115Team mapping helpersshortTeamName(), apiAliases(), teamCode()
145Init + date navigationinit(), setupDateNav()
170Load config & storeloadStore(), loadConfig()
300ClassementloadStandingsAPI(), renderStandingsTable(), exportStandings()
550CalendrierloadGames(), renderScheduleTable(), applyScheduleRowEdit()
800Lower Thirds — StudiobindLowerthirdStudio(), renderGuestSelects(), sendGuestSelection()
1000Lower Thirds — CommentateurbindLowerthirdCommentator()
1150Lower Thirds — JoueurbindLowerthirdPlayer(), playerToPayload()
1400CrosspromobuildCrosspromoPayload(), renderCrosspromoList(), sendSelectedCrosspromo()
1700Images TABSimportImageFile(), renderImagesList(), sendTabsImage()
1990SponsorsbindSponsors(), sendSponsorImage()
2200ProgrammebindProgram(), buildProgramJSON()
2330PlayoffbindPlayoff(), buildPlayoffPayload()
3200Studio compositingbindStudio(), publishStudio()
3550RéglagesbindSettings(), 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));
}
    
⚠️ Pas d'écriture atomique

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).

GET — Lire un endpoint
En attente…
POST — Tester un export (avec body JSON)
En attente…
Vérifier l'état de l'API NL
En attente…

🔧 Inspecteur store.json

Lit et permet d'inspecter ou modifier le store directement.

Lire le store actuel
En attente…
Reset complet du store (⚠️ irréversible)

Cela efface toutes les données : invités, crosspromos, joueurs, playoff, notes… Utilise uniquement pour repartir de zéro.

En attente…

🔧 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

Error: Cannot find module 'express' Node

Les dépendances ne sont pas installées.

cd "Hockey Builder"
npm install
Error: ENOENT: no such file or directory (writeFileSync) Export

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.
store.json corrompu — l'app ne démarre plus Store

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 {}
API NL retourne 403 ou timeout API

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
Image PNG refusée à l'import TABS/Sponsors Images

Le serveur valide deux choses :

  1. Magic bytes PNG : octets 0-7 = 89 50 4E 47 0D 0A 1A 0A
  2. 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
Port 3001 déjà utilisé Node
# 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 }
        
Ajouter un nouveau champ dans un JSON Xpression Dev

Exemple : ajouter "season" au classement.

  1. Dans app.js, trouve function buildStandingsJSON()
  2. Ajoute la clé dans l'objet retourné : season: "2025-2026"
  3. Redémarre le serveur (Ctrl+C → node server.js)
  4. 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.