/** * The only place that turns database rows into a ScheduleContext. * * Keeping every query here is what lets `resolver.ts` stay pure. Nothing below * makes a scheduling decision; it loads, narrows and converts. */ import { prisma } from '@/lib/db'; import { addCivilDays, startOfCivilWeek, toCivilDate, type CivilDate } from './civil'; import type { ScheduleContext, Slot } from './types'; /** * How far either side of today to load. * * Back to the Monday of the current week, because the screen draws it; forward * past the resolver's fourteen-day search, so a reopening is never missed for * want of a row. */ const LOOK_BACK_DAYS = 7; const LOOK_AHEAD_DAYS = 21; export type LoadedSettings = { shopName: string; timezone: string; countryIsoCode: string; subdivisionCode: string; refreshRateOpenSec: number; refreshRateClosedSec: number; imageFormat: 'bmp' | 'png'; }; const FALLBACK_SETTINGS: LoadedSettings = { shopName: 'ITA ITO', timezone: 'Europe/Zurich', countryIsoCode: 'CH', subdivisionCode: 'CH-GE', refreshRateOpenSec: 600, refreshRateClosedSec: 7200, imageFormat: 'bmp', }; export async function loadSettings(): Promise { const row = await prisma.settings.findUnique({ where: { id: 'singleton' } }); if (!row) { // A freshly created database must still serve a screen rather than a 500. return FALLBACK_SETTINGS; } return { shopName: row.shopName, timezone: row.timezone, countryIsoCode: row.countryIsoCode, subdivisionCode: row.subdivisionCode, refreshRateOpenSec: row.refreshRateOpenSec, refreshRateClosedSec: row.refreshRateClosedSec, imageFormat: row.imageFormat === 'png' ? 'png' : 'bmp', }; } export async function loadScheduleContext( now: Date, settings: LoadedSettings, ): Promise { const today = toCivilDate(now, settings.timezone); // The window starts at the Monday of the current week or earlier, whichever // reaches further back, so the week strip is always fully covered. const from = min(startOfCivilWeek(today), addCivilDays(today, -LOOK_BACK_DAYS)); const to = addCivilDays(today, LOOK_AHEAD_DAYS); const [weekly, exceptions, vacations, holidays] = await Promise.all([ prisma.weeklySchedule.findMany({ orderBy: { dayOfWeek: 'asc' } }), prisma.scheduleException.findMany({ where: { date: { gte: asDate(from), lte: asDate(to) } }, }), prisma.vacationPeriod.findMany({ where: { startDate: { lte: asDate(to) }, endDate: { gte: asDate(from) } }, }), prisma.publicHoliday.findMany({ where: { date: { gte: asDate(from), lte: asDate(to) }, subdivisionCode: settings.subdivisionCode, }, }), ]); return { timezone: settings.timezone, weekly: weekly.map((day) => ({ dayOfWeek: day.dayOfWeek, isClosed: day.isClosed, slots: asSlots(day.slots), })), exceptions: exceptions.map((exception) => ({ date: asCivil(exception.date), isClosed: exception.isClosed, slots: exception.slots === null ? null : asSlots(exception.slots), reason: exception.reason, noteFr: exception.noteFr, noteEn: exception.noteEn, source: exception.source, })), vacations: vacations.map((period) => ({ startDate: asCivil(period.startDate), endDate: asCivil(period.endDate), labelFr: period.labelFr, labelEn: period.labelEn, })), holidays: holidays.map((holiday) => ({ date: asCivil(holiday.date), nameFr: holiday.nameFr, nameEn: holiday.nameEn, isAutoClosed: holiday.isAutoClosed, })), }; } /** * A `@db.Date` column comes back as midnight UTC, so the first ten characters * of the ISO string are the civil date with no timezone maths involved. */ function asCivil(value: Date): CivilDate { return value.toISOString().slice(0, 10); } function asDate(value: CivilDate): Date { return new Date(`${value}T00:00:00.000Z`); } function min(a: CivilDate, b: CivilDate): CivilDate { return a < b ? a : b; } /** * Slots live in a JSON column, so they arrive untyped. Anything malformed is * dropped rather than allowed to reach the resolver: a bad row should cost one * day's hours, not the whole screen. */ function asSlots(value: unknown): Slot[] { if (!Array.isArray(value)) { return []; } return value.filter( (slot): slot is Slot => typeof slot === 'object' && slot !== null && typeof (slot as Slot).open === 'string' && typeof (slot as Slot).close === 'string', ); }