From 67dd0d398f7c011c701417c11b8e12852a0e7536 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 19:07:36 +0200 Subject: [PATCH] feat(admin): live active-work view (5s refresh) --- apps/admin/src/api/admin-sessions.ts | 13 +++++ apps/admin/src/lib/elapsed.test.ts | 14 ++++++ apps/admin/src/lib/elapsed.ts | 11 ++++ apps/admin/src/screens/Live.test.tsx | 75 ++++++++++++++++++++++++++++ apps/admin/src/screens/Live.tsx | 64 +++++++++++++++++++++++- apps/admin/src/styles.css | 63 +++++++++++++++++++++++ 6 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/api/admin-sessions.ts create mode 100644 apps/admin/src/lib/elapsed.test.ts create mode 100644 apps/admin/src/lib/elapsed.ts create mode 100644 apps/admin/src/screens/Live.test.tsx diff --git a/apps/admin/src/api/admin-sessions.ts b/apps/admin/src/api/admin-sessions.ts new file mode 100644 index 0000000..2dfb022 --- /dev/null +++ b/apps/admin/src/api/admin-sessions.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import type { WorkSession } from '@solelog/shared'; +import { apiFetch } from '../lib/api'; + +// Active work sessions across all workers (admin cross-user view). +// Polls every 5s so the Live view reflects who is working without a manual refresh. +export function useActiveSessions() { + return useQuery({ + queryKey: ['admin', 'sessions', 'active'], + queryFn: () => apiFetch('/api/admin/sessions/active'), + refetchInterval: 5000, + }); +} diff --git a/apps/admin/src/lib/elapsed.test.ts b/apps/admin/src/lib/elapsed.test.ts new file mode 100644 index 0000000..be637d6 --- /dev/null +++ b/apps/admin/src/lib/elapsed.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { formatTime } from './elapsed'; + +describe('formatTime', () => { + it('formats seconds as HH:MM:SS', () => { + expect(formatTime(0)).toBe('00:00:00'); + expect(formatTime(65)).toBe('00:01:05'); + expect(formatTime(3661)).toBe('01:01:01'); + }); + + it('never returns a negative time', () => { + expect(formatTime(-10)).toBe('00:00:00'); + }); +}); diff --git a/apps/admin/src/lib/elapsed.ts b/apps/admin/src/lib/elapsed.ts new file mode 100644 index 0000000..0560152 --- /dev/null +++ b/apps/admin/src/lib/elapsed.ts @@ -0,0 +1,11 @@ +// Pure timing helper — server-authoritative elapsed. +// Elapsed is computed from the server start_time (wall-clock), not a tick counter, +// so it survives backgrounding. Ported from the worker's lib/stopwatch.ts. + +export function formatTime(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; +} diff --git a/apps/admin/src/screens/Live.test.tsx b/apps/admin/src/screens/Live.test.tsx new file mode 100644 index 0000000..8dac052 --- /dev/null +++ b/apps/admin/src/screens/Live.test.tsx @@ -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 { + 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( + + + + ); +} + +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(); + }); +}); diff --git a/apps/admin/src/screens/Live.tsx b/apps/admin/src/screens/Live.tsx index e85170c..7ae3511 100644 --- a/apps/admin/src/screens/Live.tsx +++ b/apps/admin/src/screens/Live.tsx @@ -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
Live
; + 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 ( +
+

Laden…

+
+ ); + } + + if (isError) { + return ( +
+

Kon gegevens niet laden.

+
+ ); + } + + const sessions = Array.isArray(data) ? data : []; + + return ( +
+

Actief nu ({sessions.length})

+ {sessions.length === 0 ? ( +

Niemand is nu aan het werk.

+ ) : ( +
+ {sessions.map((session) => ( + + ))} +
+ )} +
+ ); +} + +function LiveCard({ session, now }: { session: WorkSession; now: number }) { + const elapsed = formatTime((now - Date.parse(session.start_time)) / 1000); + return ( +
+
+ {session.user_name ?? 'Onbekend'} + {session.insole_type && {session.insole_type}} +
+
{session.activity_name ?? 'Onbekende handeling'}
+
{session.pair_count} zolen
+
{elapsed}
+
+ ); } diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index acbd601..e491ef0 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -227,3 +227,66 @@ body { margin: 0; text-align: center; } + +/* ---- Shared helpers ---- */ +.muted { + color: var(--text-muted); + font-size: 15px; +} + +/* ---- Live active-work view ---- */ +.live-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; +} + +.live-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px; + background: #ffffff; + border: 1px solid var(--border); + border-radius: 16px; +} + +.live-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.live-name { + font-size: 17px; + font-weight: 600; + color: var(--text); +} + +.live-pill { + font-size: 12px; + font-weight: 600; + color: var(--primary); + background: var(--primary-light); + border-radius: 999px; + padding: 4px 10px; +} + +.live-activity { + font-size: 15px; + color: var(--text); +} + +.live-meta { + font-size: 13px; + color: var(--text-muted); +} + +.live-timer { + margin-top: 4px; + font-size: 28px; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +}