diff --git a/apps/Backend/src/routes/appointments.ts b/apps/Backend/src/routes/appointments.ts index 9f1568c..0a86b50 100644 --- a/apps/Backend/src/routes/appointments.ts +++ b/apps/Backend/src/routes/appointments.ts @@ -188,9 +188,12 @@ router.post( // 2. Attempt to find the next available slot let [hour, minute] = originalStartTime.split(":").map(Number); - const pad = (n: number) => n.toString().padStart(2, "0"); + // Step by 15 minutes to support quarter-hour starts, but keep appointment duration 30 mins + const STEP_MINUTES = 15; + const APPT_DURATION_MINUTES = 30; + while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) { const currentStartTime = `${pad(hour)}:${pad(minute)}`; @@ -211,7 +214,7 @@ router.post( ); if (!staffConflict) { - const endMinute = minute + 30; + const endMinute = minute + APPT_DURATION_MINUTES; let endHour = hour + Math.floor(endMinute / 60); let realEndMinute = endMinute % 60; @@ -255,11 +258,11 @@ router.post( return res.status(201).json(responseData); } - // Move to next 30-min slot - minute += 30; + // Move to next STEP_MINUTES slot + minute += STEP_MINUTES; if (minute >= 60) { - hour += 1; - minute = 0; + hour += Math.floor(minute / 60); + minute = minute % 60; } } diff --git a/apps/Frontend/src/components/appointments/appointment-form.tsx b/apps/Frontend/src/components/appointments/appointment-form.tsx index 4104ed4..5975f08 100644 --- a/apps/Frontend/src/components/appointments/appointment-form.tsx +++ b/apps/Frontend/src/components/appointments/appointment-form.tsx @@ -34,7 +34,7 @@ import { UpdateAppointment, } from "@repo/db/types"; import { DateInputField } from "@/components/ui/dateInputField"; -import { parseLocalDate } from "@/utils/dateUtils"; +import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils"; import { PatientSearch, SearchCriteria, @@ -106,10 +106,7 @@ export function AppointmentForm({ userId: user?.id, patientId: appointment.patientId, title: appointment.title, - date: - typeof appointment.date === "string" - ? parseLocalDate(appointment.date) - : appointment.date, + date: parseLocalDate(appointment.date), startTime: appointment.startTime || "09:00", // Default "09:00" endTime: appointment.endTime || "09:30", // Default "09:30" type: appointment.type, @@ -125,11 +122,8 @@ export function AppointmentForm({ userId: user?.id, patientId: Number(parsedStoredData.patientId), date: parsedStoredData.date - ? typeof parsedStoredData.date === "string" - ? parseLocalDate(parsedStoredData.date) - : new Date(parsedStoredData.date) // in case it’s a stringified date or timestamp - : new Date(), - + ? parseLocalDate(parsedStoredData.date) + : parseLocalDate(new Date()), title: parsedStoredData.title || "", startTime: parsedStoredData.startTime, endTime: parsedStoredData.endTime, @@ -260,11 +254,7 @@ export function AppointmentForm({ if (parsedStoredData.staff) form.setValue("staffId", parsedStoredData.staff); if (parsedStoredData.date) { - const parsedDate = - typeof parsedStoredData.date === "string" - ? parseLocalDate(parsedStoredData.date) - : new Date(parsedStoredData.date); - form.setValue("date", parsedDate); + form.setValue("date", parseLocalDate(parsedStoredData.date)); } // ---- patient prefill: check main cache, else fetch once ---- @@ -388,7 +378,7 @@ export function AppointmentForm({ : `Appointment with ${selectedStaff?.name}`; } - const formattedDate = data.date.toLocaleDateString("en-CA"); + const formattedDate = formatLocalDate(data.date); onSubmit({ ...data, @@ -575,11 +565,7 @@ export function AppointmentForm({ )} /> - +
n.toString().padStart(2, "0"); + const timeStr = `${pad(hour)}:${pad(minute)}`; + + // Only allow start times up to 18:00 (last start for 30-min appointment) + if (timeStr > "18:00") continue; + const hour12 = hour > 12 ? hour - 12 : hour; const period = hour >= 12 ? "PM" : "AM"; - const timeStr = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; - const displayTime = `${hour12}:${minute.toString().padStart(2, "0")} ${period}`; + const displayTime = `${hour12}:${pad(minute)} ${period}`; timeSlots.push({ time: timeStr, displayTime }); } } @@ -399,7 +405,7 @@ export default function AppointmentsPage() { patientName, staffId, status: apt.status ?? null, - date: formatLocalDate(parseLocalDate(apt.date)), + date: formatLocalDate(apt.date), startTime: normalizedStart, endTime: normalizedEnd, } as ScheduledAppointment; @@ -827,7 +833,7 @@ export default function AppointmentsPage() { > {selectedDate - ? selectedDate.toLocaleDateString() + ? formatDateToHumanReadable(selectedDate) : "Pick a date"} diff --git a/apps/Frontend/src/utils/dateUtils.ts b/apps/Frontend/src/utils/dateUtils.ts index 1be7ebd..f8e78b2 100644 --- a/apps/Frontend/src/utils/dateUtils.ts +++ b/apps/Frontend/src/utils/dateUtils.ts @@ -1,6 +1,15 @@ /** + * Use parseLocalDate when you need a Date object at local midnight + * (for calendars, date pickers, Date math in the browser). + * + * * Parse a date string in yyyy-MM-dd format (assumed local) into a JS Date object. * No timezone conversion is applied. Returns a Date at midnight local time. + * + * * Accepts: + * - "YYYY-MM-DD" + * - ISO/timestamp string (will take left-of-'T' date portion) + * - Date object (will return a new Date set to that local calendar day at midnight) */ export function parseLocalDate(input: string | Date): Date { @@ -35,6 +44,10 @@ export function parseLocalDate(input: string | Date): Date { } /** + * Use formatLocalDate when you need a date-only string "YYYY-MM-DD" (for displaying stable date values in UI lists, + * sending to APIs, storing in sessionStorage/DB where date-only is required). + * + * * Format a date value into a "YYYY-MM-DD" string with **no timezone shifts**. * * Handles all common input cases: @@ -71,80 +84,18 @@ export function formatLocalDate(input?: string | Date): string { if (input instanceof Date) { if (isNaN(input.getTime())) return ""; - const utcYear = input.getUTCFullYear(); - const utcMonth = input.getUTCMonth(); - const utcDate = input.getUTCDate(); - - const localYear = input.getFullYear(); - const localMonth = input.getMonth(); - const localDate = input.getDate(); - - const useUTC = - utcYear !== localYear || utcMonth !== localMonth || utcDate !== localDate; - - if (useUTC) { - // Use UTC fields → preserves original date of ISO instants - const y = utcYear; - const m = `${utcMonth + 1}`.padStart(2, "0"); - const d = `${utcDate}`.padStart(2, "0"); - return `${y}-${m}-${d}`; - } else { - // Use local fields → preserves local-midnight constructed Dates - const y = localYear; - const m = `${localMonth + 1}`.padStart(2, "0"); - const d = `${localDate}`.padStart(2, "0"); - return `${y}-${m}-${d}`; - } + // ALWAYS use the local calendar fields for Date objects. + // This avoids day-flips when a Date was constructed from an ISO instant + // and the browser's timezone would otherwise show a different calendar day. + const y = input.getFullYear(); + const m = `${input.getMonth() + 1}`.padStart(2, "0"); + const d = `${input.getDate()}`.padStart(2, "0"); + return `${y}-${m}-${d}`; } return ""; } -/** - * Get a Date object representing midnight UTC for a given local date. - * Useful for comparing or storing dates consistently across timezones. - */ -export function toUTCDate(date: Date): Date { - return new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - ); -} - -// ---------- formatUTCDateStringToLocal ---------- -/** - * If `dateStr` is: - * - date-only "YYYY-MM-DD" -> returns it unchanged - * - ISO instant/string -> returns the LOCAL calendar date "YYYY-MM-DD" of that instant - */ -export function formatUTCDateStringToLocal(dateStr: string): string { - if (!dateStr) return ""; - if (isDateOnlyString(dateStr)) return dateStr; - - const d = new Date(dateStr); - if (isNaN(d.getTime())) { - // fallback: strip time part and treat as local date - try { - const maybe = parseLocalDate(dateStr); - return formatLocalDate(maybe); - } catch { - return ""; - } - } - return formatLocalDate(d); // uses local fields of the instant -} - -/** - * Frontend-only normalizer. - * - Returns "YYYY-MM-DD" string representing the calendar date the user expects. - * - This avoids producing ISO instants that confuse frontend display. - * - * Use this for UI display or for sending date-only values to backend if backend accepts date-only. - */ -export function normalizeToISOString(date: Date | string): string { - const parsed = parseLocalDate(date); // returns local-midnight-based Date - return formatLocalDate(parsed); // "YYYY-MM-DD" -} - // ---------- helpers ---------- const MONTH_SHORT = [ "Jan", @@ -167,22 +118,28 @@ function isDateOnlyString(s: string): boolean { // ---------- formatDateToHumanReadable ---------- /** - * Frontend-safe: never lets timezone shift the displayed calendar day. - * - "YYYY-MM-DD" strings are shown exactly. - * - Date objects are shown using their calendar fields (getFullYear/getMonth/getDate). - * - ISO/timestamp strings are parsed and shown using UTC date component so they do not flip on client TZ. + * Frontend-safe human readable formatter. + * + * Rules: + * - If input is a date-only string "YYYY-MM-DD", format it directly (no TZ math). + * - If input is a Date object, use its local calendar fields (getFullYear/getMonth/getDate). + * - If input is any other string (ISO/timestamp), DO NOT call new Date(isoString) directly + * for display. Instead, use parseLocalDate(dateInput) to extract the local calendar day + * (strip time portion) and render that. This prevents off-by-one day drift. + * + * Output example: "Oct 7, 2025" */ export function formatDateToHumanReadable(dateInput?: string | Date): string { if (!dateInput) return "N/A"; - // date-only string -> show as-is + // date-only string -> show as-is using MONTH_SHORT if (typeof dateInput === "string" && isDateOnlyString(dateInput)) { const [y, m, d] = dateInput.split("-"); if (!y || !m || !d) return "Invalid Date"; return `${MONTH_SHORT[parseInt(m, 10) - 1]} ${d}, ${y}`; } - // Handle Date object + // Date object -> use local calendar fields if (dateInput instanceof Date) { if (isNaN(dateInput.getTime())) return "Invalid Date"; const dd = String(dateInput.getDate()); @@ -191,18 +148,26 @@ export function formatDateToHumanReadable(dateInput?: string | Date): string { return `${mm} ${dd}, ${yy}`; } - // Handle ISO/timestamp string (UTC-based to avoid shifting) - const parsed = new Date(dateInput); - if (isNaN(parsed.getTime())) { - console.error("Invalid date input provided:", dateInput); - return "Invalid Date"; + // Other string (likely ISO/timestamp) -> normalize via parseLocalDate + // This preserves the calendar day the user expects (no timezone drift). + if (typeof dateInput === "string") { + try { + const localDate = parseLocalDate(dateInput); + const dd = String(localDate.getDate()); + const mm = MONTH_SHORT[localDate.getMonth()]; + const yy = localDate.getFullYear(); + return `${mm} ${dd}, ${yy}`; + } catch (err) { + console.error("Invalid date input provided:", dateInput, err); + return "Invalid Date"; + } } - const dd = String(parsed.getUTCDate()); - const mm = MONTH_SHORT[parsed.getUTCMonth()]; - const yy = parsed.getUTCFullYear(); - return `${mm} ${dd}, ${yy}`; + + return "Invalid Date"; } + +// ---------------- OCR Date helper -------------------------- /** * Convert any OCR numeric-ish value into a number. * Handles string | number | null | undefined gracefully.