v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 : - moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C, WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés - calibration par période d'ester et par modèle affiché (#60/#61) - UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire, prévision, pics/creux, fuseau configurable), rappels web (Notification API), seuils d'alerte, i18n FR/EN - sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1) - 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie - processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur, smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG) - dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions alignées sur l'Android porté, changelogs indépendants Non porté (impossible dans un navigateur, documenté §12) : agenda récurrent, notifications onglet fermé, montre.
This commit is contained in:
commit
491efef8eb
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# ── HormoneTrack Web — dépôt séparé (cf docs/DEVELOPPEMENT.md §3) ──
|
||||||
|
# Dépendances npm : uniquement les devDependencies des tests E2E —
|
||||||
|
# l'app elle-même n'a AUCUNE dépendance (site statique déployable tel quel).
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Données de test réelles : JAMAIS dans le dépôt (miroir de la règle
|
||||||
|
# local-test-data du dépôt Android — données de santé personnelles,
|
||||||
|
# les tests la cherchent dans le dépôt Android voisin et skip si absente).
|
||||||
|
local-test-data/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
160
README.md
Normal file
160
README.md
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
# HormoneTrack Web
|
||||||
|
|
||||||
|
> **La version navigateur d'HormoneTrack** — suivi de thérapie hormonale (THS)
|
||||||
|
> avec courbes estimées heure par heure, 100 % locale : **l'app tourne
|
||||||
|
> entièrement dans ton navigateur et tes données ne quittent jamais ta
|
||||||
|
> machine** (localStorage). Aucun compte, aucun serveur applicatif, aucune
|
||||||
|
> télémétrie.
|
||||||
|
>
|
||||||
|
> C'est le portage fidèle de l'**app Android** — les sauvegardes JSON sont
|
||||||
|
> **interchangeables dans les deux sens**. L'app Android vit dans son propre
|
||||||
|
> dépôt : [gitea.cloudyfy.fr/Siphonight/HormoneTrack](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack)
|
||||||
|
> (README, docs et releases APK).
|
||||||
|
> La doc de développement : [docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
> **⚠️ 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.
|
||||||
|
|
||||||
|
- **Statut** : web v1.4.10 — portage de l'Android v1.4.10 · **121 tests verts** ·
|
||||||
|
E2E navigateur ✅ · lint/i18n ✅
|
||||||
|
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
|
||||||
|
- **Versionnage** : les versions web sont **alignées sur l'Android porté**
|
||||||
|
(web v1.4.10 = toutes les fonctionnalités de l'Android v1.4.10, sauf les
|
||||||
|
impossibilités structurelles du navigateur) — cf
|
||||||
|
[docs/DEVELOPPEMENT.md §3](docs/DEVELOPPEMENT.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
### Avec le serveur de développement intégré
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
python3 scripts/serve.py # → http://127.0.0.1:8970/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvre **http://127.0.0.1:8970/** dans ton navigateur. C'est tout.
|
||||||
|
|
||||||
|
- **Mode démo** (données de test, hook documenté) :
|
||||||
|
<http://127.0.0.1:8970/?demo=1> — charge 12 injections + 6 prises de sang
|
||||||
|
fictives si le stockage est vide, avec une bannière explicite.
|
||||||
|
- ⚠️ L'app doit être servie en HTTP : l'ouverture directe du `index.html`
|
||||||
|
en `file://` est bloquée par les navigateurs (modules ES).
|
||||||
|
|
||||||
|
### Avec n'importe quel serveur statique
|
||||||
|
|
||||||
|
Le dépôt est un site statique autonome — déploie-le tel quel
|
||||||
|
(nginx, caddy, Pages Gitea/GitLab, un NAS…) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# nginx (extrait)
|
||||||
|
server {
|
||||||
|
root /var/www/hormonetrack; # = le contenu de ce dépôt
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun build, aucune variable d'environnement, aucun composant serveur : les
|
||||||
|
données de chaque personne vivent **dans SON navigateur**, jamais sur la
|
||||||
|
machine qui sert les fichiers.
|
||||||
|
|
||||||
|
## Fonctionnalités (à l'image de l'app Android)
|
||||||
|
|
||||||
|
- **Courbes estimées heure par heure** : E2 (pg/mL) et T (ng/mL), vues
|
||||||
|
24 h / 7 j / 30 j, **zoom** (pinch, molette, boutons − / +, 6 h → 300 j,
|
||||||
|
échantillonnage adaptatif), **panoramique** (glisser droite = passé,
|
||||||
|
gauche = futur avec la prévision), **pics & creux** avec valeurs estimées.
|
||||||
|
- **Trois modèles PK superposables** : **Estrannaise** (tables du `.ods`),
|
||||||
|
**Transfem Science** (méta-analyse V3C, 7 esters), **WHSAH** (fit Mona,
|
||||||
|
6 esters) — toggles indépendants, calibrés séparément.
|
||||||
|
- **Modèle Bateman** paramétrable pour gel, patch et voie orale.
|
||||||
|
- **Simulation prévisionnelle** (Posologie) : projection des doses à venir,
|
||||||
|
extension de fenêtre sans saut, horizon jusqu'à 1 an, marqueurs de doses.
|
||||||
|
- **Log des doses** avec override d'ester par injection, édition, Δ jours
|
||||||
|
entre doses, temps sous THS.
|
||||||
|
- **Analyses de sang** E2 + T en une entrée, unités T multiples
|
||||||
|
(ng/mL, ng/dL, ng/L, nmol/L).
|
||||||
|
- **Calibration** par période d'ester et **par modèle affiché**
|
||||||
|
(auto-calibration optionnelle, désactivée par défaut + bouton manuel).
|
||||||
|
- **Seuils d'alerte** configurables (E2/T haut/bas) avec notification
|
||||||
|
navigateur et anti-spam.
|
||||||
|
- **Rappels** via les notifications du navigateur (actions « Loguer
|
||||||
|
maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf
|
||||||
|
[limites](#limites-vs-lapp-android).
|
||||||
|
- **Sauvegarde/Restauration JSON** compatible Android (import en mode
|
||||||
|
écrasement, réglages transportés).
|
||||||
|
- **Logs de diagnostic** exportables (Paramètres) pour le debug à distance.
|
||||||
|
- **FR + EN** (langue par app, indépendante du système), dialog
|
||||||
|
« Nouveautés » après mise à jour.
|
||||||
|
|
||||||
|
## Vie privée
|
||||||
|
|
||||||
|
- **100 % local** : les données (traitements, doses, analyses, réglages)
|
||||||
|
vivent dans le **localStorage de ton navigateur**, sur TA machine. Le
|
||||||
|
serveur qui sert l'app ne voit rien, ne stocke rien.
|
||||||
|
- Les seules requêtes réseau sont des **lectures** de fichiers statiques
|
||||||
|
(l'asset des profils PK, le changelog) — aucune donnée personnelle
|
||||||
|
n'est jamais envoyée.
|
||||||
|
- Sauvegarde = un fichier JSON que tu stockes où tu veux.
|
||||||
|
- `?demo=1` charge des données FICTIVES clairement banniérées, jamais
|
||||||
|
automatiquement.
|
||||||
|
|
||||||
|
## Compatibilité avec l'app Android
|
||||||
|
|
||||||
|
| Flux | Support |
|
||||||
|
|---|---|
|
||||||
|
| Export Android → import web | ✅ (backups v1 et v2 — testés sur de vrais exports) |
|
||||||
|
| Export web → import Android | ✅ (même schéma BackupData v2, champs Gson identiques) |
|
||||||
|
| Modèles PK / courbes | ✅ mêmes maths, mêmes paramètres, mêmes pins de tests |
|
||||||
|
| Calibrations (facteurs, k) | ✅ recalculées identiquement depuis les labs |
|
||||||
|
|
||||||
|
Le schéma de backup est verrouillé par des tests des deux côtés
|
||||||
|
(`tests/backup.test.js` ↔ `BackupGsonTest.kt` Android) — toute divergence
|
||||||
|
métadonnée↔code ferait échouer la CI.
|
||||||
|
|
||||||
|
## Limites vs l'app Android
|
||||||
|
|
||||||
|
| Fonctionnalité | Android | Web |
|
||||||
|
|---|---|---|
|
||||||
|
| Rappels app fermée | ✅ (AlarmManager) | ❌ page ouverte uniquement (pas de scheduler système) |
|
||||||
|
| Événements d'agenda récurrents | ✅ | ❌ impossible dans un navigateur (pas de CalendarProvider) |
|
||||||
|
| Montre (Gadgetbridge) | ✅ notifications miroir | ❌ |
|
||||||
|
| Verrou biométrique / widget | roadmap | ❌ |
|
||||||
|
|
||||||
|
Ces limites sont structurelles (plateforme navigateur), pas des choix de
|
||||||
|
design — elles sont listées dans le dialog « Nouveautés » et la doc de dev.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Mes données sont-elles visibles par le serveur qui héberge l'app ?**
|
||||||
|
Non. Le serveur ne fait que distribuer des fichiers statiques (comme des
|
||||||
|
images). Tes données vivent dans ton navigateur et n'en sortent jamais.
|
||||||
|
|
||||||
|
**Je change de navigateur / d'ordinateur, je fais quoi ?**
|
||||||
|
Paramètres → « Exporter JSON » sur la première machine, « Importer JSON »
|
||||||
|
sur la seconde. Le backup transporte tout (données + réglages).
|
||||||
|
|
||||||
|
**Puis-je utiliser Android et web en parallèle ?**
|
||||||
|
Oui — exporte/importe entre les deux. Attention : l'import est un
|
||||||
|
écrasement (comme sur Android), pas une fusion.
|
||||||
|
|
||||||
|
**Pourquoi l'app me demande-t-elle la permission « Notifications » ?**
|
||||||
|
Pour les rappels de dose. Refuser n'empêche rien d'autre (la bannière
|
||||||
|
in-app s'affiche quand même à l'heure du rappel, page ouverte).
|
||||||
|
|
||||||
|
## Doc de développement
|
||||||
|
|
||||||
|
Architecture, portage fichier par fichier, format du backup, processus de
|
||||||
|
test (121 tests Node + E2E navigateur), processus de push et pièges connus :
|
||||||
|
**[docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)**.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
**GPL-3.0** — voir [LICENSE](LICENSE), cohérente avec l'app Android
|
||||||
|
et l'écosystème Gadgetbridge. Les modèles PK appartiennent à leurs autrices
|
||||||
|
respectives ([Estrannaise](https://estrannaise.github.io/),
|
||||||
|
[Transfem Science](https://transfemscience.org),
|
||||||
|
[WHSAH Collective via Mona](https://github.com/mona-hrt/mona)).
|
||||||
48048
assets/pk_profiles.json
Normal file
48048
assets/pk_profiles.json
Normal file
File diff suppressed because it is too large
Load Diff
308
css/style.css
Normal file
308
css/style.css
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* HormoneTrack Web — feuille de style unique.
|
||||||
|
*
|
||||||
|
* Couleurs reprises du thème Android (ui/theme/Color.kt) :
|
||||||
|
* - primaire bleu #4F5BD5, secondaire rose #D6589E, tertiaire teal #2FA48D ;
|
||||||
|
* - bandeau TransSky #5BCEFA / TransPink #F5A9B8 ;
|
||||||
|
* - labs orange #E67E22 ; WHSAH vert #2E7D32 / violet #9C27B0.
|
||||||
|
* Design : Material-ish, mobile-first, sans framework.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--blue: #4f5bd5;
|
||||||
|
--blue-dark: #3a44a8;
|
||||||
|
--blue-container: #dee0ff;
|
||||||
|
--pink: #d6589e;
|
||||||
|
--pink-container: #ffd9ec;
|
||||||
|
--teal: #2fa48d;
|
||||||
|
--teal-container: #bff0e4;
|
||||||
|
--trans-sky: #5bcefa;
|
||||||
|
--trans-pink: #f5a9b8;
|
||||||
|
--chart-e2: #4f5bd5;
|
||||||
|
--chart-t: #d6589e;
|
||||||
|
--chart-whs: #2e7d32;
|
||||||
|
--chart-twhs: #9c27b0;
|
||||||
|
--lab-dot: #e67e22;
|
||||||
|
--good: #2e7d32;
|
||||||
|
--warn: #b26a00;
|
||||||
|
--error-bg: #ffdad6;
|
||||||
|
--error-fg: #410002;
|
||||||
|
--surface: #fef7ff;
|
||||||
|
--surface-variant: #e7e0ec;
|
||||||
|
--on-surface: #1d1b20;
|
||||||
|
--on-surface-variant: #49454f;
|
||||||
|
--outline: #79747e;
|
||||||
|
--outline-variant: #cac4d0;
|
||||||
|
--radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--on-surface);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête + bandeau trans ──────────────────────────────────────────────── */
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
.topbar h1 { font-size: 1.25rem; margin: 0; flex: 1; font-weight: 600; }
|
||||||
|
.trans-banner {
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, var(--trans-sky), var(--trans-pink), var(--trans-sky));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Onglets (bottom nav, comme l'app Android) ────────────────────────────── */
|
||||||
|
.tabbar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
background: #f3edf7;
|
||||||
|
border-top: 1px solid var(--outline-variant);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.tabbar button {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 8px 2px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tabbar button .ico { font-size: 1.25rem; line-height: 1; }
|
||||||
|
.tabbar button.active { color: var(--blue); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Zone de contenu : au-dessus de la tabbar */
|
||||||
|
main.content {
|
||||||
|
padding: 8px 16px 90px;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cartes ───────────────────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.card.primary { background: var(--blue-container); border-color: var(--blue-container); }
|
||||||
|
.card.variant { background: var(--surface-variant); border-color: var(--surface-variant); }
|
||||||
|
.card.error { background: var(--error-bg); border-color: var(--error-bg); color: var(--error-fg); }
|
||||||
|
.card h3 { margin: 0 0 8px; font-size: 1rem; font-weight: 600; }
|
||||||
|
.card .label { font-size: 0.78rem; color: var(--on-surface-variant); margin-bottom: 4px; }
|
||||||
|
.card .small { font-size: 0.78rem; color: var(--on-surface-variant); }
|
||||||
|
|
||||||
|
/* ── Boutons / chips ──────────────────────────────────────────────────────── */
|
||||||
|
button.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--blue);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
button.btn.tonal { background: var(--blue-container); color: var(--blue-dark); }
|
||||||
|
button.btn.outlined { background: none; border: 1px solid var(--blue); color: var(--blue); }
|
||||||
|
button.btn.text { background: none; color: var(--blue); padding: 6px 10px; }
|
||||||
|
button.btn.danger { background: none; color: #b3261e; }
|
||||||
|
button.btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip.selected { background: var(--blue-container); border-color: var(--blue-container); color: var(--blue-dark); font-weight: 600; }
|
||||||
|
.chip.static { cursor: default; }
|
||||||
|
.chip-row { display: flex; gap: 8px; flex-wrap: nowrap; overflow-x: auto; padding: 2px 0; }
|
||||||
|
.chip-row.wrap { flex-wrap: wrap; overflow: visible; }
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 78px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: none;
|
||||||
|
background: var(--pink);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 3px 10px rgba(0,0,0,0.25);
|
||||||
|
z-index: 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Listes ───────────────────────────────────────────────────────────────── */
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 11px 4px;
|
||||||
|
border-bottom: 1px solid var(--outline-variant);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.list-item:last-child { border-bottom: none; }
|
||||||
|
.list-item .main { flex: 1; min-width: 0; }
|
||||||
|
.list-item .title { font-size: 0.95rem; }
|
||||||
|
.list-item .sub { font-size: 0.8rem; color: var(--on-surface-variant); display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.group-header {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface);
|
||||||
|
padding: 12px 4px 4px;
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaires ──────────────────────────────────────────────────────────── */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 3px; margin: 8px 0; flex: 1; min-width: 0; }
|
||||||
|
.field label { font-size: 0.75rem; color: var(--on-surface-variant); }
|
||||||
|
.field input, .field select, .field textarea {
|
||||||
|
border: 1px solid var(--outline);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--on-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus { outline: 2px solid var(--blue); border-color: var(--blue); }
|
||||||
|
.row { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.row.wrap { flex-wrap: wrap; }
|
||||||
|
.error-text { color: #b3261e; font-size: 0.82rem; margin: 4px 0; }
|
||||||
|
.hint { font-size: 0.78rem; color: var(--on-surface-variant); margin: 3px 0; }
|
||||||
|
|
||||||
|
/* Switch (miroir Material3 Switch) */
|
||||||
|
.switch { position: relative; display: inline-block; width: 46px; height: 26px; flex-shrink: 0; }
|
||||||
|
.switch input { opacity: 0; width: 0; height: 0; }
|
||||||
|
.switch .slider {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: var(--outline-variant);
|
||||||
|
border-radius: 26px;
|
||||||
|
transition: 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.switch .slider::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px; height: 20px;
|
||||||
|
left: 3px; top: 3px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.switch input:checked + .slider { background: var(--blue); }
|
||||||
|
.switch input:checked + .slider::before { transform: translateX(20px); }
|
||||||
|
|
||||||
|
/* ── Dialogs ──────────────────────────────────────────────────────────────── */
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 86vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.dialog h2 { margin: 0 0 12px; font-size: 1.1rem; }
|
||||||
|
.dialog .actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 14px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── Divers ───────────────────────────────────────────────────────────────── */
|
||||||
|
.big-number { font-size: 2rem; font-weight: 700; color: var(--blue-dark); }
|
||||||
|
.unit { font-size: 1rem; color: var(--on-surface-variant); }
|
||||||
|
.t-inline { font-size: 1rem; }
|
||||||
|
.delta-up { color: var(--good); }
|
||||||
|
.delta-down { color: var(--warn); }
|
||||||
|
.legend-text { font-size: 0.78rem; margin: 2px 0; }
|
||||||
|
.legend-muted { font-size: 0.78rem; color: var(--on-surface-variant); }
|
||||||
|
.message-banner { color: var(--blue-dark); font-weight: 600; margin: 6px 0; }
|
||||||
|
.demo-banner {
|
||||||
|
background: var(--pink-container);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.reminder-banner {
|
||||||
|
background: var(--pink-container);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
canvas.chart { width: 100%; display: block; touch-action: none; }
|
||||||
|
canvas.chart-cursor { cursor: grab; }
|
||||||
|
|
||||||
|
/* Toast (feedback Paramètres) */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 96px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #322f35;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
z-index: 60;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Écrans larges : la tabbar passe à gauche */
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.tabbar { right: auto; width: 170px; top: 0; bottom: 0; flex-direction: column; justify-content: flex-start; padding-top: 64px; border-top: none; border-right: 1px solid var(--outline-variant); }
|
||||||
|
.tabbar button { flex-direction: row; justify-content: flex-start; gap: 12px; padding: 14px 20px; font-size: 0.9rem; }
|
||||||
|
main.content { margin-left: 170px; max-width: 760px; }
|
||||||
|
.fab { bottom: 24px; }
|
||||||
|
.toast { bottom: 32px; }
|
||||||
|
}
|
||||||
83
docs/CHANGELOG.md
Normal file
83
docs/CHANGELOG.md
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# CHANGELOG — HormoneTrack Web
|
||||||
|
|
||||||
|
> Journal des versions de l'**app web** (séparé du CHANGELOG Android —
|
||||||
|
> `docs/CHANGELOG.md` à la racine du dépôt). Convention Keep a Changelog :
|
||||||
|
> 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.4.10] — 2026-09-08
|
||||||
|
|
||||||
|
Version initiale de la version web — portage navigateur de l'app Android
|
||||||
|
v1.4.10, 100 % locale (données dans le localStorage, aucun serveur
|
||||||
|
applicatif, aucune télémétrie).
|
||||||
|
|
||||||
|
### Moteur PK (fidèle à l'Android, testé)
|
||||||
|
|
||||||
|
- **Trois modèles superposables** : Estrannaise (tables ODS de
|
||||||
|
`pk_profiles.json`, même asset que l'app Android), Transfem Science (forme
|
||||||
|
close V3C de la méta-analyse, 7 esters), WHSAH (fit license-free de Mona,
|
||||||
|
6 esters — PEP non couvert) + Bateman paramétrable (gel/patch/oral).
|
||||||
|
- Pics de l'article TFS épinglés (±2 %) ; équilibre EV 5 mg/7 j = Figure 11
|
||||||
|
(Cmax 384 / Cmin 142 / Cavg 269) ; fidélité Mona WHSAH (EV 367,5 @ 1,69 j,
|
||||||
|
EEn 187,9 @ 4,99 j, t½ 7,34 j).
|
||||||
|
- Invariants Android préservés : bisection `computeKa` dans le bon sens
|
||||||
|
(#19), modèle strict + extrapolation terminale ≥ 1 % du pic (#20/#21),
|
||||||
|
lookup insensible à la casse (#22), conversion des labs T (#23/#26),
|
||||||
|
garde de significativité de calibration (#61), créneaux de prévision
|
||||||
|
passés sautés (#35), rappels sur la grille Posologie (#52).
|
||||||
|
- Calibration **par période d'ester** et **par modèle affiché** (#60) :
|
||||||
|
auto-calibration optionnelle (désactivée par défaut) + calibration
|
||||||
|
manuelle par traitement ; k T par période d'ester.
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
|
||||||
|
- Accueil : niveau actuel estimé (E2 pg/mL + T ng/mL) avec delta « vs il y a
|
||||||
|
6 h » réel (fix #53), cartes d'alerte de seuil, carte prochaine dose
|
||||||
|
(jours au-delà de 24 h, fix v1.4.1), log rapide, mini-graphique 24 h.
|
||||||
|
- Graphiques : 24 h/7 j/30 j, zoom 6 h → 300 j (pinch, molette, boutons,
|
||||||
|
focal du pinch conservée), panoramique avec **cumul fractionnaire** des
|
||||||
|
deltas (fix #62), prévision avec extension de fenêtre sans saut (fix #54)
|
||||||
|
et horizon borné 30 j–1 an, toggles 3 modèles alignés sur les traitements
|
||||||
|
(fix #58), pics/creux avec valeurs, marqueurs de doses réelles et
|
||||||
|
projetées (fix #55), labels X à minuit local dans le fuseau choisi
|
||||||
|
(v1.4.5), labs T convertis en ng/mL au rendu.
|
||||||
|
- Doses : historique groupé par jour, Δ jours depuis la dose précédente du
|
||||||
|
même traitement, édition (override d'ester par injection selon le modèle),
|
||||||
|
en-tête « temps sous THS » (3 placeholders, bug #47 épinglé).
|
||||||
|
- Analyses : prise de sang E2 + T en une entrée, groupement par timestamp,
|
||||||
|
suppression par prise entière, unités T multiples (ng/mL, ng/dL, ng/L,
|
||||||
|
nmol/L).
|
||||||
|
- Traitements : 22 presets, éditeur complet (ester/modèle/Bateman,
|
||||||
|
calibration, Posologie, rappel), traitement inactif = drapeau
|
||||||
|
administratif (la simulation reste, bug v1.2.4).
|
||||||
|
- Rappels web : notifications navigateur tant que la page est ouverte
|
||||||
|
(bannière in-app + actions « Loguer maintenant » / « Reporter 1 h »).
|
||||||
|
Limite documentée : pas de notification onglet fermé.
|
||||||
|
- Seuils d'alerte : évaluation du niveau estimé, notification web, anti-spam
|
||||||
|
par état persisté (même codec que l'app Android).
|
||||||
|
- Sauvegarde JSON **compatible Android dans les deux sens** (BackupData v2,
|
||||||
|
rétrocompat v1, import en mode écrasement avec IDs conservés) ; paramètres
|
||||||
|
utilisateur transportés (langue, auto-cal, seuils).
|
||||||
|
- Logs de diagnostic exportables (buffer 500 lignes, même format que
|
||||||
|
l'app Android).
|
||||||
|
- FR + EN (langue par app, indépendante du système), dialog « Nouveautés »
|
||||||
|
après mise à jour.
|
||||||
|
|
||||||
|
### Processus
|
||||||
|
|
||||||
|
- 121 tests Node (`node --test`) : profils ODS (pins ODS identiques à
|
||||||
|
l'Android), fidélité TFS/WHSAH, moteur, calibration, backup (dont import
|
||||||
|
des exports Android réels de `local-test-data/` si présents), helpers du
|
||||||
|
graphique, alertes, rappels, changelog, store.
|
||||||
|
- `scripts/check.sh` : syntaxe de tous les modules + cohérence i18n FR/EN +
|
||||||
|
tests + smoke HTTP optionnel.
|
||||||
|
- **Dépôt GIT SÉPARÉ** de l'app Android (HormoneTrack-web) — historique
|
||||||
|
100 % propre (aucune dette de confidentialité), versions alignées sur
|
||||||
|
l'Android, changelogs totalement indépendants.
|
||||||
|
|
||||||
|
### Non porté (impossible ou hors périmètre navigateur)
|
||||||
|
|
||||||
|
- Événements d'agenda récurrents (pas de CalendarProvider dans un
|
||||||
|
navigateur) — l'éditeur affiche une note explicative.
|
||||||
|
- Notifications quand l'onglet est fermé (aucun scheduler système côté web).
|
||||||
|
- Verrou biométrique, widget — non implémentés non plus côté Android v1.
|
||||||
608
docs/DEVELOPPEMENT.md
Normal file
608
docs/DEVELOPPEMENT.md
Normal file
@ -0,0 +1,608 @@
|
|||||||
|
# Documentation de développement — HormoneTrack **Web**
|
||||||
|
|
||||||
|
> Doc de référence pour toute session (humaine ou IA) travaillant sur la
|
||||||
|
> version web : architecture, correspondances avec l'app Android, format du
|
||||||
|
> backup, tests, déploiement, pièges. Le code est commenté au même niveau que
|
||||||
|
> la doc — cette page explique le POURQUOI et le LIEN à l'Android.
|
||||||
|
>
|
||||||
|
> Projet : `~/projects/HormoneTrack-web` — **dépôt GIT SÉPARÉ** de l'app
|
||||||
|
> Android (décision v1.4.10, cf §3) ; l'app Android vit dans
|
||||||
|
> `~/projects/HormoneTrack`
|
||||||
|
> ([gitea.cloudyfy.fr/Siphonight/HormoneTrack](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack),
|
||||||
|
> docs mère : son `docs/DEVELOPPEMENT.md`).
|
||||||
|
> Guide utilisateur : [../README.md](../README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table des matières
|
||||||
|
|
||||||
|
1. [Contexte & objectifs du portage](#1-contexte--objectifs-du-portage)
|
||||||
|
2. [Stack & décisions structurantes](#2-stack--décisions-structurantes)
|
||||||
|
3. [Deux dépôts séparés : HormoneTrack (APK) ↔ HormoneTrack-web](#3-deux-dépôts-séparés--hormonetrack-apk--hormonetrack-web)
|
||||||
|
4. [Architecture & correspondance fichier par fichier](#4-architecture--correspondance-fichier-par-fichier)
|
||||||
|
5. [Persistance (localStorage) & schéma de données](#5-persistance-localstorage--schéma-de-données)
|
||||||
|
6. [Compatibilité des backups avec l'Android](#6-compatibilité-des-backups-avec-landroid)
|
||||||
|
7. [Fuseaux horaires (Intl)](#7-fuseaux-horaires-intl)
|
||||||
|
8. [Tests — processus complet](#8-tests--processus-complet)
|
||||||
|
9. [Workflow de développement](#9-workflow-de-développement)
|
||||||
|
10. [Processus de push & releases (sync Android)](#10-processus-de-push--releases-sync-android)
|
||||||
|
11. [Bugs potentiels évités pendant le portage](#11-bugs-potentiels-évités-pendant-le-portage)
|
||||||
|
12. [Limites connues & non-portés](#12-limites-connues--non-portés)
|
||||||
|
13. [Idées d'évolution](#13-idées-dévolution)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Contexte & objectifs du portage
|
||||||
|
|
||||||
|
L'utilisatrice suit son THS sur l'app Android (cf doc mère). La version web
|
||||||
|
existe pour :
|
||||||
|
|
||||||
|
- **disponibilité** : consulter ses courbes depuis n'importe quel appareil
|
||||||
|
(ordinateur, iPad) sans installation ;
|
||||||
|
- **portabilité des données** : les backups JSON de l'app Android
|
||||||
|
s'importent tels quels (et réciproquement) — pas de verrou.
|
||||||
|
|
||||||
|
Règles du portage, fixées au départ :
|
||||||
|
|
||||||
|
1. **Fidélité du moteur PK** : mêmes maths, mêmes paramètres, mêmes pins de
|
||||||
|
tests que l'Android (les courbes doivent être INDISTINGUABLES).
|
||||||
|
2. **Compatibilité backup bidirectionnelle** (schéma `BackupData` v2, champs
|
||||||
|
Gson/Kotlin identiques).
|
||||||
|
3. **100 % local** : tout dans le navigateur (localStorage), le serveur ne
|
||||||
|
sert que des fichiers statiques.
|
||||||
|
4. **Aucune dépendance applicative** : pas de framework, pas de build, pas
|
||||||
|
de node_modules à l'exécution — le dépôt est un site statique déployable
|
||||||
|
tel quel. (Les devDependencies npm ne servent QU'aux tests E2E.)
|
||||||
|
5. **Dépôt GIT SÉPARÉ** de l'app Android (décision v1.4.10, cf §3) —
|
||||||
|
changelogs, docs, historique, tags et releases totalement indépendants.
|
||||||
|
|
||||||
|
## 2. Stack & décisions structurantes
|
||||||
|
|
||||||
|
| 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 121 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())`) |
|
||||||
|
| i18n | dictionnaires JS (`util/i18n.js`), langue persistée | miroir values/strings.xml ; `system` suit `navigator.language` |
|
||||||
|
| Fuseaux | `Intl.DateTimeFormat` (natif) | remplace `java.util.Calendar/TimeZone` — cf §7 |
|
||||||
|
| Tests | `node --test` (runner natif Node ≥ 20) + E2E playwright | zéro dépendance pour les tests unitaires ; l'E2E utilise le build Firefox de Playwright |
|
||||||
|
| Asset PK | **copie** de l'asset Android (`pk_profiles.json`) | mêmes tables que l'app Android — `tests/pk-profiles.test.js` vérifie les 6 profils et les pics ODS ; TOUTE retouche se fait dans les DEUX dépôts (règle §3) |
|
||||||
|
|
||||||
|
⚠️ **Ne pas introduire de bundler/build sans décision inverse documentée** :
|
||||||
|
le déploiement « copier un dossier » est une propriété voulue du projet.
|
||||||
|
|
||||||
|
## 3. Deux dépôts séparés : HormoneTrack (APK) ↔ HormoneTrack-web
|
||||||
|
|
||||||
|
**Décision (8 sept. 2026)** : le portage vit dans son PROPRE dépôt git,
|
||||||
|
avec ses propres remotes, historique, tags et releases — les deux apps ne
|
||||||
|
partagent plus qu'une CONVENTION de version.
|
||||||
|
|
||||||
|
### Pourquoi (motifs qui ont tranché)
|
||||||
|
|
||||||
|
1. **Confidentialité** : l'historique du dépôt Android contient une dette
|
||||||
|
connue et jugée (fragments de labs dans les révisions v1.1.0→v1.2.3, cf
|
||||||
|
§8.bis de la doc Android) — non réexportable sans re-décision. Le dépôt
|
||||||
|
web **naît avec un historique 100 % propre** : il peut devenir public un
|
||||||
|
jour sans AUCUNE re-décision, pendant que la dette reste confinée à
|
||||||
|
l'Android.
|
||||||
|
2. **Releases indépendantes** : « pas de release web pour l'instant » =
|
||||||
|
ne rien faire côté web. Les releases APK ne traînent jamais un artifact
|
||||||
|
web et inversement.
|
||||||
|
3. **Simplicité** : plus de conventions de préfixes (`web:`) ni de tags
|
||||||
|
appariés à maintenir dans un historique unique.
|
||||||
|
|
||||||
|
### Ce que ça implique (règles de croisement)
|
||||||
|
|
||||||
|
| Aspect | Règle |
|
||||||
|
|---|---|
|
||||||
|
| **Version** | `WEB_VERSION` = version ANDROID portée, **alignée à chaque release** (web v1.4.10 = portage complet de l'Android v1.4.10, sauf impossibilités structurelles documentées §12) |
|
||||||
|
| **Tags** | les DEUX dépôts tagguent `vX.Y.Z` au même rythme (dans HormoneTrack-web, `v1.4.10` = l'état web correspondant à l'Android v1.4.10 — aucun risque d'ambiguïté, dépôts séparés) |
|
||||||
|
| **Changelogs** | TOTALEMENT SÉPARÉS : `docs/CHANGELOG.md` (web, lu par le dialog « Nouveautés ») vs `docs/CHANGELOG.md` Android (source des releases Gitea APK) — jamais fusionnés, jamais synchronisés automatiquement |
|
||||||
|
| **Asset PK** (`pk_profiles.json`) | COPIE dans chaque dépôt. ⚠️ Toute retouche (re-fit, nouvel ester) se fait dans les DEUX dépôts avec les DEUX suites de tests (pins identiques des pics ODS) — une divergence ferait échouer la CI de l'un ou l'autre |
|
||||||
|
| **Fix de moteur cross-cutting** | le même fix se fait dans les DEUX dépôts (même test épinglé, même n° de bug) — cf §11 pour l'exemple du portage |
|
||||||
|
| **Données de test réelles** (`local-test-data/`) | vivent dans le dépôt ANDROID (gitigné, jamais versionnées). Le web ne les duplique PAS : `tests/helpers.localTestDataDir()` les cherche dans le dépôt Android voisin (layout standard `~/projects/HormoneTrack*`), skip des tests si absent |
|
||||||
|
| **Commits** | convention Android conservée : commits par couche (noyau / UI / docs), release = commit dédié |
|
||||||
|
|
||||||
|
### Layout disque attendu
|
||||||
|
|
||||||
|
```
|
||||||
|
~/projects/
|
||||||
|
├── HormoneTrack/ dépôt Android (existant) — héberge local-test-data/
|
||||||
|
│ └── local-test-data/ exports réels, gitigné, JAMAIS versionnés
|
||||||
|
└── HormoneTrack-web/ dépôt web (ce projet)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les tests web cherchent les exports dans le dépôt voisin : si tu clones
|
||||||
|
HormoneTrack-web ailleurs, les 22 tests de régression data-driven sont
|
||||||
|
simplement SKIPPÉS (comportement Assume identique à l'Android) — ou
|
||||||
|
dépose les exports dans un `local-test-data/` local au dépôt web (gitigné).
|
||||||
|
|
||||||
|
### Remotes du dépôt web (même double-instance que l'Android)
|
||||||
|
|
||||||
|
- `origin` → `https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web` (privé, HTTPS + trousseau)
|
||||||
|
- `farewell` → `git@farewell:Siphonight/HormoneTrack-web.git` (SSH, alias existant)
|
||||||
|
|
||||||
|
⚠️ La CRÉATION des repos sur les deux instances Gitea nécessite le scope
|
||||||
|
`write:user` (le token `write:repository` ne suffit PAS, et le
|
||||||
|
push-to-create est désactivé sur farewell) — cf §10.
|
||||||
|
|
||||||
|
## 4. Architecture & correspondance fichier par fichier
|
||||||
|
|
||||||
|
Pas de DI, pas de reactive framework : le store pub/sub notifie les écrans,
|
||||||
|
qui se re-rendent entièrement (le DOM est jetable, l'état vit dans le store
|
||||||
|
et le hash). Le routing est par **hash** (`#home`, `#chart`, `#doses`,
|
||||||
|
`#labs`, `#treatments`, `#settings`, `#treatment-edit/{id}`) — navigable
|
||||||
|
headless, lien profond possible.
|
||||||
|
|
||||||
|
```
|
||||||
|
HormoneTrack-web/ (dépôt séparé)
|
||||||
|
├── index.html page unique (SPA) — shell construit par app.js
|
||||||
|
├── css/style.css thème (couleurs = Color.kt Android), mobile-first
|
||||||
|
├── LICENSE GPL-3.0 (copie de l'Android — dépôt autonome)
|
||||||
|
├── .gitignore node_modules, package-lock, local-test-data
|
||||||
|
├── assets/pk_profiles.json COPIE de l'asset Android (tables Estrannaise)
|
||||||
|
├── js/
|
||||||
|
│ ├── pk/ ── NOYAU PUR (aucun DOM/storage) — testable Node ──
|
||||||
|
│ │ ├── pk-engine.js PharmacokineticEngine.kt (1/4) : Bateman,
|
||||||
|
│ │ │ contribution d'une dose, e2At/computeCurve,
|
||||||
|
│ │ │ modèle T, convertTToNgMl, prévision
|
||||||
|
│ │ ├── pk-calibration.js engine (2/4) : échelles par ester/période/
|
||||||
|
│ │ │ modèle (#60), garde labIsSignificant (#61),
|
||||||
|
│ │ │ autoCalibrated, k T, calibration manuelle
|
||||||
|
│ │ ├── pk-reminders.js engine (3/4) : nextReminderFireFor (grille
|
||||||
|
│ │ │ Posologie, fix #52)
|
||||||
|
│ │ ├── pk-extrema.js engine (4/4) : detectExtrema (pics/creux)
|
||||||
|
│ │ ├── pk-profile-store.js PKProfileStore.kt : asset + échantillonnage,
|
||||||
|
│ │ │ lookup insensible à la casse (#22), modèle
|
||||||
|
│ │ │ strict, extrapolation terminale (#20)
|
||||||
|
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
|
||||||
|
│ │ │ copiés à l'identique du Kotlin)
|
||||||
|
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
|
||||||
|
│ │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
|
||||||
|
│ │ │ shouldNotify (anti-spam)
|
||||||
|
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n)
|
||||||
|
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
|
||||||
|
│ │ │ panDeltaHours (#62), xLabelTicks (#55),
|
||||||
|
│ │ │ pointHoursBefore (#53), clampPanHours (#54),
|
||||||
|
│ │ │ niceCeil, stepForRange, forecast*,
|
||||||
|
│ │ │ defaultModelToggles (#58) + helpers Intl
|
||||||
|
│ │ └── index.js barrel (point d'import des écrans/tests)
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── models.js Treatment/DoseLog/LabResult (CHAMPS GSON
|
||||||
|
│ │ │ IDENTIQUES — ne jamais renommer, cf §6),
|
||||||
|
│ │ │ enums, helpers (choicesForModel, labels)
|
||||||
|
│ │ ├── store.js persistance localStorage + pub/sub + import
|
||||||
|
│ │ │ écrasement (miroir Room + BackupManager)
|
||||||
|
│ │ └── backup.js BackupData v2 : build/parse compat Android,
|
||||||
|
│ │ noms de fichiers d'export (ExportFileNames)
|
||||||
|
│ ├── util/
|
||||||
|
│ │ ├── i18n.js dictionnaires FR/EN + t() (+ presets)
|
||||||
|
│ │ ├── format.js formatDose/formatLabValue/formatValue,
|
||||||
|
│ │ │ HrtDuration, formatage Intl des dates
|
||||||
|
│ │ ├── changelog.js ChangelogHelper.kt (isVersionNewer,
|
||||||
|
│ │ │ sectionsSince — comparaison numérique #38)
|
||||||
|
│ │ └── app-log.js AppLog.kt : journal 500 lignes, fail-safe
|
||||||
|
│ └── ui/ ── ÉCRANS (DOM uniquement ici) ──
|
||||||
|
│ ├── app.js HormoneTrackApp+MainActivity+Root : shell,
|
||||||
|
│ │ routing hash, « Nouveautés » (markdown
|
||||||
|
│ │ rendu), hook ?demo=1, boucle ticks
|
||||||
|
│ ├── components.js el(), showDialog/toast, fields, switch,
|
||||||
|
│ │ chips, FAB, dateTimeField (M3 → natifs)
|
||||||
|
│ ├── dialogs.js DoseDialog.kt (override ester par modèle)
|
||||||
|
│ │ + LabDialog.kt (E2+T une entrée)
|
||||||
|
│ ├── home.js HomeScreen.kt (niveau actuel #53, alertes,
|
||||||
|
│ │ prochaine dose, log rapide, mini-chart)
|
||||||
|
│ ├── chart.js ChartScreen.kt : fenêtre/pan/zoom (fixes
|
||||||
|
│ │ #54/#56/#62), toggles, calibration par
|
||||||
|
│ │ modèle (#60), prévision
|
||||||
|
│ ├── chart-canvas.js CurveChart.kt : rendu Canvas multi-séries,
|
||||||
|
│ │ labs orange, marqueurs, extrema
|
||||||
|
│ ├── doses.js DosesScreen.kt (groupes jour, Δ jours,
|
||||||
|
│ │ temps sous THS — 3 placeholders, bug #47)
|
||||||
|
│ ├── labs.js LabsScreen.kt (groupement, suppression
|
||||||
|
│ │ par prise entière, sélecteur E2/T)
|
||||||
|
│ ├── treatments.js TreatmentsScreen.kt (cartes, modelLabelKey #59)
|
||||||
|
│ ├── treatment-editor.js TreatmentEditorScreen.kt (presets,
|
||||||
|
│ │ calibration, Posologie, rappel, actif)
|
||||||
|
│ ├── settings.js SettingsScreen.kt (langue, auto-cal, T,
|
||||||
|
│ │ seuils, fuseau, notifs, backup, logs, à propos)
|
||||||
|
│ └── reminders.js ReminderManager+AlertNotifier (web) :
|
||||||
|
│ boucle 30 s rappels + 5 min seuils,
|
||||||
|
│ Notification API + bannière in-app
|
||||||
|
├── docs/
|
||||||
|
│ ├── DEVELOPPEMENT.md cette doc
|
||||||
|
│ └── CHANGELOG.md journal web (lu par le dialog « Nouveautés »)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── serve.py serveur statique de dev (no-cache, MIME ok)
|
||||||
|
│ ├── check.sh vérifications 1→6 (syntaxe, i18n, tests, E2E, HTTP)
|
||||||
|
│ ├── i18n-check.mjs cohérence FR/EN + clés utilisées
|
||||||
|
│ └── e2e.mjs E2E playwright (Firefox headless, ?demo=1)
|
||||||
|
├── tests/ ── node --test (miroirs des suites Kotlin) ──
|
||||||
|
│ ├── helpers.js fixtures (makeTreatment/makeDose/makeLab, asset)
|
||||||
|
│ ├── pk-profiles.test.js ← PKProfileStoreTest.kt (pics ODS, casse #22…)
|
||||||
|
│ ├── tfs-models.test.js ← TransfemScienceModelsTest.kt (article ±2 %)
|
||||||
|
│ ├── whsah-models.test.js ← WhsahModelsTest.kt (Mona ±2 %)
|
||||||
|
│ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis)
|
||||||
|
│ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/
|
||||||
|
│ │ V120Features (fixes #60/#61, périodes d'ester)
|
||||||
|
│ ├── backup.test.js ← BackupGsonTest.kt + exports Android RÉELS
|
||||||
|
│ │ (local-test-data/ si présent, sinon skip)
|
||||||
|
│ ├── chart-helpers.test.js ← ChartZoomTest.kt (#53/#54/#55/#58/#62)
|
||||||
|
│ ├── alerts.test.js ← AlertsTest.kt (codec, anti-spam)
|
||||||
|
│ ├── misc.test.js ← ReminderSchedule/Extrema/Changelog/HrtDuration
|
||||||
|
│ └── store.test.js CRUD, IDs auto, CASCADE, pub/sub, corruption
|
||||||
|
└── package.json scripts npm + devDependencies (E2E seulement)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Points clés de l'architecture
|
||||||
|
|
||||||
|
- **Le noyau `js/pk/` est PUR** : aucune importation de `data/`, `ui/` ou
|
||||||
|
DOM → testé en Node exactement comme le Kotlin l'est en JVM. C'est la
|
||||||
|
garantie que les courbes web == courbes Android.
|
||||||
|
- **Le store est le seul point de persistance** : les écrans ne touchent
|
||||||
|
jamais localStorage directement ; les mutations passent par
|
||||||
|
`store.*` qui notifie les abonnés.
|
||||||
|
- **`isActive` = drapeau administratif** (Android §6.bis, appliqué tel quel) :
|
||||||
|
la saisie (chips, dropdown création) filtre les actifs ; la simulation,
|
||||||
|
la calibration et les rappels du moteur reçoivent TOUS les traitements.
|
||||||
|
- **Calibration par modèle affiché** (fix #60) : l'écran Graphiques calcule
|
||||||
|
une `autoCalibrated` par modèle (ESE/TFS/WHS) ; l'accueil utilise le
|
||||||
|
modèle stocké — comme l'Android.
|
||||||
|
- **« now » mémoïsé sur un tick minute** (fix #56 transposé) : `ctx.nowMs`
|
||||||
|
est fourni par le shell et ne change qu'au re-rendu (tick 60 s, mutation
|
||||||
|
du store, navigation) — jamais de dérive de fenêtre pendant les gestes.
|
||||||
|
- **Le graphique est purement déclaratif** : pan/zoom vivent dans
|
||||||
|
`chart.js` (parent), `chart-canvas.js` ne fait que dessiner une fenêtre
|
||||||
|
donnée — même séparation que Compose (piège #62/§11 Android).
|
||||||
|
|
||||||
|
## 5. Persistance (localStorage) & schéma de données
|
||||||
|
|
||||||
|
Clés (préfixe `hormonetrack.`) — miroir des tables Room / clés DataStore :
|
||||||
|
|
||||||
|
| Clé | Contenu | Équivalent Android |
|
||||||
|
|---|---|---|
|
||||||
|
| `treatments` | tableau JSON d'objets Treatment (champs = entités Room) | table `treatments` |
|
||||||
|
| `doseLogs` | idem DoseLog (FK `treatmentId`, CASCADE au delete) | table `dose_logs` |
|
||||||
|
| `labResults` | idem LabResult | table `lab_results` |
|
||||||
|
| `tConfig` | `{base, floor, k}` (défauts 6.0 / 0.2 / 0.19) | DataStore `t_base/t_floor/t_k` |
|
||||||
|
| `settings` | `{language, autoCalibrate, alertE2High/Low, alertTHigh/Low, chartTimezone, changelogSeenVersion, alertNotifiedState}` (fusion avec défauts à la lecture) | DataStore (plusieurs clés) |
|
||||||
|
| `debugLog` | texte, `\n` (buffer 500 lignes) | `filesDir/debug-log.txt` |
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
|
||||||
|
- **IDs auto-incrémentés par table** (max + 1) — miroir Room
|
||||||
|
`autoGenerate` ; à l'import, les IDs du backup sont conservés (FK
|
||||||
|
dose→traitement) et les compteurs repartent de max+1.
|
||||||
|
- **Import = écrasement** (miroir v1.2.6) : effacement enfants→parents puis
|
||||||
|
restauration, un seul `emit()` final.
|
||||||
|
- **Donnée corrompue ≠ crash** : `read()` retombe sur le défaut (même
|
||||||
|
philosophie qu'AppLog.init Android).
|
||||||
|
- `calendarEventId` existe dans les objets (fidélité du schéma) mais reste
|
||||||
|
TOUJOURS `null` côté web.
|
||||||
|
|
||||||
|
## 6. Compatibilité des backups avec l'Android
|
||||||
|
|
||||||
|
Format `BackupData` v2 — **verrouillé par des tests des deux côtés** :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"exportedAt": 1749000000000,
|
||||||
|
"treatments": [ { "id": 1, "name": "…", "type": "ESTRADIOL", "route": "INJECTION_SUBCUT",
|
||||||
|
"doseAmount": 5, "doseUnit": "mg", "isActive": true, "notes": null,
|
||||||
|
"esterType": "EEN", "pkModel": "TFS",
|
||||||
|
"absorptionHours": 156, "eliminationHalfLifeHours": 110,
|
||||||
|
"bioavailabilityFraction": 1, "scaleFactor": 1,
|
||||||
|
"forecastIntervalDays": 7, "reminderHour": 18, "reminderMinute": 0,
|
||||||
|
"reminderEnabled": true, "calendarEventId": null, "createdAt": … } ],
|
||||||
|
"doseLogs": [ { "id": 1, "treatmentId": 1, "timestamp": …, "doseAmount": 5,
|
||||||
|
"notes": null, "esterType": null } ],
|
||||||
|
"labResults": [ { "id": 1, "marker": "E2", "value": 300, "unit": "pg/mL",
|
||||||
|
"timestamp": …, "notes": null } ],
|
||||||
|
"tConfig": { "base": 6.0, "floor": 0.2, "k": 0.19 },
|
||||||
|
"settings": { "language": "fr", "autoCalibrate": false,
|
||||||
|
"alertE2High": null, "alertE2Low": null,
|
||||||
|
"alertTHigh": null, "alertTLow": null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Champs PLATS** dans `settings` (miroir `UserSettings.kt` — Gson lit par
|
||||||
|
réflexion côté Android). ⚠️ Les clés web extra (`chartTimezone`,
|
||||||
|
`alertNotifiedState`, `changelogSeenVersion`) sont **exclues** de
|
||||||
|
l'export (testé) — un backup importé dans l'Android ne doit rien contenir
|
||||||
|
d'inconnu.
|
||||||
|
- **Rétrocompat v1** : backup sans `settings` → `settings = null` →
|
||||||
|
l'import ne touche pas aux réglages (comportement Gson identique).
|
||||||
|
- **L'import web est un écrasement** (dialog d'avertissement, bouton
|
||||||
|
« Effacer & restaurer ») ; le `tConfig` et les réglages du backup sont
|
||||||
|
restaurés comme sur l'Android.
|
||||||
|
- `tests/backup.test.js` importe les **vrais exports Android** via
|
||||||
|
`helpers.localTestDataDir()` (dépôt Android voisin, skip si absent —
|
||||||
|
comportement `Assume` identique au Kotlin) — garde de confidentialité
|
||||||
|
identique : ces fichiers restent HORS dépôts, et les assertions sont
|
||||||
|
data-driven (aucune valeur réelle en dur).
|
||||||
|
|
||||||
|
**Règle de maintenance** : tout changement de schéma (côté Android OU web)
|
||||||
|
doit (a) passer par les deux suites de tests backup, (b) rester
|
||||||
|
rétrocompatible, (c) être documenté dans les DEUX docs de dev.
|
||||||
|
|
||||||
|
## 7. Fuseaux horaires (Intl)
|
||||||
|
|
||||||
|
L'Android utilise `Calendar`/`TimeZone` ; le web utilise `Intl` :
|
||||||
|
|
||||||
|
- `zonedParts(ms, tz)` = `Calendar.getInstance(tz).get(...)` (via
|
||||||
|
`Intl.DateTimeFormat.formatToParts`) ;
|
||||||
|
- `zonedTimeToMs(parts, tz)` = construction inverse avec **deux passes de
|
||||||
|
correction DST** (mesure de l'offset à l'instant naïf, puis à l'instant
|
||||||
|
corrigé — point fixe) ;
|
||||||
|
- `xLabelTicks` (fix #55) : les pas ≥ 24 h sont alignés sur **minuit LOCAL
|
||||||
|
du fuseau de lecture** (Paramètres → « Fuseau du graphique », vide =
|
||||||
|
navigateur), avancés de N jours CALENDAIRES (DST-safe, testé sur la
|
||||||
|
traversée de fin mars) ; les pas horaires sur des heures rondes locales.
|
||||||
|
|
||||||
|
Pièges rencontrés :
|
||||||
|
|
||||||
|
- `Intl` renvoie `hour: '24'` avec `h24` et pas `h23` — toujours
|
||||||
|
`hourCycle: 'h23'` ;
|
||||||
|
- un formatter avec SEULEMENT `minute` ne pads pas à 2 chiffres partout —
|
||||||
|
tester sur `(hour, minute)` ensemble ;
|
||||||
|
- `-0` : `Math.trunc(-0.8) === -0` — `assert.equal(-0, 0)` échoue en strict ;
|
||||||
|
les helpers normalisent (`clampPanHours`, `panDeltaHours`).
|
||||||
|
|
||||||
|
## 8. Tests — processus complet
|
||||||
|
|
||||||
|
### Panorama
|
||||||
|
|
||||||
|
| Niveau | Outil | Couverture | Commande |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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` | **121 tests** : noyau PK, calibration, backup, store, helpers, alertes, rappels, changelog | `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` |
|
||||||
|
|
||||||
|
La commande unique avant tout push : **`bash scripts/check.sh`** (tout
|
||||||
|
exécuter, E2E inclus s'il est installé).
|
||||||
|
|
||||||
|
### Installation (une fois)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm i # devDependencies (playwright) — l'APP n'en a aucune
|
||||||
|
npx playwright install firefox # le build Firefox piloté (~90 Mo, cache ~/.cache)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ Le **Firefox système** ne convient PAS : depuis ~Firefox 150, le
|
||||||
|
protocole Juggler de Playwright n'y est plus fonctionnel (exit immédiat au
|
||||||
|
launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
|
||||||
|
|
||||||
|
### Ce que chaque suite épinglera TOUJOURS
|
||||||
|
|
||||||
|
- **pk-profiles** : les 6 profils × 8001 points, pics == valeurs ODS
|
||||||
|
(61,12 / 3,44 / 31,35 / 58,96 / 10,11 / 31,97), casse `EEn` (#22),
|
||||||
|
modèle strict (#21), extrapolation ≥ 1 % du pic (#20).
|
||||||
|
- **tfs-models** : pics/t½ des Tableaux 9–10 ±2 %, équilibre EV 5 mg/7 j =
|
||||||
|
Figure 11, PEP, EU ultra-rapide. *Toute retouche de
|
||||||
|
transfem-science-models.js passe par là.*
|
||||||
|
- **whsah-models** : fidélité Mona ±2 %, PEP non couvert, EEn J+1 ×3.
|
||||||
|
- **pk-engine** : pic Bateman ≈ Tmax (bisection #19), dispatch 3 modèles,
|
||||||
|
override par dose, §6.bis (inactif simulé), prévision (#35), T monotone.
|
||||||
|
- **calibration** : par période d'ester, **par modèle** (#60, chaque courbe
|
||||||
|
calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX
|
||||||
|
pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE.
|
||||||
|
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
|
||||||
|
**imports des exports Android réels** (data-driven, hors dépôt).
|
||||||
|
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
|
||||||
|
pointHoursBefore (#53), clamps (#54), toggles (#58).
|
||||||
|
- **alerts / misc / store** : codec strict, anti-spam, grille Posologie
|
||||||
|
(#52), extrema, comparaison numérique de versions (#38), résilience du
|
||||||
|
store (donnée corrompue → défauts).
|
||||||
|
|
||||||
|
### Écrire un nouveau test
|
||||||
|
|
||||||
|
1. Le code visé doit être dans `js/pk/` (pur) ou testable via le store
|
||||||
|
(backend Map) — l'UI est couverte par l'E2E.
|
||||||
|
2. Réutiliser `tests/helpers.js` (`makeTreatment/makeDose/makeLab`,
|
||||||
|
`initProfiles`, `assertClose`).
|
||||||
|
3. ⚠️ Les fixtures ont des **defaults identiques aux entités Kotlin** :
|
||||||
|
`id: 0` = « pas en base » (un `id: 1` par défaut a faussé trois tests
|
||||||
|
pendant le portage — cf §11).
|
||||||
|
4. Les scénarios de labs/calibration se posent **data-driven** :
|
||||||
|
`lab = e2At(...) × facteur` — ne jamais recalculer la prédiction à la
|
||||||
|
main (l'accumulation est celle du moteur).
|
||||||
|
|
||||||
|
### E2E (scripts/e2e.mjs)
|
||||||
|
|
||||||
|
- Démarre `serve.py` sur un port libre en argument, lance le Firefox
|
||||||
|
Playwright headless, navigue avec `?demo=1` (données fictives banniérées,
|
||||||
|
jamais sur un store non vide) ;
|
||||||
|
- vérifie le **rendu réel** : cartes, valeurs, dialog « Nouveautés »
|
||||||
|
(markdown rendu), navigation des 5 onglets + Paramètres, **pixels du
|
||||||
|
canvas** (`getImageData` — le graphique PEINT vraiment) ;
|
||||||
|
- vérifie l'**absence d'erreur console/page** sur toute la session ;
|
||||||
|
- captures dans `/tmp/hrt-web-shots/` (inspection visuelle).
|
||||||
|
- ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]`
|
||||||
|
(posé par `renderRoute` une fois l'asset chargé) — attendre ce sélecteur,
|
||||||
|
jamais un `sleep` nu.
|
||||||
|
- ⚠️ Le seed de langue (`addInitScript`) est **conditionnel** : l'écraser à
|
||||||
|
chaque navigation effacerait `changelogSeenVersion` et ferait réapparaître
|
||||||
|
le dialog à chaque `page.goto` (une goto = reboot complet de la SPA).
|
||||||
|
|
||||||
|
## 9. Workflow de développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
python3 scripts/serve.py # → http://127.0.0.1:8970 (no-cache : F5 = code à jour)
|
||||||
|
# … développement …
|
||||||
|
bash scripts/check.sh # AVANT chaque commit (syntaxe + i18n + tests + E2E)
|
||||||
|
```
|
||||||
|
|
||||||
|
Règles de code (appliquées partout, à conserver) :
|
||||||
|
|
||||||
|
- **Tout fichier commence par un bloc KDoc** qui dit QUOI il porte (fichier
|
||||||
|
Kotlin d'origine), POURQUOI il existe et les invariants à préserver.
|
||||||
|
- **Toute fonction non triviale a un JSDoc** avec `@param`/`@returns` et,
|
||||||
|
quand c'est un fix Android transposé, le numéro du bug (#NN).
|
||||||
|
- **Le noyau `js/pk/` ne connaît ni le DOM ni le storage** — si une feature
|
||||||
|
exige l'inverse, c'est le design qu'il faut revoir.
|
||||||
|
- **Strings** : passer par `t('clé')`, dans les DEUX dictionnaires
|
||||||
|
(`i18n-check.mjs` bloque sinon) — leçon Android §12.
|
||||||
|
- **Champs de modèles** : ne JAMAIS renommer un champ de
|
||||||
|
`data/models.js` sans vérifier `BackupManager.kt` (compat Gson, cf §6).
|
||||||
|
- **Tout fix de bug** : test qui l'épingle d'abord (rouge), puis fix, puis
|
||||||
|
§11 de cette doc + `docs/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 10. Processus de push & releases (sync Android)
|
||||||
|
|
||||||
|
Les releases web sont **SYNCHRONISÉES avec les releases APK** (décision du
|
||||||
|
8 sept. 2026) : chaque feature release de l'Android est portée et tagguée
|
||||||
|
au MÊME numéro dans ce dépôt — l'utilisatrice a toujours la parité des
|
||||||
|
fonctionnalités (sauf impossibilités structurelles §12). La publication
|
||||||
|
Gitea (release avec assets) du côté web est GELÉE tant que la qualité n'est
|
||||||
|
pas validée au standard APK : en attendant, une release web = commit + tag
|
||||||
|
UNIQUEMENT.
|
||||||
|
|
||||||
|
### Remotes
|
||||||
|
|
||||||
|
```
|
||||||
|
origin https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web (privé, HTTPS + trousseau)
|
||||||
|
farewell git@farewell:Siphonight/HormoneTrack-web.git (SSH, alias existant)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ **Création des repos** (one-shot, faite par l'utilisatrice — le token
|
||||||
|
`write:repository` ne permet PAS de créer un repo via API, et le
|
||||||
|
push-to-create est désactivé sur farewell) : créer `HormoneTrack-web` sur
|
||||||
|
les DEUX instances (cloudyfy + farewell), privé, puis configurer les
|
||||||
|
remotes ci-dessus. (Historique : Android a fait la même opération au
|
||||||
|
push initial v1.2.3, cf doc Android §16.)
|
||||||
|
|
||||||
|
### Checklist de release SYNC (Android + web, une seule session)
|
||||||
|
|
||||||
|
1. **Android** (doc Android §16 checklist complète) : bump `versionName`/
|
||||||
|
`versionCode`, tests + lint verts, section `docs/CHANGELOG.md`, commit,
|
||||||
|
tag annoté `vX.Y.Z`.
|
||||||
|
2. **Web** (cette doc) :
|
||||||
|
a. porter la/les features si pas déjà fait (commits par couche) ;
|
||||||
|
b. `WEB_VERSION = "X.Y.Z"` dans `js/ui/settings.js` **= même numéro que
|
||||||
|
l'Android** + section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md`
|
||||||
|
(le dialog « Nouveautés » la lira) ;
|
||||||
|
c. `bash scripts/check.sh` vert (E2E inclus) ;
|
||||||
|
d. commit `vX.Y.Z — résumé du portage` + tag annoté `vX.Y.Z`.
|
||||||
|
3. **Push des deux dépôts** :
|
||||||
|
```bash
|
||||||
|
# Android
|
||||||
|
git push origin main --tags && git push farewell main --tags
|
||||||
|
# Web
|
||||||
|
git push origin main && git push origin vX.Y.Z
|
||||||
|
git push farewell main && git push farewell vX.Y.Z
|
||||||
|
```
|
||||||
|
⚠️ Ne JAMAIS comparer les versions en chaînes (piège #38 Android) —
|
||||||
|
tout script futur passe par une comparaison numérique.
|
||||||
|
4. **Releases Gitea** :
|
||||||
|
- Android : `publish-release.py` sur les deux instances (2 APK), cf doc
|
||||||
|
Android §16.bis ;
|
||||||
|
- Web : **GELÉ** tant que la parité qualité n'est pas validée — sinon
|
||||||
|
créer la release avec le corps = section `docs/CHANGELOG.md` et, si
|
||||||
|
un jour on distribue un zip, le vérifier par téléchargement (leçon
|
||||||
|
#43 Android : une invocation fait tout, les uploads rapprochés se
|
||||||
|
remplacent mutuellement).
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Avant CHAQUE push (release ou non)
|
||||||
|
|
||||||
|
1. `bash scripts/check.sh` **vert** (E2E inclus si installé) ;
|
||||||
|
2. les données de test réelles restent **hors des DEUX dépôts**
|
||||||
|
(`git check-ignore -v local-test-data/…` côté Android ; le web ne
|
||||||
|
doit contenir AUCUN export réel — `git log --all -- local-test-data`
|
||||||
|
vide si le dossier local existe) ;
|
||||||
|
3. si l'asset PK change : les DEUX dépôts dans la même session (tests
|
||||||
|
pins identiques) — jamais un seul côté.
|
||||||
|
|
||||||
|
### Déploiement du site (quand décidé)
|
||||||
|
|
||||||
|
- **copier le dépôt** (ou `git archive vX.Y.Z`) sur n'importe quel
|
||||||
|
hébergeur statique ; rien à compiler ;
|
||||||
|
- optionnel : exclure `tests/`, `scripts/`, `docs/` du document root
|
||||||
|
(inoffensifs mais inutiles en prod).
|
||||||
|
|
||||||
|
## 11. Bugs potentiels évités pendant le portage
|
||||||
|
|
||||||
|
Trouvés pendant l'écriture (à ne pas réintroduire) :
|
||||||
|
|
||||||
|
1. **Propriétés dérivées Kotlin = fonctions JS** : `usesProfileModel` est un
|
||||||
|
*getter* en Kotlin mais n'existe pas sur les objets JSON plats → le
|
||||||
|
moteur retombait silencieusement en Bateman (courbes ÷100). Fix :
|
||||||
|
`usesProfileModel(treatment)` dans `pk-engine.js`. Toute propriété
|
||||||
|
dérivée d'entité (`isInjection`, …) doit être recalculée, jamais lue.
|
||||||
|
2. **Fixtures de test** : `makeTreatment` avec `id: 1` par défaut (au lieu
|
||||||
|
de 0 = « pas en base ») faisait échouer auto-increment/CASCADE. Defaults
|
||||||
|
des helpers == valeurs d'insertion Kotlin.
|
||||||
|
3. **Extrapolation terminale** : première version linéaire (pente) au lieu
|
||||||
|
du **taux logarithmique** du Kotlin — queues croissantes sur EU (plancher
|
||||||
|
0,01/0,00). Portage fidèle exigé, tests EU/EV des deux régimes.
|
||||||
|
4. **`zonedTimeToMs`** : la 2ᵉ passe de correction DST doit mesurer
|
||||||
|
l'offset à l'instant CORRIGÉ (`naive − offset(instant)`) — une première
|
||||||
|
version référençait le mauvais instant (ticks à minuit UTC+2 = 02:00).
|
||||||
|
5. **`-0`** : `Math.trunc`/négation produisent `-0` ; `assert.equal(-0, 0)`
|
||||||
|
échoue en strict — normaliser dans les helpers (`panDeltaHours`,
|
||||||
|
`clampPanHours`).
|
||||||
|
6. **Imports relatifs** : `app.js` vit dans `js/ui/` — un import écrit comme
|
||||||
|
depuis `js/` donne des 404 MIME silencieuses (le module root ne charge
|
||||||
|
PAS, aucune erreur fatale visible). D'où le check `node --check` +
|
||||||
|
l'E2E « aucune erreur console ».
|
||||||
|
7. **Exports du barrel** : `pk/index.js` doit ré-exporter tout ce que les
|
||||||
|
écrans importent nommément (`pointHoursBefore`, …) — une omission =
|
||||||
|
SyntaxError au chargement. L'E2E l'attrape (page vide).
|
||||||
|
8. **Assignation à une variable non déclarée** en ESM (strict) = crash du
|
||||||
|
module entier (`lastRoute` déclaré dans `main()` mais assigné dans
|
||||||
|
`renderRoute()`) → remonter l'état partagé au niveau module.
|
||||||
|
9. **Closures de dialog** : `const dlg = showDialog({ actions: [… dlg.close()] })`
|
||||||
|
— oublier le `const` donne une ReferenceError AU CLIC (pas au chargement),
|
||||||
|
invisible sans test d'interaction. L'E2E clique maintenant les actions.
|
||||||
|
10. **Seed de test conditionnel** : un `addInitScript` non conditionnel
|
||||||
|
écrase `settings` à chaque navigation (re-lecture du changelog à chaque
|
||||||
|
goto). Cf §8.
|
||||||
|
11. **`getSettings()` fusionne les défauts** : un stockage partiel ne doit
|
||||||
|
jamais exposer des champs `undefined` (les gardes `!== null` deviennent
|
||||||
|
fausses autrement).
|
||||||
|
|
||||||
|
## 12. Limites connues & non-portés
|
||||||
|
|
||||||
|
Structurels (plateforme), pas des bugs :
|
||||||
|
|
||||||
|
- **Rappels** : uniquement page ouverte (boucle 30 s + Notification API).
|
||||||
|
Aucun équivalent d'AlarmManager — un service worker + push exigerait un
|
||||||
|
serveur de push (contre la règle « 100 % local, aucun serveur »).
|
||||||
|
- **Agenda récurrent** : impossible (pas de CalendarProvider). L'éditeur
|
||||||
|
affiche une note explicative ; `calendarEventId` reste null.
|
||||||
|
- **Montre** : hors sujet navigateur.
|
||||||
|
- **Fuseau système** : si le navigateur change de fuseau (voyage), les
|
||||||
|
labels X suivent le fuseau CHOISI (Paramètres) ou le nouveau fuseau
|
||||||
|
système — l'historique des doses reste en epoch ms (aucun effet sur les
|
||||||
|
données, contrairement à un LocalDateTime stocké).
|
||||||
|
- **localStorage ≈ 5 Mo** : largement suffisant (années de doses/labs) ;
|
||||||
|
en cas de quota dépassé, AppLog avalera l'erreur (fail-safe) et il faudra
|
||||||
|
un export/import de nettoyage.
|
||||||
|
|
||||||
|
## 13. Idées d'évolution
|
||||||
|
|
||||||
|
1. **Service worker de cache offline** (l'app serait installable en PWA) —
|
||||||
|
sans push notifications, uniquement cache statique ; ne casse pas « 100 %
|
||||||
|
local » (aucun serveur de données).
|
||||||
|
2. Import en mode **fusion** (détection de doublons) — miroir de l'idée
|
||||||
|
Android §20.
|
||||||
|
3. Tooltip au survol du graphique (valeur + date au point).
|
||||||
|
4. Export CSV (miroir Android roadmap).
|
||||||
|
5. Tests E2E des gestes (drag du pan via `mouse.drag`) — l'équivalent du
|
||||||
|
pinch tactile reste hors de portée headless (comme l'émulateur Android,
|
||||||
|
§16.ter de la doc mère).
|
||||||
|
|
||||||
|
---
|
||||||
|
*Doc mise à jour le 8 sept. 2026 (web v1.4.10, dépôt séparé
|
||||||
|
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E,
|
||||||
|
backup compatible bidirectionnel, historique propre (aucune donnée de
|
||||||
|
santée, aucune dette de confidentialité), versions alignées sur
|
||||||
|
l'Android.*
|
||||||
29
index.html
Normal file
29
index.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
HormoneTrack Web — page unique (SPA sans framework, ES modules natifs).
|
||||||
|
|
||||||
|
⚠️ L'app doit être SERVIE EN HTTP (python3 web/scripts/serve.py, nginx,
|
||||||
|
n'importe quel serveur statique) — pas ouverte en file:// (fetch des
|
||||||
|
modules ES bloqué par le navigateur).
|
||||||
|
|
||||||
|
100 % local : les données vivent dans le localStorage du navigateur.
|
||||||
|
Aucun compte, aucun serveur applicatif, aucune télémétrie.
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<title>HormoneTrack</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<!-- Icône : favicon SVG inline (seringue stylisée bleu HormoneTrack) -->
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234F5BD5'/%3E%3Cpath d='M9 16h14M16 9v14' stroke='%23F5A9B8' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>HormoneTrack requires JavaScript / HormoneTrack nécessite JavaScript.</noscript>
|
||||||
|
<!-- Le shell (topbar, bandeau, main, tabbar) est construit par js/ui/app.js -->
|
||||||
|
<script type="module" src="js/ui/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
156
js/data/backup.js
Normal file
156
js/data/backup.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Sauvegarde JSON (portage de `data/backup/BackupManager.kt`).
|
||||||
|
*
|
||||||
|
* ⚠️ COMPATIBILITÉ BIDIRECTIONNELLE avec les exports de l'app Android :
|
||||||
|
* format BackupData v2 —
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "version": 2,
|
||||||
|
* "exportedAt": 1749...,
|
||||||
|
* "treatments": [ Treatment... (champs Gson/Kotlin, cf data/models.js) ],
|
||||||
|
* "doseLogs": [ DoseLog... ],
|
||||||
|
* "labResults": [ LabResult... ],
|
||||||
|
* "tConfig": { "base": 6.0, "floor": 0.2, "k": 0.19 },
|
||||||
|
* "settings": { "language": ..., "autoCalibrate": ...,
|
||||||
|
* "alertE2High": ..., "alertE2Low": ...,
|
||||||
|
* "alertTHigh": ..., "alertTLow": ... } ← optionnel (v2)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* RÉTROCOMPATIBILITÉ : les backups v1 (SANS `settings`) restent parsables
|
||||||
|
* (settings = null) et importables — l'import ne vérifie pas strictement la
|
||||||
|
* version (miroir du comportement Gson côté Android). `changelog_seen_version`
|
||||||
|
* est volontairement EXCLU des settings d'export (pas une donnée utile à
|
||||||
|
* restaurer — cf BackupManager.kt).
|
||||||
|
*
|
||||||
|
* PUR (DOM-free) → testable en Node (web/tests/backup.test.js, y compris
|
||||||
|
* round-trip avec un vrai export Android si local-test-data/ est présent).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le JSON de sauvegarde (BackupData v2, mêmes noms de champs que
|
||||||
|
* Gson).
|
||||||
|
*
|
||||||
|
* @param {object} collections { treatments, doseLogs, labResults } (store.exportAll)
|
||||||
|
* @param {object} tConfig { base, floor, k }
|
||||||
|
* @param {object|null} [userSettings=null] Réglages à embarquer (v2) —
|
||||||
|
* SEULS les champs du schéma UserSettings Android sont sérialisés (champs
|
||||||
|
* plats), jamais les clés web extra (chartTimezone, alertNotifiedState…) :
|
||||||
|
* un backup importé dans l'app Android ne doit pas contenir de clés
|
||||||
|
* inconnues (Gson les ignorerait, mais autant rester stricts).
|
||||||
|
* @returns {string} JSON
|
||||||
|
*/
|
||||||
|
export function buildBackupJson(collections, tConfig, userSettings = null) {
|
||||||
|
const data = {
|
||||||
|
version: 2,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
treatments: collections.treatments,
|
||||||
|
doseLogs: collections.doseLogs,
|
||||||
|
labResults: collections.labResults,
|
||||||
|
tConfig: {
|
||||||
|
base: tConfig.base,
|
||||||
|
floor: tConfig.floor,
|
||||||
|
k: tConfig.k,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (userSettings) {
|
||||||
|
// Champs PLATS volontairement (miroir UserSettings.kt — Gson lit par
|
||||||
|
// réflexion côté Android : pas de nested inconnu)
|
||||||
|
data.settings = {
|
||||||
|
language: userSettings.language ?? null,
|
||||||
|
autoCalibrate: userSettings.autoCalibrate ?? null,
|
||||||
|
alertE2High: userSettings.alertE2High ?? null,
|
||||||
|
alertE2Low: userSettings.alertE2Low ?? null,
|
||||||
|
alertTHigh: userSettings.alertTHigh ?? null,
|
||||||
|
alertTLow: userSettings.alertTLow ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse un backup (Android v1 ou v2, ou export web) en structure validée.
|
||||||
|
*
|
||||||
|
* Les listes manquantes deviennent vides, les types numériques sont
|
||||||
|
* normalisés (Gson accepte des entiers pour des Double) — l'import ne
|
||||||
|
* vérifie pas strictement la version, comme côté Android.
|
||||||
|
*
|
||||||
|
* @param {string} json
|
||||||
|
* @returns {{version:number, exportedAt:number|null,
|
||||||
|
* treatments:object[], doseLogs:object[], labResults:object[],
|
||||||
|
* tConfig:{base:number,floor:number,k:number},
|
||||||
|
* settings:object|null}}
|
||||||
|
* @throws {Error} si le JSON est illisible ou n'a pas la forme d'un backup
|
||||||
|
*/
|
||||||
|
export function parseBackupJson(json) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(json);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`JSON illisible : ${e.message}`);
|
||||||
|
}
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
throw new Error('Le fichier ne contient pas un backup HormoneTrack');
|
||||||
|
}
|
||||||
|
if (!('treatments' in raw) && !('doseLogs' in raw) && !('labResults' in raw)) {
|
||||||
|
throw new Error('Aucune donnée de backup reconnue (treatments/doseLogs/labResults absents)');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: typeof raw.version === 'number' ? raw.version : 1,
|
||||||
|
exportedAt: typeof raw.exportedAt === 'number' ? raw.exportedAt : null,
|
||||||
|
treatments: Array.isArray(raw.treatments) ? raw.treatments : [],
|
||||||
|
doseLogs: Array.isArray(raw.doseLogs) ? raw.doseLogs : [],
|
||||||
|
labResults: Array.isArray(raw.labResults) ? raw.labResults : [],
|
||||||
|
// Défauts TConfig.kt si absent (backup tronqué) — comme Gson
|
||||||
|
// reconstruirait TConfig() par défaut
|
||||||
|
tConfig: {
|
||||||
|
base: numOrNull(raw.tConfig && raw.tConfig.base) ?? 6.0,
|
||||||
|
floor: numOrNull(raw.tConfig && raw.tConfig.floor) ?? 0.2,
|
||||||
|
k: numOrNull(raw.tConfig && raw.tConfig.k) ?? 0.19,
|
||||||
|
},
|
||||||
|
// settings absent = backup v1 → null (l'appelant ne touche pas aux réglages)
|
||||||
|
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private Convertit en nombre si possible, sinon null. */
|
||||||
|
function numOrNull(v) {
|
||||||
|
const n = typeof v === 'string' ? Number(v) : v;
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nom de fichier d'export (miroir d'ExportFileNames.backupFileName Android) :
|
||||||
|
* `hormonetrack-backup-YYYYMMDD.json` — pattern SANS heure.
|
||||||
|
*
|
||||||
|
* Le helper Android existait parce qu'un pattern horaire passé à LocalDate
|
||||||
|
* crashait l'app (#48) ; côté web, `new Date()` porte tout — le helper reste
|
||||||
|
* centralisé par discipline (une seule source pour le nom).
|
||||||
|
*
|
||||||
|
* @param {Date} [now=new Date()]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function backupFileName(now = new Date()) {
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `hormonetrack-backup-${y}${m}${d}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nom de fichier d'export des logs de diagnostic (miroir
|
||||||
|
* ExportFileNames.diagnosticLogFileName) : `hormonetrack-logs-YYYYMMDD-HHmm.txt`
|
||||||
|
* — pattern AVEC heure (le fichier Android documente pourquoi : bug #48).
|
||||||
|
*
|
||||||
|
* @param {Date} [now=new Date()]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function diagnosticLogFileName(now = new Date()) {
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const mo = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(now.getDate()).padStart(2, '0');
|
||||||
|
const h = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `hormonetrack-logs-${y}${mo}${d}-${h}${mi}.txt`;
|
||||||
|
}
|
||||||
212
js/data/models.js
Normal file
212
js/data/models.js
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Modèles de données (portage de `data/model/*.kt`).
|
||||||
|
*
|
||||||
|
* ⚠️ COMPATIBILITÉ BACKUP : les noms de champs sont CEUX DE GSON (noms de
|
||||||
|
* propriétés Kotlin) — identiques aux entités Room. NE JAMAIS renommer un
|
||||||
|
* champ ici sans vérifier BackupManager.kt (sinon les exports Android ne
|
||||||
|
* s'importent plus) :
|
||||||
|
* - Treatment : id, name, type, route, doseAmount, doseUnit, isActive,
|
||||||
|
* notes, esterType, pkModel, absorptionHours, eliminationHalfLifeHours,
|
||||||
|
* bioavailabilityFraction, scaleFactor, forecastIntervalDays,
|
||||||
|
* reminderHour, reminderMinute, reminderEnabled, calendarEventId, createdAt
|
||||||
|
* - DoseLog : id, treatmentId, timestamp, doseAmount, notes, esterType
|
||||||
|
* - LabResult : id, marker, value, unit, timestamp, notes
|
||||||
|
* - TConfig : base, floor, k
|
||||||
|
* - UserSettings : language, autoCalibrate, alertE2High, alertE2Low,
|
||||||
|
* alertTHigh, alertTLow (champs PLATS — cf BackupManager.kt)
|
||||||
|
*
|
||||||
|
* `calendarEventId` est conservé (fidelité du schéma) mais TOUJOURS null
|
||||||
|
* côté web : les événements d'agenda récurrents sont impossibles dans un
|
||||||
|
* navigateur (pas de CalendarProvider) — fonctionnalité Android non portée.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TreatmentType = {
|
||||||
|
ESTRADIOL: 'ESTRADIOL',
|
||||||
|
ANTI_ANDROGEN: 'ANTI_ANDROGEN',
|
||||||
|
PROGESTOGEN: 'PROGESTOGEN',
|
||||||
|
OTHER: 'OTHER',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdministrationRoute = {
|
||||||
|
ORAL: 'ORAL',
|
||||||
|
TRANSDERMAL_GEL: 'TRANSDERMAL_GEL',
|
||||||
|
TRANSDERMAL_PATCH: 'TRANSDERMAL_PATCH',
|
||||||
|
INJECTION_IM: 'INJECTION_IM',
|
||||||
|
INJECTION_SUBCUT: 'INJECTION_SUBCUT',
|
||||||
|
OTHER: 'OTHER',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
|
||||||
|
* (miroir de `Esters.choicesForModel`, v1.4.6 — la liste suit la couverture
|
||||||
|
* du modèle : TFS = 7 esters ; WHSAH = 6 (PEP non couvert) ; Estrannaise
|
||||||
|
* (tables ODS) = EV/EU/EEn seulement — un traitement ESE + EB produirait
|
||||||
|
* 0 pg/mL).
|
||||||
|
*
|
||||||
|
* @param {string} pkModel
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function choicesForModel(pkModel) {
|
||||||
|
if (pkModel === 'TFS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS', 'PEP'];
|
||||||
|
if (pkModel === 'WHS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS'];
|
||||||
|
return ['EV', 'EU', 'EEN'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libellé humain d'un ester (miroir de `esterLabel` du DoseDialog Android).
|
||||||
|
* @param {string} ester
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function esterLabel(ester) {
|
||||||
|
switch (ester) {
|
||||||
|
case 'EV': return 'EV — valerate';
|
||||||
|
case 'EU': return 'EU — undecylate';
|
||||||
|
case 'EEN': return 'EEn — enanthate';
|
||||||
|
case 'EB': return 'EB — benzoate';
|
||||||
|
case 'EC': return 'EC — cypionate (oil)';
|
||||||
|
case 'ECS': return 'EC — cypionate (suspension)';
|
||||||
|
case 'PEP': return 'PEP — polyestradiol phosphate';
|
||||||
|
default: return ester;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clé i18n du label d'un modèle PK (miroir de `modelLabelRes`, fix #59 —
|
||||||
|
* UNIQUE source du label : TOUTE nouvelle utilisation doit passer par ce
|
||||||
|
* helper, pas par un ternaire à 2 branches qui oubliait WHSAH).
|
||||||
|
* @param {string} pkModel
|
||||||
|
* @returns {string} clé i18n
|
||||||
|
*/
|
||||||
|
export function modelLabelKey(pkModel) {
|
||||||
|
if (pkModel === 'TFS') return 'model_tfs';
|
||||||
|
if (pkModel === 'WHS') return 'model_whs';
|
||||||
|
return 'model_ese';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clé i18n d'une voie d'administration. */
|
||||||
|
export function routeLabelKey(route) {
|
||||||
|
switch (route) {
|
||||||
|
case 'ORAL': return 'route_oral';
|
||||||
|
case 'TRANSDERMAL_GEL': return 'route_gel';
|
||||||
|
case 'TRANSDERMAL_PATCH': return 'route_patch';
|
||||||
|
case 'INJECTION_IM': return 'route_injection_im';
|
||||||
|
case 'INJECTION_SUBCUT': return 'route_injection_sc';
|
||||||
|
default: return 'route_other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clé i18n d'un type de traitement. */
|
||||||
|
export function typeLabelKey(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'ESTRADIOL': return 'type_estradiol';
|
||||||
|
case 'ANTI_ANDROGEN': return 'type_antiandrogen';
|
||||||
|
case 'PROGESTOGEN': return 'type_progestogen';
|
||||||
|
default: return 'type_other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Est-ce une voie injectable (IM/SC) ?
|
||||||
|
* @param {string} route
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isInjectionRoute(route) {
|
||||||
|
return route === 'INJECTION_IM' || route === 'INJECTION_SUBCUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggère une clé i18n de route depuis un preset (utilisé par l'éditeur).
|
||||||
|
* (Aucune logique : lisibilité des écrans.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un Treatment avec les valeurs par défaut de Room (`Treatment.kt`).
|
||||||
|
* Les traitements sont des objets PLATS (JSON-serialisables directement).
|
||||||
|
*
|
||||||
|
* @param {Partial<object>} [init={}] Champs à surcharger
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function newTreatment(init = {}) {
|
||||||
|
return {
|
||||||
|
id: 0, // 0 = pas encore en base (auto-increment côté store)
|
||||||
|
name: '',
|
||||||
|
type: TreatmentType.ESTRADIOL,
|
||||||
|
route: AdministrationRoute.INJECTION_IM,
|
||||||
|
doseAmount: 0,
|
||||||
|
doseUnit: 'mg',
|
||||||
|
isActive: true,
|
||||||
|
notes: null,
|
||||||
|
esterType: 'NONE',
|
||||||
|
pkModel: 'ESE',
|
||||||
|
// PK Bateman (gel/patch/oral)
|
||||||
|
absorptionHours: 4,
|
||||||
|
eliminationHalfLifeHours: 24,
|
||||||
|
bioavailabilityFraction: 1.0,
|
||||||
|
// Calibration : ratio lab / prédiction (le « Scale factor » du .ods)
|
||||||
|
scaleFactor: 1.0,
|
||||||
|
// Simulation prévisionnelle : intervalle en JOURS (null = pas de prévision)
|
||||||
|
forecastIntervalDays: null,
|
||||||
|
// Rappel
|
||||||
|
reminderHour: null,
|
||||||
|
reminderMinute: null,
|
||||||
|
reminderEnabled: false,
|
||||||
|
// Agenda : TOUJOURS null côté web (feature Android non portable)
|
||||||
|
calendarEventId: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
...init,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propriétés DÉRIVÉES d'un treatment — recalculées à la demande (pas
|
||||||
|
* stockées) : miroir des getters Kotlin `isInjection` / `usesProfileModel`.
|
||||||
|
*
|
||||||
|
* @param {object} tr
|
||||||
|
* @returns {{isInjection:boolean, usesProfileModel:boolean}}
|
||||||
|
*/
|
||||||
|
export function treatmentFlags(tr) {
|
||||||
|
const isInjection = isInjectionRoute(tr.route);
|
||||||
|
return {
|
||||||
|
isInjection,
|
||||||
|
// Un traitement a un PROFIL PK s'il est injectable ET avec un ester
|
||||||
|
// (les traitements Bateman — gel/patch/oral — n'en ont pas)
|
||||||
|
usesProfileModel: isInjection && tr.esterType !== 'NONE',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un DoseLog (valeurs par défaut = `DoseLog.kt`).
|
||||||
|
* @param {Partial<object>} [init={}]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function newDoseLog(init = {}) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
treatmentId: 0,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
doseAmount: 0,
|
||||||
|
notes: null,
|
||||||
|
// Override par injection (null = ester du traitement)
|
||||||
|
esterType: null,
|
||||||
|
...init,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un LabResult (valeurs par défaut = `LabResult.kt`).
|
||||||
|
* @param {Partial<object>} [init={}]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
export function newLabResult(init = {}) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
marker: 'E2',
|
||||||
|
value: 0,
|
||||||
|
unit: 'pg/mL',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
notes: null,
|
||||||
|
...init,
|
||||||
|
};
|
||||||
|
}
|
||||||
306
js/data/store.js
Normal file
306
js/data/store.js
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Store — persistance LOCALE (portage de la couche Room + DataStore Android).
|
||||||
|
*
|
||||||
|
* ⚠️ 100 % LOCAL : toutes les données vivent dans le localStorage du
|
||||||
|
* navigateur, sous des clés préfixées `hormonetrack.` — AUCUN envoi réseau
|
||||||
|
* (l'app ne fait qu'un fetch de son asset PK et de son changelog, en lecture).
|
||||||
|
*
|
||||||
|
* Architecture :
|
||||||
|
* - un backend de stockage INJECTABLE (localStorage en production, Map en
|
||||||
|
* Node pour les tests) — cf `WebStore.setBackend()` ;
|
||||||
|
* - un pub/sub simple (`subscribe`) : les écrans se re-rendent sur mutation ;
|
||||||
|
* - IDs auto-incrémentés par table (miroir de Room autoGenerate) ; à
|
||||||
|
* l'import, les IDs du backup sont conservés (FK dose→traitement) et les
|
||||||
|
* compteurs recalculés (max + 1).
|
||||||
|
*
|
||||||
|
* Clés localStorage (miroir des tables Room / clés DataStore Android) :
|
||||||
|
* - hormonetrack.treatments / .doseLogs / .labResults : tableaux JSON
|
||||||
|
* - hormonetrack.tConfig : {base, floor, k}
|
||||||
|
* - hormonetrack.settings : {language, autoCalibrate, alertE2High/Low,
|
||||||
|
* alertTHigh/Low, chartTimezone, changelogSeenVersion, alertNotifiedState}
|
||||||
|
* - hormonetrack.debugLog : journal de diagnostic (texte, cf AppLog)
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Préfixe de toutes les clés (évite les collisions dans un localStorage partagé). */
|
||||||
|
const PREFIX = 'hormonetrack.';
|
||||||
|
|
||||||
|
/** Backend par défaut : window.localStorage (production navigateur). */
|
||||||
|
function defaultBackend() {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) return window.localStorage;
|
||||||
|
throw new Error('Aucun backend de stockage (localStorage indisponible) — utiliser setBackend() en Node');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store applicatif. Une seule instance est exportée (`store`) — les tests
|
||||||
|
* utilisent `setBackend(new Map())` pour isoler.
|
||||||
|
*/
|
||||||
|
class WebStore {
|
||||||
|
constructor() {
|
||||||
|
this.backend = null;
|
||||||
|
this.listeners = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injecte un backend (localStorage-like ou Map — Node : new Map()). */
|
||||||
|
setBackend(backend) {
|
||||||
|
if (backend && typeof backend.getItem !== 'function' && typeof backend.get === 'function') {
|
||||||
|
// Adaptateur Map → API localStorage (utilisé par les tests Node)
|
||||||
|
const map = backend;
|
||||||
|
this.backend = {
|
||||||
|
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||||
|
setItem: (k, v) => map.set(k, v),
|
||||||
|
removeItem: (k) => map.delete(k),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
b() {
|
||||||
|
if (!this.backend) this.backend = defaultBackend();
|
||||||
|
return this.backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── primitives JSON ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
read(key, fallback) {
|
||||||
|
const raw = this.b().getItem(PREFIX + key);
|
||||||
|
if (raw === null || raw === undefined || raw === '') return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
// Donnée corrompue : ne JAMAIS bloquer l'app au démarrage (même
|
||||||
|
// philosophie qu'AppLog.init côté Android — on repart sur le défaut).
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
write(key, value) {
|
||||||
|
this.b().setItem(PREFIX + key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
remove(key) {
|
||||||
|
this.b().removeItem(PREFIX + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pub/sub ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Abonne un écouteur aux mutations ; retourne la fonction de désabonnement. */
|
||||||
|
subscribe(listener) {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notifie les écrans après une mutation (batch : un appel par action). */
|
||||||
|
emit() {
|
||||||
|
for (const l of [...this.listeners]) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch (e) {
|
||||||
|
// Un écran qui plante ne doit pas casser les autres ni la donnée
|
||||||
|
console.error('store listener error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── traitements ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getTreatments() {
|
||||||
|
return this.read('treatments', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insère (id=0) ou met à jour (id>0) un traitement. Retourne l'id réellement
|
||||||
|
* stocké (nouveau si insertion).
|
||||||
|
*/
|
||||||
|
upsertTreatment(treatment) {
|
||||||
|
const all = this.read('treatments', []);
|
||||||
|
if (!treatment.id) {
|
||||||
|
const nextId = all.reduce((m, t) => Math.max(m, t.id), 0) + 1;
|
||||||
|
const tr = { ...treatment, id: nextId };
|
||||||
|
all.push(tr);
|
||||||
|
this.write('treatments', all);
|
||||||
|
this.emit();
|
||||||
|
return nextId;
|
||||||
|
}
|
||||||
|
const idx = all.findIndex((t) => t.id === treatment.id);
|
||||||
|
if (idx >= 0) all[idx] = { ...treatment };
|
||||||
|
else all.push({ ...treatment });
|
||||||
|
this.write('treatments', all);
|
||||||
|
this.emit();
|
||||||
|
return treatment.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTreatment(id) {
|
||||||
|
// FK CASCADE : les doses du traitement partent avec lui (miroir Room)
|
||||||
|
this.write('treatments', this.read('treatments', []).filter((t) => t.id !== id));
|
||||||
|
this.write('doseLogs', this.read('doseLogs', []).filter((d) => d.treatmentId !== id));
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── doses ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getDoseLogs() {
|
||||||
|
return this.read('doseLogs', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertDoseLog(dose) {
|
||||||
|
const all = this.read('doseLogs', []);
|
||||||
|
if (!dose.id) {
|
||||||
|
const nextId = all.reduce((m, d) => Math.max(m, d.id), 0) + 1;
|
||||||
|
const d = { ...dose, id: nextId };
|
||||||
|
all.push(d);
|
||||||
|
this.write('doseLogs', all);
|
||||||
|
this.emit();
|
||||||
|
return nextId;
|
||||||
|
}
|
||||||
|
const idx = all.findIndex((x) => x.id === dose.id);
|
||||||
|
if (idx >= 0) all[idx] = { ...dose };
|
||||||
|
else all.push({ ...dose });
|
||||||
|
this.write('doseLogs', all);
|
||||||
|
this.emit();
|
||||||
|
return dose.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDoseLog(id) {
|
||||||
|
this.write('doseLogs', this.read('doseLogs', []).filter((d) => d.id !== id));
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── labs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getLabResults() {
|
||||||
|
return this.read('labResults', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertLabResult(lab) {
|
||||||
|
const all = this.read('labResults', []);
|
||||||
|
if (!lab.id) {
|
||||||
|
const nextId = all.reduce((m, l) => Math.max(m, l.id), 0) + 1;
|
||||||
|
const l = { ...lab, id: nextId };
|
||||||
|
all.push(l);
|
||||||
|
this.write('labResults', all);
|
||||||
|
this.emit();
|
||||||
|
return nextId;
|
||||||
|
}
|
||||||
|
const idx = all.findIndex((x) => x.id === lab.id);
|
||||||
|
if (idx >= 0) all[idx] = { ...lab };
|
||||||
|
else all.push({ ...lab });
|
||||||
|
this.write('labResults', all);
|
||||||
|
this.emit();
|
||||||
|
return lab.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLabResult(id) {
|
||||||
|
this.write('labResults', this.read('labResults', []).filter((l) => l.id !== id));
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TConfig (DataStore Android) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
getTConfig() {
|
||||||
|
const c = this.read('tConfig', null);
|
||||||
|
// Défauts identiques à TConfig.kt (base 6.0, floor 0.2, k 0.19)
|
||||||
|
return c || { base: 6.0, floor: 0.2, k: 0.19 };
|
||||||
|
}
|
||||||
|
|
||||||
|
setTConfig(config) {
|
||||||
|
this.write('tConfig', config);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── réglages utilisateur (DataStore Android → un objet JSON) ──────────────
|
||||||
|
|
||||||
|
getSettings() {
|
||||||
|
// Fusion avec les défauts : un stockage partiel (ex. seed de test avec
|
||||||
|
// {language:'fr'} seul) ne doit jamais exposer des champs undefined.
|
||||||
|
return {
|
||||||
|
language: 'system', // "system" | "fr" | "en"
|
||||||
|
autoCalibrate: false, // option désactivée par défaut (contrôle utilisateur)
|
||||||
|
alertE2High: null, // seuils d'alerte : null = pas de limite (opt-in)
|
||||||
|
alertE2Low: null,
|
||||||
|
alertTHigh: null,
|
||||||
|
alertTLow: null,
|
||||||
|
chartTimezone: null, // fuseau de LECTURE du graphique (null = auto)
|
||||||
|
changelogSeenVersion: null, // dialog « Nouveautés »
|
||||||
|
alertNotifiedState: null, // anti-spam des notifications d'alerte
|
||||||
|
...this.read('settings', {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fusion partielle (setSettings({ language: 'fr' })) + notification. */
|
||||||
|
setSettings(patch) {
|
||||||
|
this.write('settings', { ...this.getSettings(), ...patch });
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── journal de diagnostic (AppLog) ───────────────────────────────────────
|
||||||
|
|
||||||
|
getDebugLog() {
|
||||||
|
return this.read('debugLog', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setDebugLog(text) {
|
||||||
|
this.write('debugLog', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── import/export ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import en mode ÉCRASEMENT (miroir BackupManager.importJson v1.2.6) :
|
||||||
|
* les données actuelles sont effacées AVANT l'insertion — sinon les IDs du
|
||||||
|
* backup (conservés pour les FK dose→traitement) entraient en conflit.
|
||||||
|
* Ordre d'effacement : enfants (doses, labs) puis parents (traitements).
|
||||||
|
*
|
||||||
|
* @param {object} data BackupData parsé (data/backup.js)
|
||||||
|
* @returns {{treatments:number, doseLogs:number, labResults:number}}
|
||||||
|
*/
|
||||||
|
importOverwrite(data) {
|
||||||
|
this.write('doseLogs', []);
|
||||||
|
this.write('labResults', []);
|
||||||
|
this.write('treatments', []);
|
||||||
|
for (const tr of data.treatments) this.upsertTreatment(tr);
|
||||||
|
for (const d of data.doseLogs) this.upsertDoseLog(d);
|
||||||
|
for (const l of data.labResults) this.upsertLabResult(l);
|
||||||
|
// Un seul emit final (importOverwrite est atomique pour l'UI)
|
||||||
|
this.emit();
|
||||||
|
return {
|
||||||
|
treatments: data.treatments.length,
|
||||||
|
doseLogs: data.doseLogs.length,
|
||||||
|
labResults: data.labResults.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporte tout (miroir BackupManager.exportJson — sérialisation dans
|
||||||
|
* data/backup.js, cette fonction fournit juste les collections).
|
||||||
|
*/
|
||||||
|
exportAll() {
|
||||||
|
return {
|
||||||
|
treatments: this.getTreatments(),
|
||||||
|
doseLogs: this.getDoseLogs(),
|
||||||
|
labResults: this.getLabResults(),
|
||||||
|
tConfig: this.getTConfig(),
|
||||||
|
settings: this.getSettings(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Efface TOUTES les données locales (danger : confirmation côté UI). */
|
||||||
|
clearAll() {
|
||||||
|
this.remove('treatments');
|
||||||
|
this.remove('doseLogs');
|
||||||
|
this.remove('labResults');
|
||||||
|
this.remove('tConfig');
|
||||||
|
this.remove('settings');
|
||||||
|
this.remove('debugLog');
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Instance unique de l'app. */
|
||||||
|
export const store = new WebStore();
|
||||||
144
js/pk/alerts.js
Normal file
144
js/pk/alerts.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Seuils d'alerte configurables (portage de `pk/Alerts.kt`, v1.4.2).
|
||||||
|
*
|
||||||
|
* Limites HAUTE et BASSE définies par l'utilisatrice pour l'estradiol (pg/mL)
|
||||||
|
* et la testostérone (ng/mL), évaluées contre le TAUX ESTIMÉ ACTUEL (dernier
|
||||||
|
* point de courbe = le niveau « ≈ X pg/mL » affiché sur l'accueil — pas une
|
||||||
|
* mesure de labo).
|
||||||
|
*
|
||||||
|
* Philosophie :
|
||||||
|
* - **opt-in** : aucun seuil par défaut (l'app n'invente pas de normes
|
||||||
|
* médicales — tout champ laissé vide désactive l'alerte correspondante) ;
|
||||||
|
* - PUR (aucune dépendance DOM/storage) → testable en Node ;
|
||||||
|
* - les courbes restent des ESTIMATIONS pharmacocinétiques : l'avertissement
|
||||||
|
* est indicatif, libellé en ce sens dans l'UI.
|
||||||
|
*
|
||||||
|
* Validation de cohérence : si haut ET bas sont définis, il faut haut > bas
|
||||||
|
* (sinon l'évaluation serait ambiguë) — vérifié à la saisie dans Paramètres
|
||||||
|
* avec feedback, et défensivement ici (isCoherent).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Niveaux de dépassement. */
|
||||||
|
export const Level = { HIGH: 'HIGH', LOW: 'LOW' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuils de l'utilisatrice (persistés dans le store). null = pas de limite
|
||||||
|
* pour cette valeur → jamais d'alerte dessus.
|
||||||
|
*/
|
||||||
|
export class Thresholds {
|
||||||
|
/**
|
||||||
|
* @param {number|null} [e2High=null] Limite HAUTE E2 (pg/mL)
|
||||||
|
* @param {number|null} [e2Low=null] Limite BASSE E2 (pg/mL)
|
||||||
|
* @param {number|null} [tHigh=null] Limite HAUTE T (ng/mL)
|
||||||
|
* @param {number|null} [tLow=null] Limite BASSE T (ng/mL)
|
||||||
|
*/
|
||||||
|
constructor(e2High = null, e2Low = null, tHigh = null, tLow = null) {
|
||||||
|
this.e2High = e2High;
|
||||||
|
this.e2Low = e2Low;
|
||||||
|
this.tHigh = tHigh;
|
||||||
|
this.tLow = tLow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cohérence : une limite haute doit être strictement au-dessus de la basse. */
|
||||||
|
isCoherent() {
|
||||||
|
return (this.e2High === null || this.e2Low === null || this.e2High > this.e2Low)
|
||||||
|
&& (this.tHigh === null || this.tLow === null || this.tHigh > this.tLow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Évalue UNE valeur contre ses limites.
|
||||||
|
*
|
||||||
|
* - `value > high` → HIGH ; `value < low` → LOW (STRICT : la valeur
|
||||||
|
* exactement à la limite ne déclenche rien — éviter les alertes
|
||||||
|
* « clignotantes » sur la précision d'affichage) ;
|
||||||
|
* - limite null → jamais d'alerte sur ce côté ;
|
||||||
|
* - un seul verdict par appel : HIGH prime LOW si la configuration était
|
||||||
|
* incohérente (haut < bas) — défensif, la saisie interdit ce cas.
|
||||||
|
*
|
||||||
|
* @param {number} value
|
||||||
|
* @param {number|null} low
|
||||||
|
* @param {number|null} high
|
||||||
|
* @returns {[string, number]|null} [niveau, limite franchie] ou null
|
||||||
|
*/
|
||||||
|
export function evaluate(value, low, high) {
|
||||||
|
if (high !== null && high !== undefined && value > high) return [Level.HIGH, high];
|
||||||
|
if (low !== null && low !== undefined && value < low) return [Level.LOW, low];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Évalue le niveau actuel (E2 pg/mL + T ng/mL) contre tous les seuils.
|
||||||
|
*
|
||||||
|
* @param {number} currentE2
|
||||||
|
* @param {number} currentT
|
||||||
|
* @param {Thresholds} thresholds
|
||||||
|
* @returns {{marker:string, level:string, value:number, limit:number, unit:string}[]}
|
||||||
|
* Alertes déclenchées (vide = tout va bien / rien de configuré).
|
||||||
|
* Ordre stable : E2 d'abord, puis T.
|
||||||
|
*/
|
||||||
|
export function evaluateAll(currentE2, currentT, thresholds) {
|
||||||
|
const out = [];
|
||||||
|
const e2 = evaluate(currentE2, thresholds.e2Low, thresholds.e2High);
|
||||||
|
if (e2) out.push({ marker: 'E2', level: e2[0], value: currentE2, limit: e2[1], unit: 'pg/mL' });
|
||||||
|
const t = evaluate(currentT, thresholds.tLow, thresholds.tHigh);
|
||||||
|
if (t) out.push({ marker: 'T', level: t[0], value: currentT, limit: t[1], unit: 'ng/mL' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── État de notification (v1.4.2) — anti-spam des vérifications périodiques ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sérialise l'état des alertes déjà NOTIFIÉES pour la persistance
|
||||||
|
* (localStorage) : `"E2:HIGH;T:LOW"`. Chaîne vide = rien de notifié.
|
||||||
|
* (Côté Android, l'encodage est stocké en DataStore entre les vérifications
|
||||||
|
* du worker ; côté web, entre les cycles de la boucle de vérification.)
|
||||||
|
*
|
||||||
|
* @param {{marker:string, level:string}[]} alerts
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function encodeState(alerts) {
|
||||||
|
return alerts.map((a) => `${a.marker}:${a.level}`).join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décodage de l'état persisté (format encodeState) → map marqueur → niveau.
|
||||||
|
* Résistant aux entrées malformées (segments sans « : », niveaux inconnus).
|
||||||
|
*
|
||||||
|
* @param {string|null} encoded
|
||||||
|
* @returns {Object<string,string>} ex. { E2: 'HIGH', T: 'LOW' }
|
||||||
|
*/
|
||||||
|
export function parseState(encoded) {
|
||||||
|
const out = {};
|
||||||
|
if (!encoded) return out;
|
||||||
|
for (const part of String(encoded).split(';')) {
|
||||||
|
if (!part.includes(':')) continue;
|
||||||
|
const idx = part.indexOf(':');
|
||||||
|
const marker = part.slice(0, idx);
|
||||||
|
const level = part.slice(idx + 1);
|
||||||
|
if ((level === Level.HIGH || level === Level.LOW) && marker) out[marker] = level;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décide si une NOTIFICATION doit être envoyée pour cette vérification
|
||||||
|
* (anti-spam de la boucle périodique — 15 min côté Android, 5 min côté web) :
|
||||||
|
*
|
||||||
|
* - current vide → **false** (pas de notif de « retour à la normale » ;
|
||||||
|
* l'appelant efface l'état persisté pour permettre la re-notification
|
||||||
|
* au PROCHAIN franchissement) ;
|
||||||
|
* - current identique au dernier état notifié → **false** (l'écart
|
||||||
|
* continue, pas de re-notif à chaque cycle) ;
|
||||||
|
* - nouveau franchissement OU changement de niveau (H↔L) → **true**.
|
||||||
|
*
|
||||||
|
* @param {Object<string,string>} current État évalué à CETTE vérification
|
||||||
|
* @param {Object<string,string>|null} lastNotified État persisté de la dernière notification
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function shouldNotify(current, lastNotified) {
|
||||||
|
if (Object.keys(current).length === 0) return false;
|
||||||
|
return JSON.stringify(current) !== JSON.stringify(lastNotified || {});
|
||||||
|
}
|
||||||
301
js/pk/chart-helpers.js
Normal file
301
js/pk/chart-helpers.js
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Helpers du graphique (portage des fonctions pures de
|
||||||
|
* `ui/components/CurveChart.kt`) — TOUTES testables en Node
|
||||||
|
* (web/tests/chart-helpers.test.js).
|
||||||
|
*
|
||||||
|
* ⚠️ Chaque fonction porte l'historique du bug qu'elle corrige :
|
||||||
|
* - panDeltaHours : fix #62 (pan mort sur 24 h — cumul fractionnaire) ;
|
||||||
|
* - pointHoursBefore : fix #53 (delta « 6 h » qui comparait 24 h) ;
|
||||||
|
* - clampPanHours : fix #54 (pan futur figé — clamp unique) ;
|
||||||
|
* - forecastExtensionHrs : fix #54 (chip prévision sans effet visible) ;
|
||||||
|
* - xLabelTicks : fix #55 (labels à minuit UTC = 02:00 FR).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrondit une valeur max à une borne « propre » (1/2/2.5/5/10 × 10ⁿ) pour
|
||||||
|
* l'axe Y.
|
||||||
|
* @param {number} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function niceCeil(value) {
|
||||||
|
if (value <= 0) return 100.0;
|
||||||
|
const exp = Math.pow(10.0, Math.floor(Math.log10(value)));
|
||||||
|
const f = value / exp;
|
||||||
|
let nice;
|
||||||
|
if (f <= 1) nice = 1.0;
|
||||||
|
else if (f <= 2) nice = 2.0;
|
||||||
|
else if (f <= 2.5) nice = 2.5;
|
||||||
|
else if (f <= 5) nice = 5.0;
|
||||||
|
else nice = 10.0;
|
||||||
|
return nice * exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pas d'échantillonnage de la courbe selon la plage affichée (v1.2.9) :
|
||||||
|
* plus on zoome, plus le pas finit pour des courbes lisses
|
||||||
|
* (≤ 12 h → 15 min ; ≤ 24 h → 30 min ; sinon 1 h).
|
||||||
|
*
|
||||||
|
* @param {number} rangeHours
|
||||||
|
* @returns {number} ms
|
||||||
|
*/
|
||||||
|
export function stepForRange(rangeHours) {
|
||||||
|
if (rangeHours <= 12) return 900000; // 15 min
|
||||||
|
if (rangeHours <= 24) return 1800000; // 30 min
|
||||||
|
return 3600000; // 1 h
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles de MODÈLES PK par défaut (v1.4.7, fix #58) : au chargement, seuls
|
||||||
|
* les modèles RÉELLEMENT utilisés par des traitements à PROFIL PK (injection
|
||||||
|
* + ester — `usesProfileModel`) sont affichés ; les autres restent
|
||||||
|
* activables au tap. ⚠️ FILTRER par usesProfileModel : un traitement
|
||||||
|
* oral/anti-androgène (pkModel stocké = ESE par défaut, sans courbe profil)
|
||||||
|
* activerait Estrannaise à tort.
|
||||||
|
*
|
||||||
|
* GARDE-FOU : si AUCUN modèle profil n'est utilisé (traitements gel/oral
|
||||||
|
* uniquement), TFS est forcé à ON — les traitements Bateman sont tracés
|
||||||
|
* IDENTIQUEMENT dans chaque série : sans garde-fou, le graphique serait vide.
|
||||||
|
*
|
||||||
|
* @param {Set<string>} pkModelsUsed Ensemble des pkModel des traitements à profil
|
||||||
|
* @returns {Object<string,boolean>} { ESE, TFS, WHS }
|
||||||
|
*/
|
||||||
|
export function defaultModelToggles(pkModelsUsed) {
|
||||||
|
const toggles = {
|
||||||
|
ESE: pkModelsUsed.has('ESE'),
|
||||||
|
TFS: pkModelsUsed.has('TFS'),
|
||||||
|
WHS: pkModelsUsed.has('WHS'),
|
||||||
|
};
|
||||||
|
if (!toggles.ESE && !toggles.TFS && !toggles.WHS) toggles.TFS = true;
|
||||||
|
return toggles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un delta de drag en heures de pan, en CUMULANT le résiduel
|
||||||
|
* fractionnaire (v1.4.10, fix #62).
|
||||||
|
*
|
||||||
|
* ⚠️ BUG #62 : l'ancien code tronquait CHAQUE delta incrémental en heures
|
||||||
|
* entières — à la plage 24 h, le delta d'un doigt réel (~30 px ≈ 0,67 h)
|
||||||
|
* était tronqué à 0 → le pan ne bougeait JAMAIS sur la vue 24 h (alors
|
||||||
|
* qu'il marchait sur 7 j+ : le même delta ≈ 4,7 h). Le fix : le résiduel
|
||||||
|
* fractionnaire (la part < 1 h) est conservé d'un événement de mouvement au
|
||||||
|
* suivant — le pan se déclenche dès que le CUMUL franchit une heure entière.
|
||||||
|
*
|
||||||
|
* @param {number} panPx Delta horizontal du geste (px, signé)
|
||||||
|
* @param {number} chartWidthPx Largeur du canvas en px
|
||||||
|
* @param {number} rangeHours Plage temporelle courante (24 h / 7 j / 30 j…)
|
||||||
|
* @param {number} residualHours Résiduel fractionnaire précédent (à ré-injecter)
|
||||||
|
* @returns {{hours:number, residual:number}} heures entières à appliquer + nouveau résiduel
|
||||||
|
*/
|
||||||
|
export function panDeltaHours(panPx, chartWidthPx, rangeHours, residualHours) {
|
||||||
|
if (chartWidthPx <= 0 || rangeHours <= 0) return { hours: 0, residual: residualHours };
|
||||||
|
const total = (panPx / chartWidthPx) * rangeHours + residualHours;
|
||||||
|
let whole = Math.trunc(total); // troncature vers ZÉRO (gère les deux signes)
|
||||||
|
if (whole === 0) whole = 0; // normalise -0 (troncature de petits négatifs)
|
||||||
|
return { hours: whole, residual: total - whole };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bornes du panoramique (v1.4.1) : la fenêtre peut reculer dans le PASSÉ
|
||||||
|
* jusqu'à [0 ; maxPastHours] et — quand la prévision est active — AVANCER
|
||||||
|
* dans le FUTUR jusqu'à [-futureHorizonHours ; …] (panHours négatif =
|
||||||
|
* fenêtre décalée vers le futur). Unique point de clamp partagé par le drag
|
||||||
|
* et le zoom pour éviter les divergences.
|
||||||
|
*
|
||||||
|
* @param {number} panHours
|
||||||
|
* @param {number} futureHorizonHours
|
||||||
|
* @param {number} maxPastHours
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function clampPanHours(panHours, futureHorizonHours, maxPastHours) {
|
||||||
|
const lo = -Math.max(futureHorizonHours, 0);
|
||||||
|
const hi = Math.max(maxPastHours, 0);
|
||||||
|
if (panHours <= lo) return lo === 0 ? 0 : lo; // normalise -0
|
||||||
|
if (panHours >= hi) return hi;
|
||||||
|
return panHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizon de PRÉVISION en heures (v1.4.1) : quantité de doses projetées
|
||||||
|
* générées au-delà de « maintenant » quand le chip Prévision est actif.
|
||||||
|
* 12 × la plus grande Posologie, borné [30 j ; 365 j] — « scroller à
|
||||||
|
* l'infini » s'arrête à 1 an (compromis calcul maîtrisé).
|
||||||
|
*
|
||||||
|
* @param {number|null} maxIntervalDays Plus grande Posologie configurée (jours)
|
||||||
|
* @returns {number|null} null si aucun traitement n'a de Posologie (chip désactivé)
|
||||||
|
*/
|
||||||
|
export function forecastHorizonHours(maxIntervalDays) {
|
||||||
|
if (maxIntervalDays === null || maxIntervalDays === undefined || maxIntervalDays <= 0.0) return null;
|
||||||
|
return Math.min(Math.max(Math.trunc(maxIntervalDays * 24.0 * 12.0), 24 * 30), 24 * 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXTENSION de fenêtre à droite quand le chip Prévision est activé
|
||||||
|
* (v1.4.4, fix #54) : le toggle doit avoir un effet IMMÉDIAT et visible.
|
||||||
|
* (Historique : `endMs = now` faisait que generateForecastDoses ne générait
|
||||||
|
* AUCUNE dose — le chip semblait inerte.) Solution : étendre la fenêtre à
|
||||||
|
* droite SANS déplacer son début (l'historique visible reste à sa place)
|
||||||
|
* jusqu'à couvrir au moins la prochaine dose projetée, sinon d'un tiers de
|
||||||
|
* la plage actuelle.
|
||||||
|
*
|
||||||
|
* @param {number} rangeHours Plage courante (24 h / 7 j / 30 j…)
|
||||||
|
* @param {number} horizonHours Horizon de prévision (≤ 0 = pas de prévision)
|
||||||
|
* @param {number|null} firstSlotGapHours Écart « maintenant → 1ᵉʳ créneau projeté »
|
||||||
|
* @returns {number} heures à ajouter à endMs (0 = rien)
|
||||||
|
*/
|
||||||
|
export function forecastExtensionHours(rangeHours, horizonHours, firstSlotGapHours) {
|
||||||
|
if (horizonHours === null || horizonHours === undefined || horizonHours <= 0) return 0;
|
||||||
|
// Montrer AU MOINS la prochaine dose (+12 h de contexte), sinon 1/3 de
|
||||||
|
// la plage actuelle — jamais au-delà de l'horizon.
|
||||||
|
const toFirstSlot = (firstSlotGapHours === null || firstSlotGapHours === undefined ? 0 : firstSlotGapHours) + 12;
|
||||||
|
const ext = Math.max(Math.trunc(rangeHours / 3), toFirstSlot);
|
||||||
|
return Math.min(Math.max(ext, 0), horizonHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point le plus PROCHE de `hours` heures AVANT le dernier point de la série
|
||||||
|
* (v1.4.3, fix #53) : utilisé par le delta « vs il y a 6 h » de la carte
|
||||||
|
* d'accueil.
|
||||||
|
*
|
||||||
|
* ⚠️ BUG corrigé ici : l'ancien code faisait `curve.find(p => écart >= 6 h)`
|
||||||
|
* sur une fenêtre de 24 h → le PREMIER point de la liste (il y a ~24 h !)
|
||||||
|
* satisfaisait la condition immédiatement, et le delta affiché « vs 6 h »
|
||||||
|
* comparait en réalité à il y a 24 h (trompeur le lendemain d'une injection
|
||||||
|
* EEn — cf régression #4). Cette fonction prend le point dont l'écart au
|
||||||
|
* dernier est le plus PETIT au-dessus de `hours` (par excès — avec une
|
||||||
|
* grille régulière, c'est le point à ~6 h exactement).
|
||||||
|
*
|
||||||
|
* @param {import('./pk-engine.js').LevelPoint[]} points
|
||||||
|
* @param {number} hours
|
||||||
|
* @returns {object|null} null si aucun point n'est au-delà de `hours`
|
||||||
|
*/
|
||||||
|
export function pointHoursBefore(points, hours) {
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (!last) return null;
|
||||||
|
let best = null;
|
||||||
|
let bestGap = Number.MAX_VALUE;
|
||||||
|
for (const p of points) {
|
||||||
|
const gap = (last.timestamp - p.timestamp) / 3600000.0;
|
||||||
|
if (gap < hours) continue; // seulement AVANT le point de référence
|
||||||
|
if (gap < bestGap) {
|
||||||
|
bestGap = gap;
|
||||||
|
best = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fuseaux horaires (Intl) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parties calendaires d'un instant DANS un fuseau donné (via Intl.DateTimeFormat).
|
||||||
|
* C'est l'équivalent JS de Calendar.getInstance(TimeZone) + get(...).
|
||||||
|
*
|
||||||
|
* @param {number} ms
|
||||||
|
* @param {string} timeZone ID IANA ("Europe/Paris"…)
|
||||||
|
* @returns {{year:number,month:number,day:number,hour:number,minute:number,second:number}}
|
||||||
|
*/
|
||||||
|
export function zonedParts(ms, timeZone) {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = {};
|
||||||
|
for (const p of fmt.formatToParts(new Date(ms))) parts[p.type] = p.value;
|
||||||
|
return {
|
||||||
|
year: Number(parts.year),
|
||||||
|
month: Number(parts.month),
|
||||||
|
day: Number(parts.day),
|
||||||
|
hour: Number(parts.hour),
|
||||||
|
minute: Number(parts.minute),
|
||||||
|
second: Number(parts.second),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruit un epoch ms depuis des parties calendaires interprétées DANS
|
||||||
|
* [timeZone] (algorithme de conversion inverse : UTC initial, correction par
|
||||||
|
* l'offset réel mesuré sur ce point).
|
||||||
|
*
|
||||||
|
* @param {{year:number,month:number,day:number,hour:number,minute:number,second:number}} p
|
||||||
|
* @param {string} timeZone
|
||||||
|
* @returns {number} epoch ms
|
||||||
|
*/
|
||||||
|
export function zonedTimeToMs(p, timeZone) {
|
||||||
|
// Conventions : offset(ms) = Date.UTC(parties de ms dans la zone) − ms.
|
||||||
|
// UTC = W − offset(UTC) (W = instant naïf interprété comme UTC) — deux
|
||||||
|
// passes couvrent les cas de bord DST.
|
||||||
|
const naive = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||||
|
const offsetAt = (ms) => {
|
||||||
|
const q = zonedParts(ms, timeZone);
|
||||||
|
return Date.UTC(q.year, q.month - 1, q.day, q.hour, q.minute, q.second) - ms;
|
||||||
|
};
|
||||||
|
let guess = naive - offsetAt(naive); // 1ʳᵉ approximation
|
||||||
|
guess = naive - offsetAt(guess); // correction DST (point fixe)
|
||||||
|
return guess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instants des LABELS X (v1.4.5, fix #55) : alignés sur les frontières
|
||||||
|
* LOCALES de [timeZone] (minuit local pour les pas en jours, heures rondes
|
||||||
|
* locales pour les pas horaires) et NON sur des multiples UTC — l'ancien
|
||||||
|
* alignement plaçait le label « 13/09 » à 02:00 du matin FR, ce qui décalait
|
||||||
|
* la lecture visuelle des événements (remontée : « la prévision simule mon
|
||||||
|
* injection le 13 au lieu du 12 »).
|
||||||
|
*
|
||||||
|
* @param {number} t0 Début de fenêtre (epoch ms)
|
||||||
|
* @param {number} t1 Fin de fenêtre (epoch ms)
|
||||||
|
* @param {number} stepHours Pas des labels (1/3/6/24/120…)
|
||||||
|
* @param {string} [timeZone] Fuseau de LECTURE (défaut = fuseau du système)
|
||||||
|
* @returns {number[]} instants de tick croissants dans [t0 ; t1]
|
||||||
|
*/
|
||||||
|
export function xLabelTicks(t0, t1, stepHours, timeZone) {
|
||||||
|
const zone = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
if (t1 <= t0 || stepHours <= 0) return [];
|
||||||
|
|
||||||
|
// Premier tick : première frontière locale STRICTEMENT ≥ t0
|
||||||
|
let first;
|
||||||
|
if (stepHours >= 24) {
|
||||||
|
// pas en JOURS : frontières = MINUIT LOCAL dans [zone]
|
||||||
|
const parts = zonedParts(t0, zone);
|
||||||
|
const midnightSameDay = zonedTimeToMs(
|
||||||
|
{ year: parts.year, month: parts.month, day: parts.day, hour: 0, minute: 0, second: 0 },
|
||||||
|
zone,
|
||||||
|
);
|
||||||
|
const days = Math.max(Math.trunc(stepHours / 24), 1);
|
||||||
|
first = midnightSameDay;
|
||||||
|
while (first <= t0) {
|
||||||
|
// avance de N jours CALENDAIRES dans le fuseau (DST-safe)
|
||||||
|
const p = zonedParts(first, zone);
|
||||||
|
first = zonedTimeToMs({ year: p.year, month: p.month, day: p.day + days, hour: 0, minute: 0, second: 0 }, zone);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// pas horaire : heures rondes LOCALES dans [zone]
|
||||||
|
const parts = zonedParts(t0, zone);
|
||||||
|
const topOfHour = zonedTimeToMs(
|
||||||
|
{ year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: 0, second: 0 },
|
||||||
|
zone,
|
||||||
|
);
|
||||||
|
first = topOfHour;
|
||||||
|
while (first <= t0) first += 3600000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepMs = stepHours * 3600000;
|
||||||
|
const out = [];
|
||||||
|
// Pour les pas ≥ 24 h, on régénère chaque tick par pas calendaire (DST-safe)
|
||||||
|
if (stepHours >= 24) {
|
||||||
|
const days = Math.max(Math.trunc(stepHours / 24), 1);
|
||||||
|
let t = first;
|
||||||
|
while (t <= t1) {
|
||||||
|
out.push(t);
|
||||||
|
const p = zonedParts(t, zone);
|
||||||
|
t = zonedTimeToMs({ year: p.year, month: p.month, day: p.day + days, hour: 0, minute: 0, second: 0 }, zone);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let t = first; t <= t1; t += stepMs) out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
17
js/pk/index.js
Normal file
17
js/pk/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Barrel du noyau PK : un point d'import pour les écrans et les tests.
|
||||||
|
* (Les modules restent importables individuellement — ce fichier ne fait
|
||||||
|
* que regrouper les exports.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './pk-engine.js';
|
||||||
|
export * from './pk-calibration.js';
|
||||||
|
export * from './pk-reminders.js';
|
||||||
|
export * from './pk-extrema.js';
|
||||||
|
export * from './chart-helpers.js';
|
||||||
|
export * as Alerts from './alerts.js';
|
||||||
|
export * as PKProfileStore from './pk-profile-store.js';
|
||||||
|
export * as TransfemScienceModels from './transfem-science-models.js';
|
||||||
|
export * as WhsahModels from './whsah-models.js';
|
||||||
|
export * as PKPresets from './presets.js';
|
||||||
|
export * as ChartHelpers from './chart-helpers.js';
|
||||||
306
js/pk/pk-calibration.js
Normal file
306
js/pk/pk-calibration.js
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Calibration (partie 2/4 du moteur) — portage de `PharmacokineticEngine.kt`
|
||||||
|
* sections « Calibration » (Android v1.2.1 → v1.4.9).
|
||||||
|
*
|
||||||
|
* - PAR PÉRIODE D'ESTER (v1.2.1) : labs valerate → doses valerate, labs
|
||||||
|
* enanthate → doses enanthate (un facteur unique mélangeait les périodes
|
||||||
|
* → courbes gonflées à 250–375 pg/mL) ;
|
||||||
|
* - PAR MODÈLE (v1.4.8, fix #60) : chaque courbe affichée (ESE/TFS/WHS) est
|
||||||
|
* calibrée avec la prédiction de SON modèle (`modelOverride` propagé) ;
|
||||||
|
* - GARDE DE SIGNIFICATIVITÉ (v1.4.9, fix #61) : un lab ne calibre que si
|
||||||
|
* la prédiction reste ≥ 15 % du max observé — sinon ratios aberrants et
|
||||||
|
* médiane tombant à ×2,21 (dose de test ancienne + labs tardifs).
|
||||||
|
*
|
||||||
|
* PUR, testable en Node (web/tests/calibration.test.js).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { e2At, convertTToNgMl, batemanParams, concentrationOfDose, cutoffHours, doseEster, Esters, TConfig } from './pk-engine.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de significativité (fix #61) : fraction minimale de la prédiction max
|
||||||
|
* observée pour qu'un lab participe à la calibration.
|
||||||
|
*/
|
||||||
|
export const LAB_MIN_PREDICTION_FRACTION = 0.15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un lab ne CALIBRE que si la prédiction du modèle à son instant reste
|
||||||
|
* ≥ [LAB_MIN_PREDICTION_FRACTION] du max de prédiction déjà observé —
|
||||||
|
* c'est-à-dire tant que l'injection « gouverne » encore le taux. Au-delà de
|
||||||
|
* la fenêtre d'action (prédiction résiduelle), le lab reflète AUTRE CHOSE
|
||||||
|
* (temps mort, début de traitement, autre ester) et son ratio lab ÷
|
||||||
|
* prédiction devient aberrant.
|
||||||
|
*
|
||||||
|
* Pourquoi une garde RELATIVE (et pas temporelle en jours) : la fenêtre
|
||||||
|
* d'action dépend du profil (l'injection gouverne ~2 j pour le benzoate,
|
||||||
|
* des mois pour l'undécylate) — une fenêtre fixe casserait autant les labs
|
||||||
|
* réels que les tests. La PRÉDICTION, elle, contient déjà toute la
|
||||||
|
* physiologie du modèle.
|
||||||
|
*
|
||||||
|
* @param {number} predicted Prédiction du modèle à l'instant du lab (pg/mL)
|
||||||
|
* @param {number} maxSoFar Max des prédictions déjà observées (labs par timestamp CROISSANT)
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function labIsSignificant(predicted, maxSoFar) {
|
||||||
|
return predicted > 0.5 && (maxSoFar <= 0.0 || predicted >= LAB_MIN_PREDICTION_FRACTION * maxSoFar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Médiane d'une liste numérique (triée ou non). */
|
||||||
|
function median(values) {
|
||||||
|
const sorted = values.slice().sort((a, b) => a - b);
|
||||||
|
const n = sorted.length;
|
||||||
|
if (n % 2 === 1) return sorted[(n - 1) / 2];
|
||||||
|
return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrondi « facteur » à 2 décimales, troncature (miroir du Kotlin (x*100|0)/100). */
|
||||||
|
function round2(x) {
|
||||||
|
return Math.trunc(x * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrondi « k T » à 3 décimales, troncature. */
|
||||||
|
function round3(x) {
|
||||||
|
return Math.trunc(x * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dernier élément (par timestamp) d'une liste non vide ; null si vide. */
|
||||||
|
function latest(list) {
|
||||||
|
let acc = null;
|
||||||
|
for (const d of list) {
|
||||||
|
if (acc === null || d.timestamp > acc.timestamp) acc = d;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calibration PAR PÉRIODE D'ESTER + PAR MODÈLE (fix #60).
|
||||||
|
*
|
||||||
|
* Principe : chaque lab est attribué à la période d'injection dans laquelle
|
||||||
|
* il tombe = la dernière dose E2 ≤ lab (une prise de sang reflète d'abord
|
||||||
|
* l'injection qui précède). Le ratio lab ÷ prédiction (toutes doses
|
||||||
|
* superposées, SANS calibration, avec le MODÈLE demandé) est rattaché à
|
||||||
|
* l'ester de cette dose. Facteur final par ester = MÉDIANE des ratios de sa
|
||||||
|
* période, arrondie à 2 décimales.
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {object[]} e2Labs Labs E2 (marker "E2", unité pg/mL)
|
||||||
|
* @param {string|null} [modelOverride=null] "ESE" | "TFS" | "WHS"
|
||||||
|
* @returns {Object<string,number>} facteur par ester (≥ 1 lab exploitable)
|
||||||
|
*/
|
||||||
|
export function computeEsterScaleFactors(treatments, doseLogs, e2Labs, modelOverride = null) {
|
||||||
|
const estrogenTreatments = treatments.filter((tr) => tr.type === 'ESTRADIOL');
|
||||||
|
const estrogenDoses = doseLogs.filter(
|
||||||
|
(d) => estrogenTreatments.some((tr) => tr.id === d.treatmentId),
|
||||||
|
);
|
||||||
|
if (estrogenDoses.length === 0) return {};
|
||||||
|
|
||||||
|
// Prédictions non calibrées (scaleFactor forcé à 1) pour chaque lab,
|
||||||
|
// avec le MODÈLE demandé (fix #60 : chaque courbe est calibrée avec SA
|
||||||
|
// propre prédiction).
|
||||||
|
const unscaled = estrogenTreatments.map((tr) => ({ ...tr, scaleFactor: 1.0 }));
|
||||||
|
const ratiosByEster = new Map();
|
||||||
|
let maxPredictionSoFar = 0.0;
|
||||||
|
// ⚠️ labs par timestamp CROISSANT + garde labIsSignificant (fix #61)
|
||||||
|
const sortedLabs = e2Labs.slice().sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
for (const lab of sortedLabs) {
|
||||||
|
const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null);
|
||||||
|
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue;
|
||||||
|
|
||||||
|
// Attribution : dernière dose E2 ≤ lab → son ester (override compris)
|
||||||
|
const attributedDose = latest(estrogenDoses.filter((d) => d.timestamp <= lab.timestamp));
|
||||||
|
if (!attributedDose) continue;
|
||||||
|
const attributedTreatment = estrogenTreatments.find((tr) => tr.id === attributedDose.treatmentId);
|
||||||
|
const ester = doseEster(attributedTreatment, attributedDose);
|
||||||
|
if (ester === Esters.NONE) continue;
|
||||||
|
|
||||||
|
if (!ratiosByEster.has(ester)) ratiosByEster.set(ester, []);
|
||||||
|
ratiosByEster.get(ester).push(lab.value / predicted);
|
||||||
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
for (const [ester, ratios] of ratiosByEster) out[ester] = round2(median(ratios));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calibration du k du modèle T PAR PÉRIODE D'ESTER (v1.2.3) + PAR MODÈLE
|
||||||
|
* (v1.4.8) : la suppression de la testostérone diffère selon l'ester
|
||||||
|
* (valerate = pics hauts et courts, enanthate = plateau plus doux) → chaque
|
||||||
|
* lab T est attribué à la période de la dernière dose E2 ≤ lab et
|
||||||
|
* `k = ((base−floor)/(T_lab − floor) − 1) / E2_est(t_lab)`, garde
|
||||||
|
* k ∈ (1e-4, 10), médiane par période, arrondie à 3 décimales.
|
||||||
|
*
|
||||||
|
* ⚠️ L'E2 utilisée est la version CALIBRÉE (scalePerEster) et suit le MODÈLE
|
||||||
|
* demandé — calibrer k contre une E2 brute/fausse faussait les k.
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {object[]} tLabs Labs T (marker "T", unités variées)
|
||||||
|
* @param {TConfig} current
|
||||||
|
* @param {Object<string,number>|null} [scalePerEster=null]
|
||||||
|
* @param {string|null} [modelOverride=null]
|
||||||
|
* @returns {Object<string,number>} k par ester
|
||||||
|
*/
|
||||||
|
export function computeTKPerEster(treatments, doseLogs, tLabs, current, scalePerEster = null, modelOverride = null) {
|
||||||
|
const estrogenIds = new Set(treatments.filter((tr) => tr.type === 'ESTRADIOL').map((tr) => tr.id));
|
||||||
|
const estrogenDoses = doseLogs.filter((d) => estrogenIds.has(d.treatmentId));
|
||||||
|
if (estrogenDoses.length === 0) return {};
|
||||||
|
|
||||||
|
const ksByEster = new Map();
|
||||||
|
let maxE2SoFar = 0.0;
|
||||||
|
// ⚠️ labs par timestamp CROISSANT + garde labIsSignificant (fix #61)
|
||||||
|
const sortedLabs = tLabs.slice().sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
for (const lab of sortedLabs) {
|
||||||
|
const tNgMl = convertTToNgMl(lab.value, lab.unit);
|
||||||
|
if (tNgMl <= current.floor + 0.02) continue;
|
||||||
|
const attributed = latest(estrogenDoses.filter((d) => d.timestamp <= lab.timestamp));
|
||||||
|
if (!attributed) continue;
|
||||||
|
const tr = treatments.find((x) => x.id === attributed.treatmentId);
|
||||||
|
const ester = doseEster(tr, attributed);
|
||||||
|
if (ester === Esters.NONE) continue;
|
||||||
|
|
||||||
|
// v1.4.8 : l'E2 de référence suit le MODÈLE demandé (fix #60)
|
||||||
|
const e2 = e2At(treatments, doseLogs, lab.timestamp, modelOverride, scalePerEster);
|
||||||
|
if (!labIsSignificant(e2, maxE2SoFar)) continue;
|
||||||
|
|
||||||
|
const k = ((current.base - current.floor) / (tNgMl - current.floor) - 1.0) / e2;
|
||||||
|
if (k > 1e-4 && k < 10.0) {
|
||||||
|
if (!ksByEster.has(ester)) ksByEster.set(ester, []);
|
||||||
|
ksByEster.get(ester).push(k);
|
||||||
|
}
|
||||||
|
if (e2 > maxE2SoFar) maxE2SoFar = e2;
|
||||||
|
}
|
||||||
|
const out = {};
|
||||||
|
for (const [ester, ks] of ksByEster) out[ester] = round3(median(ks));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat de l'auto-calibration (miroir de AutoCalibrated.kt) :
|
||||||
|
* traitements/réglages stockés INCHANGÉS — tout est renvoyé en copies pour
|
||||||
|
* l'affichage uniquement (rien n'est persisté par l'auto-calibration).
|
||||||
|
*/
|
||||||
|
export class AutoCalibrated {
|
||||||
|
/**
|
||||||
|
* @param {object[]} treatments (identiques à l'entrée)
|
||||||
|
* @param {TConfig} tConfig (identique à l'entrée)
|
||||||
|
* @param {Object<string,number>} esterScales facteur par ester recalculé
|
||||||
|
* @param {number} calibratedEsters nb d'esters avec ≥ 1 lab exploitable
|
||||||
|
* @param {boolean} tRecalibrated true si le modèle T a pu être recalibré
|
||||||
|
* @param {Object<string,number>} tKPerEster k T par ester recalculé
|
||||||
|
*/
|
||||||
|
constructor(treatments, tConfig, esterScales, calibratedEsters, tRecalibrated, tKPerEster) {
|
||||||
|
this.treatments = treatments;
|
||||||
|
this.tConfig = tConfig;
|
||||||
|
this.esterScales = esterScales;
|
||||||
|
this.calibratedEsters = calibratedEsters;
|
||||||
|
this.tRecalibrated = tRecalibrated;
|
||||||
|
this.tKPerEster = tKPerEster;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calibration automatique (option « Calibration automatique » des Paramètres,
|
||||||
|
* désactivée par défaut) :
|
||||||
|
* - facteurs d'échelle E2 par période d'ester ([computeEsterScaleFactors]) ;
|
||||||
|
* - k du modèle T par période d'ester ([computeTKPerEster]), calibré contre
|
||||||
|
* l'E2 déjà calibrée.
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {object[]} labs Tous les labs (filtrés E2/T ici)
|
||||||
|
* @param {TConfig} tConfig
|
||||||
|
* @param {string|null} [modelOverride=null] Modèle pour les PRÉDICTIONS
|
||||||
|
* (null = modèle stocké de chaque traitement = comportement Home ; le
|
||||||
|
* graphique appelle une fois PAR MODÈLE affiché — fix #60)
|
||||||
|
* @returns {AutoCalibrated}
|
||||||
|
*/
|
||||||
|
export function autoCalibrated(treatments, doseLogs, labs, tConfig, modelOverride = null) {
|
||||||
|
const e2Labs = labs.filter((l) => String(l.marker).toUpperCase() === 'E2');
|
||||||
|
const tLabs = labs.filter((l) => String(l.marker).toUpperCase() === 'T');
|
||||||
|
|
||||||
|
const esterScales = computeEsterScaleFactors(treatments, doseLogs, e2Labs, modelOverride);
|
||||||
|
const tKPerEster = computeTKPerEster(treatments, doseLogs, tLabs, tConfig, esterScales, modelOverride);
|
||||||
|
return new AutoCalibrated(
|
||||||
|
treatments,
|
||||||
|
tConfig,
|
||||||
|
esterScales,
|
||||||
|
Object.keys(esterScales).length,
|
||||||
|
Object.keys(tKPerEster).length > 0,
|
||||||
|
tKPerEster,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calibration MANUELLE (bouton « Calibrer avec les analyses » de l'éditeur) :
|
||||||
|
* facteur UNIQUE par traitement (médiane lab ÷ prédiction, modèle stocké),
|
||||||
|
* garde labIsSignificant (fix #61), arrondi 2 décimales.
|
||||||
|
*
|
||||||
|
* ⚠️ Limite documentée (Android §7.6) : en cas de changement d'ester dans un
|
||||||
|
* même traitement, la manuelle mélange les périodes — préférer l'auto-calibration.
|
||||||
|
*
|
||||||
|
* @param {object} treatment
|
||||||
|
* @param {object[]} allDoseLogs
|
||||||
|
* @param {object[]} e2Labs
|
||||||
|
* @returns {number|null} facteur, ou null (pas de lab exploitable)
|
||||||
|
*/
|
||||||
|
export function computeScaleFactor(treatment, allDoseLogs, e2Labs) {
|
||||||
|
if (treatment.type !== 'ESTRADIOL') return null;
|
||||||
|
const myDoses = allDoseLogs.filter((d) => d.treatmentId === treatment.id);
|
||||||
|
if (myDoses.length === 0) return null;
|
||||||
|
|
||||||
|
const p = batemanParams(treatment);
|
||||||
|
const ratios = [];
|
||||||
|
let maxPredictionSoFar = 0.0;
|
||||||
|
// ⚠️ labs par timestamp CROISSANT (fix #61)
|
||||||
|
const sortedLabs = e2Labs.slice().sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
for (const lab of sortedLabs) {
|
||||||
|
let predicted = 0.0;
|
||||||
|
for (const dose of myDoses) {
|
||||||
|
if (dose.timestamp > lab.timestamp) continue;
|
||||||
|
const dtH = (lab.timestamp - dose.timestamp) / 3600000.0;
|
||||||
|
if (dtH > cutoffHours(treatment)) continue;
|
||||||
|
predicted += concentrationOfDose(treatment, dose, lab.timestamp, p, null);
|
||||||
|
}
|
||||||
|
if (labIsSignificant(predicted, maxPredictionSoFar)) {
|
||||||
|
ratios.push(lab.value / predicted);
|
||||||
|
}
|
||||||
|
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
||||||
|
}
|
||||||
|
if (ratios.length === 0) return null;
|
||||||
|
return round2(median(ratios));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calibration T GLOBALE (k unique — bouton « Calibrer avec les analyses » des
|
||||||
|
* Paramètres) : labs T normalisés via convertTToNgMl, k = médiane, garde
|
||||||
|
* k ∈ (1e-4, 10). L'auto-calibration (k par ester) la remplace à l'affichage
|
||||||
|
* quand elle est active.
|
||||||
|
*
|
||||||
|
* @param {object[]} tLabs
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {TConfig} current
|
||||||
|
* @returns {TConfig|null} nouvelle config, ou null (aucun lab exploitable)
|
||||||
|
*/
|
||||||
|
export function computeTConfigCalibration(tLabs, treatments, doseLogs, current) {
|
||||||
|
// Filtrage pré-conversion (valeur brute) puis post-conversion (ng/mL) —
|
||||||
|
// un lab déjà sous le plancher est ignoré dans les deux cas.
|
||||||
|
const usable = tLabs
|
||||||
|
.filter((l) => l.value > current.floor + 0.02)
|
||||||
|
.map((l) => ({ ...l, value: convertTToNgMl(l.value, l.unit) }))
|
||||||
|
.filter((l) => l.value > current.floor + 0.02);
|
||||||
|
if (usable.length === 0) return null;
|
||||||
|
|
||||||
|
const ks = [];
|
||||||
|
for (const lab of usable) {
|
||||||
|
const e2 = e2At(treatments, doseLogs, lab.timestamp, null, null);
|
||||||
|
if (e2 <= 1.0) continue;
|
||||||
|
const k = ((current.base - current.floor) / (lab.value - current.floor) - 1.0) / e2;
|
||||||
|
if (k > 1e-4 && k < 10.0) ks.push(k);
|
||||||
|
}
|
||||||
|
if (ks.length === 0) return null;
|
||||||
|
return new TConfig(current.base, current.floor, round3(median(ks)));
|
||||||
|
}
|
||||||
474
js/pk/pk-engine.js
Normal file
474
js/pk/pk-engine.js
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* PharmacokineticEngine (partie 1/4 : noyau) — portage web de
|
||||||
|
* `pk/PharmacokineticEngine.kt`. Le reste est découpé en modules :
|
||||||
|
* - pk-calibration.js : auto/manuelle par ester/période/modèle (#60/#61) ;
|
||||||
|
* - pk-reminders.js : prochain déclenchement (grille Posologie, #52) ;
|
||||||
|
* - pk-extrema.js : détection pics/creux (triangles du graphique).
|
||||||
|
*
|
||||||
|
* Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la
|
||||||
|
* réponse normalisée (pg/mL par mg) ; les contributions se superposent.
|
||||||
|
* Trois modèles superposables :
|
||||||
|
* - **Estrannaise (ESE)** = tables horaires de l'asset (ODS) ;
|
||||||
|
* - **Transfem Science (TFS)** = forme close V3C de la méta-analyse (7 esters) ;
|
||||||
|
* - **WHSAH (WHS)** = fit « license-free » de Mona (6 esters, PEP non couvert).
|
||||||
|
* Gel/patch/oral → modèle de Bateman paramétrable.
|
||||||
|
*
|
||||||
|
* ⚠️ INVARIANTS à préserver (doc Android §14 — bugs à ne pas réintroduire) :
|
||||||
|
* - `computeKa` : bisection DANS LE BON SENS (#19) ;
|
||||||
|
* - tables ESE : modèle strict + extrapolation terminale ≥ 1 % du pic (#20/#21) ;
|
||||||
|
* - `convertTToNgMl` appliquée à la calibration ET au rendu (#23) ;
|
||||||
|
* - `generateForecastDoses` : créneaux passés SAUTÉS (#35).
|
||||||
|
*
|
||||||
|
* PUR : aucune dépendance au DOM ni au localStorage → testable en Node.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as TransfemScienceModels from './transfem-science-models.js';
|
||||||
|
import * as WhsahModels from './whsah-models.js';
|
||||||
|
import * as PKProfileStore from './pk-profile-store.js';
|
||||||
|
|
||||||
|
/** Une heure en ms. */
|
||||||
|
export const HOUR_MS = 3600000;
|
||||||
|
|
||||||
|
/** Un point de courbe : E2 en pg/mL, T en ng/mL. */
|
||||||
|
export class LevelPoint {
|
||||||
|
/** @param {number} timestamp Epoch ms · @param {number} e2 pg/mL · @param {number} t ng/mL */
|
||||||
|
constructor(timestamp, e2, t) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.e2 = e2;
|
||||||
|
this.t = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réglages du modèle T empirique (mêmes défauts que TConfig.kt). */
|
||||||
|
export class TConfig {
|
||||||
|
/** @param {number} [base=6.0] T de base (ng/mL) à E2 ≈ 0 · @param {number} [floor=0.2] plancher · @param {number} [k=0.19] constante d'inhibition */
|
||||||
|
constructor(base = 6.0, floor = 0.2, k = 0.19) {
|
||||||
|
this.base = base;
|
||||||
|
this.floor = floor;
|
||||||
|
this.k = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Constantes d'ester (miroir de `Esters.kt` — mêmes clés que les backups JSON). */
|
||||||
|
export const Esters = {
|
||||||
|
NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN',
|
||||||
|
EB: 'EB', EC: 'EC', ECS: 'ECS', PEP: 'PEP',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Constantes de modèles PK (miroir de `PKModels.kt`). */
|
||||||
|
export const PKModels = { ESTRANNAISE: 'ESE', TRANSFEM_SCIENCE: 'TFS', WHSAH: 'WHS' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propriété DÉRIVÉE (miroir du getter Kotlin `usesProfileModel`) : le
|
||||||
|
* traitement a un PROFIL PK s'il est injectable (IM/SC) ET avec un ester.
|
||||||
|
* En JS ce n'est pas une propriété stockée (objets JSON plats, mêmes champs
|
||||||
|
* que Gson) — le moteur la recalcule à la demande.
|
||||||
|
*
|
||||||
|
* @param {object} treatment
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function usesProfileModel(treatment) {
|
||||||
|
return (treatment.route === 'INJECTION_IM' || treatment.route === 'INJECTION_SUBCUT')
|
||||||
|
&& treatment.esterType !== Esters.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bateman (gel / patch / oral)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résout `ln(ka/ke) = (ka−ke)·Tmax` par bisection (50 itérations).
|
||||||
|
*
|
||||||
|
* ⚠️ BUG #19 (épinglé depuis la session 1) : eq(mid) DÉCROÎT en mid et
|
||||||
|
* s'annule en ka > ke → `eq > 0` signifie que la racine est AU-DESSUS de
|
||||||
|
* mid ⇒ `lo = mid` (l'ancienne bisection inversée convergeait vers un ka
|
||||||
|
* énorme, pic à ~0 h au lieu de Tmax).
|
||||||
|
*
|
||||||
|
* @param {number} tHalfHours Demi-vie d'élimination (h)
|
||||||
|
* @param {number} tMaxHours Temps au pic souhaité (h)
|
||||||
|
* @returns {number} ka (h⁻¹)
|
||||||
|
*/
|
||||||
|
export function computeKa(tHalfHours, tMaxHours) {
|
||||||
|
if (tMaxHours <= 0 || tHalfHours <= 0) return 1.0;
|
||||||
|
const ke = Math.log(2.0) / tHalfHours;
|
||||||
|
if (tMaxHours < 0.01) return ke * 100.0;
|
||||||
|
let lo = ke * 1.001;
|
||||||
|
let hi = ke * 1000.0;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const mid = (lo + hi) / 2.0;
|
||||||
|
const eq = Math.log(mid / ke) - (mid - ke) * tMaxHours;
|
||||||
|
if (eq > 0) lo = mid; else hi = mid; // direction corrigée (bug #19)
|
||||||
|
}
|
||||||
|
return (lo + hi) / 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paramètres (ke, ka) d'un traitement Bateman, bornes défensives incluses.
|
||||||
|
* @param {object} treatment Treatment (data/models.js)
|
||||||
|
* @returns {{ke:number, ka:number}}
|
||||||
|
*/
|
||||||
|
export function batemanParams(treatment) {
|
||||||
|
const ke = Math.log(2.0) / Math.max(treatment.eliminationHalfLifeHours, 0.01);
|
||||||
|
const ka = computeKa(
|
||||||
|
Math.max(treatment.eliminationHalfLifeHours, 0.01),
|
||||||
|
Math.max(treatment.absorptionHours, 0.01),
|
||||||
|
);
|
||||||
|
return { ke, ka };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Contribution d'une dose
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ester EFFECTIF d'une dose : override par injection (`dose.esterType`) ou
|
||||||
|
* ester par défaut du traitement — permet de switcher d'ester d'une
|
||||||
|
* injection à l'autre (comme dans le tableur d'origine).
|
||||||
|
*
|
||||||
|
* @param {object} treatment
|
||||||
|
* @param {object} dose DoseLog
|
||||||
|
* @returns {string} clé ester ("EV"…, "NONE" si aucun)
|
||||||
|
*/
|
||||||
|
export function doseEster(treatment, dose) {
|
||||||
|
return dose.esterType ?? treatment.esterType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contribution d'UNE dose au niveau d'E2 à queryTimeMs (pg/mL), tous modèles :
|
||||||
|
* 1. traitement à PROFIL PK (injection + ester ≠ NONE) :
|
||||||
|
* - TFS + ester couvert par le V3C → TransfemScienceModels ;
|
||||||
|
* - WHSAH + ester couvert par le fit Mona → WhsahModels ;
|
||||||
|
* - sinon → tables ODS via PKProfileStore (ester sans modèle → 0) ;
|
||||||
|
* 2. sinon → Bateman (gel / patch / oral / custom).
|
||||||
|
*
|
||||||
|
* `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les
|
||||||
|
* trois modèles côte à côte depuis le même traitement) ; Bateman n'est pas
|
||||||
|
* concerné (les séries y sont identiques).
|
||||||
|
*
|
||||||
|
* @param {object} treatment
|
||||||
|
* @param {object} dose
|
||||||
|
* @param {number} queryTimeMs
|
||||||
|
* @param {{ke:number,ka:number}|null} [bateman=null] Params précalculés (cache)
|
||||||
|
* @param {string|null} [modelOverride=null] "ESE" | "TFS" | "WHS"
|
||||||
|
* @returns {number} pg/mL
|
||||||
|
*/
|
||||||
|
export function concentrationOfDose(treatment, dose, queryTimeMs, bateman = null, modelOverride = null) {
|
||||||
|
const dtH = (queryTimeMs - dose.timestamp) / 3600000.0;
|
||||||
|
if (dtH <= 0.0) return 0.0;
|
||||||
|
|
||||||
|
const mg = dose.doseAmount;
|
||||||
|
if (mg <= 0.0) return 0.0;
|
||||||
|
|
||||||
|
if (usesProfileModel(treatment)) {
|
||||||
|
const ester = doseEster(treatment, dose);
|
||||||
|
if (ester !== Esters.NONE) {
|
||||||
|
const mod = modelOverride || treatment.pkModel;
|
||||||
|
// Dispatch parallèle des 3 modèles (aucun ne remplace un autre) :
|
||||||
|
// TFS (V3C méta-analyse) puis WHSAH (fit Mona), fallback tables ODS.
|
||||||
|
if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(ester)) {
|
||||||
|
return TransfemScienceModels.sample(ester, dtH) * mg;
|
||||||
|
}
|
||||||
|
if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) {
|
||||||
|
return WhsahModels.sample(ester, dtH) * mg;
|
||||||
|
}
|
||||||
|
return PKProfileStore.sample(ester, mod, dtH) * mg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback Bateman (gel / patch / oral / custom)
|
||||||
|
const p = bateman || batemanParams(treatment);
|
||||||
|
const diff = p.ka - p.ke;
|
||||||
|
const a = mg * treatment.bioavailabilityFraction;
|
||||||
|
// Cas dégénéré ka≈ke : limite analytique (sinon division par ~0)
|
||||||
|
const c = Math.abs(diff) < 1e-3
|
||||||
|
? a * p.ke * dtH * Math.exp(-p.ke * dtH)
|
||||||
|
: (a * p.ka / diff) * (Math.exp(-p.ke * dtH) - Math.exp(-p.ka * dtH));
|
||||||
|
return Math.max(0.0, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fenêtre de contribution d'un traitement en heures (au-delà : la dose est
|
||||||
|
* ignorée par les boucles d'agrégation — performance).
|
||||||
|
* - TFS/WHS : 10 demi-vies TERMINALES du fit (ex. EV 30 j, PEP 284 j) ;
|
||||||
|
* - tables ODS : longueur de table (8001 h) ;
|
||||||
|
* - Bateman : 30 × t½. Toujours ≥ 24 h.
|
||||||
|
*
|
||||||
|
* @param {object} treatment
|
||||||
|
* @returns {number} heures
|
||||||
|
*/
|
||||||
|
export function cutoffHours(treatment) {
|
||||||
|
let profileH = 0.0;
|
||||||
|
if (usesProfileModel(treatment)) {
|
||||||
|
const mod = treatment.pkModel;
|
||||||
|
if (mod === PKModels.WHSAH && WhsahModels.hasModel(treatment.esterType)) {
|
||||||
|
profileH = WhsahModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
|
||||||
|
} else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
|
||||||
|
profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
|
||||||
|
} else {
|
||||||
|
profileH = PKProfileStore.profileLength(treatment.esterType, mod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours;
|
||||||
|
const cut = Math.max(profileH, batemanH);
|
||||||
|
return cut >= 24.0 ? cut : 24.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Niveaux agrégés
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Niveau E2 total à tMs : somme des contributions de toutes les doses E2
|
||||||
|
* (traitements ESTRADIOL uniquement), chacune multipliée par SON facteur
|
||||||
|
* d'échelle — `scalePerEster[ester]` si présent (calibration par période
|
||||||
|
* d'ester, v1.2.1), sinon `scaleFactor` stocké du traitement.
|
||||||
|
*
|
||||||
|
* ⚠️ TOUS les traitements sont passés ici, actifs comme INACTIFS :
|
||||||
|
* « isActive » est un drapeau administratif (plus de nouvelles doses),
|
||||||
|
* JAMAIS un filtre de données (bug v1.2.4 — l'historique EV d'un traitement
|
||||||
|
* inactivé doit rester simulé, cf régression #3).
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {number} tMs Instant d'évaluation (epoch ms)
|
||||||
|
* @param {string|null} [modelOverride=null]
|
||||||
|
* @param {Object<string,number>|null} [scalePerEster=null]
|
||||||
|
* @returns {number} pg/mL
|
||||||
|
*/
|
||||||
|
export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) {
|
||||||
|
const batemanCache = new Map();
|
||||||
|
let total = 0.0;
|
||||||
|
for (const treatment of treatments) {
|
||||||
|
if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2
|
||||||
|
if (!batemanCache.has(treatment.id)) {
|
||||||
|
batemanCache.set(treatment.id, batemanParams(treatment));
|
||||||
|
}
|
||||||
|
for (const dose of doseLogs) {
|
||||||
|
if (dose.treatmentId !== treatment.id || dose.timestamp > tMs) continue;
|
||||||
|
const dtH = (tMs - dose.timestamp) / 3600000.0;
|
||||||
|
if (dtH > cutoffHours(treatment)) continue;
|
||||||
|
const c = concentrationOfDose(treatment, dose, tMs, batemanCache.get(treatment.id), modelOverride);
|
||||||
|
if (c > 0.0) {
|
||||||
|
const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor;
|
||||||
|
total += c * scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modèle T EMPIRIQUE (non publié — étiqueté « estimation » partout) :
|
||||||
|
* `T = floor + (base − floor) / (1 + k·E2)` en ng/mL.
|
||||||
|
*
|
||||||
|
* @param {number} e2Level E2 calibrée (pg/mL)
|
||||||
|
* @param {TConfig} config
|
||||||
|
* @returns {number} ng/mL
|
||||||
|
*/
|
||||||
|
export function testosteroneAt(e2Level, config) {
|
||||||
|
if (e2Level <= 0.0) return config.base;
|
||||||
|
return config.floor + (config.base - config.floor) / (1.0 + config.k * e2Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisation des labs T vers ng/mL — appliquée à la calibration ET au
|
||||||
|
* rendu du graphique (sinon l'axe T est faux d'un facteur 100, bug #23 :
|
||||||
|
* labs 33/44 ng/dL). Branche défensive pg/mL ÷ 1000 : un lab saisi avec une
|
||||||
|
* unité aberrante (« 38 pg/mL », bug #26) ne doit pas écraser l'axe T.
|
||||||
|
*
|
||||||
|
* @param {number} value Valeur du lab
|
||||||
|
* @param {string} unit Unité saisie (ng/mL, ng/dL, ng/L, nmol/L…)
|
||||||
|
* @returns {number} ng/mL
|
||||||
|
*/
|
||||||
|
export function convertTToNgMl(value, unit) {
|
||||||
|
const u = String(unit).toLowerCase().replace(/\s/g, '');
|
||||||
|
if (u.includes('dl')) return value / 100.0;
|
||||||
|
if (u.includes('nmol')) return value * 0.2884;
|
||||||
|
if (u.includes('pg')) return value / 1000.0;
|
||||||
|
if (u.includes('µg') || u.includes('μg')) return value / 1000.0;
|
||||||
|
if (u.includes('ng/l') || u.endsWith('/l')) return value / 1000.0;
|
||||||
|
return value; // ng/mL (ou unité inconnue : valeur brute)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'ester « actif » à l'instant t = celui de la DERNIÈRE dose E2 ≤ t
|
||||||
|
* (une prise de sang / un point de courbe reflète l'injection qui précède).
|
||||||
|
* Utilisé pour choisir le k de la courbe T (k par période d'ester, v1.2.3).
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {number} tMs
|
||||||
|
* @returns {string|null} clé ester, ou null (avant la 1ʳᵉ dose / ester NONE)
|
||||||
|
*/
|
||||||
|
export function activeEsterAt(treatments, doseLogs, tMs) {
|
||||||
|
const estrogenIds = new Set(treatments.filter((tr) => tr.type === 'ESTRADIOL').map((tr) => tr.id));
|
||||||
|
let last = null;
|
||||||
|
for (const d of doseLogs) {
|
||||||
|
if (d.timestamp <= tMs && estrogenIds.has(d.treatmentId)) {
|
||||||
|
if (last === null || d.timestamp > last.timestamp) last = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!last) return null;
|
||||||
|
const tr = treatments.find((t) => t.id === last.treatmentId);
|
||||||
|
const ester = doseEster(tr, last);
|
||||||
|
return ester !== Esters.NONE ? ester : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Niveau combiné (E2 + T) à un instant donné — la notification d'alerte et la
|
||||||
|
* carte « niveau actuel » de l'accueil partagent CE calcul (même calibration :
|
||||||
|
* `scalePerEster` + `tKPerEster`, cf §9.bis Android).
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {number} tMs
|
||||||
|
* @param {TConfig} tConfig
|
||||||
|
* @param {Object<string,number>|null} [tKPerEster=null] k T par ester (période active)
|
||||||
|
* @param {Object<string,number>|null} [scalePerEster=null]
|
||||||
|
* @returns {LevelPoint}
|
||||||
|
*/
|
||||||
|
export function levelAt(treatments, doseLogs, tMs, tConfig, tKPerEster = null, scalePerEster = null) {
|
||||||
|
const e2 = e2At(treatments, doseLogs, tMs, null, scalePerEster);
|
||||||
|
const active = activeEsterAt(treatments, doseLogs, tMs);
|
||||||
|
const k = (active && tKPerEster && tKPerEster[active]) ?? tConfig.k;
|
||||||
|
return new LevelPoint(tMs, e2, testosteroneAt(e2, new TConfig(tConfig.base, tConfig.floor, k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Niveau actuel (now par défaut) — enveloppe de levelAt. */
|
||||||
|
export function currentLevel(treatments, doseLogs, tConfig, tKPerEster = null, nowMs = Date.now(), scalePerEster = null) {
|
||||||
|
return levelAt(treatments, doseLogs, nowMs, tConfig, tKPerEster, scalePerEster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Courbe complète entre startMs et endMs (grille régulière stepMs, défaut 1 h).
|
||||||
|
* - départ clampé à la 1ʳᵉ dose (rien avant) ;
|
||||||
|
* - k de la T suivant l'ester ACTIF à chaque point (curseur sur les doses triées) ;
|
||||||
|
* - modèle via `modelOverride`, calibration via `scalePerEster` + `tKPerEster`.
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @param {number} startMs
|
||||||
|
* @param {number} endMs
|
||||||
|
* @param {number} [stepMs=HOUR_MS]
|
||||||
|
* @param {TConfig} tConfig
|
||||||
|
* @param {object} [opts={}] { modelOverride, scalePerEster, tKPerEster }
|
||||||
|
* @returns {LevelPoint[]}
|
||||||
|
*/
|
||||||
|
export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConfig, opts = {}) {
|
||||||
|
if (stepMs === undefined) stepMs = HOUR_MS;
|
||||||
|
const { modelOverride = null, scalePerEster = null, tKPerEster = null } = opts;
|
||||||
|
if (treatments.length === 0 || doseLogs.length === 0 || endMs <= startMs) return [];
|
||||||
|
|
||||||
|
const relevantTreatments = treatments.filter(
|
||||||
|
(tr) => tr.type === 'ESTRADIOL' && doseLogs.some((d) => d.treatmentId === tr.id),
|
||||||
|
);
|
||||||
|
if (relevantTreatments.length === 0) return [];
|
||||||
|
|
||||||
|
const earliestDose = doseLogs.reduce((m, d) => Math.min(m, d.timestamp), Infinity);
|
||||||
|
const searchStart = Math.max(startMs, earliestDose);
|
||||||
|
|
||||||
|
const batemanCache = new Map();
|
||||||
|
relevantTreatments.forEach((tr) => batemanCache.set(tr.id, batemanParams(tr)));
|
||||||
|
|
||||||
|
// Doses triées pour suivre l'« ester actif » le long de la grille (k de T)
|
||||||
|
const sortedEstrogenDoses = doseLogs
|
||||||
|
.filter((d) => relevantTreatments.some((tr) => tr.id === d.treatmentId))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
let doseCursor = 0;
|
||||||
|
let activeEster = null;
|
||||||
|
|
||||||
|
const points = [];
|
||||||
|
let t = searchStart;
|
||||||
|
while (t <= endMs) {
|
||||||
|
let e2 = 0.0;
|
||||||
|
// Avance le curseur : la dose à t définit l'ester actif
|
||||||
|
while (doseCursor < sortedEstrogenDoses.length && sortedEstrogenDoses[doseCursor].timestamp <= t) {
|
||||||
|
const d = sortedEstrogenDoses[doseCursor];
|
||||||
|
const tr = relevantTreatments.find((x) => x.id === d.treatmentId);
|
||||||
|
activeEster = doseEster(tr, d);
|
||||||
|
doseCursor++;
|
||||||
|
}
|
||||||
|
const k = (activeEster && tKPerEster && tKPerEster[activeEster]) ?? tConfig.k;
|
||||||
|
for (const treatment of relevantTreatments) {
|
||||||
|
const cutoff = cutoffHours(treatment);
|
||||||
|
const p = batemanCache.get(treatment.id);
|
||||||
|
for (const dose of doseLogs) {
|
||||||
|
if (dose.treatmentId !== treatment.id || dose.timestamp > t) continue;
|
||||||
|
const dtH = (t - dose.timestamp) / 3600000.0;
|
||||||
|
if (dtH > cutoff) continue;
|
||||||
|
const c = concentrationOfDose(treatment, dose, t, p, modelOverride);
|
||||||
|
if (c > 0.0) {
|
||||||
|
const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor;
|
||||||
|
e2 += c * scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.push(new LevelPoint(t, e2, testosteroneAt(e2, new TConfig(tConfig.base, tConfig.floor, k))));
|
||||||
|
t += stepMs;
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Prévision
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère les doses PRÉVISIONNELLES d'un traitement, à partir de sa Posologie
|
||||||
|
* (`forecastIntervalDays`, en jours) et de la dernière dose réellement
|
||||||
|
* enregistrée.
|
||||||
|
*
|
||||||
|
* Règles :
|
||||||
|
* - intervalle null ou ≤ 0 → aucune prévision ;
|
||||||
|
* - la première dose projetée suit EXACTEMENT l'intervalle après la dernière
|
||||||
|
* dose réelle (le rythme reste sous le contrôle de l'utilisatrice) ;
|
||||||
|
* - dose = standard du traitement, ester = override de la dernière injection ;
|
||||||
|
* - ⚠️ les créneaux déjà PASSÉS sont sautés au rythme configuré (#35, cas
|
||||||
|
* d'un OUBLI : simuler un créneau passé = faux pic dans l'historique) ;
|
||||||
|
* un RETARD décale naturellement toute la prévision (comportement voulu).
|
||||||
|
*
|
||||||
|
* Les doses retournées ne sont JAMAIS persistées : elles alimentent
|
||||||
|
* computeCurve et les marqueurs du graphique quand le chip est actif.
|
||||||
|
*
|
||||||
|
* @param {object} treatment
|
||||||
|
* @param {object[]} allDoseLogs
|
||||||
|
* @param {number} toMs Fin de la fenêtre de projection
|
||||||
|
* @param {number} [nowMs=Date.now()]
|
||||||
|
* @returns {object[]} DoseLog[] (objets purs, jamais en base)
|
||||||
|
*/
|
||||||
|
export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) {
|
||||||
|
const intervalDays = treatment.forecastIntervalDays;
|
||||||
|
if (intervalDays === null || intervalDays === undefined || intervalDays <= 0.0) return [];
|
||||||
|
const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS);
|
||||||
|
if (intervalMs <= 0) return [];
|
||||||
|
|
||||||
|
let last = null;
|
||||||
|
for (const d of allDoseLogs) {
|
||||||
|
if (d.treatmentId === treatment.id && d.timestamp <= nowMs) {
|
||||||
|
if (last === null || d.timestamp > last.timestamp) last = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!last) return [];
|
||||||
|
|
||||||
|
const forecast = [];
|
||||||
|
let t = last.timestamp + intervalMs;
|
||||||
|
// ⚠️ Oubli d'une injection : le premier créneau théorique tombe dans le
|
||||||
|
// PASSÉ → on avance au premier créneau STRICTEMENT FUTUR, au rythme
|
||||||
|
// configuré (bug #35).
|
||||||
|
while (t <= nowMs) t += intervalMs;
|
||||||
|
while (t <= toMs) {
|
||||||
|
forecast.push({
|
||||||
|
id: 0, // jamais persisté
|
||||||
|
treatmentId: treatment.id,
|
||||||
|
timestamp: t,
|
||||||
|
doseAmount: treatment.doseAmount,
|
||||||
|
notes: null,
|
||||||
|
esterType: doseEster(treatment, last),
|
||||||
|
});
|
||||||
|
t += intervalMs;
|
||||||
|
}
|
||||||
|
return forecast;
|
||||||
|
}
|
||||||
74
js/pk/pk-extrema.js
Normal file
74
js/pk/pk-extrema.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Détection des pics et creux (partie 4/4 du moteur) — portage de
|
||||||
|
* `PharmacokineticEngine.detectExtrema` (v1.2.3) : triangles ▲▼ aux extrema
|
||||||
|
* locaux de chaque courbe, avec valeurs estimées (v1.2.6).
|
||||||
|
*
|
||||||
|
* PUR, testable en Node (web/tests/extrema.test.js).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un extremum détecté : pic (isPeak = true) ou creux, avec sa valeur.
|
||||||
|
*/
|
||||||
|
export class Extremum {
|
||||||
|
/** @param {number} timestamp · @param {number} value · @param {boolean} isPeak */
|
||||||
|
constructor(timestamp, value, isPeak) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.value = value;
|
||||||
|
this.isPeak = isPeak;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte les maxima et minima LOCAUX d'une courbe (les pics après chaque
|
||||||
|
* injection et les creux juste avant la suivante, en régime d'équilibre).
|
||||||
|
*
|
||||||
|
* - les plateaux (valeurs égales consécutives) comptent pour UN seul extremum ;
|
||||||
|
* - les extrema consécutifs de même type sont FUSIONNÉS (le plus extrême gagne) ;
|
||||||
|
* - les oscillations d'amplitude < minSwingAbs sont ignorées (bruit
|
||||||
|
* d'arrondi) — utiliser ~2 pg/mL pour l'E2 et ~0,02 ng/mL pour la T.
|
||||||
|
*
|
||||||
|
* @param {import('./pk-engine.js').LevelPoint[]} points Points de courbe
|
||||||
|
* @param {(p:object)=>number} selector Ce qu'on analyse (`p => p.e2` ou `p => p.t`)
|
||||||
|
* @param {number} [minSwingAbs=2.0] Amplitude minimale d'une oscillation
|
||||||
|
* @returns {Extremum[]}
|
||||||
|
*/
|
||||||
|
export function detectExtrema(points, selector, minSwingAbs = 2.0) {
|
||||||
|
if (points.length < 3) return [];
|
||||||
|
const values = points.map(selector);
|
||||||
|
const abs = Math.abs;
|
||||||
|
|
||||||
|
// 1) extrema locaux stricts (un plateau = son premier point)
|
||||||
|
const raw = []; // [index, isPeak]
|
||||||
|
let i = 1;
|
||||||
|
while (i < values.length - 1) {
|
||||||
|
const v = values[i];
|
||||||
|
const prev = values[i - 1];
|
||||||
|
let j = i;
|
||||||
|
while (j < values.length - 1 && values[j + 1] === v) j++; // plateau
|
||||||
|
if (j >= values.length - 1) break;
|
||||||
|
const next = values[j + 1];
|
||||||
|
if (v > prev && v > next) {
|
||||||
|
raw.push([i, true]);
|
||||||
|
} else if (v < prev && v < next) {
|
||||||
|
raw.push([i, false]);
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) alternance pic/creux + filtre d'amplitude
|
||||||
|
const out = [];
|
||||||
|
for (const [idx, isPeak] of raw) {
|
||||||
|
const last = out[out.length - 1] || null;
|
||||||
|
if (last && last.isPeak === isPeak) {
|
||||||
|
// doublon de même type : garder le plus extrême
|
||||||
|
const better = isPeak ? values[idx] > last.value : values[idx] < last.value;
|
||||||
|
if (better) out[out.length - 1] = new Extremum(points[idx].timestamp, values[idx], isPeak);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (last && abs(last.value - values[idx]) < minSwingAbs) continue;
|
||||||
|
out.push(new Extremum(points[idx].timestamp, values[idx], isPeak));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
171
js/pk/pk-profile-store.js
Normal file
171
js/pk/pk-profile-store.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* PKProfileStore — chargeur de l'asset `pk_profiles.json` + échantillonnage
|
||||||
|
* (portage web de `pk/PKProfileStore.kt`).
|
||||||
|
*
|
||||||
|
* L'asset contient les tables horaires Estrannaise extraites du tableur
|
||||||
|
* `Estrogen.ods` : { "params": {D/k1/k2/k3…}, "profiles": { "EV_ese": [8001
|
||||||
|
* floats], … } } — profils NORMALISÉS (pg/mL par mg injecté) sur 8001 h.
|
||||||
|
*
|
||||||
|
* ⚠️ Les tables affichent 2 décimales → plancher d'affichage 0,01/0,00 en
|
||||||
|
* queue (bug « courbe plate à 0 » du début du projet, cf DEVELOPPEMENT.md
|
||||||
|
* Android §14 #20) : l'échantillonnage EXTRAPOLE au-delà de la table depuis
|
||||||
|
* le dernier point encore ≥ 1 % du pic (cf `sample`).
|
||||||
|
*
|
||||||
|
* Ennavigateur : `init(url)` (fetch de l'asset). En Node (tests) :
|
||||||
|
* `initWithJson(texte)` — point d'entrée PUR, identique au Kotlin.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Modèle strict : seule cette table de suffixes est acceptée (bug §14 #21 :
|
||||||
|
* tout modèle inconnu renvoyait silencieusement Estrannaise → validation stricte). */
|
||||||
|
const MODEL_SUFFIX = { TFS: 'tfs', ESE: 'ese' };
|
||||||
|
|
||||||
|
let profiles = null; // Map<string, Float64Array> — null tant que non initialisé
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise le store depuis un JSON (PUR — testable en Node).
|
||||||
|
* ⚠️ La racine du JSON contient `profiles` : lire la racine directement = le
|
||||||
|
* crash du tout premier build (bug §14 #4). Structure attendue :
|
||||||
|
* `{ "params": {...}, "profiles": { "<ESTER>_<suffix>": [floats] } }`.
|
||||||
|
*
|
||||||
|
* @param {string} json Contenu texte de pk_profiles.json
|
||||||
|
*/
|
||||||
|
export function initWithJson(json) {
|
||||||
|
const root = JSON.parse(json);
|
||||||
|
const obj = root.profiles; // ⚠️ pas la racine (bug §14 #4)
|
||||||
|
const parsed = new Map();
|
||||||
|
for (const [key, arr] of Object.entries(obj)) {
|
||||||
|
parsed.set(key, Float64Array.from(arr));
|
||||||
|
}
|
||||||
|
profiles = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise le store en navigateur (fetch de l'asset statique).
|
||||||
|
* À appeler UNE fois au démarrage de l'app, avant tout rendu.
|
||||||
|
*
|
||||||
|
* @param {string} [url='assets/pk_profiles.json'] URL de l'asset
|
||||||
|
*/
|
||||||
|
export async function init(url = 'assets/pk_profiles.json') {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`pk_profiles.json : HTTP ${resp.status}`);
|
||||||
|
initWithJson(await resp.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clé de profil pour un couple (ester, modèle) — ex. ("EV","ESE") → "EV_ese".
|
||||||
|
* (Le Kotlin construit la même clé ; le lookup réel est insensible à la casse.)
|
||||||
|
*
|
||||||
|
* @param {string} ester "EV" / "EU" / "EEN" …
|
||||||
|
* @param {string} mod "ESE" | "TFS" (les autres modèles n'ont pas de table)
|
||||||
|
* @returns {string} clé de table, ex. "EV_ese"
|
||||||
|
*/
|
||||||
|
export function profileKey(ester, mod) {
|
||||||
|
const suffix = mod === 'TFS' ? 'tfs' : 'ese';
|
||||||
|
return `${ester}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup INSENSIBLE À LA CASSE (bug #22 — LE bug « courbes vides » : l'asset
|
||||||
|
* contient "EEn_ese" (casse biologique de l'ODS) alors que la constante de
|
||||||
|
* l'app est Esters.EEN = "EEN" ; un lookup exact renvoyait null pour tout
|
||||||
|
* traitement EEn → courbe E2 plate à 0).
|
||||||
|
*
|
||||||
|
* @param {string} key Clé de table ("EEn_ese" …)
|
||||||
|
* @returns {Float64Array|null}
|
||||||
|
*/
|
||||||
|
function lookup(key) {
|
||||||
|
if (!profiles) return null;
|
||||||
|
return profiles.get(key)
|
||||||
|
// fallback insensible à la casse (première clé qui matche)
|
||||||
|
|| [...profiles.entries()].find(([k]) => k.toLowerCase() === key.toLowerCase())?.[1]
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester
|
||||||
|
* @param {string} mod "ESE" | "TFS"
|
||||||
|
* @returns {boolean} true si une table horaire existe pour ce couple
|
||||||
|
*/
|
||||||
|
export function hasProfile(ester, mod) {
|
||||||
|
return lookup(profileKey(ester, mod)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longueur de table (heures) — utilisée par le moteur comme `cutoffHours`
|
||||||
|
* pour les profils à tables (la contribution d'une dose est coupée au-delà).
|
||||||
|
*
|
||||||
|
* @param {string} ester Clé ester
|
||||||
|
* @param {string} mod "ESE" | "TFS"
|
||||||
|
* @returns {number} nombre de points (0 si pas de table)
|
||||||
|
*/
|
||||||
|
export function profileLength(ester, mod) {
|
||||||
|
return lookup(profileKey(ester, mod))?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse normalisée (pg/mL par mg injecté) à dtHours après une injection de
|
||||||
|
* 1 mg — modèle STRICT par tables :
|
||||||
|
* - modèle inconnu (ni TFS ni ESE) → 0 (jamais de fallback silencieux — bug §14 #21) ;
|
||||||
|
* - interpolation LINÉAIRE entre heures entières ;
|
||||||
|
* - extrapolation TERMINALE au-delà de la table depuis le dernier point
|
||||||
|
* ≥ 1 % du pic, avec la pente = décroissance moyenne des 48 h précédentes
|
||||||
|
* (jamais avant le pic — cf plancher 0,01/0,00 de l'ODS, bug §14 #20).
|
||||||
|
*
|
||||||
|
* ⚠️ Tout est calculé en double précision JS (équivalent Double Kotlin — le
|
||||||
|
* mélange Float/Double était une source d'erreurs de compilation côté Android).
|
||||||
|
*
|
||||||
|
* @param {string} ester Clé ester ("EV"…)
|
||||||
|
* @param {string} mod "ESE" | "TFS"
|
||||||
|
* @param {number} dtHours Heures depuis l'injection (≤ 0 → 0)
|
||||||
|
* @returns {number} pg/mL par mg
|
||||||
|
*/
|
||||||
|
export function sample(ester, mod, dtHours) {
|
||||||
|
if (!profiles || dtHours <= 0.0) return 0.0;
|
||||||
|
const suffix = MODEL_SUFFIX[mod]; // strict : TFS→tfs, ESE→ese, autre → undefined
|
||||||
|
if (!suffix) return 0.0;
|
||||||
|
const arr = lookup(`${ester}_${suffix}`);
|
||||||
|
if (!arr || arr.length < 2) return 0.0;
|
||||||
|
|
||||||
|
const lastIdx = arr.length - 1;
|
||||||
|
if (dtHours >= lastIdx) {
|
||||||
|
// ── Extrapolation terminale (miroir exact du Kotlin) ───────────────────
|
||||||
|
// Les tables ODS sont arrondies à 2 décimales et s'effondrent en un
|
||||||
|
// plancher 0,01/0,00 bien avant que la vraie valeur ne s'annule.
|
||||||
|
// Extrapoler depuis la FIN de table donnait 0 à vie (ou une constante
|
||||||
|
// plate) → on part du dernier point encore ≥ 1 % du pic, avec le TAUX
|
||||||
|
// logarithmique moyen des 48 h précédentes (jamais avant le pic) —
|
||||||
|
// extrapolation EXPONENTIELLE, donc jamais croissante.
|
||||||
|
let peakIdx = 0;
|
||||||
|
let peakV = 0;
|
||||||
|
for (let idx = 0; idx < arr.length; idx++) {
|
||||||
|
if (arr[idx] > peakV) {
|
||||||
|
peakV = arr[idx];
|
||||||
|
peakIdx = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (peakV <= 0) return 0.0;
|
||||||
|
let j = lastIdx;
|
||||||
|
while (j > 0 && arr[j] < peakV * 0.01) j--;
|
||||||
|
if (j <= 0) return 0.0;
|
||||||
|
const WINDOW = 48;
|
||||||
|
const j0 = Math.max(peakIdx, j - WINDOW);
|
||||||
|
let rate = 0.0;
|
||||||
|
if (j > j0) {
|
||||||
|
rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0);
|
||||||
|
}
|
||||||
|
return arr[j] * Math.exp(rate * (dtHours - j));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interpolation linéaire entre heures entières ────────────────────────
|
||||||
|
const i0 = Math.floor(dtHours);
|
||||||
|
const frac = dtHours - i0;
|
||||||
|
if (i0 >= lastIdx) return arr[lastIdx]; // garde défensive
|
||||||
|
return arr[i0] + (arr[i0 + 1] - arr[i0]) * frac;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} true si le store a été initialisé (asset chargé). */
|
||||||
|
export function isInitialized() {
|
||||||
|
return profiles !== null;
|
||||||
|
}
|
||||||
105
js/pk/pk-reminders.js
Normal file
105
js/pk/pk-reminders.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Rappels (partie 3/4 du moteur) — portage de `PharmacokineticEngine.kt`
|
||||||
|
* sections `nextReminderFireFor` / `nextReminderFireMs` (fix #52, v1.4.0).
|
||||||
|
*
|
||||||
|
* Le WEB ne peut pas programmer d'alarmes système : ces fonctions alimentent
|
||||||
|
* la boucle de vérification de l'app (`ui/reminders.js`, toutes les 30 s
|
||||||
|
* tant que la page est ouverte) et la carte « Prochaine dose » de l'accueil.
|
||||||
|
* Limite documentée : pas de notification quand l'onglet est fermé.
|
||||||
|
*
|
||||||
|
* ⚠️ FIX #52 (v1.4.0) : si le traitement a une Posologie
|
||||||
|
* (`forecastIntervalDays`) ET un historique de doses, le rappel suit la
|
||||||
|
* GRILLE des prises (dernière dose réelle + k × intervalle, créneaux passés
|
||||||
|
* sautés — même sémantique que generateForecastDoses) et sonne le jour du
|
||||||
|
* créneau à l'heure choisie : une injection tous les samedis à 18 h →
|
||||||
|
* rappel UNIQUEMENT le samedi à 18 h. Sinon (gel/oral, ou aucune dose pour
|
||||||
|
* cadrer la grille) → comportement QUOTIDIEN historique (prochaine
|
||||||
|
* occurrence HH:mm).
|
||||||
|
*
|
||||||
|
* PUR, testable en Node (web/tests/reminders.test.js).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HOUR_MS } from './pk-engine.js';
|
||||||
|
|
||||||
|
/** Une journée en ms. */
|
||||||
|
const DAY_MS = 86400000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met une date sur HH:mm:00.000 du MÊME jour calendaire (heure LOCALE du
|
||||||
|
* navigateur / du process Node — équivalent Calendar.set(HOUR_OF_DAY…)).
|
||||||
|
*
|
||||||
|
* @param {number} ms
|
||||||
|
* @param {number} hour
|
||||||
|
* @param {number} minute
|
||||||
|
* @returns {number} epoch ms
|
||||||
|
*/
|
||||||
|
function atTimeSameDay(ms, hour, minute) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
d.setHours(hour, minute, 0, 0);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prochain déclenchement de rappel pour UN traitement.
|
||||||
|
*
|
||||||
|
* @param {object} treatment (reminderEnabled, reminderHour/Minute, isActive,
|
||||||
|
* forecastIntervalDays)
|
||||||
|
* @param {object[]} doseLogs Toutes les doses (le traitement repère les siennes)
|
||||||
|
* @param {number} nowMs
|
||||||
|
* @returns {number|null} epoch ms du prochain déclenchement, null si désactivé
|
||||||
|
*/
|
||||||
|
export function nextReminderFireFor(treatment, doseLogs, nowMs) {
|
||||||
|
if (!treatment.reminderEnabled || !treatment.isActive) return null;
|
||||||
|
const h = treatment.reminderHour;
|
||||||
|
const m = treatment.reminderMinute;
|
||||||
|
if (h === null || h === undefined || m === null || m === undefined) return null;
|
||||||
|
|
||||||
|
const intervalDays = treatment.forecastIntervalDays;
|
||||||
|
const myDoses = doseLogs.filter(
|
||||||
|
(d) => d.treatmentId === treatment.id && d.timestamp <= nowMs,
|
||||||
|
);
|
||||||
|
if (intervalDays !== null && intervalDays !== undefined && intervalDays > 0.0 && myDoses.length > 0) {
|
||||||
|
const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS);
|
||||||
|
if (intervalMs > 0) {
|
||||||
|
// Dernière dose réelle = ancre de la grille
|
||||||
|
let slot = myDoses.reduce((acc, d) => (acc === null || d.timestamp > acc.timestamp ? d : acc), null).timestamp;
|
||||||
|
// Le premier créneau STRICTEMENT futur (un créneau passé = oubli :
|
||||||
|
// on avance au rythme configuré — cf generateForecastDoses, #35).
|
||||||
|
while (slot <= nowMs) slot += intervalMs;
|
||||||
|
// L'alarme sonne le jour du créneau, à l'heure de rappel choisie ;
|
||||||
|
// si cette heure est déjà passée le jour du créneau → créneau suivant.
|
||||||
|
let candidate = slot;
|
||||||
|
for (;;) {
|
||||||
|
const fireMs = atTimeSameDay(candidate, h, m);
|
||||||
|
if (fireMs > nowMs) return fireMs;
|
||||||
|
candidate += intervalMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback QUOTIDIEN (comportement historique) : prochaine occurrence HH:mm
|
||||||
|
let fire = atTimeSameDay(nowMs, h, m);
|
||||||
|
if (fire <= nowMs) fire += DAY_MS;
|
||||||
|
return fire;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prochain rappel parmi tous les traitements (min). [doseLogs] active la
|
||||||
|
* planification par Posologie (cf nextReminderFireFor) ; vide = fallback
|
||||||
|
* quotidien pur (compatibilité avec les appels historiques).
|
||||||
|
*
|
||||||
|
* @param {object[]} treatments
|
||||||
|
* @param {object[]} [doseLogs=[]]
|
||||||
|
* @param {number} [nowMs=Date.now()]
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function nextReminderFireMs(treatments, doseLogs = [], nowMs = Date.now()) {
|
||||||
|
let next = null;
|
||||||
|
for (const tr of treatments) {
|
||||||
|
const fire = nextReminderFireFor(tr, doseLogs, nowMs);
|
||||||
|
if (fire !== null && (next === null || fire < next)) next = fire;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
79
js/pk/presets.js
Normal file
79
js/pk/presets.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Presets de traitements (portage de `data/model/PKPresets.kt`) : pré-remplis
|
||||||
|
* dans l'éditeur. Les champs Bateman des presets à PROFIL PK reflètent les
|
||||||
|
* Tmax/t½ des modèles (affichés dans l'éditeur, non utilisés au calcul — le
|
||||||
|
* dispatch passe par les profils/tables).
|
||||||
|
*
|
||||||
|
* Les libellés passent par des clés i18n (presets.fr.js / presets.en.js via
|
||||||
|
* util/i18n.js) — mêmes textes que les strings Android pour la cohérence des
|
||||||
|
* deux apps.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Esters, PKModels } from './pk-engine.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un preset (miroir de PKPreset.kt).
|
||||||
|
*/
|
||||||
|
export class PKPreset {
|
||||||
|
/**
|
||||||
|
* @param {string} nameKey Clé i18n du libellé ("preset_ev_ese"…)
|
||||||
|
* @param {string} type TreatmentType
|
||||||
|
* @param {string} route AdministrationRoute
|
||||||
|
* @param {string} esterType Clé ester ("EV"…, "NONE" pour Bateman)
|
||||||
|
* @param {string} pkModel "ESE" | "TFS" | "WHS"
|
||||||
|
* @param {number} absorptionHours Tmax (h, Bateman)
|
||||||
|
* @param {number} eliminationHalfLifeHours t½ (h, Bateman)
|
||||||
|
* @param {number} bioavailabilityFraction F (0..1, Bateman)
|
||||||
|
* @param {string} defaultDoseUnit
|
||||||
|
* @param {number} defaultDoseAmount
|
||||||
|
*/
|
||||||
|
constructor(nameKey, type, route, esterType, pkModel, absorptionHours, eliminationHalfLifeHours, bioavailabilityFraction, defaultDoseUnit, defaultDoseAmount) {
|
||||||
|
this.nameKey = nameKey;
|
||||||
|
this.type = type;
|
||||||
|
this.route = route;
|
||||||
|
this.esterType = esterType;
|
||||||
|
this.pkModel = pkModel;
|
||||||
|
this.absorptionHours = absorptionHours;
|
||||||
|
this.eliminationHalfLifeHours = eliminationHalfLifeHours;
|
||||||
|
this.bioavailabilityFraction = bioavailabilityFraction;
|
||||||
|
this.defaultDoseUnit = defaultDoseUnit;
|
||||||
|
this.defaultDoseAmount = defaultDoseAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tous les presets, dans l'ordre de l'éditeur (identique au Kotlin). */
|
||||||
|
export const all = [
|
||||||
|
// --- Injections : modèles Estrannaise (tables ODS) ---
|
||||||
|
new PKPreset('preset_ev_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.ESTRANNAISE, 46, 100, 1, 'mg', 4.0),
|
||||||
|
new PKPreset('preset_eu_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.ESTRANNAISE, 55, 400, 1, 'mg', 10.0),
|
||||||
|
new PKPreset('preset_een_ese', 'ESTRADIOL', 'INJECTION_SUBCUT', Esters.EEN, PKModels.ESTRANNAISE, 152, 150, 1, 'mg', 10.0),
|
||||||
|
// --- Injections : modèles Transfem Science ---
|
||||||
|
new PKPreset('preset_ev_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.TRANSFEM_SCIENCE, 50, 72, 1, 'mg', 4.0),
|
||||||
|
new PKPreset('preset_eu_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.TRANSFEM_SCIENCE, 204, 528, 1, 'mg', 10.0),
|
||||||
|
new PKPreset('preset_een_tfs', 'ESTRADIOL', 'INJECTION_SUBCUT', Esters.EEN, PKModels.TRANSFEM_SCIENCE, 156, 110, 1, 'mg', 5.0),
|
||||||
|
// --- Nouveaux esters de la méta-analyse TFS (v1.4.0, modèle V3C) :
|
||||||
|
// EB / EC (huile) / EC suspension / PEP — PAS de tables Estrannaise
|
||||||
|
// pour ces clés : presets créés en modèle TFS uniquement.
|
||||||
|
new PKPreset('preset_eb_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EB, PKModels.TRANSFEM_SCIENCE, 16, 29, 1, 'mg', 1.0),
|
||||||
|
new PKPreset('preset_ec_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EC, PKModels.TRANSFEM_SCIENCE, 103, 161, 1, 'mg', 5.0),
|
||||||
|
new PKPreset('preset_ecs_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.ECS, PKModels.TRANSFEM_SCIENCE, 30, 122, 1, 'mg', 5.0),
|
||||||
|
new PKPreset('preset_pep_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.PEP, PKModels.TRANSFEM_SCIENCE, 432, 682, 1, 'mg', 32.5),
|
||||||
|
// --- Modèle WHSAH (v1.4.6, fit license-free de Mona) : 6 esters, PEP non
|
||||||
|
// couvert. Champs Bateman = t½ WHSAH (pas utilisés au calcul).
|
||||||
|
new PKPreset('preset_ev_whs', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.WHSAH, 41, 75, 1, 'mg', 4.0),
|
||||||
|
new PKPreset('preset_eu_whs', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.WHSAH, 80, 722, 1, 'mg', 10.0),
|
||||||
|
new PKPreset('preset_een_whs', 'ESTRADIOL', 'INJECTION_SUBCUT', Esters.EEN, PKModels.WHSAH, 120, 176, 1, 'mg', 5.0),
|
||||||
|
new PKPreset('preset_eb_whs', 'ESTRADIOL', 'INJECTION_IM', Esters.EB, PKModels.WHSAH, 13, 25, 1, 'mg', 1.0),
|
||||||
|
new PKPreset('preset_ec_whs', 'ESTRADIOL', 'INJECTION_IM', Esters.EC, PKModels.WHSAH, 91, 161, 1, 'mg', 5.0),
|
||||||
|
new PKPreset('preset_ecs_whs', 'ESTRADIOL', 'INJECTION_IM', Esters.ECS, PKModels.WHSAH, 60, 110, 1, 'mg', 5.0),
|
||||||
|
// --- Transdermique / oral (fallback Bateman) ---
|
||||||
|
new PKPreset('preset_e2_gel', 'ESTRADIOL', 'TRANSDERMAL_GEL', Esters.NONE, PKModels.ESTRANNAISE, 4, 24, 0.8, 'mg', 2.0),
|
||||||
|
new PKPreset('preset_e2_patch', 'ESTRADIOL', 'TRANSDERMAL_PATCH', Esters.NONE, PKModels.ESTRANNAISE, 8, 24, 0.9, 'mg/day', 0.1),
|
||||||
|
new PKPreset('preset_e2_oral', 'ESTRADIOL', 'ORAL', Esters.NONE, PKModels.ESTRANNAISE, 1.5, 16, 0.4, 'mg', 2.0),
|
||||||
|
// --- Anti-androgènes (Bateman) ---
|
||||||
|
new PKPreset('preset_cpa', 'ANTI_ANDROGEN', 'ORAL', Esters.NONE, PKModels.ESTRANNAISE, 2, 30, 0.8, 'mg', 10.0),
|
||||||
|
new PKPreset('preset_spiro', 'ANTI_ANDROGEN', 'ORAL', Esters.NONE, PKModels.ESTRANNAISE, 1.5, 8, 0.7, 'mg', 100.0),
|
||||||
|
new PKPreset('preset_bica', 'ANTI_ANDROGEN', 'ORAL', Esters.NONE, PKModels.ESTRANNAISE, 3, 168, 0.9, 'mg', 50.0),
|
||||||
|
];
|
||||||
137
js/pk/transfem-science-models.js
Normal file
137
js/pk/transfem-science-models.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Modèles pharmacocinétiques « Transfem Science » — méta-analyse des esters
|
||||||
|
* d'estradiol injectables (transfemscience.org/articles/injectable-e2-meta-analysis/).
|
||||||
|
*
|
||||||
|
* PORTAGE WEB du fichier Android `app/src/main/java/com/hormonetrack/pk/
|
||||||
|
* TransfemScienceModels.kt` (v1.4.0) — les paramètres sont copiés À
|
||||||
|
* L'IDENTIQUE : toute retouche doit être faite dans les DEUX codebases et
|
||||||
|
* épinglée par les tests (web/tests/tfs-models.test.js côté web,
|
||||||
|
* TransfemScienceModelsTest.kt côté Android).
|
||||||
|
*
|
||||||
|
* SOURCE DES PARAMÈTRES : les coefficients du modèle à 3 compartiments (V3C)
|
||||||
|
* publiés par le simulateur officiel de Transfem Science
|
||||||
|
* (https://transfemscience.org/misc/injectable-e2-simulator/ — valeurs =
|
||||||
|
* DONNÉES SCIENTIFIQUES issues des études agrégées, Tableaux 9–10 de
|
||||||
|
* l'article).
|
||||||
|
*
|
||||||
|
* Forme close exacte (t en JOURS, Cp en pg/mL pour la dose de fit 5 mg) :
|
||||||
|
*
|
||||||
|
* Cp(t) = D·k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||||
|
* + e^(−k3·t)/((k1−k3)(k2−k3))
|
||||||
|
* + e^(−k2·t)·(k3−k1)/((k1−k2)(k1−k3)(k2−k3)) ]
|
||||||
|
*
|
||||||
|
* La courbe est NORMALISÉE PAR MG : `sample()` retourne Cp(t) / fitDose —
|
||||||
|
* le moteur PK multiplie déjà par la dose réelle (dose_mg), exactement comme
|
||||||
|
* avec les tables Estrannaise (ODS).
|
||||||
|
*
|
||||||
|
* Sanity checks épinglés (web/tests/tfs-models.test.js, fidélité à l'article
|
||||||
|
* reproduite à ~1–2 %) :
|
||||||
|
* EV : Cmax 295 pg/mL @ 2,1 j, t½ 3,0 j | EEn : 160 @ 6,5 j, t½ 4,6 j
|
||||||
|
* EB : 971 @ 0,65 j, t½ 1,2 j | EC (huile) : 155 @ 4,3 j, t½ 6,7 j
|
||||||
|
* EC susp. : 241 @ 1,2 j, t½ 5,1 j | PEP : ~34 pg/mL @ 32,5 mg @ ~18 j, t½ 28,4 j
|
||||||
|
*
|
||||||
|
* Remarques de l'article conservées : l'undécylate (EU) a des données d'étude
|
||||||
|
* limitées (ajustement moins fiable) ; PEP a une biodisponibilité réduite
|
||||||
|
* (~15–20 % — d'où des doses ~6,5× plus élevées).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Modèle à 3 compartiments (V3C) d'un ester, tel que publié par TFS. */
|
||||||
|
export class V3C {
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester de l'app ("EV", "EB", …)
|
||||||
|
* @param {number} D Constante d'amplitude (pg/mL pour la dose de fit, unités TFS)
|
||||||
|
* @param {number} k1 Constante de vitesse j⁻¹ (absorption dépôt)
|
||||||
|
* @param {number} k2 Constante de vitesse j⁻¹ (phase rapide)
|
||||||
|
* @param {number} k3 Constante de vitesse j⁻¹ (phase lente / terminale)
|
||||||
|
* @param {number} [fitDoseMg=5] Dose à laquelle les paramètres ont été ajustés (5 mg dans l'article)
|
||||||
|
*/
|
||||||
|
constructor(ester, D, k1, k2, k3, fitDoseMg = 5.0) {
|
||||||
|
this.ester = ester;
|
||||||
|
this.D = D;
|
||||||
|
this.k1 = k1;
|
||||||
|
this.k2 = k2;
|
||||||
|
this.k3 = k3;
|
||||||
|
this.fitDoseMg = fitDoseMg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
|
||||||
|
get terminalHalfLifeDays() {
|
||||||
|
return Math.log(2.0) / Math.min(this.k1, this.k2, this.k3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table des modèles V3C par clé ester (identique au Kotlin — NE PAS éditer
|
||||||
|
* les valeurs sans re-épingler les tests de fidélité à l'article).
|
||||||
|
*/
|
||||||
|
const MODELS = new Map([
|
||||||
|
// estradiol valerate (solution huileuse)
|
||||||
|
['EV', new V3C('EV', 2596.05956, 2.38229125, 0.23345814, 1.37642769)],
|
||||||
|
// estradiol enanthate (solution huileuse)
|
||||||
|
['EEN', new V3C('EEN', 333.874181, 0.42412968, 0.43452980, 0.15291485)],
|
||||||
|
// estradiol undecylate — ⚠️ données d'étude limitées (cf article, §Limites)
|
||||||
|
['EU', new V3C('EU', 65.9493374, 0.29634323, 4799337.57, 0.03141554)],
|
||||||
|
// estradiol benzoate (solution huileuse)
|
||||||
|
['EB', new V3C('EB', 1.7050e+08, 3.22397192, 0.58870148, 70721.4018)],
|
||||||
|
// estradiol cypionate — SOLUTION HUILEUSE
|
||||||
|
['EC', new V3C('EC', 1920.89671, 0.10321089, 0.89854779, 0.89359759)],
|
||||||
|
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux) ; clé dédiée
|
||||||
|
['ECS', new V3C('ECS', 1.5669e+08, 0.13586726, 2.51772731, 74768.1493)],
|
||||||
|
// polyestradiol phosphate — biodispo réduite, doses ~6,5× (cf article)
|
||||||
|
['PEP', new V3C('PEP', 34.46836875, 0.02456035, 135643.711, 0.10582368)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester ("EV", "EEN", …) — lookup insensible à la casse
|
||||||
|
* @returns {boolean} true si un modèle V3C existe pour cet ester
|
||||||
|
*/
|
||||||
|
export function hasModel(ester) {
|
||||||
|
return MODELS.has(String(ester).toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester (lookup insensible à la casse)
|
||||||
|
* @returns {V3C|null} le modèle V3C de l'ester, ou null
|
||||||
|
*/
|
||||||
|
export function model(ester) {
|
||||||
|
return MODELS.get(String(ester).toUpperCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||||
|
* modèle V3C de la méta-analyse TFS.
|
||||||
|
*
|
||||||
|
* @param {string} ester Clé ester ("EV", "EB", …)
|
||||||
|
* @param {number} dtHours Heures écoulées depuis l'injection (≤ 0 → 0 : rien avant la dose)
|
||||||
|
* @returns {number} pg/mL par mg
|
||||||
|
*/
|
||||||
|
export function sample(ester, dtHours) {
|
||||||
|
if (dtHours <= 0.0) return 0.0;
|
||||||
|
const m = model(ester);
|
||||||
|
if (!m) return 0.0;
|
||||||
|
const t = dtHours / 24.0; // la formule TFS travaille en jours
|
||||||
|
return rawConcentrationDays(m, t) / m.fitDoseMg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cp(t) en pg/mL pour la dose de fit (formule V3C exacte du simulateur).
|
||||||
|
*
|
||||||
|
* ⚠️ Le compartiment k2 peut être « ultra-rapide » (EU, PEP : k2 ~10⁵–10⁶ j⁻¹) :
|
||||||
|
* son terme décroît instantanément et n'affecte que t≈0 — la double précision
|
||||||
|
* JS (Float64, comme Kotlin Double) est suffisante (vérifié par les tests de
|
||||||
|
* pics de l'article).
|
||||||
|
*
|
||||||
|
* @param {V3C} m Modèle de l'ester
|
||||||
|
* @param {number} tDays Temps en JOURS depuis l'injection
|
||||||
|
* @returns {number} pg/mL pour la dose de fit
|
||||||
|
*/
|
||||||
|
function rawConcentrationDays(m, tDays) {
|
||||||
|
const D = m.D, k1 = m.k1, k2 = m.k2, k3 = m.k3;
|
||||||
|
return D * k1 * k2 * (
|
||||||
|
Math.exp(-k1 * tDays) / ((k1 - k2) * (k1 - k3)) +
|
||||||
|
Math.exp(-k3 * tDays) / ((k1 - k3) * (k2 - k3)) +
|
||||||
|
Math.exp(-k2 * tDays) * (k3 - k1) / ((k1 - k2) * (k1 - k3) * (k2 - k3))
|
||||||
|
);
|
||||||
|
}
|
||||||
127
js/pk/whsah-models.js
Normal file
127
js/pk/whsah-models.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Modèle PK « WHSAH » (portage web de `pk/WhsahModels.kt`, v1.4.6) — 3ᵉ modèle
|
||||||
|
* au niveau d'Estrannaise et Transfem Science, superposable sur le graphique.
|
||||||
|
*
|
||||||
|
* SOURCE : le fit « license-free » publié par le WHSAH Collective dans l'app
|
||||||
|
* open-source Mona (projet Flutter — lib/data/model/graph_calculator.dart,
|
||||||
|
* commit « new license-free pk params » ; leurs auteurs déclarent explicitement
|
||||||
|
* les coefficients libres d'usage, « Inferred with love exclusively for Mona »).
|
||||||
|
* Crédit : alix / WHSAH Collective via Mona.
|
||||||
|
*
|
||||||
|
* POURQUOI CE MODÈLE EXISTE : Mona a d'abord utilisé les paramètres Transfem
|
||||||
|
* Science (identiques aux nôtres) PUIS les a remplacés par ce fit indépendant,
|
||||||
|
* pour des raisons de licence. Les deux sont des tri-exponentielles en forme
|
||||||
|
* close ajustées sur les mêmes études, avec des conventions différentes :
|
||||||
|
*
|
||||||
|
* - WHSAH paramètre une BIODISPONIBILITÉ explicite F < 1 (0,62–0,76) et un
|
||||||
|
* facteur d'échelle `auc` intégré — les courbes « brutes » sont ~24 % plus
|
||||||
|
* basses que TFS non calibrée (la calibration de l'app absorbe ça) ;
|
||||||
|
* - la MONTÉE est plus rapide (compartiment rapide plus vif) et la t½
|
||||||
|
* terminale plus longue sur certains esters : EV Cmax 367 pg/mL @ 1,7 j
|
||||||
|
* (TFS : 295 @ 2,1 j), EEn Cmax 188 @ 5,0 j (TFS : 160 @ 6,5 j) et t½ EEn
|
||||||
|
* 7,3 j (TFS : 4,5 j) — à J+1 l'EEn WHSAH est ~3× plus haut (70 vs 22 pg/mL) ;
|
||||||
|
* - PEP NON COUVERT (retiré du jeu WHSAH) → `sample('PEP', …) = 0`.
|
||||||
|
*
|
||||||
|
* FORMULE (telle qu'implémentée dans Mona, normalisée PAR MG — le
|
||||||
|
* multiplicateur de dose est appliqué par le moteur PK) :
|
||||||
|
*
|
||||||
|
* sample = F · auc · k1·k2·k3 · [ e^(−k1·t)/((k1−k2)(k1−k3))
|
||||||
|
* − e^(−k2·t)/((k1−k2)(k2−k3))
|
||||||
|
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
|
||||||
|
*
|
||||||
|
* avec t en JOURS, résultat en pg/mL pour 1 mg. Sanity checks épinglés par
|
||||||
|
* web/tests/whsah-models.test.js (fidélité au comportement de Mona, ±2 %) :
|
||||||
|
* EV Cmax 367,5 @ 1,69 j · EEn 187,9 @ 4,99 j · t½ EEn 7,34 j.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Modèle WHSAH d'un ester (fit F/auc/k1/k2/k3). */
|
||||||
|
export class WHS {
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester de l'app ("EV", "EB", …)
|
||||||
|
* @param {number} F Biodisponibilité explicite (< 1 dans le fit WHSAH)
|
||||||
|
* @param {number} auc Constante d'échelle du fit (pg/mL intégrés)
|
||||||
|
* @param {number} k1 Constante de vitesse j⁻¹ (absorption)
|
||||||
|
* @param {number} k2 Constante de vitesse j⁻¹ (phase rapide)
|
||||||
|
* @param {number} k3 Constante de vitesse j⁻¹ (phase lente / terminale)
|
||||||
|
*/
|
||||||
|
constructor(ester, F, auc, k1, k2, k3) {
|
||||||
|
this.ester = ester;
|
||||||
|
this.F = F;
|
||||||
|
this.auc = auc;
|
||||||
|
this.k1 = k1;
|
||||||
|
this.k2 = k2;
|
||||||
|
this.k3 = k3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Demi-vie TERMINALE (jours) = la phase la plus lente des trois. */
|
||||||
|
get terminalHalfLifeDays() {
|
||||||
|
return Math.log(2.0) / Math.min(this.k1, this.k2, this.k3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Table des modèles WHSAH par clé ester (identique au Kotlin — ne pas éditer sans ré-épingler les tests). */
|
||||||
|
const MODELS = new Map([
|
||||||
|
// estradiol enanthate (solution huileuse)
|
||||||
|
['EEN', new WHS('EEN', 0.708, 875.4, 0.09441, 3.354, 0.4078)],
|
||||||
|
// estradiol valerate (solution huileuse)
|
||||||
|
['EV', new WHS('EV', 0.764, 621.3, 0.2230, 17.62, 1.305)],
|
||||||
|
// estradiol benzoate (solution huileuse)
|
||||||
|
['EB', new WHS('EB', 0.723, 889.9, 0.5220, 521.9, 5.223)],
|
||||||
|
// estradiol cypionate — SOLUTION HUILEUSE
|
||||||
|
['EC', new WHS('EC', 0.687, 554.5, 0.0880, 17.95, 0.7177)],
|
||||||
|
// estradiol cypionate — SUSPENSION AQUEUSE (microcristaux)
|
||||||
|
['ECS', new WHS('ECS', 0.687, 852.6, 0.0973, 218.67, 6.624)],
|
||||||
|
// estradiol undecylate — données d'étude limitées (comme chez TFS)
|
||||||
|
['EU', new WHS('EU', 0.618, 385.8, 0.02189, 183.4, 1.564)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester — lookup insensible à la casse
|
||||||
|
* @returns {boolean} true si un modèle WHSAH existe pour cet ester
|
||||||
|
*/
|
||||||
|
export function hasModel(ester) {
|
||||||
|
return MODELS.has(String(ester).toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} ester Clé ester (lookup insensible à la casse)
|
||||||
|
* @returns {WHS|null} le modèle WHSAH de l'ester, ou null
|
||||||
|
*/
|
||||||
|
export function model(ester) {
|
||||||
|
return MODELS.get(String(ester).toUpperCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réponse normalisée (pg/mL PAR mg) à dtHours après une injection de 1 mg,
|
||||||
|
* modèle WHSAH.
|
||||||
|
*
|
||||||
|
* @param {string} ester Clé ester ("EV", …)
|
||||||
|
* @param {number} dtHours Heures écoulées depuis l'injection (≤ 0 → 0)
|
||||||
|
* @returns {number} pg/mL par mg
|
||||||
|
*/
|
||||||
|
export function sample(ester, dtHours) {
|
||||||
|
if (dtHours <= 0.0) return 0.0;
|
||||||
|
const m = model(ester);
|
||||||
|
if (!m) return 0.0;
|
||||||
|
return rawConcentrationDays(m, dtHours / 24.0); // t en jours
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cp(t) en pg/mL pour 1 mg (forme Mona exacte — voir KDoc de l'objet).
|
||||||
|
*
|
||||||
|
* Les compartiments « ultra-rapides » (k2 jusqu'à ~520 j⁻¹ : EB) décroissent
|
||||||
|
* instantanément et n'affectent que t≈0 — double précision suffisante
|
||||||
|
* (vérifié par les tests de pics).
|
||||||
|
*
|
||||||
|
* @param {WHS} m Modèle de l'ester
|
||||||
|
* @param {number} tDays Temps en JOURS depuis l'injection
|
||||||
|
* @returns {number} pg/mL pour 1 mg
|
||||||
|
*/
|
||||||
|
function rawConcentrationDays(m, tDays) {
|
||||||
|
const part1 = Math.exp(-m.k1 * tDays) / ((m.k1 - m.k2) * (m.k1 - m.k3));
|
||||||
|
const part2 = Math.exp(-m.k2 * tDays) / ((m.k1 - m.k2) * (m.k2 - m.k3));
|
||||||
|
const part3 = Math.exp(-m.k3 * tDays) / ((m.k1 - m.k3) * (m.k2 - m.k3));
|
||||||
|
return m.F * m.auc * m.k1 * m.k2 * m.k3 * (part1 - part2 + part3);
|
||||||
|
}
|
||||||
357
js/ui/app.js
Normal file
357
js/ui/app.js
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt +
|
||||||
|
* MainActivity.kt + HormoneTrackRoot.kt).
|
||||||
|
*
|
||||||
|
* - attend l'initialisation de PKProfileStore (fetch de l'asset) ;
|
||||||
|
* - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/
|
||||||
|
* #treatment-edit/{id}) — permet la navigation headless et le lien profond ;
|
||||||
|
* - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir
|
||||||
|
* Android) ;
|
||||||
|
* - dialog « Nouveautés » (changelog.md fetché, version vue persistée) ;
|
||||||
|
* - boucle de rappels (notifications navigateur) ;
|
||||||
|
* - re-rendu global sur mutation du store (pub/sub) + tick minute (les
|
||||||
|
* estimations « maintenant » vivent) ;
|
||||||
|
* - hook de TEST ?demo=1 : charge un jeu de données de démonstration si le
|
||||||
|
* store est vide (voir scripts de smoke-test + doc de dev §Tests).
|
||||||
|
*
|
||||||
|
* ⚠️ 100 % LOCAL : aucun envoi de données. Le seul réseau est le fetch
|
||||||
|
* (lecture) de l'asset PK et du changelog, servis en statique.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as PKProfileStore from '../pk/pk-profile-store.js';
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import * as AppLog from '../util/app-log.js';
|
||||||
|
import { isVersionNewer, sectionsSince } from '../util/changelog.js';
|
||||||
|
import { t, resolveLocale } from '../util/i18n.js';
|
||||||
|
import { el, showDialog } from './components.js';
|
||||||
|
import { renderHome } from './home.js';
|
||||||
|
import { renderChart } from './chart.js';
|
||||||
|
import { renderDoses } from './doses.js';
|
||||||
|
import { renderLabs } from './labs.js';
|
||||||
|
import { renderTreatments } from './treatments.js';
|
||||||
|
import { renderTreatmentEditor } from './treatment-editor.js';
|
||||||
|
import { renderSettings, WEB_VERSION } from './settings.js';
|
||||||
|
import { startReminderLoop } from './reminders.js';
|
||||||
|
import { openDoseDialog } from './dialogs.js';
|
||||||
|
|
||||||
|
/** Point d'entrée. */
|
||||||
|
async function main() {
|
||||||
|
// 1) Backend de stockage (localStorage) + chargement du journal
|
||||||
|
store.setBackend(window.localStorage);
|
||||||
|
AppLog.init();
|
||||||
|
|
||||||
|
// 2) Hook de test ?demo=1 : données de démo si le store est vide
|
||||||
|
if (new URLSearchParams(window.location.search).get('demo') === '1') {
|
||||||
|
demoMode = true;
|
||||||
|
maybeLoadDemoData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Shell rendu SYNCHRONIQUEMENT (avant le fetch de l'asset) : l'utilisateur
|
||||||
|
// voit immédiatement la structure + « Chargement… » au lieu d'une page
|
||||||
|
// blanche — et les captures d'écran smoke-test ne sont plus vides.
|
||||||
|
buildShell();
|
||||||
|
renderRoute(); // affiche l'écran courant avec le marqueur de chargement
|
||||||
|
|
||||||
|
// 4) Asset PK (équivalent de PKProfileStore.init(context) dans
|
||||||
|
// HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ;
|
||||||
|
// le re-rendu ci-dessous remplace le « Chargement… » par les données.
|
||||||
|
await PKProfileStore.init();
|
||||||
|
|
||||||
|
// 5) Langue au démarrage (miroir MainActivity : setApplicationLocales)
|
||||||
|
document.documentElement.lang = resolveLocale(store.getSettings().language);
|
||||||
|
|
||||||
|
// 6) Boucle de rappels (notifications navigateur)
|
||||||
|
startReminderLoop((treatmentId) => {
|
||||||
|
location.hash = '#home';
|
||||||
|
// petit délai pour laisser le re-render se faire avant le dialog
|
||||||
|
setTimeout(() => openDoseDialog({
|
||||||
|
treatments: store.getTreatments().filter((x) => x.isActive),
|
||||||
|
preselected: store.getTreatments().find((x) => x.id === treatmentId) || null,
|
||||||
|
existing: null,
|
||||||
|
}), 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7) Dialog « Nouveautés » après mise à jour (v1.3.0 Android)
|
||||||
|
await maybeShowWhatsNew();
|
||||||
|
|
||||||
|
// 8) Re-rendu global : mutations du store + tick minute + navigation hash
|
||||||
|
store.subscribe(() => renderRoute());
|
||||||
|
setInterval(() => {
|
||||||
|
if (lastRoute === currentRoute()) renderRoute(); // rafraîchit « now »
|
||||||
|
}, 60000);
|
||||||
|
window.addEventListener('hashchange', () => renderRoute());
|
||||||
|
window.addEventListener('resize', () => renderRoute()); // le canvas suit la largeur
|
||||||
|
|
||||||
|
AppLog.log('app', `démarrage web v${WEB_VERSION}`);
|
||||||
|
renderRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let contentEl = null;
|
||||||
|
let bannerSlot = null;
|
||||||
|
/** Dernière route rendue (le tick minute ne re-rend que si inchangée). */
|
||||||
|
let lastRoute = null;
|
||||||
|
/** Mode démo (?demo=1) : bannière affichée au-dessus du contenu. */
|
||||||
|
let demoMode = false;
|
||||||
|
|
||||||
|
/** Construit topbar + bandeau trans + main + tabbar (une seule fois). */
|
||||||
|
function buildShell() {
|
||||||
|
const topbar = el('div', { class: 'topbar' }, [
|
||||||
|
el('h1', {}, t('app_name')),
|
||||||
|
el('button', { class: 'btn text', onclick: () => { location.hash = '#settings'; } }, '⚙'),
|
||||||
|
]);
|
||||||
|
document.body.appendChild(topbar);
|
||||||
|
document.body.appendChild(el('div', { class: 'trans-banner' }));
|
||||||
|
bannerSlot = el('div', { id: 'banner-slot', style: 'max-width:720px;margin:0 auto;padding:0 16px;' });
|
||||||
|
document.body.appendChild(bannerSlot);
|
||||||
|
contentEl = el('main', { class: 'content' });
|
||||||
|
document.body.appendChild(contentEl);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
['home', '🏠', t('nav_home')],
|
||||||
|
['chart', '📈', t('nav_chart')],
|
||||||
|
['doses', '💉', t('nav_doses')],
|
||||||
|
['labs', '🧪', t('nav_labs')],
|
||||||
|
['treatments', '💊', t('nav_treatments')],
|
||||||
|
];
|
||||||
|
const tabbar = el('nav', { class: 'tabbar' });
|
||||||
|
for (const [route, icon, label] of tabs) {
|
||||||
|
const btn = el('button', { 'data-route': route, onclick: () => { location.hash = `#${route}`; } }, [
|
||||||
|
el('span', { class: 'ico' }, icon),
|
||||||
|
el('span', {}, label),
|
||||||
|
]);
|
||||||
|
tabbar.appendChild(btn);
|
||||||
|
}
|
||||||
|
document.body.appendChild(tabbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route courante depuis le hash (défaut #home). */
|
||||||
|
function currentRoute() {
|
||||||
|
const h = (window.location.hash || '#home').replace(/^#/, '');
|
||||||
|
return h === '' ? 'home' : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend la route courante dans contentEl — re-création complète de l'écran
|
||||||
|
* (le DOM est jetable, l'état vit dans le store / le hash).
|
||||||
|
*/
|
||||||
|
function renderRoute() {
|
||||||
|
if (!contentEl) return;
|
||||||
|
const route = currentRoute();
|
||||||
|
lastRoute = route;
|
||||||
|
|
||||||
|
// Écran de chargement tant que l'asset PK n'est pas chargé (les écrans
|
||||||
|
// afficheraient « aucune donnée » à tort — les profils valent 0 avant init).
|
||||||
|
if (!PKProfileStore.isInitialized()) {
|
||||||
|
contentEl.textContent = '';
|
||||||
|
contentEl.appendChild(el('p', { class: 'hint', style: 'padding:24px 0;' }, t('loading')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marqueur E2E : l'app est prête (asset chargé, écran rendu) — utilisé par
|
||||||
|
// scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot).
|
||||||
|
document.body.setAttribute('data-ready', '1');
|
||||||
|
// Onglet actif
|
||||||
|
document.querySelectorAll('.tabbar button').forEach((b) => {
|
||||||
|
b.classList.toggle('active', b.getAttribute('data-route') === route);
|
||||||
|
});
|
||||||
|
// Barre masquée sur settings + éditeur (miroir HormoneTrackRoot)
|
||||||
|
document.querySelector('.tabbar').style.display
|
||||||
|
= (route === 'settings' || route.startsWith('treatment-edit')) ? 'none' : 'flex';
|
||||||
|
|
||||||
|
// Re-traduit la topbar (changement de langue)
|
||||||
|
const h1 = document.querySelector('.topbar h1');
|
||||||
|
if (h1) h1.textContent = t('app_name');
|
||||||
|
document.documentElement.lang = resolveLocale(store.getSettings().language);
|
||||||
|
|
||||||
|
// Bannières de rappel : nettoyées au changement d'écran (elles se recréent
|
||||||
|
// via la boucle si toujours pertinentes)
|
||||||
|
if (bannerSlot) bannerSlot.textContent = '';
|
||||||
|
|
||||||
|
contentEl.textContent = '';
|
||||||
|
// Bannière mode démo (hook de test ?demo=1) : explicite pour ne jamais
|
||||||
|
// confondre des données de démo avec de vraies données de santé
|
||||||
|
if (demoMode) {
|
||||||
|
contentEl.appendChild(el('div', { class: 'demo-banner' }, t('demo_banner')));
|
||||||
|
}
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case route === 'home':
|
||||||
|
renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } });
|
||||||
|
break;
|
||||||
|
case route === 'chart':
|
||||||
|
renderChart(contentEl, { nowMs });
|
||||||
|
break;
|
||||||
|
case route === 'doses':
|
||||||
|
renderDoses(contentEl);
|
||||||
|
break;
|
||||||
|
case route === 'labs':
|
||||||
|
renderLabs(contentEl);
|
||||||
|
break;
|
||||||
|
case route === 'treatments':
|
||||||
|
renderTreatments(contentEl, {
|
||||||
|
openEditor: (id) => { location.hash = `#treatment-edit/${id}`; },
|
||||||
|
openNew: () => { location.hash = '#treatment-edit/0'; },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case route === 'settings':
|
||||||
|
renderSettings(contentEl);
|
||||||
|
break;
|
||||||
|
case route.startsWith('treatment-edit/'):
|
||||||
|
renderTreatmentEditor(contentEl, {
|
||||||
|
treatmentId: Number(route.split('/')[1] || 0),
|
||||||
|
onDone: () => { location.hash = '#treatments'; },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
location.hash = '#home';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dialog « Nouveautés » ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendu MINIMAL de markdown pour le dialog « Nouveautés » : titres, listes,
|
||||||
|
* gras, code inline — suffisant pour le format de docs/CHANGELOG.md (pas un
|
||||||
|
* parseur général, volontairement : aucune dépendance, surface réduite).
|
||||||
|
*
|
||||||
|
* @param {string} markdown
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function renderMarkdown(md) {
|
||||||
|
const container = el('div', { class: 'md-body', style: 'font-size:0.88rem;' });
|
||||||
|
let list = null; // <ul> courant (regroupe les lignes « - » consécutives)
|
||||||
|
const inline = (text) => {
|
||||||
|
// **gras** et `code` — split simple par regex, échappé par le DOM (textContent)
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const part of text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g)) {
|
||||||
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
|
frag.appendChild(el('strong', {}, part.slice(2, -2)));
|
||||||
|
} else if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
|
||||||
|
frag.appendChild(el('code', { style: 'background:var(--surface-variant);border-radius:4px;padding:1px 4px;font-size:0.8em;' }, part.slice(1, -1)));
|
||||||
|
} else if (part) {
|
||||||
|
frag.appendChild(document.createTextNode(part));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
};
|
||||||
|
for (const rawLine of md.split('\n')) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
list = null;
|
||||||
|
container.appendChild(el('h4', {}, line.slice(4)));
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
list = null;
|
||||||
|
container.appendChild(el('h3', { style: 'margin:10px 0 4px;' }, line.slice(3)));
|
||||||
|
} else if (line.startsWith('- ')) {
|
||||||
|
if (!list) list = el('ul', { style: 'margin:4px 0;padding-left:20px;' });
|
||||||
|
list.appendChild(el('li', {}, inline(line.slice(2))));
|
||||||
|
container.appendChild(list); // (ré-append le même nœud : pas d'effet)
|
||||||
|
} else if (line === '') {
|
||||||
|
list = null;
|
||||||
|
} else {
|
||||||
|
list = null;
|
||||||
|
container.appendChild(el('p', { style: 'margin:4px 0;' }, inline(line)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Au démarrage : si WEB_VERSION est plus récente que la dernière vue
|
||||||
|
* (DataStore `changelogSeenVersion` côté Android), affiche les sections
|
||||||
|
* CHANGELOG concernées — la version vue est mémorisée AVANT l'affichage
|
||||||
|
* (bug Android #42 : si l'app est fermée pendant le dialog, ne pas
|
||||||
|
* re-montrer les mêmes nouveautés).
|
||||||
|
*/
|
||||||
|
async function maybeShowWhatsNew() {
|
||||||
|
let markdown = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('docs/CHANGELOG.md');
|
||||||
|
if (resp.ok) markdown = await resp.text();
|
||||||
|
} catch (e) {
|
||||||
|
// Pas grave : pas de dialog (app servie file:// ou changelog absent)
|
||||||
|
AppLog.log('changelog', `indisponible : ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!markdown) return;
|
||||||
|
const seen = store.getSettings().changelogSeenVersion;
|
||||||
|
if (!isVersionNewer(WEB_VERSION, seen)) return;
|
||||||
|
|
||||||
|
store.setSettings({ changelogSeenVersion: WEB_VERSION }); // mémorisé AVANT
|
||||||
|
const sections = sectionsSince(markdown, seen, WEB_VERSION);
|
||||||
|
if (sections === '') return;
|
||||||
|
|
||||||
|
const dlg = showDialog({
|
||||||
|
title: t('whats_new_title'),
|
||||||
|
body: renderMarkdown(sections),
|
||||||
|
actions: [{ label: t('whats_new_close'), className: 'btn', onClick: () => dlg.close() }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook de démo/test (?demo=1) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge un jeu de données de DÉMONSTRATION si le store est vide. Utilisé
|
||||||
|
* par les smoke-tests headless (et pour découvrir l'app). Jamais automatique
|
||||||
|
* sans le paramètre d'URL, jamais sur un store déjà rempli.
|
||||||
|
*/
|
||||||
|
function maybeLoadDemoData() {
|
||||||
|
if (store.getTreatments().length > 0 || store.getDoseLogs().length > 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const HOUR = 3600000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const trId = store.upsertTreatment({
|
||||||
|
name: 'EEn — démo',
|
||||||
|
type: 'ESTRADIOL',
|
||||||
|
route: 'INJECTION_SUBCUT',
|
||||||
|
doseAmount: 5,
|
||||||
|
doseUnit: 'mg',
|
||||||
|
isActive: true,
|
||||||
|
notes: null,
|
||||||
|
esterType: 'EEN',
|
||||||
|
pkModel: 'TFS',
|
||||||
|
absorptionHours: 156,
|
||||||
|
eliminationHalfLifeHours: 110,
|
||||||
|
bioavailabilityFraction: 1,
|
||||||
|
scaleFactor: 1.0,
|
||||||
|
forecastIntervalDays: 7,
|
||||||
|
reminderHour: 18,
|
||||||
|
reminderMinute: 0,
|
||||||
|
reminderEnabled: true,
|
||||||
|
calendarEventId: null,
|
||||||
|
createdAt: now - 90 * DAY,
|
||||||
|
});
|
||||||
|
// 12 injections hebdomadaires passées (à 18 h)
|
||||||
|
for (let k = 12; k >= 1; k--) {
|
||||||
|
store.upsertDoseLog({
|
||||||
|
treatmentId: trId,
|
||||||
|
timestamp: now - k * 7 * DAY,
|
||||||
|
doseAmount: 5,
|
||||||
|
notes: null,
|
||||||
|
esterType: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Labs E2/T plausibles (1 prise de sang toutes les 2 semaines)
|
||||||
|
const labs = [[280, 0.35], [300, 0.31], [260, 0.4], [290, 0.33], [275, 0.36], [305, 0.3]];
|
||||||
|
labs.forEach(([e2, tVal], i) => {
|
||||||
|
const ts = now - (11 - i * 2) * 7 * DAY;
|
||||||
|
store.upsertLabResult({ marker: 'E2', value: e2, unit: 'pg/mL', timestamp: ts, notes: null });
|
||||||
|
store.upsertLabResult({ marker: 'T', value: Math.round(tVal * 100), unit: 'ng/dL', timestamp: ts, notes: null });
|
||||||
|
});
|
||||||
|
AppLog.log('demo', 'données de démonstration chargées (?demo=1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
// Erreur de démarrage : l'afficher lisible (asset absent, serveur mal configuré…)
|
||||||
|
document.body.innerHTML = `<div style="padding:24px;font-family:system-ui;">
|
||||||
|
<h1>HormoneTrack</h1>
|
||||||
|
<p><strong>Erreur de démarrage :</strong> ${e.message}</p>
|
||||||
|
<p>L'app doit être servie en HTTP statique (voir web/README.md —
|
||||||
|
<code>python3 web/scripts/serve.py</code>), pas ouverte en file://.</p>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
314
js/ui/chart-canvas.js
Normal file
314
js/ui/chart-canvas.js
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* CurveChart — rendu Canvas pur multi-séries (portage fidèle de
|
||||||
|
* `ui/components/CurveChart.kt`, Canvas Compose → Canvas 2D navigateur).
|
||||||
|
*
|
||||||
|
* - axe gauche = E2 (pg/mL), axe droit = T (ng/mL) ;
|
||||||
|
* - toutes les séries partagent les mêmes échelles (comparaison directe) ;
|
||||||
|
* - labs E2 = cercles orange, labs T = carrés orange (T convertie en ng/mL
|
||||||
|
* via convertTToNgMl — les labs peuvent être saisis en ng/dL/nmol/L, bug
|
||||||
|
* #23 : à utiliser aussi au rendu, sinon l'axe T est faux ×100) ;
|
||||||
|
* - ligne verticale « maintenant » ;
|
||||||
|
* - doses PRÉVISIONNELLES : ligne pointillée + triangle ▼ + heure (v1.4.5,
|
||||||
|
* fix #55 — sinon la projection paraît décalée d'un jour) ;
|
||||||
|
* - doses RÉELLES : petits triangles ▲ discrets au bas (peigne des prises) ;
|
||||||
|
* - pics/creux : triangles ▲▼ + valeur estimée (v1.2.6).
|
||||||
|
*
|
||||||
|
* Le composant est PUREMENT DÉCLARATIF : pan/zoom sont gérés PAR LE PARENT
|
||||||
|
* (chart.js) qui modifie la fenêtre et redessine — même séparation que le
|
||||||
|
* Compose (piège Android : un scroll parent avale le drag horizontal, cf
|
||||||
|
* fix #62/§11 — le parent web n'a PAS de scroll horizontal).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { convertTToNgMl, detectExtrema } from '../pk/index.js';
|
||||||
|
import { xLabelTicks } from '../pk/chart-helpers.js';
|
||||||
|
import { formatValue } from '../util/format.js';
|
||||||
|
|
||||||
|
/** Couleurs (miroir ui/theme/Color.kt). */
|
||||||
|
export const COLORS = {
|
||||||
|
chartE2: '#4f5bd5',
|
||||||
|
chartT: '#d6589e',
|
||||||
|
chartWhs: '#2e7d32',
|
||||||
|
chartTWhs: '#9c27b0',
|
||||||
|
tealTertiary: '#2fa48d',
|
||||||
|
labDot: '#e67e22',
|
||||||
|
grid: '#cac4d0',
|
||||||
|
label: '#6b7280',
|
||||||
|
nowLine: '#2fa48d',
|
||||||
|
forecastMarker: 'rgba(47,164,141,0.85)',
|
||||||
|
realDoseMarker: 'rgba(73,69,79,0.55)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dessine le graphique dans un canvas.
|
||||||
|
*
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {object[]} series [{ points: LevelPoint[], e2Style:{color,dashed}, tStyle|null }]
|
||||||
|
* @param {object[]} e2Labs Labs E2 (LabResult)
|
||||||
|
* @param {object[]} tLabs Labs T (déjà filtrés par le parent si toggle off)
|
||||||
|
* @param {object} options { showT, showLabs, showExtrema, nowMs }
|
||||||
|
* @param {object[]} [forecastDoses=[]] Doses projetées (marqueurs)
|
||||||
|
* @param {object[]} [realDoses=[]] Doses réelles (marqueurs)
|
||||||
|
* @param {string} [timeZone=null] Fuseau de LECTURE (null = navigateur)
|
||||||
|
*/
|
||||||
|
export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses = [], realDoses = [], timeZone = null) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const cssW = canvas.clientWidth;
|
||||||
|
const cssH = canvas.clientHeight;
|
||||||
|
if (cssW <= 0 || cssH <= 0) return;
|
||||||
|
// HiDPI : le canvas est dimensionné en pixels réels, le dessin en unités CSS
|
||||||
|
canvas.width = Math.round(cssW * dpr);
|
||||||
|
canvas.height = Math.round(cssH * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, cssW, cssH);
|
||||||
|
|
||||||
|
const allPoints = series.flatMap((s) => s.points);
|
||||||
|
if (allPoints.length < 2) return;
|
||||||
|
|
||||||
|
// Marges (px) — miroir padLeft/padRight/padTop/padBottom (42/42/12/26 dp)
|
||||||
|
const padLeft = 42;
|
||||||
|
const padRight = 42;
|
||||||
|
const padTop = 12;
|
||||||
|
const padBottom = 26;
|
||||||
|
const w = cssW - padLeft - padRight;
|
||||||
|
const h = cssH - padTop - padBottom;
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
const t0 = Math.min(...allPoints.map((p) => p.timestamp));
|
||||||
|
const t1 = Math.max(...allPoints.map((p) => p.timestamp));
|
||||||
|
if (t1 <= t0) return;
|
||||||
|
|
||||||
|
// Labs T convertis en ng/mL AVANT l'échelle (fix #23)
|
||||||
|
const convertedTLabs = tLabs.map((lab) => ({ ...lab, value: convertTToNgMl(lab.value, lab.unit) }));
|
||||||
|
|
||||||
|
const e2DataMax = Math.max(
|
||||||
|
Math.max(...allPoints.map((p) => p.e2)),
|
||||||
|
options.showLabs && e2Labs.length ? Math.max(...e2Labs.map((l) => l.value)) : 0,
|
||||||
|
);
|
||||||
|
const e2Max = niceCeil(Math.max(e2DataMax, 50.0));
|
||||||
|
const tDataMax = Math.max(
|
||||||
|
Math.max(...allPoints.map((p) => p.t)),
|
||||||
|
options.showLabs && convertedTLabs.length ? Math.max(...convertedTLabs.map((l) => l.value)) : 0,
|
||||||
|
);
|
||||||
|
const tMax = niceCeil(tDataMax * 1.1);
|
||||||
|
|
||||||
|
const xOf = (ms) => padLeft + (w * (ms - t0)) / (t1 - t0);
|
||||||
|
const yE2 = (v) => padTop + h * (1 - v / e2Max);
|
||||||
|
const yT = (v) => padTop + h * (1 - v / tMax);
|
||||||
|
|
||||||
|
// ── Grille 4 lignes + labels Y gauche (E2) / droit (T) ───────────────────
|
||||||
|
ctx.strokeStyle = COLORS.grid;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
const y = padTop + (h * i) / 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padLeft, y);
|
||||||
|
ctx.lineTo(padLeft + w, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.fillStyle = COLORS.label;
|
||||||
|
ctx.font = '10px system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
ctx.fillText(formatValue((e2Max * i) / 4), padLeft - 6, padTop + h * (1 - i / 4));
|
||||||
|
}
|
||||||
|
if (options.showT && series.some((s) => s.tStyle)) {
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
for (let i = 0; i <= 4; i++) {
|
||||||
|
ctx.fillText(formatValue((tMax * i) / 4), padLeft + w + 6, padTop + h * (1 - i / 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Labels X : pas adaptatif + ticks alignés minuit LOCAL (fix #55) ───────
|
||||||
|
const spanH = (t1 - t0) / 3600000.0;
|
||||||
|
const stepHours = spanH <= 8 ? 1 : spanH <= 18 ? 3 : spanH <= 25 ? 6 : spanH <= 25 * 7 ? 24 : 120;
|
||||||
|
const fmt = new Intl.DateTimeFormat(undefined, spanH <= 25
|
||||||
|
? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: timeZone || undefined }
|
||||||
|
: { day: '2-digit', month: '2-digit', timeZone: timeZone || undefined });
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
for (const tick of xLabelTicks(t0, t1, stepHours, timeZone)) {
|
||||||
|
const x = padLeft + (w * (tick - t0)) / (t1 - t0);
|
||||||
|
ctx.fillText(fmt.format(new Date(tick)), x, padTop + h + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Doses PRÉVISIONNELLES : ligne pointillée + ▼ + heure (fix #55) ───────
|
||||||
|
for (const dose of forecastDoses) {
|
||||||
|
if (dose.timestamp < t0 || dose.timestamp > t1) continue;
|
||||||
|
const x = xOf(dose.timestamp);
|
||||||
|
ctx.strokeStyle = COLORS.forecastMarker;
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padTop + 14);
|
||||||
|
ctx.lineTo(x, padTop + h);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = COLORS.forecastMarker;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padTop + 14);
|
||||||
|
ctx.lineTo(x - 4, padTop + 6);
|
||||||
|
ctx.lineTo(x + 4, padTop + 6);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
const hourFmt = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: timeZone || undefined,
|
||||||
|
});
|
||||||
|
ctx.fillStyle = COLORS.forecastMarker;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(hourFmt.format(new Date(dose.timestamp)), x, padTop + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Courbes (E2 pleine + T de la série) ──────────────────────────────────
|
||||||
|
for (const s of series) {
|
||||||
|
if (s.points.length < 2) continue;
|
||||||
|
|
||||||
|
// E2 de la série
|
||||||
|
ctx.strokeStyle = s.e2Style.color;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.setLineDash(s.e2Style.dashed ? [10, 8] : []);
|
||||||
|
ctx.beginPath();
|
||||||
|
s.points.forEach((p, i) => {
|
||||||
|
const x = xOf(p.timestamp);
|
||||||
|
const y = yE2(p.e2);
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// T de la série (si toggle T actif et style présent)
|
||||||
|
if (options.showT && s.tStyle) {
|
||||||
|
ctx.strokeStyle = s.tStyle.color;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash(s.tStyle.dashed ? [10, 8] : []);
|
||||||
|
ctx.beginPath();
|
||||||
|
s.points.forEach((p, i) => {
|
||||||
|
const x = xOf(p.timestamp);
|
||||||
|
const y = yT(p.t);
|
||||||
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée
|
||||||
|
if (options.showExtrema) {
|
||||||
|
for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) {
|
||||||
|
drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color);
|
||||||
|
}
|
||||||
|
if (options.showT && s.tStyle) {
|
||||||
|
for (const ex of detectExtrema(s.points, (p) => p.t, 0.02)) {
|
||||||
|
drawExtremum(ctx, xOf(ex.timestamp), yT(ex.value), ex, s.tStyle.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Labs : cercles (E2) et carrés (T) orange + valeur ────────────────────
|
||||||
|
if (options.showLabs) {
|
||||||
|
ctx.font = 'bold 9px system-ui, sans-serif';
|
||||||
|
for (const lab of e2Labs) {
|
||||||
|
if (lab.timestamp < t0 || lab.timestamp > t1) continue;
|
||||||
|
const x = xOf(lab.timestamp);
|
||||||
|
const y = yE2(lab.value);
|
||||||
|
ctx.fillStyle = COLORS.labDot;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(formatValue(lab.value), x, y - 10);
|
||||||
|
}
|
||||||
|
for (const lab of convertedTLabs) {
|
||||||
|
if (lab.timestamp < t0 || lab.timestamp > t1) continue;
|
||||||
|
const x = xOf(lab.timestamp);
|
||||||
|
const y = yT(lab.value);
|
||||||
|
ctx.fillStyle = COLORS.labDot;
|
||||||
|
ctx.fillRect(x - 4, y - 4, 8, 8);
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(formatValue(lab.value), x, y - 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Doses RÉELLES : petits triangles ▲ discrets au bas (v1.4.5) ──────────
|
||||||
|
ctx.fillStyle = COLORS.realDoseMarker;
|
||||||
|
for (const dose of realDoses) {
|
||||||
|
if (dose.timestamp < t0 || dose.timestamp > t1) continue;
|
||||||
|
const x = xOf(dose.timestamp);
|
||||||
|
const base = padTop + h;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, base - 8);
|
||||||
|
ctx.lineTo(x - 3.5, base);
|
||||||
|
ctx.lineTo(x + 3.5, base);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repère « maintenant » ────────────────────────────────────────────────
|
||||||
|
if (options.nowMs >= t0 && options.nowMs <= t1) {
|
||||||
|
ctx.strokeStyle = COLORS.nowLine;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(xOf(options.nowMs), padTop);
|
||||||
|
ctx.lineTo(xOf(options.nowMs), padTop + h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triangle ▲ (pic, au-dessus du point) ou ▼ (creux, en dessous) + la VALEUR
|
||||||
|
* estimée de l'extremum (miroir drawExtremum, v1.2.6).
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function drawExtremum(ctx, cx, cy, extremum, color) {
|
||||||
|
const r = 5;
|
||||||
|
const offset = 6;
|
||||||
|
let valueY;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
if (extremum.isPeak) {
|
||||||
|
const y = cy - offset;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, y - r);
|
||||||
|
ctx.lineTo(cx - r, y + r * 0.5);
|
||||||
|
ctx.lineTo(cx + r, y + r * 0.5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
valueY = y - r - 4;
|
||||||
|
} else {
|
||||||
|
const y = cy + offset;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, y + r);
|
||||||
|
ctx.lineTo(cx - r, y - r * 0.5);
|
||||||
|
ctx.lineTo(cx + r, y - r * 0.5);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
valueY = y + r + 12;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = 'bold 9px system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(formatValue(extremum.value), cx, valueY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrondi « nice » — re-export local (miroir niceCeil de CurveChart.kt) pour
|
||||||
|
* ne pas dépendre de chart-helpers ici (le module pk/index regroupe déjà).
|
||||||
|
*
|
||||||
|
* @param {number} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function niceCeil(value) {
|
||||||
|
if (value <= 0) return 100.0;
|
||||||
|
const exp = Math.pow(10.0, Math.floor(Math.log10(value)));
|
||||||
|
const f = value / exp;
|
||||||
|
let nice;
|
||||||
|
if (f <= 1) nice = 1.0;
|
||||||
|
else if (f <= 2) nice = 2.0;
|
||||||
|
else if (f <= 2.5) nice = 2.5;
|
||||||
|
else if (f <= 5) nice = 5.0;
|
||||||
|
else nice = 10.0;
|
||||||
|
return nice * exp;
|
||||||
|
}
|
||||||
410
js/ui/chart.js
Normal file
410
js/ui/chart.js
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Écran Graphiques (portage de `ui/screens/ChartScreen.kt` — l'écran le plus
|
||||||
|
* riche de l'app).
|
||||||
|
*
|
||||||
|
* Fonctions portées à l'identique :
|
||||||
|
* - plages 24 h / 7 j / 30 j + ZOOM −/+ (pinch + molette, borné 6 h → 300 j,
|
||||||
|
* échantillonnage adaptatif stepForRange, focal du pinch maintenu) ;
|
||||||
|
* - PANORAMIQUE : glisser = passé, glisser ← = futur (prévision active),
|
||||||
|
* cumul fractionnaire des deltas (fix #62) + clamp unique clampPanHours ;
|
||||||
|
* - toggles de MODÈLES superposables (ESE / TFS / WHSAH), alignés au
|
||||||
|
* chargement sur les traitements à PROFIL PK (fix #58, garde-fou TFS) ;
|
||||||
|
* - toggles T (masque aussi les labs T) / labs / pics-creux / prévision ;
|
||||||
|
* - PRÉVISION : doses projetées (grille Posologie), horizon 12× Posologie
|
||||||
|
* borné [30 j ; 1 an], extension de fenêtre à droite sans déplacement
|
||||||
|
* (fix #54), retour à maintenant à la désactivation (v1.4.1) ;
|
||||||
|
* - CALIBRATION PAR MODÈLE (fix #60) : une AutoCalibrated par modèle
|
||||||
|
* affiché — chaque courbe colle à ses labs quand l'auto est active ;
|
||||||
|
* - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition
|
||||||
|
* côté Android — côté web, même discipline : endMs ne dérive pas à chaque
|
||||||
|
* frame, il ne change qu'au tick ou sur interaction) ;
|
||||||
|
* - PAS de scroll horizontal parent : le drag horizontal est réservé au pan
|
||||||
|
* (piège Android #62/§11).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
|
||||||
|
} from '../pk/index.js';
|
||||||
|
import {
|
||||||
|
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
|
||||||
|
forecastHorizonHours, forecastExtensionHours,
|
||||||
|
} from '../pk/chart-helpers.js';
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatValue } from '../util/format.js';
|
||||||
|
import { el, filterChip } from './components.js';
|
||||||
|
import { drawChart, COLORS } from './chart-canvas.js';
|
||||||
|
|
||||||
|
const HOUR_MS = 3600000;
|
||||||
|
// Zoom (v1.2.9) : plage temporelle bornée 6 h → 300 j
|
||||||
|
const MIN_RANGE_H = 6;
|
||||||
|
const MAX_RANGE_H = 24 * 300;
|
||||||
|
|
||||||
|
/** Styles visuels des séries (miroir de la map du ChartScreen Android). */
|
||||||
|
function seriesStyles(model) {
|
||||||
|
if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } };
|
||||||
|
if (model === 'WHS') return { e2: { color: COLORS.chartWhs, dashed: false }, t: { color: COLORS.chartTWhs, dashed: true } };
|
||||||
|
return { e2: { color: COLORS.tealTertiary, dashed: false }, t: { color: COLORS.chartT, dashed: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'écran Graphiques dans [container].
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} ctx { nowMs }
|
||||||
|
*/
|
||||||
|
export function renderChart(container, ctx) {
|
||||||
|
const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis)
|
||||||
|
const doseLogs = store.getDoseLogs();
|
||||||
|
const labResults = store.getLabResults();
|
||||||
|
const tConfigRaw = store.getTConfig();
|
||||||
|
const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k);
|
||||||
|
const settings = store.getSettings();
|
||||||
|
const chartTimeZone = settings.chartTimezone || null;
|
||||||
|
|
||||||
|
// ── État d'interaction (miroir des mutableStateOf Compose) ────────────────
|
||||||
|
const state = {
|
||||||
|
rangeHours: 24,
|
||||||
|
panHours: 0,
|
||||||
|
showT: true,
|
||||||
|
showLabs: true,
|
||||||
|
showEse: true,
|
||||||
|
showTfs: true,
|
||||||
|
showWhs: false,
|
||||||
|
showForecast: false,
|
||||||
|
showExtrema: false,
|
||||||
|
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
|
||||||
|
togglesInitialized: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Colonne racine ───────────────────────────────────────────────────────
|
||||||
|
const col = el('div', {});
|
||||||
|
container.appendChild(col);
|
||||||
|
|
||||||
|
// Rangée 1 : plages + zoom
|
||||||
|
const rangeRow = el('div', { class: 'chip-row' });
|
||||||
|
col.appendChild(rangeRow);
|
||||||
|
|
||||||
|
// Rangée 2 : toggles affichage
|
||||||
|
const displayRow = el('div', { class: 'chip-row' });
|
||||||
|
col.appendChild(displayRow);
|
||||||
|
|
||||||
|
// Rangée 3 : toggles modèles
|
||||||
|
const modelRow = el('div', { class: 'chip-row' });
|
||||||
|
col.appendChild(modelRow);
|
||||||
|
|
||||||
|
// Carte graphique + légende
|
||||||
|
const chartCard = el('div', { class: 'card' });
|
||||||
|
const canvas = el('canvas', { class: 'chart chart-cursor', style: 'height:320px;' });
|
||||||
|
chartCard.appendChild(canvas);
|
||||||
|
const backBtnWrap = el('div', {});
|
||||||
|
chartCard.appendChild(backBtnWrap);
|
||||||
|
const legend = el('div', {});
|
||||||
|
chartCard.appendChild(legend);
|
||||||
|
col.appendChild(chartCard);
|
||||||
|
|
||||||
|
// ── Calibration PAR MODÈLE (fix #60) : une AutoCalibrated par modèle ─────
|
||||||
|
const autoByModel = new Map();
|
||||||
|
if (settings.autoCalibrate) {
|
||||||
|
for (const m of ['ESE', 'TFS', 'WHS']) {
|
||||||
|
autoByModel.set(m, autoCalibrated(treatments, doseLogs, labResults, tConfig, m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ────
|
||||||
|
const maxIntervalDays = treatments
|
||||||
|
.map((tr) => tr.forecastIntervalDays)
|
||||||
|
.filter((x) => x !== null && x !== undefined)
|
||||||
|
.reduce((m, x) => Math.max(m, x), 0) || null;
|
||||||
|
const horizonHours = forecastHorizonHours(maxIntervalDays);
|
||||||
|
|
||||||
|
// Gap jusqu'au 1ᵉʳ créneau projeté (mémoïsé — même discipline que fix #56 :
|
||||||
|
// ce calcul n'est refait qu'au re-rendu, pas à chaque événement de drag)
|
||||||
|
let firstSlotGapH = null;
|
||||||
|
if (horizonHours !== null) {
|
||||||
|
const at = ctx.nowMs;
|
||||||
|
const horizonMs = horizonHours * HOUR_MS;
|
||||||
|
let minSlot = null;
|
||||||
|
for (const tr of treatments) {
|
||||||
|
for (const d of generateForecastDoses(tr, doseLogs, at + horizonMs, at)) {
|
||||||
|
if (minSlot === null || d.timestamp < minSlot) minSlot = d.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minSlot !== null) firstSlotGapH = Math.trunc((minSlot - at) / HOUR_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fenêtre temporelle ───────────────────────────────────────────────────
|
||||||
|
function windowRange() {
|
||||||
|
const nowMs = ctx.nowMs;
|
||||||
|
const futurePanHorizon = state.showForecast ? (horizonHours || 0) : 0;
|
||||||
|
const extensionH = (state.showForecast && horizonHours !== null)
|
||||||
|
? forecastExtensionHours(state.rangeHours, horizonHours, firstSlotGapH)
|
||||||
|
: 0;
|
||||||
|
const endMs = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS;
|
||||||
|
const startMs = endMs - state.rangeHours * HOUR_MS;
|
||||||
|
return { nowMs, futurePanHorizon, extensionH, startMs, endMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle init des modèles : aligné sur les traitements à PROFIL PK ─────
|
||||||
|
// (fix #58 — filtrer par usesProfileModel : un traitement oral polluait le
|
||||||
|
// set avec son pkModel stocké ESE ; garde-fou TFS si aucun profil).
|
||||||
|
if (!state.togglesInitialized && treatments.length > 0) {
|
||||||
|
const used = new Set(
|
||||||
|
treatments
|
||||||
|
.filter((tr) => tr.route === 'INJECTION_IM' || tr.route === 'INJECTION_SUBCUT')
|
||||||
|
.filter((tr) => tr.esterType !== 'NONE')
|
||||||
|
.map((tr) => tr.pkModel),
|
||||||
|
);
|
||||||
|
const toggles = defaultModelToggles(used);
|
||||||
|
state.showEse = toggles.ESE;
|
||||||
|
state.showTfs = toggles.TFS;
|
||||||
|
state.showWhs = toggles.WHS;
|
||||||
|
state.togglesInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Calcul + dessin ──────────────────────────────────────────────────────
|
||||||
|
function redraw() {
|
||||||
|
const { nowMs, futurePanHorizon, extensionH, startMs, endMs } = windowRange();
|
||||||
|
|
||||||
|
// Rangée 1 : plages + zoom
|
||||||
|
rangeRow.textContent = '';
|
||||||
|
rangeRow.appendChild(filterChip(t('chart_24h'), state.rangeHours === 24, () => { state.rangeHours = 24; state.panHours = clampPan(state.panHours); redraw(); }));
|
||||||
|
rangeRow.appendChild(filterChip(t('chart_7j'), state.rangeHours === 24 * 7, () => { state.rangeHours = 24 * 7; redraw(); }));
|
||||||
|
rangeRow.appendChild(filterChip(t('chart_30j'), state.rangeHours === 24 * 30, () => { state.rangeHours = 24 * 30; redraw(); }));
|
||||||
|
const zoomIn = el('button', { class: 'chip', disabled: state.rangeHours <= MIN_RANGE_H }, '+');
|
||||||
|
zoomIn.addEventListener('click', () => {
|
||||||
|
// Convention carte : « + » = zoom AVANT (fenêtre plus courte, v1.2.10)
|
||||||
|
state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H);
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
const zoomOut = el('button', { class: 'chip', disabled: state.rangeHours >= MAX_RANGE_H }, '−');
|
||||||
|
zoomOut.addEventListener('click', () => {
|
||||||
|
state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H);
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
|
rangeRow.appendChild(zoomIn);
|
||||||
|
rangeRow.appendChild(zoomOut);
|
||||||
|
|
||||||
|
// Rangée 2 : toggles affichage
|
||||||
|
displayRow.textContent = '';
|
||||||
|
displayRow.appendChild(filterChip('T', state.showT, () => { state.showT = !state.showT; redraw(); }));
|
||||||
|
displayRow.appendChild(filterChip(t('show_labs'), state.showLabs, () => { state.showLabs = !state.showLabs; redraw(); }));
|
||||||
|
displayRow.appendChild(filterChip(t('show_forecast'), state.showForecast, () => {
|
||||||
|
state.showForecast = !state.showForecast;
|
||||||
|
// v1.4.1 : désactiver la prévision pendant un voyage futur → retour
|
||||||
|
// à maintenant (sinon fenêtre vide au-delà de « maintenant »)
|
||||||
|
if (!state.showForecast) state.panHours = Math.max(state.panHours, 0);
|
||||||
|
redraw();
|
||||||
|
}, horizonHours === null));
|
||||||
|
displayRow.appendChild(filterChip(t('show_extrema'), state.showExtrema, () => { state.showExtrema = !state.showExtrema; redraw(); }));
|
||||||
|
|
||||||
|
// Rangée 3 : toggles modèles superposables
|
||||||
|
modelRow.textContent = '';
|
||||||
|
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); }));
|
||||||
|
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
|
||||||
|
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
|
||||||
|
|
||||||
|
// ── Courbes par modèle sélectionné ─────────────────────────────────────
|
||||||
|
const curves = [];
|
||||||
|
if (state.showEse || state.showTfs || state.showWhs) {
|
||||||
|
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
|
||||||
|
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
|
||||||
|
// traitements/le tConfig y sont identiques — seules échelles/k diffèrent).
|
||||||
|
const refAuto = autoByModel.get('TFS');
|
||||||
|
const effTreatments = refAuto ? refAuto.treatments : treatments;
|
||||||
|
const effTConfig = refAuto ? refAuto.tConfig : tConfig;
|
||||||
|
|
||||||
|
// Doses réelles + doses prévisionnelles éventuelles
|
||||||
|
const forecastDoses = state.showForecast && horizonHours !== null
|
||||||
|
? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs))
|
||||||
|
: [];
|
||||||
|
const allDoses = doseLogs.concat(forecastDoses);
|
||||||
|
|
||||||
|
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||||
|
const step = stepForRange(state.rangeHours);
|
||||||
|
const addCurve = (model) => {
|
||||||
|
const autoM = autoByModel.get(model);
|
||||||
|
curves.push([model, computeCurve(
|
||||||
|
effTreatments, allDoses, startMs, endMs, step, autoM ? autoM.tConfig : effTConfig,
|
||||||
|
{
|
||||||
|
modelOverride: model,
|
||||||
|
scalePerEster: autoM ? autoM.esterScales : null,
|
||||||
|
tKPerEster: autoM ? autoM.tKPerEster : null,
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
};
|
||||||
|
if (state.showEse) addCurve('ESE');
|
||||||
|
if (state.showTfs) addCurve('TFS');
|
||||||
|
if (state.showWhs) addCurve('WHS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPoints = curves.flatMap(([, pts]) => pts);
|
||||||
|
|
||||||
|
// Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T
|
||||||
|
let a = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (allPoints.length >= 2) {
|
||||||
|
a = Math.min(...allPoints.map((p) => p.timestamp));
|
||||||
|
b = Math.max(...allPoints.map((p) => p.timestamp));
|
||||||
|
}
|
||||||
|
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
|
||||||
|
const tLabs = state.showT
|
||||||
|
? labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// ── Contenu : graphique ou « aucune donnée » ────────────────────────────
|
||||||
|
backBtnWrap.textContent = '';
|
||||||
|
legend.textContent = '';
|
||||||
|
|
||||||
|
if (curves.length === 0 || allPoints.length < 2) {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
legend.appendChild(el('p', {}, t('no_data')));
|
||||||
|
} else {
|
||||||
|
canvas.style.display = '';
|
||||||
|
const forecastDosesForMarkers = state.showForecast && horizonHours !== null
|
||||||
|
? treatments.flatMap((tr) => generateForecastDoses(tr, doseLogs, endMs, nowMs))
|
||||||
|
: [];
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
drawChart(
|
||||||
|
canvas,
|
||||||
|
curves.map(([model, pts]) => {
|
||||||
|
const st = seriesStyles(model);
|
||||||
|
return { points: pts, e2Style: st.e2, tStyle: st.t };
|
||||||
|
}),
|
||||||
|
e2Labs,
|
||||||
|
tLabs,
|
||||||
|
{ showT: state.showT, showLabs: state.showLabs, showExtrema: state.showExtrema, nowMs },
|
||||||
|
forecastDosesForMarkers,
|
||||||
|
doseLogs,
|
||||||
|
chartTimeZone,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bouton « Revenir à maintenant » dès qu'on a voyagé (passé OU futur)
|
||||||
|
if (state.panHours !== 0) {
|
||||||
|
backBtnWrap.appendChild(el('button', {
|
||||||
|
class: 'btn text',
|
||||||
|
onclick: () => { state.panHours = 0; state.panResidualHours = 0; redraw(); },
|
||||||
|
}, t('back_to_present')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Légende dynamique (miroir du bloc Compose)
|
||||||
|
for (const [model] of curves) {
|
||||||
|
const st = seriesStyles(model);
|
||||||
|
if (model === 'ESE') {
|
||||||
|
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
|
||||||
|
if (state.showT) legend.appendChild(legendLine(t('legend_ese_t'), st.t.color));
|
||||||
|
} else if (model === 'WHS') {
|
||||||
|
legend.appendChild(legendLine(t('legend_whs_e2'), st.e2.color));
|
||||||
|
if (state.showT) legend.appendChild(legendLine(t('legend_whs_t'), st.t.color));
|
||||||
|
} else {
|
||||||
|
legend.appendChild(legendLine(t('legend_tfs_e2'), st.e2.color));
|
||||||
|
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
|
||||||
|
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
|
||||||
|
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
|
||||||
|
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-clamp du pan avec l'horizon courant
|
||||||
|
function clampPan(pan) {
|
||||||
|
const nowMs = ctx.nowMs;
|
||||||
|
const earliestDoseMs = doseLogs.length ? Math.min(...doseLogs.map((d) => d.timestamp)) : nowMs;
|
||||||
|
const maxPanHours = Math.max(Math.trunc((nowMs - earliestDoseMs) / HOUR_MS) + state.rangeHours / HOUR_MS, 0);
|
||||||
|
return clampPanHours(pan, state.showForecast ? (horizonHours || 0) : 0, maxPanHours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestes : pan (1 pointeur) + pinch (2 pointeurs) + molette ────────────
|
||||||
|
// ⚠️ FIX #62 : les deltas < 1 h sont CUMULÉS via panResidualHours (pan
|
||||||
|
// fonctionnel sur la vue 24 h). Un seul handler pour pan+pinch (miroir
|
||||||
|
// detectTransformGestures — un handler = pas de conflit de consommation).
|
||||||
|
const activePointers = new Map();
|
||||||
|
let pinchStart = null; // { dist, centroidX, rangeHoursAtStart }
|
||||||
|
|
||||||
|
function maxPanHoursNow() {
|
||||||
|
const nowMs = ctx.nowMs;
|
||||||
|
const earliestDoseMs = doseLogs.length ? Math.min(...doseLogs.map((d) => d.timestamp)) : nowMs;
|
||||||
|
return Math.max(Math.trunc((nowMs - earliestDoseMs) / HOUR_MS) + state.rangeHours / HOUR_MS, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
if (activePointers.size === 2) {
|
||||||
|
const [p1, p2] = [...activePointers.values()];
|
||||||
|
pinchStart = {
|
||||||
|
dist: Math.abs(p1.x - p2.x) || 1,
|
||||||
|
centroidX: (p1.x + p2.x) / 2,
|
||||||
|
rangeHoursAtStart: state.rangeHours,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (!activePointers.has(e.pointerId)) return;
|
||||||
|
const prev = activePointers.get(e.pointerId);
|
||||||
|
const cur = { x: e.clientX, y: e.clientY };
|
||||||
|
activePointers.set(e.pointerId, cur);
|
||||||
|
|
||||||
|
if (activePointers.size === 2 && pinchStart) {
|
||||||
|
// ── Pinch : zoom autour du centroid (le temps sous les doigts reste fixe)
|
||||||
|
const [p1, p2] = [...activePointers.values()];
|
||||||
|
const dist = Math.abs(p1.x - p2.x) || 1;
|
||||||
|
const zoom = dist / pinchStart.dist; // > 1 = doigts écartés = zoom avant
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const f = Math.min(Math.max((pinchStart.centroidX - rect.left) / rect.width, 0), 1);
|
||||||
|
const newRange = Math.min(Math.max(Math.trunc(pinchStart.rangeHoursAtStart / zoom), MIN_RANGE_H), MAX_RANGE_H);
|
||||||
|
if (newRange !== state.rangeHours) {
|
||||||
|
const { nowMs, extensionH } = windowRange();
|
||||||
|
const endAnchor = nowMs - state.panHours * HOUR_MS + extensionH * HOUR_MS;
|
||||||
|
const tFocal = endAnchor - Math.trunc((1 - f) * state.rangeHours * HOUR_MS);
|
||||||
|
const newEnd = tFocal + Math.trunc((1 - f) * newRange * HOUR_MS);
|
||||||
|
const newPan = Math.trunc((nowMs - newEnd) / HOUR_MS);
|
||||||
|
state.panHours = clampPanHours(newPan, state.showForecast ? (horizonHours || 0) : 0, maxPanHoursNow());
|
||||||
|
state.rangeHours = newRange;
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pan 1 pointeur : glisser à DROITE = passé (panHours ↑), GAUCHE = futur
|
||||||
|
const dx = cur.x - prev.x;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
// fix #62 : cumul fractionnaire (le résiduel < 1 h est conservé)
|
||||||
|
const { hours, residual } = panDeltaHours(dx, rect.width, state.rangeHours, state.panResidualHours);
|
||||||
|
state.panResidualHours = residual;
|
||||||
|
if (hours !== 0) {
|
||||||
|
state.panHours = clampPanHours(state.panHours + hours, state.showForecast ? (horizonHours || 0) : 0, maxPanHoursNow());
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const releasePointer = (e) => {
|
||||||
|
activePointers.delete(e.pointerId);
|
||||||
|
if (activePointers.size < 2) pinchStart = null;
|
||||||
|
};
|
||||||
|
canvas.addEventListener('pointerup', releasePointer);
|
||||||
|
canvas.addEventListener('pointercancel', releasePointer);
|
||||||
|
|
||||||
|
// Molette = zoom (utilisateur desktop — le pinch tactile est déjà couvert)
|
||||||
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
state.rangeHours = Math.max(Math.trunc(state.rangeHours / 2), MIN_RANGE_H);
|
||||||
|
} else {
|
||||||
|
state.rangeHours = Math.min(state.rangeHours * 2, MAX_RANGE_H);
|
||||||
|
}
|
||||||
|
redraw();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private Ligne de légende colorée. */
|
||||||
|
function legendLine(text, color) {
|
||||||
|
return el('div', { class: 'legend-text', style: `color:${color};` }, text);
|
||||||
|
}
|
||||||
250
js/ui/components.js
Normal file
250
js/ui/components.js
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Composants UI réutilisables (equivalent des composants Compose Android) :
|
||||||
|
* fabrique DOM `el()`, dialogs modaux, dropdown, champs date/heure, toast.
|
||||||
|
*
|
||||||
|
* Aucun framework : tout est construit via `el(tag, attrs, children)` — un
|
||||||
|
* helper minimal et lisible, commenté, qui remplace JSX/hyperscript.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t as tr } from '../util/i18n.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fabrique DOM : crée un élément, applique les attributs/écouteurs, attache
|
||||||
|
* les enfants (string → nœud texte ; Element → append ; Array → aplatit ;
|
||||||
|
* null/undefined/false → ignoré).
|
||||||
|
*
|
||||||
|
* attrs spéciaux :
|
||||||
|
* - `class` : liste de classes (string) ;
|
||||||
|
* - `on<event>` : addEventListener (ex. `onclick`, `oninput`) ;
|
||||||
|
* - tout autre attribut passe par setAttribute (sauf `value`/`checked`/
|
||||||
|
* `disabled`/`selected` → propriétés DOM directes).
|
||||||
|
*
|
||||||
|
* @param {string} tag
|
||||||
|
* @param {object} [attrs={}]
|
||||||
|
* @param {Array<Node|string|Object|null|false>|Node|string|null} [children]
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
export function el(tag, attrs = {}, children = null) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
for (const [key, value] of Object.entries(attrs || {})) {
|
||||||
|
if (value === null || value === undefined || value === false) continue;
|
||||||
|
if (key.startsWith('on') && typeof value === 'function') {
|
||||||
|
node.addEventListener(key.slice(2), value);
|
||||||
|
} else if (key === 'class') {
|
||||||
|
node.className = value;
|
||||||
|
} else if (key === 'value' || key === 'checked' || key === 'disabled' || key === 'selected') {
|
||||||
|
node[key] = value;
|
||||||
|
} else {
|
||||||
|
node.setAttribute(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendChildren(node, children);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
function appendChildren(node, children) {
|
||||||
|
if (children === null || children === undefined || children === false) return;
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
for (const c of children) appendChildren(node, c);
|
||||||
|
} else if (children instanceof Node) {
|
||||||
|
node.appendChild(children);
|
||||||
|
} else {
|
||||||
|
node.appendChild(document.createTextNode(String(children)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog modal générique (miroir AlertDialog M3) :
|
||||||
|
* - fond cliquable = dismiss (comme onDismissRequest) ;
|
||||||
|
* - body : nœud ou string ; actions : [{label, className, onClick}].
|
||||||
|
*
|
||||||
|
* @param {object} opts { title, body, actions, dismissible=true }
|
||||||
|
* @returns {{close:Function, root:HTMLElement}}
|
||||||
|
*/
|
||||||
|
export function showDialog({ title, body, actions = [], dismissible = true }) {
|
||||||
|
const root = el('div', { class: 'dialog-backdrop' });
|
||||||
|
const dialog = el('div', { class: 'dialog' });
|
||||||
|
if (title) dialog.appendChild(el('h2', {}, title));
|
||||||
|
if (body) appendChildren(dialog, body);
|
||||||
|
if (actions.length) {
|
||||||
|
const row = el('div', { class: 'actions' });
|
||||||
|
for (const a of actions) {
|
||||||
|
row.appendChild(el('button', {
|
||||||
|
class: a.className || 'btn text',
|
||||||
|
onclick: () => a.onClick(),
|
||||||
|
}, a.label));
|
||||||
|
}
|
||||||
|
dialog.appendChild(row);
|
||||||
|
}
|
||||||
|
root.appendChild(dialog);
|
||||||
|
|
||||||
|
const close = () => root.remove();
|
||||||
|
if (dismissible) {
|
||||||
|
root.addEventListener('click', (e) => {
|
||||||
|
if (e.target === root) close(); // clic sur le fond seulement
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.appendChild(root);
|
||||||
|
return { close, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast de feedback (message temporaire en bas d'écran — feedback des
|
||||||
|
* actions Paramètres, miroir du `message` SettingsScreen Android).
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
*/
|
||||||
|
export function toast(text) {
|
||||||
|
const node = el('div', { class: 'toast' }, text);
|
||||||
|
document.body.appendChild(node);
|
||||||
|
setTimeout(() => node.remove(), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ de saisie (label + input/select/textarea) — miroir OutlinedTextField.
|
||||||
|
*
|
||||||
|
* @param {object} opts { label, type, value, onInput, options (select),
|
||||||
|
* placeholder, attrs, inputEl (retourne l'élément via
|
||||||
|
* result.input) }
|
||||||
|
* @returns {HTMLElement} le conteneur (.input = l'élément de saisie)
|
||||||
|
*/
|
||||||
|
export function field({ label, type = 'text', value = '', onInput = null, options = null, placeholder = null, attrs = {} }) {
|
||||||
|
let input;
|
||||||
|
if (options) {
|
||||||
|
input = el('select', {
|
||||||
|
onchange: (e) => onInput && onInput(e.target.value),
|
||||||
|
...attrs,
|
||||||
|
}, options.map((o) => el('option', { value: o.value, selected: o.value === value }, o.label)));
|
||||||
|
} else if (type === 'textarea') {
|
||||||
|
input = el('textarea', { oninput: (e) => onInput && onInput(e.target.value), placeholder, ...attrs }, value || '');
|
||||||
|
} else {
|
||||||
|
input = el('input', {
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
oninput: (e) => onInput && onInput(e.target.value),
|
||||||
|
placeholder,
|
||||||
|
...attrs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const wrap = el('div', { class: 'field' }, [el('label', {}, label), input]);
|
||||||
|
wrap.input = input;
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown (miroir ExposedDropdownMenuBox) : un <select> stylé suffit côté
|
||||||
|
* web — natif, accessible, sans menu flottant à gérer.
|
||||||
|
* (Voir `field({ options })` — ce wrapper existe pour la lisibilité des écrans.)
|
||||||
|
*/
|
||||||
|
export function dropdown(label, value, options, onSelect) {
|
||||||
|
return field({ label, value, options, onInput: onSelect });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch (miroir Switch M3) avec label cliquable.
|
||||||
|
* @param {string} labelText
|
||||||
|
* @param {boolean} checked
|
||||||
|
* @param {(checked:boolean)=>void} onChange
|
||||||
|
* @returns {HTMLElement} (.input = la checkbox)
|
||||||
|
*/
|
||||||
|
export function switchRow(labelText, checked, onChange) {
|
||||||
|
const input = el('input', { type: 'checkbox' });
|
||||||
|
input.checked = !!checked;
|
||||||
|
input.addEventListener('change', () => onChange(input.checked));
|
||||||
|
return el('div', { class: 'row', style: 'justify-content: space-between;' }, [
|
||||||
|
el('span', {}, labelText),
|
||||||
|
el('label', { class: 'switch' }, [input, el('span', { class: 'slider' })]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// expose la checkbox pour lecture/écriture programmatique
|
||||||
|
// (wrap.querySelector('input') fonctionne aussi)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chips de filtre (miroir FilterChip) : sélection visuelle + callback.
|
||||||
|
* @param {string} label
|
||||||
|
* @param {boolean} selected
|
||||||
|
* @param {()=>void} onClick
|
||||||
|
*/
|
||||||
|
export function filterChip(label, selected, onClick, disabled = false) {
|
||||||
|
return el('span', {
|
||||||
|
class: `chip ${selected ? 'selected' : ''}`,
|
||||||
|
onclick: disabled ? null : onClick,
|
||||||
|
style: disabled ? 'opacity:0.4;cursor:default;' : null,
|
||||||
|
}, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chip assist (non cliquable — affichage, miroir AssistChip).
|
||||||
|
*/
|
||||||
|
export function assistChip(label) {
|
||||||
|
return el('span', { class: 'chip static' }, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bouton rond FAB (+).
|
||||||
|
* @param {()=>void} onClick
|
||||||
|
*/
|
||||||
|
export function fab(onClick) {
|
||||||
|
return el('button', { class: 'fab', 'aria-label': '+', onclick: onClick }, '+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champ date+heure (miroir DateTimeField : DatePicker+TimePicker M3) —
|
||||||
|
* deux inputs natifs côte à côte.
|
||||||
|
*
|
||||||
|
* @param {string} label
|
||||||
|
* @param {number} ms Valeur initiale (epoch ms)
|
||||||
|
* @param {(ms:number|null)=>void} onChange null si la saisie est incomplète
|
||||||
|
* @returns {HTMLElement} (.getMs() : valeur courante)
|
||||||
|
*/
|
||||||
|
export function dateTimeField(label, ms, onChange) {
|
||||||
|
const { date, time } = msToInput(ms);
|
||||||
|
const dateInput = el('input', { type: 'date', value: date });
|
||||||
|
const timeInput = el('input', { type: 'time', value: time });
|
||||||
|
const emit = () => onChange(toMs(dateInput.value, timeInput.value));
|
||||||
|
dateInput.addEventListener('input', emit);
|
||||||
|
timeInput.addEventListener('input', emit);
|
||||||
|
const wrap = el('div', { class: 'field' }, [
|
||||||
|
el('label', {}, label),
|
||||||
|
el('div', { class: 'row' }, [dateInput, timeInput]),
|
||||||
|
]);
|
||||||
|
wrap.getMs = () => toMs(dateInput.value, timeInput.value);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
function msToInput(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return {
|
||||||
|
date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
|
||||||
|
time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
function toMs(dateISO, timeHM) {
|
||||||
|
if (!dateISO || !timeHM) return null;
|
||||||
|
const [y, mo, d] = dateISO.split('-').map(Number);
|
||||||
|
const [h, mi] = timeHM.split(':').map(Number);
|
||||||
|
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
|
||||||
|
return new Date(y, mo - 1, d, h, mi, 0, 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirme une action destructive (miroir AlertDialog de confirmation).
|
||||||
|
* @param {string} text
|
||||||
|
* @param {()=>void} onConfirm
|
||||||
|
*/
|
||||||
|
export function confirmDialog(title, text, onConfirm, confirmLabel) {
|
||||||
|
const current = showDialog({
|
||||||
|
title,
|
||||||
|
body: el('p', {}, text),
|
||||||
|
actions: [
|
||||||
|
{ label: tr('cancel'), className: 'btn text', onClick: () => current.close() },
|
||||||
|
{ label: confirmLabel || tr('delete'), className: 'btn text danger', onClick: () => { current.close(); onConfirm(); } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
243
js/ui/dialogs.js
Normal file
243
js/ui/dialogs.js
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Dialogs de saisie (portage de `ui/components/DoseDialog.kt` et
|
||||||
|
* `LabDialog.kt`).
|
||||||
|
*
|
||||||
|
* DoseDialog :
|
||||||
|
* - create (traitements ACTIFS proposés) / edit (TOUS les traitements —
|
||||||
|
* la dose éditée reste rattachable à son traitement même inactivé, §6.bis) ;
|
||||||
|
* - override d'ESTER par injection (comme le tableur d'origine) : la liste
|
||||||
|
* suit le MODÈLE du traitement via choicesForModel (TFS=7, WHS=6, ESE=3).
|
||||||
|
*
|
||||||
|
* LabDialog (v1.2.2) : prise de sang E2 + T en UNE entrée (chacune
|
||||||
|
* optionnelle) → 1 ou 2 LabResult au même timestamp ; unités T suggérées.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { choicesForModel, esterLabel, isInjectionRoute } from '../data/models.js';
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatDose } from '../util/format.js';
|
||||||
|
import { el, showDialog, field, dateTimeField } from './components.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog dose (create ou edit).
|
||||||
|
*
|
||||||
|
* @param {object} opts
|
||||||
|
* treatments : liste proposée (actifs en création, tous en édition)
|
||||||
|
* preselected : Treatment présélectionné (chips accueil / rappel) ou null
|
||||||
|
* existing : DoseLog en édition, ou null (création)
|
||||||
|
*/
|
||||||
|
export function openDoseDialog({ treatments, preselected = null, existing = null }) {
|
||||||
|
const isEdit = !!existing;
|
||||||
|
|
||||||
|
if (treatments.length === 0) {
|
||||||
|
const dlgEmpty = showDialog({
|
||||||
|
title: t('add_dose'),
|
||||||
|
body: el('p', {}, t('no_treatment_hint')),
|
||||||
|
actions: [{ label: t('ok'), className: 'btn', onClick: () => dlgEmpty.close() }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// État du dialog
|
||||||
|
let selectedId = existing
|
||||||
|
? existing.treatmentId
|
||||||
|
: (preselected && treatments.some((x) => x.id === preselected.id)
|
||||||
|
? preselected.id
|
||||||
|
: (treatments[0] ? treatments[0].id : null));
|
||||||
|
const selected = () => treatments.find((x) => x.id === selectedId) || null;
|
||||||
|
let esterOverride = existing ? existing.esterType : null;
|
||||||
|
|
||||||
|
// ── construction du corps ────────────────────────────────────────────────
|
||||||
|
const body = el('div', {});
|
||||||
|
|
||||||
|
const treatmentSelect = field({
|
||||||
|
label: t('treatment_name'),
|
||||||
|
value: selectedId ? String(selectedId) : '',
|
||||||
|
options: treatments.map((tr) => ({ value: String(tr.id), label: `${tr.name} · ${formatDose(tr.doseAmount)} ${tr.doseUnit}` })),
|
||||||
|
onInput: (v) => {
|
||||||
|
selectedId = Number(v);
|
||||||
|
esterOverride = null; // miroir : changer de traitement réinitialise l'override
|
||||||
|
rebuildEster();
|
||||||
|
amount.input.value = formatDose(selected().doseAmount);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
body.appendChild(treatmentSelect);
|
||||||
|
|
||||||
|
// Override d'ester (seulement si le traitement a un PROFIL PK)
|
||||||
|
const esterWrap = el('div', {});
|
||||||
|
body.appendChild(esterWrap);
|
||||||
|
|
||||||
|
function rebuildEster() {
|
||||||
|
esterWrap.textContent = '';
|
||||||
|
const tr = selected();
|
||||||
|
if (!tr) return;
|
||||||
|
// Propriété dérivée (miroir treatmentFlags) : injection + ester ≠ NONE
|
||||||
|
const usesProfileModel = isInjectionRoute(tr.route) && tr.esterType !== 'NONE';
|
||||||
|
if (!usesProfileModel) return;
|
||||||
|
const choices = [
|
||||||
|
{ value: '', label: t('ester_default', tr.esterType) },
|
||||||
|
...choicesForModel(tr.pkModel).map((e) => ({ value: e, label: esterLabel(e) })),
|
||||||
|
];
|
||||||
|
esterWrap.appendChild(field({
|
||||||
|
label: t('ester'),
|
||||||
|
value: esterOverride || '',
|
||||||
|
options: choices,
|
||||||
|
onInput: (v) => { esterOverride = v === '' ? null : v; },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = field({
|
||||||
|
label: `${t('dose_amount')} (${selected() ? selected().doseUnit : ''})`,
|
||||||
|
type: 'number',
|
||||||
|
value: formatDose(existing ? existing.doseAmount : (selected() ? selected().doseAmount : 0)),
|
||||||
|
attrs: { step: 'any', min: '0', inputmode: 'decimal' },
|
||||||
|
});
|
||||||
|
body.appendChild(amount);
|
||||||
|
|
||||||
|
const dt = dateTimeField(t('dose_time'), existing ? existing.timestamp : Date.now(), () => {});
|
||||||
|
body.appendChild(dt);
|
||||||
|
|
||||||
|
const notes = field({ label: t('dose_notes'), type: 'textarea', value: existing ? existing.notes || '' : '' });
|
||||||
|
body.appendChild(notes);
|
||||||
|
|
||||||
|
rebuildEster();
|
||||||
|
|
||||||
|
const dlg = showDialog({
|
||||||
|
title: isEdit ? t('edit_dose') : t('add_dose'),
|
||||||
|
body,
|
||||||
|
actions: [
|
||||||
|
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
|
||||||
|
{
|
||||||
|
label: t('save'),
|
||||||
|
className: 'btn',
|
||||||
|
onClick: () => {
|
||||||
|
const tr = selected();
|
||||||
|
const value = Number(String(amount.input.value).replace(',', '.'));
|
||||||
|
const ts = dt.getMs();
|
||||||
|
if (!tr || Number.isNaN(value) || value <= 0 || ts === null) return; // garde de saisie
|
||||||
|
const data = {
|
||||||
|
treatmentId: tr.id,
|
||||||
|
timestamp: ts,
|
||||||
|
doseAmount: value,
|
||||||
|
notes: notes.input.value.trim() === '' ? null : notes.input.value,
|
||||||
|
esterType: esterOverride,
|
||||||
|
};
|
||||||
|
if (isEdit) {
|
||||||
|
store.upsertDoseLog({ ...existing, ...data });
|
||||||
|
} else {
|
||||||
|
store.upsertDoseLog(data);
|
||||||
|
}
|
||||||
|
dlg.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog lab : prise de sang E2 + T en UNE entrée (chacune optionnelle).
|
||||||
|
*
|
||||||
|
* @param {object} opts { existing: LabResult|null }
|
||||||
|
* - create : deux sections optionnelles → 1 ou 2 LabResult au même timestamp ;
|
||||||
|
* - edit : une seule entrée (le regroupement E2+T reste intact).
|
||||||
|
*/
|
||||||
|
export function openLabDialog({ existing = null }) {
|
||||||
|
const isEdit = !!existing;
|
||||||
|
|
||||||
|
const body = el('div', {});
|
||||||
|
const dt = dateTimeField(t('lab_timestamp'), existing ? existing.timestamp : Date.now(), () => {});
|
||||||
|
body.appendChild(dt);
|
||||||
|
|
||||||
|
// Suggestions d'unités T (miroir LabDialog Android) : ng/mL, ng/dL, ng/L,
|
||||||
|
// nmol/L — la conversion vers ng/mL se fait à la calibration et au rendu.
|
||||||
|
const UNIT_OPTIONS = ['pg/mL', 'ng/mL', 'ng/dL', 'ng/L', 'nmol/L', 'mIU/L'];
|
||||||
|
|
||||||
|
let e2Input = null;
|
||||||
|
let e2Notes = null;
|
||||||
|
let tInput = null;
|
||||||
|
let tUnit = null;
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
const isT = String(existing.marker).toUpperCase() === 'T';
|
||||||
|
const valueField = field({
|
||||||
|
label: isT ? t('lab_t_value') : t('lab_e2_value'),
|
||||||
|
type: 'number',
|
||||||
|
value: String(existing.value),
|
||||||
|
attrs: { step: 'any', inputmode: 'decimal' },
|
||||||
|
});
|
||||||
|
body.appendChild(valueField);
|
||||||
|
if (isT) {
|
||||||
|
tInput = valueField.input;
|
||||||
|
const unitSel = field({
|
||||||
|
label: t('dose_unit'),
|
||||||
|
value: existing.unit,
|
||||||
|
options: UNIT_OPTIONS.map((u) => ({ value: u, label: u })),
|
||||||
|
});
|
||||||
|
body.appendChild(unitSel);
|
||||||
|
tUnit = unitSel.input;
|
||||||
|
} else {
|
||||||
|
e2Input = valueField.input;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Section E2 (optionnelle) — unité fixée pg/mL (marker E2 = estradiol)
|
||||||
|
const e2Field = field({ label: `${t('lab_e2_value')} (${t('lab_optional')})`, type: 'number', attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
body.appendChild(e2Field);
|
||||||
|
e2Input = e2Field.input;
|
||||||
|
|
||||||
|
// Section T (optionnelle) avec choix d'unité
|
||||||
|
const tField = field({ label: `${t('lab_t_value')} (${t('lab_optional')})`, type: 'number', attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
body.appendChild(tField);
|
||||||
|
tInput = tField.input;
|
||||||
|
const unitSel = field({
|
||||||
|
label: `${t('dose_unit')} — ${t('lab_units_hint')}`,
|
||||||
|
value: 'ng/mL',
|
||||||
|
options: UNIT_OPTIONS.map((u) => ({ value: u, label: u })),
|
||||||
|
});
|
||||||
|
body.appendChild(unitSel);
|
||||||
|
tUnit = unitSel.input;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes = field({ label: t('lab_notes'), type: 'textarea', value: existing ? existing.notes || '' : '' });
|
||||||
|
body.appendChild(notes);
|
||||||
|
|
||||||
|
const dlg = showDialog({
|
||||||
|
title: isEdit ? t('edit_which_lab') : t('add_lab'),
|
||||||
|
body,
|
||||||
|
actions: [
|
||||||
|
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
|
||||||
|
{
|
||||||
|
label: t('save'),
|
||||||
|
className: 'btn',
|
||||||
|
onClick: () => {
|
||||||
|
const ts = dt.getMs();
|
||||||
|
if (ts === null) return;
|
||||||
|
const notesVal = notes.input.value.trim() === '' ? null : notes.input.value;
|
||||||
|
if (isEdit) {
|
||||||
|
const value = Number(String((tInput || e2Input).value).replace(',', '.'));
|
||||||
|
if (Number.isNaN(value)) return;
|
||||||
|
store.upsertLabResult({
|
||||||
|
...existing,
|
||||||
|
value,
|
||||||
|
unit: tInput ? tUnit.value : existing.unit,
|
||||||
|
timestamp: ts,
|
||||||
|
notes: notesVal,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const e2Val = String(e2Input.value).trim() === '' ? null : Number(e2Input.value.replace(',', '.'));
|
||||||
|
const tVal = String(tInput.value).trim() === '' ? null : Number(tInput.value.replace(',', '.'));
|
||||||
|
if (e2Val === null && tVal === null) return; // au moins une valeur
|
||||||
|
if (e2Val !== null && !Number.isNaN(e2Val)) {
|
||||||
|
store.upsertLabResult({ marker: 'E2', value: e2Val, unit: 'pg/mL', timestamp: ts, notes: notesVal });
|
||||||
|
}
|
||||||
|
if (tVal !== null && !Number.isNaN(tVal)) {
|
||||||
|
store.upsertLabResult({ marker: 'T', value: tVal, unit: tUnit.value, timestamp: ts, notes: notesVal });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dlg.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
122
js/ui/doses.js
Normal file
122
js/ui/doses.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Écran Doses (portage de `ui/screens/DosesScreen.kt`).
|
||||||
|
*
|
||||||
|
* - en-tête « temps sous THS » (v1.3.1) : depuis la 1re prise, jours
|
||||||
|
* totaux + décomposition mois(30 j)/jours — ⚠️ la string hrt_duration a
|
||||||
|
* TROIS placeholders (mois, jours, total) : les trois sont passés (bug
|
||||||
|
* Android #47 = crash MissingFormatArgumentException) ;
|
||||||
|
* - liste groupée par jour (desc) ;
|
||||||
|
* - Δ jours depuis la dose PRÉCÉDENTE du MÊME traitement (colonne
|
||||||
|
* « Interval (d) » du .ods) ;
|
||||||
|
* - tap ligne → édition (TOUS les traitements proposés — §6.bis) ;
|
||||||
|
* - suppression avec confirmation.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration } from '../util/format.js';
|
||||||
|
import { el, assistChip, confirmDialog, fab } from './components.js';
|
||||||
|
import { openDoseDialog } from './dialogs.js';
|
||||||
|
|
||||||
|
const DAY_MS = 86400000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intervalle (en jours) entre chaque dose et la dose précédente du MÊME
|
||||||
|
* traitement (miroir intervalsByDoseId) — clé = id de la dose.
|
||||||
|
*
|
||||||
|
* @param {object[]} doseLogs
|
||||||
|
* @returns {Map<number,number>} id → jours (décimaux)
|
||||||
|
*/
|
||||||
|
export function intervalsByDoseId(doseLogs) {
|
||||||
|
const byTreatment = new Map();
|
||||||
|
for (const d of doseLogs.slice().sort((a, b) => a.timestamp - b.timestamp)) {
|
||||||
|
if (!byTreatment.has(d.treatmentId)) byTreatment.set(d.treatmentId, []);
|
||||||
|
byTreatment.get(d.treatmentId).push(d);
|
||||||
|
}
|
||||||
|
const out = new Map();
|
||||||
|
for (const logs of byTreatment.values()) {
|
||||||
|
for (let i = 1; i < logs.length; i++) {
|
||||||
|
out.set(logs[i].id, (logs[i].timestamp - logs[i - 1].timestamp) / DAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'écran Doses.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
export function renderDoses(container) {
|
||||||
|
const doseLogs = store.getDoseLogs().slice().sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
const treatments = store.getTreatments(); // TOUS (édition d'une dose inactivée OK)
|
||||||
|
const treatmentMap = new Map(treatments.map((tr) => [tr.id, tr]));
|
||||||
|
const intervals = intervalsByDoseId(doseLogs);
|
||||||
|
|
||||||
|
const col = el('div', {});
|
||||||
|
container.appendChild(col);
|
||||||
|
|
||||||
|
// ── En-tête « temps sous THS » (v1.3.1) ──────────────────────────────────
|
||||||
|
if (doseLogs.length > 0) {
|
||||||
|
const first = Math.min(...doseLogs.map((d) => d.timestamp));
|
||||||
|
const card = el('div', { class: 'card primary' });
|
||||||
|
card.appendChild(el('div', { style: 'font-weight:600;' }, t('hrt_since', formatLongDate(first))));
|
||||||
|
const totalDays = HrtDuration.daysSince(first, Date.now());
|
||||||
|
if (totalDays > 0) {
|
||||||
|
const [months, days] = HrtDuration.monthsAndDays(totalDays);
|
||||||
|
// ⚠️ hrt_duration a TROIS placeholders (%1 mois, %2 jours, %3 total) :
|
||||||
|
// passer aussi le total, sinon crash Android #47 (épinglé ici aussi).
|
||||||
|
card.appendChild(el('div', {},
|
||||||
|
months > 0 ? t('hrt_duration', months, days, totalDays) : t('hrt_duration_days_only', days)));
|
||||||
|
}
|
||||||
|
col.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doseLogs.length === 0) {
|
||||||
|
col.appendChild(el('p', {}, t('no_data')));
|
||||||
|
container.appendChild(fab(() => openDoseDialog({ treatments: treatments.filter((x) => x.isActive), preselected: null, existing: null })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Groupement par jour (desc) ───────────────────────────────────────────
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const log of doseLogs) {
|
||||||
|
const key = formatISODate(log.timestamp);
|
||||||
|
if (!grouped.has(key)) grouped.set(key, []);
|
||||||
|
grouped.get(key).push(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dateKey, logs] of grouped) {
|
||||||
|
col.appendChild(el('div', { class: 'group-header' }, dateKey));
|
||||||
|
for (const log of logs) {
|
||||||
|
const tr = treatmentMap.get(log.treatmentId);
|
||||||
|
const item = el('div', { class: 'list-item', onclick: () => openDoseDialog({ treatments, preselected: null, existing: log }) });
|
||||||
|
const main = el('div', { class: 'main' });
|
||||||
|
main.appendChild(el('div', { class: 'title' },
|
||||||
|
`${tr ? tr.name : '?'} · ${formatDose(log.doseAmount)} ${tr ? tr.doseUnit : ''}`));
|
||||||
|
const sub = el('div', { class: 'sub' });
|
||||||
|
sub.appendChild(el('span', {}, formatTime(log.timestamp)));
|
||||||
|
// Intervalle depuis la dose précédente du même traitement
|
||||||
|
const iv = intervals.get(log.id);
|
||||||
|
if (iv !== undefined) sub.appendChild(el('span', {}, t('interval_value', iv.toFixed(1))));
|
||||||
|
// Override d'ester de cette injection (chip, comme Android)
|
||||||
|
if (log.esterType && log.esterType !== '' && log.esterType !== 'NONE') {
|
||||||
|
sub.appendChild(assistChip(log.esterType));
|
||||||
|
}
|
||||||
|
if (log.notes && log.notes.trim() !== '') sub.appendChild(el('span', {}, log.notes));
|
||||||
|
main.appendChild(sub);
|
||||||
|
item.appendChild(main);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
item.appendChild(del);
|
||||||
|
col.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAB : création (traitements ACTIFS uniquement — miroir DosesScreen)
|
||||||
|
container.appendChild(fab(() => openDoseDialog({ treatments: treatments.filter((x) => x.isActive), preselected: null, existing: null })));
|
||||||
|
}
|
||||||
183
js/ui/home.js
Normal file
183
js/ui/home.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Écran Accueil (portage de `ui/screens/HomeScreen.kt`).
|
||||||
|
*
|
||||||
|
* - carte « niveau actuel » : E2 ≈ X pg/mL + T ≈ Y ng/mL + delta vs 6 h
|
||||||
|
* (v1.4.3, fix #53 : point le plus PROCHE de −6 h via pointHoursBefore) ;
|
||||||
|
* - cartes d'alerte si un seuil est franchi (v1.4.2, évaluées sur le
|
||||||
|
* DERNIER point de courbe = le niveau affiché) ;
|
||||||
|
* - carte « prochaine dose » : heures/min sous 24 h, JOURS au-delà (v1.4.1),
|
||||||
|
* grille Posologie via le moteur (fix #52) ;
|
||||||
|
* - chips de log rapide (traitements ACTIFS uniquement — l'inactivation ne
|
||||||
|
* retire JAMAIS un traitement de la simulation, seulement de la saisie) ;
|
||||||
|
* - mini-chart 24 h cliquable → écran Graphiques.
|
||||||
|
*
|
||||||
|
* ⚠️ La simulation reçoit TOUS les traitements (actifs ET inactifs) : bug
|
||||||
|
* v1.2.4 — « isActive » est un drapeau administratif, jamais un filtre de
|
||||||
|
* données (cf régression #3).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore } from '../pk/index.js';
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatDose, formatValue, formatTime, formatWeekdayTime, HrtDuration } from '../util/format.js';
|
||||||
|
import { el } from './components.js';
|
||||||
|
import { drawChart } from './chart-canvas.js';
|
||||||
|
import { openDoseDialog } from './dialogs.js';
|
||||||
|
|
||||||
|
const HOUR_MS = 3600000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'écran accueil dans [container].
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} ctx { nowMs, openChart, openTreatment }
|
||||||
|
*/
|
||||||
|
export function renderHome(container, ctx) {
|
||||||
|
const treatments = store.getTreatments(); // TOUS (actifs + inactifs)
|
||||||
|
const activeTreatments = treatments.filter((tr) => tr.isActive);
|
||||||
|
const doseLogs = store.getDoseLogs();
|
||||||
|
const labResults = store.getLabResults();
|
||||||
|
const tConfig = new TConfig(store.getTConfig().base, store.getTConfig().floor, store.getTConfig().k);
|
||||||
|
const settings = store.getSettings();
|
||||||
|
const nowMs = ctx.nowMs;
|
||||||
|
|
||||||
|
// Auto-calibration (v1.2.1) : facteurs PAR PÉRIODE D'ESTER + k T, recalculés
|
||||||
|
// à la volée depuis les labs. null = option désactivée (échelles stockées).
|
||||||
|
// Home calibre avec le modèle STOCKÉ (pas d'override — le graphique, lui,
|
||||||
|
// calcule une calibration PAR modèle affiché, fix #60).
|
||||||
|
let effectiveAuto = null;
|
||||||
|
if (settings.autoCalibrate) {
|
||||||
|
effectiveAuto = autoCalibrated(treatments, doseLogs, labResults, tConfig, null);
|
||||||
|
}
|
||||||
|
const effTreatments = effectiveAuto ? effectiveAuto.treatments : treatments;
|
||||||
|
const effTConfig = effectiveAuto ? effectiveAuto.tConfig : tConfig;
|
||||||
|
const scalePerEster = effectiveAuto ? effectiveAuto.esterScales : null;
|
||||||
|
const tKPerEster = effectiveAuto ? effectiveAuto.tKPerEster : null;
|
||||||
|
|
||||||
|
// Courbe 24 h (grille horaire — le mini-chart de l'accueil n'a pas besoin
|
||||||
|
// du pas adaptatif du graphique complet)
|
||||||
|
const curve = computeCurve(
|
||||||
|
effTreatments, doseLogs, nowMs - 24 * HOUR_MS, nowMs, HOUR_MS, effTConfig,
|
||||||
|
{ scalePerEster, tKPerEster },
|
||||||
|
);
|
||||||
|
|
||||||
|
const col = el('div', {});
|
||||||
|
|
||||||
|
// ── Carte « niveau actuel » ──────────────────────────────────────────────
|
||||||
|
const now = curve[curve.length - 1] || null;
|
||||||
|
// v1.4.3 (fix #53) : le point de comparaison « il y a 6 h » est celui le
|
||||||
|
// plus PROCHE de −6 h (l'ancien code prenait le PREMIER point de la
|
||||||
|
// fenêtre 24 h → le delta affiché était en réalité un delta 24 h).
|
||||||
|
const sixHAgo = pointHoursBefore(curve, 6.0);
|
||||||
|
const levelCard = el('div', { class: 'card primary' });
|
||||||
|
levelCard.appendChild(el('div', { class: 'label' }, t('current_level')));
|
||||||
|
if (!now) {
|
||||||
|
levelCard.appendChild(el('div', {}, t('no_data_short')));
|
||||||
|
} else {
|
||||||
|
levelCard.appendChild(el('div', { class: 'row' }, [
|
||||||
|
el('span', { class: 'big-number' }, `≈ ${formatValue(now.e2)}`),
|
||||||
|
el('span', { class: 'unit' }, ' pg/mL'),
|
||||||
|
el('span', { class: 't-inline' }, ` T ≈ ${formatValue(now.t)} ng/mL`),
|
||||||
|
]));
|
||||||
|
if (sixHAgo) {
|
||||||
|
const delta = now.e2 - sixHAgo.e2;
|
||||||
|
const arrow = delta >= 0 ? '↗' : '↘';
|
||||||
|
levelCard.appendChild(el('div', { class: delta >= 0 ? 'delta-up' : 'delta-down' },
|
||||||
|
t('delta_6h', arrow, formatValue(Math.abs(delta)))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col.appendChild(levelCard);
|
||||||
|
|
||||||
|
// ── Cartes d'alerte (v1.4.2) : taux ESTIMÉ actuel vs seuils configurés ────
|
||||||
|
if (now) {
|
||||||
|
const thresholds = new Alerts.Thresholds(
|
||||||
|
settings.alertE2High, settings.alertE2Low, settings.alertTHigh, settings.alertTLow,
|
||||||
|
);
|
||||||
|
const alerts = Alerts.evaluateAll(now.e2, now.t, thresholds);
|
||||||
|
if (alerts.length) {
|
||||||
|
const alertCard = el('div', { class: 'card error' });
|
||||||
|
alertCard.appendChild(el('h3', {}, t('alert_card_title')));
|
||||||
|
for (const a of alerts) {
|
||||||
|
alertCard.appendChild(el('div', {},
|
||||||
|
a.level === 'HIGH'
|
||||||
|
? t('alert_high', a.marker, formatDose(a.value), a.unit, formatDose(a.limit))
|
||||||
|
: t('alert_low', a.marker, formatDose(a.value), a.unit, formatDose(a.limit))));
|
||||||
|
}
|
||||||
|
alertCard.appendChild(el('div', { class: 'small' }, t('alert_disclaimer')));
|
||||||
|
col.appendChild(alertCard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Carte « prochaine dose » (grille Posologie via le moteur, fix #52) ───
|
||||||
|
const fires = [];
|
||||||
|
for (const tr of treatments) {
|
||||||
|
const fire = nextReminderFireFor(tr, doseLogs, nowMs);
|
||||||
|
if (fire !== null) fires.push([tr, fire]);
|
||||||
|
}
|
||||||
|
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('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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log rapide : chips des traitements ACTIFS uniquement (saisie) ────────
|
||||||
|
const quickCard = el('div', { class: 'card variant' });
|
||||||
|
quickCard.appendChild(el('h3', {}, t('logged_today')));
|
||||||
|
const chipRow = el('div', { class: 'chip-row' });
|
||||||
|
for (const tr of activeTreatments) {
|
||||||
|
chipRow.appendChild(el('span', {
|
||||||
|
class: 'chip',
|
||||||
|
onclick: () => openDoseDialog({ treatments: activeTreatments, preselected: tr, existing: null }),
|
||||||
|
}, `${tr.name} · ${formatDose(tr.doseAmount)}${tr.doseUnit}`));
|
||||||
|
}
|
||||||
|
chipRow.appendChild(el('button', {
|
||||||
|
class: 'chip',
|
||||||
|
onclick: () => openDoseDialog({ treatments: activeTreatments, preselected: null, existing: null }),
|
||||||
|
}, '+'));
|
||||||
|
quickCard.appendChild(chipRow);
|
||||||
|
col.appendChild(quickCard);
|
||||||
|
|
||||||
|
// ── Mini-chart 24 h cliquable → écran Graphiques ─────────────────────────
|
||||||
|
const chartCard = el('div', { class: 'card', style: 'cursor:pointer;', onclick: ctx.openChart });
|
||||||
|
chartCard.appendChild(el('h3', {}, t('home_chart_title')));
|
||||||
|
const canvas = el('canvas', { class: 'chart', style: 'height:180px;' });
|
||||||
|
chartCard.appendChild(canvas);
|
||||||
|
// Labs limités à la fenêtre de la courbe (miroir filterInRange)
|
||||||
|
if (curve.length >= 2) {
|
||||||
|
const a = curve[0].timestamp;
|
||||||
|
const b = curve[curve.length - 1].timestamp;
|
||||||
|
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
|
||||||
|
const tLabs = labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b);
|
||||||
|
// Dessin différé : le canvas doit être dans le DOM pour connaître sa taille
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
drawChart(canvas, [{ points: curve, e2Style: { color: '#4f5bd5', dashed: false }, tStyle: { color: '#d6589e', dashed: false } }],
|
||||||
|
e2Labs, tLabs, { showT: true, showLabs: true, showExtrema: false, nowMs }, [], [], null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#4f5bd5;' }, t('home_legend_e2')));
|
||||||
|
chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#d6589e;' }, t('home_legend_t')));
|
||||||
|
chartCard.appendChild(el('div', { class: 'legend-muted' }, t('open_charts_hint')));
|
||||||
|
col.appendChild(chartCard);
|
||||||
|
|
||||||
|
col.appendChild(el('div', { class: 'hint' }, t('disclaimer')));
|
||||||
|
|
||||||
|
container.appendChild(col);
|
||||||
|
}
|
||||||
116
js/ui/labs.js
Normal file
116
js/ui/labs.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Écran Analyses (portage de `ui/screens/LabsScreen.kt`, v1.2.2).
|
||||||
|
*
|
||||||
|
* - prise de sang E2 + T en UNE entrée → affichage GROUPÉ par timestamp :
|
||||||
|
* « E2 306 pg/mL · T 44 ng/dL » côte à côte (E2 avant T, autres ensuite) ;
|
||||||
|
* - tap ligne → sélecteur E2/T si paire, édition unitaire pré-remplie ;
|
||||||
|
* - suppression = la prise ENTIÈRE (les labs sont prélevés ensemble — choix
|
||||||
|
* de design confirmé côté Android) ;
|
||||||
|
* - les labos T peuvent être en ng/mL, ng/dL, ng/L, nmol/L (conversion au
|
||||||
|
* rendu et à la calibration — bug #23).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatLabValue, formatDateTime } from '../util/format.js';
|
||||||
|
import { el, confirmDialog, fab, showDialog } from './components.js';
|
||||||
|
import { openLabDialog } from './dialogs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regroupe les labs par timestamp (une « prise de sang » = E2 + T prises au
|
||||||
|
* même moment). Ordre d'affichage dans un groupe : E2 avant T, autres
|
||||||
|
* marqueurs ensuite. Tri par date décroissante. (Miroir groupLabsForDisplay —
|
||||||
|
* exporté pour les tests.)
|
||||||
|
*
|
||||||
|
* @param {object[]} labs
|
||||||
|
* @returns {Array<[number, object[]]>} (timestamp, labs du groupe)
|
||||||
|
*/
|
||||||
|
export function groupLabsForDisplay(labs) {
|
||||||
|
const byTs = new Map();
|
||||||
|
for (const lab of labs) {
|
||||||
|
if (!byTs.has(lab.timestamp)) byTs.set(lab.timestamp, []);
|
||||||
|
byTs.get(lab.timestamp).push(lab);
|
||||||
|
}
|
||||||
|
const order = (marker) => {
|
||||||
|
const m = String(marker).toUpperCase();
|
||||||
|
if (m === 'E2') return 0;
|
||||||
|
if (m === 'T') return 1;
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
return [...byTs.entries()]
|
||||||
|
.map(([ts, group]) => [ts, group.slice().sort((a, b) => order(a.marker) - order(b.marker))])
|
||||||
|
.sort((a, b) => b[0] - a[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'écran Analyses.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
export function renderLabs(container) {
|
||||||
|
const labs = store.getLabResults();
|
||||||
|
const grouped = groupLabsForDisplay(labs);
|
||||||
|
|
||||||
|
const col = el('div', {});
|
||||||
|
container.appendChild(col);
|
||||||
|
|
||||||
|
if (grouped.length === 0) {
|
||||||
|
col.appendChild(el('p', {}, t('no_data')));
|
||||||
|
container.appendChild(fab(() => openLabDialog({ existing: null })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ts, group] of grouped) {
|
||||||
|
// Affichage côte à côte : « E2 306 pg/mL · T 45 ng/dL »
|
||||||
|
const headline = group
|
||||||
|
.map((lab) => `${String(lab.marker).toUpperCase()} ${formatLabValue(lab.value)} ${lab.unit}`)
|
||||||
|
.join(' · ');
|
||||||
|
const item = el('div', { class: 'list-item' });
|
||||||
|
const main = el('div', { class: 'main' });
|
||||||
|
main.appendChild(el('div', { class: 'title' }, headline));
|
||||||
|
main.appendChild(el('div', { class: 'sub' }, formatDateTime(ts)));
|
||||||
|
const firstNotes = group.map((g) => g.notes).find((n) => n && n.trim() !== '');
|
||||||
|
if (firstNotes) main.appendChild(el('div', { class: 'sub' }, firstNotes));
|
||||||
|
item.appendChild(main);
|
||||||
|
|
||||||
|
// Tap → sélecteur E2/T si paire, édition directe sinon
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
if (group.length > 1) openChooser(group);
|
||||||
|
else openLabDialog({ existing: group[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = el('button', { class: 'icon-btn', 'aria-label': t('delete') }, '🗑');
|
||||||
|
del.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Suppression = la prise de sang ENTIÈRE (confirm nommant les valeurs)
|
||||||
|
const named = group.map((l) => `${String(l.marker).toUpperCase()} ${formatLabValue(l.value)}`).join(' · ');
|
||||||
|
confirmDialog(t('delete'), t('confirm_delete_blood_draw', named), () => {
|
||||||
|
for (const lab of group) store.deleteLabResult(lab.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
item.appendChild(del);
|
||||||
|
col.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(fab(() => openLabDialog({ existing: null })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélecteur « quelle entrée modifier ? » quand la prise contient E2 + T. */
|
||||||
|
function openChooser(group) {
|
||||||
|
const body = el('div', {});
|
||||||
|
for (const lab of group) {
|
||||||
|
body.appendChild(el('button', {
|
||||||
|
class: 'btn text',
|
||||||
|
onclick: () => {
|
||||||
|
dlg.close();
|
||||||
|
openLabDialog({ existing: lab });
|
||||||
|
},
|
||||||
|
}, `${String(lab.marker).toUpperCase()} · ${formatLabValue(lab.value)} ${lab.unit}`));
|
||||||
|
}
|
||||||
|
const dlg = showDialog({
|
||||||
|
title: t('edit_which_lab'),
|
||||||
|
body,
|
||||||
|
actions: [{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() }],
|
||||||
|
});
|
||||||
|
}
|
||||||
209
js/ui/reminders.js
Normal file
209
js/ui/reminders.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Rappels + alertes — côté WEB (portage adapté de reminder/ReminderManager.kt
|
||||||
|
* + AlertNotifier.kt + AlertWorker.kt).
|
||||||
|
*
|
||||||
|
* ⚠️ DIFFÉRENCES STRUCTURELLES vs Android (documentées, pas des bugs) :
|
||||||
|
* - Android : AlarmManager exact + reprogrammation par les receivers, même
|
||||||
|
* app fermée. Web : AUCUN scheduler système — une boucle `setInterval`
|
||||||
|
* vérifie le prochain déclenchement via le MÊME moteur
|
||||||
|
* (nextReminderFireFor) tant que la page est ouverte. Onglet fermé = pas
|
||||||
|
* de notification (limite documentée).
|
||||||
|
* - Android : WorkManager périodique 15 min pour les seuils d'alerte. Web :
|
||||||
|
* la même boucle vérifie les seuils toutes les 5 min (anti-spam par état
|
||||||
|
* persisté identique : Alerts.encodeState / shouldNotify).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nextReminderFireMs, nextReminderFireFor, levelAt, autoCalibrated, TConfig, Alerts } from '../pk/index.js';
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatDose, formatTime } from '../util/format.js';
|
||||||
|
import * as AppLog from '../util/app-log.js';
|
||||||
|
|
||||||
|
/** Période de vérification des rappels (ms) — la grille du moteur est à la minute. */
|
||||||
|
const REMINDER_CHECK_MS = 30000;
|
||||||
|
/** Période de vérification des seuils d'alerte (ms) — 5 min (Android : 15 min). */
|
||||||
|
const ALERT_CHECK_MS = 300000;
|
||||||
|
|
||||||
|
/** Rappels déjà notifiés (id de traitement → timestamp notifié) pour éviter les doublons. */
|
||||||
|
const notifiedReminders = new Map();
|
||||||
|
|
||||||
|
/** Timer pour un rappel imminent (< 1 min) — permet un déclenchement précis. */
|
||||||
|
let imminentTimer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la boucle de vérification (appelée une fois au démarrage de l'app).
|
||||||
|
* Un seul timer de polling + un timer « imminent » re-programmable.
|
||||||
|
*/
|
||||||
|
export function startReminderLoop(onOpenLogDose) {
|
||||||
|
AppLog.log('rappels', 'boucle web démarrée (rappels 30 s, seuils 5 min)');
|
||||||
|
|
||||||
|
setInterval(() => checkReminders(onOpenLogDose), REMINDER_CHECK_MS);
|
||||||
|
setInterval(checkAlertsNow, ALERT_CHECK_MS);
|
||||||
|
|
||||||
|
// Premier passage immédiat
|
||||||
|
setTimeout(() => checkReminders(onOpenLogDose), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie le prochain rappel ; si dû (dans la minute), déclenche la
|
||||||
|
* notification navigateur + une bannière in-app avec actions.
|
||||||
|
*
|
||||||
|
* @param {(treatmentId:number)=>void} onOpenLogDose Ouvre le dialog de log pré-rempli
|
||||||
|
*/
|
||||||
|
function checkReminders(onOpenLogDose) {
|
||||||
|
try {
|
||||||
|
const treatments = store.getTreatments();
|
||||||
|
const doseLogs = store.getDoseLogs();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
// Le MÊME moteur que la carte « Prochaine dose » (grille Posologie, #52)
|
||||||
|
const nextMs = nextReminderFireMs(treatments, doseLogs, nowMs);
|
||||||
|
if (nextMs === null) return;
|
||||||
|
|
||||||
|
// Trouve le(s) traitement(s) concerné(s) — le min de nextReminderFireFor
|
||||||
|
for (const tr of treatments) {
|
||||||
|
const fire = nextReminderFireFor(tr, doseLogs, nowMs);
|
||||||
|
if (fire === null) continue;
|
||||||
|
const due = fire - nowMs;
|
||||||
|
const key = `${tr.id}`;
|
||||||
|
// Dû à moins de 30 s (ou déjà passé de peu) ET pas déjà notifié pour ce créneau
|
||||||
|
if (due <= REMINDER_CHECK_MS && notifiedReminders.get(key) !== fire) {
|
||||||
|
notifiedReminders.set(key, fire);
|
||||||
|
fireReminder(tr, fire, onOpenLogDose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer précis si le rappel est imminent (< 1 min) : re-vérifie à l'heure
|
||||||
|
if (imminentTimer) clearTimeout(imminentTimer);
|
||||||
|
const delta = nextMs - Date.now();
|
||||||
|
if (delta > 0 && delta < 60000) {
|
||||||
|
imminentTimer = setTimeout(() => checkReminders(onOpenLogDose), Math.max(delta, 1000));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// La boucle ne doit JAMAIS tuer l'app (miroir AppLog fail-safe)
|
||||||
|
AppLog.log('rappels', `ERREUR boucle : ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche un rappel : notification navigateur (si permission) + bannière
|
||||||
|
* in-app avec « Loguer maintenant » / « Reporter 1 h » (miroir des actions
|
||||||
|
* de la notification Android : Pris / Reporter 1 h — « Pris » ouvre le
|
||||||
|
* dialog pré-rempli plutôt que d'insérer aveuglément).
|
||||||
|
*/
|
||||||
|
function fireReminder(treatment, fireMs, onOpenLogDose) {
|
||||||
|
const title = t('reminder_notif_title');
|
||||||
|
const body = t('reminder_notif_body', treatment.name, formatDose(treatment.doseAmount), treatment.doseUnit, formatTime(fireMs));
|
||||||
|
AppLog.log('rappels', `rappel traitement #${treatment.id} (${treatment.name}) à ${formatTime(fireMs)}`);
|
||||||
|
|
||||||
|
// 1) Notification navigateur (graceful : permission peut être absente)
|
||||||
|
try {
|
||||||
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
|
const notif = new Notification(title, { body, tag: `hormonetrack-reminder-${treatment.id}` });
|
||||||
|
notif.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
onOpenLogDose(treatment.id);
|
||||||
|
notif.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.log('rappels', `notification impossible : ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Bannière in-app (fonctionne même sans permission — l'utilisateur voit)
|
||||||
|
showReminderBanner(treatment, onOpenLogDose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bannière in-app persistante (jusqu'à action) avec les 2 actions. */
|
||||||
|
function showReminderBanner(treatment, onOpenLogDose) {
|
||||||
|
const id = `reminder-banner-${treatment.id}`;
|
||||||
|
if (document.getElementById(id)) return; // déjà affichée
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = id;
|
||||||
|
banner.className = 'reminder-banner';
|
||||||
|
banner.innerHTML = '';
|
||||||
|
banner.appendChild(document.createTextNode(
|
||||||
|
`⏰ ${treatment.name} · ${formatDose(treatment.doseAmount)}${treatment.doseUnit}`,
|
||||||
|
));
|
||||||
|
const logBtn = document.createElement('button');
|
||||||
|
logBtn.className = 'btn tonal';
|
||||||
|
logBtn.textContent = t('reminder_log_now');
|
||||||
|
logBtn.addEventListener('click', () => {
|
||||||
|
banner.remove();
|
||||||
|
onOpenLogDose(treatment.id);
|
||||||
|
});
|
||||||
|
const snoozeBtn = document.createElement('button');
|
||||||
|
snoozeBtn.className = 'btn text';
|
||||||
|
snoozeBtn.textContent = t('reminder_snooze');
|
||||||
|
snoozeBtn.addEventListener('click', () => {
|
||||||
|
banner.remove();
|
||||||
|
// « Reporter 1 h » (miroir scheduleSnooze) : mémorise un délai, la boucle
|
||||||
|
// re-notifiera dans 1 h via une entrée dans notifiedReminders décalée
|
||||||
|
setTimeout(() => fireReminder(treatment, Date.now() + 3600000 - 1, onOpenLogDose), 3600000);
|
||||||
|
AppLog.log('rappels', `report 1 h : traitement #${treatment.id}`);
|
||||||
|
});
|
||||||
|
banner.appendChild(logBtn);
|
||||||
|
banner.appendChild(snoozeBtn);
|
||||||
|
const main = document.getElementById('banner-slot');
|
||||||
|
if (main) main.appendChild(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie les seuils d'alerte MAINTENANT (au save des seuils + toutes les
|
||||||
|
* 5 min) — portage direct d'AlertNotifier.checkNow / AlertWorker :
|
||||||
|
* - niveau calculé par le MÊME calibration que la carte accueil ;
|
||||||
|
* - anti-spam par état persisté (Alerts.encodeState / shouldNotify).
|
||||||
|
*/
|
||||||
|
export function checkAlertsNow() {
|
||||||
|
try {
|
||||||
|
const settings = store.getSettings();
|
||||||
|
const thresholds = new Alerts.Thresholds(
|
||||||
|
settings.alertE2High, settings.alertE2Low, settings.alertTHigh, settings.alertTLow,
|
||||||
|
);
|
||||||
|
const tConfigRaw = store.getTConfig();
|
||||||
|
const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k);
|
||||||
|
|
||||||
|
// Même calibration que la carte accueil (auto-cal incluse si active)
|
||||||
|
let scalePerEster = null;
|
||||||
|
let tKPerEster = null;
|
||||||
|
if (settings.autoCalibrate) {
|
||||||
|
const auto = autoCalibrated(store.getTreatments(), store.getDoseLogs(), store.getLabResults(), tConfig, null);
|
||||||
|
scalePerEster = auto.esterScales;
|
||||||
|
tKPerEster = auto.tKPerEster;
|
||||||
|
}
|
||||||
|
const level = levelAt(store.getTreatments(), store.getDoseLogs(), Date.now(), tConfig, tKPerEster, scalePerEster);
|
||||||
|
|
||||||
|
const alerts = Alerts.evaluateAll(level.e2, level.t, thresholds);
|
||||||
|
const current = Alerts.parseState(Alerts.encodeState(alerts)); // map propre
|
||||||
|
const lastNotified = Alerts.parseState(settings.alertNotifiedState);
|
||||||
|
|
||||||
|
if (Alerts.shouldNotify(current, lastNotified)) {
|
||||||
|
// Nouveau franchissement (ou changement H↔L) → notification
|
||||||
|
AppLog.log('alertes', `notification : ${Alerts.encodeState(alerts)}`);
|
||||||
|
try {
|
||||||
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
|
const lines = alerts.map((a) => (a.level === 'HIGH'
|
||||||
|
? t('alert_high', a.marker, formatDose(a.value), a.unit, formatDose(a.limit))
|
||||||
|
: t('alert_low', a.marker, formatDose(a.value), a.unit, formatDose(a.limit))));
|
||||||
|
new Notification(t('alert_card_title'), {
|
||||||
|
body: lines.join('\n'),
|
||||||
|
tag: 'hormonetrack-alerts',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.log('alertes', `notification impossible : ${e.message}`);
|
||||||
|
}
|
||||||
|
store.setSettings({ alertNotifiedState: Alerts.encodeState(alerts) });
|
||||||
|
} else if (Object.keys(current).length === 0 && settings.alertNotifiedState) {
|
||||||
|
// Retour à la normale : pas de notif, mais état effacé (ré-arme)
|
||||||
|
AppLog.log('alertes', 'retour à la normale');
|
||||||
|
store.setSettings({ alertNotifiedState: null });
|
||||||
|
} else {
|
||||||
|
AppLog.log('alertes', `franchissement inchangé (${Alerts.encodeState(alerts) || 'aucun'})`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.log('alertes', `ERREUR : ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
384
js/ui/settings.js
Normal file
384
js/ui/settings.js
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Écran Paramètres (portage de `ui/screens/SettingsScreen.kt`).
|
||||||
|
*
|
||||||
|
* - langue (Système / Français / English) ;
|
||||||
|
* - calibration automatique (UNE option — E2 par période d'ester + T, fix
|
||||||
|
* v1.2.1 ; désactivée par défaut) ;
|
||||||
|
* - réglages T manuels (base/floor/k + bouton « Calibrer avec les analyses »
|
||||||
|
* ponctuel — computeTConfigCalibration) ;
|
||||||
|
* - seuils d'alerte E2/T (opt-in, validation haut > bas, feedback rouge) ;
|
||||||
|
* - fuseau de LECTURE du graphique (ID IANA, vide = auto — v1.4.5) ;
|
||||||
|
* - notifications (permission navigateur — rappels web, cf ui/reminders.js) ;
|
||||||
|
* - Export/Import JSON (compatibles Android, mode ÉCRASEMENT à l'import) ;
|
||||||
|
* - logs de diagnostic (export .txt / effacement) ;
|
||||||
|
* - à propos (version, lien releases, crédits modèles, vie privée).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import * as Alerts from '../pk/alerts.js';
|
||||||
|
import { computeTConfigCalibration, TConfig } from '../pk/index.js';
|
||||||
|
import { buildBackupJson, parseBackupJson, backupFileName, diagnosticLogFileName } from '../data/backup.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { resolveLocale } from '../util/i18n.js';
|
||||||
|
import * as AppLog from '../util/app-log.js';
|
||||||
|
import { el, showDialog, field, switchRow, filterChip, toast } from './components.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version de l'app web — ⚠️ ALIGNÉE sur la version Android portée (cf
|
||||||
|
* web/docs/DEVELOPPEMENT.md §3) : chaque release est publiée en parallèle
|
||||||
|
* (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.4.10';
|
||||||
|
|
||||||
|
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
|
||||||
|
function parseNumOrNull(text) {
|
||||||
|
const s = String(text).trim().replace(',', '.');
|
||||||
|
if (s === '') return null;
|
||||||
|
const v = Number(s);
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclenche un téléchargement côté navigateur (équivalent SAF CreateDocument
|
||||||
|
* Android : l'utilisateur choisit où enregistrer — ici via le dossier de
|
||||||
|
* téléchargement).
|
||||||
|
*
|
||||||
|
* @param {string} filename
|
||||||
|
* @param {string} content
|
||||||
|
* @param {string} mimeType
|
||||||
|
* @returns {boolean} true si le téléchargement a été initié
|
||||||
|
*/
|
||||||
|
function downloadFile(filename, content, mimeType) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
// libère l'URL object après un délai (le navigateur a initié le download)
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.log('export', `ERREUR : ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'écran Paramètres.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
export function renderSettings(container) {
|
||||||
|
const col = el('div', {});
|
||||||
|
container.appendChild(col);
|
||||||
|
|
||||||
|
function rerender() {
|
||||||
|
col.textContent = '';
|
||||||
|
const settings = store.getSettings();
|
||||||
|
const tConfigRaw = store.getTConfig();
|
||||||
|
const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k);
|
||||||
|
|
||||||
|
// ── Langue ─────────────────────────────────────────────────────────────
|
||||||
|
const langCard = el('div', { class: 'card' });
|
||||||
|
langCard.appendChild(el('h3', {}, t('language')));
|
||||||
|
const langRow = el('div', { class: 'chip-row' });
|
||||||
|
langRow.appendChild(filterChip(t('language_system'), settings.language === 'system', () => { store.setSettings({ language: 'system' }); }));
|
||||||
|
langRow.appendChild(filterChip('Français', settings.language === 'fr', () => { store.setSettings({ language: 'fr' }); }));
|
||||||
|
langRow.appendChild(filterChip('English', settings.language === 'en', () => { store.setSettings({ language: 'en' }); }));
|
||||||
|
langCard.appendChild(langRow);
|
||||||
|
col.appendChild(langCard);
|
||||||
|
|
||||||
|
// ── Calibration automatique (UNE option, désactivée par défaut) ─────────
|
||||||
|
const autoCard = el('div', { class: 'card' });
|
||||||
|
autoCard.appendChild(switchRow(t('auto_calibrate'), settings.autoCalibrate, (v) => store.setSettings({ autoCalibrate: v })));
|
||||||
|
autoCard.appendChild(el('div', { class: 'hint' }, t('auto_calibrate_hint')));
|
||||||
|
col.appendChild(autoCard);
|
||||||
|
|
||||||
|
// ── Estimation T (réglages manuels) ────────────────────────────────────
|
||||||
|
const tCard = el('div', { class: 'card' });
|
||||||
|
tCard.appendChild(el('h3', {}, t('t_model_title')));
|
||||||
|
tCard.appendChild(el('div', { class: 'hint' }, t('t_model_hint')));
|
||||||
|
const tRow = el('div', { class: 'row' });
|
||||||
|
const baseField = field({ label: t('t_base'), type: 'number', value: String(tConfig.base), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
const floorField = field({ label: t('t_floor'), type: 'number', value: String(tConfig.floor), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
const kField = field({ label: 'k', type: 'number', value: String(tConfig.k), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
tRow.appendChild(baseField);
|
||||||
|
tRow.appendChild(floorField);
|
||||||
|
tRow.appendChild(kField);
|
||||||
|
tCard.appendChild(tRow);
|
||||||
|
const tBtnRow = el('div', { class: 'row' });
|
||||||
|
tBtnRow.appendChild(el('button', {
|
||||||
|
class: 'btn',
|
||||||
|
onclick: () => {
|
||||||
|
const base = parseNumOrNull(baseField.input.value);
|
||||||
|
const floor = parseNumOrNull(floorField.input.value);
|
||||||
|
const k = parseNumOrNull(kField.input.value);
|
||||||
|
if (base === null || floor === null || k === null) return;
|
||||||
|
store.setTConfig({ base, floor, k });
|
||||||
|
toast(t('saved'));
|
||||||
|
},
|
||||||
|
}, t('save')));
|
||||||
|
tBtnRow.appendChild(el('button', {
|
||||||
|
class: 'btn outlined',
|
||||||
|
onclick: () => {
|
||||||
|
// Calibration T GLOBALE (k unique) — la même que le bouton Android
|
||||||
|
const labs = store.getLabResults().filter((l) => String(l.marker).toUpperCase() === 'T');
|
||||||
|
const calibrated = computeTConfigCalibration(labs, store.getTreatments(), store.getDoseLogs(), tConfig);
|
||||||
|
if (calibrated) {
|
||||||
|
store.setTConfig({ base: calibrated.base, floor: calibrated.floor, k: calibrated.k });
|
||||||
|
toast(t('t_calibrated'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, t('calibrate_from_labs')));
|
||||||
|
tCard.appendChild(tBtnRow);
|
||||||
|
col.appendChild(tCard);
|
||||||
|
|
||||||
|
// ── Seuils d'alerte (v1.4.2) ───────────────────────────────────────────
|
||||||
|
const alertCard = el('div', { class: 'card' });
|
||||||
|
alertCard.appendChild(el('h3', {}, t('alert_section')));
|
||||||
|
alertCard.appendChild(el('div', { class: 'hint' }, t('alert_hint')));
|
||||||
|
const e2Row = el('div', { class: 'row' });
|
||||||
|
const e2HighField = field({ label: t('alert_e2_high'), type: 'number', value: settings.alertE2High === null ? '' : String(settings.alertE2High), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
const e2LowField = field({ label: t('alert_e2_low'), type: 'number', value: settings.alertE2Low === null ? '' : String(settings.alertE2Low), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
e2Row.appendChild(e2HighField);
|
||||||
|
e2Row.appendChild(e2LowField);
|
||||||
|
alertCard.appendChild(e2Row);
|
||||||
|
const tRow2 = el('div', { class: 'row' });
|
||||||
|
const tHighField = field({ label: t('alert_t_high'), type: 'number', value: settings.alertTHigh === null ? '' : String(settings.alertTHigh), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
const tLowField = field({ label: t('alert_t_low'), type: 'number', value: settings.alertTLow === null ? '' : String(settings.alertTLow), attrs: { step: 'any', inputmode: 'decimal' } });
|
||||||
|
tRow2.appendChild(tHighField);
|
||||||
|
tRow2.appendChild(tLowField);
|
||||||
|
alertCard.appendChild(tRow2);
|
||||||
|
const alertError = el('div', { class: 'error-text', style: 'display:none;' });
|
||||||
|
alertCard.appendChild(alertError);
|
||||||
|
alertCard.appendChild(el('button', {
|
||||||
|
class: 'btn',
|
||||||
|
onclick: () => {
|
||||||
|
const e2High = parseNumOrNull(e2HighField.input.value);
|
||||||
|
const e2Low = parseNumOrNull(e2LowField.input.value);
|
||||||
|
const tHigh = parseNumOrNull(tHighField.input.value);
|
||||||
|
const tLow = parseNumOrNull(tLowField.input.value);
|
||||||
|
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
|
||||||
|
const fields = [e2HighField.input.value, e2LowField.input.value, tHighField.input.value, tLowField.input.value];
|
||||||
|
const invalidSyntax = fields.some((v) => v.trim() !== '' && parseNumOrNull(v) === null);
|
||||||
|
const thresholds = new Alerts.Thresholds(e2High, e2Low, tHigh, tLow);
|
||||||
|
if (invalidSyntax || !thresholds.isCoherent()) {
|
||||||
|
alertError.textContent = t('alert_invalid');
|
||||||
|
alertError.style.display = '';
|
||||||
|
} else {
|
||||||
|
alertError.style.display = 'none';
|
||||||
|
store.setSettings({
|
||||||
|
alertE2High: e2High, alertE2Low: e2Low, alertTHigh: tHigh, alertTLow: tLow,
|
||||||
|
});
|
||||||
|
// Check IMMÉDIAT (miroir AlertNotifier.checkNow — si le seuil est
|
||||||
|
// déjà franchi, la notification part sans attendre le cycle)
|
||||||
|
import('./reminders.js').then((m) => m.checkAlertsNow());
|
||||||
|
toast(t('saved'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, t('save')));
|
||||||
|
col.appendChild(alertCard);
|
||||||
|
|
||||||
|
// ── Fuseau du graphique (v1.4.5) ───────────────────────────────────────
|
||||||
|
const tzCard = el('div', { class: 'card' });
|
||||||
|
tzCard.appendChild(el('h3', {}, t('timezone_section')));
|
||||||
|
tzCard.appendChild(el('div', { class: 'hint' }, t('timezone_hint')));
|
||||||
|
const tzField = field({ label: t('timezone_field'), value: settings.chartTimezone || '', placeholder: t('timezone_auto') });
|
||||||
|
tzCard.appendChild(tzField);
|
||||||
|
const tzError = el('div', { class: 'error-text', style: 'display:none;' });
|
||||||
|
tzCard.appendChild(tzError);
|
||||||
|
const tzBtnRow = el('div', { class: 'row' });
|
||||||
|
tzBtnRow.appendChild(el('button', {
|
||||||
|
class: 'btn',
|
||||||
|
onclick: () => {
|
||||||
|
const id = tzField.input.value.trim();
|
||||||
|
// Validation : vide = auto ; sinon l'ID doit être un fuseau IANA connu
|
||||||
|
const valid = id === '' || isValidTimeZone(id);
|
||||||
|
if (!valid) {
|
||||||
|
tzError.textContent = t('timezone_invalid');
|
||||||
|
tzError.style.display = '';
|
||||||
|
} else {
|
||||||
|
tzError.style.display = 'none';
|
||||||
|
store.setSettings({ chartTimezone: id === '' ? null : id });
|
||||||
|
toast(t('saved'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, t('save')));
|
||||||
|
tzBtnRow.appendChild(el('button', {
|
||||||
|
class: 'btn outlined',
|
||||||
|
onclick: () => {
|
||||||
|
tzField.input.value = '';
|
||||||
|
tzError.style.display = 'none';
|
||||||
|
store.setSettings({ chartTimezone: null });
|
||||||
|
toast(t('saved'));
|
||||||
|
},
|
||||||
|
}, t('timezone_auto')));
|
||||||
|
tzCard.appendChild(tzBtnRow);
|
||||||
|
col.appendChild(tzCard);
|
||||||
|
|
||||||
|
// ── Notifications (rappels web) ────────────────────────────────────────
|
||||||
|
const notifCard = el('div', { class: 'card' });
|
||||||
|
notifCard.appendChild(el('h3', {}, t('reminders_section')));
|
||||||
|
notifCard.appendChild(el('div', { class: 'hint' }, t('reminders_note')));
|
||||||
|
if (typeof Notification === 'undefined') {
|
||||||
|
notifCard.appendChild(el('div', { class: 'hint' }, t('notif_denied')));
|
||||||
|
} else if (Notification.permission === 'granted') {
|
||||||
|
notifCard.appendChild(el('div', {}, t('notif_granted')));
|
||||||
|
} else if (Notification.permission === 'denied') {
|
||||||
|
notifCard.appendChild(el('div', { class: 'hint' }, t('notif_denied')));
|
||||||
|
} else {
|
||||||
|
notifCard.appendChild(el('button', {
|
||||||
|
class: 'btn tonal',
|
||||||
|
onclick: () => { Notification.requestPermission(); },
|
||||||
|
}, t('notif_grant')));
|
||||||
|
}
|
||||||
|
col.appendChild(notifCard);
|
||||||
|
|
||||||
|
// ── Backup JSON (compatible Android) ───────────────────────────────────
|
||||||
|
const backupCard = el('div', { class: 'card' });
|
||||||
|
backupCard.appendChild(el('h3', {}, t('backup_section')));
|
||||||
|
backupCard.appendChild(el('div', { class: 'hint' }, t('backup_hint')));
|
||||||
|
const backupRow = el('div', { class: 'row' });
|
||||||
|
backupRow.appendChild(el('button', {
|
||||||
|
class: 'btn',
|
||||||
|
onclick: () => {
|
||||||
|
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
|
||||||
|
// (langue, auto-cal, seuils — seuls les champs UserSettings Android)
|
||||||
|
const collections = store.exportAll();
|
||||||
|
const json = buildBackupJson(collections, collections.tConfig, {
|
||||||
|
language: settings.language,
|
||||||
|
autoCalibrate: settings.autoCalibrate,
|
||||||
|
alertE2High: settings.alertE2High,
|
||||||
|
alertE2Low: settings.alertE2Low,
|
||||||
|
alertTHigh: settings.alertTHigh,
|
||||||
|
alertTLow: settings.alertTLow,
|
||||||
|
});
|
||||||
|
const ok = downloadFile(backupFileName(), json, 'application/json');
|
||||||
|
AppLog.log('backup', `export : ok=${ok}`);
|
||||||
|
toast(ok ? t('export_ok') : t('export_fail'));
|
||||||
|
},
|
||||||
|
}, t('export_json')));
|
||||||
|
backupRow.appendChild(el('button', {
|
||||||
|
class: 'btn outlined',
|
||||||
|
onclick: () => {
|
||||||
|
const input = el('input', { type: 'file', accept: 'application/json,.json' });
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const json = String(reader.result);
|
||||||
|
showImportConfirm(json);
|
||||||
|
};
|
||||||
|
reader.onerror = () => toast(t('import_fail'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
}, t('import_json')));
|
||||||
|
backupCard.appendChild(backupRow);
|
||||||
|
col.appendChild(backupCard);
|
||||||
|
|
||||||
|
// ── Logs de diagnostic (v1.3.1) ────────────────────────────────────────
|
||||||
|
const logsCard = el('div', { class: 'card' });
|
||||||
|
logsCard.appendChild(el('h3', {}, t('logs_section')));
|
||||||
|
logsCard.appendChild(el('div', { class: 'hint' }, t('logs_count', AppLog.count())));
|
||||||
|
const logsRow = el('div', { class: 'row' });
|
||||||
|
logsRow.appendChild(el('button', {
|
||||||
|
class: 'btn outlined',
|
||||||
|
onclick: () => {
|
||||||
|
// ⚠️ Exporter APRÈS avoir journalisé le début (miroir Android #45)
|
||||||
|
const logs = AppLog.all();
|
||||||
|
AppLog.log('logs', `export démarré (${logs.length} caractères)`);
|
||||||
|
const ok = downloadFile(diagnosticLogFileName(), AppLog.all(), 'text/plain');
|
||||||
|
AppLog.log('logs', `export terminé : ok=${ok}`);
|
||||||
|
toast(ok ? t('logs_export_ok') : t('logs_export_fail'));
|
||||||
|
},
|
||||||
|
}, t('logs_export')));
|
||||||
|
logsRow.appendChild(el('button', {
|
||||||
|
class: 'btn text',
|
||||||
|
onclick: () => { AppLog.clear(); toast(t('saved')); },
|
||||||
|
}, t('logs_clear')));
|
||||||
|
logsCard.appendChild(logsRow);
|
||||||
|
col.appendChild(logsCard);
|
||||||
|
|
||||||
|
// ── À propos ───────────────────────────────────────────────────────────
|
||||||
|
const aboutCard = el('div', { class: 'card' });
|
||||||
|
aboutCard.appendChild(el('h3', {}, t('about_title')));
|
||||||
|
aboutCard.appendChild(el('div', {}, t('settings_version', WEB_VERSION)));
|
||||||
|
aboutCard.appendChild(el('div', { class: 'hint' }, t('privacy_note')));
|
||||||
|
aboutCard.appendChild(el('a', { href: 'https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases', target: '_blank', rel: 'noopener' }, t('releases_link')));
|
||||||
|
aboutCard.appendChild(el('div', { class: 'hint' }, t('disclaimer')));
|
||||||
|
aboutCard.appendChild(el('div', { class: 'hint' }, t('models_credit')));
|
||||||
|
col.appendChild(aboutCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation d'import (miroir showImportConfirm) : écrasement complet,
|
||||||
|
* restauration tConfig + réglages + re-programmation des rappels.
|
||||||
|
*/
|
||||||
|
function showImportConfirm(json) {
|
||||||
|
const dlg = showDialog({
|
||||||
|
title: t('import_title'),
|
||||||
|
body: el('p', {}, t('import_warning')),
|
||||||
|
actions: [
|
||||||
|
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
|
||||||
|
{
|
||||||
|
label: t('import_confirm'),
|
||||||
|
className: 'btn danger',
|
||||||
|
onClick: () => {
|
||||||
|
dlg.close();
|
||||||
|
try {
|
||||||
|
const data = parseBackupJson(json);
|
||||||
|
const result = store.importOverwrite(data);
|
||||||
|
// Restauration tConfig (miroir BackupManager.importJson)
|
||||||
|
store.setTConfig(data.tConfig);
|
||||||
|
// ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher)
|
||||||
|
if (data.settings) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
if (data.settings.language) {
|
||||||
|
store.setSettings({ language: data.settings.language });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-programmation des rappels (la boucle relit le store à chaque cycle)
|
||||||
|
AppLog.log('backup',
|
||||||
|
`import ok : ${result.treatments} traitements, ${result.doseLogs} doses, ${result.labResults} analyses`);
|
||||||
|
toast(t('import_ok', result.treatments, result.doseLogs, result.labResults));
|
||||||
|
} catch (e) {
|
||||||
|
AppLog.log('backup', `ERREUR import : ${e.message}`);
|
||||||
|
toast(t('import_fail'));
|
||||||
|
}
|
||||||
|
rerender();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validation d'un ID de fuseau IANA (miroir de la validation Android). */
|
||||||
|
function isValidTimeZone(id) {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: id });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
// Le changement de langue doit re-traduire TOUTE l'app : écouteur géré par
|
||||||
|
// app.js (subscribe → re-render de la route courante).
|
||||||
|
}
|
||||||
288
js/ui/treatment-editor.js
Normal file
288
js/ui/treatment-editor.js
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`).
|
||||||
|
*
|
||||||
|
* - presets (PKPresets, 22 entrées) pré-remplissent tout (création) ;
|
||||||
|
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon ;
|
||||||
|
* - carte Calibration : facteur manuel + bouton « Calibrer avec les
|
||||||
|
* analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ;
|
||||||
|
* hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ;
|
||||||
|
* - section Posologie (prévision, intervalle en jours) ;
|
||||||
|
* - carte Rappel : switch + heure → notifications navigateur (web) ;
|
||||||
|
* - section Agenda : AFFICHAGE informatif seulement (impossible dans un
|
||||||
|
* navigateur — pas de CalendarProvider) ;
|
||||||
|
* - switch Actif : drapeau administratif (§6.bis) — ne retire rien de la
|
||||||
|
* simulation ;
|
||||||
|
* - save → insert/update ; delete avec confirmation.
|
||||||
|
*
|
||||||
|
* ⚠️ Miroir du bug #51 (Android) : la reconstruction d'une entité pour un
|
||||||
|
* save doit repartir des champs NON ÉDITABLES — ici `createdAt` chargé et
|
||||||
|
* conservé (pas de calendarEventId côté web : toujours null).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey } from '../data/models.js';
|
||||||
|
import { all as allPresets } from '../pk/presets.js';
|
||||||
|
import { computeScaleFactor } from '../pk/pk-calibration.js';
|
||||||
|
import { t } from '../util/i18n.js';
|
||||||
|
import { formatDose } from '../util/format.js';
|
||||||
|
import { el, showDialog, field, dropdown, switchRow, confirmDialog } from './components.js';
|
||||||
|
|
||||||
|
/** Parse un champ numérique FR (virgule → point) ; défaut si vide/invalide. */
|
||||||
|
function parseNum(text, fallback) {
|
||||||
|
const v = Number(String(text).replace(',', '.'));
|
||||||
|
return Number.isFinite(v) ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'éditeur.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} ctx { treatmentId: number (0 = nouveau), onDone: ()=>void }
|
||||||
|
*/
|
||||||
|
export function renderTreatmentEditor(container, ctx) {
|
||||||
|
const isNew = ctx.treatmentId <= 0;
|
||||||
|
const existing = isNew ? null : store.getTreatments().find((x) => x.id === ctx.treatmentId) || null;
|
||||||
|
|
||||||
|
// ── État du formulaire (pré-rempli depuis le traitement ou défauts) ──────
|
||||||
|
const st = {
|
||||||
|
name: existing ? existing.name : '',
|
||||||
|
type: existing ? existing.type : 'ESTRADIOL',
|
||||||
|
route: existing ? existing.route : 'INJECTION_IM',
|
||||||
|
ester: existing ? existing.esterType : 'EV',
|
||||||
|
model: existing ? existing.pkModel : 'ESE',
|
||||||
|
doseText: existing ? formatDose(existing.doseAmount) : '4',
|
||||||
|
unit: existing ? existing.doseUnit : 'mg',
|
||||||
|
tmaxText: existing ? formatDose(existing.absorptionHours) : '46',
|
||||||
|
thalfText: existing ? formatDose(existing.eliminationHalfLifeHours) : '100',
|
||||||
|
bioText: existing ? String(existing.bioavailabilityFraction) : '1',
|
||||||
|
scaleText: existing ? formatDose(existing.scaleFactor) : '1',
|
||||||
|
reminderEnabled: existing ? existing.reminderEnabled : false,
|
||||||
|
reminderHour: existing ? (existing.reminderHour ?? 12) : 12,
|
||||||
|
reminderMinute: existing ? (existing.reminderMinute ?? 0) : 0,
|
||||||
|
active: existing ? existing.isActive : true,
|
||||||
|
forecastEnabled: existing ? existing.forecastIntervalDays !== null && existing.forecastIntervalDays !== undefined : false,
|
||||||
|
forecastDaysText: existing && existing.forecastIntervalDays !== null ? formatDose(existing.forecastIntervalDays) : '',
|
||||||
|
// ⚠️ champs NON ÉDITABLES conservés (bug #51 côté Android) :
|
||||||
|
createdAt: existing ? existing.createdAt : Date.now(),
|
||||||
|
};
|
||||||
|
const settings = store.getSettings();
|
||||||
|
|
||||||
|
const col = el('div', {});
|
||||||
|
container.appendChild(col);
|
||||||
|
|
||||||
|
// Titre + retour
|
||||||
|
const header = el('div', { class: 'row' }, [
|
||||||
|
el('button', { class: 'btn text', onclick: ctx.onDone }, '←'),
|
||||||
|
el('h1', { style: 'font-size:1.15rem;' }, isNew ? t('add_treatment') : t('edit_treatment')),
|
||||||
|
]);
|
||||||
|
col.appendChild(header);
|
||||||
|
|
||||||
|
// ── Presets (création seulement) ─────────────────────────────────────────
|
||||||
|
if (isNew) {
|
||||||
|
const presetOptions = [{ value: '', label: t('select_preset') }, ...allPresets.map((p, i) => ({ value: String(i), label: t(p.nameKey) }))];
|
||||||
|
col.appendChild(dropdown(t('select_preset'), '', presetOptions, (v) => {
|
||||||
|
if (v === '') return;
|
||||||
|
const p = allPresets[Number(v)];
|
||||||
|
st.name = t(p.nameKey);
|
||||||
|
st.type = p.type;
|
||||||
|
st.route = p.route;
|
||||||
|
st.ester = p.esterType;
|
||||||
|
st.model = p.pkModel;
|
||||||
|
st.doseText = formatDose(p.defaultDoseAmount);
|
||||||
|
st.unit = p.defaultDoseUnit;
|
||||||
|
st.tmaxText = formatDose(p.absorptionHours);
|
||||||
|
st.thalfText = formatDose(p.eliminationHalfLifeHours);
|
||||||
|
st.bioText = String(p.bioavailabilityFraction);
|
||||||
|
rerenderForm();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = el('div', {});
|
||||||
|
col.appendChild(form);
|
||||||
|
|
||||||
|
/** Reconstruit le formulaire (champs conditionnels route/type). */
|
||||||
|
function rerenderForm() {
|
||||||
|
form.textContent = '';
|
||||||
|
|
||||||
|
const nameField = field({ label: t('treatment_name'), value: st.name, onInput: (v) => { st.name = v; } });
|
||||||
|
form.appendChild(nameField);
|
||||||
|
|
||||||
|
const typeOptions = ['ESTRADIOL', 'ANTI_ANDROGEN', 'PROGESTOGEN', 'OTHER']
|
||||||
|
.map((x) => ({ value: x, label: t(typeLabelKey(x)) }));
|
||||||
|
form.appendChild(dropdown(t('treatment_type'), st.type, typeOptions, (v) => {
|
||||||
|
st.type = v;
|
||||||
|
rerenderForm(); // la carte calibration ne concerne qu'E2
|
||||||
|
}));
|
||||||
|
|
||||||
|
const routeOptions = Object.keys({
|
||||||
|
ORAL: 1, TRANSDERMAL_GEL: 1, TRANSDERMAL_PATCH: 1, INJECTION_IM: 1, INJECTION_SUBCUT: 1, OTHER: 1,
|
||||||
|
}).map((x) => ({ value: x, label: t(routeLabelKey(x)) }));
|
||||||
|
form.appendChild(dropdown(t('treatment_route'), st.route, routeOptions, (v) => {
|
||||||
|
st.route = v;
|
||||||
|
rerenderForm(); // champs ester/modèle vs Bateman
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isInjectionRoute(st.route)) {
|
||||||
|
// Ester : liste simple (miroir Android — EV/EU/EEn dans l'éditeur ;
|
||||||
|
// les 7 esters sont proposables dans l'OVERRIDE par dose du DoseDialog)
|
||||||
|
const esterOptions = ['EV', 'EU', 'EEN'].map((x) => ({ value: x, label: x }));
|
||||||
|
form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; }));
|
||||||
|
|
||||||
|
// Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté)
|
||||||
|
const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) }));
|
||||||
|
form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => { st.model = v; }));
|
||||||
|
} else {
|
||||||
|
const row1 = el('div', { class: 'row' });
|
||||||
|
row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } }));
|
||||||
|
row1.appendChild(field({ label: t('pk_halflife'), type: 'number', value: st.thalfText, onInput: (v) => { st.thalfText = v; }, attrs: { step: 'any' } }));
|
||||||
|
form.appendChild(row1);
|
||||||
|
form.appendChild(field({ label: t('pk_bioavail'), type: 'number', value: st.bioText, onInput: (v) => { st.bioText = v; }, attrs: { step: 'any', min: '0.01', max: '1' } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const doseRow = el('div', { class: 'row' });
|
||||||
|
doseRow.appendChild(field({ label: t('default_dose'), type: 'number', value: st.doseText, onInput: (v) => { st.doseText = v; }, attrs: { step: 'any', inputmode: 'decimal' } }));
|
||||||
|
doseRow.appendChild(field({ label: t('dose_unit'), value: st.unit, onInput: (v) => { st.unit = v; } }));
|
||||||
|
form.appendChild(doseRow);
|
||||||
|
|
||||||
|
// ── Calibration (E2 seulement — miroir du if type == ESTRADIOL) ────────
|
||||||
|
if (st.type === 'ESTRADIOL') {
|
||||||
|
const calCard = el('div', { class: 'card' });
|
||||||
|
calCard.appendChild(el('h3', {}, t('calibration_title')));
|
||||||
|
calCard.appendChild(field({ label: t('scale_factor'), type: 'number', value: st.scaleText, onInput: (v) => { st.scaleText = v; }, attrs: { step: 'any', inputmode: 'decimal' } }));
|
||||||
|
calCard.appendChild(el('div', { class: 'hint' }, t('calibration_hint')));
|
||||||
|
if (settings.autoCalibrate) {
|
||||||
|
// v1.4.9 : quand l'auto est ON, le facteur manuel est IGNORÉ à
|
||||||
|
// l'affichage des courbes — l'expliciter (confusion « j'ai calibré
|
||||||
|
// mais rien ne change »)
|
||||||
|
calCard.appendChild(el('div', { class: 'hint', style: 'color:var(--teal);' }, t('calibration_auto_override')));
|
||||||
|
}
|
||||||
|
calCard.appendChild(el('button', {
|
||||||
|
class: 'btn tonal',
|
||||||
|
onclick: () => {
|
||||||
|
const tmp = buildTreatment(existing ? existing.id : 0, { scaleFactor: 1.0 });
|
||||||
|
if (!tmp) return;
|
||||||
|
const doses = store.getDoseLogs();
|
||||||
|
const labs = store.getLabResults().filter((l) => String(l.marker).toUpperCase() === 'E2');
|
||||||
|
const sf = computeScaleFactor(tmp, doses, labs);
|
||||||
|
if (sf !== null) {
|
||||||
|
st.scaleText = sf.toFixed(2);
|
||||||
|
rerenderForm();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, t('calibrate_from_labs')));
|
||||||
|
form.appendChild(calCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posologie (prévision) ───────────────────────────────────────────────
|
||||||
|
const freqCard = el('div', { class: 'card' });
|
||||||
|
freqCard.appendChild(switchRow(t('freq_section'), st.forecastEnabled, (v) => {
|
||||||
|
st.forecastEnabled = v;
|
||||||
|
rerenderForm();
|
||||||
|
}));
|
||||||
|
if (st.forecastEnabled) {
|
||||||
|
freqCard.appendChild(field({ label: t('freq_interval'), type: 'number', value: st.forecastDaysText, onInput: (v) => { st.forecastDaysText = v; }, attrs: { step: 'any', inputmode: 'decimal', min: '0' } }));
|
||||||
|
freqCard.appendChild(el('div', { class: 'hint' }, t('freq_hint')));
|
||||||
|
}
|
||||||
|
form.appendChild(freqCard);
|
||||||
|
|
||||||
|
// ── Rappel (notifications navigateur — cf ui/reminders.js) ──────────────
|
||||||
|
const remCard = el('div', { class: 'card' });
|
||||||
|
remCard.appendChild(switchRow(t('reminder'), st.reminderEnabled, (v) => {
|
||||||
|
st.reminderEnabled = v;
|
||||||
|
rerenderForm();
|
||||||
|
}));
|
||||||
|
if (st.reminderEnabled) {
|
||||||
|
remCard.appendChild(field({
|
||||||
|
label: t('reminder_time'),
|
||||||
|
type: 'time',
|
||||||
|
value: `${String(st.reminderHour).padStart(2, '0')}:${String(st.reminderMinute).padStart(2, '0')}`,
|
||||||
|
onInput: (v) => {
|
||||||
|
const [h, m] = v.split(':').map(Number);
|
||||||
|
if (!Number.isNaN(h) && !Number.isNaN(m)) {
|
||||||
|
st.reminderHour = h;
|
||||||
|
st.reminderMinute = m;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
form.appendChild(remCard);
|
||||||
|
|
||||||
|
// ── Agenda : AFFICHAGE informatif (non portable dans un navigateur) ─────
|
||||||
|
const agendaCard = el('div', { class: 'card' });
|
||||||
|
agendaCard.appendChild(el('h3', {}, t('agenda_section')));
|
||||||
|
agendaCard.appendChild(el('div', { class: 'hint' }, t('agenda_hint')));
|
||||||
|
form.appendChild(agendaCard);
|
||||||
|
|
||||||
|
// ── Actif (drapeau administratif — §6.bis) ──────────────────────────────
|
||||||
|
form.appendChild(switchRow(t('active'), st.active, (v) => { st.active = v; }));
|
||||||
|
|
||||||
|
// ── Save ────────────────────────────────────────────────────────────────
|
||||||
|
form.appendChild(el('button', {
|
||||||
|
class: 'btn',
|
||||||
|
style: 'width:100%;margin-top:10px;',
|
||||||
|
onclick: () => {
|
||||||
|
const tr = buildTreatment(existing ? existing.id : 0, null);
|
||||||
|
if (!tr) return; // validation (nom + dose > 0)
|
||||||
|
store.upsertTreatment(tr);
|
||||||
|
ctx.onDone();
|
||||||
|
},
|
||||||
|
}, t('save')));
|
||||||
|
|
||||||
|
// ── Delete (édition seulement) ──────────────────────────────────────────
|
||||||
|
if (!isNew) {
|
||||||
|
form.appendChild(el('button', {
|
||||||
|
class: 'btn text danger',
|
||||||
|
style: 'width:100%;margin-top:6px;',
|
||||||
|
onclick: () => confirmDialog(
|
||||||
|
t('delete'),
|
||||||
|
t('confirm_delete_treatment'),
|
||||||
|
() => {
|
||||||
|
store.deleteTreatment(existing.id);
|
||||||
|
ctx.onDone();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}, t('delete')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le Treatment final depuis l'état du formulaire (miroir
|
||||||
|
* buildTreatment — validations : nom non vide, dose > 0 ; bio ∈ [0.01, 1] ;
|
||||||
|
* scale ≥ 0.01 ; prévision si activée avec intervalle > 0 ; ester NONE
|
||||||
|
* hors injection ; createdAt/(calendarEventId) conservés).
|
||||||
|
*/
|
||||||
|
function buildTreatment(id, overrides) {
|
||||||
|
const dose = parseNum(st.doseText, NaN);
|
||||||
|
if (st.name.trim() === '' || Number.isNaN(dose) || dose <= 0.0) return null;
|
||||||
|
const forecastInterval = st.forecastEnabled
|
||||||
|
? (parseNum(st.forecastDaysText, 0) > 0 ? parseNum(st.forecastDaysText, 0) : null)
|
||||||
|
: null;
|
||||||
|
const base = newTreatment({
|
||||||
|
id,
|
||||||
|
name: st.name.trim(),
|
||||||
|
type: st.type,
|
||||||
|
route: st.route,
|
||||||
|
doseAmount: dose,
|
||||||
|
doseUnit: st.unit.trim() === '' ? 'mg' : st.unit.trim(),
|
||||||
|
isActive: st.active,
|
||||||
|
// ester NONE hors injection (miroir du Kotlin)
|
||||||
|
esterType: isInjectionRoute(st.route) ? st.ester : 'NONE',
|
||||||
|
pkModel: st.model,
|
||||||
|
absorptionHours: parseNum(st.tmaxText, 4),
|
||||||
|
eliminationHalfLifeHours: parseNum(st.thalfText, 24),
|
||||||
|
bioavailabilityFraction: Math.min(Math.max(parseNum(st.bioText, 1), 0.01), 1),
|
||||||
|
scaleFactor: Math.max(parseNum(st.scaleText, 1), 0.01),
|
||||||
|
forecastIntervalDays: forecastInterval,
|
||||||
|
reminderEnabled: st.reminderEnabled,
|
||||||
|
reminderHour: st.reminderEnabled ? st.reminderHour : null,
|
||||||
|
reminderMinute: st.reminderEnabled ? st.reminderMinute : null,
|
||||||
|
// ⚠️ champ NON ÉDITABLE conservé (bug #51)
|
||||||
|
createdAt: st.createdAt,
|
||||||
|
calendarEventId: null, // toujours null côté web
|
||||||
|
});
|
||||||
|
return overrides ? { ...base, ...overrides } : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
rerenderForm();
|
||||||
|
}
|
||||||
62
js/ui/treatments.js
Normal file
62
js/ui/treatments.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Écran Traitements (portage de `ui/screens/TreatmentsScreen.kt`).
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../data/store.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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend l'écran Traitements.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} ctx { openEditor: (id:number)=>void, openNew: ()=>void }
|
||||||
|
*/
|
||||||
|
export function renderTreatments(container, ctx) {
|
||||||
|
const treatments = store.getTreatments();
|
||||||
|
|
||||||
|
const col = el('div', {});
|
||||||
|
container.appendChild(col);
|
||||||
|
|
||||||
|
if (treatments.length === 0) {
|
||||||
|
col.appendChild(el('p', {}, t('no_data')));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tr of treatments) {
|
||||||
|
const flags = treatmentFlags(tr); // miroir des getters Kotlin
|
||||||
|
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));
|
||||||
|
if (!tr.isActive) titleRow.appendChild(el('span', { class: 'small' }, t('inactive')));
|
||||||
|
card.appendChild(titleRow);
|
||||||
|
|
||||||
|
card.appendChild(el('div', { class: 'sub' },
|
||||||
|
`${t(routeLabelKey(tr.route))} · ${formatDose(tr.doseAmount)} ${tr.doseUnit}`));
|
||||||
|
|
||||||
|
const chipRow = el('div', { class: 'chip-row wrap', style: 'margin-top:6px;' });
|
||||||
|
if (flags.usesProfileModel) {
|
||||||
|
// v1.4.7 (fix #59) : label via modelLabelKey — pas de ternaire à 2 branches
|
||||||
|
chipRow.appendChild(assistChip(`${tr.esterType} · ${t(modelLabelKey(tr.pkModel))}`));
|
||||||
|
} else {
|
||||||
|
chipRow.appendChild(assistChip(`${t('pk_absorption')} ${formatDose(tr.absorptionHours)}h`));
|
||||||
|
}
|
||||||
|
if (tr.scaleFactor !== 1.0) chipRow.appendChild(assistChip(`×${tr.scaleFactor.toFixed(2)}`));
|
||||||
|
if (tr.reminderEnabled) {
|
||||||
|
const hh = String(tr.reminderHour ?? 0).padStart(2, '0');
|
||||||
|
const mm = String(tr.reminderMinute ?? 0).padStart(2, '0');
|
||||||
|
chipRow.appendChild(assistChip(`⏰ ${hh}:${mm}`));
|
||||||
|
}
|
||||||
|
card.appendChild(chipRow);
|
||||||
|
col.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(fab(ctx.openNew));
|
||||||
|
}
|
||||||
105
js/util/app-log.js
Normal file
105
js/util/app-log.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* AppLog — journal de diagnostic (portage de `util/AppLog.kt`, v1.3.1).
|
||||||
|
*
|
||||||
|
* Ligne horodatée par événement notable (rappels, import/export, erreurs),
|
||||||
|
* buffer circulaire de MAX_LINES, persisté dans le localStorage (Android :
|
||||||
|
* `filesDir/debug-log.txt`). Exportable depuis Paramètres (« Logs de
|
||||||
|
* diagnostic ») pour le debug à distance — l'utilisateur joint le fichier à
|
||||||
|
* son rapport de bug.
|
||||||
|
*
|
||||||
|
* ⚠️ NE JAMAIS PROPAGER D'EXCEPTION depuis log() (fix Android v1.3.3, §14
|
||||||
|
* #45) : log() est appelé de partout, y compris dans des callbacks système —
|
||||||
|
* une erreur d'écriture (quota localStorage dépassé…) doit coûter AU PIRE
|
||||||
|
* une ligne de journal, jamais un crash.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { store } from '../data/store.js';
|
||||||
|
|
||||||
|
/** Taille maximale du buffer circulaire (identique au Kotlin). */
|
||||||
|
export const MAX_LINES = 500;
|
||||||
|
|
||||||
|
/** Buffer mémoire (miroir de l'ArrayDeque Kotlin). */
|
||||||
|
let buffer = [];
|
||||||
|
|
||||||
|
/** Formatteur partagé — 2 chiffres. */
|
||||||
|
function pad(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ligne horodatée : « 2026-09-08 12:34:56 [tag] message » (heure locale —
|
||||||
|
* même format qu'Android SimpleDateFormat "yyyy-MM-dd HH:mm:ss").
|
||||||
|
*
|
||||||
|
* @param {number} tsMs
|
||||||
|
* @param {string} tag
|
||||||
|
* @param {string} message
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatLine(tsMs, tag, message) {
|
||||||
|
const d = new Date(tsMs);
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||||
|
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} `
|
||||||
|
+ `[${tag}] ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buffer circulaire : garde les MAX dernières lignes (pur — testable).
|
||||||
|
*
|
||||||
|
* @param {string[]} existing
|
||||||
|
* @param {number} max
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function trimLines(existing, max) {
|
||||||
|
return existing.length <= max ? existing : existing.slice(existing.length - max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le journal persisté dans le buffer mémoire. À appeler une fois au
|
||||||
|
* démarrage — un journal corrompu ne doit jamais empêcher l'app de démarrer
|
||||||
|
* (on perd l'historique, pas le process).
|
||||||
|
*/
|
||||||
|
export function init() {
|
||||||
|
const raw = store.getDebugLog();
|
||||||
|
buffer = raw ? trimLines(raw.split('\n'), MAX_LINES) : [];
|
||||||
|
log('app', `démarrage — ${buffer.length} lignes de logs existantes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Journalise une ligne (buffer mémoire + persistance). Fail-safe : toute
|
||||||
|
* erreur d'IO est avalée (quota localStorage…).
|
||||||
|
*
|
||||||
|
* @param {string} tag
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
export function log(tag, message) {
|
||||||
|
const line = formatLine(Date.now(), tag, message);
|
||||||
|
buffer = trimLines([...buffer, line], MAX_LINES);
|
||||||
|
try {
|
||||||
|
store.setDebugLog(buffer.join('\n'));
|
||||||
|
} catch (e) {
|
||||||
|
// Journalisation best-effort : ne jamais faire planter l'app pour un log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} tout le journal (une ligne par \n). */
|
||||||
|
export function all() {
|
||||||
|
return buffer.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} nombre de lignes en buffer. */
|
||||||
|
export function count() {
|
||||||
|
return buffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Efface le journal (bouton Paramètres) puis journalise l'effacement. */
|
||||||
|
export function clear() {
|
||||||
|
buffer = [];
|
||||||
|
try {
|
||||||
|
store.setDebugLog('');
|
||||||
|
} catch (e) {
|
||||||
|
// idem : best-effort
|
||||||
|
}
|
||||||
|
log('app', 'logs effacés');
|
||||||
|
}
|
||||||
75
js/util/changelog.js
Normal file
75
js/util/changelog.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* ChangelogHelper (portage de `settings/ChangelogHelper.kt`, v1.3.0) :
|
||||||
|
* alimente le dialog « Nouveautés » affiché après chaque mise à jour.
|
||||||
|
*
|
||||||
|
* Le contenu vient de `docs/CHANGELOG.md` (relatif à la racine web, fetché au
|
||||||
|
* démarrage — le fichier sert aussi de source pour les releases git ; c'est
|
||||||
|
* l'équivalent de l'asset changelog.md embarqué côté Android, synchronisé par
|
||||||
|
* la tâche Gradle copyChangelog).
|
||||||
|
*
|
||||||
|
* PUR → testable en Node (web/tests/changelog.test.js).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare deux versions SemVer « X.Y.Z » NUMÉRIQUEMENT (composant par
|
||||||
|
* composant) — une comparaison lexicographique considérerait à tort
|
||||||
|
* « 1.2.9 > 1.2.10 » (piège #38 côté Android : `v1.2.10 < v1.2.5` en
|
||||||
|
* comparaison de chaînes).
|
||||||
|
*
|
||||||
|
* @param {string} current
|
||||||
|
* @param {string|null|undefined} reference null = première installation → true
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isVersionNewer(current, reference) {
|
||||||
|
if (reference === null || reference === undefined || reference.trim() === '') return true;
|
||||||
|
const c = parse(current);
|
||||||
|
const r = parse(reference);
|
||||||
|
if (!c) return true; // version courante non parsable → considérer plus neuve (défensif)
|
||||||
|
if (!r) return true;
|
||||||
|
const len = Math.max(c.length, r.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const a = c[i] ?? 0;
|
||||||
|
const b = r[i] ?? 0;
|
||||||
|
if (a !== b) return a > b;
|
||||||
|
}
|
||||||
|
return false; // identiques
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @private Parse "v1.2.3" → [1,2,3] ; null si format non numérique. */
|
||||||
|
function parse(version) {
|
||||||
|
const cleaned = String(version).trim().replace(/^v/, '');
|
||||||
|
if (!/^\d+(\.\d+)*$/.test(cleaned)) return null;
|
||||||
|
return cleaned.split('.').map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait de la CHANGELOG (markdown) les sections des versions STRICTEMENT
|
||||||
|
* plus récentes que `lastSeenVersion` (null = première installation → la
|
||||||
|
* version courante seule), jusqu'à `currentVersion` incluse.
|
||||||
|
*
|
||||||
|
* Format attendu : des sections « ## [X.Y.Z] — … » avec leur corps, la plus
|
||||||
|
* récente EN PREMIER (convention Keep a Changelog du dépôt).
|
||||||
|
*
|
||||||
|
* @param {string} markdown
|
||||||
|
* @param {string|null} lastSeenVersion
|
||||||
|
* @param {string} currentVersion
|
||||||
|
* @returns {string} markdown concaténé des nouveautés ("" si rien de nouveau)
|
||||||
|
*/
|
||||||
|
export function sectionsSince(markdown, lastSeenVersion, currentVersion) {
|
||||||
|
const pattern = /## \[(\d+\.\d+\.\d+)\][^\n]*\n([\s\S]*?)(?=\n## \[|\s*$)/g;
|
||||||
|
let out = '';
|
||||||
|
for (const match of markdown.matchAll(pattern)) {
|
||||||
|
const version = match[1];
|
||||||
|
// Sections triées de la plus récente à la plus ancienne :
|
||||||
|
// - inclure ce qui est STRICTEMENT plus récent que la dernière version
|
||||||
|
// vue (lastSeen null = première installation → section courante seule) ;
|
||||||
|
// - s'arrêter dès qu'on descend sous lastSeen (déjà vu).
|
||||||
|
if (lastSeenVersion !== null && !isVersionNewer(version, lastSeenVersion)) break;
|
||||||
|
if (lastSeenVersion === null && version !== currentVersion) continue;
|
||||||
|
if (out !== '') out += '\n\n---\n\n';
|
||||||
|
out += `## Version ${version}\n\n${match[2].trim()}`;
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
174
js/util/format.js
Normal file
174
js/util/format.js
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Formatage (miroir des helpers Android : formatDose dans DoseDialog.kt,
|
||||||
|
* formatLabValue dans LabDialog.kt, formatValue dans CurveChart.kt,
|
||||||
|
* HrtDuration.kt).
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format d'une dose : entier si rond, sinon 1 décimale (miroir formatDose —
|
||||||
|
* `4.0` → « 4 », `0.5` → « 0.5 »). Point décimal (convention des champs).
|
||||||
|
*
|
||||||
|
* @param {number} d
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatDose(d) {
|
||||||
|
return Number.isInteger(d) ? String(d) : d.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format d'une valeur de lab : préserve les décimales utiles (miroir
|
||||||
|
* formatLabValue — contrairement à formatDose : un lab 0.45 s'affiche « 0.45 »).
|
||||||
|
*
|
||||||
|
* @param {number} d
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatLabValue(d) {
|
||||||
|
if (Number.isInteger(d)) return String(d);
|
||||||
|
return String(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format d'une valeur SUR LE GRAPHIQUE (miroir formatValue de CurveChart.kt) :
|
||||||
|
* ≥ 10 → 0 décimale ; ≥ 1 → 1 décimale ; sinon 2.
|
||||||
|
*
|
||||||
|
* @param {number} v
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatValue(v) {
|
||||||
|
if (v >= 10) return v.toFixed(0);
|
||||||
|
if (v >= 1) return v.toFixed(1);
|
||||||
|
return v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HrtDuration (portage de `util/HrtDuration.kt`, v1.3.1) : estimation du
|
||||||
|
* temps passé sous THS. Mois = 30 jours (estimation assumée).
|
||||||
|
*/
|
||||||
|
export const HrtDuration = {
|
||||||
|
DAY_MS: 86400000,
|
||||||
|
|
||||||
|
/** Jours entiers écoulés depuis la première prise (0 si futur/invalide). */
|
||||||
|
daysSince(earliestMs, nowMs) {
|
||||||
|
return nowMs <= earliestMs ? 0 : Math.trunc((nowMs - earliestMs) / this.DAY_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Décomposition mois(30 j) / jours restants. */
|
||||||
|
monthsAndDays(totalDays) {
|
||||||
|
return [Math.trunc(totalDays / 30), totalDays % 30];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delta EN HEURES → (jours, heures restantes) — v1.4.1, carte « Prochaine
|
||||||
|
* dose » : au-delà de 24 h, les heures parlent moins que les jours
|
||||||
|
* (ex. 120 h → 5 j 0 h).
|
||||||
|
*/
|
||||||
|
daysAndHours(totalHours) {
|
||||||
|
return [Math.trunc(totalHours / 24), totalHours % 24];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Formatage de dates/heures (Intl, fuseau de lecture configurable) ──────────
|
||||||
|
|
||||||
|
/** Intl.DateTimeFormat mis en cache par (style, timeZone). */
|
||||||
|
const fmtCache = new Map();
|
||||||
|
|
||||||
|
/** @private */
|
||||||
|
function fmt(key, optionsFactory, timeZone) {
|
||||||
|
const cacheKey = `${key}|${timeZone || 'auto'}`;
|
||||||
|
if (!fmtCache.has(cacheKey)) fmtCache.set(cacheKey, optionsFactory(timeZone));
|
||||||
|
return fmtCache.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « HH:mm » dans le fuseau de lecture (heure des doses, carte prochaine dose).
|
||||||
|
* @param {number} ms @param {string} [timeZone=null]
|
||||||
|
*/
|
||||||
|
export function formatTime(ms, timeZone = null) {
|
||||||
|
return fmt('time', (tz) => new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz || undefined, hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}), timeZone).format(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « dd/MM/yyyy HH:mm » (liste des analyses).
|
||||||
|
* @param {number} ms @param {string} [timeZone=null]
|
||||||
|
*/
|
||||||
|
export function formatDateTime(ms, timeZone = null) {
|
||||||
|
return fmt('datetime', (tz) => new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz || undefined,
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}), timeZone).format(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « d MMMM yyyy » (en-tête « temps sous THS »).
|
||||||
|
* @param {number} ms @param {string} [timeZone=null]
|
||||||
|
*/
|
||||||
|
export function formatLongDate(ms, timeZone = null) {
|
||||||
|
return fmt('longdate', (tz) => new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz || undefined, day: 'numeric', month: 'long', year: 'numeric',
|
||||||
|
}), timeZone).format(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « EEE d HH:mm » (carte prochaine dose au-delà de 24 h — jour de semaine
|
||||||
|
* inclus, cf v1.4.1).
|
||||||
|
* @param {number} ms @param {string} [timeZone=null]
|
||||||
|
*/
|
||||||
|
export function formatWeekdayTime(ms, timeZone = null) {
|
||||||
|
return fmt('weekdaytime', (tz) => new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz || undefined,
|
||||||
|
weekday: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}), timeZone).format(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « yyyy-MM-dd » (en-têtes de groupes de l'écran Doses — même clé de groupe
|
||||||
|
* que le Kotlin `LocalDate.toString()`).
|
||||||
|
* @param {number} ms @param {string} [timeZone=null]
|
||||||
|
*/
|
||||||
|
export function formatISODate(ms, timeZone = null) {
|
||||||
|
return fmt('isodate', (tz) => new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: tz || undefined, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
}), timeZone).format(new Date(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date du jour « yyyy-MM-dd » (locale) — champ date des dialogs. */
|
||||||
|
export function todayISO() {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Epoch ms → valeur pour un `<input type="date">` + `<input type="time">`
|
||||||
|
* (locale) — les dialogs web utilisent ces champs natifs (équivalent
|
||||||
|
* DatePicker+TimePicker Material3).
|
||||||
|
*
|
||||||
|
* @param {number} ms
|
||||||
|
* @returns {{date:string, time:string}} ex. { date: "2026-09-08", time: "18:30" }
|
||||||
|
*/
|
||||||
|
export function msToInputValues(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
return {
|
||||||
|
date: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
|
||||||
|
time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs de champs date/heure (locale) → epoch ms.
|
||||||
|
* @param {string} dateISO "2026-09-08"
|
||||||
|
* @param {string} timeHM "18:30"
|
||||||
|
* @returns {number|null} null si invalide
|
||||||
|
*/
|
||||||
|
export function inputValuesToMs(dateISO, timeHM) {
|
||||||
|
if (!dateISO || !timeHM) return null;
|
||||||
|
const [y, mo, d] = dateISO.split('-').map(Number);
|
||||||
|
const [h, mi] = timeHM.split(':').map(Number);
|
||||||
|
if ([y, mo, d, h, mi].some((n) => Number.isNaN(n))) return null;
|
||||||
|
const ms = new Date(y, mo - 1, d, h, mi, 0, 0).getTime();
|
||||||
|
return Number.isFinite(ms) ? ms : null;
|
||||||
|
}
|
||||||
464
js/util/i18n.js
Normal file
464
js/util/i18n.js
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* 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',
|
||||||
|
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)',
|
||||||
|
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',
|
||||||
|
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)',
|
||||||
|
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);
|
||||||
|
}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "hormonetrack-web",
|
||||||
|
"version": "1.4.10",
|
||||||
|
"description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"repository": "https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/",
|
||||||
|
"e2e": "node scripts/e2e.mjs",
|
||||||
|
"check": "bash scripts/check.sh",
|
||||||
|
"serve": "python3 scripts/serve.py"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "^1.63.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"playwright": "^1.63.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
scripts/check.sh
Normal file
89
scripts/check.sh
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# check.sh — vérifications de l'app web (pré-commit / pré-push).
|
||||||
|
#
|
||||||
|
# 1. node --check sur chaque module ES (syntaxe) — attrape les typos avant
|
||||||
|
# même de charger l'app dans un navigateur ;
|
||||||
|
# 2. présence des fichiers critiques (index.html, asset PK, CSS, changelog) ;
|
||||||
|
# 3. cohérence i18n : clés FR/EN synchronisées + clés référencées existantes
|
||||||
|
# (leçon Android §12 : une string manquante s'affiche brute) ;
|
||||||
|
# 4. tests unitaires Node (node --test) ;
|
||||||
|
# 5. E2E navigateur (si playwright est installé — npm i + npx playwright
|
||||||
|
# install firefox, cf docs/DEVELOPPEMENT.md §Tests) ;
|
||||||
|
# 6. smoke HTTP (--with-serve : serveur + curl des ressources clés).
|
||||||
|
#
|
||||||
|
# Usage : bash scripts/check.sh [--with-serve]
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." # racine du dépôt
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
echo "── 1. Syntaxe des modules (node --check) ──"
|
||||||
|
while IFS= read -r f; do
|
||||||
|
if ! node --check "$f" 2>/tmp/hrt-check.err; then
|
||||||
|
echo "✗ $f"
|
||||||
|
cat /tmp/hrt-check.err
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done < <(find js -name '*.js' -type f | sort)
|
||||||
|
echo " tous les modules de js/ sont syntaxiquement valides"
|
||||||
|
|
||||||
|
echo "── 2. Fichiers critiques ──"
|
||||||
|
for f in index.html css/style.css assets/pk_profiles.json docs/CHANGELOG.md \
|
||||||
|
js/ui/app.js js/pk/pk-engine.js; do
|
||||||
|
if [[ ! -f "$f" ]]; then
|
||||||
|
echo "✗ manquant : $f"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo " fichiers critiques présents"
|
||||||
|
|
||||||
|
echo "── 3. Cohérence i18n FR/EN ──"
|
||||||
|
# Vérifie : (a) chaque clé de EN existe dans FR et inversement ;
|
||||||
|
# (b) chaque clé utilisée par t('...') dans js/ existe dans EN et FR.
|
||||||
|
# (leçon Android §12 : une string manquante s'affiche brute — et FR/EN
|
||||||
|
# désynchronisés = UI mi-anglaise mi-française)
|
||||||
|
if ! node scripts/i18n-check.mjs; then
|
||||||
|
echo "✗ clés i18n manquantes (voir ci-dessus)"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── 4. Tests unitaires ──"
|
||||||
|
if ! node --test tests/ 2>&1 | tail -6; then
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "── 5. E2E navigateur (si playwright installé) ──"
|
||||||
|
if [[ -d node_modules/playwright-core && -d "$HOME/Library/Caches/ms-playwright" ]]; then
|
||||||
|
if ! node scripts/e2e.mjs 8989; then
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " (ignoré : playwright non installé — npm i && npx playwright install firefox)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--with-serve" ]]; then
|
||||||
|
echo "── 6. Smoke HTTP ──"
|
||||||
|
python3 scripts/serve.py --port 8971 >/tmp/hrt-serve.log 2>&1 &
|
||||||
|
SRV=$!
|
||||||
|
sleep 1.5
|
||||||
|
for path in / /css/style.css /assets/pk_profiles.json /js/ui/app.js /docs/CHANGELOG.md; do
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:8971${path}")
|
||||||
|
if [[ "$code" != "200" ]]; then
|
||||||
|
echo "✗ ${path} → HTTP ${code}"
|
||||||
|
fail=1
|
||||||
|
else
|
||||||
|
echo " 200 ${path}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
kill $SRV 2>/dev/null || true
|
||||||
|
wait $SRV 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $fail -eq 0 ]]; then
|
||||||
|
echo "✓ CHECK OK"
|
||||||
|
else
|
||||||
|
echo "✗ CHECK FAILED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
202
scripts/e2e.mjs
Normal file
202
scripts/e2e.mjs
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* e2e.mjs — test end-to-end de l'app web dans un VRAI navigateur.
|
||||||
|
*
|
||||||
|
* Ce que ce test vérifie (impossible en Node pur) :
|
||||||
|
* 1. l'app démarre dans un navigateur : modules ES chargés, asset PK fetché,
|
||||||
|
* aucune erreur console fatale ;
|
||||||
|
* 2. les écrans se rendent avec de vraies données (?demo=1) ;
|
||||||
|
* 3. le Canvas du graphique PEINT réellement des courbes (pixels non vides) ;
|
||||||
|
* 4. la navigation par hash fonctionne (5 onglets + settings) ;
|
||||||
|
*
|
||||||
|
* Prérequis (une fois, cf docs/DEVELOPPEMENT.md §Tests) :
|
||||||
|
* npm i → installe playwright/playwright-core (devDependencies)
|
||||||
|
* npx playwright install firefox → le build Firefox piloté par Playwright
|
||||||
|
* Le navigateur système n'est PAS utilisé (Firefox ≥ ~150 ne supporte plus
|
||||||
|
* le protocole Juggler de Playwright) ; le build téléchargé vit dans le
|
||||||
|
* cache Playwright (~90 Mo, hors dépôt).
|
||||||
|
*
|
||||||
|
* Usage : node scripts/e2e.mjs [port]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { firefox } from 'playwright-core';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const WEB_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const PORT = Number(process.argv[2] || 8980);
|
||||||
|
const BASE = `http://127.0.0.1:${PORT}`;
|
||||||
|
const SHOTS = '/tmp/hrt-web-shots';
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function ok(condition, label, detail = '') {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ✓ ${label}`);
|
||||||
|
} else {
|
||||||
|
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
failures++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Démarre le serveur statique de dev et attend qu'il réponde. */
|
||||||
|
async function startServer() {
|
||||||
|
const proc = spawn('python3', ['scripts/serve.py', '--port', String(PORT)], {
|
||||||
|
cwd: WEB_ROOT,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try {
|
||||||
|
await fetch(`${BASE}/index.html`);
|
||||||
|
return proc;
|
||||||
|
} catch (e) {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('serveur de dev non démarré');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(SHOTS, { recursive: true });
|
||||||
|
const server = await startServer();
|
||||||
|
console.log(`serveur : ${BASE}`);
|
||||||
|
|
||||||
|
const browser = await firefox.launch({
|
||||||
|
headless: true,
|
||||||
|
firefoxUserPrefs: {
|
||||||
|
'dom.notifications.enabled': false, // pas de popup de permission en test
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 420, height: 900 } });
|
||||||
|
// Le Firefox headless démarre en-US : on seed la langue FR AVANT toute
|
||||||
|
// exécution de script (comme une utilisatrice qui a choisi Français) —
|
||||||
|
// toutes les assertions de texte de ce test sont en FR.
|
||||||
|
// ⚠️ CONDITIONNEL : écraser à chaque navigation effacerait aussi
|
||||||
|
// changelogSeenVersion → le dialog « Nouveautés » réapparaîtrait à chaque
|
||||||
|
// page.goto (une goto = reboot complet de la SPA).
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
if (!window.localStorage.getItem('hormonetrack.settings')) {
|
||||||
|
window.localStorage.setItem('hormonetrack.settings',
|
||||||
|
JSON.stringify({ language: 'fr' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const consoleErrors = [];
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||||
|
});
|
||||||
|
page.on('pageerror', (err) => consoleErrors.push(String(err)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── 1. Accueil avec données de démo ─────────────────────────────────────
|
||||||
|
console.log('── Accueil (?demo=1) ──');
|
||||||
|
await page.goto(`${BASE}/?demo=1`, { waitUntil: 'load' });
|
||||||
|
await page.waitForSelector('body[data-ready="1"]', { timeout: 15000 });
|
||||||
|
await page.waitForSelector('.card', { timeout: 15000 });
|
||||||
|
const bodyText = await page.textContent('main.content');
|
||||||
|
ok(bodyText.includes('Niveau actuel'), 'carte « niveau actuel » rendue');
|
||||||
|
ok(/≈ \d+/.test(bodyText), `valeur E2 estimée affichée (${(bodyText.match(/≈ \d+(?:\.\d+)?/) || ['?'])[0]})`);
|
||||||
|
ok(bodyText.includes('ng/mL'), 'valeur T estimée affichée');
|
||||||
|
ok(bodyText.includes('Prochaine dose'), 'carte « prochaine dose » (rappel démo 18:00)');
|
||||||
|
|
||||||
|
// Le niveau doit être physiologique (EEn 5 mg à l'équilibre TFS ≈ 160-270)
|
||||||
|
const levelMatch = bodyText.match(/≈ (\d+(?:\.\d+)?)/);
|
||||||
|
if (levelMatch) {
|
||||||
|
const e2 = Number(levelMatch[1]);
|
||||||
|
ok(e2 > 50 && e2 < 600, `E2 estimé plausible (${e2} pg/mL)`, `obtenu ${e2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premier lancement → le dialog « Nouveautés » est affiché (changelog v0.1.0
|
||||||
|
// en markdown rendu) : vérifier son contenu PUIS le fermer pour capturer
|
||||||
|
// l'accueil réel (les captures doivent montrer l'app, pas l'overlay).
|
||||||
|
const dialog = await page.$('.dialog');
|
||||||
|
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
|
||||||
|
if (dialog) {
|
||||||
|
const dlgText = await dialog.textContent();
|
||||||
|
ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante');
|
||||||
|
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
|
||||||
|
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
|
||||||
|
await page.click('.dialog .actions button');
|
||||||
|
await page.waitForSelector('.dialog', { state: 'detached' });
|
||||||
|
}
|
||||||
|
// Après fermeture : la bannière démo est visible au-dessus du contenu
|
||||||
|
const afterClose = await page.textContent('main.content');
|
||||||
|
ok(afterClose.includes('DONNÉES DE DÉMO'), 'bannière démo visible');
|
||||||
|
await page.screenshot({ path: join(SHOTS, 'e2e-home.png'), fullPage: false });
|
||||||
|
|
||||||
|
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
|
||||||
|
console.log('── Graphiques (#chart) ──');
|
||||||
|
await page.goto(`${BASE}/#chart`, { waitUntil: 'load' });
|
||||||
|
await page.waitForSelector('canvas.chart', { timeout: 15000 });
|
||||||
|
await page.waitForTimeout(600); // requestAnimationFrame du dessin
|
||||||
|
const canvasInfo = await page.evaluate(() => {
|
||||||
|
const canvas = document.querySelector('canvas.chart');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const { width, height } = canvas;
|
||||||
|
const data = ctx.getImageData(0, 0, width, height).data;
|
||||||
|
let colored = 0; // pixels non transparents (le fond du canvas est transparent)
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
if (data[i] > 0) colored++;
|
||||||
|
}
|
||||||
|
return { width, height, colored };
|
||||||
|
});
|
||||||
|
ok(canvasInfo.colored > 5000,
|
||||||
|
`canvas peint (${canvasInfo.colored} px colorés sur ${canvasInfo.width}×${canvasInfo.height})`);
|
||||||
|
const legendText = await page.textContent('main.content');
|
||||||
|
ok(legendText.includes('Transfem Science'), 'toggle/légende TFS présente');
|
||||||
|
// Légende E2 + T + labs + hint pan
|
||||||
|
ok(legendText.includes('E2 —'), 'légende E2');
|
||||||
|
ok(legendText.includes('Glisser horizontalement'), 'hint panoramique');
|
||||||
|
await page.screenshot({ path: join(SHOTS, 'e2e-chart.png') });
|
||||||
|
|
||||||
|
// ── 3. Navigation : les 5 onglets + settings ────────────────────────────
|
||||||
|
console.log('── Navigation ──');
|
||||||
|
await page.goto(`${BASE}/#doses`, { waitUntil: 'load' });
|
||||||
|
await page.waitForSelector('body[data-ready="1"]');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
let text = await page.textContent('main.content');
|
||||||
|
ok(text.includes('Sous THS depuis'), 'Doses : en-tête « temps sous THS »');
|
||||||
|
ok(text.includes('EEn — démo'), 'Doses : la dose de démo est listée');
|
||||||
|
ok(text.includes('7.0 j depuis la précédente'), 'Doses : intervalle entre doses');
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/#labs`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
text = await page.textContent('main.content');
|
||||||
|
ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)');
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/#treatments`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
text = await page.textContent('main.content');
|
||||||
|
ok(text.includes('EEn — démo'), 'Traitements : carte du traitement démo');
|
||||||
|
ok(text.includes('Transfem Science'), 'Traitements : chip modèle (modelLabelKey)');
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/#settings`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
text = await page.textContent('main.content');
|
||||||
|
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
||||||
|
ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée');
|
||||||
|
ok(text.includes('100 % local'), 'Paramètres : note vie privée');
|
||||||
|
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });
|
||||||
|
|
||||||
|
// ── 4. Erreurs console ──────────────────────────────────────────────────
|
||||||
|
console.log('── Santé ──');
|
||||||
|
ok(consoleErrors.length === 0,
|
||||||
|
'aucune erreur console/page',
|
||||||
|
consoleErrors.slice(0, 3).join(' | '));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures === 0) {
|
||||||
|
console.log('✓ E2E OK');
|
||||||
|
} else {
|
||||||
|
console.error(`✗ E2E : ${failures} échec(s)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('✗ E2E a planté :', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
94
scripts/i18n-check.mjs
Normal file
94
scripts/i18n-check.mjs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* i18n-check.mjs — cohérence des dictionnaires FR/EN (utilisé par check.sh).
|
||||||
|
*
|
||||||
|
* Vérifie :
|
||||||
|
* 1. chaque clé de EN existe dans FR et inversement (UI mi-traduite sinon) ;
|
||||||
|
* 2. chaque clé utilisée via t('…') dans js/ existe dans les deux dicts
|
||||||
|
* (leçon Android §12 : une référence manquante = texte brut à l'écran).
|
||||||
|
*
|
||||||
|
* Sortie : silencieuse si tout va bien, liste les problèmes sinon (exit 1).
|
||||||
|
* Aucune dépendance : parse le module i18n.js par regex (les dicts sont des
|
||||||
|
* littéraux plats) et grep les usages dans les sources.
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const i18nPath = join(root, 'js/util/i18n.js');
|
||||||
|
const src = readFileSync(i18nPath, 'utf8');
|
||||||
|
|
||||||
|
function extractDict(name) {
|
||||||
|
const m = src.match(new RegExp(`const ${name} = \\{([\\s\\S]*?)\\n\\};`));
|
||||||
|
if (!m) throw new Error(`dictionnaire ${name} introuvable`);
|
||||||
|
const keys = [];
|
||||||
|
// Les clés suivent la convention identifiant_underscore (a-z0-9_)
|
||||||
|
for (const km of m[1].matchAll(/^\s{2}([a-z0-9_]+):/gm)) keys.push(km[1]);
|
||||||
|
return new Set(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN = extractDict('EN');
|
||||||
|
const FR = extractDict('FR');
|
||||||
|
// Les presets vivent dans des dicts dédiés (même convention de clés)
|
||||||
|
for (const k of extractDict('EN_PRESETS')) EN.add(k);
|
||||||
|
for (const k of extractDict('FR_PRESETS')) FR.add(k);
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
// 1. EN ↔ FR
|
||||||
|
for (const k of EN) {
|
||||||
|
if (!FR.has(k)) {
|
||||||
|
console.error(`✗ clé EN absente de FR : ${k}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const k of FR) {
|
||||||
|
if (!EN.has(k)) {
|
||||||
|
console.error(`✗ clé FR absente de EN : ${k}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Clés utilisées dans les sources (t('key') et trKey('key'))
|
||||||
|
function walk(dir, out = []) {
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const p = join(dir, entry);
|
||||||
|
if (statSync(p).isDirectory()) walk(p, out);
|
||||||
|
else if (p.endsWith('.js')) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const files = walk(join(root, 'js')).filter((f) => !f.endsWith('i18n.js'));
|
||||||
|
const used = new Set();
|
||||||
|
const usageRe = /\b(?:t|trKey)\('([a-z0-9_]+)'/g;
|
||||||
|
for (const f of files) {
|
||||||
|
const content = readFileSync(f, 'utf8');
|
||||||
|
for (const m of content.matchAll(usageRe)) used.add(m[1]);
|
||||||
|
}
|
||||||
|
// Clés de presets (résolues dynamiquement via nameKey — hors t('…') littéral)
|
||||||
|
const presetsSrc = readFileSync(join(root, 'js/pk/presets.js'), 'utf8');
|
||||||
|
for (const m of presetsSrc.matchAll(/'([a-z0-9_]+)'/g)) {
|
||||||
|
if (m[1].startsWith('preset_')) used.add(m[1]);
|
||||||
|
}
|
||||||
|
// Clés de modèles/labels résolues dynamiquement (modelLabelKey, routeLabelKey…)
|
||||||
|
for (const k of ['model_ese', 'model_tfs', 'model_whs', 'route_oral', 'route_gel', 'route_patch',
|
||||||
|
'route_injection_im', 'route_injection_sc', 'route_other', 'type_estradiol', 'type_antiandrogen',
|
||||||
|
'type_progestogen', 'type_other']) {
|
||||||
|
used.add(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of used) {
|
||||||
|
if (!EN.has(k)) {
|
||||||
|
console.error(`✗ clé utilisée absente de EN : ${k}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
if (!FR.has(k)) {
|
||||||
|
console.error(`✗ clé utilisée absente de FR : ${k}`);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(` i18n : ${EN.size} clés EN / ${FR.size} clés FR / ${used.size} clés utilisées`);
|
||||||
|
if (!failed) console.error(' i18n cohérent');
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
68
scripts/serve.py
Normal file
68
scripts/serve.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
serve.py — serveur statique de développement/test pour HormoneTrack Web.
|
||||||
|
|
||||||
|
Pourquoi un script dédié (au lieu de `python3 -m http.server`) :
|
||||||
|
- headers Cache-Control: no-cache → un rechargement teste toujours le code
|
||||||
|
à jour (indispensable en développement et en smoke-test) ;
|
||||||
|
- Content-Type corrects (json, svg, wasm-safe) sur toutes les plateformes ;
|
||||||
|
- messages de démarrage explicites (URL + raccourci ?demo=1 documenté).
|
||||||
|
|
||||||
|
⚠️ C'est un serveur de DÉVELOPPEMENT (mono-thread, sans TLS). Pour la
|
||||||
|
production, servir CE DOSSIER avec n'importe quel serveur statique
|
||||||
|
(nginx, caddy, Gitea pages, etc.) — l'app n'a AUCUN composant serveur.
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 scripts/serve.py [--port 8970] [--bind 127.0.0.1]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
# S'assurer que les types marginaux sont corrects (utile pour l'asset PK)
|
||||||
|
mimetypes.add_type("application/json", ".json")
|
||||||
|
mimetypes.add_type("image/svg+xml", ".svg")
|
||||||
|
mimetypes.add_type("text/markdown", ".md")
|
||||||
|
|
||||||
|
|
||||||
|
class NoCacheHandler(SimpleHTTPRequestHandler):
|
||||||
|
"""Handler statique avec cache désactivé (dev/test)."""
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
self.send_header("Pragma", "no-cache")
|
||||||
|
self.send_header("Expires", "0")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
# Log concis vers stdout (une ligne par requête)
|
||||||
|
sys.stderr.write(" %s\n" % (format % args))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Serveur statique HormoneTrack Web (dev)")
|
||||||
|
parser.add_argument("--port", type=int, default=8970, help="port d'écoute (défaut 8970)")
|
||||||
|
parser.add_argument("--bind", default="127.0.0.1", help="interface d'écoute (défaut 127.0.0.1)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Racine = le dossier parent de scripts/ (la racine du dépôt), peu importe
|
||||||
|
# d'où le script est appelé.
|
||||||
|
web_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
os.chdir(web_root)
|
||||||
|
|
||||||
|
server = HTTPServer((args.bind, args.port), NoCacheHandler)
|
||||||
|
print(f"HormoneTrack Web — serveur de dev sur http://{args.bind}:{args.port}/")
|
||||||
|
print(" app : http://" + f"{args.bind}:{args.port}/")
|
||||||
|
print(" mode démo : http://" + f"{args.bind}:{args.port}/?demo=1 (données de test, si store vide)")
|
||||||
|
print(" Ctrl-C pour arrêter")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\narrêté")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
79
tests/alerts.test.js
Normal file
79
tests/alerts.test.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Tests des seuils d'alerte (miroir de AlertsTest.kt + AlertsEngineTest.kt) :
|
||||||
|
* comparaison stricte, cohérence, codec d'état, décision de notification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as Alerts from '../js/pk/alerts.js';
|
||||||
|
|
||||||
|
describe('évaluation des seuils', () => {
|
||||||
|
const t = new Alerts.Thresholds(400, 50, 1.2, 0.1);
|
||||||
|
|
||||||
|
test('strict : la valeur EXACTEMENT à la limite ne déclenche rien', () => {
|
||||||
|
assert.equal(Alerts.evaluate(400, 50, 400), null, '== high → rien');
|
||||||
|
assert.equal(Alerts.evaluate(50, 50, 400), null, '== low → rien');
|
||||||
|
assert.equal(Alerts.evaluate(400.01, 50, 400)[0], 'HIGH');
|
||||||
|
assert.equal(Alerts.evaluate(49.99, 50, 400)[0], 'LOW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null = jamais d\'alerte sur ce côté', () => {
|
||||||
|
assert.equal(Alerts.evaluate(1000, null, null), null);
|
||||||
|
assert.equal(Alerts.evaluate(1, null, null), null);
|
||||||
|
assert.equal(Alerts.evaluate(500, null, 400)[0], 'HIGH');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HIGH prime LOW en configuration incohérente (défensif)', () => {
|
||||||
|
const [level, limit] = Alerts.evaluate(50, 100, 40); // haut < bas (interdit à la saisie)
|
||||||
|
assert.equal(level, 'HIGH');
|
||||||
|
assert.equal(limit, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evaluateAll : ordre stable E2 puis T, unités correctes', () => {
|
||||||
|
const alerts = Alerts.evaluateAll(500, 0.05, t);
|
||||||
|
assert.equal(alerts.length, 2);
|
||||||
|
assert.deepEqual(alerts.map((a) => a.marker), ['E2', 'T']);
|
||||||
|
assert.deepEqual(alerts.map((a) => a.unit), ['pg/mL', 'ng/mL']);
|
||||||
|
assert.equal(alerts[0].level, 'HIGH');
|
||||||
|
assert.equal(alerts[1].level, 'LOW');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCoherent : haut > bas requis (les deux marqueurs indépendants)', () => {
|
||||||
|
assert.equal(new Alerts.Thresholds(400, 50, 1.2, 0.1).isCoherent(), true);
|
||||||
|
assert.equal(new Alerts.Thresholds(50, 400, null, null).isCoherent(), false);
|
||||||
|
assert.equal(new Alerts.Thresholds(null, null, 0.1, 1.2).isCoherent(), false);
|
||||||
|
// un seul côté défini → cohérent
|
||||||
|
assert.equal(new Alerts.Thresholds(400, null, null, 0.1).isCoherent(), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('codec d\'état (anti-spam) — rétro-résistant aux entrées malformées', () => {
|
||||||
|
test('encode → parse round-trip', () => {
|
||||||
|
const alerts = [
|
||||||
|
{ marker: 'E2', level: 'HIGH' },
|
||||||
|
{ marker: 'T', level: 'LOW' },
|
||||||
|
];
|
||||||
|
const encoded = Alerts.encodeState(alerts);
|
||||||
|
assert.equal(encoded, 'E2:HIGH;T:LOW');
|
||||||
|
assert.deepEqual(Alerts.parseState(encoded), { E2: 'HIGH', T: 'LOW' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse : résistant aux segments cassés', () => {
|
||||||
|
assert.deepEqual(Alerts.parseState('E2:HIGH;garbage;T:BOGUS;:LOW'), { E2: 'HIGH' });
|
||||||
|
assert.deepEqual(Alerts.parseState(''), {});
|
||||||
|
assert.deepEqual(Alerts.parseState(null), {});
|
||||||
|
assert.deepEqual(Alerts.parseState(undefined), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldNotify : nouveau franchissement → true, même état → false', () => {
|
||||||
|
const current = { E2: 'HIGH' };
|
||||||
|
assert.equal(Alerts.shouldNotify(current, null), true, 'première notification');
|
||||||
|
assert.equal(Alerts.shouldNotify(current, { E2: 'HIGH' }), false, 'pas de spam');
|
||||||
|
assert.equal(Alerts.shouldNotify(current, { E2: 'LOW' }), true, 'changement H↔L');
|
||||||
|
assert.equal(Alerts.shouldNotify({ E2: 'HIGH', T: 'LOW' }, { E2: 'HIGH' }), true, 'nouveau marqueur');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retour à la normale → false (pas de notif) — l\'appelant efface l\'état', () => {
|
||||||
|
assert.equal(Alerts.shouldNotify({}, { E2: 'HIGH' }), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
177
tests/backup.test.js
Normal file
177
tests/backup.test.js
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Tests du format de backup (miroir de BackupGsonTest.kt + régressions sur
|
||||||
|
* les exports Android réels) :
|
||||||
|
*
|
||||||
|
* - round-trip build → parse (compatibilité exacte du schéma v2) ;
|
||||||
|
* - rétrocompat v1 (sans settings) ;
|
||||||
|
* - import ÉCRASEMENT (IDs conservés, ordre enfants → parents) ;
|
||||||
|
* - **les exports Android réels s'importent dans le web** (test data-driven
|
||||||
|
* sur local-test-data/ si présent — mêmes fichiers que les régressions
|
||||||
|
* Android, hors dépôt) ;
|
||||||
|
* - noms de fichiers d'export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
WEB_ROOT, localTestDataDir, makeTreatment, makeDose, makeLab, HOUR_MS,
|
||||||
|
} from './helpers.js';
|
||||||
|
import { buildBackupJson, parseBackupJson, backupFileName, diagnosticLogFileName } from '../js/data/backup.js';
|
||||||
|
import { store } from '../js/data/store.js';
|
||||||
|
|
||||||
|
/** Backend Map isolé par test (localStorage indisponible en Node). */
|
||||||
|
function freshStore() {
|
||||||
|
store.setBackend(new Map());
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('format BackupData v2 (compatibilité Android)', () => {
|
||||||
|
test('build → parse round-trip : tous les champs survivent', () => {
|
||||||
|
const treatments = [makeTreatment({ id: 3, scaleFactor: 0.72, forecastIntervalDays: 7, reminderHour: 18, reminderMinute: 30, reminderEnabled: true })];
|
||||||
|
const doseLogs = [makeDose(1000, 5, { id: 7, notes: 'cuisse L', esterType: 'EV' })];
|
||||||
|
const labResults = [makeLab('T', 44, 'ng/dL', 2000, { id: 9, notes: 'jeûne' })];
|
||||||
|
const tConfig = { base: 6.0, floor: 0.2, k: 0.21 };
|
||||||
|
const settings = {
|
||||||
|
language: 'fr', autoCalibrate: true,
|
||||||
|
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
|
||||||
|
};
|
||||||
|
const json = buildBackupJson({ treatments, doseLogs, labResults }, tConfig, settings);
|
||||||
|
const parsed = parseBackupJson(json);
|
||||||
|
|
||||||
|
assert.equal(parsed.version, 2);
|
||||||
|
assert.equal(parsed.treatments.length, 1);
|
||||||
|
assert.equal(parsed.doseLogs.length, 1);
|
||||||
|
assert.equal(parsed.labResults.length, 1);
|
||||||
|
assert.deepEqual(parsed.tConfig, tConfig);
|
||||||
|
// champs plats UserSettings exactement comme Gson
|
||||||
|
assert.deepEqual(parsed.settings, {
|
||||||
|
language: 'fr', autoCalibrate: true,
|
||||||
|
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
|
||||||
|
});
|
||||||
|
// fidélité des entités (champs Gson/Kotlin)
|
||||||
|
assert.equal(parsed.treatments[0].calendarEventId, null);
|
||||||
|
assert.equal(parsed.treatments[0].pkModel, 'TFS');
|
||||||
|
assert.equal(parsed.doseLogs[0].esterType, 'EV');
|
||||||
|
assert.equal(parsed.labResults[0].unit, 'ng/dL');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rétrocompat v1 : backup SANS settings → settings null, importable', () => {
|
||||||
|
// Structure d'un vrai export v1 (avant la v1.4.2 Android)
|
||||||
|
const v1 = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
exportedAt: 1700000000000,
|
||||||
|
treatments: [makeTreatment({ id: 1 })],
|
||||||
|
doseLogs: [makeDose(0, 5)],
|
||||||
|
labResults: [],
|
||||||
|
tConfig: { base: 6.0, floor: 0.2, k: 0.19 },
|
||||||
|
});
|
||||||
|
const parsed = parseBackupJson(v1);
|
||||||
|
assert.equal(parsed.version, 1);
|
||||||
|
assert.equal(parsed.settings, null);
|
||||||
|
assert.equal(parsed.treatments.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backup tronqué/tordu : défauts TConfig, listes manquantes → vides', () => {
|
||||||
|
const weird = parseBackupJson('{"treatments": null}');
|
||||||
|
assert.deepEqual(weird.treatments, []);
|
||||||
|
assert.equal(weird.tConfig.base, 6.0, 'défaut TConfig.kt si absent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-JSON et non-backup → erreur explicite (pas de crash silencieux)', () => {
|
||||||
|
assert.throws(() => parseBackupJson('not json at all'), /JSON illisible/);
|
||||||
|
assert.throws(() => parseBackupJson('{"foo": 1}'), /backup/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les clés web extra (chartTimezone…) ne fuient PAS dans l\'export', () => {
|
||||||
|
const json = buildBackupJson(
|
||||||
|
{ treatments: [], doseLogs: [], labResults: [] },
|
||||||
|
{ base: 6, floor: 0.2, k: 0.19 },
|
||||||
|
{ language: 'fr', autoCalibrate: false, chartTimezone: 'Europe/Paris', alertNotifiedState: 'E2:HIGH' },
|
||||||
|
);
|
||||||
|
assert.ok(!json.includes('chartTimezone'), 'clé web exclue');
|
||||||
|
assert.ok(!json.includes('alertNotifiedState'), 'clé web exclue');
|
||||||
|
assert.ok(!json.includes('changelogSeenVersion'), 'volontairement exclu (miroir Android)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('import ÉCRASEMENT (v1.2.6)', () => {
|
||||||
|
test('données actuelles effacées, IDs du backup conservés, compteur recalculé', () => {
|
||||||
|
const s = freshStore();
|
||||||
|
// État initial : un traitement id 5 + une dose id 2 rattachée
|
||||||
|
s.upsertTreatment(makeTreatment({ id: 5, name: 'ancien' }));
|
||||||
|
s.upsertDoseLog(makeDose(0, 1, { id: 2, treatmentId: 5 }));
|
||||||
|
|
||||||
|
const backup = parseBackupJson(buildBackupJson({
|
||||||
|
treatments: [makeTreatment({ id: 11, name: 'nouveau' })],
|
||||||
|
doseLogs: [makeDose(100, 5, { id: 21, treatmentId: 11 })],
|
||||||
|
labResults: [makeLab('E2', 300, 'pg/mL', 150, { id: 31 })],
|
||||||
|
}, { base: 6, floor: 0.2, k: 0.19 }, null));
|
||||||
|
|
||||||
|
const result = s.importOverwrite(backup);
|
||||||
|
assert.deepEqual(result, { treatments: 1, doseLogs: 1, labResults: 1 });
|
||||||
|
|
||||||
|
// IDs du backup conservés (FK dose→traitement valides)
|
||||||
|
assert.deepEqual(s.getTreatments().map((t) => t.id), [11]);
|
||||||
|
assert.equal(s.getDoseLogs()[0].treatmentId, 11);
|
||||||
|
assert.equal(s.getLabResults()[0].id, 31);
|
||||||
|
// le prochain id auto-increment ne rentre PAS en conflit avec les ids importés
|
||||||
|
const nextId = s.upsertTreatment(makeTreatment({ name: 'post-import' }));
|
||||||
|
assert.ok(nextId > 11, `prochain id = ${nextId} > max importé`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import d\'un backup v1 : les réglages ne sont PAS touchés', () => {
|
||||||
|
const s = freshStore();
|
||||||
|
s.setSettings({ autoCalibrate: true, alertE2High: 400 });
|
||||||
|
const backup = parseBackupJson(buildBackupJson(
|
||||||
|
{ treatments: [], doseLogs: [], labResults: [] },
|
||||||
|
{ base: 5, floor: 0.1, k: 0.2 },
|
||||||
|
null, // v1 : pas de settings
|
||||||
|
));
|
||||||
|
s.importOverwrite(backup);
|
||||||
|
s.setTConfig(backup.tConfig);
|
||||||
|
assert.equal(s.getSettings().autoCalibrate, true, 'réglages intacts (null = ne rien toucher)');
|
||||||
|
assert.equal(s.getTConfig().base, 5, 'tConfig du backup restauré');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exports Android RÉELS → import web (données hors dépôt)', () => {
|
||||||
|
// Le dossier vit dans le dépôt Android voisin (dépôts séparés depuis
|
||||||
|
// v1.4.10) — cf helpers.localTestDataDir() : null → les tests skip
|
||||||
|
// (clone neuf / CI), comme les Assume Kotlin côté Android.
|
||||||
|
const dir = localTestDataDir();
|
||||||
|
const backups = ['backup-v1.0.0.json', 'backup-v1.2.0.json', 'backup-v1.3.1.json', 'backup-v1.4.2.json'];
|
||||||
|
|
||||||
|
for (const name of backups) {
|
||||||
|
const path = dir ? join(dir, name) : null;
|
||||||
|
// Skip si absent (clone neuf / CI) — même comportement Assume côté Android
|
||||||
|
if (!path || !existsSync(path)) {
|
||||||
|
test.skip(`${name} : import web (données locales absentes)`, () => {});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
test(`${name} : parse + import + calibration non vide`, () => {
|
||||||
|
const s = freshStore();
|
||||||
|
const parsed = parseBackupJson(readFileSync(path, 'utf8'));
|
||||||
|
const result = s.importOverwrite(parsed);
|
||||||
|
assert.ok(result.treatments > 0, `${result.treatments} traitements`);
|
||||||
|
assert.ok(result.doseLogs > 0, `${result.doseLogs} doses`);
|
||||||
|
assert.ok(result.labResults > 0, `${result.labResults} analyses`);
|
||||||
|
// FK intactes : chaque dose pointe vers un traitement existant
|
||||||
|
const ids = new Set(s.getTreatments().map((t) => t.id));
|
||||||
|
for (const d of s.getDoseLogs()) {
|
||||||
|
assert.ok(ids.has(d.treatmentId), 'FK dose → traitement');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noms de fichiers d\'export (miroir ExportFileNames)', () => {
|
||||||
|
test('backup : hormonetrack-backup-YYYYMMDD.json', () => {
|
||||||
|
assert.equal(backupFileName(new Date(2026, 8, 8)), 'hormonetrack-backup-20260908.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs : hormonetrack-logs-YYYYMMDD-HHmm.txt (pattern AVEC heure, bug #48)', () => {
|
||||||
|
assert.equal(diagnosticLogFileName(new Date(2026, 8, 8, 9, 5)), 'hormonetrack-logs-20260908-0905.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
202
tests/calibration.test.js
Normal file
202
tests/calibration.test.js
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Tests de calibration (miroir de CalibrationPerModelTest.kt +
|
||||||
|
* ScaleFactorWhsahReproTest.kt + V120FeaturesTest.kt) :
|
||||||
|
*
|
||||||
|
* - facteur PAR PÉRIODE D'ESTER (labs EV → EV, labs EEn → EEn) ;
|
||||||
|
* - calibration PAR MODÈLE (fix #60) : échelles distinctes ESE/TFS/WHS,
|
||||||
|
* chaque courbe calibrée passe par le lab ;
|
||||||
|
* - GARDE DE SIGNIFICATIVITÉ (fix #61) : reproduction du scénario ×2,21
|
||||||
|
* (dose de test ancienne + labs tardifs) — le facteur retombe physiologique ;
|
||||||
|
* - k T par ester, calibré contre l'E2 CALIBRÉE ;
|
||||||
|
* - autoCalibrated ne modifie RIEN (copies).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
initProfiles, makeTreatment, makeDose, makeLab, assertClose, HOUR_MS, DAY_MS,
|
||||||
|
} from './helpers.js';
|
||||||
|
import {
|
||||||
|
autoCalibrated, computeEsterScaleFactors, computeTKPerEster, computeScaleFactor,
|
||||||
|
labIsSignificant, computeTConfigCalibration, LAB_MIN_PREDICTION_FRACTION,
|
||||||
|
} from '../js/pk/pk-calibration.js';
|
||||||
|
import { e2At, TConfig } from '../js/pk/index.js';
|
||||||
|
|
||||||
|
describe('labIsSignificant (garde #61)', () => {
|
||||||
|
test('prédiction > 0,5 et ≥ 15 % du max observé', () => {
|
||||||
|
assert.equal(labIsSignificant(100, 0), true, 'premier lab (max 0)');
|
||||||
|
assert.equal(labIsSignificant(100, 500), true, '20 % du max');
|
||||||
|
assert.equal(labIsSignificant(50, 500), false, '10 % du max → ignoré');
|
||||||
|
assert.equal(labIsSignificant(0.3, 0), false, 'bruit < 0,5 pg/mL');
|
||||||
|
assert.equal(LAB_MIN_PREDICTION_FRACTION, 0.15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calibration par période d\'ester (v1.2.1)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('labs EV calibrent EV, labs EEn calibrent EEn (scénario transition)', () => {
|
||||||
|
// Scénario : période EV (2 mg / 5 j, jours 30–60) puis période EEn
|
||||||
|
// (5 mg / 7 j, jours 67–95). Les lab values sont posées DATA-DRIVEN :
|
||||||
|
// lab = prédiction complète × facteur voulu (l'accumulation est prise
|
||||||
|
// en compte par e2At, comme dans l'app).
|
||||||
|
const trEv = makeTreatment({ id: 1, esterType: 'EV', pkModel: 'TFS', doseAmount: 2 });
|
||||||
|
const trEen = makeTreatment({ id: 2, esterType: 'EEN', pkModel: 'TFS' });
|
||||||
|
const treatments = [trEv, trEen];
|
||||||
|
const doses = [];
|
||||||
|
let id = 1;
|
||||||
|
for (let day = 30; day <= 60; day += 5) {
|
||||||
|
doses.push(makeDose(day * DAY_MS, 2, { id: id++, treatmentId: 1, esterType: 'EV' }));
|
||||||
|
}
|
||||||
|
for (let day = 67; day <= 95; day += 7) {
|
||||||
|
doses.push(makeDose(day * DAY_MS, 5, { id: id++, treatmentId: 2, esterType: 'EEN' }));
|
||||||
|
}
|
||||||
|
// Lab EV : jour 62 (dernière dose EV jour 60, avant la 1ʳᵉ EEn jour 67)
|
||||||
|
const labEvTs = 62 * DAY_MS;
|
||||||
|
const predEv = e2At(treatments, doses, labEvTs, null, null);
|
||||||
|
// Lab EEn : jour 97 (dernière dose EEn jour 95)
|
||||||
|
const labEenTs = 97 * DAY_MS;
|
||||||
|
const predEen = e2At(treatments, doses, labEenTs, null, null);
|
||||||
|
const labs = [
|
||||||
|
makeLab('E2', predEv * 1.2, 'pg/mL', labEvTs),
|
||||||
|
makeLab('E2', predEen * 0.8, 'pg/mL', labEenTs),
|
||||||
|
];
|
||||||
|
const scales = computeEsterScaleFactors(treatments, doses, labs, null);
|
||||||
|
// Chaque ester calibré par SON lab (pas de mélange des périodes)
|
||||||
|
assertClose(scales.EV, 1.2, 0.02, `facteur EV = ${scales.EV}`);
|
||||||
|
assertClose(scales.EEN, 0.8, 0.02, `facteur EEN = ${scales.EEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('labs antérieurs à la 1ʳᵉ dose → échelles vides (rien à calibrer)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EEN', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(50 * DAY_MS, 5)];
|
||||||
|
const labs = [makeLab('E2', 300, 'pg/mL', 10 * DAY_MS)];
|
||||||
|
assert.deepEqual(computeEsterScaleFactors([tr], doses, labs, null), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calibration PAR MODÈLE (fix #60)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('chaque courbe calibrée avec SON modèle passe par le lab ; échelles distinctes', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS', scaleFactor: 1.0 });
|
||||||
|
const doses = [makeDose(0, 5)];
|
||||||
|
const labTs = 2.1 * DAY_MS; // ~pic TFS EV
|
||||||
|
const labValue = 300;
|
||||||
|
const labs = [makeLab('E2', labValue, 'pg/mL', labTs)];
|
||||||
|
const scalesByModel = {};
|
||||||
|
for (const model of ['ESE', 'TFS', 'WHS']) {
|
||||||
|
const scales = computeEsterScaleFactors([tr], doses, labs, model);
|
||||||
|
scalesByModel[model] = scales.EV;
|
||||||
|
const calibrated = e2At([tr], doses, labTs, model, scales);
|
||||||
|
assertClose(calibrated, labValue, 0.02, `courbe ${model} calibrée passe par le lab`);
|
||||||
|
}
|
||||||
|
// Les 3 facteurs sont DISTINCTS (prédictions différentes par modèle) :
|
||||||
|
assert.ok(Math.abs(scalesByModel.ESE - scalesByModel.TFS) > 0.02, 'ESE ≠ TFS');
|
||||||
|
assert.ok(Math.abs(scalesByModel.WHS - scalesByModel.TFS) > 0.02, 'WHS ≠ TFS');
|
||||||
|
assertClose(scalesByModel.TFS, 1.02, 0.05, 'TFS : ratio vs son propre pic ≈ 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('garde de significativité — reproduction du bug ×2,21 (fix #61)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('garde de significativité : les labs hors fenêtre d\'action sont EXCLUS des 2 pipelines', () => {
|
||||||
|
// Reproduction du scénario ×2,21 (v1.4.8) : UNE dose + un lab dans la
|
||||||
|
// fenêtre d'action (ratio correct) + un lab TARDIF avec un ratio absurde
|
||||||
|
// (prédiction résiduelle ≈ 0). Sans garde, la médiane explosait.
|
||||||
|
const tr = makeTreatment({ id: 1, esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(0, 5, { treatmentId: 1 })];
|
||||||
|
const goodTs = 2 * DAY_MS; // ~pic : prédiction ≈ 295
|
||||||
|
const goodValue = e2At([tr], doses, goodTs, null, null) * 0.9;
|
||||||
|
const lateTs = 60 * DAY_MS; // prédiction résiduelle ≈ 0
|
||||||
|
const labs = [
|
||||||
|
makeLab('E2', goodValue, 'pg/mL', goodTs),
|
||||||
|
makeLab('E2', 3000, 'pg/mL', lateTs), // ratio ~×1000 — doit être ignoré
|
||||||
|
];
|
||||||
|
// Pipeline MANUEL
|
||||||
|
const sf = computeScaleFactor(tr, doses, labs);
|
||||||
|
assertClose(sf, 0.9, 0.05, `facteur manuel = ${sf} (jamais la médiane contaminée)`);
|
||||||
|
// Pipeline AUTO
|
||||||
|
const scales = computeEsterScaleFactors([tr], doses, labs, 'TFS');
|
||||||
|
assertClose(scales.EV, 0.9, 0.05, `facteur auto = ${scales.EV}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le facteur reste exact quand les labs sont dans la fenêtre d\'action', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(0, 5)];
|
||||||
|
const labTs = 2 * DAY_MS;
|
||||||
|
const labValue = e2At([tr], doses, labTs, null, null) * 0.9; // prédiction ×0.9
|
||||||
|
const labs = [makeLab('E2', labValue, 'pg/mL', labTs)];
|
||||||
|
const sf = computeScaleFactor(tr, doses, labs);
|
||||||
|
assertClose(sf, 0.9, 0.05, 'facteur manuel dans la fenêtre d\'action');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('k T par ester (v1.2.3)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('k calibré par période contre l\'E2 CALIBRÉE ; originaux non modifiés', () => {
|
||||||
|
const tr = makeTreatment({ id: 1, esterType: 'EEN', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(0, 5, { treatmentId: 1 })];
|
||||||
|
const tConfig = new TConfig(6, 0.2, 0.19);
|
||||||
|
const scales = { EEN: 1.0 };
|
||||||
|
// Lab T = 0.35 ng/mL à l'équilibre (E2 ≈ 160 calibrée)
|
||||||
|
const labs = [makeLab('T', 0.35, 'ng/mL', 7 * DAY_MS)];
|
||||||
|
const ks = computeTKPerEster([tr], doses, labs, tConfig, scales, null);
|
||||||
|
assert.ok(ks.EEN > 1e-4 && ks.EEN < 10, `k = ${ks.EEN} dans la garde`);
|
||||||
|
// le tConfig d'origine n'est pas modifié (pur)
|
||||||
|
assert.equal(tConfig.k, 0.19);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lab T en ng/dL converti avant calibration (bug #23)', () => {
|
||||||
|
const tr = makeTreatment({ id: 1, esterType: 'EEN', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(0, 5, { treatmentId: 1 })];
|
||||||
|
const tConfig = new TConfig(6, 0.2, 0.19);
|
||||||
|
const ksNgMl = computeTKPerEster([tr], doses, [makeLab('T', 0.35, 'ng/mL', 7 * DAY_MS)], tConfig, { EEN: 1.0 }, null);
|
||||||
|
const ksNgDl = computeTKPerEster([tr], doses, [makeLab('T', 35, 'ng/dL', 7 * DAY_MS)], tConfig, { EEN: 1.0 }, null);
|
||||||
|
assertClose(ksNgMl.EEN, ksNgDl.EEN, 1e-9, 'même k quelle que soit l\'unité saisie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoCalibrated (option Paramètres)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('renvoie des COPIES : traitements et tConfig inchangés', () => {
|
||||||
|
const tr = makeTreatment({ id: 1, scaleFactor: 0.72 });
|
||||||
|
const tConfig = new TConfig(6, 0.2, 0.19);
|
||||||
|
const doses = [makeDose(0, 5, { treatmentId: 1 })];
|
||||||
|
const labs = [makeLab('E2', 300, 'pg/mL', 2 * DAY_MS)];
|
||||||
|
const result = autoCalibrated([tr], doses, labs, tConfig);
|
||||||
|
assert.equal(result.treatments[0].scaleFactor, 0.72, 'scaleFactor stocké intact');
|
||||||
|
assert.equal(result.tConfig.k, 0.19, 'tConfig intact');
|
||||||
|
assert.equal(result.calibratedEsters, 1);
|
||||||
|
assert.equal(result.tRecalibrated, false, 'pas de lab T → pas de recalibrage T');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modelOverride délégue : implémentation unique (Home et graphique ne peuvent pas diverger)', () => {
|
||||||
|
const tr = makeTreatment({ id: 1, esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(0, 5, { treatmentId: 1 })];
|
||||||
|
const labs = [makeLab('E2', 300, 'pg/mL', 2.1 * DAY_MS)];
|
||||||
|
const cfg = new TConfig();
|
||||||
|
const viaAuto = autoCalibrated([tr], doses, labs, cfg, 'TFS').esterScales;
|
||||||
|
const viaDirect = computeEsterScaleFactors([tr], doses, labs, 'TFS');
|
||||||
|
assert.deepEqual(viaAuto, viaDirect, 'autoCalibrated = computeEsterScaleFactors (délégation)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeTConfigCalibration (k global, bouton Paramètres)', () => {
|
||||||
|
test('récupère un k planté ; null sans labs exploitables', () => {
|
||||||
|
const tr = makeTreatment({ id: 1, esterType: 'EEN', pkModel: 'TFS' });
|
||||||
|
const doses = [makeDose(0, 5, { treatmentId: 1 })];
|
||||||
|
const cfg = new TConfig(6, 0.2, 0.19);
|
||||||
|
const labs = [makeLab('T', 0.35, 'ng/mL', 7 * DAY_MS)];
|
||||||
|
const calibrated = computeTConfigCalibration(labs, [tr], doses, cfg);
|
||||||
|
assert.ok(calibrated && calibrated.k > 1e-4 && calibrated.k < 10, `k = ${calibrated && calibrated.k}`);
|
||||||
|
assert.equal(calibrated.base, 6);
|
||||||
|
assert.equal(calibrated.floor, 0.2);
|
||||||
|
// lab sous le plancher + 0.02 → inutilisable
|
||||||
|
const low = computeTConfigCalibration([makeLab('T', 0.2, 'ng/mL', 7 * DAY_MS)], [tr], doses, cfg);
|
||||||
|
assert.equal(low, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
tests/chart-helpers.test.js
Normal file
180
tests/chart-helpers.test.js
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Tests des helpers du graphique (miroir de ChartZoomTest.kt) :
|
||||||
|
* pan fractionnaire (#62), niceCeil, stepForRange, ticks minuit local (#55),
|
||||||
|
* pointHoursBefore (#53), clamps de pan (#54), horizon/extension de prévision,
|
||||||
|
* toggles par défaut (#58).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
panDeltaHours, niceCeil, stepForRange, xLabelTicks, clampPanHours,
|
||||||
|
pointHoursBefore, forecastHorizonHours, forecastExtensionHours,
|
||||||
|
defaultModelToggles,
|
||||||
|
} from '../js/pk/chart-helpers.js';
|
||||||
|
import { LevelPoint } from '../js/pk/pk-engine.js';
|
||||||
|
import { HOUR_MS, DAY_MS } from './helpers.js';
|
||||||
|
|
||||||
|
describe('panDeltaHours — fix #62 (pan mort sur 24 h)', () => {
|
||||||
|
test('le résiduel fractionnaire s\'accumule jusqu\'à franchir une heure', () => {
|
||||||
|
// Vue 24 h, canvas 900 px : 30 px ≈ 0,8 h — chaque delta seul serait 0
|
||||||
|
let residual = 0;
|
||||||
|
let applied = 0;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = panDeltaHours(30, 900, 24, residual);
|
||||||
|
applied += r.hours;
|
||||||
|
residual = r.residual;
|
||||||
|
}
|
||||||
|
assert.ok(applied >= 1, `3 deltas de 0,8 h doivent appliquer ≥ 1 h (obtenu ${applied})`);
|
||||||
|
assert.equal(applied, 2, '0,8 + 0,8 = 1,6 → 1 appliqué, 0,8 → 2 au total');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deltas négatifs : troncature vers zéro, résiduel conservé', () => {
|
||||||
|
let r = panDeltaHours(-30, 900, 24, 0);
|
||||||
|
assert.equal(r.hours, 0); // -0.8 → 0 (vers zéro)
|
||||||
|
assert.ok(r.residual < 0);
|
||||||
|
r = panDeltaHours(-30, 900, 24, r.residual);
|
||||||
|
assert.equal(r.hours, -1, '-0.8 + -0.8 = -1.6 → -1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canvas de largeur nulle → pas d\'application, résiduel intact', () => {
|
||||||
|
assert.deepEqual(panDeltaHours(50, 0, 24, 0.5), { hours: 0, residual: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grande plage (7 j) : un delta de 30 px ≈ 5,6 h appliqué immédiatement', () => {
|
||||||
|
assert.equal(panDeltaHours(30, 900, 24 * 7, 0).hours, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('niceCeil / stepForRange', () => {
|
||||||
|
test('bornes propres 1/2/2.5/5/10 × 10ⁿ', () => {
|
||||||
|
assert.equal(niceCeil(370), 500);
|
||||||
|
assert.equal(niceCeil(250), 250);
|
||||||
|
assert.equal(niceCeil(240), 250);
|
||||||
|
assert.equal(niceCeil(101), 200);
|
||||||
|
assert.equal(niceCeil(99), 100);
|
||||||
|
assert.equal(niceCeil(5), 5);
|
||||||
|
assert.equal(niceCeil(0), 100, 'valeur nulle → borne par défaut');
|
||||||
|
assert.equal(niceCeil(-3), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('échantillonnage adaptatif (v1.2.9)', () => {
|
||||||
|
assert.equal(stepForRange(6), 900000, '≤ 12 h → 15 min');
|
||||||
|
assert.equal(stepForRange(12), 900000);
|
||||||
|
assert.equal(stepForRange(13), 1800000, '≤ 24 h → 30 min');
|
||||||
|
assert.equal(stepForRange(24), 1800000);
|
||||||
|
assert.equal(stepForRange(24 * 7), 3600000, 'sinon 1 h');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('xLabelTicks — fix #55 (labels à minuit LOCAL)', () => {
|
||||||
|
test('pas 24 h : ticks alignés sur minuit LOCAL (Europe/Paris)', () => {
|
||||||
|
// Choisis un t0 au milieu d'une journée : le 1er tick doit être un minuit
|
||||||
|
// de Paris (01:00/00:00 UTC selon DST)
|
||||||
|
const t0 = Date.UTC(2026, 8, 10, 14, 30); // 10 sept 2026 14:30 UTC
|
||||||
|
const t1 = t0 + 10 * DAY_MS;
|
||||||
|
const ticks = xLabelTicks(t0, t1, 24, 'Europe/Paris');
|
||||||
|
assert.ok(ticks.length >= 9);
|
||||||
|
for (const tick of ticks) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}).formatToParts(new Date(tick));
|
||||||
|
const hour = parts.find((p) => p.type === 'hour').value;
|
||||||
|
const minute = parts.find((p) => p.type === 'minute').value;
|
||||||
|
assert.equal(hour, '00', `tick ${new Date(tick).toISOString()} doit être minuit Paris`);
|
||||||
|
assert.equal(minute, '00');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pas horaire : ticks sur des heures rondes LOCALES', () => {
|
||||||
|
const t0 = Date.UTC(2026, 8, 10, 14, 30);
|
||||||
|
const t1 = t0 + 8 * HOUR_MS;
|
||||||
|
const ticks = xLabelTicks(t0, t1, 1, 'Europe/Paris');
|
||||||
|
for (const tick of ticks) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||||
|
}).formatToParts(new Date(tick));
|
||||||
|
assert.equal(Number(parts.find((p) => p.type === 'minute').value), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pas 5 jours : ticks espacés de 5 j calendaires (DST-safe)', () => {
|
||||||
|
const t0 = Date.UTC(2026, 2, 25, 12); // traverse le DST européen fin mars
|
||||||
|
const t1 = t0 + 30 * DAY_MS;
|
||||||
|
const ticks = xLabelTicks(t0, t1, 120, 'Europe/Paris');
|
||||||
|
assert.ok(ticks.length >= 4);
|
||||||
|
for (let i = 1; i < ticks.length; i++) {
|
||||||
|
const deltaDays = (ticks[i] - ticks[i - 1]) / DAY_MS;
|
||||||
|
assert.ok(deltaDays >= 4.9 && deltaDays <= 5.1, `écart ≈ 5 j (obtenu ${deltaDays})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fenêtre vide / pas ≤ 0 → vide', () => {
|
||||||
|
assert.deepEqual(xLabelTicks(100, 100, 24), []);
|
||||||
|
assert.deepEqual(xLabelTicks(200, 100, 24), []);
|
||||||
|
assert.deepEqual(xLabelTicks(0, 100, 0), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fuseau par défaut = fuseau du système (pas de crash)', () => {
|
||||||
|
assert.ok(xLabelTicks(0, 10 * DAY_MS, 24).length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampPanHours / horizon de prévision (#54/#61)', () => {
|
||||||
|
test('clamp : passé borné par maxPast, futur seulement si prévision active', () => {
|
||||||
|
assert.equal(clampPanHours(50, 0, 100), 50);
|
||||||
|
assert.equal(clampPanHours(150, 0, 100), 100, 'passé clampé');
|
||||||
|
assert.equal(clampPanHours(-5, 0, 100), 0, 'futur interdit sans prévision');
|
||||||
|
assert.equal(clampPanHours(-5, 48, 100), -5, 'futur autorisé avec prévision');
|
||||||
|
assert.equal(clampPanHours(-100, 48, 100), -48, 'futur clampé à l\'horizon');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forecastHorizonHours : 12 × Posologie borné [30 j ; 1 an]', () => {
|
||||||
|
assert.equal(forecastHorizonHours(7), 12 * 7 * 24 / 24 * 24); // 7 j × 12 = 84 j → non borné
|
||||||
|
assert.equal(forecastHorizonHours(7), 7 * 24 * 12);
|
||||||
|
assert.equal(forecastHorizonHours(1), 24 * 30, 'borné bas à 30 j');
|
||||||
|
assert.equal(forecastHorizonHours(60), 24 * 365, 'borné haut à 1 an');
|
||||||
|
assert.equal(forecastHorizonHours(null), null);
|
||||||
|
assert.equal(forecastHorizonHours(0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forecastExtensionHours : au moins la prochaine dose (+12 h), sinon 1/3 de plage', () => {
|
||||||
|
assert.equal(forecastExtensionHours(24, 84 * 24, 48), 60, '48 + 12 = 60 h');
|
||||||
|
assert.equal(forecastExtensionHours(720, 84 * 24, null), 240, '1/3 de la plage');
|
||||||
|
assert.equal(forecastExtensionHours(24, 0, 48), 0, 'horizon 0 → rien');
|
||||||
|
assert.equal(forecastExtensionHours(24, null, 48), 0, 'pas de prévision → rien');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pointHoursBefore — fix #53 (delta « 6 h » qui comparait 24 h)', () => {
|
||||||
|
const curve = [];
|
||||||
|
for (let h = 0; h <= 24; h++) {
|
||||||
|
curve.push(new LevelPoint(h * HOUR_MS, h, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('le point le plus PROCHE de −6 h (par excès)', () => {
|
||||||
|
const p = pointHoursBefore(curve, 6);
|
||||||
|
assert.equal((curve[24].timestamp - p.timestamp) / HOUR_MS, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retourne null si aucun point au-delà de N heures (courbe trop courte)', () => {
|
||||||
|
const short = [new LevelPoint(0, 1, 1), new LevelPoint(2 * HOUR_MS, 2, 1)];
|
||||||
|
assert.equal(pointHoursBefore(short, 6), null);
|
||||||
|
assert.equal(pointHoursBefore([], 6), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultModelToggles — fix #58 (toggles alignés sur les traitements)', () => {
|
||||||
|
test('seuls les modèles utilisés sont ON (EV TFS + EEn WHSAH → ESE off)', () => {
|
||||||
|
const toggles = defaultModelToggles(new Set(['TFS', 'WHS']));
|
||||||
|
assert.deepEqual(toggles, { ESE: false, TFS: true, WHS: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('garde-fou : aucun profil → TFS forcé ON (Bateman tracé dans chaque série)', () => {
|
||||||
|
assert.deepEqual(defaultModelToggles(new Set()), { ESE: false, TFS: true, WHS: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un seul modèle utilisé → les autres restent off', () => {
|
||||||
|
assert.deepEqual(defaultModelToggles(new Set(['ESE'])), { ESE: true, TFS: false, WHS: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
134
tests/helpers.js
Normal file
134
tests/helpers.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Helpers de test partagés (chargement de l'asset PK, fabriques de fixtures).
|
||||||
|
*
|
||||||
|
* Les tests tournent en Node pur (`node --test tests/`) — aucun DOM ni
|
||||||
|
* localStorage : la couche pure (pk/, backup) est testée directement, le
|
||||||
|
* store est testé avec un backend Map injectable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { initWithJson } from '../js/pk/pk-profile-store.js';
|
||||||
|
|
||||||
|
/** Racine du dépôt web. */
|
||||||
|
export const WEB_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dossier des données de test RÉELLES (exports Android de l'utilisatrice) —
|
||||||
|
* HORS dépôt (données de santé, cf docs/DEVELOPPEMENT.md §8).
|
||||||
|
*
|
||||||
|
* Depuis la séparation des dépôts (v1.4.10), ce dossier vit dans le dépôt
|
||||||
|
* ANDROID voisin (`../HormoneTrack/local-test-data/`) : le web ne duplique
|
||||||
|
* rien, il ne fait que le CONSOMMER en lecture (skip des tests s'il est
|
||||||
|
* absent — clone neuf, CI, autre machine). Candidats par ordre de priorité :
|
||||||
|
* 1. le dépôt Android voisin (layout standard ~/projects/HormoneTrack*)
|
||||||
|
* 2. un sibling direct `../local-test-data` (ancien layout repo unique)
|
||||||
|
* 3. un dossier local-test-data/ dans le dépôt web lui-même (si l'utilisatrice
|
||||||
|
* préfère y déposer ses exports — gitigné)
|
||||||
|
*/
|
||||||
|
export function localTestDataDir() {
|
||||||
|
const candidates = [
|
||||||
|
join(WEB_ROOT, '..', 'HormoneTrack', 'local-test-data'),
|
||||||
|
join(WEB_ROOT, '..', 'local-test-data'),
|
||||||
|
join(WEB_ROOT, 'local-test-data'),
|
||||||
|
];
|
||||||
|
for (const dir of candidates) {
|
||||||
|
if (existsSync(dir)) return dir;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Candidats de fichiers de backup réels pour les régressions data-driven. */
|
||||||
|
export function localBackupPath(name) {
|
||||||
|
const dir = localTestDataDir();
|
||||||
|
return dir ? join(dir, name) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge l'asset PK réel (le MÊME fichier que l'app Android embarque) et
|
||||||
|
* initialise PKProfileStore. À appeler dans les suites qui touchent aux
|
||||||
|
* profils ODS / au dispatch moteur.
|
||||||
|
*/
|
||||||
|
export function initProfiles() {
|
||||||
|
const json = readFileSync(join(WEB_ROOT, 'assets/pk_profiles.json'), 'utf8');
|
||||||
|
initWithJson(json);
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contenu brut parsé de l'asset (sans initialiser le store). */
|
||||||
|
export function rawProfiles() {
|
||||||
|
return JSON.parse(readFileSync(join(WEB_ROOT, 'assets/pk_profiles.json'), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heure en ms. */
|
||||||
|
export const HOUR_MS = 3600000;
|
||||||
|
/** Jour en ms. */
|
||||||
|
export const DAY_MS = 86400000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traitement injectable E2 par défaut (EEn TFS 5 mg) — valeurs des presets
|
||||||
|
* `preset_een_tfs`. id=0 = « pas encore en base » (insertion côté store).
|
||||||
|
*/
|
||||||
|
export function makeTreatment(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
name: 'EEn test',
|
||||||
|
type: 'ESTRADIOL',
|
||||||
|
route: 'INJECTION_SUBCUT',
|
||||||
|
doseAmount: 5,
|
||||||
|
doseUnit: 'mg',
|
||||||
|
isActive: true,
|
||||||
|
notes: null,
|
||||||
|
esterType: 'EEN',
|
||||||
|
pkModel: 'TFS',
|
||||||
|
absorptionHours: 156,
|
||||||
|
eliminationHalfLifeHours: 110,
|
||||||
|
bioavailabilityFraction: 1,
|
||||||
|
scaleFactor: 1.0,
|
||||||
|
forecastIntervalDays: null,
|
||||||
|
reminderHour: null,
|
||||||
|
reminderMinute: null,
|
||||||
|
reminderEnabled: false,
|
||||||
|
calendarEventId: null,
|
||||||
|
createdAt: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dose simple rattachée au traitement par défaut (id 0 — celui de makeTreatment). */
|
||||||
|
export function makeDose(timestamp, doseAmount = 5, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
treatmentId: 0,
|
||||||
|
timestamp,
|
||||||
|
doseAmount,
|
||||||
|
notes: null,
|
||||||
|
esterType: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lab simple. */
|
||||||
|
export function makeLab(marker, value, unit, timestamp, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
marker,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
timestamp,
|
||||||
|
notes: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** assert proche (tolérance absolue ou relative) — les tests PK pin des valeurs d'article. */
|
||||||
|
export function assertClose(actual, expected, tolerance, message = '') {
|
||||||
|
const diff = Math.abs(actual - expected);
|
||||||
|
const tol = typeof tolerance === 'number' && tolerance <= 1 && tolerance > 0 && expected !== 0
|
||||||
|
? Math.abs(expected) * tolerance
|
||||||
|
: tolerance;
|
||||||
|
if (diff > tol) {
|
||||||
|
throw new Error(`${message}\n attendu ≈ ${expected} (±${tol}) — obtenu ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
tests/misc.test.js
Normal file
214
tests/misc.test.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Tests rappels (grille Posologie, miroir ReminderScheduleTest.kt — fix #52)
|
||||||
|
* + extrema (miroir ExtremaTest.kt) + changelog (miroir ChangelogHelperTest.kt)
|
||||||
|
* + HrtDuration (miroir HrtDurationTest.kt).
|
||||||
|
*
|
||||||
|
* ⚠️ Les tests de rappel sont indépendants du fuseau : ils construisent les
|
||||||
|
* heures attendues avec LES MÊMES primitives (Date locale) que le module
|
||||||
|
* testé, sur des dates hors changement d'heure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { nextReminderFireFor, nextReminderFireMs } from '../js/pk/pk-reminders.js';
|
||||||
|
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 } 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é). */
|
||||||
|
function atTimeSameDay(ms, h, m) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rappels — grille Posologie (fix #52)', () => {
|
||||||
|
test('dose il y a 2 j + intervalle 7 j → alarme dans 5 j à l\'heure choisie (PAS demain)', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const tr = makeTreatment({ reminderEnabled: true, reminderHour: 18, reminderMinute: 0, forecastIntervalDays: 7 });
|
||||||
|
const fire = nextReminderFireFor(tr, [makeDose(now - 2 * DAY_MS, 5)], now);
|
||||||
|
// prochaine occurrence de la grille : J+5 à 18:00
|
||||||
|
const expected = atTimeSameDay(now + 5 * DAY_MS, 18, 0);
|
||||||
|
assert.equal(fire, expected, `attendu ${new Date(expected)}, obtenu ${new Date(fire)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('heure de rappel déjà passée le jour du créneau → créneau suivant', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const hour = new Date(now).getHours();
|
||||||
|
const tr = makeTreatment({
|
||||||
|
reminderEnabled: true, reminderHour: hour, reminderMinute: 0,
|
||||||
|
forecastIntervalDays: 7,
|
||||||
|
});
|
||||||
|
// créneau théorique (dernière dose + 7 j) = il y a 2 h → son HH:00 est passé
|
||||||
|
const dose = makeDose(now - 7 * DAY_MS - 2 * HOUR_MS, 5);
|
||||||
|
const slot = dose.timestamp + 7 * DAY_MS;
|
||||||
|
const fire = nextReminderFireFor(tr, [dose], now);
|
||||||
|
|
||||||
|
assert.ok(fire > now, 'jamais dans le passé');
|
||||||
|
const fired = new Date(fire);
|
||||||
|
assert.equal(fired.getHours(), hour, 'à l\'heure de rappel choisie');
|
||||||
|
assert.equal(fired.getMinutes(), 0);
|
||||||
|
// fire est un créneau de la grille (k × 7 j après le créneau initial,
|
||||||
|
// ± le recadrage HH:mm — le snap à l'heure fait varier l'écart de < 24 h)
|
||||||
|
const k = Math.round((fire - slot) / (7 * DAY_MS));
|
||||||
|
assert.ok(k >= 1, `créneau suivant (k=${k})`);
|
||||||
|
assert.ok(Math.abs(fire - slot - k * 7 * DAY_MS) < DAY_MS, 'aligné sur la grille (± recadrage horaire)');
|
||||||
|
// le créneau SAUTÉ (k-1) était bien passé à l'heure de rappel
|
||||||
|
assert.ok(atTimeSameDay(slot + (k - 1) * 7 * DAY_MS, hour, 0) <= now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback QUOTIDIEN sans Posologie (gel/oral) : prochaine occurrence HH:mm', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const tr = makeTreatment({ reminderEnabled: true, reminderHour: 9, reminderMinute: 30, forecastIntervalDays: null });
|
||||||
|
const fire = nextReminderFireFor(tr, [], now);
|
||||||
|
const todayAt = atTimeSameDay(now, 9, 30);
|
||||||
|
const expected = todayAt > now ? todayAt : todayAt + DAY_MS;
|
||||||
|
assert.equal(fire, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('créneau manqué sauté : jamais dans le passé (oubli d\'injection)', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const tr = makeTreatment({ reminderEnabled: true, reminderHour: 18, reminderMinute: 0, forecastIntervalDays: 7 });
|
||||||
|
// dernière dose il y a 10 jours : créneaux J-3 et J+4 → J+4
|
||||||
|
const fire = nextReminderFireFor(tr, [makeDose(now - 10 * DAY_MS, 5)], now);
|
||||||
|
assert.ok(fire > now, 'jamais dans le passé');
|
||||||
|
assert.equal(fire, atTimeSameDay(now + 4 * DAY_MS, 18, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agrégat multi-traitements : le plus tôt gagne', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const a = makeTreatment({
|
||||||
|
id: 1, reminderEnabled: true, reminderHour: 23, reminderMinute: 0,
|
||||||
|
forecastIntervalDays: 7,
|
||||||
|
});
|
||||||
|
const b = makeTreatment({
|
||||||
|
id: 2, reminderEnabled: true, reminderHour: 8, reminderMinute: 0,
|
||||||
|
forecastIntervalDays: 3,
|
||||||
|
});
|
||||||
|
const next = nextReminderFireMs([a, b], [makeDose(now - 2 * DAY_MS, 1, { treatmentId: 1 }), makeDose(now - 2 * DAY_MS, 1, { treatmentId: 2 })], now);
|
||||||
|
assert.equal(next, Math.min(
|
||||||
|
nextReminderFireFor(a, [makeDose(now - 2 * DAY_MS, 1, { treatmentId: 1 })], now),
|
||||||
|
nextReminderFireFor(b, [makeDose(now - 2 * DAY_MS, 1, { treatmentId: 2 })], now),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traitements inactifs / rappel désactivé / heure absente → null', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
assert.equal(nextReminderFireFor(makeTreatment({ reminderEnabled: false }), [], now), null);
|
||||||
|
assert.equal(nextReminderFireFor(makeTreatment({ reminderEnabled: true, isActive: false, reminderHour: 8, reminderMinute: 0 }), [], now), null);
|
||||||
|
assert.equal(nextReminderFireFor(makeTreatment({ reminderEnabled: true, reminderHour: null, reminderMinute: null }), [], now), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectExtrema (pics/creux du graphique)', () => {
|
||||||
|
test('alternance stricte pic/creux en régime d\'équilibre (4 doses hebdo)', () => {
|
||||||
|
// Superposition de 4 doses hebdo EV TFS : oscillation ≥ 4 extrema
|
||||||
|
const points = [];
|
||||||
|
for (let h = 0; h <= 4 * 7 * 24; h += 6) {
|
||||||
|
let e2 = 0;
|
||||||
|
for (let k = 0; k < 4; k++) {
|
||||||
|
const dtH = h - k * 7 * 24;
|
||||||
|
if (dtH > 0) {
|
||||||
|
// profil EV TFS approx (pic 59 @ 51 h, t½ 3 j) — suffisant pour l'oscillation
|
||||||
|
const t = dtH / 24;
|
||||||
|
e2 += 5 * 59 * Math.exp(-(Math.abs(Math.log((t + 0.3) / 2.4)) ** 2) * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.push(new LevelPoint(h * HOUR_MS, e2, 0.5));
|
||||||
|
}
|
||||||
|
const extrema = detectExtrema(points, (p) => p.e2, 2.0);
|
||||||
|
assert.ok(extrema.length >= 4, `≥ 4 extrema (obtenu ${extrema.length})`);
|
||||||
|
for (let i = 1; i < extrema.length; i++) {
|
||||||
|
assert.notEqual(extrema[i].isPeak, extrema[i - 1].isPeak, 'alternance stricte');
|
||||||
|
}
|
||||||
|
// un pic est au-dessus de ses voisins (dans la liste alternée)
|
||||||
|
const peak = extrema.find((e) => e.isPeak);
|
||||||
|
const trough = extrema.find((e) => !e.isPeak);
|
||||||
|
assert.ok(peak.value > trough.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('courbe monotone → vide ; série plate → vide ; trop courte → vide', () => {
|
||||||
|
const mono = [new LevelPoint(0, 1, 0), new LevelPoint(1, 2, 0), new LevelPoint(2, 3, 0), new LevelPoint(3, 4, 0)];
|
||||||
|
assert.deepEqual(detectExtrema(mono, (p) => p.e2), []);
|
||||||
|
const flat = [new LevelPoint(0, 5, 0), new LevelPoint(1, 5, 0), new LevelPoint(2, 5, 0), new LevelPoint(3, 5, 0)];
|
||||||
|
assert.deepEqual(detectExtrema(flat, (p) => p.e2), []);
|
||||||
|
assert.deepEqual(detectExtrema([new LevelPoint(0, 1, 0)], (p) => p.e2), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filtre d\'amplitude : une oscillation sous le seuil produit UN pivot', () => {
|
||||||
|
const tiny = [
|
||||||
|
new LevelPoint(0, 100, 0),
|
||||||
|
new LevelPoint(1, 100.5, 0), // pic local < minSwing 2
|
||||||
|
new LevelPoint(2, 100, 0),
|
||||||
|
new LevelPoint(3, 100.5, 0),
|
||||||
|
new LevelPoint(4, 100, 0),
|
||||||
|
];
|
||||||
|
const filtered = detectExtrema(tiny, (p) => p.e2, 2.0);
|
||||||
|
assert.ok(filtered.length <= 1, 'l\'alternance complète est filtrée');
|
||||||
|
const kept = detectExtrema(tiny, (p) => p.e2, 0.1);
|
||||||
|
assert.ok(kept.length >= 2, 'avec un seuil bas, l\'alternance apparaît');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ChangelogHelper (comparaison numérique — piège lexicographique #38)', () => {
|
||||||
|
test('1.2.9 < 1.2.10 (la comparaison de chaînes serait fausse)', () => {
|
||||||
|
assert.equal(isVersionNewer('1.2.10', '1.2.9'), true);
|
||||||
|
assert.equal(isVersionNewer('1.2.9', '1.2.10'), false);
|
||||||
|
assert.equal(isVersionNewer('1.4.2', '1.4.2'), false);
|
||||||
|
assert.equal(isVersionNewer('0.2.0', '0.1.9'), true);
|
||||||
|
assert.equal(isVersionNewer('1.0.0', null), true, 'première installation');
|
||||||
|
assert.equal(isVersionNewer('1.0.0', ''), true);
|
||||||
|
assert.equal(isVersionNewer('1.1.0', 'bogus'), true, 'référence non parsable → true');
|
||||||
|
assert.equal(isVersionNewer('v1.1.0', '1.0.9'), true, 'préfixe v toléré');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sectionsSince : uniquement les versions plus récentes que la dernière vue', () => {
|
||||||
|
const md = [
|
||||||
|
'## [0.3.0] — la plus récente',
|
||||||
|
'Corrige le pan.',
|
||||||
|
'',
|
||||||
|
'## [0.2.0] — milieu',
|
||||||
|
'Ajoute le zoom.',
|
||||||
|
'',
|
||||||
|
'## [0.1.0] — la plus ancienne',
|
||||||
|
'Première version.',
|
||||||
|
].join('\n');
|
||||||
|
// vue 0.1.0 → 0.2.0 + 0.3.0
|
||||||
|
const out = sectionsSince(md, '0.1.0', '0.3.0');
|
||||||
|
assert.ok(out.includes('[0.3.0]') === false && out.includes('Version 0.3.0'), 'titre réécrit');
|
||||||
|
assert.ok(out.includes('Corrige le pan.'));
|
||||||
|
assert.ok(out.includes('Ajoute le zoom.'));
|
||||||
|
assert.ok(!out.includes('Première version.'), 'version déjà vue exclue');
|
||||||
|
// première installation → section courante seule
|
||||||
|
const first = sectionsSince(md, null, '0.3.0');
|
||||||
|
assert.ok(first.includes('Corrige le pan.'));
|
||||||
|
assert.ok(!first.includes('Ajoute le zoom.'));
|
||||||
|
// rien de nouveau
|
||||||
|
assert.equal(sectionsSince(md, '0.3.0', '0.3.0'), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HrtDuration (temps sous THS)', () => {
|
||||||
|
test('daysSince : 0 si futur/invalide, sinon jours entiers', () => {
|
||||||
|
assert.equal(HrtDuration.daysSince(10 * DAY_MS, 9 * DAY_MS), 0);
|
||||||
|
assert.equal(HrtDuration.daysSince(10 * DAY_MS, 10 * DAY_MS + 3 * DAY_MS + 12 * HOUR_MS), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('monthsAndDays : mois de 30 jours (estimation assumée)', () => {
|
||||||
|
assert.deepEqual(HrtDuration.monthsAndDays(95), [3, 5]);
|
||||||
|
assert.deepEqual(HrtDuration.monthsAndDays(0), [0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daysAndHours : carte « prochaine dose » au-delà de 24 h (v1.4.1)', () => {
|
||||||
|
assert.deepEqual(HrtDuration.daysAndHours(122), [5, 2]);
|
||||||
|
assert.deepEqual(HrtDuration.daysAndHours(24), [1, 0]);
|
||||||
|
assert.deepEqual(HrtDuration.daysAndHours(5), [0, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertClose helper (sanity)', () => {
|
||||||
|
assertClose(300, 295, 0.02);
|
||||||
|
assert.throws(() => assertClose(300, 100, 0.02));
|
||||||
|
});
|
||||||
|
});
|
||||||
272
tests/pk-engine.test.js
Normal file
272
tests/pk-engine.test.js
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Tests du moteur PK (miroir de PharmacokineticEngineTest.kt) :
|
||||||
|
* Bateman (bisection — attrape la bisection inversée #19), superposition,
|
||||||
|
* dispatch des 3 modèles, ester override par dose, modèle T monotone/borné,
|
||||||
|
* prévision (créneaux passés sautés #35), coupures.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
initProfiles, makeTreatment, makeDose, assertClose, HOUR_MS, DAY_MS,
|
||||||
|
} from './helpers.js';
|
||||||
|
import {
|
||||||
|
computeKa, batemanParams, concentrationOfDose, e2At, testosteroneAt,
|
||||||
|
convertTToNgMl, computeCurve, generateForecastDoses, activeEsterAt,
|
||||||
|
levelAt, currentLevel, doseEster, cutoffHours, Esters, PKModels, TConfig,
|
||||||
|
} from '../js/pk/index.js';
|
||||||
|
import * as TFS from '../js/pk/transfem-science-models.js';
|
||||||
|
|
||||||
|
describe('computeKa (bisection Bateman)', () => {
|
||||||
|
test('pic Bateman ≈ Tmax (attrape la bisection inversée, bug #19)', () => {
|
||||||
|
const treatment = makeTreatment({
|
||||||
|
route: 'TRANSDERMAL_GEL', esterType: 'NONE',
|
||||||
|
absorptionHours: 8, eliminationHalfLifeHours: 24, bioavailabilityFraction: 0.9,
|
||||||
|
});
|
||||||
|
const p = batemanParams(treatment);
|
||||||
|
// Le max de C(dt) doit tomber à ~8 h, pas à ~0 h
|
||||||
|
let peakH = 0;
|
||||||
|
let peakV = 0;
|
||||||
|
for (let h = 0; h <= 48; h += 0.1) {
|
||||||
|
const dose = makeDose(0, 1);
|
||||||
|
const c = concentrationOfDose(treatment, dose, h * HOUR_MS, p);
|
||||||
|
if (c > peakV) {
|
||||||
|
peakV = c;
|
||||||
|
peakH = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(peakH - 8) < 0.6, `pic Bateman = ${peakH.toFixed(1)} h, attendu ≈ 8 h`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ke = ln2/t½', () => {
|
||||||
|
const p = batemanParams(makeTreatment({ absorptionHours: 8, eliminationHalfLifeHours: 24 }));
|
||||||
|
assertClose(p.ke, Math.log(2) / 24, 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cas dégénéré ka≈ke : pas de division par ~0, courbe en cloche', () => {
|
||||||
|
const t1 = makeTreatment({ route: 'ORAL', esterType: 'NONE', absorptionHours: 10, eliminationHalfLifeHours: 10.0005, bioavailabilityFraction: 0.5 });
|
||||||
|
const c1 = concentrationOfDose(t1, makeDose(0, 2), 5 * HOUR_MS);
|
||||||
|
const c2 = concentrationOfDose(t1, makeDose(0, 2), 10 * HOUR_MS);
|
||||||
|
assert.ok(c1 > 0 && c2 > 0, 'valeurs finies positives');
|
||||||
|
assert.ok(Number.isFinite(c1) && Number.isFinite(c2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispatch concentrationOfDose (3 modèles superposables)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('TFS : EV 5 mg → pic ≈ 5×59 pg/mL (via le MOTEUR, pas directement TFS)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS', doseAmount: 5 });
|
||||||
|
let peakV = 0;
|
||||||
|
for (let h = 0; h <= 14 * 24; h += 0.5) {
|
||||||
|
const c = concentrationOfDose(tr, makeDose(0, 5), h * HOUR_MS);
|
||||||
|
if (c > peakV) peakV = c;
|
||||||
|
}
|
||||||
|
assertClose(peakV, 295, 0.05, 'pic EV TFS via moteur');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ESE : EV 5 mg → pic ≈ 5×61,1 pg/mL (tables ODS)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'ESE', doseAmount: 5 });
|
||||||
|
let peakV = 0;
|
||||||
|
for (let h = 0; h <= 14 * 24; h += 0.5) {
|
||||||
|
const c = concentrationOfDose(tr, makeDose(0, 5), h * HOUR_MS);
|
||||||
|
if (c > peakV) peakV = c;
|
||||||
|
}
|
||||||
|
assertClose(peakV, 5 * 61.12, 0.5, 'pic EV ESE via moteur');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modelOverride : un traitement ESE force en TFS donne le pic TFS (et réciproquement)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'ESE', doseAmount: 5 });
|
||||||
|
const cTfs = concentrationOfDose(tr, makeDose(0, 5), 51 * HOUR_MS, null, 'TFS');
|
||||||
|
const cEse = concentrationOfDose(tr, makeDose(0, 5), 51 * HOUR_MS);
|
||||||
|
assert.ok(Math.abs(cTfs - cEse) > 1, `override doit changer la valeur (${cEse} → ${cTfs})`);
|
||||||
|
// TFS à 51 h ≈ autour du pic 2,1 j ≈ 295 ; contrôle de cohérence directe
|
||||||
|
assertClose(cTfs, TFS.sample('EV', 51) * 5, 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WHS dispatch (v1.4.6) : coupure 10 × t½, PEP → 0', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EEN', pkModel: 'WHS', doseAmount: 5 });
|
||||||
|
const c = concentrationOfDose(tr, makeDose(0, 5), 5 * DAY_MS);
|
||||||
|
assertClose(c, 188, 2, 'pic EEn WHSAH via moteur ~5 j');
|
||||||
|
// coupure : t½ EEn WHS 7,34 j → 10 t½ = 73,4 j ; à 80 j → 0 (cutoff)
|
||||||
|
const cut = cutoffHours(tr);
|
||||||
|
assertClose(cut, 7.34 * 24 * 10, 0.5, 'cutoff WHSAH = 10 t½');
|
||||||
|
const trPep = makeTreatment({ esterType: 'PEP', pkModel: 'WHS', doseAmount: 5 });
|
||||||
|
assert.equal(concentrationOfDose(trPep, makeDose(0, 5), 10 * DAY_MS), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anti-androgène → 0 en E2 (type != ESTRADIOL ignoré par e2At)', () => {
|
||||||
|
const tr = makeTreatment({ type: 'ANTI_ANDROGEN', route: 'ORAL', esterType: 'NONE' });
|
||||||
|
assert.equal(e2At([tr], [makeDose(0, 10)], 12 * HOUR_MS), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('superposition de deux doses + linéarité du scaleFactor', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS', doseAmount: 5, scaleFactor: 1.0 });
|
||||||
|
const t = 100 * HOUR_MS;
|
||||||
|
const one = e2At([tr], [makeDose(0, 5)], t);
|
||||||
|
const two = e2At([tr], [makeDose(0, 5), makeDose(HOUR_MS, 5)], t + HOUR_MS);
|
||||||
|
assert.ok(two > one, 'deux doses > une dose');
|
||||||
|
|
||||||
|
const trScaled = { ...tr, scaleFactor: 0.72 };
|
||||||
|
assertClose(e2At([trScaled], [makeDose(0, 5)], t), one * 0.72, 1e-9, 'scaleFactor linéaire');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('override d\'ESTER par dose (EV≫EU à 45 h)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const doseEv = makeDose(0, 1, { esterType: 'EV' });
|
||||||
|
const doseEu = makeDose(0, 1, { esterType: 'EU' });
|
||||||
|
const cEv = concentrationOfDose(tr, doseEv, 45 * HOUR_MS);
|
||||||
|
const cEu = concentrationOfDose(tr, doseEu, 45 * HOUR_MS);
|
||||||
|
assert.ok(cEv > cEu * 4, `EV à 45 h (${cEv}) doit ≫ EU (${cEu})`);
|
||||||
|
assert.equal(doseEster(tr, { ...doseEu, esterType: null }), 'EV', 'null = ester du traitement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dt ≤ 0 (dose future) et dose ≤ 0 → 0', () => {
|
||||||
|
const tr = makeTreatment();
|
||||||
|
assert.equal(concentrationOfDose(tr, makeDose(Date.now() + HOUR_MS, 5), Date.now()), 0);
|
||||||
|
assert.equal(concentrationOfDose(tr, makeDose(0, 0), 5 * HOUR_MS), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('modèle T empirique', () => {
|
||||||
|
const cfg = new TConfig(6.0, 0.2, 0.19);
|
||||||
|
|
||||||
|
test('monotone décroissante en E2, bornée (floor, base)', () => {
|
||||||
|
let prev = testosteroneAt(0, cfg);
|
||||||
|
assert.equal(prev, 6.0, 'T(0) = base');
|
||||||
|
for (let e2 = 10; e2 <= 2000; e2 += 10) {
|
||||||
|
const t = testosteroneAt(e2, cfg);
|
||||||
|
assert.ok(t < prev, `décroissance attendue à E2=${e2}`);
|
||||||
|
assert.ok(t > cfg.floor, 'au-dessus du plancher');
|
||||||
|
prev = t;
|
||||||
|
}
|
||||||
|
// T ≈ 0,4 à E2 ≈ 150 (valeur de référence de la doc)
|
||||||
|
assertClose(testosteroneAt(150, cfg), 0.42, 0.08, 'T à E2=150');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convertTToNgMl : ng/dL ÷100, ng/L ÷1000, nmol/L ×0,2884, pg/mL défensif ÷1000 (bugs #23/#26)', () => {
|
||||||
|
assertClose(convertTToNgMl(44, 'ng/dL'), 0.44, 1e-9);
|
||||||
|
assertClose(convertTToNgMl(0.44, 'ng/mL'), 0.44, 1e-9);
|
||||||
|
assertClose(convertTToNgMl(440, 'ng/L'), 0.44, 1e-9);
|
||||||
|
assertClose(convertTToNgMl(1.5, 'nmol/L'), 0.4326, 1e-9);
|
||||||
|
assertClose(convertTToNgMl(38, 'pg/mL'), 0.038, 1e-9, 'lab aberrant neutralisé');
|
||||||
|
assertClose(convertTToNgMl(0.44, 'NG/ML'), 0.44, 1e-9, 'insensible à la casse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activeEsterAt : la dernière dose ≤ t définit l\'ester actif', () => {
|
||||||
|
const trEv = makeTreatment({ id: 1, esterType: 'EV' });
|
||||||
|
const trEen = makeTreatment({ id: 2, esterType: 'EEN' });
|
||||||
|
const doses = [
|
||||||
|
makeDose(0, 2, { id: 10, treatmentId: 1 }),
|
||||||
|
makeDose(30 * DAY_MS, 5, { id: 11, treatmentId: 2 }),
|
||||||
|
];
|
||||||
|
assert.equal(activeEsterAt([trEv, trEen], doses, 10 * DAY_MS), 'EV');
|
||||||
|
assert.equal(activeEsterAt([trEv, trEen], doses, 31 * DAY_MS), 'EEN');
|
||||||
|
assert.equal(activeEsterAt([trEv, trEen], doses, 0), 'EV', 'la dose à t=0 compte (≤ t)');
|
||||||
|
assert.equal(activeEsterAt([trEv, trEen], doses, -1000), null, 'avant la 1ʳᵉ dose');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('levelAt : k de l\'ester actif appliqué au modèle T', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EEN' });
|
||||||
|
const doses = [makeDose(0, 5)];
|
||||||
|
const tMs = 5 * DAY_MS;
|
||||||
|
const base = levelAt([tr], doses, tMs, new TConfig(6, 0.2, 0.19), { EEN: 0.05 });
|
||||||
|
const withDefaultK = levelAt([tr], doses, tMs, new TConfig(6, 0.2, 0.19), null);
|
||||||
|
assert.ok(base.t > withDefaultK.t, 'k plus petit → T plus haute');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeCurve', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('grille horaire clampée à la 1ʳᵉ dose (rien avant)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const start = 10 * DAY_MS;
|
||||||
|
const firstDose = 20 * DAY_MS;
|
||||||
|
const curve = computeCurve([tr], [makeDose(firstDose, 5)], start, 30 * DAY_MS, HOUR_MS, new TConfig());
|
||||||
|
assert.equal(curve.length, (30 - 20) * 24 + 1, 'commence à la 1ʳᵉ dose');
|
||||||
|
assert.ok(curve.every((p) => p.timestamp >= firstDose));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vide sans doses / sans traitements / fenêtre inversée', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
assert.deepEqual(computeCurve([tr], [], 0, DAY_MS, HOUR_MS, new TConfig()), []);
|
||||||
|
assert.deepEqual(computeCurve([], [makeDose(0)], 0, DAY_MS, HOUR_MS, new TConfig()), []);
|
||||||
|
assert.deepEqual(computeCurve([tr], [makeDose(0)], DAY_MS, 0, HOUR_MS, new TConfig()), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inactivation d\'un traitement : la simulation RESTE (bug v1.2.4, régression #3)', () => {
|
||||||
|
const trInactive = makeTreatment({ isActive: false, esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const curve = computeCurve([trInactive], [makeDose(0, 5)], 0, 5 * DAY_MS, HOUR_MS, new TConfig());
|
||||||
|
assert.ok(curve.length > 0);
|
||||||
|
assert.ok(Math.max(...curve.map((p) => p.e2)) > 50, 'EV inactif doit être simulé');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les 3 modèles donnent 3 courbes distinctes (seuil 2 %)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EV', pkModel: 'TFS' });
|
||||||
|
const at = 2 * DAY_MS;
|
||||||
|
const cfg = new TConfig();
|
||||||
|
const ese = computeCurve([tr], [makeDose(0, 5)], 0, at, DAY_MS / 4, cfg, { modelOverride: 'ESE' });
|
||||||
|
const tfs = computeCurve([tr], [makeDose(0, 5)], 0, at, DAY_MS / 4, cfg, { modelOverride: 'TFS' });
|
||||||
|
const whs = computeCurve([tr], [makeDose(0, 5)], 0, at, DAY_MS / 4, cfg, { modelOverride: 'WHS' });
|
||||||
|
const e2Of = (c) => c[Math.floor(c.length / 2)].e2;
|
||||||
|
assert.ok(Math.abs(e2Of(ese) - e2Of(tfs)) / e2Of(tfs) > 0.02, 'ESE ≠ TFS');
|
||||||
|
assert.ok(Math.abs(e2Of(whs) - e2Of(tfs)) / e2Of(tfs) > 0.02, 'WHS ≠ TFS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateForecastDoses (prévision)', () => {
|
||||||
|
test('grille exacte depuis la dernière dose, strictement future', () => {
|
||||||
|
const now = 100 * DAY_MS;
|
||||||
|
const tr = makeTreatment({ forecastIntervalDays: 7 });
|
||||||
|
const doses = [makeDose(now - 10 * DAY_MS, 5)];
|
||||||
|
const forecast = generateForecastDoses(tr, doses, now + 30 * DAY_MS, now);
|
||||||
|
// Dernière dose J-10 → créneaux J-3 (passé, sauté), J+4, J+11, J+18, J+25
|
||||||
|
assert.deepEqual(
|
||||||
|
forecast.map((d) => Math.round((d.timestamp - now) / DAY_MS)),
|
||||||
|
[4, 11, 18, 25],
|
||||||
|
'créneaux passés sautés (bug #35), rythme exact 7 j',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vide sans intervalle / intervalle ≤ 0 / sans doses', () => {
|
||||||
|
const now = 100 * DAY_MS;
|
||||||
|
assert.deepEqual(generateForecastDoses(makeTreatment({ forecastIntervalDays: null }), [makeDose(now - 1)], now + 7 * DAY_MS, now), []);
|
||||||
|
assert.deepEqual(generateForecastDoses(makeTreatment({ forecastIntervalDays: 0 }), [makeDose(now - 1)], now + 7 * DAY_MS, now), []);
|
||||||
|
assert.deepEqual(generateForecastDoses(makeTreatment({ forecastIntervalDays: 7 }), [], now + 7 * DAY_MS, now), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('l\'override d\'ester de la DERNIÈRE injection est projeté', () => {
|
||||||
|
const now = 100 * DAY_MS;
|
||||||
|
const tr = makeTreatment({ forecastIntervalDays: 7, esterType: 'EEN' });
|
||||||
|
const doses = [makeDose(now - 1 * DAY_MS, 5, { esterType: 'EV' })];
|
||||||
|
const forecast = generateForecastDoses(tr, doses, now + 14 * DAY_MS, now);
|
||||||
|
assert.equal(forecast.length, 2, 'créneaux J+6 et J+13');
|
||||||
|
assert.equal(forecast[0].esterType, 'EV', 'la projection suit la dernière injection réelle');
|
||||||
|
assert.equal(forecast[0].doseAmount, 5, 'dose standard du traitement');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un RETARD décale toute la prévision (comportement voulu)', () => {
|
||||||
|
const now = 100 * DAY_MS;
|
||||||
|
const tr = makeTreatment({ forecastIntervalDays: 7 });
|
||||||
|
// dernière prise il y a 9 jours (2 jours de retard)
|
||||||
|
// créneaux théoriques : J-2 (passé, SAUTÉ) puis J+5, J+12…
|
||||||
|
const forecast = generateForecastDoses(tr, [makeDose(now - 9 * DAY_MS, 5)], now + 10 * DAY_MS, now);
|
||||||
|
assert.deepEqual(forecast.map((d) => Math.round((d.timestamp - now) / DAY_MS)), [5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('currentLevel', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('niveau combiné cohérent avec computeCurve (même calibration)', () => {
|
||||||
|
const tr = makeTreatment({ esterType: 'EEN', pkModel: 'TFS' });
|
||||||
|
const now = 6 * DAY_MS;
|
||||||
|
// Grille 1 h sur [now−1 h ; now] → le dernier point est exactement « now »
|
||||||
|
const curve = computeCurve([tr], [makeDose(0, 5)], now - HOUR_MS, now, HOUR_MS, new TConfig());
|
||||||
|
const lvl = currentLevel([tr], [makeDose(0, 5)], new TConfig(), null, now);
|
||||||
|
assert.ok(curve.length >= 1);
|
||||||
|
assertClose(lvl.e2, curve[curve.length - 1].e2, 1e-9);
|
||||||
|
assertClose(lvl.t, curve[curve.length - 1].t, 1e-9);
|
||||||
|
});
|
||||||
|
});
|
||||||
100
tests/pk-profiles.test.js
Normal file
100
tests/pk-profiles.test.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Tests de fidélité de l'asset pk_profiles.json + échantillonnage
|
||||||
|
* (miroir de PKProfileStoreTest.kt côté Android).
|
||||||
|
*
|
||||||
|
* ⚠️ L'asset web est une COPIE de l'asset Android : ces tests garantissent
|
||||||
|
* que les deux apps lisent EXACTEMENT les mêmes tables (toute divergence =
|
||||||
|
* courbes différentes entre Android et web).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe, before } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { initProfiles, rawProfiles } from './helpers.js';
|
||||||
|
import * as PKProfileStore from '../js/pk/pk-profile-store.js';
|
||||||
|
|
||||||
|
describe('pk_profiles.json (asset partagé avec Android)', () => {
|
||||||
|
before(() => initProfiles());
|
||||||
|
|
||||||
|
test('6 profils présents × 8001 points', () => {
|
||||||
|
const raw = rawProfiles();
|
||||||
|
const expected = ['EV_ese', 'EU_ese', 'EEn_ese', 'EV_tfs', 'EU_tfs', 'EEn_tfs'];
|
||||||
|
for (const key of expected) {
|
||||||
|
assert.ok(raw.profiles[key], `profil manquant : ${key}`);
|
||||||
|
assert.equal(raw.profiles[key].length, 8001, `${key} doit avoir 8001 points`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pics == valeurs ODS de référence (pg/mL par mg)', () => {
|
||||||
|
// Mêmes valeurs épinglées côté Android (doc §7.1 / PKProfileStoreTest)
|
||||||
|
const refs = [
|
||||||
|
['EV', 'ESE', 61.12],
|
||||||
|
['EU', 'ESE', 3.44],
|
||||||
|
['EEn', 'ESE', 31.35],
|
||||||
|
['EV', 'TFS', 58.96],
|
||||||
|
['EU', 'TFS', 10.11],
|
||||||
|
['EEn', 'TFS', 31.97],
|
||||||
|
];
|
||||||
|
for (const [ester, mod, peak] of refs) {
|
||||||
|
let max = 0;
|
||||||
|
for (let h = 0; h <= 8000; h++) {
|
||||||
|
const v = PKProfileStore.sample(ester, mod, h);
|
||||||
|
if (v > max) max = v;
|
||||||
|
}
|
||||||
|
assert.ok(Math.abs(max - peak) < 0.02, `pic ${ester}_${mod} = ${max}, attendu ${peak}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EV_ese : pic à ~45 h', () => {
|
||||||
|
let peakH = 0;
|
||||||
|
let peakV = 0;
|
||||||
|
for (let h = 0; h <= 500; h++) {
|
||||||
|
const v = PKProfileStore.sample('EV', 'ESE', h);
|
||||||
|
if (v > peakV) {
|
||||||
|
peakV = v;
|
||||||
|
peakH = h;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(peakH >= 40 && peakH <= 50, `Tmax EV_ese = ${peakH} h, attendu ~45 h`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zéro avant injection / à dt=0', () => {
|
||||||
|
assert.equal(PKProfileStore.sample('EV', 'ESE', 0), 0);
|
||||||
|
assert.equal(PKProfileStore.sample('EV', 'ESE', -5), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modèle STRICT : modèle inconnu → 0 (jamais de fallback silencieux, bug §14 #21)', () => {
|
||||||
|
assert.equal(PKProfileStore.sample('EV', 'WHS', 100), 0); // WHS n'a pas de tables ODS
|
||||||
|
assert.equal(PKProfileStore.sample('EV', 'XXX', 100), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookup INSENSIBLE À LA CASSE (bug #22 — courbes EEn vides)', () => {
|
||||||
|
// Esters.EEN = "EEN" (constante app) vs "EEn" (casse biologique de l'asset)
|
||||||
|
assert.equal(PKProfileStore.sample('EEN', 'ESE', 152), PKProfileStore.sample('EEn', 'ESE', 152));
|
||||||
|
assert.ok(PKProfileStore.sample('EEN', 'ESE', 152) > 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extrapolation terminale : au-delà de la table, décroissante (EV) et jamais croissante (EU)', () => {
|
||||||
|
// La table fait 8001 h : l'extrapolation ne s'applique qu'AU-DELÀ.
|
||||||
|
// EV (t½ ~4 j ODS) : décroissance exponentielle mesurable après 8000 h.
|
||||||
|
const ev8500 = PKProfileStore.sample('EV', 'ESE', 8500);
|
||||||
|
const ev9000 = PKProfileStore.sample('EV', 'ESE', 9000);
|
||||||
|
assert.ok(ev8500 > 0, 'queue extrapolée mesurable');
|
||||||
|
assert.ok(ev9000 < ev8500, `décroissance attendue (${ev8500} → ${ev9000})`);
|
||||||
|
// EU (ester ultra-lent, tail plate au plancher 0,04) : taux log ≈ 0 →
|
||||||
|
// plateau constant — en tout cas JAMAIS croissante (bug #20 : extrapoler
|
||||||
|
// depuis le plancher 0,01/0,00 donnait 0 à vie ou une constante plate).
|
||||||
|
const eu8000 = PKProfileStore.sample('EU', 'ESE', 8000);
|
||||||
|
const eu8500 = PKProfileStore.sample('EU', 'ESE', 8500);
|
||||||
|
assert.ok(eu8000 > 0, 'EU mesurable au-delà de la table');
|
||||||
|
assert.ok(eu8500 <= eu8000, 'jamais croissante');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('interpolation linéaire entre points entiers', () => {
|
||||||
|
// Le plateau 45/46 h d'EV_ese est plat (piège du test Android) — prendre
|
||||||
|
// une heure en montée : la valeur à 100.5 h = moyenne de 100 et 101
|
||||||
|
const raw = rawProfiles();
|
||||||
|
const arr = raw.profiles.EV_ese;
|
||||||
|
const expected = (arr[100] + arr[101]) / 2;
|
||||||
|
assert.ok(Math.abs(PKProfileStore.sample('EV', 'ESE', 100.5) - expected) < 1e-9);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/store.test.js
Normal file
96
tests/store.test.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Tests du store (localStorage) avec backend Map injectable : CRUD, IDs
|
||||||
|
* auto-incrémentés, CASCADE, pub/sub, réglages fusionnés.
|
||||||
|
* (Le module est identique en production — seul le backend change.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe, beforeEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { store } from '../js/data/store.js';
|
||||||
|
import { makeTreatment, makeDose, makeLab } from './helpers.js';
|
||||||
|
|
||||||
|
describe('store (localStorage → backend Map en test)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
store.setBackend(new Map());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsert : id 0 = insertion (id auto), id > 0 = mise à jour', () => {
|
||||||
|
const id = store.upsertTreatment(makeTreatment({ id: 0, name: 'A' }));
|
||||||
|
assert.equal(id, 1);
|
||||||
|
store.upsertTreatment(makeTreatment({ id, name: 'A modifié' }));
|
||||||
|
assert.equal(store.getTreatments().length, 1);
|
||||||
|
assert.equal(store.getTreatments()[0].name, 'A modifié');
|
||||||
|
const id2 = store.upsertTreatment(makeTreatment({ id: 0, name: 'B' }));
|
||||||
|
assert.equal(id2, 2, 'auto-increment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IDs auto-incrémentés par table (miroir Room autoGenerate)', () => {
|
||||||
|
const d1 = store.upsertDoseLog(makeDose(0, 1));
|
||||||
|
const d2 = store.upsertDoseLog(makeDose(1, 1));
|
||||||
|
const l1 = store.upsertLabResult(makeLab('E2', 100, 'pg/mL', 0));
|
||||||
|
assert.equal(d1, 1);
|
||||||
|
assert.equal(d2, 2);
|
||||||
|
assert.equal(l1, 1, 'compteurs indépendants par table');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteTreatment : CASCADE sur ses doses (miroir Room FK)', () => {
|
||||||
|
const trId = store.upsertTreatment(makeTreatment({}));
|
||||||
|
store.upsertDoseLog(makeDose(0, 1, { treatmentId: trId }));
|
||||||
|
store.upsertDoseLog(makeDose(1, 1, { treatmentId: trId }));
|
||||||
|
const other = store.upsertTreatment(makeTreatment({ name: 'autre' }));
|
||||||
|
store.upsertDoseLog(makeDose(2, 1, { treatmentId: other }));
|
||||||
|
store.deleteTreatment(trId);
|
||||||
|
assert.equal(store.getTreatments().length, 1);
|
||||||
|
assert.equal(store.getDoseLogs().length, 1, 'les doses du traitement supprimé sont parties');
|
||||||
|
assert.equal(store.getDoseLogs()[0].treatmentId, other);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pub/sub : les écrans sont notifiés à chaque mutation', () => {
|
||||||
|
let calls = 0;
|
||||||
|
const unsub = store.subscribe(() => calls++);
|
||||||
|
store.upsertTreatment(makeTreatment({}));
|
||||||
|
store.upsertDoseLog(makeDose(0, 1));
|
||||||
|
store.deleteDoseLog(1);
|
||||||
|
assert.equal(calls, 3);
|
||||||
|
unsub();
|
||||||
|
store.upsertLabResult(makeLab('T', 0.4, 'ng/mL', 0));
|
||||||
|
assert.equal(calls, 3, 'désabonné');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un listener qui plante ne bloque pas les autres ni la donnée', () => {
|
||||||
|
let ok = 0;
|
||||||
|
store.subscribe(() => {
|
||||||
|
throw new Error('listener cassé');
|
||||||
|
});
|
||||||
|
store.subscribe(() => ok++);
|
||||||
|
store.upsertTreatment(makeTreatment({}));
|
||||||
|
assert.equal(ok, 1, 'le second listener a bien tourné');
|
||||||
|
assert.equal(store.getTreatments().length, 1, 'la donnée est écrite');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSettings fusionne partiellement (pas d\'écrasement des autres clés)', () => {
|
||||||
|
store.setSettings({ language: 'fr' });
|
||||||
|
store.setSettings({ autoCalibrate: true });
|
||||||
|
const s = store.getSettings();
|
||||||
|
assert.equal(s.language, 'fr', 'la langue est conservée');
|
||||||
|
assert.equal(s.autoCalibrate, true);
|
||||||
|
assert.equal(s.alertE2High, null, 'défaut opt-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('donnée corrompue dans le storage → valeurs par défaut (jamais de crash au boot)', () => {
|
||||||
|
store.setBackend(new Map([['hormonetrack.treatments', '}}}pas du json']]));
|
||||||
|
assert.deepEqual(store.getTreatments(), []);
|
||||||
|
store.setBackend(new Map([['hormonetrack.tConfig', '{base:']]));
|
||||||
|
assert.deepEqual(store.getTConfig(), { base: 6.0, floor: 0.2, k: 0.19 }, 'défauts TConfig.kt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearAll remet tout à zéro', () => {
|
||||||
|
store.upsertTreatment(makeTreatment({}));
|
||||||
|
store.upsertLabResult(makeLab('E2', 1, 'pg/mL', 0));
|
||||||
|
store.setTConfig({ base: 5, floor: 0.1, k: 0.2 });
|
||||||
|
store.clearAll();
|
||||||
|
assert.deepEqual(store.getTreatments(), []);
|
||||||
|
assert.deepEqual(store.getLabResults(), []);
|
||||||
|
assert.deepEqual(store.getTConfig(), { base: 6.0, floor: 0.2, k: 0.19 });
|
||||||
|
});
|
||||||
|
});
|
||||||
116
tests/tfs-models.test.js
Normal file
116
tests/tfs-models.test.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Fidélité du modèle TFS V3C à l'article Transfem Science
|
||||||
|
* (miroir de TransfemScienceModelsTest.kt — toute retouche de
|
||||||
|
* transfem-science-models.js passe par CE test).
|
||||||
|
*
|
||||||
|
* Valeurs épinglées = Tableaux 9–10 + Figure 11 de
|
||||||
|
* transfemscience.org/articles/injectable-e2-meta-analysis/ :
|
||||||
|
* EV 295 pg/mL @ 2,1 j · EEn 160 @ 6,5 j · EB 971 @ 0,65 j ·
|
||||||
|
* EC 155 @ 4,3 j · ECS 241 @ 1,2 j · PEP ~34 @ 32,5 mg @ ~18 j
|
||||||
|
* Équilibre EV 5 mg/7 j : Cmax 384 / Cmin 142 / Cavg 269.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertClose, DAY_MS, HOUR_MS } from './helpers.js';
|
||||||
|
import * as TFS from '../js/pk/transfem-science-models.js';
|
||||||
|
|
||||||
|
/** Cherche le pic d'un ester (mg) sur une fenêtre de jours. */
|
||||||
|
function findPeak(ester, doseMg, maxDays) {
|
||||||
|
let best = { v: 0, tDays: 0 };
|
||||||
|
for (let h = 0; h <= maxDays * 24; h += 0.25) {
|
||||||
|
const v = TFS.sample(ester, h) * doseMg;
|
||||||
|
if (v > best.v) best = { v, tDays: h / 24 };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TransfemScienceModels (V3C, méta-analyse)', () => {
|
||||||
|
test('pics / Tmax de l\'article (±2 %)', () => {
|
||||||
|
const ev = findPeak('EV', 1, 15);
|
||||||
|
assertClose(ev.v, 59.0, 0.03, 'pic EV par mg'); // 295/5
|
||||||
|
assertClose(ev.tDays, 2.1, 0.25, 'Tmax EV');
|
||||||
|
|
||||||
|
const een = findPeak('EEN', 1, 20);
|
||||||
|
assertClose(een.v, 32.0, 0.03, 'pic EEn par mg'); // 160/5
|
||||||
|
assertClose(een.tDays, 6.5, 0.5, 'Tmax EEn');
|
||||||
|
|
||||||
|
const eb = findPeak('EB', 1, 5);
|
||||||
|
assertClose(eb.v, 194.2, 0.03, 'pic EB par mg'); // 971/5
|
||||||
|
assertClose(eb.tDays, 0.65, 0.2, 'Tmax EB');
|
||||||
|
|
||||||
|
const ec = findPeak('EC', 1, 20);
|
||||||
|
assertClose(ec.v, 31.0, 0.03, 'pic EC par mg'); // 155/5
|
||||||
|
assertClose(ec.tDays, 4.3, 0.5, 'Tmax EC');
|
||||||
|
|
||||||
|
const ecs = findPeak('ECS', 1, 10);
|
||||||
|
assertClose(ecs.v, 48.2, 0.03, 'pic ECS par mg'); // 241/5
|
||||||
|
assertClose(ecs.tDays, 1.2, 0.3, 'Tmax ECS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demi-vies terminales (t½ = ln2/min(k1,k2,k3))', () => {
|
||||||
|
assertClose(TFS.model('EV').terminalHalfLifeDays, 3.0, 0.05);
|
||||||
|
assertClose(TFS.model('EEN').terminalHalfLifeDays, 4.53, 0.05);
|
||||||
|
assertClose(TFS.model('EB').terminalHalfLifeDays, 1.18, 0.05);
|
||||||
|
assertClose(TFS.model('EC').terminalHalfLifeDays, 6.7, 0.1);
|
||||||
|
assertClose(TFS.model('ECS').terminalHalfLifeDays, 5.1, 0.1);
|
||||||
|
assertClose(TFS.model('PEP').terminalHalfLifeDays, 28.4, 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PEP : pic faible par mg mais dose ~6,5× → 32,5 mg ≈ 34 pg/mL @ ~18 j', () => {
|
||||||
|
const peak = findPeak('PEP', 32.5, 60);
|
||||||
|
assertClose(peak.v, 34, 0.06, 'pic PEP à 32,5 mg');
|
||||||
|
assertClose(peak.tDays, 18, 2, 'Tmax PEP');
|
||||||
|
// PEP est présent dans choicesForModel('TFS') mais l'est UNIQUEMENT là
|
||||||
|
assert.ok(TFS.hasModel('PEP'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linéarité par mg : 2× dose = 2× niveau (aux pics comme en queue)', () => {
|
||||||
|
const at50h = TFS.sample('EEN', 50);
|
||||||
|
assertClose(at50h * 2, TFS.sample('EEN', 50) * 2, 1e-12); // trivial par construction
|
||||||
|
// plus utile : 5 mg à 50 h == 5 × (1 mg à 50 h)
|
||||||
|
assertClose(TFS.sample('EEN', 50) * 5, TFS.sample('EEN', 50) * 5, 1e-12);
|
||||||
|
// contrôle réel de linéarité du dispatch dans le moteur (doseAmount)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('état d\'équilibre EV 5 mg/7 j ≈ Figure 11 (Cmax 384 / Cmin 142 / Cavg 269)', () => {
|
||||||
|
// Superposition de 8 doses hebdomadaires (au-delà : contribution négligeable)
|
||||||
|
// → régime d'équilibre sur le dernier cycle
|
||||||
|
const doses = [];
|
||||||
|
for (let k = 8; k >= 1; k--) doses.push((8 - k) * 7 * DAY_MS);
|
||||||
|
const cycleStart = doses[doses.length - 1]; // dernière dose
|
||||||
|
let cmax = 0;
|
||||||
|
let cmin = Infinity;
|
||||||
|
let sum = 0;
|
||||||
|
let n = 0;
|
||||||
|
for (let h = 0; h <= 7 * 24; h += 1) {
|
||||||
|
const t = cycleStart + h * HOUR_MS;
|
||||||
|
let level = 0;
|
||||||
|
for (const d of doses) {
|
||||||
|
const dtH = (t - d) / 3600000;
|
||||||
|
if (dtH > 0) level += TFS.sample('EV', dtH) * 5;
|
||||||
|
}
|
||||||
|
cmax = Math.max(cmax, level);
|
||||||
|
cmin = Math.min(cmin, level);
|
||||||
|
sum += level;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
assertClose(cmax, 384, 0.04, 'Cmax équilibre EV 5/7');
|
||||||
|
assertClose(cmin, 142, 0.06, 'Cmin équilibre EV 5/7');
|
||||||
|
assertClose(sum / n, 269, 0.04, 'Cavg équilibre EV 5/7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('esters sans modèle → 0 ; dt ≤ 0 → 0', () => {
|
||||||
|
assert.equal(TFS.sample('XXX', 50), 0);
|
||||||
|
assert.equal(TFS.sample('EV', 0), 0);
|
||||||
|
assert.equal(TFS.sample('EV', -1), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compartiment ultra-rapide (EU k2 ~4,8×10⁶ j⁻¹) : double précision suffisante', () => {
|
||||||
|
// EU : le terme k2 décroît instantanément — le pic doit rester ~10 pg/mL/mg,
|
||||||
|
// Tmax ~198 h (≈ 8,25 j — valeur du README, t½ terminale ~22 j)
|
||||||
|
const eu = findPeak('EU', 1, 60);
|
||||||
|
assertClose(eu.v, 10.1, 0.05, 'pic EU par mg');
|
||||||
|
assertClose(eu.tDays, 8.25, 0.5, `Tmax EU ≈ 198 h, obtenu ${eu.tDays} j`);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
tests/whsah-models.test.js
Normal file
65
tests/whsah-models.test.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Fidélité du modèle WHSAH au comportement de Mona
|
||||||
|
* (miroir de WhsahModelsTest.kt — fit « license-free » du WHSAH Collective).
|
||||||
|
*
|
||||||
|
* Valeurs épinglées (±2 %) : EV Cmax 367,5 @ 1,69 j · EEn 187,9 @ 4,99 j ·
|
||||||
|
* t½ EEn 7,34 j · PEP NON COUVERT · linéarité par mg.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { assertClose } from './helpers.js';
|
||||||
|
import * as WHS from '../js/pk/whsah-models.js';
|
||||||
|
|
||||||
|
function findPeak(ester, doseMg, maxDays) {
|
||||||
|
let best = { v: 0, tDays: 0 };
|
||||||
|
for (let h = 0; h <= maxDays * 24; h += 0.25) {
|
||||||
|
const v = WHS.sample(ester, h) * doseMg;
|
||||||
|
if (v > best.v) best = { v, tDays: h / 24 };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WhsahModels (fit Mona, license-free)', () => {
|
||||||
|
test('pics Mona : EV 367,5 @ 1,69 j (5 mg) · EEn 187,9 @ 4,99 j (5 mg)', () => {
|
||||||
|
const ev = findPeak('EV', 5, 10);
|
||||||
|
assertClose(ev.v, 367.5, 0.02, 'pic EV WHSAH 5 mg');
|
||||||
|
assertClose(ev.tDays, 1.69, 0.1, 'Tmax EV WHSAH');
|
||||||
|
|
||||||
|
const een = findPeak('EEN', 5, 20);
|
||||||
|
assertClose(een.v, 187.9, 0.02, 'pic EEn WHSAH 5 mg');
|
||||||
|
assertClose(een.tDays, 4.99, 0.15, 'Tmax EEn WHSAH');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('t½ terminale EEn ≈ 7,34 j (plus longue que TFS 4,5 j)', () => {
|
||||||
|
assertClose(WHS.model('EEN').terminalHalfLifeDays, 7.34, 0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EEn à J+1 ~3× plus haut que TFS (70 vs 22 pg/mL) — la « montée rapide » WHSAH', () => {
|
||||||
|
// Sémantique épinglée côté Android (différence visible entre les apps)
|
||||||
|
const eenJ1 = WHS.sample('EEN', 24) * 5;
|
||||||
|
assertClose(eenJ1, 70, 0.15, 'EEn WHSAH à J+1 (5 mg)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PEP non couvert → 0 (retiré du fit WHSAH)', () => {
|
||||||
|
assert.equal(WHS.sample('PEP', 100), 0);
|
||||||
|
assert.equal(WHS.hasModel('PEP'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linéarité par mg', () => {
|
||||||
|
const a = WHS.sample('EV', 40);
|
||||||
|
assertClose(a * 3, WHS.sample('EV', 40) * 3, 1e-12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('6 esters couverts (sans PEP) — choicesForModel en dépend', () => {
|
||||||
|
for (const e of ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS']) {
|
||||||
|
assert.ok(WHS.hasModel(e), `${e} devrait être couvert`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dt ≤ 0 → 0 ; ester inconnu → 0', () => {
|
||||||
|
assert.equal(WHS.sample('EV', 0), 0);
|
||||||
|
assert.equal(WHS.sample('EV', -3), 0);
|
||||||
|
assert.equal(WHS.sample('XXX', 40), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user