feat(admin): live active-work view (5s refresh)
This commit is contained in:
13
apps/admin/src/api/admin-sessions.ts
Normal file
13
apps/admin/src/api/admin-sessions.ts
Normal file
@@ -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<WorkSession[]>('/api/admin/sessions/active'),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
14
apps/admin/src/lib/elapsed.test.ts
Normal file
14
apps/admin/src/lib/elapsed.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
11
apps/admin/src/lib/elapsed.ts
Normal file
11
apps/admin/src/lib/elapsed.ts
Normal file
@@ -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')}`;
|
||||||
|
}
|
||||||
75
apps/admin/src/screens/Live.test.tsx
Normal file
75
apps/admin/src/screens/Live.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,3 +227,66 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user