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;
|
const sec = s % 60;
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
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 { activities, workSessions } from '../db/schema';
|
||||||
import { getSessionUser } from '../lib/require-user';
|
import { getSessionUser } from '../lib/require-user';
|
||||||
import { toWorkSession } from '../lib/work-session';
|
import { toWorkSession } from '../lib/work-session';
|
||||||
import { quote, formatDuration } from '../lib/csv';
|
import { buildSessionsCsv } from '../lib/csv';
|
||||||
|
|
||||||
export const sessionsRoutes = new Hono();
|
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')))
|
.where(and(eq(workSessions.userId, sessionUser.id), eq(workSessions.status, 'completed')))
|
||||||
.orderBy(asc(workSessions.startTime));
|
.orderBy(asc(workSessions.startTime));
|
||||||
|
|
||||||
const header = [
|
const csv = buildSessionsCsv(
|
||||||
'ID',
|
rows.map(({ session, activityName }) => ({
|
||||||
'Task',
|
id: session.id,
|
||||||
'Insole Type',
|
activityName,
|
||||||
'No. of Insoles',
|
insoleType: session.insoleType,
|
||||||
'Date',
|
pairCount: session.pairCount,
|
||||||
'Total Duration',
|
startTime: session.startTime,
|
||||||
'Paused Duration',
|
endTime: session.endTime,
|
||||||
'Start Time',
|
durationSeconds: session.durationSeconds,
|
||||||
'End Time',
|
pausedSeconds: session.pausedSeconds,
|
||||||
]
|
})),
|
||||||
.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');
|
|
||||||
|
|
||||||
return c.body(csv, 200, {
|
return c.body(csv, 200, {
|
||||||
'Content-Type': 'text/csv; charset=utf-8',
|
'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