feat(appointment-timeslot)
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
: new Date(parsedStoredData.date) // in case it’s a stringified date or timestamp
|
: parseLocalDate(new Date()),
|
||||||
: 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|
||||||
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}`;
|
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 {
|
||||||
|
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";
|
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.
|
* Convert any OCR numeric-ish value into a number.
|
||||||
* Handles string | number | null | undefined gracefully.
|
* Handles string | number | null | undefined gracefully.
|
||||||
|
|||||||
Reference in New Issue
Block a user