test(api): Reports + admin export — fill gaps + lifecycle test
Drives the report/export parts of src/routes/admin.ts to 100% stmts/branch/funcs/lines. Adds one lifecycle test plus gap tests for the previously-uncovered branches; no production behavior changed. Tests added (apps/api/test): - report.test.ts: invalid insole_type / non-numeric activity_id → 400; activity filter narrows; null insole_type bucketed "Onbekend"; null duration counts as 0 worked; export 400 on missing/inverted range. Migrated the report + export describes to resetDb(beforeEach) so exact totals are order-independent (removed the activity_id isolation workaround in the all-users export test). - integration/report-aggregation.test.ts: lifecycle — 2 workers × 2 activities × 2 types → exact totals + every by_worker/by_activity/ by_type row (values and worked-desc order) → user_id filter narrows. Defensive annotations in admin.ts report loop (behavior unchanged): - pausedSeconds/pairCount ?? 0 — NOT NULL columns, DB-impossible nulls. - user_name/activity_name ?? 'Onbekend' — unreachable; userId FK cascades on delete and activityId FK is enforced (libsql enforces FKs), so every session always joins to a real user and activity. No bugs found; the code was already correct.
This commit is contained in:
@@ -152,6 +152,7 @@ adminRoutes.get('/api/admin/report', async (c) => {
|
|||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const worked = r.session.durationSeconds ?? 0;
|
const worked = r.session.durationSeconds ?? 0;
|
||||||
|
/* c8 ignore next 2 -- pausedSeconds & pairCount are NOT NULL (schema defaults); the ?? guards DB-impossible nulls */
|
||||||
const paused = r.session.pausedSeconds ?? 0;
|
const paused = r.session.pausedSeconds ?? 0;
|
||||||
const pairs = r.session.pairCount ?? 0;
|
const pairs = r.session.pairCount ?? 0;
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ adminRoutes.get('/api/admin/report', async (c) => {
|
|||||||
const uid = r.session.userId;
|
const uid = r.session.userId;
|
||||||
const w = workers.get(uid) ?? {
|
const w = workers.get(uid) ?? {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
|
/* c8 ignore next -- userId FK cascades on user delete, so a session's user always joins */
|
||||||
user_name: r.userName ?? 'Onbekend',
|
user_name: r.userName ?? 'Onbekend',
|
||||||
worked_seconds: 0,
|
worked_seconds: 0,
|
||||||
paused_seconds: 0,
|
paused_seconds: 0,
|
||||||
@@ -178,6 +180,7 @@ adminRoutes.get('/api/admin/report', async (c) => {
|
|||||||
const aid = r.session.activityId;
|
const aid = r.session.activityId;
|
||||||
const a = acts.get(aid) ?? {
|
const a = acts.get(aid) ?? {
|
||||||
activity_id: aid,
|
activity_id: aid,
|
||||||
|
/* c8 ignore next -- activityId FK is enforced, so a session's activity always joins */
|
||||||
activity_name: r.activityName ?? 'Onbekend',
|
activity_name: r.activityName ?? 'Onbekend',
|
||||||
worked_seconds: 0,
|
worked_seconds: 0,
|
||||||
paused_seconds: 0,
|
paused_seconds: 0,
|
||||||
|
|||||||
162
apps/api/test/integration/report-aggregation.test.ts
Normal file
162
apps/api/test/integration/report-aggregation.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { createApp } from '../../src/app';
|
||||||
|
import { db } from '../../src/db/client';
|
||||||
|
import { user, workSessions } from '../../src/db/schema';
|
||||||
|
import { authToken, bearer, seedActivity, resetDb } from '../helpers';
|
||||||
|
|
||||||
|
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
|
||||||
|
|
||||||
|
async function userIdByEmail(email: string): Promise<string> {
|
||||||
|
const [u] = await db.select({ id: user.id }).from(user).where(eq(user.email, email));
|
||||||
|
return u.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a completed session with exact, known metrics — no timing, fully deterministic.
|
||||||
|
async function seedCompleted(opts: {
|
||||||
|
userId: string;
|
||||||
|
activityId: number;
|
||||||
|
insoleType: string;
|
||||||
|
worked: number;
|
||||||
|
paused: number;
|
||||||
|
pairs: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const start = new Date('2026-03-01T08:00:00.000Z');
|
||||||
|
await db.insert(workSessions).values({
|
||||||
|
userId: opts.userId,
|
||||||
|
activityId: opts.activityId,
|
||||||
|
insoleType: opts.insoleType,
|
||||||
|
pairCount: opts.pairs,
|
||||||
|
startTime: start,
|
||||||
|
endTime: new Date(start.getTime() + (opts.worked + opts.paused) * 1000),
|
||||||
|
durationSeconds: opts.worked,
|
||||||
|
pausedSeconds: opts.paused,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('report aggregation lifecycle (2 workers × 2 activities → exact totals + breakdowns)', () => {
|
||||||
|
beforeEach(resetDb);
|
||||||
|
|
||||||
|
it('reports exact totals, breakdowns sorted by worked desc, and narrows on a worker filter', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-agg-life-admin@example.com', 'admin');
|
||||||
|
await authToken(app, 'report-agg-life-w1@example.com');
|
||||||
|
await authToken(app, 'report-agg-life-w2@example.com');
|
||||||
|
const w1 = await userIdByEmail('report-agg-life-w1@example.com');
|
||||||
|
const w2 = await userIdByEmail('report-agg-life-w2@example.com');
|
||||||
|
const frezen = await seedActivity('Frezen');
|
||||||
|
const lijmen = await seedActivity('Lijmen');
|
||||||
|
|
||||||
|
// W1: Frezen/Kurk 300s, Lijmen/Berk 100s. W2: Frezen/Kurk 200s, Lijmen/Berk 50s.
|
||||||
|
await seedCompleted({
|
||||||
|
userId: w1,
|
||||||
|
activityId: frezen,
|
||||||
|
insoleType: 'Kurk',
|
||||||
|
worked: 300,
|
||||||
|
paused: 30,
|
||||||
|
pairs: 2,
|
||||||
|
});
|
||||||
|
await seedCompleted({
|
||||||
|
userId: w1,
|
||||||
|
activityId: lijmen,
|
||||||
|
insoleType: 'Berk',
|
||||||
|
worked: 100,
|
||||||
|
paused: 10,
|
||||||
|
pairs: 1,
|
||||||
|
});
|
||||||
|
await seedCompleted({
|
||||||
|
userId: w2,
|
||||||
|
activityId: frezen,
|
||||||
|
insoleType: 'Kurk',
|
||||||
|
worked: 200,
|
||||||
|
paused: 20,
|
||||||
|
pairs: 3,
|
||||||
|
});
|
||||||
|
await seedCompleted({
|
||||||
|
userId: w2,
|
||||||
|
activityId: lijmen,
|
||||||
|
insoleType: 'Berk',
|
||||||
|
worked: 50,
|
||||||
|
paused: 5,
|
||||||
|
pairs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await (
|
||||||
|
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
|
||||||
|
).json();
|
||||||
|
|
||||||
|
// Totals sum every session.
|
||||||
|
expect(body.totals).toEqual({ worked_seconds: 650, paused_seconds: 65, pairs: 7, sessions: 4 });
|
||||||
|
|
||||||
|
// by_worker: W1 (400) before W2 (250) — sorted worked desc.
|
||||||
|
expect(body.by_worker.map((w: { user_name: string }) => w.user_name)).toEqual([
|
||||||
|
'report-agg-life-w1',
|
||||||
|
'report-agg-life-w2',
|
||||||
|
]);
|
||||||
|
expect(body.by_worker[0]).toMatchObject({
|
||||||
|
user_id: w1,
|
||||||
|
worked_seconds: 400,
|
||||||
|
paused_seconds: 40,
|
||||||
|
pairs: 3,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
expect(body.by_worker[1]).toMatchObject({
|
||||||
|
user_id: w2,
|
||||||
|
worked_seconds: 250,
|
||||||
|
paused_seconds: 25,
|
||||||
|
pairs: 4,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// by_activity: Frezen (500) before Lijmen (150).
|
||||||
|
expect(body.by_activity.map((a: { activity_name: string }) => a.activity_name)).toEqual([
|
||||||
|
'Frezen',
|
||||||
|
'Lijmen',
|
||||||
|
]);
|
||||||
|
expect(body.by_activity[0]).toMatchObject({
|
||||||
|
activity_id: frezen,
|
||||||
|
worked_seconds: 500,
|
||||||
|
pairs: 5,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
expect(body.by_activity[1]).toMatchObject({
|
||||||
|
activity_id: lijmen,
|
||||||
|
worked_seconds: 150,
|
||||||
|
pairs: 2,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// by_type: Kurk (500) before Berk (150).
|
||||||
|
expect(body.by_type.map((t: { insole_type: string }) => t.insole_type)).toEqual([
|
||||||
|
'Kurk',
|
||||||
|
'Berk',
|
||||||
|
]);
|
||||||
|
expect(body.by_type[0]).toMatchObject({
|
||||||
|
insole_type: 'Kurk',
|
||||||
|
worked_seconds: 500,
|
||||||
|
pairs: 5,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
expect(body.by_type[1]).toMatchObject({
|
||||||
|
insole_type: 'Berk',
|
||||||
|
worked_seconds: 150,
|
||||||
|
pairs: 2,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A worker filter narrows totals and breakdowns to W1 only.
|
||||||
|
const narrowed = await (
|
||||||
|
await app.request(`/api/admin/report?${WIDE}&user_id=${w1}`, { headers: bearer(adminTok) })
|
||||||
|
).json();
|
||||||
|
expect(narrowed.totals).toEqual({
|
||||||
|
worked_seconds: 400,
|
||||||
|
paused_seconds: 40,
|
||||||
|
pairs: 3,
|
||||||
|
sessions: 2,
|
||||||
|
});
|
||||||
|
expect(narrowed.by_worker).toHaveLength(1);
|
||||||
|
expect(narrowed.by_worker[0].user_id).toBe(w1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import { createApp } from '../src/app';
|
import { createApp } from '../src/app';
|
||||||
import { db } from '../src/db/client';
|
import { db } from '../src/db/client';
|
||||||
import { workSessions } from '../src/db/schema';
|
import { user, workSessions } from '../src/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { authToken, bearer, seedActivity } from './helpers';
|
import { authToken, bearer, seedActivity, resetDb } from './helpers';
|
||||||
|
|
||||||
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
|
const WIDE = `from=${new Date('2000-01-01').toISOString()}&to=${new Date('2100-01-01').toISOString()}`;
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ async function completed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('GET /api/admin/report', () => {
|
describe('GET /api/admin/report', () => {
|
||||||
|
beforeEach(resetDb);
|
||||||
|
|
||||||
it('401s without a token and 403s for a worker', async () => {
|
it('401s without a token and 403s for a worker', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
|
expect((await app.request(`/api/admin/report?${WIDE}`)).status).toBe(401);
|
||||||
@@ -132,9 +134,114 @@ describe('GET /api/admin/report', () => {
|
|||||||
expect(body.by_activity).toEqual([]);
|
expect(body.by_activity).toEqual([]);
|
||||||
expect(body.by_type).toEqual([]);
|
expect(body.by_type).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('400s on an invalid insole_type or a non-numeric activity_id', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-badfilter@example.com', 'admin');
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(`/api/admin/report?${WIDE}&insole_type=Plastic`, {
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.request(`/api/admin/report?${WIDE}&activity_id=abc`, {
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows by the activity filter', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-actfilter-admin@example.com', 'admin');
|
||||||
|
const worker = await authToken(app, 'report-actfilter-worker@example.com');
|
||||||
|
const frezen = await seedActivity('Frezen');
|
||||||
|
const lijmen = await seedActivity('Lijmen');
|
||||||
|
await completed(app, worker, frezen, 'Kurk', 100, 2);
|
||||||
|
await completed(app, worker, lijmen, 'Berk', 40, 2);
|
||||||
|
|
||||||
|
const body = await (
|
||||||
|
await app.request(`/api/admin/report?${WIDE}&activity_id=${frezen}`, {
|
||||||
|
headers: bearer(adminTok),
|
||||||
|
})
|
||||||
|
).json();
|
||||||
|
expect(body.totals.sessions).toBe(1);
|
||||||
|
expect(body.totals.worked_seconds).toBe(100);
|
||||||
|
expect(body.by_activity).toHaveLength(1);
|
||||||
|
expect(body.by_activity[0].activity_id).toBe(frezen);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buckets a session with a null insole_type under "Onbekend"', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-nulltype-admin@example.com', 'admin');
|
||||||
|
await authToken(app, 'report-nulltype-worker@example.com');
|
||||||
|
const [w] = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, 'report-nulltype-worker@example.com'));
|
||||||
|
const act = await seedActivity('Frezen');
|
||||||
|
await db.insert(workSessions).values({
|
||||||
|
userId: w.id,
|
||||||
|
activityId: act,
|
||||||
|
insoleType: null,
|
||||||
|
pairCount: 2,
|
||||||
|
startTime: new Date('2026-03-01T08:00:00.000Z'),
|
||||||
|
endTime: new Date('2026-03-01T08:05:00.000Z'),
|
||||||
|
durationSeconds: 300,
|
||||||
|
pausedSeconds: 0,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await (
|
||||||
|
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
|
||||||
|
).json();
|
||||||
|
// Only the type bucket falls back; the worker/activity are real.
|
||||||
|
expect(body.by_type).toHaveLength(1);
|
||||||
|
expect(body.by_type[0].insole_type).toBe('Onbekend');
|
||||||
|
expect(body.by_type[0].worked_seconds).toBe(300);
|
||||||
|
expect(body.by_worker[0].user_name).toBe('report-nulltype-worker');
|
||||||
|
expect(body.by_activity[0].activity_name).toBe('Frezen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts a completed session with a null duration as 0 worked seconds', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'report-nulldur-admin@example.com', 'admin');
|
||||||
|
await authToken(app, 'report-nulldur-worker@example.com');
|
||||||
|
const [w] = await db
|
||||||
|
.select({ id: user.id })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.email, 'report-nulldur-worker@example.com'));
|
||||||
|
const act = await seedActivity('Frezen');
|
||||||
|
// A completed row whose duration was never computed (e.g. admin edit with no end time).
|
||||||
|
await db.insert(workSessions).values({
|
||||||
|
userId: w.id,
|
||||||
|
activityId: act,
|
||||||
|
insoleType: 'Kurk',
|
||||||
|
pairCount: 2,
|
||||||
|
startTime: new Date('2026-03-01T08:00:00.000Z'),
|
||||||
|
endTime: null,
|
||||||
|
durationSeconds: null,
|
||||||
|
pausedSeconds: 0,
|
||||||
|
status: 'completed',
|
||||||
|
source: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await (
|
||||||
|
await app.request(`/api/admin/report?${WIDE}`, { headers: bearer(adminTok) })
|
||||||
|
).json();
|
||||||
|
expect(body.totals.sessions).toBe(1);
|
||||||
|
expect(body.totals.worked_seconds).toBe(0); // null duration counts as 0
|
||||||
|
expect(body.by_worker[0].worked_seconds).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/admin/export', () => {
|
describe('GET /api/admin/export', () => {
|
||||||
|
beforeEach(resetDb);
|
||||||
|
|
||||||
it('401s without a token and 403s for a worker', async () => {
|
it('401s without a token and 403s for a worker', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
|
expect((await app.request(`/api/admin/export?${WIDE}`)).status).toBe(401);
|
||||||
@@ -143,6 +250,18 @@ describe('GET /api/admin/export', () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('400s on a missing or inverted range', async () => {
|
||||||
|
const app = createApp();
|
||||||
|
const adminTok = await authToken(app, 'export-badquery-admin@example.com', 'admin');
|
||||||
|
expect((await app.request('/api/admin/export', { 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/export?${inverted}`, { headers: bearer(adminTok) })).status,
|
||||||
|
).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it('exports all workers with a leading Worker column, range in the filename', async () => {
|
it('exports all workers with a leading Worker column, range in the filename', async () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
|
const adminTok = await authToken(app, 'export-admin@example.com', 'admin');
|
||||||
@@ -152,9 +271,8 @@ describe('GET /api/admin/export', () => {
|
|||||||
await completed(app, a, act, 'Kurk', 90, 2);
|
await completed(app, a, act, 'Kurk', 90, 2);
|
||||||
await completed(app, b, act, 'Berk', 60, 2);
|
await completed(app, b, act, 'Berk', 60, 2);
|
||||||
|
|
||||||
// Scope to this test's own activity so the all-users export isolates from the
|
// resetDb isolates this test, so the unfiltered export must contain exactly both workers.
|
||||||
// sessions other tests seed into the shared, run-scoped DB.
|
const res = await app.request(`/api/admin/export?${WIDE}`, {
|
||||||
const res = await app.request(`/api/admin/export?${WIDE}&activity_id=${act}`, {
|
|
||||||
headers: bearer(adminTok),
|
headers: bearer(adminTok),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -186,3 +186,39 @@ Guaranteed behaviors:
|
|||||||
stops it via the admin endpoint → the completed session appears in `GET /api/admin/report`
|
stops it via the admin endpoint → the completed session appears in `GET /api/admin/report`
|
||||||
(totals, `by_worker`, `by_activity`, `by_type` all matching the stored row) and in
|
(totals, `by_worker`, `by_activity`, `by_type` all matching the stored row) and in
|
||||||
`GET /api/admin/export` (header + exactly one row, with the leading Worker column).
|
`GET /api/admin/export` (header + exactly one row, with the leading Worker column).
|
||||||
|
|
||||||
|
## Reports + admin export
|
||||||
|
|
||||||
|
Src covered: the report/export parts of `src/routes/admin.ts` (`parseReportQuery`,
|
||||||
|
`buildSessionFilters`, `GET /api/admin/report`, `GET /api/admin/export`) — `admin.ts` is at 100%
|
||||||
|
stmts/branch/funcs/lines. Three defensive aggregation `??` guards are `c8 ignore`d with reasons
|
||||||
|
rather than left silent: `pausedSeconds`/`pairCount` are `NOT NULL` (DB-impossible nulls), and the
|
||||||
|
`user_name`/`activity_name` fallbacks are unreachable because the `user_id` FK cascades on delete
|
||||||
|
and the `activity_id` FK is enforced (every session always joins to a real user and activity).
|
||||||
|
|
||||||
|
Guaranteed behaviors:
|
||||||
|
|
||||||
|
- Both `GET /api/admin/report` and `GET /api/admin/export` are gated: 401 without a token, 403 for a
|
||||||
|
worker.
|
||||||
|
- Query parsing (`parseReportQuery`, shared by report + export): 400 when `from`/`to` is missing,
|
||||||
|
unparseable, or inverted (`to < from`); 400 on an `insole_type` outside `Kurk`/`Berk`/`3D`; 400 on
|
||||||
|
a non-numeric `activity_id`; the export route rejects the same bad queries with 400.
|
||||||
|
- Report scope: only `completed` sessions inside `[from, to]` (by `start_time`) are counted —
|
||||||
|
active/discarded rows are excluded.
|
||||||
|
- Filters narrow the result set: `user_id`, `insole_type`, and `activity_id` each restrict the
|
||||||
|
counted sessions (individually and combined).
|
||||||
|
- Totals sum `worked_seconds`, `paused_seconds`, `pairs`, and `sessions` across every matched row,
|
||||||
|
and equal the sum of each breakdown.
|
||||||
|
- Breakdowns `by_worker`, `by_activity`, and `by_type` group correctly and are each sorted by
|
||||||
|
`worked_seconds` descending.
|
||||||
|
- A completed session with a null `insole_type` is bucketed under `"Onbekend"` in `by_type` (worker
|
||||||
|
and activity buckets stay real); a completed session with a null `duration_seconds` contributes
|
||||||
|
`0` worked seconds.
|
||||||
|
- An empty range yields zero totals and empty `by_worker`/`by_activity`/`by_type` arrays.
|
||||||
|
- Admin export: emits an all-users CSV with the leading `Worker` column; the `text/csv` content-type
|
||||||
|
and a `Content-Disposition` filename of `solelog-report_<from>_<to>.csv` (dates sliced to
|
||||||
|
`YYYY-MM-DD`); rows ordered by start time ascending; honors the same filters as the report.
|
||||||
|
- Lifecycle (`test/integration/report-aggregation.test.ts`): seed known completed sessions for two
|
||||||
|
workers × two activities × two insole types → assert exact totals and every `by_worker`,
|
||||||
|
`by_activity`, `by_type` row (values and descending order) → apply a `user_id` filter → assert the
|
||||||
|
totals and breakdowns narrow to that worker only.
|
||||||
|
|||||||
Reference in New Issue
Block a user