vliaudatandClaude Opus 5 5f7aa951d3 feat: explain a schedule change on the panel, and preview it in advance
Two things the shop asked for, and one it will notice.

**A message on an exception.** A closure or a late opening can now carry
a reason, and it reaches the panel. It shows while the door is shut and
disappears the moment the shop opens — a notice explaining a late
opening is worse than useless once the door is open.

It outranks a free message on purpose: "closed this afternoon, back
tomorrow" is what someone standing outside needs, and "new collection in
store" can wait. Say so if you would rather it were the other way.

The first version keyed this off the day's `isOpen`, which means "this
day has opening hours" — so a day opening at 14:00 counted as open all
morning, exactly when the reason is needed. A test caught it; the rule
now reads the state at this minute.

**Reusable phrases.** The same handful of notices get written over and
over. /admin/modeles keeps them, translated once, and offers them
wherever a message is composed — exceptions, closure periods, the
banner. Picking one costs no translation at all: the saved English is
reused directly, where the service takes the better part of ten seconds.

Exception notes and holiday labels are translated too, which they were
not before. After the save rather than during it, so a slow service
never costs the shop its dates.

**Previewing the future.** The dashboard can render the panel at any
moment within about a year: "what will the window say while I'm away?"
is worth answering before someone is standing in front of a locked door.
The whole pipeline was already a function of "now", so this costs
passing a different instant. An unparseable or absurd value falls back
to the present rather than confidently rendering nonsense.

Checked against a three-week holiday, which surfaced something worth
knowing: the automatic "opens on…" line stays empty, because the
resolver's search is bounded to fourteen days. During a long closure the
message is the only thing that tells customers when the shop is back —
which is a good reason for this feature to exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-23 09:34:14 +02:00
2026-09-20 18:22:00 +02:00

Horaires ITA ITO

Panneau d'administration des horaires d'ouverture de la boutique ITA ITO, et serveur d'affichage pour un écran e-ink TRMNL 7,5" (kit DIY Seeed) posé en vitrine.

Changer un horaire depuis un téléphone, debout dans la boutique, en deux clics — et que l'écran suive tout seul, jours fériés genevois et message bilingue compris.

  Écran e-ink (ESP32, Wi-Fi boutique)
        │  GET /api/setup · GET /api/display · POST /api/log
        │  GET /api/device/image/<hash>.bmp
        ▼
  ┌──────────────────────────────────────────────┐
  │  Next.js 16 + PostgreSQL 16                  │
  │  horaires.ita-ito.com                        │
  └──────────────────────────────────────────────┘
        ▲                          │
        │ OIDC                     ├─► api.loxi.ch          (traduction FR→EN)
   Navigateur admin                └─► openholidaysapi.org  (jours fériés CH-GE)
     auth.loxi.ch

Ce qu'il faut savoir avant tout

L'écran ne reçoit rien. Il dort sur batterie, se réveille, demande quoi afficher, et se rendort. On ne peut pas lui pousser une mise à jour : le seul levier est la durée de son sommeil, réglable dans Paramètres → Écran. Une modification apparaît donc au réveil suivant, pas immédiatement. C'est la nature du BYOS, pas une limitation de cette application.

Le nom du fichier image est le hash de son contenu. Si rien n'a changé, l'écran reconnaît le nom, ne redessine pas, et économise sa batterie. C'est pourquoi le rendu doit rester reproductible à l'octet près — d'où les polices et le logo versionnés dans public/.

Prérequis

  • Docker et Docker Compose
  • Node.js ≥ 20.9 (seulement pour le développement hors conteneur)
  • Une application OIDC dans Authentik (voir plus bas)
  • Un écran TRMNL 7,5" dont le portail captif accepte un serveur personnalisé

Installation locale

cp .env.example .env
sed -i "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 33)|" .env
# Renseignez ensuite POSTGRES_PASSWORD, AUTH_AUTHENTIK_*, TRANSLATION_API_KEY.

docker compose up -d db --wait      # PostgreSQL seul
npm install
npx prisma migrate deploy
npm run seed                        # horaires de démonstration, idempotent
npm run dev                         # http://localhost:3010

Le port 3010 n'est pas un caprice : 3000 est déjà pris par une autre pile sur la machine de développement.

Pour faire tourner l'application elle aussi en conteneur :

docker compose up -d --build --wait

Commandes utiles

Commande Effet
npm run dev serveur de développement sur 3010
npm test toute la suite (unitaire + intégration)
npm run coverage idem, avec les seuils de couverture
npm run lint / npm run typecheck ESLint / TypeScript strict
npm run screen:preview rend l'écran dans .preview/, sans appareil
npm run brand régénère le logo depuis le CDN de la boutique
npm run holidays:sync synchronise les jours fériés maintenant
npm run translate:test "texte" vérifie le service de traduction

