feat(worker): Geschiedenis screen with session list and CSV export

This commit is contained in:
Bas van Rossem
2026-06-17 16:30:34 +02:00
parent 5af5a9c2bb
commit 134e01a2e8
5 changed files with 302 additions and 1 deletions

View File

@@ -2,6 +2,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { StartSessionInput, WorkSession } from '@solelog/shared';
import { apiFetch } from '../lib/api';
export function useSessions() {
return useQuery({
queryKey: ['sessions'],
queryFn: () => apiFetch<WorkSession[]>('/api/sessions'),
});
}
export function useActiveSessions() {
return useQuery({
queryKey: ['sessions', 'active'],

View File

@@ -0,0 +1,23 @@
import { API_URL } from './api';
import { getToken } from './auth-storage';
/**
* Authenticated CSV download. A plain `<a href>` cannot carry the bearer token,
* so we fetch the blob ourselves and trigger a download via an object URL.
*/
export async function downloadExport(): Promise<void> {
const token = getToken();
const res = await fetch(`${API_URL}/api/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error('Kan de export niet openen');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'insole-production-report.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,104 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { WorkSession } from '@solelog/shared';
import History from './History';
import { apiFetch } from '../lib/api';
import { downloadExport } from '../lib/export';
// Mock the network layer so no real requests are made.
vi.mock('../lib/api', () => ({
apiFetch: vi.fn(),
}));
// Mock the authenticated CSV download helper.
vi.mock('../lib/export', () => ({
downloadExport: vi.fn(),
}));
const mockedApiFetch = vi.mocked(apiFetch);
const mockedDownloadExport = vi.mocked(downloadExport);
function session(overrides: Partial<WorkSession> = {}): WorkSession {
return {
id: 1,
user_id: 'u1',
activity_id: 1,
activity_name: 'Frezen',
insole_type: 'Kurk',
pair_count: 2,
start_time: '2026-01-02T08:30:00.000Z',
end_time: '2026-01-02T09:31:01.000Z',
duration_seconds: 3661,
status: 'completed',
source: 'app',
notes: null,
created_at: '2026-01-02T08:30:00.000Z',
...overrides,
};
}
function renderHistory() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<History />
</QueryClientProvider>,
);
}
describe('History', () => {
beforeEach(() => {
mockedApiFetch.mockReset();
mockedDownloadExport.mockReset();
mockedApiFetch.mockResolvedValue([]);
mockedDownloadExport.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders the header and export button in Dutch', () => {
renderHistory();
expect(screen.getByText('Geschiedenis')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Exporteer CSV' })).toBeInTheDocument();
});
it('shows the empty state when there are no sessions', async () => {
mockedApiFetch.mockResolvedValue([]);
renderHistory();
expect(await screen.findByText('Nog geen opgeslagen sessies.')).toBeInTheDocument();
});
it('renders a session card with activity name, type, count and duration', async () => {
mockedApiFetch.mockResolvedValue([session({ duration_seconds: 3661 })]);
renderHistory();
expect(await screen.findByText('Frezen')).toBeInTheDocument();
const card = screen.getByText('Frezen').closest('.session-card') as HTMLElement;
expect(card).not.toBeNull();
expect(card.textContent).toContain('Kurk');
expect(card.textContent).toContain('2 inlegzolen');
// 3661s -> "1h 1m" (hours present).
expect(card.textContent).toContain('1h 1m');
});
it('uses the singular noun for a count of 1', async () => {
mockedApiFetch.mockResolvedValue([session({ pair_count: 1 })]);
renderHistory();
const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement;
expect(card.textContent).toContain('1 inlegzool');
expect(card.textContent).not.toContain('1 inlegzolen');
});
it('triggers the CSV download on Exporteer CSV', async () => {
const user = userEvent.setup();
renderHistory();
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
expect(mockedDownloadExport).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,7 +1,95 @@
import { useState } from 'react';
import type { WorkSession } from '@solelog/shared';
import { useSessions } from '../api/sessions';
import { downloadExport } from '../lib/export';
// Legacy History duration format: "1h 5m" / "3m 20s" / "45s".
function formatDuration(totalSeconds: number | null): string {
const s = totalSeconds ?? 0;
const hrs = Math.floor(s / 3600);
const mins = Math.floor((s % 3600) / 60);
const secs = s % 60;
if (hrs > 0) return `${hrs}h ${mins}m`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
});
}
function SessionCard({ session }: { session: WorkSession }) {
const noun = session.pair_count === 1 ? 'inlegzool' : 'inlegzolen';
return (
<li className="session-card">
<div className="session-main">
<span className="session-name">{session.activity_name ?? 'Onbekende handeling'}</span>
<span className="session-datetime">
{formatDate(session.start_time)} {formatTime(session.start_time)}
</span>
</div>
<div className="session-badges">
{session.insole_type && <span className="pill pill-grey">{session.insole_type}</span>}
<span className="pill pill-blue">
{session.pair_count} {noun}
</span>
<span className="pill pill-grey">{formatDuration(session.duration_seconds)}</span>
</div>
</li>
);
}
export default function History() {
const sessionsQuery = useSessions();
const sessions = sessionsQuery.data ?? [];
const [exportError, setExportError] = useState<string | null>(null);
async function handleExport() {
setExportError(null);
try {
await downloadExport();
} catch {
setExportError('Kan de export niet openen');
}
}
return (
<div className="screen">
<h1 className="screen-title">Geschiedenis</h1>
<div className="history-header">
<h1 className="screen-title">Geschiedenis</h1>
<button type="button" className="pill-btn" onClick={handleExport}>
Exporteer CSV
</button>
</div>
{exportError && (
<p className="login-error" style={{ textAlign: 'left' }}>
Fout {exportError}
</p>
)}
{!sessionsQuery.isLoading && sessions.length === 0 ? (
<p className="muted" style={{ textAlign: 'center', marginTop: 48 }}>
Nog geen opgeslagen sessies.
</p>
) : (
<ul className="session-list">
{sessions.map((session) => (
<SessionCard key={session.id} session={session} />
))}
</ul>
)}
</div>
);
}

View File

@@ -273,3 +273,82 @@ body {
border-radius: 12px;
cursor: pointer;
}
/* ---- History (Geschiedenis) ---- */
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pill-btn {
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
color: #ffffff;
background: var(--primary);
border: none;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
}
.session-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.session-card {
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.session-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.session-name {
font-size: 16px;
font-weight: 600;
}
.session-datetime {
font-size: 14px;
color: var(--text-muted);
}
.session-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 4px 10px;
font-size: 13px;
font-weight: 600;
border-radius: 999px;
}
.pill-grey {
background: #f3f4f6;
color: var(--text-muted);
}
.pill-blue {
background: var(--primary-light);
color: var(--primary);
}