/** * shared/openingHours.ts * Helpers for clinic opening hours validation. * Used by both server (queue.join validation) and client (PatientQueue display). */ export type DayKey = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday"; export interface DaySchedule { open: string; // "HH:MM" 24h format, e.g. "08:30" close: string; // "HH:MM" 24h format, e.g. "18:00" closed: boolean; } export type OpeningHours = Partial>; export const DAY_KEYS: DayKey[] = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ]; export const DAY_LABELS_FR: Record = { monday: "Lundi", tuesday: "Mardi", wednesday: "Mercredi", thursday: "Jeudi", friday: "Vendredi", saturday: "Samedi", sunday: "Dimanche", }; /** Map JS Date.getDay() (0=Sunday) to DayKey */ const JS_DAY_TO_KEY: DayKey[] = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", ]; /** Parse "HH:MM" into { hours, minutes } */ function parseTime(time: string): { hours: number; minutes: number } { const [h, m] = time.split(":").map(Number); return { hours: h ?? 0, minutes: m ?? 0 }; } /** Convert HH:MM to total minutes since midnight */ function toMinutes(time: string): number { const { hours, minutes } = parseTime(time); return hours * 60 + minutes; } /** Format HH:MM to human-readable "8h30" or "18h00" */ export function formatTime(time: string): string { const { hours, minutes } = parseTime(time); return minutes === 0 ? `${hours}h` : `${hours}h${String(minutes).padStart(2, "0")}`; } /** * Check if the clinic is currently open based on its opening hours. * @param openingHours - The clinic's opening hours configuration (from DB) * @param now - Optional Date to check against (defaults to current time) * @returns true if the clinic is currently open */ export function isClinicOpen( openingHours: OpeningHours | null | undefined, now?: Date ): boolean { // If no hours configured → always open if (!openingHours || Object.keys(openingHours).length === 0) return true; const date = now ?? new Date(); const dayKey = JS_DAY_TO_KEY[date.getDay()]; const schedule = openingHours[dayKey]; // Day not configured → closed if (!schedule) return false; if (schedule.closed) return false; const currentMinutes = date.getHours() * 60 + date.getMinutes(); const openMinutes = toMinutes(schedule.open); const closeMinutes = toMinutes(schedule.close); return currentMinutes >= openMinutes && currentMinutes < closeMinutes; } /** * Get today's schedule for the clinic. */ export function getTodaySchedule( openingHours: OpeningHours | null | undefined, now?: Date ): { dayKey: DayKey; schedule: DaySchedule | null } { const date = now ?? new Date(); const dayKey = JS_DAY_TO_KEY[date.getDay()]; if (!openingHours) return { dayKey, schedule: null }; const schedule = openingHours[dayKey] ?? null; return { dayKey, schedule }; } /** * Get the next opening time (day + time) from now. * Returns null if no hours are configured. */ export function getNextOpeningTime( openingHours: OpeningHours | null | undefined, now?: Date ): { dayKey: DayKey; dayLabel: string; openTime: string } | null { if (!openingHours || Object.keys(openingHours).length === 0) return null; const date = now ?? new Date(); const todayJsDay = date.getDay(); // 0=Sunday // Check next 7 days (including today if not yet open) for (let offset = 0; offset < 7; offset++) { const jsDay = (todayJsDay + offset) % 7; const dayKey = JS_DAY_TO_KEY[jsDay]; const schedule = openingHours[dayKey]; if (!schedule || schedule.closed) continue; const openMinutes = toMinutes(schedule.open); const currentMinutes = date.getHours() * 60 + date.getMinutes(); // Today: only if opening time is in the future if (offset === 0 && currentMinutes >= openMinutes) continue; return { dayKey, dayLabel: DAY_LABELS_FR[dayKey], openTime: formatTime(schedule.open), }; } return null; } /** * Build a human-readable error message when the clinic is closed. */ export function buildClosedMessage( openingHours: OpeningHours | null | undefined, clinicName: string, now?: Date ): string { const next = getNextOpeningTime(openingHours, now); const { dayKey, schedule } = getTodaySchedule(openingHours, now); const todayLabel = DAY_LABELS_FR[dayKey]; if (!schedule || schedule.closed) { if (next) { return `${clinicName} est fermé aujourd'hui (${todayLabel}). Prochaine ouverture : ${next.dayLabel} à ${next.openTime}.`; } return `${clinicName} est actuellement fermé.`; } // Today has hours but we're outside them const currentMinutes = (now ?? new Date()).getHours() * 60 + (now ?? new Date()).getMinutes(); const openMinutes = toMinutes(schedule.open); const closeMinutes = toMinutes(schedule.close); if (currentMinutes < openMinutes) { return `${clinicName} ouvre à ${formatTime(schedule.open)} aujourd'hui. Revenez plus tard.`; } if (currentMinutes >= closeMinutes) { if (next) { return `${clinicName} est fermé pour aujourd'hui. Prochaine ouverture : ${next.dayLabel} à ${next.openTime}.`; } return `${clinicName} est fermé pour aujourd'hui.`; } return `${clinicName} est actuellement fermé.`; } /** * Format opening hours for display in a weekly schedule. */ export function formatWeeklySchedule( openingHours: OpeningHours | null | undefined ): Array<{ dayKey: DayKey; dayLabel: string; hours: string }> { return DAY_KEYS.map((dayKey) => { const schedule = openingHours?.[dayKey]; if (!schedule || schedule.closed) { return { dayKey, dayLabel: DAY_LABELS_FR[dayKey], hours: "Fermé" }; } return { dayKey, dayLabel: DAY_LABELS_FR[dayKey], hours: `${formatTime(schedule.open)} – ${formatTime(schedule.close)}`, }; }); }