189 lines
5.8 KiB
TypeScript
189 lines
5.8 KiB
TypeScript
/**
|
||
* 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<Record<DayKey, DaySchedule>>;
|
||
|
||
export const DAY_KEYS: DayKey[] = [
|
||
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||
];
|
||
|
||
export const DAY_LABELS_FR: Record<DayKey, string> = {
|
||
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)}`,
|
||
};
|
||
});
|
||
}
|