feat(worker): Geschiedenis screen with session list and CSV export
This commit is contained in:
@@ -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'],
|
||||
|
||||
23
apps/worker/src/lib/export.ts
Normal file
23
apps/worker/src/lib/export.ts
Normal 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);
|
||||
}
|
||||
104
apps/worker/src/screens/History.test.tsx
Normal file
104
apps/worker/src/screens/History.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user