Compare commits
No commits in common. "main" and "v1.9.5" have entirely different histories.
15
README.md
15
README.md
@ -17,15 +17,12 @@
|
||||
> Fie-toi toujours à tes analyses de sang et aux consignes de ton
|
||||
> endocrinologue.
|
||||
|
||||
- **Statut** : web v1.13.0 — parité fonctionnelle avec l'Android v1.13.0
|
||||
sur la logique miroirée (reco de prise de sang : **valeur E2 attendue au
|
||||
creux** quand l'auto-calibration est ON + **régime poolé** — un
|
||||
re-parenting d'historique ne redémarre plus la stabilisation + **cible de
|
||||
creux** opt-in colorant la prédiction, jamais de notification ; le fix
|
||||
#70 et les extras Doses de la v1.12 restent couverts) ;
|
||||
l'auto-backup journalier et les notifications Android restent
|
||||
structurellement non portés, cf §12 ·
|
||||
**199 tests verts** · E2E navigateur ✅ · conteneur testé en
|
||||
- **Statut** : web v1.9.5 — parité fonctionnelle avec l'Android v1.9.5
|
||||
(modèle Estrannaise analytique + nuage d'incertitude MCMC exclusif ESE ;
|
||||
presets des 6 esters injectables ESE ; l'auto-backup journalier Android
|
||||
reste structurellement non porté, cf §12 — l'export manuel couvre la
|
||||
donnée) ·
|
||||
**175 tests verts** · E2E navigateur ✅ · conteneur testé en
|
||||
configuration durcie ✅ · lint/i18n ✅
|
||||
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)
|
||||
|
||||
@ -5,120 +5,6 @@
|
||||
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog
|
||||
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
|
||||
|
||||
## [1.13.0] — 2026-09-30
|
||||
|
||||
### Ajouté — recommandation de prise de sang : valeur prédite, régime poolé, cible de creux
|
||||
|
||||
- **Valeur E2 attendue au creux recommandé** : la carte affiche désormais
|
||||
« ≈ X pg/mL » quand l'auto-calibration fournit ses facteurs (sinon rien —
|
||||
une valeur brute non calibrée serait trompeuse). Miroir de
|
||||
`LabRecommendation.predictedE2`.
|
||||
- **Régime poolé** (`pooledRegimeDoses`, miroir du Kotlin) : les doses de
|
||||
tous les traitements partageant (ester effectif, mg) forment UNE séquence
|
||||
— un re-parenting d'historique (« … - old » créé a posteriori) ne
|
||||
redémarre plus la stabilisation. Garde-fous : ester ≠ → jamais poolé ;
|
||||
dose ≠ → exclue (le trou qui suit casse la séquence, conservateur).
|
||||
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle pour la
|
||||
prochaine prise de sang — la valeur prédite est colorée dans/hors cible.
|
||||
⚠️ DISTINCT des seuils d'alerte (surveillance continue + notifications) :
|
||||
la cible ne notifie jamais. Voyage dans le backup (champs optionnels,
|
||||
rétrocompatibles).
|
||||
|
||||
Vérifié : 198 tests + E2E verts (`check.sh`).
|
||||
|
||||
## [1.12.0] — 2026-09-29
|
||||
|
||||
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
|
||||
|
||||
- Miroir de l'Android v1.12.0 (remontée : « un traitement mis en inactif ne
|
||||
devrait plus être pris en compte pour les futures prédictions ») :
|
||||
`generateForecastDoses` (js/pk/pk-engine.js) garde `!isActive` — et
|
||||
l'horizon du chip Prévision du graphique (js/ui/chart.js) ne considère
|
||||
que les Posologies ACTIVES.
|
||||
- §6.bis inchangé : l'historique d'un inactif reste simulé — seul le FUTUR
|
||||
s'arrête. 3 tests miroirs (inactif vide, actif inchangé, avant/après
|
||||
archivage).
|
||||
|
||||
### Ajouté — page Doses : glyphes de forme de prise + marqueurs « prise de sang le même jour »
|
||||
|
||||
- Chaque ligne de dose porte le glyphe de sa forme (💊 oral, 💉 injection
|
||||
IM/SC, 💧 gel, 🩹 patch, 🧴 autre — helper pur `routeGlyph` dans
|
||||
js/data/models.js, miroir de `routeIcon`).
|
||||
- Une analyse de sang LE MÊME JOUR qu'une dose affiche un encart latéral
|
||||
🧪↑ (avant la dose) et/ou 🧪↓ (après) — helper pur `labMarkersForDose`
|
||||
(js/util/format.js, jour calendaire LOCAL ; lab à l'heure exacte =
|
||||
APRÈS, convention documentée).
|
||||
- 10 tests (`tests/doses-extras.test.js`, miroirs de DosesExtrasTest.kt).
|
||||
|
||||
Vérifié : 194 tests verts + E2E (`check.sh`).
|
||||
|
||||
## [1.11.0] — 2026-09-29
|
||||
|
||||
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
|
||||
|
||||
- Miroir de l'Android v1.11.0 (demande : « les traitements inactifs sont
|
||||
peu discernables — les placer à la fin, et peut-être les rendre plus
|
||||
distincts ») : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**
|
||||
sous un en-tête « Traitements inactifs » (seulement s'il en existe),
|
||||
cartes **atténuées** (opacity 0,55) en plus du badge.
|
||||
- Tri STABLE : l'ordre relatif à l'intérieur de chaque groupe est conservé.
|
||||
Helper pur `treatmentsForDisplay` dans `js/data/models.js` (miroir du
|
||||
Kotlin, node-safe) — 4 tests (`tests/treatments-display.test.js`,
|
||||
miroir de TreatmentsDisplayTest.kt).
|
||||
|
||||
Vérifié : 184 tests verts + E2E (`check.sh`).
|
||||
|
||||
## [1.10.0] — 2026-09-27
|
||||
|
||||
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
|
||||
|
||||
- Miroir de l'Android v1.10.0 (remontée + données réelles → 6ᵉ régression
|
||||
épinglée côté Android) : le « creux » était le **minimum de la fenêtre
|
||||
entière** (créneau N−1 → créneau N). Pour un ester à montée lente (EEn :
|
||||
pic ~J+5 ≈ intervalle 7 j), ce minimum tombe **juste après l'injection
|
||||
PRÉCÉDENTE** (creux d'absorption, ~3 h) — la date proposée était gouvernée
|
||||
par l'injection N−1, antérieure à la stabilisation, et le texte « juste
|
||||
avant ton injection du … » était faux de plusieurs jours.
|
||||
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
|
||||
courbe avant l'injection) — restaure le principe v1.8.0 documenté et rend
|
||||
la carte cohérente. Pour les esters à t½ courte (EV), le point
|
||||
pré-injection EST le minimum de fenêtre : comportement inchangé.
|
||||
- Tests : la suite lab-timing (12) passe inchangée — sa sémantique épinglée
|
||||
(« creux juste avant l'injection, écart ≤ 2 h ») était déjà celle du fix ;
|
||||
180 tests + E2E verts (`check.sh`).
|
||||
|
||||
Les autres changements de la v1.10.0 Android (dialog fantôme #69, rappel
|
||||
sauté si dose déjà loggée) n'ont pas d'équivalent web (notifications et
|
||||
intents de notification sont spécifiques Android).
|
||||
|
||||
## [1.9.7] — 2026-09-20
|
||||
|
||||
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
|
||||
|
||||
- Miroir de l'Android v1.9.7 (remontée + données réelles) : la règle de
|
||||
stabilisation comparait l'écart inter-doses **EXACTEMENT** à l'écart
|
||||
précédent — un log 30 min plus tard cassait le régime et repoussait la
|
||||
stabilisation de 5 × t½ à chaque injection (creux recommandé fuyant).
|
||||
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
|
||||
Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'intervalle
|
||||
THÉORIQUE). Un vrai changement de créneau (2 j au lieu de 7 j) reste
|
||||
hors fenêtre. Tests : « interval change » ré-épinglé (+21 j) + 2 tests
|
||||
de tolérance miroirs (12 verts lab-timing).
|
||||
|
||||
## [1.9.6] — 2026-09-20
|
||||
|
||||
### Modifié — la carte de l'accueil liste TOUTES les prochaines prises
|
||||
|
||||
- Miroir de l'Android v1.9.6 (remontée : le rappel quotidien CPA masquait
|
||||
le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte
|
||||
« Prochaine dose » devient « **Prochaines doses** » (titre pluriel dès 2
|
||||
lignes) — **une ligne par traitement, TRIÉES par prochaine prise** ;
|
||||
la 1ʳᵉ ligne (la plus proche) reste mise en avant. Format de ligne
|
||||
inchangé (`Xj Yh · sam. 26 10:00 (nom)`, `XhMM · HH:mm`, `N min`).
|
||||
- Moteur : helper PUR `nextDoseLineText` dans `format.js` (miroir du
|
||||
Kotlin `nextDoseLine`) — 3 tests miroirs dans misc.test.js.
|
||||
- Les NOTIFICATIONS de rappel sont inchangées.
|
||||
|
||||
## [1.9.5] — 2026-09-20
|
||||
|
||||
### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts)
|
||||
|
||||
@ -60,7 +60,7 @@ Règles du portage, fixées au départ :
|
||||
|
||||
| Choix | Décision | Pourquoi |
|
||||
|---|---|---|
|
||||
| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 199 tests |
|
||||
| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 175 tests |
|
||||
| Framework UI | Aucun — DOM via un helper `el()` (`ui/components.js`) | même philosophie que la v1 Android (pas de ViewModel/DI) ; une seule abstraction à connaître |
|
||||
| Rendu graphique | Canvas 2D natif (portage de `CurveChart.kt`) | pas de lib de charts : fidélité du rendu, zéro dépendance |
|
||||
| Persistance | `localStorage` (clés préfixées `hormonetrack.`) | données petites (JSON) ; backend INJECTABLE → testable en Node (`store.setBackend(new Map())`) |
|
||||
@ -260,12 +260,6 @@ HormoneTrack-web/ (dépôt séparé)
|
||||
│ │ créneau, saut de stabilisation, gardes)
|
||||
│ ├── lab-notes.test.js ← labNotesForDisplay (v1.7.1 : notes d'une
|
||||
│ │ prise E2+T — les DEUX distinctes affichées)
|
||||
│ ├── treatments-display.test.js ← treatmentsForDisplay (v1.11.0 : actifs
|
||||
│ │ d'abord, inactifs regroupés en bas — tri
|
||||
│ │ stable)
|
||||
│ ├── doses-extras.test.js ← fix #70 garde prévision inactifs +
|
||||
│ │ routeGlyph + labMarkersForDose (v1.12.0)
|
||||
│ │ + régime poolé + statut cible (v1.13.0)
|
||||
│ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0
|
||||
│ │ « Tracé labs » + v1.6.0 prolongation —
|
||||
│ │ passage exact, garde #61, horizon cutoff)
|
||||
@ -411,7 +405,7 @@ Pièges rencontrés :
|
||||
|---|---|---|---|
|
||||
| Syntaxe | `node --check` | tous les modules ES | `bash scripts/check.sh` |
|
||||
| i18n | `scripts/i18n-check.mjs` | clés FR/EN synchronisées + clés utilisées | (dans check.sh) |
|
||||
| Unitaires | `node --test` | **199 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4), ordre d'affichage des traitements (v1.11.0), garde prévision inactifs + extras Doses (v1.12.0), régime poolé + prédiction creux + statut cible (v1.13.0) | `npm test` |
|
||||
| Unitaires | `node --test` | **175 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4) | `npm test` |
|
||||
| E2E navigateur | playwright-core + build Firefox | app réelle : rendu, canvas peint (pixels), navigation, dialog changelog, langue | `npm run e2e` |
|
||||
| Smoke HTTP | curl | ressources clés en 200 | `check.sh --with-serve` |
|
||||
|
||||
@ -454,13 +448,8 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
|
||||
garde #61 sur la prolongation, flag off = v1.5.0 bit-compatible,
|
||||
`lastAnchorMs`.
|
||||
- **lab-timing** : v1.8.0 « prochaine prise de sang recommandée » (miroir
|
||||
du Kotlin, §7.11 Android) : creux = niveau PRÉ-INJECTION du créneau
|
||||
(**fix #68 v1.10.0**), **régime POOLÉ entre traitements identiques**
|
||||
(**v1.13.0** — `pooledRegimeDoses`, le re-parenting d'historique ne
|
||||
redémarre plus la stabilisation), **prédiction E2 au creux**
|
||||
(`predictedE2`, seulement si calibration fournie) et **statut cible**
|
||||
(`troughTargetStatus` — jamais de notification, distinct des seuils
|
||||
d'alerte), saut au premier creux STABILISÉ (5 × t½ — TFS/WHS
|
||||
du Kotlin, §7.11 Android) : creux = minimum de la courbe prévisionnelle
|
||||
entre 2 injections, saut au premier creux STABILISÉ (5 × t½ — TFS/WHS
|
||||
analytiques, Estrannaise lue dans la table via terminalHalfLifeDays),
|
||||
filtres (≥ now+6 h, > dernière prise, Posologie requise), invite
|
||||
« renseigne une Posologie », oral seul → null.
|
||||
@ -564,9 +553,8 @@ fonctionnalités (sauf impossibilités structurelles §12).
|
||||
**v1.4.10 = la première release web publiée (8 sept. 2026, cloudyfy)** :
|
||||
tag + release Gitea avec l'archive zip de déploiement, APRES validation
|
||||
complète (132 tests unitaires + E2E navigateur local + container-test réel
|
||||
en configuration durcie). Le repo farewell a été créé depuis — les releases
|
||||
y sont publiées sur les DEUX instances (backfill v1.9.5 + v1.10.0→v1.12.0
|
||||
le 29 sept. 2026, cf étape 4).
|
||||
en configuration durcie). Le dépôt farewell reste en attente de la création
|
||||
du repo (one-shot côté Gitea).
|
||||
|
||||
### Remotes
|
||||
|
||||
@ -612,14 +600,8 @@ push initial v1.2.3, cf doc Android §16.)
|
||||
attache l'archive `HormoneTrack-web-vX.Y.Z.zip` construite PAR
|
||||
`git archive` AU TAG (jamais le working tree), vérifiée par
|
||||
téléchargement (uid des leçons #37/#43 Android : une invocation fait
|
||||
tout, vérif par téléchargement, échec bruyant). Idem `farewell`.
|
||||
⚠️ **ÉTAPE JAMAIS OPTIONNELLE** (constat 29 sept. 2026) : les tags
|
||||
v1.10.0 → v1.12.0 avaient été poussés SANS leurs releases (étape
|
||||
sautée par l'assistant) — découvert par l'utilisatrice, backfill
|
||||
publié sur les DEUX instances le jour même, + **v1.9.5 backfillée
|
||||
sur farewell** (jamais publiée à l'époque, seule cloudyfy l'avait
|
||||
été). Leçon : « commit + tag » n'est PAS « release » — la checklist
|
||||
s'arrête quand le zip est vérifié par téléchargement.
|
||||
tout, vérif par téléchargement, échec bruyant). Idem `farewell` une
|
||||
fois le repo créé.
|
||||
5. **Smoke-test** : Android sur le Pixel 9 (checklist §21 Android) ; web
|
||||
sur le navigateur : import d'un backup réel, pan/zoom, export/import,
|
||||
langue.
|
||||
|
||||
@ -64,9 +64,6 @@ export function buildBackupJson(collections, tConfig, userSettings = null) {
|
||||
alertE2Low: userSettings.alertE2Low ?? null,
|
||||
alertTHigh: userSettings.alertTHigh ?? null,
|
||||
alertTLow: userSettings.alertTLow ?? null,
|
||||
// v1.13.0 : cible de creux (champs optionnels — rétrocompatibles)
|
||||
troughTargetLow: userSettings.troughTargetLow ?? null,
|
||||
troughTargetHigh: userSettings.troughTargetHigh ?? null,
|
||||
};
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
|
||||
@ -211,43 +211,3 @@ export function newLabResult(init = {}) {
|
||||
...init,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, miroir fidèle de
|
||||
* `ui/screens/TreatmentsScreen.kt` — même KDoc) : les ACTIFS d'abord, les
|
||||
* INACTIFS regroupés TOUT EN BAS — l'ancien ordre brut du store mélangeait
|
||||
* les deux groupes et le badge discret ne suffisait plus à les discerner
|
||||
* (« peu discernables », remontée).
|
||||
*
|
||||
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé.
|
||||
* PUR (aucune dépendance DOM) → testable en node
|
||||
* (tests/treatments-display.test.js, miroir de TreatmentsDisplayTest.kt).
|
||||
*
|
||||
* @param {Array<object>} treatments
|
||||
* @returns {Array<object>} nouvelle liste triée (l'entrée n'est pas mutée)
|
||||
*/
|
||||
export function treatmentsForDisplay(treatments) {
|
||||
return [...treatments].sort((a, b) => Number(b.isActive) - Number(a.isActive));
|
||||
}
|
||||
|
||||
/**
|
||||
* GLYPHE de FORME DE PRISE (v1.12.0, miroir de `routeIcon` —
|
||||
* `ui/screens/DosesScreen.kt`, même KDoc) : l'app web n'a pas d'icônes
|
||||
* Material → émojis, même mapping : ORAL = pilule 💊 ; IM/SC = seringue 💉
|
||||
* (glyphe partagé, la page Doses différencie par le texte de la ligne) ;
|
||||
* GEL = goutte 💧 ; PATCH = sparadrap 🩹 ; OTHER/inconnu = compte-gouttes
|
||||
* 🧴. PUR → testable en node (tests/doses-extras.test.js).
|
||||
*
|
||||
* @param {string|null} route
|
||||
* @returns {string} émoji
|
||||
*/
|
||||
export function routeGlyph(route) {
|
||||
switch (route) {
|
||||
case 'ORAL': return '💊';
|
||||
case 'INJECTION_IM':
|
||||
case 'INJECTION_SUBCUT': return '💉';
|
||||
case 'TRANSDERMAL_GEL': return '💧';
|
||||
case 'TRANSDERMAL_PATCH': return '🩹';
|
||||
default: return '🧴';
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,8 +226,6 @@ class WebStore {
|
||||
alertE2Low: null,
|
||||
alertTHigh: null,
|
||||
alertTLow: null,
|
||||
troughTargetLow: null, // cible de creux (v1.13.0, opt-in — référence de lab, JAMAIS de notification)
|
||||
troughTargetHigh: null,
|
||||
chartTimezone: null, // fuseau de LECTURE du graphique (null = auto)
|
||||
changelogSeenVersion: null, // dialog « Nouveautés »
|
||||
alertNotifiedState: null, // anti-spam des notifications d'alerte
|
||||
|
||||
@ -13,7 +13,7 @@ export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-t
|
||||
export * as LabTiming from './lab-timing.js';
|
||||
export * as EstrannaiseModels from './estrannaise-models.js';
|
||||
export { sample as estrannaiseSample, hasModel as estrannaiseHasModel } from './estrannaise-models.js';
|
||||
export { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus } from './lab-timing.js';
|
||||
export { nextBloodDrawRecommendation, shouldSuggestPosology } from './lab-timing.js';
|
||||
export { compute as computeEstrannaiseCloud, DEFAULT_NB_CURVES } from './estrannaise-cloud.js';
|
||||
export * from './chart-helpers.js';
|
||||
export * as Alerts from './alerts.js';
|
||||
|
||||
@ -10,10 +10,7 @@
|
||||
* énormément, le creux reflète le niveau de fond et l'accumulation) ;
|
||||
* 2. un creux n'est interprétable que si le régime est **stabilisé** — règle
|
||||
* des 5 demi-vies terminales (≈ 97 % de l'équilibre) ;
|
||||
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; fix
|
||||
* #68 v1.10.0 : le creux = niveau PRÉ-INJECTION du créneau — l'ancien
|
||||
* minimum de fenêtre entière tombait juste après l'injection PRÉCÉDENTE
|
||||
* pour les esters à montée lente, date antérieure à la stabilisation).
|
||||
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0).
|
||||
*
|
||||
* CALCUL (déduit de ce que l'app connaît déjà) :
|
||||
* - traitement porteur : ESTRADIOL + injectable + ACTIF + Posologie
|
||||
@ -62,14 +59,11 @@ const HOUR_MS = 3600000;
|
||||
* @param {object[]} doseLogs toutes les doses
|
||||
* @param {object[]} labs toutes les prises de sang existantes
|
||||
* @param {number} [nowMs=Date.now()]
|
||||
* @param {Object<string,number>} [scalePerEster=null] facteurs d'échelle PAR
|
||||
* ESTER (v1.13.0, issus de l'auto-calibration quand elle est activée) —
|
||||
* alimente `predictedE2` ; absent → `predictedE2 = null` (carte sans valeur)
|
||||
* @returns {{troughMs:number, injectionMs:number, ester:string,
|
||||
* terminalHalfLifeDays:number, regimeStartMs:number, stabilizedAtMs:number,
|
||||
* wasAlreadyStabilized:boolean, predictedE2:number|null}|null}
|
||||
* wasAlreadyStabilized:boolean}|null}
|
||||
*/
|
||||
export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now(), scalePerEster = null) {
|
||||
export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now()) {
|
||||
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ───────────
|
||||
const carrier = treatments.find((t) =>
|
||||
t.type === 'ESTRADIOL'
|
||||
@ -90,12 +84,7 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
|
||||
// ── 3) Début du régime courant + date de stabilisation ────────────────────
|
||||
const myDoses = doseLogs.filter((d) => d.treatmentId === carrier.id);
|
||||
if (myDoses.length === 0) return null;
|
||||
// v1.13.0 : régime POOLÉ (cf pooledRegimeDoses) — le re-parenting
|
||||
// d'historique (ex. « 6d - old » créé a posteriori, remontée v1.12.0)
|
||||
// ne doit pas faire croire à un nouveau régime.
|
||||
const regimeStartMs = computeRegimeStartMs(
|
||||
carrier, pooledRegimeDoses(carrier, doseLogs, treatments),
|
||||
);
|
||||
const regimeStartMs = computeRegimeStartMs(carrier, myDoses);
|
||||
const stabilizedAtMs = regimeStartMs + Math.round(STABILIZATION_HALF_LIVES * tHalfDays * 24 * HOUR_MS);
|
||||
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui qui
|
||||
// gouverne les creux futurs et le texte de la carte
|
||||
@ -126,25 +115,18 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
|
||||
const minTroughMs = nowMs + MIN_HORIZON_HOURS * HOUR_MS;
|
||||
let windowStart = nowMs;
|
||||
for (const slot of slots) {
|
||||
// ⚠️ FIX #68 (v1.10.0, miroir du Kotlin) : le creux = niveau
|
||||
// PRÉ-INJECTION du créneau (dernier point de courbe avant l'injection —
|
||||
// principe 1, « le moment le plus comparable »). L'ancien MINIMUM DE LA
|
||||
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters à
|
||||
// montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum local est
|
||||
// le creux d'absorption, quelques heures après l'injection PRÉCÉDENTE) :
|
||||
// la date proposée précédait la stabilisation affichée sur la MÊME
|
||||
// carte, et le texte « juste avant ton injection du … » était faux de
|
||||
// plusieurs jours (remontée v1.9.8). Pour les esters à t½ courte (EV),
|
||||
// le point pré-injection EST le minimum de fenêtre : inchangé.
|
||||
// Creux du créneau = minimum de la courbe entre l'injection précédente
|
||||
// (ou maintenant) et ce créneau
|
||||
const window = curve.filter((p) => p.timestamp > windowStart && p.timestamp < slot.timestamp);
|
||||
let trough = null;
|
||||
for (const p of curve) {
|
||||
if (p.timestamp > windowStart && p.timestamp < slot.timestamp) trough = p;
|
||||
for (const p of window) {
|
||||
if (trough === null || p.e2 < trough.e2) trough = p;
|
||||
}
|
||||
if (
|
||||
trough !== null
|
||||
&& trough.timestamp >= minTroughMs
|
||||
&& trough.timestamp > lastLabMs
|
||||
&& trough.timestamp >= stabilizedAtMs // sauter au premier creux stabilisé
|
||||
&& slot.timestamp >= stabilizedAtMs // sauter au premier creux stabilisé
|
||||
) {
|
||||
return {
|
||||
troughMs: trough.timestamp,
|
||||
@ -154,15 +136,6 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
|
||||
regimeStartMs,
|
||||
stabilizedAtMs,
|
||||
wasAlreadyStabilized: stabilizedAtMs <= nowMs,
|
||||
// v1.13.0 : E2 attendue au creux = valeur brute du modèle × facteur
|
||||
// du ester actif (lookup insensible à la casse) ; null sans calibration
|
||||
predictedE2: (() => {
|
||||
if (!scalePerEster) return null;
|
||||
const entry = Object.entries(scalePerEster).find(
|
||||
([k]) => String(k).toUpperCase() === activeEster,
|
||||
);
|
||||
return entry ? trough.e2 * entry[1] : null;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
windowStart = slot.timestamp;
|
||||
@ -186,16 +159,8 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs =
|
||||
* modifiée/irrégulière) change le creux — conservateur assumé : une série
|
||||
* d'intervalles chaotiques maintient la carte « non stabilisée », ce qui est
|
||||
* pharmacocinétiquement vrai (le trough n'est comparable que sur un
|
||||
* intervalle régulier).
|
||||
*
|
||||
* v1.9.7 (miroir du Kotlin, remontée : « la suggestion change tout le temps
|
||||
* à chaque injection si l'injection n'est pas faite pile à la même heure ») :
|
||||
* la comparaison EXACTE cassait le régime pour un log à 12:30 au lieu de
|
||||
* 12:00 — chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h
|
||||
* (« je m'injecte le même jour, à l'heure près »). La fenêtre se réfère à
|
||||
* l'intervalle THÉORIQUE, pas à l'écart précédent : des logs à 6,8 j puis
|
||||
* 7,2 j ne se déstabilisent plus en cascade. Un vrai changement (2 j au lieu
|
||||
* de 7 j) reste hors fenêtre.
|
||||
* intervalle régulier). Comparaison EXACTE (au ms) : 7 j ± 1 h casse le
|
||||
* régime, reflétant un vrai changement de créneau.
|
||||
*
|
||||
* @private
|
||||
* @param {object} carrier traitement porteur
|
||||
@ -207,22 +172,17 @@ function computeRegimeStartMs(carrier, myDoses) {
|
||||
let idx = sorted.length - 1;
|
||||
let regimeStart = sorted[idx].timestamp;
|
||||
let current = sorted[idx];
|
||||
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± 24 h (la Posologie est un
|
||||
// prérequis de la carte : absente → on s'arrête à la dernière dose).
|
||||
const intervalDays = carrier.forecastIntervalDays;
|
||||
if (!intervalDays || intervalDays <= 0) return regimeStart;
|
||||
const intervalMs = Math.round(intervalDays * 24 * HOUR_MS);
|
||||
const gapMin = intervalMs - GAP_TOLERANCE_MS;
|
||||
const gapMax = intervalMs + GAP_TOLERANCE_MS;
|
||||
// Écart EXIGÉ pour remonter : celui des deux doses les plus récentes
|
||||
let gapExpected = -1;
|
||||
const DOSE_EPS = 1e-6;
|
||||
while (idx > 0) {
|
||||
const prev = sorted[idx - 1];
|
||||
const gap = current.timestamp - prev.timestamp;
|
||||
const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev))
|
||||
&& Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS;
|
||||
// gap > 0 : un doublon de log n'est jamais un régime
|
||||
const sameGap = gap > 0 && gap >= gapMin && gap <= gapMax;
|
||||
const sameGap = gapExpected < 0 || gap === gapExpected;
|
||||
if (!sameMarker || !sameGap) break;
|
||||
gapExpected = gap;
|
||||
regimeStart = prev.timestamp;
|
||||
current = prev;
|
||||
idx--;
|
||||
@ -230,11 +190,6 @@ function computeRegimeStartMs(carrier, myDoses) {
|
||||
return regimeStart;
|
||||
}
|
||||
|
||||
/** Tolérance de l'écart inter-doses (v1.9.7, miroir GAP_TOLERANCE_MS Kotlin) :
|
||||
* ± 24 h autour de l'intervalle de Posologie — un log à 12:30 au lieu de
|
||||
* 12:00 ne repousse plus la stabilisation de 5 × t½. */
|
||||
const GAP_TOLERANCE_MS = 24 * HOUR_MS;
|
||||
|
||||
/**
|
||||
* Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement
|
||||
* E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la
|
||||
@ -252,57 +207,3 @@ export function shouldSuggestPosology(treatments) {
|
||||
&& (t.forecastIntervalDays ?? 0) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0, miroir fidèle du Kotlin — même KDoc) :
|
||||
* toutes les doses de TOUS les traitements partageant l'ester effectif du
|
||||
* porteur ET son mg exact (± eps), triées par timestamp. Un re-parenting
|
||||
* d'historique (« … - old ») ne doit plus redémarrer la stabilisation.
|
||||
* Garde-fous : ester ≠ → pas de mutualisation ; dose ≠ → exclue (le trou
|
||||
* qui suit casse la séquence — conservateur).
|
||||
*
|
||||
* @param {object} carrier
|
||||
* @param {object[]} allDoseLogs
|
||||
* @param {object[]} allTreatments
|
||||
* @returns {object[]} triée par timestamp
|
||||
*/
|
||||
export function pooledRegimeDoses(carrier, allDoseLogs, allTreatments) {
|
||||
let last = null;
|
||||
for (const d of allDoseLogs) {
|
||||
if (d.treatmentId === carrier.id && (last === null || d.timestamp > last.timestamp)) last = d;
|
||||
}
|
||||
if (!last) return [];
|
||||
const activeEster = String(doseEster(carrier, last)).toUpperCase();
|
||||
const amount = last.doseAmount;
|
||||
const ownerById = new Map(allTreatments.map((t) => [t.id, t]));
|
||||
const out = [];
|
||||
for (const d of allDoseLogs) {
|
||||
const owner = ownerById.get(d.treatmentId);
|
||||
if (!owner) continue;
|
||||
// Ester EFFECTIF via le traitement PROPRIÉTAIRE (override compris)
|
||||
if (String(doseEster(owner, d)).toUpperCase() !== activeEster) continue;
|
||||
if (Math.abs(d.doseAmount - amount) >= 1e-6) continue;
|
||||
out.push(d);
|
||||
}
|
||||
return out.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Statut d'une valeur par rapport à la CIBLE DE CREUX personnelle (v1.13.0,
|
||||
* miroir du Kotlin — même KDoc). ⚠️ DISTINCT des seuils d'alerte (alerts.js,
|
||||
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
|
||||
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB, évaluée en
|
||||
* lecture seule sur la carte, JAMAIS de notification.
|
||||
*
|
||||
* @param {number|null} predictedE2
|
||||
* @param {number|null} low
|
||||
* @param {number|null} high
|
||||
* @returns {'IN_TARGET'|'BELOW'|'ABOVE'|null} null si prédiction ou cible absente
|
||||
*/
|
||||
export function troughTargetStatus(predictedE2, low, high) {
|
||||
if (predictedE2 === null || predictedE2 === undefined) return null;
|
||||
if (low === null || low === undefined || high === null || high === undefined) return null;
|
||||
if (predictedE2 < low) return 'BELOW';
|
||||
if (predictedE2 > high) return 'ABOVE';
|
||||
return 'IN_TARGET';
|
||||
}
|
||||
|
||||
@ -512,14 +512,6 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
|
||||
export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) {
|
||||
const intervalDays = treatment.forecastIntervalDays;
|
||||
if (intervalDays === null || intervalDays === undefined || intervalDays <= 0.0) return [];
|
||||
// ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : un traitement INACTIF ne
|
||||
// projette PLUS de créneaux — l'archivage arrête les prédictions
|
||||
// d'injections futures (graphique Prévision, extension de fenêtre,
|
||||
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
|
||||
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
|
||||
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le FUTUR
|
||||
// s'arrête.
|
||||
if (!treatment.isActive) return [];
|
||||
const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS);
|
||||
if (intervalMs <= 0) return [];
|
||||
|
||||
|
||||
@ -171,10 +171,7 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
}
|
||||
|
||||
// ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ────
|
||||
// ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : SEULS les traitements ACTIFS
|
||||
// comptent — la Posologie d'un archivé n'active plus le chip Prévision.
|
||||
const maxIntervalDays = treatments
|
||||
.filter((tr) => tr.isActive)
|
||||
.map((tr) => tr.forecastIntervalDays)
|
||||
.filter((x) => x !== null && x !== undefined)
|
||||
.reduce((m, x) => Math.max(m, x), 0) || null;
|
||||
|
||||
@ -15,9 +15,8 @@
|
||||
*/
|
||||
|
||||
import { store } from '../data/store.js';
|
||||
import { routeGlyph, routeLabelKey } from '../data/models.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration, labMarkersForDose } from '../util/format.js';
|
||||
import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration } from '../util/format.js';
|
||||
import { el, assistChip, confirmDialog, fab } from './components.js';
|
||||
import { openDoseDialog } from './dialogs.js';
|
||||
|
||||
@ -92,17 +91,7 @@ export function renderDoses(container) {
|
||||
col.appendChild(el('div', { class: 'group-header' }, dateKey));
|
||||
for (const log of logs) {
|
||||
const tr = treatmentMap.get(log.treatmentId);
|
||||
// v1.12.0 : marqueurs « prise de sang le même jour » (↑ avant / ↓
|
||||
// après) — miroir labMarkersForDose de DosesScreen.kt
|
||||
const markers = labMarkersForDose(store.getLabResults(), log.timestamp);
|
||||
const item = el('div', { class: 'list-item', onclick: () => openDoseDialog({ treatments, preselected: null, existing: log }) });
|
||||
// v1.12.0 : GLYPHE de forme de prise à gauche (💊 oral, 💉 injection
|
||||
// IM/SC, 💧 gel, 🩹 patch, 🧴 autre — cf routeGlyph dans models.js)
|
||||
item.appendChild(el('span', {
|
||||
class: 'sub',
|
||||
style: 'align-self:center;font-size:1.1em;',
|
||||
title: tr ? t(routeLabelKey(tr.route)) : '',
|
||||
}, routeGlyph(tr ? tr.route : null)));
|
||||
const main = el('div', { class: 'main' });
|
||||
main.appendChild(el('div', { class: 'title' },
|
||||
`${tr ? tr.name : '?'} · ${formatDose(log.doseAmount)} ${tr ? tr.doseUnit : ''}`));
|
||||
@ -118,28 +107,12 @@ export function renderDoses(container) {
|
||||
if (log.notes && log.notes.trim() !== '') sub.appendChild(el('span', {}, log.notes));
|
||||
main.appendChild(sub);
|
||||
item.appendChild(main);
|
||||
const side = el('div', { class: 'row', style: 'align-items:center;gap:2px;' });
|
||||
// Encart latéral « prise de sang le même jour » (title = phrase
|
||||
// complète pour l'accessibilité, miroir des contentDescription)
|
||||
if (markers.before) {
|
||||
side.appendChild(el('span', {
|
||||
class: 'sub', style: 'color:var(--accent,#d6589e);',
|
||||
title: t('lab_before_dose'),
|
||||
}, '🧪↑'));
|
||||
}
|
||||
if (markers.after) {
|
||||
side.appendChild(el('span', {
|
||||
class: 'sub', style: 'color:var(--accent,#d6589e);',
|
||||
title: t('lab_after_dose'),
|
||||
}, '🧪↓'));
|
||||
}
|
||||
const del = el('button', { class: 'icon-btn', 'aria-label': t('delete') }, '🗑');
|
||||
del.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // ne pas déclencher l'édition
|
||||
confirmDialog(t('delete'), t('confirm_delete'), () => store.deleteDoseLog(log.id));
|
||||
});
|
||||
side.appendChild(del);
|
||||
item.appendChild(side);
|
||||
item.appendChild(del);
|
||||
col.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,10 +18,10 @@
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation, troughTargetStatus } from '../pk/index.js';
|
||||
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation } from '../pk/index.js';
|
||||
import { store } from '../data/store.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatDose, formatValue, formatDateTime, formatWeekdayTime, nextDoseLineText } from '../util/format.js';
|
||||
import { formatDose, formatValue, formatTime, formatWeekdayTime, formatDateTime, HrtDuration } from '../util/format.js';
|
||||
import { el } from './components.js';
|
||||
import { drawChart, COLORS } from './chart-canvas.js';
|
||||
import { openDoseDialog } from './dialogs.js';
|
||||
@ -109,14 +109,7 @@ export function renderHome(container, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Carte « prochaines doses » (grille Posologie via le moteur, fix #52) ──
|
||||
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
|
||||
// ligne par traitement TRIÉES par prochaine prise (helper pur
|
||||
// nextDoseLineText, miroir du Kotlin nextDoseLine) — l'ancienne carte ne
|
||||
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
|
||||
// totalement le rappel de l'injection hebdomadaire. Le titre passe au
|
||||
// pluriel dès qu'il y a au moins deux lignes ; la 1ʳᵉ ligne (la plus
|
||||
// proche) reste mise en avant (font-weight 600).
|
||||
// ── Carte « prochaine dose » (grille Posologie via le moteur, fix #52) ───
|
||||
const fires = [];
|
||||
for (const tr of treatments) {
|
||||
const fire = nextReminderFireFor(tr, doseLogs, nowMs);
|
||||
@ -124,12 +117,24 @@ export function renderHome(container, ctx) {
|
||||
}
|
||||
if (fires.length) {
|
||||
fires.sort((a, b) => a[1] - b[1]);
|
||||
const [nextTreatment, next] = fires[0];
|
||||
const deltaMs = next - nowMs;
|
||||
const h = Math.floor(deltaMs / HOUR_MS);
|
||||
const m = Math.floor((deltaMs % HOUR_MS) / 60000);
|
||||
const card = el('div', { class: 'card' });
|
||||
card.appendChild(el('div', { class: 'label' }, t(fires.length === 1 ? 'next_dose' : 'next_doses')));
|
||||
fires.forEach(([treatment, next], idx) => {
|
||||
card.appendChild(el('div', { style: idx === 0 ? 'font-weight:600;' : '' },
|
||||
nextDoseLineText(nowMs, next, treatment.name)));
|
||||
});
|
||||
card.appendChild(el('div', { class: 'label' }, t('next_dose')));
|
||||
if (h >= 24) {
|
||||
// v1.4.1 : au-delà de 24 h, afficher en JOURS + jour de semaine
|
||||
const [days, remH] = HrtDuration.daysAndHours(h);
|
||||
card.appendChild(el('div', { style: 'font-weight:600;' },
|
||||
t('next_dose_days', days, remH, formatWeekdayTime(next), nextTreatment.name)));
|
||||
} else if (h > 0) {
|
||||
card.appendChild(el('div', { style: 'font-weight:600;' },
|
||||
`${h}h${String(m).padStart(2, '0')} · ${formatTime(next)} (${nextTreatment.name})`));
|
||||
} else {
|
||||
card.appendChild(el('div', { style: 'font-weight:600;' },
|
||||
`${m} min · ${formatTime(next)} (${nextTreatment.name})`));
|
||||
}
|
||||
col.appendChild(card);
|
||||
}
|
||||
|
||||
@ -138,32 +143,12 @@ export function renderHome(container, ctx) {
|
||||
// l'accueil (demande v1.8.1) : creux daté + créneau associé + mention de
|
||||
// stabilisation SEULEMENT si le régime n'était pas déjà stable (sinon le
|
||||
// texte est identique à chaque ouverture). Caché si non calculable.
|
||||
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si l'auto-calibration
|
||||
// fournit ses facteurs) + statut face à la cible de creux (lecture seule,
|
||||
// jamais de notification — distinct des seuils d'alerte).
|
||||
const labRec = nextBloodDrawRecommendation(
|
||||
treatments, doseLogs, labResults, nowMs, effectiveAuto ? effectiveAuto.esterScales : null,
|
||||
);
|
||||
const troughTarget = store.getSettings().troughTargetLow !== null
|
||||
&& store.getSettings().troughTargetHigh !== null
|
||||
? { low: store.getSettings().troughTargetLow, high: store.getSettings().troughTargetHigh }
|
||||
: null;
|
||||
const labRec = nextBloodDrawRecommendation(treatments, doseLogs, labResults, nowMs);
|
||||
if (labRec) {
|
||||
const recCard = el('div', { class: 'card variant' });
|
||||
recCard.appendChild(el('div', { class: 'label' }, t('labrec_section')));
|
||||
recCard.appendChild(el('div', { style: 'font-weight:600;' },
|
||||
t('labrec_when', formatWeekdayTime(labRec.troughMs))));
|
||||
if (labRec.predictedE2 !== null && labRec.predictedE2 !== undefined) {
|
||||
const status = troughTargetStatus(
|
||||
labRec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null,
|
||||
);
|
||||
const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}`
|
||||
: status !== null ? ` — ${t('labrec_out_of_target')}` : '';
|
||||
const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)'
|
||||
: status !== null ? 'var(--accent,#d6589e)' : '';
|
||||
recCard.appendChild(el('div', { style: statusColor ? `color:${statusColor};` : '' },
|
||||
t('labrec_predicted', formatDose(Math.round(labRec.predictedE2))) + statusText));
|
||||
}
|
||||
recCard.appendChild(el('div', {},
|
||||
t('labrec_before_injection', labRec.ester, formatWeekdayTime(labRec.injectionMs))));
|
||||
if (!labRec.wasAlreadyStabilized) {
|
||||
|
||||
@ -14,8 +14,8 @@
|
||||
|
||||
import { store } from '../data/store.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatLabValue, formatDateTime, formatDose, labNotesForDisplay } from '../util/format.js';
|
||||
import { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus, autoCalibrated } from '../pk/index.js';
|
||||
import { formatLabValue, formatDateTime, labNotesForDisplay } from '../util/format.js';
|
||||
import { nextBloodDrawRecommendation, shouldSuggestPosology } from '../pk/index.js';
|
||||
import { el, confirmDialog, fab, showDialog } from './components.js';
|
||||
import { openLabDialog } from './dialogs.js';
|
||||
|
||||
@ -64,46 +64,19 @@ export function renderLabs(container) {
|
||||
// découvrable). Les deux encarts sont mutuellement exclusifs.
|
||||
const treatments = store.getTreatments();
|
||||
const doseLogs = store.getDoseLogs();
|
||||
// v1.13.0 : auto-calibration → facteurs d'échelle pour la prédiction
|
||||
// « ≈ X pg/mL au creux » (null si l'option est OFF — même pattern home.js)
|
||||
const settings = store.getSettings();
|
||||
const effectiveAuto = settings.autoCalibrate
|
||||
? autoCalibrated(treatments, doseLogs, labs, store.getTConfig(), null)
|
||||
: null;
|
||||
const rec = nextBloodDrawRecommendation(
|
||||
treatments, doseLogs, labs, Date.now(), effectiveAuto ? effectiveAuto.esterScales : null,
|
||||
);
|
||||
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
|
||||
const troughTarget = settings.troughTargetLow !== null && settings.troughTargetHigh !== null
|
||||
? { low: settings.troughTargetLow, high: settings.troughTargetHigh }
|
||||
: null;
|
||||
const rec = nextBloodDrawRecommendation(treatments, doseLogs, labs);
|
||||
if (rec) {
|
||||
const card = el('div', { class: 'card' });
|
||||
card.appendChild(el('div', { class: 'title' }, t('labrec_section')));
|
||||
const mainLines = [
|
||||
card.appendChild(el('div', { class: 'main' }, [
|
||||
el('div', {}, t('labrec_when', formatDateTime(rec.troughMs))),
|
||||
];
|
||||
// Prédiction E2 + statut cible (v1.13.0 — lecture seule, jamais de notif)
|
||||
if (rec.predictedE2 !== null && rec.predictedE2 !== undefined) {
|
||||
const status = troughTargetStatus(
|
||||
rec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null,
|
||||
);
|
||||
const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}`
|
||||
: status !== null ? ` — ${t('labrec_out_of_target')}` : '';
|
||||
const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)'
|
||||
: status !== null ? 'var(--accent,#d6589e)' : '';
|
||||
mainLines.push(el('div', { style: statusColor ? `color:${statusColor};` : '' },
|
||||
t('labrec_predicted', formatDose(Math.round(rec.predictedE2))) + statusText));
|
||||
}
|
||||
mainLines.push(
|
||||
el('div', {}, t('labrec_before_injection', rec.ester, formatDateTime(rec.injectionMs))),
|
||||
el('div', { class: 'legend-muted' },
|
||||
rec.wasAlreadyStabilized
|
||||
? t('labrec_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))
|
||||
: t('labrec_not_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))),
|
||||
el('div', { class: 'legend-muted' }, t('labrec_disclaimer')),
|
||||
);
|
||||
card.appendChild(el('div', { class: 'main' }, mainLines));
|
||||
]));
|
||||
col.appendChild(card);
|
||||
} else if (shouldSuggestPosology(treatments)) {
|
||||
const card = el('div', { class: 'card' });
|
||||
|
||||
@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
|
||||
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
|
||||
* v1.4.10 = portage initial de l'Android v1.4.10.
|
||||
*/
|
||||
export const WEB_VERSION = '1.13.0';
|
||||
export const WEB_VERSION = '1.9.5';
|
||||
|
||||
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
|
||||
function parseNumOrNull(text) {
|
||||
@ -185,46 +185,6 @@ export function renderSettings(container) {
|
||||
}, t('save')));
|
||||
col.appendChild(alertCard);
|
||||
|
||||
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────────
|
||||
// ⚠️ DISTINCT des seuils d'alerte (carte au-dessus) : référence de lab
|
||||
// personnelle (« je veux être dans [basse, haute] au creux recommandé »),
|
||||
// lecture seule sur la carte reco — JAMAIS de notification (miroir
|
||||
// TroughTargetCard.kt).
|
||||
const targetCard = el('div', { class: 'card' });
|
||||
targetCard.appendChild(el('h3', {}, t('trough_target_section')));
|
||||
targetCard.appendChild(el('div', { class: 'hint' }, t('trough_target_hint')));
|
||||
const ttLowField = field({ label: t('trough_target_low'), type: 'number', value: settings.troughTargetLow === null ? '' : String(settings.troughTargetLow), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||
const ttHighField = field({ label: t('trough_target_high'), type: 'number', value: settings.troughTargetHigh === null ? '' : String(settings.troughTargetHigh), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||
const ttRow = el('div', { class: 'row' });
|
||||
ttRow.appendChild(ttLowField);
|
||||
ttRow.appendChild(ttHighField);
|
||||
targetCard.appendChild(ttRow);
|
||||
const ttError = el('div', { class: 'error-text', style: 'display:none;' });
|
||||
targetCard.appendChild(ttError);
|
||||
targetCard.appendChild(el('button', {
|
||||
class: 'btn',
|
||||
onclick: () => {
|
||||
const low = parseNumOrNull(ttLowField.input.value);
|
||||
const high = parseNumOrNull(ttHighField.input.value);
|
||||
if ((low === null) !== (high === null)) {
|
||||
// Un seul champ rempli = saisie incomplète (pas de « demi-cible »)
|
||||
ttError.textContent = t('trough_target_incomplete');
|
||||
ttError.style.display = '';
|
||||
} else if (low !== null && high !== null && high <= low) {
|
||||
ttError.textContent = t('trough_target_invalid');
|
||||
ttError.style.display = '';
|
||||
} else {
|
||||
ttError.style.display = 'none';
|
||||
// null/null = désactivation (setTroughTarget efface, miroir Android)
|
||||
store.setSettings({
|
||||
troughTargetLow: low, troughTargetHigh: high,
|
||||
});
|
||||
toast(t('saved'));
|
||||
}
|
||||
},
|
||||
}, t('save')));
|
||||
col.appendChild(targetCard);
|
||||
|
||||
// ── Fuseau du graphique (v1.4.5) ───────────────────────────────────────
|
||||
const tzCard = el('div', { class: 'card' });
|
||||
tzCard.appendChild(el('h3', {}, t('timezone_section')));
|
||||
@ -381,23 +341,13 @@ export function renderSettings(container) {
|
||||
store.setTConfig(data.tConfig);
|
||||
// ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher)
|
||||
if (data.settings) {
|
||||
const patch = {
|
||||
store.setSettings({
|
||||
autoCalibrate: data.settings.autoCalibrate ?? false,
|
||||
alertE2High: data.settings.alertE2High ?? null,
|
||||
alertE2Low: data.settings.alertE2Low ?? null,
|
||||
alertTHigh: data.settings.alertTHigh ?? null,
|
||||
alertTLow: data.settings.alertTLow ?? null,
|
||||
};
|
||||
// v1.13.0 : cible de creux — restaurée SEULEMENT si le backup
|
||||
// en porte une (champs non nuls ET cohérents) ; un backup
|
||||
// ancien ne doit pas effacer la cible locale (miroir Android)
|
||||
if (data.settings.troughTargetLow !== null && data.settings.troughTargetLow !== undefined
|
||||
&& data.settings.troughTargetHigh !== null && data.settings.troughTargetHigh !== undefined
|
||||
&& data.settings.troughTargetHigh > data.settings.troughTargetLow) {
|
||||
patch.troughTargetLow = data.settings.troughTargetLow;
|
||||
patch.troughTargetHigh = data.settings.troughTargetHigh;
|
||||
}
|
||||
store.setSettings(patch);
|
||||
});
|
||||
if (data.settings.language) {
|
||||
store.setSettings({ language: data.settings.language });
|
||||
}
|
||||
|
||||
@ -5,15 +5,11 @@
|
||||
* Cartes (nom, voie, dose, chips ester·modèle / Tmax / ×scale / ⏰ rappel,
|
||||
* badge inactif) → tap = éditeur. ⚠️ Le chip modèle passe par modelLabelKey
|
||||
* (fix #59 : un ternaire à 2 branches affichait « Estrannaise » pour WHSAH).
|
||||
*
|
||||
* v1.11.0 (demande utilisatrice, miroir Android) : les ACTIFS d'abord, les
|
||||
* INACTIFS regroupés TOUT EN BAS sous un en-tête de section, cartes
|
||||
* ATTÉNUÉES (opacity 0.55) en plus du badge — « peu discernables ».
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { store } from '../data/store.js';
|
||||
import { modelLabelKey, routeLabelKey, treatmentFlags, treatmentsForDisplay } from '../data/models.js';
|
||||
import { modelLabelKey, routeLabelKey, treatmentFlags } from '../data/models.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatDose } from '../util/format.js';
|
||||
import { el, assistChip, fab } from './components.js';
|
||||
@ -24,10 +20,7 @@ import { el, assistChip, fab } from './components.js';
|
||||
* @param {object} ctx { openEditor: (id:number)=>void, openNew: ()=>void }
|
||||
*/
|
||||
export function renderTreatments(container, ctx) {
|
||||
// v1.11.0 : actifs d'abord, inactifs regroupés en bas (cf treatmentsForDisplay)
|
||||
const treatments = treatmentsForDisplay(store.getTreatments());
|
||||
const active = treatments.filter((tr) => tr.isActive);
|
||||
const inactive = treatments.filter((tr) => !tr.isActive);
|
||||
const treatments = store.getTreatments();
|
||||
|
||||
const col = el('div', {});
|
||||
container.appendChild(col);
|
||||
@ -36,18 +29,9 @@ export function renderTreatments(container, ctx) {
|
||||
col.appendChild(el('p', {}, t('no_data')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend UNE carte traitement. @param {object} tr @param {boolean} dimmed
|
||||
* carte inactive → opacité réduite (second signal visuel au-delà du
|
||||
* badge ; reste lisible et cliquable).
|
||||
*/
|
||||
const renderCard = (tr, dimmed) => {
|
||||
for (const tr of treatments) {
|
||||
const flags = treatmentFlags(tr); // miroir des getters Kotlin
|
||||
const card = el('div', {
|
||||
class: 'card',
|
||||
style: `cursor:pointer;${dimmed ? 'opacity:0.55;' : ''}`,
|
||||
onclick: () => ctx.openEditor(tr.id),
|
||||
});
|
||||
const card = el('div', { class: 'card', style: 'cursor:pointer;', onclick: () => ctx.openEditor(tr.id) });
|
||||
|
||||
const titleRow = el('div', { class: 'row' });
|
||||
titleRow.appendChild(el('span', { style: 'font-weight:600;flex:1;' }, tr.name));
|
||||
@ -71,19 +55,7 @@ export function renderTreatments(container, ctx) {
|
||||
chipRow.appendChild(assistChip(`⏰ ${hh}:${mm}`));
|
||||
}
|
||||
card.appendChild(chipRow);
|
||||
return card;
|
||||
};
|
||||
|
||||
for (const tr of active) {
|
||||
col.appendChild(renderCard(tr, false));
|
||||
}
|
||||
|
||||
// En-tête de section SEULEMENT s'il existe des inactifs (miroir Android)
|
||||
if (inactive.length > 0) {
|
||||
col.appendChild(el('p', { class: 'sub', style: 'margin:14px 0 4px;' }, t('inactive_section')));
|
||||
}
|
||||
for (const tr of inactive) {
|
||||
col.appendChild(renderCard(tr, true));
|
||||
col.appendChild(card);
|
||||
}
|
||||
|
||||
container.appendChild(fab(ctx.openNew));
|
||||
|
||||
@ -113,33 +113,6 @@ export function formatLongDate(ms, timeZone = null) {
|
||||
}), 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).
|
||||
@ -240,32 +213,3 @@ export function labNotesForDisplay(group) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ const EN = {
|
||||
no_data_short: 'No data — log an injection to see estimates',
|
||||
delta_6h: '{0} {1} pg/mL vs 6 h ago',
|
||||
next_dose: 'Next dose',
|
||||
next_doses: 'Upcoming doses',
|
||||
next_dose_days: '{0} d {1} h · {2} ({3})',
|
||||
logged_today: 'Quick log',
|
||||
home_chart_title: 'Last 24 h',
|
||||
@ -64,20 +63,8 @@ const EN = {
|
||||
// Labs
|
||||
edit_which_lab: 'Which entry to edit?',
|
||||
confirm_delete_blood_draw: 'Delete the whole blood draw ({0})?',
|
||||
lab_before_dose: 'Blood test earlier the same day, before this dose',
|
||||
lab_after_dose: 'Blood test later the same day, after this dose',
|
||||
labrec_predicted: 'Expected at this trough: ≈ {0} pg/mL',
|
||||
labrec_in_target: 'within your target',
|
||||
labrec_out_of_target: 'outside your target',
|
||||
trough_target_section: 'Trough target (blood draw)',
|
||||
trough_target_hint: 'Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.',
|
||||
trough_target_low: 'Target low (pg/mL)',
|
||||
trough_target_high: 'Target high (pg/mL)',
|
||||
trough_target_incomplete: 'Fill both fields — or leave both empty to disable.',
|
||||
trough_target_invalid: 'Target high must be greater than target low.',
|
||||
// Traitements
|
||||
inactive: 'inactive',
|
||||
inactive_section: 'Inactive treatments',
|
||||
pk_absorption: 'Tmax',
|
||||
pk_halflife: 'Half-life (h)',
|
||||
pk_bioavail: 'Bioavailability (0–1)',
|
||||
@ -265,7 +252,6 @@ const FR = {
|
||||
no_data_short: 'Aucune donnée — logue une injection pour voir les estimations',
|
||||
delta_6h: '{0} {1} pg/mL vs il y a 6 h',
|
||||
next_dose: 'Prochaine dose',
|
||||
next_doses: 'Prochaines doses',
|
||||
next_dose_days: '{0} j {1} h · {2} ({3})',
|
||||
logged_today: 'Log rapide',
|
||||
home_chart_title: 'Dernières 24 h',
|
||||
@ -284,19 +270,7 @@ const FR = {
|
||||
confirm_delete_treatment: 'Supprimer ce traitement ? Son historique de doses sera supprimé aussi (les analyses sont conservées).',
|
||||
edit_which_lab: 'Quelle entrée modifier ?',
|
||||
confirm_delete_blood_draw: 'Supprimer la prise de sang entière ({0}) ?',
|
||||
lab_before_dose: 'Prise de sang plus tôt le même jour, avant cette dose',
|
||||
lab_after_dose: 'Prise de sang plus tard le même jour, après cette dose',
|
||||
labrec_predicted: 'Attendu à ce creux : ≈ {0} pg/mL',
|
||||
labrec_in_target: 'dans ta cible',
|
||||
labrec_out_of_target: 'hors de ta cible',
|
||||
trough_target_section: 'Cible de creux (prise de sang)',
|
||||
trough_target_hint: 'Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.',
|
||||
trough_target_low: 'Cible basse (pg/mL)',
|
||||
trough_target_high: 'Cible haute (pg/mL)',
|
||||
trough_target_incomplete: 'Remplis les deux champs — ou laisse les deux vides pour désactiver.',
|
||||
trough_target_invalid: 'La cible haute doit être supérieure à la cible basse.',
|
||||
inactive: 'inactive',
|
||||
inactive_section: 'Traitements inactifs',
|
||||
pk_absorption: 'Tmax',
|
||||
pk_halflife: 'Demi-vie (h)',
|
||||
pk_bioavail: 'Biodisponibilité (0–1)',
|
||||
|
||||
@ -46,11 +46,9 @@ describe('format BackupData v2 (compatibilité Android)', () => {
|
||||
assert.equal(parsed.labResults.length, 1);
|
||||
assert.deepEqual(parsed.tConfig, tConfig);
|
||||
// champs plats UserSettings exactement comme Gson
|
||||
// (v1.13.0 : la cible de creux voyage aussi — champs optionnels)
|
||||
assert.deepEqual(parsed.settings, {
|
||||
language: 'fr', autoCalibrate: true,
|
||||
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
|
||||
troughTargetLow: null, troughTargetHigh: null,
|
||||
});
|
||||
// fidélité des entités (champs Gson/Kotlin)
|
||||
assert.equal(parsed.treatments[0].calendarEventId, null);
|
||||
|
||||
@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Tests des ajouts v1.12.0 à la page Doses (miroir de DosesExtrasTest.kt +
|
||||
* ForecastDosesGuardTest.kt) :
|
||||
* - FIX #70 : un traitement INACTIF ne projette PLUS de créneaux
|
||||
* (generateForecastDoses) — l'historique reste simulé (§6.bis) ;
|
||||
* - routeGlyph : un émoji PAR forme de prise (IM/SC partagent la seringue
|
||||
* par design — différenciées par le texte de la ligne côté web) ;
|
||||
* - labMarkersForDose : marqueurs « prise de sang le même jour » —
|
||||
* avant/après la dose, jour calendaire LOCAL, frontière minuit.
|
||||
*
|
||||
* Déterministes (instants construits via Date locale, hors changement
|
||||
* d'heure — même discipline que misc.test.js).
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { generateForecastDoses } from '../js/pk/pk-engine.js';
|
||||
import { routeGlyph, treatmentsForDisplay } from '../js/data/models.js';
|
||||
import { labMarkersForDose } from '../js/util/format.js';
|
||||
import { makeTreatment, makeDose, DAY_MS, HOUR_MS } from './helpers.js';
|
||||
|
||||
const NOW = 1_790_000_000_000;
|
||||
|
||||
describe('forecast guard for inactive treatments (fix #70, v1.12.0)', () => {
|
||||
test('un inactif à Posologie + historique ne projette plus rien', () => {
|
||||
const archived = makeTreatment({ id: 1, isActive: false, forecastIntervalDays: 7 });
|
||||
const doses = [
|
||||
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - DAY_MS, 5, { treatmentId: 1 }),
|
||||
];
|
||||
assert.equal(generateForecastDoses(archived, doses, NOW + 30 * DAY_MS, NOW).length, 0);
|
||||
});
|
||||
|
||||
test('un actif jumeau projette toujours (comportement inchangé)', () => {
|
||||
const active = makeTreatment({ id: 2, isActive: true, forecastIntervalDays: 7 });
|
||||
const doses = [
|
||||
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 2 }),
|
||||
makeDose(NOW - DAY_MS, 5, { treatmentId: 2 }),
|
||||
];
|
||||
const slots = generateForecastDoses(active, doses, NOW + 30 * DAY_MS, NOW);
|
||||
assert.ok(slots.length > 0, 'un actif à Posologie projette');
|
||||
assert.equal(slots[0].timestamp, NOW - DAY_MS + 7 * DAY_MS);
|
||||
});
|
||||
|
||||
test('archiver un traitement actif arrête ses créneaux futurs', () => {
|
||||
const treatment = makeTreatment({ id: 3, isActive: true, forecastIntervalDays: 7 });
|
||||
const doses = [makeDose(NOW - DAY_MS, 5, { treatmentId: 3 })];
|
||||
const before = generateForecastDoses(treatment, doses, NOW + 30 * DAY_MS, NOW);
|
||||
const after = generateForecastDoses(
|
||||
{ ...treatment, isActive: false }, doses, NOW + 30 * DAY_MS, NOW,
|
||||
);
|
||||
assert.ok(before.length > 0);
|
||||
assert.equal(after.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route glyphs (v1.12.0)', () => {
|
||||
test('un glyphe par forme (IM/SC partagent la seringue par design)', () => {
|
||||
const oral = routeGlyph('ORAL');
|
||||
const im = routeGlyph('INJECTION_IM');
|
||||
const sc = routeGlyph('INJECTION_SUBCUT');
|
||||
const gel = routeGlyph('TRANSDERMAL_GEL');
|
||||
const patch = routeGlyph('TRANSDERMAL_PATCH');
|
||||
const other = routeGlyph('OTHER');
|
||||
assert.notEqual(oral, im);
|
||||
assert.notEqual(oral, gel);
|
||||
assert.notEqual(im, gel);
|
||||
assert.notEqual(gel, patch);
|
||||
assert.notEqual(patch, other);
|
||||
assert.equal(im, sc, 'IM/SC : même seringue (texte de ligne distinct côté web)');
|
||||
});
|
||||
|
||||
test('route inconnue/null → glyphe OTHER', () => {
|
||||
assert.equal(routeGlyph(null), routeGlyph('OTHER'));
|
||||
assert.equal(routeGlyph(undefined), routeGlyph('OTHER'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('lab markers per dose (v1.12.0)', () => {
|
||||
// Jour calendaire LOCAL de node (hors changement d'heure)
|
||||
const noon = new Date(2026, 8, 27, 12, 0).getTime(); // 27 sept 2026 12:00 local
|
||||
|
||||
const lab = (atMs) => ({ marker: 'E2', value: 100, unit: 'pg/mL', timestamp: atMs });
|
||||
|
||||
test('labs avant ET après la dose le même jour → les deux marqueurs', () => {
|
||||
const m = labMarkersForDose([lab(noon - HOUR_MS), lab(noon + HOUR_MS)], noon);
|
||||
assert.equal(m.before, true);
|
||||
assert.equal(m.after, true);
|
||||
});
|
||||
|
||||
test('lab d\'un autre jour ne marque jamais', () => {
|
||||
const m = labMarkersForDose([lab(noon - 24 * HOUR_MS)], noon);
|
||||
assert.equal(m.before, false);
|
||||
assert.equal(m.after, false);
|
||||
});
|
||||
|
||||
test('frontière minuit : 23:50 la veille ≠ jour suivant', () => {
|
||||
const lateEvening = new Date(2026, 8, 26, 23, 50).getTime();
|
||||
const earlyMorning = new Date(2026, 8, 27, 0, 10).getTime();
|
||||
const m = labMarkersForDose([lab(lateEvening)], earlyMorning);
|
||||
assert.equal(m.before, false);
|
||||
assert.equal(m.after, false);
|
||||
});
|
||||
|
||||
test('lab exactement à l\'heure de la dose compte comme APRÈS (convention)', () => {
|
||||
const m = labMarkersForDose([lab(noon)], noon);
|
||||
assert.equal(m.before, false);
|
||||
assert.equal(m.after, true);
|
||||
});
|
||||
|
||||
test('aucun lab → aucun marqueur', () => {
|
||||
const m = labMarkersForDose([], noon);
|
||||
assert.equal(m.before, false);
|
||||
assert.equal(m.after, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── v1.13.0 : régime poolé + prédiction au creux + statut cible ────────────
|
||||
import { pooledRegimeDoses, troughTargetStatus } from '../js/pk/lab-timing.js';
|
||||
import { buildBackupJson, parseBackupJson } from '../js/data/backup.js';
|
||||
|
||||
describe('pooled regime doses (v1.13.0, miroir LabTimingV113Test)', () => {
|
||||
test('le pool traverse la frontière de re-parenting (même ester, même mg)', () => {
|
||||
const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 });
|
||||
const old = makeTreatment({ id: 10, isActive: false, forecastIntervalDays: 6 });
|
||||
const all = [
|
||||
makeDose(NOW - 50 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 43 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 36 * DAY_MS, 5, { treatmentId: 10 }),
|
||||
makeDose(NOW - 29 * DAY_MS, 5, { treatmentId: 10 }),
|
||||
makeDose(NOW - 22 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 15 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
];
|
||||
const pooled = pooledRegimeDoses(active, all, [active, old]);
|
||||
assert.equal(pooled.length, 7, 'le pool traverse la frontière t1/t10');
|
||||
});
|
||||
|
||||
test('ester différent → jamais poolé (EV inactif hors du régime EEn)', () => {
|
||||
const active = makeTreatment({ id: 1, isActive: true, esterType: 'EEN' });
|
||||
const inactiveEv = makeTreatment({ id: 2, isActive: false, esterType: 'EV' });
|
||||
const all = [
|
||||
makeDose(NOW - 20 * DAY_MS, 5, { treatmentId: 2 }),
|
||||
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
];
|
||||
assert.equal(pooledRegimeDoses(active, all, [active, inactiveEv]).length, 1);
|
||||
});
|
||||
|
||||
test('dose de mg différent → exclue (le 8 mg casse le régime)', () => {
|
||||
const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 });
|
||||
const all = [
|
||||
makeDose(NOW - 28 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 21 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 14 * DAY_MS, 5, { treatmentId: 1 }),
|
||||
makeDose(NOW - 7 * DAY_MS, 8, { treatmentId: 1 }),
|
||||
makeDose(NOW, 5, { treatmentId: 1 }),
|
||||
];
|
||||
const pooled = pooledRegimeDoses(active, all, [active]);
|
||||
assert.equal(pooled.length, 4, 'le 8 mg est exclu du pool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trough target status (v1.13.0)', () => {
|
||||
test('matrice in/below/above + bornes incluses + null', () => {
|
||||
assert.equal(troughTargetStatus(175, 150, 200), 'IN_TARGET');
|
||||
assert.equal(troughTargetStatus(140, 150, 200), 'BELOW');
|
||||
assert.equal(troughTargetStatus(220, 150, 200), 'ABOVE');
|
||||
assert.equal(troughTargetStatus(150, 150, 200), 'IN_TARGET');
|
||||
assert.equal(troughTargetStatus(200, 150, 200), 'IN_TARGET');
|
||||
assert.equal(troughTargetStatus(null, 150, 200), null);
|
||||
assert.equal(troughTargetStatus(175, null, 200), null);
|
||||
assert.equal(troughTargetStatus(175, 150, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backup round-trip trough target (v1.13.0)', () => {
|
||||
test('les champs voyagent et restent absents quand non définis', () => {
|
||||
const userSettings = { language: 'fr', troughTargetLow: 150, troughTargetHigh: 200 };
|
||||
const parsed = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, userSettings));
|
||||
assert.equal(parsed.settings.troughTargetLow, 150);
|
||||
assert.equal(parsed.settings.troughTargetHigh, 200);
|
||||
const parsed2 = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, { language: 'en' }));
|
||||
assert.equal(parsed2.settings.troughTargetLow, null);
|
||||
assert.equal(parsed2.settings.troughTargetHigh, null);
|
||||
});
|
||||
});
|
||||
@ -113,35 +113,12 @@ describe('LabTiming (miroir Android v1.8.0)', () => {
|
||||
});
|
||||
|
||||
test('changement d\'intervalle récent réinitialise la stabilisation', () => {
|
||||
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart de
|
||||
// 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7, [6 j ;
|
||||
// 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un vrai
|
||||
// changement de créneau reste un changement — miroir Android)
|
||||
// 9 semaines à 7 j, puis une dose 2 j après la plus ancienne des deux
|
||||
// dernières : l'écart terminal devient 2 j → régime fondé sur cette dose
|
||||
// (celle à NOW−14 j avait un écart de 5 j ≠ 2 j → hors régime)
|
||||
const doses = weeklyDoses(9).concat([makeDose(NOW - 9 * DAY_MS, 5.0)]);
|
||||
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
|
||||
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
|
||||
assert.equal(rec.injectionMs, NOW + 21 * DAY_MS);
|
||||
assert.equal(rec.wasAlreadyStabilized, false);
|
||||
});
|
||||
|
||||
test('le flou d\'heure de log ne réinitialise PAS la stabilisation (v1.9.7)', () => {
|
||||
// REMONTÉE v1.9.7 (miroir Android) : « la suggestion change tout le
|
||||
// temps à chaque injection si l'injection n'est pas faite pile à la
|
||||
// même heure » — chaque écart reste dans Posologie ± 24 h → le régime
|
||||
// remonte la séquence entière (63 j) → déjà stabilisée.
|
||||
const jitterHours = [2, -3, 5, -1, 23, -23, 1, -0.5, 0.75];
|
||||
const doses = jitterHours.map((jh, i) =>
|
||||
makeDose(NOW - (63 - 7 * i) * DAY_MS + jh * HOUR_MS, 5.0));
|
||||
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
|
||||
assert.equal(rec.wasAlreadyStabilized, true);
|
||||
});
|
||||
|
||||
test('un écart au-delà de 24 h de tolérance réinitialise (v1.9.7)', () => {
|
||||
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j : hors fenêtre
|
||||
// → le régime ne remonte pas avant la dose en retard → non stabilisée
|
||||
const doses = weeklyDoses(9).map((d, i) =>
|
||||
i === 7 ? makeDose(NOW - 14 * DAY_MS + 25 * HOUR_MS, 5.0) : d);
|
||||
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
|
||||
assert.equal(rec.injectionMs, NOW + 14 * DAY_MS);
|
||||
assert.equal(rec.wasAlreadyStabilized, false);
|
||||
});
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import { nextReminderFireFor, nextReminderFireMs } from '../js/pk/pk-reminders.j
|
||||
import { detectExtrema } from '../js/pk/pk-extrema.js';
|
||||
import { LevelPoint } from '../js/pk/pk-engine.js';
|
||||
import { isVersionNewer, sectionsSince } from '../js/util/changelog.js';
|
||||
import { HrtDuration, nextDoseLineText } from '../js/util/format.js';
|
||||
import { HrtDuration } from '../js/util/format.js';
|
||||
import { makeTreatment, makeDose, DAY_MS, HOUR_MS, assertClose } from './helpers.js';
|
||||
|
||||
/** Met (h, m) sur le jour calendaire de [ms] (locale — miroir du module testé). */
|
||||
@ -212,30 +212,3 @@ describe('HrtDuration (temps sous THS)', () => {
|
||||
assert.throws(() => assertClose(300, 100, 0.02));
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextDoseLineText (carte « Prochaines doses » — v1.9.6, miroir NextDoseLineTest.kt)', () => {
|
||||
// Hors DST : les heures attendues sont construites avec les MÊMES
|
||||
// primitives (Intl fuseau système) que le module testé.
|
||||
const now = new Date(2026, 8, 21, 12, 0, 0).getTime(); // lundi 21/09/2026 12:00
|
||||
const timeOf = (h, m) => new Date(2026, 8, 21, h, m, 0).getTime();
|
||||
|
||||
test('au-delà de 24 h — jours + jour de semaine', () => {
|
||||
// fire = samedi 26/09 10:00 (118 h plus tard)
|
||||
const fire = now + 118 * HOUR_MS;
|
||||
const line = nextDoseLineText(now, fire, 'Injection EEn 5mg 7d');
|
||||
assert.match(line, /^4j 22h · .+ \(Injection EEn 5mg 7d\)$/);
|
||||
// le jour de semaine est présent (le format Intl diffère selon la locale
|
||||
// système : on épingle la STRUCTURE, pas le libellé)
|
||||
assert.ok(!line.startsWith('118'), 'delta affiché en jours, pas en heures');
|
||||
});
|
||||
|
||||
test('entre 1 h et 24 h — heures + minutes', () => {
|
||||
const fire = timeOf(14, 5);
|
||||
assert.equal(nextDoseLineText(now, fire, 'CPA'), `2h05 · 14:05 (CPA)`);
|
||||
});
|
||||
|
||||
test('moins d\'une heure — minutes', () => {
|
||||
const fire = timeOf(12, 7);
|
||||
assert.equal(nextDoseLineText(now, fire, 'CPA'), `7 min · 12:07 (CPA)`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Tests de l'ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, miroir de
|
||||
* TreatmentsDisplayTest.kt — demande utilisatrice : « les traitements
|
||||
* inactifs faudrait les placer à la fin (tout en bas) de la liste ») :
|
||||
* - les ACTIFS d'abord, les INACTIFS regroupés après ;
|
||||
* - tri STABLE (ordre conservé à l'intérieur de chaque groupe) ;
|
||||
* - cas limites : liste vide, tout actif, tout inactif (identité).
|
||||
*/
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { treatmentsForDisplay } from '../js/data/models.js';
|
||||
import { makeTreatment } from './helpers.js';
|
||||
|
||||
describe('treatments display order (v1.11.0)', () => {
|
||||
test('les inactifs sont regroupés à la fin', () => {
|
||||
const mixed = [
|
||||
makeTreatment({ id: 1, name: 'EEn', isActive: true }),
|
||||
makeTreatment({ id: 2, name: 'EV-old', isActive: false }),
|
||||
makeTreatment({ id: 3, name: 'CPA', isActive: true }),
|
||||
makeTreatment({ id: 4, name: 'EEn-old', isActive: false }),
|
||||
makeTreatment({ id: 5, name: 'Ritaline', isActive: true }),
|
||||
];
|
||||
assert.deepEqual(
|
||||
treatmentsForDisplay(mixed).map((t) => t.id),
|
||||
[1, 3, 5, 2, 4],
|
||||
'actifs d\'abord (ordre conservé), inactifs après (ordre conservé)',
|
||||
);
|
||||
});
|
||||
|
||||
test('tri stable : ordre conservé à l\'intérieur de chaque groupe', () => {
|
||||
const mixed = [
|
||||
makeTreatment({ id: 10, name: 'A', isActive: false }),
|
||||
makeTreatment({ id: 11, name: 'B', isActive: true }),
|
||||
makeTreatment({ id: 12, name: 'C', isActive: false }),
|
||||
makeTreatment({ id: 13, name: 'D', isActive: true }),
|
||||
];
|
||||
assert.deepEqual(
|
||||
treatmentsForDisplay(mixed).map((t) => t.id),
|
||||
[11, 13, 10, 12],
|
||||
);
|
||||
});
|
||||
|
||||
test('tout actif / tout inactif : ordre inchangé (identité)', () => {
|
||||
const actifs = [
|
||||
makeTreatment({ id: 3, isActive: true }),
|
||||
makeTreatment({ id: 1, isActive: true }),
|
||||
];
|
||||
assert.deepEqual(treatmentsForDisplay(actifs).map((t) => t.id), [3, 1]);
|
||||
const inactifs = [
|
||||
makeTreatment({ id: 2, isActive: false }),
|
||||
makeTreatment({ id: 1, isActive: false }),
|
||||
];
|
||||
assert.deepEqual(treatmentsForDisplay(inactifs).map((t) => t.id), [2, 1]);
|
||||
});
|
||||
|
||||
test('liste vide reste vide ; entrée non mutée', () => {
|
||||
assert.equal(treatmentsForDisplay([]).length, 0);
|
||||
const mixed = [
|
||||
makeTreatment({ id: 2, isActive: false }),
|
||||
makeTreatment({ id: 1, isActive: true }),
|
||||
];
|
||||
treatmentsForDisplay(mixed);
|
||||
assert.deepEqual(mixed.map((t) => t.id), [2, 1], 'la liste d\'entrée n\'est pas mutée');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user