feat(appointment-timeslot)

This commit is contained in:
2025-10-07 04:10:01 +05:30
parent 441258e54a
commit b07fce6106
4 changed files with 77 additions and 117 deletions

View File

@@ -188,9 +188,12 @@ router.post(
// 2. Attempt to find the next available slot // 2. Attempt to find the next available slot
let [hour, minute] = originalStartTime.split(":").map(Number); let [hour, minute] = originalStartTime.split(":").map(Number);
const pad = (n: number) => n.toString().padStart(2, "0"); 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) { while (`${pad(hour)}:${pad(minute)}` <= MAX_END_TIME) {
const currentStartTime = `${pad(hour)}:${pad(minute)}`; const currentStartTime = `${pad(hour)}:${pad(minute)}`;
@@ -211,7 +214,7 @@ router.post(
); );
if (!staffConflict) { if (!staffConflict) {
const endMinute = minute + 30; const endMinute = minute + APPT_DURATION_MINUTES;
let endHour = hour + Math.floor(endMinute / 60); let endHour = hour + Math.floor(endMinute / 60);
let realEndMinute = endMinute % 60; let realEndMinute = endMinute % 60;
@@ -255,11 +258,11 @@ router.post(
return res.status(201).json(responseData); return res.status(201).json(responseData);
} }
// Move to next 30-min slot // Move to next STEP_MINUTES slot
minute += 30; minute += STEP_MINUTES;
if (minute >= 60) { if (minute >= 60) {
hour += 1; hour += Math.floor(minute / 60);
minute = 0; minute = minute % 60;
} }
} }

View File

