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.