Spaces:
Sleeping
Sleeping
Rick commited on
Commit ·
4c80166
0
Parent(s):
HF deploy snapshot (no app.db)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- .gitignore +56 -0
- Dockerfile +36 -0
- LICENSE +396 -0
- README.md +153 -0
- REFACTORING.md +161 -0
- SPEC_Add_Crew_Functionality.md +327 -0
- SPEC_Vessel_Crew_Add_Functionality.md +429 -0
- app.py +0 -0
- check_gpu.py +77 -0
- data/default/chats.json +1 -0
- data/default/context.json +1 -0
- data/default/history.json +1 -0
- data/default/inventory.json +1 -0
- data/default/med_photo_jobs.json +1 -0
- data/default/med_photo_queue.json +1 -0
- data/default/patients.json +1 -0
- data/default/settings.json +1 -0
- data/default/tools.json +1 -0
- data/default/vessel.json +1 -0
- db_store.py +0 -0
- debug_inference.py +47 -0
- docs/FRESH_INSTALL.md +111 -0
- medgemma15_test.py +369 -0
- medgemma27b.py +261 -0
- medgemma4.py +144 -0
- medgemma_common.py +210 -0
- medgemma_writeup.md +347 -0
- requirements.txt +13 -0
- run_med_advisor.sh +118 -0
- scripts/bootstrap_ubuntu24_sailingmedadvisor.sh +199 -0
- scripts/copy_pharma_lorraine_to_rick.sh +65 -0
- scripts/copy_pharma_lorraine_to_rick_pure.sh +73 -0
- scripts/import_clean_triage_tree.py +345 -0
- scripts/install_fresh_copy.sh +139 -0
- scripts/verify_fresh_install.py +310 -0
- seed/triage_prompt_tree.default.json +0 -0
- ships_medicine_chest_medicines_filled.xlsx +0 -0
- static/data/triage_samples.json +602 -0
- static/favicon.svg +5 -0
- static/js/chat.js +0 -0
- static/js/crew.js +0 -0
- static/js/equipment.js +1315 -0
- static/js/main.js +1243 -0
- static/js/pharmacy.js +1723 -0
- static/js/recovery.js +206 -0
- static/js/settings.js +0 -0
- static/js/utils.js +497 -0
- static/style.css +30 -0
- templates/index.html +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
seed/app.db filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Existing entries ---
|
| 2 |
+
.env.*
|
| 3 |
+
data/
|
| 4 |
+
datasets/
|
| 5 |
+
outputs/
|
| 6 |
+
runs/
|
| 7 |
+
wandb/
|
| 8 |
+
mlruns/
|
| 9 |
+
checkpoints/
|
| 10 |
+
*.pt
|
| 11 |
+
*.pth
|
| 12 |
+
*.ckpt
|
| 13 |
+
*.safetensors
|
| 14 |
+
*.bin
|
| 15 |
+
|
| 16 |
+
# --- New FastAPI/Python Additions ---
|
| 17 |
+
venv/
|
| 18 |
+
.env
|
| 19 |
+
__pycache__/
|
| 20 |
+
*.pyc
|
| 21 |
+
.pytest_cache/
|
| 22 |
+
.vscode/
|
| 23 |
+
.codium/
|
| 24 |
+
|
| 25 |
+
# --- Data logic (Keep as is if you want JSONs in Gitea) ---
|
| 26 |
+
data/*
|
| 27 |
+
!data/.gitkeep
|
| 28 |
+
!data/*.json
|
| 29 |
+
!data/default/
|
| 30 |
+
!data/default/*.json
|
| 31 |
+
!data/default/uploads/
|
| 32 |
+
!data/default/uploads/medicines/
|
| 33 |
+
# Local secrets (keep out of version control)
|
| 34 |
+
templates/sidebars/SailingMedAdvisorDeloy\ token\ Huggingface
|
| 35 |
+
|
| 36 |
+
# Local virtualenvs
|
| 37 |
+
.venv/
|
| 38 |
+
venv/
|
| 39 |
+
# Editor/temp artifacts
|
| 40 |
+
*.swp
|
| 41 |
+
|
| 42 |
+
# Local DB/runtime artifacts
|
| 43 |
+
# Keep primary app.db tracked for deployment/demo portability.
|
| 44 |
+
app.db.*
|
| 45 |
+
seed/*.db
|
| 46 |
+
not_needed/
|
| 47 |
+
|
| 48 |
+
# Local scratch/export artifacts
|
| 49 |
+
server.log
|
| 50 |
+
Untitled Folder/
|
| 51 |
+
*_export_text.txt
|
| 52 |
+
consumables
|
| 53 |
+
consumables to import.txt
|
| 54 |
+
non-consumables to import.txt
|
| 55 |
+
who list for import.txt
|
| 56 |
+
copy_pharm_lorraine_to_rick.sh
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Author: Rick Escher
|
| 3 |
+
# Project: SailingMedAdvisor
|
| 4 |
+
# Context: Google HAI-DEF Framework
|
| 5 |
+
# Models: Google MedGemmas
|
| 6 |
+
# Program: Kaggle Impact Challenge
|
| 7 |
+
# =============================================================================
|
| 8 |
+
|
| 9 |
+
FROM python:3.10-slim
|
| 10 |
+
|
| 11 |
+
# 1. Install system tools as ROOT
|
| 12 |
+
RUN apt-get update && apt-get install -y build-essential curl && rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# 2. Setup the user but STAY as root for a moment
|
| 15 |
+
RUN useradd -m -u 1000 user
|
| 16 |
+
WORKDIR /home/user/app
|
| 17 |
+
|
| 18 |
+
# 3. Create the folder while still ROOT
|
| 19 |
+
RUN mkdir -p /home/user/app/data \
|
| 20 |
+
/home/user/app/uploads/medicines \
|
| 21 |
+
/home/user/app/offload \
|
| 22 |
+
/home/user/app/models_cache \
|
| 23 |
+
/home/user/app/backups && \
|
| 24 |
+
chown -R user:user /home/user/app
|
| 25 |
+
|
| 26 |
+
# 4. NOW switch to the user
|
| 27 |
+
USER user
|
| 28 |
+
ENV HOME=/home/user \
|
| 29 |
+
PATH=/home/user/.local/bin:$PATH
|
| 30 |
+
|
| 31 |
+
# 5. Copy files and install (using --chown for safety)
|
| 32 |
+
COPY --chown=user requirements.txt .
|
| 33 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 34 |
+
COPY --chown=user . .
|
| 35 |
+
|
| 36 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Attribution 4.0 International
|
| 2 |
+
|
| 3 |
+
=======================================================================
|
| 4 |
+
|
| 5 |
+
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
| 6 |
+
does not provide legal services or legal advice. Distribution of
|
| 7 |
+
Creative Commons public licenses does not create a lawyer-client or
|
| 8 |
+
other relationship. Creative Commons makes its licenses and related
|
| 9 |
+
information available on an "as-is" basis. Creative Commons gives no
|
| 10 |
+
warranties regarding its licenses, any material licensed under their
|
| 11 |
+
terms and conditions, or any related information. Creative Commons
|
| 12 |
+
disclaims all liability for damages resulting from their use to the
|
| 13 |
+
fullest extent possible.
|
| 14 |
+
|
| 15 |
+
Using Creative Commons Public Licenses
|
| 16 |
+
|
| 17 |
+
Creative Commons public licenses provide a standard set of terms and
|
| 18 |
+
conditions that creators and other rights holders may use to share
|
| 19 |
+
original works of authorship and other material subject to copyright
|
| 20 |
+
and certain other rights specified in the public license below. The
|
| 21 |
+
following considerations are for informational purposes only, are not
|
| 22 |
+
exhaustive, and do not form part of our licenses.
|
| 23 |
+
|
| 24 |
+
Considerations for licensors: Our public licenses are
|
| 25 |
+
intended for use by those authorized to give the public
|
| 26 |
+
permission to use material in ways otherwise restricted by
|
| 27 |
+
copyright and certain other rights. Our licenses are
|
| 28 |
+
irrevocable. Licensors should read and understand the terms
|
| 29 |
+
and conditions of the license they choose before applying it.
|
| 30 |
+
Licensors should also secure all rights necessary before
|
| 31 |
+
applying our licenses so that the public can reuse the
|
| 32 |
+
material as expected. Licensors should clearly mark any
|
| 33 |
+
material not subject to the license. This includes other CC-
|
| 34 |
+
licensed material, or material used under an exception or
|
| 35 |
+
limitation to copyright. More considerations for licensors:
|
| 36 |
+
wiki.creativecommons.org/Considerations_for_licensors
|
| 37 |
+
|
| 38 |
+
Considerations for the public: By using one of our public
|
| 39 |
+
licenses, a licensor grants the public permission to use the
|
| 40 |
+
licensed material under specified terms and conditions. If
|
| 41 |
+
the licensor's permission is not necessary for any reason--for
|
| 42 |
+
example, because of any applicable exception or limitation to
|
| 43 |
+
copyright--then that use is not regulated by the license. Our
|
| 44 |
+
licenses grant only permissions under copyright and certain
|
| 45 |
+
other rights that a licensor has authority to grant. Use of
|
| 46 |
+
the licensed material may still be restricted for other
|
| 47 |
+
reasons, including because others have copyright or other
|
| 48 |
+
rights in the material. A licensor may make special requests,
|
| 49 |
+
such as asking that all changes be marked or described.
|
| 50 |
+
Although not required by our licenses, you are encouraged to
|
| 51 |
+
respect those requests where reasonable. More considerations
|
| 52 |
+
for the public:
|
| 53 |
+
wiki.creativecommons.org/Considerations_for_licensees
|
| 54 |
+
|
| 55 |
+
=======================================================================
|
| 56 |
+
|
| 57 |
+
Creative Commons Attribution 4.0 International Public License
|
| 58 |
+
|
| 59 |
+
By exercising the Licensed Rights (defined below), You accept and agree
|
| 60 |
+
to be bound by the terms and conditions of this Creative Commons
|
| 61 |
+
Attribution 4.0 International Public License ("Public License"). To the
|
| 62 |
+
extent this Public License may be interpreted as a contract, You are
|
| 63 |
+
granted the Licensed Rights in consideration of Your acceptance of
|
| 64 |
+
these terms and conditions, and the Licensor grants You such rights in
|
| 65 |
+
consideration of benefits the Licensor receives from making the
|
| 66 |
+
Licensed Material available under these terms and conditions.
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
Section 1 -- Definitions.
|
| 70 |
+
|
| 71 |
+
a. Adapted Material means material subject to Copyright and Similar
|
| 72 |
+
Rights that is derived from or based upon the Licensed Material
|
| 73 |
+
and in which the Licensed Material is translated, altered,
|
| 74 |
+
arranged, transformed, or otherwise modified in a manner requiring
|
| 75 |
+
permission under the Copyright and Similar Rights held by the
|
| 76 |
+
Licensor. For purposes of this Public License, where the Licensed
|
| 77 |
+
Material is a musical work, performance, or sound recording,
|
| 78 |
+
Adapted Material is always produced where the Licensed Material is
|
| 79 |
+
synched in timed relation with a moving image.
|
| 80 |
+
|
| 81 |
+
b. Adapter's License means the license You apply to Your Copyright
|
| 82 |
+
and Similar Rights in Your contributions to Adapted Material in
|
| 83 |
+
accordance with the terms and conditions of this Public License.
|
| 84 |
+
|
| 85 |
+
c. Copyright and Similar Rights means copyright and/or similar rights
|
| 86 |
+
closely related to copyright including, without limitation,
|
| 87 |
+
performance, broadcast, sound recording, and Sui Generis Database
|
| 88 |
+
Rights, without regard to how the rights are labeled or
|
| 89 |
+
categorized. For purposes of this Public License, the rights
|
| 90 |
+
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
| 91 |
+
Rights.
|
| 92 |
+
|
| 93 |
+
d. Effective Technological Measures means those measures that, in the
|
| 94 |
+
absence of proper authority, may not be circumvented under laws
|
| 95 |
+
fulfilling obligations under Article 11 of the WIPO Copyright
|
| 96 |
+
Treaty adopted on December 20, 1996, and/or similar international
|
| 97 |
+
agreements.
|
| 98 |
+
|
| 99 |
+
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
| 100 |
+
any other exception or limitation to Copyright and Similar Rights
|
| 101 |
+
that applies to Your use of the Licensed Material.
|
| 102 |
+
|
| 103 |
+
f. Licensed Material means the artistic or literary work, database,
|
| 104 |
+
or other material to which the Licensor applied this Public
|
| 105 |
+
License.
|
| 106 |
+
|
| 107 |
+
g. Licensed Rights means the rights granted to You subject to the
|
| 108 |
+
terms and conditions of this Public License, which are limited to
|
| 109 |
+
all Copyright and Similar Rights that apply to Your use of the
|
| 110 |
+
Licensed Material and that the Licensor has authority to license.
|
| 111 |
+
|
| 112 |
+
h. Licensor means the individual(s) or entity(ies) granting rights
|
| 113 |
+
under this Public License.
|
| 114 |
+
|
| 115 |
+
i. Share means to provide material to the public by any means or
|
| 116 |
+
process that requires permission under the Licensed Rights, such
|
| 117 |
+
as reproduction, public display, public performance, distribution,
|
| 118 |
+
dissemination, communication, or importation, and to make material
|
| 119 |
+
available to the public including in ways that members of the
|
| 120 |
+
public may access the material from a place and at a time
|
| 121 |
+
individually chosen by them.
|
| 122 |
+
|
| 123 |
+
j. Sui Generis Database Rights means rights other than copyright
|
| 124 |
+
resulting from Directive 96/9/EC of the European Parliament and of
|
| 125 |
+
the Council of 11 March 1996 on the legal protection of databases,
|
| 126 |
+
as amended and/or succeeded, as well as other essentially
|
| 127 |
+
equivalent rights anywhere in the world.
|
| 128 |
+
|
| 129 |
+
k. You means the individual or entity exercising the Licensed Rights
|
| 130 |
+
under this Public License. Your has a corresponding meaning.
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
Section 2 -- Scope.
|
| 134 |
+
|
| 135 |
+
a. License grant.
|
| 136 |
+
|
| 137 |
+
1. Subject to the terms and conditions of this Public License,
|
| 138 |
+
the Licensor hereby grants You a worldwide, royalty-free,
|
| 139 |
+
non-sublicensable, non-exclusive, irrevocable license to
|
| 140 |
+
exercise the Licensed Rights in the Licensed Material to:
|
| 141 |
+
|
| 142 |
+
a. reproduce and Share the Licensed Material, in whole or
|
| 143 |
+
in part; and
|
| 144 |
+
|
| 145 |
+
b. produce, reproduce, and Share Adapted Material.
|
| 146 |
+
|
| 147 |
+
2. Exceptions and Limitations. For the avoidance of doubt, where
|
| 148 |
+
Exceptions and Limitations apply to Your use, this Public
|
| 149 |
+
License does not apply, and You do not need to comply with
|
| 150 |
+
its terms and conditions.
|
| 151 |
+
|
| 152 |
+
3. Term. The term of this Public License is specified in Section
|
| 153 |
+
6(a).
|
| 154 |
+
|
| 155 |
+
4. Media and formats; technical modifications allowed. The
|
| 156 |
+
Licensor authorizes You to exercise the Licensed Rights in
|
| 157 |
+
all media and formats whether now known or hereafter created,
|
| 158 |
+
and to make technical modifications necessary to do so. The
|
| 159 |
+
Licensor waives and/or agrees not to assert any right or
|
| 160 |
+
authority to forbid You from making technical modifications
|
| 161 |
+
necessary to exercise the Licensed Rights, including
|
| 162 |
+
technical modifications necessary to circumvent Effective
|
| 163 |
+
Technological Measures. For purposes of this Public License,
|
| 164 |
+
simply making modifications authorized by this Section 2(a)
|
| 165 |
+
(4) never produces Adapted Material.
|
| 166 |
+
|
| 167 |
+
5. Downstream recipients.
|
| 168 |
+
|
| 169 |
+
a. Offer from the Licensor -- Licensed Material. Every
|
| 170 |
+
recipient of the Licensed Material automatically
|
| 171 |
+
receives an offer from the Licensor to exercise the
|
| 172 |
+
Licensed Rights under the terms and conditions of this
|
| 173 |
+
Public License.
|
| 174 |
+
|
| 175 |
+
b. No downstream restrictions. You may not offer or impose
|
| 176 |
+
any additional or different terms or conditions on, or
|
| 177 |
+
apply any Effective Technological Measures to, the
|
| 178 |
+
Licensed Material if doing so restricts exercise of the
|
| 179 |
+
Licensed Rights by any recipient of the Licensed
|
| 180 |
+
Material.
|
| 181 |
+
|
| 182 |
+
6. No endorsement. Nothing in this Public License constitutes or
|
| 183 |
+
may be construed as permission to assert or imply that You
|
| 184 |
+
are, or that Your use of the Licensed Material is, connected
|
| 185 |
+
with, or sponsored, endorsed, or granted official status by,
|
| 186 |
+
the Licensor or others designated to receive attribution as
|
| 187 |
+
provided in Section 3(a)(1)(A)(i).
|
| 188 |
+
|
| 189 |
+
b. Other rights.
|
| 190 |
+
|
| 191 |
+
1. Moral rights, such as the right of integrity, are not
|
| 192 |
+
licensed under this Public License, nor are publicity,
|
| 193 |
+
privacy, and/or other similar personality rights; however, to
|
| 194 |
+
the extent possible, the Licensor waives and/or agrees not to
|
| 195 |
+
assert any such rights held by the Licensor to the limited
|
| 196 |
+
extent necessary to allow You to exercise the Licensed
|
| 197 |
+
Rights, but not otherwise.
|
| 198 |
+
|
| 199 |
+
2. Patent and trademark rights are not licensed under this
|
| 200 |
+
Public License.
|
| 201 |
+
|
| 202 |
+
3. To the extent possible, the Licensor waives any right to
|
| 203 |
+
collect royalties from You for the exercise of the Licensed
|
| 204 |
+
Rights, whether directly or through a collecting society
|
| 205 |
+
under any voluntary or waivable statutory or compulsory
|
| 206 |
+
licensing scheme. In all other cases the Licensor expressly
|
| 207 |
+
reserves any right to collect such royalties.
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
Section 3 -- License Conditions.
|
| 211 |
+
|
| 212 |
+
Your exercise of the Licensed Rights is expressly made subject to the
|
| 213 |
+
following conditions.
|
| 214 |
+
|
| 215 |
+
a. Attribution.
|
| 216 |
+
|
| 217 |
+
1. If You Share the Licensed Material (including in modified
|
| 218 |
+
form), You must:
|
| 219 |
+
|
| 220 |
+
a. retain the following if it is supplied by the Licensor
|
| 221 |
+
with the Licensed Material:
|
| 222 |
+
|
| 223 |
+
i. identification of the creator(s) of the Licensed
|
| 224 |
+
Material and any others designated to receive
|
| 225 |
+
attribution, in any reasonable manner requested by
|
| 226 |
+
the Licensor (including by pseudonym if
|
| 227 |
+
designated);
|
| 228 |
+
|
| 229 |
+
ii. a copyright notice;
|
| 230 |
+
|
| 231 |
+
iii. a notice that refers to this Public License;
|
| 232 |
+
|
| 233 |
+
iv. a notice that refers to the disclaimer of
|
| 234 |
+
warranties;
|
| 235 |
+
|
| 236 |
+
v. a URI or hyperlink to the Licensed Material to the
|
| 237 |
+
extent reasonably practicable;
|
| 238 |
+
|
| 239 |
+
b. indicate if You modified the Licensed Material and
|
| 240 |
+
retain an indication of any previous modifications; and
|
| 241 |
+
|
| 242 |
+
c. indicate the Licensed Material is licensed under this
|
| 243 |
+
Public License, and include the text of, or the URI or
|
| 244 |
+
hyperlink to, this Public License.
|
| 245 |
+
|
| 246 |
+
2. You may satisfy the conditions in Section 3(a)(1) in any
|
| 247 |
+
reasonable manner based on the medium, means, and context in
|
| 248 |
+
which You Share the Licensed Material. For example, it may be
|
| 249 |
+
reasonable to satisfy the conditions by providing a URI or
|
| 250 |
+
hyperlink to a resource that includes the required
|
| 251 |
+
information.
|
| 252 |
+
|
| 253 |
+
3. If requested by the Licensor, You must remove any of the
|
| 254 |
+
information required by Section 3(a)(1)(A) to the extent
|
| 255 |
+
reasonably practicable.
|
| 256 |
+
|
| 257 |
+
4. If You Share Adapted Material You produce, the Adapter's
|
| 258 |
+
License You apply must not prevent recipients of the Adapted
|
| 259 |
+
Material from complying with this Public License.
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
Section 4 -- Sui Generis Database Rights.
|
| 263 |
+
|
| 264 |
+
Where the Licensed Rights include Sui Generis Database Rights that
|
| 265 |
+
apply to Your use of the Licensed Material:
|
| 266 |
+
|
| 267 |
+
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
| 268 |
+
to extract, reuse, reproduce, and Share all or a substantial
|
| 269 |
+
portion of the contents of the database;
|
| 270 |
+
|
| 271 |
+
b. if You include all or a substantial portion of the database
|
| 272 |
+
contents in a database in which You have Sui Generis Database
|
| 273 |
+
Rights, then the database in which You have Sui Generis Database
|
| 274 |
+
Rights (but not its individual contents) is Adapted Material; and
|
| 275 |
+
|
| 276 |
+
c. You must comply with the conditions in Section 3(a) if You Share
|
| 277 |
+
all or a substantial portion of the contents of the database.
|
| 278 |
+
|
| 279 |
+
For the avoidance of doubt, this Section 4 supplements and does not
|
| 280 |
+
replace Your obligations under this Public License where the Licensed
|
| 281 |
+
Rights include other Copyright and Similar Rights.
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
| 285 |
+
|
| 286 |
+
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
| 287 |
+
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
| 288 |
+
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
| 289 |
+
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
| 290 |
+
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
| 291 |
+
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
| 292 |
+
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
| 293 |
+
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
| 294 |
+
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
| 295 |
+
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
| 296 |
+
|
| 297 |
+
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
| 298 |
+
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
| 299 |
+
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
| 300 |
+
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
| 301 |
+
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
| 302 |
+
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
| 303 |
+
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
| 304 |
+
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
| 305 |
+
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
| 306 |
+
|
| 307 |
+
c. The disclaimer of warranties and limitation of liability provided
|
| 308 |
+
above shall be interpreted in a manner that, to the extent
|
| 309 |
+
possible, most closely approximates an absolute disclaimer and
|
| 310 |
+
waiver of all liability.
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
Section 6 -- Term and Termination.
|
| 314 |
+
|
| 315 |
+
a. This Public License applies for the term of the Copyright and
|
| 316 |
+
Similar Rights licensed here. However, if You fail to comply with
|
| 317 |
+
this Public License, then Your rights under this Public License
|
| 318 |
+
terminate automatically.
|
| 319 |
+
|
| 320 |
+
b. Where Your right to use the Licensed Material has terminated under
|
| 321 |
+
Section 6(a), it reinstates:
|
| 322 |
+
|
| 323 |
+
1. automatically as of the date the violation is cured, provided
|
| 324 |
+
it is cured within 30 days of Your discovery of the
|
| 325 |
+
violation; or
|
| 326 |
+
|
| 327 |
+
2. upon express reinstatement by the Licensor.
|
| 328 |
+
|
| 329 |
+
For the avoidance of doubt, this Section 6(b) does not affect any
|
| 330 |
+
right the Licensor may have to seek remedies for Your violations
|
| 331 |
+
of this Public License.
|
| 332 |
+
|
| 333 |
+
c. For the avoidance of doubt, the Licensor may also offer the
|
| 334 |
+
Licensed Material under separate terms or conditions or stop
|
| 335 |
+
distributing the Licensed Material at any time; however, doing so
|
| 336 |
+
will not terminate this Public License.
|
| 337 |
+
|
| 338 |
+
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
| 339 |
+
License.
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
Section 7 -- Other Terms and Conditions.
|
| 343 |
+
|
| 344 |
+
a. The Licensor shall not be bound by any additional or different
|
| 345 |
+
terms or conditions communicated by You unless expressly agreed.
|
| 346 |
+
|
| 347 |
+
b. Any arrangements, understandings, or agreements regarding the
|
| 348 |
+
Licensed Material not stated herein are separate from and
|
| 349 |
+
independent of the terms and conditions of this Public License.
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
Section 8 -- Interpretation.
|
| 353 |
+
|
| 354 |
+
a. For the avoidance of doubt, this Public License does not, and
|
| 355 |
+
shall not be interpreted to, reduce, limit, restrict, or impose
|
| 356 |
+
conditions on any use of the Licensed Material that could lawfully
|
| 357 |
+
be made without permission under this Public License.
|
| 358 |
+
|
| 359 |
+
b. To the extent possible, if any provision of this Public License is
|
| 360 |
+
deemed unenforceable, it shall be automatically reformed to the
|
| 361 |
+
minimum extent necessary to make it enforceable. If the provision
|
| 362 |
+
cannot be reformed, it shall be severed from this Public License
|
| 363 |
+
without affecting the enforceability of the remaining terms and
|
| 364 |
+
conditions.
|
| 365 |
+
|
| 366 |
+
c. No term or condition of this Public License will be waived and no
|
| 367 |
+
failure to comply consented to unless expressly agreed to by the
|
| 368 |
+
Licensor.
|
| 369 |
+
|
| 370 |
+
d. Nothing in this Public License constitutes or may be interpreted
|
| 371 |
+
as a limitation upon, or waiver of, any privileges and immunities
|
| 372 |
+
that apply to the Licensor or You, including from the legal
|
| 373 |
+
processes of any jurisdiction or authority.
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
=======================================================================
|
| 377 |
+
|
| 378 |
+
Creative Commons is not a party to its public
|
| 379 |
+
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
| 380 |
+
its public licenses to material it publishes and in those instances
|
| 381 |
+
will be considered the “Licensor.” The text of the Creative Commons
|
| 382 |
+
public licenses is dedicated to the public domain under the CC0 Public
|
| 383 |
+
Domain Dedication. Except for the limited purpose of indicating that
|
| 384 |
+
material is shared under a Creative Commons public license or as
|
| 385 |
+
otherwise permitted by the Creative Commons policies published at
|
| 386 |
+
creativecommons.org/policies, Creative Commons does not authorize the
|
| 387 |
+
use of the trademark "Creative Commons" or any other trademark or logo
|
| 388 |
+
of Creative Commons without its prior written consent including,
|
| 389 |
+
without limitation, in connection with any unauthorized modifications
|
| 390 |
+
to any of its public licenses or any other arrangements,
|
| 391 |
+
understandings, or agreements concerning use of licensed material. For
|
| 392 |
+
the avoidance of doubt, this paragraph does not form part of the
|
| 393 |
+
public licenses.
|
| 394 |
+
|
| 395 |
+
Creative Commons may be contacted at creativecommons.org.
|
| 396 |
+
|
README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SailingMedAdvisor
|
| 3 |
+
emoji: ⛵
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# SailingMedAdvisor
|
| 11 |
+
|
| 12 |
+
Offline-first emergency decision support for offshore crews, using Google MedGemma models with a structured triage workflow.
|
| 13 |
+
|
| 14 |
+
## What This Repository Contains
|
| 15 |
+
|
| 16 |
+
This repository contains the active application code and core runtime assets for:
|
| 17 |
+
|
| 18 |
+
- FastAPI backend (`app.py`)
|
| 19 |
+
- SQLite persistence layer (`app.db`, `db_store.py`)
|
| 20 |
+
- MedGemma inference adapters (`medgemma4.py`, `medgemma27b.py`, `medgemma_common.py`)
|
| 21 |
+
- Frontend UI (`templates/`, `static/`)
|
| 22 |
+
- Default seed data (`data/default/`)
|
| 23 |
+
- Startup script (`run_med_advisor.sh`)
|
| 24 |
+
|
| 25 |
+
Non-project scratch/export artifacts have been removed from version control.
|
| 26 |
+
|
| 27 |
+
## Core Capabilities
|
| 28 |
+
|
| 29 |
+
- Triage and inquiry consultation modes
|
| 30 |
+
- Clinical triage pathway dropdowns (Domain, Problem, Anatomy, Mechanism/Cause, Severity/Complication)
|
| 31 |
+
- Patient condition capture (Consciousness, Breathing, Circulation, Overall Stability)
|
| 32 |
+
- Prompt assembly with pathway fallback to general triage instructions when path coverage is incomplete
|
| 33 |
+
- Consultation logging with restore/demo-restore workflows
|
| 34 |
+
- Crew, vessel, inventory, and settings management from UI
|
| 35 |
+
- Model parameters in Settings (temperature, top-p, top-k, token limits, etc.)
|
| 36 |
+
|
| 37 |
+
## Models
|
| 38 |
+
|
| 39 |
+
- `google/medgemma-1.5-4b-it`
|
| 40 |
+
- `google/medgemma-27b-text-it` (runtime adapter file: `medgemma27b.py`)
|
| 41 |
+
|
| 42 |
+
Both model paths are wired to use settings-defined sampling/token parameters.
|
| 43 |
+
|
| 44 |
+
## Quick Start
|
| 45 |
+
|
| 46 |
+
1. Create and activate a virtual environment:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
python3 -m venv .venv
|
| 50 |
+
source .venv/bin/activate
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
2. Install dependencies:
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
pip install -r requirements.txt
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
3. Start the app:
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
chmod +x run_med_advisor.sh
|
| 63 |
+
./run_med_advisor.sh
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
4. Open:
|
| 67 |
+
|
| 68 |
+
- Local: `http://127.0.0.1:5000`
|
| 69 |
+
- LAN: `http://<your-machine-ip>:5000`
|
| 70 |
+
|
| 71 |
+
Portable startup (works on machines without a working GPU):
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
FORCE_CUDA=0 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## Fresh Install On A New Computer (Contest Repro Path)
|
| 78 |
+
|
| 79 |
+
Use the installer script to set up and verify a new machine:
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
git clone https://github.com/rickeae/SailingMedAdvisor.git
|
| 83 |
+
cd SailingMedAdvisor
|
| 84 |
+
chmod +x scripts/install_fresh_copy.sh
|
| 85 |
+
./scripts/install_fresh_copy.sh --skip-clone
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
For full instructions and troubleshooting, see `docs/FRESH_INSTALL.md`.
|
| 89 |
+
|
| 90 |
+
You can re-run the deterministic installation verification at any time:
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
./.venv/bin/python scripts/verify_fresh_install.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
For a clean Ubuntu 24.04 environment, you can run the all-in-one bootstrap script:
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
chmod +x scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 100 |
+
./scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## Demo Reproduction (27B scenario)
|
| 104 |
+
|
| 105 |
+
For the Kaggle demo scenario, use the 27B model path in the UI:
|
| 106 |
+
|
| 107 |
+
1. Open `http://127.0.0.1:5000`.
|
| 108 |
+
2. In MedGemma Consultation, choose `Triage Consultation`.
|
| 109 |
+
3. Set model to `google/medgemma-27b-text-it`.
|
| 110 |
+
4. Enter the fish-hook cheek scenario used in the demo.
|
| 111 |
+
5. Select the matching clinical triage pathway values.
|
| 112 |
+
6. Submit and compare output structure against the demo video.
|
| 113 |
+
|
| 114 |
+
## Authentication Behavior
|
| 115 |
+
|
| 116 |
+
- If crew credentials are configured, login is required.
|
| 117 |
+
- If no credentials are configured yet, login is auto-admitted.
|
| 118 |
+
|
| 119 |
+
Credentials are managed from the app UI (Vessel & Crew / Settings flows).
|
| 120 |
+
|
| 121 |
+
## Data Storage
|
| 122 |
+
|
| 123 |
+
- Primary runtime data is stored in `app.db`.
|
| 124 |
+
- Default dataset JSONs live in `data/default/` and are used for baseline content and seeding support.
|
| 125 |
+
|
| 126 |
+
## Repository Layout (Primary)
|
| 127 |
+
|
| 128 |
+
```text
|
| 129 |
+
SailingMedAdvisor/
|
| 130 |
+
├── app.py
|
| 131 |
+
├── app.db
|
| 132 |
+
├── db_store.py
|
| 133 |
+
├── medgemma4.py
|
| 134 |
+
├── medgemma27b.py
|
| 135 |
+
├── medgemma_common.py
|
| 136 |
+
├── run_med_advisor.sh
|
| 137 |
+
├── requirements.txt
|
| 138 |
+
├── templates/
|
| 139 |
+
├── static/
|
| 140 |
+
├── scripts/
|
| 141 |
+
└── data/default/
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
## Operational Notes
|
| 145 |
+
|
| 146 |
+
- The startup script performs CUDA preflight when `FORCE_CUDA=1` (default).
|
| 147 |
+
- CPU fallback on CUDA runtime errors is disabled by default (`ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=0`).
|
| 148 |
+
- If GPU is already occupied, the app surfaces a GPU-busy style failure message instead of silently switching devices.
|
| 149 |
+
|
| 150 |
+
## Medical Safety Note
|
| 151 |
+
|
| 152 |
+
This software is a decision-support aid for constrained/offshore scenarios.
|
| 153 |
+
It is not a replacement for licensed medical professionals or emergency services.
|
REFACTORING.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Code Refactoring Documentation
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
The codebase has been refactored to improve maintainability and enable easier team collaboration by splitting monolithic files into smaller, focused modules.
|
| 5 |
+
|
| 6 |
+
## Structure Changes
|
| 7 |
+
|
| 8 |
+
### Frontend JavaScript (Before → After)
|
| 9 |
+
|
| 10 |
+
**Before:**
|
| 11 |
+
- `templates/index.html` - ~900 lines (HTML + CSS + JavaScript all in one file)
|
| 12 |
+
|
| 13 |
+
**After:**
|
| 14 |
+
```
|
| 15 |
+
static/js/
|
| 16 |
+
├── main.js - Core utilities, navigation, data loading (~110 lines)
|
| 17 |
+
├── chat.js - Triage/Inquiry chat functionality (~110 lines)
|
| 18 |
+
├── crew.js - Crew management, CRUD operations (~490 lines)
|
| 19 |
+
├── pharmacy.js - Medicine inventory management (~190 lines)
|
| 20 |
+
├── settings.js - Configuration management (~10 lines)
|
| 21 |
+
└── vessel.js - Vessel information (~25 lines)
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
### Benefits
|
| 25 |
+
|
| 26 |
+
1. **Parallel Development**: Multiple developers can work on different features without merge conflicts
|
| 27 |
+
- Developer A: Works on `crew.js` (crew page features)
|
| 28 |
+
- Developer B: Works on `pharmacy.js` (inventory features)
|
| 29 |
+
|
| 30 |
+
2. **Code Organization**: Each file has a single responsibility
|
| 31 |
+
- Easier to locate and fix bugs
|
| 32 |
+
- Clearer code structure
|
| 33 |
+
- Better separation of concerns
|
| 34 |
+
|
| 35 |
+
3. **Maintainability**: Smaller files are easier to understand and modify
|
| 36 |
+
- Average file size: ~150 lines (vs 900+ lines before)
|
| 37 |
+
- Focused functionality per module
|
| 38 |
+
|
| 39 |
+
4. **Reusability**: Shared utilities in `main.js` can be used across modules
|
| 40 |
+
|
| 41 |
+
## Module Descriptions
|
| 42 |
+
|
| 43 |
+
### main.js
|
| 44 |
+
- Toggle functions for collapsible sections
|
| 45 |
+
- Tab navigation (`showTab`)
|
| 46 |
+
- Central data loading (`loadData`)
|
| 47 |
+
- Tools and history display functions
|
| 48 |
+
- Window initialization
|
| 49 |
+
|
| 50 |
+
### chat.js
|
| 51 |
+
- Chat state management (isPrivate, lastPrompt, isProcessing)
|
| 52 |
+
- UI update functions (`updateUI`, `togglePriv`)
|
| 53 |
+
- Chat execution (`runChat`, `repeatLast`)
|
| 54 |
+
- Enter key handler for message submission
|
| 55 |
+
|
| 56 |
+
### crew.js
|
| 57 |
+
- Crew display and sorting logic
|
| 58 |
+
- CRUD operations (add, auto-save, delete)
|
| 59 |
+
- Emergency contact management
|
| 60 |
+
- Document upload/delete (passport photos)
|
| 61 |
+
- Import/export functionality
|
| 62 |
+
- Crew list CSV generation
|
| 63 |
+
|
| 64 |
+
### pharmacy.js
|
| 65 |
+
- Medicine inventory display
|
| 66 |
+
- Add/save/delete medicine operations
|
| 67 |
+
- CSV import/export functionality
|
| 68 |
+
- Category and controlled substance handling
|
| 69 |
+
|
| 70 |
+
### settings.js
|
| 71 |
+
- Configuration save functionality
|
| 72 |
+
- Settings synchronization with backend
|
| 73 |
+
|
| 74 |
+
### vessel.js
|
| 75 |
+
- Vessel information save/load operations
|
| 76 |
+
|
| 77 |
+
## File Loading Order
|
| 78 |
+
|
| 79 |
+
The modules are loaded in this specific order in `index.html`:
|
| 80 |
+
```html
|
| 81 |
+
<script src="/static/js/chat.js"></script>
|
| 82 |
+
<script src="/static/js/crew.js"></script>
|
| 83 |
+
<script src="/static/js/pharmacy.js"></script>
|
| 84 |
+
<script src="/static/js/settings.js"></script>
|
| 85 |
+
<script src="/static/js/vessel.js"></script>
|
| 86 |
+
<script src="/static/js/main.js"></script> <!-- Last - initializes the app -->
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
**Important**: `main.js` must load last because it contains `window.onload` which calls functions from other modules.
|
| 90 |
+
|
| 91 |
+
## Development Workflow
|
| 92 |
+
|
| 93 |
+
### Working on Crew Features
|
| 94 |
+
1. Edit `static/js/crew.js`
|
| 95 |
+
2. Refresh browser to test
|
| 96 |
+
3. No need to touch other files
|
| 97 |
+
|
| 98 |
+
### Working on Pharmacy Features
|
| 99 |
+
1. Edit `static/js/pharmacy.js`
|
| 100 |
+
2. Refresh browser to test
|
| 101 |
+
3. Independent of crew.js changes
|
| 102 |
+
|
| 103 |
+
### Adding New Features
|
| 104 |
+
1. Create new module in `static/js/` (e.g., `reports.js`)
|
| 105 |
+
2. Add script tag to `index.html` before `main.js`
|
| 106 |
+
3. Implement functionality
|
| 107 |
+
4. Call from `loadData()` in main.js if needed
|
| 108 |
+
|
| 109 |
+
## Testing
|
| 110 |
+
|
| 111 |
+
After refactoring, test these key workflows:
|
| 112 |
+
- [ ] Chat/Triage functionality
|
| 113 |
+
- [ ] Add/edit/delete crew members
|
| 114 |
+
- [ ] Auto-save in crew details
|
| 115 |
+
- [ ] Document upload (passport photos)
|
| 116 |
+
- [ ] Add/edit/delete medicines
|
| 117 |
+
- [ ] CSV import/export (crew and pharmacy)
|
| 118 |
+
- [ ] Settings save
|
| 119 |
+
- [ ] Vessel information save
|
| 120 |
+
- [ ] Tab navigation
|
| 121 |
+
- [ ] History display
|
| 122 |
+
|
| 123 |
+
## Backward Compatibility
|
| 124 |
+
|
| 125 |
+
The refactoring maintains 100% backward compatibility:
|
| 126 |
+
- Same API endpoints
|
| 127 |
+
- Same data structures
|
| 128 |
+
- Same UI/UX
|
| 129 |
+
- Same functionality
|
| 130 |
+
|
| 131 |
+
Only the code organization changed, not the behavior.
|
| 132 |
+
|
| 133 |
+
## Future Improvements
|
| 134 |
+
|
| 135 |
+
Potential next steps for further refactoring:
|
| 136 |
+
|
| 137 |
+
### Phase 2 (Optional):
|
| 138 |
+
- Extract CSS from inline styles to `static/css/style.css`
|
| 139 |
+
- Split HTML into template partials
|
| 140 |
+
|
| 141 |
+
### Phase 3 (Optional - Backend):
|
| 142 |
+
```
|
| 143 |
+
app.py →
|
| 144 |
+
├── config.py - Configuration & defaults
|
| 145 |
+
├── database.py - Data operations
|
| 146 |
+
├── auth.py - Authentication
|
| 147 |
+
├── models.py - AI model loading
|
| 148 |
+
└── routes.py - API endpoints
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
## Rollback Plan
|
| 152 |
+
|
| 153 |
+
If issues arise, restore from backup:
|
| 154 |
+
```bash
|
| 155 |
+
# The original file had all JavaScript inline
|
| 156 |
+
# Can be reconstructed by concatenating modules
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
## Questions?
|
| 160 |
+
|
| 161 |
+
Contact the development team or refer to individual module files for implementation details.
|
SPEC_Add_Crew_Functionality.md
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Specification: Add Crew Member Functionality
|
| 2 |
+
## Vessel & Crew Info Page
|
| 3 |
+
|
| 4 |
+
**Document Version:** 1.0
|
| 5 |
+
**Date:** January 22, 2026
|
| 6 |
+
**System:** SailingMedAdvisor v5.7
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 1. Overview
|
| 11 |
+
|
| 12 |
+
The Add Crew Member functionality allows users to register new crew members, passengers, or captain into the SailingMedAdvisor system. This feature is located on the **Vessel & Crew Info** tab within a collapsible "Add New Crew Member" section under "Crew Information".
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 2. User Interface Location
|
| 17 |
+
|
| 18 |
+
- **Primary Tab:** Vessel & Crew (4th tab in main navigation)
|
| 19 |
+
- **Parent Section:** Crew Information (collapsible)
|
| 20 |
+
- **Component:** Add New Crew Member (nested collapsible form)
|
| 21 |
+
- **UI State:** Initially collapsed (must be expanded to access)
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 3. Form Fields
|
| 26 |
+
|
| 27 |
+
### 3.1 Personal Information
|
| 28 |
+
|
| 29 |
+
| Field Name | Input Type | Required | Validation | Notes |
|
| 30 |
+
|------------|------------|----------|------------|-------|
|
| 31 |
+
| First Name | Text | Yes* | Non-empty after trim | Left column of 3-column grid |
|
| 32 |
+
| Middle Name(s) | Text | No | None | Center column of 3-column grid |
|
| 33 |
+
| Last Name | Text | Yes* | Non-empty after trim | Right column of 3-column grid |
|
| 34 |
+
| Sex | Dropdown | Yes* | Must select value | Options: Male, Female, Non-binary, Other, Prefer not to say |
|
| 35 |
+
| Birthdate | Date | Yes* | Must have value | Standard HTML date picker |
|
| 36 |
+
| Position | Dropdown | Yes* | Must select value | Options: Captain, Crew, Passenger |
|
| 37 |
+
|
| 38 |
+
### 3.2 Travel Documents
|
| 39 |
+
|
| 40 |
+
| Field Name | Input Type | Required | Validation | Notes |
|
| 41 |
+
|------------|------------|----------|------------|-------|
|
| 42 |
+
| Citizenship | Text + Datalist | Yes* | Non-empty after trim | Autocomplete suggestions from predefined country list |
|
| 43 |
+
| Passport Number | Text | Yes* | Non-empty after trim | Unique identifier |
|
| 44 |
+
| Issue Date | Date | No | None | Passport issue date |
|
| 45 |
+
| Expiry Date | Date | No | None | Passport expiration date |
|
| 46 |
+
|
| 47 |
+
**Country Datalist:** USA, Canada, UK, Australia, New Zealand, France, Germany, Spain, Italy, Netherlands, Singapore, Malaysia, Thailand, Philippines, Japan, China, India
|
| 48 |
+
|
| 49 |
+
### 3.3 Contact Information
|
| 50 |
+
|
| 51 |
+
| Field Name | Input Type | Required | Validation | Notes |
|
| 52 |
+
|------------|------------|----------|------------|-------|
|
| 53 |
+
| Cell/WhatsApp | Text | No | None | Placeholder format: +1234567890 |
|
| 54 |
+
| Passport Photo/PDF | File | No | Image/* or PDF, <5MB | Uploaded separately after crew creation |
|
| 55 |
+
| Passport Page Photo/PDF | File | No | Image/* or PDF, <5MB | Uploaded separately after crew creation |
|
| 56 |
+
|
| 57 |
+
### 3.4 Emergency Contact Information
|
| 58 |
+
|
| 59 |
+
| Field Name | Input Type | Required | Validation | Notes |
|
| 60 |
+
|------------|------------|----------|------------|-------|
|
| 61 |
+
| Name | Text | No | None | Emergency contact's full name |
|
| 62 |
+
| Relationship | Text | No | None | Relationship to crew member |
|
| 63 |
+
| Phone | Text | No | None | Emergency contact phone number |
|
| 64 |
+
| Email | Email | No | None | Emergency contact email address |
|
| 65 |
+
| Emergency Contact Notes | Text | No | None | Additional emergency contact information |
|
| 66 |
+
|
| 67 |
+
\* = Required field validation enforced
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## 4. Functional Behavior
|
| 72 |
+
|
| 73 |
+
### 4.1 Add Operation Workflow
|
| 74 |
+
|
| 75 |
+
1. **User Input Phase:**
|
| 76 |
+
- User expands "Crew Information" section (if collapsed)
|
| 77 |
+
- User expands "Add New Crew Member" section (if collapsed)
|
| 78 |
+
- User fills out form fields
|
| 79 |
+
- User clicks "+ Add Crew Member" button
|
| 80 |
+
|
| 81 |
+
2. **Validation Phase:**
|
| 82 |
+
```javascript
|
| 83 |
+
Required Field Checks (in order):
|
| 84 |
+
1. First Name - Alert: "Please enter first name and last name"
|
| 85 |
+
2. Last Name - Alert: "Please enter first name and last name"
|
| 86 |
+
3. Sex - Alert: "Please select sex"
|
| 87 |
+
4. Birthdate - Alert: "Please enter birthdate"
|
| 88 |
+
5. Position - Alert: "Please select position"
|
| 89 |
+
6. Citizenship - Alert: "Please enter citizenship"
|
| 90 |
+
7. Passport Number - Alert: "Please enter passport number"
|
| 91 |
+
```
|
| 92 |
+
- If any validation fails, display alert and stop processing
|
| 93 |
+
- User must correct the issue and re-submit
|
| 94 |
+
|
| 95 |
+
3. **Data Creation Phase:**
|
| 96 |
+
- Fetch existing crew data from `/api/data/patients`
|
| 97 |
+
- Create new crew member object:
|
| 98 |
+
```javascript
|
| 99 |
+
{
|
| 100 |
+
id: Date.now().toString(), // Timestamp-based unique ID
|
| 101 |
+
firstName: string,
|
| 102 |
+
middleName: string,
|
| 103 |
+
lastName: string,
|
| 104 |
+
sex: string,
|
| 105 |
+
birthdate: string (YYYY-MM-DD),
|
| 106 |
+
position: string,
|
| 107 |
+
citizenship: string,
|
| 108 |
+
passportNumber: string,
|
| 109 |
+
passportIssue: string (YYYY-MM-DD),
|
| 110 |
+
passportExpiry: string (YYYY-MM-DD),
|
| 111 |
+
emergencyContactName: string,
|
| 112 |
+
emergencyContactRelation: string,
|
| 113 |
+
emergencyContactPhone: string,
|
| 114 |
+
emergencyContactEmail: string,
|
| 115 |
+
emergencyContactNotes: string,
|
| 116 |
+
phoneNumber: string,
|
| 117 |
+
passportPhoto: '', // Empty initially
|
| 118 |
+
passportPage: '', // Empty initially
|
| 119 |
+
history: '' // Empty initially
|
| 120 |
+
}
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
4. **Data Persistence Phase:**
|
| 124 |
+
- Append new crew member to existing array
|
| 125 |
+
- POST updated array to `/api/data/patients` endpoint
|
| 126 |
+
- Server persists data to JSON file
|
| 127 |
+
|
| 128 |
+
5. **UI Update Phase:**
|
| 129 |
+
- Clear all form fields (reset to empty/default state)
|
| 130 |
+
- Call `loadData()` function to refresh UI
|
| 131 |
+
- New crew member appears in:
|
| 132 |
+
- Crew Information list (on current tab)
|
| 133 |
+
- Crew Health & Log dropdown
|
| 134 |
+
- Chat page crew member selector
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 5. Data Model
|
| 139 |
+
|
| 140 |
+
### 5.1 Storage Location
|
| 141 |
+
- **Endpoint:** `/api/data/patients`
|
| 142 |
+
- **Method:** GET (retrieve), POST (update)
|
| 143 |
+
- **Format:** JSON array
|
| 144 |
+
- **File:** `data/patients.json`
|
| 145 |
+
|
| 146 |
+
### 5.2 ID Generation
|
| 147 |
+
- **Algorithm:** `Date.now().toString()`
|
| 148 |
+
- **Format:** Unix timestamp as string (e.g., "1737522671234")
|
| 149 |
+
- **Uniqueness:** Guaranteed by millisecond precision
|
| 150 |
+
- **Note:** IDs are not sequential, time-based for traceability
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## 6. Integration Points
|
| 155 |
+
|
| 156 |
+
### 6.1 Affected Components
|
| 157 |
+
1. **Crew Member Dropdown (Chat Page):**
|
| 158 |
+
- New crew member added to `p-select` dropdown
|
| 159 |
+
- Available for triage queries immediately
|
| 160 |
+
|
| 161 |
+
2. **Crew Medical List:**
|
| 162 |
+
- New entry created with empty medical history
|
| 163 |
+
- Accessible on "Crew Health & Log" tab
|
| 164 |
+
|
| 165 |
+
3. **Crew Information List:**
|
| 166 |
+
- New collapsible section created
|
| 167 |
+
- Shows crew member details with edit capability
|
| 168 |
+
|
| 169 |
+
### 6.2 Related Functions
|
| 170 |
+
- `loadCrewData()` - Refreshes all crew-dependent UI elements
|
| 171 |
+
- `getCrewDisplayName()` - Formats name as "Last, First"
|
| 172 |
+
- `getCrewFullName()` - Formats name as "First Last"
|
| 173 |
+
- `calculateAge()` - Calculates age from birthdate
|
| 174 |
+
|
| 175 |
+
---
|
| 176 |
+
|
| 177 |
+
## 7. User Experience Features
|
| 178 |
+
|
| 179 |
+
### 7.1 Form Behavior
|
| 180 |
+
- **Auto-trim:** All text fields automatically trimmed before save
|
| 181 |
+
- **Reset on Success:** Form clears completely after successful add
|
| 182 |
+
- **Datalist Support:** Citizenship field provides autocomplete suggestions
|
| 183 |
+
- **File Upload:** Document uploads handled separately after crew creation
|
| 184 |
+
|
| 185 |
+
### 7.2 Validation Feedback
|
| 186 |
+
- **Real-time:** No real-time validation (submit-time only)
|
| 187 |
+
- **Error Messages:** Alert-based, blocking further action until resolved
|
| 188 |
+
- **Field Focus:** No automatic focus on error field (manual user correction required)
|
| 189 |
+
|
| 190 |
+
---
|
| 191 |
+
|
| 192 |
+
## 8. Button Specification
|
| 193 |
+
|
| 194 |
+
### 8.1 Add Crew Member Button
|
| 195 |
+
- **Label:** "+ Add Crew Member"
|
| 196 |
+
- **Style:**
|
| 197 |
+
- Background: `var(--dark)` (#2c3e50)
|
| 198 |
+
- Text: White
|
| 199 |
+
- Width: 100% of container
|
| 200 |
+
- Class: `btn btn-sm`
|
| 201 |
+
- **Action:** `onclick="addCrew()"`
|
| 202 |
+
- **Location:** Bottom of Add New Crew Member form
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## 9. Edge Cases and Error Handling
|
| 207 |
+
|
| 208 |
+
### 9.1 Duplicate Detection
|
| 209 |
+
- **Current Behavior:** No duplicate detection
|
| 210 |
+
- **Allowed:** Multiple crew members with identical names
|
| 211 |
+
- **Recommendation:** Consider adding duplicate warning for same passport number
|
| 212 |
+
|
| 213 |
+
### 9.2 Network Failures
|
| 214 |
+
- **No Error Handling:** Function assumes successful API calls
|
| 215 |
+
- **Risk:** Silent failure if network or server issues occur
|
| 216 |
+
- **Recommendation:** Add try-catch blocks and user feedback
|
| 217 |
+
|
| 218 |
+
### 9.3 Concurrent Modifications
|
| 219 |
+
- **Race Condition:** Possible if multiple users add crew simultaneously
|
| 220 |
+
- **Mitigation:** Last-write-wins (later POST overwrites earlier)
|
| 221 |
+
- **Recommendation:** Implement server-side locking or conflict detection
|
| 222 |
+
|
| 223 |
+
---
|
| 224 |
+
|
| 225 |
+
## 10. Security Considerations
|
| 226 |
+
|
| 227 |
+
### 10.1 Input Sanitization
|
| 228 |
+
- **Client-side:** Basic HTML escaping via `escapeHtml()` on display
|
| 229 |
+
- **Server-side:** Assumed (not verified in frontend code)
|
| 230 |
+
- **File Uploads:** Size limit enforced (5MB), type validation (image/PDF)
|
| 231 |
+
|
| 232 |
+
### 10.2 Authentication
|
| 233 |
+
- **Current:** Uses `credentials: 'same-origin'` for API calls
|
| 234 |
+
- **Session-based:** Assumes server-side session validation
|
| 235 |
+
- **Login:** Crew-specific login credentials managed separately in Settings
|
| 236 |
+
|
| 237 |
+
---
|
| 238 |
+
|
| 239 |
+
## 11. Accessibility Notes
|
| 240 |
+
|
| 241 |
+
### 11.1 Current State
|
| 242 |
+
- **Labels:** Present for all form fields
|
| 243 |
+
- **Required Indicators:** Asterisk (*) in label text
|
| 244 |
+
- **Keyboard Navigation:** Standard HTML form tab order
|
| 245 |
+
- **Screen Reader:** Field labels associated with inputs
|
| 246 |
+
|
| 247 |
+
### 11.2 Improvements Needed
|
| 248 |
+
- **ARIA attributes:** Not currently implemented
|
| 249 |
+
- **Error announcements:** Alert dialogs are announced but could be improved
|
| 250 |
+
- **Focus management:** No automatic focus on validation errors
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## 12. Performance Characteristics
|
| 255 |
+
|
| 256 |
+
### 12.1 Scalability
|
| 257 |
+
- **Data Structure:** Array-based, linear search for operations
|
| 258 |
+
- **Current Capacity:** Suitable for small crews (< 100 members)
|
| 259 |
+
- **Load Time:** O(n) where n = number of crew members
|
| 260 |
+
- **Recommendation:** Consider indexing for larger datasets
|
| 261 |
+
|
| 262 |
+
### 12.2 Network Efficiency
|
| 263 |
+
- **Data Transfer:** Full array sent on each POST (not incremental)
|
| 264 |
+
- **Bandwidth:** Minimal for typical crew sizes
|
| 265 |
+
- **Optimization:** Could implement PATCH for single crew updates
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
## 13. Testing Scenarios
|
| 270 |
+
|
| 271 |
+
### 13.1 Happy Path
|
| 272 |
+
1. Fill all required fields with valid data
|
| 273 |
+
2. Click "+ Add Crew Member"
|
| 274 |
+
3. Verify form clears
|
| 275 |
+
4. Verify crew appears in all relevant lists
|
| 276 |
+
5. Verify crew is selectable in chat dropdown
|
| 277 |
+
|
| 278 |
+
### 13.2 Validation Testing
|
| 279 |
+
1. Submit with each required field missing (one at a time)
|
| 280 |
+
2. Verify correct error message displayed
|
| 281 |
+
3. Verify form data retained after validation failure
|
| 282 |
+
|
| 283 |
+
### 13.3 Data Integrity
|
| 284 |
+
1. Add crew member
|
| 285 |
+
2. Refresh page
|
| 286 |
+
3. Verify crew member persists
|
| 287 |
+
4. Verify all fields saved correctly
|
| 288 |
+
|
| 289 |
+
### 13.4 Special Characters
|
| 290 |
+
1. Enter names with special characters (e.g., O'Brien, José)
|
| 291 |
+
2. Verify correct storage and display
|
| 292 |
+
3. Check CSV export format
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## 14. Related Documentation
|
| 297 |
+
|
| 298 |
+
- **Main Application:** `app.py` - Backend API endpoints
|
| 299 |
+
- **Frontend Logic:** `static/js/crew.js` - Crew management functions
|
| 300 |
+
- **UI Template:** `templates/index.html` - Form structure
|
| 301 |
+
- **Data Schema:** `data/patients.json` - Sample data structure
|
| 302 |
+
|
| 303 |
+
---
|
| 304 |
+
|
| 305 |
+
## 15. Version History
|
| 306 |
+
|
| 307 |
+
| Version | Date | Changes | Author |
|
| 308 |
+
|---------|------|---------|--------|
|
| 309 |
+
| 1.0 | 2026-01-22 | Initial specification based on code analysis | System |
|
| 310 |
+
|
| 311 |
+
---
|
| 312 |
+
|
| 313 |
+
## 16. Future Enhancements
|
| 314 |
+
|
| 315 |
+
### 16.1 Potential Improvements
|
| 316 |
+
1. **Photo capture:** Direct camera integration for passport photos
|
| 317 |
+
2. **Barcode scanning:** Auto-fill from passport MRZ scan
|
| 318 |
+
3. **Duplicate detection:** Warning for similar names/passport numbers
|
| 319 |
+
4. **Batch import:** CSV import for multiple crew members
|
| 320 |
+
5. **Validation enhancement:** Real-time field validation
|
| 321 |
+
6. **Data export:** Individual crew member PDF dossier generation
|
| 322 |
+
7. **History tracking:** Audit log of crew member additions/changes
|
| 323 |
+
|
| 324 |
+
### 16.2 Integration Opportunities
|
| 325 |
+
1. **External APIs:** Passport validation services
|
| 326 |
+
2. **Compliance:** International maritime crew list standards (IMO FAL forms)
|
| 327 |
+
3. **Sync:** Cloud backup and multi-device synchronization
|
SPEC_Vessel_Crew_Add_Functionality.md
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Specification: Add Crew Member Functionality
|
| 2 |
+
## Vessel & Crew Info Page - SailingMedAdvisor
|
| 3 |
+
|
| 4 |
+
**Document Version:** 1.0
|
| 5 |
+
**Last Updated:** January 22, 2026
|
| 6 |
+
**Component:** Vessel & Crew Information Management
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## Overview
|
| 11 |
+
|
| 12 |
+
The Add Crew Member functionality allows authorized users to register new crew members, passengers, or captain information into the SailingMedAdvisor system. This feature is located within the "Vessel & Crew Info" tab and provides a comprehensive data entry form for capturing essential crew information, documentation, and emergency contacts.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Location & Access
|
| 17 |
+
|
| 18 |
+
**Navigation Path:**
|
| 19 |
+
Main Navigation Bar → **VESSEL & CREW** tab → Crew Information section → **Add New Crew Member** (expandable section)
|
| 20 |
+
|
| 21 |
+
**Access Control:**
|
| 22 |
+
- Requires user authentication (session-based)
|
| 23 |
+
- Available to all authenticated users
|
| 24 |
+
- No role-based restrictions currently implemented
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## User Interface Components
|
| 29 |
+
|
| 30 |
+
### 1. Collapsible Section Header
|
| 31 |
+
|
| 32 |
+
**Element:** `div.col-header.crew-med-header`
|
| 33 |
+
**Default State:** Collapsed (▸ icon)
|
| 34 |
+
**Interactive Element:** Clickable header with toggle icon
|
| 35 |
+
**Visual Indicator:** Arrow icon (▸ when collapsed, ▾ when expanded)
|
| 36 |
+
|
| 37 |
+
**Header Text:** "Add New Crew Member"
|
| 38 |
+
|
| 39 |
+
**Behavior:**
|
| 40 |
+
- Single-click toggles expansion/collapse
|
| 41 |
+
- Icon rotates to indicate current state
|
| 42 |
+
- State persists using localStorage with key based on `data-sidebar-id="crew-add"`
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
### 2. Input Form Fields
|
| 47 |
+
|
| 48 |
+
The add crew form is organized into logical grouped sections with a responsive grid layout.
|
| 49 |
+
|
| 50 |
+
#### **2.1 Name Fields**
|
| 51 |
+
**Layout:** 3-column grid (`grid-template-columns: 1fr 1fr 1fr`)
|
| 52 |
+
|
| 53 |
+
| Field Label | Element ID | Type | Required | Placeholder | Notes |
|
| 54 |
+
|------------|-----------|------|----------|-------------|--------|
|
| 55 |
+
| First Name | `cn-first` | text | Yes (*) | - | Primary identifier |
|
| 56 |
+
| Middle Name(s) | `cn-middle` | text | No | - | Optional middle names |
|
| 57 |
+
| Last Name | `cn-last` | text | Yes (*) | - | Family name |
|
| 58 |
+
|
| 59 |
+
#### **2.2 Personal Information**
|
| 60 |
+
**Layout:** 3-column grid
|
| 61 |
+
|
| 62 |
+
| Field Label | Element ID | Type | Required | Options/Format | Notes |
|
| 63 |
+
|------------|-----------|------|----------|----------------|--------|
|
| 64 |
+
| Sex | `cn-sex` | select | Yes (*) | Male, Female, Non-binary, Other, Prefer not to say | Gender identity |
|
| 65 |
+
| Birthdate | `cn-birthdate` | date | Yes (*) | YYYY-MM-DD | Age calculation base |
|
| 66 |
+
| Position | `cn-position` | select | Yes (*) | Captain, Crew, Passenger | Role aboard vessel |
|
| 67 |
+
|
| 68 |
+
#### **2.3 Documentation Fields**
|
| 69 |
+
**Layout:** 4-column grid
|
| 70 |
+
|
| 71 |
+
| Field Label | Element ID | Type | Required | Format/List | Notes |
|
| 72 |
+
|------------|-----------|------|----------|-------------|--------|
|
| 73 |
+
| Citizenship | `cn-citizenship` | text + datalist | Yes (*) | Country names | Autocomplete enabled |
|
| 74 |
+
| Passport Number | `cn-passport` | text | Yes (*) | Alphanumeric | Official passport ID |
|
| 75 |
+
| Issue Date | `cn-pass-issue` | date | No | YYYY-MM-DD | Passport issue |
|
| 76 |
+
| Expiry Date | `cn-pass-expiry` | date | No | YYYY-MM-DD | Passport expiration |
|
| 77 |
+
|
| 78 |
+
**Datalist Options** (id="countries"):
|
| 79 |
+
USA, Canada, UK, Australia, New Zealand, France, Germany, Spain, Italy, Netherlands, Singapore, Malaysia, Thailand, Philippines, Japan, China, India
|
| 80 |
+
|
| 81 |
+
#### **2.4 Contact & File Upload**
|
| 82 |
+
**Layout:** 3-column grid (contact) + single row (files)
|
| 83 |
+
|
| 84 |
+
| Field Label | Element ID | Type | Required | Format | Notes |
|
| 85 |
+
|------------|-----------|------|----------|--------|--------|
|
| 86 |
+
| Cell/WhatsApp | `cn-phone` | text | No | +[country][number] | International format |
|
| 87 |
+
| Passport Photo/PDF | `cn-passport-photo` | file | No | image/*,.pdf | Photo upload |
|
| 88 |
+
| Passport Page Photo/PDF | `cn-passport-page` | file | No | image/*,.pdf | Document scan |
|
| 89 |
+
|
| 90 |
+
#### **2.5 Emergency Contact Section**
|
| 91 |
+
**Sub-header:** "Emergency Contact"
|
| 92 |
+
**Layout:** 4-column grid + notes field
|
| 93 |
+
|
| 94 |
+
| Field Label | Element ID | Type | Required | Format | Notes |
|
| 95 |
+
|------------|-----------|------|----------|--------|--------|
|
| 96 |
+
| Name | `cn-emerg-name` | text | No | Full name | Contact person |
|
| 97 |
+
| Relationship | `cn-emerg-rel` | text | No | e.g., Spouse, Parent | Relation to crew |
|
| 98 |
+
| Phone | `cn-emerg-phone` | text | No | Phone number | Contact number |
|
| 99 |
+
| Email | `cn-emerg-email` | email | No | email@domain.com | Email address |
|
| 100 |
+
| Emergency Contact Notes | `cn-emerg-notes` | text | No | Additional info | Full-width field |
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
### 3. Action Button
|
| 105 |
+
|
| 106 |
+
**Element:** `<button onclick="addCrew()">`
|
| 107 |
+
**Class:** `btn btn-sm`
|
| 108 |
+
**Style:** `background:var(--dark); width:100%;`
|
| 109 |
+
**Text:** "+ Add Crew Member"
|
| 110 |
+
|
| 111 |
+
**Visual Properties:**
|
| 112 |
+
- Dark background color (CSS variable: `--dark` = `#2c3e50`)
|
| 113 |
+
- Full width of container
|
| 114 |
+
- Small button sizing (`btn-sm` = `padding: 6px 12px; font-size: 13px`)
|
| 115 |
+
- White text color
|
| 116 |
+
- Pointer cursor on hover
|
| 117 |
+
- Transition effects on interaction
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## Functional Behavior
|
| 122 |
+
|
| 123 |
+
### 1. Form Submission Process
|
| 124 |
+
|
| 125 |
+
**Trigger:** Click on "+ Add Crew Member" button
|
| 126 |
+
**Handler Function:** `addCrew()` (defined in `/static/js/crew.js`)
|
| 127 |
+
|
| 128 |
+
**Execution Flow:**
|
| 129 |
+
|
| 130 |
+
1. **Data Collection**
|
| 131 |
+
- Reads all input field values by element ID
|
| 132 |
+
- Reads file uploads (if any)
|
| 133 |
+
- Constructs crew member object
|
| 134 |
+
|
| 135 |
+
2. **Validation**
|
| 136 |
+
- Checks required fields (marked with *)
|
| 137 |
+
- First Name, Last Name, Sex, Birthdate, Position, Citizenship, and Passport Number are mandatory
|
| 138 |
+
- Displays browser alert if required fields are missing
|
| 139 |
+
- Validation message format: "Please fill in: First Name, Last Name, Sex, Birthdate, Position, Citizenship, Passport Number."
|
| 140 |
+
|
| 141 |
+
3. **File Handling**
|
| 142 |
+
- Processes uploaded passport photo/PDF files
|
| 143 |
+
- Stores file references or base64 encoded data
|
| 144 |
+
- Associates files with crew member record
|
| 145 |
+
|
| 146 |
+
4. **API Request**
|
| 147 |
+
```javascript
|
| 148 |
+
POST /api/data/patients
|
| 149 |
+
Headers: { credentials: 'same-origin' }
|
| 150 |
+
Content-Type: application/json
|
| 151 |
+
Body: [updated patients array including new crew member]
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
5. **Response Handling**
|
| 155 |
+
- **Success (200 OK):**
|
| 156 |
+
- Displays success alert
|
| 157 |
+
- Clears all form fields
|
| 158 |
+
- Reloads crew list display (`loadData()`)
|
| 159 |
+
- Collapses the Add New Crew Member section
|
| 160 |
+
|
| 161 |
+
- **Failure (non-200):**
|
| 162 |
+
- Displays error alert with message
|
| 163 |
+
- Retains form data for correction
|
| 164 |
+
- Does not clear form fields
|
| 165 |
+
|
| 166 |
+
6. **UI Updates**
|
| 167 |
+
- Updates crew member dropdown in chat interface (`#p-select`)
|
| 168 |
+
- Updates crew list display in Crew Information section
|
| 169 |
+
- Updates crew list in Crew Health & Log tab
|
| 170 |
+
- Refreshes crew credentials (if username/password set)
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
### 2. Data Structure
|
| 175 |
+
|
| 176 |
+
**Stored Object Format:**
|
| 177 |
+
```javascript
|
| 178 |
+
{
|
| 179 |
+
"firstName": string,
|
| 180 |
+
"middleName": string | "",
|
| 181 |
+
"lastName": string,
|
| 182 |
+
"name": string, // Computed: "firstName middleName lastName"
|
| 183 |
+
"sex": string, // "Male" | "Female" | "Non-binary" | "Other" | "Prefer not to say"
|
| 184 |
+
"birthdate": string, // "YYYY-MM-DD"
|
| 185 |
+
"position": string, // "Captain" | "Crew" | "Passenger"
|
| 186 |
+
"citizenship": string,
|
| 187 |
+
"passportNumber": string,
|
| 188 |
+
"passportIssueDate": string | "", // "YYYY-MM-DD"
|
| 189 |
+
"passportExpiryDate": string | "", // "YYYY-MM-DD"
|
| 190 |
+
"phone": string | "",
|
| 191 |
+
"passportPhoto": string | null, // File reference or base64
|
| 192 |
+
"passportPage": string | null, // File reference or base64
|
| 193 |
+
"emergencyContact": {
|
| 194 |
+
"name": string | "",
|
| 195 |
+
"relationship": string | "",
|
| 196 |
+
"phone": string | "",
|
| 197 |
+
"email": string | "",
|
| 198 |
+
"notes": string | ""
|
| 199 |
+
},
|
| 200 |
+
"history": string | "", // Medical history notes
|
| 201 |
+
"username": string | "", // Optional login credential
|
| 202 |
+
"password": string | "" // Optional login credential
|
| 203 |
+
}
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
**Storage Location:**
|
| 207 |
+
`/data/patients.json` (server-side file storage)
|
| 208 |
+
|
| 209 |
+
**Data Persistence:**
|
| 210 |
+
- Written to disk immediately upon successful API call
|
| 211 |
+
- Loaded on page initialization and tab navigation
|
| 212 |
+
- Cached in browser session until page reload
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
### 3. Form Field Clearing
|
| 217 |
+
|
| 218 |
+
After successful submission, all form fields are reset:
|
| 219 |
+
|
| 220 |
+
```javascript
|
| 221 |
+
document.getElementById('cn-first').value = '';
|
| 222 |
+
document.getElementById('cn-middle').value = '';
|
| 223 |
+
document.getElementById('cn-last').value = '';
|
| 224 |
+
// ... (all fields cleared to empty string)
|
| 225 |
+
document.getElementById('cn-passport-photo').value = '';
|
| 226 |
+
document.getElementById('cn-passport-page').value = '';
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
### 4. Integration Points
|
| 232 |
+
|
| 233 |
+
#### **4.1 Patient Selection Dropdown**
|
| 234 |
+
- Location: MEDGEMMA CHAT tab → "Crew Member Receiving Care"
|
| 235 |
+
- Element ID: `#p-select`
|
| 236 |
+
- Auto-populates with format: "LastName, FirstName"
|
| 237 |
+
- Includes default option: "Unnamed Crew"
|
| 238 |
+
- Updates immediately after adding new crew member
|
| 239 |
+
|
| 240 |
+
#### **4.2 Crew Information List**
|
| 241 |
+
- Location: VESSEL & CREW tab → Crew Information section
|
| 242 |
+
- Container ID: `#crew-info-list`
|
| 243 |
+
- Displays all registered crew members as expandable cards
|
| 244 |
+
- Each card shows:
|
| 245 |
+
- Name and position
|
| 246 |
+
- Basic details (birthdate, citizenship, passport)
|
| 247 |
+
- Emergency contact information
|
| 248 |
+
- Edit and Delete action buttons
|
| 249 |
+
|
| 250 |
+
#### **4.3 Crew Health & Log**
|
| 251 |
+
- Location: CREW HEALTH & LOG tab
|
| 252 |
+
- Container ID: `#crew-medical-list`
|
| 253 |
+
- Shows crew members with medical history entries
|
| 254 |
+
- Allows adding medical notes per crew member
|
| 255 |
+
|
| 256 |
+
#### **4.4 Login Credentials**
|
| 257 |
+
- Location: SETTINGS tab → Crew Login Credentials
|
| 258 |
+
- Displays crew members who have username/password set
|
| 259 |
+
- Enables authentication for restricted access
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
## CSS Styling
|
| 264 |
+
|
| 265 |
+
### Form Container Styles
|
| 266 |
+
|
| 267 |
+
```css
|
| 268 |
+
.col-body {
|
| 269 |
+
padding: 15px;
|
| 270 |
+
background: #f8f9fa;
|
| 271 |
+
display: none; /* Initially collapsed */
|
| 272 |
+
}
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
### Grid Layout
|
| 276 |
+
|
| 277 |
+
```css
|
| 278 |
+
display: grid;
|
| 279 |
+
grid-template-columns: 1fr 1fr 1fr; /* 3-column for names */
|
| 280 |
+
grid-template-columns: 1fr 1fr 1fr 1fr; /* 4-column for docs/contact */
|
| 281 |
+
gap: 8px;
|
| 282 |
+
margin-bottom: 8px;
|
| 283 |
+
font-size: 15px;
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
### Input Field Styles
|
| 287 |
+
|
| 288 |
+
```css
|
| 289 |
+
input, select, textarea {
|
| 290 |
+
padding: 6px;
|
| 291 |
+
width: 100%;
|
| 292 |
+
font-size: 15px;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
label {
|
| 296 |
+
font-size: 13px;
|
| 297 |
+
margin-bottom: 2px;
|
| 298 |
+
display: block;
|
| 299 |
+
}
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
### Button Styles
|
| 303 |
+
|
| 304 |
+
```css
|
| 305 |
+
.btn-sm {
|
| 306 |
+
padding: 6px 12px;
|
| 307 |
+
font-size: 13px;
|
| 308 |
+
background: var(--dark); /* #2c3e50 */
|
| 309 |
+
color: white;
|
| 310 |
+
border: none;
|
| 311 |
+
border-radius: 4px;
|
| 312 |
+
cursor: pointer;
|
| 313 |
+
font-weight: bold;
|
| 314 |
+
}
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
## Error Handling
|
| 320 |
+
|
| 321 |
+
### Client-Side Validation Errors
|
| 322 |
+
|
| 323 |
+
**Missing Required Fields:**
|
| 324 |
+
```
|
| 325 |
+
Alert Message: "Please fill in: [list of missing fields]"
|
| 326 |
+
Behavior: Form remains open, data retained, focus on first missing field
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
**File Upload Errors:**
|
| 330 |
+
```
|
| 331 |
+
Alert Message: "Error uploading files: [error description]"
|
| 332 |
+
Behavior: Form remains open, previously entered data retained
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
### Server-Side Errors
|
| 336 |
+
|
| 337 |
+
**API Request Failure:**
|
| 338 |
+
```
|
| 339 |
+
Alert Message: "Failed to add crew member: [HTTP status or error message]"
|
| 340 |
+
Behavior: Form remains open, all data retained for retry
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
**Network Error:**
|
| 344 |
+
```
|
| 345 |
+
Alert Message: "Network error. Please check your connection and try again."
|
| 346 |
+
Behavior: Form remains open, all data retained
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
---
|
| 350 |
+
|
| 351 |
+
## Accessibility Features
|
| 352 |
+
|
| 353 |
+
- **Keyboard Navigation:** Full tab-through support for all form fields
|
| 354 |
+
- **Screen Reader Support:** Proper label associations via `<label>` elements
|
| 355 |
+
- **Required Field Indicators:** Asterisk (*) in field labels
|
| 356 |
+
- **Focus Management:** First field receives focus when section expands
|
| 357 |
+
- **Error Messaging:** Clear, descriptive error messages for validation failures
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
## Security Considerations
|
| 362 |
+
|
| 363 |
+
1. **Authentication:** Requires active session (cookie-based)
|
| 364 |
+
2. **Data Validation:** Server-side validation of all inputs
|
| 365 |
+
3. **File Upload Security:**
|
| 366 |
+
- Restricted to image/* and .pdf MIME types
|
| 367 |
+
- File size limits enforced server-side
|
| 368 |
+
- Sanitized file names
|
| 369 |
+
|
| 370 |
+
4. **XSS Prevention:** All user inputs are escaped before rendering
|
| 371 |
+
5. **CSRF Protection:** SessionMiddleware with same-site=lax policy
|
| 372 |
+
|
| 373 |
+
---
|
| 374 |
+
|
| 375 |
+
## Performance Considerations
|
| 376 |
+
|
| 377 |
+
- **Form Load Time:** < 100ms (minimal DOM manipulation)
|
| 378 |
+
- **API Response Time:** Typically < 500ms for add operation
|
| 379 |
+
- **File Upload:** Dependent on file size and connection speed
|
| 380 |
+
- **Data Reload:** Full crew list refresh after successful addition
|
| 381 |
+
|
| 382 |
+
---
|
| 383 |
+
|
| 384 |
+
## Browser Compatibility
|
| 385 |
+
|
| 386 |
+
- **Tested Browsers:** Chrome, Firefox, Edge, Safari
|
| 387 |
+
- **Required Features:**
|
| 388 |
+
- ES6+ JavaScript support
|
| 389 |
+
- Fetch API
|
| 390 |
+
- LocalStorage
|
| 391 |
+
- HTML5 form inputs (date, email, file)
|
| 392 |
+
- CSS Grid Layout
|
| 393 |
+
|
| 394 |
+
---
|
| 395 |
+
|
| 396 |
+
## Related Documentation
|
| 397 |
+
|
| 398 |
+
- Backend API: `/api/data/patients` endpoint specification
|
| 399 |
+
- Crew Management Module: `/static/js/crew.js`
|
| 400 |
+
- Data Storage: `/data/patients.json` format specification
|
| 401 |
+
- Authentication: Session management and login flow
|
| 402 |
+
|
| 403 |
+
---
|
| 404 |
+
|
| 405 |
+
## Known Issues & Limitations
|
| 406 |
+
|
| 407 |
+
1. **File Storage:** Currently stores base64 encoded files in JSON (may cause performance issues with large files)
|
| 408 |
+
2. **Validation:** Phone number format validation is not enforced
|
| 409 |
+
3. **Duplicate Prevention:** No check for duplicate passport numbers or names
|
| 410 |
+
4. **Photo Preview:** Uploaded photos are not previewed before submission
|
| 411 |
+
5. **Internationalization:** Form labels and messages are English-only
|
| 412 |
+
|
| 413 |
+
---
|
| 414 |
+
|
| 415 |
+
## Future Enhancements
|
| 416 |
+
|
| 417 |
+
- Photo preview before submission
|
| 418 |
+
- Duplicate detection (passport number, name)
|
| 419 |
+
- International phone number validation
|
| 420 |
+
- Multi-language support
|
| 421 |
+
- Batch import from CSV/Excel
|
| 422 |
+
- Photo compression before upload
|
| 423 |
+
- Separate file storage system (not embedded in JSON)
|
| 424 |
+
- Auto-save draft functionality
|
| 425 |
+
- Field-level inline validation
|
| 426 |
+
|
| 427 |
+
---
|
| 428 |
+
|
| 429 |
+
**End of Specification**
|
app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
check_gpu.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
"""
|
| 10 |
+
GPU Detection Diagnostic Script
|
| 11 |
+
Run this to check if PyTorch can see our NVIDIA GPU
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
print("=" * 60)
|
| 17 |
+
print("GPU Detection Diagnostic")
|
| 18 |
+
print("=" * 60)
|
| 19 |
+
|
| 20 |
+
# Check if torch is installed
|
| 21 |
+
try:
|
| 22 |
+
import torch
|
| 23 |
+
print(f"✓ PyTorch installed: {torch.__version__}")
|
| 24 |
+
except ImportError as e:
|
| 25 |
+
print(f"✗ PyTorch not found: {e}")
|
| 26 |
+
print(" Install with: pip install torch")
|
| 27 |
+
sys.exit(1)
|
| 28 |
+
|
| 29 |
+
print()
|
| 30 |
+
|
| 31 |
+
# Check CUDA availability
|
| 32 |
+
print(f"CUDA available: {torch.cuda.is_available()}")
|
| 33 |
+
print(f"CUDA version (compiled): {torch.version.cuda}")
|
| 34 |
+
|
| 35 |
+
if torch.cuda.is_available():
|
| 36 |
+
print(f"✓ GPU DETECTED!")
|
| 37 |
+
print(f" Number of GPUs: {torch.cuda.device_count()}")
|
| 38 |
+
|
| 39 |
+
for i in range(torch.cuda.device_count()):
|
| 40 |
+
print(f"\n GPU {i}:")
|
| 41 |
+
print(f" Name: {torch.cuda.get_device_name(i)}")
|
| 42 |
+
props = torch.cuda.get_device_properties(i)
|
| 43 |
+
print(f" Total memory: {props.total_memory / 1024**3:.2f} GB")
|
| 44 |
+
print(f" Compute capability: {props.major}.{props.minor}")
|
| 45 |
+
|
| 46 |
+
# Test tensor creation
|
| 47 |
+
try:
|
| 48 |
+
test_tensor = torch.zeros(10, 10).cuda()
|
| 49 |
+
print(f"\n✓ Successfully created tensor on GPU: {test_tensor.device}")
|
| 50 |
+
del test_tensor
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"\n✗ Failed to create tensor on GPU: {e}")
|
| 53 |
+
else:
|
| 54 |
+
print("✗ NO GPU DETECTED")
|
| 55 |
+
print("\nPossible reasons:")
|
| 56 |
+
print(" 1. No NVIDIA GPU installed")
|
| 57 |
+
print(" 2. NVIDIA drivers not installed")
|
| 58 |
+
print(" 3. PyTorch installed without CUDA support")
|
| 59 |
+
print(" 4. CUDA toolkit version mismatch")
|
| 60 |
+
|
| 61 |
+
print("\nTo install PyTorch with CUDA support:")
|
| 62 |
+
print(" pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118")
|
| 63 |
+
|
| 64 |
+
# Check for NVIDIA driver
|
| 65 |
+
try:
|
| 66 |
+
import subprocess
|
| 67 |
+
result = subprocess.run(['nvidia-smi'], capture_output=True, text=True)
|
| 68 |
+
if result.returncode == 0:
|
| 69 |
+
print("\n✓ nvidia-smi found - NVIDIA driver is installed")
|
| 70 |
+
print("\nNVIDIA Driver Info:")
|
| 71 |
+
print(result.stdout)
|
| 72 |
+
else:
|
| 73 |
+
print("\n✗ nvidia-smi not found - NVIDIA driver may not be installed")
|
| 74 |
+
except FileNotFoundError:
|
| 75 |
+
print("\n✗ nvidia-smi not found - NVIDIA driver may not be installed")
|
| 76 |
+
|
| 77 |
+
print("\n" + "=" * 60)
|
data/default/chats.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/context.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
data/default/history.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/inventory.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/med_photo_jobs.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/med_photo_queue.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/patients.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/settings.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
data/default/tools.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
data/default/vessel.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
db_store.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
debug_inference.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
"""
|
| 10 |
+
Quick runtime diagnostic for local inference behavior.
|
| 11 |
+
|
| 12 |
+
This script calls the running app's offline-status endpoint and prints
|
| 13 |
+
the exact environment flags/model cache state the backend sees.
|
| 14 |
+
"""
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
# Check what the API reports
|
| 18 |
+
print("1. Checking API status:")
|
| 19 |
+
resp = requests.get("http://127.0.0.1:5000/api/offline/check")
|
| 20 |
+
data = resp.json()
|
| 21 |
+
|
| 22 |
+
print(f" Offline mode: {data.get('offline_mode')}")
|
| 23 |
+
print(f" Cache dir: {data.get('cache_dir')}")
|
| 24 |
+
print(f" Models cached:")
|
| 25 |
+
for m in data.get('models', []):
|
| 26 |
+
status = "✓" if m['cached'] else "✗"
|
| 27 |
+
print(f" {status} {m['model']}")
|
| 28 |
+
|
| 29 |
+
# Check environment from the app's perspective
|
| 30 |
+
print("\n2. Checking app environment flags:")
|
| 31 |
+
env_flags = data.get('env', {})
|
| 32 |
+
for k, v in sorted(env_flags.items()):
|
| 33 |
+
print(f" {k}: {v}")
|
| 34 |
+
|
| 35 |
+
print("\n3. Attempting to decode why GPU not used...")
|
| 36 |
+
print(" Possible causes:")
|
| 37 |
+
print(" - DISABLE_LOCAL_INFERENCE might be True")
|
| 38 |
+
print(" - HF_REMOTE_TOKEN might be set (using remote API)")
|
| 39 |
+
print(" - Models loading to CPU instead of GPU")
|
| 40 |
+
print(" - device_map not working correctly")
|
| 41 |
+
|
| 42 |
+
# Try to trigger a simple test query and see response time
|
| 43 |
+
print("\n4. Check if you can see logs during query:")
|
| 44 |
+
print(" In terminal running server, watch for:")
|
| 45 |
+
print(" - 'Loading model...' or similar")
|
| 46 |
+
print(" - Any torch/transformers messages")
|
| 47 |
+
print(" - Error messages")
|
docs/FRESH_INSTALL.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SailingMedAdvisor Fresh Install Guide
|
| 2 |
+
|
| 3 |
+
This guide is the reproducible install path for setting up a working copy of SailingMedAdvisor on a new computer.
|
| 4 |
+
|
| 5 |
+
## 1. Prerequisites
|
| 6 |
+
|
| 7 |
+
- OS: Linux (tested on Ubuntu-class environments)
|
| 8 |
+
- Python: `3.10+`
|
| 9 |
+
- Git: installed
|
| 10 |
+
- GPU runtime (recommended for MedGemma inference):
|
| 11 |
+
- NVIDIA driver + CUDA-compatible PyTorch environment
|
| 12 |
+
- Network access for first-time dependency/model downloads
|
| 13 |
+
|
| 14 |
+
## 2. One-Command Install (Recommended)
|
| 15 |
+
|
| 16 |
+
From any directory:
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
git clone https://github.com/rickeae/SailingMedAdvisor.git
|
| 20 |
+
cd SailingMedAdvisor
|
| 21 |
+
chmod +x scripts/install_fresh_copy.sh
|
| 22 |
+
./scripts/install_fresh_copy.sh --skip-clone
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
What this does:
|
| 26 |
+
|
| 27 |
+
1. Creates `.venv`
|
| 28 |
+
2. Installs dependencies from `requirements.txt`
|
| 29 |
+
3. Runs deterministic verification (`scripts/verify_fresh_install.py`)
|
| 30 |
+
|
| 31 |
+
## 2b. Full Ubuntu 24.04 Bootstrap (System + Clone + Install + Verify)
|
| 32 |
+
|
| 33 |
+
If the machine is truly fresh and you want one script to do the full flow:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
git clone https://github.com/rickeae/SailingMedAdvisor.git
|
| 37 |
+
cd SailingMedAdvisor
|
| 38 |
+
chmod +x scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 39 |
+
./scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Optional: also launch the app automatically after verification:
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
./scripts/bootstrap_ubuntu24_sailingmedadvisor.sh --start
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 3. Verification Command (Standalone)
|
| 49 |
+
|
| 50 |
+
You can re-run install verification any time:
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
./.venv/bin/python scripts/verify_fresh_install.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
Verification covers:
|
| 57 |
+
|
| 58 |
+
- Python version
|
| 59 |
+
- Required files present
|
| 60 |
+
- Required package imports
|
| 61 |
+
- Database initialization/schema checks
|
| 62 |
+
- Default triage tree JSON integrity
|
| 63 |
+
- API startup smoke test via `GET /api/db/status`
|
| 64 |
+
|
| 65 |
+
## 4. Start the Application
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
FORCE_CUDA=0 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Open:
|
| 72 |
+
|
| 73 |
+
- Local: `http://127.0.0.1:5000`
|
| 74 |
+
- LAN: `http://<machine-ip>:5000`
|
| 75 |
+
|
| 76 |
+
GPU-known-good optional start:
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
FORCE_CUDA=1 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## 5. Prepare for Offline Use (Before Departure)
|
| 83 |
+
|
| 84 |
+
In the app UI:
|
| 85 |
+
|
| 86 |
+
1. Go to `Settings -> Offline Readiness Check`
|
| 87 |
+
2. Click `Check cache status`
|
| 88 |
+
3. Click `Download missing models` while internet is available
|
| 89 |
+
4. Enable offline flags before operating without internet
|
| 90 |
+
|
| 91 |
+
Expected required models:
|
| 92 |
+
|
| 93 |
+
- `google/medgemma-1.5-4b-it`
|
| 94 |
+
- `google/medgemma-27b-text-it`
|
| 95 |
+
|
| 96 |
+
## 6. Demo Reproduction Note
|
| 97 |
+
|
| 98 |
+
To reproduce the challenge demo scenario, select the 27B model in the consultation UI:
|
| 99 |
+
|
| 100 |
+
- `google/medgemma-27b-text-it`
|
| 101 |
+
|
| 102 |
+
## 7. Troubleshooting
|
| 103 |
+
|
| 104 |
+
- CUDA preflight fails:
|
| 105 |
+
- Check `FORCE_CUDA` and NVIDIA driver health.
|
| 106 |
+
- Use kernel logs (`journalctl -k | grep -i -E 'NVRM|Xid'`) to diagnose driver errors.
|
| 107 |
+
- API smoke test fails:
|
| 108 |
+
- Re-run `./.venv/bin/python scripts/verify_fresh_install.py --timeout 90`
|
| 109 |
+
- Check for port conflicts and Python import errors.
|
| 110 |
+
- Missing model cache in offline mode:
|
| 111 |
+
- Disable offline flags temporarily, reconnect internet, then use `Download missing models`.
|
medgemma15_test.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Author: Rick Escher
|
| 3 |
+
# Project: SailingMedAdvisor
|
| 4 |
+
# Context: Google HAI-DEF Framework
|
| 5 |
+
# Models: Google MedGemmas
|
| 6 |
+
# Program: Kaggle Impact Challenge
|
| 7 |
+
# =============================================================================
|
| 8 |
+
import os
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# Force offline mode + make sure only GPU 0 is visible before torch import.
|
| 14 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 15 |
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
| 16 |
+
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "0")
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
# --- GEMMA3 MASK PATCH (torch<2.6) ---
|
| 21 |
+
def _torch_version_ge(major: int, minor: int) -> bool:
|
| 22 |
+
"""
|
| 23 |
+
Torch Version Ge helper.
|
| 24 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 25 |
+
"""
|
| 26 |
+
try:
|
| 27 |
+
base = torch.__version__.split("+", 1)[0]
|
| 28 |
+
parts = base.split(".")
|
| 29 |
+
return (int(parts[0]), int(parts[1])) >= (major, minor)
|
| 30 |
+
except Exception:
|
| 31 |
+
return False
|
| 32 |
+
|
| 33 |
+
if not _torch_version_ge(2, 6):
|
| 34 |
+
try:
|
| 35 |
+
import transformers.models.gemma3.modeling_gemma3 as gemma_model
|
| 36 |
+
_orig_create_causal_mask_mapping = gemma_model.create_causal_mask_mapping
|
| 37 |
+
|
| 38 |
+
def _create_causal_mask_mapping_no_or(*args, **kwargs):
|
| 39 |
+
# torch<2.6 can't use or_mask_function; ignore token_type_ids for text-only.
|
| 40 |
+
"""
|
| 41 |
+
Create Causal Mask Mapping No Or helper.
|
| 42 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 43 |
+
"""
|
| 44 |
+
if len(args) >= 7:
|
| 45 |
+
args = list(args)
|
| 46 |
+
args[6] = None
|
| 47 |
+
if "token_type_ids" in kwargs:
|
| 48 |
+
kwargs = dict(kwargs)
|
| 49 |
+
kwargs["token_type_ids"] = None
|
| 50 |
+
return _orig_create_causal_mask_mapping(*args, **kwargs)
|
| 51 |
+
|
| 52 |
+
gemma_model.create_causal_mask_mapping = _create_causal_mask_mapping_no_or
|
| 53 |
+
except Exception:
|
| 54 |
+
# If the import fails, let the main error surface later.
|
| 55 |
+
pass
|
| 56 |
+
# -------------------------------------------------
|
| 57 |
+
|
| 58 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoProcessor
|
| 59 |
+
|
| 60 |
+
def _safe_pad_token_id(tok):
|
| 61 |
+
"""
|
| 62 |
+
Safe Pad Token Id helper.
|
| 63 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 64 |
+
"""
|
| 65 |
+
pad = getattr(tok, "pad_token_id", None)
|
| 66 |
+
if pad is not None:
|
| 67 |
+
return pad
|
| 68 |
+
eos = getattr(tok, "eos_token_id", None)
|
| 69 |
+
if isinstance(eos, (list, tuple)):
|
| 70 |
+
return eos[0] if eos else None
|
| 71 |
+
return eos
|
| 72 |
+
|
| 73 |
+
def _iter_cache_roots() -> list[Path]:
|
| 74 |
+
"""
|
| 75 |
+
Iter Cache Roots helper.
|
| 76 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 77 |
+
"""
|
| 78 |
+
roots: list[Path] = []
|
| 79 |
+
env_cache = os.environ.get("HUGGINGFACE_HUB_CACHE")
|
| 80 |
+
if env_cache:
|
| 81 |
+
roots.append(Path(env_cache))
|
| 82 |
+
env_home = os.environ.get("HF_HOME")
|
| 83 |
+
if env_home:
|
| 84 |
+
roots.append(Path(env_home) / "hub")
|
| 85 |
+
roots.append(Path("/mnt/modelcache/models_cache/hub"))
|
| 86 |
+
roots.append(Path("/mnt/modelcache/hf_home/hub"))
|
| 87 |
+
# De-dup while keeping order.
|
| 88 |
+
seen = set()
|
| 89 |
+
final: list[Path] = []
|
| 90 |
+
for root in roots:
|
| 91 |
+
if root in seen:
|
| 92 |
+
continue
|
| 93 |
+
seen.add(root)
|
| 94 |
+
if root.is_dir():
|
| 95 |
+
final.append(root)
|
| 96 |
+
return final
|
| 97 |
+
|
| 98 |
+
def _snapshot_complete(snapshot: Path) -> bool:
|
| 99 |
+
"""
|
| 100 |
+
Snapshot Complete helper.
|
| 101 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 102 |
+
"""
|
| 103 |
+
index = snapshot / "model.safetensors.index.json"
|
| 104 |
+
if index.exists():
|
| 105 |
+
try:
|
| 106 |
+
data = json.loads(index.read_text())
|
| 107 |
+
except Exception:
|
| 108 |
+
return False
|
| 109 |
+
weight_map = data.get("weight_map") or {}
|
| 110 |
+
files = set(weight_map.values())
|
| 111 |
+
if not files:
|
| 112 |
+
return False
|
| 113 |
+
for name in files:
|
| 114 |
+
f = snapshot / name
|
| 115 |
+
if not f.exists():
|
| 116 |
+
return False
|
| 117 |
+
target = f.resolve()
|
| 118 |
+
if str(target).endswith(".incomplete"):
|
| 119 |
+
return False
|
| 120 |
+
return True
|
| 121 |
+
single = snapshot / "model.safetensors"
|
| 122 |
+
if single.exists():
|
| 123 |
+
target = single.resolve()
|
| 124 |
+
return not str(target).endswith(".incomplete")
|
| 125 |
+
return False
|
| 126 |
+
|
| 127 |
+
def _pick_latest_complete(snapshot_dir: Path, model_id: str) -> str:
|
| 128 |
+
"""
|
| 129 |
+
Pick Latest Complete helper.
|
| 130 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 131 |
+
"""
|
| 132 |
+
if not snapshot_dir.is_dir():
|
| 133 |
+
raise FileNotFoundError(f"Snapshot dir not found for {model_id}: {snapshot_dir}")
|
| 134 |
+
snapshots = [d for d in snapshot_dir.iterdir() if d.is_dir()]
|
| 135 |
+
if not snapshots:
|
| 136 |
+
raise FileNotFoundError(f"No snapshots found for {model_id} in: {snapshot_dir}")
|
| 137 |
+
snapshots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
| 138 |
+
for snap in snapshots:
|
| 139 |
+
if _snapshot_complete(snap):
|
| 140 |
+
return str(snap)
|
| 141 |
+
raise FileNotFoundError(
|
| 142 |
+
f"Snapshots found for {model_id}, but weights are incomplete (.incomplete). "
|
| 143 |
+
f"Checked: {snapshot_dir}"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
def _resolve_snapshot(model_id: str, snapshot_override: str | None) -> str:
|
| 147 |
+
"""
|
| 148 |
+
Resolve Snapshot helper.
|
| 149 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 150 |
+
"""
|
| 151 |
+
if snapshot_override:
|
| 152 |
+
override = Path(snapshot_override)
|
| 153 |
+
# Accept either a snapshot path or the repo root containing "snapshots/".
|
| 154 |
+
if (override / "snapshots").is_dir():
|
| 155 |
+
return _pick_latest_complete(override / "snapshots", model_id)
|
| 156 |
+
if override.is_dir() and _snapshot_complete(override):
|
| 157 |
+
return str(override)
|
| 158 |
+
return snapshot_override
|
| 159 |
+
safe = model_id.replace("/", "--")
|
| 160 |
+
snapshots: list[Path] = []
|
| 161 |
+
for root in _iter_cache_roots():
|
| 162 |
+
base = root / f"models--{safe}" / "snapshots"
|
| 163 |
+
if not base.is_dir():
|
| 164 |
+
continue
|
| 165 |
+
snapshots.extend([d for d in base.iterdir() if d.is_dir()])
|
| 166 |
+
if not snapshots:
|
| 167 |
+
raise FileNotFoundError(
|
| 168 |
+
f"No snapshots found for {model_id}. Checked: {', '.join(map(str, _iter_cache_roots()))}"
|
| 169 |
+
)
|
| 170 |
+
snapshots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
| 171 |
+
for snap in snapshots:
|
| 172 |
+
if _snapshot_complete(snap):
|
| 173 |
+
return str(snap)
|
| 174 |
+
raise FileNotFoundError(
|
| 175 |
+
f"Snapshots found for {model_id}, but weights are incomplete (.incomplete). "
|
| 176 |
+
f"Checked: {', '.join(map(str, _iter_cache_roots()))}"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
if not torch.cuda.is_available():
|
| 180 |
+
raise RuntimeError("CUDA not available. This script requires the RTX 5000 GPU.")
|
| 181 |
+
|
| 182 |
+
gpu_name = torch.cuda.get_device_name(0)
|
| 183 |
+
if "RTX 5000" not in gpu_name.upper():
|
| 184 |
+
raise RuntimeError(f"Unexpected GPU detected: '{gpu_name}'. Expected RTX 5000.")
|
| 185 |
+
if not torch.cuda.is_bf16_supported():
|
| 186 |
+
raise RuntimeError("bfloat16 not supported on this GPU/driver. This model is unstable in float16.")
|
| 187 |
+
|
| 188 |
+
def _pick_input_device(model) -> str:
|
| 189 |
+
"""
|
| 190 |
+
Pick Input Device helper.
|
| 191 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 192 |
+
"""
|
| 193 |
+
if hasattr(model, "hf_device_map"):
|
| 194 |
+
device_map = getattr(model, "hf_device_map") or {}
|
| 195 |
+
preferred = None
|
| 196 |
+
for name, dev in device_map.items():
|
| 197 |
+
if isinstance(dev, str) and dev.startswith("cuda"):
|
| 198 |
+
if "embed" in name:
|
| 199 |
+
return dev
|
| 200 |
+
preferred = dev
|
| 201 |
+
if preferred:
|
| 202 |
+
return preferred
|
| 203 |
+
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 204 |
+
|
| 205 |
+
def _normalize_device_map(device_map: str | dict) -> str | dict:
|
| 206 |
+
"""
|
| 207 |
+
Normalize Device Map helper.
|
| 208 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 209 |
+
"""
|
| 210 |
+
if isinstance(device_map, str):
|
| 211 |
+
value = device_map.strip()
|
| 212 |
+
if value in {"auto", "balanced", "balanced_low_0", "sequential"}:
|
| 213 |
+
return value
|
| 214 |
+
if value.startswith("cuda") or value.startswith("cpu"):
|
| 215 |
+
return {"": value}
|
| 216 |
+
return device_map
|
| 217 |
+
|
| 218 |
+
def _device_map_all_cuda(device_map: str | dict) -> bool:
|
| 219 |
+
"""
|
| 220 |
+
Device Map All Cuda helper.
|
| 221 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 222 |
+
"""
|
| 223 |
+
if isinstance(device_map, str):
|
| 224 |
+
return device_map.strip().startswith("cuda")
|
| 225 |
+
if isinstance(device_map, dict):
|
| 226 |
+
return all(str(v).startswith("cuda") for v in device_map.values())
|
| 227 |
+
return False
|
| 228 |
+
|
| 229 |
+
def _load_model(
|
| 230 |
+
snapshot: str,
|
| 231 |
+
quant4: bool,
|
| 232 |
+
device_map: str | dict,
|
| 233 |
+
max_memory: dict | None,
|
| 234 |
+
cpu_offload: bool,
|
| 235 |
+
):
|
| 236 |
+
"""
|
| 237 |
+
Load Model helper.
|
| 238 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 239 |
+
"""
|
| 240 |
+
model_kwargs = {
|
| 241 |
+
"device_map": _normalize_device_map(device_map),
|
| 242 |
+
"dtype": torch.bfloat16,
|
| 243 |
+
"attn_implementation": "eager",
|
| 244 |
+
"local_files_only": True,
|
| 245 |
+
"low_cpu_mem_usage": True,
|
| 246 |
+
}
|
| 247 |
+
if max_memory:
|
| 248 |
+
model_kwargs["max_memory"] = max_memory
|
| 249 |
+
if quant4:
|
| 250 |
+
try:
|
| 251 |
+
from transformers import BitsAndBytesConfig
|
| 252 |
+
except Exception as exc:
|
| 253 |
+
raise RuntimeError(f"bitsandbytes not available for 4-bit load: {exc}")
|
| 254 |
+
bnb_kwargs = dict(
|
| 255 |
+
load_in_4bit=True,
|
| 256 |
+
bnb_4bit_compute_dtype=torch.bfloat16,
|
| 257 |
+
bnb_4bit_use_double_quant=True,
|
| 258 |
+
bnb_4bit_quant_type="nf4",
|
| 259 |
+
)
|
| 260 |
+
if cpu_offload or not _device_map_all_cuda(device_map):
|
| 261 |
+
bnb_kwargs["llm_int8_enable_fp32_cpu_offload"] = True
|
| 262 |
+
model_kwargs["quantization_config"] = BitsAndBytesConfig(**bnb_kwargs)
|
| 263 |
+
model = AutoModelForCausalLM.from_pretrained(snapshot, **model_kwargs)
|
| 264 |
+
tokenizer = AutoTokenizer.from_pretrained(snapshot, local_files_only=True)
|
| 265 |
+
try:
|
| 266 |
+
processor = AutoProcessor.from_pretrained(snapshot, local_files_only=True)
|
| 267 |
+
except Exception:
|
| 268 |
+
processor = None
|
| 269 |
+
model.eval()
|
| 270 |
+
return model, tokenizer, processor
|
| 271 |
+
|
| 272 |
+
def _ensure_gpu_used(model) -> None:
|
| 273 |
+
"""
|
| 274 |
+
Ensure Gpu Used helper.
|
| 275 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 276 |
+
"""
|
| 277 |
+
device_map = getattr(model, "hf_device_map", None) or {}
|
| 278 |
+
if any(isinstance(v, str) and v.startswith("cuda") for v in device_map.values()):
|
| 279 |
+
return
|
| 280 |
+
for param in model.parameters():
|
| 281 |
+
if param.device.type == "cuda":
|
| 282 |
+
return
|
| 283 |
+
raise RuntimeError("Model did not place any weights on CUDA. RTX 5000 usage is required.")
|
| 284 |
+
|
| 285 |
+
def ask(model, tokenizer, processor, text: str, max_new_tokens: int, raw_prompt: bool):
|
| 286 |
+
"""
|
| 287 |
+
Ask helper.
|
| 288 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 289 |
+
"""
|
| 290 |
+
if raw_prompt:
|
| 291 |
+
prompt = text
|
| 292 |
+
else:
|
| 293 |
+
# Use the model's chat template (<start_of_turn> ... <end_of_turn>)
|
| 294 |
+
messages = [{"role": "user", "content": text}]
|
| 295 |
+
prompt = tokenizer.apply_chat_template(
|
| 296 |
+
messages,
|
| 297 |
+
add_generation_prompt=True,
|
| 298 |
+
tokenize=False,
|
| 299 |
+
)
|
| 300 |
+
if processor is not None:
|
| 301 |
+
inputs = processor(text=prompt, return_tensors="pt")
|
| 302 |
+
input_ids = inputs.get("input_ids")
|
| 303 |
+
else:
|
| 304 |
+
inputs = tokenizer(prompt, return_tensors="pt")
|
| 305 |
+
input_ids = inputs.get("input_ids")
|
| 306 |
+
input_device = _pick_input_device(model)
|
| 307 |
+
inputs = {k: v.to(input_device) for k, v in inputs.items()}
|
| 308 |
+
|
| 309 |
+
with torch.inference_mode():
|
| 310 |
+
out = model.generate(
|
| 311 |
+
**inputs,
|
| 312 |
+
max_new_tokens=max_new_tokens,
|
| 313 |
+
do_sample=False,
|
| 314 |
+
pad_token_id=_safe_pad_token_id(tokenizer),
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
response = tokenizer.decode(out[0][input_ids.shape[-1]:], skip_special_tokens=True)
|
| 318 |
+
return response.strip()
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__":
|
| 321 |
+
parser = argparse.ArgumentParser(description="MedGemma local test runner")
|
| 322 |
+
parser.add_argument("--model", choices=["4b", "27b"], default="4b", help="Select model size.")
|
| 323 |
+
parser.add_argument("--model-id", default="", help="Override Hugging Face model id.")
|
| 324 |
+
parser.add_argument("--snapshot", default="", help="Override snapshot path directly.")
|
| 325 |
+
parser.add_argument("--max-new-tokens", type=int, default=600, help="Generation length.")
|
| 326 |
+
parser.add_argument("--prompt", default="Explain first aid for a deep fishhook embedded in the cheek while at sea.")
|
| 327 |
+
parser.add_argument("--quant4", action="store_true", help="Load model in 4-bit (recommended for 27B).")
|
| 328 |
+
parser.add_argument("--device-map", default="", help="Override device_map (e.g. 'auto' or 'cuda:0').")
|
| 329 |
+
parser.add_argument("--raw-prompt", action="store_true", help="Use raw prompt (no chat template).")
|
| 330 |
+
parser.add_argument("--max-memory-gpu", default="15GiB", help="Max GPU memory for auto device_map.")
|
| 331 |
+
parser.add_argument("--max-memory-cpu", default="64GiB", help="Max CPU memory for auto device_map.")
|
| 332 |
+
parser.add_argument("--cpu-offload", action="store_true", help="Enable CPU offload for 4-bit quant.")
|
| 333 |
+
args = parser.parse_args()
|
| 334 |
+
|
| 335 |
+
model_id = args.model_id.strip() or (
|
| 336 |
+
"google/medgemma-27b-text-it" if args.model == "27b" else "google/medgemma-1.5-4b-it"
|
| 337 |
+
)
|
| 338 |
+
snapshot = _resolve_snapshot(model_id, args.snapshot.strip() or None)
|
| 339 |
+
if not os.path.isdir(snapshot):
|
| 340 |
+
raise FileNotFoundError(f"Model snapshot not found at: {snapshot}")
|
| 341 |
+
|
| 342 |
+
device_map = args.device_map.strip() or ("auto" if args.model == "27b" and not args.quant4 else "cuda:0")
|
| 343 |
+
print(f"Loading {model_id} from snapshot: {snapshot}")
|
| 344 |
+
print(f"device_map: {device_map}")
|
| 345 |
+
if args.model == "27b" and not args.quant4 and device_map != "auto":
|
| 346 |
+
print("Warning: 27B model typically needs --device-map auto or --quant4 on RTX 5000.")
|
| 347 |
+
|
| 348 |
+
max_memory = None
|
| 349 |
+
if torch.cuda.is_available() and not _device_map_all_cuda(device_map):
|
| 350 |
+
max_memory = {0: args.max_memory_gpu, "cpu": args.max_memory_cpu}
|
| 351 |
+
model, tokenizer, processor = _load_model(
|
| 352 |
+
snapshot,
|
| 353 |
+
args.quant4,
|
| 354 |
+
device_map,
|
| 355 |
+
max_memory,
|
| 356 |
+
args.cpu_offload,
|
| 357 |
+
)
|
| 358 |
+
_ensure_gpu_used(model)
|
| 359 |
+
query = args.prompt
|
| 360 |
+
print(f"\nQUERY: {query}")
|
| 361 |
+
|
| 362 |
+
try:
|
| 363 |
+
response = ask(model, tokenizer, processor, query, args.max_new_tokens, args.raw_prompt)
|
| 364 |
+
if not response:
|
| 365 |
+
print("\nRESPONSE: [Still blank. Attempting fallback...]")
|
| 366 |
+
else:
|
| 367 |
+
print(f"\nRESPONSE:\n{'-'*30}\n{response}\n{'-'*30}")
|
| 368 |
+
except Exception as e:
|
| 369 |
+
print(f"\nERROR: {e}")
|
medgemma27b.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Author: Rick Escher
|
| 3 |
+
# Project: SailingMedAdvisor
|
| 4 |
+
# Context: Google HAI-DEF Framework
|
| 5 |
+
# Models: Google MedGemmas
|
| 6 |
+
# Program: Kaggle Impact Challenge
|
| 7 |
+
# =============================================================================
|
| 8 |
+
"""
|
| 9 |
+
MedGemma 27B inference runner.
|
| 10 |
+
|
| 11 |
+
Design intent:
|
| 12 |
+
- Use 4-bit quantization to fit 27B inference on constrained edge hardware.
|
| 13 |
+
- Support automatic and manual layer placement across GPU/CPU.
|
| 14 |
+
- Reuse loaded model when snapshot/load signature is unchanged to reduce
|
| 15 |
+
repeated load latency and VRAM fragmentation.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from typing import Any, Dict
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import gc
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
|
| 27 |
+
|
| 28 |
+
from medgemma_common import (
|
| 29 |
+
cap_new_tokens,
|
| 30 |
+
normalize_device_map,
|
| 31 |
+
pick_input_device,
|
| 32 |
+
resolve_model_max_length,
|
| 33 |
+
resolve_snapshot,
|
| 34 |
+
safe_pad_token_id,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
MODEL_ID = "google/medgemma-27b-text-it"
|
| 38 |
+
|
| 39 |
+
_MODEL = None
|
| 40 |
+
_TOKENIZER = None
|
| 41 |
+
_ACTIVE_SNAPSHOT = None
|
| 42 |
+
_ACTIVE_LOAD_SIGNATURE = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _default_dtype() -> torch.dtype:
|
| 46 |
+
"""Select default compute dtype with BF16 preference when available."""
|
| 47 |
+
if os.environ.get("FORCE_FP16", "").strip() == "1":
|
| 48 |
+
return torch.float16
|
| 49 |
+
if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
|
| 50 |
+
return torch.bfloat16
|
| 51 |
+
if torch.cuda.is_available():
|
| 52 |
+
return torch.float16
|
| 53 |
+
return torch.float32
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _load_quant_config() -> Any:
|
| 57 |
+
"""Build BitsAndBytes 4-bit configuration for 27B local inference."""
|
| 58 |
+
try:
|
| 59 |
+
from transformers import BitsAndBytesConfig
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
raise RuntimeError(f"bitsandbytes not available for 4-bit load: {exc}")
|
| 62 |
+
bnb_compute_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
| 63 |
+
return BitsAndBytesConfig(
|
| 64 |
+
load_in_4bit=True,
|
| 65 |
+
bnb_4bit_compute_dtype=bnb_compute_dtype,
|
| 66 |
+
bnb_4bit_use_double_quant=True,
|
| 67 |
+
bnb_4bit_quant_type="nf4",
|
| 68 |
+
llm_int8_enable_fp32_cpu_offload=True,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _resolve_num_layers(config_obj: Any) -> int:
|
| 73 |
+
"""Extract layer count from model config with safe fallback."""
|
| 74 |
+
if config_obj is None:
|
| 75 |
+
return 62
|
| 76 |
+
val = getattr(config_obj, "num_hidden_layers", None)
|
| 77 |
+
if isinstance(val, int) and val > 0:
|
| 78 |
+
return val
|
| 79 |
+
text_cfg = getattr(config_obj, "text_config", None)
|
| 80 |
+
val = getattr(text_cfg, "num_hidden_layers", None)
|
| 81 |
+
if isinstance(val, int) and val > 0:
|
| 82 |
+
return val
|
| 83 |
+
return 62
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _manual_device_map(num_layers: int, gpu_layers: int) -> Dict[str, str]:
|
| 87 |
+
"""
|
| 88 |
+
Create deterministic layer placement for mixed GPU/CPU execution.
|
| 89 |
+
|
| 90 |
+
The first `gpu_layers` transformer blocks stay on GPU; remaining layers
|
| 91 |
+
plus final norm/head are offloaded to CPU.
|
| 92 |
+
"""
|
| 93 |
+
gpu_layers = max(0, min(int(gpu_layers), int(num_layers)))
|
| 94 |
+
dm: Dict[str, str] = {
|
| 95 |
+
"model.embed_tokens": "cuda:0",
|
| 96 |
+
"model.rotary_emb": "cuda:0",
|
| 97 |
+
"model.norm": "cpu",
|
| 98 |
+
"lm_head": "cpu",
|
| 99 |
+
}
|
| 100 |
+
for i in range(int(num_layers)):
|
| 101 |
+
dm[f"model.layers.{i}"] = "cuda:0" if i < gpu_layers else "cpu"
|
| 102 |
+
return dm
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _resolve_device_map_for_27b(
|
| 106 |
+
device_map: str | dict,
|
| 107 |
+
*,
|
| 108 |
+
resolved_snapshot: str,
|
| 109 |
+
local_files_only: bool,
|
| 110 |
+
) -> str | Dict[str, str]:
|
| 111 |
+
"""Normalize configured device map into a concrete mapping."""
|
| 112 |
+
if isinstance(device_map, dict):
|
| 113 |
+
return normalize_device_map(device_map)
|
| 114 |
+
if not isinstance(device_map, str):
|
| 115 |
+
return normalize_device_map(device_map)
|
| 116 |
+
value = device_map.strip()
|
| 117 |
+
mode = value.lower()
|
| 118 |
+
if mode.startswith("manual"):
|
| 119 |
+
config_obj = AutoConfig.from_pretrained(resolved_snapshot, local_files_only=local_files_only)
|
| 120 |
+
num_layers = _resolve_num_layers(config_obj)
|
| 121 |
+
gpu_layers = int(os.environ.get("MODEL_GPU_LAYERS_27B", "14"))
|
| 122 |
+
if ":" in mode:
|
| 123 |
+
try:
|
| 124 |
+
gpu_layers = int(mode.split(":", 1)[1].strip())
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
return _manual_device_map(num_layers, gpu_layers)
|
| 128 |
+
return normalize_device_map(value)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def load_model(
|
| 132 |
+
*,
|
| 133 |
+
snapshot: str | None = None,
|
| 134 |
+
device_map: str | dict = "auto",
|
| 135 |
+
dtype: torch.dtype | None = None,
|
| 136 |
+
max_memory: Dict[str, str] | None = None,
|
| 137 |
+
local_files_only: bool = True,
|
| 138 |
+
) -> tuple[Any, Any]:
|
| 139 |
+
"""
|
| 140 |
+
Load/reuse 27B model and tokenizer.
|
| 141 |
+
|
| 142 |
+
Reload triggers:
|
| 143 |
+
- Snapshot changed
|
| 144 |
+
- Device map changed
|
| 145 |
+
- Dtype changed
|
| 146 |
+
- Max-memory map changed
|
| 147 |
+
"""
|
| 148 |
+
global _MODEL, _TOKENIZER, _ACTIVE_SNAPSHOT, _ACTIVE_LOAD_SIGNATURE
|
| 149 |
+
if dtype is None:
|
| 150 |
+
dtype = _default_dtype()
|
| 151 |
+
resolved = resolve_snapshot(MODEL_ID, snapshot)
|
| 152 |
+
normalized_device_map = _resolve_device_map_for_27b(
|
| 153 |
+
device_map,
|
| 154 |
+
resolved_snapshot=resolved,
|
| 155 |
+
local_files_only=local_files_only,
|
| 156 |
+
)
|
| 157 |
+
memory_sig = tuple(sorted((max_memory or {}).items(), key=lambda kv: str(kv[0])))
|
| 158 |
+
load_sig = (str(dtype), str(normalized_device_map), memory_sig)
|
| 159 |
+
if (
|
| 160 |
+
_MODEL is not None
|
| 161 |
+
and _TOKENIZER is not None
|
| 162 |
+
and _ACTIVE_SNAPSHOT == resolved
|
| 163 |
+
and _ACTIVE_LOAD_SIGNATURE == load_sig
|
| 164 |
+
):
|
| 165 |
+
return _MODEL, _TOKENIZER
|
| 166 |
+
if _MODEL is not None or _TOKENIZER is not None:
|
| 167 |
+
unload_model()
|
| 168 |
+
|
| 169 |
+
quant_config = _load_quant_config()
|
| 170 |
+
attn_impl = (os.environ.get("MODEL_ATTN_IMPL_27B", "eager") or "").strip() or "eager"
|
| 171 |
+
model_kwargs: Dict[str, Any] = {
|
| 172 |
+
"torch_dtype": dtype,
|
| 173 |
+
"device_map": normalized_device_map,
|
| 174 |
+
"local_files_only": local_files_only,
|
| 175 |
+
"low_cpu_mem_usage": True,
|
| 176 |
+
"quantization_config": quant_config,
|
| 177 |
+
"offload_folder": os.environ.get("MODEL_OFFLOAD_DIR", "offload"),
|
| 178 |
+
# Avoid flash/SDPA kernel selection issues on older GPUs/offload mixes.
|
| 179 |
+
"attn_implementation": attn_impl,
|
| 180 |
+
}
|
| 181 |
+
if max_memory:
|
| 182 |
+
model_kwargs["max_memory"] = max_memory
|
| 183 |
+
|
| 184 |
+
_TOKENIZER = AutoTokenizer.from_pretrained(resolved, use_fast=True, local_files_only=local_files_only)
|
| 185 |
+
_MODEL = AutoModelForCausalLM.from_pretrained(resolved, **model_kwargs)
|
| 186 |
+
_MODEL.eval()
|
| 187 |
+
_ACTIVE_SNAPSHOT = resolved
|
| 188 |
+
_ACTIVE_LOAD_SIGNATURE = load_sig
|
| 189 |
+
return _MODEL, _TOKENIZER
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def unload_model() -> None:
|
| 193 |
+
"""Release 27B references and request CUDA cache cleanup."""
|
| 194 |
+
global _MODEL, _TOKENIZER, _ACTIVE_SNAPSHOT, _ACTIVE_LOAD_SIGNATURE
|
| 195 |
+
_MODEL = None
|
| 196 |
+
_TOKENIZER = None
|
| 197 |
+
_ACTIVE_SNAPSHOT = None
|
| 198 |
+
_ACTIVE_LOAD_SIGNATURE = None
|
| 199 |
+
gc.collect()
|
| 200 |
+
if torch.cuda.is_available():
|
| 201 |
+
torch.cuda.empty_cache()
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def generate(
|
| 205 |
+
prompt: str,
|
| 206 |
+
cfg: Dict[str, Any],
|
| 207 |
+
*,
|
| 208 |
+
snapshot: str | None = None,
|
| 209 |
+
device_map: str | dict = "auto",
|
| 210 |
+
max_memory: Dict[str, str] | None = None,
|
| 211 |
+
) -> str:
|
| 212 |
+
"""
|
| 213 |
+
Generate one assistant response with context-length and token safeguards.
|
| 214 |
+
|
| 215 |
+
This path caps input tokens for VRAM stability, then caps output tokens
|
| 216 |
+
against remaining model context budget.
|
| 217 |
+
"""
|
| 218 |
+
model, tokenizer = load_model(snapshot=snapshot, device_map=device_map, max_memory=max_memory)
|
| 219 |
+
|
| 220 |
+
# Keep prompt construction aligned with instruction chat fine-tuning.
|
| 221 |
+
messages = [{"role": "user", "content": prompt}]
|
| 222 |
+
prompt_text = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
| 223 |
+
inputs = tokenizer(prompt_text, return_tensors="pt")
|
| 224 |
+
# Keep context bounded for 27B to control KV-cache VRAM on 16GB GPUs.
|
| 225 |
+
try:
|
| 226 |
+
max_input_tokens = int(os.environ.get("MODEL_MAX_INPUT_TOKENS_27B", "2048"))
|
| 227 |
+
except Exception:
|
| 228 |
+
max_input_tokens = 2048
|
| 229 |
+
input_ids = inputs.get("input_ids")
|
| 230 |
+
if (
|
| 231 |
+
max_input_tokens > 0
|
| 232 |
+
and input_ids is not None
|
| 233 |
+
and input_ids.shape[-1] > max_input_tokens
|
| 234 |
+
):
|
| 235 |
+
start = input_ids.shape[-1] - max_input_tokens
|
| 236 |
+
for key in ("input_ids", "attention_mask"):
|
| 237 |
+
if key in inputs and inputs[key] is not None and inputs[key].shape[-1] > max_input_tokens:
|
| 238 |
+
inputs[key] = inputs[key][:, start:]
|
| 239 |
+
input_ids = inputs.get("input_ids")
|
| 240 |
+
input_device = pick_input_device(model)
|
| 241 |
+
inputs = {k: v.to(input_device) for k, v in inputs.items()}
|
| 242 |
+
|
| 243 |
+
# Preserve prompt token count so decode excludes the original prompt.
|
| 244 |
+
input_len = input_ids.shape[-1] if input_ids is not None else inputs["input_ids"].shape[-1]
|
| 245 |
+
model_max_len = resolve_model_max_length(model, tokenizer)
|
| 246 |
+
max_new_tokens = cap_new_tokens(cfg.get("tk"), input_len, model_max_len)
|
| 247 |
+
|
| 248 |
+
with torch.inference_mode():
|
| 249 |
+
out = model.generate(
|
| 250 |
+
**inputs,
|
| 251 |
+
max_new_tokens=max_new_tokens,
|
| 252 |
+
temperature=cfg.get("t"),
|
| 253 |
+
top_p=cfg.get("p"),
|
| 254 |
+
top_k=cfg.get("k"),
|
| 255 |
+
repetition_penalty=cfg.get("rep_penalty", 1.1),
|
| 256 |
+
do_sample=(cfg.get("t", 0) > 0),
|
| 257 |
+
pad_token_id=safe_pad_token_id(tokenizer),
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
response = tokenizer.decode(out[0][input_len:], skip_special_tokens=True)
|
| 261 |
+
return response.strip()
|
medgemma4.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Author: Rick Escher
|
| 3 |
+
# Project: SailingMedAdvisor
|
| 4 |
+
# Context: Google HAI-DEF Framework
|
| 5 |
+
# Models: Google MedGemmas
|
| 6 |
+
# Program: Kaggle Impact Challenge
|
| 7 |
+
# =============================================================================
|
| 8 |
+
"""
|
| 9 |
+
MedGemma 4B inference runner.
|
| 10 |
+
|
| 11 |
+
Design intent:
|
| 12 |
+
- Keep 4B loading fast and deterministic for triage-first workflows.
|
| 13 |
+
- Reuse one model/tokenizer instance per active snapshot to avoid repeated
|
| 14 |
+
GPU allocation churn between requests.
|
| 15 |
+
- Apply safety caps from runtime config before generation so user-provided
|
| 16 |
+
token settings cannot exceed model context limits.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from typing import Any, Dict
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
import gc
|
| 25 |
+
|
| 26 |
+
import torch
|
| 27 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 28 |
+
|
| 29 |
+
from medgemma_common import (
|
| 30 |
+
cap_new_tokens,
|
| 31 |
+
pick_input_device,
|
| 32 |
+
resolve_model_max_length,
|
| 33 |
+
resolve_snapshot,
|
| 34 |
+
safe_pad_token_id,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
MODEL_ID = "google/medgemma-1.5-4b-it"
|
| 38 |
+
|
| 39 |
+
_MODEL = None
|
| 40 |
+
_TOKENIZER = None
|
| 41 |
+
_ACTIVE_SNAPSHOT = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _default_dtype() -> torch.dtype:
|
| 45 |
+
"""Select a stable default dtype based on runtime hardware and flags."""
|
| 46 |
+
if os.environ.get("FORCE_FP16", "").strip() == "1":
|
| 47 |
+
return torch.float16
|
| 48 |
+
if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
|
| 49 |
+
return torch.bfloat16
|
| 50 |
+
if torch.cuda.is_available():
|
| 51 |
+
return torch.float16
|
| 52 |
+
return torch.float32
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def load_model(
|
| 56 |
+
*,
|
| 57 |
+
snapshot: str | None = None,
|
| 58 |
+
device_map: str | dict = "cuda:0",
|
| 59 |
+
dtype: torch.dtype | None = None,
|
| 60 |
+
attn_implementation: str | None = "eager",
|
| 61 |
+
local_files_only: bool = True,
|
| 62 |
+
) -> tuple[Any, Any]:
|
| 63 |
+
"""
|
| 64 |
+
Load or reuse the 4B model.
|
| 65 |
+
|
| 66 |
+
Reuse strategy:
|
| 67 |
+
- If snapshot path matches `_ACTIVE_SNAPSHOT`, return cached objects.
|
| 68 |
+
- Otherwise load tokenizer/model once and pin as active snapshot.
|
| 69 |
+
"""
|
| 70 |
+
global _MODEL, _TOKENIZER, _ACTIVE_SNAPSHOT
|
| 71 |
+
if dtype is None:
|
| 72 |
+
dtype = _default_dtype()
|
| 73 |
+
resolved = resolve_snapshot(MODEL_ID, snapshot)
|
| 74 |
+
if _MODEL is not None and _TOKENIZER is not None and _ACTIVE_SNAPSHOT == resolved:
|
| 75 |
+
return _MODEL, _TOKENIZER
|
| 76 |
+
|
| 77 |
+
model_kwargs: Dict[str, Any] = {
|
| 78 |
+
"torch_dtype": dtype,
|
| 79 |
+
"device_map": device_map,
|
| 80 |
+
"local_files_only": local_files_only,
|
| 81 |
+
"low_cpu_mem_usage": True,
|
| 82 |
+
}
|
| 83 |
+
if attn_implementation:
|
| 84 |
+
model_kwargs["attn_implementation"] = attn_implementation
|
| 85 |
+
|
| 86 |
+
_TOKENIZER = AutoTokenizer.from_pretrained(resolved, use_fast=True, local_files_only=local_files_only)
|
| 87 |
+
_MODEL = AutoModelForCausalLM.from_pretrained(resolved, **model_kwargs)
|
| 88 |
+
_MODEL.eval()
|
| 89 |
+
_ACTIVE_SNAPSHOT = resolved
|
| 90 |
+
return _MODEL, _TOKENIZER
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def unload_model() -> None:
|
| 94 |
+
"""Release model/tokenizer references and clear CUDA cache when present."""
|
| 95 |
+
global _MODEL, _TOKENIZER, _ACTIVE_SNAPSHOT
|
| 96 |
+
_MODEL = None
|
| 97 |
+
_TOKENIZER = None
|
| 98 |
+
_ACTIVE_SNAPSHOT = None
|
| 99 |
+
gc.collect()
|
| 100 |
+
if torch.cuda.is_available():
|
| 101 |
+
torch.cuda.empty_cache()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def generate(prompt: str, cfg: Dict[str, Any], *, snapshot: str | None = None, device_map: str | dict = "cuda:0") -> str:
|
| 105 |
+
"""
|
| 106 |
+
Generate one assistant response using chat-template formatting.
|
| 107 |
+
|
| 108 |
+
`cfg` keys consumed:
|
| 109 |
+
- `tk`: max new tokens target
|
| 110 |
+
- `t`: temperature
|
| 111 |
+
- `p`: top-p
|
| 112 |
+
- `k`: top-k
|
| 113 |
+
- `rep_penalty`: repetition penalty
|
| 114 |
+
"""
|
| 115 |
+
model, tokenizer = load_model(snapshot=snapshot, device_map=device_map)
|
| 116 |
+
|
| 117 |
+
# Use the tokenizer's chat template to keep prompt framing aligned with
|
| 118 |
+
# the instruction-tuned MedGemma 4B format.
|
| 119 |
+
messages = [{"role": "user", "content": prompt}]
|
| 120 |
+
prompt_text = tokenizer.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
|
| 121 |
+
inputs = tokenizer(prompt_text, return_tensors="pt")
|
| 122 |
+
input_ids = inputs.get("input_ids")
|
| 123 |
+
input_device = pick_input_device(model)
|
| 124 |
+
inputs = {k: v.to(input_device) for k, v in inputs.items()}
|
| 125 |
+
|
| 126 |
+
# Preserve prompt token length so we only decode newly generated tokens.
|
| 127 |
+
input_len = input_ids.shape[-1] if input_ids is not None else inputs["input_ids"].shape[-1]
|
| 128 |
+
model_max_len = resolve_model_max_length(model, tokenizer)
|
| 129 |
+
max_new_tokens = cap_new_tokens(cfg.get("tk"), input_len, model_max_len)
|
| 130 |
+
|
| 131 |
+
with torch.inference_mode():
|
| 132 |
+
out = model.generate(
|
| 133 |
+
**inputs,
|
| 134 |
+
max_new_tokens=max_new_tokens,
|
| 135 |
+
temperature=cfg.get("t"),
|
| 136 |
+
top_p=cfg.get("p"),
|
| 137 |
+
top_k=cfg.get("k"),
|
| 138 |
+
repetition_penalty=cfg.get("rep_penalty", 1.1),
|
| 139 |
+
do_sample=(cfg.get("t", 0) > 0),
|
| 140 |
+
pad_token_id=safe_pad_token_id(tokenizer),
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
response = tokenizer.decode(out[0][input_len:], skip_special_tokens=True)
|
| 144 |
+
return response.strip()
|
medgemma_common.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# Author: Rick Escher
|
| 3 |
+
# Project: SailingMedAdvisor
|
| 4 |
+
# Context: Google HAI-DEF Framework
|
| 5 |
+
# Models: Google MedGemmas
|
| 6 |
+
# Program: Kaggle Impact Challenge
|
| 7 |
+
# =============================================================================
|
| 8 |
+
"""
|
| 9 |
+
Shared helpers for MedGemma inference runners.
|
| 10 |
+
|
| 11 |
+
This module centralizes shared safeguards used by both 4B and 27B runners:
|
| 12 |
+
- Resolving a complete local snapshot from HF cache roots
|
| 13 |
+
- Guarding token lengths against model context limits
|
| 14 |
+
- Selecting stable padding and input device behavior across device maps
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Dict, List
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def safe_pad_token_id(tok):
|
| 28 |
+
"""Return a usable pad token ID fallback for generation APIs."""
|
| 29 |
+
pad = getattr(tok, "pad_token_id", None)
|
| 30 |
+
if pad is not None:
|
| 31 |
+
return pad
|
| 32 |
+
eos = getattr(tok, "eos_token_id", None)
|
| 33 |
+
if isinstance(eos, (list, tuple)):
|
| 34 |
+
return eos[0] if eos else None
|
| 35 |
+
return eos
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def iter_cache_roots() -> List[Path]:
|
| 39 |
+
"""Yield known HuggingFace cache roots, deduplicated and existing only."""
|
| 40 |
+
roots: List[Path] = []
|
| 41 |
+
env_cache = os.environ.get("HUGGINGFACE_HUB_CACHE")
|
| 42 |
+
if env_cache:
|
| 43 |
+
roots.append(Path(env_cache))
|
| 44 |
+
env_home = os.environ.get("HF_HOME")
|
| 45 |
+
if env_home:
|
| 46 |
+
roots.append(Path(env_home) / "hub")
|
| 47 |
+
roots.append(Path("/mnt/modelcache/models_cache/hub"))
|
| 48 |
+
roots.append(Path("/mnt/modelcache/hf_home/hub"))
|
| 49 |
+
seen = set()
|
| 50 |
+
final: List[Path] = []
|
| 51 |
+
for root in roots:
|
| 52 |
+
if root in seen:
|
| 53 |
+
continue
|
| 54 |
+
seen.add(root)
|
| 55 |
+
if root.is_dir():
|
| 56 |
+
final.append(root)
|
| 57 |
+
return final
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _snapshot_complete(snapshot: Path) -> bool:
|
| 61 |
+
"""Validate that a snapshot directory has complete safetensor files."""
|
| 62 |
+
index = snapshot / "model.safetensors.index.json"
|
| 63 |
+
if index.exists():
|
| 64 |
+
try:
|
| 65 |
+
data = json.loads(index.read_text())
|
| 66 |
+
except Exception:
|
| 67 |
+
return False
|
| 68 |
+
weight_map = data.get("weight_map") or {}
|
| 69 |
+
files = set(weight_map.values())
|
| 70 |
+
if not files:
|
| 71 |
+
return False
|
| 72 |
+
for name in files:
|
| 73 |
+
f = snapshot / name
|
| 74 |
+
if not f.exists():
|
| 75 |
+
return False
|
| 76 |
+
target = f.resolve()
|
| 77 |
+
if str(target).endswith(".incomplete"):
|
| 78 |
+
return False
|
| 79 |
+
return True
|
| 80 |
+
single = snapshot / "model.safetensors"
|
| 81 |
+
if single.exists():
|
| 82 |
+
target = single.resolve()
|
| 83 |
+
return not str(target).endswith(".incomplete")
|
| 84 |
+
return False
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _pick_latest_complete(snapshot_dir: Path, model_id: str) -> str:
|
| 88 |
+
"""Pick newest snapshot that passes completeness checks."""
|
| 89 |
+
if not snapshot_dir.is_dir():
|
| 90 |
+
raise FileNotFoundError(f"Snapshot dir not found for {model_id}: {snapshot_dir}")
|
| 91 |
+
snapshots = [d for d in snapshot_dir.iterdir() if d.is_dir()]
|
| 92 |
+
if not snapshots:
|
| 93 |
+
raise FileNotFoundError(f"No snapshots found for {model_id} in: {snapshot_dir}")
|
| 94 |
+
snapshots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
| 95 |
+
for snap in snapshots:
|
| 96 |
+
if _snapshot_complete(snap):
|
| 97 |
+
return str(snap)
|
| 98 |
+
raise FileNotFoundError(
|
| 99 |
+
f"Snapshots found for {model_id}, but weights are incomplete (.incomplete). "
|
| 100 |
+
f"Checked: {snapshot_dir}"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def resolve_snapshot(model_id: str, snapshot_override: str | None = None) -> str:
|
| 105 |
+
"""
|
| 106 |
+
Resolve a concrete snapshot path for local model loading.
|
| 107 |
+
|
| 108 |
+
Preference order:
|
| 109 |
+
1) Caller override path (direct snapshot or `.../snapshots`)
|
| 110 |
+
2) Latest complete snapshot from known cache roots
|
| 111 |
+
"""
|
| 112 |
+
if snapshot_override:
|
| 113 |
+
override = Path(snapshot_override)
|
| 114 |
+
if (override / "snapshots").is_dir():
|
| 115 |
+
return _pick_latest_complete(override / "snapshots", model_id)
|
| 116 |
+
if override.is_dir() and _snapshot_complete(override):
|
| 117 |
+
return str(override)
|
| 118 |
+
return snapshot_override
|
| 119 |
+
safe = model_id.replace("/", "--")
|
| 120 |
+
snapshots: List[Path] = []
|
| 121 |
+
for root in iter_cache_roots():
|
| 122 |
+
base = root / f"models--{safe}" / "snapshots"
|
| 123 |
+
if not base.is_dir():
|
| 124 |
+
continue
|
| 125 |
+
snapshots.extend([d for d in base.iterdir() if d.is_dir()])
|
| 126 |
+
if not snapshots:
|
| 127 |
+
raise FileNotFoundError(
|
| 128 |
+
f"No snapshots found for {model_id}. Checked: {', '.join(map(str, iter_cache_roots()))}"
|
| 129 |
+
)
|
| 130 |
+
snapshots.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
| 131 |
+
for snap in snapshots:
|
| 132 |
+
if _snapshot_complete(snap):
|
| 133 |
+
return str(snap)
|
| 134 |
+
raise FileNotFoundError(
|
| 135 |
+
f"Snapshots found for {model_id}, but weights are incomplete (.incomplete). "
|
| 136 |
+
f"Checked: {', '.join(map(str, iter_cache_roots()))}"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def resolve_model_max_length(model, tok=None):
|
| 141 |
+
"""Infer effective model context length from config/tokenizer metadata."""
|
| 142 |
+
cfg = getattr(model, "config", None)
|
| 143 |
+
candidates: List[int] = []
|
| 144 |
+
if cfg is not None:
|
| 145 |
+
for attr in ("max_position_embeddings", "max_seq_len", "max_sequence_length", "n_positions"):
|
| 146 |
+
val = getattr(cfg, attr, None)
|
| 147 |
+
if isinstance(val, int) and val > 0:
|
| 148 |
+
candidates.append(val)
|
| 149 |
+
text_cfg = getattr(cfg, "text_config", None)
|
| 150 |
+
if text_cfg is not None:
|
| 151 |
+
for attr in ("max_position_embeddings", "max_seq_len", "max_sequence_length", "n_positions"):
|
| 152 |
+
val = getattr(text_cfg, attr, None)
|
| 153 |
+
if isinstance(val, int) and val > 0:
|
| 154 |
+
candidates.append(val)
|
| 155 |
+
if tok is not None:
|
| 156 |
+
tok_max = getattr(tok, "model_max_length", None)
|
| 157 |
+
if isinstance(tok_max, int) and 0 < tok_max < 1_000_000_000:
|
| 158 |
+
candidates.append(tok_max)
|
| 159 |
+
return min(candidates) if candidates else None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def cap_new_tokens(max_new_tokens, input_len: int, model_max_len: int | None):
|
| 163 |
+
"""Clamp requested output tokens so prompt+response stay within context."""
|
| 164 |
+
if not isinstance(max_new_tokens, int):
|
| 165 |
+
return max_new_tokens
|
| 166 |
+
if model_max_len is None:
|
| 167 |
+
return max_new_tokens
|
| 168 |
+
if input_len >= model_max_len:
|
| 169 |
+
return 1
|
| 170 |
+
max_allowed = max(model_max_len - input_len - 1, 1)
|
| 171 |
+
return min(max_new_tokens, max_allowed)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def pick_input_device(model) -> str:
|
| 175 |
+
"""
|
| 176 |
+
Choose where prompt tensors should be placed before generation.
|
| 177 |
+
|
| 178 |
+
For partitioned/offloaded models, this prefers embedding-layer device.
|
| 179 |
+
"""
|
| 180 |
+
if hasattr(model, "hf_device_map"):
|
| 181 |
+
device_map = getattr(model, "hf_device_map") or {}
|
| 182 |
+
preferred = None
|
| 183 |
+
for name, dev in device_map.items():
|
| 184 |
+
if isinstance(dev, str) and dev.startswith("cuda"):
|
| 185 |
+
if "embed" in name:
|
| 186 |
+
return dev
|
| 187 |
+
preferred = dev
|
| 188 |
+
if preferred:
|
| 189 |
+
return preferred
|
| 190 |
+
return "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def normalize_device_map(device_map: str | Dict[str, str]):
|
| 194 |
+
"""Normalize common string forms into transformers-compatible device maps."""
|
| 195 |
+
if isinstance(device_map, str):
|
| 196 |
+
value = device_map.strip()
|
| 197 |
+
if value in {"auto", "balanced", "balanced_low_0", "sequential"}:
|
| 198 |
+
return value
|
| 199 |
+
if value.startswith("cuda") or value.startswith("cpu"):
|
| 200 |
+
return {"": value}
|
| 201 |
+
return device_map
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def device_map_all_cuda(device_map: str | Dict[str, str]) -> bool:
|
| 205 |
+
"""Return True when all mapped targets point to CUDA devices."""
|
| 206 |
+
if isinstance(device_map, str):
|
| 207 |
+
return device_map.strip().startswith("cuda")
|
| 208 |
+
if isinstance(device_map, dict):
|
| 209 |
+
return all(str(v).startswith("cuda") for v in device_map.values())
|
| 210 |
+
return False
|
medgemma_writeup.md
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SailingMedAdvisor
|
| 2 |
+
## Offline Emergency Decision Support for Offshore Crews Using MedGemma
|
| 3 |
+
|
| 4 |
+
**One-Sentence Summary:**
|
| 5 |
+
SailingMedAdvisor is a clinical decision-support system for offshore sailing vessels, powered by Google’s MedGemma models from the Health AI Developer Foundations (HAI-DEF), operating on edge hardware without reliable internet access.
|
| 6 |
+
|
| 7 |
+
### Project name
|
| 8 |
+
|
| 9 |
+
SailingMedAdvisor
|
| 10 |
+
|
| 11 |
+
### Your team: Rick Escher (solo project lead)
|
| 12 |
+
Captain of **s/v Aphrodite** since 2015, with direct operational responsibility for offshore safety, medical decision-making, and emergency logistics. Background in physics and space science (**M.S. in Space Science**). Former founder of **Recognia Inc.** (now TradingCentral.com).
|
| 13 |
+
|
| 14 |
+
**Role in this project:** problem definition, system architecture, product design, implementation, prompt pathway design, offline deployment/testing, and clinical workflow validation against real offshore scenarios.
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
### Problem statement
|
| 19 |
+
|
| 20 |
+
Offshore sailing vessels routinely operate hundreds or thousands of miles from the nearest hospital. During ocean passages there is no reliable internet connectivity, no rapid evacuation capability, and often no professional medical assistance available. The crew must manage traumatic injuries, infections, allergic reactions, burns, embedded objects, and other acute medical conditions using only the limited supplies stored onboard. Under the principles of SOLAS and longstanding maritime law, the captain bears ultimate responsibility for the health and safety of all persons aboard, including the provision of reasonable medical care within the vessel’s operational constraints.
|
| 21 |
+
|
| 22 |
+
Most AI healthcare tools assume constant internet access or centralized cloud infrastructure. That assumption fails in maritime, polar, wilderness, and disaster-response environments. In these settings, privacy and autonomy are equally critical: crew medical history should not leave the vessel, and treatment guidance must function entirely offline.
|
| 23 |
+
|
| 24 |
+
The core problem addressed by this project is:
|
| 25 |
+
|
| 26 |
+
> **Can a clinically capable large language model support structured emergency reasoning fully offline, on edge hardware, while respecting supply constraints, privacy, and distance from definitive care?**
|
| 27 |
+
|
| 28 |
+
SailingMedAdvisor demonstrates that MedGemma can operate in constrained, real-world, non-cloud environments and provide structured, context-aware decision support during medical events at sea.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
### Impact potential
|
| 33 |
+
|
| 34 |
+
In a local pilot snapshot (as of 2026-02-23), the system shows measurable operational impact for offshore decision support:
|
| 35 |
+
|
| 36 |
+
- **Sustained use, not one-off demo:** **114 completed consultations** were recorded locally (`109` on 4B, `5` on 27B).
|
| 37 |
+
- **Fast first-pass guidance option:** 4B averaged **102.2 seconds** per run, enabling near-real-time initial triage support.
|
| 38 |
+
- **Structured emergency framing:** retained consultation responses included an evacuation-state marker (`STAY`, `URGENT`, or `IMMEDIATE`) in **9/9** reviewed cases.
|
| 39 |
+
|
| 40 |
+
**Method:** metrics were computed from local SQLite tables (`chat_metrics`, `history_entries`) using run counts, duration fields, and response-text pattern checks. No external telemetry was used.
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
### Overall solution
|
| 45 |
+
|
| 46 |
+
SailingMedAdvisor integrates Google’s MedGemma models into a structured triage workflow that:
|
| 47 |
+
|
| 48 |
+
1. Runs entirely offline on a local machine (for this project a Lenovo P53 with an NVIDIA Quadro RTX 5000 GPU).
|
| 49 |
+
2. Uses a hierarchical clinical triage pathway to constrain reasoning.
|
| 50 |
+
3. Incorporates onboard medical inventory into the prompt.
|
| 51 |
+
4. Stores all patient and vessel data locally in SQLite.
|
| 52 |
+
5. Avoids network calls during inference.
|
| 53 |
+
|
| 54 |
+
The system supports two reasoning modes:
|
| 55 |
+
|
| 56 |
+
- **General Triage Mode:** Used when limited structured inputs are provided.
|
| 57 |
+
- **Pathway-Specific Mode:** Activated when structured triage inputs are provided.
|
| 58 |
+
|
| 59 |
+
When structured inputs are supplied, the system dynamically assembles a pathway-specific prompt composed of modular clinical instruction blocks. This increases reasoning specificity and reduces generic or irrelevant recommendations.
|
| 60 |
+
|
| 61 |
+
Inference is performed locally using:
|
| 62 |
+
|
| 63 |
+
- `google/medgemma-1.5-4b-it`
|
| 64 |
+
- `google/medgemma-27b-text-it`
|
| 65 |
+
|
| 66 |
+
No cloud APIs are used during runtime.
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
### Technical details
|
| 71 |
+
|
| 72 |
+
## Inputs and Data Handling
|
| 73 |
+
|
| 74 |
+
There is no external dataset. The system operates on structured and contextual inputs including:
|
| 75 |
+
|
| 76 |
+
- Triage dropdown selections
|
| 77 |
+
- Patient condition indicators (Consciousness, Breathing, Circulation, Stability)
|
| 78 |
+
- Crew medical history
|
| 79 |
+
- Vessel-specific operational constraints (crew size, sea state, power, environment, evacuation feasibility)
|
| 80 |
+
- Onboard medical inventory (medications, procedural supplies, monitoring tools)
|
| 81 |
+
- Distance from definitive care
|
| 82 |
+
|
| 83 |
+
All data is stored locally in `app.db` (SQLite).
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## Prompt Construction Strategy
|
| 88 |
+
|
| 89 |
+
Act as a Trauma Surgeon. You are in Trauma Mode. Rule out physiological failure before addressing anatomical appearance. Do not provide wound care instructions until you have confirmed Airway, Breathing, and Circulation (ABC) stability.
|
| 90 |
+
The system now uses a hierarchical prompt assembly approach:
|
| 91 |
+
|
| 92 |
+
1. User selects structured triage categories:
|
| 93 |
+
- Domain (Trauma, Medical Illness, Environmental, Dental, Psychological)
|
| 94 |
+
- Problem Type
|
| 95 |
+
- Anatomy
|
| 96 |
+
- Mechanism/Cause
|
| 97 |
+
- Severity/Complication
|
| 98 |
+
|
| 99 |
+
2. The system maps selections to predefined clinical prompt modules.
|
| 100 |
+
|
| 101 |
+
3. Modules are combined with:
|
| 102 |
+
- Structured inventory constraints (pharmaceuticals, equipment, consumables)
|
| 103 |
+
- Distance-from-care context
|
| 104 |
+
- Structured emergency safety framework (airway, breathing, bleeding checks, and evacuation thresholds)
|
| 105 |
+
- Clarifying question templates
|
| 106 |
+
|
| 107 |
+
4. If the pathway is incomplete, the system falls back to a general triage framework.
|
| 108 |
+
|
| 109 |
+
This structured assembly improves:
|
| 110 |
+
|
| 111 |
+
- Clinical specificity
|
| 112 |
+
- Operational alignment
|
| 113 |
+
- Supply awareness
|
| 114 |
+
- Reduction of generic responses
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
### Inventory Constraints
|
| 118 |
+
|
| 119 |
+
Onboard medical inventory is structured into three operational categories:
|
| 120 |
+
|
| 121 |
+
1. **Pharmaceuticals** – antibiotics, analgesics, antiemetics, epinephrine, antiparasitics, and controlled medications.
|
| 122 |
+
2. **Equipment** – suturing kits, irrigation devices, splints, monitoring tools, dental repair materials, and procedural instruments.
|
| 123 |
+
3. **Consumables** – gauze, sterile saline, gloves, dressings, syringes, and other expendable supplies.
|
| 124 |
+
|
| 125 |
+
Recommendations are constrained to what is physically available onboard. This prevents unrealistic or non-executable guidance and ensures that suggested interventions are operationally feasible in a remote maritime environment.
|
| 126 |
+
|
| 127 |
+
Because vessels routinely enter new jurisdictions, controlled medications must be tracked, declared, and reconciled for customs and immigration authorities. The system includes structured controlled-substance logging not only for clinical accountability but also to support regulatory compliance during international clearance procedures.
|
| 128 |
+
|
| 129 |
+
#### Operational Continuity and System Reliability
|
| 130 |
+
|
| 131 |
+
Additional vessel and crew management features were integrated intentionally. The system includes:
|
| 132 |
+
|
| 133 |
+
- Vessel documentation records
|
| 134 |
+
- Crew list management including passport information, vaccine history and photographs
|
| 135 |
+
- Controlled medicine logs
|
| 136 |
+
- Export tools for immigration and customs documentation
|
| 137 |
+
|
| 138 |
+
These capabilities serve dual purposes.
|
| 139 |
+
|
| 140 |
+
First, they support real operational requirements during international clearance.
|
| 141 |
+
|
| 142 |
+
Second, they ensure the software is used regularly between medical events. In remote environments, infrequently used computer systems risk becoming outdated or misconfigured. By embedding high-frequency operational tasks into the platform, SailingMedAdvisor remains active, validated, and familiar to users. This reduces startup friction and increases reliability during times of actual emergency.
|
| 143 |
+
|
| 144 |
+
Together, structured pathway logic, inventory constraint modeling, stabilization scaffolding, and operational integration allow MedGemma to function as a context-aware clinical reasoning engine within the practical realities of offshore sailing.
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
## Model Integration
|
| 148 |
+
|
| 149 |
+
MedGemma is loaded using Hugging Face Transformers with runtime-selectable parameters:
|
| 150 |
+
|
| 151 |
+
- Temperature
|
| 152 |
+
- Top-p
|
| 153 |
+
- Top-k
|
| 154 |
+
- Max new tokens
|
| 155 |
+
|
| 156 |
+
The inference adapters (`medgemma4.py`, `medgemma27b.py`), with shared helpers in `medgemma_common.py`, standardize prompt formatting, device handling, and token budgeting for consistent behavior. When sampling is enabled, outputs are not strictly deterministic.
|
| 157 |
+
|
| 158 |
+
Startup logic performs:
|
| 159 |
+
|
| 160 |
+
- CUDA preflight validation
|
| 161 |
+
- Configurable GPU memory caps and placement constraints
|
| 162 |
+
- Optional CPU fallback control (disabled by default)
|
| 163 |
+
- Explicit failure if GPU is unavailable when `FORCE_CUDA=1`
|
| 164 |
+
|
| 165 |
+
Inference runs locally in edge deployment mode. A separate remote-inference path exists only when local inference is explicitly disabled (for example, hosted mode).
|
| 166 |
+
|
| 167 |
+
---
|
| 168 |
+
|
| 169 |
+
### Reproducibility (initial results)
|
| 170 |
+
|
| 171 |
+
- Public repository: https://github.com/rickeae/SailingMedAdvisor
|
| 172 |
+
- Pinned code snapshot for this submission: `de93f406d161b832482494b9c90b4f2578e3a85a`
|
| 173 |
+
- Models used: `google/medgemma-1.5-4b-it`, `google/medgemma-27b-text-it`
|
| 174 |
+
|
| 175 |
+
#### Setup and run
|
| 176 |
+
|
| 177 |
+
```bash
|
| 178 |
+
git clone https://github.com/rickeae/SailingMedAdvisor.git
|
| 179 |
+
cd SailingMedAdvisor
|
| 180 |
+
git checkout de93f406d161b832482494b9c90b4f2578e3a85a
|
| 181 |
+
python3 -m venv .venv
|
| 182 |
+
source .venv/bin/activate
|
| 183 |
+
pip install -r requirements.txt
|
| 184 |
+
chmod +x run_med_advisor.sh
|
| 185 |
+
./run_med_advisor.sh
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
Open: `http://127.0.0.1:5000`
|
| 189 |
+
|
| 190 |
+
#### Reproduce the demo scenario
|
| 191 |
+
|
| 192 |
+
1. Select **Triage Consultation** mode.
|
| 193 |
+
2. Select model: **google/medgemma-27b-text-it**.
|
| 194 |
+
3. Enter the fish-hook cheek scenario shown in the demo.
|
| 195 |
+
4. Select the matching clinical pathway values shown in the demo.
|
| 196 |
+
5. Submit and compare the returned guidance structure to the video.
|
| 197 |
+
|
| 198 |
+
---
|
| 199 |
+
|
| 200 |
+
# Architecture & Components
|
| 201 |
+
|
| 202 |
+
## High-Level Workflow
|
| 203 |
+
|
| 204 |
+
1. Captain/crew enters scenario details and optional triage pathway selections.
|
| 205 |
+
2. System assembles a mode-specific prompt (general triage or pathway-specific triage).
|
| 206 |
+
3. Prompt is augmented with onboard inventory and relevant vessel/crew context.
|
| 207 |
+
4. MedGemma runs locally and returns structured guidance.
|
| 208 |
+
5. Result is displayed and optionally stored in the consultation log for replay/review.
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## Core Components
|
| 213 |
+
|
| 214 |
+
**Backend**
|
| 215 |
+
- Python
|
| 216 |
+
- FastAPI
|
| 217 |
+
- SQLite
|
| 218 |
+
|
| 219 |
+
**Models**
|
| 220 |
+
- MedGemma 1.5 4B
|
| 221 |
+
- MedGemma 27B
|
| 222 |
+
|
| 223 |
+
**Frontend**
|
| 224 |
+
- HTML templates
|
| 225 |
+
- JavaScript
|
| 226 |
+
- Structured triage dropdowns
|
| 227 |
+
|
| 228 |
+
**Inference Pipeline**
|
| 229 |
+
- Hugging Face Transformers
|
| 230 |
+
- CUDA acceleration
|
| 231 |
+
- Parameterized sampling
|
| 232 |
+
|
| 233 |
+
**Edge Constraints**
|
| 234 |
+
- No external API calls in edge deployment mode
|
| 235 |
+
- No telemetry
|
| 236 |
+
- No remote logging
|
| 237 |
+
- Fully local state persistence
|
| 238 |
+
|
| 239 |
+
---
|
| 240 |
+
|
| 241 |
+
# Demo and Results
|
| 242 |
+
|
| 243 |
+
The demonstration presents a real offshore scenario: a barbed fish hook embedded in a child’s cheek in a remote harbor in the Solomon Islands. Patient identifiers are fictionalized, but the medical scenario reflects an actual offshore event.
|
| 244 |
+
|
| 245 |
+
Observed system behaviors:
|
| 246 |
+
|
| 247 |
+
- Network disabled during inference.
|
| 248 |
+
- GPU utilization rises to 100%, confirming local execution.
|
| 249 |
+
- MedGemma returns:
|
| 250 |
+
- Structured triage summary
|
| 251 |
+
- Initial assessment
|
| 252 |
+
- Stabilization plan
|
| 253 |
+
- Procedural considerations
|
| 254 |
+
- Clarifying questions
|
| 255 |
+
|
| 256 |
+
When the structured pathway is selected:
|
| 257 |
+
|
| 258 |
+
The generated guidance becomes more anatomy-specific, mechanism-aware, and inventory-constrained than in generic triage mode, reducing irrelevant recommendations.
|
| 259 |
+
|
| 260 |
+
The resulting guidance is anatomy-specific, operationally constrained, and supply-aware.
|
| 261 |
+
|
| 262 |
+
Evaluation is qualitative and scenario-based, focusing on:
|
| 263 |
+
|
| 264 |
+
- Structured output consistency
|
| 265 |
+
- Offline reliability
|
| 266 |
+
- Clinical coherence
|
| 267 |
+
- Operational realism
|
| 268 |
+
|
| 269 |
+
### Measured feasibility results (pilot)
|
| 270 |
+
|
| 271 |
+
| Metric | 4B (`google/medgemma-1.5-4b-it`) | 27B (`google/medgemma-27b-text-it`) | Measurement source |
|
| 272 |
+
| --- | ---: | ---: | --- |
|
| 273 |
+
| Logged completed runs | 109 | 5 | `chat_metrics.count` |
|
| 274 |
+
| Mean response time | 102.2 s | 2,904.9 s (48.4 min) | `chat_metrics.avg_ms` |
|
| 275 |
+
| Retained-log median response time | 53.4 s (n=7) | 3,476.6 s (n=1) | `history_entries.duration_ms` |
|
| 276 |
+
| Retained-log P95 response time | 109.4 s (n=7) | 3,476.6 s (n=1) | `history_entries.duration_ms` |
|
| 277 |
+
| Non-empty response rate (retained log) | 7/7 (100%) | 2/2 (100%) | `history_entries.response` |
|
| 278 |
+
|
| 279 |
+
**Method note:** `history_entries` currently contains a pruned subset (9 records). The `chat_metrics` table captures aggregate throughput across a larger run set.
|
| 280 |
+
|
| 281 |
+
The system demonstrates that MedGemma can perform context-constrained clinical reasoning entirely at the edge.
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
|
| 285 |
+
# Final Model Description
|
| 286 |
+
|
| 287 |
+
The final configuration uses:
|
| 288 |
+
|
| 289 |
+
- MedGemma 1.5 4B for lightweight deployment
|
| 290 |
+
- MedGemma 27B for higher-fidelity reasoning
|
| 291 |
+
- No fine-tuning
|
| 292 |
+
- Hierarchical prompt assembly
|
| 293 |
+
- Controlled generation parameters
|
| 294 |
+
|
| 295 |
+
Validation strategy:
|
| 296 |
+
|
| 297 |
+
- Structured vs unstructured prompt comparison
|
| 298 |
+
- Pathway-specific vs general reasoning evaluation
|
| 299 |
+
- Multiple real-world offshore case simulations
|
| 300 |
+
- GPU-only execution validation
|
| 301 |
+
|
| 302 |
+
There is no leaderboard dataset; evaluation focuses on feasibility, reproducibility, and real-world applicability.
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
# Sources / References
|
| 307 |
+
|
| 308 |
+
- Google Health AI Developer Foundations (HAI-DEF)
|
| 309 |
+
- MedGemma model collection (Hugging Face)
|
| 310 |
+
- Hugging Face Transformers
|
| 311 |
+
- FastAPI documentation
|
| 312 |
+
- SQLite documentation
|
| 313 |
+
- CUDA runtime documentation
|
| 314 |
+
- Public code repository: https://github.com/rickeae/SailingMedAdvisor
|
| 315 |
+
|
| 316 |
+
All models are used in accordance with HAI-DEF Terms of Use.
|
| 317 |
+
|
| 318 |
+
---
|
| 319 |
+
|
| 320 |
+
# Future Work
|
| 321 |
+
|
| 322 |
+
Planned enhancements include:
|
| 323 |
+
|
| 324 |
+
- Structured risk scoring overlay
|
| 325 |
+
- Dosage calculation module
|
| 326 |
+
- Formal evacuation threshold classifier
|
| 327 |
+
- Offline PDF export of consultations
|
| 328 |
+
- Multilingual support
|
| 329 |
+
- Model distillation for lower-power hardware
|
| 330 |
+
- Structured evaluation against maritime medical manuals
|
| 331 |
+
|
| 332 |
+
Long-term applications extend beyond sailing vessels to:
|
| 333 |
+
|
| 334 |
+
- Remote research stations
|
| 335 |
+
- Disaster response environments
|
| 336 |
+
- Wilderness expeditions
|
| 337 |
+
- Rural mobile clinics
|
| 338 |
+
|
| 339 |
+
---
|
| 340 |
+
|
| 341 |
+
# Conclusion
|
| 342 |
+
|
| 343 |
+
SailingMedAdvisor demonstrates that MedGemma can operate as a structured clinical decision-support engine entirely at the edge.
|
| 344 |
+
|
| 345 |
+
The system runs fully offline, keeps all patient data local, integrates operational constraints, and delivers structured medical reasoning tailored to offshore emergencies.
|
| 346 |
+
|
| 347 |
+
This project shows that high-fidelity medical AI does not require cloud infrastructure to be effective. In constrained environments where internet access is unavailable and evacuation may be delayed, MedGemma can function as a privacy-preserving, operationally grounded clinical reasoning system.
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
jinja2
|
| 4 |
+
python-multipart
|
| 5 |
+
aiofiles
|
| 6 |
+
pillow
|
| 7 |
+
torch
|
| 8 |
+
transformers
|
| 9 |
+
bitsandbytes
|
| 10 |
+
accelerate
|
| 11 |
+
safetensors
|
| 12 |
+
huggingface-hub
|
| 13 |
+
itsdangerous
|
run_med_advisor.sh
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
# run_med_advisor.sh - Secure startup script for MedGemma Advisor
|
| 10 |
+
|
| 11 |
+
echo "=================================================="
|
| 12 |
+
echo "SailingMeAdvisor - Offline emergency medical guidance for offshore sailors,"
|
| 13 |
+
echo "powered by MedGemma (HAI-DEF)"
|
| 14 |
+
echo ""
|
| 15 |
+
echo "=================================================="
|
| 16 |
+
|
| 17 |
+
# Check if virtual environment exists
|
| 18 |
+
if [ ! -d ".venv" ]; then
|
| 19 |
+
echo "❌ Error: Virtual environment not found!"
|
| 20 |
+
echo "Please create it first: python3 -m venv .venv"
|
| 21 |
+
exit 1
|
| 22 |
+
fi
|
| 23 |
+
|
| 24 |
+
# Activate virtual environment
|
| 25 |
+
source .venv/bin/activate
|
| 26 |
+
|
| 27 |
+
# Check if required packages are installed
|
| 28 |
+
python3 -c "import fastapi, uvicorn" 2>/dev/null || {
|
| 29 |
+
echo "❌ Error: FastAPI or Uvicorn not installed. Install with: pip install fastapi uvicorn[standard]"
|
| 30 |
+
exit 1
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# Set environment variables (can be customized)
|
| 34 |
+
# export ADMIN_PASSWORD='your_secure_password'
|
| 35 |
+
# export SECRET_KEY='your_secret_key'
|
| 36 |
+
# Prefer BF16 for stability; set FORCE_FP16=1 (and ALLOW_FP16=1) to override.
|
| 37 |
+
# Respect user override; default to 0 to prefer BF16 on supported GPUs.
|
| 38 |
+
export FORCE_FP16="${FORCE_FP16:-0}"
|
| 39 |
+
# Keep SDP kernels conservative on RTX 5000/Turing; opt in to fast kernels manually.
|
| 40 |
+
export USE_FAST_SDP="${USE_FAST_SDP:-0}"
|
| 41 |
+
# Tab bar theme toggle:
|
| 42 |
+
# 1 = splash purple (#7452B9), 0 = default gray.
|
| 43 |
+
export USE_SPLASH_PURPLE_TABBAR="${USE_SPLASH_PURPLE_TABBAR:-0}"
|
| 44 |
+
# Legacy env retained for compatibility with any existing checks.
|
| 45 |
+
export USE_FLASH_ATTENTION="${USE_FLASH_ATTENTION:-$USE_FAST_SDP}"
|
| 46 |
+
export TORCH_USE_CUDA_DSA=0
|
| 47 |
+
# Choose a safe default for mixed hardware:
|
| 48 |
+
# - If user explicitly sets FORCE_CUDA, honor it.
|
| 49 |
+
# - If unset, prefer GPU only when NVIDIA tooling is present.
|
| 50 |
+
if [ -z "${FORCE_CUDA+x}" ]; then
|
| 51 |
+
if command -v nvidia-smi >/dev/null 2>&1; then
|
| 52 |
+
export FORCE_CUDA="1"
|
| 53 |
+
else
|
| 54 |
+
export FORCE_CUDA="0"
|
| 55 |
+
fi
|
| 56 |
+
else
|
| 57 |
+
export FORCE_CUDA
|
| 58 |
+
fi
|
| 59 |
+
# Keep GPU-only behavior by default; set to 1 only if we explicitly want CPU fallback on CUDA runtime faults.
|
| 60 |
+
export ALLOW_CPU_FALLBACK_ON_CUDA_ERROR="${ALLOW_CPU_FALLBACK_ON_CUDA_ERROR:-0}"
|
| 61 |
+
# Keep global cap high for 4B but reserve headroom for 27B KV cache.
|
| 62 |
+
export MODEL_MAX_GPU_MEM="${MODEL_MAX_GPU_MEM:-15GiB}"
|
| 63 |
+
export MODEL_MAX_GPU_MEM_27B="${MODEL_MAX_GPU_MEM_27B:-8GiB}"
|
| 64 |
+
export MODEL_MAX_CPU_MEM=64GiB
|
| 65 |
+
# 0 disables hard cap so token count comes from Settings (tr_tok/in_tok).
|
| 66 |
+
export MODEL_MAX_NEW_TOKENS_27B="${MODEL_MAX_NEW_TOKENS_27B:-0}"
|
| 67 |
+
export MODEL_MAX_INPUT_TOKENS_27B="${MODEL_MAX_INPUT_TOKENS_27B:-2048}"
|
| 68 |
+
export MODEL_DEVICE_MAP_27B="${MODEL_DEVICE_MAP_27B:-manual}"
|
| 69 |
+
export MODEL_GPU_LAYERS_27B="${MODEL_GPU_LAYERS_27B:-14}"
|
| 70 |
+
export MODEL_ATTN_IMPL_27B="${MODEL_ATTN_IMPL_27B:-eager}"
|
| 71 |
+
# Reduce allocator fragmentation on long sessions.
|
| 72 |
+
export PYTORCH_CUDA_ALLOC_CONF="${PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}"
|
| 73 |
+
|
| 74 |
+
# CUDA preflight: fail early when FORCE_CUDA=1 so we don't silently run on CPU.
|
| 75 |
+
if [ "$FORCE_CUDA" = "1" ]; then
|
| 76 |
+
echo "🔎 CUDA preflight (FORCE_CUDA=1)"
|
| 77 |
+
python3 - <<'PY'
|
| 78 |
+
import sys
|
| 79 |
+
import torch
|
| 80 |
+
|
| 81 |
+
if not torch.cuda.is_available():
|
| 82 |
+
print("❌ CUDA preflight failed: torch.cuda.is_available() is False")
|
| 83 |
+
try:
|
| 84 |
+
torch.cuda.current_device()
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
print(f" CUDA error: {exc}")
|
| 87 |
+
sys.exit(1)
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
_ = torch.zeros(1, device="cuda")
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
print(f"❌ CUDA preflight failed during tensor allocation: {exc}")
|
| 93 |
+
sys.exit(1)
|
| 94 |
+
|
| 95 |
+
print(f"✅ CUDA preflight passed on GPU: {torch.cuda.get_device_name(0)}")
|
| 96 |
+
PY
|
| 97 |
+
if [ $? -ne 0 ]; then
|
| 98 |
+
echo "Hint: check kernel GPU errors with: journalctl -k | grep -i -E 'NVRM|Xid'"
|
| 99 |
+
echo "If errors persist, reboot or reload NVIDIA driver modules before restarting SailingMedAdvisor."
|
| 100 |
+
exit 1
|
| 101 |
+
fi
|
| 102 |
+
fi
|
| 103 |
+
|
| 104 |
+
# Detect a LAN IP to share in the startup banner (best effort)
|
| 105 |
+
LAN_IP=$(hostname -I 2>/dev/null | awk 'NF{print $1; exit}')
|
| 106 |
+
if [ -z "$LAN_IP" ] && command -v ip >/dev/null 2>&1; then
|
| 107 |
+
LAN_IP=$(ip route get 8.8.8.8 2>/dev/null | awk 'NR==1 {print $7}')
|
| 108 |
+
fi
|
| 109 |
+
|
| 110 |
+
# Run the application
|
| 111 |
+
echo "🚀 Starting server on http://127.0.0.1:5000"
|
| 112 |
+
if [ -n "$LAN_IP" ]; then
|
| 113 |
+
echo "🌐 LAN access: http://${LAN_IP}:5000"
|
| 114 |
+
else
|
| 115 |
+
echo "🌐 LAN access: http://<this-machine-ip>:5000"
|
| 116 |
+
fi
|
| 117 |
+
echo "=================================================="
|
| 118 |
+
python3 -m uvicorn app:app --host 0.0.0.0 --port 5000
|
scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
# scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 10 |
+
#
|
| 11 |
+
# Purpose:
|
| 12 |
+
# End-to-end bootstrap for a clean Ubuntu 24.04 machine:
|
| 13 |
+
# 1) install required system packages
|
| 14 |
+
# 2) clone SailingMedAdvisor anonymously from GitHub
|
| 15 |
+
# 3) run project install script
|
| 16 |
+
# 4) run fresh-install verification
|
| 17 |
+
# 5) optionally start the app
|
| 18 |
+
#
|
| 19 |
+
# Usage:
|
| 20 |
+
# chmod +x scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 21 |
+
# ./scripts/bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 22 |
+
#
|
| 23 |
+
# Optional flags:
|
| 24 |
+
# --target <dir> Install directory (default: $HOME/SailingMedAdvisor)
|
| 25 |
+
# --branch <name> Git branch (default: main)
|
| 26 |
+
# --repo-url <url> Repo URL (default: public GitHub URL)
|
| 27 |
+
# --skip-system-packages Skip apt install step
|
| 28 |
+
# --start Start app after verification
|
| 29 |
+
# --prefer-gpu-start Prefer GPU settings when starting app (default is CPU-safe start)
|
| 30 |
+
# --force-cuda <0|1> Set FORCE_CUDA explicitly when starting app
|
| 31 |
+
# --help Show usage
|
| 32 |
+
|
| 33 |
+
set -euo pipefail
|
| 34 |
+
|
| 35 |
+
REPO_URL="https://github.com/rickeae/SailingMedAdvisor.git"
|
| 36 |
+
BRANCH="main"
|
| 37 |
+
TARGET_DIR="${HOME}/SailingMedAdvisor"
|
| 38 |
+
SKIP_SYSTEM_PACKAGES="0"
|
| 39 |
+
START_APP="0"
|
| 40 |
+
PREFER_GPU_START="0"
|
| 41 |
+
FORCE_CUDA_OVERRIDE=""
|
| 42 |
+
|
| 43 |
+
usage() {
|
| 44 |
+
cat <<'EOF'
|
| 45 |
+
bootstrap_ubuntu24_sailingmedadvisor.sh
|
| 46 |
+
|
| 47 |
+
Flags:
|
| 48 |
+
--target <dir> Install directory (default: $HOME/SailingMedAdvisor)
|
| 49 |
+
--branch <name> Git branch (default: main)
|
| 50 |
+
--repo-url <url> Repo URL (default: https://github.com/rickeae/SailingMedAdvisor.git)
|
| 51 |
+
--skip-system-packages Skip apt package installation
|
| 52 |
+
--start Start app after successful verification
|
| 53 |
+
--prefer-gpu-start Prefer GPU startup flags (default start is CPU-safe)
|
| 54 |
+
--force-cuda <0|1> Force FORCE_CUDA value when starting app
|
| 55 |
+
--help Show this help text
|
| 56 |
+
EOF
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
while [[ $# -gt 0 ]]; do
|
| 60 |
+
case "$1" in
|
| 61 |
+
--target)
|
| 62 |
+
TARGET_DIR="$2"; shift 2 ;;
|
| 63 |
+
--branch)
|
| 64 |
+
BRANCH="$2"; shift 2 ;;
|
| 65 |
+
--repo-url)
|
| 66 |
+
REPO_URL="$2"; shift 2 ;;
|
| 67 |
+
--skip-system-packages)
|
| 68 |
+
SKIP_SYSTEM_PACKAGES="1"; shift ;;
|
| 69 |
+
--start)
|
| 70 |
+
START_APP="1"; shift ;;
|
| 71 |
+
--prefer-gpu-start)
|
| 72 |
+
PREFER_GPU_START="1"; shift ;;
|
| 73 |
+
--force-cuda)
|
| 74 |
+
FORCE_CUDA_OVERRIDE="$2"; shift 2 ;;
|
| 75 |
+
--help|-h)
|
| 76 |
+
usage; exit 0 ;;
|
| 77 |
+
*)
|
| 78 |
+
echo "Unknown argument: $1"
|
| 79 |
+
usage
|
| 80 |
+
exit 1 ;;
|
| 81 |
+
esac
|
| 82 |
+
done
|
| 83 |
+
|
| 84 |
+
run_as_root() {
|
| 85 |
+
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
| 86 |
+
"$@"
|
| 87 |
+
return
|
| 88 |
+
fi
|
| 89 |
+
if command -v sudo >/dev/null 2>&1; then
|
| 90 |
+
sudo "$@"
|
| 91 |
+
return
|
| 92 |
+
fi
|
| 93 |
+
echo "ERROR: Need root or sudo to run: $*"
|
| 94 |
+
exit 1
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
install_system_packages() {
|
| 98 |
+
if [[ "$SKIP_SYSTEM_PACKAGES" == "1" ]]; then
|
| 99 |
+
echo "[info] Skipping apt package installation (--skip-system-packages)"
|
| 100 |
+
return
|
| 101 |
+
fi
|
| 102 |
+
if ! command -v apt-get >/dev/null 2>&1; then
|
| 103 |
+
echo "[warn] apt-get not found; skipping package install step."
|
| 104 |
+
return
|
| 105 |
+
fi
|
| 106 |
+
echo "[step] Installing system packages (git, python3, venv, pip, certs)"
|
| 107 |
+
run_as_root apt-get update
|
| 108 |
+
run_as_root apt-get install -y git python3 python3-venv python3-pip ca-certificates
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
clone_or_update_repo() {
|
| 112 |
+
echo "[step] Cloning/updating repository"
|
| 113 |
+
if [[ -d "$TARGET_DIR/.git" ]]; then
|
| 114 |
+
echo "[info] Existing repo found at $TARGET_DIR"
|
| 115 |
+
git -C "$TARGET_DIR" fetch --all --tags
|
| 116 |
+
git -C "$TARGET_DIR" checkout "$BRANCH"
|
| 117 |
+
git -C "$TARGET_DIR" pull --ff-only
|
| 118 |
+
return
|
| 119 |
+
fi
|
| 120 |
+
if [[ -e "$TARGET_DIR" && ! -d "$TARGET_DIR/.git" ]]; then
|
| 121 |
+
echo "ERROR: Target exists but is not a git repo: $TARGET_DIR"
|
| 122 |
+
exit 1
|
| 123 |
+
fi
|
| 124 |
+
git clone --branch "$BRANCH" "$REPO_URL" "$TARGET_DIR"
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
install_project() {
|
| 128 |
+
echo "[step] Running project installer"
|
| 129 |
+
cd "$TARGET_DIR"
|
| 130 |
+
chmod +x scripts/install_fresh_copy.sh
|
| 131 |
+
./scripts/install_fresh_copy.sh --skip-clone
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
verify_project() {
|
| 135 |
+
echo "[step] Running verification"
|
| 136 |
+
cd "$TARGET_DIR"
|
| 137 |
+
./.venv/bin/python scripts/verify_fresh_install.py
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
resolve_force_cuda() {
|
| 141 |
+
if [[ -n "$FORCE_CUDA_OVERRIDE" ]]; then
|
| 142 |
+
echo "$FORCE_CUDA_OVERRIDE"
|
| 143 |
+
return
|
| 144 |
+
fi
|
| 145 |
+
# Default to CPU-safe startup for reproducibility across unknown machines.
|
| 146 |
+
# This avoids failing on hosts with partial/broken GPU drivers.
|
| 147 |
+
if [[ "$PREFER_GPU_START" != "1" ]]; then
|
| 148 |
+
echo "0"
|
| 149 |
+
return
|
| 150 |
+
fi
|
| 151 |
+
if command -v nvidia-smi >/dev/null 2>&1; then
|
| 152 |
+
echo "1"
|
| 153 |
+
else
|
| 154 |
+
echo "0"
|
| 155 |
+
fi
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
start_project_if_requested() {
|
| 159 |
+
if [[ "$START_APP" != "1" ]]; then
|
| 160 |
+
return
|
| 161 |
+
fi
|
| 162 |
+
cd "$TARGET_DIR"
|
| 163 |
+
chmod +x run_med_advisor.sh
|
| 164 |
+
FORCE_CUDA_VALUE="$(resolve_force_cuda)"
|
| 165 |
+
if [[ "$FORCE_CUDA_VALUE" == "1" ]]; then
|
| 166 |
+
echo "[step] Starting SailingMedAdvisor in GPU-preferred mode (FORCE_CUDA=1)"
|
| 167 |
+
FORCE_CUDA=1 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 168 |
+
else
|
| 169 |
+
echo "[step] Starting SailingMedAdvisor in CPU-safe mode (FORCE_CUDA=0)"
|
| 170 |
+
FORCE_CUDA=0 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 171 |
+
fi
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
main() {
|
| 175 |
+
echo "=================================================="
|
| 176 |
+
echo "SailingMedAdvisor Ubuntu 24.04 Bootstrap"
|
| 177 |
+
echo "Target: $TARGET_DIR"
|
| 178 |
+
echo "Repo: $REPO_URL ($BRANCH)"
|
| 179 |
+
echo "=================================================="
|
| 180 |
+
|
| 181 |
+
install_system_packages
|
| 182 |
+
clone_or_update_repo
|
| 183 |
+
install_project
|
| 184 |
+
verify_project
|
| 185 |
+
|
| 186 |
+
cat <<EOF
|
| 187 |
+
|
| 188 |
+
[done] Fresh install test completed successfully.
|
| 189 |
+
Installed at: $TARGET_DIR
|
| 190 |
+
|
| 191 |
+
To run manually:
|
| 192 |
+
cd "$TARGET_DIR"
|
| 193 |
+
FORCE_CUDA=0 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 194 |
+
|
| 195 |
+
EOF
|
| 196 |
+
start_project_if_requested
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
main "$@"
|
scripts/copy_pharma_lorraine_to_rick.sh
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
# Copy only pharmaceuticals (type == "medication") from one workspace to another.
|
| 12 |
+
# Equipment and consumables in the destination are preserved.
|
| 13 |
+
|
| 14 |
+
SRC_WORKSPACE="${SRC_WORKSPACE:-Lorraine}"
|
| 15 |
+
DEST_WORKSPACE="${DEST_WORKSPACE:-Rick}"
|
| 16 |
+
APP_HOME="${APP_HOME:-/home/user/app}"
|
| 17 |
+
|
| 18 |
+
slug() {
|
| 19 |
+
echo "${1:-}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
SRC_SLUG=$(slug "$SRC_WORKSPACE")
|
| 23 |
+
DEST_SLUG=$(slug "$DEST_WORKSPACE")
|
| 24 |
+
|
| 25 |
+
DATA_BASE="$APP_HOME/data"
|
| 26 |
+
UPLOAD_BASE="$APP_HOME/uploads"
|
| 27 |
+
|
| 28 |
+
SRC_INV="$DATA_BASE/$SRC_SLUG/inventory.json"
|
| 29 |
+
DEST_INV="$DATA_BASE/$DEST_SLUG/inventory.json"
|
| 30 |
+
|
| 31 |
+
if [[ ! -f "$SRC_INV" ]]; then
|
| 32 |
+
echo "Source inventory not found: $SRC_INV" >&2
|
| 33 |
+
exit 1
|
| 34 |
+
fi
|
| 35 |
+
if [[ ! -f "$DEST_INV" ]]; then
|
| 36 |
+
echo "Destination inventory not found: $DEST_INV" >&2
|
| 37 |
+
exit 1
|
| 38 |
+
fi
|
| 39 |
+
|
| 40 |
+
backup="$DEST_INV.bak.$(date +%s)"
|
| 41 |
+
if ! cp "$DEST_INV" "$backup" 2>/dev/null; then
|
| 42 |
+
echo "Unable to create backup at $backup. Check permissions (maybe the data dir is read-only)." >&2
|
| 43 |
+
exit 1
|
| 44 |
+
fi
|
| 45 |
+
|
| 46 |
+
jq -s '
|
| 47 |
+
def norm: ( .type // "" ) | ascii_downcase;
|
| 48 |
+
(.[0] // []) as $dest
|
| 49 |
+
| (.[1] // []) as $src
|
| 50 |
+
| ($dest | map(select(norm != "medication"))) as $dest_keep
|
| 51 |
+
| ($src | map(select(norm == "medication"))) as $src_meds
|
| 52 |
+
| $dest_keep + $src_meds
|
| 53 |
+
' "$DEST_INV" "$SRC_INV" > "${DEST_INV}.tmp"
|
| 54 |
+
mv "${DEST_INV}.tmp" "$DEST_INV"
|
| 55 |
+
|
| 56 |
+
SRC_PHOTOS="$UPLOAD_BASE/$SRC_SLUG/medicines"
|
| 57 |
+
DEST_PHOTOS="$UPLOAD_BASE/$DEST_SLUG/medicines"
|
| 58 |
+
if [[ -d "$SRC_PHOTOS" ]]; then
|
| 59 |
+
mkdir -p "$DEST_PHOTOS"
|
| 60 |
+
rsync -av "$SRC_PHOTOS/" "$DEST_PHOTOS/"
|
| 61 |
+
else
|
| 62 |
+
echo "No medicine photos found at $SRC_PHOTOS (skipping photos copy)"
|
| 63 |
+
fi
|
| 64 |
+
|
| 65 |
+
echo "Done. Backed up: $backup"
|
scripts/copy_pharma_lorraine_to_rick_pure.sh
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
# Copy only pharmaceuticals from Lorraine to Rick without jq.
|
| 12 |
+
# Heuristic: anything not explicitly consumable/equipment/durable is treated as medication.
|
| 13 |
+
# Requires python3 (available) for JSON manipulation; avoids sudo.
|
| 14 |
+
|
| 15 |
+
SRC_WORKSPACE="${SRC_WORKSPACE:-Lorraine}"
|
| 16 |
+
DEST_WORKSPACE="${DEST_WORKSPACE:-Rick}"
|
| 17 |
+
APP_HOME="${APP_HOME:-/home/user/app}"
|
| 18 |
+
|
| 19 |
+
slug() {
|
| 20 |
+
echo "${1:-}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
SRC_SLUG=$(slug "$SRC_WORKSPACE")
|
| 24 |
+
DEST_SLUG=$(slug "$DEST_WORKSPACE")
|
| 25 |
+
|
| 26 |
+
SRC_INV="$APP_HOME/data/$SRC_SLUG/inventory.json"
|
| 27 |
+
DEST_INV="$APP_HOME/data/$DEST_SLUG/inventory.json"
|
| 28 |
+
|
| 29 |
+
if [[ ! -f "$SRC_INV" ]]; then
|
| 30 |
+
echo "Source inventory not found: $SRC_INV" >&2
|
| 31 |
+
exit 1
|
| 32 |
+
fi
|
| 33 |
+
if [[ ! -f "$DEST_INV" ]]; then
|
| 34 |
+
echo "Destination inventory not found: $DEST_INV" >&2
|
| 35 |
+
exit 1
|
| 36 |
+
fi
|
| 37 |
+
|
| 38 |
+
backup="$DEST_INV.bak.$(date +%s)"
|
| 39 |
+
cp "$DEST_INV" "$backup"
|
| 40 |
+
|
| 41 |
+
python3 - <<PY
|
| 42 |
+
import json
|
| 43 |
+
from pathlib import Path
|
| 44 |
+
|
| 45 |
+
src = Path("$SRC_INV").read_text()
|
| 46 |
+
dest = Path("$DEST_INV").read_text()
|
| 47 |
+
try:
|
| 48 |
+
src_data = json.loads(src)
|
| 49 |
+
dest_data = json.loads(dest)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
raise SystemExit(f"JSON parse error: {e}")
|
| 52 |
+
|
| 53 |
+
def norm_type(item):
|
| 54 |
+
return (item.get("type") or "").strip().lower()
|
| 55 |
+
|
| 56 |
+
keep_types = {"consumable", "equipment", "durable"}
|
| 57 |
+
dest_keep = [x for x in dest_data if norm_type(x) in keep_types]
|
| 58 |
+
src_meds = [x for x in src_data if norm_type(x) not in keep_types]
|
| 59 |
+
|
| 60 |
+
merged = dest_keep + src_meds
|
| 61 |
+
Path("$DEST_INV").write_text(json.dumps(merged, indent=2))
|
| 62 |
+
PY
|
| 63 |
+
|
| 64 |
+
SRC_PH="$APP_HOME/uploads/$SRC_SLUG/medicines"
|
| 65 |
+
DEST_PH="$APP_HOME/uploads/$DEST_SLUG/medicines"
|
| 66 |
+
if [[ -d "$SRC_PH" ]]; then
|
| 67 |
+
mkdir -p "$DEST_PH"
|
| 68 |
+
cp -a "$SRC_PH/." "$DEST_PH/"
|
| 69 |
+
else
|
| 70 |
+
echo "No medicine photos found at $SRC_PH (skipping photos copy)"
|
| 71 |
+
fi
|
| 72 |
+
|
| 73 |
+
echo "Done. Backup created at $backup"
|
scripts/import_clean_triage_tree.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
"""
|
| 10 |
+
Import the clean hierarchical triage tree into SailingMedAdvisor's runtime schema.
|
| 11 |
+
|
| 12 |
+
Input schema (clean, editable):
|
| 13 |
+
{
|
| 14 |
+
"tree_version": "1.0",
|
| 15 |
+
"domain": {
|
| 16 |
+
"Trauma": {
|
| 17 |
+
"presentation": {
|
| 18 |
+
"Laceration": {
|
| 19 |
+
"region_or_system": {
|
| 20 |
+
"Head": {
|
| 21 |
+
"condition_state": [...],
|
| 22 |
+
"risk_modifier": [...]
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
Runtime schema (current app expects):
|
| 32 |
+
{
|
| 33 |
+
"base_doctrine": "...",
|
| 34 |
+
"tree": {
|
| 35 |
+
"Trauma": {
|
| 36 |
+
"mindset": "...",
|
| 37 |
+
"problems": {
|
| 38 |
+
"Laceration": {
|
| 39 |
+
"procedure": "...",
|
| 40 |
+
"anatomy_guardrails": {"Head": "..."},
|
| 41 |
+
"severity_modifiers": {"Stable": "..."},
|
| 42 |
+
"mechanism_modifiers": {"Fall mechanism": "..."}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
from __future__ import annotations
|
| 51 |
+
|
| 52 |
+
import argparse
|
| 53 |
+
import json
|
| 54 |
+
import re
|
| 55 |
+
import sys
|
| 56 |
+
from pathlib import Path
|
| 57 |
+
from typing import Any, Dict, List, Optional
|
| 58 |
+
|
| 59 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 60 |
+
if str(PROJECT_ROOT) not in sys.path:
|
| 61 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 62 |
+
|
| 63 |
+
import db_store
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
DEFAULT_BASE_DOCTRINE = (
|
| 67 |
+
"You are SailingMedAdvisor. Role: Damage-control for Vessel Captain. "
|
| 68 |
+
"Priority: MARCH-PAWS. Rules: Numbered imperative steps, timed reassessment intervals, "
|
| 69 |
+
"no speculation, only Medical Chest items. For Ethan: weight-based dosing. "
|
| 70 |
+
"Output: STAY, URGENT, or IMMEDIATE."
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
DOMAIN_MINDSETS = {
|
| 74 |
+
"Trauma": "Physiology over appearance. Stabilize first. Order: Hemostasis -> Airway -> Breathing -> Circulation.",
|
| 75 |
+
"Medical Illness": "Vitals trends and treatment response only. Avoid rare/complex diagnoses.",
|
| 76 |
+
"Environmental": "Neutralize the pathogen (environment) first.",
|
| 77 |
+
"Dental": "Preservation only. No extractions unless airway is threatened.",
|
| 78 |
+
"Behavioral": "Vessel safety first. Secure the environment; avoid chemical restraint.",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
DOMAIN_ALIASES = {
|
| 82 |
+
"medical illness": "Medical Illness",
|
| 83 |
+
"behavioral / psychological": "Behavioral",
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def norm(value: Any) -> str:
|
| 88 |
+
"""
|
| 89 |
+
Norm helper.
|
| 90 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 91 |
+
"""
|
| 92 |
+
return re.sub(r"[^a-z0-9]+", "", str(value or "").strip().lower())
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def first_existing_key(options: Dict[str, Any], wanted: str) -> Optional[str]:
|
| 96 |
+
"""
|
| 97 |
+
First Existing Key helper.
|
| 98 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 99 |
+
"""
|
| 100 |
+
if not isinstance(options, dict) or not wanted:
|
| 101 |
+
return None
|
| 102 |
+
if wanted in options:
|
| 103 |
+
return wanted
|
| 104 |
+
w = norm(wanted)
|
| 105 |
+
for key in options.keys():
|
| 106 |
+
if norm(key) == w:
|
| 107 |
+
return key
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def as_list(values: Any) -> List[str]:
|
| 112 |
+
"""
|
| 113 |
+
As List helper.
|
| 114 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 115 |
+
"""
|
| 116 |
+
if not isinstance(values, list):
|
| 117 |
+
return []
|
| 118 |
+
seen = set()
|
| 119 |
+
out: List[str] = []
|
| 120 |
+
for v in values:
|
| 121 |
+
s = str(v or "").strip()
|
| 122 |
+
if not s:
|
| 123 |
+
continue
|
| 124 |
+
n = norm(s)
|
| 125 |
+
if n in seen:
|
| 126 |
+
continue
|
| 127 |
+
seen.add(n)
|
| 128 |
+
out.append(s)
|
| 129 |
+
return out
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def choose_text(
|
| 133 |
+
existing_map: Dict[str, Any],
|
| 134 |
+
key: str,
|
| 135 |
+
fallback: str,
|
| 136 |
+
) -> str:
|
| 137 |
+
"""
|
| 138 |
+
Choose Text helper.
|
| 139 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 140 |
+
"""
|
| 141 |
+
if isinstance(existing_map, dict):
|
| 142 |
+
hit = first_existing_key(existing_map, key)
|
| 143 |
+
if hit and isinstance(existing_map.get(hit), str) and existing_map.get(hit).strip():
|
| 144 |
+
return existing_map.get(hit).strip()
|
| 145 |
+
return fallback
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def convert_clean_to_runtime(
|
| 149 |
+
clean: Dict[str, Any],
|
| 150 |
+
existing_runtime: Dict[str, Any],
|
| 151 |
+
) -> Dict[str, Any]:
|
| 152 |
+
"""
|
| 153 |
+
Convert Clean To Runtime helper.
|
| 154 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 155 |
+
"""
|
| 156 |
+
clean_domains = clean.get("domain")
|
| 157 |
+
if not isinstance(clean_domains, dict) or not clean_domains:
|
| 158 |
+
raise ValueError("Input clean JSON must contain a non-empty 'domain' object.")
|
| 159 |
+
|
| 160 |
+
existing_tree = existing_runtime.get("tree") if isinstance(existing_runtime, dict) else {}
|
| 161 |
+
if not isinstance(existing_tree, dict):
|
| 162 |
+
existing_tree = {}
|
| 163 |
+
|
| 164 |
+
base_doctrine = (clean.get("base_doctrine") or "").strip()
|
| 165 |
+
if not base_doctrine:
|
| 166 |
+
base_doctrine = (existing_runtime.get("base_doctrine") or "").strip() if isinstance(existing_runtime, dict) else ""
|
| 167 |
+
if not base_doctrine:
|
| 168 |
+
base_doctrine = DEFAULT_BASE_DOCTRINE
|
| 169 |
+
|
| 170 |
+
runtime_tree: Dict[str, Any] = {}
|
| 171 |
+
for raw_domain_name, domain_payload in clean_domains.items():
|
| 172 |
+
if not isinstance(domain_payload, dict):
|
| 173 |
+
continue
|
| 174 |
+
domain_name = str(raw_domain_name or "").strip()
|
| 175 |
+
if not domain_name:
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
canonical_domain = DOMAIN_ALIASES.get(domain_name.lower(), domain_name)
|
| 179 |
+
existing_domain_key = first_existing_key(existing_tree, canonical_domain) or first_existing_key(existing_tree, domain_name)
|
| 180 |
+
existing_domain = existing_tree.get(existing_domain_key, {}) if existing_domain_key else {}
|
| 181 |
+
existing_problems = existing_domain.get("problems") if isinstance(existing_domain, dict) else {}
|
| 182 |
+
if not isinstance(existing_problems, dict):
|
| 183 |
+
existing_problems = {}
|
| 184 |
+
|
| 185 |
+
mindset = (domain_payload.get("mindset") or "").strip()
|
| 186 |
+
if not mindset:
|
| 187 |
+
if isinstance(existing_domain, dict):
|
| 188 |
+
mindset = (existing_domain.get("mindset") or "").strip()
|
| 189 |
+
if not mindset:
|
| 190 |
+
mindset = DOMAIN_MINDSETS.get(canonical_domain, "")
|
| 191 |
+
|
| 192 |
+
clean_presentations = domain_payload.get("presentation")
|
| 193 |
+
if not isinstance(clean_presentations, dict):
|
| 194 |
+
clean_presentations = {}
|
| 195 |
+
|
| 196 |
+
problems_out: Dict[str, Any] = {}
|
| 197 |
+
for raw_presentation_name, presentation_payload in clean_presentations.items():
|
| 198 |
+
if not isinstance(presentation_payload, dict):
|
| 199 |
+
continue
|
| 200 |
+
presentation_name = str(raw_presentation_name or "").strip()
|
| 201 |
+
if not presentation_name:
|
| 202 |
+
continue
|
| 203 |
+
|
| 204 |
+
existing_problem_key = first_existing_key(existing_problems, presentation_name)
|
| 205 |
+
existing_problem = existing_problems.get(existing_problem_key, {}) if existing_problem_key else {}
|
| 206 |
+
if not isinstance(existing_problem, dict):
|
| 207 |
+
existing_problem = {}
|
| 208 |
+
|
| 209 |
+
procedure = (presentation_payload.get("procedure") or "").strip()
|
| 210 |
+
if not procedure:
|
| 211 |
+
procedure = (existing_problem.get("procedure") or "").strip()
|
| 212 |
+
if not procedure:
|
| 213 |
+
procedure = (
|
| 214 |
+
f"Manage {presentation_name} under {canonical_domain} pathway. "
|
| 215 |
+
"Use selected region/system, condition state, and risk modifier to drive step priorities."
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
existing_anatomy = existing_problem.get("anatomy_guardrails") if isinstance(existing_problem, dict) else {}
|
| 219 |
+
existing_severity = existing_problem.get("severity_modifiers") if isinstance(existing_problem, dict) else {}
|
| 220 |
+
existing_mechanism = existing_problem.get("mechanism_modifiers") if isinstance(existing_problem, dict) else {}
|
| 221 |
+
if not isinstance(existing_anatomy, dict):
|
| 222 |
+
existing_anatomy = {}
|
| 223 |
+
if not isinstance(existing_severity, dict):
|
| 224 |
+
existing_severity = {}
|
| 225 |
+
if not isinstance(existing_mechanism, dict):
|
| 226 |
+
existing_mechanism = {}
|
| 227 |
+
|
| 228 |
+
anatomy_guardrails: Dict[str, str] = {}
|
| 229 |
+
severity_modifiers: Dict[str, str] = {}
|
| 230 |
+
mechanism_modifiers: Dict[str, str] = {}
|
| 231 |
+
|
| 232 |
+
region_map = presentation_payload.get("region_or_system")
|
| 233 |
+
if not isinstance(region_map, dict):
|
| 234 |
+
region_map = {}
|
| 235 |
+
|
| 236 |
+
for raw_region_name, region_payload in region_map.items():
|
| 237 |
+
region_name = str(raw_region_name or "").strip()
|
| 238 |
+
if not region_name:
|
| 239 |
+
continue
|
| 240 |
+
region_obj = region_payload if isinstance(region_payload, dict) else {}
|
| 241 |
+
|
| 242 |
+
anatomy_guardrails[region_name] = choose_text(
|
| 243 |
+
existing_anatomy,
|
| 244 |
+
region_name,
|
| 245 |
+
(
|
| 246 |
+
f"Focus region/system: {region_name}. Prioritize site-specific risks, trend changes, "
|
| 247 |
+
"and reassessment intervals aligned with selected condition and risk modifiers."
|
| 248 |
+
),
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
for state_name in as_list(region_obj.get("condition_state")):
|
| 252 |
+
if state_name in severity_modifiers:
|
| 253 |
+
continue
|
| 254 |
+
severity_modifiers[state_name] = choose_text(
|
| 255 |
+
existing_severity,
|
| 256 |
+
state_name,
|
| 257 |
+
(
|
| 258 |
+
f"Condition state: {state_name}. Escalate urgency based on trend and response; "
|
| 259 |
+
"repeat focused reassessment at short intervals."
|
| 260 |
+
),
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
for risk_name in as_list(region_obj.get("risk_modifier")):
|
| 264 |
+
if risk_name in mechanism_modifiers:
|
| 265 |
+
continue
|
| 266 |
+
mechanism_modifiers[risk_name] = choose_text(
|
| 267 |
+
existing_mechanism,
|
| 268 |
+
risk_name,
|
| 269 |
+
(
|
| 270 |
+
f"Risk modifier: {risk_name}. Adjust monitoring window, hidden-injury suspicion, "
|
| 271 |
+
"and evacuation threshold accordingly."
|
| 272 |
+
),
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
problems_out[presentation_name] = {
|
| 276 |
+
"procedure": procedure,
|
| 277 |
+
"anatomy_guardrails": anatomy_guardrails,
|
| 278 |
+
"severity_modifiers": severity_modifiers,
|
| 279 |
+
"mechanism_modifiers": mechanism_modifiers,
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if problems_out:
|
| 283 |
+
runtime_tree[canonical_domain] = {
|
| 284 |
+
"mindset": mindset,
|
| 285 |
+
"problems": problems_out,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
if not runtime_tree:
|
| 289 |
+
raise ValueError("Converted runtime tree is empty; check input JSON content.")
|
| 290 |
+
|
| 291 |
+
return {
|
| 292 |
+
"base_doctrine": base_doctrine,
|
| 293 |
+
"tree": runtime_tree,
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def main() -> int:
|
| 298 |
+
"""
|
| 299 |
+
Main helper.
|
| 300 |
+
Detailed inline notes are included to support safe maintenance and future edits.
|
| 301 |
+
"""
|
| 302 |
+
parser = argparse.ArgumentParser(description="Import clean hierarchical triage tree JSON into app runtime DB schema.")
|
| 303 |
+
parser.add_argument("--input", required=True, help="Path to clean triage JSON (tree_version/schema/domain format).")
|
| 304 |
+
parser.add_argument("--db", default="app.db", help="Path to SQLite database (default: app.db).")
|
| 305 |
+
parser.add_argument("--preview-out", default="", help="Optional path to write converted runtime JSON preview.")
|
| 306 |
+
parser.add_argument("--dry-run", action="store_true", help="Convert and validate, but do not write to DB.")
|
| 307 |
+
args = parser.parse_args()
|
| 308 |
+
|
| 309 |
+
input_path = Path(args.input)
|
| 310 |
+
if not input_path.exists():
|
| 311 |
+
raise SystemExit(f"Input file not found: {input_path}")
|
| 312 |
+
|
| 313 |
+
try:
|
| 314 |
+
clean_payload = json.loads(input_path.read_text(encoding="utf-8"))
|
| 315 |
+
except Exception as exc:
|
| 316 |
+
raise SystemExit(f"Invalid JSON in {input_path}: {exc}") from exc
|
| 317 |
+
|
| 318 |
+
db_store.configure_db(Path(args.db))
|
| 319 |
+
existing = db_store.get_triage_prompt_tree() or {}
|
| 320 |
+
converted = convert_clean_to_runtime(clean_payload, existing)
|
| 321 |
+
|
| 322 |
+
if args.preview_out:
|
| 323 |
+
preview_path = Path(args.preview_out)
|
| 324 |
+
preview_path.write_text(json.dumps(converted, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 325 |
+
print(f"Preview written: {preview_path}")
|
| 326 |
+
|
| 327 |
+
tree = converted.get("tree", {})
|
| 328 |
+
print(f"Converted domains: {len(tree)}")
|
| 329 |
+
for domain_name, domain_node in tree.items():
|
| 330 |
+
problems = domain_node.get("problems", {}) if isinstance(domain_node, dict) else {}
|
| 331 |
+
print(f"- {domain_name}: problems={len(problems)}")
|
| 332 |
+
|
| 333 |
+
if args.dry_run:
|
| 334 |
+
print("Dry run only; DB not modified.")
|
| 335 |
+
return 0
|
| 336 |
+
|
| 337 |
+
saved = db_store.set_triage_prompt_tree(converted)
|
| 338 |
+
saved_tree = saved.get("tree", {}) if isinstance(saved, dict) else {}
|
| 339 |
+
print(f"Saved to DB: {args.db}")
|
| 340 |
+
print(f"Saved domains: {len(saved_tree)}")
|
| 341 |
+
return 0
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
if __name__ == "__main__":
|
| 345 |
+
raise SystemExit(main())
|
scripts/install_fresh_copy.sh
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
# scripts/install_fresh_copy.sh
|
| 10 |
+
#
|
| 11 |
+
# Purpose:
|
| 12 |
+
# Streamline first-time setup on a new machine:
|
| 13 |
+
# 1) clone repository (optional)
|
| 14 |
+
# 2) create virtual environment
|
| 15 |
+
# 3) install Python dependencies
|
| 16 |
+
# 4) run deterministic install verification
|
| 17 |
+
#
|
| 18 |
+
# Usage examples:
|
| 19 |
+
# ./scripts/install_fresh_copy.sh
|
| 20 |
+
# ./scripts/install_fresh_copy.sh --target ~/SailingMedAdvisor --repo-url https://github.com/rickeae/SailingMedAdvisor.git
|
| 21 |
+
# ./scripts/install_fresh_copy.sh --skip-clone --skip-verify
|
| 22 |
+
|
| 23 |
+
set -euo pipefail
|
| 24 |
+
|
| 25 |
+
REPO_URL="https://github.com/rickeae/SailingMedAdvisor.git"
|
| 26 |
+
BRANCH="main"
|
| 27 |
+
TARGET_DIR=""
|
| 28 |
+
SKIP_CLONE="0"
|
| 29 |
+
SKIP_VERIFY="0"
|
| 30 |
+
PYTHON_BIN="python3"
|
| 31 |
+
|
| 32 |
+
usage() {
|
| 33 |
+
cat <<'EOF'
|
| 34 |
+
install_fresh_copy.sh
|
| 35 |
+
|
| 36 |
+
Options:
|
| 37 |
+
--repo-url <url> Git repository URL (default: official GitHub repo)
|
| 38 |
+
--branch <name> Branch to checkout (default: main)
|
| 39 |
+
--target <path> Target directory (default: current directory if --skip-clone, else ./SailingMedAdvisor)
|
| 40 |
+
--python <bin> Python executable (default: python3)
|
| 41 |
+
--skip-clone Use existing repository in target/current directory
|
| 42 |
+
--skip-verify Skip post-install verification script
|
| 43 |
+
-h, --help Show this help
|
| 44 |
+
EOF
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
while [[ $# -gt 0 ]]; do
|
| 48 |
+
case "$1" in
|
| 49 |
+
--repo-url)
|
| 50 |
+
REPO_URL="$2"; shift 2 ;;
|
| 51 |
+
--branch)
|
| 52 |
+
BRANCH="$2"; shift 2 ;;
|
| 53 |
+
--target)
|
| 54 |
+
TARGET_DIR="$2"; shift 2 ;;
|
| 55 |
+
--python)
|
| 56 |
+
PYTHON_BIN="$2"; shift 2 ;;
|
| 57 |
+
--skip-clone)
|
| 58 |
+
SKIP_CLONE="1"; shift ;;
|
| 59 |
+
--skip-verify)
|
| 60 |
+
SKIP_VERIFY="1"; shift ;;
|
| 61 |
+
-h|--help)
|
| 62 |
+
usage; exit 0 ;;
|
| 63 |
+
*)
|
| 64 |
+
echo "Unknown option: $1"
|
| 65 |
+
usage
|
| 66 |
+
exit 1 ;;
|
| 67 |
+
esac
|
| 68 |
+
done
|
| 69 |
+
|
| 70 |
+
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
|
| 71 |
+
echo "ERROR: Python executable not found: $PYTHON_BIN"
|
| 72 |
+
exit 1
|
| 73 |
+
fi
|
| 74 |
+
|
| 75 |
+
if [[ "$SKIP_CLONE" == "1" ]]; then
|
| 76 |
+
if [[ -n "$TARGET_DIR" ]]; then
|
| 77 |
+
WORKDIR="$TARGET_DIR"
|
| 78 |
+
else
|
| 79 |
+
WORKDIR="$(pwd)"
|
| 80 |
+
fi
|
| 81 |
+
else
|
| 82 |
+
if [[ -z "$TARGET_DIR" ]]; then
|
| 83 |
+
TARGET_DIR="$(pwd)/SailingMedAdvisor"
|
| 84 |
+
fi
|
| 85 |
+
WORKDIR="$TARGET_DIR"
|
| 86 |
+
if [[ -d "$WORKDIR/.git" ]]; then
|
| 87 |
+
echo "[info] Existing git repo detected at $WORKDIR; fetching latest branch $BRANCH"
|
| 88 |
+
git -C "$WORKDIR" fetch --all --tags
|
| 89 |
+
git -C "$WORKDIR" checkout "$BRANCH"
|
| 90 |
+
git -C "$WORKDIR" pull --ff-only
|
| 91 |
+
else
|
| 92 |
+
echo "[info] Cloning $REPO_URL into $WORKDIR"
|
| 93 |
+
git clone --branch "$BRANCH" "$REPO_URL" "$WORKDIR"
|
| 94 |
+
fi
|
| 95 |
+
fi
|
| 96 |
+
|
| 97 |
+
if [[ ! -f "$WORKDIR/app.py" || ! -f "$WORKDIR/requirements.txt" ]]; then
|
| 98 |
+
echo "ERROR: $WORKDIR does not look like SailingMedAdvisor repository root."
|
| 99 |
+
exit 1
|
| 100 |
+
fi
|
| 101 |
+
|
| 102 |
+
echo "[info] Working directory: $WORKDIR"
|
| 103 |
+
cd "$WORKDIR"
|
| 104 |
+
|
| 105 |
+
if [[ ! -d ".venv" ]]; then
|
| 106 |
+
echo "[info] Creating virtual environment"
|
| 107 |
+
"$PYTHON_BIN" -m venv .venv
|
| 108 |
+
fi
|
| 109 |
+
|
| 110 |
+
echo "[info] Upgrading pip/setuptools/wheel"
|
| 111 |
+
./.venv/bin/python -m pip install --upgrade pip setuptools wheel
|
| 112 |
+
|
| 113 |
+
echo "[info] Installing dependencies"
|
| 114 |
+
./.venv/bin/pip install -r requirements.txt
|
| 115 |
+
|
| 116 |
+
chmod +x run_med_advisor.sh
|
| 117 |
+
chmod +x scripts/verify_fresh_install.py || true
|
| 118 |
+
|
| 119 |
+
if [[ "$SKIP_VERIFY" == "0" ]]; then
|
| 120 |
+
echo "[info] Running installation verification"
|
| 121 |
+
./.venv/bin/python scripts/verify_fresh_install.py
|
| 122 |
+
else
|
| 123 |
+
echo "[warn] Verification skipped (--skip-verify)"
|
| 124 |
+
fi
|
| 125 |
+
|
| 126 |
+
cat <<'EOF'
|
| 127 |
+
|
| 128 |
+
Installation complete.
|
| 129 |
+
|
| 130 |
+
Next steps:
|
| 131 |
+
1) Start app:
|
| 132 |
+
FORCE_CUDA=0 ALLOW_CPU_FALLBACK_ON_CUDA_ERROR=1 ./run_med_advisor.sh
|
| 133 |
+
2) Open:
|
| 134 |
+
http://127.0.0.1:5000
|
| 135 |
+
3) In Settings > Offline Readiness Check:
|
| 136 |
+
- Check cache status
|
| 137 |
+
- Download missing models (while online)
|
| 138 |
+
- Enable offline mode before offshore use
|
| 139 |
+
EOF
|
scripts/verify_fresh_install.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# =============================================================================
|
| 3 |
+
# Author: Rick Escher
|
| 4 |
+
# Project: SailingMedAdvisor
|
| 5 |
+
# Context: Google HAI-DEF Framework
|
| 6 |
+
# Models: Google MedGemmas
|
| 7 |
+
# Program: Kaggle Impact Challenge
|
| 8 |
+
# =============================================================================
|
| 9 |
+
"""
|
| 10 |
+
scripts/verify_fresh_install.py
|
| 11 |
+
|
| 12 |
+
Purpose:
|
| 13 |
+
Run a deterministic "fresh machine" verification for SailingMedAdvisor.
|
| 14 |
+
This checks runtime prerequisites, required files, database schema, default
|
| 15 |
+
triage tree content, and a lightweight API startup smoke test.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import importlib
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import sqlite3
|
| 25 |
+
import subprocess
|
| 26 |
+
import sys
|
| 27 |
+
import time
|
| 28 |
+
import urllib.error
|
| 29 |
+
import urllib.request
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import List, Tuple
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
REQUIRED_IMPORTS = [
|
| 35 |
+
"fastapi",
|
| 36 |
+
"uvicorn",
|
| 37 |
+
"jinja2",
|
| 38 |
+
"multipart", # python-multipart import name
|
| 39 |
+
"aiofiles",
|
| 40 |
+
"PIL",
|
| 41 |
+
"torch",
|
| 42 |
+
"transformers",
|
| 43 |
+
"bitsandbytes",
|
| 44 |
+
"accelerate",
|
| 45 |
+
"safetensors",
|
| 46 |
+
"huggingface_hub",
|
| 47 |
+
"itsdangerous",
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
REQUIRED_FILES = [
|
| 51 |
+
"app.py",
|
| 52 |
+
"db_store.py",
|
| 53 |
+
"requirements.txt",
|
| 54 |
+
"run_med_advisor.sh",
|
| 55 |
+
"seed/triage_prompt_tree.default.json",
|
| 56 |
+
"templates/index.html",
|
| 57 |
+
"static/js/chat.js",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
REQUIRED_TABLES = [
|
| 61 |
+
"settings_meta",
|
| 62 |
+
"crew",
|
| 63 |
+
"triage_options",
|
| 64 |
+
"triage_prompt_modules",
|
| 65 |
+
"triage_prompt_tree",
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class CheckResults:
|
| 70 |
+
def __init__(self) -> None:
|
| 71 |
+
self.ok: List[str] = []
|
| 72 |
+
self.fail: List[str] = []
|
| 73 |
+
self.warn: List[str] = []
|
| 74 |
+
|
| 75 |
+
def pass_(self, msg: str) -> None:
|
| 76 |
+
self.ok.append(msg)
|
| 77 |
+
print(f"[PASS] {msg}")
|
| 78 |
+
|
| 79 |
+
def fail_(self, msg: str) -> None:
|
| 80 |
+
self.fail.append(msg)
|
| 81 |
+
print(f"[FAIL] {msg}")
|
| 82 |
+
|
| 83 |
+
def warn_(self, msg: str) -> None:
|
| 84 |
+
self.warn.append(msg)
|
| 85 |
+
print(f"[WARN] {msg}")
|
| 86 |
+
|
| 87 |
+
def summary(self) -> int:
|
| 88 |
+
print("\n=== Verification Summary ===")
|
| 89 |
+
print(f"Passed: {len(self.ok)}")
|
| 90 |
+
print(f"Warnings: {len(self.warn)}")
|
| 91 |
+
print(f"Failed: {len(self.fail)}")
|
| 92 |
+
return 1 if self.fail else 0
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _repo_root() -> Path:
|
| 96 |
+
return Path(__file__).resolve().parents[1]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def check_python_version(results: CheckResults) -> None:
|
| 100 |
+
if sys.version_info >= (3, 10):
|
| 101 |
+
results.pass_(f"Python version is supported: {sys.version.split()[0]}")
|
| 102 |
+
else:
|
| 103 |
+
results.fail_(f"Python >= 3.10 required, found {sys.version.split()[0]}")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def check_required_files(results: CheckResults, repo: Path) -> None:
|
| 107 |
+
missing = [rel for rel in REQUIRED_FILES if not (repo / rel).exists()]
|
| 108 |
+
if missing:
|
| 109 |
+
results.fail_(f"Missing required files: {', '.join(missing)}")
|
| 110 |
+
return
|
| 111 |
+
results.pass_("Required project files are present")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def check_imports(results: CheckResults) -> None:
|
| 115 |
+
missing = []
|
| 116 |
+
for mod in REQUIRED_IMPORTS:
|
| 117 |
+
try:
|
| 118 |
+
importlib.import_module(mod)
|
| 119 |
+
except Exception:
|
| 120 |
+
missing.append(mod)
|
| 121 |
+
if missing:
|
| 122 |
+
results.fail_(f"Missing Python imports: {', '.join(missing)}")
|
| 123 |
+
else:
|
| 124 |
+
results.pass_("All required Python packages import successfully")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _is_valid_sqlite(path: Path) -> bool:
|
| 128 |
+
try:
|
| 129 |
+
with path.open("rb") as f:
|
| 130 |
+
return f.read(16).startswith(b"SQLite format 3")
|
| 131 |
+
except Exception:
|
| 132 |
+
return False
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _ensure_runtime_db(repo: Path, results: CheckResults) -> Path:
|
| 136 |
+
db_path = repo / "app.db"
|
| 137 |
+
if db_path.exists() and _is_valid_sqlite(db_path):
|
| 138 |
+
results.pass_(f"Runtime DB present: {db_path}")
|
| 139 |
+
return db_path
|
| 140 |
+
|
| 141 |
+
# If DB is absent/invalid on fresh machine, initialize schema via db_store.
|
| 142 |
+
try:
|
| 143 |
+
import db_store
|
| 144 |
+
|
| 145 |
+
db_store.configure_db(db_path)
|
| 146 |
+
if db_path.exists() and _is_valid_sqlite(db_path):
|
| 147 |
+
results.pass_(f"Runtime DB initialized: {db_path}")
|
| 148 |
+
return db_path
|
| 149 |
+
results.fail_("Failed to initialize runtime DB")
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
results.fail_(f"DB initialization error: {exc}")
|
| 152 |
+
return db_path
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def check_db_schema(results: CheckResults, db_path: Path) -> None:
|
| 156 |
+
if not db_path.exists() or not _is_valid_sqlite(db_path):
|
| 157 |
+
results.fail_("DB schema check skipped: app.db missing or invalid")
|
| 158 |
+
return
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
with sqlite3.connect(db_path) as conn:
|
| 162 |
+
names = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
| 163 |
+
missing = [t for t in REQUIRED_TABLES if t not in names]
|
| 164 |
+
if missing:
|
| 165 |
+
results.fail_(f"DB missing required tables: {', '.join(missing)}")
|
| 166 |
+
else:
|
| 167 |
+
results.pass_("DB schema includes required tables")
|
| 168 |
+
|
| 169 |
+
row = conn.execute("SELECT payload FROM triage_prompt_tree WHERE id=1").fetchone()
|
| 170 |
+
if not row:
|
| 171 |
+
results.fail_("triage_prompt_tree id=1 is missing")
|
| 172 |
+
return
|
| 173 |
+
payload = json.loads(row[0] or "{}")
|
| 174 |
+
if not isinstance(payload.get("tree"), dict) or not payload["tree"]:
|
| 175 |
+
results.fail_("triage_prompt_tree payload has no valid tree")
|
| 176 |
+
else:
|
| 177 |
+
results.pass_("triage_prompt_tree payload exists and is valid JSON")
|
| 178 |
+
except Exception as exc:
|
| 179 |
+
results.fail_(f"DB schema read failed: {exc}")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def check_default_tree_json(results: CheckResults, repo: Path) -> None:
|
| 183 |
+
path = repo / "seed/triage_prompt_tree.default.json"
|
| 184 |
+
try:
|
| 185 |
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
| 186 |
+
except Exception as exc:
|
| 187 |
+
results.fail_(f"Default tree JSON unreadable: {exc}")
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
if not isinstance(payload.get("base_doctrine"), str) or not payload["base_doctrine"].strip():
|
| 191 |
+
results.fail_("Default tree is missing base_doctrine text")
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
tree = payload.get("tree")
|
| 195 |
+
if not isinstance(tree, dict) or not tree:
|
| 196 |
+
results.fail_("Default tree JSON missing top-level tree map")
|
| 197 |
+
return
|
| 198 |
+
|
| 199 |
+
required_domains = {
|
| 200 |
+
"Trauma",
|
| 201 |
+
"Illness",
|
| 202 |
+
"Toxins/Bites/Stings & Environmental Hazards",
|
| 203 |
+
"Dental",
|
| 204 |
+
"Psychological/Behavioral",
|
| 205 |
+
}
|
| 206 |
+
missing_domains = sorted(required_domains - set(tree.keys()))
|
| 207 |
+
if missing_domains:
|
| 208 |
+
results.warn_(f"Default tree missing expected domains: {', '.join(missing_domains)}")
|
| 209 |
+
else:
|
| 210 |
+
results.pass_("Default tree includes expected core domains")
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def _poll_db_status(base_url: str, timeout_s: int) -> Tuple[bool, str]:
|
| 214 |
+
start = time.time()
|
| 215 |
+
url = f"{base_url}/api/db/status"
|
| 216 |
+
while time.time() - start < timeout_s:
|
| 217 |
+
try:
|
| 218 |
+
with urllib.request.urlopen(url, timeout=2.0) as resp:
|
| 219 |
+
if resp.status != 200:
|
| 220 |
+
time.sleep(0.5)
|
| 221 |
+
continue
|
| 222 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 223 |
+
if isinstance(data, dict) and "exists" in data:
|
| 224 |
+
return True, f"/api/db/status ok: exists={data.get('exists')} size={data.get('size')}"
|
| 225 |
+
except urllib.error.URLError:
|
| 226 |
+
time.sleep(0.5)
|
| 227 |
+
except Exception:
|
| 228 |
+
time.sleep(0.5)
|
| 229 |
+
return False, "Timed out waiting for /api/db/status"
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def smoke_test_api_startup(results: CheckResults, repo: Path, port: int, timeout_s: int) -> None:
|
| 233 |
+
env = os.environ.copy()
|
| 234 |
+
# Keep startup deterministic and fast for verification.
|
| 235 |
+
env["VERIFY_MODELS_ON_START"] = "0"
|
| 236 |
+
env["AUTO_VERIFY_ONLINE"] = "0"
|
| 237 |
+
env["AUTO_DOWNLOAD_MODELS"] = env.get("AUTO_DOWNLOAD_MODELS", "0")
|
| 238 |
+
|
| 239 |
+
cmd = [
|
| 240 |
+
sys.executable,
|
| 241 |
+
"-m",
|
| 242 |
+
"uvicorn",
|
| 243 |
+
"app:app",
|
| 244 |
+
"--host",
|
| 245 |
+
"127.0.0.1",
|
| 246 |
+
"--port",
|
| 247 |
+
str(port),
|
| 248 |
+
]
|
| 249 |
+
proc = subprocess.Popen(
|
| 250 |
+
cmd,
|
| 251 |
+
cwd=str(repo),
|
| 252 |
+
stdout=subprocess.PIPE,
|
| 253 |
+
stderr=subprocess.STDOUT,
|
| 254 |
+
text=True,
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
try:
|
| 258 |
+
ok, detail = _poll_db_status(f"http://127.0.0.1:{port}", timeout_s=timeout_s)
|
| 259 |
+
if ok:
|
| 260 |
+
results.pass_(f"API startup smoke test passed ({detail})")
|
| 261 |
+
else:
|
| 262 |
+
# Pull a short tail to help debug startup failures.
|
| 263 |
+
tail = ""
|
| 264 |
+
try:
|
| 265 |
+
if proc.stdout:
|
| 266 |
+
out = proc.stdout.read() or ""
|
| 267 |
+
tail = out[-1200:]
|
| 268 |
+
except Exception:
|
| 269 |
+
pass
|
| 270 |
+
if tail.strip():
|
| 271 |
+
results.fail_(f"API smoke test failed: {detail}\n--- uvicorn tail ---\n{tail}")
|
| 272 |
+
else:
|
| 273 |
+
results.fail_(f"API smoke test failed: {detail}")
|
| 274 |
+
finally:
|
| 275 |
+
try:
|
| 276 |
+
proc.terminate()
|
| 277 |
+
proc.wait(timeout=8)
|
| 278 |
+
except Exception:
|
| 279 |
+
try:
|
| 280 |
+
proc.kill()
|
| 281 |
+
except Exception:
|
| 282 |
+
pass
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def main() -> int:
|
| 286 |
+
parser = argparse.ArgumentParser(description="Verify fresh SailingMedAdvisor install")
|
| 287 |
+
parser.add_argument("--repo", default=str(_repo_root()), help="Repository root path")
|
| 288 |
+
parser.add_argument("--skip-smoke", action="store_true", help="Skip uvicorn startup smoke test")
|
| 289 |
+
parser.add_argument("--port", type=int, default=5077, help="Port for smoke test server")
|
| 290 |
+
parser.add_argument("--timeout", type=int, default=40, help="Smoke test timeout in seconds")
|
| 291 |
+
args = parser.parse_args()
|
| 292 |
+
|
| 293 |
+
repo = Path(args.repo).resolve()
|
| 294 |
+
results = CheckResults()
|
| 295 |
+
print(f"[info] Verifying repository: {repo}")
|
| 296 |
+
|
| 297 |
+
check_python_version(results)
|
| 298 |
+
check_required_files(results, repo)
|
| 299 |
+
check_imports(results)
|
| 300 |
+
db_path = _ensure_runtime_db(repo, results)
|
| 301 |
+
check_db_schema(results, db_path)
|
| 302 |
+
check_default_tree_json(results, repo)
|
| 303 |
+
if not args.skip_smoke:
|
| 304 |
+
smoke_test_api_startup(results, repo, port=args.port, timeout_s=args.timeout)
|
| 305 |
+
|
| 306 |
+
return results.summary()
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
raise SystemExit(main())
|
seed/triage_prompt_tree.default.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ships_medicine_chest_medicines_filled.xlsx
ADDED
|
Binary file (18 kB). View file
|
|
|
static/data/triage_samples.json
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": 1,
|
| 4 |
+
"situation": "Open Femur Fracture",
|
| 5 |
+
"chat_text": "One of the crew fell from the mast and their thigh is completely deformed with a bone sticking out. They are barely awake and breathing very fast. There is a lot of blood and they look incredibly pale.",
|
| 6 |
+
"responsive": "Drowsy",
|
| 7 |
+
"breathing": "Rapid/Shallow",
|
| 8 |
+
"pain": "10/10",
|
| 9 |
+
"main_problem": "Heavy bleeding/deformed thigh",
|
| 10 |
+
"temp": "36.2°C",
|
| 11 |
+
"circulation": "Pale, weak pulse, BP 90/60",
|
| 12 |
+
"cause": "Fall from mast during squall"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": 2,
|
| 16 |
+
"situation": "Tension Pneumothorax",
|
| 17 |
+
"chat_text": "He got hit hard in the chest by the boom and now he can't catch his breath. He’s struggling to breathe, his neck veins are bulging out, and his lips are turning blue.",
|
| 18 |
+
"responsive": "Alert/Anxious",
|
| 19 |
+
"breathing": "Struggling",
|
| 20 |
+
"pain": "8/10",
|
| 21 |
+
"main_problem": "One side of chest not moving",
|
| 22 |
+
"temp": "37.0°C",
|
| 23 |
+
"circulation": "Distended neck veins, low BP",
|
| 24 |
+
"cause": "Blown into shroud by boom"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": 3,
|
| 28 |
+
"situation": "Severe Scalp Laceration",
|
| 29 |
+
"chat_text": "A heavy block hit her in the head and blood is literally pulsing out in a spray. She’s awake but there’s a massive amount of bright red blood everywhere.",
|
| 30 |
+
"responsive": "Alert",
|
| 31 |
+
"breathing": "Normal",
|
| 32 |
+
"pain": "7/10",
|
| 33 |
+
"main_problem": "Arterial spurting from head",
|
| 34 |
+
"temp": "36.8°C",
|
| 35 |
+
"circulation": "Rapid pulse, BP normal",
|
| 36 |
+
"cause": "Hit by mainsheet block"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": 4,
|
| 40 |
+
"situation": "Traumatic Amputation",
|
| 41 |
+
"chat_text": "Her hand got sucked into the electric winch. Three fingers are gone and the stumps are bleeding uncontrollably. She’s passed out and her skin is cold and white.",
|
| 42 |
+
"responsive": "Unconscious",
|
| 43 |
+
"breathing": "Labored",
|
| 44 |
+
"pain": "N/A",
|
| 45 |
+
"main_problem": "Missing fingers (R hand)",
|
| 46 |
+
"temp": "35.5°C",
|
| 47 |
+
"circulation": "Massive hemorrhage, shock",
|
| 48 |
+
"cause": "Hand caught in electric winch"
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": 5,
|
| 52 |
+
"situation": "Internal Hemorrhage",
|
| 53 |
+
"chat_text": "He was thrown against the cockpit table during a roll. His stomach is becoming very hard and bloated, he’s breathing fast, and he looks like he’s going into shock.",
|
| 54 |
+
"responsive": "Drowsy",
|
| 55 |
+
"breathing": "Fast",
|
| 56 |
+
"pain": "6/10",
|
| 57 |
+
"main_problem": "Distended/rigid abdomen",
|
| 58 |
+
"temp": "36.0°C",
|
| 59 |
+
"circulation": "Cold/clammy, BP dropping",
|
| 60 |
+
"cause": "Thrown against cockpit table"
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": 6,
|
| 64 |
+
"situation": "Crush Injury (Foot)",
|
| 65 |
+
"chat_text": "An anchor fell on his foot. The pain is a 9 out of 10, his foot is turning blue and cold, and I can't find a pulse anywhere on his ankle.",
|
| 66 |
+
"responsive": "Normal",
|
| 67 |
+
"breathing": "Normal",
|
| 68 |
+
"pain": "9/10",
|
| 69 |
+
"main_problem": "Swollen, blue, no pulse in foot",
|
| 70 |
+
"temp": "37.2°C",
|
| 71 |
+
"circulation": "Good BP, peripheral blockage",
|
| 72 |
+
"cause": "Heavy anchor dropped on foot"
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"id": 7,
|
| 76 |
+
"situation": "Flail Chest",
|
| 77 |
+
"chat_text": "He slammed his chest into a winch. Part of his ribcage is moving inward when he breathes in and outward when he breathes out. He’s in a lot of pain and struggling for air.",
|
| 78 |
+
"responsive": "Alert",
|
| 79 |
+
"breathing": "Very painful",
|
| 80 |
+
"pain": "9/10",
|
| 81 |
+
"main_problem": "Paradoxical chest movement",
|
| 82 |
+
"temp": "37.1°C",
|
| 83 |
+
"circulation": "Fast pulse",
|
| 84 |
+
"cause": "Chest slammed into winch"
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"id": 8,
|
| 88 |
+
"situation": "Concussion/TBI",
|
| 89 |
+
"chat_text": "She hit her head on the deck. She’s very confused, keep throwing up, and one of her pupils is much larger than the other.",
|
| 90 |
+
"responsive": "Confused",
|
| 91 |
+
"breathing": "Normal",
|
| 92 |
+
"pain": "5/10",
|
| 93 |
+
"main_problem": "Repeated vomiting, pupil dilation",
|
| 94 |
+
"temp": "36.9°C",
|
| 95 |
+
"circulation": "BP 140/90 (Rising)",
|
| 96 |
+
"cause": "Slip on wet deck, head hit GRP"
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"id": 9,
|
| 100 |
+
"situation": "Dislocated Shoulder",
|
| 101 |
+
"chat_text": "He reached for the rail during a big wave and his shoulder popped out. It looks completely misshapen and he can't move his arm at all.",
|
| 102 |
+
"responsive": "Alert",
|
| 103 |
+
"breathing": "Normal",
|
| 104 |
+
"pain": "8/10",
|
| 105 |
+
"main_problem": "Visual deformity, arm locked",
|
| 106 |
+
"temp": "37.0°C",
|
| 107 |
+
"circulation": "Normal",
|
| 108 |
+
"cause": "Reaching for rail during roll"
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"id": 10,
|
| 112 |
+
"situation": "Impaled Object",
|
| 113 |
+
"chat_text": "The spinnaker pole shattered and a long, sharp piece of carbon fiber is stuck deep in his thigh. It’s not bleeding much but the object is still in there.",
|
| 114 |
+
"responsive": "Alert",
|
| 115 |
+
"breathing": "Normal",
|
| 116 |
+
"pain": "7/10",
|
| 117 |
+
"main_problem": "Shard of carbon fiber in thigh",
|
| 118 |
+
"temp": "36.8°C",
|
| 119 |
+
"circulation": "Steady, bleeding controlled",
|
| 120 |
+
"cause": "Shattered spinnaker pole"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": 11,
|
| 124 |
+
"situation": "Severe Hypothermia",
|
| 125 |
+
"chat_text": "We pulled him out of the water after 30 minutes. He’s stopped shivering, he’s mumbles when he speaks, and his body feels ice cold and stiff.",
|
| 126 |
+
"responsive": "Mumbling",
|
| 127 |
+
"breathing": "Very slow",
|
| 128 |
+
"pain": "None",
|
| 129 |
+
"main_problem": "Shivering stopped, rigid",
|
| 130 |
+
"temp": "31.0°C",
|
| 131 |
+
"circulation": "Barely palpable pulse",
|
| 132 |
+
"cause": "30 mins in 15°C water (MOB)"
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"id": 12,
|
| 136 |
+
"situation": "Heat Stroke",
|
| 137 |
+
"chat_text": "He’s been working in the engine room and now he’s unconscious. His skin is red and bone dry, he’s having a seizure, and he feels like he's burning up.",
|
| 138 |
+
"responsive": "Unconscious",
|
| 139 |
+
"breathing": "Snoring",
|
| 140 |
+
"pain": "N/A",
|
| 141 |
+
"main_problem": "Hot, dry skin; seizures",
|
| 142 |
+
"temp": "41.1°C",
|
| 143 |
+
"circulation": "Tachycardia (140 bpm)",
|
| 144 |
+
"cause": "Engine room repair in tropics"
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"id": 13,
|
| 148 |
+
"situation": "Saltwater Aspiration",
|
| 149 |
+
"chat_text": "She swallowed a lot of water when she fell overboard. She’s coughing constantly, gasping for air, and her lips look blue.",
|
| 150 |
+
"responsive": "Alert",
|
| 151 |
+
"breathing": "Gasping",
|
| 152 |
+
"pain": "6/10",
|
| 153 |
+
"main_problem": "Persistent coughing, blue lips",
|
| 154 |
+
"temp": "37.5°C",
|
| 155 |
+
"circulation": "Rapid pulse",
|
| 156 |
+
"cause": "Swallowed water during MOB"
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"id": 14,
|
| 160 |
+
"situation": "Severe Dehydration",
|
| 161 |
+
"chat_text": "He’s been seasick for days and hasn't peed in 24 hours. His eyes are sunken in, he’s very weak, and he has a slight fever.",
|
| 162 |
+
"responsive": "Lethargic",
|
| 163 |
+
"breathing": "Normal",
|
| 164 |
+
"pain": "4/10",
|
| 165 |
+
"main_problem": "No urine for 24h, sunken eyes",
|
| 166 |
+
"temp": "38.2°C",
|
| 167 |
+
"circulation": "Weak pulse, very low BP",
|
| 168 |
+
"cause": "Chronic seasickness/vomiting"
|
| 169 |
+
},
|
| 170 |
+
{
|
| 171 |
+
"id": 15,
|
| 172 |
+
"situation": "2nd Degree Sunburn",
|
| 173 |
+
"chat_text": "He fell asleep on deck and has a massive sunburn. Almost half his body is covered in large blisters and he’s shaking even though he has a fever.",
|
| 174 |
+
"responsive": "Alert",
|
| 175 |
+
"breathing": "Normal",
|
| 176 |
+
"pain": "8/10",
|
| 177 |
+
"main_problem": "Blistering over 40% of body",
|
| 178 |
+
"temp": "38.5°C",
|
| 179 |
+
"circulation": "Shivers, mild hypotension",
|
| 180 |
+
"cause": "Fallen asleep on deck in doldrums"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"id": 16,
|
| 184 |
+
"situation": "Immersion Foot",
|
| 185 |
+
"chat_text": "His boots have been wet for four days straight. His feet are completely white, numb, and the skin is starting to peel off in chunks.",
|
| 186 |
+
"responsive": "Alert",
|
| 187 |
+
"breathing": "Normal",
|
| 188 |
+
"pain": "6/10",
|
| 189 |
+
"main_problem": "Feet white, numb, peeling",
|
| 190 |
+
"temp": "36.5°C",
|
| 191 |
+
"circulation": "Poor capillary refill",
|
| 192 |
+
"cause": "4 days in wet boots on watch"
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"id": 17,
|
| 196 |
+
"situation": "Severe Hyponatremia",
|
| 197 |
+
"chat_text": "He’s been drinking gallons of water but eating no salt. Now he’s staggering around like he’s drunk and his speech is totally slurred.",
|
| 198 |
+
"responsive": "Confused",
|
| 199 |
+
"breathing": "Normal",
|
| 200 |
+
"pain": "2/10",
|
| 201 |
+
"main_problem": "Slurred speech, staggering",
|
| 202 |
+
"temp": "37.0°C",
|
| 203 |
+
"circulation": "Normal BP",
|
| 204 |
+
"cause": "Over-drinking water, no salt"
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"id": 18,
|
| 208 |
+
"situation": "Deep Frostbite",
|
| 209 |
+
"chat_text": "We’ve been handling ice-cold lines and his fingers have turned hard and gray-black. He can't feel them at all.",
|
| 210 |
+
"responsive": "Alert",
|
| 211 |
+
"breathing": "Normal",
|
| 212 |
+
"pain": "3/10",
|
| 213 |
+
"main_problem": "Fingers hard, black/gray",
|
| 214 |
+
"temp": "35.8°C",
|
| 215 |
+
"circulation": "Poor circulation to hand",
|
| 216 |
+
"cause": "Hand handling icy lines"
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"id": 19,
|
| 220 |
+
"situation": "Nitrogen Narcosis",
|
| 221 |
+
"chat_text": "He came up too fast after checking the hull. He’s acting very strangely, giggling, and seems totally confused about where he is.",
|
| 222 |
+
"responsive": "Giggling",
|
| 223 |
+
"breathing": "Fast",
|
| 224 |
+
"pain": "None",
|
| 225 |
+
"main_problem": "Irrational behavior/confusion",
|
| 226 |
+
"temp": "36.7°C",
|
| 227 |
+
"circulation": "Normal",
|
| 228 |
+
"cause": "Rapid ascent from hull check"
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"id": 20,
|
| 232 |
+
"situation": "Lightning Strike",
|
| 233 |
+
"chat_text": "The boat was hit by lightning. He’s unconscious and not breathing. I can't find a pulse and there are weird burn marks on his skin.",
|
| 234 |
+
"responsive": "Unconscious",
|
| 235 |
+
"breathing": "Arrested",
|
| 236 |
+
"pain": "N/A",
|
| 237 |
+
"main_problem": "Cardiac arrest, \"feather\" burns",
|
| 238 |
+
"temp": "36.4°C",
|
| 239 |
+
"circulation": "No pulse (requires CPR)",
|
| 240 |
+
"cause": "Direct hit on mast during storm"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"id": 21,
|
| 244 |
+
"situation": "Acute Appendicitis",
|
| 245 |
+
"chat_text": "She has a 9 out of 10 pain in her lower right stomach. If I press down and let go, the pain is even worse. She also has a fever.",
|
| 246 |
+
"responsive": "Alert",
|
| 247 |
+
"breathing": "Guarded",
|
| 248 |
+
"pain": "9/10",
|
| 249 |
+
"main_problem": "Rebound tenderness lower R",
|
| 250 |
+
"temp": "38.9°C",
|
| 251 |
+
"circulation": "High BP from pain",
|
| 252 |
+
"cause": "Random/Bacterial"
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"id": 22,
|
| 256 |
+
"situation": "Sepsis (Infected Wound)",
|
| 257 |
+
"chat_text": "An old coral cut on his leg has red streaks coming out of it. He’s shaking with chills, has a high fever, and his blood pressure seems very low.",
|
| 258 |
+
"responsive": "Drowsy",
|
| 259 |
+
"breathing": "Rapid",
|
| 260 |
+
"pain": "5/10",
|
| 261 |
+
"main_problem": "Red streaks, shaking chills",
|
| 262 |
+
"temp": "39.5°C",
|
| 263 |
+
"circulation": "BP 80/50 (Septic shock)",
|
| 264 |
+
"cause": "Uncleaned coral cut"
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"id": 23,
|
| 268 |
+
"situation": "Anaphylaxis",
|
| 269 |
+
"chat_text": "He got bit by an insect and now his throat is swelling up. He’s wheezing, covered in hives, and looks like he’s about to pass out.",
|
| 270 |
+
"responsive": "Drowsy",
|
| 271 |
+
"breathing": "Wheezing",
|
| 272 |
+
"pain": "4/10",
|
| 273 |
+
"main_problem": "Swollen throat, hives",
|
| 274 |
+
"temp": "37.2°C",
|
| 275 |
+
"circulation": "BP dropping rapidly",
|
| 276 |
+
"cause": "Unknown insect bite/food"
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"id": 24,
|
| 280 |
+
"situation": "Myocardial Infarction",
|
| 281 |
+
"chat_text": "He says it feels like an elephant is sitting on his chest. The pain is going down his left arm, he’s sweating, and his pulse feels irregular.",
|
| 282 |
+
"responsive": "Alert",
|
| 283 |
+
"breathing": "Shortness",
|
| 284 |
+
"pain": "9/10",
|
| 285 |
+
"main_problem": "Crushing chest pain/Left arm",
|
| 286 |
+
"temp": "37.0°C",
|
| 287 |
+
"circulation": "Irregular pulse, sweating",
|
| 288 |
+
"cause": "Clogged artery (Heart Attack)"
|
| 289 |
+
},
|
| 290 |
+
{
|
| 291 |
+
"id": 25,
|
| 292 |
+
"situation": "Diabetic Ketoacidosis",
|
| 293 |
+
"chat_text": "He’s very confused and his breath smells sweet, almost like fruit. He’s breathing very deeply and fast and says he’s incredibly thirsty.",
|
| 294 |
+
"responsive": "Confused",
|
| 295 |
+
"breathing": "Deep/Fast",
|
| 296 |
+
"pain": "3/10",
|
| 297 |
+
"main_problem": "Fruity breath, extreme thirst",
|
| 298 |
+
"temp": "37.4°C",
|
| 299 |
+
"circulation": "Weak pulse",
|
| 300 |
+
"cause": "Insulin pump failure"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"id": 26,
|
| 304 |
+
"situation": "Perforated Ulcer",
|
| 305 |
+
"chat_text": "He has a sudden, agonizing pain in his stomach. His belly feels as hard as a board and he’s pale and sweating.",
|
| 306 |
+
"responsive": "Alert",
|
| 307 |
+
"breathing": "Shallow",
|
| 308 |
+
"pain": "10/10",
|
| 309 |
+
"main_problem": "Sudden, board-like abdomen",
|
| 310 |
+
"temp": "37.8°C",
|
| 311 |
+
"circulation": "Shock signs",
|
| 312 |
+
"cause": "Long-term NSAID use (Advil)"
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"id": 27,
|
| 316 |
+
"situation": "Kidney Stones",
|
| 317 |
+
"chat_text": "He’s in 10 out of 10 pain in his side and back. He’s pacing around because he can't get comfortable and there is blood in his urine.",
|
| 318 |
+
"responsive": "Alert",
|
| 319 |
+
"breathing": "Fast",
|
| 320 |
+
"pain": "10/10",
|
| 321 |
+
"main_problem": "Agonizing flank pain/blood",
|
| 322 |
+
"temp": "37.3°C",
|
| 323 |
+
"circulation": "Pacing around, high BP",
|
| 324 |
+
"cause": "Dehydration"
|
| 325 |
+
},
|
| 326 |
+
{
|
| 327 |
+
"id": 28,
|
| 328 |
+
"situation": "Acute Asthma Attack",
|
| 329 |
+
"chat_text": "She’s having a massive asthma attack. Her inhaler isn't working and I can't hear any air moving in her chest at all. Her lips are turning blue.",
|
| 330 |
+
"responsive": "Alert",
|
| 331 |
+
"breathing": "Silent",
|
| 332 |
+
"pain": "7/10",
|
| 333 |
+
"main_problem": "No air movement (Silent chest)",
|
| 334 |
+
"temp": "37.0°C",
|
| 335 |
+
"circulation": "Tachycardia",
|
| 336 |
+
"cause": "Mold in cabin/ventilation"
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"id": 29,
|
| 340 |
+
"situation": "Ischemic Stroke",
|
| 341 |
+
"chat_text": "One side of his face is drooping and he can't move his right arm or leg. He’s awake but his blood pressure is extremely high.",
|
| 342 |
+
"responsive": "Alert",
|
| 343 |
+
"breathing": "Normal",
|
| 344 |
+
"pain": "2/10",
|
| 345 |
+
"main_problem": "Facial droop, R-side paralysis",
|
| 346 |
+
"temp": "36.8°C",
|
| 347 |
+
"circulation": "BP 180/110",
|
| 348 |
+
"cause": "Blood clot"
|
| 349 |
+
},
|
| 350 |
+
{
|
| 351 |
+
"id": 30,
|
| 352 |
+
"situation": "Status Epilepticus",
|
| 353 |
+
"chat_text": "He’s been having a violent seizure for over five minutes straight and it won't stop. His breathing is irregular and he’s turning red.",
|
| 354 |
+
"responsive": "Seizing",
|
| 355 |
+
"breathing": "Irregular",
|
| 356 |
+
"pain": "N/A",
|
| 357 |
+
"main_problem": "Continuous convulsions >5min",
|
| 358 |
+
"temp": "38.0°C",
|
| 359 |
+
"circulation": "Rapid pulse",
|
| 360 |
+
"cause": "Missed meds/High stress"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"id": 31,
|
| 364 |
+
"situation": "Carbon Monoxide",
|
| 365 |
+
"chat_text": "Everyone in the cabin is lethargic with a headache. One person has bright red skin and is breathing very slowly. We suspect an exhaust leak.",
|
| 366 |
+
"responsive": "Lethargic",
|
| 367 |
+
"breathing": "Slow",
|
| 368 |
+
"pain": "5/10",
|
| 369 |
+
"main_problem": "Cherry-red skin, headache",
|
| 370 |
+
"temp": "36.6°C",
|
| 371 |
+
"circulation": "Normal",
|
| 372 |
+
"cause": "Leaking heater/engine exhaust"
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"id": 32,
|
| 376 |
+
"situation": "Ciguatera Poisoning",
|
| 377 |
+
"chat_text": "We ate a barracuda and now he’s acting weird. He says cold water feels hot to him, and his heart rate has dropped to 40 beats per minute.",
|
| 378 |
+
"responsive": "Alert",
|
| 379 |
+
"breathing": "Normal",
|
| 380 |
+
"pain": "6/10",
|
| 381 |
+
"main_problem": "Hot feels cold, cold feels hot",
|
| 382 |
+
"temp": "37.2°C",
|
| 383 |
+
"circulation": "Bradycardia (Slow pulse)",
|
| 384 |
+
"cause": "Eating reef fish (Barracuda)"
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"id": 33,
|
| 388 |
+
"situation": "Box Jellyfish Sting",
|
| 389 |
+
"chat_text": "He was stung by a jellyfish and went into cardiac arrest almost immediately. There are massive red welts all over his chest and legs.",
|
| 390 |
+
"responsive": "Unconscious",
|
| 391 |
+
"breathing": "Arrested",
|
| 392 |
+
"pain": "10/10",
|
| 393 |
+
"main_problem": "Massive welts, heart failure",
|
| 394 |
+
"temp": "37.4°C",
|
| 395 |
+
"circulation": "Cardiac arrest signs",
|
| 396 |
+
"cause": "Swimming in doldrums"
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"id": 34,
|
| 400 |
+
"situation": "Cellulitis",
|
| 401 |
+
"chat_text": "A small cut on her leg has turned into a massive, hot, red swelling that is spreading quickly. She has a high fever.",
|
| 402 |
+
"responsive": "Alert",
|
| 403 |
+
"breathing": "Normal",
|
| 404 |
+
"pain": "7/10",
|
| 405 |
+
"main_problem": "Leg hot, red, and swollen",
|
| 406 |
+
"temp": "39.0°C",
|
| 407 |
+
"circulation": "Fast pulse",
|
| 408 |
+
"cause": "Infected shaving cut"
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"id": 35,
|
| 412 |
+
"situation": "Chemical Burn (Eyes)",
|
| 413 |
+
"chat_text": "Battery acid splashed directly into his eyes. He can't open them, he’s in 10 out of 10 pain, and his eyes look hazy and white.",
|
| 414 |
+
"responsive": "Alert",
|
| 415 |
+
"breathing": "Normal",
|
| 416 |
+
"pain": "10/10",
|
| 417 |
+
"main_problem": "Cannot open eyes, white haze",
|
| 418 |
+
"temp": "36.8°C",
|
| 419 |
+
"circulation": "Normal",
|
| 420 |
+
"cause": "Battery acid splash"
|
| 421 |
+
},
|
| 422 |
+
{
|
| 423 |
+
"id": 36,
|
| 424 |
+
"situation": "Aspiration Pneumonia",
|
| 425 |
+
"chat_text": "He’s been sick since he inhaled some vomit. He’s coughing up green gunk, has a high fever, and is struggling to breathe.",
|
| 426 |
+
"responsive": "Alert",
|
| 427 |
+
"breathing": "Labored",
|
| 428 |
+
"pain": "5/10",
|
| 429 |
+
"main_problem": "Productive cough (green)",
|
| 430 |
+
"temp": "39.2°C",
|
| 431 |
+
"circulation": "Low oxygen saturation",
|
| 432 |
+
"cause": "Vomit inhaled during storm"
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"id": 37,
|
| 436 |
+
"situation": "Acute Urinary Retention",
|
| 437 |
+
"chat_text": "He’s in extreme pain because he hasn't been able to pee for hours. His lower stomach is hard and bulging.",
|
| 438 |
+
"responsive": "Alert",
|
| 439 |
+
"breathing": "Normal",
|
| 440 |
+
"pain": "9/10",
|
| 441 |
+
"main_problem": "Bladder distended, cannot pee",
|
| 442 |
+
"temp": "37.1°C",
|
| 443 |
+
"circulation": "High BP",
|
| 444 |
+
"cause": "Enlarged prostate"
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"id": 38,
|
| 448 |
+
"situation": "Dental Abscess",
|
| 449 |
+
"chat_text": "His tooth is broken and now his entire face is swollen. His eye is starting to swell shut and he has a high fever.",
|
| 450 |
+
"responsive": "Alert",
|
| 451 |
+
"breathing": "Normal",
|
| 452 |
+
"pain": "8/10",
|
| 453 |
+
"main_problem": "Face swollen, eye closing",
|
| 454 |
+
"temp": "38.6°C",
|
| 455 |
+
"circulation": "Normal",
|
| 456 |
+
"cause": "Cracked tooth"
|
| 457 |
+
},
|
| 458 |
+
{
|
| 459 |
+
"id": 39,
|
| 460 |
+
"situation": "Pulmonary Embolism",
|
| 461 |
+
"chat_text": "He suddenly got a sharp pain in his chest and can't breathe. His lips are blue and he’s coughing up a little bit of blood.",
|
| 462 |
+
"responsive": "Alert",
|
| 463 |
+
"breathing": "Sharp pain",
|
| 464 |
+
"pain": "9/10",
|
| 465 |
+
"main_problem": "Sudden SOB, coughing blood",
|
| 466 |
+
"temp": "37.4°C",
|
| 467 |
+
"circulation": "Cyanosis (Blue lips)",
|
| 468 |
+
"cause": "DVT from long sitting/watch"
|
| 469 |
+
},
|
| 470 |
+
{
|
| 471 |
+
"id": 40,
|
| 472 |
+
"situation": "Bowel Obstruction",
|
| 473 |
+
"chat_text": "He hasn't been able to go to the bathroom or pass gas. Now he is actually vomiting stuff that smells like a bowel movement.",
|
| 474 |
+
"responsive": "Alert",
|
| 475 |
+
"breathing": "Normal",
|
| 476 |
+
"pain": "7/10",
|
| 477 |
+
"main_problem": "Fecal vomiting, no gas",
|
| 478 |
+
"temp": "37.6°C",
|
| 479 |
+
"circulation": "Low BP, dehydrated",
|
| 480 |
+
"cause": "Previous surgery/Adhesions"
|
| 481 |
+
},
|
| 482 |
+
{
|
| 483 |
+
"id": 41,
|
| 484 |
+
"situation": "Dengue Hemorrhagic",
|
| 485 |
+
"chat_text": "He has a high fever and his gums are bleeding. He’s covered in dark, blackish bruises and looks very weak.",
|
| 486 |
+
"responsive": "Drowsy",
|
| 487 |
+
"breathing": "Normal",
|
| 488 |
+
"pain": "7/10",
|
| 489 |
+
"main_problem": "Bleeding gums, black bruises",
|
| 490 |
+
"temp": "39.8°C",
|
| 491 |
+
"circulation": "Low BP (Shock)",
|
| 492 |
+
"cause": "Mosquito bite in Sumatra"
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"id": 42,
|
| 496 |
+
"situation": "Malaria (Falciparum)",
|
| 497 |
+
"chat_text": "He’s totally confused and has a 40.5°C fever. His eyes look yellow and he’s breathing very fast.",
|
| 498 |
+
"responsive": "Confused",
|
| 499 |
+
"breathing": "Rapid",
|
| 500 |
+
"pain": "6/10",
|
| 501 |
+
"main_problem": "Cycling high fever, yellow eyes",
|
| 502 |
+
"temp": "40.5°C",
|
| 503 |
+
"circulation": "Tachycardia",
|
| 504 |
+
"cause": "Mosquito bite"
|
| 505 |
+
},
|
| 506 |
+
{
|
| 507 |
+
"id": 43,
|
| 508 |
+
"situation": "Giardia (Severe)",
|
| 509 |
+
"chat_text": "He has constant, explosive diarrhea that smells like sulfur. He’s becoming very dehydrated and lightheaded.",
|
| 510 |
+
"responsive": "Alert",
|
| 511 |
+
"breathing": "Normal",
|
| 512 |
+
"pain": "5/10",
|
| 513 |
+
"main_problem": "Explosive sulfurous diarrhea",
|
| 514 |
+
"temp": "37.5°C",
|
| 515 |
+
"circulation": "Orthostatic hypotension",
|
| 516 |
+
"cause": "Bad water tank hygiene"
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"id": 44,
|
| 520 |
+
"situation": "Corneal Ulcer",
|
| 521 |
+
"chat_text": "His eye is bright red and filled with pus. He says it feels like there is sand in it and he can't look at any light.",
|
| 522 |
+
"responsive": "Alert",
|
| 523 |
+
"breathing": "Normal",
|
| 524 |
+
"pain": "9/10",
|
| 525 |
+
"main_problem": "Constant sand sensation, pus",
|
| 526 |
+
"temp": "36.8°C",
|
| 527 |
+
"circulation": "Normal",
|
| 528 |
+
"cause": "Contact lens left in too long"
|
| 529 |
+
},
|
| 530 |
+
{
|
| 531 |
+
"id": 45,
|
| 532 |
+
"situation": "Orchitis",
|
| 533 |
+
"chat_text": "He has sudden, 9 out of 10 pain in his groin. One side is swollen to the size of a grapefruit and he has a fever.",
|
| 534 |
+
"responsive": "Alert",
|
| 535 |
+
"breathing": "Normal",
|
| 536 |
+
"pain": "9/10",
|
| 537 |
+
"main_problem": "Scrotal swelling (grapefruit)",
|
| 538 |
+
"temp": "38.8°C",
|
| 539 |
+
"circulation": "Pain-induced high BP",
|
| 540 |
+
"cause": "Bacterial/STI"
|
| 541 |
+
},
|
| 542 |
+
{
|
| 543 |
+
"id": 46,
|
| 544 |
+
"situation": "Meningitis",
|
| 545 |
+
"chat_text": "He has a very high fever and a splitting headache. His neck is so stiff he can't touch his chin to his chest and light hurts his eyes.",
|
| 546 |
+
"responsive": "Lethargic",
|
| 547 |
+
"breathing": "Normal",
|
| 548 |
+
"pain": "9/10",
|
| 549 |
+
"main_problem": "Stiff neck, light sensitivity",
|
| 550 |
+
"temp": "40.1°C",
|
| 551 |
+
"circulation": "BP 100/60",
|
| 552 |
+
"cause": "Viral/Bacterial"
|
| 553 |
+
},
|
| 554 |
+
{
|
| 555 |
+
"id": 47,
|
| 556 |
+
"situation": "Staph (MRSA)",
|
| 557 |
+
"chat_text": "He has a huge, painful, pus-filled lump that looks like a spider bite. It’s hot to the touch and he has a fever.",
|
| 558 |
+
"responsive": "Alert",
|
| 559 |
+
"breathing": "Normal",
|
| 560 |
+
"pain": "6/10",
|
| 561 |
+
"main_problem": "\"Spider bite\" look, pus-filled",
|
| 562 |
+
"temp": "38.2°C",
|
| 563 |
+
"circulation": "Normal",
|
| 564 |
+
"cause": "Shared towels/gym gear"
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
"id": 48,
|
| 568 |
+
"situation": "Leptospirosis",
|
| 569 |
+
"chat_text": "His eyes are bright red, his skin looks yellow, and his calves are in a lot of pain. He has a high fever and low blood pressure.",
|
| 570 |
+
"responsive": "Alert",
|
| 571 |
+
"breathing": "Normal",
|
| 572 |
+
"pain": "8/10",
|
| 573 |
+
"main_problem": "Calf pain, jaundice, red eyes",
|
| 574 |
+
"temp": "39.4°C",
|
| 575 |
+
"circulation": "Low BP",
|
| 576 |
+
"cause": "Rat urine in bilge water"
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"id": 49,
|
| 580 |
+
"situation": "Sea Urchin Granuloma",
|
| 581 |
+
"chat_text": "He stepped on a sea urchin and has over 20 spines stuck in his foot. His joints are starting to feel stiff and lock up.",
|
| 582 |
+
"responsive": "Alert",
|
| 583 |
+
"breathing": "Normal",
|
| 584 |
+
"pain": "4/10",
|
| 585 |
+
"main_problem": "20+ spines, joints locking",
|
| 586 |
+
"temp": "37.2°C",
|
| 587 |
+
"circulation": "Normal",
|
| 588 |
+
"cause": "Stepped on urchin in surf"
|
| 589 |
+
},
|
| 590 |
+
{
|
| 591 |
+
"id": 50,
|
| 592 |
+
"situation": "Ectopic Pregnancy",
|
| 593 |
+
"chat_text": "She has a sudden, ripping pain in her lower stomach and just fainted. She is very pale, cold, and her blood pressure is very low.",
|
| 594 |
+
"responsive": "Alert",
|
| 595 |
+
"breathing": "Fast",
|
| 596 |
+
"pain": "10/10",
|
| 597 |
+
"main_problem": "Ripping pelvic pain, fainting",
|
| 598 |
+
"temp": "36.5°C",
|
| 599 |
+
"circulation": "BP 85/40 (Internal bleed)",
|
| 600 |
+
"cause": "Ruptured fallopian tube"
|
| 601 |
+
}
|
| 602 |
+
]
|
static/favicon.svg
ADDED
|
|
static/js/chat.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/js/crew.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/js/equipment.js
ADDED
|
@@ -0,0 +1,1315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================================================
|
| 2 |
+
* Author: Rick Escher
|
| 3 |
+
* Project: SailingMedAdvisor
|
| 4 |
+
* Context: Google HAI-DEF Framework
|
| 5 |
+
* Models: Google MedGemmas
|
| 6 |
+
* Program: Kaggle Impact Challenge
|
| 7 |
+
* ========================================================================== */
|
| 8 |
+
/*
|
| 9 |
+
File: static/js/equipment.js
|
| 10 |
+
Author notes: Equipment and medical supplies management.
|
| 11 |
+
|
| 12 |
+
Key Responsibilities:
|
| 13 |
+
- Medical equipment inventory (durable goods: AED, blood pressure cuff, etc.)
|
| 14 |
+
- Consumables tracking (bandages, gauze, gloves, syringes, etc.)
|
| 15 |
+
- Import/export functionality for equipment lists (TSV format)
|
| 16 |
+
- Resource availability trackiang (in-stock vs unavailable)
|
| 17 |
+
- Equipment classification and categorization
|
| 18 |
+
|
| 19 |
+
Equipment Classification:
|
| 20 |
+
- 'durable': Medical equipment (reusable, tracked equipment)
|
| 21 |
+
- 'consumable': Single-use supplies (tracked by quantity)
|
| 22 |
+
- 'medication': Medicines (redirects to pharmacy module)
|
| 23 |
+
|
| 24 |
+
Data Flow:
|
| 25 |
+
- Equipment: /api/data/tools (tools.json)
|
| 26 |
+
- Medications: /api/data/inventory (inventory.json)
|
| 27 |
+
- Import/Export: TSV files for bulk updates
|
| 28 |
+
|
| 29 |
+
Integration Points:
|
| 30 |
+
- pharmacy.js: Medication management
|
| 31 |
+
- main.js: Tab navigation, initial data load
|
| 32 |
+
*/
|
| 33 |
+
|
| 34 |
+
// Utility function imports from utils.js (with fallbacks)
|
| 35 |
+
const workspaceHeaders = (window.Utils && window.Utils.workspaceHeaders) ? window.Utils.workspaceHeaders : (extra = {}) => extra;
|
| 36 |
+
const fetchJson = (window.Utils && window.Utils.fetchJson) ? window.Utils.fetchJson : async (url, options = {}) => {
|
| 37 |
+
const res = await fetch(url, { credentials: 'same-origin', ...options });
|
| 38 |
+
const data = await res.json().catch(() => ({}));
|
| 39 |
+
if (!res.ok || data.error) throw new Error(data.error || `Status ${res.status}`);
|
| 40 |
+
return data;
|
| 41 |
+
};
|
| 42 |
+
const eqEscapeHtml = (window.Utils && window.Utils.escapeHtml) ? window.Utils.escapeHtml : (str) => str;
|
| 43 |
+
|
| 44 |
+
// ============================================================================
|
| 45 |
+
// STATE MANAGEMENT
|
| 46 |
+
// ============================================================================
|
| 47 |
+
|
| 48 |
+
let equipmentCache = []; // Cached equipment/consumables from server
|
| 49 |
+
const equipmentSaveTimers = {}; // Debounce timers for auto-save
|
| 50 |
+
|
| 51 |
+
const eqWorkspaceHeaders = workspaceHeaders;
|
| 52 |
+
|
| 53 |
+
const DEFAULT_EQUIPMENT_CATEGORIES = [
|
| 54 |
+
'Diagnostics & monitoring',
|
| 55 |
+
'Instruments & tools',
|
| 56 |
+
'Airway & breathing',
|
| 57 |
+
'Splints & supports',
|
| 58 |
+
'Eye care',
|
| 59 |
+
'Dental',
|
| 60 |
+
'PPE',
|
| 61 |
+
'Survival & utility',
|
| 62 |
+
'Other'
|
| 63 |
+
];
|
| 64 |
+
const DEFAULT_CONSUMABLE_CATEGORIES = [
|
| 65 |
+
'Wound care & dressings',
|
| 66 |
+
'Burn care',
|
| 67 |
+
'Antiseptics & hygiene',
|
| 68 |
+
'Irrigation & syringes',
|
| 69 |
+
'Splints & supports',
|
| 70 |
+
'PPE',
|
| 71 |
+
'Survival & utility',
|
| 72 |
+
'Other'
|
| 73 |
+
];
|
| 74 |
+
|
| 75 |
+
const EQ_TIER_OPTIONS = [
|
| 76 |
+
{ value: '', label: 'Select...' },
|
| 77 |
+
{ value: 'Tier 1', label: 'Tier 1 — Emergency & Surgical' },
|
| 78 |
+
{ value: 'Tier 2', label: 'Tier 2 — Stabilization & Acute' },
|
| 79 |
+
{ value: 'Tier 3', label: 'Tier 3 — Supportive & Maintenance' },
|
| 80 |
+
];
|
| 81 |
+
|
| 82 |
+
const EQ_TIER_SUBCATEGORIES = {
|
| 83 |
+
'Tier 1': [
|
| 84 |
+
'Local Anesthesia',
|
| 85 |
+
'Respiratory/Anaphylaxis',
|
| 86 |
+
'Critical Antibiotics (Systemic)',
|
| 87 |
+
'Critical Antibiotics (Ophthalmic)',
|
| 88 |
+
'Emergency Steroids',
|
| 89 |
+
],
|
| 90 |
+
'Tier 2': [
|
| 91 |
+
'Analgesics (Moderate/Severe Pain)',
|
| 92 |
+
'NSAIDs (Mild/Moderate Pain)',
|
| 93 |
+
'Topical Antiseptics/Antibiotics',
|
| 94 |
+
'Standard Antibiotics/Antivirals',
|
| 95 |
+
'Antihistamines/Steroid Creams',
|
| 96 |
+
],
|
| 97 |
+
'Tier 3': [
|
| 98 |
+
'Gastrointestinal (Nausea/Diarrhea/Reflux)',
|
| 99 |
+
'Hydration/Electrolytes',
|
| 100 |
+
'Dermatological (Fungal/Parasitic)',
|
| 101 |
+
'Diagnostic/Maintenance',
|
| 102 |
+
'Chronic/Behavioral',
|
| 103 |
+
],
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* eqBuildTierOptions: function-level behavior note for maintainers.
|
| 108 |
+
* Keep this block synchronized with implementation changes.
|
| 109 |
+
*/
|
| 110 |
+
function eqBuildTierOptions(selected = '') {
|
| 111 |
+
return EQ_TIER_OPTIONS.map((opt) => `<option value="${eqEscapeHtml(opt.value)}" ${opt.value === selected ? 'selected' : ''}>${eqEscapeHtml(opt.label)}</option>`).join('');
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* eqBuildTierSubcategoryOptions: function-level behavior note for maintainers.
|
| 116 |
+
* Keep this block synchronized with implementation changes.
|
| 117 |
+
*/
|
| 118 |
+
function eqBuildTierSubcategoryOptions(tier = '', selected = '') {
|
| 119 |
+
const options = [{ value: '', label: 'Select...' }];
|
| 120 |
+
const list = EQ_TIER_SUBCATEGORIES[tier] || [];
|
| 121 |
+
list.forEach((entry) => options.push({ value: entry, label: entry }));
|
| 122 |
+
return options.map((opt) => `<option value="${eqEscapeHtml(opt.value)}" ${opt.value === selected ? 'selected' : ''}>${eqEscapeHtml(opt.label)}</option>`).join('');
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* handleEquipmentTierChange: function-level behavior note for maintainers.
|
| 127 |
+
* Keep this block synchronized with implementation changes.
|
| 128 |
+
*/
|
| 129 |
+
function handleEquipmentTierChange(itemId) {
|
| 130 |
+
const tierEl = document.getElementById(`eq-tier-${itemId}`);
|
| 131 |
+
const catEl = document.getElementById(`eq-tiercat-${itemId}`);
|
| 132 |
+
if (!tierEl || !catEl) return;
|
| 133 |
+
const tierVal = tierEl.value || '';
|
| 134 |
+
const current = catEl.value || '';
|
| 135 |
+
catEl.innerHTML = eqBuildTierSubcategoryOptions(tierVal, current);
|
| 136 |
+
if (current && !(EQ_TIER_SUBCATEGORIES[tierVal] || []).includes(current)) {
|
| 137 |
+
catEl.value = '';
|
| 138 |
+
}
|
| 139 |
+
scheduleSaveEquipment(itemId);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* initTierFormControls: function-level behavior note for maintainers.
|
| 144 |
+
* Keep this block synchronized with implementation changes.
|
| 145 |
+
*/
|
| 146 |
+
function initTierFormControls() {
|
| 147 |
+
const pairs = [
|
| 148 |
+
{ tier: 'eq-new-tier', cat: 'eq-new-tiercat' },
|
| 149 |
+
{ tier: 'cons-new-tier', cat: 'cons-new-tiercat' },
|
| 150 |
+
{ tier: 'med-new-tier', cat: 'med-new-tiercat' },
|
| 151 |
+
];
|
| 152 |
+
pairs.forEach(({ tier, cat }) => {
|
| 153 |
+
const tierEl = document.getElementById(tier);
|
| 154 |
+
const catEl = document.getElementById(cat);
|
| 155 |
+
if (!tierEl || !catEl) return;
|
| 156 |
+
tierEl.innerHTML = eqBuildTierOptions(tierEl.value || '');
|
| 157 |
+
catEl.innerHTML = eqBuildTierSubcategoryOptions(tierEl.value || '', catEl.value || '');
|
| 158 |
+
if (!tierEl.dataset.bound) {
|
| 159 |
+
tierEl.dataset.bound = 'true';
|
| 160 |
+
tierEl.addEventListener('change', () => {
|
| 161 |
+
catEl.innerHTML = eqBuildTierSubcategoryOptions(tierEl.value || '', '');
|
| 162 |
+
});
|
| 163 |
+
}
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
window.refreshEquipmentCategoriesFromSettings = function () {
|
| 168 |
+
if (equipmentCache.length) {
|
| 169 |
+
renderEquipment(equipmentCache);
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Update section header count badges.
|
| 175 |
+
*
|
| 176 |
+
* Displays item counts in section headers (e.g., "Medical Equipment (12)").
|
| 177 |
+
*
|
| 178 |
+
* @param {string} id - Element ID of the count badge
|
| 179 |
+
* @param {number} count - Number of items to display
|
| 180 |
+
*/
|
| 181 |
+
function updateSectionCount(id, count) {
|
| 182 |
+
const el = document.getElementById(id);
|
| 183 |
+
if (el) {
|
| 184 |
+
el.textContent = `(${count})`;
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* Normalize equipment item with default values.
|
| 190 |
+
*
|
| 191 |
+
* Ensures all expected fields exist with appropriate defaults.
|
| 192 |
+
* Similar to pharmacy's ensurePharmacyDefaults but for equipment/consumables.
|
| 193 |
+
*
|
| 194 |
+
* Equipment Structure:
|
| 195 |
+
* ```javascript
|
| 196 |
+
* {
|
| 197 |
+
* id: string, // Unique ID (eq-timestamp-random)
|
| 198 |
+
* name: string, // Equipment/consumable name
|
| 199 |
+
* category: string, // Category for grouping
|
| 200 |
+
* type: string, // 'durable' | 'consumable' | 'medication'
|
| 201 |
+
* storageLocation: string, // Where it's stored
|
| 202 |
+
* subLocation: string, // Specific location details
|
| 203 |
+
* status: string, // 'In Stock' | 'Out of Stock' | 'Maintenance'
|
| 204 |
+
* expiryDate: string, // ISO date for consumables
|
| 205 |
+
* lastInspection: string, // ISO date of last inspection
|
| 206 |
+
* batteryType: string, // For powered equipment
|
| 207 |
+
* batteryStatus: string, // Battery condition
|
| 208 |
+
* calibrationDue: string, // ISO date for next calibration
|
| 209 |
+
* totalQty: string, // Current quantity
|
| 210 |
+
* minPar: string, // Minimum stock level
|
| 211 |
+
* supplier: string, // Supplier name
|
| 212 |
+
* parentId: string, // For nested equipment (e.g., kit contents)
|
| 213 |
+
* requiresPower: boolean, // Needs batteries/power
|
| 214 |
+
* notes: string, // Additional information
|
| 215 |
+
* excludeFromResources: boolean // Hide from public resource lists
|
| 216 |
+
* }
|
| 217 |
+
* ```
|
| 218 |
+
*
|
| 219 |
+
* @param {Object} item - Raw equipment data
|
| 220 |
+
* @returns {Object} Normalized equipment object with all fields
|
| 221 |
+
*/
|
| 222 |
+
function ensureEquipmentDefaults(item) {
|
| 223 |
+
return {
|
| 224 |
+
id: item.id || `eq-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
|
| 225 |
+
name: item.name || '',
|
| 226 |
+
category: item.category || '',
|
| 227 |
+
type: item.type || 'durable', // durable | consumable
|
| 228 |
+
storageLocation: item.storageLocation || '',
|
| 229 |
+
subLocation: item.subLocation || '',
|
| 230 |
+
status: item.status || 'In Stock',
|
| 231 |
+
expiryDate: item.expiryDate || '',
|
| 232 |
+
lastInspection: item.lastInspection || '',
|
| 233 |
+
batteryType: item.batteryType || '',
|
| 234 |
+
batteryStatus: item.batteryStatus || '',
|
| 235 |
+
calibrationDue: item.calibrationDue || '',
|
| 236 |
+
totalQty: item.totalQty || '',
|
| 237 |
+
minPar: item.minPar || '',
|
| 238 |
+
supplier: item.supplier || '',
|
| 239 |
+
parentId: item.parentId || '',
|
| 240 |
+
requiresPower: item.requiresPower || false,
|
| 241 |
+
priorityTier: item.priorityTier || '',
|
| 242 |
+
tierCategory: item.tierCategory || '',
|
| 243 |
+
notes: item.notes || '',
|
| 244 |
+
excludeFromResources: Boolean(item.excludeFromResources),
|
| 245 |
+
};
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Load and render all equipment from server.
|
| 250 |
+
*
|
| 251 |
+
* Loading Process:
|
| 252 |
+
* 1. Fetches from /api/data/tools
|
| 253 |
+
* 2. Normalizes all items
|
| 254 |
+
* 3. Classifies into equipment/consumables/medications
|
| 255 |
+
* 4. Renders to appropriate lists
|
| 256 |
+
* 5. Updates section count badges
|
| 257 |
+
*
|
| 258 |
+
* @param {string} expandId - Optional ID of item to auto-expand after render
|
| 259 |
+
*/
|
| 260 |
+
async function loadEquipment(expandId = null) {
|
| 261 |
+
const list = document.getElementById('equipment-list');
|
| 262 |
+
if (!list) return;
|
| 263 |
+
list.innerHTML = '';
|
| 264 |
+
try {
|
| 265 |
+
if (typeof refreshEquipmentCategoryOptions === 'function') {
|
| 266 |
+
refreshEquipmentCategoryOptions();
|
| 267 |
+
}
|
| 268 |
+
initTierFormControls();
|
| 269 |
+
const data = await fetchJson('/api/data/tools');
|
| 270 |
+
equipmentCache = (Array.isArray(data) ? data : []).map(ensureEquipmentDefaults);
|
| 271 |
+
renderEquipment(equipmentCache, expandId);
|
| 272 |
+
} catch (err) {
|
| 273 |
+
updateSectionCount('equipment-count', 0);
|
| 274 |
+
updateSectionCount('consumables-count', 0);
|
| 275 |
+
const errHtml = `<div style="color:red; padding:12px;">Error loading equipment: ${err.message}</div>`;
|
| 276 |
+
const equipmentList = document.getElementById('equipment-list');
|
| 277 |
+
const consumablesList = document.getElementById('consumables-list');
|
| 278 |
+
if (equipmentList) equipmentList.innerHTML = errHtml;
|
| 279 |
+
if (consumablesList) consumablesList.innerHTML = errHtml;
|
| 280 |
+
list.innerHTML = errHtml;
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/**
|
| 285 |
+
* Classify equipment into category buckets.
|
| 286 |
+
*
|
| 287 |
+
* Classification Rules:
|
| 288 |
+
* 1. type='medication' → 'medication'
|
| 289 |
+
* 2. type='consumable' → 'consumable'
|
| 290 |
+
* 3. Otherwise → 'equipment' (durable goods)
|
| 291 |
+
*
|
| 292 |
+
* Used to route items to correct display lists and apply appropriate styling.
|
| 293 |
+
*
|
| 294 |
+
* @param {Object} item - Equipment item to classify
|
| 295 |
+
* @returns {string} 'medication' | 'consumable' | 'equipment'
|
| 296 |
+
*/
|
| 297 |
+
function classifyEquipment(item) {
|
| 298 |
+
const type = (item.type || '').toLowerCase();
|
| 299 |
+
if (type === 'medication') return 'medication';
|
| 300 |
+
if (type === 'consumable') return 'consumable';
|
| 301 |
+
return 'equipment';
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
/**
|
| 305 |
+
* showImportStatus: function-level behavior note for maintainers.
|
| 306 |
+
* Keep this block synchronized with implementation changes.
|
| 307 |
+
*/
|
| 308 |
+
function showImportStatus(id, message, isError = false) {
|
| 309 |
+
const el = document.getElementById(id);
|
| 310 |
+
if (!el) return;
|
| 311 |
+
el.textContent = message;
|
| 312 |
+
el.style.color = isError ? 'var(--red)' : '#1f2d3d';
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
async function ensureEquipmentCacheLoaded() {
|
| 316 |
+
if (equipmentCache.length) return equipmentCache;
|
| 317 |
+
const data = await fetchJson('/api/data/tools');
|
| 318 |
+
equipmentCache = (Array.isArray(data) ? data : []).map(ensureEquipmentDefaults);
|
| 319 |
+
return equipmentCache;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/**
|
| 323 |
+
* Sanitize value for tab-delimited (TSV) export.
|
| 324 |
+
*
|
| 325 |
+
* TSV Requirements:
|
| 326 |
+
* - No tab characters (field delimiter)
|
| 327 |
+
* - No newlines (row delimiter)
|
| 328 |
+
* - Consistent whitespace
|
| 329 |
+
*
|
| 330 |
+
* Transformations:
|
| 331 |
+
* - Tabs → spaces
|
| 332 |
+
* - Newlines → spaces
|
| 333 |
+
* - Trim leading/trailing whitespace
|
| 334 |
+
* - Null/undefined → empty string
|
| 335 |
+
*
|
| 336 |
+
* @param {any} value - Value to sanitize
|
| 337 |
+
* @returns {string} TSV-safe string
|
| 338 |
+
*/
|
| 339 |
+
function sanitizeTSVField(value) {
|
| 340 |
+
const text = value == null ? '' : value.toString();
|
| 341 |
+
return text.replace(/\t/g, ' ').replace(/\r?\n/g, ' ').trim();
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/**
|
| 345 |
+
* Build tab-delimited (TSV) content from equipment items.
|
| 346 |
+
*
|
| 347 |
+
* TSV Format (3 columns):
|
| 348 |
+
* Name Quantity Comment
|
| 349 |
+
*
|
| 350 |
+
* Features:
|
| 351 |
+
* - Alphabetically sorted by name
|
| 352 |
+
* - Sanitized fields (no tabs/newlines)
|
| 353 |
+
* - Customizable comment column via commentFn
|
| 354 |
+
* - Filters empty rows
|
| 355 |
+
*
|
| 356 |
+
* Compatible with Excel, Google Sheets, Numbers, and text editors.
|
| 357 |
+
*
|
| 358 |
+
* @param {Array<Object>} items - Equipment items to export
|
| 359 |
+
* @param {Function} commentFn - Function to extract comment from item
|
| 360 |
+
* @returns {string} TSV-formatted text
|
| 361 |
+
*/
|
| 362 |
+
function buildTabDelimitedContent(items, commentFn) {
|
| 363 |
+
const rows = [...items]
|
| 364 |
+
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }))
|
| 365 |
+
.map((item) => {
|
| 366 |
+
const name = sanitizeTSVField(item.name);
|
| 367 |
+
const quantity = sanitizeTSVField(item.totalQty);
|
| 368 |
+
const comment = sanitizeTSVField(typeof commentFn === 'function' ? commentFn(item) : item.notes);
|
| 369 |
+
return [name, quantity, comment].join('\t');
|
| 370 |
+
})
|
| 371 |
+
.filter((line) => line.trim());
|
| 372 |
+
return rows.join('\n');
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
/**
|
| 376 |
+
* Trigger browser download of TSV file.
|
| 377 |
+
*
|
| 378 |
+
* Process:
|
| 379 |
+
* 1. Creates Blob with TSV MIME type
|
| 380 |
+
* 2. Generates object URL
|
| 381 |
+
* 3. Creates temporary <a> element
|
| 382 |
+
* 4. Triggers click to start download
|
| 383 |
+
* 5. Cleans up object URL after 1.5s
|
| 384 |
+
*
|
| 385 |
+
* MIME Type: text/tab-separated-values
|
| 386 |
+
*
|
| 387 |
+
* @param {string} filename - Suggested filename for download
|
| 388 |
+
* @param {string} content - TSV-formatted content
|
| 389 |
+
*/
|
| 390 |
+
function downloadTabDelimitedFile(filename, content) {
|
| 391 |
+
const blob = new Blob([content], { type: 'text/tab-separated-values' });
|
| 392 |
+
const url = URL.createObjectURL(blob);
|
| 393 |
+
const link = document.createElement('a');
|
| 394 |
+
link.href = url;
|
| 395 |
+
link.download = filename;
|
| 396 |
+
document.body.appendChild(link);
|
| 397 |
+
link.click();
|
| 398 |
+
document.body.removeChild(link);
|
| 399 |
+
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
async function exportConsumables() {
|
| 403 |
+
try {
|
| 404 |
+
const items = await ensureEquipmentCacheLoaded();
|
| 405 |
+
const consumables = items.filter((item) => classifyEquipment(item) === 'consumable');
|
| 406 |
+
if (!consumables.length) {
|
| 407 |
+
showImportStatus('consumables-import-status', 'No consumables available to export.', true);
|
| 408 |
+
return;
|
| 409 |
+
}
|
| 410 |
+
const content = buildTabDelimitedContent(consumables, (item) => item.notes);
|
| 411 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 412 |
+
downloadTabDelimitedFile(`consumables-${timestamp}.tsv`, content);
|
| 413 |
+
showImportStatus('consumables-import-status', `Prepared ${consumables.length} consumable(s) for download.`);
|
| 414 |
+
} catch (err) {
|
| 415 |
+
showImportStatus('consumables-import-status', err.message, true);
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
/**
|
| 420 |
+
* parseTabDelimitedRows: function-level behavior note for maintainers.
|
| 421 |
+
* Keep this block synchronized with implementation changes.
|
| 422 |
+
*/
|
| 423 |
+
function parseTabDelimitedRows(text) {
|
| 424 |
+
if (typeof text !== 'string') return [];
|
| 425 |
+
return text
|
| 426 |
+
.split(/\r?\n/)
|
| 427 |
+
.map((line) => line.trim())
|
| 428 |
+
.filter(Boolean)
|
| 429 |
+
.map((line) => {
|
| 430 |
+
const cols = line.split('\t');
|
| 431 |
+
return {
|
| 432 |
+
name: (cols[0] || '').trim(),
|
| 433 |
+
quantity: (cols[1] || '').trim(),
|
| 434 |
+
comment: cols.length > 2 ? cols.slice(2).join('\t').trim() : '',
|
| 435 |
+
};
|
| 436 |
+
})
|
| 437 |
+
.filter((row) => row.name);
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
/**
|
| 441 |
+
* parseConsumableFileText: function-level behavior note for maintainers.
|
| 442 |
+
* Keep this block synchronized with implementation changes.
|
| 443 |
+
*/
|
| 444 |
+
function parseConsumableFileText(text) {
|
| 445 |
+
return parseTabDelimitedRows(text).map((row) => ({
|
| 446 |
+
name: row.name,
|
| 447 |
+
totalQty: row.quantity,
|
| 448 |
+
notes: row.comment,
|
| 449 |
+
type: 'consumable',
|
| 450 |
+
}));
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
/**
|
| 454 |
+
* parseEquipmentFileText: function-level behavior note for maintainers.
|
| 455 |
+
* Keep this block synchronized with implementation changes.
|
| 456 |
+
*/
|
| 457 |
+
function parseEquipmentFileText(text) {
|
| 458 |
+
return parseTabDelimitedRows(text).map((row) => ({
|
| 459 |
+
name: row.name,
|
| 460 |
+
totalQty: row.quantity,
|
| 461 |
+
notes: row.comment,
|
| 462 |
+
type: 'durable',
|
| 463 |
+
}));
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
/**
|
| 467 |
+
* entryNameKey: function-level behavior note for maintainers.
|
| 468 |
+
* Keep this block synchronized with implementation changes.
|
| 469 |
+
*/
|
| 470 |
+
function entryNameKey(name) {
|
| 471 |
+
return (name || '').trim().toLowerCase();
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/**
|
| 475 |
+
* buildEntryMap: function-level behavior note for maintainers.
|
| 476 |
+
* Keep this block synchronized with implementation changes.
|
| 477 |
+
*/
|
| 478 |
+
function buildEntryMap(entries) {
|
| 479 |
+
const map = new Map();
|
| 480 |
+
entries.forEach((entry) => {
|
| 481 |
+
const key = entryNameKey(entry.name);
|
| 482 |
+
if (!key) return;
|
| 483 |
+
map.set(key, entry);
|
| 484 |
+
});
|
| 485 |
+
return map;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/**
|
| 489 |
+
* mergeEntriesByName: function-level behavior note for maintainers.
|
| 490 |
+
* Keep this block synchronized with implementation changes.
|
| 491 |
+
*/
|
| 492 |
+
function mergeEntriesByName(existing, entryMap, targetClassifier) {
|
| 493 |
+
const result = [];
|
| 494 |
+
const usedKeys = new Set();
|
| 495 |
+
(existing || []).forEach((item) => {
|
| 496 |
+
const category = classifyEquipment(item);
|
| 497 |
+
if (category === targetClassifier) {
|
| 498 |
+
const key = entryNameKey(item.name);
|
| 499 |
+
if (entryMap.has(key)) {
|
| 500 |
+
result.push(entryMap.get(key));
|
| 501 |
+
usedKeys.add(key);
|
| 502 |
+
return;
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
result.push(item);
|
| 506 |
+
});
|
| 507 |
+
entryMap.forEach((entry, key) => {
|
| 508 |
+
if (!usedKeys.has(key)) {
|
| 509 |
+
result.push(entry);
|
| 510 |
+
}
|
| 511 |
+
});
|
| 512 |
+
return result;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
async function mergeConsumableImports(entries, statusId) {
|
| 516 |
+
if (!entries.length) {
|
| 517 |
+
showImportStatus(statusId, 'No consumables found in file.', true);
|
| 518 |
+
return;
|
| 519 |
+
}
|
| 520 |
+
const normalized = entries.map((entry) => ensureEquipmentDefaults({ ...entry, type: 'consumable' }));
|
| 521 |
+
const data = await fetchJson('/api/data/tools');
|
| 522 |
+
const existing = Array.isArray(data) ? data.map(ensureEquipmentDefaults) : [];
|
| 523 |
+
const entryMap = buildEntryMap(normalized);
|
| 524 |
+
const merged = mergeEntriesByName(existing, entryMap, 'consumable');
|
| 525 |
+
|
| 526 |
+
const saveRes = await fetch('/api/data/tools', {
|
| 527 |
+
method: 'POST',
|
| 528 |
+
headers: { 'Content-Type': 'application/json' },
|
| 529 |
+
body: JSON.stringify(merged),
|
| 530 |
+
credentials: 'same-origin',
|
| 531 |
+
});
|
| 532 |
+
if (!saveRes.ok) {
|
| 533 |
+
let detail = '';
|
| 534 |
+
try {
|
| 535 |
+
const err = await saveRes.json();
|
| 536 |
+
detail = err?.error ? `: ${err.error}` : '';
|
| 537 |
+
} catch (_) {}
|
| 538 |
+
throw new Error(`Save failed (${saveRes.status})${detail}`);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
equipmentCache = merged;
|
| 542 |
+
renderEquipment(equipmentCache);
|
| 543 |
+
showImportStatus(statusId, `Imported ${entryMap.size} consumable(s) from file.`);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
async function mergeEquipmentImports(entries, statusId) {
|
| 547 |
+
if (!entries.length) {
|
| 548 |
+
showImportStatus(statusId, 'No equipment entries found in file.', true);
|
| 549 |
+
return;
|
| 550 |
+
}
|
| 551 |
+
const normalized = entries.map((entry) => ensureEquipmentDefaults({ ...entry, type: 'durable' }));
|
| 552 |
+
const data = await fetchJson('/api/data/tools');
|
| 553 |
+
const existing = Array.isArray(data) ? data.map(ensureEquipmentDefaults) : [];
|
| 554 |
+
const entryMap = buildEntryMap(normalized);
|
| 555 |
+
const merged = mergeEntriesByName(existing, entryMap, 'equipment');
|
| 556 |
+
|
| 557 |
+
const saveRes = await fetch('/api/data/tools', {
|
| 558 |
+
method: 'POST',
|
| 559 |
+
headers: { 'Content-Type': 'application/json' },
|
| 560 |
+
body: JSON.stringify(merged),
|
| 561 |
+
credentials: 'same-origin',
|
| 562 |
+
});
|
| 563 |
+
if (!saveRes.ok) {
|
| 564 |
+
let detail = '';
|
| 565 |
+
try {
|
| 566 |
+
const err = await saveRes.json();
|
| 567 |
+
detail = err?.error ? `: ${err.error}` : '';
|
| 568 |
+
} catch (_) {}
|
| 569 |
+
throw new Error(`Save failed (${saveRes.status})${detail}`);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
equipmentCache = merged;
|
| 573 |
+
renderEquipment(equipmentCache);
|
| 574 |
+
showImportStatus(statusId, `Imported ${entryMap.size} equipment item(s) from file.`);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/**
|
| 578 |
+
* openConsumablesFilePicker: function-level behavior note for maintainers.
|
| 579 |
+
* Keep this block synchronized with implementation changes.
|
| 580 |
+
*/
|
| 581 |
+
function openConsumablesFilePicker() {
|
| 582 |
+
const input = document.getElementById('consumables-import-file');
|
| 583 |
+
if (!input) return;
|
| 584 |
+
input.value = '';
|
| 585 |
+
input.click();
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
async function handleConsumablesFileImport(event) {
|
| 589 |
+
const file = event?.target?.files?.[0];
|
| 590 |
+
if (!file) return;
|
| 591 |
+
try {
|
| 592 |
+
const content = await file.text();
|
| 593 |
+
const entries = parseConsumableFileText(content);
|
| 594 |
+
await mergeConsumableImports(entries, 'consumables-import-status');
|
| 595 |
+
} catch (err) {
|
| 596 |
+
showImportStatus('consumables-import-status', err.message, true);
|
| 597 |
+
} finally {
|
| 598 |
+
if (event?.target) event.target.value = '';
|
| 599 |
+
}
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
/**
|
| 603 |
+
* openEquipmentFilePicker: function-level behavior note for maintainers.
|
| 604 |
+
* Keep this block synchronized with implementation changes.
|
| 605 |
+
*/
|
| 606 |
+
function openEquipmentFilePicker() {
|
| 607 |
+
const input = document.getElementById('equipment-import-file');
|
| 608 |
+
if (!input) return;
|
| 609 |
+
input.value = '';
|
| 610 |
+
input.click();
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
async function handleEquipmentFileImport(event) {
|
| 614 |
+
const file = event?.target?.files?.[0];
|
| 615 |
+
if (!file) return;
|
| 616 |
+
try {
|
| 617 |
+
const content = await file.text();
|
| 618 |
+
const entries = parseEquipmentFileText(content);
|
| 619 |
+
await mergeEquipmentImports(entries, 'equipment-import-status');
|
| 620 |
+
} catch (err) {
|
| 621 |
+
showImportStatus('equipment-import-status', err.message, true);
|
| 622 |
+
} finally {
|
| 623 |
+
if (event?.target) event.target.value = '';
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
async function exportEquipmentItems() {
|
| 628 |
+
try {
|
| 629 |
+
const items = await ensureEquipmentCacheLoaded();
|
| 630 |
+
const equipment = items.filter((item) => classifyEquipment(item) === 'equipment');
|
| 631 |
+
if (!equipment.length) {
|
| 632 |
+
showImportStatus('equipment-import-status', 'No equipment available to export.', true);
|
| 633 |
+
return;
|
| 634 |
+
}
|
| 635 |
+
const content = buildTabDelimitedContent(equipment, (item) => item.notes || item.storageLocation);
|
| 636 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 637 |
+
downloadTabDelimitedFile(`equipment-${timestamp}.tsv`, content);
|
| 638 |
+
showImportStatus('equipment-import-status', `Prepared ${equipment.length} equipment item(s) for download.`);
|
| 639 |
+
} catch (err) {
|
| 640 |
+
showImportStatus('equipment-import-status', err.message, true);
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
/**
|
| 645 |
+
* Render all equipment items to appropriate lists.
|
| 646 |
+
*
|
| 647 |
+
* Classification and Routing:
|
| 648 |
+
* 1. Classifies each item (equipment/consumable/medication)
|
| 649 |
+
* 2. Routes to correct list container:
|
| 650 |
+
* - equipment → #equipment-list
|
| 651 |
+
* - consumable → #consumables-list
|
| 652 |
+
* - medication → #medication-list
|
| 653 |
+
* 3. Sorts alphabetically within each category
|
| 654 |
+
* 4. Updates section count badges
|
| 655 |
+
*
|
| 656 |
+
* Expansion Feature:
|
| 657 |
+
* If expandId provided, auto-expands that specific item's card
|
| 658 |
+
* (useful after adding new item to show it immediately).
|
| 659 |
+
*
|
| 660 |
+
* @param {Array<Object>} items - All equipment/consumables to render
|
| 661 |
+
* @param {string} expandId - Optional ID of item to auto-expand
|
| 662 |
+
*/
|
| 663 |
+
function renderEquipment(items, expandId = null) {
|
| 664 |
+
const storeList = document.getElementById('equipment-list');
|
| 665 |
+
const medicationList = document.getElementById('medication-list');
|
| 666 |
+
const consumablesList = document.getElementById('consumables-list');
|
| 667 |
+
|
| 668 |
+
const stores = [];
|
| 669 |
+
const meds = [];
|
| 670 |
+
const cons = [];
|
| 671 |
+
(items || []).forEach((item) => {
|
| 672 |
+
const bucket = classifyEquipment(item);
|
| 673 |
+
if (bucket === 'medication') meds.push(item);
|
| 674 |
+
else if (bucket === 'consumable') cons.push(item);
|
| 675 |
+
else stores.push(item);
|
| 676 |
+
});
|
| 677 |
+
|
| 678 |
+
const sortByName = (a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' });
|
| 679 |
+
stores.sort(sortByName);
|
| 680 |
+
cons.sort(sortByName);
|
| 681 |
+
|
| 682 |
+
if (storeList) {
|
| 683 |
+
storeList.innerHTML = stores.length
|
| 684 |
+
? stores.map((item) => renderEquipmentCard(item, expandId)).join('')
|
| 685 |
+
: '<div style="color:#666; padding:12px;">No medical equipment entries available.</div>';
|
| 686 |
+
}
|
| 687 |
+
if (medicationList) {
|
| 688 |
+
medicationList.innerHTML = meds.length ? meds.map((item) => renderEquipmentCard(item, expandId)).join('') : '';
|
| 689 |
+
}
|
| 690 |
+
if (consumablesList) {
|
| 691 |
+
consumablesList.innerHTML = cons.length
|
| 692 |
+
? cons.map((item) => renderEquipmentCard(item, expandId)).join('')
|
| 693 |
+
: '<div style="color:#666; padding:12px;">No consumable entries available.</div>';
|
| 694 |
+
}
|
| 695 |
+
updateSectionCount('equipment-count', stores.length);
|
| 696 |
+
updateSectionCount('consumables-count', cons.length);
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
/**
|
| 700 |
+
* renderEquipmentCard: function-level behavior note for maintainers.
|
| 701 |
+
* Keep this block synchronized with implementation changes.
|
| 702 |
+
*/
|
| 703 |
+
function renderEquipmentCard(item, expandId = null) {
|
| 704 |
+
const itemType = (item.type || '').toLowerCase() || 'durable';
|
| 705 |
+
const isConsumable = itemType === 'consumable';
|
| 706 |
+
const isEquipment = itemType === 'durable';
|
| 707 |
+
const lowStock = item.minPar && Number(item.totalQty) <= Number(item.minPar);
|
| 708 |
+
const expirySoon = item.expiryDate && daysUntil(item.expiryDate) <= 60;
|
| 709 |
+
const headerNote = [lowStock ? 'Low Stock' : null, expirySoon ? 'Expiring Soon' : null, item.status && item.status !== 'In Stock' ? item.status : null]
|
| 710 |
+
.filter(Boolean)
|
| 711 |
+
.join(' · ');
|
| 712 |
+
const title = isConsumable
|
| 713 |
+
? `${item.name || 'Consumable'}${item.totalQty ? ` — ${item.totalQty}` : ''}`
|
| 714 |
+
: `${item.name || 'Medical Equipment'}${item.totalQty ? ` — ${item.totalQty}` : ''}`;
|
| 715 |
+
const isOpen = expandId && item.id === expandId;
|
| 716 |
+
const bodyDisplay = isOpen ? 'display:block;' : '';
|
| 717 |
+
const arrow = isOpen ? '▾' : '▸';
|
| 718 |
+
// Palette: equipment stays green; consumables match pink shell (#f7eefc / #fcf7ff).
|
| 719 |
+
const headerBg = isConsumable
|
| 720 |
+
? (item.excludeFromResources ? '#ffecef' : '#f7eefc')
|
| 721 |
+
: (item.excludeFromResources ? '#ffecef' : '#e8fdef');
|
| 722 |
+
const headerBorderColor = isConsumable
|
| 723 |
+
? (item.excludeFromResources ? '#ffcbd3' : '#dec9f7')
|
| 724 |
+
: (item.excludeFromResources ? '#ffbfbf' : '#bde8c8');
|
| 725 |
+
const bodyBg = isConsumable
|
| 726 |
+
? (item.excludeFromResources ? '#fff7f8' : '#fcf7ff')
|
| 727 |
+
: (item.excludeFromResources ? '#fff6f6' : '#f7fff7');
|
| 728 |
+
const bodyBorderColor = isConsumable
|
| 729 |
+
? (item.excludeFromResources ? '#ffdbe2' : '#e6d7fb')
|
| 730 |
+
: (item.excludeFromResources ? '#ffcfd0' : '#cfe9d5');
|
| 731 |
+
const badgeColor = item.excludeFromResources ? '#d32f2f' : '#2e7d32';
|
| 732 |
+
const badgeText = item.excludeFromResources ? 'Resource Currently Unavailable' : 'Resource Available';
|
| 733 |
+
const availabilityBadge = `<span style="margin-left:auto; padding:2px 10px; border-radius:999px; background:${badgeColor}; color:#fff; font-size:11px; white-space:nowrap;">${badgeText}</span>`;
|
| 734 |
+
const consumableDetail = `
|
| 735 |
+
<div style="padding:10px; background:#fff; border:1px solid #dbe6f8; border-radius:6px;">
|
| 736 |
+
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
|
| 737 |
+
<span class="dev-tag">dev:consumable-detail-header</span>
|
| 738 |
+
<span style="font-weight:700;">Consumable Detail</span>
|
| 739 |
+
<button onclick="event.stopPropagation(); deleteEquipment('${item.id}')" class="btn btn-sm" style="background:var(--red); margin-left:auto;">🗑 Delete Consumble</button>
|
| 740 |
+
</div>
|
| 741 |
+
<div style="display:grid; grid-template-columns: 2fr 1fr; gap:10px; margin-bottom:10px; align-items:end;">
|
| 742 |
+
<div>
|
| 743 |
+
<div class="dev-tag">dev:consumable-detail-name</div>
|
| 744 |
+
<label style="font-weight:700; font-size:12px;">Consumable Name</label>
|
| 745 |
+
<input id="eq-name-${item.id}" type="text" value="${item.name}" style="width:100%; padding:8px;" oninput="scheduleSaveEquipment('${item.id}')">
|
| 746 |
+
</div>
|
| 747 |
+
<div>
|
| 748 |
+
<div class="dev-tag">dev:consumable-detail-qty</div>
|
| 749 |
+
<label style="font-weight:700; font-size:12px;">Quantity</label>
|
| 750 |
+
<input id="eq-qty-${item.id}" type="text" value="${item.totalQty}" style="width:100%; padding:8px;" oninput="scheduleSaveEquipment('${item.id}')">
|
| 751 |
+
</div>
|
| 752 |
+
<div style="grid-column: span 2; display:grid; grid-template-columns: repeat(2, minmax(200px, 1fr)); gap:10px;">
|
| 753 |
+
<div>
|
| 754 |
+
<label style="font-weight:700; font-size:12px;">Priority Tier</label>
|
| 755 |
+
<select id="eq-tier-${item.id}" style="width:100%; padding:8px;" onchange="handleEquipmentTierChange('${item.id}')">
|
| 756 |
+
${eqBuildTierOptions(item.priorityTier)}
|
| 757 |
+
</select>
|
| 758 |
+
</div>
|
| 759 |
+
<div>
|
| 760 |
+
<label style="font-weight:700; font-size:12px;">Functional Subcategory</label>
|
| 761 |
+
<select id="eq-tiercat-${item.id}" style="width:100%; padding:8px;" onchange="scheduleSaveEquipment('${item.id}')">
|
| 762 |
+
${eqBuildTierSubcategoryOptions(item.priorityTier, item.tierCategory)}
|
| 763 |
+
</select>
|
| 764 |
+
</div>
|
| 765 |
+
</div>
|
| 766 |
+
<div style="grid-column: span 2;">
|
| 767 |
+
<div class="dev-tag">dev:consumable-detail-notes</div>
|
| 768 |
+
<label style="font-weight:700; font-size:12px;">Notes</label>
|
| 769 |
+
<textarea id="eq-notes-${item.id}" style="width:100%; padding:8px; min-height:60px;" oninput="scheduleSaveEquipment('${item.id}')">${item.notes || ''}</textarea>
|
| 770 |
+
</div>
|
| 771 |
+
</div>
|
| 772 |
+
<div style="display:flex; align-items:center; gap:8px; margin-top:6px;">
|
| 773 |
+
<input id="eq-exclude-${item.id}" type="checkbox" ${item.excludeFromResources ? 'checked' : ''} onchange="scheduleSaveEquipment('${item.id}')">
|
| 774 |
+
<label style="font-size:12px; line-height:1.2; margin:0;">Resource Currently Unavailable</label>
|
| 775 |
+
</div>
|
| 776 |
+
</div>`;
|
| 777 |
+
|
| 778 |
+
const equipmentDetail = `
|
| 779 |
+
<div class="collapsible history-item">
|
| 780 |
+
<div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start; align-items:center; background:${headerBg}; border:1px solid ${headerBorderColor}; padding:8px 12px;">
|
| 781 |
+
<span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">${arrow}</span>
|
| 782 |
+
<span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${title}</span>
|
| 783 |
+
${headerNote ? `<span class="sidebar-pill" style="margin-right:8px; background:${lowStock ? '#ffebee' : '#fff7e0'}; color:${lowStock ? '#c62828' : '#b26a00'};">${headerNote}</span>` : ''}
|
| 784 |
+
${availabilityBadge}
|
| 785 |
+
</div>
|
| 786 |
+
<div class="col-body" style="padding:12px; background:${bodyBg}; border:1px solid ${bodyBorderColor}; border-radius:6px; ${bodyDisplay}">
|
| 787 |
+
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
|
| 788 |
+
<span style="font-weight:700;">${isEquipment ? 'Medical Equipment Detail' : 'Equipment Detail'}</span>
|
| 789 |
+
<button onclick="event.stopPropagation(); deleteEquipment('${item.id}')" class="btn btn-sm" style="background:var(--red); margin-left:auto;">🗑 Delete Medical Equipment Item</button>
|
| 790 |
+
</div>
|
| 791 |
+
<div style="display:grid; grid-template-columns: 2fr 1fr; gap:10px; margin-bottom:10px; align-items:end;">
|
| 792 |
+
<div>
|
| 793 |
+
<label style="font-weight:700; font-size:12px;">Medical Equipment Name</label>
|
| 794 |
+
<input id="eq-name-${item.id}" type="text" value="${item.name}" style="width:100%; padding:8px;" oninput="scheduleSaveEquipment('${item.id}')">
|
| 795 |
+
</div>
|
| 796 |
+
<div>
|
| 797 |
+
<label style="font-weight:700; font-size:12px;">Quantity</label>
|
| 798 |
+
<input id="eq-qty-${item.id}" type="text" value="${item.totalQty}" style="width:100%; padding:8px;" oninput="scheduleSaveEquipment('${item.id}')">
|
| 799 |
+
</div>
|
| 800 |
+
<div style="grid-column: span 2; display:grid; grid-template-columns: repeat(2, minmax(200px, 1fr)); gap:10px;">
|
| 801 |
+
<div>
|
| 802 |
+
<label style="font-weight:700; font-size:12px;">Priority Tier</label>
|
| 803 |
+
<select id="eq-tier-${item.id}" style="width:100%; padding:8px;" onchange="handleEquipmentTierChange('${item.id}')">
|
| 804 |
+
${eqBuildTierOptions(item.priorityTier)}
|
| 805 |
+
</select>
|
| 806 |
+
</div>
|
| 807 |
+
<div>
|
| 808 |
+
<label style="font-weight:700; font-size:12px;">Functional Subcategory</label>
|
| 809 |
+
<select id="eq-tiercat-${item.id}" style="width:100%; padding:8px;" onchange="scheduleSaveEquipment('${item.id}')">
|
| 810 |
+
${eqBuildTierSubcategoryOptions(item.priorityTier, item.tierCategory)}
|
| 811 |
+
</select>
|
| 812 |
+
</div>
|
| 813 |
+
</div>
|
| 814 |
+
<div style="grid-column: span 2;">
|
| 815 |
+
<label style="font-weight:700; font-size:12px;">Storage Location</label>
|
| 816 |
+
<input id="eq-loc-${item.id}" type="text" value="${item.storageLocation}" placeholder="Medical Bag 1, Locker B" style="width:100%; padding:8px;" oninput="scheduleSaveEquipment('${item.id}')">
|
| 817 |
+
</div>
|
| 818 |
+
<div style="grid-column: span 2;">
|
| 819 |
+
<label style="font-weight:700; font-size:12px;">Notes</label>
|
| 820 |
+
<textarea id="eq-notes-${item.id}" style="width:100%; padding:8px; min-height:60px;" oninput="scheduleSaveEquipment('${item.id}')">${item.notes || ''}</textarea>
|
| 821 |
+
</div>
|
| 822 |
+
</div>
|
| 823 |
+
<div style="display:flex; align-items:center; gap:8px; margin-top:6px;">
|
| 824 |
+
<input id="eq-exclude-${item.id}" type="checkbox" ${item.excludeFromResources ? 'checked' : ''} onchange="scheduleSaveEquipment('${item.id}')">
|
| 825 |
+
<label style="font-size:12px; line-height:1.2; margin:0;">Resource Currently Unavailable</label>
|
| 826 |
+
</div>
|
| 827 |
+
</div>
|
| 828 |
+
</div>`;
|
| 829 |
+
|
| 830 |
+
if (isConsumable) {
|
| 831 |
+
return `
|
| 832 |
+
<div class="collapsible history-item">
|
| 833 |
+
<div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start; align-items:center; background:${headerBg}; border:1px solid ${headerBorderColor}; padding:8px 12px;">
|
| 834 |
+
<span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">${arrow}</span>
|
| 835 |
+
<span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${title}</span>
|
| 836 |
+
${headerNote ? `<span class="sidebar-pill" style="margin-right:8px; background:${lowStock ? '#ffebee' : '#fff7e0'}; color:${lowStock ? '#c62828' : '#b26a00'};">${headerNote}</span>` : ''}
|
| 837 |
+
${availabilityBadge}
|
| 838 |
+
</div>
|
| 839 |
+
<div class="col-body" style="padding:12px; background:${bodyBg}; border:1px solid ${bodyBorderColor}; border-radius:6px; ${bodyDisplay}">
|
| 840 |
+
${consumableDetail}
|
| 841 |
+
</div>
|
| 842 |
+
</div>`;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
return equipmentDetail;
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
/**
|
| 849 |
+
* Schedule debounced auto-save for equipment item.
|
| 850 |
+
*
|
| 851 |
+
* Debounce Period: 600ms
|
| 852 |
+
*
|
| 853 |
+
* Allows rapid typing/editing without flooding the backend with saves.
|
| 854 |
+
* Each equipment item has independent timer.
|
| 855 |
+
*
|
| 856 |
+
* Triggered by: Input/change events on equipment form fields
|
| 857 |
+
*
|
| 858 |
+
* @param {string} id - Equipment item ID to save
|
| 859 |
+
*/
|
| 860 |
+
function scheduleSaveEquipment(id) {
|
| 861 |
+
if (equipmentSaveTimers[id]) clearTimeout(equipmentSaveTimers[id]);
|
| 862 |
+
equipmentSaveTimers[id] = setTimeout(() => saveEquipment(id), 600);
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
/**
|
| 866 |
+
* Save equipment item changes to server.
|
| 867 |
+
*
|
| 868 |
+
* Save Process:
|
| 869 |
+
* 1. Loads full equipment list
|
| 870 |
+
* 2. Finds item by ID
|
| 871 |
+
* 3. Updates fields from form inputs
|
| 872 |
+
* 4. Writes entire list back to server
|
| 873 |
+
* 5. Updates cache and re-renders with item expanded
|
| 874 |
+
*
|
| 875 |
+
* Fields Saved:
|
| 876 |
+
* - name, category, type, storage location
|
| 877 |
+
* - status, dates (expiry, inspection, calibration)
|
| 878 |
+
* - quantities (total, min PAR)
|
| 879 |
+
* - battery info, supplier, notes
|
| 880 |
+
* - excludeFromResources flag
|
| 881 |
+
*
|
| 882 |
+
* Re-render Strategy:
|
| 883 |
+
* After save, re-renders with saved item expanded so user can verify
|
| 884 |
+
* changes persisted correctly.
|
| 885 |
+
*
|
| 886 |
+
* @param {string} id - Equipment item ID to save
|
| 887 |
+
*/
|
| 888 |
+
async function saveEquipment(id) {
|
| 889 |
+
const getVal = (elementId, fallback = '') => {
|
| 890 |
+
const el = document.getElementById(elementId);
|
| 891 |
+
return el ? el.value : fallback;
|
| 892 |
+
};
|
| 893 |
+
const data = await fetchJson('/api/data/tools');
|
| 894 |
+
const items = Array.isArray(data) ? data.map(ensureEquipmentDefaults) : [];
|
| 895 |
+
const eq = items.find((i) => i.id === id);
|
| 896 |
+
if (!eq) return;
|
| 897 |
+
eq.name = getVal(`eq-name-${id}`, eq.name);
|
| 898 |
+
eq.type = getVal(`eq-type-${id}`, eq.type || 'durable');
|
| 899 |
+
eq.storageLocation = getVal(`eq-loc-${id}`, eq.storageLocation);
|
| 900 |
+
eq.subLocation = getVal(`eq-subloc-${id}`, eq.subLocation);
|
| 901 |
+
eq.parentId = getVal(`eq-parent-${id}`, eq.parentId);
|
| 902 |
+
eq.status = getVal(`eq-status-${id}`, eq.status || 'In Stock');
|
| 903 |
+
eq.expiryDate = getVal(`eq-exp-${id}`, eq.expiryDate);
|
| 904 |
+
eq.lastInspection = getVal(`eq-inspect-${id}`, eq.lastInspection);
|
| 905 |
+
eq.batteryType = getVal(`eq-batt-${id}`, eq.batteryType);
|
| 906 |
+
eq.calibrationDue = getVal(`eq-cal-${id}`, eq.calibrationDue);
|
| 907 |
+
eq.totalQty = getVal(`eq-qty-${id}`, eq.totalQty);
|
| 908 |
+
eq.minPar = getVal(`eq-par-${id}`, eq.minPar);
|
| 909 |
+
eq.supplier = getVal(`eq-sup-${id}`, eq.supplier);
|
| 910 |
+
eq.priorityTier = getVal(`eq-tier-${id}`, eq.priorityTier);
|
| 911 |
+
eq.tierCategory = getVal(`eq-tiercat-${id}`, eq.tierCategory);
|
| 912 |
+
eq.notes = getVal(`eq-notes-${id}`, eq.notes);
|
| 913 |
+
const excludeEl = document.getElementById(`eq-exclude-${id}`);
|
| 914 |
+
eq.excludeFromResources = !!(excludeEl && excludeEl.checked);
|
| 915 |
+
|
| 916 |
+
await fetch('/api/data/tools', {
|
| 917 |
+
method: 'POST',
|
| 918 |
+
headers: { 'Content-Type': 'application/json' },
|
| 919 |
+
body: JSON.stringify(items),
|
| 920 |
+
credentials: 'same-origin',
|
| 921 |
+
});
|
| 922 |
+
equipmentCache = items;
|
| 923 |
+
renderEquipment(equipmentCache, id);
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
/**
|
| 927 |
+
* openEquipmentAddForm: function-level behavior note for maintainers.
|
| 928 |
+
* Keep this block synchronized with implementation changes.
|
| 929 |
+
*/
|
| 930 |
+
function openEquipmentAddForm() {
|
| 931 |
+
// Ensure both outer and inner collapsibles are open so the add button is visible.
|
| 932 |
+
const outerHeader = document.querySelector('#equipment-section-header');
|
| 933 |
+
if (outerHeader) {
|
| 934 |
+
const outerBody = outerHeader.nextElementSibling;
|
| 935 |
+
if (outerBody && outerBody.style.display !== 'block') {
|
| 936 |
+
toggleSection(outerHeader);
|
| 937 |
+
}
|
| 938 |
+
}
|
| 939 |
+
const header = document.getElementById('equipment-add-header');
|
| 940 |
+
if (header) {
|
| 941 |
+
const body = header.nextElementSibling;
|
| 942 |
+
if (body && body.style.display !== 'block') {
|
| 943 |
+
toggleSection(header);
|
| 944 |
+
}
|
| 945 |
+
}
|
| 946 |
+
setTimeout(() => {
|
| 947 |
+
const nameField = document.getElementById('eq-new-name');
|
| 948 |
+
if (nameField) nameField.focus();
|
| 949 |
+
}, 30);
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
/**
|
| 953 |
+
* getNewEquipmentVal: function-level behavior note for maintainers.
|
| 954 |
+
* Keep this block synchronized with implementation changes.
|
| 955 |
+
*/
|
| 956 |
+
function getNewEquipmentVal(id) {
|
| 957 |
+
const el = document.getElementById(id);
|
| 958 |
+
return el ? el.value.trim() : '';
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
/**
|
| 962 |
+
* Add new medical equipment item from form.
|
| 963 |
+
*
|
| 964 |
+
* Validation:
|
| 965 |
+
* - Name is required (focuses field if missing)
|
| 966 |
+
* - All other fields optional
|
| 967 |
+
*
|
| 968 |
+
* Process:
|
| 969 |
+
* 1. Collects form values
|
| 970 |
+
* 2. Creates new item with generated ID
|
| 971 |
+
* 3. Adds to equipment list
|
| 972 |
+
* 4. Saves to server
|
| 973 |
+
* 5. Clears form for next entry
|
| 974 |
+
* 6. Reloads with new item expanded
|
| 975 |
+
*
|
| 976 |
+
* Item Type: 'durable' (reusable equipment)
|
| 977 |
+
* Category: 'Medical Equipment'
|
| 978 |
+
*
|
| 979 |
+
* Use Cases:
|
| 980 |
+
* - AED, blood pressure cuff, thermometer
|
| 981 |
+
* - Stethoscope, pulse oximeter, glucometer
|
| 982 |
+
* - Trauma shears, splints, suction devices
|
| 983 |
+
*/
|
| 984 |
+
async function addMedicalStore() {
|
| 985 |
+
const name = getNewEquipmentVal('eq-new-name');
|
| 986 |
+
if (!name) {
|
| 987 |
+
alert('Please enter a Medical Equipment name');
|
| 988 |
+
const nameField = document.getElementById('eq-new-name');
|
| 989 |
+
if (nameField) nameField.focus();
|
| 990 |
+
return;
|
| 991 |
+
}
|
| 992 |
+
const quantity = getNewEquipmentVal('eq-new-qty');
|
| 993 |
+
const location = getNewEquipmentVal('eq-new-loc');
|
| 994 |
+
const notes = document.getElementById('eq-new-notes')?.value || '';
|
| 995 |
+
const exclude = document.getElementById('eq-new-exclude')?.checked || false;
|
| 996 |
+
const priorityTier = getNewEquipmentVal('eq-new-tier');
|
| 997 |
+
const tierCategory = getNewEquipmentVal('eq-new-tiercat');
|
| 998 |
+
const newId = `eq-${Date.now()}`;
|
| 999 |
+
const newItem = ensureEquipmentDefaults({
|
| 1000 |
+
id: newId,
|
| 1001 |
+
name,
|
| 1002 |
+
type: 'durable',
|
| 1003 |
+
storageLocation: location,
|
| 1004 |
+
totalQty: quantity,
|
| 1005 |
+
priorityTier,
|
| 1006 |
+
tierCategory,
|
| 1007 |
+
notes,
|
| 1008 |
+
status: 'In Stock',
|
| 1009 |
+
excludeFromResources: exclude,
|
| 1010 |
+
});
|
| 1011 |
+
|
| 1012 |
+
const data = await fetchJson('/api/data/tools');
|
| 1013 |
+
const items = Array.isArray(data) ? data.map(ensureEquipmentDefaults) : [];
|
| 1014 |
+
items.push(newItem);
|
| 1015 |
+
await fetch('/api/data/tools', {
|
| 1016 |
+
method: 'POST',
|
| 1017 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1018 |
+
body: JSON.stringify(items),
|
| 1019 |
+
credentials: 'same-origin',
|
| 1020 |
+
});
|
| 1021 |
+
|
| 1022 |
+
// Clear the add form for the next entry
|
| 1023 |
+
['eq-new-name','eq-new-loc','eq-new-qty','eq-new-notes','eq-new-tier','eq-new-tiercat']
|
| 1024 |
+
.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ''; });
|
| 1025 |
+
const newExclude = document.getElementById('eq-new-exclude');
|
| 1026 |
+
if (newExclude) newExclude.checked = false;
|
| 1027 |
+
|
| 1028 |
+
loadEquipment(newId);
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
/**
|
| 1032 |
+
* canonicalMedKey: function-level behavior note for maintainers.
|
| 1033 |
+
* Keep this block synchronized with implementation changes.
|
| 1034 |
+
*/
|
| 1035 |
+
function canonicalMedKey(generic, brand, strength, formStrength = '') {
|
| 1036 |
+
const clean = (val) => (val || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
| 1037 |
+
const strengthVal = clean(strength || formStrength).replace(/unspecified/g, '');
|
| 1038 |
+
return `${clean(generic)}|${clean(brand)}|${strengthVal}`;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
async function addMedicationItem() {
|
| 1042 |
+
const name = getNewEquipmentVal('med-new-name');
|
| 1043 |
+
if (!name) {
|
| 1044 |
+
alert('Please enter a Medication name');
|
| 1045 |
+
const nameField = document.getElementById('med-new-name');
|
| 1046 |
+
if (nameField) nameField.focus();
|
| 1047 |
+
return;
|
| 1048 |
+
}
|
| 1049 |
+
const sortSel = document.getElementById('med-new-sort');
|
| 1050 |
+
const sortCustom = document.getElementById('med-new-sort-custom');
|
| 1051 |
+
const sortCategoryRaw = sortSel ? (sortSel.value === '__custom' ? (sortCustom?.value || '') : (sortSel.value || '')) : '';
|
| 1052 |
+
const sortCategory = (sortCategoryRaw || '').trim();
|
| 1053 |
+
const verified = !!document.getElementById('med-new-verified')?.checked;
|
| 1054 |
+
const exclude = document.getElementById('med-new-exclude')?.checked || false;
|
| 1055 |
+
const controlled = document.getElementById('med-new-ctrl')?.value === 'true';
|
| 1056 |
+
const dosage = document.getElementById('med-new-dose')?.value || '';
|
| 1057 |
+
const expiryDate = document.getElementById('med-new-exp')?.value || '';
|
| 1058 |
+
const expiryQty = getNewEquipmentVal('med-new-exp-qty') || '';
|
| 1059 |
+
const expiryBatch = getNewEquipmentVal('med-new-exp-batch') || '';
|
| 1060 |
+
const notes = document.getElementById('med-new-notes')?.value || '';
|
| 1061 |
+
const priorityTier = getNewEquipmentVal('med-new-tier');
|
| 1062 |
+
const tierCategory = getNewEquipmentVal('med-new-tiercat');
|
| 1063 |
+
const newId = `med-${Date.now()}`;
|
| 1064 |
+
const purchaseHistory = [];
|
| 1065 |
+
if (expiryDate) {
|
| 1066 |
+
purchaseHistory.push({
|
| 1067 |
+
id: `ph-${Date.now()}`,
|
| 1068 |
+
date: expiryDate,
|
| 1069 |
+
quantity: expiryQty || getNewEquipmentVal('med-new-qty') || '',
|
| 1070 |
+
notes: '',
|
| 1071 |
+
manufacturer: getNewEquipmentVal('med-new-sup') || '',
|
| 1072 |
+
batchLot: expiryBatch || '',
|
| 1073 |
+
});
|
| 1074 |
+
}
|
| 1075 |
+
const newMed = {
|
| 1076 |
+
id: newId,
|
| 1077 |
+
genericName: name,
|
| 1078 |
+
brandName: getNewEquipmentVal('med-new-brand'),
|
| 1079 |
+
form: getNewEquipmentVal('med-new-form'),
|
| 1080 |
+
strength: getNewEquipmentVal('med-new-strength'),
|
| 1081 |
+
formStrength: [getNewEquipmentVal('med-new-form'), getNewEquipmentVal('med-new-strength')].join(' ').trim(),
|
| 1082 |
+
currentQuantity: getNewEquipmentVal('med-new-qty') || '',
|
| 1083 |
+
minThreshold: getNewEquipmentVal('med-new-par') || '',
|
| 1084 |
+
unit: getNewEquipmentVal('med-new-unit') || '',
|
| 1085 |
+
storageLocation: getNewEquipmentVal('med-new-loc') || '',
|
| 1086 |
+
expiryDate: document.getElementById('med-new-exp')?.value || '',
|
| 1087 |
+
batchLot: '',
|
| 1088 |
+
controlled,
|
| 1089 |
+
manufacturer: getNewEquipmentVal('med-new-sup') || '',
|
| 1090 |
+
primaryIndication: getNewEquipmentVal('med-new-indication') || '',
|
| 1091 |
+
allergyWarnings: getNewEquipmentVal('med-new-allergy') || '',
|
| 1092 |
+
standardDosage: dosage,
|
| 1093 |
+
sortCategory,
|
| 1094 |
+
priorityTier,
|
| 1095 |
+
tierCategory,
|
| 1096 |
+
verified,
|
| 1097 |
+
notes,
|
| 1098 |
+
purchaseHistory,
|
| 1099 |
+
source: 'manual_entry',
|
| 1100 |
+
excludeFromResources: exclude,
|
| 1101 |
+
};
|
| 1102 |
+
|
| 1103 |
+
const data = await fetchJson('/api/data/inventory');
|
| 1104 |
+
const items = Array.isArray(data) ? data : [];
|
| 1105 |
+
const g = (newMed.genericName || '').trim().toLowerCase();
|
| 1106 |
+
const b = (newMed.brandName || '').trim().toLowerCase();
|
| 1107 |
+
const targetKey = canonicalMedKey(g, b, newMed.strength, newMed.formStrength);
|
| 1108 |
+
const dup = items.find((m) => {
|
| 1109 |
+
const mg = (m.genericName || '').trim().toLowerCase();
|
| 1110 |
+
const mb = (m.brandName || '').trim().toLowerCase();
|
| 1111 |
+
return canonicalMedKey(mg, mb, m.strength, m.formStrength) === targetKey;
|
| 1112 |
+
});
|
| 1113 |
+
if (dup) {
|
| 1114 |
+
alert('A medication with the same Generic + Brand + Strength already exists. Please adjust to keep entries unique.');
|
| 1115 |
+
return;
|
| 1116 |
+
}
|
| 1117 |
+
items.push(newMed);
|
| 1118 |
+
await fetch('/api/data/inventory', {
|
| 1119 |
+
method: 'POST',
|
| 1120 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1121 |
+
body: JSON.stringify(items),
|
| 1122 |
+
credentials: 'same-origin',
|
| 1123 |
+
});
|
| 1124 |
+
|
| 1125 |
+
['med-new-name','med-new-brand','med-new-form','med-new-strength','med-new-loc','med-new-exp','med-new-exp-qty','med-new-exp-batch','med-new-qty','med-new-par','med-new-unit','med-new-sup','med-new-indication','med-new-allergy','med-new-dose','med-new-notes','med-new-tier','med-new-tiercat']
|
| 1126 |
+
.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ''; });
|
| 1127 |
+
const sortSelect = document.getElementById('med-new-sort');
|
| 1128 |
+
const sortCustomInput = document.getElementById('med-new-sort-custom');
|
| 1129 |
+
if (sortSelect) sortSelect.value = '';
|
| 1130 |
+
if (sortCustomInput) { sortCustomInput.value = ''; sortCustomInput.style.display = 'none'; }
|
| 1131 |
+
const medVerified = document.getElementById('med-new-verified');
|
| 1132 |
+
if (medVerified) medVerified.checked = false;
|
| 1133 |
+
const medExclude = document.getElementById('med-new-exclude');
|
| 1134 |
+
if (medExclude) medExclude.checked = false;
|
| 1135 |
+
const ctrlSel = document.getElementById('med-new-ctrl');
|
| 1136 |
+
if (ctrlSel) ctrlSel.value = 'false';
|
| 1137 |
+
|
| 1138 |
+
if (typeof loadPharmacy === 'function') {
|
| 1139 |
+
loadPharmacy();
|
| 1140 |
+
}
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
/**
|
| 1144 |
+
* Add new consumable item from form.
|
| 1145 |
+
*
|
| 1146 |
+
* Validation:
|
| 1147 |
+
* - Name is required (focuses field if missing)
|
| 1148 |
+
* - Quantity and notes optional
|
| 1149 |
+
*
|
| 1150 |
+
* Process:
|
| 1151 |
+
* 1. Collects form values
|
| 1152 |
+
* 2. Creates new item with generated ID
|
| 1153 |
+
* 3. Adds to equipment list
|
| 1154 |
+
* 4. Saves to server
|
| 1155 |
+
* 5. Clears form for next entry
|
| 1156 |
+
* 6. Reloads with new item expanded
|
| 1157 |
+
*
|
| 1158 |
+
* Item Type: 'consumable' (single-use supplies)
|
| 1159 |
+
* Category: 'Consumable'
|
| 1160 |
+
*
|
| 1161 |
+
* Use Cases:
|
| 1162 |
+
* - Bandages, gauze pads, tape
|
| 1163 |
+
* - Gloves, masks, syringes
|
| 1164 |
+
* - Alcohol wipes, antiseptic
|
| 1165 |
+
* - Sutures, IV supplies
|
| 1166 |
+
*/
|
| 1167 |
+
async function addConsumableItem() {
|
| 1168 |
+
const name = getNewEquipmentVal('cons-new-name');
|
| 1169 |
+
if (!name) {
|
| 1170 |
+
alert('Please enter a Consumable name');
|
| 1171 |
+
const nameField = document.getElementById('cons-new-name');
|
| 1172 |
+
if (nameField) nameField.focus();
|
| 1173 |
+
return;
|
| 1174 |
+
}
|
| 1175 |
+
const quantity = getNewEquipmentVal('cons-new-qty');
|
| 1176 |
+
const notes = document.getElementById('cons-new-notes')?.value || '';
|
| 1177 |
+
const exclude = document.getElementById('cons-new-exclude')?.checked || false;
|
| 1178 |
+
const priorityTier = getNewEquipmentVal('cons-new-tier');
|
| 1179 |
+
const tierCategory = getNewEquipmentVal('cons-new-tiercat');
|
| 1180 |
+
const newId = `eq-${Date.now()}`;
|
| 1181 |
+
const newItem = ensureEquipmentDefaults({
|
| 1182 |
+
id: newId,
|
| 1183 |
+
name,
|
| 1184 |
+
type: 'consumable',
|
| 1185 |
+
totalQty: quantity,
|
| 1186 |
+
priorityTier,
|
| 1187 |
+
tierCategory,
|
| 1188 |
+
notes,
|
| 1189 |
+
status: 'In Stock',
|
| 1190 |
+
excludeFromResources: exclude,
|
| 1191 |
+
});
|
| 1192 |
+
|
| 1193 |
+
const res = await fetch('/api/data/tools', { credentials: 'same-origin' });
|
| 1194 |
+
const data = await res.json();
|
| 1195 |
+
const items = Array.isArray(data) ? data.map(ensureEquipmentDefaults) : [];
|
| 1196 |
+
items.push(newItem);
|
| 1197 |
+
await fetch('/api/data/tools', {
|
| 1198 |
+
method: 'POST',
|
| 1199 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1200 |
+
body: JSON.stringify(items),
|
| 1201 |
+
credentials: 'same-origin',
|
| 1202 |
+
});
|
| 1203 |
+
|
| 1204 |
+
['cons-new-name','cons-new-qty','cons-new-notes','cons-new-tier','cons-new-tiercat']
|
| 1205 |
+
.forEach((id) => { const el = document.getElementById(id); if (el) el.value = ''; });
|
| 1206 |
+
const consumableExclude = document.getElementById('cons-new-exclude');
|
| 1207 |
+
if (consumableExclude) consumableExclude.checked = false;
|
| 1208 |
+
|
| 1209 |
+
loadEquipment(newId);
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
/**
|
| 1213 |
+
* Delete equipment item with double confirmation.
|
| 1214 |
+
*
|
| 1215 |
+
* Two-Step Safety:
|
| 1216 |
+
* 1. Confirm dialog
|
| 1217 |
+
* 2. Type "DELETE" prompt (exact match required)
|
| 1218 |
+
*
|
| 1219 |
+
* Process:
|
| 1220 |
+
* 1. Loads full equipment list
|
| 1221 |
+
* 2. Filters out deleted item
|
| 1222 |
+
* 3. Writes remaining items to server
|
| 1223 |
+
* 4. Reloads equipment display
|
| 1224 |
+
*
|
| 1225 |
+
* Permanent Action:
|
| 1226 |
+
* No undo available. Data is permanently removed from tools.json.
|
| 1227 |
+
*
|
| 1228 |
+
* @param {string} id - Equipment item ID to delete
|
| 1229 |
+
*/
|
| 1230 |
+
async function deleteEquipment(id) {
|
| 1231 |
+
if (!confirm('Delete this equipment item?')) return;
|
| 1232 |
+
const confirmText = prompt('Type DELETE to confirm:');
|
| 1233 |
+
if (confirmText !== 'DELETE') {
|
| 1234 |
+
alert('Deletion cancelled.');
|
| 1235 |
+
return;
|
| 1236 |
+
}
|
| 1237 |
+
const res = await fetch('/api/data/tools', { credentials: 'same-origin' });
|
| 1238 |
+
const data = await res.json();
|
| 1239 |
+
const items = Array.isArray(data) ? data : [];
|
| 1240 |
+
const filtered = items.filter((i) => i.id !== id);
|
| 1241 |
+
await fetch('/api/data/tools', {
|
| 1242 |
+
method: 'POST',
|
| 1243 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1244 |
+
body: JSON.stringify(filtered),
|
| 1245 |
+
credentials: 'same-origin',
|
| 1246 |
+
});
|
| 1247 |
+
loadEquipment();
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
/**
|
| 1251 |
+
* Calculate days until a future date.
|
| 1252 |
+
*
|
| 1253 |
+
* Used for:
|
| 1254 |
+
* - Expiry warnings (items expiring soon)
|
| 1255 |
+
* - Calibration due dates
|
| 1256 |
+
* - Maintenance schedules
|
| 1257 |
+
*
|
| 1258 |
+
* Returns:
|
| 1259 |
+
* - Positive number: Days in future
|
| 1260 |
+
* - Negative number: Days in past (expired)
|
| 1261 |
+
* - 9999: Invalid date (parsing failed)
|
| 1262 |
+
*
|
| 1263 |
+
* Thresholds:
|
| 1264 |
+
* - ≤ 60 days: Show "Expiring Soon" warning
|
| 1265 |
+
* - < 0 days: Show "Expired" warning
|
| 1266 |
+
*
|
| 1267 |
+
* @param {string} dateStr - ISO date string (YYYY-MM-DD)
|
| 1268 |
+
* @returns {number} Days until date (or 9999 if invalid)
|
| 1269 |
+
*/
|
| 1270 |
+
function daysUntil(dateStr) {
|
| 1271 |
+
const now = new Date();
|
| 1272 |
+
const target = new Date(dateStr);
|
| 1273 |
+
if (isNaN(target.getTime())) return 9999;
|
| 1274 |
+
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
// Expose handlers
|
| 1278 |
+
window.loadEquipment = loadEquipment;
|
| 1279 |
+
window.addEquipment = openEquipmentAddForm;
|
| 1280 |
+
window.openEquipmentAddForm = openEquipmentAddForm;
|
| 1281 |
+
window.addMedicalStore = addMedicalStore;
|
| 1282 |
+
window.addMedicationItem = addMedicationItem;
|
| 1283 |
+
window.addConsumableItem = addConsumableItem;
|
| 1284 |
+
window.deleteEquipment = deleteEquipment;
|
| 1285 |
+
window.scheduleSaveEquipment = scheduleSaveEquipment;
|
| 1286 |
+
window.handleEquipmentTierChange = handleEquipmentTierChange;
|
| 1287 |
+
window.exportConsumables = exportConsumables;
|
| 1288 |
+
window.openConsumablesFilePicker = openConsumablesFilePicker;
|
| 1289 |
+
window.handleConsumablesFileImport = handleConsumablesFileImport;
|
| 1290 |
+
window.exportEquipmentItems = exportEquipmentItems;
|
| 1291 |
+
window.openEquipmentFilePicker = openEquipmentFilePicker;
|
| 1292 |
+
window.handleEquipmentFileImport = handleEquipmentFileImport;
|
| 1293 |
+
window.forceClearCache = function forceClearCache() {
|
| 1294 |
+
if (typeof window.resetConsultationUiForDemo === 'function') {
|
| 1295 |
+
window.resetConsultationUiForDemo();
|
| 1296 |
+
}
|
| 1297 |
+
try {
|
| 1298 |
+
[
|
| 1299 |
+
'sailingmed:lastPrompt',
|
| 1300 |
+
'sailingmed:lastPatient',
|
| 1301 |
+
'sailingmed:lastChatMode',
|
| 1302 |
+
'sailingmed:promptPreviewOpen',
|
| 1303 |
+
'sailingmed:promptPreviewContent',
|
| 1304 |
+
'sailingmed:chatState',
|
| 1305 |
+
'triage-pathway-open',
|
| 1306 |
+
'sailingmed:skipLastChat',
|
| 1307 |
+
'sailingmed:loggingOff',
|
| 1308 |
+
'sailingmed:sidebarCollapsed',
|
| 1309 |
+
].forEach((k) => localStorage.removeItem(k));
|
| 1310 |
+
localStorage.setItem('sailingmed:sidebarCollapsed', '0');
|
| 1311 |
+
sessionStorage.clear();
|
| 1312 |
+
} catch (err) { /* ignore */ }
|
| 1313 |
+
// Route through logout to ensure splash/login is shown regardless of auth state.
|
| 1314 |
+
window.location.assign(`/logout?fresh=${Date.now()}`);
|
| 1315 |
+
};
|
static/js/main.js
ADDED
|
@@ -0,0 +1,1243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================================================
|
| 2 |
+
* Author: Rick Escher
|
| 3 |
+
* Project: SailingMedAdvisor
|
| 4 |
+
* Context: Google HAI-DEF Framework
|
| 5 |
+
* Models: Google MedGemmas
|
| 6 |
+
* Program: Kaggle Impact Challenge
|
| 7 |
+
* ========================================================================== */
|
| 8 |
+
/*
|
| 9 |
+
File: static/js/main.js
|
| 10 |
+
Author notes: Application orchestration and global UI utilities.
|
| 11 |
+
|
| 12 |
+
Key Responsibilities:
|
| 13 |
+
- Tab navigation and content switching
|
| 14 |
+
- Collapsible section management (queries, headers, details)
|
| 15 |
+
- Sidebar state persistence and synchronization
|
| 16 |
+
- Application initialization and data preloading
|
| 17 |
+
- Medical chest global search across all inventories
|
| 18 |
+
- LocalStorage state restoration
|
| 19 |
+
|
| 20 |
+
Architecture Overview:
|
| 21 |
+
---------------------
|
| 22 |
+
main.js acts as the conductor for the single-page application, coordinating:
|
| 23 |
+
|
| 24 |
+
1. **Tab System**: 5 main tabs with lazy loading
|
| 25 |
+
- Chat: AI consultation interface
|
| 26 |
+
- Medical Chest: Pharmacy inventory (preloaded for performance)
|
| 27 |
+
- Crew Health & Log: Medical records and history
|
| 28 |
+
- Vessel & Crew Info: Demographics and documents
|
| 29 |
+
- Onboard Equipment: Medical equipment and consumables
|
| 30 |
+
- Settings: Configuration and offline mode
|
| 31 |
+
|
| 32 |
+
2. **Collapsible Sections**: 3 different toggle patterns
|
| 33 |
+
- toggleSection(): Standard sections (most common)
|
| 34 |
+
- toggleDetailSection(): Detail panels with special handling
|
| 35 |
+
- toggleCrewSection(): Crew cards with accordion behavior
|
| 36 |
+
|
| 37 |
+
3. **Sidebar Management**: Context-sensitive help/reference
|
| 38 |
+
- Collapsed/expanded state persists across sessions
|
| 39 |
+
- Auto-syncs with collapsible section states
|
| 40 |
+
- Shows/hides relevant content per active tab
|
| 41 |
+
|
| 42 |
+
4. **Initialization Strategy**: Staggered loading for performance
|
| 43 |
+
- Immediate: Chat tab (default landing)
|
| 44 |
+
- Preload: Medical Chest (frequent access)
|
| 45 |
+
- On-demand: Other tabs load when opened
|
| 46 |
+
- Concurrent: Crew data, settings, history loaded together
|
| 47 |
+
|
| 48 |
+
5. **Global Search**: Unified search across all inventories
|
| 49 |
+
- Searches pharmaceuticals, equipment, consumables
|
| 50 |
+
- Scope filtering (all/pharma/equipment/consumables)
|
| 51 |
+
- Grouped results by category
|
| 52 |
+
- Expandable result sections
|
| 53 |
+
|
| 54 |
+
Data Loading Flow:
|
| 55 |
+
-----------------
|
| 56 |
+
```
|
| 57 |
+
Page Load → ensureCrewData() → Promise.all([
|
| 58 |
+
/api/data/patients,
|
| 59 |
+
/api/data/history,
|
| 60 |
+
/api/data/settings
|
| 61 |
+
]) → loadCrewData() → Render UI
|
| 62 |
+
|
| 63 |
+
Tab Switch → showTab() →
|
| 64 |
+
if Chat: updateUI(), restoreCollapsibleState()
|
| 65 |
+
if CrewMedical: ensureCrewData()
|
| 66 |
+
if OnboardEquipment: loadEquipment()
|
| 67 |
+
if Settings: loadSettingsUI(), loadCrewCredentials()
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
LocalStorage Keys:
|
| 71 |
+
- sailingmed:sidebarCollapsed: Sidebar state (1=collapsed, 0=expanded)
|
| 72 |
+
- sailingmed:lastOpenCrew: Last opened crew card ID
|
| 73 |
+
- sailingmed:skipLastChat: Flag to skip restoring last chat
|
| 74 |
+
- [headerId]: Per-section collapsed state
|
| 75 |
+
|
| 76 |
+
Integration Points:
|
| 77 |
+
- crew.js: loadCrewData() renders crew lists
|
| 78 |
+
- pharmacy.js: preloadPharmacy(), loadPharmacy()
|
| 79 |
+
- equipment.js: loadEquipment()
|
| 80 |
+
- settings.js: loadSettingsUI(), loadCrewCredentials()
|
| 81 |
+
- chat.js: updateUI(), refreshPromptPreview()
|
| 82 |
+
*/
|
| 83 |
+
|
| 84 |
+
// ============================================================================
|
| 85 |
+
// STATE MANAGEMENT
|
| 86 |
+
// ============================================================================
|
| 87 |
+
|
| 88 |
+
const SIDEBAR_STATE_KEY = 'sailingmed:sidebarCollapsed';
|
| 89 |
+
const renderAssistantMarkdownMain = (window.Utils && window.Utils.renderAssistantMarkdown)
|
| 90 |
+
? window.Utils.renderAssistantMarkdown
|
| 91 |
+
: (txt) => (window.marked && typeof window.marked.parse === 'function')
|
| 92 |
+
? window.marked.parse(txt || '', { gfm: true, breaks: true })
|
| 93 |
+
: (window.escapeHtml ? window.escapeHtml(txt || '') : String(txt || '')).replace(/\n/g, '<br>');
|
| 94 |
+
let globalSidebarCollapsed = false; // Sidebar collapse state (synced across all tabs)
|
| 95 |
+
let crewDataLoaded = false; // Prevent duplicate crew data loads
|
| 96 |
+
let crewDataPromise = null; // Promise for concurrent load protection
|
| 97 |
+
let loadDataInFlight = null; // Shared promise so concurrent refreshes collapse into one request
|
| 98 |
+
let cachedPatientsRoster = null; // Last successful /api/data/patients payload for lightweight refreshes
|
| 99 |
+
const LAST_PATIENT_KEY_MAIN = 'sailingmed:lastPatient';
|
| 100 |
+
const CREW_OPTIONS_CACHE_KEY = 'sailingmed:crewOptionsCache';
|
| 101 |
+
const COLLAPSIBLE_PREF_SCHEMA_KEY = 'sailingmed:collapsiblePrefSchema';
|
| 102 |
+
const COLLAPSIBLE_PREF_SCHEMA_VERSION = '2';
|
| 103 |
+
let startupCriticalInitDone = false; // Prevent duplicate critical bootstrap runs
|
| 104 |
+
let startupDeferredInitDone = false; // Prevent duplicate deferred bootstrap runs
|
| 105 |
+
let startupCrewReadyPromise = null; // Shared crew-ready promise across startup phases
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* migrateCollapsiblePrefs: function-level behavior note for maintainers.
|
| 109 |
+
* Keep this block synchronized with implementation changes.
|
| 110 |
+
*/
|
| 111 |
+
function migrateCollapsiblePrefs() {
|
| 112 |
+
try {
|
| 113 |
+
const current = localStorage.getItem(COLLAPSIBLE_PREF_SCHEMA_KEY);
|
| 114 |
+
if (current === COLLAPSIBLE_PREF_SCHEMA_VERSION) return;
|
| 115 |
+
// Legacy values were stored inverted; normalize once.
|
| 116 |
+
['query-form-open', 'triage-pathway-open'].forEach((key) => {
|
| 117 |
+
const raw = localStorage.getItem(key);
|
| 118 |
+
if (raw === 'true') localStorage.setItem(key, 'false');
|
| 119 |
+
else if (raw === 'false') localStorage.setItem(key, 'true');
|
| 120 |
+
});
|
| 121 |
+
localStorage.setItem(COLLAPSIBLE_PREF_SCHEMA_KEY, COLLAPSIBLE_PREF_SCHEMA_VERSION);
|
| 122 |
+
} catch (err) { /* ignore */ }
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* getCrewFullNameFast: function-level behavior note for maintainers.
|
| 127 |
+
* Keep this block synchronized with implementation changes.
|
| 128 |
+
*/
|
| 129 |
+
function getCrewFullNameFast(crew) {
|
| 130 |
+
const first = crew && typeof crew.firstName === 'string' ? crew.firstName.trim() : '';
|
| 131 |
+
const last = crew && typeof crew.lastName === 'string' ? crew.lastName.trim() : '';
|
| 132 |
+
const full = `${first} ${last}`.trim();
|
| 133 |
+
if (full) return full;
|
| 134 |
+
if (crew && typeof crew.name === 'string' && crew.name.trim()) return crew.name.trim();
|
| 135 |
+
return 'Unnamed Crew';
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/**
|
| 139 |
+
* Fast-path dropdown population so the Chat crew selector is usable
|
| 140 |
+
* immediately after splash/login transition.
|
| 141 |
+
*
|
| 142 |
+
* This intentionally avoids rendering full crew/history UI and only updates
|
| 143 |
+
* `#p-select` while the rest of loadData() continues in the background.
|
| 144 |
+
*/
|
| 145 |
+
function populateCrewSelectFast(patients) {
|
| 146 |
+
if (!Array.isArray(patients)) return;
|
| 147 |
+
const select = document.getElementById('p-select');
|
| 148 |
+
if (!select) return;
|
| 149 |
+
|
| 150 |
+
let storedValue = '';
|
| 151 |
+
try {
|
| 152 |
+
storedValue = localStorage.getItem(LAST_PATIENT_KEY_MAIN) || '';
|
| 153 |
+
} catch (err) { /* ignore */ }
|
| 154 |
+
const currentValue = select.value || '';
|
| 155 |
+
const preferredValue = currentValue || storedValue;
|
| 156 |
+
|
| 157 |
+
const frag = document.createDocumentFragment();
|
| 158 |
+
const defaultOpt = document.createElement('option');
|
| 159 |
+
defaultOpt.value = '';
|
| 160 |
+
defaultOpt.textContent = 'Unnamed Crew Member';
|
| 161 |
+
frag.appendChild(defaultOpt);
|
| 162 |
+
patients.forEach((crew) => {
|
| 163 |
+
const opt = document.createElement('option');
|
| 164 |
+
opt.value = String((crew && crew.id) || '');
|
| 165 |
+
opt.textContent = getCrewFullNameFast(crew);
|
| 166 |
+
frag.appendChild(opt);
|
| 167 |
+
});
|
| 168 |
+
select.replaceChildren(frag);
|
| 169 |
+
|
| 170 |
+
if (preferredValue && Array.from(select.options).some((opt) => opt.value === preferredValue)) {
|
| 171 |
+
select.value = preferredValue;
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
if (preferredValue) {
|
| 175 |
+
const byName = Array.from(select.options).find((opt) => opt.textContent === preferredValue);
|
| 176 |
+
select.value = byName ? (byName.value || '') : '';
|
| 177 |
+
return;
|
| 178 |
+
}
|
| 179 |
+
select.value = '';
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* cacheCrewOptionsFast: persist lightweight crew selector options for instant hydration.
|
| 184 |
+
*/
|
| 185 |
+
function cacheCrewOptionsFast(patients) {
|
| 186 |
+
if (!Array.isArray(patients)) return;
|
| 187 |
+
const compact = patients
|
| 188 |
+
.map((crew) => {
|
| 189 |
+
const id = String((crew && crew.id) || '').trim();
|
| 190 |
+
if (!id) return null;
|
| 191 |
+
const label = getCrewFullNameFast(crew);
|
| 192 |
+
return { id, label };
|
| 193 |
+
})
|
| 194 |
+
.filter(Boolean);
|
| 195 |
+
if (!compact.length) return;
|
| 196 |
+
try {
|
| 197 |
+
localStorage.setItem(CREW_OPTIONS_CACHE_KEY, JSON.stringify(compact));
|
| 198 |
+
} catch (err) { /* ignore */ }
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
/**
|
| 202 |
+
* hydrateCrewSelectFromCache: render cached crew options before network completes.
|
| 203 |
+
*/
|
| 204 |
+
function hydrateCrewSelectFromCache() {
|
| 205 |
+
try {
|
| 206 |
+
const raw = localStorage.getItem(CREW_OPTIONS_CACHE_KEY);
|
| 207 |
+
if (!raw) return false;
|
| 208 |
+
const parsed = JSON.parse(raw);
|
| 209 |
+
if (!Array.isArray(parsed) || !parsed.length) return false;
|
| 210 |
+
populateCrewSelectFast(parsed.map((entry) => ({
|
| 211 |
+
id: entry && entry.id,
|
| 212 |
+
name: entry && entry.label,
|
| 213 |
+
})));
|
| 214 |
+
return true;
|
| 215 |
+
} catch (err) {
|
| 216 |
+
return false;
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/**
|
| 221 |
+
* getMedicalChestRenderCount: function-level behavior note for maintainers.
|
| 222 |
+
* Keep this block synchronized with implementation changes.
|
| 223 |
+
*/
|
| 224 |
+
function getMedicalChestRenderCount() {
|
| 225 |
+
const pharmaCount = document.querySelectorAll('#pharmacy-list .history-item').length;
|
| 226 |
+
const equipmentCount = document.querySelectorAll('#equipment-list .history-item').length;
|
| 227 |
+
const consumableCount = document.querySelectorAll('#consumables-list .history-item').length;
|
| 228 |
+
return pharmaCount + equipmentCount + consumableCount;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
async function runMedicalChestLoadCycle() {
|
| 232 |
+
const loaders = [];
|
| 233 |
+
if (typeof loadEquipment === 'function') {
|
| 234 |
+
loaders.push(Promise.resolve(loadEquipment()));
|
| 235 |
+
}
|
| 236 |
+
if (typeof loadPharmacy === 'function') {
|
| 237 |
+
loaders.push(Promise.resolve(loadPharmacy()));
|
| 238 |
+
}
|
| 239 |
+
if (!loaders.length) return;
|
| 240 |
+
await Promise.allSettled(loaders);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
async function ensureMedicalChestLoaded() {
|
| 244 |
+
await runMedicalChestLoadCycle();
|
| 245 |
+
if (getMedicalChestRenderCount() > 0) return;
|
| 246 |
+
try {
|
| 247 |
+
const [invRes, toolsRes] = await Promise.all([
|
| 248 |
+
fetch('/api/data/inventory', { credentials: 'same-origin' }),
|
| 249 |
+
fetch('/api/data/tools', { credentials: 'same-origin' }),
|
| 250 |
+
]);
|
| 251 |
+
const [invData, toolsData] = await Promise.all([
|
| 252 |
+
invRes.ok ? invRes.json() : Promise.resolve([]),
|
| 253 |
+
toolsRes.ok ? toolsRes.json() : Promise.resolve([]),
|
| 254 |
+
]);
|
| 255 |
+
const hasBackendData = (Array.isArray(invData) && invData.length > 0)
|
| 256 |
+
|| (Array.isArray(toolsData) && toolsData.length > 0);
|
| 257 |
+
if (hasBackendData) {
|
| 258 |
+
console.warn('Medical Chest rendered empty; retrying load once.');
|
| 259 |
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
| 260 |
+
await runMedicalChestLoadCycle();
|
| 261 |
+
}
|
| 262 |
+
} catch (err) {
|
| 263 |
+
console.warn('Medical Chest retry probe failed:', err);
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/**
|
| 268 |
+
* Set sidebar collapsed state across all sidebars.
|
| 269 |
+
*
|
| 270 |
+
* Sidebar States:
|
| 271 |
+
* - Expanded: Shows context help, reference content
|
| 272 |
+
* - Collapsed: Hides sidebar, maximizes main content area
|
| 273 |
+
*
|
| 274 |
+
* UI Changes:
|
| 275 |
+
* 1. Adds/removes 'collapsed' class to all .page-sidebar elements
|
| 276 |
+
* 2. Updates toggle button text ("Context ←" / "Context →")
|
| 277 |
+
* 3. Adjusts page body layout classes
|
| 278 |
+
* 4. Persists state to localStorage
|
| 279 |
+
*
|
| 280 |
+
* Applied Globally:
|
| 281 |
+
* All sidebars on the page sync to same state for consistency.
|
| 282 |
+
*
|
| 283 |
+
* @param {boolean} collapsed - True to collapse, false to expand
|
| 284 |
+
*/
|
| 285 |
+
function setSidebarState(collapsed) {
|
| 286 |
+
globalSidebarCollapsed = !!collapsed;
|
| 287 |
+
try { localStorage.setItem(SIDEBAR_STATE_KEY, globalSidebarCollapsed ? '1' : '0'); } catch (err) { /* ignore */ }
|
| 288 |
+
document.querySelectorAll('.page-sidebar').forEach((sidebar) => {
|
| 289 |
+
sidebar.classList.toggle('collapsed', globalSidebarCollapsed);
|
| 290 |
+
const button = sidebar.querySelector('.sidebar-toggle');
|
| 291 |
+
if (button) button.textContent = globalSidebarCollapsed ? 'Context ←' : 'Context →';
|
| 292 |
+
const body = sidebar.closest('.page-body');
|
| 293 |
+
if (body) {
|
| 294 |
+
body.classList.toggle('sidebar-open', !globalSidebarCollapsed);
|
| 295 |
+
body.classList.toggle('sidebar-collapsed', globalSidebarCollapsed);
|
| 296 |
+
}
|
| 297 |
+
});
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/**
|
| 301 |
+
* Toggle standard collapsible section visibility.
|
| 302 |
+
*
|
| 303 |
+
* Standard Pattern:
|
| 304 |
+
* Header with .detail-icon (▸/▾) and body that expands/collapses.
|
| 305 |
+
*
|
| 306 |
+
* Special Behaviors:
|
| 307 |
+
* 1. Crew Sort Control: Shows only when crew list expanded
|
| 308 |
+
* 2. Triage Sample Selector: Shows only when expanded AND in advanced/developer mode
|
| 309 |
+
* 3. Sidebar Sync: Updates related sidebar sections if data-sidebar-id present
|
| 310 |
+
* 4. State Persistence: Saves to localStorage if data-pref-key present
|
| 311 |
+
*
|
| 312 |
+
* Used For:
|
| 313 |
+
* - Query form sections
|
| 314 |
+
* - Settings sections
|
| 315 |
+
* - Crew list headers
|
| 316 |
+
* - Equipment sections
|
| 317 |
+
*
|
| 318 |
+
* @param {HTMLElement} el - Header element to toggle
|
| 319 |
+
*/
|
| 320 |
+
function toggleSection(el) {
|
| 321 |
+
const body = el.nextElementSibling;
|
| 322 |
+
const icon = el.querySelector('.detail-icon');
|
| 323 |
+
const isExpanded = body.style.display === "block";
|
| 324 |
+
const nextExpanded = !isExpanded;
|
| 325 |
+
body.style.display = nextExpanded ? "block" : "none";
|
| 326 |
+
if (icon) icon.textContent = nextExpanded ? "▾" : "▸";
|
| 327 |
+
// Show/hide crew sort control only when crew list expanded
|
| 328 |
+
const sortWrap = el.querySelector('#crew-sort-wrap');
|
| 329 |
+
if (sortWrap) {
|
| 330 |
+
sortWrap.style.display = nextExpanded ? "flex" : "none";
|
| 331 |
+
}
|
| 332 |
+
if (el.dataset && el.dataset.sidebarId) {
|
| 333 |
+
syncSidebarSections(el.dataset.sidebarId, nextExpanded);
|
| 334 |
+
}
|
| 335 |
+
if (el.dataset && el.dataset.prefKey) {
|
| 336 |
+
try { localStorage.setItem(el.dataset.prefKey, nextExpanded.toString()); } catch (err) { /* ignore */ }
|
| 337 |
+
}
|
| 338 |
+
if (el.id === 'query-form-header') {
|
| 339 |
+
if (typeof window.handleStartPanelToggle === 'function') {
|
| 340 |
+
window.handleStartPanelToggle(nextExpanded);
|
| 341 |
+
} else if (typeof window.updateStartPanelTitle === 'function') {
|
| 342 |
+
window.updateStartPanelTitle();
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/**
|
| 348 |
+
* Toggle detail section with prompt preview special handling.
|
| 349 |
+
*
|
| 350 |
+
* Similar to toggleSection but with additional logic for:
|
| 351 |
+
* - Prompt refresh inline button visibility (advanced/developer mode only)
|
| 352 |
+
* - ARIA attributes for accessibility (aria-expanded)
|
| 353 |
+
*
|
| 354 |
+
* Used For:
|
| 355 |
+
* - Prompt preview/editor panel (chat.js)
|
| 356 |
+
* - Other detail panels requiring ARIA support
|
| 357 |
+
*
|
| 358 |
+
* @param {HTMLElement} el - Header element to toggle
|
| 359 |
+
*/
|
| 360 |
+
function toggleDetailSection(el) {
|
| 361 |
+
const body = el.nextElementSibling;
|
| 362 |
+
const icon = el.querySelector('.detail-icon');
|
| 363 |
+
const isExpanded = body.style.display === "block";
|
| 364 |
+
const nextExpanded = !isExpanded;
|
| 365 |
+
body.style.display = nextExpanded ? "block" : "none";
|
| 366 |
+
icon.textContent = nextExpanded ? "▾" : "▸";
|
| 367 |
+
// Handle prompt refresh inline visibility
|
| 368 |
+
const refreshInline = document.getElementById('prompt-refresh-inline');
|
| 369 |
+
if (refreshInline && el.id === 'prompt-preview-header') {
|
| 370 |
+
const isAdvanced = document.body.classList.contains('mode-advanced') || document.body.classList.contains('mode-developer');
|
| 371 |
+
refreshInline.style.display = nextExpanded && isAdvanced ? 'flex' : 'none';
|
| 372 |
+
el.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false');
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
/**
|
| 377 |
+
* Toggle crew card with accordion behavior.
|
| 378 |
+
*
|
| 379 |
+
* Crew Card Features:
|
| 380 |
+
* 1. Icon: Changes ▸ (collapsed) ↔ ▾ (expanded)
|
| 381 |
+
* 2. Action Buttons: Shows/hides based on state
|
| 382 |
+
* 3. Accordion Groups: Collapses siblings in same group when opening
|
| 383 |
+
* 4. Sidebar Sync: Updates related sidebar content
|
| 384 |
+
* 5. Last Opened: Remembers last opened crew for reload restoration
|
| 385 |
+
*
|
| 386 |
+
* Accordion Behavior (Pharmacy):
|
| 387 |
+
* When opening a medication card in pharmacy, other cards in the same
|
| 388 |
+
* collapse-group automatically close to keep UI clean and focused.
|
| 389 |
+
*
|
| 390 |
+
* Used For:
|
| 391 |
+
* - Crew medical cards (crew.js)
|
| 392 |
+
* - Pharmacy medication cards (pharmacy.js)
|
| 393 |
+
* - Equipment cards (equipment.js)
|
| 394 |
+
*
|
| 395 |
+
* @param {HTMLElement} el - Crew card header element
|
| 396 |
+
*/
|
| 397 |
+
function toggleCrewSection(el) {
|
| 398 |
+
const body = el.nextElementSibling;
|
| 399 |
+
const icon = el.querySelector('.toggle-label');
|
| 400 |
+
const actionBtns = el.querySelectorAll('.history-action-btn');
|
| 401 |
+
const isExpanded = body.style.display === "block";
|
| 402 |
+
|
| 403 |
+
body.style.display = isExpanded ? "none" : "block";
|
| 404 |
+
icon.textContent = isExpanded ? "▸" : "▾";
|
| 405 |
+
actionBtns.forEach(btn => { btn.style.visibility = isExpanded ? "hidden" : "visible"; });
|
| 406 |
+
if (el.dataset && el.dataset.sidebarId) {
|
| 407 |
+
syncSidebarSections(el.dataset.sidebarId, !isExpanded);
|
| 408 |
+
}
|
| 409 |
+
// If this header participates in a collapse group, close siblings in the same group when opening
|
| 410 |
+
const group = el.querySelector('.toggle-label')?.dataset?.collapseGroup || el.dataset.collapseGroup;
|
| 411 |
+
if (!isExpanded && group) {
|
| 412 |
+
const container = el.closest('#pharmacy-list') || el.parentElement;
|
| 413 |
+
if (container) {
|
| 414 |
+
container.querySelectorAll(`.toggle-label[data-collapse-group="${group}"]`).forEach(lbl => {
|
| 415 |
+
const header = lbl.closest('.col-header');
|
| 416 |
+
if (!header || header === el) return;
|
| 417 |
+
const b = header.nextElementSibling;
|
| 418 |
+
if (b && b.style.display !== "none") {
|
| 419 |
+
b.style.display = "none";
|
| 420 |
+
lbl.textContent = ">";
|
| 421 |
+
const btns = header.querySelectorAll('.history-action-btn');
|
| 422 |
+
btns.forEach(btn => { btn.style.visibility = "hidden"; });
|
| 423 |
+
}
|
| 424 |
+
});
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
// Remember last opened crew card so we can restore after reloads (e.g., after uploads)
|
| 428 |
+
if (!isExpanded) {
|
| 429 |
+
try {
|
| 430 |
+
const parent = el.closest('.collapsible[data-crew-id]');
|
| 431 |
+
if (parent) {
|
| 432 |
+
const crewId = parent.getAttribute('data-crew-id');
|
| 433 |
+
localStorage.setItem('sailingmed:lastOpenCrew', crewId || '');
|
| 434 |
+
}
|
| 435 |
+
} catch (err) { /* ignore */ }
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
/**
|
| 440 |
+
* Toggle sidebar collapsed state.
|
| 441 |
+
*
|
| 442 |
+
* Triggered by sidebar toggle button. Applies state globally to all
|
| 443 |
+
* sidebars on the page via setSidebarState().
|
| 444 |
+
*
|
| 445 |
+
* @param {HTMLElement} btn - Toggle button element
|
| 446 |
+
*/
|
| 447 |
+
function toggleSidebar(btn) {
|
| 448 |
+
const sidebar = btn.closest ? btn.closest('.page-sidebar') : btn;
|
| 449 |
+
if (!sidebar) return;
|
| 450 |
+
// Toggle global state and apply to all sidebars
|
| 451 |
+
const nextCollapsed = !globalSidebarCollapsed;
|
| 452 |
+
setSidebarState(nextCollapsed);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
/**
|
| 456 |
+
* Sync sidebar section visibility with main content sections.
|
| 457 |
+
*
|
| 458 |
+
* When main content section opens/closes, matching sidebar sections
|
| 459 |
+
* (identified by data-sidebar-section attribute) show/hide accordingly.
|
| 460 |
+
*
|
| 461 |
+
* Example:
|
| 462 |
+
* ```html
|
| 463 |
+
* <div data-sidebar-id="crew-medical">Crew Medical</div>
|
| 464 |
+
* <!-- Opens... -->
|
| 465 |
+
* <div data-sidebar-section="crew-medical">Related help content</div>
|
| 466 |
+
* <!-- ^ This shows in sidebar -->
|
| 467 |
+
* ```
|
| 468 |
+
*
|
| 469 |
+
* @param {string} sectionId - Section identifier to sync
|
| 470 |
+
* @param {boolean} isOpen - True if section is open
|
| 471 |
+
*/
|
| 472 |
+
function syncSidebarSections(sectionId, isOpen) {
|
| 473 |
+
if (!sectionId) return;
|
| 474 |
+
document.querySelectorAll(`[data-sidebar-section="${sectionId}"]`).forEach(sec => {
|
| 475 |
+
if (isOpen) {
|
| 476 |
+
sec.classList.remove('hidden');
|
| 477 |
+
} else {
|
| 478 |
+
sec.classList.add('hidden');
|
| 479 |
+
}
|
| 480 |
+
});
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* Initialize sidebar state on page load.
|
| 485 |
+
*
|
| 486 |
+
* Initialization Process:
|
| 487 |
+
* 1. Restores collapsed state from localStorage
|
| 488 |
+
* 2. Applies state to all sidebars
|
| 489 |
+
* 3. Syncs sidebar sections with main content collapsible states
|
| 490 |
+
*
|
| 491 |
+
* Called once during startup initialization.
|
| 492 |
+
*/
|
| 493 |
+
function initSidebarSync() {
|
| 494 |
+
try {
|
| 495 |
+
const saved = localStorage.getItem(SIDEBAR_STATE_KEY);
|
| 496 |
+
globalSidebarCollapsed = saved === '1';
|
| 497 |
+
} catch (err) { /* ignore */ }
|
| 498 |
+
setSidebarState(globalSidebarCollapsed);
|
| 499 |
+
document.querySelectorAll('[data-sidebar-id]').forEach(header => {
|
| 500 |
+
const body = header.nextElementSibling;
|
| 501 |
+
const isOpen = body && body.style.display === 'block';
|
| 502 |
+
syncSidebarSections(header.dataset.sidebarId, isOpen);
|
| 503 |
+
});
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
/**
|
| 507 |
+
* Ensure crew data is loaded with concurrency protection.
|
| 508 |
+
*
|
| 509 |
+
* Loading Strategy:
|
| 510 |
+
* - First call: Initiates loadData(), sets promise
|
| 511 |
+
* - Concurrent calls: Return existing promise (no duplicate loads)
|
| 512 |
+
* - Subsequent calls after load: Return immediately (cached flag)
|
| 513 |
+
*
|
| 514 |
+
* Protects against race conditions when multiple tabs/functions
|
| 515 |
+
* request crew data simultaneously.
|
| 516 |
+
*
|
| 517 |
+
* @returns {Promise<void>} Resolves when crew data loaded
|
| 518 |
+
*/
|
| 519 |
+
async function ensureCrewData() {
|
| 520 |
+
if (crewDataLoaded) return;
|
| 521 |
+
if (crewDataPromise) return crewDataPromise;
|
| 522 |
+
crewDataPromise = loadData()
|
| 523 |
+
.then(() => { crewDataLoaded = true; crewDataPromise = null; })
|
| 524 |
+
.catch((err) => { crewDataPromise = null; throw err; });
|
| 525 |
+
return crewDataPromise;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
/**
|
| 529 |
+
* Navigate to a tab and initialize its content.
|
| 530 |
+
*
|
| 531 |
+
* Tab Switching Process:
|
| 532 |
+
* 1. Hides all content sections
|
| 533 |
+
* 2. Removes 'active' class from all tabs
|
| 534 |
+
* 3. Shows target content section
|
| 535 |
+
* 4. Adds 'active' class to clicked tab
|
| 536 |
+
* 5. Updates banner controls visibility
|
| 537 |
+
* 6. Loads tab-specific data/UI
|
| 538 |
+
*
|
| 539 |
+
* Tab-Specific Initialization:
|
| 540 |
+
*
|
| 541 |
+
* **Chat Tab:**
|
| 542 |
+
* - Updates UI (mode, privacy state)
|
| 543 |
+
* - Ensures crew data loaded (for patient selector)
|
| 544 |
+
* - Loads context sidebar
|
| 545 |
+
* - Restores collapsible state
|
| 546 |
+
* - Prefetches prompt preview
|
| 547 |
+
*
|
| 548 |
+
* **Settings Tab:**
|
| 549 |
+
* - Loads settings UI
|
| 550 |
+
* - Loads crew credentials
|
| 551 |
+
* - Loads workspace switcher
|
| 552 |
+
* - Loads context sidebar
|
| 553 |
+
*
|
| 554 |
+
* **CrewMedical / VesselCrewInfo Tabs:**
|
| 555 |
+
* - Ensures crew data loaded
|
| 556 |
+
* - Loads vessel data (VesselCrewInfo only)
|
| 557 |
+
* - Loads context sidebar
|
| 558 |
+
*
|
| 559 |
+
* **OnboardEquipment Tab:**
|
| 560 |
+
* - Loads equipment list
|
| 561 |
+
* - Preloads pharmacy data
|
| 562 |
+
* - Loads context sidebar
|
| 563 |
+
*
|
| 564 |
+
* @param {Event} e - Click event
|
| 565 |
+
* @param {string} n - Tab name/ID to show
|
| 566 |
+
*/
|
| 567 |
+
async function showTab(trigger, n) {
|
| 568 |
+
document.querySelectorAll('.content').forEach(c=>c.style.display='none');
|
| 569 |
+
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
| 570 |
+
document.getElementById(n).style.display='flex';
|
| 571 |
+
const clickedTab = trigger?.currentTarget
|
| 572 |
+
|| (trigger && trigger.nodeType === 1 ? trigger : null)
|
| 573 |
+
|| document.querySelector(`.tab[onclick*="'${n}'"]`);
|
| 574 |
+
if (clickedTab) {
|
| 575 |
+
clickedTab.classList.add('active');
|
| 576 |
+
}
|
| 577 |
+
toggleBannerControls(n);
|
| 578 |
+
if (n === 'Chat') updateUI();
|
| 579 |
+
|
| 580 |
+
if(n === 'Settings') {
|
| 581 |
+
if (typeof loadSettingsUI === 'function') {
|
| 582 |
+
await loadSettingsUI();
|
| 583 |
+
}
|
| 584 |
+
if (typeof loadCrewCredentials === 'function') {
|
| 585 |
+
loadCrewCredentials();
|
| 586 |
+
}
|
| 587 |
+
if (typeof loadWorkspaceSwitcher === 'function') {
|
| 588 |
+
loadWorkspaceSwitcher();
|
| 589 |
+
}
|
| 590 |
+
loadContext('Settings');
|
| 591 |
+
} else if(n === 'CrewMedical' || n === 'VesselCrewInfo') {
|
| 592 |
+
try {
|
| 593 |
+
await ensureCrewData();
|
| 594 |
+
if (n === 'VesselCrewInfo' && typeof ensureVesselLoaded === 'function') {
|
| 595 |
+
await ensureVesselLoaded();
|
| 596 |
+
}
|
| 597 |
+
} catch (err) {
|
| 598 |
+
console.warn('Tab load data failed:', err);
|
| 599 |
+
}
|
| 600 |
+
loadContext(n);
|
| 601 |
+
} else if (n === 'OnboardEquipment') {
|
| 602 |
+
await ensureMedicalChestLoaded();
|
| 603 |
+
loadContext(n);
|
| 604 |
+
}
|
| 605 |
+
if (n === 'Chat') {
|
| 606 |
+
try {
|
| 607 |
+
await ensureCrewData();
|
| 608 |
+
} catch (err) {
|
| 609 |
+
console.warn('Chat crew load failed:', err);
|
| 610 |
+
}
|
| 611 |
+
loadContext('Chat');
|
| 612 |
+
if (typeof window.syncStartPanelWithConsultationState === 'function') {
|
| 613 |
+
window.syncStartPanelWithConsultationState();
|
| 614 |
+
} else {
|
| 615 |
+
restoreCollapsibleState('query-form-header', true);
|
| 616 |
+
}
|
| 617 |
+
restoreCollapsibleState('triage-pathway-header', false);
|
| 618 |
+
// Prefetch prompt preview so it is ready when expanded
|
| 619 |
+
if (typeof refreshPromptPreview === 'function') {
|
| 620 |
+
refreshPromptPreview();
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
// Ensure inline onclick handlers can always resolve this function.
|
| 626 |
+
window.showTab = showTab;
|
| 627 |
+
|
| 628 |
+
/**
|
| 629 |
+
* Load crew, history, and settings data from server.
|
| 630 |
+
*
|
| 631 |
+
* Loading Strategy:
|
| 632 |
+
* - Concurrent fetch of history/settings on every call
|
| 633 |
+
* - Optional patient roster reuse for lightweight refresh paths
|
| 634 |
+
* - Patients: Hard requirement, fails if unavailable
|
| 635 |
+
* - History: Soft requirement, continues if fails (empty array)
|
| 636 |
+
* - Settings: Optional, uses defaults if unavailable
|
| 637 |
+
*
|
| 638 |
+
* Concurrency Strategy:
|
| 639 |
+
* - If a refresh is already in flight, concurrent callers share that promise
|
| 640 |
+
* unless `options.force === true`
|
| 641 |
+
*
|
| 642 |
+
* Lightweight Refresh Mode:
|
| 643 |
+
* - `options.skipPatients === true` reuses cached roster when available
|
| 644 |
+
* - This keeps post-chat refreshes fast while still updating history/settings
|
| 645 |
+
*
|
| 646 |
+
* Error Handling:
|
| 647 |
+
* - History parse failure: Warns and continues with []
|
| 648 |
+
* - Settings parse failure: Warns and continues with {}
|
| 649 |
+
* - Patients failure: Throws error and shows graceful UI fallback
|
| 650 |
+
*
|
| 651 |
+
* Race Condition Protection:
|
| 652 |
+
* If loadCrewData not yet available (script still loading), retries
|
| 653 |
+
* after 150ms to allow crew.js to finish initializing.
|
| 654 |
+
*
|
| 655 |
+
* Side Effects:
|
| 656 |
+
* - Sets window.CACHED_SETTINGS for global access
|
| 657 |
+
* - Calls loadCrewData() to render crew UI
|
| 658 |
+
* - Sets crewDataLoaded flag
|
| 659 |
+
* - Updates patient selector dropdown
|
| 660 |
+
*
|
| 661 |
+
* Fallback UI on Error:
|
| 662 |
+
* - Shows "Unable to load crew data" message
|
| 663 |
+
* - Provides "Unnamed Crew" option in selectors
|
| 664 |
+
* - Prevents cascading errors
|
| 665 |
+
*
|
| 666 |
+
* @throws {Error} If patients data unavailable or malformed
|
| 667 |
+
*/
|
| 668 |
+
async function loadData(options = {}) {
|
| 669 |
+
const opts = {
|
| 670 |
+
skipPatients: false,
|
| 671 |
+
force: false,
|
| 672 |
+
forcePatients: false,
|
| 673 |
+
...(options && typeof options === 'object' ? options : {}),
|
| 674 |
+
};
|
| 675 |
+
if (!opts.force && loadDataInFlight) {
|
| 676 |
+
return loadDataInFlight;
|
| 677 |
+
}
|
| 678 |
+
loadDataInFlight = (async () => {
|
| 679 |
+
try {
|
| 680 |
+
const historyResPromise = fetch('/api/data/history', { credentials: 'same-origin' })
|
| 681 |
+
.catch((err) => {
|
| 682 |
+
console.warn('History request failed before response; continuing without history.', err);
|
| 683 |
+
return null;
|
| 684 |
+
});
|
| 685 |
+
const settingsResPromise = fetch('/api/data/settings', { credentials: 'same-origin' })
|
| 686 |
+
.catch((err) => {
|
| 687 |
+
console.warn('Settings request failed before response; using defaults.', err);
|
| 688 |
+
return null;
|
| 689 |
+
});
|
| 690 |
+
|
| 691 |
+
const shouldFetchPatients = !opts.skipPatients
|
| 692 |
+
|| opts.forcePatients
|
| 693 |
+
|| !Array.isArray(cachedPatientsRoster)
|
| 694 |
+
|| !cachedPatientsRoster.length;
|
| 695 |
+
|
| 696 |
+
let data = Array.isArray(cachedPatientsRoster) ? cachedPatientsRoster : [];
|
| 697 |
+
if (shouldFetchPatients) {
|
| 698 |
+
// Prioritize patient roster fetch so #p-select is usable as early as possible.
|
| 699 |
+
const patientsResPromise = fetch('/api/data/patients', { credentials: 'same-origin' });
|
| 700 |
+
const patientOptionsPromise = fetch('/api/patients/options', { credentials: 'same-origin' })
|
| 701 |
+
.then(async (res) => {
|
| 702 |
+
if (!res.ok) return null;
|
| 703 |
+
const optionsData = await res.json();
|
| 704 |
+
return Array.isArray(optionsData) ? optionsData : null;
|
| 705 |
+
})
|
| 706 |
+
.then((optionsData) => {
|
| 707 |
+
if (Array.isArray(optionsData) && optionsData.length) {
|
| 708 |
+
populateCrewSelectFast(optionsData);
|
| 709 |
+
cacheCrewOptionsFast(optionsData);
|
| 710 |
+
}
|
| 711 |
+
return optionsData;
|
| 712 |
+
})
|
| 713 |
+
.catch((err) => {
|
| 714 |
+
console.warn('Patient options request failed before response; falling back to full patients payload.', err);
|
| 715 |
+
return null;
|
| 716 |
+
});
|
| 717 |
+
|
| 718 |
+
const res = await patientsResPromise;
|
| 719 |
+
if (!res.ok) {
|
| 720 |
+
if (!Array.isArray(cachedPatientsRoster) || !cachedPatientsRoster.length) {
|
| 721 |
+
throw new Error(`Patients request failed: ${res.status}`);
|
| 722 |
+
}
|
| 723 |
+
console.warn('Patients request failed; reusing cached roster. Status:', res.status);
|
| 724 |
+
data = cachedPatientsRoster;
|
| 725 |
+
} else {
|
| 726 |
+
data = await res.json();
|
| 727 |
+
if (!Array.isArray(data)) throw new Error('Unexpected patients data format');
|
| 728 |
+
cachedPatientsRoster = data;
|
| 729 |
+
populateCrewSelectFast(data);
|
| 730 |
+
cacheCrewOptionsFast(data);
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
// Ensure fast options request has settled; failures are already handled.
|
| 734 |
+
await patientOptionsPromise;
|
| 735 |
+
} else {
|
| 736 |
+
// Reuse cached roster for lightweight refreshes (e.g., after chat completion).
|
| 737 |
+
populateCrewSelectFast(data);
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
const [historyRes, settingsRes] = await Promise.all([historyResPromise, settingsResPromise]);
|
| 741 |
+
if (!settingsRes || !settingsRes.ok) console.warn('Settings request failed:', settingsRes ? settingsRes.status : 'network');
|
| 742 |
+
|
| 743 |
+
// Parse history, but never block crew rendering if it fails
|
| 744 |
+
let history = [];
|
| 745 |
+
if (historyRes && historyRes.ok) {
|
| 746 |
+
try {
|
| 747 |
+
const parsedHistory = await historyRes.json();
|
| 748 |
+
history = Array.isArray(parsedHistory) ? parsedHistory : [];
|
| 749 |
+
} catch (err) {
|
| 750 |
+
console.warn('History parse failed; continuing without history.', err);
|
| 751 |
+
history = [];
|
| 752 |
+
}
|
| 753 |
+
} else {
|
| 754 |
+
console.warn('History request failed; continuing without history. Status:', historyRes ? historyRes.status : 'network');
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
// Parse settings (optional)
|
| 758 |
+
let settings = {};
|
| 759 |
+
try {
|
| 760 |
+
settings = (settingsRes && settingsRes.ok) ? await settingsRes.json() : {};
|
| 761 |
+
} catch (err) {
|
| 762 |
+
console.warn('Settings parse failed, using defaults.', err);
|
| 763 |
+
}
|
| 764 |
+
window.CACHED_SETTINGS = settings || {};
|
| 765 |
+
|
| 766 |
+
// Ensure crew renderer is ready; retry briefly if the script is still loading
|
| 767 |
+
if (typeof loadCrewData !== 'function') {
|
| 768 |
+
console.warn('loadCrewData missing; retrying shortly…');
|
| 769 |
+
setTimeout(() => {
|
| 770 |
+
if (typeof loadCrewData === 'function') {
|
| 771 |
+
loadCrewData(data, history, settings || {});
|
| 772 |
+
} else {
|
| 773 |
+
console.error('loadCrewData still missing after retry.');
|
| 774 |
+
}
|
| 775 |
+
}, 150);
|
| 776 |
+
return;
|
| 777 |
+
}
|
| 778 |
+
loadCrewData(data, history, settings || {});
|
| 779 |
+
crewDataLoaded = true;
|
| 780 |
+
|
| 781 |
+
} catch (err) {
|
| 782 |
+
console.error('Failed to load crew data', err);
|
| 783 |
+
window.CACHED_SETTINGS = window.CACHED_SETTINGS || {};
|
| 784 |
+
// Gracefully clear UI to avoid JS errors
|
| 785 |
+
const pSelect = document.getElementById('p-select');
|
| 786 |
+
if (pSelect) pSelect.innerHTML = '<option value=\"\">Unnamed Crew Member</option>';
|
| 787 |
+
const medicalContainer = document.getElementById('crew-medical-list');
|
| 788 |
+
if (medicalContainer) medicalContainer.innerHTML = `<div style="color:#666;">Unable to load crew data. ${err.message}</div>`;
|
| 789 |
+
const infoContainer = document.getElementById('crew-info-list');
|
| 790 |
+
if (infoContainer) infoContainer.innerHTML = `<div style="color:#666;">Unable to load crew data. ${err.message}</div>`;
|
| 791 |
+
} finally {
|
| 792 |
+
loadDataInFlight = null;
|
| 793 |
+
}
|
| 794 |
+
})();
|
| 795 |
+
return loadDataInFlight;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
/**
|
| 799 |
+
* Show/hide tab-specific banner controls.
|
| 800 |
+
*
|
| 801 |
+
* Banner Control Groups:
|
| 802 |
+
*
|
| 803 |
+
* **Chat Tab:**
|
| 804 |
+
* - Mode selector (triage/inquiry)
|
| 805 |
+
* - Privacy toggle (logging on/off)
|
| 806 |
+
* - Patient selector
|
| 807 |
+
*
|
| 808 |
+
* **Crew Health & Log Tab:**
|
| 809 |
+
* - Export all medical records button
|
| 810 |
+
*
|
| 811 |
+
* **Vessel & Crew Info Tab:**
|
| 812 |
+
* - Export crew CSV button (for border crossings)
|
| 813 |
+
* - Export immigration zip button (crew + vessel package)
|
| 814 |
+
*
|
| 815 |
+
* Other tabs: All banner controls hidden
|
| 816 |
+
*
|
| 817 |
+
* @param {string} activeTab - Current active tab name
|
| 818 |
+
*/
|
| 819 |
+
function toggleBannerControls(activeTab) {
|
| 820 |
+
const triageControls = document.getElementById('banner-controls-triage');
|
| 821 |
+
const crewControls = document.getElementById('banner-controls-crew');
|
| 822 |
+
const medExportAll = document.getElementById('crew-med-export-all-btn');
|
| 823 |
+
const crewCsvBtn = document.getElementById('crew-csv-btn');
|
| 824 |
+
const immigrationZipBtn = document.getElementById('crew-immigration-zip-btn');
|
| 825 |
+
if (triageControls) triageControls.style.display = activeTab === 'Chat' ? 'flex' : 'none';
|
| 826 |
+
if (crewControls) crewControls.style.display = (activeTab === 'CrewMedical' || activeTab === 'VesselCrewInfo') ? 'flex' : 'none';
|
| 827 |
+
if (medExportAll) medExportAll.style.display = activeTab === 'CrewMedical' ? 'inline-flex' : 'none';
|
| 828 |
+
if (crewCsvBtn) crewCsvBtn.style.display = activeTab === 'VesselCrewInfo' ? 'inline-flex' : 'none';
|
| 829 |
+
if (immigrationZipBtn) immigrationZipBtn.style.display = activeTab === 'VesselCrewInfo' ? 'inline-flex' : 'none';
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
window.toggleBannerControls = toggleBannerControls;
|
| 833 |
+
|
| 834 |
+
/**
|
| 835 |
+
* Application initialization on page load.
|
| 836 |
+
*
|
| 837 |
+
* Initialization Sequence:
|
| 838 |
+
* 1. **Crew Data**: Load immediately (used by multiple tabs)
|
| 839 |
+
* 2. **Medical Chest**: Preload for fast access
|
| 840 |
+
* - preloadPharmacy(): Loads inventory
|
| 841 |
+
* - loadWhoMedsFromServer(): Loads WHO reference list
|
| 842 |
+
* - ensurePharmacyLabels(): Loads user labels
|
| 843 |
+
* - loadPharmacy(): Pre-renders pharmacy UI
|
| 844 |
+
* 3. **Chat UI**: Initialize with updateUI()
|
| 845 |
+
* 4. **Banner Controls**: Show Chat tab controls
|
| 846 |
+
* 5. **Sidebar**: Initialize and restore state
|
| 847 |
+
* 6. **Query Form**: Restore collapsed state
|
| 848 |
+
* 7. **Last Chat**: Restore previous session view
|
| 849 |
+
*
|
| 850 |
+
* Preloading Strategy:
|
| 851 |
+
* Medical Chest is preloaded because:
|
| 852 |
+
* - Frequently accessed (medications needed for consultations)
|
| 853 |
+
* - Large dataset (better to load early than wait on tab switch)
|
| 854 |
+
* - Improves perceived performance
|
| 855 |
+
*
|
| 856 |
+
* Error Handling:
|
| 857 |
+
* All preload operations use .catch() to prevent blocking page load
|
| 858 |
+
* if individual components fail.
|
| 859 |
+
*
|
| 860 |
+
* Called By: Browser on page load completion
|
| 861 |
+
*/
|
| 862 |
+
function runCriticalStartup() {
|
| 863 |
+
if (startupCriticalInitDone) return;
|
| 864 |
+
startupCriticalInitDone = true;
|
| 865 |
+
migrateCollapsiblePrefs();
|
| 866 |
+
// Use cached lightweight crew options immediately to avoid a blank selector
|
| 867 |
+
// while network requests are still in flight.
|
| 868 |
+
hydrateCrewSelectFromCache();
|
| 869 |
+
startupCrewReadyPromise = ensureCrewData().catch((err) => {
|
| 870 |
+
console.warn('ensureCrewData failed during critical boot:', err);
|
| 871 |
+
});
|
| 872 |
+
updateUI();
|
| 873 |
+
toggleBannerControls('Chat');
|
| 874 |
+
initSidebarSync();
|
| 875 |
+
if (typeof window.syncStartPanelWithConsultationState === 'function') {
|
| 876 |
+
window.syncStartPanelWithConsultationState();
|
| 877 |
+
} else {
|
| 878 |
+
restoreCollapsibleState('query-form-header', true);
|
| 879 |
+
}
|
| 880 |
+
restoreCollapsibleState('triage-pathway-header', false);
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
function runDeferredStartup() {
|
| 884 |
+
if (startupDeferredInitDone) return;
|
| 885 |
+
startupDeferredInitDone = true;
|
| 886 |
+
const preloadMedicalChest = () => {
|
| 887 |
+
// Preload Medical Chest after crew dropdown hydration to prioritize chat readiness.
|
| 888 |
+
if (typeof preloadPharmacy === 'function') {
|
| 889 |
+
preloadPharmacy().catch((err) => console.warn('preloadPharmacy failed:', err));
|
| 890 |
+
}
|
| 891 |
+
if (typeof loadWhoMedsFromServer === 'function') {
|
| 892 |
+
loadWhoMedsFromServer().catch((err) => console.warn('preload WHO meds failed:', err));
|
| 893 |
+
}
|
| 894 |
+
if (typeof ensurePharmacyLabels === 'function') {
|
| 895 |
+
ensurePharmacyLabels().catch((err) => console.warn('preload pharmacy labels failed:', err));
|
| 896 |
+
}
|
| 897 |
+
if (typeof loadPharmacy === 'function') {
|
| 898 |
+
loadPharmacy(); // pre-warm Medical Chest so list is ready when tab opens
|
| 899 |
+
}
|
| 900 |
+
};
|
| 901 |
+
const crewReady = startupCrewReadyPromise || ensureCrewData().catch((err) => {
|
| 902 |
+
console.warn('ensureCrewData failed during deferred boot:', err);
|
| 903 |
+
});
|
| 904 |
+
crewReady.finally(() => {
|
| 905 |
+
if (typeof window.requestIdleCallback === 'function') {
|
| 906 |
+
window.requestIdleCallback(preloadMedicalChest, { timeout: 800 });
|
| 907 |
+
} else {
|
| 908 |
+
setTimeout(preloadMedicalChest, 0);
|
| 909 |
+
}
|
| 910 |
+
});
|
| 911 |
+
restoreLastChatView();
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
document.addEventListener('DOMContentLoaded', runCriticalStartup);
|
| 915 |
+
window.addEventListener('load', runDeferredStartup);
|
| 916 |
+
|
| 917 |
+
// If scripts are injected late, run startup paths immediately as needed.
|
| 918 |
+
if (document.readyState === 'interactive' || document.readyState === 'complete') {
|
| 919 |
+
runCriticalStartup();
|
| 920 |
+
}
|
| 921 |
+
if (document.readyState === 'complete') {
|
| 922 |
+
runDeferredStartup();
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
// Ensure loadCrewData exists before any calls (safety for race conditions)
|
| 926 |
+
if (typeof window.loadCrewData !== 'function') {
|
| 927 |
+
console.error('window.loadCrewData is not defined at main.js load time.');
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
/**
|
| 931 |
+
* Restore collapsible section state from localStorage.
|
| 932 |
+
*
|
| 933 |
+
* Restoration Process:
|
| 934 |
+
* 1. Looks for stored state in localStorage (by header ID or data-pref-key)
|
| 935 |
+
* 2. Applies stored state or uses default if not found
|
| 936 |
+
* 3. Updates icon (▸/▾)
|
| 937 |
+
* 4. Special handling for prompt preview (ARIA + refresh button)
|
| 938 |
+
*
|
| 939 |
+
* Special Cases:
|
| 940 |
+
*
|
| 941 |
+
* **Prompt Preview Header:**
|
| 942 |
+
* - Updates aria-expanded attribute
|
| 943 |
+
* - Shows/hides prompt refresh inline button
|
| 944 |
+
*
|
| 945 |
+
* Use Cases:
|
| 946 |
+
* - Query form: Restore expanded/collapsed state
|
| 947 |
+
* - Prompt preview: Restore editor visibility
|
| 948 |
+
* - Settings sections: Restore user preferences
|
| 949 |
+
*
|
| 950 |
+
* @param {string} headerId - ID of header element
|
| 951 |
+
* @param {boolean} defaultOpen - Default state if no stored preference
|
| 952 |
+
*/
|
| 953 |
+
function restoreCollapsibleState(headerId, defaultOpen = true) {
|
| 954 |
+
const header = document.getElementById(headerId);
|
| 955 |
+
if (!header) return;
|
| 956 |
+
const body = header.nextElementSibling;
|
| 957 |
+
if (!body) return;
|
| 958 |
+
let isOpen = defaultOpen;
|
| 959 |
+
const key = header.dataset?.prefKey || headerId;
|
| 960 |
+
try {
|
| 961 |
+
const stored = localStorage.getItem(key);
|
| 962 |
+
if (stored !== null) {
|
| 963 |
+
isOpen = stored === 'true';
|
| 964 |
+
}
|
| 965 |
+
} catch (err) { /* ignore */ }
|
| 966 |
+
body.style.display = isOpen ? 'block' : 'none';
|
| 967 |
+
const icon = header.querySelector('.detail-icon');
|
| 968 |
+
if (icon) icon.textContent = isOpen ? '▾' : '▸';
|
| 969 |
+
if (headerId === 'prompt-preview-header') {
|
| 970 |
+
const refreshInline = document.getElementById('prompt-refresh-inline');
|
| 971 |
+
if (refreshInline) refreshInline.style.display = isOpen ? 'flex' : 'none';
|
| 972 |
+
header.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
| 973 |
+
}
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
/**
|
| 977 |
+
* Load context sidebar content for tab.
|
| 978 |
+
*
|
| 979 |
+
* Legacy Function:
|
| 980 |
+
* Previously loaded remote context content. Now sidebars are static HTML,
|
| 981 |
+
* so this is a no-op but kept for compatibility.
|
| 982 |
+
*
|
| 983 |
+
* @param {string} tabName - Tab name (no longer used)
|
| 984 |
+
* @deprecated Sidebars are now static HTML
|
| 985 |
+
*/
|
| 986 |
+
async function loadContext(tabName) {
|
| 987 |
+
// Sidebars are now fully static HTML; no remote context to fetch.
|
| 988 |
+
return;
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
/**
|
| 992 |
+
* Restore the most recent chat session on page load.
|
| 993 |
+
*
|
| 994 |
+
* Restoration Process:
|
| 995 |
+
* 1. Checks display element is empty (fresh load)
|
| 996 |
+
* 2. Checks skipLastChat flag (user may have cleared it)
|
| 997 |
+
* 3. Fetches history from server
|
| 998 |
+
* 4. Gets most recent entry
|
| 999 |
+
* 5. Attempts active session restore via chat.js helper
|
| 1000 |
+
* 6. Falls back to read-only rendering if active restore is unavailable
|
| 1001 |
+
*
|
| 1002 |
+
* Display Format:
|
| 1003 |
+
* - Title: "Last Session — [Patient] ([Date])"
|
| 1004 |
+
* - Query section
|
| 1005 |
+
* - Response section
|
| 1006 |
+
*
|
| 1007 |
+
* Use Cases:
|
| 1008 |
+
* - User returns to page: See previous consultation
|
| 1009 |
+
* - Page refresh: Maintain context
|
| 1010 |
+
* - Quick reference: Check recent advice
|
| 1011 |
+
*
|
| 1012 |
+
* Skip Conditions:
|
| 1013 |
+
* - User cleared display (skipLastChat=1)
|
| 1014 |
+
* - Display already has content (manual chat run)
|
| 1015 |
+
* - No history available
|
| 1016 |
+
* - History fetch fails
|
| 1017 |
+
*
|
| 1018 |
+
* Integration:
|
| 1019 |
+
* Respects skipLastChat flag set when starting a new consultation clears prior
|
| 1020 |
+
* on-screen results.
|
| 1021 |
+
*/
|
| 1022 |
+
async function restoreLastChatView() {
|
| 1023 |
+
try {
|
| 1024 |
+
const display = document.getElementById('display');
|
| 1025 |
+
if (!display || display.children.length > 0) return;
|
| 1026 |
+
try {
|
| 1027 |
+
const skip = localStorage.getItem('sailingmed:skipLastChat');
|
| 1028 |
+
if (skip === '1') return;
|
| 1029 |
+
} catch (err) { /* ignore */ }
|
| 1030 |
+
const res = await fetch('/api/data/history', { credentials: 'same-origin' });
|
| 1031 |
+
if (!res.ok) return;
|
| 1032 |
+
const history = await res.json();
|
| 1033 |
+
if (!Array.isArray(history) || history.length === 0) return;
|
| 1034 |
+
const toTimestamp = (entry) => {
|
| 1035 |
+
if (!entry || typeof entry !== 'object') return Number.NEGATIVE_INFINITY;
|
| 1036 |
+
const raw = entry.updated_at || entry.date || '';
|
| 1037 |
+
if (!raw) return Number.NEGATIVE_INFINITY;
|
| 1038 |
+
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
|
| 1039 |
+
const ts = Date.parse(normalized);
|
| 1040 |
+
return Number.isNaN(ts) ? Number.NEGATIVE_INFINITY : ts;
|
| 1041 |
+
};
|
| 1042 |
+
const sortedHistory = history.slice().sort((a, b) => toTimestamp(b) - toTimestamp(a));
|
| 1043 |
+
const last = sortedHistory[0];
|
| 1044 |
+
if (!last) return;
|
| 1045 |
+
if (typeof window.restoreHistoryEntrySession === 'function') {
|
| 1046 |
+
try {
|
| 1047 |
+
const restored = window.restoreHistoryEntrySession(last, {
|
| 1048 |
+
focusInput: false,
|
| 1049 |
+
forceLoggingOn: true,
|
| 1050 |
+
notifyRestored: false,
|
| 1051 |
+
allowTakeover: false,
|
| 1052 |
+
});
|
| 1053 |
+
if (restored) return;
|
| 1054 |
+
} catch (err) {
|
| 1055 |
+
console.warn('Failed to restore active last chat session', err);
|
| 1056 |
+
}
|
| 1057 |
+
}
|
| 1058 |
+
const parseTranscript = (entry) => {
|
| 1059 |
+
if (!entry) return { messages: [] };
|
| 1060 |
+
if (entry.response && typeof entry.response === 'object' && Array.isArray(entry.response.messages)) {
|
| 1061 |
+
return { messages: entry.response.messages, meta: entry.response.meta || {} };
|
| 1062 |
+
}
|
| 1063 |
+
if (typeof entry.response === 'string' && entry.response.trim().startsWith('{')) {
|
| 1064 |
+
try {
|
| 1065 |
+
const parsed = JSON.parse(entry.response);
|
| 1066 |
+
if (parsed && Array.isArray(parsed.messages)) {
|
| 1067 |
+
return { messages: parsed.messages, meta: parsed.meta || {} };
|
| 1068 |
+
}
|
| 1069 |
+
} catch (err) { /* ignore */ }
|
| 1070 |
+
}
|
| 1071 |
+
return { messages: [] };
|
| 1072 |
+
};
|
| 1073 |
+
const transcript = parseTranscript(last);
|
| 1074 |
+
if (transcript.messages.length && typeof window.renderTranscript === 'function') {
|
| 1075 |
+
display.innerHTML = `
|
| 1076 |
+
<div class="response-block" style="border-left-color:var(--inquiry);">
|
| 1077 |
+
<div style="font-weight:800; margin-bottom:6px;">Last Consultation (read-only) — ${last.patient || 'Unknown'} (${last.date || ''})</div>
|
| 1078 |
+
<div style="font-size:12px; color:#555;">Use the Consultation Log to restore and continue this session.</div>
|
| 1079 |
+
</div>
|
| 1080 |
+
`;
|
| 1081 |
+
window.renderTranscript(transcript.messages, { append: true });
|
| 1082 |
+
return;
|
| 1083 |
+
}
|
| 1084 |
+
const parse = (txt) => renderAssistantMarkdownMain(txt || '');
|
| 1085 |
+
const responseHtml = parse(last.response || '');
|
| 1086 |
+
const queryHtml = parse(last.query || '');
|
| 1087 |
+
display.innerHTML = `
|
| 1088 |
+
<div class="response-block">
|
| 1089 |
+
<div style="font-weight:800; margin-bottom:6px;">Last Session — ${last.patient || 'Unknown'} (${last.date || ''})</div>
|
| 1090 |
+
<div style="margin-bottom:8px;"><strong>Query:</strong><br>${queryHtml}</div>
|
| 1091 |
+
<div><strong>Response:</strong><br>${responseHtml}</div>
|
| 1092 |
+
</div>
|
| 1093 |
+
`;
|
| 1094 |
+
} catch (err) {
|
| 1095 |
+
console.warn('Failed to restore last chat view', err);
|
| 1096 |
+
} finally {
|
| 1097 |
+
if (typeof window.syncStartPanelWithConsultationState === 'function') {
|
| 1098 |
+
window.syncStartPanelWithConsultationState();
|
| 1099 |
+
}
|
| 1100 |
+
}
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
/**
|
| 1104 |
+
* Search across all medical inventories.
|
| 1105 |
+
*
|
| 1106 |
+
* Search Scope:
|
| 1107 |
+
* - **all**: Pharmaceuticals + Equipment + Consumables
|
| 1108 |
+
* - **pharma**: Medications only
|
| 1109 |
+
* - **equipment**: Durable medical equipment only
|
| 1110 |
+
* - **consumables**: Single-use supplies only
|
| 1111 |
+
*
|
| 1112 |
+
* Search Fields:
|
| 1113 |
+
*
|
| 1114 |
+
* **Pharmaceuticals:**
|
| 1115 |
+
* - Generic name, brand name
|
| 1116 |
+
* - Indication, dosage
|
| 1117 |
+
* - Storage location, notes
|
| 1118 |
+
*
|
| 1119 |
+
* **Equipment/Consumables:**
|
| 1120 |
+
* - Item name
|
| 1121 |
+
* - Storage location
|
| 1122 |
+
* - Notes, quantity
|
| 1123 |
+
*
|
| 1124 |
+
* Results Display:
|
| 1125 |
+
* - Grouped by category (Pharmaceuticals, Equipment, Consumables)
|
| 1126 |
+
* - Collapsible result sections
|
| 1127 |
+
* - Shows count per category
|
| 1128 |
+
* - Item details (title, detail, storage location)
|
| 1129 |
+
*
|
| 1130 |
+
* Use Cases:
|
| 1131 |
+
* - "Where is the amoxicillin?"
|
| 1132 |
+
* - "Do we have any splints?"
|
| 1133 |
+
* - "What's in Medical Bag 3?"
|
| 1134 |
+
* - "Find all antibiotics"
|
| 1135 |
+
*
|
| 1136 |
+
* Performance:
|
| 1137 |
+
* Concurrent fetch of inventory and tools data for fast results.
|
| 1138 |
+
* Case-insensitive search for better UX.
|
| 1139 |
+
*/
|
| 1140 |
+
async function searchMedicalChest() {
|
| 1141 |
+
const input = document.getElementById('medchest-search-input');
|
| 1142 |
+
const scopeSel = document.getElementById('medchest-search-scope');
|
| 1143 |
+
const resultsBox = document.getElementById('medchest-search-results');
|
| 1144 |
+
if (!input || !scopeSel || !resultsBox) return;
|
| 1145 |
+
const q = (input.value || '').trim().toLowerCase();
|
| 1146 |
+
const scope = scopeSel.value || 'all';
|
| 1147 |
+
if (!q) {
|
| 1148 |
+
resultsBox.innerHTML = '<div style="color:#b71c1c;">Enter a search term.</div>';
|
| 1149 |
+
return;
|
| 1150 |
+
}
|
| 1151 |
+
resultsBox.innerHTML = '<div style="color:#555;">Searching…</div>';
|
| 1152 |
+
try {
|
| 1153 |
+
const wantPharma = scope === 'all' || scope === 'pharma';
|
| 1154 |
+
const wantEquip = scope === 'all' || scope === 'equipment' || scope === 'consumables';
|
| 1155 |
+
const [invData, toolsData] = await Promise.all([
|
| 1156 |
+
wantPharma ? fetch('/api/data/inventory', { credentials: 'same-origin' }).then(r => r.json()) : Promise.resolve([]),
|
| 1157 |
+
wantEquip ? fetch('/api/data/tools', { credentials: 'same-origin' }).then(r => r.json()) : Promise.resolve([]),
|
| 1158 |
+
]);
|
| 1159 |
+
const results = [];
|
| 1160 |
+
if (wantPharma && Array.isArray(invData)) {
|
| 1161 |
+
invData.forEach(m => {
|
| 1162 |
+
const hay = [m.genericName, m.brandName, m.primaryIndication, m.standardDosage, m.storageLocation, m.notes].join(' ').toLowerCase();
|
| 1163 |
+
if (hay.includes(q)) {
|
| 1164 |
+
results.push({ section: 'Pharmaceuticals', title: m.genericName || m.brandName || 'Medication', detail: m.strength || '', extra: m.storageLocation || '' });
|
| 1165 |
+
}
|
| 1166 |
+
});
|
| 1167 |
+
}
|
| 1168 |
+
if (wantEquip && Array.isArray(toolsData)) {
|
| 1169 |
+
toolsData.forEach(t => {
|
| 1170 |
+
const hay = [t.name, t.storageLocation, t.notes, t.quantity].join(' ').toLowerCase();
|
| 1171 |
+
const isConsumable = (t.type || '').toLowerCase() === 'consumable';
|
| 1172 |
+
const sec = isConsumable ? 'Consumables' : 'Equipment';
|
| 1173 |
+
if ((scope === 'consumables' && !isConsumable) || (scope === 'equipment' && isConsumable)) return;
|
| 1174 |
+
if (hay.includes(q)) {
|
| 1175 |
+
results.push({ section: sec, title: t.name || 'Item', detail: t.quantity || '', extra: t.storageLocation || '' });
|
| 1176 |
+
}
|
| 1177 |
+
});
|
| 1178 |
+
}
|
| 1179 |
+
if (!results.length) {
|
| 1180 |
+
resultsBox.innerHTML = '<div style="color:#2c3e50;">No matches found.</div>';
|
| 1181 |
+
return;
|
| 1182 |
+
}
|
| 1183 |
+
const grouped = results.reduce((acc, r) => {
|
| 1184 |
+
acc[r.section] = acc[r.section] || [];
|
| 1185 |
+
acc[r.section].push(r);
|
| 1186 |
+
return acc;
|
| 1187 |
+
}, {});
|
| 1188 |
+
let html = '';
|
| 1189 |
+
Object.keys(grouped).forEach(sec => {
|
| 1190 |
+
const list = grouped[sec];
|
| 1191 |
+
html += `
|
| 1192 |
+
<div style="margin-bottom:10px; border:1px solid #d8e2f5; border-radius:8px;">
|
| 1193 |
+
<div style="padding:8px 10px; background:#eef3ff; cursor:pointer; font-weight:700;" onclick="toggleSearchResults(this)">
|
| 1194 |
+
${sec} — ${list.length} match(es)
|
| 1195 |
+
<span style="float:right;">▾</span>
|
| 1196 |
+
</div>
|
| 1197 |
+
<div class="medchest-search-results-body" style="padding:8px 10px; display:none; background:#fff;">
|
| 1198 |
+
${list.map(item => `
|
| 1199 |
+
<div style="padding:6px 0; border-bottom:1px solid #eee;">
|
| 1200 |
+
<div style="font-weight:700;">${item.title}</div>
|
| 1201 |
+
<div style="font-size:12px; color:#444;">${item.detail || ''}</div>
|
| 1202 |
+
<div style="font-size:12px; color:#666;">${item.extra || ''}</div>
|
| 1203 |
+
</div>
|
| 1204 |
+
`).join('')}
|
| 1205 |
+
</div>
|
| 1206 |
+
</div>
|
| 1207 |
+
`;
|
| 1208 |
+
});
|
| 1209 |
+
resultsBox.innerHTML = html;
|
| 1210 |
+
} catch (err) {
|
| 1211 |
+
resultsBox.innerHTML = `<div style="color:#b71c1c;">Search failed: ${err.message}</div>`;
|
| 1212 |
+
}
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
/**
|
| 1216 |
+
* Toggle search result section visibility.
|
| 1217 |
+
*
|
| 1218 |
+
* Each category (Pharmaceuticals, Equipment, Consumables) is a
|
| 1219 |
+
* collapsible section. This toggles individual sections.
|
| 1220 |
+
*
|
| 1221 |
+
* @param {HTMLElement} headerEl - Result category header element
|
| 1222 |
+
*/
|
| 1223 |
+
function toggleSearchResults(headerEl) {
|
| 1224 |
+
const body = headerEl.nextElementSibling;
|
| 1225 |
+
if (!body) return;
|
| 1226 |
+
const isShown = body.style.display === 'block';
|
| 1227 |
+
body.style.display = isShown ? 'none' : 'block';
|
| 1228 |
+
const arrow = headerEl.querySelector('span');
|
| 1229 |
+
if (arrow) arrow.textContent = isShown ? '▸' : '▾';
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
// expose for inline handlers
|
| 1233 |
+
window.searchMedicalChest = searchMedicalChest;
|
| 1234 |
+
window.toggleSearchResults = toggleSearchResults;
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
//
|
| 1238 |
+
|
| 1239 |
+
// MAINTENANCE NOTE
|
| 1240 |
+
// Historical auto-generated note blocks were removed because they were repetitive and
|
| 1241 |
+
// obscured real logic changes during review. Keep focused comments close to behavior-
|
| 1242 |
+
// critical code paths (UI state hydration, async fetch lifecycle, and mode-gated
|
| 1243 |
+
// controls) so maintenance remains actionable.
|
static/js/pharmacy.js
ADDED
|
@@ -0,0 +1,1723 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================================================
|
| 2 |
+
* Author: Rick Escher
|
| 3 |
+
* Project: SailingMedAdvisor
|
| 4 |
+
* Context: Google HAI-DEF Framework
|
| 5 |
+
* Models: Google MedGemmas
|
| 6 |
+
* Program: Kaggle Impact Challenge
|
| 7 |
+
* ========================================================================== */
|
| 8 |
+
/*
|
| 9 |
+
File: static/js/pharmacy.js
|
| 10 |
+
Author notes: Client-side controller for the Medical Chest (pharmaceuticals).
|
| 11 |
+
I handle rendering, edits, autosave, WHO imports, expiry tracking, and
|
| 12 |
+
single-card expansion behavior.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
let pharmacyCache = [];
|
| 16 |
+
let pharmacyFetchPromise = null;
|
| 17 |
+
const pharmacySaveTimers = {};
|
| 18 |
+
const DEFAULT_USER_LABELS = ['Antibiotic', 'Analgesic', 'Cardiac', 'Respiratory', 'Gastrointestinal', 'Endocrine', 'Emergency'];
|
| 19 |
+
let pharmacyLabelsCache = null;
|
| 20 |
+
let WHO_RECOMMENDED_MEDS = [];
|
| 21 |
+
let whoMedLoaded = false;
|
| 22 |
+
|
| 23 |
+
const TIER_OPTIONS = [
|
| 24 |
+
{ value: '', label: 'Select...' },
|
| 25 |
+
{ value: 'Tier 1', label: 'Tier 1 — Emergency & Surgical' },
|
| 26 |
+
{ value: 'Tier 2', label: 'Tier 2 — Stabilization & Acute' },
|
| 27 |
+
{ value: 'Tier 3', label: 'Tier 3 — Supportive & Maintenance' },
|
| 28 |
+
];
|
| 29 |
+
|
| 30 |
+
const TIER_SUBCATEGORIES = {
|
| 31 |
+
'Tier 1': [
|
| 32 |
+
'Local Anesthesia',
|
| 33 |
+
'Respiratory/Anaphylaxis',
|
| 34 |
+
'Critical Antibiotics (Systemic)',
|
| 35 |
+
'Critical Antibiotics (Ophthalmic)',
|
| 36 |
+
'Emergency Steroids',
|
| 37 |
+
],
|
| 38 |
+
'Tier 2': [
|
| 39 |
+
'Analgesics (Moderate/Severe Pain)',
|
| 40 |
+
'NSAIDs (Mild/Moderate Pain)',
|
| 41 |
+
'Topical Antiseptics/Antibiotics',
|
| 42 |
+
'Standard Antibiotics/Antivirals',
|
| 43 |
+
'Antihistamines/Steroid Creams',
|
| 44 |
+
],
|
| 45 |
+
'Tier 3': [
|
| 46 |
+
'Gastrointestinal (Nausea/Diarrhea/Reflux)',
|
| 47 |
+
'Hydration/Electrolytes',
|
| 48 |
+
'Dermatological (Fungal/Parasitic)',
|
| 49 |
+
'Diagnostic/Maintenance',
|
| 50 |
+
'Chronic/Behavioral',
|
| 51 |
+
],
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* buildTierOptions: function-level behavior note for maintainers.
|
| 56 |
+
* Keep this block synchronized with implementation changes.
|
| 57 |
+
*/
|
| 58 |
+
function buildTierOptions(selected = '') {
|
| 59 |
+
return TIER_OPTIONS.map((opt) => `<option value="${escapeHtml(opt.value)}" ${opt.value === selected ? 'selected' : ''}>${escapeHtml(opt.label)}</option>`).join('');
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* buildTierSubcategoryOptions: function-level behavior note for maintainers.
|
| 64 |
+
* Keep this block synchronized with implementation changes.
|
| 65 |
+
*/
|
| 66 |
+
function buildTierSubcategoryOptions(tier = '', selected = '') {
|
| 67 |
+
const options = [{ value: '', label: 'Select...' }];
|
| 68 |
+
const list = TIER_SUBCATEGORIES[tier] || [];
|
| 69 |
+
list.forEach((entry) => options.push({ value: entry, label: entry }));
|
| 70 |
+
return options.map((opt) => `<option value="${escapeHtml(opt.value)}" ${opt.value === selected ? 'selected' : ''}>${escapeHtml(opt.label)}</option>`).join('');
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/**
|
| 74 |
+
* handleMedTierChange: function-level behavior note for maintainers.
|
| 75 |
+
* Keep this block synchronized with implementation changes.
|
| 76 |
+
*/
|
| 77 |
+
function handleMedTierChange(medId) {
|
| 78 |
+
const tierEl = document.getElementById(`tier-${medId}`);
|
| 79 |
+
const catEl = document.getElementById(`tiercat-${medId}`);
|
| 80 |
+
if (!tierEl || !catEl) return;
|
| 81 |
+
const tierVal = tierEl.value || '';
|
| 82 |
+
const current = catEl.value || '';
|
| 83 |
+
catEl.innerHTML = buildTierSubcategoryOptions(tierVal, current);
|
| 84 |
+
if (current && !(TIER_SUBCATEGORIES[tierVal] || []).includes(current)) {
|
| 85 |
+
catEl.value = '';
|
| 86 |
+
}
|
| 87 |
+
scheduleSaveMedication(medId);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* initNewMedTierControls: function-level behavior note for maintainers.
|
| 92 |
+
* Keep this block synchronized with implementation changes.
|
| 93 |
+
*/
|
| 94 |
+
function initNewMedTierControls() {
|
| 95 |
+
const tierEl = document.getElementById('med-new-tier');
|
| 96 |
+
const catEl = document.getElementById('med-new-tiercat');
|
| 97 |
+
if (!tierEl || !catEl) return;
|
| 98 |
+
tierEl.innerHTML = buildTierOptions(tierEl.value || '');
|
| 99 |
+
catEl.innerHTML = buildTierSubcategoryOptions(tierEl.value || '', catEl.value || '');
|
| 100 |
+
if (!tierEl.dataset.bound) {
|
| 101 |
+
tierEl.dataset.bound = 'true';
|
| 102 |
+
tierEl.addEventListener('change', () => {
|
| 103 |
+
catEl.innerHTML = buildTierSubcategoryOptions(tierEl.value || '', '');
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// --- Shared utilities -------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
// Small, collision-resistant id generator for client-created meds/expiries.
|
| 111 |
+
function uid(prefix = 'id') {
|
| 112 |
+
// Prefer crypto for true randomness; fallback to timestamp + random.
|
| 113 |
+
if (window.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
|
| 114 |
+
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Lightweight toast helper to surface success/error without blocking alerts.
|
| 118 |
+
function showToast(message, isError = false) {
|
| 119 |
+
let el = document.getElementById('pharmacy-toast');
|
| 120 |
+
if (!el) {
|
| 121 |
+
el = document.createElement('div');
|
| 122 |
+
el.id = 'pharmacy-toast';
|
| 123 |
+
el.style.cssText =
|
| 124 |
+
'position:fixed; bottom:20px; right:20px; padding:12px 16px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,0.2); font-weight:800; z-index:9999; display:none; min-width:220px;';
|
| 125 |
+
document.body.appendChild(el);
|
| 126 |
+
}
|
| 127 |
+
el.textContent = message;
|
| 128 |
+
el.style.display = 'block';
|
| 129 |
+
el.style.background = isError ? '#ffebee' : '#e8f5e9';
|
| 130 |
+
el.style.color = isError ? '#c62828' : '#2e7d32';
|
| 131 |
+
el.style.border = `1px solid ${isError ? '#ef5350' : '#81c784'}`;
|
| 132 |
+
clearTimeout(el._timer);
|
| 133 |
+
el._timer = setTimeout(() => {
|
| 134 |
+
el.style.display = 'none';
|
| 135 |
+
}, 4000);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// Wrapper around /api/data/inventory with consistent error handling.
|
| 139 |
+
async function fetchInventory(options = {}) {
|
| 140 |
+
const headers = { ...(options.headers || {}) };
|
| 141 |
+
const res = await fetch('/api/data/inventory', { credentials: 'same-origin', ...options, headers });
|
| 142 |
+
const data = await res.json().catch(() => ({}));
|
| 143 |
+
if (!res.ok || data.error) {
|
| 144 |
+
throw new Error(data.error || `Status ${res.status}`);
|
| 145 |
+
}
|
| 146 |
+
return data;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* updatePharmacyCount: function-level behavior note for maintainers.
|
| 151 |
+
* Keep this block synchronized with implementation changes.
|
| 152 |
+
*/
|
| 153 |
+
function updatePharmacyCount(count) {
|
| 154 |
+
const el = document.getElementById('pharmacy-count');
|
| 155 |
+
if (el) {
|
| 156 |
+
el.textContent = `(${count})`;
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* syncPharmacyCountFromDOM: function-level behavior note for maintainers.
|
| 162 |
+
* Keep this block synchronized with implementation changes.
|
| 163 |
+
*/
|
| 164 |
+
function syncPharmacyCountFromDOM() {
|
| 165 |
+
const list = document.getElementById('pharmacy-list');
|
| 166 |
+
if (!list) {
|
| 167 |
+
updatePharmacyCount(0);
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
const domCount = list.querySelectorAll('.history-item').length;
|
| 171 |
+
updatePharmacyCount(domCount);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* observePharmacyList: function-level behavior note for maintainers.
|
| 176 |
+
* Keep this block synchronized with implementation changes.
|
| 177 |
+
*/
|
| 178 |
+
function observePharmacyList() {
|
| 179 |
+
const list = document.getElementById('pharmacy-list');
|
| 180 |
+
if (!list || list.dataset.countObserver === 'true') return;
|
| 181 |
+
const observer = new MutationObserver(() => syncPharmacyCountFromDOM());
|
| 182 |
+
observer.observe(list, { childList: true, subtree: true });
|
| 183 |
+
list.dataset.countObserver = 'true';
|
| 184 |
+
// Initial sync
|
| 185 |
+
syncPharmacyCountFromDOM();
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/**
|
| 189 |
+
* getTextareaHeights: function-level behavior note for maintainers.
|
| 190 |
+
* Keep this block synchronized with implementation changes.
|
| 191 |
+
*/
|
| 192 |
+
function getTextareaHeights() {
|
| 193 |
+
const map = {};
|
| 194 |
+
document.querySelectorAll('#pharmacy-list textarea').forEach((el) => {
|
| 195 |
+
if (el.id && el.style && el.style.height) {
|
| 196 |
+
map[el.id] = el.style.height;
|
| 197 |
+
}
|
| 198 |
+
});
|
| 199 |
+
return map;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/**
|
| 203 |
+
* normalizeUserLabels: function-level behavior note for maintainers.
|
| 204 |
+
* Keep this block synchronized with implementation changes.
|
| 205 |
+
*/
|
| 206 |
+
function normalizeUserLabels(list) {
|
| 207 |
+
if (!Array.isArray(list)) return [...DEFAULT_USER_LABELS];
|
| 208 |
+
const seen = new Set();
|
| 209 |
+
return list
|
| 210 |
+
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
| 211 |
+
.filter(Boolean)
|
| 212 |
+
.filter((v) => {
|
| 213 |
+
const key = v.toLowerCase();
|
| 214 |
+
if (seen.has(key)) return false;
|
| 215 |
+
seen.add(key);
|
| 216 |
+
return true;
|
| 217 |
+
});
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function ensurePharmacyLabels() {
|
| 221 |
+
if (window.CACHED_SETTINGS && Array.isArray(window.CACHED_SETTINGS.pharmacy_labels)) {
|
| 222 |
+
const normalized = normalizeUserLabels(window.CACHED_SETTINGS.pharmacy_labels);
|
| 223 |
+
if (!pharmacyLabelsCache || normalized.join('|') !== pharmacyLabelsCache.join('|')) {
|
| 224 |
+
pharmacyLabelsCache = normalized;
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
if (pharmacyLabelsCache && pharmacyLabelsCache.length) return pharmacyLabelsCache;
|
| 228 |
+
try {
|
| 229 |
+
const url = '/api/data/settings';
|
| 230 |
+
const data = await fetchJson(url);
|
| 231 |
+
if (data && Array.isArray(data.pharmacy_labels)) {
|
| 232 |
+
pharmacyLabelsCache = normalizeUserLabels(data.pharmacy_labels);
|
| 233 |
+
}
|
| 234 |
+
} catch (err) {
|
| 235 |
+
console.warn('[pharmacy] failed to load user labels, using defaults', err);
|
| 236 |
+
}
|
| 237 |
+
if (!pharmacyLabelsCache || !pharmacyLabelsCache.length) {
|
| 238 |
+
pharmacyLabelsCache = [...DEFAULT_USER_LABELS];
|
| 239 |
+
}
|
| 240 |
+
return pharmacyLabelsCache;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/**
|
| 244 |
+
* getPharmacyLabels: function-level behavior note for maintainers.
|
| 245 |
+
* Keep this block synchronized with implementation changes.
|
| 246 |
+
*/
|
| 247 |
+
function getPharmacyLabels() {
|
| 248 |
+
return pharmacyLabelsCache && pharmacyLabelsCache.length ? [...pharmacyLabelsCache] : [...DEFAULT_USER_LABELS];
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/**
|
| 252 |
+
* renderUserLabelOptions: function-level behavior note for maintainers.
|
| 253 |
+
* Keep this block synchronized with implementation changes.
|
| 254 |
+
*/
|
| 255 |
+
function renderUserLabelOptions(selectedValue = '') {
|
| 256 |
+
const selected = (selectedValue || '').trim();
|
| 257 |
+
const labels = getPharmacyLabels();
|
| 258 |
+
const isCustom = !!selected && !labels.includes(selected);
|
| 259 |
+
const options = [
|
| 260 |
+
'<option value="">Select...</option>',
|
| 261 |
+
...labels.map((label) => `<option value="${label}"${label === selected ? ' selected' : ''}>${label}</option>`),
|
| 262 |
+
`<option value="__custom"${isCustom ? ' selected' : ''}>Custom...</option>`,
|
| 263 |
+
];
|
| 264 |
+
return {
|
| 265 |
+
optionsHtml: options.join(''),
|
| 266 |
+
showCustom: isCustom,
|
| 267 |
+
customValue: isCustom ? selected : '',
|
| 268 |
+
};
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
/**
|
| 272 |
+
* populateNewMedUserLabelSelect: function-level behavior note for maintainers.
|
| 273 |
+
* Keep this block synchronized with implementation changes.
|
| 274 |
+
*/
|
| 275 |
+
function populateNewMedUserLabelSelect() {
|
| 276 |
+
const select = document.getElementById('med-new-sort');
|
| 277 |
+
const custom = document.getElementById('med-new-sort-custom');
|
| 278 |
+
if (!select || !custom) return;
|
| 279 |
+
const labels = getPharmacyLabels();
|
| 280 |
+
const selectVal = select.value;
|
| 281 |
+
const customVal = custom.value;
|
| 282 |
+
const current = selectVal === '__custom' ? customVal : selectVal;
|
| 283 |
+
const isCustom = !!current && !labels.includes(current);
|
| 284 |
+
const opts = [
|
| 285 |
+
'<option value="">Select...</option>',
|
| 286 |
+
...labels.map((label) => `<option value="${label}">${label}</option>`),
|
| 287 |
+
'<option value="__custom">Custom...</option>',
|
| 288 |
+
];
|
| 289 |
+
select.innerHTML = opts.join('');
|
| 290 |
+
select.value = isCustom ? '__custom' : (labels.includes(current) ? current : '');
|
| 291 |
+
custom.style.display = isCustom ? 'block' : 'none';
|
| 292 |
+
if (isCustom) custom.value = current;
|
| 293 |
+
else if (select.value !== '__custom') custom.value = '';
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/**
|
| 297 |
+
* refreshPharmacyLabelsFromSettings: function-level behavior note for maintainers.
|
| 298 |
+
* Keep this block synchronized with implementation changes.
|
| 299 |
+
*/
|
| 300 |
+
function refreshPharmacyLabelsFromSettings(list) {
|
| 301 |
+
const normalized = normalizeUserLabels(Array.isArray(list) ? list : pharmacyLabelsCache || DEFAULT_USER_LABELS);
|
| 302 |
+
pharmacyLabelsCache = normalized;
|
| 303 |
+
populateNewMedUserLabelSelect();
|
| 304 |
+
const listEl = document.getElementById('pharmacy-list');
|
| 305 |
+
if (listEl && pharmacyCache && pharmacyCache.length) {
|
| 306 |
+
const openIds = getOpenMedIds();
|
| 307 |
+
const textHeights = getTextareaHeights();
|
| 308 |
+
renderPharmacy(pharmacyCache, openIds, textHeights);
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/**
|
| 313 |
+
* handleSortCategoryChange: function-level behavior note for maintainers.
|
| 314 |
+
* Keep this block synchronized with implementation changes.
|
| 315 |
+
*/
|
| 316 |
+
function handleSortCategoryChange(id) {
|
| 317 |
+
const select = document.getElementById(`sort-${id}`);
|
| 318 |
+
const custom = document.getElementById(`sort-custom-${id}`);
|
| 319 |
+
if (!select || !custom) return;
|
| 320 |
+
const val = select.value;
|
| 321 |
+
const isCustom = val === '__custom';
|
| 322 |
+
custom.style.display = isCustom ? 'block' : 'none';
|
| 323 |
+
if (!isCustom) {
|
| 324 |
+
custom.value = '';
|
| 325 |
+
}
|
| 326 |
+
// Avoid rerender when switching to custom so the input stays visible while typing
|
| 327 |
+
scheduleSaveMedication(id, !isCustom);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/**
|
| 331 |
+
* toggleCustomSortField: function-level behavior note for maintainers.
|
| 332 |
+
* Keep this block synchronized with implementation changes.
|
| 333 |
+
*/
|
| 334 |
+
function toggleCustomSortField() {
|
| 335 |
+
const sel = document.getElementById('med-new-sort');
|
| 336 |
+
const custom = document.getElementById('med-new-sort-custom');
|
| 337 |
+
if (!sel || !custom) return;
|
| 338 |
+
const isCustom = sel.value === '__custom';
|
| 339 |
+
custom.style.display = isCustom ? 'block' : 'none';
|
| 340 |
+
if (!isCustom) custom.value = '';
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
/**
|
| 344 |
+
* Normalize a purchase/expiry entry to ensure consistent structure.
|
| 345 |
+
*
|
| 346 |
+
* This is the **client-side equivalent** of the backend's ensure_purchase_defaults.
|
| 347 |
+
* Purchase entries (also called expiry entries) represent individual batches or
|
| 348 |
+
* purchases of a medication. Each entry tracks:
|
| 349 |
+
* - Expiration date for that specific batch
|
| 350 |
+
* - Quantity received in that batch
|
| 351 |
+
* - Manufacturer and batch/lot number for traceability
|
| 352 |
+
* - Optional notes about storage or source
|
| 353 |
+
*
|
| 354 |
+
* Multiple purchase entries allow tracking different batches of the same medication
|
| 355 |
+
* that may have different expiration dates - critical for medical inventory management.
|
| 356 |
+
*
|
| 357 |
+
* UI State Management:
|
| 358 |
+
* --------------------
|
| 359 |
+
* The `_open` property is UI-only state (not persisted to backend) that controls
|
| 360 |
+
* whether the notes textarea is expanded by default. This provides a cleaner UI
|
| 361 |
+
* for entries without notes while allowing quick access when needed.
|
| 362 |
+
*
|
| 363 |
+
* @param {Object} p - Raw purchase/expiry data from server or user input
|
| 364 |
+
* @param {boolean} open - Whether notes section should be expanded in UI (default: false)
|
| 365 |
+
*
|
| 366 |
+
* @returns {Object} Normalized purchase entry with structure:
|
| 367 |
+
* - id {string}: Unique identifier (ph-<uuid>) - stable across saves
|
| 368 |
+
* - date {string}: Expiry date in ISO format (YYYY-MM-DD)
|
| 369 |
+
* - quantity {string}: Quantity for this batch (can be partial units)
|
| 370 |
+
* - notes {string}: Optional notes about batch/storage/source
|
| 371 |
+
* - manufacturer {string}: Manufacturer name for this batch
|
| 372 |
+
* - batchLot {string}: Batch or lot number from packaging
|
| 373 |
+
* - _open {boolean}: UI state - whether notes are expanded (not saved to DB)
|
| 374 |
+
*
|
| 375 |
+
* @example
|
| 376 |
+
* // New entry (no ID provided)
|
| 377 |
+
* ensurePurchaseDefaults({
|
| 378 |
+
* date: "2026-12-31",
|
| 379 |
+
* quantity: "100"
|
| 380 |
+
* })
|
| 381 |
+
* // Returns: { id: "ph-abc123...", date: "2026-12-31", quantity: "100", ... }
|
| 382 |
+
*
|
| 383 |
+
* @example
|
| 384 |
+
* // Existing entry from server (ID preserved)
|
| 385 |
+
* ensurePurchaseDefaults({
|
| 386 |
+
* id: "ph-12345",
|
| 387 |
+
* date: "2025-06-30",
|
| 388 |
+
* quantity: "50",
|
| 389 |
+
* manufacturer: "Pfizer"
|
| 390 |
+
* })
|
| 391 |
+
* // Returns: { id: "ph-12345", date: "2025-06-30", ..., manufacturer: "Pfizer" }
|
| 392 |
+
*
|
| 393 |
+
* Related Functions:
|
| 394 |
+
* ------------------
|
| 395 |
+
* - Backend equivalent: ensure_purchase_defaults() in app.py
|
| 396 |
+
* - Called by: ensurePharmacyDefaults(), renderPurchaseRows()
|
| 397 |
+
* - Feeds into: collectPurchaseEntries() when saving
|
| 398 |
+
*/
|
| 399 |
+
function ensurePurchaseDefaults(p, open = false) {
|
| 400 |
+
return {
|
| 401 |
+
id: p.id || uid('ph'), // Generate unique ID if not present
|
| 402 |
+
date: p.date || '', // ISO date string (YYYY-MM-DD)
|
| 403 |
+
quantity: p.quantity || '', // Batch quantity (string to allow decimals)
|
| 404 |
+
notes: p.notes || '', // Additional batch information
|
| 405 |
+
manufacturer: p.manufacturer || '', // Manufacturer for this batch
|
| 406 |
+
batchLot: p.batchLot || '', // Batch/lot number for traceability
|
| 407 |
+
_open: open // UI state: notes section expanded?
|
| 408 |
+
};
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
/**
|
| 412 |
+
* ensurePharmacyDefaults: function-level behavior note for maintainers.
|
| 413 |
+
* Keep this block synchronized with implementation changes.
|
| 414 |
+
*/
|
| 415 |
+
function ensurePharmacyDefaults(item) {
|
| 416 |
+
// Normalize a med record; if legacy top-level manufacturer/batch exists, push into first expiry row.
|
| 417 |
+
const med = {
|
| 418 |
+
id: item.id || uid('med'),
|
| 419 |
+
genericName: item.genericName || '',
|
| 420 |
+
brandName: item.brandName || '',
|
| 421 |
+
form: item.form || '',
|
| 422 |
+
strength: item.strength || '',
|
| 423 |
+
formStrength: item.formStrength || '',
|
| 424 |
+
currentQuantity: item.currentQuantity || '', // legacy; derived from purchase history
|
| 425 |
+
minThreshold: item.minThreshold || '',
|
| 426 |
+
unit: item.unit || '',
|
| 427 |
+
storageLocation: item.storageLocation || '',
|
| 428 |
+
expiryDate: item.expiryDate || '',
|
| 429 |
+
controlled: !!item.controlled,
|
| 430 |
+
primaryIndication: item.primaryIndication || '',
|
| 431 |
+
allergyWarnings: item.allergyWarnings || '',
|
| 432 |
+
standardDosage: item.standardDosage || '',
|
| 433 |
+
notes: item.notes || '',
|
| 434 |
+
sortCategory: item.sortCategory || '',
|
| 435 |
+
priorityTier: item.priorityTier || '',
|
| 436 |
+
tierCategory: item.tierCategory || '',
|
| 437 |
+
verified: !!item.verified,
|
| 438 |
+
purchaseHistory: Array.isArray(item.purchaseHistory)
|
| 439 |
+
? item.purchaseHistory.map(ensurePurchaseDefaults)
|
| 440 |
+
: [ensurePurchaseDefaults({})],
|
| 441 |
+
source: item.source || '',
|
| 442 |
+
excludeFromResources: Boolean(item.excludeFromResources),
|
| 443 |
+
};
|
| 444 |
+
// If only a combined formStrength is available, backfill strength to keep validation lenient.
|
| 445 |
+
if (!med.strength && med.formStrength) {
|
| 446 |
+
med.strength = med.formStrength;
|
| 447 |
+
}
|
| 448 |
+
med.formStrength = med.formStrength || [med.form, med.strength].join(' ').trim();
|
| 449 |
+
// Backfill manufacturer/batchLot into first purchase entry if present on legacy record
|
| 450 |
+
if (item.manufacturer && med.purchaseHistory.length && !med.purchaseHistory[0].manufacturer) {
|
| 451 |
+
med.purchaseHistory[0].manufacturer = item.manufacturer;
|
| 452 |
+
}
|
| 453 |
+
if (item.batchLot && med.purchaseHistory.length && !med.purchaseHistory[0].batchLot) {
|
| 454 |
+
med.purchaseHistory[0].batchLot = item.batchLot;
|
| 455 |
+
}
|
| 456 |
+
return med;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
/**
|
| 460 |
+
* handleVerifyToggle: function-level behavior note for maintainers.
|
| 461 |
+
* Keep this block synchronized with implementation changes.
|
| 462 |
+
*/
|
| 463 |
+
function handleVerifyToggle(medId) {
|
| 464 |
+
const cb = document.getElementById(`ver-${medId}`);
|
| 465 |
+
const isVerified = !!cb?.checked;
|
| 466 |
+
const badge = document.getElementById(`badge-ver-${medId}`);
|
| 467 |
+
if (badge) {
|
| 468 |
+
if (isVerified) {
|
| 469 |
+
badge.textContent = 'Verified';
|
| 470 |
+
badge.style.background = 'var(--inquiry)';
|
| 471 |
+
badge.style.color = '#fff';
|
| 472 |
+
badge.style.border = 'none';
|
| 473 |
+
} else {
|
| 474 |
+
badge.textContent = 'Not Verified';
|
| 475 |
+
badge.style.background = 'transparent';
|
| 476 |
+
badge.style.color = 'var(--inquiry)';
|
| 477 |
+
badge.style.border = '1px dashed #b2c7b5';
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
// Update cache immediately to avoid form collapse
|
| 481 |
+
const idx = pharmacyCache.findIndex((m) => m.id === medId);
|
| 482 |
+
if (idx !== -1) {
|
| 483 |
+
pharmacyCache[idx].verified = isVerified;
|
| 484 |
+
}
|
| 485 |
+
// Save just the verified flag to avoid any expiry validation side-effects.
|
| 486 |
+
fetch(`/api/data/inventory/${encodeURIComponent(medId)}/verify`, {
|
| 487 |
+
method: 'POST',
|
| 488 |
+
headers: { 'Content-Type': 'application/json' },
|
| 489 |
+
credentials: 'same-origin',
|
| 490 |
+
body: JSON.stringify({ verified: isVerified }),
|
| 491 |
+
})
|
| 492 |
+
.then((res) => res.json().catch(() => ({})))
|
| 493 |
+
.then((data) => {
|
| 494 |
+
if (data && data.error) {
|
| 495 |
+
showToast(`Save failed: ${data.error}`, true);
|
| 496 |
+
if (cb) cb.checked = !isVerified; // revert UI on failure
|
| 497 |
+
// revert cache on failure
|
| 498 |
+
if (idx !== -1) {
|
| 499 |
+
pharmacyCache[idx].verified = !isVerified;
|
| 500 |
+
}
|
| 501 |
+
} else {
|
| 502 |
+
showToast(isVerified ? 'Marked as Verified' : 'Marked as Not Verified');
|
| 503 |
+
// DO NOT call loadPharmacy() - it causes form to collapse
|
| 504 |
+
}
|
| 505 |
+
})
|
| 506 |
+
.catch((err) => {
|
| 507 |
+
showToast(`Save failed: ${err.message}`, true);
|
| 508 |
+
if (cb) cb.checked = !isVerified;
|
| 509 |
+
// revert cache on error
|
| 510 |
+
if (idx !== -1) {
|
| 511 |
+
pharmacyCache[idx].verified = !isVerified;
|
| 512 |
+
}
|
| 513 |
+
});
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
/**
|
| 517 |
+
* handleExcludeToggle: function-level behavior note for maintainers.
|
| 518 |
+
* Keep this block synchronized with implementation changes.
|
| 519 |
+
*/
|
| 520 |
+
function handleExcludeToggle(medId) {
|
| 521 |
+
const cb = document.getElementById(`exclude-${medId}`);
|
| 522 |
+
const isExcluded = !!cb?.checked;
|
| 523 |
+
|
| 524 |
+
// Update availability badge directly by ID (same pattern as verified badge)
|
| 525 |
+
const badge = document.getElementById(`badge-avail-${medId}`);
|
| 526 |
+
if (badge) {
|
| 527 |
+
badge.style.background = isExcluded ? '#d32f2f' : '#2e7d32';
|
| 528 |
+
badge.textContent = isExcluded ? 'Resource Currently Unavailable' : 'Resource Available';
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
// Update header background colors immediately
|
| 532 |
+
const header = cb?.closest('.history-item')?.querySelector('.col-header');
|
| 533 |
+
const body = cb?.closest('.history-item')?.querySelector('.col-body');
|
| 534 |
+
if (header) {
|
| 535 |
+
header.style.background = isExcluded ? '#ffecef' : '#eef7ff';
|
| 536 |
+
header.style.borderColor = isExcluded ? '#ffcfe0' : '#c7ddff';
|
| 537 |
+
}
|
| 538 |
+
if (body) {
|
| 539 |
+
body.style.background = isExcluded ? '#fff6f6' : '#f7fff7';
|
| 540 |
+
body.style.borderColor = isExcluded ? '#ffcfd0' : '#cfe9d5';
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// Update cache immediately
|
| 544 |
+
const idx = pharmacyCache.findIndex((m) => m.id === medId);
|
| 545 |
+
if (idx !== -1) {
|
| 546 |
+
pharmacyCache[idx].excludeFromResources = isExcluded;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
// Trigger save without rerender to avoid form collapse
|
| 550 |
+
scheduleSaveMedication(medId, false);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
/**
|
| 554 |
+
* canonicalMedKey: function-level behavior note for maintainers.
|
| 555 |
+
* Keep this block synchronized with implementation changes.
|
| 556 |
+
*/
|
| 557 |
+
function canonicalMedKey(generic, brand, strength, formStrength = '') {
|
| 558 |
+
const clean = (val) => (val || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
| 559 |
+
const strengthVal = clean(strength || formStrength).replace(/unspecified/g, '');
|
| 560 |
+
return `${clean(generic)}|${clean(brand)}|${strengthVal}`;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
/**
|
| 564 |
+
* scheduleSaveMedication: function-level behavior note for maintainers.
|
| 565 |
+
* Keep this block synchronized with implementation changes.
|
| 566 |
+
*/
|
| 567 |
+
function scheduleSaveMedication(id, rerender = false) {
|
| 568 |
+
// Debounce saves to prevent form collapse during typing.
|
| 569 |
+
// Longer delay prevents save/reload while user is still typing.
|
| 570 |
+
if (pharmacySaveTimers[id]) {
|
| 571 |
+
clearTimeout(pharmacySaveTimers[id]);
|
| 572 |
+
}
|
| 573 |
+
pharmacySaveTimers[id] = setTimeout(() => {
|
| 574 |
+
saveMedication(id, rerender);
|
| 575 |
+
}, 800); // 800ms debounce - waits for user to pause typing
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
/**
|
| 579 |
+
* getMedicationDisplayName: function-level behavior note for maintainers.
|
| 580 |
+
* Keep this block synchronized with implementation changes.
|
| 581 |
+
*/
|
| 582 |
+
function getMedicationDisplayName(med) {
|
| 583 |
+
const clean = (val) => (val || '').trim();
|
| 584 |
+
const isPlaceholder = (val) => !val || /^medication\b/i.test(val);
|
| 585 |
+
const generic = clean(med.genericName);
|
| 586 |
+
const brand = clean(med.brandName);
|
| 587 |
+
let primary = !isPlaceholder(generic) ? generic : !isPlaceholder(brand) ? brand : '';
|
| 588 |
+
if (!primary) {
|
| 589 |
+
primary = med.primaryIndication || med.manufacturer || 'Medication';
|
| 590 |
+
}
|
| 591 |
+
const showBrand = brand && !isPlaceholder(brand) && brand.toLowerCase() !== primary.toLowerCase();
|
| 592 |
+
return `${primary}${showBrand ? ' - ' + brand : ''}`;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
/**
|
| 596 |
+
* getExpiryDate: function-level behavior note for maintainers.
|
| 597 |
+
* Keep this block synchronized with implementation changes.
|
| 598 |
+
*/
|
| 599 |
+
function getExpiryDate(med) {
|
| 600 |
+
if (!med || !Array.isArray(med.purchaseHistory)) return med?.expiryDate || '';
|
| 601 |
+
let earliest = null;
|
| 602 |
+
med.purchaseHistory.forEach(ph => {
|
| 603 |
+
if (!ph || !ph.date) return;
|
| 604 |
+
const d = new Date(ph.date);
|
| 605 |
+
if (isNaN(d)) return;
|
| 606 |
+
if (!earliest || d < earliest) earliest = d;
|
| 607 |
+
});
|
| 608 |
+
return earliest ? earliest.toISOString().slice(0, 10) : (med.expiryDate || '');
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
/**
|
| 612 |
+
* getCurrentQuantity: function-level behavior note for maintainers.
|
| 613 |
+
* Keep this block synchronized with implementation changes.
|
| 614 |
+
*/
|
| 615 |
+
function getCurrentQuantity(med) {
|
| 616 |
+
if (!med || !Array.isArray(med.purchaseHistory)) return Number(med.currentQuantity) || 0;
|
| 617 |
+
return med.purchaseHistory.reduce((sum, ph) => {
|
| 618 |
+
const n = Number(ph.quantity);
|
| 619 |
+
return Number.isFinite(n) ? sum + n : sum;
|
| 620 |
+
}, 0);
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
/**
|
| 624 |
+
* sortPharmacyItems: function-level behavior note for maintainers.
|
| 625 |
+
* Keep this block synchronized with implementation changes.
|
| 626 |
+
*/
|
| 627 |
+
function sortPharmacyItems(items) {
|
| 628 |
+
const list = Array.isArray(items) ? [...items] : [];
|
| 629 |
+
const sortSel = document.getElementById('pharmacy-sort');
|
| 630 |
+
const mode = (sortSel && sortSel.value) || 'sortCategory';
|
| 631 |
+
const byText = (a, b, pathA, pathB) => {
|
| 632 |
+
const va = (pathA || '').toLowerCase();
|
| 633 |
+
const vb = (pathB || '').toLowerCase();
|
| 634 |
+
return va.localeCompare(vb);
|
| 635 |
+
};
|
| 636 |
+
list.sort((a, b) => {
|
| 637 |
+
if (mode === 'sortCategory') {
|
| 638 |
+
const hasA = !!(a.sortCategory || '').trim();
|
| 639 |
+
const hasB = !!(b.sortCategory || '').trim();
|
| 640 |
+
if (hasA && !hasB) return -1;
|
| 641 |
+
if (!hasA && hasB) return 1;
|
| 642 |
+
if (hasA && hasB) {
|
| 643 |
+
const cat = byText(a, b, a.sortCategory, b.sortCategory);
|
| 644 |
+
if (cat !== 0) return cat;
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
if (mode === 'brand') {
|
| 648 |
+
return byText(a, b, a.brandName || '', b.brandName || '');
|
| 649 |
+
}
|
| 650 |
+
if (mode === 'strength') {
|
| 651 |
+
return byText(a, b, a.strength || '', b.strength || '');
|
| 652 |
+
}
|
| 653 |
+
if (mode === 'expiry') {
|
| 654 |
+
return byText(a, b, getExpiryDate(a) || '', getExpiryDate(b) || '');
|
| 655 |
+
}
|
| 656 |
+
// default generic
|
| 657 |
+
return byText(a, b, a.genericName || a.brandName || '', b.genericName || b.brandName || '');
|
| 658 |
+
});
|
| 659 |
+
return list;
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
// Primary loader for the Medical Chest list. Pulls server data, normalizes, renders cards,
|
| 663 |
+
// and keeps the count badge in sync. WHO list is lazy-loaded alongside.
|
| 664 |
+
async function preloadPharmacy() {
|
| 665 |
+
if (pharmacyFetchPromise) return pharmacyFetchPromise;
|
| 666 |
+
pharmacyFetchPromise = fetchInventory()
|
| 667 |
+
.then((data) => {
|
| 668 |
+
pharmacyCache = (Array.isArray(data) ? data : []).map(ensurePharmacyDefaults);
|
| 669 |
+
return pharmacyCache;
|
| 670 |
+
})
|
| 671 |
+
.catch((err) => {
|
| 672 |
+
pharmacyFetchPromise = null;
|
| 673 |
+
throw err;
|
| 674 |
+
});
|
| 675 |
+
return pharmacyFetchPromise;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
// Primary loader for the Medical Chest list. Pulls server data, normalizes, renders cards,
|
| 679 |
+
// and keeps the count badge in sync. WHO list is lazy-loaded on demand.
|
| 680 |
+
async function loadPharmacy() {
|
| 681 |
+
const list = document.getElementById('pharmacy-list');
|
| 682 |
+
if (!list) return;
|
| 683 |
+
list.innerHTML = '<div style="color:#666;">Loading inventory...</div>';
|
| 684 |
+
|
| 685 |
+
// If we already have cached meds, render them immediately for perceived speed
|
| 686 |
+
if (pharmacyCache.length) {
|
| 687 |
+
updatePharmacyCount(pharmacyCache.length);
|
| 688 |
+
renderPharmacy(pharmacyCache);
|
| 689 |
+
initNewMedTierControls();
|
| 690 |
+
// Skip - don't block on WHO list
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
try {
|
| 694 |
+
// Only fetch critical data first (labels needed for dropdowns)
|
| 695 |
+
const labelsPromise = ensurePharmacyLabels();
|
| 696 |
+
|
| 697 |
+
// Fetch inventory data
|
| 698 |
+
const data = await fetchInventory();
|
| 699 |
+
pharmacyCache = (Array.isArray(data) ? data : []).map(ensurePharmacyDefaults);
|
| 700 |
+
|
| 701 |
+
// Wait for labels (needed for render)
|
| 702 |
+
await labelsPromise;
|
| 703 |
+
|
| 704 |
+
// Render immediately - don't wait for WHO list
|
| 705 |
+
populateNewMedUserLabelSelect();
|
| 706 |
+
initNewMedTierControls();
|
| 707 |
+
observePharmacyList();
|
| 708 |
+
updatePharmacyCount(pharmacyCache.length);
|
| 709 |
+
renderPharmacy(pharmacyCache);
|
| 710 |
+
syncPharmacyCountFromDOM();
|
| 711 |
+
|
| 712 |
+
// Load WHO list in background (non-blocking)
|
| 713 |
+
loadWhoMedsFromServer().catch(err => console.warn('[pharmacy] WHO list load failed:', err));
|
| 714 |
+
} catch (err) {
|
| 715 |
+
updatePharmacyCount(0);
|
| 716 |
+
list.innerHTML = `<div style="color:red;">Error loading inventory: ${err.message}</div>`;
|
| 717 |
+
}
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
/**
|
| 721 |
+
* handlePharmacySortChange: function-level behavior note for maintainers.
|
| 722 |
+
* Keep this block synchronized with implementation changes.
|
| 723 |
+
*/
|
| 724 |
+
function handlePharmacySortChange() {
|
| 725 |
+
const openIds = getOpenMedIds();
|
| 726 |
+
const textHeights = getTextareaHeights();
|
| 727 |
+
renderPharmacy(pharmacyCache, openIds, textHeights);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
/**
|
| 731 |
+
* getOpenMedIds: function-level behavior note for maintainers.
|
| 732 |
+
* Keep this block synchronized with implementation changes.
|
| 733 |
+
*/
|
| 734 |
+
function getOpenMedIds() {
|
| 735 |
+
return Array.from(document.querySelectorAll('#pharmacy-list .history-item .col-body[data-med-id]'))
|
| 736 |
+
.filter((el) => el.style.display !== 'none')
|
| 737 |
+
.map((el) => el.dataset.medId)
|
| 738 |
+
.filter(Boolean);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
/**
|
| 742 |
+
* renderPharmacy: function-level behavior note for maintainers.
|
| 743 |
+
* Keep this block synchronized with implementation changes.
|
| 744 |
+
*/
|
| 745 |
+
function renderPharmacy(items, openIds = [], textHeights = {}) {
|
| 746 |
+
const list = document.getElementById('pharmacy-list');
|
| 747 |
+
if (!list) return;
|
| 748 |
+
updatePharmacyCount((items || []).length);
|
| 749 |
+
if (!items || items.length === 0) {
|
| 750 |
+
list.innerHTML = '<div style="color:#666; padding:12px;">No medications entered. Add your first item.</div>';
|
| 751 |
+
return;
|
| 752 |
+
}
|
| 753 |
+
const sorted = sortPharmacyItems(items);
|
| 754 |
+
|
| 755 |
+
// For small lists (≤50 items), render immediately without chunking
|
| 756 |
+
if (sorted.length <= 50) {
|
| 757 |
+
list.innerHTML = sorted.map((m) => renderMedicationCard(m, openIds.includes(m.id), textHeights)).join('');
|
| 758 |
+
syncPharmacyCountFromDOM();
|
| 759 |
+
populateNewMedUserLabelSelect();
|
| 760 |
+
return;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
// For larger lists, use aggressive chunking
|
| 764 |
+
list.innerHTML = '<div style="color:#666; padding:12px;">Loading medications...</div>';
|
| 765 |
+
const chunkSize = 100; // Increased from 40 to 100
|
| 766 |
+
let idx = 0;
|
| 767 |
+
list.innerHTML = ''; // clear placeholder
|
| 768 |
+
|
| 769 |
+
const renderChunk = () => {
|
| 770 |
+
if (idx >= sorted.length) {
|
| 771 |
+
syncPharmacyCountFromDOM();
|
| 772 |
+
populateNewMedUserLabelSelect();
|
| 773 |
+
return;
|
| 774 |
+
}
|
| 775 |
+
const slice = sorted.slice(idx, idx + chunkSize);
|
| 776 |
+
const html = slice.map((m) => renderMedicationCard(m, openIds.includes(m.id), textHeights)).join('');
|
| 777 |
+
list.insertAdjacentHTML('beforeend', html);
|
| 778 |
+
idx += chunkSize;
|
| 779 |
+
|
| 780 |
+
// Use setTimeout(0) for fastest rendering without blocking
|
| 781 |
+
setTimeout(renderChunk, 0);
|
| 782 |
+
};
|
| 783 |
+
renderChunk();
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
/**
|
| 787 |
+
* renderPurchaseRows: function-level behavior note for maintainers.
|
| 788 |
+
* Keep this block synchronized with implementation changes.
|
| 789 |
+
*/
|
| 790 |
+
function renderPurchaseRows(med) {
|
| 791 |
+
// Expiry UI contract:
|
| 792 |
+
// - Rows carry stable ph-id so deletes map 1:1 to saved rows.
|
| 793 |
+
// - Manufacturer / batch live per-row (stock provenance).
|
| 794 |
+
// - We keep at least one row visible for usability.
|
| 795 |
+
const rows = med.purchaseHistory.length ? med.purchaseHistory : [ensurePurchaseDefaults({}, false)];
|
| 796 |
+
return rows
|
| 797 |
+
.map((p, idx) => {
|
| 798 |
+
const rowNum = idx + 1;
|
| 799 |
+
const expDate = p.date || 'No date';
|
| 800 |
+
const qty = p.quantity || '0';
|
| 801 |
+
return `
|
| 802 |
+
<div class="collapsible purchase-row" data-med-id="${med.id}" data-ph-id="${p.id || ''}" style="margin-bottom:8px;">
|
| 803 |
+
<div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="background:#fff; justify-content:flex-start; align-items:center; padding:8px 12px;">
|
| 804 |
+
<span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span>
|
| 805 |
+
<span style="font-weight:700; color:#37474f;">Batch ${rowNum}</span>
|
| 806 |
+
<span style="margin-left:auto; font-size:12px; color:#555;">Exp: ${expDate} • Qty: ${qty}</span>
|
| 807 |
+
<button class="btn btn-sm history-action-btn" style="background:var(--red); padding:4px 10px; margin-left:8px; visibility:hidden;" onclick="event.stopPropagation(); deletePurchaseEntry('${med.id}','${p.id || ''}')" title="Delete this batch entry">Delete</button>
|
| 808 |
+
</div>
|
| 809 |
+
<div class="col-body" style="padding:10px; display:none; background:#f9fbff; border:1px solid #d9e5f7; border-top:none;">
|
| 810 |
+
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; margin-bottom:10px;">
|
| 811 |
+
<div>
|
| 812 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Expiry Date *</label>
|
| 813 |
+
<input type="date" class="ph-date" value="${p.date || ''}" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" onchange="scheduleSaveMedication('${med.id}')">
|
| 814 |
+
</div>
|
| 815 |
+
<div>
|
| 816 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Quantity *</label>
|
| 817 |
+
<input type="number" class="ph-qty" value="${p.quantity || ''}" placeholder="0" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 818 |
+
</div>
|
| 819 |
+
</div>
|
| 820 |
+
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; margin-bottom:10px;">
|
| 821 |
+
<div>
|
| 822 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Manufacturer</label>
|
| 823 |
+
<input type="text" class="ph-manufacturer" value="${p.manufacturer || ''}" placeholder="Optional" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 824 |
+
</div>
|
| 825 |
+
<div>
|
| 826 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Batch / Lot #</label>
|
| 827 |
+
<input type="text" class="ph-batch" value="${p.batchLot || ''}" placeholder="Optional" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 828 |
+
</div>
|
| 829 |
+
</div>
|
| 830 |
+
<div>
|
| 831 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Notes</label>
|
| 832 |
+
<textarea class="ph-notes" placeholder="Optional notes about this batch" style="width:100%; padding:8px; min-height:50px; font-size:14px; border:1px solid #d0d7e2; border-radius:4px; resize:vertical;" oninput="scheduleSaveMedication('${med.id}')">${p.notes || ''}</textarea>
|
| 833 |
+
</div>
|
| 834 |
+
</div>
|
| 835 |
+
</div>`;
|
| 836 |
+
})
|
| 837 |
+
.join('');
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
/**
|
| 841 |
+
* renderMedicationCard: function-level behavior note for maintainers.
|
| 842 |
+
* Keep this block synchronized with implementation changes.
|
| 843 |
+
*/
|
| 844 |
+
function renderMedicationCard(med, isOpen = false, textHeights = {}) {
|
| 845 |
+
// Render a single medication card with summary badges and collapsible details/expiry tracking.
|
| 846 |
+
const currentQty = getCurrentQuantity(med);
|
| 847 |
+
const lowStock = med.minThreshold && Number(currentQty) <= Number(med.minThreshold);
|
| 848 |
+
const expiryDate = getExpiryDate(med);
|
| 849 |
+
const days = expiryDate ? daysUntil(expiryDate) : null;
|
| 850 |
+
const isExpired = Number.isFinite(days) && days < 0;
|
| 851 |
+
const expirySoon = Number.isFinite(days) && days >= 0 && days <= 60;
|
| 852 |
+
const expiryText = expiryDate ? `Exp: ${expiryDate}` : 'No expiry set';
|
| 853 |
+
const headerNote = [
|
| 854 |
+
lowStock ? 'Low Stock' : null,
|
| 855 |
+
isExpired ? 'Expired' : null,
|
| 856 |
+
!isExpired && expirySoon ? 'Expiring Soon' : null
|
| 857 |
+
].filter(Boolean).join(' - ');
|
| 858 |
+
const displayName = getMedicationDisplayName(med);
|
| 859 |
+
const strength = (med.strength || '').trim();
|
| 860 |
+
const userLabelRender = renderUserLabelOptions(med.sortCategory || '');
|
| 861 |
+
const labelChip = med.sortCategory && med.sortCategory.trim()
|
| 862 |
+
? `<span style="margin-left:8px; padding:2px 8px; border-radius:999px; background:rgba(46,125,50,0.12); color:var(--inquiry); font-size:11px; white-space:nowrap;">${escapeHtml(med.sortCategory.trim())}</span>`
|
| 863 |
+
: '';
|
| 864 |
+
const bodyDisplay = isOpen ? 'display:block;' : 'display:none;';
|
| 865 |
+
const arrow = isOpen ? '▾' : '▸';
|
| 866 |
+
const headerBg = med.excludeFromResources ? '#ffecef' : '#eef7ff';
|
| 867 |
+
const headerBorderColor = med.excludeFromResources ? '#ffcfe0' : '#c7ddff';
|
| 868 |
+
const bodyBg = med.excludeFromResources ? '#fff6f6' : '#f7fff7';
|
| 869 |
+
const bodyBorderColor = med.excludeFromResources ? '#ffcfd0' : '#cfe9d5';
|
| 870 |
+
const badgeColor = med.excludeFromResources ? '#d32f2f' : '#2e7d32';
|
| 871 |
+
const badgeText = med.excludeFromResources ? 'Resource Currently Unavailable' : 'Resource Available';
|
| 872 |
+
const availabilityBadge = `<span id="badge-avail-${med.id}" style="padding:2px 10px; border-radius:999px; background:${badgeColor}; color:#fff; font-size:11px; white-space:nowrap;">${badgeText}</span>`;
|
| 873 |
+
const verifiedBadge = med.verified
|
| 874 |
+
? `<span class="dev-tag">dev:med-verified</span><span id="badge-ver-${med.id}" style="padding:2px 10px; border-radius:999px; background:var(--inquiry); color:#fff; font-size:11px; white-space:nowrap;">Verified</span>`
|
| 875 |
+
: `<span class="dev-tag">dev:med-verified</span><span id="badge-ver-${med.id}" style="padding:2px 10px; border-radius:999px; background:transparent; color:var(--inquiry); font-size:11px; white-space:nowrap; border:1px dashed #b2c7b5;">Not Verified</span>`;
|
| 876 |
+
const doseHeight = textHeights[`dose-${med.id}`] ? `height:${textHeights[`dose-${med.id}`]};` : '';
|
| 877 |
+
return `
|
| 878 |
+
<div class="collapsible history-item">
|
| 879 |
+
<div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start; align-items:center; background:${headerBg}; border:1px solid ${headerBorderColor}; padding:8px 12px;">
|
| 880 |
+
<span class="dev-tag">dev:med-card</span>
|
| 881 |
+
<span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;" data-collapse-group="pharmacy">${arrow}</span>
|
| 882 |
+
<span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:700;">
|
| 883 |
+
${displayName}${strength ? ' - ' + strength : ''}
|
| 884 |
+
</span>
|
| 885 |
+
${labelChip}
|
| 886 |
+
${headerNote ? `<span class="sidebar-pill" style="margin-right:8px; background:${lowStock ? '#ffebee' : '#fff7e0'}; color:${lowStock ? '#c62828' : '#b26a00'};">${headerNote}</span>` : ''}
|
| 887 |
+
<button onclick="event.stopPropagation(); deleteMedication('${med.id}')" class="btn btn-sm history-action-btn" style="background:var(--red); visibility:hidden;">Delete Medication</button>
|
| 888 |
+
<div style="display:flex; align-items:center; gap:6px; margin-left:8px;">${verifiedBadge}${availabilityBadge}</div>
|
| 889 |
+
</div>
|
| 890 |
+
<div class="col-body" data-med-id="${med.id}" style="padding:12px; background:${bodyBg}; border:1px solid ${bodyBorderColor}; border-radius:6px; ${bodyDisplay}">
|
| 891 |
+
<div class="collapsible" style="margin-bottom:10px;">
|
| 892 |
+
<div class="col-header crew-med-header" onclick="toggleMedDetails(this)" style="background:#fff; justify-content:flex-start; align-items:center;">
|
| 893 |
+
<span class="dev-tag">dev:med-details</span>
|
| 894 |
+
<span class="detail-icon history-arrow" style="font-size:16px; margin-right:8px;">▾</span>
|
| 895 |
+
<span style="font-weight:700;">Medication Details</span>
|
| 896 |
+
</div>
|
| 897 |
+
<div class="col-body" style="padding:10px; display:block;" id="details-${med.id}">
|
| 898 |
+
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px; flex-wrap:wrap;">
|
| 899 |
+
<label style="display:flex; align-items:center; gap:6px; font-size:12px; padding:6px 10px; border:1px solid #ffcfe0; border-radius:6px; background:#fff; margin:0;">
|
| 900 |
+
<input id="exclude-${med.id}" type="checkbox" ${med.excludeFromResources ? 'checked' : ''} onchange="handleExcludeToggle('${med.id}')">
|
| 901 |
+
Resource Currently Unavailable
|
| 902 |
+
</label>
|
| 903 |
+
<label style="display:flex; align-items:center; gap:6px; font-size:12px; padding:6px 10px; border:1px solid #c7ddff; border-radius:6px; background:#fff; margin:0;">
|
| 904 |
+
<input id="ver-${med.id}" type="checkbox" ${med.verified ? 'checked' : ''} onchange="handleVerifyToggle('${med.id}'); event.stopPropagation();">
|
| 905 |
+
Verified
|
| 906 |
+
</label>
|
| 907 |
+
<label style="display:flex; align-items:center; gap:6px; font-size:12px; padding:6px 10px; border:1px solid #c7ddff; border-radius:6px; background:#fff; margin:0;">
|
| 908 |
+
<span style="font-weight:700;">User Label</span>
|
| 909 |
+
<select id="sort-${med.id}" style="padding:6px 8px;" onchange="handleSortCategoryChange('${med.id}')">
|
| 910 |
+
${userLabelRender.optionsHtml}
|
| 911 |
+
</select>
|
| 912 |
+
<input id="sort-custom-${med.id}" type="text" value="${userLabelRender.customValue}" placeholder="Custom label" style="width:160px; padding:6px; margin-left:6px; ${userLabelRender.showCustom ? '' : 'display:none;'}" oninput="scheduleSaveMedication('${med.id}', false)">
|
| 913 |
+
</label>
|
| 914 |
+
</div>
|
| 915 |
+
<div style="display:grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap:10px; margin-bottom:10px;">
|
| 916 |
+
<div>
|
| 917 |
+
<label style="font-weight:700; font-size:12px;">Generic Name</label>
|
| 918 |
+
<input id="gn-${med.id}" type="text" value="${med.genericName}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 919 |
+
</div>
|
| 920 |
+
<div>
|
| 921 |
+
<label style="font-weight:700; font-size:12px;">Brand Name</label>
|
| 922 |
+
<input id="bn-${med.id}" type="text" value="${med.brandName}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 923 |
+
</div>
|
| 924 |
+
<div>
|
| 925 |
+
<label style="font-weight:700; font-size:12px;">Form</label>
|
| 926 |
+
<input id="form-${med.id}" type="text" value="${med.form}" placeholder="Tablet, Capsule, etc." style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 927 |
+
</div>
|
| 928 |
+
<div>
|
| 929 |
+
<label style="font-weight:700; font-size:12px;">Strength</label>
|
| 930 |
+
<input id="str-${med.id}" type="text" value="${med.strength}" placeholder="500mg, 10mg/ml" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 931 |
+
</div>
|
| 932 |
+
<div style="grid-column: 1 / span 2; display:grid; grid-template-columns: repeat(3, minmax(160px, 1fr)); gap:10px;">
|
| 933 |
+
<div>
|
| 934 |
+
<label style="font-weight:700; font-size:12px;">Minimum Threshold</label>
|
| 935 |
+
<input id="min-${med.id}" type="number" value="${med.minThreshold}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 936 |
+
</div>
|
| 937 |
+
<div>
|
| 938 |
+
<label style="font-weight:700; font-size:12px;">Unit of Measure</label>
|
| 939 |
+
<input id="unit-${med.id}" type="text" value="${med.unit}" placeholder="Bottle, Box, Blister" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 940 |
+
</div>
|
| 941 |
+
</div>
|
| 942 |
+
<div style="grid-column: span 2; display:grid; grid-template-columns: repeat(2, minmax(200px, 1fr)); gap:10px;">
|
| 943 |
+
<div>
|
| 944 |
+
<label style="font-weight:700; font-size:12px;">Priority Tier</label>
|
| 945 |
+
<select id="tier-${med.id}" style="width:100%; padding:8px;" onchange="handleMedTierChange('${med.id}')">
|
| 946 |
+
${buildTierOptions(med.priorityTier)}
|
| 947 |
+
</select>
|
| 948 |
+
</div>
|
| 949 |
+
<div>
|
| 950 |
+
<label style="font-weight:700; font-size:12px;">Functional Subcategory</label>
|
| 951 |
+
<select id="tiercat-${med.id}" style="width:100%; padding:8px;" onchange="scheduleSaveMedication('${med.id}')">
|
| 952 |
+
${buildTierSubcategoryOptions(med.priorityTier, med.tierCategory)}
|
| 953 |
+
</select>
|
| 954 |
+
</div>
|
| 955 |
+
</div>
|
| 956 |
+
<div>
|
| 957 |
+
<label style="font-weight:700; font-size:12px;">Storage Location</label>
|
| 958 |
+
<input id="loc-${med.id}" type="text" value="${med.storageLocation}" placeholder="Locker A, Fridge" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 959 |
+
</div>
|
| 960 |
+
<div>
|
| 961 |
+
<label style="font-weight:700; font-size:12px;">Controlled Substance?</label>
|
| 962 |
+
<select id="ctrl-${med.id}" style="width:100%; padding:8px;" onchange="scheduleSaveMedication('${med.id}')">
|
| 963 |
+
<option value="false" ${!med.controlled ? 'selected' : ''}>No</option>
|
| 964 |
+
<option value="true" ${med.controlled ? 'selected' : ''}>Yes</option>
|
| 965 |
+
</select>
|
| 966 |
+
</div>
|
| 967 |
+
<div>
|
| 968 |
+
<label style="font-weight:700; font-size:12px;">Primary Indication</label>
|
| 969 |
+
<input id="ind-${med.id}" type="text" value="${med.primaryIndication}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 970 |
+
</div>
|
| 971 |
+
<div>
|
| 972 |
+
<label style="font-weight:700; font-size:12px;">Allergy Warnings</label>
|
| 973 |
+
<input id="alg-${med.id}" type="text" value="${med.allergyWarnings}" placeholder="e.g., Contains Sulfa" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
|
| 974 |
+
</div>
|
| 975 |
+
<div style="grid-column: span 2;">
|
| 976 |
+
<label style="font-weight:700; font-size:12px;">Standard Dosage (adult reference)</label>
|
| 977 |
+
<textarea id="dose-${med.id}" style="width:100%; padding:8px; min-height:60px; ${doseHeight}" oninput="scheduleSaveMedication('${med.id}')">${med.standardDosage || ''}</textarea>
|
| 978 |
+
</div>
|
| 979 |
+
</div>
|
| 980 |
+
</div>
|
| 981 |
+
</div>
|
| 982 |
+
<div class="collapsible" style="margin-top:12px;">
|
| 983 |
+
<div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="background:#fff6e8; border:1px solid #f0d9a8; justify-content:flex-start;">
|
| 984 |
+
<span class="dev-tag">dev:med-expiry-shell</span>
|
| 985 |
+
<span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">▸</span>
|
| 986 |
+
<span style="font-weight:700;">Expiry Tracking</span>
|
| 987 |
+
<span style="font-size:12px; color:#6a5b3a; margin-left:8px;">${med.purchaseHistory.length} batch(es)</span>
|
| 988 |
+
</div>
|
| 989 |
+
<div class="col-body" style="padding:10px; background:#fffdf7; border:1px solid #f0d9a8; border-top:none; display:none;">
|
| 990 |
+
<div class="collapsible" style="margin-bottom:10px;">
|
| 991 |
+
<div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="background:#fff; justify-content:flex-start; align-items:center;">
|
| 992 |
+
<span class="dev-tag">dev:med-expiry-add-form</span>
|
| 993 |
+
<span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span>
|
| 994 |
+
<span style="font-weight:700;">Add New Batch Entry</span>
|
| 995 |
+
</div>
|
| 996 |
+
<div class="col-body" style="padding:10px; display:none; background:#f9fbff; border:1px solid #d9e5f7; border-top:none;">
|
| 997 |
+
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; margin-bottom:10px;">
|
| 998 |
+
<div>
|
| 999 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Expiry Date *</label>
|
| 1000 |
+
<input type="date" id="new-exp-date-${med.id}" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;">
|
| 1001 |
+
</div>
|
| 1002 |
+
<div>
|
| 1003 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Quantity *</label>
|
| 1004 |
+
<input type="number" id="new-exp-qty-${med.id}" placeholder="0" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;">
|
| 1005 |
+
</div>
|
| 1006 |
+
</div>
|
| 1007 |
+
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; margin-bottom:10px;">
|
| 1008 |
+
<div>
|
| 1009 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Manufacturer</label>
|
| 1010 |
+
<input type="text" id="new-exp-manu-${med.id}" placeholder="Optional" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;">
|
| 1011 |
+
</div>
|
| 1012 |
+
<div>
|
| 1013 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Batch / Lot #</label>
|
| 1014 |
+
<input type="text" id="new-exp-batch-${med.id}" placeholder="Optional" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;">
|
| 1015 |
+
</div>
|
| 1016 |
+
</div>
|
| 1017 |
+
<div>
|
| 1018 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Notes</label>
|
| 1019 |
+
<textarea id="new-exp-notes-${med.id}" placeholder="Optional notes about this batch" style="width:100%; padding:8px; min-height:50px; font-size:14px; border:1px solid #d0d7e2; border-radius:4px; resize:vertical;"></textarea>
|
| 1020 |
+
</div>
|
| 1021 |
+
<button onclick="addPurchaseEntry('${med.id}')" class="btn btn-sm" style="background:var(--dark); width:100%; margin-top:10px;">Add Batch Entry</button>
|
| 1022 |
+
</div>
|
| 1023 |
+
</div>
|
| 1024 |
+
<div class="dev-tag" style="margin:10px 0 6px;">dev:med-expiry-list</div>
|
| 1025 |
+
<div id="ph-${med.id}">${renderPurchaseRows(med)}</div>
|
| 1026 |
+
</div>
|
| 1027 |
+
</div>
|
| 1028 |
+
</div>
|
| 1029 |
+
</div>`;
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
/**
|
| 1033 |
+
* renderWhoMedList: function-level behavior note for maintainers.
|
| 1034 |
+
* Keep this block synchronized with implementation changes.
|
| 1035 |
+
*/
|
| 1036 |
+
function renderWhoMedList() {
|
| 1037 |
+
const container = document.getElementById('who-med-list');
|
| 1038 |
+
if (!container) return;
|
| 1039 |
+
container.innerHTML = WHO_RECOMMENDED_MEDS.map((m, idx) => {
|
| 1040 |
+
const id = `who-${m.id || idx}`;
|
| 1041 |
+
return `
|
| 1042 |
+
<label style="position:relative; display:block; border:1px solid #d9e5f7; padding:12px 10px 10px 42px; border-radius:8px; background:#fff; transition:background 120ms ease, border-color 120ms ease;">
|
| 1043 |
+
<input type="checkbox" name="who-med-check" value="${id}" style="position:absolute; top:10px; left:10px;" onchange="handleWhoTileSelect(this)">
|
| 1044 |
+
<div style="display:flex; gap:10px; width:100%; align-items:flex-start;">
|
| 1045 |
+
<div style="flex:1; min-width:0;">
|
| 1046 |
+
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:8px; flex-wrap:wrap;">
|
| 1047 |
+
<div style="font-weight:800; color:#1f2d3d;">${m.genericName}</div>
|
| 1048 |
+
<button type="button" class="btn btn-xs" style="background:#0b8457; color:#fff; padding:4px 8px; line-height:1.2;" onclick="event.preventDefault(); event.stopPropagation(); addWhoMeds('${id}')">Add</button>
|
| 1049 |
+
</div>
|
| 1050 |
+
${m.alsoKnownAs ? `<div style="font-size:12px; color:#37474f;">Also known as: ${m.alsoKnownAs}</div>` : ''}
|
| 1051 |
+
${m.formStrength ? `<div style="font-size:12px; color:#455a64;">Dosage form/strength: ${m.formStrength}</div>` : ''}
|
| 1052 |
+
${m.indications ? `<div style="font-size:12px; color:#455a64;">Indications: ${m.indications}</div>` : ''}
|
| 1053 |
+
${m.contraindications ? `<div style="font-size:12px; color:#b23b3b;">Contraindications: ${m.contraindications}</div>` : ''}
|
| 1054 |
+
${m.consultDoctor ? `<div style="font-size:12px; color:#6a1b9a;">Consult doctor: ${m.consultDoctor}</div>` : ''}
|
| 1055 |
+
${m.adultDosage ? `<div style="font-size:12px; color:#455a64;">Adult dosage: ${m.adultDosage}</div>` : ''}
|
| 1056 |
+
${m.unwantedEffects ? `<div style="font-size:12px; color:#b23b3b;">Unwanted effects: ${m.unwantedEffects}</div>` : ''}
|
| 1057 |
+
${m.remarks ? `<div style="font-size:12px; color:#455a64;">Remarks: ${m.remarks}</div>` : ''}
|
| 1058 |
+
</div>
|
| 1059 |
+
</div>
|
| 1060 |
+
</label>
|
| 1061 |
+
`;
|
| 1062 |
+
}).join('') || '<div style="color:#666;">WHO list unavailable.</div>';
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
async function loadWhoMedsFromServer() {
|
| 1066 |
+
if (whoMedLoaded) return;
|
| 1067 |
+
setWhoMedStatus('Loading WHO ship medicine list...');
|
| 1068 |
+
try {
|
| 1069 |
+
const res = await fetch('/api/who/medicines', { credentials: 'same-origin' });
|
| 1070 |
+
if (!res.ok) throw new Error(`Status ${res.status}`);
|
| 1071 |
+
const data = await res.json();
|
| 1072 |
+
WHO_RECOMMENDED_MEDS = Array.isArray(data) ? data : [];
|
| 1073 |
+
whoMedLoaded = true;
|
| 1074 |
+
renderWhoMedList();
|
| 1075 |
+
setWhoMedStatus(`Loaded ${WHO_RECOMMENDED_MEDS.length} WHO medicine(s).`);
|
| 1076 |
+
} catch (err) {
|
| 1077 |
+
whoMedLoaded = false;
|
| 1078 |
+
renderWhoMedList();
|
| 1079 |
+
setWhoMedStatus(`WHO list unavailable: ${err.message}`, true);
|
| 1080 |
+
}
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
/**
|
| 1084 |
+
* parseWHOListText: function-level behavior note for maintainers.
|
| 1085 |
+
* Keep this block synchronized with implementation changes.
|
| 1086 |
+
*/
|
| 1087 |
+
function parseWHOListText(text) {
|
| 1088 |
+
if (typeof text !== 'string') return [];
|
| 1089 |
+
const lines = text
|
| 1090 |
+
.split(/\r?\n/)
|
| 1091 |
+
.map((line) => line.trim())
|
| 1092 |
+
.filter(Boolean);
|
| 1093 |
+
const now = Date.now();
|
| 1094 |
+
return lines
|
| 1095 |
+
.map((line, idx) => {
|
| 1096 |
+
const cols = line.split('\t');
|
| 1097 |
+
if (idx === 0 && /generic\s+name/i.test(cols[0] || '')) {
|
| 1098 |
+
return null;
|
| 1099 |
+
}
|
| 1100 |
+
return {
|
| 1101 |
+
id: `who-import-${now}-${idx}`,
|
| 1102 |
+
genericName: (cols[0] || '').trim(),
|
| 1103 |
+
alsoKnownAs: (cols[1] || '').trim(),
|
| 1104 |
+
formStrength: (cols[2] || '').trim(),
|
| 1105 |
+
indications: (cols[3] || '').trim(),
|
| 1106 |
+
contraindications: (cols[4] || '').trim(),
|
| 1107 |
+
consultDoctor: (cols[5] || '').trim(),
|
| 1108 |
+
adultDosage: (cols[6] || '').trim(),
|
| 1109 |
+
unwantedEffects: (cols[7] || '').trim(),
|
| 1110 |
+
remarks: (cols[8] || '').trim(),
|
| 1111 |
+
};
|
| 1112 |
+
})
|
| 1113 |
+
.filter((entry) => entry && entry.genericName);
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
/**
|
| 1117 |
+
* setWhoMedStatus: function-level behavior note for maintainers.
|
| 1118 |
+
* Keep this block synchronized with implementation changes.
|
| 1119 |
+
*/
|
| 1120 |
+
function setWhoMedStatus(message, isError = false) {
|
| 1121 |
+
const statusEl = document.getElementById('who-med-status');
|
| 1122 |
+
if (!statusEl) return;
|
| 1123 |
+
statusEl.textContent = message;
|
| 1124 |
+
statusEl.style.color = isError ? 'var(--red)' : '#1f2d3d';
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
/**
|
| 1128 |
+
* openWhoListFilePicker: function-level behavior note for maintainers.
|
| 1129 |
+
* Keep this block synchronized with implementation changes.
|
| 1130 |
+
*/
|
| 1131 |
+
function openWhoListFilePicker() {
|
| 1132 |
+
const input = document.getElementById('who-list-import-file');
|
| 1133 |
+
if (!input) return;
|
| 1134 |
+
input.value = '';
|
| 1135 |
+
input.click();
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
async function handleWhoListFileImport(event) {
|
| 1139 |
+
const file = event?.target?.files?.[0];
|
| 1140 |
+
if (!file) return;
|
| 1141 |
+
try {
|
| 1142 |
+
const text = await file.text();
|
| 1143 |
+
const entries = parseWHOListText(text);
|
| 1144 |
+
if (!entries.length) {
|
| 1145 |
+
setWhoMedStatus('No valid WHO medicines found in the file.', true);
|
| 1146 |
+
return;
|
| 1147 |
+
}
|
| 1148 |
+
WHO_RECOMMENDED_MEDS = entries;
|
| 1149 |
+
renderWhoMedList();
|
| 1150 |
+
setWhoMedStatus(`Loaded ${entries.length} WHO medicine(s).`);
|
| 1151 |
+
} catch (err) {
|
| 1152 |
+
setWhoMedStatus(`Unable to load WHO list: ${err.message}`, true);
|
| 1153 |
+
} finally {
|
| 1154 |
+
if (event?.target) event.target.value = '';
|
| 1155 |
+
}
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
async function addWhoMeds(triggerId = null) {
|
| 1159 |
+
const statusEl = document.getElementById('who-med-status');
|
| 1160 |
+
const setStatus = (msg, isErr = false) => {
|
| 1161 |
+
if (!statusEl) return;
|
| 1162 |
+
statusEl.textContent = msg;
|
| 1163 |
+
statusEl.style.color = isErr ? 'var(--red)' : '#1f2d3d';
|
| 1164 |
+
};
|
| 1165 |
+
const checked = Array.from(document.querySelectorAll('input[name="who-med-check"]:checked')).map((el) => el.value);
|
| 1166 |
+
const targetIds = new Set(checked);
|
| 1167 |
+
if (triggerId) targetIds.add(triggerId);
|
| 1168 |
+
if (!targetIds.size) {
|
| 1169 |
+
setStatus('Select one or more medicines to copy, or use an Add button.', true);
|
| 1170 |
+
return;
|
| 1171 |
+
}
|
| 1172 |
+
try {
|
| 1173 |
+
const data = await fetchInventory();
|
| 1174 |
+
const meds = Array.isArray(data) ? data.map(ensurePharmacyDefaults) : [];
|
| 1175 |
+
let added = 0;
|
| 1176 |
+
let skipped = 0;
|
| 1177 |
+
const addedNames = [];
|
| 1178 |
+
const timestamp = new Date().toISOString();
|
| 1179 |
+
Array.from(targetIds).forEach((targetVal) => {
|
| 1180 |
+
const medTpl = WHO_RECOMMENDED_MEDS.find((m, idx) => `who-${m.id || idx}` === targetVal);
|
| 1181 |
+
if (!medTpl) return;
|
| 1182 |
+
const tplBrand = (medTpl.alsoKnownAs || '').trim().toLowerCase();
|
| 1183 |
+
const tplStrengthRaw = medTpl.formStrength || '';
|
| 1184 |
+
const tplStrengthParts = tplStrengthRaw.split(',');
|
| 1185 |
+
const tplStrength = (tplStrengthParts[1] || tplStrengthParts[0] || '').trim().toLowerCase();
|
| 1186 |
+
const dup = meds.find((m) => {
|
| 1187 |
+
const g = (m.genericName || '').trim().toLowerCase();
|
| 1188 |
+
const b = (m.brandName || '').trim().toLowerCase();
|
| 1189 |
+
const s = (m.strength || '').trim().toLowerCase();
|
| 1190 |
+
return g === (medTpl.genericName || '').trim().toLowerCase() && b === tplBrand && s === tplStrength;
|
| 1191 |
+
});
|
| 1192 |
+
if (dup) {
|
| 1193 |
+
skipped += 1;
|
| 1194 |
+
return;
|
| 1195 |
+
}
|
| 1196 |
+
const newMed = ensurePharmacyDefaults({
|
| 1197 |
+
id: uid('med-who'),
|
| 1198 |
+
genericName: medTpl.genericName || '',
|
| 1199 |
+
brandName: medTpl.alsoKnownAs || '',
|
| 1200 |
+
form: medTpl.formStrength ? medTpl.formStrength.split(',')[0].trim() : '',
|
| 1201 |
+
strength: medTpl.formStrength ? (medTpl.formStrength.split(',')[1] || '').trim() : '',
|
| 1202 |
+
currentQuantity: '',
|
| 1203 |
+
minThreshold: '',
|
| 1204 |
+
unit: '',
|
| 1205 |
+
storageLocation: 'Medical Locker',
|
| 1206 |
+
expiryDate: '',
|
| 1207 |
+
batchLot: '',
|
| 1208 |
+
controlled: false,
|
| 1209 |
+
manufacturer: '',
|
| 1210 |
+
primaryIndication: medTpl.indications || '',
|
| 1211 |
+
allergyWarnings: medTpl.contraindications || medTpl.unwantedEffects || '',
|
| 1212 |
+
standardDosage: medTpl.adultDosage || '',
|
| 1213 |
+
notes: `${medTpl.remarks || medTpl.consultDoctor || 'WHO ship list import'} | Added from WHO list on ${timestamp}`,
|
| 1214 |
+
source: 'who_recommended',
|
| 1215 |
+
purchaseHistory: [],
|
| 1216 |
+
excludeFromResources: true,
|
| 1217 |
+
});
|
| 1218 |
+
meds.push(newMed);
|
| 1219 |
+
added += 1;
|
| 1220 |
+
addedNames.push(medTpl.genericName || 'Unknown');
|
| 1221 |
+
});
|
| 1222 |
+
await fetchInventory({
|
| 1223 |
+
method: 'POST',
|
| 1224 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1225 |
+
body: JSON.stringify(meds),
|
| 1226 |
+
});
|
| 1227 |
+
pharmacyCache = meds;
|
| 1228 |
+
renderPharmacy(pharmacyCache);
|
| 1229 |
+
const summaryMsg = `Added ${added} item(s)${skipped ? `, skipped ${skipped} duplicate(s)` : ''}.`;
|
| 1230 |
+
setStatus(summaryMsg);
|
| 1231 |
+
if (addedNames.length) {
|
| 1232 |
+
alert(`Added to inventory:\\n- ${addedNames.join('\\n- ')}`);
|
| 1233 |
+
}
|
| 1234 |
+
} catch (err) {
|
| 1235 |
+
setStatus(`Unable to add medicines: ${err.message}`, true);
|
| 1236 |
+
}
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
/**
|
| 1240 |
+
* handleWhoTileSelect: function-level behavior note for maintainers.
|
| 1241 |
+
* Keep this block synchronized with implementation changes.
|
| 1242 |
+
*/
|
| 1243 |
+
function handleWhoTileSelect(checkbox) {
|
| 1244 |
+
const tile = checkbox?.closest('label');
|
| 1245 |
+
if (!tile) return;
|
| 1246 |
+
const isChecked = !!checkbox.checked;
|
| 1247 |
+
tile.style.background = isChecked ? '#f1fff4' : '#fff';
|
| 1248 |
+
tile.style.borderColor = isChecked ? '#9fd7ac' : '#d9e5f7';
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
/**
|
| 1252 |
+
* daysUntil: function-level behavior note for maintainers.
|
| 1253 |
+
* Keep this block synchronized with implementation changes.
|
| 1254 |
+
*/
|
| 1255 |
+
function daysUntil(dateStr) {
|
| 1256 |
+
const now = new Date();
|
| 1257 |
+
const target = new Date(dateStr);
|
| 1258 |
+
if (isNaN(target.getTime())) return 9999;
|
| 1259 |
+
const diff = target.getTime() - now.getTime();
|
| 1260 |
+
return Math.ceil(diff / (1000 * 60 * 60 * 24));
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
/**
|
| 1264 |
+
* clearExpiryForm: function-level behavior note for maintainers.
|
| 1265 |
+
* Keep this block synchronized with implementation changes.
|
| 1266 |
+
*/
|
| 1267 |
+
function clearExpiryForm(medId) {
|
| 1268 |
+
const fields = ['new-exp-date', 'new-exp-qty', 'new-exp-manu', 'new-exp-batch', 'new-exp-notes'];
|
| 1269 |
+
fields.forEach(prefix => {
|
| 1270 |
+
const el = document.getElementById(`${prefix}-${medId}`);
|
| 1271 |
+
if (el) el.value = '';
|
| 1272 |
+
});
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
/**
|
| 1276 |
+
* addPurchaseEntry: function-level behavior note for maintainers.
|
| 1277 |
+
* Keep this block synchronized with implementation changes.
|
| 1278 |
+
*/
|
| 1279 |
+
function addPurchaseEntry(medId) {
|
| 1280 |
+
// Read values from the add form (like crew vaccine pattern)
|
| 1281 |
+
const date = document.getElementById(`new-exp-date-${medId}`)?.value || '';
|
| 1282 |
+
const qty = document.getElementById(`new-exp-qty-${medId}`)?.value || '';
|
| 1283 |
+
const manu = document.getElementById(`new-exp-manu-${medId}`)?.value || '';
|
| 1284 |
+
const batch = document.getElementById(`new-exp-batch-${medId}`)?.value || '';
|
| 1285 |
+
const notes = document.getElementById(`new-exp-notes-${medId}`)?.value || '';
|
| 1286 |
+
|
| 1287 |
+
// Validate required fields (like crew vaccine pattern)
|
| 1288 |
+
if (!date) {
|
| 1289 |
+
alert('Please enter an Expiry Date');
|
| 1290 |
+
const dateField = document.getElementById(`new-exp-date-${medId}`);
|
| 1291 |
+
if (dateField) dateField.focus();
|
| 1292 |
+
return;
|
| 1293 |
+
}
|
| 1294 |
+
if (!qty) {
|
| 1295 |
+
alert('Please enter a Quantity');
|
| 1296 |
+
const qtyField = document.getElementById(`new-exp-qty-${medId}`);
|
| 1297 |
+
if (qtyField) qtyField.focus();
|
| 1298 |
+
return;
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
const container = document.getElementById(`ph-${medId}`);
|
| 1302 |
+
if (!container) return;
|
| 1303 |
+
|
| 1304 |
+
// Get current batch count for numbering
|
| 1305 |
+
const currentRows = container.querySelectorAll('.purchase-row').length;
|
| 1306 |
+
const rowNum = currentRows + 1;
|
| 1307 |
+
|
| 1308 |
+
// Create new row with values from form
|
| 1309 |
+
const row = document.createElement('div');
|
| 1310 |
+
row.className = 'purchase-row';
|
| 1311 |
+
row.dataset.medId = medId;
|
| 1312 |
+
const newPhId = uid('ph');
|
| 1313 |
+
row.dataset.phId = newPhId;
|
| 1314 |
+
row.style.cssText = 'border:1px solid #d9e5f7; padding:12px; border-radius:8px; margin-bottom:12px; background:#fff; position:relative;';
|
| 1315 |
+
row.innerHTML = `
|
| 1316 |
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
| 1317 |
+
<span style="font-weight:700; color:#37474f;">Batch ${rowNum}</span>
|
| 1318 |
+
<button class="btn btn-sm" style="background:var(--red); padding:4px 10px;" onclick="deletePurchaseEntry('${medId}','${newPhId}')" title="Delete this batch entry">Delete</button>
|
| 1319 |
+
</div>
|
| 1320 |
+
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; margin-bottom:10px;">
|
| 1321 |
+
<div>
|
| 1322 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Expiry Date *</label>
|
| 1323 |
+
<input type="date" class="ph-date" value="${date}" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" onchange="scheduleSaveMedication('${medId}')">
|
| 1324 |
+
</div>
|
| 1325 |
+
<div>
|
| 1326 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Quantity *</label>
|
| 1327 |
+
<input type="number" class="ph-qty" value="${qty}" placeholder="0" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" oninput="scheduleSaveMedication('${medId}')">
|
| 1328 |
+
</div>
|
| 1329 |
+
</div>
|
| 1330 |
+
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:10px; margin-bottom:10px;">
|
| 1331 |
+
<div>
|
| 1332 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Manufacturer</label>
|
| 1333 |
+
<input type="text" class="ph-manufacturer" value="${manu}" placeholder="Optional" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" oninput="scheduleSaveMedication('${medId}')">
|
| 1334 |
+
</div>
|
| 1335 |
+
<div>
|
| 1336 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Batch / Lot #</label>
|
| 1337 |
+
<input type="text" class="ph-batch" value="${batch}" placeholder="Optional" style="padding:8px; font-size:14px; width:100%; border:1px solid #d0d7e2; border-radius:4px;" oninput="scheduleSaveMedication('${medId}')">
|
| 1338 |
+
</div>
|
| 1339 |
+
</div>
|
| 1340 |
+
<div>
|
| 1341 |
+
<label style="font-weight:700; font-size:12px; display:block; margin-bottom:4px;">Notes</label>
|
| 1342 |
+
<textarea class="ph-notes" placeholder="Optional notes about this batch" style="width:100%; padding:8px; min-height:50px; font-size:14px; border:1px solid #d0d7e2; border-radius:4px; resize:vertical;" oninput="scheduleSaveMedication('${medId}')">${notes}</textarea>
|
| 1343 |
+
</div>
|
| 1344 |
+
`;
|
| 1345 |
+
container.appendChild(row);
|
| 1346 |
+
|
| 1347 |
+
// Clear the form fields (like crew vaccine pattern)
|
| 1348 |
+
clearExpiryForm(medId);
|
| 1349 |
+
|
| 1350 |
+
// Save medication with new batch entry
|
| 1351 |
+
scheduleSaveMedication(medId);
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
// Persist a single medication after client-side validation. Pulls latest list to avoid
|
| 1355 |
+
// editing stale data, performs optimistic UI update, then attempts save; on failure it
|
| 1356 |
+
// reloads from server to keep UI consistent.
|
| 1357 |
+
async function saveMedication(id, rerender = false) {
|
| 1358 |
+
const openMedIds = getOpenMedIds();
|
| 1359 |
+
const textHeights = getTextareaHeights();
|
| 1360 |
+
// Use current cache; if empty, fetch once
|
| 1361 |
+
if (!pharmacyCache.length) {
|
| 1362 |
+
try {
|
| 1363 |
+
const data = await fetchInventory();
|
| 1364 |
+
pharmacyCache = Array.isArray(data) ? data.map(ensurePharmacyDefaults) : [];
|
| 1365 |
+
} catch (err) {
|
| 1366 |
+
showToast(`Unable to load inventory before saving: ${err.message}`, true);
|
| 1367 |
+
return;
|
| 1368 |
+
}
|
| 1369 |
+
}
|
| 1370 |
+
const med = pharmacyCache.find((m) => m.id === id);
|
| 1371 |
+
if (!med) {
|
| 1372 |
+
showToast('Medication not found (stale view). Reloading...', true);
|
| 1373 |
+
return loadPharmacy();
|
| 1374 |
+
}
|
| 1375 |
+
// Duplicate guard stays client-side so the user gets immediate feedback before POST.
|
| 1376 |
+
const genericVal = (document.getElementById(`gn-${id}`)?.value || '').trim();
|
| 1377 |
+
const strengthVal = (document.getElementById(`str-${id}`)?.value || '').trim();
|
| 1378 |
+
const brandVal = (document.getElementById(`bn-${id}`)?.value || '').trim();
|
| 1379 |
+
const formVal = (document.getElementById(`form-${id}`)?.value || '').trim();
|
| 1380 |
+
const targetKey = canonicalMedKey(genericVal, brandVal, strengthVal, `${formVal} ${strengthVal}`.trim());
|
| 1381 |
+
const dup = pharmacyCache.find((m) => {
|
| 1382 |
+
if (m.id === id) return false;
|
| 1383 |
+
return canonicalMedKey(m.genericName, m.brandName, m.strength, m.formStrength) === targetKey;
|
| 1384 |
+
});
|
| 1385 |
+
if (dup) {
|
| 1386 |
+
alert('A medication with the same Generic + Brand + Strength already exists. Please adjust to keep entries unique.');
|
| 1387 |
+
return;
|
| 1388 |
+
}
|
| 1389 |
+
med.genericName = document.getElementById(`gn-${id}`)?.value || '';
|
| 1390 |
+
med.brandName = document.getElementById(`bn-${id}`)?.value || '';
|
| 1391 |
+
med.form = document.getElementById(`form-${id}`)?.value || '';
|
| 1392 |
+
med.strength = document.getElementById(`str-${id}`)?.value || '';
|
| 1393 |
+
med.minThreshold = document.getElementById(`min-${id}`)?.value || '';
|
| 1394 |
+
med.unit = document.getElementById(`unit-${id}`)?.value || '';
|
| 1395 |
+
med.storageLocation = document.getElementById(`loc-${id}`)?.value || '';
|
| 1396 |
+
med.controlled = (document.getElementById(`ctrl-${id}`)?.value || 'false') === 'true';
|
| 1397 |
+
med.primaryIndication = document.getElementById(`ind-${id}`)?.value || '';
|
| 1398 |
+
med.allergyWarnings = document.getElementById(`alg-${id}`)?.value || '';
|
| 1399 |
+
med.standardDosage = document.getElementById(`dose-${id}`)?.value || '';
|
| 1400 |
+
med.excludeFromResources = !!document.getElementById(`exclude-${id}`)?.checked;
|
| 1401 |
+
const sortVal = document.getElementById(`sort-${id}`)?.value || '';
|
| 1402 |
+
const sortCustom = document.getElementById(`sort-custom-${id}`)?.value || '';
|
| 1403 |
+
med.sortCategory = sortVal === '__custom' ? sortCustom : sortVal || sortCustom || '';
|
| 1404 |
+
med.priorityTier = document.getElementById(`tier-${id}`)?.value || '';
|
| 1405 |
+
med.tierCategory = document.getElementById(`tiercat-${id}`)?.value || '';
|
| 1406 |
+
med.verified = !!document.getElementById(`ver-${id}`)?.checked;
|
| 1407 |
+
med.purchaseHistory = collectPurchaseEntries(id);
|
| 1408 |
+
// Auto-sanitize any non-ISO dates so the save doesn't fail; backend also clears invalid dates.
|
| 1409 |
+
let clearedBadDates = false;
|
| 1410 |
+
med.purchaseHistory = (med.purchaseHistory || []).map((ph) => {
|
| 1411 |
+
const ok = !ph.date || !Number.isNaN(new Date(ph.date).getTime());
|
| 1412 |
+
if (!ok) {
|
| 1413 |
+
clearedBadDates = true;
|
| 1414 |
+
return { ...ph, date: '' };
|
| 1415 |
+
}
|
| 1416 |
+
return ph;
|
| 1417 |
+
});
|
| 1418 |
+
if (clearedBadDates) {
|
| 1419 |
+
showToast('Some expiry dates were not valid and were cleared. Please re-enter as YYYY-MM-DD.', true);
|
| 1420 |
+
}
|
| 1421 |
+
med.formStrength = [med.form, med.strength].join(' ').trim();
|
| 1422 |
+
// Keep top-level manufacturer/batch in sync with first purchase entry for compatibility
|
| 1423 |
+
if (med.purchaseHistory.length) {
|
| 1424 |
+
const first = med.purchaseHistory[0];
|
| 1425 |
+
med.manufacturer = first.manufacturer || '';
|
| 1426 |
+
med.batchLot = first.batchLot || '';
|
| 1427 |
+
} else {
|
| 1428 |
+
med.manufacturer = '';
|
| 1429 |
+
med.batchLot = '';
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
// Optimistic rerender before saving to backend for instant UI feedback
|
| 1433 |
+
if (rerender) {
|
| 1434 |
+
renderPharmacy(pharmacyCache, openMedIds, textHeights);
|
| 1435 |
+
}
|
| 1436 |
+
|
| 1437 |
+
// Validate and persist with error handling; on failure, reload from server to avoid stale UI.
|
| 1438 |
+
const validation = validateMedication(med);
|
| 1439 |
+
if (!validation.ok) {
|
| 1440 |
+
showToast(validation.message, true);
|
| 1441 |
+
return;
|
| 1442 |
+
}
|
| 1443 |
+
try {
|
| 1444 |
+
const res = await fetch(`/api/data/inventory/${encodeURIComponent(id)}`, {
|
| 1445 |
+
method: 'PUT',
|
| 1446 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1447 |
+
credentials: 'same-origin',
|
| 1448 |
+
body: JSON.stringify(med),
|
| 1449 |
+
});
|
| 1450 |
+
const data = await res.json().catch(() => ({}));
|
| 1451 |
+
if (!res.ok || data.error) {
|
| 1452 |
+
throw new Error(data.error || `Status ${res.status}`);
|
| 1453 |
+
}
|
| 1454 |
+
showToast('Medication saved');
|
| 1455 |
+
// Update local cache without full reload to prevent form collapse
|
| 1456 |
+
const idx = pharmacyCache.findIndex(m => m.id === id);
|
| 1457 |
+
if (idx !== -1) {
|
| 1458 |
+
pharmacyCache[idx] = med;
|
| 1459 |
+
}
|
| 1460 |
+
// Only rerender if structure changed (labels, availability, etc)
|
| 1461 |
+
if (rerender) {
|
| 1462 |
+
renderPharmacy(pharmacyCache, openMedIds, textHeights);
|
| 1463 |
+
}
|
| 1464 |
+
} catch (err) {
|
| 1465 |
+
showToast(`Save failed: ${err.message}`, true);
|
| 1466 |
+
console.error('[pharmacy] saveMedication error:', err);
|
| 1467 |
+
// On error, reload from server to ensure consistency
|
| 1468 |
+
await loadPharmacy();
|
| 1469 |
+
}
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
// Basic client-side guardrails before hitting the backend.
|
| 1473 |
+
function validateMedication(med) {
|
| 1474 |
+
// Keep this in sync with backend ensure_item_schema/_validate_expiry for consistent rejection reasons.
|
| 1475 |
+
if (!med.genericName || !med.genericName.trim()) {
|
| 1476 |
+
return { ok: false, message: 'Generic name is required.' };
|
| 1477 |
+
}
|
| 1478 |
+
const hasStrengthHint = /\d/.test(med.formStrength || med.form || '');
|
| 1479 |
+
// Allow strength embedded in the form field (e.g., "Tablet 500 mg"). If not present,
|
| 1480 |
+
// backfill a placeholder so legacy items without strength can still be saved/removed.
|
| 1481 |
+
if ((!med.strength || !med.strength.trim()) && !hasStrengthHint) {
|
| 1482 |
+
med.strength = med.strength || 'unspecified';
|
| 1483 |
+
med.formStrength = med.formStrength || (med.form ? `${med.form} ${med.strength}` : med.strength);
|
| 1484 |
+
}
|
| 1485 |
+
// Do not block saves for expiry date/quantity issues; backend will sanitize.
|
| 1486 |
+
return { ok: true };
|
| 1487 |
+
}
|
| 1488 |
+
|
| 1489 |
+
/**
|
| 1490 |
+
* Collect and serialize all expiry entries from the DOM for a specific medication.
|
| 1491 |
+
*
|
| 1492 |
+
* This is the **critical serialization function** that bridges UI state → data model.
|
| 1493 |
+
* When a user edits expiry information in the UI, this function extracts all the
|
| 1494 |
+
* values from the DOM and packages them into the purchaseHistory array structure
|
| 1495 |
+
* expected by the backend.
|
| 1496 |
+
*
|
| 1497 |
+
* Serialization Flow:
|
| 1498 |
+
* -------------------
|
| 1499 |
+
* 1. User edits expiry fields in the UI (date, quantity, manufacturer, batch, notes)
|
| 1500 |
+
* 2. Change triggers scheduleSaveMedication() → saveMedication()
|
| 1501 |
+
* 3. saveMedication() calls **collectPurchaseEntries()** to read current UI state
|
| 1502 |
+
* 4. Serialized data is sent to backend via PUT /api/data/inventory/{id}
|
| 1503 |
+
* 5. Backend validates and persists to database
|
| 1504 |
+
*
|
| 1505 |
+
* ID Stability Contract:
|
| 1506 |
+
* ---------------------
|
| 1507 |
+
* Purchase entry IDs (ph-xxx) are **stable across saves**. This is critical because:
|
| 1508 |
+
* - Backend uses IDs to determine update vs insert (upsert logic)
|
| 1509 |
+
* - Editing an existing entry preserves its ID → backend updates in place
|
| 1510 |
+
* - Adding a new entry generates new ID → backend creates new record
|
| 1511 |
+
* - Deleting an entry removes its DOM row → backend deletes from database
|
| 1512 |
+
*
|
| 1513 |
+
* This ID stability enables precise batch-level tracking where each expiry entry
|
| 1514 |
+
* maintains its identity throughout its lifecycle.
|
| 1515 |
+
*
|
| 1516 |
+
* @param {string} medId - The medication ID whose expiry entries to collect
|
| 1517 |
+
*
|
| 1518 |
+
* @returns {Array<Object>} Array of purchase/expiry entries with structure:
|
| 1519 |
+
* - id {string}: Stable purchase entry ID (ph-<uuid>)
|
| 1520 |
+
* - date {string}: Expiry date from date input (ISO format YYYY-MM-DD)
|
| 1521 |
+
* - quantity {string}: Quantity from number input (can be decimal)
|
| 1522 |
+
* - notes {string}: Notes from textarea
|
| 1523 |
+
* - manufacturer {string}: Manufacturer name from text input
|
| 1524 |
+
* - batchLot {string}: Batch/lot number from text input
|
| 1525 |
+
*
|
| 1526 |
+
* @example
|
| 1527 |
+
* // User has edited two expiry entries in the UI
|
| 1528 |
+
* collectPurchaseEntries('med-12345')
|
| 1529 |
+
* // Returns:
|
| 1530 |
+
* [
|
| 1531 |
+
* {
|
| 1532 |
+
* id: "ph-abc123",
|
| 1533 |
+
* date: "2026-12-31",
|
| 1534 |
+
* quantity: "100",
|
| 1535 |
+
* manufacturer: "Pfizer",
|
| 1536 |
+
* batchLot: "LOT-456",
|
| 1537 |
+
* notes: "Refrigerate"
|
| 1538 |
+
* },
|
| 1539 |
+
* {
|
| 1540 |
+
* id: "ph-def456",
|
| 1541 |
+
* date: "2025-06-30",
|
| 1542 |
+
* quantity: "50",
|
| 1543 |
+
* manufacturer: "Bayer",
|
| 1544 |
+
* batchLot: "BATCH-789",
|
| 1545 |
+
* notes: ""
|
| 1546 |
+
* }
|
| 1547 |
+
* ]
|
| 1548 |
+
*
|
| 1549 |
+
* Edge Cases Handled:
|
| 1550 |
+
* ------------------
|
| 1551 |
+
* - Missing container: Returns empty array (medication may not exist or not rendered)
|
| 1552 |
+
* - Missing ph-id on row: Generates new ID (defensive - shouldn't happen normally)
|
| 1553 |
+
* - Missing DOM elements: Uses empty string fallbacks (?.value || '')
|
| 1554 |
+
* - Empty values: Preserved as empty strings (backend validates required fields)
|
| 1555 |
+
*
|
| 1556 |
+
* Data Flow Integration:
|
| 1557 |
+
* ---------------------
|
| 1558 |
+
* This function is part of the critical save path:
|
| 1559 |
+
* ```
|
| 1560 |
+
* User Edit → collectPurchaseEntries() → saveMedication() →
|
| 1561 |
+
* → Backend PUT → Database persist → UI refresh
|
| 1562 |
+
* ```
|
| 1563 |
+
*
|
| 1564 |
+
* Related Functions:
|
| 1565 |
+
* ------------------
|
| 1566 |
+
* - Called by: saveMedication() during save operation
|
| 1567 |
+
* - Counterpart: renderPurchaseRows() (data → DOM rendering)
|
| 1568 |
+
* - Feeds into: Backend upsert_inventory_item() for persistence
|
| 1569 |
+
* - Depends on: DOM structure created by renderPurchaseRows()
|
| 1570 |
+
*
|
| 1571 |
+
* DOM Structure Expected:
|
| 1572 |
+
* ----------------------
|
| 1573 |
+
* Container: #ph-{medId}
|
| 1574 |
+
* Rows: .purchase-row elements with data-ph-id attribute
|
| 1575 |
+
* Fields within each row:
|
| 1576 |
+
* - .ph-date (date input)
|
| 1577 |
+
* - .ph-qty (number input)
|
| 1578 |
+
* - .ph-notes (textarea)
|
| 1579 |
+
* - .ph-manufacturer (text input)
|
| 1580 |
+
* - .ph-batch (text input)
|
| 1581 |
+
*
|
| 1582 |
+
* Business Context:
|
| 1583 |
+
* ----------------
|
| 1584 |
+
* This function enables batch-level inventory tracking - critical for medical
|
| 1585 |
+
* supplies where different batches of the same medication have different:
|
| 1586 |
+
* - Expiration dates (safety compliance)
|
| 1587 |
+
* - Manufacturers (quality traceability)
|
| 1588 |
+
* - Lot numbers (recall management)
|
| 1589 |
+
* - Quantities (stock management)
|
| 1590 |
+
*/
|
| 1591 |
+
function collectPurchaseEntries(medId) {
|
| 1592 |
+
// Locate the container holding all expiry rows for this medication
|
| 1593 |
+
const container = document.getElementById(`ph-${medId}`);
|
| 1594 |
+
if (!container) return [];
|
| 1595 |
+
|
| 1596 |
+
// Serialize every row back into a stable structure
|
| 1597 |
+
// IDs are preserved so backend upserts map correctly (update vs insert)
|
| 1598 |
+
return Array.from(container.querySelectorAll('.purchase-row')).map((row) => {
|
| 1599 |
+
// Extract stable purchase entry ID from data attribute
|
| 1600 |
+
// Generate new ID if missing (defensive - shouldn't happen in normal flow)
|
| 1601 |
+
const phId = row.dataset.phId || uid('ph');
|
| 1602 |
+
|
| 1603 |
+
return {
|
| 1604 |
+
id: phId, // Stable ID for backend upsert
|
| 1605 |
+
date: row.querySelector('.ph-date')?.value || '', // Expiry date (ISO format)
|
| 1606 |
+
quantity: row.querySelector('.ph-qty')?.value || '', // Batch quantity
|
| 1607 |
+
notes: row.querySelector('.ph-notes')?.value || '', // Additional info
|
| 1608 |
+
manufacturer: row.querySelector('.ph-manufacturer')?.value || '', // Manufacturer name
|
| 1609 |
+
batchLot: row.querySelector('.ph-batch')?.value || '', // Batch/lot number
|
| 1610 |
+
};
|
| 1611 |
+
});
|
| 1612 |
+
}
|
| 1613 |
+
|
| 1614 |
+
async function deleteMedication(id) {
|
| 1615 |
+
if (!confirm('Delete this medication from the inventory?')) return;
|
| 1616 |
+
const confirmText = prompt('Type DELETE to confirm:');
|
| 1617 |
+
if (confirmText !== 'DELETE') {
|
| 1618 |
+
alert('Deletion cancelled.');
|
| 1619 |
+
return;
|
| 1620 |
+
}
|
| 1621 |
+
try {
|
| 1622 |
+
const res = await fetch(`/api/data/inventory/${encodeURIComponent(id)}`, {
|
| 1623 |
+
method: 'DELETE',
|
| 1624 |
+
credentials: 'same-origin',
|
| 1625 |
+
});
|
| 1626 |
+
const data = await res.json().catch(() => ({}));
|
| 1627 |
+
if (!res.ok || data.error) {
|
| 1628 |
+
throw new Error(data.error || `Status ${res.status}`);
|
| 1629 |
+
}
|
| 1630 |
+
showToast('Medication deleted');
|
| 1631 |
+
await loadPharmacy();
|
| 1632 |
+
} catch (err) {
|
| 1633 |
+
console.error('[pharmacy] delete failed', err);
|
| 1634 |
+
showToast(`Unable to delete medication: ${err.message}`, true);
|
| 1635 |
+
}
|
| 1636 |
+
}
|
| 1637 |
+
|
| 1638 |
+
async function deletePurchaseEntry(medId, phId) {
|
| 1639 |
+
const container = document.getElementById(`ph-${medId}`);
|
| 1640 |
+
if (!container) return;
|
| 1641 |
+
const rows = Array.from(container.querySelectorAll('.purchase-row'));
|
| 1642 |
+
if (!rows.length) return;
|
| 1643 |
+
const target = phId ? rows.find((r) => r.dataset.phId === phId) : rows[rows.length - 1];
|
| 1644 |
+
if (!target) return;
|
| 1645 |
+
const proceed = confirm('Delete this expiry entry?');
|
| 1646 |
+
if (!proceed) return;
|
| 1647 |
+
if (rows.length === 1) {
|
| 1648 |
+
// Keep a single empty row for UX; clear instead of remove.
|
| 1649 |
+
target.querySelector('.ph-date').value = '';
|
| 1650 |
+
target.querySelector('.ph-qty').value = '';
|
| 1651 |
+
target.querySelector('.ph-notes').value = '';
|
| 1652 |
+
const manu = target.querySelector('.ph-manufacturer');
|
| 1653 |
+
const batch = target.querySelector('.ph-batch');
|
| 1654 |
+
if (manu) manu.value = '';
|
| 1655 |
+
if (batch) batch.value = '';
|
| 1656 |
+
} else {
|
| 1657 |
+
target.remove();
|
| 1658 |
+
}
|
| 1659 |
+
await saveMedication(medId);
|
| 1660 |
+
}
|
| 1661 |
+
|
| 1662 |
+
async function addMedication() {
|
| 1663 |
+
const newId = uid('med');
|
| 1664 |
+
const placeholderName = `New medicine ${newId.slice(-6)}`;
|
| 1665 |
+
try {
|
| 1666 |
+
const draft = ensurePharmacyDefaults({
|
| 1667 |
+
id: newId,
|
| 1668 |
+
genericName: placeholderName,
|
| 1669 |
+
strength: '',
|
| 1670 |
+
purchaseHistory: [],
|
| 1671 |
+
verified: false,
|
| 1672 |
+
});
|
| 1673 |
+
pharmacyCache.push(draft);
|
| 1674 |
+
await fetch(`/api/data/inventory/${encodeURIComponent(newId)}`, {
|
| 1675 |
+
method: 'PUT',
|
| 1676 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1677 |
+
credentials: 'same-origin',
|
| 1678 |
+
body: JSON.stringify(draft),
|
| 1679 |
+
});
|
| 1680 |
+
showToast('Draft medication added — please fill required fields.');
|
| 1681 |
+
loadPharmacy();
|
| 1682 |
+
} catch (err) {
|
| 1683 |
+
showToast(`Unable to add medication: ${err.message}`, true);
|
| 1684 |
+
}
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
// Expose for inline handlers
|
| 1688 |
+
window.loadPharmacy = loadPharmacy;
|
| 1689 |
+
window.addMedication = addMedication;
|
| 1690 |
+
window.saveMedication = saveMedication;
|
| 1691 |
+
window.deleteMedication = deleteMedication;
|
| 1692 |
+
window.addPurchaseEntry = addPurchaseEntry;
|
| 1693 |
+
window.scheduleSaveMedication = scheduleSaveMedication;
|
| 1694 |
+
window.handleMedTierChange = handleMedTierChange;
|
| 1695 |
+
window.refreshPharmacyLabelsFromSettings = refreshPharmacyLabelsFromSettings;
|
| 1696 |
+
window.sortPharmacyList = function(mode) {
|
| 1697 |
+
const openMedIds = getOpenMedIds();
|
| 1698 |
+
const textHeights = getTextareaHeights();
|
| 1699 |
+
renderPharmacy(pharmacyCache, openMedIds, textHeights);
|
| 1700 |
+
};
|
| 1701 |
+
window.toggleMedDetails = function(el) {
|
| 1702 |
+
const body = el.nextElementSibling;
|
| 1703 |
+
const icon = el.querySelector('.detail-icon');
|
| 1704 |
+
const isExpanded = body && body.style.display === 'block';
|
| 1705 |
+
if (body) body.style.display = isExpanded ? 'none' : 'block';
|
| 1706 |
+
if (icon) icon.textContent = isExpanded ? '▸' : '▾';
|
| 1707 |
+
};
|
| 1708 |
+
window.deletePurchaseEntry = deletePurchaseEntry;
|
| 1709 |
+
window.togglePurchaseNotes = function(el) {
|
| 1710 |
+
const row = el.closest('.purchase-row');
|
| 1711 |
+
if (!row) return;
|
| 1712 |
+
const notes = row.querySelector('.ph-notes-container');
|
| 1713 |
+
if (!notes) return;
|
| 1714 |
+
const isHidden = notes.style.display === 'none';
|
| 1715 |
+
notes.style.display = isHidden ? 'block' : 'none';
|
| 1716 |
+
el.textContent = isHidden ? 'v' : '>';
|
| 1717 |
+
};
|
| 1718 |
+
window.addWhoMeds = addWhoMeds;
|
| 1719 |
+
window.openWhoListFilePicker = openWhoListFilePicker;
|
| 1720 |
+
window.handleWhoListFileImport = handleWhoListFileImport;
|
| 1721 |
+
window.preloadPharmacy = preloadPharmacy;
|
| 1722 |
+
window.ensurePharmacyLabels = ensurePharmacyLabels;
|
| 1723 |
+
window.loadWhoMedsFromServer = loadWhoMedsFromServer;
|
static/js/recovery.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================================================
|
| 2 |
+
* Author: Rick Escher
|
| 3 |
+
* Project: SailingMedAdvisor
|
| 4 |
+
* Context: Google HAI-DEF Framework
|
| 5 |
+
* Models: Google MedGemmas
|
| 6 |
+
* Program: Kaggle Impact Challenge
|
| 7 |
+
* ========================================================================== */
|
| 8 |
+
/*
|
| 9 |
+
Fallback interactivity recovery layer.
|
| 10 |
+
Keeps core UI controls usable if inline handlers fail due stale cache or script load issues.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
(function installUiRecovery() {
|
| 14 |
+
let crewDataRecoveryPromise = null;
|
| 15 |
+
|
| 16 |
+
async function ensureCrewDataFallback() {
|
| 17 |
+
if (crewDataRecoveryPromise) return crewDataRecoveryPromise;
|
| 18 |
+
crewDataRecoveryPromise = (async () => {
|
| 19 |
+
const [patientsRes, historyRes, settingsRes] = await Promise.all([
|
| 20 |
+
fetch('/api/data/patients', { credentials: 'same-origin' }),
|
| 21 |
+
fetch('/api/data/history', { credentials: 'same-origin' }),
|
| 22 |
+
fetch('/api/data/settings', { credentials: 'same-origin' }),
|
| 23 |
+
]);
|
| 24 |
+
if (!patientsRes.ok) {
|
| 25 |
+
throw new Error(`Patients request failed: ${patientsRes.status}`);
|
| 26 |
+
}
|
| 27 |
+
const patients = await patientsRes.json();
|
| 28 |
+
const history = historyRes.ok ? await historyRes.json().catch(() => []) : [];
|
| 29 |
+
const settings = settingsRes.ok ? await settingsRes.json().catch(() => ({})) : {};
|
| 30 |
+
if (typeof window.loadCrewData === 'function' && Array.isArray(patients)) {
|
| 31 |
+
window.loadCrewData(
|
| 32 |
+
patients,
|
| 33 |
+
Array.isArray(history) ? history : [],
|
| 34 |
+
settings && typeof settings === 'object' ? settings : {}
|
| 35 |
+
);
|
| 36 |
+
return true;
|
| 37 |
+
}
|
| 38 |
+
return false;
|
| 39 |
+
})().finally(() => {
|
| 40 |
+
crewDataRecoveryPromise = null;
|
| 41 |
+
});
|
| 42 |
+
return crewDataRecoveryPromise;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* applyBannerControlsFallback: function-level behavior note for maintainers.
|
| 47 |
+
* Keep this block synchronized with implementation changes.
|
| 48 |
+
*/
|
| 49 |
+
function applyBannerControlsFallback(activeTab) {
|
| 50 |
+
const triageControls = document.getElementById('banner-controls-triage');
|
| 51 |
+
const crewControls = document.getElementById('banner-controls-crew');
|
| 52 |
+
const medExportAll = document.getElementById('crew-med-export-all-btn');
|
| 53 |
+
const crewCsvBtn = document.getElementById('crew-csv-btn');
|
| 54 |
+
const immigrationZipBtn = document.getElementById('crew-immigration-zip-btn');
|
| 55 |
+
if (triageControls) triageControls.style.display = activeTab === 'Chat' ? 'flex' : 'none';
|
| 56 |
+
if (crewControls) crewControls.style.display = (activeTab === 'CrewMedical' || activeTab === 'VesselCrewInfo') ? 'flex' : 'none';
|
| 57 |
+
if (medExportAll) medExportAll.style.display = activeTab === 'CrewMedical' ? 'inline-flex' : 'none';
|
| 58 |
+
if (crewCsvBtn) crewCsvBtn.style.display = activeTab === 'VesselCrewInfo' ? 'inline-flex' : 'none';
|
| 59 |
+
if (immigrationZipBtn) immigrationZipBtn.style.display = activeTab === 'VesselCrewInfo' ? 'inline-flex' : 'none';
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* parseTabTargetFromOnclick: function-level behavior note for maintainers.
|
| 64 |
+
* Keep this block synchronized with implementation changes.
|
| 65 |
+
*/
|
| 66 |
+
function parseTabTargetFromOnclick(onclickValue) {
|
| 67 |
+
const raw = (onclickValue || '').toString();
|
| 68 |
+
const match = raw.match(/'([^']+)'/);
|
| 69 |
+
return match ? match[1] : '';
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* emergencyShowTab: function-level behavior note for maintainers.
|
| 74 |
+
* Keep this block synchronized with implementation changes.
|
| 75 |
+
*/
|
| 76 |
+
function emergencyShowTab(tabName, triggerEl) {
|
| 77 |
+
if (!tabName) return;
|
| 78 |
+
document.querySelectorAll('.content').forEach((section) => {
|
| 79 |
+
section.style.display = 'none';
|
| 80 |
+
});
|
| 81 |
+
const target = document.getElementById(tabName);
|
| 82 |
+
if (target) target.style.display = 'flex';
|
| 83 |
+
document.querySelectorAll('.tab').forEach((tab) => tab.classList.remove('active'));
|
| 84 |
+
if (triggerEl && triggerEl.classList) triggerEl.classList.add('active');
|
| 85 |
+
if (typeof window.toggleBannerControls === 'function') {
|
| 86 |
+
window.toggleBannerControls(tabName);
|
| 87 |
+
} else {
|
| 88 |
+
applyBannerControlsFallback(tabName);
|
| 89 |
+
}
|
| 90 |
+
if (tabName === 'Chat' || tabName === 'CrewMedical' || tabName === 'VesselCrewInfo') {
|
| 91 |
+
ensureCrewDataFallback().catch(() => {});
|
| 92 |
+
}
|
| 93 |
+
if (tabName === 'Chat' && typeof window.updateUI === 'function') {
|
| 94 |
+
window.updateUI();
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* bindTabRecovery: function-level behavior note for maintainers.
|
| 100 |
+
* Keep this block synchronized with implementation changes.
|
| 101 |
+
*/
|
| 102 |
+
function bindTabRecovery() {
|
| 103 |
+
document.querySelectorAll('.nav .tab').forEach((btn) => {
|
| 104 |
+
if (btn.dataset.recoveryBound === '1') return;
|
| 105 |
+
btn.dataset.recoveryBound = '1';
|
| 106 |
+
btn.addEventListener('click', (ev) => {
|
| 107 |
+
const tabName = btn.dataset.tabTarget || parseTabTargetFromOnclick(btn.getAttribute('onclick'));
|
| 108 |
+
if (!tabName) return;
|
| 109 |
+
ev.preventDefault();
|
| 110 |
+
ev.stopImmediatePropagation();
|
| 111 |
+
if (typeof window.showTab === 'function') {
|
| 112 |
+
try {
|
| 113 |
+
const maybePromise = window.showTab(btn, tabName);
|
| 114 |
+
if (maybePromise && typeof maybePromise.then === 'function') {
|
| 115 |
+
maybePromise.catch(() => {
|
| 116 |
+
emergencyShowTab(tabName, btn);
|
| 117 |
+
});
|
| 118 |
+
}
|
| 119 |
+
} catch (err) {
|
| 120 |
+
emergencyShowTab(tabName, btn);
|
| 121 |
+
}
|
| 122 |
+
} else {
|
| 123 |
+
emergencyShowTab(tabName, btn);
|
| 124 |
+
}
|
| 125 |
+
}, true);
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* bindSidebarRecovery: function-level behavior note for maintainers.
|
| 131 |
+
* Keep this block synchronized with implementation changes.
|
| 132 |
+
*/
|
| 133 |
+
function bindSidebarRecovery() {
|
| 134 |
+
document.querySelectorAll('.page-sidebar').forEach((sidebar) => {
|
| 135 |
+
if (sidebar.dataset.recoveryBound === '1') return;
|
| 136 |
+
sidebar.dataset.recoveryBound = '1';
|
| 137 |
+
sidebar.addEventListener('click', (ev) => {
|
| 138 |
+
const toggleBtn = sidebar.querySelector('.sidebar-toggle');
|
| 139 |
+
if (toggleBtn && !toggleBtn.contains(ev.target)) return;
|
| 140 |
+
ev.preventDefault();
|
| 141 |
+
ev.stopImmediatePropagation();
|
| 142 |
+
if (typeof window.toggleSidebar === 'function') {
|
| 143 |
+
window.toggleSidebar(sidebar);
|
| 144 |
+
return;
|
| 145 |
+
}
|
| 146 |
+
const nextCollapsed = !sidebar.classList.contains('collapsed');
|
| 147 |
+
document.querySelectorAll('.page-sidebar').forEach((node) => {
|
| 148 |
+
node.classList.toggle('collapsed', nextCollapsed);
|
| 149 |
+
});
|
| 150 |
+
document.querySelectorAll('.page-body').forEach((body) => {
|
| 151 |
+
body.classList.toggle('sidebar-collapsed', nextCollapsed);
|
| 152 |
+
body.classList.toggle('sidebar-open', !nextCollapsed);
|
| 153 |
+
});
|
| 154 |
+
}, true);
|
| 155 |
+
});
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* buildCrewLabel: function-level behavior note for maintainers.
|
| 160 |
+
* Keep this block synchronized with implementation changes.
|
| 161 |
+
*/
|
| 162 |
+
function buildCrewLabel(crew) {
|
| 163 |
+
const first = (crew && crew.firstName) ? String(crew.firstName).trim() : '';
|
| 164 |
+
const last = (crew && crew.lastName) ? String(crew.lastName).trim() : '';
|
| 165 |
+
const full = `${first} ${last}`.trim();
|
| 166 |
+
if (full) return full;
|
| 167 |
+
if (crew && crew.name) return String(crew.name).trim();
|
| 168 |
+
return 'Unnamed Crew';
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
async function ensureCrewDropdownFallback() {
|
| 172 |
+
const select = document.getElementById('p-select');
|
| 173 |
+
if (!select) return;
|
| 174 |
+
const hasRealOptions = Array.from(select.options || []).some((opt) => (opt.value || '').toString().trim());
|
| 175 |
+
if (hasRealOptions) return;
|
| 176 |
+
try {
|
| 177 |
+
const res = await fetch('/api/data/patients', { credentials: 'same-origin' });
|
| 178 |
+
if (!res.ok) return;
|
| 179 |
+
const patients = await res.json();
|
| 180 |
+
if (!Array.isArray(patients)) return;
|
| 181 |
+
const current = select.value || '';
|
| 182 |
+
select.innerHTML = '<option value="">Unnamed Crew Member</option>';
|
| 183 |
+
patients.forEach((crew) => {
|
| 184 |
+
const opt = document.createElement('option');
|
| 185 |
+
opt.value = String((crew && crew.id) || '');
|
| 186 |
+
opt.textContent = buildCrewLabel(crew);
|
| 187 |
+
select.appendChild(opt);
|
| 188 |
+
});
|
| 189 |
+
if (current) select.value = current;
|
| 190 |
+
} catch (err) {
|
| 191 |
+
// Best-effort fallback only.
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 196 |
+
bindTabRecovery();
|
| 197 |
+
bindSidebarRecovery();
|
| 198 |
+
ensureCrewDataFallback().catch(() => {});
|
| 199 |
+
ensureCrewDropdownFallback();
|
| 200 |
+
if (typeof window.toggleBannerControls === 'function') {
|
| 201 |
+
window.toggleBannerControls('Chat');
|
| 202 |
+
} else {
|
| 203 |
+
applyBannerControlsFallback('Chat');
|
| 204 |
+
}
|
| 205 |
+
});
|
| 206 |
+
})();
|
static/js/settings.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/js/utils.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* =============================================================================
|
| 2 |
+
* Author: Rick Escher
|
| 3 |
+
* Project: SailingMedAdvisor
|
| 4 |
+
* Context: Google HAI-DEF Framework
|
| 5 |
+
* Models: Google MedGemmas
|
| 6 |
+
* Program: Kaggle Impact Challenge
|
| 7 |
+
* ========================================================================== */
|
| 8 |
+
/*
|
| 9 |
+
File: static/js/utils.js
|
| 10 |
+
Author notes: Shared utility functions for the SailingMedAdvisor frontend.
|
| 11 |
+
|
| 12 |
+
Key Responsibilities:
|
| 13 |
+
- HTML escaping for XSS prevention
|
| 14 |
+
- Debounce utility for input handlers
|
| 15 |
+
- Workspace header injection (multi-tenant placeholder)
|
| 16 |
+
- Centralized fetch wrapper with error handling
|
| 17 |
+
- Data category fetch helper
|
| 18 |
+
|
| 19 |
+
Architecture:
|
| 20 |
+
------------
|
| 21 |
+
Implemented as IIFE (Immediately Invoked Function Expression) that:
|
| 22 |
+
1. Creates or extends window.Utils namespace
|
| 23 |
+
2. Registers utility functions
|
| 24 |
+
3. Exposes both namespaced (Utils.escapeHtml) and global (escapeHtml) versions
|
| 25 |
+
|
| 26 |
+
Usage Pattern:
|
| 27 |
+
```javascript
|
| 28 |
+
// Preferred (namespaced):
|
| 29 |
+
const safe = Utils.escapeHtml(userInput);
|
| 30 |
+
|
| 31 |
+
// Legacy (global):
|
| 32 |
+
const safe = escapeHtml(userInput);
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
Integration:
|
| 36 |
+
All modules import these utilities with fallbacks:
|
| 37 |
+
```javascript
|
| 38 |
+
const escapeHtml = (window.Utils && window.Utils.escapeHtml)
|
| 39 |
+
? window.Utils.escapeHtml
|
| 40 |
+
: (str) => str;
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
This allows modules to work even if utils.js fails to load.
|
| 44 |
+
*/
|
| 45 |
+
|
| 46 |
+
(function registerUtils() {
|
| 47 |
+
const Utils = window.Utils || {};
|
| 48 |
+
const CHAT_SECTION_LABELS = new Set(['avoid', 'monitor', 'evacuation', 'questions']);
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* normalizeChatSectionMarkdown: function-level behavior note for maintainers.
|
| 52 |
+
* Keep this block synchronized with implementation changes.
|
| 53 |
+
*/
|
| 54 |
+
function normalizeChatSectionMarkdown(raw) {
|
| 55 |
+
return (raw || '')
|
| 56 |
+
.toString()
|
| 57 |
+
.replace(/\r\n?/g, '\n')
|
| 58 |
+
.replace(
|
| 59 |
+
/(^|\n)\s*(?:\*\*)?\s*(Avoid|Monitor|Evacuation|Questions)\s*(?:\*\*)?\s*[–-]\s+/gim,
|
| 60 |
+
(_match, lead, label) => `${lead}**${label.charAt(0).toUpperCase()}${label.slice(1).toLowerCase()}:** `
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* normalizeChecklistMarkdown: function-level behavior note for maintainers.
|
| 66 |
+
* Keep this block synchronized with implementation changes.
|
| 67 |
+
*/
|
| 68 |
+
function normalizeChecklistMarkdown(raw) {
|
| 69 |
+
const lines = (raw || '').toString().replace(/\r\n?/g, '\n').split('\n');
|
| 70 |
+
const normalized = lines.map((line) => {
|
| 71 |
+
const heading = line.match(/^\s*\[\s*[xX ]\s*\]\s*\*\*(.+?)\*\*\s*:?\s*$/);
|
| 72 |
+
if (heading) {
|
| 73 |
+
return `**${heading[1].trim()}:**`;
|
| 74 |
+
}
|
| 75 |
+
const item = line.match(/^\s*\[\s*[xX ]\s*\]\s+(.+)$/);
|
| 76 |
+
if (item) {
|
| 77 |
+
return `- ${item[1].trim()}`;
|
| 78 |
+
}
|
| 79 |
+
return line;
|
| 80 |
+
});
|
| 81 |
+
return normalized.join('\n');
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* normalizeSectionLabelText: function-level behavior note for maintainers.
|
| 86 |
+
* Keep this block synchronized with implementation changes.
|
| 87 |
+
*/
|
| 88 |
+
function normalizeSectionLabelText(text) {
|
| 89 |
+
return (text || '')
|
| 90 |
+
.toString()
|
| 91 |
+
.replace(/\*/g, '')
|
| 92 |
+
.replace(/[::]\s*$/, '')
|
| 93 |
+
.trim()
|
| 94 |
+
.toLowerCase();
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/**
|
| 98 |
+
* annotateChatSections: function-level behavior note for maintainers.
|
| 99 |
+
* Keep this block synchronized with implementation changes.
|
| 100 |
+
*/
|
| 101 |
+
function annotateChatSections(root) {
|
| 102 |
+
if (!root || typeof root.querySelectorAll !== 'function') return;
|
| 103 |
+
|
| 104 |
+
root.querySelectorAll('strong').forEach((el) => {
|
| 105 |
+
if (CHAT_SECTION_LABELS.has(normalizeSectionLabelText(el.textContent))) {
|
| 106 |
+
el.classList.add('chat-section-label');
|
| 107 |
+
}
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
root.querySelectorAll('p').forEach((p) => {
|
| 111 |
+
const text = (p.textContent || '').trim();
|
| 112 |
+
const dashMatch = text.match(/^(Avoid|Monitor|Evacuation|Questions)\s*[–-]\s+(.+)$/i);
|
| 113 |
+
if (dashMatch) {
|
| 114 |
+
p.classList.add('chat-section-heading');
|
| 115 |
+
p.innerHTML = `<strong class="chat-section-label">${dashMatch[1]}:</strong> ${Utils.escapeHtml(dashMatch[2])}`;
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
const strong = p.querySelector('strong');
|
| 119 |
+
if (strong && CHAT_SECTION_LABELS.has(normalizeSectionLabelText(strong.textContent))) {
|
| 120 |
+
p.classList.add('chat-section-heading');
|
| 121 |
+
}
|
| 122 |
+
});
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/**
|
| 126 |
+
* extractJsonCandidateText: function-level behavior note for maintainers.
|
| 127 |
+
* Keep this block synchronized with implementation changes.
|
| 128 |
+
*/
|
| 129 |
+
function extractJsonCandidateText(raw) {
|
| 130 |
+
const trimmed = (raw || '').toString().trim();
|
| 131 |
+
if (!trimmed) return '';
|
| 132 |
+
const fenceMatch = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
| 133 |
+
return (fenceMatch ? fenceMatch[1] : trimmed).trim();
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* tryParseJsonPayload: function-level behavior note for maintainers.
|
| 138 |
+
* Keep this block synchronized with implementation changes.
|
| 139 |
+
*/
|
| 140 |
+
function tryParseJsonPayload(raw) {
|
| 141 |
+
let candidate = extractJsonCandidateText(raw);
|
| 142 |
+
if (!candidate || (!candidate.startsWith('{') && !candidate.startsWith('['))) {
|
| 143 |
+
return null;
|
| 144 |
+
}
|
| 145 |
+
for (let i = 0; i < 2; i += 1) {
|
| 146 |
+
try {
|
| 147 |
+
const parsed = JSON.parse(candidate);
|
| 148 |
+
if (typeof parsed === 'string') {
|
| 149 |
+
candidate = parsed.trim();
|
| 150 |
+
if (!candidate.startsWith('{') && !candidate.startsWith('[')) {
|
| 151 |
+
return null;
|
| 152 |
+
}
|
| 153 |
+
continue;
|
| 154 |
+
}
|
| 155 |
+
return parsed;
|
| 156 |
+
} catch (_err) {
|
| 157 |
+
return null;
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
return null;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* formatJsonInline: function-level behavior note for maintainers.
|
| 165 |
+
* Keep this block synchronized with implementation changes.
|
| 166 |
+
*/
|
| 167 |
+
function formatJsonInline(value) {
|
| 168 |
+
if (value === null) return 'null';
|
| 169 |
+
if (typeof value === 'string') return value.trim();
|
| 170 |
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
| 171 |
+
if (Array.isArray(value)) {
|
| 172 |
+
const primitive = value.every((item) => item === null || ['string', 'number', 'boolean'].includes(typeof item));
|
| 173 |
+
if (primitive) {
|
| 174 |
+
return value.map((item) => formatJsonInline(item)).join('; ');
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
return JSON.stringify(value);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/**
|
| 181 |
+
* formatJsonSection: function-level behavior note for maintainers.
|
| 182 |
+
* Keep this block synchronized with implementation changes.
|
| 183 |
+
*/
|
| 184 |
+
function formatJsonSection(title, value) {
|
| 185 |
+
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
|
| 186 |
+
return `**${title}:** ${formatJsonInline(value)}`;
|
| 187 |
+
}
|
| 188 |
+
if (Array.isArray(value)) {
|
| 189 |
+
if (!value.length) return `**${title}:** (none)`;
|
| 190 |
+
const bullets = value.map((item) => `- ${formatJsonInline(item)}`).join('\n');
|
| 191 |
+
return `**${title}:**\n${bullets}`;
|
| 192 |
+
}
|
| 193 |
+
if (value && typeof value === 'object') {
|
| 194 |
+
const entries = Object.entries(value);
|
| 195 |
+
if (!entries.length) return `**${title}:** (none)`;
|
| 196 |
+
const bullets = entries
|
| 197 |
+
.map(([k, v]) => `- ${k}: ${formatJsonInline(v)}`)
|
| 198 |
+
.join('\n');
|
| 199 |
+
return `**${title}:**\n${bullets}`;
|
| 200 |
+
}
|
| 201 |
+
return `**${title}:** ${formatJsonInline(value)}`;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/**
|
| 205 |
+
* jsonToMarkdown: function-level behavior note for maintainers.
|
| 206 |
+
* Keep this block synchronized with implementation changes.
|
| 207 |
+
*/
|
| 208 |
+
function jsonToMarkdown(value) {
|
| 209 |
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
| 210 |
+
const keyMap = {};
|
| 211 |
+
Object.keys(value).forEach((key) => {
|
| 212 |
+
keyMap[key.toLowerCase()] = key;
|
| 213 |
+
});
|
| 214 |
+
const preferred = ['Avoid', 'Monitor', 'Evacuation', 'Questions'];
|
| 215 |
+
const used = new Set();
|
| 216 |
+
const blocks = [];
|
| 217 |
+
|
| 218 |
+
preferred.forEach((label) => {
|
| 219 |
+
const realKey = keyMap[label.toLowerCase()];
|
| 220 |
+
if (!realKey) return;
|
| 221 |
+
used.add(realKey);
|
| 222 |
+
blocks.push(formatJsonSection(label, value[realKey]));
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
Object.keys(value).forEach((key) => {
|
| 226 |
+
if (used.has(key)) return;
|
| 227 |
+
blocks.push(formatJsonSection(key, value[key]));
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
if (blocks.length) {
|
| 231 |
+
return blocks.join('\n\n').trim();
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/**
|
| 238 |
+
* Escape HTML special characters to prevent XSS attacks.
|
| 239 |
+
*
|
| 240 |
+
* Character Mappings:
|
| 241 |
+
* - & → &
|
| 242 |
+
* - < → <
|
| 243 |
+
* - > → >
|
| 244 |
+
* - " → "
|
| 245 |
+
* - ' → '
|
| 246 |
+
*
|
| 247 |
+
* Use Cases:
|
| 248 |
+
* - Displaying user-entered text in HTML
|
| 249 |
+
* - Rendering crew names, medication names
|
| 250 |
+
* - Chat responses (before markdown parsing)
|
| 251 |
+
* - Search results
|
| 252 |
+
* - Any untrusted content inserted into DOM
|
| 253 |
+
*
|
| 254 |
+
* Security:
|
| 255 |
+
* Essential for preventing XSS when rendering user data. Always
|
| 256 |
+
* escape before inserting into innerHTML or creating HTML strings.
|
| 257 |
+
*
|
| 258 |
+
* Example:
|
| 259 |
+
* ```javascript
|
| 260 |
+
* const name = "<script>alert('xss')</script>";
|
| 261 |
+
* el.innerHTML = Utils.escapeHtml(name);
|
| 262 |
+
* // Result: <script>alert('xss')</script>
|
| 263 |
+
* ```
|
| 264 |
+
*
|
| 265 |
+
* @param {string} str - String to escape
|
| 266 |
+
* @returns {string} Escaped string safe for HTML insertion
|
| 267 |
+
*/
|
| 268 |
+
Utils.escapeHtml = function escapeHtml(str) {
|
| 269 |
+
return (str || '')
|
| 270 |
+
.replace(/&/g, '&')
|
| 271 |
+
.replace(/</g, '<')
|
| 272 |
+
.replace(/>/g, '>')
|
| 273 |
+
.replace(/"/g, '"')
|
| 274 |
+
.replace(/'/g, ''');
|
| 275 |
+
};
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* Debounce function calls to reduce execution frequency.
|
| 279 |
+
*
|
| 280 |
+
* Debouncing delays function execution until after a specified time
|
| 281 |
+
* has elapsed since the last call. Useful for expensive operations
|
| 282 |
+
* triggered by rapid user input.
|
| 283 |
+
*
|
| 284 |
+
* Use Cases:
|
| 285 |
+
* - Search input: Wait for user to stop typing
|
| 286 |
+
* - Auto-save: Batch saves instead of save-per-keystroke
|
| 287 |
+
* - Window resize handlers: Prevent layout thrashing
|
| 288 |
+
* - Scroll handlers: Reduce computation during scrolling
|
| 289 |
+
*
|
| 290 |
+
* Behavior:
|
| 291 |
+
* 1. First call: Starts timer
|
| 292 |
+
* 2. Subsequent calls: Reset timer
|
| 293 |
+
* 3. After delay expires: Execute function once with latest arguments
|
| 294 |
+
*
|
| 295 |
+
* Example:
|
| 296 |
+
* ```javascript
|
| 297 |
+
* const saveSettings = Utils.debounce(() => {
|
| 298 |
+
* fetch('/api/settings', {method: 'POST', ...});
|
| 299 |
+
* }, 800);
|
| 300 |
+
*
|
| 301 |
+
* // User types rapidly:
|
| 302 |
+
* saveSettings(); // Timer starts
|
| 303 |
+
* saveSettings(); // Timer resets
|
| 304 |
+
* saveSettings(); // Timer resets
|
| 305 |
+
* // 800ms later: One save executed
|
| 306 |
+
* ```
|
| 307 |
+
*
|
| 308 |
+
* Common Delays:
|
| 309 |
+
* - 250ms: Input validation, search
|
| 310 |
+
* - 400ms: Crew credentials, simple saves
|
| 311 |
+
* - 600ms: Equipment auto-save
|
| 312 |
+
* - 800ms: Settings auto-save
|
| 313 |
+
*
|
| 314 |
+
* @param {Function} fn - Function to debounce
|
| 315 |
+
* @param {number} delay - Delay in milliseconds (default: 250)
|
| 316 |
+
* @returns {Function} Debounced function
|
| 317 |
+
*/
|
| 318 |
+
Utils.debounce = function debounce(fn, delay = 250) {
|
| 319 |
+
let timer = null;
|
| 320 |
+
return (...args) => {
|
| 321 |
+
if (timer) clearTimeout(timer);
|
| 322 |
+
timer = setTimeout(() => fn(...args), delay);
|
| 323 |
+
};
|
| 324 |
+
};
|
| 325 |
+
|
| 326 |
+
/**
|
| 327 |
+
* Get current workspace label for display.
|
| 328 |
+
*
|
| 329 |
+
* Multi-Tenant Placeholder:
|
| 330 |
+
* Currently returns empty string as multi-tenancy not yet implemented.
|
| 331 |
+
* Future versions will return workspace name (vessel name, user account).
|
| 332 |
+
*
|
| 333 |
+
* Planned Use Cases:
|
| 334 |
+
* - Display current vessel name in header
|
| 335 |
+
* - Show workspace in exported files
|
| 336 |
+
* - Include in error reports
|
| 337 |
+
*
|
| 338 |
+
* @returns {string} Workspace label (currently empty)
|
| 339 |
+
* @deprecated Not yet implemented
|
| 340 |
+
*/
|
| 341 |
+
Utils.getWorkspaceLabel = function getWorkspaceLabel() {
|
| 342 |
+
return '';
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
/**
|
| 346 |
+
* Generate HTTP headers with workspace context.
|
| 347 |
+
*
|
| 348 |
+
* Multi-Tenant Placeholder:
|
| 349 |
+
* Currently returns headers unchanged. Future versions will inject
|
| 350 |
+
* workspace identifier headers for server-side routing.
|
| 351 |
+
*
|
| 352 |
+
* Planned Headers:
|
| 353 |
+
* - X-Workspace-Id: Unique workspace identifier
|
| 354 |
+
* - X-Vessel-Name: Vessel name for logging
|
| 355 |
+
*
|
| 356 |
+
* Current Usage:
|
| 357 |
+
* ```javascript
|
| 358 |
+
* fetch('/api/data/patients', {
|
| 359 |
+
* headers: workspaceHeaders({'Content-Type': 'application/json'})
|
| 360 |
+
* })
|
| 361 |
+
* ```
|
| 362 |
+
*
|
| 363 |
+
* Used Throughout:
|
| 364 |
+
* - equipment.js: Equipment saves
|
| 365 |
+
* - crew.js: Crew data operations
|
| 366 |
+
* - pharmacy.js: Medication operations
|
| 367 |
+
*
|
| 368 |
+
* @param {Object} extra - Additional headers to include
|
| 369 |
+
* @returns {Object} Headers object with workspace context (currently just extra)
|
| 370 |
+
*/
|
| 371 |
+
Utils.workspaceHeaders = function workspaceHeaders(extra = {}) {
|
| 372 |
+
return { ...extra };
|
| 373 |
+
};
|
| 374 |
+
|
| 375 |
+
/**
|
| 376 |
+
* Fetch JSON with enhanced error handling and parsing.
|
| 377 |
+
*
|
| 378 |
+
* Enhanced Fetch Features:
|
| 379 |
+
* 1. Auto-includes credentials for cookie-based auth
|
| 380 |
+
* 2. Handles empty responses gracefully (returns {})
|
| 381 |
+
* 3. Attempts JSON parse even on error responses
|
| 382 |
+
* 4. Throws detailed errors with response text
|
| 383 |
+
* 5. Consistent error format across all API calls
|
| 384 |
+
*
|
| 385 |
+
* Error Handling:
|
| 386 |
+
* - HTTP error: Throws with status code
|
| 387 |
+
* - JSON parse failure: Returns {} instead of throwing
|
| 388 |
+
* - API error field: Extracts and throws error message
|
| 389 |
+
*
|
| 390 |
+
* Example Success:
|
| 391 |
+
* ```javascript
|
| 392 |
+
* const crew = await fetchJson('/api/data/patients');
|
| 393 |
+
* // Returns: [{id: 'crew-1', name: 'John'}, ...]
|
| 394 |
+
* ```
|
| 395 |
+
*
|
| 396 |
+
* Example Error:
|
| 397 |
+
* ```javascript
|
| 398 |
+
* try {
|
| 399 |
+
* await fetchJson('/api/data/invalid');
|
| 400 |
+
* } catch (err) {
|
| 401 |
+
* // err.message: "Not found" or "Status 404"
|
| 402 |
+
* }
|
| 403 |
+
* ```
|
| 404 |
+
*
|
| 405 |
+
* Advantages Over Raw Fetch:
|
| 406 |
+
* - Consistent error messages
|
| 407 |
+
* - No need to check res.ok manually
|
| 408 |
+
* - Handles malformed JSON gracefully
|
| 409 |
+
* - Credentials included by default
|
| 410 |
+
*
|
| 411 |
+
* Used Throughout:
|
| 412 |
+
* All modules use this for API calls instead of raw fetch.
|
| 413 |
+
*
|
| 414 |
+
* @param {string} url - API endpoint URL
|
| 415 |
+
* @param {Object} options - Fetch options (method, headers, body, etc.)
|
| 416 |
+
* @returns {Promise<Object>} Parsed JSON response
|
| 417 |
+
* @throws {Error} On HTTP error or API error response
|
| 418 |
+
*/
|
| 419 |
+
Utils.fetchJson = async function fetchJson(url, options = {}) {
|
| 420 |
+
const res = await fetch(url, { credentials: 'same-origin', ...options });
|
| 421 |
+
const text = await res.text();
|
| 422 |
+
let data = {};
|
| 423 |
+
try {
|
| 424 |
+
data = text ? JSON.parse(text) : {};
|
| 425 |
+
} catch (err) {
|
| 426 |
+
data = {};
|
| 427 |
+
}
|
| 428 |
+
if (!res.ok || data?.error) {
|
| 429 |
+
const detail = data?.error || text || `Status ${res.status}`;
|
| 430 |
+
throw new Error(detail);
|
| 431 |
+
}
|
| 432 |
+
return data;
|
| 433 |
+
};
|
| 434 |
+
|
| 435 |
+
/**
|
| 436 |
+
* Fetch data by category shorthand.
|
| 437 |
+
*
|
| 438 |
+
* Convenience wrapper for common /api/data/* endpoints.
|
| 439 |
+
*
|
| 440 |
+
* Categories:
|
| 441 |
+
* - 'patients': /api/data/patients (crew list)
|
| 442 |
+
* - 'history': /api/data/history (chat logs)
|
| 443 |
+
* - 'settings': /api/data/settings (app config)
|
| 444 |
+
* - 'inventory': /api/data/inventory (medications)
|
| 445 |
+
* - 'tools': /api/data/tools (equipment/consumables)
|
| 446 |
+
* - 'vessel': /api/data/vessel (vessel info)
|
| 447 |
+
*
|
| 448 |
+
* Example:
|
| 449 |
+
* ```javascript
|
| 450 |
+
* const crew = await Utils.fetchDataCategory('patients');
|
| 451 |
+
* // Equivalent to: Utils.fetchJson('/api/data/patients')
|
| 452 |
+
* ```
|
| 453 |
+
*
|
| 454 |
+
* Benefits:
|
| 455 |
+
* - Shorter, more readable code
|
| 456 |
+
* - Consistent URL construction
|
| 457 |
+
* - Type-safe category names (if using TypeScript)
|
| 458 |
+
*
|
| 459 |
+
* @param {string} category - Data category name
|
| 460 |
+
* @param {Object} options - Fetch options
|
| 461 |
+
* @returns {Promise<Object>} Parsed JSON response
|
| 462 |
+
*/
|
| 463 |
+
Utils.fetchDataCategory = async function fetchDataCategory(category, options = {}) {
|
| 464 |
+
return Utils.fetchJson(`/api/data/${category}`, options);
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
Utils.renderAssistantMarkdown = function renderAssistantMarkdown(raw) {
|
| 468 |
+
const parsedJson = tryParseJsonPayload(raw);
|
| 469 |
+
const source = parsedJson === null ? raw : jsonToMarkdown(parsedJson);
|
| 470 |
+
const normalized = normalizeChatSectionMarkdown(normalizeChecklistMarkdown(source));
|
| 471 |
+
if (!window.marked || typeof window.marked.parse !== 'function') {
|
| 472 |
+
return Utils.escapeHtml(normalized).replace(/\n/g, '<br>');
|
| 473 |
+
}
|
| 474 |
+
const html = window.marked.parse(normalized, { gfm: true, breaks: true });
|
| 475 |
+
const wrapper = document.createElement('div');
|
| 476 |
+
wrapper.innerHTML = html;
|
| 477 |
+
annotateChatSections(wrapper);
|
| 478 |
+
return wrapper.innerHTML;
|
| 479 |
+
};
|
| 480 |
+
|
| 481 |
+
// Register Utils namespace globally
|
| 482 |
+
window.Utils = Utils;
|
| 483 |
+
|
| 484 |
+
/**
|
| 485 |
+
* Legacy Global Exposure:
|
| 486 |
+
* Expose escapeHtml directly to window for backward compatibility
|
| 487 |
+
* with scripts that expect it in global scope.
|
| 488 |
+
*
|
| 489 |
+
* Deprecated Pattern:
|
| 490 |
+
* Old: window.escapeHtml(str)
|
| 491 |
+
* New: Utils.escapeHtml(str)
|
| 492 |
+
*
|
| 493 |
+
* Both work, but namespaced version preferred.
|
| 494 |
+
*/
|
| 495 |
+
window.escapeHtml = Utils.escapeHtml;
|
| 496 |
+
window.renderAssistantMarkdown = Utils.renderAssistantMarkdown;
|
| 497 |
+
})();
|
static/style.css
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================================
|
| 2 |
+
Author: Rick Escher
|
| 3 |
+
Project: SailingMedAdvisor
|
| 4 |
+
Context: Google HAI-DEF Framework
|
| 5 |
+
Models: Google MedGemmas
|
| 6 |
+
Program: Kaggle Impact Challenge
|
| 7 |
+
========================================================================== */
|
| 8 |
+
|
| 9 |
+
/* GENERAL STYLING */
|
| 10 |
+
body { font-family: sans-serif; background: #eceff1; margin: 0; padding: 10px; }
|
| 11 |
+
.tab-container { display: flex; background: #263238; border-radius: 5px; padding: 5px; margin-bottom: 10px; }
|
| 12 |
+
.tab-link { flex: 1; background: none; border: none; color: white; padding: 15px; cursor: pointer; }
|
| 13 |
+
.tab-link.active { background: #37474f; border-bottom: 3px solid #00e5ff; font-weight: bold; }
|
| 14 |
+
.tab-content { background: white; padding: 20px; border-radius: 5px; display: none; min-height: 600px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
|
| 15 |
+
|
| 16 |
+
/* CHAT INTERFACE */
|
| 17 |
+
.scroll-area { background: #fdfdfd; border: 1px solid #ddd; border-radius: 5px; overflow-y: auto; display: flex; flex-direction: column; padding: 10px; }
|
| 18 |
+
.user-bubble { background: #e3f2fd; align-self: flex-end; padding: 10px; border-radius: 10px; margin: 5px; border: 1px solid #bbdefb; max-width: 80%; }
|
| 19 |
+
.ai-bubble { background: #f5f5f5; align-self: flex-start; padding: 10px; border-radius: 10px; margin: 5px; border: 1px solid #e0e0e0; max-width: 80%; }
|
| 20 |
+
.emergency-btn { background: #d32f2f; color: white; border: none; padding: 15px 30px; border-radius: 5px; cursor: pointer; font-weight: bold; }
|
| 21 |
+
.emergency-btn:disabled { background: #9e9e9e; }
|
| 22 |
+
|
| 23 |
+
/* INVENTORY & CREW */
|
| 24 |
+
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
| 25 |
+
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
|
| 26 |
+
th { background: #f1f1f1; }
|
| 27 |
+
.form-group { display: flex; flex-direction: column; gap: 10px; max-width: 500px; margin-bottom: 20px; }
|
| 28 |
+
textarea { height: 100px; padding: 10px; }
|
| 29 |
+
input[type="text"] { padding: 10px; }
|
| 30 |
+
.card { border-left: 5px solid #00e5ff; background: #f9f9f9; padding: 10px; margin: 10px 0; }
|
templates/index.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|