Commit Graph
8 Commits
Author SHA1 Message Date
vliaudatandClaude Opus 5 f89e4690ba feat: package the application for Docker, with deployment docs
Multi-stage build on node:22-alpine, standalone output, non-root user,
healthcheck on /api/health, and migrations applied by the entrypoint
before the first request. A failed migration stops the container rather
than serving an inconsistent database.

Getting the Prisma CLI into the runtime image took three attempts and
the reasoning is recorded in the Dockerfile. Copying it out of the build
stage leaves its transitive dependencies behind; patching them in one at
a time is a losing game. It now gets its own stage and its own tree,
with the schema and prisma.config.ts beside it, and the entrypoint runs
from there so every import resolves locally. The version is read from
our own package.json so it cannot drift from the generated client.

Two things had to change to build without a database, which a build
container rightly does not have. prisma.config.ts no longer reads the
URL through prisma's env() helper, which throws on a missing variable
even for `generate`. And lib/db.ts creates the client on first use
rather than on import: Next imports every route module while collecting
page data, so a module that threw on import failed the build with an
error naming whichever route was analysed first, which says nothing
useful. The failure now lands on the first query, where it belongs.

Verified by running the image against a real database: migrations
applied, cron scheduled in Europe/Zurich, a device paired, and the panel
image served as a genuine 1-bit 800x480 BMP — so satori, resvg and the
vendored fonts all work on musl. The image hash came out identical to
the one produced on the glibc host, which is the reproducibility the
vendored fonts were for.

The production overlay publishes through an existing Traefik, drops the
host port, mounts the filesystem read-only, and adds a nightly dump kept
for a fortnight.

README and DEPLOY are in French and cover what actually bites: the panel
receives nothing and only updates when it wakes; the issuer must match
to the character; the captive portal URL takes no trailing slash; a
rollback across a migration needs the dump, because Prisma does not
undo one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 22:48:36 +02:00
vliaudatandClaude Opus 5 ebc69dc109 chore: add a command-line translation check
Mirrors the button on the settings page, for verifying a deployment from
the host before anyone signs in, and for watching the cache work.

Measured against the live service, which settles a question the spec had
guessed at: a short phrase takes between 4.6 and 10.7 seconds, because
the endpoint really does start a Claude Code process. The ten-second
timeout the spec assumed would have failed intermittently on exactly the
kind of message this shop writes. Thirty seconds stands.

Cached answers come back in 3 milliseconds, so the cache is worth
roughly three thousand times its complexity.

Quality checked on real phrasing: times and dates survive unchanged
("Ouverture retardée à 14:00" to "Delayed opening at 14:00 today"),
which is what the prompt asks for and what the screen needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 22:08:39 +02:00
vliaudatandClaude Opus 5 830e595393 feat: sync Geneva public holidays and let the shop choose which close it
The rolling twelve-month window is fetched from openholidaysapi.org each
night at 03:00 local, and can be triggered from the page, from
POST /api/admin/holidays/sync, or from `npm run holidays:sync` for the
first run after a deployment.

The calendar is fetched twice, once per language, and the two answers
joined on the entry id. Holiday names are proper nouns with established
English forms — "Jeûne genevois" is not something a translation model
should be improvising, and this costs one extra HTTP call.

Two properties are load-bearing and tested against a real database.
The sync is idempotent: running it twice leaves exactly what running it
once did, verified live as well as against a mock. And it never touches
`isAutoClosed` on an existing row — that is the shop's decision, not the
API's, and a nightly job quietly reopening a day the owner had closed
would be invisible until someone found the door locked.

When the API is down the local cache is left untouched and the failure
is recorded with its timestamp, so the page can say how stale the
calendar is rather than showing nothing. Retries widen the gap between
attempts; the nightly job can afford to wait, the shop cannot afford a
stale calendar for a day.

node-cron runs inside the application process rather than an external
cron hitting an endpoint: one container, one shop, no second instance to
coordinate with, and no trigger endpoint to protect and document. The
reasoning is recorded next to the schedule.

Verified against the live API: nine Geneva holidays, both languages,
including the cantonal Restauration de la République.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 21:23:47 +02:00
vliaudatandClaude Opus 5 063c5758a7 chore: settle the public domain and move off port 3000
The application is published at horaires.ita-ito.com. One origin serves
both the panel and the browser, so the OIDC callback, the image URL the
device is handed and the documentation all follow from this single name.

Port 3000 is already taken by the facture_ocr stack on the development
machine, so the app listens on 3010 there and the reason is written next
to the setting rather than left to be rediscovered.

