- FIX #70 : generateForecastDoses garde !isActive — un traitement archivé à Posologie ne projette plus de créneaux ; l'horizon du chip Prévision (chart.js) ne considère que les Posologies ACTIVES. §6.bis inchangé : l'historique reste simulé, seul le FUTUR s'arrête. 3 tests miroirs. - Page Doses : routeGlyph (💊💉💧🩹🧴, miroir routeIcon) + encarts « prise de sang le même jour » 🧪↑/🧪↓ (labMarkersForDose, miroir) ; i18n FR/EN ; 10 tests doses-extras.test.js (⚠️ makeDose(ts, amount, overrides)). 194 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.12.0.
272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
/**
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Ligne « prochaine prise » d'un traitement (v1.9.6 — miroir du helper
|
|
* `nextDoseLine` Kotlin) : « Xj Yh · sam. 26 10:00 (nom) » au-delà de 24 h
|
|
* (v1.4.1 : les heures deviennent peu lisibles), « XhMM · HH:mm (nom) » en
|
|
* dessous, « N min · HH:mm (nom) » dans l'heure. PUR, testé dans
|
|
* tests/misc.test.js — sert à CHAQUE ligne de la carte « Prochaines doses »
|
|
* de l'accueil (une ligne par traitement, triées par prochaine prise).
|
|
*
|
|
* @param {number} nowMs maintenant
|
|
* @param {number} fireMs prochaine prise du traitement
|
|
* @param {string} name nom du traitement
|
|
* @param {string} [timeZone=null]
|
|
* @returns {string}
|
|
*/
|
|
export function nextDoseLineText(nowMs, fireMs, name, timeZone = null) {
|
|
const deltaMs = fireMs - nowMs;
|
|
const h = Math.floor(deltaMs / 3600000);
|
|
const m = Math.floor((deltaMs % 3600000) / 60000);
|
|
if (h >= 24) {
|
|
const days = Math.floor(h / 24);
|
|
const remH = h % 24;
|
|
return `${days}j ${remH}h · ${formatWeekdayTime(fireMs, timeZone)} (${name})`;
|
|
}
|
|
if (h > 0) return `${h}h${String(m).padStart(2, '0')} · ${formatTime(fireMs, timeZone)} (${name})`;
|
|
return `${m} min · ${formatTime(fireMs, timeZone)} (${name})`;
|
|
}
|
|
|
|
/**
|
|
* « 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 `<input type="date">` + `<input type="time">`
|
|
* (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}`);
|
|
}
|
|
|
|
/**
|
|
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, miroir fidèle de
|
|
* `ui/screens/DosesScreen.kt` — même KDoc). Une analyse compte si elle
|
|
* tombe le MÊME JOUR calendaire LOCAL que la dose ; une analyse exactement
|
|
* À l'heure de la dose compte comme APRÈS (convention documentée).
|
|
*
|
|
* PUR → testable en node (tests/doses-extras.test.js).
|
|
*
|
|
* @param {Array<object>} labs LabResult[]
|
|
* @param {number} doseMs
|
|
* @param {string} [timeZone=null] IANA (défaut : locale du navigateur)
|
|
* @returns {{before: boolean, after: boolean}}
|
|
*/
|
|
export function labMarkersForDose(labs, doseMs, timeZone = null) {
|
|
// Comparaison de JOURS CALENDAIRES : 'fr-CA' rend YYYY-MM-DD (CLDR/ISO) —
|
|
// honore `timeZone` quand fourni, sinon la locale du navigateur/node.
|
|
const opts = timeZone ? { timeZone } : {};
|
|
const day = new Date(doseMs).toLocaleDateString('fr-CA', opts);
|
|
let before = false;
|
|
let after = false;
|
|
for (const lab of labs) {
|
|
if (new Date(lab.timestamp).toLocaleDateString('fr-CA', opts) === day) {
|
|
if (lab.timestamp < doseMs) before = true;
|
|
else after = true;
|
|
}
|
|
}
|
|
return { before, after };
|
|
}
|