/** * ───────────────────────────────────────────────────────────────────────────── * Formatage (miroir des helpers Android : formatDose dans DoseDialog.kt, * formatLabValue dans LabDialog.kt, formatValue dans CurveChart.kt, * HrtDuration.kt). * ───────────────────────────────────────────────────────────────────────────── */ /** * Format d'une dose : entier si rond, sinon 1 décimale (miroir formatDose — * `4.0` → « 4 », `0.5` → « 0.5 »). Point décimal (convention des champs). * * @param {number} d * @returns {string} */ export function formatDose(d) { return Number.isInteger(d) ? String(d) : d.toFixed(1); } /** * Format d'une valeur de lab : préserve les décimales utiles (miroir * formatLabValue — contrairement à formatDose : un lab 0.45 s'affiche « 0.45 »). * * @param {number} d * @returns {string} */ export function formatLabValue(d) { if (Number.isInteger(d)) return String(d); return String(d); } /** * Format d'une valeur SUR LE GRAPHIQUE (miroir formatValue de CurveChart.kt) : * ≥ 10 → 0 décimale ; ≥ 1 → 1 décimale ; sinon 2. * * @param {number} v * @returns {string} */ export function formatValue(v) { if (v >= 10) return v.toFixed(0); if (v >= 1) return v.toFixed(1); return v.toFixed(2); } /** * HrtDuration (portage de `util/HrtDuration.kt`, v1.3.1) : estimation du * temps passé sous THS. Mois = 30 jours (estimation assumée). */ export const HrtDuration = { DAY_MS: 86400000, /** Jours entiers écoulés depuis la première prise (0 si futur/invalide). */ daysSince(earliestMs, nowMs) { return nowMs <= earliestMs ? 0 : Math.trunc((nowMs - earliestMs) / this.DAY_MS); }, /** Décomposition mois(30 j) / jours restants. */ monthsAndDays(totalDays) { return [Math.trunc(totalDays / 30), totalDays % 30]; }, /** * Delta EN HEURES → (jours, heures restantes) — v1.4.1, carte « Prochaine * dose » : au-delà de 24 h, les heures parlent moins que les jours * (ex. 120 h → 5 j 0 h). */ daysAndHours(totalHours) { return [Math.trunc(totalHours / 24), totalHours % 24]; }, }; // ── Formatage de dates/heures (Intl, fuseau de lecture configurable) ────────── /** Intl.DateTimeFormat mis en cache par (style, timeZone). */ const fmtCache = new Map(); /** @private */ function fmt(key, optionsFactory, timeZone) { const cacheKey = `${key}|${timeZone || 'auto'}`; if (!fmtCache.has(cacheKey)) fmtCache.set(cacheKey, optionsFactory(timeZone)); return fmtCache.get(cacheKey); } /** * « HH:mm » dans le fuseau de lecture (heure des doses, carte prochaine dose). * @param {number} ms @param {string} [timeZone=null] */ export function formatTime(ms, timeZone = null) { return fmt('time', (tz) => new Intl.DateTimeFormat(undefined, { timeZone: tz || undefined, hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }), timeZone).format(new Date(ms)); } /** * « dd/MM/yyyy HH:mm » (liste des analyses). * @param {number} ms @param {string} [timeZone=null] */ export function formatDateTime(ms, timeZone = null) { return fmt('datetime', (tz) => new Intl.DateTimeFormat(undefined, { timeZone: tz || undefined, day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }), timeZone).format(new Date(ms)); } /** * « d MMMM yyyy » (en-tête « temps sous THS »). * @param {number} ms @param {string} [timeZone=null] */ export function formatLongDate(ms, timeZone = null) { return fmt('longdate', (tz) => new Intl.DateTimeFormat(undefined, { timeZone: tz || undefined, day: 'numeric', month: 'long', year: 'numeric', }), timeZone).format(new Date(ms)); } /** * « EEE d HH:mm » (carte prochaine dose au-delà de 24 h — jour de semaine * inclus, cf v1.4.1). * @param {number} ms @param {string} [timeZone=null] */ export function formatWeekdayTime(ms, timeZone = null) { return fmt('weekdaytime', (tz) => new Intl.DateTimeFormat(undefined, { timeZone: tz || undefined, weekday: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }), timeZone).format(new Date(ms)); } /** * « yyyy-MM-dd » (en-têtes de groupes de l'écran Doses — même clé de groupe * que le Kotlin `LocalDate.toString()`). * @param {number} ms @param {string} [timeZone=null] */ export function formatISODate(ms, timeZone = null) { return fmt('isodate', (tz) => new Intl.DateTimeFormat('en-CA', { timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit', }), timeZone).format(new Date(ms)); } /** Date du jour « yyyy-MM-dd » (locale) — champ date des dialogs. */ export function todayISO() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } /** * Epoch ms → valeur pour un `` + `` * (locale) — les dialogs web utilisent ces champs natifs (équivalent * DatePicker+TimePicker Material3). * * @param {number} ms * @returns {{date:string, time:string}} ex. { date: "2026-09-08", time: "18:30" } */ export function msToInputValues(ms) { const d = new Date(ms); return { date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`, time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`, }; } /** * Valeurs de champs date/heure (locale) → epoch ms. * @param {string} dateISO "2026-09-08" * @param {string} timeHM "18:30" * @returns {number|null} null si invalide */ export function inputValuesToMs(dateISO, timeHM) { if (!dateISO || !timeHM) return null; const [y, mo, d] = dateISO.split('-').map(Number); const [h, mi] = timeHM.split(':').map(Number); if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null; const ms = new Date(y, mo - 1, d, h, mi, 0, 0).getTime(); return Number.isFinite(ms) ? ms : null; } /** * Notes à AFFICHER pour un groupe de labs (v1.7.1 — miroir de * `labNotesForDisplay` Android, fix du bug « une seule note sur deux * s'affiche »). Historique : le dialog de création mettait LA MÊME note aux * deux entrées d'une prise E2+T ; l'édition unitaire a cassé cette * hypothèse → l'ancien affichage (`.find()` première non vide) perdait la * note de la seconde entrée (conservée en base, juste non affichée). * * Règles (identiques à l'Android) : * - notes vides/blanches ignorées ; ordre du groupe préservé (E2 avant T) ; * - texte identique sur plusieurs entrées → une seule ligne (dédupliqué) ; * - notes distinctes → une ligne par note, préfixée du marqueur * (« E2 : … » / « T : … ») ; * - une seule note distincte → brute (comportement historique préservé). * * PUR (aucun DOM) → testé en Node (tests/lab-notes.test.js). * * @param {{marker:string, notes?:string|null}[]} group labs du même timestamp * @returns {string[]} lignes de notes à afficher (vide si aucune) */ export function labNotesForDisplay(group) { const notes = group .map((lab) => (lab.notes && String(lab.notes).trim() !== '' ? [String(lab.marker).toUpperCase(), String(lab.notes).trim()] : null)) .filter(Boolean); if (notes.length === 0) return []; // Déduplication (première occurrence de chaque texte, marqueur de sa // propre entrée conservé pour le préfixe) const seen = new Set(); const distinctPairs = []; for (const [marker, text] of notes) { if (!seen.has(text)) { seen.add(text); distinctPairs.push([marker, text]); } } if (distinctPairs.length === 1) return [distinctPairs[0][1]]; return distinctPairs.map(([marker, text]) => `${marker} : ${text}`); }