/** * Assembles, renders and stores the current panel image. * * The image is addressed by the hash of its own bytes. That single decision * buys three things: the device skips the redraw when the name has not changed * (which is where the battery life comes from), the URL is safe to cache * forever, and it cannot be enumerated. */ import { createHash } from 'node:crypto'; import { prisma } from '@/lib/db'; import { loadScheduleContext, loadSettings, type LoadedSettings } from '@/lib/schedule/context'; import { getCurrentStatus } from '@/lib/schedule/resolver'; import type { ScheduleContext, ShopStatus } from '@/lib/schedule/types'; import type { ScreenPayload } from './contract'; import { encodeScreen } from './encode'; import { renderScreenSvg } from './render'; import { buildScreenPayload, type MessageCandidate } from './viewmodel'; /** Long enough that a collision is not a thing worth thinking about. */ const HASH_LENGTH = 16; /** How many rendered images to keep. Enough to cover a device mid-fetch. */ const KEEP_IMAGES = 20; export type CurrentScreen = { payload: ScreenPayload; settings: LoadedSettings; context: ScheduleContext; status: ShopStatus; }; export async function buildCurrentScreen(now: Date, baseUrl: string): Promise { const settings = await loadSettings(); const context = await loadScheduleContext(now, settings); const messages = await loadMessages(); return { payload: buildScreenPayload({ now, ctx: context, shopName: settings.shopName, logoUrl: `${baseUrl}/brand/logo-eink.png`, messages, }), settings, context, status: getCurrentStatus(now, context), }; } export type StoredImage = { hash: string; format: 'bmp' | 'png'; filename: string; }; /** * Renders the payload and stores the bytes under their own hash. * * Re-rendering an unchanged screen produces the same hash and the same row, so * this is idempotent by construction: nothing accumulates while the shop's * hours stay put. */ export async function renderAndStore( payload: ScreenPayload, format: 'bmp' | 'png', ): Promise { const svg = await renderScreenSvg(payload); const bytes = encodeScreen(svg, format); const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH); const filename = `${hash}.${format}`; await prisma.screenImage.upsert({ where: { hash }, update: {}, // Prisma's Bytes maps to Uint8Array; Buffer is one, but its backing store // is typed loosely enough that TypeScript wants it said explicitly. create: { hash, format, bytes: new Uint8Array(bytes) }, }); await pruneOldImages(); return { hash, format, filename }; } export async function findStoredImage(hash: string): Promise<{ bytes: Buffer; format: string } | null> { const row = await prisma.screenImage.findUnique({ where: { hash } }); if (!row) { return null; } return { bytes: Buffer.from(row.bytes), format: row.format }; } async function pruneOldImages(): Promise { const keep = await prisma.screenImage.findMany({ orderBy: { createdAt: 'desc' }, take: KEEP_IMAGES, select: { hash: true }, }); await prisma.screenImage.deleteMany({ where: { hash: { notIn: keep.map((row) => row.hash) } }, }); } async function loadMessages(): Promise { const rows = await prisma.message.findMany({ where: { isActive: true }, orderBy: { priority: 'desc' }, }); return rows.map((row) => ({ textFr: row.textFr, textEn: row.textEn, startsAt: row.startsAt, endsAt: row.endsAt, priority: row.priority, isActive: row.isActive, })); }