Skip to content
47 changes: 47 additions & 0 deletions frontend/cntr/DataTable/DataTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<DataTable columns={columns} data={data} />);
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Age')).toBeInTheDocument();
});

it('paginates — shows pageSize rows and Page 1 of N', () => {
render(<DataTable columns={columns} data={data} pageSize={10} />);
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(<DataTable columns={columns} data={data} pageSize={10} />);
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(<DataTable columns={columns} data={data} pageSize={15} />);
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(<DataTable columns={columns} data={data} onRowClick={onRowClick} />);
fireEvent.click(screen.getByText('User 1'));
expect(onRowClick).toHaveBeenCalledWith(data[0]);
});
});
108 changes: 108 additions & 0 deletions frontend/cntr/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';
import React, { useState, useMemo } from 'react';

export interface Column<T> {
key: keyof T;
label: string;
sortable?: boolean;
}

interface Props<T> {
columns: Column<T>[];
data: T[];
pageSize?: number;
onRowClick?: (row: T) => void;
}

type SortDir = 'asc' | 'desc';

export function DataTable<T extends { [k: string]: unknown }>({
columns,
data,
pageSize = 10,
onRowClick,
}: Props<T>) {
const [sortKey, setSortKey] = useState<keyof T | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('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 (
<div className="w-full overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
className="px-4 py-2 text-left border-b font-semibold cursor-pointer select-none"
onClick={() => col.sortable && handleSort(col.key)}
>
{col.label}
{col.sortable && sortKey === col.key && (
<span aria-label={sortDir === 'asc' ? 'sorted ascending' : 'sorted descending'}>
{sortDir === 'asc' ? ' ▲' : ' ▼'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{pageData.map((row, i) => (
<tr
key={i}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50' : ''}
onClick={() => onRowClick?.(row)}
>
{columns.map((col) => (
<td key={String(col.key)} className="px-4 py-2 border-b">
{String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="flex items-center justify-between px-4 py-2 text-sm">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border rounded disabled:opacity-40"
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 border rounded disabled:opacity-40"
>
Next
</button>
</div>
</div>
);
}
43 changes: 43 additions & 0 deletions frontend/cntr/LiveOccupancy/LiveOccupancyWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LiveOccupancyWidget wsUrl="ws://test" capacity={10} />);
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
});

it('shows occupancy after receiving a message', () => {
render(<LiveOccupancyWidget wsUrl="ws://test" capacity={10} />);
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(<LiveOccupancyWidget wsUrl="ws://test" capacity={10} />);
const ws = MockWebSocket.instances[0];
unmount();
expect(ws.close).toHaveBeenCalled();
});
});
30 changes: 30 additions & 0 deletions frontend/cntr/LiveOccupancy/LiveOccupancyWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ wsUrl, capacity }) => {
const { data, status } = useWebSocket<OccupancyPayload>(wsUrl);

if (status === 'connecting') {
return <div className="p-4 rounded-lg bg-gray-100 text-gray-500 text-sm">Connecting…</div>;
}

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 (
<div className={`p-4 rounded-lg ${colour} text-sm font-medium`}>
{current} / {capacity} occupied
</div>
);
};
31 changes: 31 additions & 0 deletions frontend/cntr/LiveOccupancy/useWebSocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from 'react';

export type WsStatus = 'connecting' | 'open' | 'closed';

export interface UseWebSocketResult<T> {
data: T | null;
status: WsStatus;
}

export function useWebSocket<T = unknown>(url: string): UseWebSocketResult<T> {
const [data, setData] = useState<T | null>(null);
const [status, setStatus] = useState<WsStatus>('connecting');
const wsRef = useRef<WebSocket | null>(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 };
}
28 changes: 28 additions & 0 deletions frontend/cntr/charts/BookingTrendsChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
};
});

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(<BookingTrendsChart data={[]} period="week" />);
expect(screen.getByText(/loading chart data/i)).toBeInTheDocument();
});

it('renders chart when data is provided', () => {
render(<BookingTrendsChart data={mockData} period="week" />);
expect(screen.queryByText(/loading chart data/i)).not.toBeInTheDocument();
});
});
45 changes: 45 additions & 0 deletions frontend/cntr/charts/BookingTrendsChart.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ data }) => {
if (data.length === 0) {
return (
<div className="w-full h-64 animate-pulse bg-gray-200 rounded-lg flex items-center justify-center">
<span className="text-gray-400 text-sm">Loading chart data…</span>
</div>
);
}

return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend verticalAlign="bottom" />
<Line type="monotone" dataKey="bookings" name="New Bookings" stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="cancellations" name="Cancellations" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
};
32 changes: 32 additions & 0 deletions frontend/cntr/charts/OccupancyPieChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('recharts')>('recharts');
return {
...actual,
PieChart: ({ children }: { children: React.ReactNode }) => <div data-testid="pie-chart">{children}</div>,
Pie: () => null,
Cell: () => null,
Tooltip: () => null,
Legend: () => <div data-testid="legend" />,
};
});

const mockData = [
{ workspaceName: 'Alpha', occupancyPercent: 80 },
{ workspaceName: 'Beta', occupancyPercent: 50 },
];

describe('OccupancyPieChart', () => {
it('renders the pie chart container', () => {
render(<OccupancyPieChart data={mockData} />);
expect(screen.getByTestId('pie-chart')).toBeInTheDocument();
});

it('renders the legend', () => {
render(<OccupancyPieChart data={mockData} />);
expect(screen.getByTestId('legend')).toBeInTheDocument();
});
});
Loading