Getting frontend working better.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-10-07 10:27:47 -04:00
parent a92f63edeb
commit 85867fab46
14 changed files with 238 additions and 121 deletions

View File

@@ -34,6 +34,12 @@ RUN yarn install --frozen-lockfile
# Copying the rest of the application code
COPY frontend/ .
# Copy .env for build-time environment variables
COPY .env ./
# Set build-time ARG for log level (optional, for CI/CD)
ARG REACT_APP_FRONTEND_LOG_LEVEL=info
ENV REACT_APP_FRONTEND_LOG_LEVEL=$REACT_APP_FRONTEND_LOG_LEVEL
# Building the TypeScript React app for production
RUN yarn run build
@@ -59,9 +65,11 @@ COPY --from=builder /app/config-overrides.js ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
COPY --from=builder /app/build ./build
COPY --from=builder /app/.env ./
# Set environment variables for runtime
ENV FRONTEND_PORT=${FRONTEND_PORT}
ENV REACT_APP_FRONTEND_LOG_LEVEL=${REACT_APP_FRONTEND_LOG_LEVEL}
# Starting the server to serve the built React app
CMD ["serve", "-s", "build", "-l", "$FRONTEND_PORT"]

View File

@@ -116,6 +116,10 @@ async def validate_no_overlap(
Raises:
ValueError: If booking times overlap with an existing booking.
"""
logger.debug(
f"validate_no_overlap: room_id={room_id}, start_time={start_time},"
f" end_time={end_time}, exclude_booking_id={exclude_booking_id}"
)
conditions: list[ColumnElement[bool]] = [
Booking.room_id == room_id,
func.tstzrange(Booking.start_time, Booking.end_time, "[)").op("&&")(
@@ -123,12 +127,17 @@ async def validate_no_overlap(
),
]
if exclude_booking_id is not None:
logger.debug(f"validate_no_overlap: excluding booking id {exclude_booking_id}")
conditions.append(Booking.id != exclude_booking_id)
overlap_stmt = select(Booking).where(*conditions)
result = await session.scalars(overlap_stmt)
overlapping = result.first()
if overlapping:
logger.warning(f"Attempted to create overlapping booking for room_id {room_id}")
logger.warning(
f"Attempted to create overlapping booking for room_id {room_id}."
f" Overlapping booking id: {overlapping.id if overlapping else None},"
f" exclude_booking_id: {exclude_booking_id}"
)
raise ValueError(
"Booking times overlap with an existing booking for this room."
)
@@ -428,6 +437,11 @@ async def update_booking(
await _remove_old_invitees(session, current, new_invitees)
# Run validations
logger.debug(
"update_booking: calling _do_validation with"
f" exclude_booking_id={booking_id}, new_room_id={new_room_id},"
f" new_start={new_start}, new_end={new_end}, new_invitees={new_invitees}"
)
await _do_validation(
session,
new_room_id,

View File

@@ -30,6 +30,8 @@ services:
extends:
file: compose.yml
service: frontend
environment:
REACT_APP_FRONTEND_LOG_LEVEL: debug
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
@@ -42,6 +44,7 @@ services:
- ./frontend/babel.config.js:/app/babel.config.js
- ./frontend/jest.config.js:/app/jest.config.js
- ./frontend/jest.setup.js:/app/jest.setup.js
- ./.env:/app/.env
command: ["yarn", "start"]
restart: always

View File

@@ -25,7 +25,7 @@ services:
env_file:
- .env
environment:
FRONTEND_LOG_LEVEL: WARNING
REACT_APP_FRONTEND_LOG_LEVEL: WARNING
ports:
- "${FRONTEND_PORT}:${FRONTEND_PORT}"
depends_on:

View File

@@ -17,7 +17,7 @@ import { Box, Button } from "@mui/material";
// Internal imports
// Type-only imports
import type { BookingConfirmationProps } from "../interfaces";
import type { Booking } from "../interfaces";
/**
* BookingConfirmation component
@@ -27,15 +27,23 @@ import type { BookingConfirmationProps } from "../interfaces";
* @param {BookingConfirmationProps} props - Component props
* @returns {JSX.Element} Confirmation UI
*/
interface BookingConfirmationProps {
booking: Booking | null;
deleted?: boolean;
onEdit?: () => void;
onBack: () => void;
}
const BookingConfirmation: React.FC<BookingConfirmationProps> = ({
booking,
deleted = false,
onEdit,
onBack,
}) => {
if (!booking) {
if (!booking && !deleted) {
return null;
}
const { room, start_time, end_time, title, invitees } = booking;
return (
<Box
sx={{
@@ -48,28 +56,42 @@ const BookingConfirmation: React.FC<BookingConfirmationProps> = ({
textAlign: "center",
}}
>
<h2>Booking Confirmed</h2>
<Box mb={2}>
<strong>Room:</strong> {room?.name || "-"}
</Box>
<Box mb={2}>
<strong>Time:</strong> {new Date(start_time).toLocaleString()} {" "}
{new Date(end_time).toLocaleString()}
</Box>
{title && (
<Box mb={2}>
<strong>Title:</strong> {title}
</Box>
)}
{invitees && invitees.length > 0 && (
<Box mb={2}>
<strong>Invitees:</strong> {invitees.join(", ")}
</Box>
{deleted ? (
<>
<h2>Booking Deleted</h2>
<Box mb={2}>
This booking has been deleted and can no longer be edited.
</Box>
</>
) : (
<>
<h2>Booking Confirmed</h2>
<Box mb={2}>
<strong>Room:</strong> {booking?.room?.name || "-"}
</Box>
<Box mb={2}>
<strong>Time:</strong>{" "}
{booking && new Date(booking.start_time).toLocaleString()} {" "}
{booking && new Date(booking.end_time).toLocaleString()}
</Box>
{booking?.title && (
<Box mb={2}>
<strong>Title:</strong> {booking.title}
</Box>
)}
{booking?.invitees && booking.invitees.length > 0 && (
<Box mb={2}>
<strong>Invitees:</strong> {booking.invitees.join(", ")}
</Box>
)}
</>
)}
<Box mt={4} display="flex" justifyContent="center" gap={2}>
<Button variant="outlined" onClick={onEdit}>
Edit Booking
</Button>
{!deleted && onEdit && (
<Button variant="outlined" onClick={onEdit}>
Edit Booking
</Button>
)}
<Button variant="contained" onClick={onBack}>
Back to Bookings
</Button>

View File

@@ -37,6 +37,7 @@ import {
validateInvitees,
validateRoomId,
validateStart,
validateRoomAvailability,
} from "../helpers/validation";
import { roundToStrictlyFutureQuarter } from "../utils/date";
import { useBookings } from "../context/BookingContext";
@@ -46,12 +47,17 @@ import { useUsers } from "../context/UserContext";
// Type-only imports
import type { Booking, ConferenceRoom } from "../interfaces";
// BookingForm component
interface BookingFormProps {
open: boolean;
onClose: () => void;
editBooking?: Booking;
slotInfo?: { room_id?: string | number; start?: string };
onBookingSuccess?: () => void;
/**
* Called when booking is successful or deleted.
* If booking is provided, navigate to confirmation. If not, just close the form.
*/
onBookingSuccess?: (_booking?: Booking) => void;
}
const BookingForm: React.FC<BookingFormProps> = ({
@@ -69,11 +75,11 @@ const BookingForm: React.FC<BookingFormProps> = ({
}, []);
React.useEffect(() => {
logger.debug("[BookingForm] editBooking updated", editBooking);
// logger.debug("[BookingForm] editBooking updated", editBooking);
}, [editBooking]);
React.useEffect(() => {
logger.debug("[BookingForm] slotInfo updated", slotInfo);
// logger.debug("[BookingForm] slotInfo updated", slotInfo);
}, [slotInfo]);
// Contexts
const bookingsContext = useBookings();
@@ -98,20 +104,18 @@ const BookingForm: React.FC<BookingFormProps> = ({
// Room selection
const initialRoomId = useMemo(() => {
if (isEdit && editBooking) {
return getEditBookingRoomId(editBooking);
return Number(getEditBookingRoomId(editBooking));
}
if (slotInfo?.room_id) {
return String(slotInfo.room_id);
return Number(slotInfo.room_id);
}
if (rooms.length > 0) {
return String((rooms[0] as ConferenceRoom).id);
return rooms[0].id;
}
return "";
return 0;
}, [isEdit, editBooking, slotInfo, rooms]);
const [room_id, setRoomId] = useState(initialRoomId);
const currentRoom = rooms.find(
(r: ConferenceRoom) => String(r.id) === String(room_id)
);
const [room_id, setRoomId] = useState<number>(initialRoomId);
const currentRoom = rooms.find((r: ConferenceRoom) => r.id === room_id);
const roomCapacity = currentRoom?.capacity || Infinity;
// Title
@@ -192,7 +196,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
function validateFields() {
const newErrors: { [key: string]: string } = {};
// Room
const roomIdError = validateRoomId(room_id);
const roomIdError = validateRoomId(String(room_id));
if (roomIdError) {
newErrors.room_id = roomIdError;
}
@@ -206,22 +210,24 @@ const BookingForm: React.FC<BookingFormProps> = ({
if (endError) {
newErrors.end = endError;
}
// Overlap
const roomBookings = bookings.filter(
(b: Booking) => String(b.room_id) === String(room_id)
// Overlap (use helper)
const roomBookings = bookings.filter((b: Booking) => b.room_id === room_id);
const overlapErrors = validateRoomAvailability(
room_id,
start,
end,
roomBookings,
isEdit,
isEdit ? editBooking ?? null : null
);
const overlaps = roomBookings.some((b: Booking) => {
if (isEdit && b.id === editBooking?.id) {
return false;
}
return (
new Date(start) < new Date(b.end_time) &&
new Date(end) > new Date(b.start_time)
);
});
if (overlaps) {
newErrors.start = "Start/end time overlaps another booking.";
newErrors.end = "Start/end time overlaps another booking.";
if (overlapErrors.roomError) {
newErrors.room_id = overlapErrors.roomError;
}
if (overlapErrors.startError) {
newErrors.start = overlapErrors.startError;
}
if (overlapErrors.endError) {
newErrors.end = overlapErrors.endError;
}
// Invitees
const inviteesError = validateInvitees(invitees, roomCapacity);
@@ -261,7 +267,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
validateFields();
}
function handleRoomChange(event: SelectChangeEvent) {
setRoomId(String(event.target.value));
setRoomId(Number(event.target.value));
validateFields();
}
@@ -278,7 +284,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
let bookingResult;
if (!isEdit) {
bookingResult = await createBooking({
room_id,
room_id: Number(room_id),
start_time: start,
end_time: end,
title,
@@ -286,17 +292,20 @@ const BookingForm: React.FC<BookingFormProps> = ({
});
} else if (editBooking) {
const updatePayload = {
room_id,
room_id: Number(room_id),
start_time: start,
end_time: end,
title,
invitees,
};
logger.info("[BookingForm] updateBooking payload:", updatePayload);
bookingResult = await updateBooking(editBooking.id, updatePayload);
// logger.info("[BookingForm] updateBooking payload:", updatePayload);
bookingResult = await updateBooking(
Number(editBooking.id),
updatePayload
);
}
if (onBookingSuccess && bookingResult) {
onBookingSuccess();
onBookingSuccess(bookingResult);
}
onClose();
} catch (err: unknown) {
@@ -551,7 +560,7 @@ const BookingForm: React.FC<BookingFormProps> = ({
try {
await deleteBooking(editBooking.id);
if (onBookingSuccess) {
onBookingSuccess();
onBookingSuccess(); // No booking param: just close
}
onClose();
} catch (err: unknown) {

View File

@@ -9,7 +9,7 @@ export const mockRoom: ConferenceRoom = {
};
export const mockBooking: Booking = {
id: "1",
id: 1,
room_id: 1,
start_time: new Date(Date.now() + 24 * 3600000 + 3 * 3600000).toISOString(),
end_time: new Date(Date.now() + 24 * 3600000 + 4 * 3600000).toISOString(),

View File

@@ -22,15 +22,35 @@
* @property {string} BUSINESS_END - Business end time (e.g., '18:00')
*/
export const ENV = {
BACKEND_PROTOCOL: process.env.BACKEND_PROTOCOL || "http",
BACKEND_HOST: process.env.BACKEND_HOST || "localhost",
BACKEND_PORT: process.env.BACKEND_PORT || "8000",
FRONTEND_LOG_LEVEL: process.env.FRONTEND_LOG_LEVEL || "info",
NODE_ENV: process.env.FRONTEND_ENVIRONMENT || "development",
BACKEND_PROTOCOL:
process.env.REACT_APP_BACKEND_PROTOCOL ||
process.env.BACKEND_PROTOCOL ||
"http",
BACKEND_HOST:
process.env.REACT_APP_BACKEND_HOST ||
process.env.BACKEND_HOST ||
"localhost",
BACKEND_PORT:
process.env.REACT_APP_BACKEND_PORT || process.env.BACKEND_PORT || "8000",
FRONTEND_LOG_LEVEL:
process.env.REACT_APP_FRONTEND_LOG_LEVEL ||
process.env.FRONTEND_LOG_LEVEL ||
"info",
NODE_ENV:
process.env.REACT_APP_FRONTEND_ENVIRONMENT ||
process.env.FRONTEND_ENVIRONMENT ||
"development",
DEFAULT_BOOKING_INTERVAL_MINUTES:
Number(process.env.DEFAULT_BOOKING_INTERVAL_MINUTES) || 30,
BUSINESS_START: process.env.BUSINESS_START || "08:00",
BUSINESS_END: process.env.BUSINESS_END || "18:00",
Number(
process.env.REACT_APP_DEFAULT_BOOKING_INTERVAL_MINUTES ||
process.env.DEFAULT_BOOKING_INTERVAL_MINUTES
) || 30,
BUSINESS_START:
process.env.REACT_APP_BUSINESS_START ||
process.env.BUSINESS_START ||
"08:00",
BUSINESS_END:
process.env.REACT_APP_BUSINESS_END || process.env.BUSINESS_END || "18:00",
};
/**

View File

@@ -114,12 +114,14 @@ export const BookingProvider = ({ children }: { children: ReactNode }) => {
invitees?: string[];
};
const booking: Booking = {
id: String(singleData.booking_id),
id:
typeof singleData.booking_id === "number"
? singleData.booking_id
: parseInt(singleData.booking_id as string, 10),
room_id:
typeof singleData.room_id === "number" ||
typeof singleData.room_id === "string"
typeof singleData.room_id === "number"
? singleData.room_id
: String(singleData.room_id),
: parseInt(singleData.room_id as string, 10),
start_time: String(singleData.start_time),
end_time: String(singleData.end_time),
title:

View File

@@ -104,7 +104,7 @@ export function validateInvitees(
* @returns Error object with roomError, startError, endError
*/
export function validateRoomAvailability(
room_id: string,
room_id: number,
start: string,
end: string,
roomBookings: Booking[],
@@ -113,7 +113,7 @@ export function validateRoomAvailability(
): { roomError?: string; startError?: string; endError?: string } {
// Filter out the current booking if editing
const filteredBookings =
isEdit && editBooking?.id
isEdit && editBooking?.id !== undefined
? roomBookings.filter((b: Booking) => b.id !== editBooking.id)
: roomBookings;
let startConflict = false;

View File

@@ -94,8 +94,8 @@ export interface User {
}
export interface Booking {
id: string;
room_id?: number | string;
id: number;
room_id?: number;
room?: { name?: string };
start_time: string;
end_time: string;

View File

@@ -40,7 +40,7 @@ import { useRooms } from "../context/RoomContext";
import { useBookings } from "../context/BookingContext";
// Utility/helper imports
import { logger } from "../utils/logger";
// import { logger } from "../utils/logger";
import { getRoomClass } from "../helpers/calendar";
import { getInviteeName } from "../helpers/bookingList";
@@ -100,9 +100,7 @@ const BookingPage: React.FC = () => {
return [];
}
const mappedEvents = bookings.map((booking: Booking) => {
const room = rooms.find(
(r: ConferenceRoom) => String(r.id) === String(booking.room_id)
);
const room = rooms.find((r: ConferenceRoom) => r.id === booking.room_id);
let roomName = room?.name;
if (!roomName || roomName.trim() === "") {
if (
@@ -110,11 +108,6 @@ const BookingPage: React.FC = () => {
booking.room?.name.trim() !== ""
) {
roomName = booking.room?.name;
} else if (
typeof booking.room_id === "string" &&
booking.room_id.trim() !== ""
) {
roomName = booking.room_id as string;
} else if (typeof booking.room_id === "number") {
roomName = String(booking.room_id);
} else {
@@ -127,11 +120,7 @@ const BookingPage: React.FC = () => {
: [];
const start = new Date(booking.start_time);
const end = new Date(booking.end_time);
const roomClass = getRoomClass(
typeof booking.room_id === "number"
? booking.room_id
: parseInt(booking.room_id as string, 10)
);
const roomClass = getRoomClass(booking.room_id ?? 0);
return {
id: booking.id,
title: booking.title ?? "",
@@ -160,7 +149,7 @@ const BookingPage: React.FC = () => {
/** Set default room when rooms load */
useEffect(() => {
logger.debug("[BookingPage] Rooms updated", rooms);
// logger.debug("[BookingPage] Rooms updated", rooms);
if (rooms.length > 0 && !selectedRoomId) {
setSelectedRoomId(rooms[0].id);
}
@@ -289,18 +278,18 @@ const BookingPage: React.FC = () => {
start = new Date(slotInfo.start);
end = new Date(start.getTime() + 30 * 60000);
}
logger.info("[BookingPage] Slot selected", {
start,
end,
room_id: selectedRoomId,
});
// logger.info("[BookingPage] Slot selected", {
// start,
// end,
// room_id: selectedRoomId,
// });
setFormSlot({ start, end, room_id: selectedRoomId });
setEditBooking(null);
setFormOpen(true);
};
const handleSelectEvent = (event: BookingEvent) => {
logger.info("[BookingPage] Event selected", event);
// logger.info("[BookingPage] Event selected", event);
const originalBooking = event.originalBooking;
const eventIdStr = String(event.id);
if (originalBooking && originalBooking.id) {
@@ -323,8 +312,14 @@ const BookingPage: React.FC = () => {
});
} else {
setEditBooking({
id: String(event.id),
room_id: event.room_id,
id:
typeof event.id === "number"
? event.id
: parseInt(event.id as string, 10),
room_id:
typeof event.room_id === "number"
? event.room_id
: parseInt(event.room_id as string, 10),
room: event.room_name ? { name: event.room_name } : undefined,
start_time: event.start ? event.start.toString() : "",
end_time: event.end ? event.end.toString() : "",
@@ -343,7 +338,7 @@ const BookingPage: React.FC = () => {
const navigate = useNavigate();
const handleFormClose = (refresh = false, booking?: Booking) => {
logger.info("[BookingPage] Booking form closed", { refresh, booking });
// logger.info("[BookingPage] Booking form closed", { refresh, booking });
setFormOpen(false);
setFormSlot(null);
setEditBooking(null);
@@ -372,7 +367,7 @@ const BookingPage: React.FC = () => {
variant="outlined"
sx={{ ml: 2 }}
onClick={() => {
logger.info("[BookingPage] Opening room modal");
// logger.info("[BookingPage] Opening room modal");
setRoomModalOpen(true);
}}
disabled={!selectedRoomId}
@@ -383,7 +378,7 @@ const BookingPage: React.FC = () => {
<RoomDetailsModal
open={roomModalOpen}
onClose={() => {
logger.info("[BookingPage] Closing room modal");
// logger.info("[BookingPage] Closing room modal");
setRoomModalOpen(false);
}}
room={

View File

@@ -7,7 +7,8 @@
*/
// External imports
import React, { useState } from "react";
// External imports
import React, { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
// MUI imports
@@ -19,6 +20,7 @@ import { useQuery } from "@tanstack/react-query";
// Internal component imports
import BookingConfirmation from "../components/BookingConfirmation";
import BookingForm from "../components/BookingForm";
// import { logger } from "../utils/logger";
// API imports
import { connectRoomsAvailabilityStream } from "../apis/sse";
@@ -51,17 +53,46 @@ const ConfirmationPage: React.FC = () => {
const [booking, setBooking] = useState<Booking | null>(
location.state?.booking || null
);
// Track deleted state
const [deleted, setDeleted] = useState(false);
useEffect(() => {
// If no booking, redirect to booking page
if (!booking) {
navigate("/booking");
return;
}
const bookingId = location.state?.booking?.id || booking?.id;
if (!bookingId) {
return;
}
// Always get latest booking from context
const latestBooking = bookings.find((b: Booking) => b.id === bookingId);
if (latestBooking) {
setBooking(latestBooking);
}
const es = connectRoomsAvailabilityStream({
onMessage: (data) => {
if (Array.isArray(data.bookings)) {
const updated = data.bookings.find(
(b: Booking) => b.id === bookingId
);
if (updated) {
setBooking(updated);
}
}
},
onError() {
// Optionally handle SSE errors
},
});
React.useEffect(() => {
// [ConfirmationPage] Mounted log removed
return () => {
// [ConfirmationPage] Unmounted log removed
es.close();
};
}, []);
React.useEffect(() => {
// [ConfirmationPage] booking updated log removed
}, [booking]);
}, [bookings, location.state, booking?.id, navigate]);
/**
* Subscribe to SSE for live booking status updates.
*/
@@ -82,9 +113,7 @@ const ConfirmationPage: React.FC = () => {
(b: Booking) => b.id === bookingId
);
if (updated) {
setBooking((prev: Booking | null) =>
prev ? { ...prev, ...updated } : updated
);
setBooking(updated);
}
}
},
@@ -115,8 +144,7 @@ const ConfirmationPage: React.FC = () => {
});
if (!booking) {
// If no booking, redirect to booking page
navigate("/booking");
// Already handled by useEffect, but return null to avoid rendering
return null;
}
if (roomsLoading) {
@@ -143,7 +171,7 @@ const ConfirmationPage: React.FC = () => {
minHeight: "100vh",
}}
>
{editing ? (
{editing && !deleted ? (
<BookingForm
open={true}
onClose={handleFormClose}
@@ -156,14 +184,27 @@ const ConfirmationPage: React.FC = () => {
start: booking.start_time,
end: booking.end_time,
}}
onBookingSuccess={() => {
setEditing(false);
// Optionally update booking state
onBookingSuccess={(resultBooking?: Booking) => {
if (!resultBooking) {
// Deleted
// logger.info("[ConfirmationPage] Booking deleted", booking);
setDeleted(true);
setEditing(false);
setBooking(null);
} else {
// logger.info(
// "[ConfirmationPage] Booking success (created or edited)",
// resultBooking
// );
setEditing(false);
setBooking(resultBooking);
}
}}
/>
) : (
<BookingConfirmation
booking={booking}
deleted={deleted}
onEdit={handleEdit}
onBack={handleBack}
/>

View File

@@ -11,7 +11,10 @@ import log, { LogLevelDesc } from "loglevel";
* Gets the log level from environment and sets it for loglevel.
* @type {LogLevelDesc}
*/
const level: LogLevelDesc = (process.env.FRONTEND_LOG_LEVEL ||
// Use REACT_APP_ prefix for Create React App compatibility
const level: LogLevelDesc = (process.env.REACT_APP_FRONTEND_LOG_LEVEL ||
process.env.FRONTEND_LOG_LEVEL ||
"info") as LogLevelDesc;
log.setLevel(level);