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
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# ITA ITO — Panneau d'administration des horaires (écran e-ink TRMNL, BYOS)
|
||||||
|
# Copiez ce fichier en `.env` et renseignez les valeurs. `.env` n'est JAMAIS commité.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Application
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Nom de domaine public de l'app sur le VPS. Sert à trois choses :
|
||||||
|
# le callback OIDC, l'URL d'image envoyée à l'écran, et les liens du README.
|
||||||
|
APP_DOMAIN=trmnl.loxi.ch
|
||||||
|
# Port publié en local (dev). En production, Traefik s'en charge : aucun port publié.
|
||||||
|
APP_PORT=3000
|
||||||
|
APP_BIND=127.0.0.1
|
||||||
|
TZ=Europe/Zurich
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Base de données (PostgreSQL 16)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
POSTGRES_DB=horaires
|
||||||
|
POSTGRES_USER=horaires
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
DATABASE_URL=postgresql://horaires:CHANGEME@db:5432/horaires?schema=public
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Authentification — Authentik (OIDC, Authorization Code + PKCE)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Doit correspondre EXACTEMENT à l'`issuer` du document de découverte :
|
||||||
|
# https://auth.loxi.ch/application/o/<SLUG>/.well-known/openid-configuration
|
||||||
|
AUTHENTIK_ISSUER=https://auth.loxi.ch/application/o/CHANGEME/
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
# Groupe Authentik dont les membres sont administrateurs. Les autres utilisateurs
|
||||||
|
# authentifiés sont en lecture seule (rôle `viewer`).
|
||||||
|
AUTHENTIK_ADMIN_GROUP=horaires-admins
|
||||||
|
# URL publique complète de l'app. L'URI de redirection à déclarer dans Authentik est
|
||||||
|
# <NEXTAUTH_URL>/api/auth/callback/authentik
|
||||||
|
NEXTAUTH_URL=https://trmnl.loxi.ch
|
||||||
|
# Générer avec : openssl rand -base64 33
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Écran e-ink — API appareil (BYOS)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Format d'image servi à l'appareil. Le firmware Seeed référence des .bmp ;
|
||||||
|
# basculer sur `png` si l'appareil refuse le BMP.
|
||||||
|
DEVICE_IMAGE_FORMAT=bmp
|
||||||
|
# Autorise l'appareil à appeler l'API en clair (HTTP). À n'activer que si le
|
||||||
|
# firmware ESP32 échoue sur la chaîne TLS. Le jeton d'appareil circulerait alors
|
||||||
|
# en clair : il est distinct de tout autre secret et révocable depuis /admin/parametres.
|
||||||
|
DEVICE_ALLOW_HTTP=false
|
||||||
|
# Intervalles de réveil, en secondes. Court quand la boutique est ouverte ou sur le
|
||||||
|
# point de changer d'état, long la nuit et les jours de fermeture.
|
||||||
|
DEVICE_REFRESH_OPEN_SEC=600
|
||||||
|
DEVICE_REFRESH_CLOSED_SEC=7200
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Traduction FR -> EN — api.loxi.ch (projet api_llm_loxi)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# ATTENTION : cette API n'est ni Anthropic- ni OpenAI-compatible.
|
||||||
|
# Contrat réel : POST {TRANSLATION_API_URL}/api/generate {"model_id": <int>, "prompt": "..."}
|
||||||
|
# -> 200 {"stdout": "...", "stderr": "...", "exit_code": 0}
|
||||||
|
# Un échec du CLI renvoie quand même HTTP 200 : c'est `exit_code` qui fait foi.
|
||||||
|
TRANSLATION_API_URL=https://api.loxi.ch
|
||||||
|
# Clé API créée depuis la page « API keys » du dashboard api-llm-loxi (format llk_...).
|
||||||
|
TRANSLATION_API_KEY=
|
||||||
|
TRANSLATION_API_FLAVOR=loxi
|
||||||
|
# Nom du modèle, résolu en `model_id` au démarrage via GET /api/models.
|
||||||
|
# L'alias `haiku` pointe toujours vers la dernière version.
|
||||||
|
TRANSLATION_MODEL_NAME=haiku
|
||||||
|
# Le backend lance réellement le CLI Claude Code : prévoir large.
|
||||||
|
TRANSLATION_TIMEOUT_MS=30000
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Jours fériés — OpenHolidays (aucune clé requise)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
HOLIDAYS_API_URL=https://openholidaysapi.org
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Déploiement (production, docker-compose.prod.yml)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
TRAEFIK_NETWORK=web
|
||||||
|
TRAEFIK_ENTRYPOINT=websecure
|
||||||
|
TRAEFIK_CERTRESOLVER=myresolver
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
# A superseded run has nothing left to prove; stop it and free the runner.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
name: Lint, typecheck, tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run test
|
||||||
|
- run: npm run build
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# production
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
|
# environment — never commit secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# database dumps & backups
|
||||||
|
backups/
|
||||||
|
*.sql
|
||||||
|
*.dump
|
||||||
|
|
||||||
|
# generated preview artifacts
|
||||||
|
.preview/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# typescript incremental build info
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright (c) 2026 ITA ITO. All rights reserved.
|
||||||
|
|
||||||
|
This software is proprietary and confidential. No part of it may be copied,
|
||||||
|
modified, distributed, sublicensed or used, in whole or in part, without the
|
||||||
|
prior written permission of the copyright holder.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
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 :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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`.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default function AdminHome() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<h1 className="text-2xl">ITA ITO — Horaires</h1>
|
||||||
|
<p className="mt-2 text-[var(--ink-muted)]">Tableau de bord à venir.</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** Liveness probe used by the Docker HEALTHCHECK. Must stay dependency-free. */
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import './styles/index.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'ITA ITO — Horaires',
|
||||||
|
description: "Administration des horaires d'ouverture affichés en vitrine.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
// The shop-floor use case is a phone held in one hand.
|
||||||
|
viewportFit: 'cover',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="fr">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/admin');
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@import '@fontsource-variable/inter';
|
||||||
|
@import '@fontsource-variable/source-serif-4';
|
||||||
|
@import 'tailwindcss';
|
||||||
|
@import './tokens.css';
|
||||||
|
|
||||||
|
/* Tokens are the single source of truth; Tailwind utilities read them from here. */
|
||||||
|
@theme inline {
|
||||||
|
--color-canvas: var(--canvas);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-muted: var(--surface-muted);
|
||||||
|
--color-line: var(--line);
|
||||||
|
--color-line-strong: var(--line-strong);
|
||||||
|
--color-ink: var(--ink);
|
||||||
|
--color-ink-muted: var(--ink-muted);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-hover: var(--accent-hover);
|
||||||
|
--color-accent-tint: var(--accent-tint);
|
||||||
|
--color-accent-ink: var(--accent-ink);
|
||||||
|
--color-on-accent: var(--on-accent);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-danger-tint: var(--danger-tint);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-success-tint: var(--success-tint);
|
||||||
|
--radius-sm: var(--radius-sm);
|
||||||
|
--radius-md: var(--radius-md);
|
||||||
|
--radius-lg: var(--radius-lg);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-serif: var(--font-serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: var(--canvas);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* Design tokens. Values inspired by a warm, calm palette; checked for WCAG AA:
|
||||||
|
* - ink on canvas 17.9:1, ink-muted on canvas 6.4:1
|
||||||
|
* - accent (#B5552F) with white text 4.9:1; dark accent (#D97757) on dark canvas 4.9:1
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--canvas: #faf9f5;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-muted: #f3f1ea;
|
||||||
|
--line: #e8e6dc;
|
||||||
|
--line-strong: #d6d3c7;
|
||||||
|
--ink: #141413;
|
||||||
|
--ink-muted: #5e5d59;
|
||||||
|
--accent: #b5552f;
|
||||||
|
--accent-hover: #9e4827;
|
||||||
|
--accent-soft: #c96442;
|
||||||
|
--accent-tint: #f6e7df;
|
||||||
|
/* Accent text on the tinted background: 5.1:1. */
|
||||||
|
--accent-ink: #983f1e;
|
||||||
|
--on-accent: #ffffff;
|
||||||
|
--danger: #b42318;
|
||||||
|
--danger-tint: #fdecea;
|
||||||
|
--success: #3f6b4b;
|
||||||
|
--success-tint: #e7f0e9;
|
||||||
|
--observation: #c8102e;
|
||||||
|
--focus-ring: #c96442;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
|
||||||
|
--font-sans: 'Inter Variable', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
--font-serif: 'Source Serif 4 Variable', 'Source Serif 4', Georgia, 'Times New Roman', serif;
|
||||||
|
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--canvas: #262624;
|
||||||
|
--surface: #30302e;
|
||||||
|
--surface-muted: #2b2b29;
|
||||||
|
--line: #3e3e3a;
|
||||||
|
--line-strong: #52524d;
|
||||||
|
--ink: #f5f4ee;
|
||||||
|
--ink-muted: #b7b5a9;
|
||||||
|
--accent: #d97757;
|
||||||
|
--accent-hover: #e38b6e;
|
||||||
|
--accent-soft: #d97757;
|
||||||
|
--accent-tint: #3d2e27;
|
||||||
|
--accent-ink: #f0b8a0;
|
||||||
|
--on-accent: #1a1a18;
|
||||||
|
--danger: #f08a7e;
|
||||||
|
--danger-tint: #3f2724;
|
||||||
|
--success: #8fbf9a;
|
||||||
|
--success-tint: #26352a;
|
||||||
|
--observation: #ff6b6b;
|
||||||
|
--focus-ring: #d97757;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--canvas: #262624;
|
||||||
|
--surface: #30302e;
|
||||||
|
--surface-muted: #2b2b29;
|
||||||
|
--line: #3e3e3a;
|
||||||
|
--line-strong: #52524d;
|
||||||
|
--ink: #f5f4ee;
|
||||||
|
--ink-muted: #b7b5a9;
|
||||||
|
--accent: #d97757;
|
||||||
|
--accent-hover: #e38b6e;
|
||||||
|
--accent-soft: #d97757;
|
||||||
|
--accent-tint: #3d2e27;
|
||||||
|
--accent-ink: #f0b8a0;
|
||||||
|
--on-accent: #1a1a18;
|
||||||
|
--danger: #f08a7e;
|
||||||
|
--danger-tint: #3f2724;
|
||||||
|
--success: #8fbf9a;
|
||||||
|
--success-tint: #26352a;
|
||||||
|
--observation: #ff6b6b;
|
||||||
|
--focus-ring: #d97757;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status / badge tones: soft backgrounds, text ≥ 4.5:1 on its background in both themes. */
|
||||||
|
:root {
|
||||||
|
--tone-neutral-bg: #efede6;
|
||||||
|
--tone-neutral-fg: #4a4944;
|
||||||
|
--tone-accent-bg: #f6e7df;
|
||||||
|
--tone-accent-fg: #8f3f20;
|
||||||
|
--tone-sand-bg: #f1eadb;
|
||||||
|
--tone-sand-fg: #6b5a36;
|
||||||
|
--tone-ochre-bg: #f3e6c8;
|
||||||
|
--tone-ochre-fg: #6f5310;
|
||||||
|
--tone-amber-bg: #f6e2c7;
|
||||||
|
--tone-amber-fg: #7a4a0e;
|
||||||
|
--tone-terracotta-bg: #f4ddd3;
|
||||||
|
--tone-terracotta-fg: #8a3a1d;
|
||||||
|
--tone-plum-bg: #ece0ea;
|
||||||
|
--tone-plum-fg: #6a3b61;
|
||||||
|
--tone-slate-bg: #e1e7ee;
|
||||||
|
--tone-slate-fg: #3b5068;
|
||||||
|
--tone-sage-bg: #e2ebdf;
|
||||||
|
--tone-sage-fg: #3d5a37;
|
||||||
|
--tone-olive-bg: #e8e8d3;
|
||||||
|
--tone-olive-fg: #555522;
|
||||||
|
--tone-gray-bg: #e8e7e3;
|
||||||
|
--tone-gray-fg: #54534e;
|
||||||
|
|
||||||
|
/* Grid ("hoja de pedidos") in the application look. */
|
||||||
|
--grid-header-bg: #efece2;
|
||||||
|
--grid-line: #ddd8ca;
|
||||||
|
--grid-block-line: #b9b3a2;
|
||||||
|
--grid-no-bg: #f3f1ea;
|
||||||
|
--grid-active: #c96442;
|
||||||
|
--grid-selection-bg: #f6e7df;
|
||||||
|
--tabbar-height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--tone-neutral-bg: #3a3a37;
|
||||||
|
--tone-neutral-fg: #dedcd2;
|
||||||
|
--tone-accent-bg: #4a3128;
|
||||||
|
--tone-accent-fg: #f0b8a0;
|
||||||
|
--tone-sand-bg: #413a2c;
|
||||||
|
--tone-sand-fg: #e6d6b0;
|
||||||
|
--tone-ochre-bg: #443a22;
|
||||||
|
--tone-ochre-fg: #ecd18f;
|
||||||
|
--tone-amber-bg: #45351f;
|
||||||
|
--tone-amber-fg: #f0c48c;
|
||||||
|
--tone-terracotta-bg: #4a3027;
|
||||||
|
--tone-terracotta-fg: #f1b59c;
|
||||||
|
--tone-plum-bg: #40303e;
|
||||||
|
--tone-plum-fg: #e5bfdd;
|
||||||
|
--tone-slate-bg: #2f3945;
|
||||||
|
--tone-slate-fg: #bdd0e6;
|
||||||
|
--tone-sage-bg: #2f3b2d;
|
||||||
|
--tone-sage-fg: #c1dcb9;
|
||||||
|
--tone-olive-bg: #393a26;
|
||||||
|
--tone-olive-fg: #dcdca6;
|
||||||
|
--tone-gray-bg: #3a3a38;
|
||||||
|
--tone-gray-fg: #d6d4cc;
|
||||||
|
--grid-header-bg: #34342f;
|
||||||
|
--grid-line: #45443f;
|
||||||
|
--grid-block-line: #67655d;
|
||||||
|
--grid-no-bg: #2d2d2b;
|
||||||
|
--grid-active: #d97757;
|
||||||
|
--grid-selection-bg: #3d2e27;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--tone-neutral-bg: #3a3a37;
|
||||||
|
--tone-neutral-fg: #dedcd2;
|
||||||
|
--tone-accent-bg: #4a3128;
|
||||||
|
--tone-accent-fg: #f0b8a0;
|
||||||
|
--tone-sand-bg: #413a2c;
|
||||||
|
--tone-sand-fg: #e6d6b0;
|
||||||
|
--tone-ochre-bg: #443a22;
|
||||||
|
--tone-ochre-fg: #ecd18f;
|
||||||
|
--tone-amber-bg: #45351f;
|
||||||
|
--tone-amber-fg: #f0c48c;
|
||||||
|
--tone-terracotta-bg: #4a3027;
|
||||||
|
--tone-terracotta-fg: #f1b59c;
|
||||||
|
--tone-plum-bg: #40303e;
|
||||||
|
--tone-plum-fg: #e5bfdd;
|
||||||
|
--tone-slate-bg: #2f3945;
|
||||||
|
--tone-slate-fg: #bdd0e6;
|
||||||
|
--tone-sage-bg: #2f3b2d;
|
||||||
|
--tone-sage-fg: #c1dcb9;
|
||||||
|
--tone-olive-bg: #393a26;
|
||||||
|
--tone-olive-fg: #dcdca6;
|
||||||
|
--tone-gray-bg: #3a3a38;
|
||||||
|
--tone-gray-fg: #d6d4cc;
|
||||||
|
--grid-header-bg: #34342f;
|
||||||
|
--grid-line: #45443f;
|
||||||
|
--grid-block-line: #67655d;
|
||||||
|
--grid-no-bg: #2d2d2b;
|
||||||
|
--grid-active: #d97757;
|
||||||
|
--grid-selection-bg: #3d2e27;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import next from 'eslint-config-next';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['.next/**', 'node_modules/**', 'coverage/**', 'next-env.d.ts', 'prisma/generated/**'],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...next,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
// `verbatimModuleSyntax` in tsconfig.json already enforces explicit type
|
||||||
|
// imports at compile time, so the typed-linting rule would only duplicate it.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Standalone output keeps the production image small: only the traced
|
||||||
|
// dependencies ship, not the whole node_modules tree.
|
||||||
|
output: 'standalone',
|
||||||
|
reactStrictMode: true,
|
||||||
|
// node-cron is started from instrumentation.ts; it must not be bundled.
|
||||||
|
serverExternalPackages: ['node-cron', '@resvg/resvg-js'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+11203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "ita-ito-trmnl-admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"coverage": "vitest run --coverage",
|
||||||
|
"seed": "tsx prisma/seed.ts",
|
||||||
|
"screen:preview": "tsx scripts/preview.ts",
|
||||||
|
"brand": "tsx scripts/brand.ts",
|
||||||
|
"e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/source-serif-4": "^5.3.0",
|
||||||
|
"@prisma/client": "^7.10.0",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"next": "^16.3.5",
|
||||||
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
"node-cron": "^4.6.0",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"satori": "^0.33.4",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.5",
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.11",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-next": "^16.3.5",
|
||||||
|
"msw": "^2.15.0",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
|
"prisma": "^7.10.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.70.0",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { GET } from '@/app/api/health/route';
|
||||||
|
|
||||||
|
describe('health endpoint', () => {
|
||||||
|
it('reports ok', async () => {
|
||||||
|
const body = await GET().json();
|
||||||
|
expect(body).toEqual({ status: 'ok' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"ES2022"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"exactOptionalPropertyTypes": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"e2e"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('.', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['lib/**/*.test.ts', 'lib/**/*.test.tsx', 'tests/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html', 'lcov'],
|
||||||
|
include: ['lib/**'],
|
||||||
|
exclude: ['lib/**/*.test.ts', 'lib/**/*.test.tsx'],
|
||||||
|
// The business core must stay covered; the CI fails below these.
|
||||||
|
thresholds: {
|
||||||
|
'lib/schedule/**': { statements: 85, branches: 85, functions: 85, lines: 85 },
|
||||||
|
'lib/screen/**': { statements: 85, branches: 85, functions: 85, lines: 85 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user