1397 lines
50 KiB
Markdown
1397 lines
50 KiB
Markdown
# 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`:
|
|
|
|
```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`:
|
|
|
|
```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`:
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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.
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
import { and, asc, desc, eq, gte, lte, type SQL } from 'drizzle-orm';
|
|
```
|
|
|
|
Add the shared `InsoleType` import:
|
|
|
|
```ts
|
|
import { AdminUpdateSessionInput, CreateManualSessionInput, InsoleType } from '@solelog/shared';
|
|
```
|
|
|
|
Add (near the top, after `computeDuration`):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
import { buildSessionsCsv } from '../lib/csv';
|
|
```
|
|
|
|
Add the route after the report route:
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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`:
|
|
|
|
```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.
|
|
|
|
```tsx
|
|
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`:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
import Reports from './screens/Reports';
|
|
```
|
|
|
|
```tsx
|
|
<Route path="/sessies" element={<Sessions />} />
|
|
<Route path="/rapporten" element={<Reports />} />
|
|
```
|
|
|
|
- [ ] **Step 9: Add minimal styles**
|
|
|
|
Append to `apps/admin/src/styles.css`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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. ✓
|