The fixtures and the frozen payload snapshot move to the real domain too:
a contract snapshot carrying a hostname that never existed is a small
puzzle left for whoever reads it next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 18:21:24 +02:00
vliaudatandClaude Opus 5 dd4d1b97c8 feat: serve the BYOS device API
The panel now pairs, fetches its image and files its logs against this
application rather than against the TRMNL cloud.

Four endpoints: /api/setup issues a token on first contact,
/api/display hands back an image and a wake interval, /api/log stores
firmware diagnostics, and /api/device/image/<hash> serves the bytes.

The wake interval is where freshness and battery are traded off. In BYOS
nothing can be pushed: the device sleeps, wakes, asks and sleeps again.
So the interval is short while the shop trades and long overnight, and
it is shortened further whenever a change of state falls inside it —
the door opening in twenty minutes means waking in twenty-one,
whatever the base interval says.

The image filename is the hash of its own bytes. The firmware skips the
redraw when the name is unchanged, which is the whole battery strategy,
and the URL is immutable, unguessable and safe to cache forever. Two
integration tests pin this: unchanged data must yield the same filename
and store one row, changed hours must yield a different one.

MAC addresses are normalised before use. They are a primary key here,
and firmwares are inconsistent about case and separators; without this a
panel could register twice by capitalising itself differently. Header
names are read in both the hyphen and underscore spellings for the same
reason — the TRMNL docs and the Seeed sources disagree, and being
liberal costs nothing while being wrong costs a blank shop window.

Pairing is deliberately made to survive a rendering failure. The token
is issued once and only its digest is kept, so a device stranded by a
failed response would be registered yet hold no credential, and unable
to register again. The welcome image is worth far less than that. This
was found by running the flow, not by reading it.

satori, yoga and harfbuzz are marked external: bundling rewrites the
relative path satori uses to load its WebAssembly, and the renderer dies
on a missing hb.wasm.

The integration tests run against a real Postgres, in CI too. Mocking
Prisma here would only prove the mock works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 18:00:50 +02:00
vliaudatandClaude Opus 5 fccccbd118 feat: render the panel image as a 1-bit 800x480 BMP or PNG
With the move to BYOS there is no TRMNL cloud to interpret a Liquid
template, so the application draws the screen itself.

The pipeline is satori (flexbox to SVG) then resvg (SVG to pixels) then
a hand-written 1-bit encoder. Chromium was the alternative and was
rejected: half a gigabyte and a real memory appetite in the runtime
image, for a screen of eight blocks. Playwright still handles the E2E
tests, in its own pinned image, never in the application one.

The payoff is testability. The layout is asserted on the element tree
and the geometry on the SVG text, so a rendering regression shows up as
a readable diff instead of a pixel comparison. The BMP and PNG encoders
are verified field by field against their specifications, including an
independent CRC-32 for the PNG: a device rejecting a malformed image is
expensive to debug from a shop window.

Two properties are pinned because the battery depends on them: the same
payload must produce byte-identical output, and changed hours must
produce different output. The filename handed to the device is a hash of
these bytes, and the firmware skips the redraw when it is unchanged.

The fonts are vendored into public/fonts and the logo into public/brand,
both committed. Rendering must not depend on an install tree, a CDN or
the network, or the bytes drift and the panel wakes for nothing.

`npm run screen:preview` writes a real 1-bit image plus a magnified view,
which is where clipping and thin strokes give themselves up. That is how
the week strip was caught clipping and condensed.

`npm run brand` rebuilds the assets: it locates the wordmark band in the
shop logo rather than hard-coding offsets a future revision would break,
and thresholds it with the panel's own encoder. sharp is a devDependency
used only there; nothing at runtime needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 17:51:04 +02:00
vliaudatandClaude Opus 5 42ce09100e feat: add Prisma schema, initial migration and idempotent seed
Model the whole domain: settings, the reference week, dated exceptions,
vacation periods, the public-holiday cache, display messages, the
translation cache, the audit log, and the device tables the BYOS server
needs (Device, DeviceLog, SyncState).

Two modelling decisions are load-bearing and documented in the schema:

- Opening times are wall-clock "HH:mm" strings in the shop timezone,
  never instants. Nothing is stored in UTC, which turns the March and
  October daylight-saving switches into non-events instead of edge cases.
- VacationPeriod is a source of truth, never expanded into
  ScheduleException rows. The resolver reads it directly at priority
  rank 2, so a holiday sync can never overwrite a manual exception and
  editing a period leaves no orphans behind.

Device access tokens are stored only as SHA-256 digests, and the image
filename column holds a content hash: the firmware skips the redraw when
the filename is unchanged, which is where the battery life comes from.

Prisma 7 no longer accepts the connection URL in the schema file, so it
moves to prisma.config.ts with the pg driver adapter.

The dev Postgres service lands here rather than with the rest of the
Docker work, because the migration needs a database to run against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 17:33:13 +02:00
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