109 lines
3.4 KiB
JavaScript
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();
|
|
}
|