From 64624f19bad1d652dd71e7c96da19f7500009d86 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:01 +0100 Subject: [PATCH 1/9] feat(fe-03): add BookingTrendsChart component --- frontend/cntr/charts/BookingTrendsChart.tsx | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 frontend/cntr/charts/BookingTrendsChart.tsx diff --git a/frontend/cntr/charts/BookingTrendsChart.tsx b/frontend/cntr/charts/BookingTrendsChart.tsx new file mode 100644 index 0000000..2518da6 --- /dev/null +++ b/frontend/cntr/charts/BookingTrendsChart.tsx @@ -0,0 +1,45 @@ +'use client'; +import React from 'react'; +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; + +export interface BookingTrendsData { + date: string; + bookings: number; + cancellations: number; +} + +interface Props { + data: BookingTrendsData[]; + period: 'week' | 'month'; +} + +export const BookingTrendsChart: React.FC = ({ data }) => { + if (data.length === 0) { + return ( +
+ Loading chart data… +
+ ); + } + + return ( + + + + + + + + + + + ); +}; \ No newline at end of file From e32c71f9d9046b133e8c4a34fd74b65a787171e9 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:02 +0100 Subject: [PATCH 2/9] test(fe-03): add BookingTrendsChart tests --- .../cntr/charts/BookingTrendsChart.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 frontend/cntr/charts/BookingTrendsChart.test.tsx diff --git a/frontend/cntr/charts/BookingTrendsChart.test.tsx b/frontend/cntr/charts/BookingTrendsChart.test.tsx new file mode 100644 index 0000000..e70b098 --- /dev/null +++ b/frontend/cntr/charts/BookingTrendsChart.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { BookingTrendsChart } from './BookingTrendsChart'; + +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +const mockData = [ + { date: '2024-01-01', bookings: 10, cancellations: 2 }, + { date: '2024-01-02', bookings: 15, cancellations: 3 }, +]; + +describe('BookingTrendsChart', () => { + it('renders skeleton when data is empty', () => { + render(); + expect(screen.getByText(/loading chart data/i)).toBeInTheDocument(); + }); + + it('renders chart when data is provided', () => { + render(); + expect(screen.queryByText(/loading chart data/i)).not.toBeInTheDocument(); + }); +}); \ No newline at end of file From a78deb49c324f96dadac11b91d6e45b781e2c793 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:16 +0100 Subject: [PATCH 3/9] feat(fe-05): add OccupancyPieChart component --- frontend/cntr/charts/OccupancyPieChart.tsx | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 frontend/cntr/charts/OccupancyPieChart.tsx diff --git a/frontend/cntr/charts/OccupancyPieChart.tsx b/frontend/cntr/charts/OccupancyPieChart.tsx new file mode 100644 index 0000000..5a02629 --- /dev/null +++ b/frontend/cntr/charts/OccupancyPieChart.tsx @@ -0,0 +1,34 @@ +'use client'; +import React from 'react'; +import { PieChart, Pie, Cell, Tooltip, Legend } from 'recharts'; + +export interface OccupancyPieData { + workspaceName: string; + occupancyPercent: number; +} + +interface Props { + data: OccupancyPieData[]; +} + +const COLOURS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + +export const OccupancyPieChart: React.FC = ({ data }) => ( + + `${name}: ${value}%`} + > + {data.map((_, i) => ( + + ))} + + `${value}%`} /> + + +); \ No newline at end of file From 532e18a246f6355ba519793b8cc490a299c15364 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:17 +0100 Subject: [PATCH 4/9] test(fe-05): add OccupancyPieChart tests --- .../cntr/charts/OccupancyPieChart.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 frontend/cntr/charts/OccupancyPieChart.test.tsx diff --git a/frontend/cntr/charts/OccupancyPieChart.test.tsx b/frontend/cntr/charts/OccupancyPieChart.test.tsx new file mode 100644 index 0000000..ae49b79 --- /dev/null +++ b/frontend/cntr/charts/OccupancyPieChart.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { OccupancyPieChart } from './OccupancyPieChart'; + +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + PieChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Pie: () => null, + Cell: () => null, + Tooltip: () => null, + Legend: () =>
, + }; +}); + +const mockData = [ + { workspaceName: 'Alpha', occupancyPercent: 80 }, + { workspaceName: 'Beta', occupancyPercent: 50 }, +]; + +describe('OccupancyPieChart', () => { + it('renders the pie chart container', () => { + render(); + expect(screen.getByTestId('pie-chart')).toBeInTheDocument(); + }); + + it('renders the legend', () => { + render(); + expect(screen.getByTestId('legend')).toBeInTheDocument(); + }); +}); \ No newline at end of file From f269b939e7ddb18aa189a5229c34b1c6bc346ebe Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:37 +0100 Subject: [PATCH 5/9] feat(fe-06): add useWebSocket hook --- frontend/cntr/LiveOccupancy/useWebSocket.ts | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 frontend/cntr/LiveOccupancy/useWebSocket.ts diff --git a/frontend/cntr/LiveOccupancy/useWebSocket.ts b/frontend/cntr/LiveOccupancy/useWebSocket.ts new file mode 100644 index 0000000..1ff2588 --- /dev/null +++ b/frontend/cntr/LiveOccupancy/useWebSocket.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from 'react'; + +export type WsStatus = 'connecting' | 'open' | 'closed'; + +export interface UseWebSocketResult { + data: T | null; + status: WsStatus; +} + +export function useWebSocket(url: string): UseWebSocketResult { + const [data, setData] = useState(null); + const [status, setStatus] = useState('connecting'); + const wsRef = useRef(null); + + useEffect(() => { + const ws = new WebSocket(url); + wsRef.current = ws; + setStatus('connecting'); + + ws.onopen = () => setStatus('open'); + ws.onmessage = (e) => { + try { setData(JSON.parse(e.data) as T); } catch { /* ignore non-JSON */ } + }; + ws.onclose = () => setStatus('closed'); + ws.onerror = () => setStatus('closed'); + + return () => { ws.close(); }; + }, [url]); + + return { data, status }; +} \ No newline at end of file From dce61be907ce607eb9acd3622c8d7b869a00544e Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:38 +0100 Subject: [PATCH 6/9] feat(fe-06): add LiveOccupancyWidget component --- .../LiveOccupancy/LiveOccupancyWidget.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/cntr/LiveOccupancy/LiveOccupancyWidget.tsx diff --git a/frontend/cntr/LiveOccupancy/LiveOccupancyWidget.tsx b/frontend/cntr/LiveOccupancy/LiveOccupancyWidget.tsx new file mode 100644 index 0000000..a8ab4f1 --- /dev/null +++ b/frontend/cntr/LiveOccupancy/LiveOccupancyWidget.tsx @@ -0,0 +1,30 @@ +'use client'; +import React from 'react'; +import { useWebSocket } from './useWebSocket'; + +interface OccupancyPayload { + current: number; +} + +interface Props { + wsUrl: string; + capacity: number; +} + +export const LiveOccupancyWidget: React.FC = ({ wsUrl, capacity }) => { + const { data, status } = useWebSocket(wsUrl); + + if (status === 'connecting') { + return
Connecting…
; + } + + const current = data?.current ?? 0; + const pct = capacity > 0 ? (current / capacity) * 100 : 0; + const colour = pct >= 90 ? 'bg-red-100 text-red-700' : pct >= 70 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'; + + return ( +
+ {current} / {capacity} occupied +
+ ); +}; \ No newline at end of file From 335e9725c6f6ffe7019bcb582913f716ce88f655 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:15:39 +0100 Subject: [PATCH 7/9] test(fe-06): add LiveOccupancyWidget tests --- .../LiveOccupancyWidget.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 frontend/cntr/LiveOccupancy/LiveOccupancyWidget.test.tsx diff --git a/frontend/cntr/LiveOccupancy/LiveOccupancyWidget.test.tsx b/frontend/cntr/LiveOccupancy/LiveOccupancyWidget.test.tsx new file mode 100644 index 0000000..5b02cc3 --- /dev/null +++ b/frontend/cntr/LiveOccupancy/LiveOccupancyWidget.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { LiveOccupancyWidget } from './LiveOccupancyWidget'; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + readyState = 0; + close = vi.fn(); + constructor() { MockWebSocket.instances.push(this); } +} + +beforeEach(() => { + MockWebSocket.instances = []; + (global as any).WebSocket = MockWebSocket; +}); + +afterEach(() => { vi.restoreAllMocks(); }); + +describe('LiveOccupancyWidget', () => { + it('shows connecting state initially', () => { + render(); + expect(screen.getByText(/connecting/i)).toBeInTheDocument(); + }); + + it('shows occupancy after receiving a message', () => { + render(); + const ws = MockWebSocket.instances[0]; + ws.onopen?.(); + ws.onmessage?.({ data: JSON.stringify({ current: 7 }) }); + expect(screen.getByText('7 / 10 occupied')).toBeInTheDocument(); + }); + + it('calls ws.close on unmount', () => { + const { unmount } = render(); + const ws = MockWebSocket.instances[0]; + unmount(); + expect(ws.close).toHaveBeenCalled(); + }); +}); \ No newline at end of file From c181b765ebaedd838b66cc2419ef276535b64a6d Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:16:02 +0100 Subject: [PATCH 8/9] feat(fe-12): add generic sortable paginated DataTable component --- frontend/cntr/DataTable/DataTable.tsx | 108 ++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 frontend/cntr/DataTable/DataTable.tsx diff --git a/frontend/cntr/DataTable/DataTable.tsx b/frontend/cntr/DataTable/DataTable.tsx new file mode 100644 index 0000000..10970ff --- /dev/null +++ b/frontend/cntr/DataTable/DataTable.tsx @@ -0,0 +1,108 @@ +'use client'; +import React, { useState, useMemo } from 'react'; + +export interface Column { + key: keyof T; + label: string; + sortable?: boolean; +} + +interface Props { + columns: Column[]; + data: T[]; + pageSize?: number; + onRowClick?: (row: T) => void; +} + +type SortDir = 'asc' | 'desc'; + +export function DataTable({ + columns, + data, + pageSize = 10, + onRowClick, +}: Props) { + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('asc'); + const [page, setPage] = useState(1); + + const sorted = useMemo(() => { + if (!sortKey) return data; + return [...data].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return sortDir === 'asc' ? cmp : -cmp; + }); + }, [data, sortKey, sortDir]); + + const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); + const pageData = sorted.slice((page - 1) * pageSize, page * pageSize); + + function handleSort(key: keyof T) { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir('asc'); + } + setPage(1); + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {pageData.map((row, i) => ( + onRowClick?.(row)} + > + {columns.map((col) => ( + + ))} + + ))} + +
col.sortable && handleSort(col.key)} + > + {col.label} + {col.sortable && sortKey === col.key && ( + + {sortDir === 'asc' ? ' ▲' : ' ▼'} + + )} +
+ {String(row[col.key] ?? '')} +
+
+ + Page {page} of {totalPages} + +
+
+ ); +} \ No newline at end of file From 9d33a4ee74f3d8402b0b804fc687c18730cd56b4 Mon Sep 17 00:00:00 2001 From: walexjnr Date: Sat, 30 May 2026 11:16:03 +0100 Subject: [PATCH 9/9] test(fe-12): add DataTable tests --- frontend/cntr/DataTable/DataTable.test.tsx | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 frontend/cntr/DataTable/DataTable.test.tsx diff --git a/frontend/cntr/DataTable/DataTable.test.tsx b/frontend/cntr/DataTable/DataTable.test.tsx new file mode 100644 index 0000000..79f9209 --- /dev/null +++ b/frontend/cntr/DataTable/DataTable.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { DataTable } from './DataTable'; + +const columns = [ + { key: 'name' as const, label: 'Name', sortable: true }, + { key: 'age' as const, label: 'Age', sortable: true }, +]; + +const data = Array.from({ length: 15 }, (_, i) => ({ name: `User ${i + 1}`, age: 20 + i })); + +describe('DataTable', () => { + it('renders column headers', () => { + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Age')).toBeInTheDocument(); + }); + + it('paginates — shows pageSize rows and Page 1 of N', () => { + render(); + expect(screen.getByText('Page 1 of 2')).toBeInTheDocument(); + expect(screen.getByText('User 1')).toBeInTheDocument(); + expect(screen.queryByText('User 11')).not.toBeInTheDocument(); + }); + + it('navigates to next page', () => { + render(); + fireEvent.click(screen.getByText('Next')); + expect(screen.getByText('Page 2 of 2')).toBeInTheDocument(); + expect(screen.getByText('User 11')).toBeInTheDocument(); + }); + + it('sorts ascending then descending on column click', () => { + render(); + fireEvent.click(screen.getByText('Name')); + expect(screen.getByLabelText('sorted ascending')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Name')); + expect(screen.getByLabelText('sorted descending')).toBeInTheDocument(); + }); + + it('calls onRowClick with the row object', () => { + const onRowClick = vi.fn(); + render(); + fireEvent.click(screen.getByText('User 1')); + expect(onRowClick).toHaveBeenCalledWith(data[0]); + }); +}); \ No newline at end of file