Doing more refactoring.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-09-08 16:02:38 -04:00
parent 707dc76c8b
commit 3df1326727
10 changed files with 294 additions and 184 deletions

View File

@@ -9,6 +9,19 @@
import React, { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
formatLocalDateTimeInput,
roundToStrictlyFutureQuarter,
} from "../utils/date";
import { getEditBookingRoomId, formatBookingError } from "../helpers/booking";
import {
validateRoomId,
validateStart,
validateEnd,
validateInvitees,
validateInviteeEmail,
validateRoomAvailability,
} from "../helpers/validation";
import {
getRoomBookings,
createBooking,
@@ -53,51 +66,7 @@ interface BookingFormProps {
// BookingForm: form for selecting room, date, time, title, and invitees
// Format Date as 'YYYY-MM-DDTHH:mm' for datetime-local input
function formatLocalDateTimeInput(date: Date): string {
function pad(n: number): string {
return n < 10 ? "0" + n : n.toString();
}
const yyyy = date.getFullYear();
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const min = pad(date.getMinutes());
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
}
// Extract room_id from editBooking
function getEditBookingRoomId(editBooking: any): string {
// Try FullCalendar event object structure first
let id =
editBooking?._def?.extendedProps?.resource?.room_id ??
editBooking?._def?.extendedProps?.resource?.id ??
editBooking?.resource?.room_id ??
editBooking?.room_id ??
editBooking?.room?.id ??
(typeof editBooking === "object" && "roomId" in editBooking
? editBooking.roomId
: undefined) ??
(typeof editBooking === "object" &&
"room" in editBooking &&
editBooking.room &&
"id" in editBooking.room
? editBooking.room.id
: undefined) ??
"";
if (!id && typeof editBooking === "object") {
for (const k of Object.keys(editBooking)) {
if (
k.toLowerCase().includes("room") &&
typeof editBooking[k] === "number"
) {
id = editBooking[k];
break;
}
}
}
return id !== undefined && id !== null ? String(id) : "";
}
// ...existing code...
const BookingForm: React.FC<BookingFormProps> = ({
open,
@@ -413,88 +382,55 @@ const BookingForm: React.FC<BookingFormProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room_id, start, end, isEdit, editBooking?.id]);
// Helper: round a Date up to the next strictly future quarter hour
function roundToStrictlyFutureQuarter(dt: Date): Date {
const copy = new Date(dt.getTime());
copy.setSeconds(0, 0);
let add = 15 - (copy.getMinutes() % 15);
if (add === 0) add = 15;
copy.setMinutes(copy.getMinutes() + add);
return copy;
}
// ...existing code...
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [submitError, setSubmitError] = useState<string | null>(null);
function validate() {
// Independent field validation
function validateFields() {
const newErrors: { [key: string]: string } = {};
if (!room_id) newErrors.room_id = "Room is required.";
if (!start) newErrors.start = "Start time is required.";
if (!end) newErrors.end = "End time is required.";
if (start && end && new Date(start) >= new Date(end))
newErrors.end = "End time must be after start time.";
if (invitees.length > roomCapacity) {
newErrors.invitees = `The number of invitees exceeds the room's capacity (${roomCapacity}).`;
}
// Room ID
const roomIdError = validateRoomId(room_id);
if (roomIdError) newErrors.room_id = roomIdError;
// Start
const startError = validateStart(start);
if (startError) newErrors.start = startError;
// End
const endError = validateEnd(end, start);
if (endError) newErrors.end = endError;
// Invitees
const inviteesError = validateInvitees(invitees, roomCapacity);
if (inviteesError) newErrors.invitees = inviteesError;
// Individual invitee emails
let hasInviteeError = false;
invitees.forEach((email, idx) => {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
newErrors[`invitee_${idx}`] = `Invalid email: ${email}`;
const emailError = validateInviteeEmail(email);
if (emailError) {
newErrors[`invitee_${idx}`] = emailError;
hasInviteeError = true;
}
});
if (hasInviteeError) {
newErrors.invitees = "One or more invitees have invalid email addresses.";
}
// Overlap validation for start/end time (selected room)
// Room availability
if (room_id && start && end && roomBookings.length > 0) {
// Filter out the current booking if editing
const filteredBookings =
isEdit && editBooking?.id
? roomBookings.filter((b: any) => b.id !== editBooking.id)
: roomBookings;
// Only mark as conflict if there is actual overlap (not back-to-back)
let startConflict = false;
let endConflict = false;
filteredBookings.forEach((b: any) => {
// Overlap if start < b.end_time && end > b.start_time
if (
new Date(start) < new Date(b.end_time) &&
new Date(end) > new Date(b.start_time)
) {
// Mark which field overlaps
if (
new Date(start) < new Date(b.end_time) &&
new Date(start) >= new Date(b.start_time)
) {
startConflict = true;
}
if (
new Date(end) > new Date(b.start_time) &&
new Date(end) <= new Date(b.end_time)
) {
endConflict = true;
}
}
});
if (startConflict || endConflict) {
newErrors.room_id = "Room is unavailable for the selected time.";
} else {
// If no conflict, ensure room_id error is cleared
if (newErrors.room_id) delete newErrors.room_id;
}
if (startConflict) {
newErrors.start = "Start time overlaps with another booking.";
}
if (endConflict) {
newErrors.end = "End time overlaps with another booking.";
}
const availabilityErrors = validateRoomAvailability(
room_id,
start,
end,
roomBookings,
isEdit,
editBooking
);
if (availabilityErrors.roomError)
newErrors.room_id = availabilityErrors.roomError;
if (availabilityErrors.startError)
newErrors.start = availabilityErrors.startError;
if (availabilityErrors.endError)
newErrors.end = availabilityErrors.endError;
}
if (start && new Date(start) < new Date()) {
newErrors.start = "Start time cannot be in the past.";
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
logger.warn("[BookingForm] Validation failed", newErrors);
@@ -533,7 +469,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
// Move this effect after roomBookings is defined
useEffect(() => {
validate();
validateFields();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room_id, invitees, start, end, roomBookings]);
@@ -542,7 +478,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
if (submitting) return;
setSubmitting(true);
setSubmitError(null);
if (!validate()) {
if (!validateFields()) {
setSubmitting(false);
return;
}
@@ -663,18 +599,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
}
};
// Helper to format booking errors for user-friendly messages
function formatBookingError(detail: string): string {
if (!detail) return "An unknown error occurred.";
const lower = detail.toLowerCase();
if (lower.includes("overlap")) {
return "The selected room is already booked for the chosen time. Please pick a different time or room.";
}
if (lower.includes("capacity") || lower.includes("attendee")) {
return "The number of invitees exceeds the room's capacity. Please remove some invitees or choose a larger room.";
}
return detail;
}
// ...existing code...
const handleDelete = async () => {
if (!isEdit) return;

View File

@@ -10,6 +10,11 @@
import React from "react";
import { List, Typography, ListItemButton } from "@mui/material";
import type { BookingListProps } from "../schemas";
import {
sortBookingsByStartTime,
formatBookingTime,
getInviteeName,
} from "../helpers/bookingList";
/**
* BookingList component.
@@ -43,35 +48,11 @@ const BookingList: React.FC<BookingListProps> = ({ bookings, onSelect }) => {
);
}
// Sort bookings by start_time ascending (oldest first)
const sortedBookings = [...bookings].sort(
(a, b) =>
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
const sortedBookings = sortBookingsByStartTime(bookings);
return (
<List sx={{ mt: 0.5 }}>
{sortedBookings.map((booking) => {
// Format time as h:mm AM/PM
const formatTime = (dateStr: string) => {
const d = new Date(dateStr);
return d.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
// Robust invitee name extraction
const getInviteeName = (inv: any) => {
if (typeof inv === "string") return inv;
if (inv && typeof inv === "object") {
if (typeof inv.name === "string") return inv.name;
if (typeof inv.username === "string") return inv.username;
if (typeof inv.displayName === "string") return inv.displayName;
// Check for nested user object with name
if (inv.user && typeof inv.user.name === "string")
return inv.user.name;
}
return "";
};
const inviteeNames = booking.invitees
? booking.invitees.map(getInviteeName).filter(Boolean)
: [];
@@ -85,9 +66,9 @@ const BookingList: React.FC<BookingListProps> = ({ bookings, onSelect }) => {
style={{ fontWeight: 600, color: "#23272f", fontSize: "1rem" }}
>
{booking.start_time && booking.end_time
? `${formatTime(booking.start_time)} ${formatTime(
booking.end_time
)}`
? `${formatBookingTime(
booking.start_time
)}  ${formatBookingTime(booking.end_time)}`
: "Time not set"}
</span>
{inviteeNames.length > 0 && (

View File

@@ -19,6 +19,7 @@ import { EventInput } from "@fullcalendar/core";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import "./CalendarView.css";
import { getEventDisplayText, getRoomClass } from "../helpers/calendar";
/**
* Props for CalendarView.
@@ -53,23 +54,8 @@ const CalendarView: React.FC<CalendarViewProps> = ({
extendedProps?.resource?.roomId ||
"Unknown";
const invitees = extendedProps?.resource?.invitees || [];
// Format start time as HH:mm
const startTime = start
? new Date(start).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: "-";
// Display: '<start time> - <title>' or '<start time> - <room name>'
const displayText =
title && title.trim() !== ""
? `${startTime} - ${title}`
: `${startTime} - ${room}`;
// Assign CSS class for room color
const roomIdx = event.extendedProps?.resource?.room_id
? parseInt(event.extendedProps.resource.room_id, 10) % 8
: 0;
const roomClass = `room-color-${roomIdx}`;
const displayText = getEventDisplayText(title, start, room);
const roomClass = getRoomClass(extendedProps?.resource?.room_id);
return (
<Tooltip
title={

View File

@@ -24,6 +24,7 @@ import {
import MeetingRoomIcon from "@mui/icons-material/MeetingRoom";
import { FC } from "react";
import type { RoomListProps } from "../schemas";
import { getRoomListItemStyles, getRoomSecondaryText } from "../helpers/room";
/**
* Renders a list of conference rooms with their details.
@@ -99,21 +100,11 @@ const RoomList: FC<RoomListProps> = ({
}}
aria-label={`Select room ${room.name}`}
data-testid={`room-item-${room.id}`}
sx={
room.id === selectedRoomId
? {
backgroundColor: "#ececec !important",
fontWeight: 700,
color: "#23272f",
border: "1px solid #23272f",
borderRadius: 4,
}
: { color: "#23272f" }
}
sx={getRoomListItemStyles(room.id, selectedRoomId)}
>
<ListItemText
primary={room.name}
secondary={`Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`}
secondary={getRoomSecondaryText(room)}
/>
</ListItemButton>
) : (
@@ -124,7 +115,7 @@ const RoomList: FC<RoomListProps> = ({
>
<ListItemText
primary={room.name}
secondary={`Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`}
secondary={getRoomSecondaryText(room)}
/>
</ListItem>
)

View File

@@ -0,0 +1,45 @@
// booking.ts
// Utility functions for booking-related logic
export function getEditBookingRoomId(editBooking: any): string {
let id =
editBooking?._def?.extendedProps?.resource?.room_id ??
editBooking?._def?.extendedProps?.resource?.id ??
editBooking?.resource?.room_id ??
editBooking?.room_id ??
editBooking?.room?.id ??
(typeof editBooking === "object" && "roomId" in editBooking
? editBooking.roomId
: undefined) ??
(typeof editBooking === "object" &&
"room" in editBooking &&
editBooking.room &&
"id" in editBooking.room
? editBooking.room.id
: undefined) ??
"";
if (!id && typeof editBooking === "object") {
for (const k of Object.keys(editBooking)) {
if (
k.toLowerCase().includes("room") &&
typeof editBooking[k] === "number"
) {
id = editBooking[k];
break;
}
}
}
return id !== undefined && id !== null ? String(id) : "";
}
export function formatBookingError(detail: string): string {
if (!detail) return "An unknown error occurred.";
const lower = detail.toLowerCase();
if (lower.includes("overlap")) {
return "The selected room is already booked for the chosen time. Please pick a different time or room.";
}
if (lower.includes("capacity") || lower.includes("attendee")) {
return "The number of invitees exceeds the room's capacity. Please remove some invitees or choose a larger room.";
}
return detail;
}

View File

@@ -0,0 +1,29 @@
// bookingList.ts
// Utility functions for BookingList component
export function sortBookingsByStartTime(bookings: any[]): any[] {
return [...bookings].sort(
(a, b) =>
new Date(a.start_time).getTime() - new Date(b.start_time).getTime()
);
}
export function formatBookingTime(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export function getInviteeName(inv: any): string {
if (typeof inv === "string") return inv;
if (inv && typeof inv === "object") {
if (typeof inv.name === "string") return inv.name;
if (typeof inv.username === "string") return inv.username;
if (typeof inv.displayName === "string") return inv.displayName;
if (inv.user && typeof inv.user.name === "string") return inv.user.name;
}
return "";
}

View File

@@ -0,0 +1,20 @@
// calendar.ts
// Utility functions for CalendarView component
export function getEventDisplayText(
title: string,
start: Date,
room: string
): string {
const startTime = start
? start.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: "-";
return title && title.trim() !== ""
? `${startTime} - ${title}`
: `${startTime} - ${room}`;
}
export function getRoomClass(roomId: any): string {
const idx = roomId ? parseInt(roomId, 10) % 8 : 0;
return `room-color-${idx}`;
}

View File

@@ -0,0 +1,19 @@
// room.ts
// Utility functions for RoomList component
export function getRoomSecondaryText(room: any): string {
return `Location: ${room.location} | Capacity: ${room.capacity} | Equipment: ${room.equipment}`;
}
export function getRoomListItemStyles(roomId: any, selectedRoomId: any): any {
if (roomId === selectedRoomId) {
return {
backgroundColor: "#ececec !important",
fontWeight: 700,
color: "#23272f",
border: "1px solid #23272f",
borderRadius: 4,
};
}
return { color: "#23272f" };
}

View File

@@ -0,0 +1,91 @@
// validation.ts
// Independent field validation helpers for BookingForm
export function validateRoomId(room_id: string | undefined): string | null {
if (!room_id) return "Room is required.";
return null;
}
export function validateStart(start: string | undefined): string | null {
if (!start) return "Start time is required.";
if (start && new Date(start) < new Date())
return "Start time cannot be in the past.";
return null;
}
export function validateEnd(
end: string | undefined,
start?: string
): string | null {
if (!end) return "End time is required.";
if (start && end && new Date(start) >= new Date(end))
return "End time must be after start time.";
if (end && new Date(end) < new Date())
return "End time cannot be in the past.";
return null;
}
export function validateInvitees(
invitees: string[],
roomCapacity: number
): string | null {
if (invitees.length > roomCapacity) {
return `The number of invitees exceeds the room's capacity (${roomCapacity}).`;
}
return null;
}
export function validateInviteeEmail(email: string): string | null {
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return `Invalid email: ${email}`;
}
return null;
}
export function validateRoomAvailability(
room_id: string,
start: string,
end: string,
roomBookings: any[],
isEdit: boolean,
editBooking: any
): { roomError?: string; startError?: string; endError?: string } {
// Filter out the current booking if editing
const filteredBookings =
isEdit && editBooking?.id
? roomBookings.filter((b: any) => b.id !== editBooking.id)
: roomBookings;
let startConflict = false;
let endConflict = false;
filteredBookings.forEach((b: any) => {
if (
new Date(start) < new Date(b.end_time) &&
new Date(end) > new Date(b.start_time)
) {
if (
new Date(start) < new Date(b.end_time) &&
new Date(start) >= new Date(b.start_time)
) {
startConflict = true;
}
if (
new Date(end) > new Date(b.start_time) &&
new Date(end) <= new Date(b.end_time)
) {
endConflict = true;
}
}
});
const errors: { roomError?: string; startError?: string; endError?: string } =
{};
if (startConflict || endConflict) {
errors.roomError = "Room is unavailable for the selected time.";
}
if (startConflict) {
errors.startError = "Start time overlaps with another booking.";
}
if (endConflict) {
errors.endError = "End time overlaps with another booking.";
}
return errors;
}

View File

@@ -0,0 +1,23 @@
// date.ts
// Utility functions for date and time formatting and manipulation
export function formatLocalDateTimeInput(date: Date): string {
function pad(n: number): string {
return n < 10 ? "0" + n : n.toString();
}
const yyyy = date.getFullYear();
const mm = pad(date.getMonth() + 1);
const dd = pad(date.getDate());
const hh = pad(date.getHours());
const min = pad(date.getMinutes());
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
}
export function roundToStrictlyFutureQuarter(dt: Date): Date {
const copy = new Date(dt.getTime());
copy.setSeconds(0, 0);
let add = 15 - (copy.getMinutes() % 15);
if (add === 0) add = 15;
copy.setMinutes(copy.getMinutes() + add);
return copy;
}