Siphonight dfef8e90ef v1.5.0 : « Tracé labs » — portage complet de l'Android v1.5.0 (versions sync)
Miroir strict de pk/LabTrajectoryModel.kt (Android v1.5.0) :
- js/pk/lab-trajectory-model.js : courbe hybride M(t)×ρ(t) ancrée sur les
  labs — passage EXACT garanti, ρ log-linéaire, garde #61 (lab hors
  fenêtre d'action pas un ancrage), E2 seul, fenêtre [1er ; dernier lab],
  < 2 labs → vide, indépendante de la calibration (sinon double
  correction). JAMAIS dans levelAt/alertes.
- UI : chip 'Tracé labs' (off par défaut) dans la rangée modèles +
  scroll horizontal CONFINÉ à la rangée (miroir du fix Android) ;
  couleur #c2185b dashed ; leçon #63 Android-miroir — clé 'LAB' SKIPPÉE
  de la boucle légende (sinon légende TFS dupliquée).
- 11 tests miroirs de LabTrajectoryModelTest.kt (132 verts total).
- WEB_VERSION = 1.5.0 + CHANGELOG [1.5.0] + docs (§4/§8, README).

Détail d'exécution épingle: les deux problèmes du portage ont été
attrapés par node --check/E2E avant release (imports ESM faux —
labIsSignificant vit dans pk-calibration.js, pas pk-engine.js).
2026-09-11 16:17:36 +02:00

