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
The labels this application writes itself already read "6:30 pm", while
a translated notice kept whatever the French said, so the screen could
show "Closes at 6:30 pm" above "Delayed opening at 14:00". Two
conventions side by side on one panel.
The conversion is done in code, not by the prompt. The model is still
told to leave times alone — reading a clock and rewriting it is
deterministic work, and asking a language model to do it introduces a
failure mode for no benefit. Only HH:mm is touched, never a time that
already carries am or pm, so the pass is idempotent and years, dates
written 20/07 and percentages are left alone.
Hand-written translations are not rewritten: that wording was somebody's
explicit choice.
Verified against the live service: "Ouvert 10:00 – 13:00 puis 14:00 –
18:30" now comes back as "Open 10:00 am – 1:00 pm then 2:00 pm –
6:30 pm".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
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
Shop identity, the cantonal code that drives the holiday calendar, the
two wake intervals and the image format, plus the device list and a
translation self-test. The audit trail sits at the bottom, rendered
field by field in French rather than as raw JSON.
Regenerating a device token shows it once and stores only its digest, so
the panel has to be re-paired through the captive portal afterwards.
That is the point rather than a drawback: this is the control you reach
for when a token may have leaked, and a version that let you read the
old one back would not be one.
The server URL to type into the captive portal is reconstructed from the
incoming request, so the value shown is the one the device would
actually have to reach — not one assembled from configuration that may
not match what the proxy is serving.
Wake intervals are bounded by the same constants the device API enforces,
so a value the settings page accepts cannot be one the panel is refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
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
Current state at the top, the panel as it stands beside what is known
about the device, the next seven days resolved with their exceptions,
and alerts when something needs attention.
There is no "push now" button, because in BYOS there is nothing to push:
the panel sleeps on battery and fetches when it wakes. Pretending
otherwise would be the most misleading control on the page. It says so
in plain words instead, and shows when the device is next due back.
Everything about the device is inference from one timestamp and one
interval, so it lives in a tested module rather than in a template. A
panel is only called overdue once it has missed its slot by half its
interval again: firmware wake-ups drift, and an alert that cries wolf
every cycle is an alert nobody reads. Battery falls back to a voltage
estimate, labelled as an estimate, because "about a third left" is the
only thing anyone acts on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
French in, English out, with the character counter tied to the same
constant the renderer uses — so the warning and the space actually
available on the panel cannot drift apart.
Translation is a second call, not part of the save. A slow or broken
service must never cost the shop its message: the row is stored first
and marked pending, the translation follows, and a failure shows as a
badge with a retry rather than as a lost notice.
The status transitions are the subtle part and are pinned by tests.
Editing the French clears the English, including a translation someone
had corrected by hand — a translation of text that has changed is worse
than no translation. Editing only the dates or the priority leaves it
alone. A hand-written translation is never overwritten while its French
stands, and emptying it returns the row to pending.
"On the screen" is decided by the same selector the renderer uses, so
the badge cannot disagree with the panel about which message is live.
The preview is served by the device's own pipeline — payload, SVG,
threshold — so what the admin sees is the shop window down to the last
thresholded pixel. A preview drawn any other way would eventually
disagree with reality, quietly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
The service is neither Anthropic- nor OpenAI-compatible: it runs the
Claude Code CLI server-side and returns its output. Three consequences
are handled explicitly, each with a test.
The model is chosen by integer id, not by name, so the id is resolved
once from /api/models instead of being hard-coded into the environment —
a number in a .env file that silently points at the wrong model is a bad
trade for one HTTP call per process.
A failed CLI still answers HTTP 200. `exit_code` decides, not the status
line; trusting the status would store an empty translation and call it a
success. The test for this asserts a 200 carrying exit_code 1.
It really does start a process, so the timeout is thirty seconds rather
than the ten the spec assumed.
Answers are cleaned before use: models wrap text in quotes, prefix it
with "Translation:" and append notes often enough that stripping is
cheaper than re-prompting, and a stray quotation mark on a shop window
reads as a mistake.
The cache is keyed on the hash of the trimmed French text, so the same
notice is never paid for twice and whitespace does not cause a miss. The
write is an upsert: two concurrent saves of the same text should be a
no-op, not a crash.
Only the loxi adapter exists, behind the interface. Writing the
Anthropic and OpenAI adapters the spec asked for, with nothing calling
them, would be inventory rather than flexibility — the seam is the
interface, and it is there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
Create a period with a start, an end and a label, see the twelve months
ahead at a glance, delete one. The label is required and trimmed: it
goes straight onto the shop window.
Overlapping periods are refused, and the message names the one they
collide with. Two rows covering the same day would both be "in effect"
with no way to say which, and silently merging them would lose whichever
label the owner meant. Periods that merely touch — one ending the 10th,
the next starting the 11th — are fine.
The year view exists because a list of date ranges is precise and hard
to picture, while "have I left a gap in August?" is the question people
actually ask. It renders on the server; it only changes when the data
does.
A vacation period stays a source of truth and is never expanded into
exception rows, so a one-off exception placed inside one still wins and
nothing is overwritten. The page says so rather than leaving it to be
discovered.
The first version of the calendar built the months but never applied the
periods to them, so no closure would ever have shown. Caught before
commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
The page opens on the gesture the shop actually makes: changing today's
hours from a phone, behind the counter. "Closed today", "opens later at"
and "closes earlier at" are one tap plus a time, and each shows the
hours it would produce before it is applied.
The derivation is the delicate part and is pure and tested. Opening at
14:00 drops a 10:00-13:00 morning rather than keeping it, and trims the
slot the new time falls inside rather than dropping it. Closing early is
the mirror. Both are computed against what the day would normally be,
ignoring any exception already recorded, since that is what "late" is
late relative to.
The upcoming list is built by resolving each of the next sixty days, not
by reading the exception table. Vacations and public holidays are never
materialised as rows, so the table alone would quietly omit most of what
is actually in effect. Each entry is badged with the rule that produced
it, and only stored exceptions offer a delete.
Ranges are capped at 92 days and point at the holidays page beyond that:
a typo in a year field should produce an error, not thirty thousand rows.
Editing the French note clears the English one. A translation of text
that has changed is worse than no translation.
Three exports were removed from the actions module before committing:
every export in a 'use server' file becomes a publicly callable
endpoint, and those three had ended up unused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
One card per day pushed the week to roughly 1100px, so checking a change
meant scrolling back up past the day you had just edited. All seven days
now fit on one phone screen.
Each row carries the day, an open switch, its slots inline and its two
actions. The per-row "duplicate onto the other open days" button was
repeated five times in prose; it is now a short labelled control on the
row itself, with the full sentence kept as its accessible name.
Unsaved days are marked with a dot rather than a badge, and the footer
still names which days are about to change — the point of the preview is
that it is readable without leaving the week.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
The first administration page, built for the actual use case: a phone
held in one hand behind the counter. One card per day, native time
inputs so the platform keyboard does the work, and the consequences
shown before the save rather than after — the footer names which days
are about to change, and the button stays disabled until something
actually has.
"Duplicate onto the other open days" leaves closed days closed. Someone
copying Tuesday's hours means "the days I open, I open like this", not
"open seven days a week".
Validation runs in the browser for the feedback and again in the action
before the write: the client is a convenience, not a guarantee, and this
is the schedule the shop window shows. A day being closed drops its
leftover slots rather than failing on them.
A save that changes nothing writes nothing — no rows, no audit entry,
and so no needless panel redraw. Reordering slots does not count as a
change. The audit diff stores one readable line per day in French, so
the log can be read without cross-referencing the schema.
The editing helpers are pure and tested, and the write path is tested
against a real database including the read-only refusal.
Test files now run sequentially: the integration files share one
database and each truncates it, so parallel files raced. The suite takes
six seconds; giving every file its own database would buy nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
A React server action POSTs to the URL of the page it lives on, so the
middleware's method check over /admin refused every form on the site to
a viewer — including the sign-out button, which surfaced as "an
unexpected response was received from the server".
Gating pages by HTTP method was the wrong instrument: at the edge there
is no way to tell a form that changes the shop's hours from one that
ends a session. The method check now applies to /api/admin only, and
page-level writes are authorised inside the actions themselves, where
the intent is actually known. lib/auth/actions.ts carries that check and
returns an error rather than throwing, since "you do not have
permission" is a normal outcome and not a crash.
The edge decision moves into a pure function with a regression test for
this exact case. These rules are short, but they are the only thing in
front of the administration and one of them has now been got wrong once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
A viewer account has exactly two causes, fixed in completely different
places: the identity provider sent no groups at all, or it sent groups
that do not include the one granting write access. From the outside the
two look identical, so the dashboard now says which it is and what to
do about it, and the sign-in logs the same thing server-side.
The groups are carried in the session for that purpose, capped so the
cookie cannot grow with someone's group membership. Group names are not
secrets, and a support conversation that starts with the actual claim is
a thirty-second fix rather than a guessing game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
Sign-in goes through Authentik over OIDC with PKCE. Verified against the
live provider: the discovery issuer matches the configured one exactly,
and the authorize redirect carries code_challenge_method=S256.
Sessions are JWTs with no database adapter, which keeps the module
usable from edge middleware and makes a sign-in cost no query. The
trade-off is stated in the code: the role travels in the token, so
removing someone from the admin group takes effect at the next sign-in
or when the eight-hour session expires, not instantly. Immediate
revocation would mean asking Authentik on every request, which is what
api_llm_loxi does and what this application deliberately does not — it
drives a shop window, not a fleet.
Group matching is trimmed and case-insensitive. Authentik group names
are case-sensitive, but a capitalisation mismatch between the group and
the environment variable locks the shop owner out silently, and that is
the worse of the two failures. An empty variable never promotes anyone.
Authorisation is enforced twice. The middleware covers every /admin page
and /api/admin route at the edge; a guard inside the handlers repeats
the check, because a matcher is a string, strings get edited, and a
route falling outside one should not be the same thing as a route with
no access control. The rule itself lives in its own framework-free
module so it can be tested directly. Unknown HTTP verbs count as writes:
new methods arrive locked.
Pages get a redirect to the sign-in screen, API routes get a status
code — a fetch that receives an HTML login page is a confusing way to
learn you are signed out. The device API stays outside the matcher, as
the panel cannot sign in and carries its own bearer token; this is
covered by a check that /api/display still answers 401 rather than
redirecting.
The audit diff compares values by their JSON form, so slot arrays and
dates compare by value rather than identity, and a save that changed
nothing writes no entry. Recording never throws: losing the trail is
bad, refusing the user's change because the trail could not be written
is worse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
A swap file of an ignored file is not itself ignored: .env.swp does not
match .env, and vim writes the buffer's contents into it. One was picked
up by an earlier commit.
Checked before writing this: the captured buffer predates the Authentik
credentials and holds only the development Postgres password, which is
labelled as such and reachable on 127.0.0.1 only. Nothing real leaked.
The repository has no remote yet, so the history can still be scrubbed
cheaply if you would rather it were.
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
Renames the OIDC settings to AUTH_URL, AUTH_SECRET and AUTH_AUTHENTIK_*,
the names Auth.js v5 discovers on its own, which is also what the
api_llm_loxi project already runs against this same Authentik instance.
Matching it means one fewer thing to translate when comparing the two
applications, and no glue code to read the variables manually.
Also settles on a single public domain: the panel and the browser reach
the application on the same origin, so APP_DOMAIN and AUTH_URL cannot
disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
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
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
Every string the panel shows is computed here, server-side and in both
languages: the status word, the formatted hours, the next-change line,
the week strip and the banner. The renderer downstream only draws, so it
has no access to the schedule and cannot disagree with this file about
what the shop's hours are.
Closing soon is still open and opening soon is still closed: the large
word states the fact and the line under it carries the nuance, which is
the only thing a passer-by can read from the pavement.
A message someone took the trouble to write always wins the banner over
the automatic notice. When its English translation has not landed yet,
the French line shows alone rather than a placeholder.
The snapshot test is the regression guard for the whole display
pipeline: any change to the shape or to a computed string has to be
acknowledged there before it can reach the panel. lib/screen is at 100%
statements and 96% branches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
Slot validation reports every problem at once rather than the first, so
someone fixing a day's hours on a phone in the shop is not sent round
the loop three times. It covers the format, the ordering, overlaps and
the three-slot ceiling. Slots that merely touch are accepted: 13:00
closing and 13:00 reopening is pointless but not contradictory, and
refusing it would only annoy whoever typed it.
Month and day names are spelled out rather than taken from Intl. The
screen text feeds a content hash that decides whether the e-ink panel
redraws at all, so it has to be byte-stable and must not shift because
a container image ships different ICU locale data.
English uses the twelve-hour clock, as the payload contract specifies:
"Closes at 6:30 pm". Noon and midnight are covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This is the business core, written before any UI as the spec requires.
It is pure: no I/O, no database, no clock of its own. Everything arrives
in a ScheduleContext, so every rule below is exhaustively testable.
The engine works on civil (wall-clock) values rather than instants. A
slot that runs 10:00-18:30 runs 10:00-18:30 on the nights the clocks
change too, and day arithmetic goes through UTC, which has no daylight
saving. That turns the March and October switches from edge cases into
non-events, and the tests pin both of them.
Priority order, highest first: a dated exception, a vacation period, a
public holiday the shop closes for, the reference week, then closed.
Exceptions are badged by their stated reason rather than by who created
them, so a holiday imported by the sync shows as a holiday in the UI.
The search for the next opening is bounded to fourteen days. A shop that
is closed forever must not make the server spin; past the horizon the
screen simply says nothing about reopening. Both the never-open week and
the beyond-the-horizon reopening are covered.
"Soon" is strictly under thirty minutes, so a change exactly half an
hour away still reads as plain OPEN or CLOSED.
55 tests; lib/schedule sits at 97% statements and 93% branches against
an 85% floor. The thresholds were verified to actually fail the build
before being committed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
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
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