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