/** * 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} */ 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(); }