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:
Siphonight 2026-09-08 14:34:58 +02:00
commit 07fc20cd55
54 changed files with 58398 additions and 0 deletions

14
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

308
css/style.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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),
];

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`);
});
});

View 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);
});
});