Files
ita-ito-horaires/PLAN.md
T
vliaudatandClaude Opus 5 a9809f83ca chore: scaffold Next.js 16 admin app with ITA ITO design tokens
Set up the project skeleton for the ITA ITO opening-hours display admin:
Next.js 16 (App Router) with TypeScript in strict mode, Tailwind CSS 4,
Vitest, ESLint and a blocking CI workflow.

The design tokens are copied verbatim from the model_ita_ito project
(palette, Inter Variable + Source Serif 4, radii, dark theme) so the two
applications look like one family, as required by the spec.

ESLint is pinned to v9: eslint-config-next bundles a react plugin that
crashes on ESLint 10. The typed `consistent-type-imports` rule is left
out because `verbatimModuleSyntax` already enforces the same discipline
at compile time, without the cost of typed linting across the repo.

PLAN.md records the agreed architecture, including the decisions that
depart from the original spec — most importantly the move from BYOD to
a self-hosted BYOS server, which removes the TRMNL private plugin, the
Liquid template and the webhook entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 17:30:19 +02:00

23 KiB
Raw Blame History

Panneau d'administration TRMNL « ITA ITO » — plan d'implémentation

Context

La boutique ITA ITO (tricot, Genève) veut afficher ses horaires d'ouverture sur un écran e-ink TRMNL 7.5" (kit DIY Seeed, XIAO ESP32-S3 Plus, 800×480 monochrome) posé en vitrine. Le besoin réel : changer un horaire depuis un téléphone, depuis la boutique, en deux clics (fermeture exceptionnelle, ouverture retardée, congés), et que l'écran suive tout seul — y compris les jours fériés genevois et un message libre bilingue FR/EN.

Il n'existe rien aujourd'hui : /home/vl/docker/horaire_boutique est vide. On construit l'application de zéro.