469 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ─────────────────────────────────────────────────────────────────────────────
* i18n FR/EN (miroir du système Android : values/strings.xml EN défaut +
* values-fr/strings.xml).
*
* - `t(key, ...args)` : traduction + substitution de placeholders {0},{1}… ;
* - langue persistée dans le store ("system"/"fr"/"en") — "system" suit
* navigator.language (équivalent AppCompatDelegate.setApplicationLocales) ;
* - ⚠️ TOUTE nouvelle string existe dans les DEUX dictionnaires (leçon
* Android §12 : une référence manquante = affichage brut de la clé).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { store } from '../data/store.js';
/** Dictionnaire ANGLAIS (défaut — même convention que values/strings.xml). */
const EN = {
app_name: 'HormoneTrack',
// Navigation
nav_home: 'Home',
nav_chart: 'Charts',
nav_doses: 'Doses',
nav_labs: 'Labs',
nav_treatments: 'Treatments',
settings: 'Settings',
back: 'Back',
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
ok: 'OK',
add_dose: 'Log dose',
add_lab: 'Add blood test',
add_treatment: 'Add treatment',
edit_treatment: 'Edit treatment',
edit_dose: 'Edit dose',
no_data: 'No data yet',
loading: 'Loading…',
// Avertissement médical
disclaimer: '⚠️ Medical disclaimer: the curves are pharmacokinetic estimates for information purposes — not measurements. Always trust your blood tests and your endocrinologist.',
// Accueil
current_level: 'Current level (estimated)',
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_dose_days: '{0} d {1} h · {2} ({3})',
logged_today: 'Quick log',
home_chart_title: 'Last 24 h',
home_legend_e2: 'E2 — estradiol (pg/mL)',
home_legend_t: 'T — testosterone (ng/mL)',
open_charts_hint: 'Tap to open full charts',
// Alertes
alert_card_title: 'Threshold crossed (estimate)',
alert_high: '▲ {0} estimated ≈ {1} {2} — above your limit ({3})',
alert_low: '▼ {0} estimated ≈ {1} {2} — below your limit ({3})',
alert_disclaimer: 'Based on the estimated level, not a measurement.',
// Doses
hrt_since: 'On HRT since {0}',
hrt_duration: '{0} months, {1} days — {2} days total',
hrt_duration_days_only: '{0} days',
interval_value: '{0} d since previous',
confirm_delete: 'Delete this entry?',
confirm_delete_treatment: 'Delete this treatment? Its dose history will be removed too (labs are kept).',
// Labs
edit_which_lab: 'Which entry to edit?',
confirm_delete_blood_draw: 'Delete the whole blood draw ({0})?',
// Traitements
inactive: 'inactive',
pk_absorption: 'Tmax',
pk_halflife: 'Half-life (h)',
pk_bioavail: 'Bioavailability (0–1)',
treatment_name: 'Name',
treatment_type: 'Type',
treatment_route: 'Route',
ester: 'Ester',
pk_model: 'PK model',
default_dose: 'Standard dose',
dose_unit: 'Unit',
select_preset: 'Select a preset',
type_estradiol: 'Estradiol',
type_antiandrogen: 'Anti-androgen',
type_progestogen: 'Progestogen',
type_other: 'Other',
route_oral: 'Oral',
route_gel: 'Transdermal gel',
route_patch: 'Transdermal patch',
route_injection_im: 'IM injection',
route_injection_sc: 'Subcutaneous injection',
route_other: 'Other route',
model_ese: 'Estrannaise',
model_tfs: 'Transfem Science',
model_whs: 'WHSAH',
// Éditeur : calibration
calibration_title: 'Calibration',
scale_factor: 'Scale factor',
calibration_hint: 'Ratio lab ÷ model prediction. Use “Calibrate from labs” or enable auto-calibration in Settings.',
calibration_auto_override: 'Auto-calibration is ON: this manual factor is ignored on charts display.',
calibrate_from_labs: 'Calibrate from labs',
// Éditeur : posologie / prévision
freq_section: 'Posology (forecast)',
freq_interval: 'Interval (days)',
freq_hint: 'Projects upcoming doses on the chart and schedules reminders on this grid.',
// Éditeur : rappel
reminder: 'Reminder',
reminder_time: 'Reminder time',
// Éditeur : agenda
agenda_section: 'Calendar event',
agenda_hint: 'Recurring event in a local calendar (Android only). Not available in the web app.',
agenda_needs_posology: 'A posology is required for recurring events.',
agenda_permission: 'Requires calendar permissions on Android.',
// Éditeur : actif
active: 'Active',
// Dose dialog
dose_amount: 'Dose amount',
dose_time: 'Date & time',
dose_notes: 'Notes',
ester_default: 'Default ({0})',
no_treatment_hint: 'Create a treatment first (Treatments tab).',
// Lab dialog
lab_e2_value: 'E2 (pg/mL)',
lab_t_value: 'T',
lab_units_hint: 'Accepted T units: ng/mL, ng/dL, ng/L, nmol/L',
lab_timestamp: 'Date & time',
lab_notes: 'Notes',
lab_optional: 'optional',
// Paramètres
language: 'Language',
language_system: 'System',
auto_calibrate: 'Auto-calibration (E2 + T)',
auto_calibrate_hint: 'Adjusts scale factors per ester period and the T model from your labs — applied to charts display, nothing is stored.',
t_model_title: 'Testosterone estimate (manual settings)',
t_model_hint: 'T = floor + (base − floor) / (1 + k·E2). Empirical model — estimate only.',
t_base: 'Base (ng/mL)',
t_floor: 'Floor (ng/mL)',
t_calibrated: 'k calibrated from labs',
alert_section: 'Alert thresholds',
alert_hint: 'Evaluated against the ESTIMATED level on Home (opt-in: empty = no alert). High must be above low.',
alert_e2_high: 'E2 high (pg/mL)',
alert_e2_low: 'E2 low (pg/mL)',
alert_t_high: 'T high (ng/mL)',
alert_t_low: 'T low (ng/mL)',
alert_invalid: 'Invalid values (high must be above low, numbers only)',
saved: 'Saved',
timezone_section: 'Chart timezone',
timezone_hint: 'Timezone used for chart X labels and dose markers (IANA ID, e.g. Europe/Paris). Empty = device timezone.',
timezone_field: 'IANA timezone ID',
timezone_auto: 'Auto (device)',
timezone_invalid: 'Unknown timezone ID',
reminders_section: 'Reminders (web)',
reminders_note: 'Reminders fire via browser notifications while this page is open. No notification when the tab is closed (unlike the Android app).',
notif_grant: 'Enable notifications',
notif_granted: 'Notifications enabled',
notif_denied: 'Notifications blocked by the browser',
backup_section: 'Backup (JSON)',
backup_hint: 'Same format as the Android app — backups are interchangeable in both directions.',
export_json: 'Export JSON',
import_json: 'Import JSON',
export_ok: 'Backup exported',
export_fail: 'Export failed',
import_ok: 'Restored: {0} treatments, {1} doses, {2} labs',
import_fail: 'Import failed (invalid file?)',
import_title: 'Import backup',
import_warning: '⚠️ Importing REPLACES ALL current data (treatments, doses, labs) with the backup content. Continue?',
import_confirm: 'Erase & restore',
logs_section: 'Diagnostic logs',
logs_count: '{0} lines',
logs_export: 'Export logs',
logs_clear: 'Clear',
logs_export_ok: 'Diagnostic logs exported',
logs_export_fail: 'Logs export failed',
about_title: 'About',
settings_version: 'Version {0} (web)',
releases_link: 'Releases',
models_credit: 'PK models belong to their respective authors (Estrannaise, Transfem Science, WHSAH Collective via Mona).',
privacy_note: '100% local: your data never leaves this browser (localStorage). No account, no server.',
// Chart
chart_24h: '24 h',
chart_7j: '7 d',
chart_30j: '30 d',
zoom_in: 'Zoom in',
zoom_out: 'Zoom out',
show_labs: 'Labs',
show_forecast: 'Forecast',
show_extrema: 'Peaks & troughs',
show_lab_track: 'Lab track',
back_to_present: 'Back to present',
legend_ese_e2: 'E2 — Estrannaise (blue)',
legend_ese_t: 'T — Estrannaise (pink)',
legend_tfs_e2: 'E2 — Transfem Science (teal)',
legend_tfs_t: 'T — Transfem Science (pink dashed)',
legend_whs_e2: 'E2 — WHSAH (green)',
legend_whs_t: 'T — WHSAH (purple dashed)',
legend_labs: '● labs (orange) — circle = E2, square = T',
legend_forecast_dose: 'Dashed line + ▼ = projected dose',
legend_extrema: '▲▼ = local peaks & troughs (with estimated values)',
legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)',
drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).',
// Rappels (bannière + notification)
reminder_notif_title: 'HormoneTrack — dose reminder',
reminder_notif_body: '{0} · {1}{2} — scheduled for {3}',
reminder_log_now: 'Log now',
reminder_snooze: 'Snooze 1 h',
reminder_logged: 'Dose logged from reminder',
// Dialog « Nouveautés »
whats_new_title: 'What\'s new',
whats_new_close: 'Close',
// Demo (hook de test ?demo=1)
demo_banner: 'DEMO DATA (test hook) — this data lives only in this browser.',
};
/** Dictionnaire FRANÇAIS (miroir values-fr/strings.xml). */
const FR = {
app_name: 'HormoneTrack',
nav_home: 'Accueil',
nav_chart: 'Graphiques',
nav_doses: 'Doses',
nav_labs: 'Analyses',
nav_treatments: 'Traitements',
settings: 'Paramètres',
back: 'Retour',
save: 'Enregistrer',
cancel: 'Annuler',
delete: 'Supprimer',
ok: 'OK',
add_dose: 'Loguer une dose',
add_lab: 'Ajouter une analyse',
add_treatment: 'Ajouter un traitement',
edit_treatment: 'Modifier le traitement',
edit_dose: 'Modifier la dose',
no_data: 'Aucune donnée',
loading: 'Chargement…',
disclaimer: '⚠️ Avertissement médical : les courbes sont des estimations pharmacocinétiques à titre informatif — ce ne sont pas des mesures. Fie-toi toujours à tes analyses de sang et aux consignes de ton endocrinologue.',
current_level: 'Niveau actuel (estimé)',
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_dose_days: '{0} j {1} h · {2} ({3})',
logged_today: 'Log rapide',
home_chart_title: 'Dernières 24 h',
home_legend_e2: 'E2 — œstradiol (pg/mL)',
home_legend_t: 'T — testostérone (ng/mL)',
open_charts_hint: 'Taper pour ouvrir le graphique complet',
alert_card_title: 'Seuil franchi (estimation)',
alert_high: '▲ {0} estimé ≈ {1} {2} — au-dessus de ta limite ({3})',
alert_low: '▼ {0} estimé ≈ {1} {2} — en dessous de ta limite ({3})',
alert_disclaimer: 'Basé sur le niveau estimé, pas une mesure.',
hrt_since: 'Sous THS depuis le {0}',
hrt_duration: '{0} mois, {1} jours — {2} jours au total',
hrt_duration_days_only: '{0} jours',
interval_value: '{0} j depuis la précédente',
confirm_delete: 'Supprimer cette entrée ?',
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}) ?',
inactive: 'inactive',
pk_absorption: 'Tmax',
pk_halflife: 'Demi-vie (h)',
pk_bioavail: 'Biodisponibilité (0–1)',
treatment_name: 'Nom',
treatment_type: 'Type',
treatment_route: 'Voie',
ester: 'Ester',
pk_model: 'Modèle PK',
default_dose: 'Dose standard',
dose_unit: 'Unité',
select_preset: 'Choisir un preset',
type_estradiol: 'Œstradiol',
type_antiandrogen: 'Anti-androgène',
type_progestogen: 'Progestatif',
type_other: 'Autre',
route_oral: 'Orale',
route_gel: 'Gel transdermique',
route_patch: 'Patch transdermique',
route_injection_im: 'Injection IM',
route_injection_sc: 'Injection SC',
route_other: 'Autre voie',
model_ese: 'Estrannaise',
model_tfs: 'Transfem Science',
model_whs: 'WHSAH',
calibration_title: 'Calibration',
scale_factor: 'Facteur d\'échelle',
calibration_hint: 'Ratio lab ÷ prédiction du modèle. Utilise « Calibrer avec les analyses » ou active la calibration automatique dans Paramètres.',
calibration_auto_override: 'Auto-calibration active : ce facteur manuel est ignoré à l\'affichage des courbes.',
calibrate_from_labs: 'Calibrer avec les analyses',
freq_section: 'Posologie (prévision)',
freq_interval: 'Intervalle (jours)',
freq_hint: 'Projette les doses à venir sur le graphique et cale les rappels sur cette grille.',
reminder: 'Rappel',
reminder_time: 'Heure du rappel',
agenda_section: 'Événement d\'agenda',
agenda_hint: 'Événement récurrent dans un calendrier local (Android uniquement). Non disponible dans l\'app web.',
agenda_needs_posology: 'Une Posologie est requise pour les événements récurrents.',
agenda_permission: 'Nécessite les permissions agenda sur Android.',
active: 'Active',
dose_amount: 'Dose (quantité)',
dose_time: 'Date & heure',
dose_notes: 'Notes',
ester_default: 'Défaut ({0})',
no_treatment_hint: 'Crée d\'abord un traitement (onglet Traitements).',
lab_e2_value: 'E2 (pg/mL)',
lab_t_value: 'T',
lab_units_hint: 'Unités T acceptées : ng/mL, ng/dL, ng/L, nmol/L',
lab_timestamp: 'Date & heure',
lab_notes: 'Notes',
lab_optional: 'optionnel',
language: 'Langue',
language_system: 'Système',
auto_calibrate: 'Calibration automatique (E2 + T)',
auto_calibrate_hint: 'Ajuste les facteurs d\'échelle par période d\'ester et le modèle T depuis tes labs — appliqué à l\'affichage des courbes, rien n\'est stocké.',
t_model_title: 'Estimation T (réglages manuels)',
t_model_hint: 'T = plancher + (base − plancher) / (1 + k·E2). Modèle empirique — estimation seulement.',
t_base: 'Base (ng/mL)',
t_floor: 'Plancher (ng/mL)',
t_calibrated: 'k calibré avec les analyses',
alert_section: 'Seuils d\'alerte',
alert_hint: 'Évalués contre le niveau ESTIMÉ de l\'accueil (opt-in : vide = pas d\'alerte). Haut > bas requis.',
alert_e2_high: 'E2 haute (pg/mL)',
alert_e2_low: 'E2 basse (pg/mL)',
alert_t_high: 'T haute (ng/mL)',
alert_t_low: 'T basse (ng/mL)',
alert_invalid: 'Valeurs invalides (haut > bas requis, nombres seulement)',
saved: 'Enregistré',
timezone_section: 'Fuseau du graphique',
timezone_hint: 'Fuseau des labels X et marqueurs de doses (ID IANA, ex. Europe/Paris). Vide = fuseau du téléphone.',
timezone_field: 'ID de fuseau IANA',
timezone_auto: 'Auto (téléphone)',
timezone_invalid: 'ID de fuseau inconnu',
reminders_section: 'Rappels (web)',
reminders_note: 'Les rappels passent par les notifications du navigateur tant que cette page est ouverte. Pas de notification onglet fermé (contrairement à l\'app Android).',
notif_grant: 'Activer les notifications',
notif_granted: 'Notifications activées',
notif_denied: 'Notifications bloquées par le navigateur',
backup_section: 'Sauvegarde (JSON)',
backup_hint: 'Même format que l\'app Android — les backups sont interchangeables dans les deux sens.',
export_json: 'Exporter JSON',
import_json: 'Importer JSON',
export_ok: 'Sauvegarde exportée',
export_fail: 'Échec de l\'export',
import_ok: 'Restauré : {0} traitements, {1} doses, {2} analyses',
import_fail: 'Échec de l\'import (fichier invalide ?)',
import_title: 'Importer une sauvegarde',
import_warning: '⚠️ L\'import REMPLACE toutes les données actuelles (traitements, doses, analyses) par le contenu de la sauvegarde. Continuer ?',
import_confirm: 'Effacer & restaurer',
logs_section: 'Logs de diagnostic',
logs_count: '{0} lignes',
logs_export: 'Exporter les logs',
logs_clear: 'Effacer',
logs_export_ok: 'Logs de diagnostic exportés',
logs_export_fail: 'Échec de l\'export des logs',
about_title: 'À propos',
settings_version: 'Version {0} (web)',
releases_link: 'Releases',
models_credit: 'Les modèles PK appartiennent à leurs autrices respectives (Estrannaise, Transfem Science, WHSAH Collective via Mona).',
privacy_note: '100 % local : tes données ne quittent jamais ce navigateur (localStorage). Aucun compte, aucun serveur.',
chart_24h: '24 h',
chart_7j: '7 j',
chart_30j: '30 j',
zoom_in: 'Zoom avant',
zoom_out: 'Zoom arrière',
show_labs: 'Labs',
show_forecast: 'Prévision',
show_extrema: 'Pics & creux',
show_lab_track: 'Tracé labs',
back_to_present: 'Revenir à maintenant',
legend_ese_e2: 'E2 — Estrannaise (bleu)',
legend_ese_t: 'T — Estrannaise (rose)',
legend_tfs_e2: 'E2 — Transfem Science (turquoise)',
legend_tfs_t: 'T — Transfem Science (rose pointillé)',
legend_whs_e2: 'E2 — WHSAH (vert)',
legend_whs_t: 'T — WHSAH (violet pointillé)',
legend_labs: '● labs (orange) — cercle = E2, carré = T',
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)',
legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)',
drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).',
reminder_notif_title: 'HormoneTrack — rappel de dose',
reminder_notif_body: '{0} · {1}{2} — prévu à {3}',
reminder_log_now: 'Loguer maintenant',
reminder_snooze: 'Reporter 1 h',
reminder_logged: 'Dose loguée depuis le rappel',
whats_new_title: 'Nouveautés',
whats_new_close: 'Fermer',
demo_banner: 'DONNÉES DE DÉMO (hook de test) — ces données ne vivent que dans ce navigateur.',
};
/** Presets (même libellé que les strings Android). */
const EN_PRESETS = {
preset_ev_ese: 'EV injection — Estrannaise',
preset_eu_ese: 'EU injection — Estrannaise',
preset_een_ese: 'EEn injection — Estrannaise',
preset_ev_tfs: 'EV injection — Transfem Science',
preset_eu_tfs: 'EU injection — Transfem Science',
preset_een_tfs: 'EEn injection — Transfem Science',
preset_eb_tfs: 'EB injection — Transfem Science',
preset_ec_tfs: 'EC injection (oil) — Transfem Science',
preset_ecs_tfs: 'EC injection (aqueous suspension) — Transfem Science',
preset_pep_tfs: 'PEP injection — Transfem Science',
preset_ev_whs: 'EV injection — WHSAH',
preset_eu_whs: 'EU injection — WHSAH',
preset_een_whs: 'EEn injection — WHSAH',
preset_eb_whs: 'EB injection — WHSAH',
preset_ec_whs: 'EC injection (oil) — WHSAH',
preset_ecs_whs: 'EC injection (aqueous suspension) — WHSAH',
preset_e2_gel: 'E2 transdermal gel',
preset_e2_patch: 'E2 patch',
preset_e2_oral: 'Oral E2',
preset_cpa: 'Cyproterone acetate (CPA)',
preset_spiro: 'Spironolactone',
preset_bica: 'Bicalutamide',
};
const FR_PRESETS = {
preset_ev_ese: 'Injection EV — Estrannaise',
preset_eu_ese: 'Injection EU — Estrannaise',
preset_een_ese: 'Injection EEn — Estrannaise',
preset_ev_tfs: 'Injection EV — Transfem Science',
preset_eu_tfs: 'Injection EU — Transfem Science',
preset_een_tfs: 'Injection EEn — Transfem Science',
preset_eb_tfs: 'Injection EB — Transfem Science',
preset_ec_tfs: 'Injection EC (huile) — Transfem Science',
preset_ecs_tfs: 'Injection EC (suspension aqueuse) — Transfem Science',
preset_pep_tfs: 'Injection PEP — Transfem Science',
preset_ev_whs: 'Injection EV — WHSAH',
preset_eu_whs: 'Injection EU — WHSAH',
preset_een_whs: 'Injection EEn — WHSAH',
preset_eb_whs: 'Injection EB — WHSAH',
preset_ec_whs: 'Injection EC (huile) — WHSAH',
preset_ecs_whs: 'Injection EC (suspension aqueuse) — WHSAH',
preset_e2_gel: 'E2 gel transdermique',
preset_e2_patch: 'E2 patch',
preset_e2_oral: 'E2 orale',
preset_cpa: 'Acétate de cyprotérone (CPA)',
preset_spiro: 'Spironolactone',
preset_bica: 'Bicalutamide',
};
const DICTS = {
en: { ...EN, ...EN_PRESETS },
fr: { ...FR, ...FR_PRESETS },
};
/** Langue résolue pour un réglage ("system" suit le navigateur). */
export function resolveLocale(setting) {
if (setting === 'fr' || setting === 'en') return setting;
const nav = typeof navigator !== 'undefined' && navigator.language ? navigator.language.toLowerCase() : 'en';
return nav.startsWith('fr') ? 'fr' : 'en';
}
/**
* Traduit une clé dans la langue COURANTE du store, avec substitution de
* placeholders {0}, {1}…
*
* @param {string} key
* @param {...(string|number)} args
* @returns {string}
*/
export function t(key, ...args) {
const locale = resolveLocale(store.getSettings().language);
let s = DICTS[locale][key] ?? DICTS.en[key] ?? key;
args.forEach((a, i) => {
s = s.replaceAll(`{${i}}`, String(a));
});
return s;
}
/** Clé i18n → libellé dans la langue courante (utile pour les listes). */
export function trKey(key) {
return t(key);
}