Start time grid #62

Merged
stne3960 merged 30 commits from start_time_grid into main 2026-01-16 14:17:09 +01:00
4 changed files with 130 additions and 72 deletions
Showing only changes of commit 89f3ba2dec - Show all commits

View File

@ -101,7 +101,7 @@ export default function BookingForm<T>({
)} )}
<Dropdown <Dropdown
options={endTimeOptions} options={endTimeOptions}
getOptionValue={(o) => o.value} getOptionValue={(o) => o.value.toString().slice(0, 5)}
getOptionLabel={(o) => o.label} getOptionLabel={(o) => o.label}
value={endTime} value={endTime}
onChange={(v) => { onChange={(v) => {

View File

@ -1,17 +1,18 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Temporal } from "temporal-polyfill";
import Choicebox from "../Choicebox/Choicebox"; import Choicebox from "../Choicebox/Choicebox";
import InlineModal from "../InlineModal/InlineModal"; import InlineModal from "../InlineModal/InlineModal";
export interface TimeSlot { export interface TimeSlot {
time: string; time: Temporal.PlainTime;
label: string; label: string;
unavailable?: boolean; unavailable?: boolean;
} }
export interface StartTimeGridProps { export interface StartTimeGridProps {
timeSlots: TimeSlot[]; timeSlots: TimeSlot[];
selectedTime: string | null; selectedTime: Temporal.PlainTime | null;
onChange: (time: string) => void; onChange: (time: Temporal.PlainTime) => void;
heading?: string; heading?: string;
status?: string; status?: string;
children?: ReactNode; children?: ReactNode;
@ -26,7 +27,9 @@ export default function StartTimeGrid({
children, children,
}: StartTimeGridProps) { }: StartTimeGridProps) {
// Track which column was selected for arrow positioning // Track which column was selected for arrow positioning
const selectedIndex = timeSlots.findIndex((s) => s.time === selectedTime); const selectedIndex = timeSlots.findIndex(
(s) => selectedTime && s.time.equals(selectedTime),
);
const selectedColumn = selectedIndex >= 0 ? selectedIndex % 2 : 0; const selectedColumn = selectedIndex >= 0 ? selectedIndex % 2 : 0;
// Split into rows of 2 // Split into rows of 2
@ -48,22 +51,27 @@ export default function StartTimeGrid({
{rows.map((row, rowIndex) => ( {rows.map((row, rowIndex) => (
<div key={rowIndex}> <div key={rowIndex}>
<div className="grid grid-cols-2 gap-(--spacing-md)"> <div className="grid grid-cols-2 gap-(--spacing-md)">
{row.map((slot) => ( {row.map((slot) => {
<Choicebox const timeString = slot.time.toString().slice(0, 5);
key={slot.time} return (
primaryText={slot.time} <Choicebox
secondaryText={slot.label} key={timeString}
unavailable={slot.unavailable} primaryText={timeString}
name="start-time" secondaryText={slot.label}
value={slot.time} unavailable={slot.unavailable}
checked={selectedTime === slot.time} name="start-time"
onChange={() => onChange(slot.time)} value={timeString}
/> checked={
))} selectedTime !== null && slot.time.equals(selectedTime)
}
onChange={() => onChange(slot.time)}
/>
);
})}
</div> </div>
{selectedTime && {selectedTime &&
row.some((slot) => slot.time === selectedTime) && row.some((slot) => slot.time.equals(selectedTime)) &&
children && ( children && (
<InlineModal <InlineModal
arrowPosition={selectedColumn === 0 ? "left" : "right"} arrowPosition={selectedColumn === 0 ? "left" : "right"}

View File

@ -30,31 +30,64 @@ export interface BookingContext {
maxDaysInFuture: number; maxDaysInFuture: number;
} }
/** Internal booking with Temporal types */
interface ParsedBooking {
start: Temporal.PlainDateTime;
end: Temporal.PlainDateTime;
}
/** Internal room with Temporal types */
interface ParsedRoom {
id: string;
name: string;
capacity: number;
bookings: ParsedBooking[];
}
/** Internal context with Temporal types */
interface ParsedContext {
rooms: ParsedRoom[];
maxBookableHours: number;
earliestBookingTime: Temporal.PlainTime;
latestBookingTime: Temporal.PlainTime;
minimumParticipants: number;
maxDaysInFuture: number;
}
/** Parse API context to Temporal types */
function parseContext(context: BookingContext): ParsedContext {
return {
rooms: context.rooms.map((room) => ({
...room,
bookings: room.bookings.map((b) => ({
start: Temporal.PlainDateTime.from(b.start),
end: Temporal.PlainDateTime.from(b.end),
})),
})),
maxBookableHours:
Temporal.Duration.from(context.maxBookableLength).hours || 4,
earliestBookingTime: Temporal.PlainTime.from(context.earliestBookingTime),
latestBookingTime: Temporal.PlainTime.from(context.latestBookingTime),
minimumParticipants: context.minimumParticipants,
maxDaysInFuture: context.maxDaysInFuture,
};
}
/** A selectable time slot in the booking grid */ /** A selectable time slot in the booking grid */
export interface TimeSlot { export interface TimeSlot {
/** Time of day, e.g. "10:00" */ /** Time of day */
time: string; time: Temporal.PlainTime;
/** Display label, e.g. "Upp till 2 h" */ /** Display label, e.g. "Upp till 2 h" */
label: string; label: string;
/** True if no rooms are available at this time */ /** True if no rooms are available at this time */
unavailable?: boolean; unavailable?: boolean;
} }
/** Parse ISO 8601 duration (e.g. "PT4H") to hours */ function minutesToTime(minutes: number): Temporal.PlainTime {
function parseDurationToHours(duration: string): number {
return Temporal.Duration.from(duration).hours || 4;
}
function timeToMinutes(time: string): number {
const t = Temporal.PlainTime.from(time);
return t.hour * 60 + t.minute;
}
function minutesToTime(minutes: number): string {
return Temporal.PlainTime.from({ return Temporal.PlainTime.from({
hour: Math.floor(minutes / 60), hour: Math.floor(minutes / 60),
minute: minutes % 60, minute: minutes % 60,
}).toString().slice(0, 5); });
} }
/** Check if a date string is today */ /** Check if a date string is today */
@ -74,13 +107,15 @@ function getCurrentTimeMinutes(): number {
* Past time slots are marked as unavailable. * Past time slots are marked as unavailable.
*/ */
function calculateTimeSlots( function calculateTimeSlots(
context: BookingContext, context: ParsedContext,
date: string, date: string,
roomId?: string, roomId?: string,
): TimeSlot[] { ): TimeSlot[] {
const maxHours = parseDurationToHours(context.maxBookableLength); const maxHours = context.maxBookableHours;
const earliestMinutes = timeToMinutes(context.earliestBookingTime); const earliestMinutes =
const latestMinutes = timeToMinutes(context.latestBookingTime); context.earliestBookingTime.hour * 60 + context.earliestBookingTime.minute;
const latestMinutes =
context.latestBookingTime.hour * 60 + context.latestBookingTime.minute;
const roomsToCheck = roomId const roomsToCheck = roomId
? context.rooms.filter((r) => r.id === roomId) ? context.rooms.filter((r) => r.id === roomId)
: context.rooms; : context.rooms;
@ -105,10 +140,12 @@ function calculateTimeSlots(
let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60); let availableMinutes = Math.min(latestMinutes - mins, maxHours * 60);
for (const booking of room.bookings) { for (const booking of room.bookings) {
const bookingDateTime = Temporal.PlainDateTime.from(booking.start); if (booking.start.toPlainDate().toString() !== date) continue;
if (bookingDateTime.toPlainDate().toString() !== date) continue; const bookingStartTime = booking.start.toPlainTime();
const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); const bookingEndTime = booking.end.toPlainTime();
const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); const bookingStart =
bookingStartTime.hour * 60 + bookingStartTime.minute;
const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute;
if (mins >= bookingStart && mins < bookingEnd) { if (mins >= bookingStart && mins < bookingEnd) {
availableMinutes = 0; availableMinutes = 0;
@ -139,15 +176,16 @@ function calculateTimeSlots(
* Used when no specific room is filtered. * Used when no specific room is filtered.
*/ */
function findBestRoom( function findBestRoom(
context: BookingContext, context: ParsedContext,
date: string, date: string,
startTime: string, startTime: Temporal.PlainTime,
): Room | null { ): ParsedRoom | null {
const maxHours = parseDurationToHours(context.maxBookableLength); const maxHours = context.maxBookableHours;
const latestMinutes = timeToMinutes(context.latestBookingTime); const latestMinutes =
const startMinutes = timeToMinutes(startTime); context.latestBookingTime.hour * 60 + context.latestBookingTime.minute;
const startMinutes = startTime.hour * 60 + startTime.minute;
let bestRoom: Room | null = null; let bestRoom: ParsedRoom | null = null;
let bestDuration = 0; let bestDuration = 0;
for (const room of context.rooms) { for (const room of context.rooms) {
@ -158,10 +196,11 @@ function findBestRoom(
let roomAvailable = true; let roomAvailable = true;
for (const booking of room.bookings) { for (const booking of room.bookings) {
const bookingDateTime = Temporal.PlainDateTime.from(booking.start); if (booking.start.toPlainDate().toString() !== date) continue;
if (bookingDateTime.toPlainDate().toString() !== date) continue; const bookingStartTime = booking.start.toPlainTime();
const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); const bookingEndTime = booking.end.toPlainTime();
const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); const bookingStart = bookingStartTime.hour * 60 + bookingStartTime.minute;
const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute;
if (startMinutes >= bookingStart && startMinutes < bookingEnd) { if (startMinutes >= bookingStart && startMinutes < bookingEnd) {
roomAvailable = false; roomAvailable = false;
@ -186,8 +225,8 @@ function findBestRoom(
/** An end time option with value and display label */ /** An end time option with value and display label */
export interface EndTimeOption { export interface EndTimeOption {
/** Time value, e.g. "12:30" */ /** Time value */
value: string; value: Temporal.PlainTime;
/** Display label, e.g. "12:30 · 1,5 h" */ /** Display label, e.g. "12:30 · 1,5 h" */
label: string; label: string;
} }
@ -210,24 +249,26 @@ function formatDuration(minutes: number): string {
* Returns 30-minute increments up to the next booking or max duration. * Returns 30-minute increments up to the next booking or max duration.
*/ */
function calculateEndTimeOptions( function calculateEndTimeOptions(
context: BookingContext, context: ParsedContext,
date: string, date: string,
startTime: string, startTime: Temporal.PlainTime,
roomId: string, roomId: string,
): EndTimeOption[] { ): EndTimeOption[] {
const maxHours = parseDurationToHours(context.maxBookableLength); const maxHours = context.maxBookableHours;
const latestMinutes = timeToMinutes(context.latestBookingTime); const latestMinutes =
const startMinutes = timeToMinutes(startTime); context.latestBookingTime.hour * 60 + context.latestBookingTime.minute;
const startMinutes = startTime.hour * 60 + startTime.minute;
const room = context.rooms.find((r) => r.id === roomId); const room = context.rooms.find((r) => r.id === roomId);
if (!room) return []; if (!room) return [];
let maxEndMinutes = Math.min(startMinutes + maxHours * 60, latestMinutes); let maxEndMinutes = Math.min(startMinutes + maxHours * 60, latestMinutes);
for (const booking of room.bookings) { for (const booking of room.bookings) {
const bookingDateTime = Temporal.PlainDateTime.from(booking.start); if (booking.start.toPlainDate().toString() !== date) continue;
if (bookingDateTime.toPlainDate().toString() !== date) continue; const bookingStartTime = booking.start.toPlainTime();
const bookingStart = timeToMinutes(bookingDateTime.toPlainTime().toString()); const bookingEndTime = booking.end.toPlainTime();
const bookingEnd = timeToMinutes(Temporal.PlainDateTime.from(booking.end).toPlainTime().toString()); const bookingStart = bookingStartTime.hour * 60 + bookingStartTime.minute;
const bookingEnd = bookingEndTime.hour * 60 + bookingEndTime.minute;
// If start time is during a booking, no options available // If start time is during a booking, no options available
if (startMinutes >= bookingStart && startMinutes < bookingEnd) { if (startMinutes >= bookingStart && startMinutes < bookingEnd) {
@ -245,7 +286,7 @@ function calculateEndTimeOptions(
const duration = formatDuration(mins - startMinutes); const duration = formatDuration(mins - startMinutes);
options.push({ options.push({
value: time, value: time,
label: `${time} · ${duration}`, label: `${time.toString().slice(0, 5)} · ${duration}`,
}); });
} }
return options; return options;
@ -274,7 +315,11 @@ function calculateEndTimeOptions(
*/ */
export function useGroupRoomBooking(context: BookingContext, date: string) { export function useGroupRoomBooking(context: BookingContext, date: string) {
const [roomFilter, setRoomFilter] = useState<string>(""); const [roomFilter, setRoomFilter] = useState<string>("");
const [selectedTime, setSelectedTime] = useState<string | null>(null); const [selectedTime, setSelectedTime] = useState<Temporal.PlainTime | null>(
null,
);
const parsedContext = useMemo(() => parseContext(context), [context]);
const roomOptions = useMemo( const roomOptions = useMemo(
() => [ () => [
@ -285,24 +330,29 @@ export function useGroupRoomBooking(context: BookingContext, date: string) {
); );
const timeSlots = useMemo( const timeSlots = useMemo(
() => calculateTimeSlots(context, date, roomFilter || undefined), () => calculateTimeSlots(parsedContext, date, roomFilter || undefined),
[context, date, roomFilter], [parsedContext, date, roomFilter],
); );
const bookedRoom = useMemo(() => { const bookedRoom = useMemo(() => {
if (roomFilter) { if (roomFilter) {
return context.rooms.find((r) => r.id === roomFilter) || null; return parsedContext.rooms.find((r) => r.id === roomFilter) || null;
} }
if (selectedTime) { if (selectedTime) {
return findBestRoom(context, date, selectedTime); return findBestRoom(parsedContext, date, selectedTime);
} }
return null; return null;
}, [context, date, roomFilter, selectedTime]); }, [parsedContext, date, roomFilter, selectedTime]);
const endTimeOptions = useMemo(() => { const endTimeOptions = useMemo(() => {
if (!selectedTime || !bookedRoom) return []; if (!selectedTime || !bookedRoom) return [];
return calculateEndTimeOptions(context, date, selectedTime, bookedRoom.id); return calculateEndTimeOptions(
}, [context, date, selectedTime, bookedRoom]); parsedContext,
date,
selectedTime,
bookedRoom.id,
);
}, [parsedContext, date, selectedTime, bookedRoom]);
const clearSelection = () => setSelectedTime(null); const clearSelection = () => setSelectedTime(null);

View File

@ -68,7 +68,7 @@ export default function StartTimeGridSection() {
onChange={booking.setSelectedTime} onChange={booking.setSelectedTime}
> >
<BookingForm <BookingForm
key={booking.selectedTime} key={booking.selectedTime?.toString()}
endTimeOptions={booking.endTimeOptions} endTimeOptions={booking.endTimeOptions}
participantOptions={peopleOptions} participantOptions={peopleOptions}
getOptionValue={getPersonValue} getOptionValue={getPersonValue}