refactor(api): extract buildSessionsCsv shared by worker + admin exports
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
34
apps/api/test/csv.test.ts
Normal file
34
apps/api/test/csv.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user