@@ -34,7 +34,7 @@ import {
UpdateAppointment, UpdateAppointment,
} from "@repo/db/types"; } from "@repo/db/types";
import { DateInputField } from "@/components/ui/dateInputField"; import { DateInputField } from "@/components/ui/dateInputField";
import { parseLocalDate } from "@/utils/dateUtils"; import { formatLocalDate, parseLocalDate } from "@/utils/dateUtils";
import { import {
PatientSearch, PatientSearch,
SearchCriteria, SearchCriteria,
@@ -106,10 +106,7 @@ export function AppointmentForm({
userId: user?.id, userId: user?.id,
patientId: appointment.patientId, patientId: appointment.patientId,
title: appointment.title, title: appointment.title,
date: date: parseLocalDate(appointment.date),
typeof appointment.date === "string"
? parseLocalDate(appointment.date)
: appointment.date,
startTime: appointment.startTime || "09:00", // Default "09:00" startTime: appointment.startTime || "09:00", // Default "09:00"
endTime: appointment.endTime || "09:30", // Default "09:30" endTime: appointment.endTime || "09:30", // Default "09:30"
type: appointment.type, type: appointment.type,
@@ -125,11 +122,8 @@ export function AppointmentForm({
userId: user?.id, userId: user?.id,
patientId: Number(parsedStoredData.patientId), patientId: Number(parsedStoredData.patientId),
date: parsedStoredData.date date: parsedStoredData.date
? typeof parsedStoredData.date === "string" ? parseLocalDate(parsedStoredData.date)
? parseLocalDate(parsedStoredData.date) : parseLocalDate(new Date()),
: new Date(parsedStoredData.date) // in case its a stringified date or timestamp
: new Date(),
title: parsedStoredData.title || "", title: parsedStoredData.title || "",
startTime: parsedStoredData.startTime, startTime: parsedStoredData.startTime,
endTime: parsedStoredData.endTime, endTime: parsedStoredData.endTime,
@@ -260,11 +254,7 @@ export function AppointmentForm({
if (parsedStoredData.staff) if (parsedStoredData.staff)
form.setValue("staffId", parsedStoredData.staff); form.setValue("staffId", parsedStoredData.staff);
if (parsedStoredData.date) { if (parsedStoredData.date) {
const parsedDate = form.setValue("date", parseLocalDate(parsedStoredData.date));
typeof parsedStoredData.date === "string"
? parseLocalDate(parsedStoredData.date)
: new Date(parsedStoredData.date);
form.setValue("date", parsedDate);
} }
// ---- patient prefill: check main cache, else fetch once ---- // ---- patient prefill: check main cache, else fetch once ----
@@ -388,7 +378,7 @@ export function AppointmentForm({
: `Appointment with ${selectedStaff?.name}`; : `Appointment with ${selectedStaff?.name}`;
} }
const formattedDate = data.date.toLocaleDateString("en-CA"); const formattedDate = formatLocalDate(data.date);
onSubmit({ onSubmit({
...data, ...data,
@@ -575,11 +565,7 @@ export function AppointmentForm({
)} )}
/> />
<DateInputField <DateInputField control={form.control} name="date" label="Date" />
control={form.control}
name="date"
label="Date"
/>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField

View File

@@ -5,6 +5,7 @@ import {
parseLocalDate, parseLocalDate,
formatLocalDate, formatLocalDate,
formatLocalTime, formatLocalTime,
formatDateToHumanReadable,
} from "@/utils/dateUtils"; } from "@/utils/dateUtils";
import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal"; import { AddAppointmentModal } from "@/components/appointments/add-appointment-modal";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -162,14 +163,19 @@ export default function AppointmentsPage() {
color: colors[index % colors.length] || "bg-gray-400", color: colors[index % colors.length] || "bg-gray-400",
})); }));
// Generate time slots from 8:00 AM to 6:00 PM in 30-minute increments // Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments
const timeSlots: TimeSlot[] = []; const timeSlots: TimeSlot[] = [];
for (let hour = 8; hour <= 18; hour++) { for (let hour = 8; hour <= 18; hour++) {
for (let minute = 0; minute < 60; minute += 30) { for (let minute = 0; minute < 60; minute += 15) {
const pad = (n: number) => 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 hour12 = hour > 12 ? hour - 12 : hour;
const period = hour >= 12 ? "PM" : "AM"; const period = hour >= 12 ? "PM" : "AM";
const timeStr = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; const displayTime = `${hour12}:${pad(minute)} ${period}`;
const displayTime = `${hour12}:${minute.toString().padStart(2, "0")} ${period}`;
timeSlots.push({ time: timeStr, displayTime }); timeSlots.push({ time: timeStr, displayTime });
} }
} }
@@ -399,7 +405,7 @@ export default function AppointmentsPage() {
patientName, patientName,
staffId, staffId,
status: apt.status ?? null, status: apt.status ?? null,
date: formatLocalDate(parseLocalDate(apt.date)), date: formatLocalDate(apt.date),
startTime: normalizedStart, startTime: normalizedStart,
endTime: normalizedEnd, endTime: normalizedEnd,
} as ScheduledAppointment; } as ScheduledAppointment;
@@ -827,7 +833,7 @@ export default function AppointmentsPage() {
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate {selectedDate
? selectedDate.toLocaleDateString() ? formatDateToHumanReadable(selectedDate)
: "Pick a date"} : "Pick a date"}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -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. * 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. * 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 { 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**. * Format a date value into a "YYYY-MM-DD" string with **no timezone shifts**.
* *
* Handles all common input cases: * Handles all common input cases:
@@ -71,80 +84,18 @@ export function formatLocalDate(input?: string | Date): string {
if (input instanceof Date) { if (input instanceof Date) {
if (isNaN(input.getTime())) return ""; if (isNaN(input.getTime())) return "";
const utcYear = input.getUTCFullYear(); // ALWAYS use the local calendar fields for Date objects.
const utcMonth = input.getUTCMonth(); // This avoids day-flips when a Date was constructed from an ISO instant
const utcDate = input.getUTCDate(); // and the browser's timezone would otherwise show a different calendar day.
const y = input.getFullYear();
const localYear = input.getFullYear(); const m = `${input.getMonth() + 1}`.padStart(2, "0");
const localMonth = input.getMonth(); const d = `${input.getDate()}`.padStart(2, "0");
const localDate = input.getDate(); return `${y}-${m}-${d}`;
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}`;
}
} }
return ""; 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 ---------- // ---------- helpers ----------
const MONTH_SHORT = [ const MONTH_SHORT = [
"Jan", "Jan",
@@ -167,22 +118,28 @@ function isDateOnlyString(s: string): boolean {
// ---------- formatDateToHumanReadable ---------- // ---------- formatDateToHumanReadable ----------
/** /**
* Frontend-safe: never lets timezone shift the displayed calendar day. * Frontend-safe human readable formatter.
* - "YYYY-MM-DD" strings are shown exactly. *
* - Date objects are shown using their calendar fields (getFullYear/getMonth/getDate). * Rules:
* - ISO/timestamp strings are parsed and shown using UTC date component so they do not flip on client TZ. * - 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 { export function formatDateToHumanReadable(dateInput?: string | Date): string {
if (!dateInput) return "N/A"; 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)) { if (typeof dateInput === "string" && isDateOnlyString(dateInput)) {
const [y, m, d] = dateInput.split("-"); const [y, m, d] = dateInput.split("-");
if (!y || !m || !d) return "Invalid Date"; if (!y || !m || !d) return "Invalid Date";
return `${MONTH_SHORT[parseInt(m, 10) - 1]} ${d}, ${y}`; return `${MONTH_SHORT[parseInt(m, 10) - 1]} ${d}, ${y}`;
} }
// Handle Date object // Date object -> use local calendar fields
if (dateInput instanceof Date) { if (dateInput instanceof Date) {
if (isNaN(dateInput.getTime())) return "Invalid Date"; if (isNaN(dateInput.getTime())) return "Invalid Date";
const dd = String(dateInput.getDate()); const dd = String(dateInput.getDate());
@@ -191,18 +148,26 @@ export function formatDateToHumanReadable(dateInput?: string | Date): string {
return `${mm} ${dd}, ${yy}`; return `${mm} ${dd}, ${yy}`;
} }
// Handle ISO/timestamp string (UTC-based to avoid shifting) // Other string (likely ISO/timestamp) -> normalize via parseLocalDate
const parsed = new Date(dateInput); // This preserves the calendar day the user expects (no timezone drift).
if (isNaN(parsed.getTime())) { if (typeof dateInput === "string") {
console.error("Invalid date input provided:", dateInput); try {
return "Invalid Date"; 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()]; return "Invalid Date";
const yy = parsed.getUTCFullYear();
return `${mm} ${dd}, ${yy}`;
} }
// ---------------- OCR Date helper --------------------------
/** /**
* Convert any OCR numeric-ish value into a number. * Convert any OCR numeric-ish value into a number.
* Handles string | number | null | undefined gracefully. * Handles string | number | null | undefined gracefully.