Les tests d'intégration ont besoin d'une base à eux — TEST_DATABASE_URL. Ils la vident entièrement, donc elle ne doit jamais désigner une base qui contient quoi que ce soit d'utile. Sans cette variable, ils se désactivent plutôt que de détruire quelque chose.

docker compose exec db psql -U horaires -d postgres -c "create database horaires_test owner horaires;"
DATABASE_URL="$TEST_DATABASE_URL" npx prisma migrate deploy

Configuration d'Authentik

  1. Groupe — créez horaires-admins et ajoutez-y les personnes qui peuvent modifier les horaires. Ne liez pas ce groupe à l'application : seuls les administrateurs pourraient alors se connecter, et le rôle lecture seule deviendrait inatteignable. Pour restreindre l'accès, créez un groupe plus large et liez celui-là.

  2. Provider — OAuth2/OpenID Provider :

    Champ Valeur
    Client type Confidential
    Signing Key n'importe quel certificat — obligatoire
    Redirect URIs (Strict) https://horaires.ita-ito.com/api/auth/callback/authentik
    http://localhost:3010/api/auth/callback/authentik
    Scopes openid, email, profile (les valeurs par défaut)
    Subject mode Based on the User's Email
    Include claims in id_token activé

    Le claim groups arrive via le scope profile : aucun mapping à créer. Rien à activer pour le PKCE, Authentik annonce S256 et Auth.js l'utilise seul.

  3. Application — slug horaires-ita-ito, liée au provider. Le slug détermine l'issuer, qui doit correspondre au caractère près, barre oblique finale comprise.

  4. Vérification, avant même de lancer l'application :

    curl -s https://auth.loxi.ch/application/o/horaires-ita-ito/.well-known/openid-configuration | jq .issuer
    

    La valeur doit être identique à AUTH_AUTHENTIK_ISSUER. C'est la première cause d'échec de connexion, et elle se diagnostique mal depuis l'application.

Le rôle est fixé à la connexion. Retirer quelqu'un du groupe ne coupe pas sa session en cours : l'effet arrive à la reconnexion, ou au bout de huit heures. Si un compte apparaît en lecture seule alors qu'il ne devrait pas, le tableau de bord affiche les groupes réellement reçus et celui qui était attendu.

Appairage de l'écran

Aucun reflashage. Le firmware d'origine suffit.

  1. Maintenez le bouton au dos de l'écran 5 secondes : il ouvre un réseau Wi-Fi nommé TRMNL.
  2. Connectez-vous-y depuis un téléphone ; le portail s'ouvre tout seul.
  3. Donnez le Wi-Fi de la boutique, puis Advanced → Custom Server → Yes.
  4. Saisissez https://horaires.ita-ito.com — sans barre oblique finale. L'URL exacte est rappelée dans Paramètres → Appareils.
  5. Validez. L'écran s'enregistre seul au premier appel et affiche les horaires.

Il apparaît ensuite dans Paramètres → Appareils avec son identifiant, sa dernière visite, son firmware et sa batterie.

Si Custom Server n'apparaît pas dans le portail, le firmware livré avec le kit est une version qui ne l'expose pas. Il faut alors flasher le firmware TRMNL officiel — ce qui sort du cadre de ce dépôt. Vérifiez ce point avant de déployer quoi que ce soit.

Mise en production

Voir DEPLOY.md : première installation, mises à jour, sauvegardes, restauration et retour arrière.

Dépannage

Symptôme Cause la plus probable
Connexion refusée en boucle AUTH_AUTHENTIK_ISSUER ne correspond pas exactement à l'issuer annoncé
« Compte en lecture seule » le compte n'est pas dans AUTHENTIK_ADMIN_GROUP ; le tableau de bord affiche les groupes reçus
L'écran reste blanc il n'a pas encore atteint le serveur : vérifiez l'URL du portail captif, sans barre oblique finale
L'écran n'a pas suivi une modification normal jusqu'à son prochain réveil ; le tableau de bord indique quand il est attendu
« La traduction a échoué » TRANSLATION_API_KEY absente ou révoquée — npm run translate:test le dit précisément
Jours fériés absents lancez npm run holidays:sync ; la page Fériés affiche la dernière synchro réussie
Aperçu d'écran en erreur le rendu a besoin de public/fonts et public/brand ; ils sont versionnés, vérifiez qu'ils sont présents

Licence

Propriétaire. Voir LICENSE.

S
Description
Administration des horaires ITA ITO et serveur d'affichage BYOS pour écran e-ink TRMNL 7,5"
Readme
639 KiB