Files
pardis/src/lib/resume.js
2026-09-19 21:53:10 +02:00

109 lines
3.4 KiB
JavaScript

/**
* Builds the last-day resume — the app's primary output, meant to be copied
* straight into a paper logbook.
*
* Durations are computed from *rounded* boundaries so the printed times and the
* printed totals always agree. Because adjacent entries share one stored
* timestamp, rounding can never open a gap between them.
*/
import { fmtDayLabel, fmtDuration, roundTs } from './time.js';
/**
* @param {object} day
* @param {Array} entries
* @param {number} roundingMinutes
*/
export function buildResume(day, entries, roundingMinutes = 15) {
if (!day) return null;
const fallbackEnd = day.endedTs ?? Date.now();
/** @type {Map<string, {name: string, totalMs: number, tasks: {name: string, ms: number}[]}>} */
const byClient = new Map();
/** @type {{type: string, restaurant: string|null, ms: number}|null} */
let lunch = null;
let workedMs = 0;
let omitted = 0;
for (const e of [...entries].sort((a, b) => a.seq - b.seq)) {
const start = roundTs(e.startTs, roundingMinutes);
const end = roundTs(e.endTs ?? fallbackEnd, roundingMinutes);
const ms = Math.max(0, end - start);
if (ms === 0) {
// Real but shorter than one rounding step. Kept in the data, left out of the
// logbook, and reported so the worker knows something was dropped.
omitted++;
continue;
}
if (e.kind === 'lunch') {
lunch = {
type: day.lunchType ?? 'panier',
restaurant: day.restaurant ?? null,
ms: (lunch?.ms ?? 0) + ms,
};
continue;
}
workedMs += ms;
const client = e.client || 'Sans client';
if (!byClient.has(client)) byClient.set(client, { name: client, totalMs: 0, tasks: [] });
const bucket = byClient.get(client);
bucket.totalMs += ms;
// Same task twice in a day (before and after lunch) reads as one line.
const existing = bucket.tasks.find((t) => t.name === e.task);
if (existing) existing.ms += ms;
else bucket.tasks.push({ name: e.task, ms });
}
const clients = [...byClient.values()].sort((a, b) => b.totalMs - a.totalMs);
for (const c of clients) c.tasks.sort((a, b) => b.ms - a.ms);
return {
dateKey: day.date,
label: fmtDayLabel(day.date),
clients,
lunch,
workedMs,
totalMs: Math.max(0, roundTs(fallbackEnd, roundingMinutes) -
roundTs(day.startedTs ?? Math.min(...entries.map((e) => e.startTs), fallbackEnd), roundingMinutes)),
omitted,
};
}
/** The exact string the copy button puts on the clipboard. */
export function resumeToText(resume) {
if (!resume) return '';
const lines = [resume.label, ''];
// Pad task names to a common width so the durations line up in the logbook.
const width = Math.max(
0,
...resume.clients.flatMap((c) => c.tasks.map((t) => t.name.length)),
);
for (const c of resume.clients) {
lines.push(`${c.name} — ${fmtDuration(c.totalMs)}`);
for (const t of c.tasks) {
lines.push(` ${t.name.padEnd(width)} ${fmtDuration(t.ms)}`);
}
lines.push('');
}
if (resume.lunch) {
const where =
resume.lunch.type === 'restaurant'
? `restaurant (${resume.lunch.restaurant || 'non précisé'})`
: 'panier';
lines.push(`Déjeuner : ${where}, ${fmtDuration(resume.lunch.ms)}`);
lines.push('');
}
lines.push(`Travail hors repas : ${fmtDuration(resume.workedMs)}`);
lines.push(`Total journée (repas compris) : ${fmtDuration(resume.totalMs)}`);
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}