diff --git a/apps/worker/src/api/sessions.ts b/apps/worker/src/api/sessions.ts index 127ce91..b70adef 100644 --- a/apps/worker/src/api/sessions.ts +++ b/apps/worker/src/api/sessions.ts @@ -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('/api/sessions'), + }); +} + export function useActiveSessions() { return useQuery({ queryKey: ['sessions', 'active'], diff --git a/apps/worker/src/lib/export.ts b/apps/worker/src/lib/export.ts new file mode 100644 index 0000000..6e70da3 --- /dev/null +++ b/apps/worker/src/lib/export.ts @@ -0,0 +1,23 @@ +import { API_URL } from './api'; +import { getToken } from './auth-storage'; + +/** + * Authenticated CSV download. A plain `` cannot carry the bearer token, + * so we fetch the blob ourselves and trigger a download via an object URL. + */ +export async function downloadExport(): Promise { + 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); +} diff --git a/apps/worker/src/screens/History.test.tsx b/apps/worker/src/screens/History.test.tsx new file mode 100644 index 0000000..fae0806 --- /dev/null +++ b/apps/worker/src/screens/History.test.tsx @@ -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 { + 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( + + + , + ); +} + +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); + }); +}); diff --git a/apps/worker/src/screens/History.tsx b/apps/worker/src/screens/History.tsx index bff6ed5..6aa0d61 100644 --- a/apps/worker/src/screens/History.tsx +++ b/apps/worker/src/screens/History.tsx @@ -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 ( +
  • +
    + {session.activity_name ?? 'Onbekende handeling'} + + {formatDate(session.start_time)} • {formatTime(session.start_time)} + +
    +
    + {session.insole_type && {session.insole_type}} + + {session.pair_count} {noun} + + {formatDuration(session.duration_seconds)} +
    +
  • + ); +} + export default function History() { + const sessionsQuery = useSessions(); + const sessions = sessionsQuery.data ?? []; + const [exportError, setExportError] = useState(null); + + async function handleExport() { + setExportError(null); + try { + await downloadExport(); + } catch { + setExportError('Kan de export niet openen'); + } + } + return (
    -

    Geschiedenis

    +
    +

    Geschiedenis

    + +
    + + {exportError && ( +

    + Fout — {exportError} +

    + )} + + {!sessionsQuery.isLoading && sessions.length === 0 ? ( +

    + Nog geen opgeslagen sessies. +

    + ) : ( +
      + {sessions.map((session) => ( + + ))} +
    + )}
    ); } diff --git a/apps/worker/src/styles.css b/apps/worker/src/styles.css index a90c2c9..ecde670 100644 --- a/apps/worker/src/styles.css +++ b/apps/worker/src/styles.css @@ -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); +}