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
159 lines
5.6 KiB
TypeScript
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));
|
|
}
|