Enhance real-time space status updates and handling

- Improve WebSocket connection reliability with updated transport options.
- Introduce logic to prioritize real-time updates over initial data for live views.
- Add logging for better debugging of WebSocket events and data propagation.
- Update UI components (`SpaceCard`, `FloorplanViewer`) to avoid overwrites from stale data.
- Refactor date handling for consistent behavior across components.
main
Pau Costa Ferrer 2026-03-28 16:27:23 +01:00
parent 1b3c3044ca
commit 453fe68e42
7 changed files with 82 additions and 31 deletions

View File

@ -4,11 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users.">
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/events/events.gateway.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/SpaceCard.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/floor/[id]/+page.svelte" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -130,7 +132,7 @@
<updated>1774643312599</updated> <updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" /> <workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" /> <workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="4842000" /> <workItem from="1774706361502" duration="5157000" />
</task> </task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services."> <task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" /> <option name="closed" value="true" />
@ -212,7 +214,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774710987605</updated> <updated>1774710987605</updated>
</task> </task>
<option name="localTasksCounter" value="11" /> <task id="LOCAL-00011" summary="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users.">
<option name="closed" value="true" />
<created>1774711356754</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1774711356754</updated>
</task>
<option name="localTasksCounter" value="12" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -230,6 +240,7 @@
<MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." /> <MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation." />
<MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation. Update `UsersController` and `UsersService` to support user role updates, password changes, and invite token generation.&#10;- Create DTOs (`RegisterDto`, `UpdateUserDto`) for input validation in user management endpoints.&#10;- Extend `AuthService` and `AuthController` with invite-based registration logic.&#10;- Update admin layout to include &quot;Users&quot; link for easy access to user management." /> <MESSAGE value="Add user management and registration features with invite-based onboarding&#10;&#10;- Implement `+page.svelte` for admin user management, allowing role updates, password resets, and invite link generation. Update `UsersController` and `UsersService` to support user role updates, password changes, and invite token generation.&#10;- Create DTOs (`RegisterDto`, `UpdateUserDto`) for input validation in user management endpoints.&#10;- Extend `AuthService` and `AuthController` with invite-based registration logic.&#10;- Update admin layout to include &quot;Users&quot; link for easy access to user management." />
<MESSAGE value="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting." /> <MESSAGE value="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting." />
<option name="LAST_COMMIT_MESSAGE" value="Add &quot;My Bookings&quot; page and enable reservation updates and cancellations&#10;&#10;- Implement `+page.svelte` for managing user reservations with real-time updates.&#10;- Add API endpoints for updating and canceling reservations with enhanced validation and authorization.&#10;- Introduce `UpdateReservationDto` for refined reservation update handling.&#10;- Update `reservations.service.ts` to handle overlapping checks, time adjustments, and admin actions.&#10;- Extend layout to include a link to the &quot;My Bookings&quot; page.&#10;- Add `date-fns` library for date formatting." /> <MESSAGE value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." />
<option name="LAST_COMMIT_MESSAGE" value="Add building and floor management with admin editor modal&#10;&#10;- Implement `BuildingEditorModal.svelte` for creating, editing, and deleting buildings and floors.&#10;- Add `CreateBuildingDto`, `UpdateBuildingDto`, `CreateFloorDto`, and `UpdateFloorDto` for validation.&#10;- Extend `SpacesService` and `SpacesController` with endpoints for building and floor CRUD operations.&#10;- Update Prisma schema to include building descriptions and image URLs, and floor names.&#10;- Enhance dashboard with building management functionality for admin users." />
</component> </component>
</project> </project>

View File

@ -27,7 +27,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@SubscribeMessage('join_floor') @SubscribeMessage('join_floor')
handleJoinFloor(client: Socket, floorId: string) { handleJoinFloor(client: Socket, floorId: string) {
client.join(floorId); client.join(floorId);
console.log(`Client ${client.id} joined floor: ${floorId}`); console.log(`Client ${client.id} joined floor: ${floorId}. Rooms:`, Array.from(client.rooms));
} }
@SubscribeMessage('leave_floor') @SubscribeMessage('leave_floor')
@ -37,6 +37,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
} }
emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean, reservationData?: any) { emitSpaceStatusChanged(floorId: string, spaceId: string, isOccupied: boolean, reservationData?: any) {
console.log(`Emitting space_status_changed to floor ${floorId}:`, { spaceId, isOccupied });
this.server.to(floorId).emit('space_status_changed', { this.server.to(floorId).emit('space_status_changed', {
spaceId, spaceId,
isOccupied, isOccupied,

View File

@ -90,6 +90,8 @@ export class ReservationsService {
} }
}); });
console.log('Emitted space_status_changed for new reservation', space.floorId, spaceId);
return reservation; return reservation;
} }
@ -134,6 +136,8 @@ export class ReservationsService {
false, false,
); );
console.log('Emitted space_status_changed for cancelled reservation', reservation.space.floorId, reservation.spaceId);
return { message: 'Reservation cancelled' }; return { message: 'Reservation cancelled' };
} }

View File

