diff --git a/apps/api/src/lib/csv.ts b/apps/api/src/lib/csv.ts index 67ba557..7e78596 100644 --- a/apps/api/src/lib/csv.ts +++ b/apps/api/src/lib/csv.ts @@ -11,3 +11,62 @@ export function formatDuration(totalSeconds: number): string { const sec = s % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } + +// 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'); +} diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index f08369a..4b2fa77 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -5,7 +5,7 @@ import { db } from '../db/client'; import { activities, workSessions } from '../db/schema'; import { getSessionUser } from '../lib/require-user'; import { toWorkSession } from '../lib/work-session'; -import { quote, formatDuration } from '../lib/csv'; +import { buildSessionsCsv } from '../lib/csv'; export const sessionsRoutes = new Hono(); @@ -20,45 +20,18 @@ sessionsRoutes.get('/api/export', async (c) => { .where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed'))) .orderBy(asc(workSessions.startTime)); - const header = [ - 'ID', - 'Task', - 'Insole Type', - 'No. of Insoles', - 'Date', - 'Total Duration', - 'Paused Duration', - 'Start Time', - 'End Time', - ] - .map(quote) - .join(','); - - const dataLines = rows.map(({ session, activityName }) => { - const start = new Date(session.startTime); - const end = session.endTime ? new Date(session.endTime) : null; - return [ - session.id, - activityName ?? '', - session.insoleType ?? 'Kurk', - session.pairCount ?? 2, - start.toLocaleDateString('nl-BE', { day: '2-digit', month: '2-digit', year: 'numeric' }), - formatDuration(session.durationSeconds ?? 0), - formatDuration(session.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(','); - }); - - const csv = [header, ...dataLines].join('\n'); + 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', diff --git a/apps/api/test/csv.test.ts b/apps/api/test/csv.test.ts new file mode 100644 index 0000000..ab2967a --- /dev/null +++ b/apps/api/test/csv.test.ts @@ -0,0 +1,34 @@ +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); + }); +});