/** * Builds the frozen screen payload from resolved schedule data. * * Every string the panel will show is computed here, server-side and in both * languages. The renderer downstream draws and nothing else: it has no access * to the schedule, so it cannot disagree with this file about what the shop's * hours are. That separation is what lets the whole display be tested without * a device attached. */ import { addCivilDays, civilDayOfWeek, startOfCivilWeek, toCivilDate, toIsoWithOffset, } from '@/lib/schedule/civil'; import { formatDateEn, formatDateFr, formatHoursText, formatNextChangeEn, formatNextChangeFr, shortDayLabelEn, shortDayLabelFr, type NextChange, } from '@/lib/schedule/format'; import { detectDivergence, getCurrentStatus, resolveCivilDay } from '@/lib/schedule/resolver'; import type { ScheduleContext } from '@/lib/schedule/types'; import { AUTO_BANNERS, MESSAGE_MAX_CHARS, SCREEN_SCHEMA_VERSION, type ScreenBanner, type ScreenPayload, type ScreenWeekDay, } from './contract'; export type MessageCandidate = { textFr: string; /** `null` while the translation is pending, or if it failed. */ textEn: string | null; startsAt: Date | null; endsAt: Date | null; priority: number; isActive: boolean; }; export type ScreenInput = { now: Date; ctx: ScheduleContext; shopName: string; logoUrl: string; messages: MessageCandidate[]; }; /** The highest-priority message that is active and inside its validity window. */ export function selectActiveMessage( messages: MessageCandidate[], now: Date, ): MessageCandidate | null { const instant = now.getTime(); const live = messages.filter( (message) => message.isActive && (message.startsAt === null || message.startsAt.getTime() <= instant) && (message.endsAt === null || message.endsAt.getTime() >= instant), ); if (live.length === 0) { return null; } return live.reduce((best, candidate) => (candidate.priority > best.priority ? candidate : best)); } export function buildScreenPayload({ now, ctx, shopName, logoUrl, messages, }: ScreenInput): ScreenPayload { const today = toCivilDate(now, ctx.timezone); const status = getCurrentStatus(now, ctx); // Closing soon is still open, and opening soon is still closed: the large // word states the fact, the line under it carries the nuance. const isOpenNow = status.status === 'OPEN' || status.status === 'CLOSING_SOON'; const nextChange: NextChange | null = status.nextChangeAt ? { kind: isOpenNow ? 'CLOSES' : 'OPENS', date: status.nextChangeAt.date, time: status.nextChangeAt.time, } : null; return { schema: SCREEN_SCHEMA_VERSION, generated_at: toIsoWithOffset(now, ctx.timezone), shop_name: shopName, logo_url: logoUrl, today: { date_fr: formatDateFr(today), date_en: formatDateEn(today), status: status.status, status_fr: isOpenNow ? 'OUVERT' : 'FERMÉ', status_en: isOpenNow ? 'OPEN' : 'CLOSED', hours_text: formatHoursText(status.today.slots), next_change_fr: formatNextChangeFr(nextChange, today), next_change_en: formatNextChangeEn(nextChange, today), is_exception: status.today.isException, }, week: buildWeek(today, ctx), banner: buildBanner(now, ctx, messages), }; } /** Monday through Sunday of the week containing `today`. */ function buildWeek(today: string, ctx: ScheduleContext): ScreenWeekDay[] { const monday = startOfCivilWeek(today); return Array.from({ length: 7 }, (_, offset) => { const date = addCivilDays(monday, offset); const day = resolveCivilDay(date, ctx); const dayOfWeek = civilDayOfWeek(date); return { label_fr: shortDayLabelFr(dayOfWeek), label_en: shortDayLabelEn(dayOfWeek), hours_text: formatHoursText(day.slots), is_today: date === today, is_closed: !day.isOpen, is_exception: day.isException, }; }); } function buildBanner( now: Date, ctx: ScheduleContext, messages: MessageCandidate[], ): ScreenBanner | null { // A message someone took the trouble to write always wins the space. const message = selectActiveMessage(messages, now); if (message) { return { kind: 'MESSAGE', text_fr: clamp(message.textFr), text_en: clamp(message.textEn ?? ''), }; } const divergence = detectDivergence(now, ctx); if (divergence === 'NONE') { return null; } const kind = divergence === 'TODAY' ? 'SCHEDULE_TODAY' : 'SCHEDULE_WEEK'; const wording = AUTO_BANNERS[kind]; return { kind, text_fr: wording.fr, text_en: wording.en }; } /** * The editor warns before anyone can save something this long, so reaching * here means the limit changed after the fact. Cut rather than let the text * spill off a panel nobody can scroll. */ function clamp(text: string): string { if (text.length <= MESSAGE_MAX_CHARS) { return text; } return `${text.slice(0, MESSAGE_MAX_CHARS - 1).trimEnd()}…`; }