@ -91,6 +91,7 @@
}) })
}); });
console.log('Booking created successfully');
dispatch('close'); dispatch('close');
} catch (e: any) { } catch (e: any) {
error = e.message || "Failed to create booking"; error = e.message || "Failed to create booking";

View File

@ -16,11 +16,18 @@
let containerWidth = 800; let containerWidth = 800;
let containerHeight = 600; let containerHeight = 600;
// Track if we have received any real-time updates to avoid overwriting them with stale initial status
let realtimeUpdates: Record<string, any> = {};
$: { $: {
// Initialize space status from the current state // Initialize space status from the current state (e.g. from API)
spaces.forEach(s => { spaces.forEach(s => {
// For desks, we check if there's any reservation for the selected date // Don't overwrite if we already have a real-time update for today
// For meeting rooms, we check if there's a reservation overlapping with the current/selected time if (isToday && realtimeUpdates[s.id]) {
spaceStatus[s.id] = realtimeUpdates[s.id];
return;
}
const activeReservation = s.reservations && s.reservations.length > 0 ? s.reservations[0] : null; const activeReservation = s.reservations && s.reservations.length > 0 ? s.reservations[0] : null;
spaceStatus[s.id] = { spaceStatus[s.id] = {
isOccupied: !!activeReservation, isOccupied: !!activeReservation,
@ -74,23 +81,37 @@
containerHeight += 40; containerHeight += 40;
} }
// Disable real-time updates if viewing a different date than today $: isToday = new Date(selectedDate.getTime()).setHours(0,0,0,0) === new Date().setHours(0,0,0,0);
$: isToday = new Date().toDateString() === selectedDate.toDateString();
onMount(() => { onMount(() => {
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
socket = io(socketUrl); socket = io(socketUrl, {
transports: ['websocket'], // Prefer WebSockets
});
socket.on('connect', () => { socket.on('connect', () => {
console.log('Connected to WebSocket for floor:', floorId);
socket.emit('join_floor', floorId); socket.emit('join_floor', floorId);
}); });
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean, reservation?: any }) => { socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean, reservation?: any }) => {
if (!isToday) return; // Ignore real-time updates for historical/future views console.log('Space status changed received:', data);
spaceStatus[data.spaceId] = { if (!isToday) {
console.log('Ignoring real-time update: not today', {
selectedDate: selectedDate.toDateString(),
now: new Date().toDateString()
});
return;
}
const update = {
isOccupied: data.isOccupied, isOccupied: data.isOccupied,
reservation: data.reservation reservation: data.reservation
}; };
// Save to realtimeUpdates to prevent overwriting by API re-fetches
realtimeUpdates[data.spaceId] = update;
spaceStatus[data.spaceId] = update;
spaceStatus = { ...spaceStatus }; // Trigger reactivity spaceStatus = { ...spaceStatus }; // Trigger reactivity
}); });
}); });

View File

@ -12,24 +12,35 @@
let isOccupied = initialStatus; let isOccupied = initialStatus;
let socket: Socket; let socket: Socket;
$: isToday = new Date().toDateString() === selectedDate.toDateString(); // Track if we have received any real-time updates for this space
let realtimeOccupied: boolean | null = null;
$: isToday = new Date(selectedDate.getTime()).setHours(0,0,0,0) === new Date().setHours(0,0,0,0);
$: if (initialStatus !== undefined) { $: if (initialStatus !== undefined) {
isOccupied = initialStatus; if (isToday && realtimeOccupied !== null) {
isOccupied = realtimeOccupied;
} else {
isOccupied = initialStatus;
}
} }
onMount(() => { onMount(() => {
// Connect to WebSocket using the environment variable for the backend URL. // Connect to WebSocket using the environment variable for the backend URL.
const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; const socketUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
socket = io(socketUrl); socket = io(socketUrl, {
transports: ['websocket'],
});
socket.on('connect', () => { socket.on('connect', () => {
console.log('Connected to WebSocket'); console.log('Connected to WebSocket (SpaceCard)');
socket.emit('join_floor', floorId); socket.emit('join_floor', floorId);
}); });
socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean }) => { socket.on('space_status_changed', (data: { spaceId: string, isOccupied: boolean }) => {
if (data.spaceId === spaceId && isToday) { if (data.spaceId === spaceId && isToday) {
console.log('Space status updated for:', spaceId, data.isOccupied);
realtimeOccupied = data.isOccupied;
isOccupied = data.isOccupied; isOccupied = data.isOccupied;
} }
}); });

View File

@ -10,31 +10,33 @@
let floor: any = null; let floor: any = null;
let loading = true; let loading = true;
let selectedDateString = new Date().toISOString().split('T')[0]; let selectedDateString = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD in local time
let selectedDate = new Date(); let selectedDate = new Date();
$: { $: {
selectedDate = new Date(selectedDateString); selectedDate = new Date(selectedDateString + 'T00:00:00'); // Initialize to start of day in local time
if (selectedDateString === new Date().toISOString().split('T')[0]) { if (selectedDateString === new Date().toLocaleDateString('en-CA')) {
selectedDate = new Date(); selectedDate = new Date();
} else { } else {
selectedDate.setHours(7, 0, 0, 0); selectedDate.setHours(7, 0, 0, 0);
} }
} }
let floorIdFromUrl = $page.params.id;
$: floorIdFromUrl = $page.params.id;
onMount(async () => { onMount(async () => {
const floorId = $page.params.id;
if (!$token) { if (!$token) {
goto(`/login?redirect=/dashboard/floor/${floorId}`); goto(`/login?redirect=/dashboard/floor/${floorIdFromUrl}`);
return; return;
} }
await loadFloor(floorId); await loadFloor(floorIdFromUrl, selectedDate);
loading = false; loading = false;
}); });
async function loadFloor(floorId: string) { async function loadFloor(id: string, date: Date) {
try { try {
floor = await apiFetch(`/spaces/floor/${floorId}?date=${selectedDate.toISOString()}`); floor = await apiFetch(`/spaces/floor/${id}?date=${date.toISOString()}`);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
if (e.message?.includes('401')) { if (e.message?.includes('401')) {
@ -43,8 +45,8 @@
} }
} }
$: if (floor && floor.id && selectedDate) { $: if (floorIdFromUrl && selectedDate) {
loadFloor(floor.id); loadFloor(floorIdFromUrl, selectedDate);
} }
</script> </script>