feat(admin): live active-work view (5s refresh)

This commit is contained in:
Bas van Rossem
2026-06-17 19:07:36 +02:00
parent 286e2d29db
commit 67dd0d398f
6 changed files with 238 additions and 2 deletions

View File

@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared';
import Live from './Live';
import { apiFetch } from '../lib/api';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn(),
}));
const mockApiFetch = vi.mocked(apiFetch);
function makeSession(over: Partial<WorkSession>): WorkSession {
return {
id: 1,
user_id: 'u1',
activity_id: 10,
activity_name: 'Frezen',
user_name: 'Jan',
insole_type: 'Kurk',
pair_count: 4,
start_time: new Date(Date.now() - 65_000).toISOString(),
end_time: null,
duration_seconds: null,
status: 'active',
source: 'app',
notes: null,
created_at: new Date().toISOString(),
...over,
};
}
function renderLive() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<Live />
</QueryClientProvider>
);
}
describe('Live', () => {
beforeEach(() => {
mockApiFetch.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders a card per active session with header count', async () => {
mockApiFetch.mockResolvedValue([
makeSession({ id: 1, user_name: 'Jan', activity_name: 'Frezen', insole_type: 'Kurk' }),
makeSession({ id: 2, user_name: 'Piet', activity_name: 'Lijmen', insole_type: 'Berk' }),
]);
renderLive();
expect(await screen.findByText('Actief nu (2)')).toBeInTheDocument();
expect(screen.getByText('Jan')).toBeInTheDocument();
expect(screen.getByText('Frezen')).toBeInTheDocument();
expect(screen.getByText('Piet')).toBeInTheDocument();
expect(screen.getByText('Lijmen')).toBeInTheDocument();
});
it('shows the empty state when nobody is working', async () => {
mockApiFetch.mockResolvedValue([]);
renderLive();
expect(await screen.findByText('Niemand is nu aan het werk.')).toBeInTheDocument();
expect(screen.getByText('Actief nu (0)')).toBeInTheDocument();
});
});

View File

@@ -1,4 +1,64 @@
// Placeholder — replaced by the live active-work view in Task 5.
import { useEffect, useState } from 'react';
import type { WorkSession } from '@solelog/shared';
import { useActiveSessions } from '../api/admin-sessions';
import { formatTime } from '../lib/elapsed';
export default function Live() {
return <div className="screen">Live</div>;
const { data, isLoading, isError } = useActiveSessions();
// Client-side 1s tick so the elapsed timer on each card keeps counting up
// between the 5s server refreshes.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
if (isLoading) {
return (
<div className="screen">
<p className="muted">Laden</p>
</div>
);
}
if (isError) {
return (
<div className="screen">
<p className="muted">Kon gegevens niet laden.</p>
</div>
);
}
const sessions = Array.isArray(data) ? data : [];
return (
<div className="screen">
<h1 className="screen-title">Actief nu ({sessions.length})</h1>
{sessions.length === 0 ? (
<p className="muted">Niemand is nu aan het werk.</p>
) : (
<div className="live-grid">
{sessions.map((session) => (
<LiveCard key={session.id} session={session} now={now} />
))}
</div>
)}
</div>
);
}
function LiveCard({ session, now }: { session: WorkSession; now: number }) {
const elapsed = formatTime((now - Date.parse(session.start_time)) / 1000);
return (
<article className="live-card">
<div className="live-card-head">
<span className="live-name">{session.user_name ?? 'Onbekend'}</span>
{session.insole_type && <span className="live-pill">{session.insole_type}</span>}
</div>
<div className="live-activity">{session.activity_name ?? 'Onbekende handeling'}</div>
<div className="live-meta">{session.pair_count} zolen</div>
<div className="live-timer">{elapsed}</div>
</article>
);
}