Start time grid #62
@ -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) => {
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user