feat: extend schedule to 9 PM and make appointments span multiple rows by duration

- Time slots now run 8:00 AM – 9:00 PM (was 8:00 AM – 6:00 PM)
- Appointments visually span the correct number of 15-min rows based on startTime/endTime using HTML rowSpan
- Covered rows are skipped so the grid layout stays consistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-05 22:17:00 -04:00
parent 800008792a
commit fea0dd4d59

View File

@@ -323,15 +323,15 @@ export default function AppointmentsPage() {
); );
}; };
// Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments // Generate time slots from 8:00 AM to 9:00 PM in 15-minute increments
const timeSlots: TimeSlot[] = []; const timeSlots: TimeSlot[] = [];
for (let hour = 8; hour <= 18; hour++) { for (let hour = 8; hour <= 21; hour++) {
for (let minute = 0; minute < 60; minute += 15) { for (let minute = 0; minute < 60; minute += 15) {
const pad = (n: number) => n.toString().padStart(2, "0"); const pad = (n: number) => n.toString().padStart(2, "0");
const timeStr = `${pad(hour)}:${pad(minute)}`; const timeStr = `${pad(hour)}:${pad(minute)}`;
// Only allow start times up to 18:00 (last start for 30-min appointment) // Only allow start times up to 21:00 (9 PM)
if (timeStr > "18:00") continue; if (timeStr > "21: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";
@@ -585,6 +585,35 @@ export default function AppointmentsPage() {
return processed; return processed;
}); });
// Compute how many 15-min slots an appointment occupies
const getSlotSpan = (apt: ScheduledAppointment): number => {
const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5);
const endStr = (typeof apt.endTime === "string" ? apt.endTime : formatLocalTime(apt.endTime)).substring(0, 5);
const startParts = startStr.split(":");
const endParts = endStr.split(":");
const startH = parseInt(startParts[0] ?? "0", 10);
const startM = parseInt(startParts[1] ?? "0", 10);
const endH = parseInt(endParts[0] ?? "0", 10);
const endM = parseInt(endParts[1] ?? "0", 10);
const diff = (endH * 60 + endM) - (startH * 60 + startM);
return Math.max(1, Math.round(diff / 15));
};
// Slots that are "continued" rows of a multi-slot appointment (should not render a td)
const coveredSlots = new Set<string>();
(processedAppointments ?? []).forEach((apt) => {
const span = getSlotSpan(apt);
if (span <= 1) return;
const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5);
const [startH, startM] = startStr.split(":").map(Number);
for (let i = 1; i < span; i++) {
const totalMin = (startH ?? 0) * 60 + (startM ?? 0) + i * 15;
const h = Math.floor(totalMin / 60).toString().padStart(2, "0");
const m = (totalMin % 60).toString().padStart(2, "0");
coveredSlots.add(`${h}:${m}-${apt.staffId}`);
}
});
// Check if appointment exists at a specific time slot and staff // Check if appointment exists at a specific time slot and staff
const getAppointmentAtSlot = (timeSlot: TimeSlot, staffId: number) => { const getAppointmentAtSlot = (timeSlot: TimeSlot, staffId: number) => {
if (!processedAppointments || processedAppointments.length === 0) if (!processedAppointments || processedAppointments.length === 0)
@@ -800,12 +829,14 @@ export default function AppointmentsPage() {
staffIndex, staffIndex,
appointment, appointment,
staff, staff,
rowSpan = 1,
}: { }: {
timeSlot: TimeSlot; timeSlot: TimeSlot;
staffId: number; staffId: number;
staffIndex: number; staffIndex: number;
appointment: ScheduledAppointment | undefined; appointment: ScheduledAppointment | undefined;
staff: Staff; staff: Staff;
rowSpan?: number;
}) { }) {
const blocked = !isWithinOfficeHours(timeSlot.time, staffIndex); const blocked = !isWithinOfficeHours(timeSlot.time, staffIndex);
@@ -841,6 +872,7 @@ export default function AppointmentsPage() {
className={`px-1 py-1 border relative h-14 ${ className={`px-1 py-1 border relative h-14 ${
isOver && canDrop ? "bg-green-100" : blocked ? "bg-gray-100" : "" isOver && canDrop ? "bg-green-100" : blocked ? "bg-gray-100" : ""
}`} }`}
rowSpan={rowSpan > 1 ? rowSpan : undefined}
title={blocked ? "Outside office hours — click to override" : undefined} title={blocked ? "Outside office hours — click to override" : undefined}
> >
{appointment ? ( {appointment ? (
@@ -1573,19 +1605,22 @@ export default function AppointmentsPage() {
<td className="border px-2 py-1 text-xs text-gray-600 font-medium"> <td className="border px-2 py-1 text-xs text-gray-600 font-medium">
{timeSlot.displayTime} {timeSlot.displayTime}
</td> </td>
{staffMembers.map((staff, staffIndex) => ( {staffMembers.map((staff, staffIndex) => {
<DroppableTimeSlot if (coveredSlots.has(`${timeSlot.time}-${staff.id}`)) return null;
key={`${timeSlot.time}-${staff.id}`} const apt = getAppointmentAtSlot(timeSlot, Number(staff.id));
timeSlot={timeSlot} const span = apt ? getSlotSpan(apt) : 1;
staffId={Number(staff.id)} return (
staffIndex={staffIndex} <DroppableTimeSlot
appointment={getAppointmentAtSlot( key={`${timeSlot.time}-${staff.id}`}
timeSlot, timeSlot={timeSlot}
Number(staff.id) staffId={Number(staff.id)}
)} staffIndex={staffIndex}
staff={staff} appointment={apt}
/> staff={staff}
))} rowSpan={span}
/>
);
})}
</tr> </tr>
))} ))}
</tbody> </tbody>