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[] = [];
for (let hour = 8; hour <= 18; hour++) {
for (let hour = 8; hour <= 21; hour++) {
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;
// Only allow start times up to 21:00 (9 PM)
if (timeStr > "21:00") continue;
const hour12 = hour > 12 ? hour - 12 : hour;
const period = hour >= 12 ? "PM" : "AM";
@@ -585,6 +585,35 @@ export default function AppointmentsPage() {
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
const getAppointmentAtSlot = (timeSlot: TimeSlot, staffId: number) => {
if (!processedAppointments || processedAppointments.length === 0)
@@ -800,12 +829,14 @@ export default function AppointmentsPage() {
staffIndex,
appointment,
staff,
rowSpan = 1,
}: {
timeSlot: TimeSlot;
staffId: number;
staffIndex: number;
appointment: ScheduledAppointment | undefined;
staff: Staff;
rowSpan?: number;
}) {
const blocked = !isWithinOfficeHours(timeSlot.time, staffIndex);
@@ -841,6 +872,7 @@ export default function AppointmentsPage() {
className={`px-1 py-1 border relative h-14 ${
isOver && canDrop ? "bg-green-100" : blocked ? "bg-gray-100" : ""
}`}
rowSpan={rowSpan > 1 ? rowSpan : undefined}
title={blocked ? "Outside office hours — click to override" : undefined}
>
{appointment ? (
@@ -1573,19 +1605,22 @@ export default function AppointmentsPage() {
<td className="border px-2 py-1 text-xs text-gray-600 font-medium">
{timeSlot.displayTime}
</td>
{staffMembers.map((staff, staffIndex) => (
<DroppableTimeSlot
key={`${timeSlot.time}-${staff.id}`}
timeSlot={timeSlot}
staffId={Number(staff.id)}
staffIndex={staffIndex}
appointment={getAppointmentAtSlot(
timeSlot,
Number(staff.id)
)}
staff={staff}
/>
))}
{staffMembers.map((staff, staffIndex) => {
if (coveredSlots.has(`${timeSlot.time}-${staff.id}`)) return null;
const apt = getAppointmentAtSlot(timeSlot, Number(staff.id));
const span = apt ? getSlotSpan(apt) : 1;
return (
<DroppableTimeSlot
key={`${timeSlot.time}-${staff.id}`}
timeSlot={timeSlot}
staffId={Number(staff.id)}
staffIndex={staffIndex}
appointment={apt}
staff={staff}
rowSpan={span}
/>
);
})}
</tr>
))}
</tbody>