Portage navigateur complet de l'app Android HormoneTrack v1.4.10 : - moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C, WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés - calibration par période d'ester et par modèle affiché (#60/#61) - UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire, prévision, pics/creux, fuseau configurable), rappels web (Notification API), seuils d'alerte, i18n FR/EN - sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1) - 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie - processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur, smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG) - dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions alignées sur l'Android porté, changelogs indépendants Non porté (impossible dans un navigateur, documenté §12) : agenda récurrent, notifications onglet fermé, montre.
302 lines
13 KiB
JavaScript
302 lines
13 KiB
JavaScript
/**
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
* Helpers du graphique (portage des fonctions pures de
|
||
* `ui/components/CurveChart.kt`) — TOUTES testables en Node
|
||
* (web/tests/chart-helpers.test.js).
|
||
*
|
||
* ⚠️ Chaque fonction porte l'historique du bug qu'elle corrige :
|
||
* - panDeltaHours : fix #62 (pan mort sur 24 h — cumul fractionnaire) ;
|
||
* - pointHoursBefore : fix #53 (delta « 6 h » qui comparait 24 h) ;
|
||
* - clampPanHours : fix #54 (pan futur figé — clamp unique) ;
|
||
* - forecastExtensionHrs : fix #54 (chip prévision sans effet visible) ;
|
||
* - xLabelTicks : fix #55 (labels à minuit UTC = 02:00 FR).
|
||
* ─────────────────────────────────────────────────────────────────────────────
|
||
*/
|
||
|
||
/**
|
||
* Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour
|
||
* l'axe Y.
|
||
* @param {number} value
|
||
* @returns {number}
|
||
*/
|
||
export function niceCeil(value) {
|
||
if (value <= 0) return 100.0;
|
||
const exp = Math.pow(10.0, Math.floor(Math.log10(value)));
|
||
const f = value / exp;
|
||
let nice;
|
||
if (f <= 1) nice = 1.0;
|
||
else if (f <= 2) nice = 2.0;
|
||
else if (f <= 2.5) nice = 2.5;
|
||
else if (f <= 5) nice = 5.0;
|
||
else nice = 10.0;
|
||
return nice * exp;
|
||
}
|
||
|
||
/**
|
||
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
|
||
* plus on zoome, plus le pas finit pour des courbes lisses
|
||
* (≤ 12 h → 15 min ; ≤ 24 h → 30 min ; sinon 1 h).
|
||
*
|
||
* @param {number} rangeHours
|
||
* @returns {number} ms
|
||
*/
|
||
export function stepForRange(rangeHours) {
|
||
if (rangeHours <= 12) return 900000; // 15 min
|
||
if (rangeHours <= 24) return 1800000; // 30 min
|
||
return 3600000; // 1 h
|
||
}
|
||
|
||
/**
|
||
* Toggles de MODÈLES PK par défaut (v1.4.7, fix #58) : au chargement, seuls
|
||
* les modèles RÉELLEMENT utilisés par des traitements à PROFIL PK (injection
|
||
* + ester — `usesProfileModel`) sont affichés ; les autres restent
|
||
* activables au tap. ⚠️ FILTRER par usesProfileModel : un traitement
|
||
* oral/anti-androgène (pkModel stocké = ESE par défaut, sans courbe profil)
|
||
* activerait Estrannaise à tort.
|
||
*
|
||
* GARDE-FOU : si AUCUN modèle profil n'est utilisé (traitements gel/oral
|
||
* uniquement), TFS est forcé à ON — les traitements Bateman sont tracés
|
||
* IDENTIQUEMENT dans chaque série : sans garde-fou, le graphique serait vide.
|
||
*
|
||
* @param {Set<string>} pkModelsUsed Ensemble des pkModel des traitements à profil
|
||
* @returns {Object<string,boolean>} { ESE, TFS, WHS }
|
||
*/
|
||
export function defaultModelToggles(pkModelsUsed) {
|
||
const toggles = {
|
||
ESE: pkModelsUsed.has('ESE'),
|
||
TFS: pkModelsUsed.has('TFS'),
|
||
WHS: pkModelsUsed.has('WHS'),
|
||
};
|
||
if (!toggles.ESE && !toggles.TFS && !toggles.WHS) toggles.TFS = true;
|
||
return toggles;
|
||
}
|
||
|
||
/**
|
||
* Convertit un delta de drag en heures de pan, en CUMULANT le résiduel
|
||
* fractionnaire (v1.4.10, fix #62).
|
||
*
|
||
* ⚠️ BUG #62 : l'ancien code tronquait CHAQUE delta incrémental en heures
|
||
* entières — à la plage 24 h, le delta d'un doigt réel (~30 px ≈ 0,67 h)
|
||
* était tronqué à 0 → le pan ne bougeait JAMAIS sur la vue 24 h (alors
|
||
* qu'il marchait sur 7 j+ : le même delta ≈ 4,7 h). Le fix : le résiduel
|
||
* fractionnaire (la part < 1 h) est conservé d'un événement de mouvement au
|
||
* suivant — le pan se déclenche dès que le CUMUL franchit une heure entière.
|
||
*
|
||
* @param {number} panPx Delta horizontal du geste (px, signé)
|
||
* @param {number} chartWidthPx Largeur du canvas en px
|
||
* @param {number} rangeHours Plage temporelle courante (24 h / 7 j / 30 j…)
|
||
* @param {number} residualHours Résiduel fractionnaire précédent (à ré-injecter)
|
||
* @returns {{hours:number, residual:number}} heures entières à appliquer + nouveau résiduel
|
||
*/
|
||
export function panDeltaHours(panPx, chartWidthPx, rangeHours, residualHours) {
|
||
if (chartWidthPx <= 0 || rangeHours <= 0) return { hours: 0, residual: residualHours };
|
||
const total = (panPx / chartWidthPx) * rangeHours + residualHours;
|
||
let whole = Math.trunc(total); // troncature vers ZÉRO (gère les deux signes)
|
||
if (whole === 0) whole = 0; // normalise -0 (troncature de petits négatifs)
|
||
return { hours: whole, residual: total - whole };
|
||
}
|
||
|
||
/**
|
||
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
|
||
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
|
||
* dans le FUTUR jusqu'à [-futureHorizonHours ; …] (panHours négatif =
|
||
* fenêtre décalée vers le futur). Unique point de clamp partagé par le drag
|
||
* et le zoom pour éviter les divergences.
|
||
*
|
||
* @param {number} panHours
|
||
* @param {number} futureHorizonHours
|
||
* @param {number} maxPastHours
|
||
* @returns {number}
|
||
*/
|
||
export function clampPanHours(panHours, futureHorizonHours, maxPastHours) {
|
||
const lo = -Math.max(futureHorizonHours, 0);
|
||
const hi = Math.max(maxPastHours, 0);
|
||
if (panHours <= lo) return lo === 0 ? 0 : lo; // normalise -0
|
||
if (panHours >= hi) return hi;
|
||
return panHours;
|
||
}
|
||
|
||
/**
|
||
* Horizon de PRÉVISION en heures (v1.4.1) : quantité de doses projetées
|
||
* générées au-delà de « maintenant » quand le chip Prévision est actif.
|
||
* 12 × la plus grande Posologie, borné [30 j ; 365 j] — « scroller à
|
||
* l'infini » s'arrête à 1 an (compromis calcul maîtrisé).
|
||
*
|
||
* @param {number|null} maxIntervalDays Plus grande Posologie configurée (jours)
|
||
* @returns {number|null} null si aucun traitement n'a de Posologie (chip désactivé)
|
||
*/
|
||
export function forecastHorizonHours(maxIntervalDays) {
|
||
if (maxIntervalDays === null || maxIntervalDays === undefined || maxIntervalDays <= 0.0) return null;
|
||
return Math.min(Math.max(Math.trunc(maxIntervalDays * 24.0 * 12.0), 24 * 30), 24 * 365);
|
||
}
|
||
|
||
/**
|
||
* EXTENSION de fenêtre à droite quand le chip Prévision est activé
|
||
* (v1.4.4, fix #54) : le toggle doit avoir un effet IMMÉDIAT et visible.
|
||
* (Historique : `endMs = now` faisait que generateForecastDoses ne générait
|
||
* AUCUNE dose — le chip semblait inerte.) Solution : étendre la fenêtre à
|
||
* droite SANS déplacer son début (l'historique visible reste à sa place)
|
||
* jusqu'à couvrir au moins la prochaine dose projetée, sinon d'un tiers de
|
||
* la plage actuelle.
|
||
*
|
||
* @param {number} rangeHours Plage courante (24 h / 7 j / 30 j…)
|
||
* @param {number} horizonHours Horizon de prévision (≤ 0 = pas de prévision)
|
||
* @param {number|null} firstSlotGapHours Écart « maintenant → 1ᵉʳ créneau projeté »
|
||
* @returns {number} heures à ajouter à endMs (0 = rien)
|
||
*/
|
||
export function forecastExtensionHours(rangeHours, horizonHours, firstSlotGapHours) {
|
||
if (horizonHours === null || horizonHours === undefined || horizonHours <= 0) return 0;
|
||
// Montrer AU MOINS la prochaine dose (+12 h de contexte), sinon 1/3 de
|
||
// la plage actuelle — jamais au-delà de l'horizon.
|
||
const toFirstSlot = (firstSlotGapHours === null || firstSlotGapHours === undefined ? 0 : firstSlotGapHours) + 12;
|
||
const ext = Math.max(Math.trunc(rangeHours / 3), toFirstSlot);
|
||
return Math.min(Math.max(ext, 0), horizonHours);
|
||
}
|
||
|
||
/**
|
||
* Point le plus PROCHE de `hours` heures AVANT le dernier point de la série
|
||
* (v1.4.3, fix #53) : utilisé par le delta « vs il y a 6 h » de la carte
|
||
* d'accueil.
|
||
*
|
||
* ⚠️ BUG corrigé ici : l'ancien code faisait `curve.find(p => écart >= 6 h)`
|
||
* sur une fenêtre de 24 h → le PREMIER point de la liste (il y a ~24 h !)
|
||
* satisfaisait la condition immédiatement, et le delta affiché « vs 6 h »
|
||
* comparait en réalité à il y a 24 h (trompeur le lendemain d'une injection
|
||
* EEn — cf régression #4). Cette fonction prend le point dont l'écart au
|
||
* dernier est le plus PETIT au-dessus de `hours` (par excès — avec une
|
||
* grille régulière, c'est le point à ~6 h exactement).
|
||
*
|
||
* @param {import('./pk-engine.js').LevelPoint[]} points
|
||
* @param {number} hours
|
||
* @returns {object|null} null si aucun point n'est au-delà de `hours`
|
||
*/
|
||
export function pointHoursBefore(points, hours) {
|
||
const last = points[points.length - 1];
|
||
if (!last) return null;
|
||
let best = null;
|
||
let bestGap = Number.MAX_VALUE;
|
||
for (const p of points) {
|
||
const gap = (last.timestamp - p.timestamp) / 3600000.0;
|
||
if (gap < hours) continue; // seulement AVANT le point de référence
|
||
if (gap < bestGap) {
|
||
bestGap = gap;
|
||
best = p;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// ── Fuseaux horaires (Intl) ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parties calendaires d'un instant DANS un fuseau donné (via Intl.DateTimeFormat).
|
||
* C'est l'équivalent JS de Calendar.getInstance(TimeZone) + get(...).
|
||
*
|
||
* @param {number} ms
|
||
* @param {string} timeZone ID IANA ("Europe/Paris"…)
|
||
* @returns {{year:number,month:number,day:number,hour:number,minute:number,second:number}}
|
||
*/
|
||
export function zonedParts(ms, timeZone) {
|
||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||
timeZone,
|
||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||
hourCycle: 'h23',
|
||
});
|
||
const parts = {};
|
||
for (const p of fmt.formatToParts(new Date(ms))) parts[p.type] = p.value;
|
||
return {
|
||
year: Number(parts.year),
|
||
month: Number(parts.month),
|
||
day: Number(parts.day),
|
||
hour: Number(parts.hour),
|
||
minute: Number(parts.minute),
|
||
second: Number(parts.second),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Reconstruit un epoch ms depuis des parties calendaires interprétées DANS
|
||
* [timeZone] (algorithme de conversion inverse : UTC initial, correction par
|
||
* l'offset réel mesuré sur ce point).
|
||
*
|
||
* @param {{year:number,month:number,day:number,hour:number,minute:number,second:number}} p
|
||
* @param {string} timeZone
|
||
* @returns {number} epoch ms
|
||
*/
|
||
export function zonedTimeToMs(p, timeZone) {
|
||
// Conventions : offset(ms) = Date.UTC(parties de ms dans la zone) − ms.
|
||
// UTC = W − offset(UTC) (W = instant naïf interprété comme UTC) — deux
|
||
// passes couvrent les cas de bord DST.
|
||
const naive = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||
const offsetAt = (ms) => {
|
||
const q = zonedParts(ms, timeZone);
|
||
return Date.UTC(q.year, q.month - 1, q.day, q.hour, q.minute, q.second) - ms;
|
||
};
|
||
let guess = naive - offsetAt(naive); // 1ʳᵉ approximation
|
||
guess = naive - offsetAt(guess); // correction DST (point fixe)
|
||
return guess;
|
||
}
|
||
|
||
/**
|
||
* Instants des LABELS X (v1.4.5, fix #55) : alignés sur les frontières
|
||
* LOCALES de [timeZone] (minuit local pour les pas en jours, heures rondes
|
||
* locales pour les pas horaires) et NON sur des multiples UTC — l'ancien
|
||
* alignement plaçait le label « 13/09 » à 02:00 du matin FR, ce qui décalait
|
||
* la lecture visuelle des événements (remontée : « la prévision simule mon
|
||
* injection le 13 au lieu du 12 »).
|
||
*
|
||
* @param {number} t0 Début de fenêtre (epoch ms)
|
||
* @param {number} t1 Fin de fenêtre (epoch ms)
|
||
* @param {number} stepHours Pas des labels (1/3/6/24/120…)
|
||
* @param {string} [timeZone] Fuseau de LECTURE (défaut = fuseau du système)
|
||
* @returns {number[]} instants de tick croissants dans [t0 ; t1]
|
||
*/
|
||
export function xLabelTicks(t0, t1, stepHours, timeZone) {
|
||
const zone = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
if (t1 <= t0 || stepHours <= 0) return [];
|
||
|
||
// Premier tick : première frontière locale STRICTEMENT ≥ t0
|
||
let first;
|
||
if (stepHours >= 24) {
|
||
// pas en JOURS : frontières = MINUIT LOCAL dans [zone]
|
||
const parts = zonedParts(t0, zone);
|
||
const midnightSameDay = zonedTimeToMs(
|
||
{ year: parts.year, month: parts.month, day: parts.day, hour: 0, minute: 0, second: 0 },
|
||
zone,
|
||
);
|
||
const days = Math.max(Math.trunc(stepHours / 24), 1);
|
||
first = midnightSameDay;
|
||
while (first <= t0) {
|
||
// avance de N jours CALENDAIRES dans le fuseau (DST-safe)
|
||
const p = zonedParts(first, zone);
|
||
first = zonedTimeToMs({ year: p.year, month: p.month, day: p.day + days, hour: 0, minute: 0, second: 0 }, zone);
|
||
}
|
||
} else {
|
||
// pas horaire : heures rondes LOCALES dans [zone]
|
||
const parts = zonedParts(t0, zone);
|
||
const topOfHour = zonedTimeToMs(
|
||
{ year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: 0, second: 0 },
|
||
zone,
|
||
);
|
||
first = topOfHour;
|
||
while (first <= t0) first += 3600000;
|
||
}
|
||
|
||
const stepMs = stepHours * 3600000;
|
||
const out = [];
|
||
// Pour les pas ≥ 24 h, on régénère chaque tick par pas calendaire (DST-safe)
|
||
if (stepHours >= 24) {
|
||
const days = Math.max(Math.trunc(stepHours / 24), 1);
|
||
let t = first;
|
||
while (t <= t1) {
|
||
out.push(t);
|
||
const p = zonedParts(t, zone);
|
||
t = zonedTimeToMs({ year: p.year, month: p.month, day: p.day + days, hour: 0, minute: 0, second: 0 }, zone);
|
||
}
|
||
} else {
|
||
for (let t = first; t <= t1; t += stepMs) out.push(t);
|
||
}
|
||
return out;
|
||
}
|