First working prototype
This commit is contained in:
105
src/lib/resume.js
Normal file
105
src/lib/resume.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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,
|
||||
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(`Total travaillé : ${fmtDuration(resume.workedMs)}`);
|
||||
|
||||
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
Reference in New Issue
Block a user