queue-med/shared/openingHours.ts

189 lines
5.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)}`,
};
});
}