Files
solelog/docs/superpowers/plans/2026-06-24-phase-3b2-reports-export.md

50 KiB

Phase 3b·2 — Reports + All-Users Export — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give the admin a Rapporten screen with period + worker/type/activity filters showing headline totals and three breakdowns (per worker / activity / type), plus an all-users CSV export of the matching completed sessions.

Architecture: Two new endpoints under the existing /api/admin/* admin guard share one filtered-query helper. GET /api/admin/report fetches the filtered joined rows and aggregates in JS into ReportResponse. GET /api/admin/export streams the same rows as CSV via a shared buildSessionsCsv(rows, {includeWorker}) extracted from the existing self-scoped /api/export. A tables-only Reports screen drives both via React Query; the bearer-auth'd CSV download uses a raw fetch → Blob.

Tech Stack: Hono, Drizzle (libsql/SQLite), zod (@solelog/shared), React + React Query + Vite (admin), Vitest + Testing Library.

Global Constraints

  • Completed-only: every total and exported row is status='completed'. Active/discarded never counts. (Verbatim from spec §scope-decision 3.)
  • Metrics (all four): worked_seconds (sum of duration_seconds, already excludes paused), paused_seconds, pairs (sum of pair_count), sessions (row count).
  • Admin-gated: both endpoints live under the existing adminRoutes.use('/api/admin/*', …) guard → 401 (no session) / 403 (non-admin) for free.
  • Date semantics: client sends from/to as ISO instants spanning whole local days; server compares them against start_time inclusively. No server-side timezone logic.
  • Dependency-light: no new runtime dependencies (no chart library). Reuse lib/csv (quote, formatDuration) and admin lib/elapsed (formatTime).
  • CSV byte-compatibility: the refactor must keep /api/export output byte-identical (existing apps/api/test/export.test.ts is the regression guard).
  • Code style: oxfmt — 2-space, single quotes, semicolons, width 100, trailing-comma all. Run npx oxfmt <files> before committing.

Task 1: DRY CSV builder — extract buildSessionsCsv

Extract the inline header/row builder from the self-scoped /api/export into a reusable function so the new admin export shares one format. Output for the existing route stays byte-identical.

Files:

  • Modify: apps/api/src/lib/csv.ts (add SessionCsvRow + buildSessionsCsv)
  • Modify: apps/api/src/routes/sessions.ts:12-67 (use the new builder)
  • Test: apps/api/test/csv.test.ts (new — unit test for the builder)
  • Regression: apps/api/test/export.test.ts (must still pass unchanged)

Interfaces:

  • Produces: buildSessionsCsv(rows: SessionCsvRow[], opts?: { includeWorker?: boolean }): string and the SessionCsvRow interface — consumed by Task 3.

  • Step 1: Write the failing unit test

Create apps/api/test/csv.test.ts:

import { describe, it, expect } from 'vitest';
import { buildSessionsCsv, type SessionCsvRow } from '../src/lib/csv';

const row: SessionCsvRow = {
  id: 7,
  activityName: 'Frezen',
  userName: 'Jan',
  insoleType: 'Kurk',
  pairCount: 2,
  startTime: new Date('2026-06-17T08:00:00Z'),
  endTime: new Date('2026-06-17T08:01:30Z'),
  durationSeconds: 90,
  pausedSeconds: 0,
};

describe('buildSessionsCsv', () => {
  it('omits the Worker column by default (legacy header)', () => {
    const lines = buildSessionsCsv([row]).split('\n');
    expect(lines[0]).toBe(
      '"ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
    );
    expect(lines[1]).toContain('"Frezen"');
    expect(lines[1]).toContain('"00:01:30"');
    expect(lines[1]).not.toContain('"Jan"');
  });

  it('prepends a Worker column when includeWorker is true', () => {
    const lines = buildSessionsCsv([row], { includeWorker: true }).split('\n');
    expect(lines[0]).toBe(
      '"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
    );
    expect(lines[1].startsWith('"Jan"')).toBe(true);
  });
});
  • Step 2: Run it to verify it fails

Run: yarn workspace @solelog/api test csv Expected: FAIL — buildSessionsCsv is not exported.

  • Step 3: Implement the builder

Append to apps/api/src/lib/csv.ts:

// One completed session, flattened for CSV. Times accept Date | number | string.
export interface SessionCsvRow {
  id: number;
  activityName: string | null;
  userName?: string | null;
  insoleType: string | null;
  pairCount: number;
  startTime: Date | number | string;
  endTime: Date | number | string | null;
  durationSeconds: number | null;
  pausedSeconds: number | null;
}

// Build the CSV body shared by the worker self-export and the admin all-users export.
// includeWorker prepends a Worker column; everything else matches the legacy format byte-for-byte.
export function buildSessionsCsv(
  rows: SessionCsvRow[],
  opts: { includeWorker?: boolean } = {},
): string {
  const includeWorker = opts.includeWorker ?? false;
  const header = [
    ...(includeWorker ? ['Worker'] : []),
    'ID',
    'Task',
    'Insole Type',
    'No. of Insoles',
    'Date',
    'Total Duration',
    'Paused Duration',
    'Start Time',
    'End Time',
  ]
    .map(quote)
    .join(',');

  const dataLines = rows.map((row) => {
    const start = new Date(row.startTime);
    const end = row.endTime ? new Date(row.endTime) : null;
    return [
      ...(includeWorker ? [row.userName ?? ''] : []),
      row.id,
      row.activityName ?? '',
      row.insoleType ?? 'Kurk',
      row.pairCount ?? 2,
      start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }),
      formatDuration(row.durationSeconds ?? 0),
      formatDuration(row.pausedSeconds ?? 0),
      start.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
      end
        ? end.toLocaleTimeString('nl-BE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
        : '',
    ]
      .map(quote)
      .join(',');
  });

  return [header, ...dataLines].join('\n');
}
  • Step 4: Rewire the self-scoped export to use the builder

In apps/api/src/routes/sessions.ts, replace the body of the /api/export handler (the header/dataLines/csv block, current lines ~23-61) so it builds via the shared function. The query stays the same; map its rows into SessionCsvRow:

  const csv = buildSessionsCsv(
    rows.map(({ session, activityName }) => ({
      id: session.id,
      activityName,
      insoleType: session.insoleType,
      pairCount: session.pairCount,
      startTime: session.startTime,
      endTime: session.endTime,
      durationSeconds: session.durationSeconds,
      pausedSeconds: session.pausedSeconds,
    })),
  );

  return c.body(csv, 200, {
    'Content-Type': 'text/csv; charset=utf-8',
    'Content-Disposition': 'attachment; filename="insole-production-report.csv"',
  });

Update the import line in sessions.ts from import { quote, formatDuration } from '../lib/csv'; to import { buildSessionsCsv } from '../lib/csv'; (the route no longer references quote/formatDuration directly).

  • Step 5: Run the new unit test + the regression suite

Run: yarn workspace @solelog/api test csv export Expected: PASS — both csv.test.ts and the unchanged export.test.ts are green (byte-identical output).

  • Step 6: Typecheck, format, commit
yarn workspace @solelog/api typecheck
npx oxfmt apps/api/src/lib/csv.ts apps/api/src/routes/sessions.ts apps/api/test/csv.test.ts
git add apps/api/src/lib/csv.ts apps/api/src/routes/sessions.ts apps/api/test/csv.test.ts
git commit -m "refactor(api): extract buildSessionsCsv shared by worker + admin exports"

Task 2: Shared report contracts + /api/admin/report

Add the ReportResponse contracts and the report endpoint with its shared filter helper. Aggregation happens in JS over the filtered joined rows.

Files:

  • Modify: packages/shared/src/index.ts (append Report* schemas)
  • Modify: apps/api/src/routes/admin.ts (add parseReportQuery, buildSessionFilters, the report route, imports)
  • Test: apps/api/test/report.test.ts (new)

Interfaces:

  • Consumes: baseSelect, workSessions, activities, user (already in admin.ts).

  • Produces:

    • shared ReportResponse (+ ReportTotals, ReportWorkerRow, ReportActivityRow, ReportTypeRow) — consumed by Tasks 4-5.
    • parseReportQuery(c): ReportQuery | null and buildSessionFilters(q: ReportQuery): SQL[] — consumed by Task 3.
    • type ReportQuery = { from: Date; to: Date; userId?: string; insoleType?: string; activityId?: number }.
  • Step 1: Add the shared contracts

Append to packages/shared/src/index.ts:

export const ReportTotals = z.object({
  worked_seconds: z.number().int(),
  paused_seconds: z.number().int(),
  pairs: z.number().int(),
  sessions: z.number().int(),
});
export type ReportTotals = z.infer<typeof ReportTotals>;

export const ReportWorkerRow = ReportTotals.extend({
  user_id: z.string(),
  user_name: z.string(),
});
export type ReportWorkerRow = z.infer<typeof ReportWorkerRow>;

export const ReportActivityRow = ReportTotals.extend({
  activity_id: z.number().int(),
  activity_name: z.string(),
});
export type ReportActivityRow = z.infer<typeof ReportActivityRow>;

export const ReportTypeRow = ReportTotals.extend({
  insole_type: z.string(),
});
export type ReportTypeRow = z.infer<typeof ReportTypeRow>;

export const ReportResponse = z.object({
  range: z.object({ from: z.string(), to: z.string() }),
  totals: ReportTotals,
  by_worker: z.array(ReportWorkerRow),
  by_activity: z.array(ReportActivityRow),
  by_type: z.array(ReportTypeRow),
});
export type ReportResponse = z.infer<typeof ReportResponse>;
  • Step 2: Write the failing endpoint test

Create apps/api/test/report.test.ts. Reuse the completedSession pattern (start → backdate → stop) so durations are exact. Helper builds a wide from/to window.

import { describe, it, expect } from 'vitest';
import type { Hono } from 'hono';
import { createApp } from '../src/app';
import { db } from '../src/db/client';
import { workSessions } from '../src/db/schema';
import { eq } from 'drizzle-orm';
import { authToken, bearer, seedActivity } from './helpers';

const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;

async function completed(
  app: Hono,
  token: string,
  activityId: number,
  insoleType: string,
  durationSeconds: number,
  pairCount = 2,
): Promise<number> {
  const startRes = await app.request('/api/sessions/start', {
    method: 'POST',
    headers: bearer(token),
    body: JSON.stringify({ activity_id: activityId, insole_type: insoleType, pair_count: pairCount }),
  });
  const started = await startRes.json();
  await db
    .update(workSessions)
    .set({ startTime: new Date(Date.now() - durationSeconds * 1000) })
    .where(eq(workSessions.id, started.id));
  await app.request(`/api/sessions/${started.id}/stop`, { method: 'POST', headers: bearer(token) });
  return started.id as number;
}

describe('GET /api/admin/report', () => {
  it('401s without a token and 403s for a worker', async () => {
    const app = createApp();
    expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
    const workerTok = await authToken(app, 'report-worker@example.com');
    const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(workerTok) });
    expect(res.status).toBe(403);
  });

  it('400s on a missing or inverted range', async () => {
    const app = createApp();
    const adminTok = await authToken(app, 'report-badrange@example.com', 'admin');
    expect((await app.request('/api/admin/report', { headers: bearer(adminTok) })).status).toBe(400);
    const inverted = `from=${new Date('2026-02-01').toISOString()}&to=${new Date('2026-01-01').toISOString()}`;
    expect(
      (await app.request(`/api/admin/report?${inverted}`, { headers: bearer(adminTok) })).status,
    ).toBe(400);
  });

  it('aggregates totals that equal the sum of each breakdown', async () => {
    const app = createApp();
    const adminTok = await authToken(app, 'report-agg-admin@example.com', 'admin');
    const workerTok = await authToken(app, 'report-agg-worker@example.com'); // 'report-agg-worker'
    const frezen = await seedActivity('Frezen');
    const lijmen = await seedActivity('Lijmen');
    await completed(app, workerTok, frezen, 'Kurk', 100, 2);
    await completed(app, workerTok, lijmen, 'Berk', 50, 3);

    const res = await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) });
    expect(res.status).toBe(200);
    const body = await res.json();

    expect(body.totals.worked_seconds).toBe(150);
    expect(body.totals.pairs).toBe(5);
    expect(body.totals.sessions).toBe(2);

    const sum = (rows: { worked_seconds: number; pairs: number; sessions: number }[]) =>
      rows.reduce(
        (a, r) => ({
          worked_seconds: a.worked_seconds + r.worked_seconds,
          pairs: a.pairs + r.pairs,
          sessions: a.sessions + r.sessions,
        }),
        { worked_seconds: 0, pairs: 0, sessions: 0 },
      );
    expect(sum(body.by_worker)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
    expect(sum(body.by_activity)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
    expect(sum(body.by_type)).toEqual({ worked_seconds: 150, pairs: 5, sessions: 2 });
    expect(body.by_activity).toHaveLength(2);
    expect(body.by_type.map((t: { insole_type: string }) => t.insole_type).sort()).toEqual([
      'Berk',
      'Kurk',
    ]);
  });

  it('excludes active/discarded and respects the user and type filters', async () => {
    const app = createApp();
    const adminTok = await authToken(app, 'report-filter-admin@example.com', 'admin');
    const a = await authToken(app, 'report-filter-a@example.com');
    const b = await authToken(app, 'report-filter-b@example.com');
    const act = await seedActivity('Slijpen');
    await completed(app, a, act, 'Kurk', 30, 2); // counts for A/Kurk
    await completed(app, b, act, 'Berk', 40, 2); // counts for B/Berk
    // A: an active session (no duration) — must be excluded.
    await app.request('/api/sessions/start', {
      method: 'POST',
      headers: bearer(a),
      body: JSON.stringify({ activity_id: act, insole_type: 'Kurk', pair_count: 2 }),
    });

    // Resolve A's user id via the roster.
    const roster = await (
      await app.request('/api/admin/users', { headers: bearer(adminTok) })
    ).json();
    const userA = roster.find((u: { email: string }) => u.email === 'report-filter-a@example.com');

    const res = await app.request(
      `/api/admin/report?${WIDE}&user_id=${userA.id}&insole_type=Kurk`,
      { headers: bearer(adminTok) },
    );
    const body = await res.json();
    expect(body.totals.sessions).toBe(1); // only A's completed Kurk session
    expect(body.totals.worked_seconds).toBe(30);
  });

  it('returns zeros and empty arrays for an empty range', async () => {
    const app = createApp();
    const adminTok = await authToken(app, 'report-empty-admin@example.com', 'admin');
    const empty = `from=${new Date('1990-01-01').toISOString()}&to=${new Date('1990-02-01').toISOString()}`;
    const res = await app.request(`/api/admin/report?${empty}`, { headers: bearer(adminTok) });
    const body = await res.json();
    expect(body.totals).toEqual({ worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 });
    expect(body.by_worker).toEqual([]);
    expect(body.by_activity).toEqual([]);
    expect(body.by_type).toEqual([]);
  });
});
  • Step 3: Run it to verify it fails

Run: yarn workspace @solelog/api test report Expected: FAIL — /api/admin/report returns 404 (route not defined).

  • Step 4: Implement the filter helper + route

In apps/api/src/routes/admin.ts:

Update the drizzle import to add and, gte, lte, and the SQL type:

import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';

Add the shared InsoleType import:

import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';

Add (near the top, after computeDuration):

type ReportQuery = {
  from: Date;
  to: Date;
  userId?: string;
  insoleType?: string;
  activityId?: number;
};

// Parse + validate the shared report/export query params. Returns null on a bad range.
function parseReportQuery(c: { req: { query: (k: string) => string | undefined } }): ReportQuery | null {
  const fromRaw = c.req.query('from');
  const toRaw = c.req.query('to');
  if (!fromRaw || !toRaw) return null;
  const from = new Date(fromRaw);
  const to = new Date(toRaw);
  if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to < from) return null;

  const insoleType = c.req.query('insole_type') || undefined;
  if (insoleType && !InsoleType.safeParse(insoleType).success) return null;

  const activityIdRaw = c.req.query('activity_id');
  let activityId: number | undefined;
  if (activityIdRaw) {
    activityId = Number.parseInt(activityIdRaw, 10);
    if (Number.isNaN(activityId)) return null;
  }

  return { from, to, userId: c.req.query('user_id') || undefined, insoleType, activityId };
}

// completed + date-range + optional worker/type/activity — shared by report and export.
function buildSessionFilters(q: ReportQuery): SQL[] {
  const conds: SQL[] = [
    eq(workSessions.status, 'completed'),
    gte(workSessions.startTime, q.from),
    lte(workSessions.startTime, q.to),
  ];
  if (q.userId) conds.push(eq(workSessions.userId, q.userId));
  if (q.insoleType) conds.push(eq(workSessions.insoleType, q.insoleType));
  if (q.activityId !== undefined) conds.push(eq(workSessions.activityId, q.activityId));
  return conds;
}

Add the route (after the existing /api/admin/users route is fine):

adminRoutes.get('/api/admin/report', async (c) => {
  const q = parseReportQuery(c);
  if (!q) return c.json({ error: 'Invalid query' }, 400);

  const rows = await db
    .select(baseSelect)
    .from(workSessions)
    .leftJoin(activities, eq(workSessions.activityId, activities.id))
    .leftJoin(user, eq(workSessions.userId, user.id))
    .where(and(...buildSessionFilters(q)));

  const totals = { worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
  const workers = new Map<
    string,
    { user_id: string; user_name: string; worked_seconds: number; paused_seconds: number; pairs: number; sessions: number }
  >();
  const acts = new Map<
    number,
    { activity_id: number; activity_name: string; worked_seconds: number; paused_seconds: number; pairs: number; sessions: number }
  >();
  const types = new Map<
    string,
    { insole_type: string; worked_seconds: number; paused_seconds: number; pairs: number; sessions: number }
  >();

  for (const r of rows) {
    const worked = r.session.durationSeconds ?? 0;
    const paused = r.session.pausedSeconds ?? 0;
    const pairs = r.session.pairCount ?? 0;

    totals.worked_seconds += worked;
    totals.paused_seconds += paused;
    totals.pairs += pairs;
    totals.sessions += 1;

    const uid = r.session.userId;
    const w =
      workers.get(uid) ??
      { user_id: uid, user_name: r.userName ?? 'Onbekend', worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
    w.worked_seconds += worked;
    w.paused_seconds += paused;
    w.pairs += pairs;
    w.sessions += 1;
    workers.set(uid, w);

    const aid = r.session.activityId;
    const a =
      acts.get(aid) ??
      { activity_id: aid, activity_name: r.activityName ?? 'Onbekend', worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
    a.worked_seconds += worked;
    a.paused_seconds += paused;
    a.pairs += pairs;
    a.sessions += 1;
    acts.set(aid, a);

    const tkey = r.session.insoleType ?? 'Onbekend';
    const t =
      types.get(tkey) ??
      { insole_type: tkey, worked_seconds: 0, paused_seconds: 0, pairs: 0, sessions: 0 };
    t.worked_seconds += worked;
    t.paused_seconds += paused;
    t.pairs += pairs;
    t.sessions += 1;
    types.set(tkey, t);
  }

  const byWorked = (x: { worked_seconds: number }, y: { worked_seconds: number }) =>
    y.worked_seconds - x.worked_seconds;

  return c.json({
    range: { from: q.from.toISOString(), to: q.to.toISOString() },
    totals,
    by_worker: [...workers.values()].sort(byWorked),
    by_activity: [...acts.values()].sort(byWorked),
    by_type: [...types.values()].sort(byWorked),
  });
});
  • Step 5: Run the test to verify it passes

Run: yarn workspace @solelog/api test report Expected: PASS — all five cases green.

  • Step 6: Typecheck, format, commit
yarn workspace @solelog/api typecheck
npx oxfmt packages/shared/src/index.ts apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git add packages/shared/src/index.ts apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git commit -m "feat(api): admin report endpoint + ReportResponse contracts"

Task 3: /api/admin/export — all-users filtered CSV

Reuse buildSessionFilters (Task 2) and buildSessionsCsv (Task 1) to emit the detail CSV with a Worker column.

Files:

  • Modify: apps/api/src/routes/admin.ts (add the export route + buildSessionsCsv import)
  • Test: apps/api/test/report.test.ts (add an admin export describe block — same file)

Interfaces:

  • Consumes: parseReportQuery, buildSessionFilters (Task 2), buildSessionsCsv (Task 1).

  • Step 1: Add the failing export tests

Append to apps/api/test/report.test.ts:

describe('GET /api/admin/export', () => {
  it('401s without a token and 403s for a worker', async () => {
    const app = createApp();
    expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
    const workerTok = await authToken(app, 'export-admin-worker@example.com');
    const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(workerTok) });
    expect(res.status).toBe(403);
  });

  it('exports all workers with a leading Worker column, range in the filename', async () => {
    const app = createApp();
    const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
    const a = await authToken(app, 'export-allusers-a@example.com');
    const b = await authToken(app, 'export-allusers-b@example.com');
    const act = await seedActivity('Frezen');
    await completed(app, a, act, 'Kurk', 90, 2);
    await completed(app, b, act, 'Berk', 60, 2);

    const res = await app.request(`/api/admin/export?${WIDE}`, { headers: bearer(adminTok) });
    expect(res.status).toBe(200);
    expect(res.headers.get('content-type')).toContain('text/csv');
    expect(res.headers.get('content-disposition')).toMatch(
      /attachment; filename="solelog-report_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv"/,
    );

    const lines = (await res.text()).split('\n');
    expect(lines[0]).toBe(
      '"Worker","ID","Task","Insole Type","No. of Insoles","Date","Total Duration","Paused Duration","Start Time","End Time"',
    );
    expect(lines).toHaveLength(3); // header + 2 workers' sessions
    expect(lines.some((l) => l.includes('export-allusers-a'))).toBe(true);
    expect(lines.some((l) => l.includes('export-allusers-b'))).toBe(true);
  });

  it('respects the type filter', async () => {
    const app = createApp();
    const adminTok = await authToken(app, 'export-typefilter-admin@example.com', 'admin');
    const a = await authToken(app, 'export-typefilter-a@example.com');
    const act = await seedActivity('Lijmen');
    await completed(app, a, act, 'Kurk', 30, 2);
    await completed(app, a, act, 'Berk', 40, 2);

    const res = await app.request(`/api/admin/export?${WIDE}&insole_type=Berk`, {
      headers: bearer(adminTok),
    });
    const lines = (await res.text()).split('\n');
    expect(lines).toHaveLength(2); // header + only the Berk row
    expect(lines[1]).toContain('"Berk"');
  });
});
  • Step 2: Run it to verify it fails

Run: yarn workspace @solelog/api test report Expected: FAIL — /api/admin/export returns 404.

  • Step 3: Implement the export route

In apps/api/src/routes/admin.ts, add buildSessionsCsv to the csv import. There is currently no csv import in admin.ts, so add:

import { buildSessionsCsv } from '../lib/csv';

Add the route after the report route:

adminRoutes.get('/api/admin/export', async (c) => {
  const q = parseReportQuery(c);
  if (!q) return c.json({ error: 'Invalid query' }, 400);

  const rows = await db
    .select(baseSelect)
    .from(workSessions)
    .leftJoin(activities, eq(workSessions.activityId, activities.id))
    .leftJoin(user, eq(workSessions.userId, user.id))
    .where(and(...buildSessionFilters(q)))
    .orderBy(asc(workSessions.startTime));

  const csv = buildSessionsCsv(
    rows.map((r) => ({
      id: r.session.id,
      activityName: r.activityName,
      userName: r.userName,
      insoleType: r.session.insoleType,
      pairCount: r.session.pairCount,
      startTime: r.session.startTime,
      endTime: r.session.endTime,
      durationSeconds: r.session.durationSeconds,
      pausedSeconds: r.session.pausedSeconds,
    })),
    { includeWorker: true },
  );

  const fromDate = q.from.toISOString().slice(0, 10);
  const toDate = q.to.toISOString().slice(0, 10);
  return c.body(csv, 200, {
    'Content-Type': 'text/csv; charset=utf-8',
    'Content-Disposition': `attachment; filename="solelog-report_${fromDate}_${toDate}.csv"`,
  });
});
  • Step 4: Run the test to verify it passes

Run: yarn workspace @solelog/api test report Expected: PASS — report + export describes all green.

  • Step 5: Full API suite, typecheck, format, commit
yarn workspace @solelog/api test
yarn workspace @solelog/api typecheck
npx oxfmt apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git add apps/api/src/routes/admin.ts apps/api/test/report.test.ts
git commit -m "feat(api): admin all-users filtered CSV export"

Task 4: Admin API client — useReport + downloadExport

The React Query hook for the report and the bearer-auth'd CSV download helper.

Files:

  • Create: apps/admin/src/api/reports.ts
  • Test: apps/admin/src/api/reports.test.ts

Interfaces:

  • Consumes: apiFetch, API_URL from ../lib/api; getToken from ../lib/auth-storage; ReportResponse from @solelog/shared.

  • Produces:

    • interface ReportFilters { from: string; to: string; userId?: string; insoleType?: string; activityId?: number }
    • filtersToQuery(f: ReportFilters): string
    • useReport(filters: ReportFilters) (React Query)
    • downloadExport(filters: ReportFilters): Promise<void> — consumed by Task 5.
  • Step 1: Write the failing test

Create apps/admin/src/api/reports.test.ts:

import { afterEach, describe, expect, it, vi } from 'vitest';
import { filtersToQuery, downloadExport, type ReportFilters } from './reports';

vi.mock('../lib/auth-storage', () => ({ getToken: () => 'tok-123' }));

const FILTERS: ReportFilters = {
  from: '2026-06-15T00:00:00.000Z',
  to: '2026-06-21T22:00:00.000Z',
  userId: 'u1',
  insoleType: 'Kurk',
  activityId: 7,
};

describe('filtersToQuery', () => {
  it('serializes all params', () => {
    const q = new URLSearchParams(filtersToQuery(FILTERS));
    expect(q.get('from')).toBe(FILTERS.from);
    expect(q.get('to')).toBe(FILTERS.to);
    expect(q.get('user_id')).toBe('u1');
    expect(q.get('insole_type')).toBe('Kurk');
    expect(q.get('activity_id')).toBe('7');
  });

  it('omits empty optional filters', () => {
    const q = new URLSearchParams(filtersToQuery({ from: 'a', to: 'b' }));
    expect(q.has('user_id')).toBe(false);
    expect(q.has('insole_type')).toBe(false);
    expect(q.has('activity_id')).toBe(false);
  });
});

describe('downloadExport', () => {
  afterEach(() => vi.restoreAllMocks());

  it('fetches with the bearer token and triggers a download', async () => {
    const blob = new Blob(['csv'], { type: 'text/csv' });
    const fetchMock = vi.fn().mockResolvedValue({
      ok: true,
      blob: () => Promise.resolve(blob),
      headers: new Headers({
        'content-disposition': 'attachment; filename="solelog-report_2026-06-15_2026-06-21.csv"',
      }),
    });
    vi.stubGlobal('fetch', fetchMock);
    const createUrl = vi.fn(() => 'blob:x');
    const revokeUrl = vi.fn();
    vi.stubGlobal('URL', { ...URL, createObjectURL: createUrl, revokeObjectURL: revokeUrl });
    const click = vi.fn();
    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(click);

    await downloadExport(FILTERS);

    expect(fetchMock).toHaveBeenCalledTimes(1);
    const [url, init] = fetchMock.mock.calls[0];
    expect(url).toContain('/api/admin/export?');
    expect(url).toContain('insole_type=Kurk');
    expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok-123');
    expect(createUrl).toHaveBeenCalledWith(blob);
    expect(click).toHaveBeenCalledTimes(1);
    expect(revokeUrl).toHaveBeenCalledWith('blob:x');
  });

  it('throws on a non-ok response', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
    await expect(downloadExport(FILTERS)).rejects.toThrow();
  });
});
  • Step 2: Run it to verify it fails

Run: yarn workspace @solelog/admin test reports Expected: FAIL — ./reports module not found.

  • Step 3: Implement the client

Create apps/admin/src/api/reports.ts:

import { useQuery } from '@tanstack/react-query';
import type { ReportResponse } from '@solelog/shared';
import { API_URL, apiFetch } from '../lib/api';
import { getToken } from '../lib/auth-storage';

export interface ReportFilters {
  from: string; // ISO instant (start of from-day, local tz)
  to: string; // ISO instant (end of to-day, local tz)
  userId?: string;
  insoleType?: string;
  activityId?: number;
}

// Build the querystring; omit empty optional filters.
export function filtersToQuery(f: ReportFilters): string {
  const params = new URLSearchParams();
  params.set('from', f.from);
  params.set('to', f.to);
  if (f.userId) params.set('user_id', f.userId);
  if (f.insoleType) params.set('insole_type', f.insoleType);
  if (f.activityId !== undefined) params.set('activity_id', String(f.activityId));
  return params.toString();
}

export function useReport(filters: ReportFilters) {
  return useQuery({
    queryKey: ['admin', 'report', filters],
    queryFn: () => apiFetch<ReportResponse>(`/api/admin/report?${filtersToQuery(filters)}`),
  });
}

function filenameFromResponse(res: Response): string {
  const cd = res.headers.get('content-disposition');
  const m = cd?.match(/filename="(.+?)"/);
  return m ? m[1] : 'solelog-report.csv';
}

// The export endpoint is bearer-auth'd, so a plain <a href> can't carry the token:
// fetch with the Authorization header, then download the resulting Blob.
export async function downloadExport(filters: ReportFilters): Promise<void> {
  const token = getToken();
  const res = await fetch(`${API_URL}/api/admin/export?${filtersToQuery(filters)}`, {
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  });
  if (!res.ok) throw new Error(`Export mislukt: ${res.status}`);
  const blob = await res.blob();
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filenameFromResponse(res);
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}
  • Step 4: Run the test to verify it passes

Run: yarn workspace @solelog/admin test reports Expected: PASS — both describes green.

  • Step 5: Typecheck, format, commit
yarn workspace @solelog/admin typecheck
npx oxfmt apps/admin/src/api/reports.ts apps/admin/src/api/reports.test.ts
git add apps/admin/src/api/reports.ts apps/admin/src/api/reports.test.ts
git commit -m "feat(admin): reports API client (useReport + downloadExport)"

Task 5: Rapporten screen + nav wiring

The tables-only screen: filter bar with presets, headline totals, three breakdown tables, and the export button. Plus the sidebar/route wiring.

Files:

  • Create: apps/admin/src/screens/Reports.tsx
  • Create: apps/admin/src/lib/date-range.ts (preset/date helpers — pure, unit-tested)
  • Modify: apps/admin/src/components/Sidebar.tsx (move Rapporten into nav)
  • Modify: apps/admin/src/App.tsx (add /rapporten route)
  • Modify: apps/admin/src/styles.css (report table styles — minimal, reuse existing classes where possible)
  • Test: apps/admin/src/lib/date-range.test.ts, apps/admin/src/screens/Reports.test.tsx

Interfaces:

  • Consumes: useReport, downloadExport, ReportFilters (Task 4); useAdminUsers (../api/admin-sessions); useActivities (../api/activities); formatTime (../lib/elapsed).

  • Produces: localDayRange helpers (see below) and the <Reports /> screen.

  • Step 1: Write the failing date-range helper test

Create apps/admin/src/lib/date-range.test.ts:

import { describe, expect, it } from 'vitest';
import { dayStartISO, dayEndISO, thisWeek, thisMonth } from './date-range';

describe('date-range helpers', () => {
  it('dayStartISO/dayEndISO bound a local day', () => {
    const start = new Date(dayStartISO('2026-06-17'));
    const end = new Date(dayEndISO('2026-06-17'));
    expect(start.getHours()).toBe(0);
    expect(start.getMinutes()).toBe(0);
    expect(end.getHours()).toBe(23);
    expect(end.getMinutes()).toBe(59);
    expect(end.getTime()).toBeGreaterThan(start.getTime());
  });

  it('thisWeek returns Monday..today (from/to are YYYY-MM-DD)', () => {
    const ref = new Date('2026-06-17T12:00:00'); // a Wednesday
    const { from, to } = thisWeek(ref);
    expect(from).toBe('2026-06-15'); // Monday
    expect(to).toBe('2026-06-17');
  });

  it('thisMonth returns the 1st..today', () => {
    const ref = new Date('2026-06-17T12:00:00');
    const { from, to } = thisMonth(ref);
    expect(from).toBe('2026-06-01');
    expect(to).toBe('2026-06-17');
  });
});
  • Step 2: Run it to verify it fails

Run: yarn workspace @solelog/admin test date-range Expected: FAIL — ./date-range not found.

  • Step 3: Implement the date-range helpers

Create apps/admin/src/lib/date-range.ts:

// Helpers for the report filter bar. Dates are 'YYYY-MM-DD' (what <input type="date"> uses);
// the *ISO instants* sent to the API are derived in the admin's local timezone so a picked
// day means that whole local day.

function pad(n: number): string {
  return String(n).padStart(2, '0');
}

export function toDateStr(d: Date): string {
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

// Start of the given local day, as an ISO instant.
export function dayStartISO(dateStr: string): string {
  const [y, m, d] = dateStr.split('-').map(Number);
  return new Date(y, m - 1, d, 0, 0, 0, 0).toISOString();
}

// End of the given local day, as an ISO instant.
export function dayEndISO(dateStr: string): string {
  const [y, m, d] = dateStr.split('-').map(Number);
  return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
}

export function thisWeek(ref: Date): { from: string; to: string } {
  const day = ref.getDay(); // 0=Sun..6=Sat
  const diffToMonday = (day + 6) % 7;
  const monday = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate() - diffToMonday);
  return { from: toDateStr(monday), to: toDateStr(ref) };
}

export function thisMonth(ref: Date): { from: string; to: string } {
  const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
  return { from: toDateStr(first), to: toDateStr(ref) };
}

// 'Alles' — a wide window from epoch-ish to today.
export function allTime(ref: Date): { from: string; to: string } {
  return { from: '2000-01-01', to: toDateStr(ref) };
}
  • Step 4: Run the helper test to verify it passes

Run: yarn workspace @solelog/admin test date-range Expected: PASS.

  • Step 5: Write the failing Reports screen test

Create apps/admin/src/screens/Reports.test.tsx:

import { render, screen, waitFor } 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 { ReportResponse } from '@solelog/shared';
import Reports from './Reports';
import { apiFetch } from '../lib/api';
import * as reportsApi from '../api/reports';

vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), API_URL: 'http://test' }));
const mockApiFetch = vi.mocked(apiFetch);

const REPORT: ReportResponse = {
  range: { from: '2026-06-15T00:00:00.000Z', to: '2026-06-17T21:59:59.999Z' },
  totals: { worked_seconds: 7800, paused_seconds: 600, pairs: 84, sessions: 36 },
  by_worker: [
    { user_id: 'u1', user_name: 'Jan', worked_seconds: 4500, paused_seconds: 300, pairs: 48, sessions: 21 },
    { user_id: 'u2', user_name: 'An', worked_seconds: 3300, paused_seconds: 300, pairs: 36, sessions: 15 },
  ],
  by_activity: [
    { activity_id: 1, activity_name: 'Frezen', worked_seconds: 4900, paused_seconds: 200, pairs: 40, sessions: 18 },
    { activity_id: 2, activity_name: 'Lijmen', worked_seconds: 2900, paused_seconds: 400, pairs: 44, sessions: 18 },
  ],
  by_type: [
    { insole_type: 'Kurk', worked_seconds: 5000, paused_seconds: 300, pairs: 50, sessions: 20 },
    { insole_type: 'Berk', worked_seconds: 2800, paused_seconds: 300, pairs: 34, sessions: 16 },
  ],
};

function mockEndpoints() {
  mockApiFetch.mockImplementation((path: string) => {
    if (path.startsWith('/api/admin/report')) return Promise.resolve(REPORT as never);
    if (path === '/api/admin/users')
      return Promise.resolve([{ id: 'u1', name: 'Jan', email: 'jan@x' }] as never);
    if (path === '/api/activities') return Promise.resolve([] as never);
    return Promise.resolve([] as never);
  });
}

function renderReports() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={queryClient}>
      <Reports />
    </QueryClientProvider>,
  );
}

describe('Reports', () => {
  beforeEach(() => mockApiFetch.mockReset());
  afterEach(() => vi.clearAllMocks());

  it('renders headline totals and the three breakdown tables', async () => {
    mockEndpoints();
    renderReports();

    // Headline: 7800s = 02:10:00 worked, 84 zolen, 36 sessies.
    expect(await screen.findByText(/02:10:00/)).toBeInTheDocument();
    expect(screen.getByText(/84/)).toBeInTheDocument();
    expect(screen.getByText(/36/)).toBeInTheDocument();

    expect(screen.getByText('Per medewerker')).toBeInTheDocument();
    expect(screen.getByText('Per handeling')).toBeInTheDocument();
    expect(screen.getByText('Per type')).toBeInTheDocument();
    expect(screen.getByText('Jan')).toBeInTheDocument();
    expect(screen.getByText('Frezen')).toBeInTheDocument();
    expect(screen.getByText('Kurk')).toBeInTheDocument();
  });

  it('refetches with new params when a preset is clicked', async () => {
    mockEndpoints();
    renderReports();
    await screen.findByText('Jan');

    const callsBefore = mockApiFetch.mock.calls.filter((c) =>
      String(c[0]).startsWith('/api/admin/report'),
    ).length;
    await userEvent.click(screen.getByRole('button', { name: 'Deze maand' }));

    await waitFor(() => {
      const reportCalls = mockApiFetch.mock.calls.filter((c) =>
        String(c[0]).startsWith('/api/admin/report'),
      );
      expect(reportCalls.length).toBeGreaterThan(callsBefore);
    });
  });

  it('calls downloadExport with the current filters when Exporteer CSV is clicked', async () => {
    mockEndpoints();
    const spy = vi.spyOn(reportsApi, 'downloadExport').mockResolvedValue();
    renderReports();
    await screen.findByText('Jan');

    await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
    expect(spy).toHaveBeenCalledTimes(1);
    const arg = spy.mock.calls[0][0];
    expect(arg.from).toBeTruthy();
    expect(arg.to).toBeTruthy();
  });
});
  • Step 6: Run it to verify it fails

Run: yarn workspace @solelog/admin test Reports Expected: FAIL — ./Reports not found.

  • Step 7: Implement the screen

Create apps/admin/src/screens/Reports.tsx. It imports downloadExport via the module namespace so the test's vi.spyOn(reportsApi, 'downloadExport') intercepts it.

import { useMemo, useState } from 'react';
import type { InsoleType, ReportTotals } from '@solelog/shared';
import { useReport, type ReportFilters } from '../api/reports';
import * as reportsApi from '../api/reports';
import { useAdminUsers } from '../api/admin-sessions';
import { useActivities } from '../api/activities';
import { formatTime } from '../lib/elapsed';
import { allTime, dayEndISO, dayStartISO, thisMonth, thisWeek, toDateStr } from '../lib/date-range';

type DateRange = { from: string; to: string }; // YYYY-MM-DD

const TYPES: InsoleType[] = ['Kurk', 'Berk', '3D'];

export default function Reports() {
  const [range, setRange] = useState<DateRange>(() => thisWeek(new Date()));
  const [userId, setUserId] = useState('');
  const [insoleType, setInsoleType] = useState('');
  const [activityId, setActivityId] = useState('');

  const filters: ReportFilters = useMemo(
    () => ({
      from: dayStartISO(range.from),
      to: dayEndISO(range.to),
      userId: userId || undefined,
      insoleType: insoleType || undefined,
      activityId: activityId ? Number(activityId) : undefined,
    }),
    [range, userId, insoleType, activityId],
  );

  const reportQuery = useReport(filters);
  const usersQuery = useAdminUsers();
  const activitiesQuery = useActivities();
  const [exportError, setExportError] = useState<string | null>(null);

  async function onExport() {
    setExportError(null);
    try {
      await reportsApi.downloadExport(filters);
    } catch {
      setExportError('Export mislukt. Probeer opnieuw.');
    }
  }

  const t = reportQuery.data?.totals;

  return (
    <div className="screen">
      <div className="reports-head">
        <h1 className="screen-title">Rapporten</h1>
        <button type="button" className="btn-primary" onClick={onExport}>
          Exporteer CSV
        </button>
      </div>

      <div className="reports-filters">
        <div className="reports-presets">
          <button type="button" onClick={() => setRange(thisWeek(new Date()))}>
            Deze week
          </button>
          <button type="button" onClick={() => setRange(thisMonth(new Date()))}>
            Deze maand
          </button>
          <button type="button" onClick={() => setRange(allTime(new Date()))}>
            Alles
          </button>
        </div>
        <label>
          Van
          <input
            type="date"
            value={range.from}
            max={range.to}
            onChange={(e) => setRange((r) => ({ ...r, from: e.target.value }))}
          />
        </label>
        <label>
          Tot
          <input
            type="date"
            value={range.to}
            min={range.from}
            max={toDateStr(new Date())}
            onChange={(e) => setRange((r) => ({ ...r, to: e.target.value }))}
          />
        </label>
        <label>
          Medewerker
          <select value={userId} onChange={(e) => setUserId(e.target.value)}>
            <option value="">Alle</option>
            {(usersQuery.data ?? []).map((u) => (
              <option key={u.id} value={u.id}>
                {u.name}
              </option>
            ))}
          </select>
        </label>
        <label>
          Type
          <select value={insoleType} onChange={(e) => setInsoleType(e.target.value)}>
            <option value="">Alle</option>
            {TYPES.map((ty) => (
              <option key={ty} value={ty}>
                {ty}
              </option>
            ))}
          </select>
        </label>
        <label>
          Handeling
          <select value={activityId} onChange={(e) => setActivityId(e.target.value)}>
            <option value="">Alle</option>
            {(activitiesQuery.data ?? []).map((a) => (
              <option key={a.id} value={String(a.id)}>
                {a.name}
              </option>
            ))}
          </select>
        </label>
      </div>

      {exportError && <p className="form-error">{exportError}</p>}

      {reportQuery.isLoading ? (
        <p className="muted">Laden</p>
      ) : reportQuery.isError ? (
        <p className="muted">Kon rapport niet laden.</p>
      ) : (
        <>
          <div className="reports-totals">
            <strong>{formatTime(t?.worked_seconds ?? 0)}</strong> gewerkt ·{' '}
            <strong>{t?.pairs ?? 0}</strong> zolen · <strong>{t?.sessions ?? 0}</strong> sessies ·{' '}
            <strong>{formatTime(t?.paused_seconds ?? 0)}</strong> pauze
          </div>

          <BreakdownTable
            title="Per medewerker"
            label="Medewerker"
            rows={(reportQuery.data?.by_worker ?? []).map((r) => ({ key: r.user_id, name: r.user_name, ...r }))}
          />
          <BreakdownTable
            title="Per handeling"
            label="Handeling"
            rows={(reportQuery.data?.by_activity ?? []).map((r) => ({ key: String(r.activity_id), name: r.activity_name, ...r }))}
          />
          <BreakdownTable
            title="Per type"
            label="Type"
            rows={(reportQuery.data?.by_type ?? []).map((r) => ({ key: r.insole_type, name: r.insole_type, ...r }))}
          />
        </>
      )}
    </div>
  );
}

type BreakdownRow = ReportTotals & { key: string; name: string };

function BreakdownTable({ title, label, rows }: { title: string; label: string; rows: BreakdownRow[] }) {
  return (
    <section className="reports-section">
      <h2 className="reports-section-title">{title}</h2>
      {rows.length === 0 ? (
        <p className="muted">Geen gegevens.</p>
      ) : (
        <table className="reports-table">
          <thead>
            <tr>
              <th>{label}</th>
              <th>Gewerkt</th>
              <th>Zolen</th>
              <th>Sessies</th>
              <th>Pauze</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r) => (
              <tr key={r.key}>
                <td>{r.name}</td>
                <td>{formatTime(r.worked_seconds)}</td>
                <td>{r.pairs}</td>
                <td>{r.sessions}</td>
                <td>{formatTime(r.paused_seconds)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  );
}
  • Step 8: Wire nav + route

In apps/admin/src/components/Sidebar.tsx, move Rapporten into navItems and shrink soonItems:

const navItems = [
  { to: '/', label: 'Live' },
  { to: '/handelingen', label: 'Handelingen' },
  { to: '/sessies', label: 'Sessies' },
  { to: '/rapporten', label: 'Rapporten' },
] as const;

// Sections planned for the final Phase 3b cycle — shown muted/disabled.
const soonItems = ['Gebruikers'] as const;

In apps/admin/src/App.tsx, import and add the route:

import Reports from './screens/Reports';
            <Route path="/sessies" element={<Sessions />} />
            <Route path="/rapporten" element={<Reports />} />
  • Step 9: Add minimal styles

Append to apps/admin/src/styles.css:

.reports-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}
.reports-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.75rem;
  margin: 1rem 0;
}
.reports-filters label {
  display: flex;
  flex-direction: column;
  font-size: 0.8rem;
  gap: 0.25rem;
}
.reports-presets {
  display: flex;
  gap: 0.5rem;
}
.reports-totals {
  padding: 0.75rem 1rem;
  background: var(--surface, #f4f4f5);
  border-radius: 0.5rem;
  margin-bottom: 1.5rem;
}
.reports-section {
  margin-bottom: 1.5rem;
}
.reports-section-title {
  font-size: 1rem;
  margin-bottom: 0.5rem;
}
.reports-table {
  width: 100%;
  border-collapse: collapse;
}
.reports-table th,
.reports-table td {
  text-align: left;
  padding: 0.4rem 0.6rem;
  border-bottom: 1px solid var(--border, #e4e4e7);
}
.reports-table td:not(:first-child),
.reports-table th:not(:first-child) {
  text-align: right;
}
  • Step 10: Run the screen + helper tests

Run: yarn workspace @solelog/admin test Reports date-range Expected: PASS — all three Reports cases + date-range cases green.

  • Step 11: Full admin suite, typecheck, build, format, commit
yarn workspace @solelog/admin test
yarn workspace @solelog/admin typecheck
yarn workspace @solelog/admin build
npx oxfmt apps/admin/src/screens/Reports.tsx apps/admin/src/screens/Reports.test.tsx apps/admin/src/lib/date-range.ts apps/admin/src/lib/date-range.test.ts apps/admin/src/components/Sidebar.tsx apps/admin/src/App.tsx
git add apps/admin/src/screens/Reports.tsx apps/admin/src/screens/Reports.test.tsx apps/admin/src/lib/date-range.ts apps/admin/src/lib/date-range.test.ts apps/admin/src/components/Sidebar.tsx apps/admin/src/App.tsx apps/admin/src/styles.css
git commit -m "feat(admin): Rapporten screen with filters, totals, breakdowns + CSV export"

Final verification (after all tasks)

  • yarn workspace @solelog/api test — all green (incl. csv, report, regression export).
  • yarn workspace @solelog/admin test — all green (incl. reports, date-range, Reports).
  • yarn workspace @solelog/api typecheck && yarn workspace @solelog/admin typecheck — clean.
  • yarn workspace @solelog/admin build — succeeds.
  • npx oxlint — clean.
  • git log --oneline shows five task commits.

Self-review notes (plan vs spec)

  • Spec A (report endpoint): Task 2. Filtered query helper, JS aggregation, sort by worked desc, empty→zeros, null type bucketed. ✓
  • Spec A (export endpoint): Task 3. All-users, Worker column, range filename, filter-respecting. ✓
  • Spec A (DRY refactor): Task 1. buildSessionsCsv with includeWorker; regression guard via existing export.test.ts. ✓
  • Spec B (contracts): Task 2 Step 1. ReportResponse + row types. ✓
  • Spec C (UI): Task 5. Sidebar move, /rapporten route, filter bar + presets, headline totals, three tables, bearer download. ✓ Task 4 supplies useReport/downloadExport/filtersToQuery. ✓
  • Metrics (all four): worked/paused/pairs/sessions in totals + every breakdown row + table columns. ✓
  • Completed-only / admin-gated / date semantics: Global Constraints; enforced in buildSessionFilters + parseReportQuery (server) and date-range.ts (client). ✓
  • Testing: API aggregation/boundary/filter/gating + export; admin client + screen + date helpers. ✓
  • No new deps; tables-only: confirmed — only URLSearchParams, Blob, existing helpers. ✓