Décisions prises pendant le cadrage (elles modifient la spec d'origine)

Sujet Spec initiale Décision retenue Pourquoi
Mode TRMNL BYOD + Private Plugin BYOS auto-hébergé Pas de licence BYOD à payer, pas de limite 2 Ko / 12 req-h, pas de dépendance au cloud TRMNL. Seeed et TRMNL confirment : « BYOS, no license required, for any TRMNL compatible device ».
Rendu de l'écran Template Liquid rendu par TRMNL Rendu PNG/BMP 1-bit par notre app Conséquence directe du BYOS : il n'y a plus de serveur TRMNL pour interpréter du Liquid.
Webhook + debounce §1 Supprimés En BYOS on ne pousse rien : l'appareil dort et vient chercher. Le seul levier est refresh_rate.
trmnl-plugin/, settings.yml, full.liquid §1 Supprimés Sans objet en BYOS.
Stack Next.js 15 Next.js 16 + Auth.js v5 + Prisma + Postgres 16 Recopie le branchement Authentik/OIDC déjà en production dans api_llm_loxi, et les tokens visuels de model_ita_ito.
Vacances VacationPeriod matérialise des ScheduleException Résolution à la volée VacationPeriod reste seule source de vérité, lue au rang 2 du resolver. Aucun risque d'écraser une exception MANUAL, aucun orphelin quand on modifie une période.
Traduction API Anthropic- ou OpenAI-compatible Adaptateur api.loxi.ch natif Ni l'un ni l'autre : c'est ton api_llm_loxi, POST /api/generate {model_id, prompt} → {stdout, exit_code, …}.
Rafraîchissement 15 min fixe Adaptatif 10 min en heures d'ouverture, 2 h la nuit et jours fermés. Autonomie vs fraîcheur.
Seed Horaires réels Seed générique Mar–Sam 10:00–18:30, corrigé depuis l'UI —

Ce qui reste vrai de la spec

Le cœur métier ne bouge pas : moteur de résolution pur et testé à fond, contrat screen.json figé en schema: 1 et verrouillé par snapshot, UI d'admin en français et responsive, auth Authentik avec rôles, journal d'audit, fériés OpenHolidays, traduction FR→EN avec cache, Docker dev/prod, CI bloquante.


Architecture

Un seul service applicatif + Postgres. L'écran et l'humain attaquent la même app, par deux chemins qui ne se croisent jamais.

  Écran e-ink (ESP32, LAN boutique)
        │  GET /api/setup      header ID: <MAC>
        │  GET /api/display    header Access-Token, Battery-Voltage, RSSI, FW-Version…
        │  POST /api/log
        │  GET /api/device/image/<hash>.bmp        ← non devinable, immuable, cacheable
        ▼
  ┌─────────────────────────────────────────────────────┐
  │  Next.js 16 (App Router, TS strict)   trmnl.loxi.ch │
  │                                                     │
  │  lib/schedule/resolver.ts   ← pur, sans I/O, 100 % testé
  │  lib/screen/viewmodel.ts    ← Prisma → ScreenPayload (schema 1)
  │  lib/screen/render.ts       ← ScreenPayload → SVG → PNG → BMP 1-bit
  │  lib/device/refresh.ts      ← refresh_rate adaptatif
  │  lib/translation/           ← TranslationProvider + adaptateur loxi
  │  lib/holidays/              ← OpenHolidays, upsert idempotent
  │  app/admin/**               ← UI française, protégée Auth.js
  └─────────────────────────────────────────────────────┘
        ▲                                   │
        │ OIDC (auth.loxi.ch)               │ POST /api/generate (Bearer llk_…)
   Navigateur admin                    api.loxi.ch  (traduction FR→EN)
                                            │
                                    openholidaysapi.org (fériés CH-GE)

Le pipeline d'affichage est une chaîne de fonctions pures, c'est ce qui rend le tout testable :

Prisma ──► ScheduleContext ──► resolveWeek/getCurrentStatus ──► ScreenPayload (JSON figé)
                                                                      │
                                    ┌─────────────────────────────────┤
                                    ▼                                 ▼
                          renderSvg() → PNG → BMP 1-bit      aperçu HTML /admin (même SVG)
                                    │
                            hash du contenu = filename

Le filename renvoyé à l'appareil est le hash du contenu. Le firmware compare le nom de fichier avec celui qu'il a en cache : contenu identique → pas de redraw → batterie économisée. C'est gratuit et c'est le gain d'autonomie le plus important du projet.

Rendu de l'image — choix technique

satori (HTML/flexbox → SVG, moteur Yoga) puis @resvg/resvg-js (SVG → PNG), puis un encodeur BMP 1-bit maison (~50 lignes : en-têtes BITMAPFILEHEADER/INFOHEADER, palette 2 couleurs, lignes paddées à 4 octets) avec seuillage sans tramage.

Pourquoi pas Chromium headless : ajouterait ~500 Mo et une consommation mémoire sérieuse à l'image runtime pour un écran de 8 blocs. Playwright reste présent pour l'E2E, mais dans son image dédiée, jamais dans l'image applicative.

Bénéfice test majeur : on snapshot la chaîne SVG, pas un binaire. Un diff de rendu est lisible en revue. Le binaire n'est vérifié que sur ses invariants (dimensions, profondeur, taille).

Format servi : DEVICE_IMAGE_FORMAT=bmp|png, défaut bmp (le firmware Seeed référence des .bmp). Bascule par variable si l'appareil réclame du PNG.


Modèle de données (Prisma)

Reprend la §4, moins ce que le BYOS rend caduc, plus ce qu'il impose.

Settings          id @default("singleton")  // ligne unique
                  shopName, timezone ("Europe/Zurich")
                  countryIsoCode ("CH"), subdivisionCode ("CH-GE")
                  defaultClosedMessageFr / En
                  refreshRateOpenSec (600), refreshRateClosedSec (7200)
                  imageFormat ("bmp"), updatedAt

WeeklySchedule    dayOfWeek @unique (0=dimanche..6), isClosed, slots Json
                  // [{"open":"10:00","close":"13:00"},…] max 3 plages

ScheduleException date @unique, isClosed, slots Json?
                  reason (TEMPORARY|HOLIDAY|VACATION|SPECIAL_EVENT)
                  noteFr, noteEn, source (MANUAL|HOLIDAY_API)
                  createdBy, createdAt

VacationPeriod    startDate, endDate, labelFr, labelEn, createdBy
                  // source de vérité, JAMAIS matérialisée en exceptions

PublicHoliday     @@unique([date, subdivisionCode])
                  nameFr, nameEn, nationwide, source, syncedAt, isAutoClosed

Message           textFr, textEn, translationStatus (PENDING|DONE|MANUAL|ERROR)
                  startsAt, endsAt?, priority, isActive, createdBy

TranslationCache  @@unique([sourceHash, targetLang, model])
                  sourceText, translatedText, createdAt

AuditLog          userEmail, action, entity, entityId, diff Json, createdAt

// — ajouts imposés par le BYOS —
Device            macAddress @unique, friendlyId, apiKeyHash
                  label, isActive
                  lastSeenAt, fwVersion, batteryVoltage, percentCharged, rssi
                  lastFilename, lastRefreshRate

DeviceLog         deviceId, level, message, payload Json, createdAt
                  // rétention courte, purgée par le job quotidien

SyncState         key @unique ("holidays"), lastSuccessAt, lastErrorAt, lastError

apiKeyHash : on stocke un SHA-256, jamais le jeton en clair — même discipline que les clés llk_… de api_llm_loxi.

Moteur de résolution — lib/schedule/resolver.ts

Module pur, aucune I/O, aucun accès Prisma. Signature de la §4 conservée :

resolveDay(date: Date, ctx: ScheduleContext): ResolvedDay
resolveWeek(fromDate: Date, ctx: ScheduleContext): ResolvedDay[]
getCurrentStatus(now: Date, ctx: ScheduleContext): ShopStatus

Priorité : ScheduleException MANUAL → VacationPeriod → PublicHoliday si isAutoClosed → WeeklySchedule → fermé.

Heure locale, jamais d'UTC. Les créneaux sont des chaînes "HH:mm" en heure murale Europe/Zurich. On ne fait aucune arithmétique sur des instants UTC pour décider d'une plage : c'est ce qui rend les changements d'heure de mars et d'octobre non-événements. date-fns-tz sert uniquement aux conversions aux frontières (now → date locale, formatage).

nextOpening : balayage borné à 14 jours, retourne null au-delà. Une boutique fermée toute l'année ne doit pas faire boucler le serveur — c'est un cas de test explicite.

Bandeau automatique : divergence sur aujourd'hui seul → « Changement d'horaire aujourd'hui », divergence ailleurs dans les 7 jours → « Horaires exceptionnels cette semaine ». Libellés FR+EN en dur, aucun appel API. Un message libre actif l'emporte sur le bandeau automatique.


Fichiers à créer

PLAN.md  README.md  DEPLOY.md  LICENSE  .env.example  .gitignore
Dockerfile  docker-compose.yml  docker-compose.prod.yml  docker-entrypoint.sh
.github/workflows/ci.yml

prisma/schema.prisma  prisma/seed.ts

lib/schedule/resolver.ts            ← cœur métier, pur
lib/schedule/validate.ts            ← plages : format, ordre, chevauchement, max 3
lib/schedule/format.ts              ← dates/heures FR + EN, Europe/Zurich
lib/schedule/context.ts             ← Prisma → ScheduleContext (seule couche I/O)
lib/screen/contract.ts              ← types ScreenPayload, SCHEMA_VERSION, MESSAGE_MAX_CHARS
lib/screen/viewmodel.ts             ← ScheduleContext → ScreenPayload
lib/screen/render.tsx               ← ScreenPayload → SVG (satori)
lib/screen/encode.ts                ← SVG → PNG (resvg) → BMP 1-bit seuillé
lib/device/auth.ts                  ← Access-Token, comparaison temps constant
lib/device/refresh.ts               ← refresh_rate adaptatif
lib/translation/provider.ts         ← interface TranslationProvider
lib/translation/loxi.ts             ← adaptateur api.loxi.ch
lib/translation/service.ts          ← cache sha256, retries, statuts
lib/holidays/openholidays.ts        ← client FR + EN, retry exponentiel
lib/holidays/sync.ts                ← upsert idempotent
lib/audit.ts   lib/auth.ts   lib/ratelimit.ts   lib/db.ts

app/api/setup/route.ts              ← device API
app/api/display/route.ts
app/api/log/route.ts
app/api/device/image/[hash]/route.ts
app/api/health/route.ts
app/api/admin/**/route.ts
app/(auth)/login/page.tsx
app/admin/page.tsx                  ← tableau de bord + aperçu 800×480
app/admin/horaires/page.tsx
app/admin/exceptions/page.tsx
app/admin/vacances/page.tsx
app/admin/feries/page.tsx
app/admin/messages/page.tsx
app/admin/parametres/page.tsx
app/styles/tokens.css               ← copié de model_ita_ito
middleware.ts  instrumentation.ts   ← node-cron démarré ici

scripts/brand.ts                    ← logo → logo.png + logo-eink.png (1-bit, 260px)
scripts/preview.ts                  ← npm run screen:preview → aperçu 800×480 local
public/brand/                       ← logos générés, COMMITÉS

e2e/*.spec.ts                       ← Playwright

Réutilisation de l'existant

  • /home/vl/docker/model_ita_ito/frontend/src/styles/tokens.css — copié tel quel. Palette (--canvas #faf9f5, --accent #b5552f, --ink #141413), rayons 6/8/12 px, Inter Variable + Source Serif 4 pour les titres, thème sombre complet, contrastes déjà validés WCAG AA. C'est la continuité visuelle demandée en §7, gratuite.
  • /home/vl/docker/api_llm_loxi/frontend/src/auth.ts — le branchement Auth.js v5 ↔ Authentik (issuer, claim groups, callbacks) est en production ; on le reprend au lieu de le réinventer.
  • /home/vl/docker/model_ita_ito/docker-compose.prod.yml — les labels Traefik, l'ancre x-hardening (no-new-privileges, cap_drop: ALL, read_only) et le réseau edge externe sont le patron de déploiement du VPS.
  • /home/vl/docker/api_llm_loxi/docs/API.md — contrat exact de la traduction.

API appareil (BYOS)

Route Entrée Sortie
GET /api/setup header ID: <MAC> {status, api_key, friendly_id, image_url, message} — crée le Device au premier contact ; si déjà provisionné, api_key vide
GET /api/display Access-Token, ID, Battery-Voltage, Percent-Charged, RSSI, FW-Version, Width, Height {image_url, filename, refresh_rate, update_firmware:false, reset_firmware:false, special_function:"none", image_url_timeout:0}
POST /api/log Access-Token + {logs:[…]} 204
GET /api/device/image/<hash>.bmp — l'image, Cache-Control: immutable

Appairage (README, pas de reflash) : maintenir le bouton ~5 s → portail captif → Wi-Fi → Advanced > Custom Server > Yes → https://trmnl.loxi.ch sans slash final.

refresh_rate adaptatif, calculé à chaque /api/display : refreshRateOpenSec (600) si la boutique est ouverte ou ouvre dans l'heure, refreshRateClosedSec (7200) sinon, et raccourci pour tomber juste après le prochain changement d'état (ouverture/fermeture) plutôt que de le manquer de 9 minutes.

Risques identifiés, à vérifier sur l'appareil

  1. Champ « Custom Server » — le firmware TRMNL officiel l'expose sous Advanced, mais le README du firmware Seeed livré sur le kit référence un backend usetrmnl.com fixe et ne le documente pas. Si le portail captif ne propose pas le champ, il faudra flasher le firmware TRMNL officiel — ce qui contredit la contrainte §1. À vérifier avant l'incrément 5, c'est le seul point qui peut remettre en cause l'architecture.
  2. TLS sur ESP32 — tu as choisi le VPS public en HTTPS. Ces firmwares trébuchent parfois sur les chaînes de certificats. Plan de repli prévu dès le début : un vhost HTTP dédié à l'appareil (DEVICE_ALLOW_HTTP=true), token d'appareil distinct et révocable, URL d'image non devinable.
  3. BMP vs PNG — indécidable depuis la doc. Les deux encodeurs sont écrits, DEVICE_IMAGE_FORMAT bascule, on tranche sur l'appareil réel.

Traduction FR → EN via api.loxi.ch

Contrat réel (lu dans api_llm_loxi/docs/API.md) :

POST {TRANSLATION_API_URL}/api/generate
Authorization: Bearer llk_…
{"model_id": 5, "prompt": "<système + texte FR>"}
→ 200 {"stdout":"…", "stderr":"…", "exit_code":0, "duration_ms":2774}

Trois pièges, traités explicitement :

  • model_id est un entier, pas un nom. On ne le code pas en dur : au démarrage on résout TRANSLATION_MODEL_NAME=haiku via GET /api/models?provider=claude&active=true, résultat mis en cache. L'alias haiku pointe toujours vers la dernière version.
  • Un échec CLI renvoie quand même 200. On vérifie exit_code !== 0 → ERROR. Ne jamais se fier au code HTTP seul.
  • Le backend lance réellement le CLI Claude Code (CLI_TIMEOUT_SECONDS=300 chez toi). Le timeout de 10 s de la §6 est irréaliste : 30 s, 2 retries.

Le reste conforme à la §6 : interface TranslationProvider (les adaptateurs anthropic/openai restent possibles derrière TRANSLATION_API_FLAVOR, non écrits tant qu'inutiles), cache sha256(FR), traduction asynchrone non bloquante, MANUAL jamais écrasé tant que le FR ne change pas, badge + bouton « Réessayer ».


Sécurité

  • Pages /admin/** et routes /api/admin/** : middleware Auth.js, aucune exception. Rôles depuis le claim groups : AUTHENTIK_ADMIN_GROUP → admin, sinon viewer (lecture seule).
  • Routes appareil : Access-Token comparé avec crypto.timingSafeEqual, rate-limit par IP et par appareil. Ce sont les seules routes hors OIDC.
  • /api/device/image/<hash> : hash de contenu non devinable, pas d'énumération possible.
  • Aucun secret en dur, aucun secret en base en clair. .env hors Git.
  • AuditLog sur chaque écriture admin : utilisateur, action, entité, diff JSON, horodatage.

Variables d'environnement (.env.example)

DATABASE_URL                 POSTGRES_USER / PASSWORD / DB
APP_DOMAIN                   # ex. trmnl.loxi.ch — callback OIDC + image_url  ← À CONFIRMER
NEXTAUTH_URL  NEXTAUTH_SECRET
AUTHENTIK_ISSUER             # https://auth.loxi.ch/application/o/<SLUG>/     ← À CONFIRMER
AUTHENTIK_CLIENT_ID  AUTHENTIK_CLIENT_SECRET
AUTHENTIK_ADMIN_GROUP                                                        ← À CONFIRMER
TRANSLATION_API_URL          # https://api.loxi.ch
TRANSLATION_API_KEY          # llk_…                                         ← À CONFIRMER
TRANSLATION_MODEL_NAME=haiku  TRANSLATION_API_FLAVOR=loxi  TRANSLATION_TIMEOUT_MS=30000
HOLIDAYS_API_URL=https://openholidaysapi.org
DEVICE_IMAGE_FORMAT=bmp  DEVICE_ALLOW_HTTP=false
TZ=Europe/Zurich

Les quatre « À CONFIRMER » sont documentés dans .env.example et demandés au moment où l'incrément correspondant démarre — jamais devinés.


Jobs planifiés

node-cron démarré depuis instrumentation.ts (un seul conteneur applicatif, pas de coordination multi-instance à prévoir). Choix documenté dans le README, conformément à la §2.

  • 03:00 Europe/Zurich — synchro OpenHolidays sur la fenêtre glissante [J, J+1 an], deux appels (languageIsoCode=FR puis EN), upsert idempotent, retry exponentiel ×3, SyncState mis à jour. Une exception MANUAL n'est jamais écrasée.
  • 03:15 — purge des DeviceLog de plus de 30 jours.
  • Déclenchement manuel : POST /api/admin/holidays/sync + bouton dans /admin/feries.

En cas d'indisponibilité de l'API : on garde le cache local, on logue, l'UI affiche la date de dernière synchro réussie. Jamais d'échec silencieux.


Tests

Seuil : ≥ 85 % sur lib/schedule/** et lib/screen/**, CI bloquante.

Unitaires (Vitest) — resolver.ts avec table de cas exhaustive : jour ouvert/fermé, plages multiples, exception MANUAL qui prime sur un férié, vacances qui priment sur l'horaire fixe, férié non fermant, semaine entièrement fermée (nextOpening ne boucle pas), changements d'heure des derniers dimanches de mars et d'octobre, passage d'année, bornes 23:59 / 00:00. Plus : validation des plages, formatage FR/EN, détection du bandeau automatique, refresh_rate adaptatif, encodeur BMP 1-bit (en-têtes, padding, seuillage).

Intégration — /api/admin/** : 401 sans session, 403 viewer, 200 admin. /api/display : 401 sans Access-Token, 200 avec, filename stable quand le contenu ne change pas (le test qui protège la batterie). Snapshot du ScreenPayload : c'est lui qui bloque les régressions de contrat. Snapshot du SVG rendu. Fériés sous MSW : nominal, vide, 500, timeout, idempotence (2 synchros → mêmes données). Traduction sous MSW : succès, exit_code != 0 malgré un HTTP 200, cache (2 appels → 1 requête), MANUAL non écrasé.

E2E (Playwright, auth mockée) — modifier l'horaire du jour → le payload le reflète ; créer un message → traduction affichée → présent dans le payload ; ajouter des vacances → les jours concernés passent fermés. Plus une passe axe (a11y) et un rendu iPhone.


Ordre d'implémentation

Un incrément = tests verts + commit Conventional Commits en anglais. Aucune tâche ne démarre sur des tests rouges.

# Incrément Sortie vérifiable
0 Repo, Next.js 16 TS strict, Tailwind 4 + tokens.css, ESLint, CI verte, PLAN.md npm run lint && npm run typecheck
1 Schéma Prisma + migration + seed générique prisma migrate dev, seed idempotent
2 resolver.ts + sa batterie de tests — avant toute UI couverture ≥ 85 % sur lib/schedule
3 contract.ts + viewmodel.ts + snapshot du payload snapshot commité
4 render.tsx + encode.ts + npm run screen:preview BMP 800×480 1-bit ouvrable
5 /api/setup, /api/display, /api/log, image servie, refresh_rate adaptatif appairage réel de l'écran
6 Auth Authentik, middleware, rôles, AuditLog 401/403/200 testés
7 UI : horaires → exceptions → vacances → messages → tableau de bord → paramètres utilisable au téléphone
8 Traduction loxi + cache + tests MSW —
9 Synchro OpenHolidays + cron quotidien idempotence prouvée
10 Docker dev + prod, README.md, DEPLOY.md docker compose up de zéro
11 E2E Playwright + passe a11y et responsive suite verte

L'incrément 5 est le jalon de vérité : c'est là qu'on saura si le firmware du kit accepte un serveur personnalisé. Il est placé tôt exprès, avant d'avoir investi dans l'UI.

Git : branches feat/*, PR vers main, CI bloquante. Repo privé ita-ito-trmnl-admin créé via gh repo create — je demanderai confirmation avant le premier push.


Vérification de bout en bout

# 1. Stack complète depuis zéro
docker compose up -d --build --wait
docker compose exec app npx prisma migrate deploy && npm run seed

# 2. Qualité
npm run lint && npm run typecheck && npm run test -- --coverage

# 3. Contrat d'affichage, sans appareil
npm run screen:preview            # → aperçu 800×480 dans le navigateur
curl -s localhost:3000/api/display -H "Access-Token: <clé du seed>" | jq
curl -s localhost:3000/api/device/image/<hash>.bmp -o /tmp/screen.bmp
file /tmp/screen.bmp              # attendu : PC bitmap, 800 x 480 x 1

# 4. Non-régression horaires (le test qui compte)
#    modifier l'horaire du jour dans /admin/horaires, puis :
#    le ScreenPayload change, le filename change, le refresh_rate suit

# 5. E2E
npx playwright test

Validation finale sur l'appareil : appairage via le portail captif, l'écran affiche les horaires du jour, une modification faite depuis un téléphone apparaît au réveil suivant.


Points à confirmer

  1. Domaine public de l'app sur le VPS (trmnl.loxi.ch ?) — nécessaire au callback OIDC et à l'image_url servie à l'appareil.
  2. Slug de l'application Authentik et nom du groupe admin.
  3. Clé API llk_… pour api.loxi.ch, et confirmation que l'instance est bien joignable depuis le VPS (ton docker-compose.yml la borne à 127.0.0.1 en local).
  4. Nom du repo GitHub privé : ita-ito-trmnl-admin ?

Aucun de ces quatre points ne bloque les incréments 0 à 4. Je les demanderai au moment voulu ; ils sont déjà documentés dans .env.example.