Rick commited on
Commit
4c80166
·
0 Parent(s):

HF deploy snapshot (no app.db)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +56 -0
  3. Dockerfile +36 -0
  4. LICENSE +396 -0
  5. README.md +153 -0
  6. REFACTORING.md +161 -0
  7. SPEC_Add_Crew_Functionality.md +327 -0
  8. SPEC_Vessel_Crew_Add_Functionality.md +429 -0
  9. app.py +0 -0
  10. check_gpu.py +77 -0
  11. data/default/chats.json +1 -0
  12. data/default/context.json +1 -0
  13. data/default/history.json +1 -0
  14. data/default/inventory.json +1 -0
  15. data/default/med_photo_jobs.json +1 -0
  16. data/default/med_photo_queue.json +1 -0
  17. data/default/patients.json +1 -0
  18. data/default/settings.json +1 -0
  19. data/default/tools.json +1 -0
  20. data/default/vessel.json +1 -0
  21. db_store.py +0 -0
  22. debug_inference.py +47 -0
  23. docs/FRESH_INSTALL.md +111 -0
  24. medgemma15_test.py +369 -0
  25. medgemma27b.py +261 -0
  26. medgemma4.py +144 -0
  27. medgemma_common.py +210 -0
  28. medgemma_writeup.md +347 -0
  29. requirements.txt +13 -0
  30. run_med_advisor.sh +118 -0
  31. scripts/bootstrap_ubuntu24_sailingmedadvisor.sh +199 -0
  32. scripts/copy_pharma_lorraine_to_rick.sh +65 -0
  33. scripts/copy_pharma_lorraine_to_rick_pure.sh +73 -0
  34. scripts/import_clean_triage_tree.py +345 -0
  35. scripts/install_fresh_copy.sh +139 -0
  36. scripts/verify_fresh_install.py +310 -0
  37. seed/triage_prompt_tree.default.json +0 -0
  38. ships_medicine_chest_medicines_filled.xlsx +0 -0
  39. static/data/triage_samples.json +602 -0
  40. static/favicon.svg +5 -0
  41. static/js/chat.js +0 -0
  42. static/js/crew.js +0 -0
  43. static/js/equipment.js +1315 -0
  44. static/js/main.js +1243 -0
  45. static/js/pharmacy.js +1723 -0
  46. static/js/recovery.js +206 -0
  47. static/js/settings.js +0 -0
  48. static/js/utils.js +497 -0
  49. static/style.css +30 -0
  50. 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
+ * - & → &amp;
242
+ * - < → &lt;
243
+ * - > → &gt;
244
+ * - " → &quot;
245
+ * - ' → &#39;
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: &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
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, '&amp;')
271
+ .replace(/</g, '&lt;')
272
+ .replace(/>/g, '&gt;')
273
+ .replace(/"/g, '&quot;')
274
+ .replace(/'/g, '&#39;');
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