diff --git a/lib/schedule/civil.test.ts b/lib/schedule/civil.test.ts index 5ce8fb6..318f7ec 100644 --- a/lib/schedule/civil.test.ts +++ b/lib/schedule/civil.test.ts @@ -5,8 +5,10 @@ import { civilDayOfWeek, compareCivil, minutesOfTime, + startOfCivilWeek, toCivilDate, toCivilTime, + toIsoWithOffset, } from './civil'; const ZURICH = 'Europe/Zurich'; @@ -99,3 +101,36 @@ describe('minutesOfTime', () => { expect(minutesOfTime('23:59')).toBe(1439); }); }); + +describe('startOfCivilWeek', () => { + it('returns the Monday of that week', () => { + // 2026-09-20 is a Sunday: its week started on Monday the 14th. + expect(startOfCivilWeek('2026-09-20')).toBe('2026-09-14'); + expect(startOfCivilWeek('2026-09-21')).toBe('2026-09-21'); + expect(startOfCivilWeek('2026-09-23')).toBe('2026-09-21'); + }); + + it('reaches back across a month boundary', () => { + expect(startOfCivilWeek('2026-10-01')).toBe('2026-09-28'); + }); +}); + +describe('toIsoWithOffset', () => { + it('carries the summer offset', () => { + expect(toIsoWithOffset(new Date('2026-09-20T08:15:00Z'), ZURICH)).toBe( + '2026-09-20T10:15:00+02:00', + ); + }); + + it('carries the winter offset', () => { + expect(toIsoWithOffset(new Date('2026-01-15T08:15:00Z'), ZURICH)).toBe( + '2026-01-15T09:15:00+01:00', + ); + }); + + it('prints UTC as +00:00', () => { + expect(toIsoWithOffset(new Date('2026-09-20T08:15:30Z'), 'UTC')).toBe( + '2026-09-20T08:15:30+00:00', + ); + }); +}); diff --git a/lib/schedule/civil.ts b/lib/schedule/civil.ts index 2b505fd..159eb1f 100644 --- a/lib/schedule/civil.ts +++ b/lib/schedule/civil.ts @@ -101,6 +101,46 @@ export function isWithinCivilRange(date: CivilDate, start: CivilDate, end: Civil 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(':'); diff --git a/lib/screen/__snapshots__/viewmodel.test.ts.snap b/lib/screen/__snapshots__/viewmodel.test.ts.snap new file mode 100644 index 0000000..405f3d2 --- /dev/null +++ b/lib/screen/__snapshots__/viewmodel.test.ts.snap @@ -0,0 +1,84 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`buildScreenPayload > matches the frozen payload contract 1`] = ` +{ + "banner": { + "kind": "MESSAGE", + "text_en": "Exceptionally closed on Thursday afternoon", + "text_fr": "Fermeture exceptionnelle jeudi après-midi", + }, + "generated_at": "2026-09-22T12:00:00+02:00", + "logo_url": "https://trmnl.loxi.ch/brand/logo-eink.png", + "schema": 1, + "shop_name": "ITA ITO", + "today": { + "date_en": "Tuesday 22 September", + "date_fr": "Mardi 22 septembre", + "hours_text": "10:00 – 18:30", + "is_exception": false, + "next_change_en": "Closes at 6:30 pm", + "next_change_fr": "Ferme à 18:30", + "status": "OPEN", + "status_en": "OPEN", + "status_fr": "OUVERT", + }, + "week": [ + { + "hours_text": "", + "is_closed": true, + "is_exception": false, + "is_today": false, + "label_en": "Mon", + "label_fr": "Lun", + }, + { + "hours_text": "10:00 – 18:30", + "is_closed": false, + "is_exception": false, + "is_today": true, + "label_en": "Tue", + "label_fr": "Mar", + }, + { + "hours_text": "10:00 – 13:00 · 14:00 – 18:30", + "is_closed": false, + "is_exception": false, + "is_today": false, + "label_en": "Wed", + "label_fr": "Mer", + }, + { + "hours_text": "14:00 – 18:30", + "is_closed": false, + "is_exception": true, + "is_today": false, + "label_en": "Thu", + "label_fr": "Jeu", + }, + { + "hours_text": "", + "is_closed": true, + "is_exception": true, + "is_today": false, + "label_en": "Fri", + "label_fr": "Ven", + }, + { + "hours_text": "10:00 – 18:30", + "is_closed": false, + "is_exception": false, + "is_today": false, + "label_en": "Sat", + "label_fr": "Sam", + }, + { + "hours_text": "", + "is_closed": true, + "is_exception": false, + "is_today": false, + "label_en": "Sun", + "label_fr": "Dim", + }, + ], +} +`; diff --git a/lib/screen/contract.ts b/lib/screen/contract.ts new file mode 100644 index 0000000..686cc55 --- /dev/null +++ b/lib/screen/contract.ts @@ -0,0 +1,86 @@ +/** + * The screen payload contract. + * + * This is the frozen boundary of the whole system: the resolver fills it, the + * renderer reads it, and a snapshot test pins it. Every string the panel shows + * is computed here, server-side — the renderer performs no business logic, it + * only draws. That is what makes the display testable without a device. + * + * Bump SCREEN_SCHEMA_VERSION when the shape changes, never silently. + */ + +export const SCREEN_SCHEMA_VERSION = 1; + +/** + * How much free text the bottom banner can hold before it stops fitting. + * Shared by the message editor's counter and the renderer, so the warning the + * user sees and the space actually available can never drift apart. + */ +export const MESSAGE_MAX_CHARS = 120; + +export type ScreenStatus = 'OPEN' | 'CLOSED' | 'CLOSING_SOON' | 'OPENING_SOON'; + +/** + * Where the banner text came from: something a person wrote, or the automatic + * notice raised when the week departs from the reference schedule. + */ +export type ScreenBannerKind = 'MESSAGE' | 'SCHEDULE_TODAY' | 'SCHEDULE_WEEK'; + +export type ScreenToday = { + date_fr: string; + date_en: string; + status: ScreenStatus; + /** The single word shown in very large type. */ + status_fr: string; + status_en: string; + /** "10:00 – 13:00 · 14:00 – 18:30", empty when closed all day. */ + hours_text: string; + /** "Ferme à 18:30" / "Ouvre demain à 10:00", empty when there is nothing ahead. */ + next_change_fr: string; + next_change_en: string; + is_exception: boolean; +}; + +export type ScreenWeekDay = { + label_fr: string; + label_en: string; + hours_text: string; + is_today: boolean; + is_closed: boolean; + is_exception: boolean; +}; + +export type ScreenBanner = { + kind: ScreenBannerKind; + text_fr: string; + text_en: string; +}; + +export type ScreenPayload = { + schema: number; + /** ISO 8601 with the shop's UTC offset, e.g. "2026-09-20T10:15:00+02:00". */ + generated_at: string; + shop_name: string; + logo_url: string; + today: ScreenToday; + /** Always seven entries, Monday through Sunday of the current week. */ + week: ScreenWeekDay[]; + /** `null` when the shop has nothing to say, so the week block takes the room. */ + banner: ScreenBanner | null; +}; + +/** + * Automatic banner wording. Pre-translated on purpose: these lines must never + * depend on the translation service being reachable. + */ +export const AUTO_BANNERS: Record<'SCHEDULE_TODAY' | 'SCHEDULE_WEEK', { fr: string; en: string }> = + { + SCHEDULE_TODAY: { + fr: "Changement d'horaire aujourd'hui", + en: 'Opening hours changed today', + }, + SCHEDULE_WEEK: { + fr: 'Horaires exceptionnels cette semaine', + en: 'Unusual opening hours this week', + }, + }; diff --git a/lib/screen/viewmodel.test.ts b/lib/screen/viewmodel.test.ts new file mode 100644 index 0000000..1d00159 --- /dev/null +++ b/lib/screen/viewmodel.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, it } from 'vitest'; + +import type { ScheduleContext, Slot } from '@/lib/schedule/types'; + +import { MESSAGE_MAX_CHARS, SCREEN_SCHEMA_VERSION } from './contract'; +import { buildScreenPayload, selectActiveMessage, type MessageCandidate } from './viewmodel'; + +const ZURICH = 'Europe/Zurich'; +const FULL_DAY: Slot[] = [{ open: '10:00', close: '18:30' }]; +const SPLIT_DAY: Slot[] = [ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, +]; + +function context(overrides: Partial = {}): ScheduleContext { + return { + timezone: ZURICH, + weekly: [ + { dayOfWeek: 0, isClosed: true, slots: [] }, + { dayOfWeek: 1, isClosed: true, slots: [] }, + { dayOfWeek: 2, isClosed: false, slots: FULL_DAY }, + { dayOfWeek: 3, isClosed: false, slots: SPLIT_DAY }, + { dayOfWeek: 4, isClosed: false, slots: FULL_DAY }, + { dayOfWeek: 5, isClosed: false, slots: FULL_DAY }, + { dayOfWeek: 6, isClosed: false, slots: FULL_DAY }, + ], + exceptions: [], + vacations: [], + holidays: [], + ...overrides, + }; +} + +function input(overrides: Partial[0]> = {}) { + return { + // Tuesday 2026-09-22, 12:00 in Zurich: the shop is open. + now: new Date('2026-09-22T10:00:00Z'), + ctx: context(), + shopName: 'ITA ITO', + logoUrl: 'https://trmnl.loxi.ch/brand/logo-eink.png', + messages: [] as MessageCandidate[], + ...overrides, + }; +} + +describe('selectActiveMessage', () => { + const now = new Date('2026-09-22T10:00:00Z'); + + const base: MessageCandidate = { + textFr: 'Bonjour', + textEn: 'Hello', + startsAt: null, + endsAt: null, + priority: 0, + isActive: true, + }; + + it('returns nothing when there is nothing to say', () => { + expect(selectActiveMessage([], now)).toBeNull(); + }); + + it('returns a permanent active message', () => { + expect(selectActiveMessage([base], now)?.textFr).toBe('Bonjour'); + }); + + it('ignores a deactivated message', () => { + expect(selectActiveMessage([{ ...base, isActive: false }], now)).toBeNull(); + }); + + it('ignores a message whose window has not opened', () => { + const future = { ...base, startsAt: new Date('2026-09-23T00:00:00Z') }; + expect(selectActiveMessage([future], now)).toBeNull(); + }); + + it('ignores a message whose window has closed', () => { + const past = { ...base, endsAt: new Date('2026-09-21T00:00:00Z') }; + expect(selectActiveMessage([past], now)).toBeNull(); + }); + + it('keeps a message inside its window', () => { + const current = { + ...base, + startsAt: new Date('2026-09-21T00:00:00Z'), + endsAt: new Date('2026-09-23T00:00:00Z'), + }; + expect(selectActiveMessage([current], now)?.textFr).toBe('Bonjour'); + }); + + it('prefers the highest priority', () => { + const chosen = selectActiveMessage( + [ + { ...base, textFr: 'Normale', priority: 1 }, + { ...base, textFr: 'Urgente', priority: 9 }, + ], + now, + ); + expect(chosen?.textFr).toBe('Urgente'); + }); +}); + +describe('buildScreenPayload', () => { + it('stamps the schema version and the generation time with the shop offset', () => { + const payload = buildScreenPayload(input()); + expect(payload.schema).toBe(SCREEN_SCHEMA_VERSION); + expect(payload.generated_at).toBe('2026-09-22T12:00:00+02:00'); + }); + + it('describes an open day', () => { + const payload = buildScreenPayload(input()); + expect(payload.today).toMatchObject({ + date_fr: 'Mardi 22 septembre', + date_en: 'Tuesday 22 September', + status: 'OPEN', + status_fr: 'OUVERT', + status_en: 'OPEN', + hours_text: '10:00 – 18:30', + next_change_fr: 'Ferme à 18:30', + next_change_en: 'Closes at 6:30 pm', + is_exception: false, + }); + }); + + it('still says OUVERT when closing is imminent', () => { + // 18:05 local: the big word must stay OUVERT, the detail line carries the warning. + const payload = buildScreenPayload(input({ now: new Date('2026-09-22T16:05:00Z') })); + expect(payload.today.status).toBe('CLOSING_SOON'); + expect(payload.today.status_fr).toBe('OUVERT'); + expect(payload.today.next_change_fr).toBe('Ferme à 18:30'); + }); + + it('describes a closed day and points at the next opening', () => { + // Monday 2026-09-21, 12:00 local. + const payload = buildScreenPayload(input({ now: new Date('2026-09-21T10:00:00Z') })); + expect(payload.today.status).toBe('CLOSED'); + expect(payload.today.status_fr).toBe('FERMÉ'); + expect(payload.today.status_en).toBe('CLOSED'); + expect(payload.today.hours_text).toBe(''); + expect(payload.today.next_change_fr).toBe('Ouvre demain à 10:00'); + expect(payload.today.next_change_en).toBe('Opens tomorrow at 10:00 am'); + }); + + it('says nothing about the future when the shop never reopens', () => { + const payload = buildScreenPayload( + input({ + ctx: context({ + weekly: [0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => ({ + dayOfWeek, + isClosed: true, + slots: [], + })), + }), + }), + ); + expect(payload.today.next_change_fr).toBe(''); + expect(payload.today.next_change_en).toBe(''); + }); + + it('lays the week out Monday to Sunday and marks today', () => { + const payload = buildScreenPayload(input()); + expect(payload.week).toHaveLength(7); + expect(payload.week.map((day) => day.label_fr)).toEqual([ + 'Lun', + 'Mar', + 'Mer', + 'Jeu', + 'Ven', + 'Sam', + 'Dim', + ]); + expect(payload.week.map((day) => day.label_en)).toEqual([ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]); + // Tuesday is index 1 of a Monday-first week. + expect(payload.week.map((day) => day.is_today)).toEqual([ + false, + true, + false, + false, + false, + false, + false, + ]); + expect(payload.week[0]?.is_closed).toBe(true); + expect(payload.week[2]?.hours_text).toBe('10:00 – 13:00 · 14:00 – 18:30'); + }); + + it('marks exceptional days in the week strip', () => { + const payload = buildScreenPayload( + input({ + ctx: context({ + exceptions: [ + { + date: '2026-09-24', + isClosed: true, + slots: null, + reason: 'TEMPORARY', + noteFr: 'Fermé', + noteEn: 'Closed', + source: 'MANUAL', + }, + ], + }), + }), + ); + expect(payload.week[3]?.is_exception).toBe(true); + expect(payload.week[3]?.is_closed).toBe(true); + }); + + it('carries no banner when the shop has nothing to say', () => { + expect(buildScreenPayload(input()).banner).toBeNull(); + }); + + it('raises the automatic banner when only today departs from the reference week', () => { + const payload = buildScreenPayload( + input({ + ctx: context({ + exceptions: [ + { + date: '2026-09-22', + isClosed: false, + slots: [{ open: '14:00', close: '18:30' }], + reason: 'TEMPORARY', + noteFr: 'Ouverture retardée', + noteEn: 'Late opening', + source: 'MANUAL', + }, + ], + }), + }), + ); + expect(payload.banner).toEqual({ + kind: 'SCHEDULE_TODAY', + text_fr: "Changement d'horaire aujourd'hui", + text_en: 'Opening hours changed today', + }); + }); + + it('raises the weekly banner when a later day departs', () => { + const payload = buildScreenPayload( + input({ + ctx: context({ + exceptions: [ + { + date: '2026-09-24', + isClosed: true, + slots: null, + reason: 'TEMPORARY', + noteFr: null, + noteEn: null, + source: 'MANUAL', + }, + ], + }), + }), + ); + expect(payload.banner?.kind).toBe('SCHEDULE_WEEK'); + }); + + it('lets a free-text message win over the automatic banner', () => { + const payload = buildScreenPayload( + input({ + ctx: context({ + exceptions: [ + { + date: '2026-09-24', + isClosed: true, + slots: null, + reason: 'TEMPORARY', + noteFr: null, + noteEn: null, + source: 'MANUAL', + }, + ], + }), + messages: [ + { + textFr: 'Nouvelle collection en boutique', + textEn: 'New collection in store', + startsAt: null, + endsAt: null, + priority: 0, + isActive: true, + }, + ], + }), + ); + expect(payload.banner).toEqual({ + kind: 'MESSAGE', + text_fr: 'Nouvelle collection en boutique', + text_en: 'New collection in store', + }); + }); + + it('shows the French line alone when the translation is missing', () => { + const payload = buildScreenPayload( + input({ + messages: [ + { + textFr: 'Fermeture exceptionnelle', + textEn: null, + startsAt: null, + endsAt: null, + priority: 0, + isActive: true, + }, + ], + }), + ); + expect(payload.banner?.text_fr).toBe('Fermeture exceptionnelle'); + expect(payload.banner?.text_en).toBe(''); + }); + + it('truncates a message that would overflow the banner', () => { + const tooLong = 'a'.repeat(MESSAGE_MAX_CHARS + 40); + const payload = buildScreenPayload( + input({ + messages: [ + { + textFr: tooLong, + textEn: tooLong, + startsAt: null, + endsAt: null, + priority: 0, + isActive: true, + }, + ], + }), + ); + expect(payload.banner?.text_fr.length).toBeLessThanOrEqual(MESSAGE_MAX_CHARS); + expect(payload.banner?.text_fr.endsWith('…')).toBe(true); + }); + + it('matches the frozen payload contract', () => { + // This snapshot is the regression guard for the whole display pipeline: + // any change to the shape or to a computed string has to be acknowledged + // here before it can reach the panel. + expect( + buildScreenPayload( + input({ + ctx: context({ + exceptions: [ + { + date: '2026-09-24', + isClosed: false, + slots: [{ open: '14:00', close: '18:30' }], + reason: 'TEMPORARY', + noteFr: 'Ouverture retardée', + noteEn: 'Late opening', + source: 'MANUAL', + }, + ], + holidays: [ + { + date: '2026-09-25', + nameFr: 'Jeûne genevois', + nameEn: 'Geneva Fast', + isAutoClosed: true, + }, + ], + }), + messages: [ + { + textFr: 'Fermeture exceptionnelle jeudi après-midi', + textEn: 'Exceptionally closed on Thursday afternoon', + startsAt: null, + endsAt: null, + priority: 0, + isActive: true, + }, + ], + }), + ), + ).toMatchSnapshot(); + }); +}); diff --git a/lib/screen/viewmodel.ts b/lib/screen/viewmodel.ts new file mode 100644 index 0000000..3b10b75 --- /dev/null +++ b/lib/screen/viewmodel.ts @@ -0,0 +1,172 @@ +/** + * 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()}…`; +}