Files
ita-ito-horaires/lib/schedule/civil.ts
T
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

159 lines
5.6 KiB
TypeScript

/**
* Civil (wall-clock) date and time helpers.
*
* The whole scheduling core works on civil values — "2026-09-20" and "18:30"
* as the shop would read them off a wall — rather than on instants. That is a
* deliberate choice: a slot that runs 10:00-18:30 runs 10:00-18:30 on the day
* the clocks change too, and no UTC arithmetic can accidentally shift it by an
* hour twice a year.
*
* Conversions from an instant to a civil value happen exactly once, at the
* edge, through Intl with an explicit timezone. Everything downstream is
* string and integer arithmetic.
*/
/** A civil date, `YYYY-MM-DD`. */
export type CivilDate = string;
/** A civil time of day, `HH:mm`, 24-hour. */
export type CivilTime = string;
const MS_PER_DAY = 86_400_000;
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
const timeFormatters = new Map<string, Intl.DateTimeFormat>();
function dateFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = dateFormatters.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
dateFormatters.set(timeZone, formatter);
}
return formatter;
}
function timeFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = timeFormatters.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-GB', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
});
timeFormatters.set(timeZone, formatter);
}
return formatter;
}
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
const found = parts.find((candidate) => candidate.type === type);
if (!found) {
throw new Error(`Missing "${type}" in formatted date`);
}
return found.value;
}
/** The civil date the given instant falls on, in `timeZone`. */
export function toCivilDate(instant: Date, timeZone: string): CivilDate {
const parts = dateFormatter(timeZone).formatToParts(instant);
return `${part(parts, 'year')}-${part(parts, 'month')}-${part(parts, 'day')}`;
}
/** The wall-clock time the given instant shows, in `timeZone`. */
export function toCivilTime(instant: Date, timeZone: string): CivilTime {
const parts = timeFormatter(timeZone).formatToParts(instant);
return `${part(parts, 'hour')}:${part(parts, 'minute')}`;
}
/**
* Adds whole days to a civil date.
*
* The arithmetic goes through UTC on purpose: UTC has no daylight saving, so
* "one day later" is always exactly 86 400 000 ms and the result is the civil
* date a human would name, including on the nights the clocks change.
*/
export function addCivilDays(date: CivilDate, days: number): CivilDate {
const shifted = new Date(civilToUtcMs(date) + days * MS_PER_DAY);
const year = shifted.getUTCFullYear().toString().padStart(4, '0');
const month = (shifted.getUTCMonth() + 1).toString().padStart(2, '0');
const day = shifted.getUTCDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
/** Day of the week, 0 = Sunday .. 6 = Saturday, matching `Date.prototype.getDay`. */
export function civilDayOfWeek(date: CivilDate): number {
return new Date(civilToUtcMs(date)).getUTCDay();
}
/** Whole days from `a` to `b`; negative when `b` is earlier. */
export function civilDaysBetween(a: CivilDate, b: CivilDate): number {
return Math.round((civilToUtcMs(b) - civilToUtcMs(a)) / MS_PER_DAY);
}
/** Chronological comparator; civil dates are lexicographically ordered too. */
export function compareCivil(a: CivilDate, b: CivilDate): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/** True when `date` falls within `[start, end]`, both inclusive. */
export function isWithinCivilRange(date: CivilDate, start: CivilDate, end: CivilDate): boolean {
return compareCivil(date, start) >= 0 && compareCivil(date, end) <= 0;
}
/** The Monday of the week containing `date`. */
export function startOfCivilWeek(date: CivilDate): CivilDate {
const dayOfWeek = civilDayOfWeek(date);
// getDay() puts Sunday at 0; the shop's week starts on Monday.
const daysSinceMonday = (dayOfWeek + 6) % 7;
return addCivilDays(date, -daysSinceMonday);
}
/**
* The instant as an ISO 8601 string carrying the shop's UTC offset, e.g.
* "2026-09-20T10:15:00+02:00".
*
* The offset is derived from the zone itself rather than assumed, so the same
* code prints +01:00 in winter and +02:00 in summer without being told.
*/
export function toIsoWithOffset(instant: Date, timeZone: string): string {
const date = toCivilDate(instant, timeZone);
const time = toCivilTime(instant, timeZone);
const seconds = instant.getUTCSeconds().toString().padStart(2, '0');
// Reading the wall clock back as if it were UTC gives the offset directly.
const asUtc = Date.UTC(
Number(date.slice(0, 4)),
Number(date.slice(5, 7)) - 1,
Number(date.slice(8, 10)),
Number(time.slice(0, 2)),
Number(time.slice(3, 5)),
instant.getUTCSeconds(),
);
const offsetMinutes = Math.round((asUtc - instant.getTime()) / 60_000);
const sign = offsetMinutes < 0 ? '-' : '+';
const absolute = Math.abs(offsetMinutes);
const hours = Math.floor(absolute / 60)
.toString()
.padStart(2, '0');
const minutes = (absolute % 60).toString().padStart(2, '0');
return `${date}T${time}:${seconds}${sign}${hours}:${minutes}`;
}
/** Minutes since midnight for a `HH:mm` wall-clock time. */
export function minutesOfTime(time: CivilTime): number {
const [hours, minutes] = time.split(':');
return Number(hours) * 60 + Number(minutes);
}
function civilToUtcMs(date: CivilDate): number {
const [year, month, day] = date.split('-');
return Date.UTC(Number(year), Number(month) - 1, Number(day));
}