Rick commited on
Commit
8ed4e01
·
1 Parent(s): e45be98

Add triage samples and update medical resources UI

Browse files
app.py CHANGED
@@ -324,6 +324,7 @@ def get_defaults():
324
  "generic_name, brand_name, form, strength, expiry_date, batch_lot, "
325
  "storage_location, manufacturer, indication, allergy_warnings, dosage, notes."
326
  ),
 
327
  }
328
 
329
 
@@ -347,6 +348,14 @@ def db_op(cat, data=None, workspace=None):
347
  "homePort": "",
348
  "callSign": "",
349
  "tonnage": "",
 
 
 
 
 
 
 
 
350
  "crewCapacity": "",
351
  }
352
  else:
@@ -369,7 +378,12 @@ def db_op(cat, data=None, workspace=None):
369
  path.write_text(json.dumps(data, indent=4))
370
  return data
371
 
372
- return json.loads(path.read_text() or "[]")
 
 
 
 
 
373
 
374
 
375
  def safe_float(val, default):
@@ -393,6 +407,40 @@ def _is_resource_excluded(item):
393
  return bool(val)
394
 
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  def build_prompt(settings, mode, msg, p_name, workspace):
397
  rep_penalty = safe_float(settings.get("rep_penalty", 1.1) or 1.1, 1.1)
398
  mission_context = settings.get("mission_context", "")
@@ -421,19 +469,19 @@ def build_prompt(settings, mode, msg, p_name, workspace):
421
  continue
422
  if not item_name:
423
  continue
424
- cat = (m.get("type") or "").strip().lower()
425
  key = (item_name or "").strip().lower()
426
  if not key:
427
  continue
428
- if cat == "medication":
429
  pharma_items[key] = item_name
430
  elif cat == "consumable":
431
  consumable_items[key] = item_name
432
  elif cat == "equipment":
433
  equip_items[key] = item_name
434
  else:
435
- # If no recognizable category, skip to avoid polluting lists
436
- continue
437
  pharma_list = [pharma_items[k] for k in sorted(pharma_items)]
438
  equip_list = [equip_items[k] for k in sorted(equip_items)]
439
  consumable_list = [consumable_items[k] for k in sorted(consumable_items)]
@@ -447,21 +495,7 @@ def build_prompt(settings, mode, msg, p_name, workspace):
447
  if tool_name:
448
  tool_items.append(tool_name)
449
  tool_items.sort(key=lambda s: (s or "").lower())
450
- tools = ", ".join(tool_items)
451
-
452
- def _patient_display_name(record, fallback):
453
- if not record:
454
- return fallback
455
- name = record.get("name") or record.get("fullName") or ""
456
- if name and name.strip():
457
- return name
458
- parts = [
459
- record.get("firstName") or "",
460
- record.get("middleName") or "",
461
- record.get("lastName") or "",
462
- ]
463
- combined = " ".join(part for part in parts if part).strip()
464
- return combined or fallback
465
 
466
  patient_record = next(
467
  (
@@ -475,20 +509,67 @@ def build_prompt(settings, mode, msg, p_name, workspace):
475
  p_hist = patient_record.get("history", "No records.")
476
  p_sex = patient_record.get("sex") or patient_record.get("gender") or "Unknown"
477
  p_birth = patient_record.get("birthdate") or "Unknown"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
  prompt_sections = [
480
  f"MISSION CONTEXT: {mission_context}" if mission_context else "",
481
  f"TRIAGE INSTRUCTION:\n{settings.get('triage_instruction')}",
482
  "RESOURCES:\n"
483
  f"- Pharmaceuticals: {pharma_str or 'None listed'}\n"
484
- f"- Medical Equipment: {equip_str or 'None listed'}\n"
485
- f"- Consumables: {consumable_str or 'None listed'}\n"
486
- f"- Tools: {tools or 'None listed'}",
487
  "PATIENT:\n"
488
  f"- Name: {display_name}\n"
489
  f"- Sex: {p_sex}\n"
490
  f"- Date of Birth: {p_birth}\n"
491
- f"- Medical History (profile): {p_hist or 'No records.'}",
 
492
  f"SITUATION:\n{msg}",
493
  ]
494
  prompt = "\n\n".join(section for section in prompt_sections if section.strip())
@@ -1394,6 +1475,7 @@ async def chat(request: Request, _=Depends(require_auth)):
1394
  start_time = datetime.now()
1395
  form = await request.form()
1396
  msg = form.get("message")
 
1397
  p_name = form.get("patient")
1398
  mode = form.get("mode")
1399
  is_priv = form.get("private") == "true"
@@ -1454,15 +1536,25 @@ async def chat(request: Request, _=Depends(require_auth)):
1454
 
1455
  if not is_priv:
1456
  h = db_op("history", workspace=workspace)
 
 
 
 
 
1457
  h.append(
1458
  {
1459
  "id": datetime.now().isoformat(),
1460
  "date": datetime.now().strftime("%Y-%m-%d %H:%M"),
1461
- "patient": p_name if mode == "triage" else "Inquiry",
 
 
1462
  "query": msg,
 
1463
  "response": res,
1464
  "model": models["active_name"],
1465
  "duration_ms": elapsed_ms,
 
 
1466
  }
1467
  )
1468
  db_op("history", h, workspace=workspace)
 
324
  "generic_name, brand_name, form, strength, expiry_date, batch_lot, "
325
  "storage_location, manufacturer, indication, allergy_warnings, dosage, notes."
326
  ),
327
+ "vaccine_types": ["MMR", "DTaP", "HepB", "HepA", "Td/Tdap", "Influenza", "COVID-19"],
328
  }
329
 
330
 
 
348
  "homePort": "",
349
  "callSign": "",
350
  "tonnage": "",
351
+ "netTonnage": "",
352
+ "mmsi": "",
353
+ "hullNumber": "",
354
+ "starboardEngine": "",
355
+ "starboardEngineSn": "",
356
+ "portEngine": "",
357
+ "portEngineSn": "",
358
+ "ribSn": "",
359
  "crewCapacity": "",
360
  }
361
  else:
 
378
  path.write_text(json.dumps(data, indent=4))
379
  return data
380
 
381
+ loaded = json.loads(path.read_text() or "[]")
382
+ if cat == "settings":
383
+ if not isinstance(loaded, dict):
384
+ loaded = {}
385
+ return {**get_defaults(), **loaded}
386
+ return loaded
387
 
388
 
389
  def safe_float(val, default):
 
407
  return bool(val)
408
 
409
 
410
+ def _patient_display_name(record, fallback):
411
+ if not record:
412
+ return fallback
413
+ name = record.get("name") or record.get("fullName") or ""
414
+ if name and name.strip():
415
+ return name
416
+ parts = [
417
+ record.get("firstName") or "",
418
+ record.get("middleName") or "",
419
+ record.get("lastName") or "",
420
+ ]
421
+ combined = " ".join(part for part in parts if part).strip()
422
+ return combined or fallback
423
+
424
+
425
+ def lookup_patient_display_name(p_name, workspace, default="Unnamed Crew"):
426
+ if not p_name:
427
+ return default
428
+ try:
429
+ patients = db_op("patients", workspace=workspace)
430
+ except Exception:
431
+ return default
432
+ rec = next(
433
+ (
434
+ p
435
+ for p in patients
436
+ if (p.get("id") and p.get("id") == p_name)
437
+ or (p.get("name") and p.get("name") == p_name)
438
+ ),
439
+ None,
440
+ )
441
+ return _patient_display_name(rec, p_name or default)
442
+
443
+
444
  def build_prompt(settings, mode, msg, p_name, workspace):
445
  rep_penalty = safe_float(settings.get("rep_penalty", 1.1) or 1.1, 1.1)
446
  mission_context = settings.get("mission_context", "")
 
469
  continue
470
  if not item_name:
471
  continue
472
+ cat = (m.get("type") or "medication").strip().lower()
473
  key = (item_name or "").strip().lower()
474
  if not key:
475
  continue
476
+ if cat in {"medication", ""}:
477
  pharma_items[key] = item_name
478
  elif cat == "consumable":
479
  consumable_items[key] = item_name
480
  elif cat == "equipment":
481
  equip_items[key] = item_name
482
  else:
483
+ # Default unknown types to medication so they are not dropped
484
+ pharma_items[key] = item_name
485
  pharma_list = [pharma_items[k] for k in sorted(pharma_items)]
486
  equip_list = [equip_items[k] for k in sorted(equip_items)]
487
  consumable_list = [consumable_items[k] for k in sorted(consumable_items)]
 
495
  if tool_name:
496
  tool_items.append(tool_name)
497
  tool_items.sort(key=lambda s: (s or "").lower())
498
+ equipment_extra = ", ".join(tool_items)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
 
500
  patient_record = next(
501
  (
 
509
  p_hist = patient_record.get("history", "No records.")
510
  p_sex = patient_record.get("sex") or patient_record.get("gender") or "Unknown"
511
  p_birth = patient_record.get("birthdate") or "Unknown"
512
+ vaccines = patient_record.get("vaccines") or []
513
+
514
+ def _format_vaccines(vax_list):
515
+ if not isinstance(vax_list, list) or not vax_list:
516
+ return "No vaccines recorded."
517
+ formatted = []
518
+ for v in vax_list:
519
+ if not isinstance(v, dict):
520
+ continue
521
+ parts = []
522
+ v_type = v.get("vaccineType") or "Vaccine"
523
+ date = v.get("dateAdministered")
524
+ dose = v.get("doseNumber")
525
+ trade = v.get("tradeNameManufacturer")
526
+ lot = v.get("lotNumber")
527
+ provider = v.get("provider")
528
+ provider_country = v.get("providerCountry")
529
+ next_due = v.get("nextDoseDue")
530
+ exp = v.get("expirationDate")
531
+ site = v.get("siteRoute")
532
+ reactions = v.get("reactions")
533
+ if date:
534
+ parts.append(f"Date: {date}")
535
+ if dose:
536
+ parts.append(f"Dose: {dose}")
537
+ if trade:
538
+ parts.append(f"Trade/Manufacturer: {trade}")
539
+ if lot:
540
+ parts.append(f"Lot: {lot}")
541
+ if provider:
542
+ parts.append(f"Provider: {provider}")
543
+ if provider_country:
544
+ parts.append(f"Provider Country: {provider_country}")
545
+ if next_due:
546
+ parts.append(f"Next Dose Due: {next_due}")
547
+ if exp:
548
+ parts.append(f"Expiration: {exp}")
549
+ if site:
550
+ parts.append(f"Site/Route: {site}")
551
+ if reactions:
552
+ parts.append(f"Reactions: {reactions}")
553
+ details = "; ".join(parts)
554
+ if details:
555
+ formatted.append(f"{v_type} ({details})")
556
+ else:
557
+ formatted.append(v_type)
558
+ return "; ".join(formatted) if formatted else "No vaccines recorded."
559
 
560
  prompt_sections = [
561
  f"MISSION CONTEXT: {mission_context}" if mission_context else "",
562
  f"TRIAGE INSTRUCTION:\n{settings.get('triage_instruction')}",
563
  "RESOURCES:\n"
564
  f"- Pharmaceuticals: {pharma_str or 'None listed'}\n"
565
+ f"- Medical Equipment: {equip_str or equipment_extra or 'None listed'}\n"
566
+ f"- Consumables: {consumable_str or 'None listed'}",
 
567
  "PATIENT:\n"
568
  f"- Name: {display_name}\n"
569
  f"- Sex: {p_sex}\n"
570
  f"- Date of Birth: {p_birth}\n"
571
+ f"- Medical History (profile): {p_hist or 'No records.'}\n"
572
+ f"- Vaccines: {_format_vaccines(vaccines)}",
573
  f"SITUATION:\n{msg}",
574
  ]
575
  prompt = "\n\n".join(section for section in prompt_sections if section.strip())
 
1475
  start_time = datetime.now()
1476
  form = await request.form()
1477
  msg = form.get("message")
1478
+ user_msg_raw = msg
1479
  p_name = form.get("patient")
1480
  mode = form.get("mode")
1481
  is_priv = form.get("private") == "true"
 
1536
 
1537
  if not is_priv:
1538
  h = db_op("history", workspace=workspace)
1539
+ patient_display = (
1540
+ lookup_patient_display_name(p_name, workspace, default="Unnamed Crew")
1541
+ if mode == "triage"
1542
+ else "Inquiry"
1543
+ )
1544
  h.append(
1545
  {
1546
  "id": datetime.now().isoformat(),
1547
  "date": datetime.now().strftime("%Y-%m-%d %H:%M"),
1548
+ "patient": patient_display,
1549
+ "patient_id": p_name or "",
1550
+ "mode": mode,
1551
  "query": msg,
1552
+ "user_query": user_msg_raw,
1553
  "response": res,
1554
  "model": models["active_name"],
1555
  "duration_ms": elapsed_ms,
1556
+ "prompt": prompt,
1557
+ "injected_prompt": prompt,
1558
  }
1559
  )
1560
  db_op("history", h, workspace=workspace)
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/js/chat.js CHANGED
@@ -6,9 +6,73 @@ let isProcessing = false;
6
  let currentMode = 'triage';
7
  const LAST_PROMPT_KEY = 'sailingmed:lastPrompt';
8
  const LAST_PATIENT_KEY = 'sailingmed:lastPatient';
 
 
9
  const PROMPT_PREVIEW_STATE_KEY = 'sailingmed:promptPreviewOpen';
10
  const PROMPT_PREVIEW_CONTENT_KEY = 'sailingmed:promptPreviewContent';
11
  const CHAT_STATE_KEY = 'sailingmed:chatState';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  function setupPromptInjectionPanel() {
14
  const promptHeader = document.getElementById('prompt-preview-header');
@@ -76,6 +140,21 @@ if (document.readyState !== 'loading') {
76
  document.addEventListener('DOMContentLoaded', () => applyChatState(currentMode), { once: true });
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  function updateUI() {
80
  const banner = document.getElementById('banner');
81
  const modeSelect = document.getElementById('mode-select');
@@ -207,6 +286,7 @@ function togglePriv() {
207
  btn.style.background = isPrivate ? 'var(--triage)' : '#333';
208
  btn.style.border = isPrivate ? '2px solid #fff' : '1px solid #222';
209
  btn.innerText = isPrivate ? 'LOGGING: OFF' : 'LOGGING: ON';
 
210
  updateUI();
211
  }
212
 
@@ -217,14 +297,16 @@ async function runChat(promptText = null, force28b = false) {
217
  isProcessing = true;
218
  lastPrompt = txt;
219
  const startTime = Date.now();
 
 
220
 
221
- // Show loading indicator
222
- const display = document.getElementById('display');
223
- const loadingDiv = document.createElement('div');
224
- loadingDiv.id = 'loading-indicator';
225
- loadingDiv.className = 'loading-indicator';
226
- loadingDiv.innerHTML = '🔄 Analyzing...';
227
- display.appendChild(loadingDiv);
228
  display.scrollTop = display.scrollHeight;
229
 
230
  // Disable buttons
@@ -283,30 +365,77 @@ async function runChat(promptText = null, force28b = false) {
283
  : (res.response || '').replace(/\n/g, '<br>');
284
  const meta = res.model ? `[${res.model}${res.duration_ms ? ` · ${Math.round(res.duration_ms)} ms` : ` · ${Math.round(durationMs)} ms`}]` : '';
285
  display.innerHTML += `<div class="response-block"><b>${meta}</b><br>${parsed}</div>`;
 
 
 
286
  }
287
 
288
- if (!promptText) {
289
- document.getElementById('msg').value = '';
290
- }
291
  persistChatState();
292
- try { localStorage.setItem(LAST_PROMPT_KEY, lastPrompt); } catch (err) { /* ignore storage issues */ }
 
 
 
293
  if (display.lastElementChild) display.lastElementChild.scrollIntoView({behavior:'smooth'});
294
  } catch (error) {
295
  loadingDiv.remove();
296
  display.innerHTML += `<div class="response-block" style="border-left-color:var(--red);"><b>ERROR:</b> ${error.message}</div>`;
297
  } finally {
298
  isProcessing = false;
 
 
299
  document.getElementById('run-btn').disabled = false;
300
  document.getElementById('repeat-btn').disabled = false;
301
  }
302
  }
303
 
304
- function repeatLast() {
305
- if (lastPrompt) {
306
- runChat(lastPrompt);
307
- } else {
308
- alert('No previous prompt to repeat');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  }
 
310
  }
311
 
312
  // Handle Enter key for submission
@@ -324,6 +453,7 @@ document.addEventListener('DOMContentLoaded', () => {
324
  }
325
  const savedPrompt = localStorage.getItem(LAST_PROMPT_KEY);
326
  if (savedPrompt) lastPrompt = savedPrompt;
 
327
 
328
  // Debug current patient select state
329
  const pSelect = document.getElementById('p-select');
@@ -334,6 +464,109 @@ document.addEventListener('DOMContentLoaded', () => {
334
  }
335
  });
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  function setMode(mode) {
338
  const target = mode === 'inquiry' ? 'inquiry' : 'triage';
339
  if (target === currentMode) return;
 
6
  let currentMode = 'triage';
7
  const LAST_PROMPT_KEY = 'sailingmed:lastPrompt';
8
  const LAST_PATIENT_KEY = 'sailingmed:lastPatient';
9
+ const LAST_CHAT_MODE_KEY = 'sailingmed:lastChatMode';
10
+ const LOGGING_MODE_KEY = 'sailingmed:loggingOff';
11
  const PROMPT_PREVIEW_STATE_KEY = 'sailingmed:promptPreviewOpen';
12
  const PROMPT_PREVIEW_CONTENT_KEY = 'sailingmed:promptPreviewContent';
13
  const CHAT_STATE_KEY = 'sailingmed:chatState';
14
+ let triageSamples = [];
15
+
16
+ function escapeHtml(str) {
17
+ return (str || '')
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&#39;');
23
+ }
24
+
25
+ async function loadTriageSamples() {
26
+ if (triageSamples.length) return triageSamples;
27
+ try {
28
+ const res = await fetch('/static/data/triage_samples.json', { cache: 'no-store' });
29
+ if (!res.ok) throw new Error(`Status ${res.status}`);
30
+ triageSamples = await res.json();
31
+ const select = document.getElementById('triage-sample-select');
32
+ if (select && Array.isArray(triageSamples)) {
33
+ triageSamples.forEach((s) => {
34
+ const opt = document.createElement('option');
35
+ opt.value = String(s.id);
36
+ opt.textContent = `${s.id}. ${s.situation}`;
37
+ select.appendChild(opt);
38
+ });
39
+ }
40
+ } catch (err) {
41
+ console.warn('Unable to load triage samples', err);
42
+ }
43
+ return triageSamples;
44
+ }
45
+
46
+ function setSelectValue(selectEl, value) {
47
+ if (!selectEl) return;
48
+ const match = Array.from(selectEl.options).find(o => o.value === value || o.textContent === value);
49
+ if (match) {
50
+ selectEl.value = match.value;
51
+ return;
52
+ }
53
+ const opt = new Option(value, value, true, true);
54
+ selectEl.add(opt);
55
+ selectEl.value = value;
56
+ }
57
+
58
+ function applyTriageSample(sampleId) {
59
+ if (!sampleId) return;
60
+ const sample = triageSamples.find((s) => String(s.id) === String(sampleId));
61
+ if (!sample) return;
62
+ const msgTextarea = document.getElementById('msg');
63
+ if (msgTextarea) {
64
+ msgTextarea.value = sample.chat_text || '';
65
+ msgTextarea.dispatchEvent(new Event('input', { bubbles: true }));
66
+ }
67
+ setSelectValue(document.getElementById('triage-consciousness'), sample.responsive || '');
68
+ setSelectValue(document.getElementById('triage-breathing-status'), sample.breathing || '');
69
+ setSelectValue(document.getElementById('triage-pain-level'), sample.pain || '');
70
+ setSelectValue(document.getElementById('triage-main-problem'), sample.main_problem || '');
71
+ setSelectValue(document.getElementById('triage-temperature'), sample.temp || '');
72
+ setSelectValue(document.getElementById('triage-circulation'), sample.circulation || '');
73
+ setSelectValue(document.getElementById('triage-cause'), sample.cause || '');
74
+ persistChatState();
75
+ }
76
 
77
  function setupPromptInjectionPanel() {
78
  const promptHeader = document.getElementById('prompt-preview-header');
 
140
  document.addEventListener('DOMContentLoaded', () => applyChatState(currentMode), { once: true });
141
  }
142
 
143
+ document.addEventListener('DOMContentLoaded', () => {
144
+ try {
145
+ const stored = localStorage.getItem(LOGGING_MODE_KEY);
146
+ if (stored === '1') {
147
+ isPrivate = true;
148
+ }
149
+ } catch (err) { /* ignore */ }
150
+ const btn = document.getElementById('priv-btn');
151
+ if (btn) {
152
+ btn.style.background = isPrivate ? 'var(--triage)' : '#333';
153
+ btn.style.border = isPrivate ? '2px solid #fff' : '1px solid #222';
154
+ btn.innerText = isPrivate ? 'LOGGING: OFF' : 'LOGGING: ON';
155
+ }
156
+ });
157
+
158
  function updateUI() {
159
  const banner = document.getElementById('banner');
160
  const modeSelect = document.getElementById('mode-select');
 
286
  btn.style.background = isPrivate ? 'var(--triage)' : '#333';
287
  btn.style.border = isPrivate ? '2px solid #fff' : '1px solid #222';
288
  btn.innerText = isPrivate ? 'LOGGING: OFF' : 'LOGGING: ON';
289
+ try { localStorage.setItem(LOGGING_MODE_KEY, isPrivate ? '1' : '0'); } catch (err) { /* ignore */ }
290
  updateUI();
291
  }
292
 
 
297
  isProcessing = true;
298
  lastPrompt = txt;
299
  const startTime = Date.now();
300
+ const blocker = document.getElementById('chat-blocker');
301
+ if (blocker) blocker.style.display = 'flex';
302
 
303
+ // Show loading indicator
304
+ const display = document.getElementById('display');
305
+ const loadingDiv = document.createElement('div');
306
+ loadingDiv.id = 'loading-indicator';
307
+ loadingDiv.className = 'loading-indicator';
308
+ loadingDiv.innerHTML = '🔄 Analyzing...';
309
+ display.appendChild(loadingDiv);
310
  display.scrollTop = display.scrollHeight;
311
 
312
  // Disable buttons
 
365
  : (res.response || '').replace(/\n/g, '<br>');
366
  const meta = res.model ? `[${res.model}${res.duration_ms ? ` · ${Math.round(res.duration_ms)} ms` : ` · ${Math.round(durationMs)} ms`}]` : '';
367
  display.innerHTML += `<div class="response-block"><b>${meta}</b><br>${parsed}</div>`;
368
+ if (typeof loadData === 'function') {
369
+ loadData(); // refresh crew history/logs after a chat completes
370
+ }
371
  }
372
 
373
+ // Keep user-entered text so they can tweak/re-run
 
 
374
  persistChatState();
375
+ try {
376
+ localStorage.setItem(LAST_PROMPT_KEY, lastPrompt);
377
+ localStorage.setItem(LAST_CHAT_MODE_KEY, currentMode);
378
+ } catch (err) { /* ignore storage issues */ }
379
  if (display.lastElementChild) display.lastElementChild.scrollIntoView({behavior:'smooth'});
380
  } catch (error) {
381
  loadingDiv.remove();
382
  display.innerHTML += `<div class="response-block" style="border-left-color:var(--red);"><b>ERROR:</b> ${error.message}</div>`;
383
  } finally {
384
  isProcessing = false;
385
+ const blocker = document.getElementById('chat-blocker');
386
+ if (blocker) blocker.style.display = 'none';
387
  document.getElementById('run-btn').disabled = false;
388
  document.getElementById('repeat-btn').disabled = false;
389
  }
390
  }
391
 
392
+ function restoreLast() {
393
+ // Restore last mode used
394
+ let modeToRestore = currentMode;
395
+ try {
396
+ const storedMode = localStorage.getItem(LAST_CHAT_MODE_KEY);
397
+ if (storedMode) modeToRestore = storedMode;
398
+ } catch (_) { /* ignore */ }
399
+ if (modeToRestore !== currentMode) {
400
+ setMode(modeToRestore);
401
+ }
402
+ const state = loadChatState();
403
+ const modeState = state[modeToRestore] || {};
404
+ const msgEl = document.getElementById('msg');
405
+ if (msgEl && typeof modeState.msg === 'string') {
406
+ msgEl.value = modeState.msg;
407
+ msgEl.focus();
408
+ }
409
+ if (modeToRestore === 'triage' && modeState.fields && typeof modeState.fields === 'object') {
410
+ Object.entries(modeState.fields).forEach(([id, val]) => {
411
+ const el = document.getElementById(id);
412
+ if (el) el.value = val;
413
+ });
414
+ }
415
+ // Restore patient selection
416
+ const patientSelect = document.getElementById('p-select');
417
+ if (patientSelect) {
418
+ const savedPatient = (() => {
419
+ try { return localStorage.getItem(LAST_PATIENT_KEY) || ''; } catch (_) { return ''; }
420
+ })();
421
+ if (savedPatient && Array.from(patientSelect.options).some(o => o.value === savedPatient)) {
422
+ patientSelect.value = savedPatient;
423
+ }
424
+ }
425
+ // Restore prompt preview if cached
426
+ const promptBox = document.getElementById('prompt-preview');
427
+ const promptHeader = document.getElementById('prompt-preview-header');
428
+ if (promptBox) {
429
+ try {
430
+ const cached = localStorage.getItem(PROMPT_PREVIEW_CONTENT_KEY);
431
+ if (cached) {
432
+ promptBox.value = cached;
433
+ promptBox.dataset.autofilled = 'false';
434
+ if (promptHeader) togglePromptPreviewArrow(promptHeader, true);
435
+ }
436
+ } catch (_) { /* ignore */ }
437
  }
438
+ alert('Last chat restored to editor. You can revise and resend.');
439
  }
440
 
441
  // Handle Enter key for submission
 
453
  }
454
  const savedPrompt = localStorage.getItem(LAST_PROMPT_KEY);
455
  if (savedPrompt) lastPrompt = savedPrompt;
456
+ loadTriageSamples().catch(() => {});
457
 
458
  // Debug current patient select state
459
  const pSelect = document.getElementById('p-select');
 
464
  }
465
  });
466
 
467
+ async function reactivateChat(historyId) {
468
+ try {
469
+ const res = await fetch('/api/data/history', { credentials: 'same-origin' });
470
+ if (!res.ok) throw new Error(`History load failed (${res.status})`);
471
+ const data = await res.json();
472
+ const entry = Array.isArray(data) ? data.find((h) => h.id === historyId) : null;
473
+ if (!entry) {
474
+ alert('Unable to reactivate: history entry not found.');
475
+ return;
476
+ }
477
+ const sameThread = Array.isArray(data)
478
+ ? data
479
+ .filter((h) => {
480
+ const samePatient = (entry.patient_id && h.patient_id === entry.patient_id) || (entry.patient && h.patient === entry.patient);
481
+ const sameMode = (entry.mode || 'triage') === (h.mode || 'triage');
482
+ return samePatient && sameMode;
483
+ })
484
+ .sort((a, b) => (a.date || '').localeCompare(b.date || ''))
485
+ : [entry];
486
+ const transcript = sameThread
487
+ .map((h, idx) => {
488
+ const title = h.date ? `Entry ${idx + 1} — ${h.date}` : `Entry ${idx + 1}`;
489
+ return `${title}\nQUERY:\n${h.query || ''}\nRESPONSE:\n${h.response || ''}`;
490
+ })
491
+ .join('\n\n----\n\n');
492
+ // Set mode based on stored value if present
493
+ if (entry.mode) {
494
+ setMode(entry.mode);
495
+ }
496
+ // Navigate to the correct tab
497
+ const triageTabBtn = document.querySelector('.tab.tab-triage');
498
+ if (triageTabBtn) {
499
+ triageTabBtn.click();
500
+ }
501
+ // Attempt to restore patient selection using patient_id first, then name
502
+ const patientSelect = document.getElementById('p-select');
503
+ if (patientSelect) {
504
+ let targetVal = entry.patient_id || '';
505
+ if (targetVal && Array.from(patientSelect.options).some((o) => o.value === targetVal)) {
506
+ patientSelect.value = targetVal;
507
+ } else if (entry.patient) {
508
+ const matchByName = Array.from(patientSelect.options).find((o) => o.textContent === entry.patient);
509
+ if (matchByName) patientSelect.value = matchByName.value;
510
+ }
511
+ try { localStorage.setItem(LAST_PATIENT_KEY, patientSelect.value || ''); } catch (err) { /* ignore */ }
512
+ }
513
+ // Restore chat area with previous exchange
514
+ const display = document.getElementById('display');
515
+ if (display) {
516
+ const queryHtml = escapeHtml(entry.query || '').replace(/\n/g, '<br>');
517
+ const respHtml = escapeHtml(entry.response || '').replace(/\n/g, '<br>');
518
+ display.innerHTML = `
519
+ <div class="response-block" style="border-left-color:var(--inquiry);">
520
+ <b>Reactivated Chat</b><br>
521
+ <div style="margin-top:6px;"><strong>Query:</strong><br>${queryHtml}</div>
522
+ <div style="margin-top:6px;"><strong>Response:</strong><br>${respHtml}</div>
523
+ </div>`;
524
+ display.scrollTop = display.scrollHeight;
525
+ }
526
+ // Prefill message box with last query so user can continue
527
+ const msgTextarea = document.getElementById('msg');
528
+ if (msgTextarea) {
529
+ msgTextarea.value = entry.query || '';
530
+ msgTextarea.focus();
531
+ }
532
+ // Restore prompt (injected) view if available
533
+ const promptBox = document.getElementById('prompt-preview');
534
+ const promptHeader = document.getElementById('prompt-preview-header');
535
+ if (promptBox && entry.prompt) {
536
+ promptBox.value = entry.prompt;
537
+ promptBox.dataset.autofilled = 'false';
538
+ try { localStorage.setItem(PROMPT_PREVIEW_CONTENT_KEY, entry.prompt); } catch (err) { /* ignore */ }
539
+ if (promptHeader) {
540
+ togglePromptPreviewArrow(promptHeader, true);
541
+ }
542
+ }
543
+ // Inject transcript into prompt preview so the model sees prior context
544
+ if (promptBox) {
545
+ const transcriptBlock = transcript ? `Previous chat transcript:\n${transcript}` : '';
546
+ const combined = [transcriptBlock, promptBox.value].filter(Boolean).join('\n\n');
547
+ promptBox.value = combined;
548
+ promptBox.dataset.autofilled = 'false';
549
+ try { localStorage.setItem(PROMPT_PREVIEW_CONTENT_KEY, combined); } catch (err) { /* ignore */ }
550
+ if (promptHeader) {
551
+ togglePromptPreviewArrow(promptHeader, true);
552
+ }
553
+ const container = document.getElementById('prompt-preview-container');
554
+ if (container) container.style.display = 'block';
555
+ }
556
+ // Persist last prompt locally
557
+ if (entry.query) {
558
+ lastPrompt = entry.query;
559
+ try { localStorage.setItem(LAST_PROMPT_KEY, entry.query); } catch (err) { /* ignore */ }
560
+ }
561
+ alert('Chat restored. You can continue the conversation.');
562
+ } catch (err) {
563
+ alert(`Unable to reactivate chat: ${err.message}`);
564
+ }
565
+ }
566
+
567
+ window.reactivateChat = reactivateChat;
568
+ window.applyTriageSample = applyTriageSample;
569
+
570
  function setMode(mode) {
571
  const target = mode === 'inquiry' ? 'inquiry' : 'triage';
572
  if (target === currentMode) return;
static/js/crew.js CHANGED
@@ -2,6 +2,7 @@
2
 
3
  // Reuse the chat dropdown storage key without redefining the global constant
4
  const CREW_LAST_PATIENT_KEY = typeof LAST_PATIENT_KEY !== 'undefined' ? LAST_PATIENT_KEY : 'sailingmed:lastPatient';
 
5
 
6
  function escapeHtml(str) {
7
  return (str || '')
@@ -125,11 +126,16 @@ function groupHistoryByPatient(history) {
125
  if (item && item.id) {
126
  historyStoreById[item.id] = item;
127
  }
128
- let key = (item.patient || '').trim();
129
- if (!key) key = 'Unnamed Crew';
130
- if (key.toLowerCase() === 'inquiry') key = 'Inquiry History';
131
- if (!map[key]) map[key] = [];
132
- map[key].push(item);
 
 
 
 
 
133
  });
134
  return map;
135
  }
@@ -154,6 +160,7 @@ function renderHistoryEntries(entries) {
154
  <span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span>
155
  <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600; font-size:13px;">${date || 'Entry'}${preview ? ' — ' + preview : ''}</span>
156
  <div style="display:flex; gap:6px; align-items:center;">
 
157
  <button class="btn btn-sm history-entry-action" style="background:var(--inquiry); visibility:hidden;" onclick="event.stopPropagation(); exportHistoryItemById('${item.id || ''}')">Export</button>
158
  <button class="btn btn-sm history-entry-action" style="background:var(--red); visibility:hidden;" onclick="event.stopPropagation(); deleteHistoryItemById('${item.id || ''}')">Delete</button>
159
  </div>
@@ -190,14 +197,119 @@ function getHistoryForCrew(p, historyMap) {
190
  if (fullName) keys.push(fullName);
191
  if (p.name) keys.push(p.name);
192
  if (p.firstName || p.lastName) keys.push(`${p.firstName || ''} ${p.lastName || ''}`.trim());
 
193
  for (const k of keys) {
194
  if (historyMap[k]) return historyMap[k];
195
  }
196
  return [];
197
  }
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  // Load crew data for both medical and vessel/crew info
200
- function loadCrewData(data, history = []) {
201
  if (!Array.isArray(data)) {
202
  console.warn('loadCrewData expected array, got', data);
203
  return;
@@ -318,6 +430,10 @@ function loadCrewData(data, history = []) {
318
  const ageStr = calculateAge(p.birthdate);
319
  const posInfo = p.position ? ` • ${p.position}` : '';
320
  const info = `${displayName}${ageStr}${posInfo}`;
 
 
 
 
321
 
322
  // Check if crew has data to determine default collapse state
323
  const hasData = p.firstName && p.lastName && p.citizenship;
@@ -387,18 +503,54 @@ function loadCrewData(data, history = []) {
387
  <div style="margin-bottom:8px; font-size:13px;">
388
  <input type="text" id="phone-${p.id}" value="${p.phoneNumber || ''}" placeholder="Cell/WhatsApp Number" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;">
389
  </div>
390
- <div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:8px;">
391
- <div style="border:1px solid #ddd; padding:8px; border-radius:4px; background:#f9f9f9;">
392
- <label style="margin-bottom:4px; display:block; font-weight:bold; font-size:11px;">Passport Photo:</label>
393
- ${p.passportPhoto ? (p.passportPhoto.startsWith('data:image/') ? `<div style="margin-bottom:4px;"><img src="${p.passportPhoto}" style="max-width:100%; max-height:120px; border:1px solid #ccc; border-radius:4px; cursor:pointer;" onclick="window.open('${p.passportPhoto}', '_blank')"><div style="margin-top:4px;"><button onclick="deleteDocument('${p.id}', 'passportPhoto')" style="background:var(--red); color:white; border:none; padding:2px 8px; border-radius:3px; cursor:pointer; font-size:10px;">🗑 Delete</button></div></div>` : `<div style="margin-bottom:4px;"><a href="${p.passportPhoto}" target="_blank" style="color:var(--inquiry); font-size:11px;">📎 View PDF</a> | <button onclick="deleteDocument('${p.id}', 'passportPhoto')" style="background:none; border:none; color:var(--red); cursor:pointer; font-size:10px;">🗑</button></div>`) : ''}
394
- <input type="file" id="pp-${p.id}" accept="image/*,.pdf" onchange="uploadDocument('${p.id}', 'passportPhoto', this)" style="font-size:10px; width:100%;">
 
 
 
 
 
 
395
  </div>
396
- <div style="border:1px solid #ddd; padding:8px; border-radius:4px; background:#f9f9f9;">
397
- <label style="margin-bottom:4px; display:block; font-weight:bold; font-size:11px;">Passport Page Photo:</label>
398
- ${p.passportPage ? (p.passportPage.startsWith('data:image/') ? `<div style="margin-bottom:4px;"><img src="${p.passportPage}" style="max-width:100%; max-height:120px; border:1px solid #ccc; border-radius:4px; cursor:pointer;" onclick="window.open('${p.passportPage}', '_blank')"><div style="margin-top:4px;"><button onclick="deleteDocument('${p.id}', 'passportPage')" style="background:var(--red); color:white; border:none; padding:2px 8px; border-radius:3px; cursor:pointer; font-size:10px;">🗑 Delete</button></div></div>` : `<div style="margin-bottom:4px;"><a href="${p.passportPage}" target="_blank" style="color:var(--inquiry); font-size:11px;">📎 View PDF</a> | <button onclick="deleteDocument('${p.id}', 'passportPage')" style="background:none; border:none; color:var(--red); cursor:pointer; font-size:10px;">🗑</button></div>`) : ''}
399
- <input type="file" id="ppg-${p.id}" accept="image/*,.pdf" onchange="uploadDocument('${p.id}', 'passportPage', this)" style="font-size:10px; width:100%;">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
  </div>
401
- </div>
402
  </div>
403
  </div>
404
  </div>
@@ -464,6 +616,7 @@ async function addCrew() {
464
  emergencyContactEmail: emergencyContactEmail,
465
  emergencyContactNotes: emergencyContactNotes,
466
  phoneNumber: phoneNumber,
 
467
  passportPhoto: '',
468
  passportPage: '',
469
  history: ''
@@ -494,6 +647,75 @@ async function addCrew() {
494
  loadData();
495
  }
496
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
497
  // Auto-save profile (debounced to prevent excessive saves)
498
  let saveTimers = {};
499
  async function autoSaveProfile(id) {
@@ -826,6 +1048,12 @@ async function loadVesselInfo() {
826
  setVal('vessel-tonnage', v.tonnage);
827
  setVal('vessel-net-tonnage', v.netTonnage);
828
  setVal('vessel-mmsi', v.mmsi);
 
 
 
 
 
 
829
  }
830
 
831
  async function saveVesselInfo() {
@@ -837,7 +1065,13 @@ async function saveVesselInfo() {
837
  callSign: document.getElementById('vessel-callsign')?.value || '',
838
  tonnage: document.getElementById('vessel-tonnage')?.value || '',
839
  netTonnage: document.getElementById('vessel-net-tonnage')?.value || '',
840
- mmsi: document.getElementById('vessel-mmsi')?.value || ''
 
 
 
 
 
 
841
  };
842
  await fetch('/api/data/vessel', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(v), credentials:'same-origin'});
843
  alert('Vessel information saved.');
 
2
 
3
  // Reuse the chat dropdown storage key without redefining the global constant
4
  const CREW_LAST_PATIENT_KEY = typeof LAST_PATIENT_KEY !== 'undefined' ? LAST_PATIENT_KEY : 'sailingmed:lastPatient';
5
+ const DEFAULT_VACCINE_TYPES = [];
6
 
7
  function escapeHtml(str) {
8
  return (str || '')
 
126
  if (item && item.id) {
127
  historyStoreById[item.id] = item;
128
  }
129
+ const keys = [];
130
+ const patientName = (item.patient || '').trim();
131
+ if (patientName) keys.push(patientName);
132
+ if (item.patient_id) keys.push(`id:${item.patient_id}`);
133
+ if (!patientName && !item.patient_id) keys.push('Unnamed Crew');
134
+ if (patientName && patientName.toLowerCase() === 'inquiry') keys.push('Inquiry History');
135
+ keys.forEach((k) => {
136
+ if (!map[k]) map[k] = [];
137
+ map[k].push(item);
138
+ });
139
  });
140
  return map;
141
  }
 
160
  <span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span>
161
  <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600; font-size:13px;">${date || 'Entry'}${preview ? ' — ' + preview : ''}</span>
162
  <div style="display:flex; gap:6px; align-items:center;">
163
+ <button class="btn btn-sm history-entry-action" style="background:#3949ab; visibility:hidden;" onclick="event.stopPropagation(); reactivateChat('${item.id || ''}')">↩ Reactivate</button>
164
  <button class="btn btn-sm history-entry-action" style="background:var(--inquiry); visibility:hidden;" onclick="event.stopPropagation(); exportHistoryItemById('${item.id || ''}')">Export</button>
165
  <button class="btn btn-sm history-entry-action" style="background:var(--red); visibility:hidden;" onclick="event.stopPropagation(); deleteHistoryItemById('${item.id || ''}')">Delete</button>
166
  </div>
 
197
  if (fullName) keys.push(fullName);
198
  if (p.name) keys.push(p.name);
199
  if (p.firstName || p.lastName) keys.push(`${p.firstName || ''} ${p.lastName || ''}`.trim());
200
+ if (p.id) keys.push(`id:${p.id}`);
201
  for (const k of keys) {
202
  if (historyMap[k]) return historyMap[k];
203
  }
204
  return [];
205
  }
206
 
207
+ function getVaccineOptions(settings = {}) {
208
+ const raw = Array.isArray(settings.vaccine_types) ? settings.vaccine_types : DEFAULT_VACCINE_TYPES;
209
+ const seen = new Set();
210
+ return raw
211
+ .map((v) => (typeof v === 'string' ? v.trim() : ''))
212
+ .filter((v) => !!v)
213
+ .filter((v) => {
214
+ const key = v.toLowerCase();
215
+ if (seen.has(key)) return false;
216
+ seen.add(key);
217
+ return true;
218
+ });
219
+ }
220
+
221
+ function renderVaccineDetails(v) {
222
+ const fields = [
223
+ ['Vaccine Type/Disease', v.vaccineType],
224
+ ['Date Administered', v.dateAdministered],
225
+ ['Dose Number', v.doseNumber],
226
+ ['Next Dose Due Date', v.nextDoseDue],
227
+ ['Trade Name & Manufacturer', v.tradeNameManufacturer],
228
+ ['Lot/Batch Number', v.lotNumber],
229
+ ['Administering Clinic/Provider', v.provider],
230
+ ['Clinic/Provider Country', v.providerCountry],
231
+ ['Expiration Date (dose)', v.expirationDate],
232
+ ['Site & Route', v.siteRoute],
233
+ ['Allergic Reactions', v.reactions],
234
+ ['Remarks', v.remarks],
235
+ ];
236
+ const rows = fields
237
+ .filter(([, val]) => val)
238
+ .map(([label, val]) => `<div><strong>${escapeHtml(label)}:</strong> ${escapeHtml(val)}</div>`)
239
+ .join('');
240
+ return rows || '<div style="color:#666;">No details recorded for this dose.</div>';
241
+ }
242
+
243
+ function renderVaccineList(vaccines = [], crewId) {
244
+ if (!Array.isArray(vaccines) || vaccines.length === 0) {
245
+ return '<div style="font-size:12px; color:#666;">No vaccines recorded.</div>';
246
+ }
247
+ return vaccines
248
+ .map((v) => {
249
+ const vid = escapeHtml(v.id || '');
250
+ const label = escapeHtml(v.vaccineType || 'Vaccine');
251
+ const date = escapeHtml(v.dateAdministered || '');
252
+ return `
253
+ <div class="collapsible" style="margin-bottom:8px;">
254
+ <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="justify-content:flex-start; background:#fff;">
255
+ <span class="dev-tag">dev:crew-vax-entry</span>
256
+ <span class="toggle-label history-arrow" style="font-size:16px; margin-right:8px;">▸</span>
257
+ <span style="flex:1; font-weight:600; font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${label}${date ? ' — ' + date : ''}</span>
258
+ <button onclick="event.stopPropagation(); deleteVaccine('${crewId}', '${vid}')" class="btn btn-sm history-action-btn" style="background:var(--red); visibility:hidden;">🗑 Delete</button>
259
+ </div>
260
+ <div class="col-body" style="padding:10px; display:none; font-size:12px; background:#f9fbff; border:1px solid #e0e7ff; border-top:none;">
261
+ ${renderVaccineDetails(v)}
262
+ </div>
263
+ </div>`;
264
+ })
265
+ .join('');
266
+ }
267
+
268
+ function clearVaccineInputs(crewId) {
269
+ const ids = [
270
+ `vx-type-${crewId}`,
271
+ `vx-type-other-${crewId}`,
272
+ `vx-date-${crewId}`,
273
+ `vx-dose-${crewId}`,
274
+ `vx-trade-${crewId}`,
275
+ `vx-lot-${crewId}`,
276
+ `vx-provider-${crewId}`,
277
+ `vx-provider-country-${crewId}`,
278
+ `vx-next-${crewId}`,
279
+ `vx-exp-${crewId}`,
280
+ `vx-site-${crewId}`,
281
+ `vx-remarks-${crewId}`,
282
+ ];
283
+ ids.forEach((id) => {
284
+ const el = document.getElementById(id);
285
+ if (el) {
286
+ el.value = '';
287
+ if (id.includes('type-other')) {
288
+ el.style.display = 'none';
289
+ }
290
+ }
291
+ });
292
+ const rx = document.getElementById(`vx-reactions-${crewId}`);
293
+ if (rx) rx.value = '';
294
+ const typeSelect = document.getElementById(`vx-type-${crewId}`);
295
+ if (typeSelect) typeSelect.value = '';
296
+ }
297
+
298
+ function handleVaccineTypeChange(crewId) {
299
+ const select = document.getElementById(`vx-type-${crewId}`);
300
+ const other = document.getElementById(`vx-type-other-${crewId}`);
301
+ if (!select || !other) return;
302
+ const showOther = select.value === '__other__';
303
+ other.style.display = showOther ? 'block' : 'none';
304
+ if (!showOther) {
305
+ other.value = '';
306
+ } else {
307
+ other.focus();
308
+ }
309
+ }
310
+
311
  // Load crew data for both medical and vessel/crew info
312
+ function loadCrewData(data, history = [], settings = {}) {
313
  if (!Array.isArray(data)) {
314
  console.warn('loadCrewData expected array, got', data);
315
  return;
 
430
  const ageStr = calculateAge(p.birthdate);
431
  const posInfo = p.position ? ` • ${p.position}` : '';
432
  const info = `${displayName}${ageStr}${posInfo}`;
433
+ const vaccines = Array.isArray(p.vaccines) ? p.vaccines : [];
434
+ const vaccineOptions = getVaccineOptions(settings);
435
+ const vaccineOptionMarkup = vaccineOptions.map((opt) => `<option value="${escapeHtml(opt)}">${escapeHtml(opt)}</option>`).join('');
436
+ const vaccineList = renderVaccineList(vaccines, p.id);
437
 
438
  // Check if crew has data to determine default collapse state
439
  const hasData = p.firstName && p.lastName && p.citizenship;
 
503
  <div style="margin-bottom:8px; font-size:13px;">
504
  <input type="text" id="phone-${p.id}" value="${p.phoneNumber || ''}" placeholder="Cell/WhatsApp Number" onchange="autoSaveProfile('${p.id}')" style="padding:5px; width:100%;">
505
  </div>
506
+ <div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:8px;">
507
+ <div style="border:1px solid #ddd; padding:8px; border-radius:4px; background:#f9f9f9;">
508
+ <label style="margin-bottom:4px; display:block; font-weight:bold; font-size:11px;">Passport Photo:</label>
509
+ ${p.passportPhoto ? (p.passportPhoto.startsWith('data:image/') ? `<div style="margin-bottom:4px;"><img src="${p.passportPhoto}" style="max-width:100%; max-height:120px; border:1px solid #ccc; border-radius:4px; cursor:pointer;" onclick="window.open('${p.passportPhoto}', '_blank')"><div style="margin-top:4px;"><button onclick="deleteDocument('${p.id}', 'passportPhoto')" style="background:var(--red); color:white; border:none; padding:2px 8px; border-radius:3px; cursor:pointer; font-size:10px;">🗑 Delete</button></div></div>` : `<div style="margin-bottom:4px;"><a href="${p.passportPhoto}" target="_blank" style="color:var(--inquiry); font-size:11px;">📎 View PDF</a> | <button onclick="deleteDocument('${p.id}', 'passportPhoto')" style="background:none; border:none; color:var(--red); cursor:pointer; font-size:10px;">🗑</button></div>`) : ''}
510
+ <input type="file" id="pp-${p.id}" accept="image/*,.pdf" onchange="uploadDocument('${p.id}', 'passportPhoto', this)" style="font-size:10px; width:100%;">
511
+ </div>
512
+ <div style="border:1px solid #ddd; padding:8px; border-radius:4px; background:#f9f9f9;">
513
+ <label style="margin-bottom:4px; display:block; font-weight:bold; font-size:11px;">Passport Page Photo:</label>
514
+ ${p.passportPage ? (p.passportPage.startsWith('data:image/') ? `<div style="margin-bottom:4px;"><img src="${p.passportPage}" style="max-width:100%; max-height:120px; border:1px solid #ccc; border-radius:4px; cursor:pointer;" onclick="window.open('${p.passportPage}', '_blank')"><div style="margin-top:4px;"><button onclick="deleteDocument('${p.id}', 'passportPage')" style="background:var(--red); color:white; border:none; padding:2px 8px; border-radius:3px; cursor:pointer; font-size:10px;">🗑 Delete</button></div></div>` : `<div style="margin-bottom:4px;"><a href="${p.passportPage}" target="_blank" style="color:var(--inquiry); font-size:11px;">📎 View PDF</a> | <button onclick="deleteDocument('${p.id}', 'passportPage')" style="background:none; border:none; color:var(--red); cursor:pointer; font-size:10px;">🗑</button></div>`) : ''}
515
+ <input type="file" id="ppg-${p.id}" accept="image/*,.pdf" onchange="uploadDocument('${p.id}', 'passportPage', this)" style="font-size:10px; width:100%;">
516
+ </div>
517
  </div>
518
+ <div class="collapsible" style="margin-top:12px;">
519
+ <div class="col-header crew-med-header" onclick="toggleCrewSection(this)" style="background:#fff6e8; border:1px solid #f0d9a8; justify-content:flex-start;">
520
+ <span class="dev-tag">dev:crew-vax-shell</span>
521
+ <span class="toggle-label history-arrow" style="font-size:18px; margin-right:8px;">▸</span>
522
+ <span style="font-weight:700;">Crew Vaccines</span>
523
+ <span style="font-size:12px; color:#6a5b3a; margin-left:8px;">${vaccines.length} recorded</span>
524
+ </div>
525
+ <div class="col-body" style="padding:10px; background:#fffdf7; border:1px solid #f0d9a8; border-top:none; display:none;">
526
+ <div class="dev-tag" style="margin-bottom:6px;">dev:crew-vax-form</div>
527
+ <div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:8px; margin-bottom:8px; font-size:12px;">
528
+ <div style="grid-column: span 2;">
529
+ <label style="font-weight:700; font-size:12px;">Vaccine Type/Disease *</label>
530
+ <select id="vx-type-${p.id}" onchange="handleVaccineTypeChange('${p.id}')" style="width:100%; padding:6px;">
531
+ <option value="">Select or choose Other…</option>
532
+ ${vaccineOptionMarkup}
533
+ <option value="__other__">Other (type below)</option>
534
+ </select>
535
+ <input id="vx-type-other-${p.id}" type="text" style="width:100%; padding:6px; margin-top:6px; display:none;" placeholder="Enter other vaccine type">
536
+ </div>
537
+ <div><label style="font-weight:700; font-size:12px;">Date Administered</label><input id="vx-date-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="26-Jan-2026"></div>
538
+ <div><label style="font-weight:700; font-size:12px;">Dose Number</label><input id="vx-dose-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Dose 1 of 3"></div>
539
+ <div><label style="font-weight:700; font-size:12px;">Next Dose Due Date</label><input id="vx-next-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="e.g., 10-Feb-2026"></div>
540
+ <div style="grid-column: span 2;"><label style="font-weight:700; font-size:12px;">Trade Name & Manufacturer</label><input id="vx-trade-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Adacel by Sanofi Pasteur"></div>
541
+ <div><label style="font-weight:700; font-size:12px;">Lot/Batch Number</label><input id="vx-lot-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Batch #12345X"></div>
542
+ <div><label style="font-weight:700; font-size:12px;">Administering Clinic/Provider</label><input id="vx-provider-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Harbor Medical Clinic, Dock 3"></div>
543
+ <div><label style="font-weight:700; font-size:12px;">Clinic/Provider Country</label><input id="vx-provider-country-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Spain"></div>
544
+ <div><label style="font-weight:700; font-size:12px;">Expiration Date (dose)</label><input id="vx-exp-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="e.g., 30-Dec-2026"></div>
545
+ <div><label style="font-weight:700; font-size:12px;">Site & Route</label><input id="vx-site-${p.id}" type="text" style="width:100%; padding:6px;" placeholder="Left Arm - IM"></div>
546
+ <div style="grid-column: span 1;"><label style="font-weight:700; font-size:12px;">Allergic Reactions</label><textarea id="vx-reactions-${p.id}" style="width:100%; padding:6px; min-height:60px;" placeholder="Redness, fever, swelling..."></textarea></div>
547
+ <div style="grid-column: span 1;"><label style="font-weight:700; font-size:12px;">Remarks</label><textarea id="vx-remarks-${p.id}" style="width:100%; padding:6px; min-height:60px;" placeholder="Notes, special handling, country requirements, follow-up instructions..."></textarea></div>
548
+ </div>
549
+ <button onclick="addVaccine('${p.id}')" class="btn btn-sm" style="background:var(--dark); width:100%;"><span class="dev-tag">dev:crew-vax-add</span>+ Add Vaccine</button>
550
+ <div class="dev-tag" style="margin:10px 0 6px;">dev:crew-vax-list</div>
551
+ <div id="vax-list-${p.id}">${vaccineList}</div>
552
+ </div>
553
  </div>
 
554
  </div>
555
  </div>
556
  </div>
 
616
  emergencyContactEmail: emergencyContactEmail,
617
  emergencyContactNotes: emergencyContactNotes,
618
  phoneNumber: phoneNumber,
619
+ vaccines: [],
620
  passportPhoto: '',
621
  passportPage: '',
622
  history: ''
 
647
  loadData();
648
  }
649
 
650
+ async function addVaccine(crewId) {
651
+ const getVal = (suffix) => document.getElementById(`vx-${suffix}-${crewId}`)?.value.trim() || '';
652
+ const typeSelect = document.getElementById(`vx-type-${crewId}`);
653
+ const selectedType = typeSelect ? typeSelect.value : '';
654
+ const otherVal = getVal('type-other');
655
+ const vaccineType = selectedType === '__other__' ? otherVal : selectedType;
656
+ if (!vaccineType) {
657
+ alert('Please enter Vaccine Type/Disease');
658
+ if (selectedType === '__other__') {
659
+ const otherField = document.getElementById(`vx-type-other-${crewId}`);
660
+ if (otherField) otherField.focus();
661
+ } else if (typeSelect) {
662
+ typeSelect.focus();
663
+ }
664
+ return;
665
+ }
666
+ const entry = {
667
+ id: `vax-${Date.now()}`,
668
+ vaccineType,
669
+ dateAdministered: getVal('date'),
670
+ doseNumber: getVal('dose'),
671
+ tradeNameManufacturer: getVal('trade'),
672
+ lotNumber: getVal('lot'),
673
+ provider: getVal('provider'),
674
+ providerCountry: getVal('provider-country'),
675
+ nextDoseDue: getVal('next'),
676
+ expirationDate: getVal('exp'),
677
+ siteRoute: getVal('site'),
678
+ reactions: document.getElementById(`vx-reactions-${crewId}`)?.value.trim() || '',
679
+ remarks: document.getElementById(`vx-remarks-${crewId}`)?.value.trim() || ''
680
+ };
681
+
682
+ const data = await (await fetch('/api/data/patients', { credentials: 'same-origin' })).json();
683
+ const patient = data.find((p) => p.id === crewId);
684
+ if (!patient) {
685
+ alert('Crew member not found.');
686
+ return;
687
+ }
688
+ patient.vaccines = Array.isArray(patient.vaccines) ? patient.vaccines : [];
689
+ patient.vaccines.push(entry);
690
+ await fetch('/api/data/patients', {
691
+ method: 'POST',
692
+ headers: { 'Content-Type': 'application/json' },
693
+ body: JSON.stringify(data),
694
+ credentials: 'same-origin',
695
+ });
696
+ clearVaccineInputs(crewId);
697
+ loadData();
698
+ }
699
+
700
+ async function deleteVaccine(crewId, vaccineId) {
701
+ if (!vaccineId) return;
702
+ if (!confirm('Delete this vaccine record?')) return;
703
+ const data = await (await fetch('/api/data/patients', { credentials: 'same-origin' })).json();
704
+ const patient = data.find((p) => p.id === crewId);
705
+ if (!patient || !Array.isArray(patient.vaccines)) {
706
+ alert('Vaccine record not found.');
707
+ return;
708
+ }
709
+ patient.vaccines = patient.vaccines.filter((v) => v.id !== vaccineId);
710
+ await fetch('/api/data/patients', {
711
+ method: 'POST',
712
+ headers: { 'Content-Type': 'application/json' },
713
+ body: JSON.stringify(data),
714
+ credentials: 'same-origin',
715
+ });
716
+ loadData();
717
+ }
718
+
719
  // Auto-save profile (debounced to prevent excessive saves)
720
  let saveTimers = {};
721
  async function autoSaveProfile(id) {
 
1048
  setVal('vessel-tonnage', v.tonnage);
1049
  setVal('vessel-net-tonnage', v.netTonnage);
1050
  setVal('vessel-mmsi', v.mmsi);
1051
+ setVal('vessel-hull-number', v.hullNumber);
1052
+ setVal('vessel-starboard-engine', v.starboardEngine);
1053
+ setVal('vessel-starboard-sn', v.starboardEngineSn);
1054
+ setVal('vessel-port-engine', v.portEngine);
1055
+ setVal('vessel-port-sn', v.portEngineSn);
1056
+ setVal('vessel-rib-sn', v.ribSn);
1057
  }
1058
 
1059
  async function saveVesselInfo() {
 
1065
  callSign: document.getElementById('vessel-callsign')?.value || '',
1066
  tonnage: document.getElementById('vessel-tonnage')?.value || '',
1067
  netTonnage: document.getElementById('vessel-net-tonnage')?.value || '',
1068
+ mmsi: document.getElementById('vessel-mmsi')?.value || '',
1069
+ hullNumber: document.getElementById('vessel-hull-number')?.value || '',
1070
+ starboardEngine: document.getElementById('vessel-starboard-engine')?.value || '',
1071
+ starboardEngineSn: document.getElementById('vessel-starboard-sn')?.value || '',
1072
+ portEngine: document.getElementById('vessel-port-engine')?.value || '',
1073
+ portEngineSn: document.getElementById('vessel-port-sn')?.value || '',
1074
+ ribSn: document.getElementById('vessel-rib-sn')?.value || ''
1075
  };
1076
  await fetch('/api/data/vessel', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(v), credentials:'same-origin'});
1077
  alert('Vessel information saved.');
static/js/equipment.js CHANGED
@@ -1005,6 +1005,10 @@ async function addMedicationItem() {
1005
  return;
1006
  }
1007
  const category = getNewEquipmentVal('med-new-cat') || 'Medication';
 
 
 
 
1008
  const exclude = document.getElementById('med-new-exclude')?.checked || false;
1009
  const newId = `med-${Date.now()}`;
1010
  const newMed = {
@@ -1024,6 +1028,8 @@ async function addMedicationItem() {
1024
  primaryIndication: '',
1025
  allergyWarnings: '',
1026
  standardDosage: '',
 
 
1027
  photos: [],
1028
  purchaseHistory: [],
1029
  source: 'manual_entry',
@@ -1044,6 +1050,12 @@ async function addMedicationItem() {
1044
 
1045
  ['med-new-name','med-new-cat','med-new-loc','med-new-subloc','med-new-parent','med-new-exp','med-new-inspect','med-new-batt','med-new-cal','med-new-qty','med-new-par','med-new-sup','med-new-notes']
1046
  .forEach((id) => { const el = document.getElementById(id); if (el) el.value = ''; });
 
 
 
 
 
 
1047
  const medExclude = document.getElementById('med-new-exclude');
1048
  if (medExclude) medExclude.checked = false;
1049
  const typeSel = document.getElementById('med-new-type');
 
1005
  return;
1006
  }
1007
  const category = getNewEquipmentVal('med-new-cat') || 'Medication';
1008
+ const sortSel = document.getElementById('med-new-sort');
1009
+ const sortCustom = document.getElementById('med-new-sort-custom');
1010
+ const sortCategory = sortSel ? (sortSel.value === 'Other' ? (sortCustom?.value || '') : (sortSel.value || '')) : '';
1011
+ const verified = !!document.getElementById('med-new-verified')?.checked;
1012
  const exclude = document.getElementById('med-new-exclude')?.checked || false;
1013
  const newId = `med-${Date.now()}`;
1014
  const newMed = {
 
1028
  primaryIndication: '',
1029
  allergyWarnings: '',
1030
  standardDosage: '',
1031
+ sortCategory,
1032
+ verified,
1033
  photos: [],
1034
  purchaseHistory: [],
1035
  source: 'manual_entry',
 
1050
 
1051
  ['med-new-name','med-new-cat','med-new-loc','med-new-subloc','med-new-parent','med-new-exp','med-new-inspect','med-new-batt','med-new-cal','med-new-qty','med-new-par','med-new-sup','med-new-notes']
1052
  .forEach((id) => { const el = document.getElementById(id); if (el) el.value = ''; });
1053
+ const sortSelect = document.getElementById('med-new-sort');
1054
+ const sortCustomInput = document.getElementById('med-new-sort-custom');
1055
+ if (sortSelect) sortSelect.value = '';
1056
+ if (sortCustomInput) { sortCustomInput.value = ''; sortCustomInput.style.display = 'none'; }
1057
+ const medVerified = document.getElementById('med-new-verified');
1058
+ if (medVerified) medVerified.checked = false;
1059
  const medExclude = document.getElementById('med-new-exclude');
1060
  if (medExclude) medExclude.checked = false;
1061
  const typeSel = document.getElementById('med-new-type');
static/js/main.js CHANGED
@@ -146,18 +146,26 @@ async function showTab(e, n) {
146
  async function loadData() {
147
  console.log('[DEBUG] loadData: start');
148
  try {
149
- const [res, historyRes] = await Promise.all([
150
  fetch('/api/data/patients', { credentials: 'same-origin' }),
151
  fetch('/api/data/history', { credentials: 'same-origin' }),
 
152
  ]);
153
- console.log('[DEBUG] loadData: status patients', res.status, 'history', historyRes.status);
154
  if (!res.ok) throw new Error(`Patients request failed: ${res.status}`);
155
  if (!historyRes.ok) throw new Error(`History request failed: ${historyRes.status}`);
 
156
  const data = await res.json();
157
  const history = await historyRes.json();
 
 
 
 
 
 
158
  console.log('[DEBUG] loadData: patients length', Array.isArray(data) ? data.length : 'n/a');
159
  if (!Array.isArray(data)) throw new Error('Unexpected patients data format');
160
- loadCrewData(data, Array.isArray(history) ? history : []);
161
  } catch (err) {
162
  console.error('[DEBUG] Failed to load crew data', err);
163
  // Gracefully clear UI to avoid JS errors
 
146
  async function loadData() {
147
  console.log('[DEBUG] loadData: start');
148
  try {
149
+ const [res, historyRes, settingsRes] = await Promise.all([
150
  fetch('/api/data/patients', { credentials: 'same-origin' }),
151
  fetch('/api/data/history', { credentials: 'same-origin' }),
152
+ fetch('/api/data/settings', { credentials: 'same-origin' })
153
  ]);
154
+ console.log('[DEBUG] loadData: status patients', res.status, 'history', historyRes.status, 'settings', settingsRes.status);
155
  if (!res.ok) throw new Error(`Patients request failed: ${res.status}`);
156
  if (!historyRes.ok) throw new Error(`History request failed: ${historyRes.status}`);
157
+ if (!settingsRes.ok) console.warn('Settings request failed:', settingsRes.status);
158
  const data = await res.json();
159
  const history = await historyRes.json();
160
+ let settings = {};
161
+ try {
162
+ settings = await settingsRes.json();
163
+ } catch (err) {
164
+ console.warn('Settings parse failed, using defaults.', err);
165
+ }
166
  console.log('[DEBUG] loadData: patients length', Array.isArray(data) ? data.length : 'n/a');
167
  if (!Array.isArray(data)) throw new Error('Unexpected patients data format');
168
+ loadCrewData(data, Array.isArray(history) ? history : [], settings || {});
169
  } catch (err) {
170
  console.error('[DEBUG] Failed to load crew data', err);
171
  // Gracefully clear UI to avoid JS errors
static/js/pharmacy.js CHANGED
@@ -117,6 +117,28 @@ function getTextareaHeights() {
117
  return map;
118
  }
119
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  function ensurePurchaseDefaults(p) {
121
  return {
122
  id: p.id || `ph-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
@@ -145,6 +167,8 @@ function ensurePharmacyDefaults(item) {
145
  primaryIndication: item.primaryIndication || '',
146
  allergyWarnings: item.allergyWarnings || '',
147
  standardDosage: item.standardDosage || '',
 
 
148
  photos: Array.isArray(item.photos) ? item.photos : [],
149
  purchaseHistory: Array.isArray(item.purchaseHistory)
150
  ? item.purchaseHistory.map(ensurePurchaseDefaults)
@@ -155,12 +179,12 @@ function ensurePharmacyDefaults(item) {
155
  };
156
  }
157
 
158
- function scheduleSaveMedication(id) {
159
  if (pharmacySaveTimers[id]) {
160
  clearTimeout(pharmacySaveTimers[id]);
161
  }
162
  pharmacySaveTimers[id] = setTimeout(() => {
163
- saveMedication(id);
164
  }, 400);
165
  }
166
 
@@ -180,13 +204,17 @@ function getMedicationDisplayName(med) {
180
  function sortPharmacyItems(items) {
181
  const list = Array.isArray(items) ? [...items] : [];
182
  const sortSel = document.getElementById('pharmacy-sort');
183
- const mode = (sortSel && sortSel.value) || 'generic';
184
  const byText = (a, b, pathA, pathB) => {
185
  const va = (pathA || '').toLowerCase();
186
  const vb = (pathB || '').toLowerCase();
187
  return va.localeCompare(vb);
188
  };
189
  list.sort((a, b) => {
 
 
 
 
190
  if (mode === 'brand') {
191
  return byText(a, b, a.brandName || '', b.brandName || '');
192
  }
@@ -224,6 +252,12 @@ async function loadPharmacy() {
224
  }
225
  }
226
 
 
 
 
 
 
 
227
  function getOpenMedIds() {
228
  return Array.from(document.querySelectorAll('#pharmacy-list .history-item .col-body[data-med-id]'))
229
  .filter((el) => el.style.display !== 'none')
@@ -296,6 +330,7 @@ function renderMedicationCard(med, isOpen = true, textHeights = {}) {
296
  const headerNote = [lowStock ? 'Low Stock' : null, expirySoon ? 'Expiring Soon' : null].filter(Boolean).join(' · ');
297
  const displayName = getMedicationDisplayName(med);
298
  const strength = (med.strength || '').trim();
 
299
  const bodyDisplay = isOpen ? 'display:block;' : 'display:none;';
300
  const arrow = isOpen ? '▾' : '▸';
301
  const headerBg = med.excludeFromResources ? '#ffecef' : '#eef7ff';
@@ -304,7 +339,10 @@ function renderMedicationCard(med, isOpen = true, textHeights = {}) {
304
  const bodyBorderColor = med.excludeFromResources ? '#ffcfd0' : '#cfe9d5';
305
  const badgeColor = med.excludeFromResources ? '#d32f2f' : '#2e7d32';
306
  const badgeText = med.excludeFromResources ? 'Resource Currently Unavailable' : 'Resource Available';
307
- 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>`;
 
 
 
308
  const doseHeight = textHeights[`dose-${med.id}`] ? `height:${textHeights[`dose-${med.id}`]};` : '';
309
  const photoThumbs = (med.photos || []).map(
310
  (src, idx) => `
@@ -322,9 +360,14 @@ function renderMedicationCard(med, isOpen = true, textHeights = {}) {
322
  <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:700;">
323
  ${displayName}${strength ? ' — ' + strength : ''}
324
  </span>
 
325
  ${headerNote ? `<span class="sidebar-pill" style="margin-right:8px; background:${lowStock ? '#ffebee' : '#fff7e0'}; color:${lowStock ? '#c62828' : '#b26a00'};">${headerNote}</span>` : ''}
326
  <button onclick="event.stopPropagation(); deleteMedication('${med.id}')" class="btn btn-sm history-action-btn" style="background:var(--red); visibility:hidden;">🗑 Delete Medication</button>
327
- ${availabilityBadge}
 
 
 
 
328
  </div>
329
  <div class="col-body" data-med-id="${med.id}" style="padding:12px; background:${bodyBg}; border:1px solid ${bodyBorderColor}; border-radius:6px; ${bodyDisplay}">
330
  <div class="collapsible" style="margin-bottom:10px;">
@@ -335,10 +378,10 @@ function renderMedicationCard(med, isOpen = true, textHeights = {}) {
335
  </div>
336
  <div class="col-body" style="padding:10px; display:block;" id="details-${med.id}">
337
  <div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
338
- <input id="exclude-${med.id}" type="checkbox" ${med.excludeFromResources ? 'checked' : ''} onchange="scheduleSaveMedication('${med.id}')">
339
- <label style="font-size:12px; line-height:1.2; margin:0;">Resource Currently Unavailable</label>
340
- </div>
341
- <div style="display:grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap:10px; margin-bottom:10px;">
342
  <div>
343
  <label style="font-weight:700; font-size:12px;">Generic Name</label>
344
  <input id="gn-${med.id}" type="text" value="${med.genericName}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
@@ -389,6 +432,21 @@ function renderMedicationCard(med, isOpen = true, textHeights = {}) {
389
  <option value="true" ${med.controlled ? 'selected' : ''}>Yes</option>
390
  </select>
391
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  <div>
393
  <label style="font-weight:700; font-size:12px;">Manufacturer</label>
394
  <input id="manu-${med.id}" type="text" value="${med.manufacturer}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
@@ -625,7 +683,7 @@ function addPurchaseEntry(medId) {
625
  scheduleSaveMedication(medId);
626
  }
627
 
628
- async function saveMedication(id) {
629
  const openMedIds = getOpenMedIds();
630
  const textHeights = getTextareaHeights();
631
  const data = await (await fetchInventory()).json();
@@ -655,6 +713,8 @@ async function saveMedication(id) {
655
  med.allergyWarnings = document.getElementById(`alg-${id}`)?.value || '';
656
  med.standardDosage = document.getElementById(`dose-${id}`)?.value || '';
657
  med.excludeFromResources = !!document.getElementById(`exclude-${id}`)?.checked;
 
 
658
  med.purchaseHistory = collectPurchaseEntries(id);
659
 
660
  await fetchInventory({
@@ -663,7 +723,9 @@ async function saveMedication(id) {
663
  body: JSON.stringify(meds),
664
  });
665
  pharmacyCache = meds;
666
- renderPharmacy(pharmacyCache, openMedIds, textHeights);
 
 
667
  }
668
 
669
  function collectPurchaseEntries(medId) {
 
117
  return map;
118
  }
119
 
120
+ function handleSortCategoryChange(id) {
121
+ const select = document.getElementById(`sort-${id}`);
122
+ const custom = document.getElementById(`sort-custom-${id}`);
123
+ if (!select || !custom) return;
124
+ const val = select.value;
125
+ const isCustom = val === 'Other';
126
+ custom.style.display = isCustom ? 'block' : 'none';
127
+ if (!isCustom) {
128
+ custom.value = '';
129
+ }
130
+ scheduleSaveMedication(id, true);
131
+ }
132
+
133
+ function toggleCustomSortField() {
134
+ const sel = document.getElementById('med-new-sort');
135
+ const custom = document.getElementById('med-new-sort-custom');
136
+ if (!sel || !custom) return;
137
+ const isCustom = sel.value === 'Other';
138
+ custom.style.display = isCustom ? 'block' : 'none';
139
+ if (!isCustom) custom.value = '';
140
+ }
141
+
142
  function ensurePurchaseDefaults(p) {
143
  return {
144
  id: p.id || `ph-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
 
167
  primaryIndication: item.primaryIndication || '',
168
  allergyWarnings: item.allergyWarnings || '',
169
  standardDosage: item.standardDosage || '',
170
+ sortCategory: item.sortCategory || '',
171
+ verified: !!item.verified,
172
  photos: Array.isArray(item.photos) ? item.photos : [],
173
  purchaseHistory: Array.isArray(item.purchaseHistory)
174
  ? item.purchaseHistory.map(ensurePurchaseDefaults)
 
179
  };
180
  }
181
 
182
+ function scheduleSaveMedication(id, rerender = false) {
183
  if (pharmacySaveTimers[id]) {
184
  clearTimeout(pharmacySaveTimers[id]);
185
  }
186
  pharmacySaveTimers[id] = setTimeout(() => {
187
+ saveMedication(id, rerender);
188
  }, 400);
189
  }
190
 
 
204
  function sortPharmacyItems(items) {
205
  const list = Array.isArray(items) ? [...items] : [];
206
  const sortSel = document.getElementById('pharmacy-sort');
207
+ const mode = (sortSel && sortSel.value) || 'sortCategory';
208
  const byText = (a, b, pathA, pathB) => {
209
  const va = (pathA || '').toLowerCase();
210
  const vb = (pathB || '').toLowerCase();
211
  return va.localeCompare(vb);
212
  };
213
  list.sort((a, b) => {
214
+ if (mode === 'sortCategory') {
215
+ const cat = byText(a, b, a.sortCategory || '', b.sortCategory || '');
216
+ if (cat !== 0) return cat;
217
+ }
218
  if (mode === 'brand') {
219
  return byText(a, b, a.brandName || '', b.brandName || '');
220
  }
 
252
  }
253
  }
254
 
255
+ function handlePharmacySortChange() {
256
+ const openIds = getOpenMedIds();
257
+ const textHeights = getTextareaHeights();
258
+ renderPharmacy(pharmacyCache, openIds, textHeights);
259
+ }
260
+
261
  function getOpenMedIds() {
262
  return Array.from(document.querySelectorAll('#pharmacy-list .history-item .col-body[data-med-id]'))
263
  .filter((el) => el.style.display !== 'none')
 
330
  const headerNote = [lowStock ? 'Low Stock' : null, expirySoon ? 'Expiring Soon' : null].filter(Boolean).join(' · ');
331
  const displayName = getMedicationDisplayName(med);
332
  const strength = (med.strength || '').trim();
333
+ const sortLabel = med.sortCategory ? `<span style="font-size:11px; color:#455a64; margin-right:8px;">${med.sortCategory}</span>` : '';
334
  const bodyDisplay = isOpen ? 'display:block;' : 'display:none;';
335
  const arrow = isOpen ? '▾' : '▸';
336
  const headerBg = med.excludeFromResources ? '#ffecef' : '#eef7ff';
 
339
  const bodyBorderColor = med.excludeFromResources ? '#ffcfd0' : '#cfe9d5';
340
  const badgeColor = med.excludeFromResources ? '#d32f2f' : '#2e7d32';
341
  const badgeText = med.excludeFromResources ? 'Resource Currently Unavailable' : 'Resource Available';
342
+ const availabilityBadge = `<span style="padding:2px 10px; border-radius:999px; background:${badgeColor}; color:#fff; font-size:11px; white-space:nowrap;">${badgeText}</span>`;
343
+ const verifiedBadge = med.verified
344
+ ? `<span style="padding:2px 10px; border-radius:999px; background:#1b5e20; color:#fff; font-size:11px; white-space:nowrap;">Verified</span>`
345
+ : `<span style="padding:2px 10px; border-radius:999px; background:transparent; color:#1b5e20; font-size:11px; white-space:nowrap; border:1px dashed #b2c7b5;">Not verified</span>`;
346
  const doseHeight = textHeights[`dose-${med.id}`] ? `height:${textHeights[`dose-${med.id}`]};` : '';
347
  const photoThumbs = (med.photos || []).map(
348
  (src, idx) => `
 
360
  <span style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:700;">
361
  ${displayName}${strength ? ' — ' + strength : ''}
362
  </span>
363
+ ${sortLabel}
364
  ${headerNote ? `<span class="sidebar-pill" style="margin-right:8px; background:${lowStock ? '#ffebee' : '#fff7e0'}; color:${lowStock ? '#c62828' : '#b26a00'};">${headerNote}</span>` : ''}
365
  <button onclick="event.stopPropagation(); deleteMedication('${med.id}')" class="btn btn-sm history-action-btn" style="background:var(--red); visibility:hidden;">🗑 Delete Medication</button>
366
+ <div style="display:flex; align-items:center; gap:6px; margin-left:8px;">${verifiedBadge}${availabilityBadge}</div>
367
+ <label style="display:flex; align-items:center; gap:4px; font-size:11px; margin-left:8px; padding:4px 8px; border:1px solid #c7ddff; border-radius:6px; background:#fff;" onclick="event.stopPropagation();">
368
+ <input id="ver-${med.id}" type="checkbox" ${med.verified ? 'checked' : ''} onchange="scheduleSaveMedication('${med.id}', true); event.stopPropagation();">
369
+ Verified
370
+ </label>
371
  </div>
372
  <div class="col-body" data-med-id="${med.id}" style="padding:12px; background:${bodyBg}; border:1px solid ${bodyBorderColor}; border-radius:6px; ${bodyDisplay}">
373
  <div class="collapsible" style="margin-bottom:10px;">
 
378
  </div>
379
  <div class="col-body" style="padding:10px; display:block;" id="details-${med.id}">
380
  <div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
381
+ <input id="exclude-${med.id}" type="checkbox" ${med.excludeFromResources ? 'checked' : ''} onchange="scheduleSaveMedication('${med.id}', true)">
382
+ <label style="font-size:12px; line-height:1.2; margin:0;">Resource Currently Unavailable</label>
383
+ </div>
384
+ <div style="display:grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap:10px; margin-bottom:10px;">
385
  <div>
386
  <label style="font-weight:700; font-size:12px;">Generic Name</label>
387
  <input id="gn-${med.id}" type="text" value="${med.genericName}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
 
432
  <option value="true" ${med.controlled ? 'selected' : ''}>Yes</option>
433
  </select>
434
  </div>
435
+ <div>
436
+ <label style="font-weight:700; font-size:12px;">Sort Category</label>
437
+ <select id="sort-${med.id}" style="width:100%; padding:8px;" onchange="handleSortCategoryChange('${med.id}')">
438
+ <option value="">Select...</option>
439
+ <option value="Antibiotic" ${med.sortCategory === 'Antibiotic' ? 'selected' : ''}>Antibiotic</option>
440
+ <option value="Analgesic" ${med.sortCategory === 'Analgesic' ? 'selected' : ''}>Analgesic</option>
441
+ <option value="Cardiac" ${med.sortCategory === 'Cardiac' ? 'selected' : ''}>Cardiac</option>
442
+ <option value="Respiratory" ${med.sortCategory === 'Respiratory' ? 'selected' : ''}>Respiratory</option>
443
+ <option value="Gastrointestinal" ${med.sortCategory === 'Gastrointestinal' ? 'selected' : ''}>Gastrointestinal</option>
444
+ <option value="Endocrine" ${med.sortCategory === 'Endocrine' ? 'selected' : ''}>Endocrine</option>
445
+ <option value="Emergency" ${med.sortCategory === 'Emergency' ? 'selected' : ''}>Emergency</option>
446
+ <option value="Other" ${med.sortCategory && !['Antibiotic','Analgesic','Cardiac','Respiratory','Gastrointestinal','Endocrine','Emergency'].includes(med.sortCategory) ? 'selected' : ''}>Custom...</option>
447
+ </select>
448
+ <input id="sort-custom-${med.id}" type="text" value="${(['Antibiotic','Analgesic','Cardiac','Respiratory','Gastrointestinal','Endocrine','Emergency'].includes(med.sortCategory) ? '' : med.sortCategory) || ''}" placeholder="Custom category" style="width:100%; padding:6px; margin-top:6px; ${med.sortCategory && !['Antibiotic','Analgesic','Cardiac','Respiratory','Gastrointestinal','Endocrine','Emergency'].includes(med.sortCategory) ? '' : 'display:none;'}" oninput="scheduleSaveMedication('${med.id}', true)">
449
+ </div>
450
  <div>
451
  <label style="font-weight:700; font-size:12px;">Manufacturer</label>
452
  <input id="manu-${med.id}" type="text" value="${med.manufacturer}" style="width:100%; padding:8px;" oninput="scheduleSaveMedication('${med.id}')">
 
683
  scheduleSaveMedication(medId);
684
  }
685
 
686
+ async function saveMedication(id, rerender = false) {
687
  const openMedIds = getOpenMedIds();
688
  const textHeights = getTextareaHeights();
689
  const data = await (await fetchInventory()).json();
 
713
  med.allergyWarnings = document.getElementById(`alg-${id}`)?.value || '';
714
  med.standardDosage = document.getElementById(`dose-${id}`)?.value || '';
715
  med.excludeFromResources = !!document.getElementById(`exclude-${id}`)?.checked;
716
+ med.sortCategory = document.getElementById(`sort-${id}`)?.value || document.getElementById(`sort-custom-${id}`)?.value || '';
717
+ med.verified = !!document.getElementById(`ver-${id}`)?.checked;
718
  med.purchaseHistory = collectPurchaseEntries(id);
719
 
720
  await fetchInventory({
 
723
  body: JSON.stringify(meds),
724
  });
725
  pharmacyCache = meds;
726
+ if (rerender) {
727
+ renderPharmacy(pharmacyCache, openMedIds, textHeights);
728
+ }
729
  }
730
 
731
  function collectPurchaseEntries(medId) {
static/js/settings.js CHANGED
@@ -13,7 +13,8 @@ const DEFAULT_SETTINGS = {
13
  rep_penalty: 1.1,
14
  user_mode: "user",
15
  med_photo_model: "qwen",
16
- med_photo_prompt: "You are a pharmacy intake assistant on a sailing vessel. Look at the medication photo and return JSON only with keys: generic_name, brand_name, form, strength, expiry_date, batch_lot, storage_location, manufacturer, indication, allergy_warnings, dosage, notes."
 
17
  };
18
 
19
  let settingsDirty = false;
@@ -21,6 +22,7 @@ let settingsLoaded = false;
21
  let settingsAutoSaveTimer = null;
22
  let workspaceListLoaded = false;
23
  let offlineStatusCache = null;
 
24
 
25
  function setUserMode(mode) {
26
  const body = document.body;
@@ -42,6 +44,8 @@ function setUserMode(mode) {
42
 
43
  function applySettingsToUI(data = {}) {
44
  const merged = { ...DEFAULT_SETTINGS, ...(data || {}) };
 
 
45
  Object.keys(merged).forEach(k => {
46
  const el = document.getElementById(k);
47
  if (el) {
@@ -164,6 +168,7 @@ async function saveSettings(showAlert = true, reason = 'manual') {
164
  s[k] = val;
165
  }
166
  });
 
167
  console.log('[settings] saving', { reason, payload: s });
168
  updateSettingsStatus('Saving…', false);
169
  const headers = { 'Content-Type': 'application/json' };
@@ -188,7 +193,7 @@ async function saveSettings(showAlert = true, reason = 'manual') {
188
  const updated = await res.json();
189
  console.log('[settings] save response', updated);
190
  // Preserve the locally selected user_mode to avoid flicker if the server echoes stale data
191
- const merged = { ...updated, user_mode: s.user_mode || updated.user_mode };
192
  applySettingsToUI(merged);
193
  try { localStorage.setItem('user_mode', updated.user_mode || 'user'); } catch (err) { /* ignore */ }
194
  settingsDirty = false;
@@ -232,6 +237,77 @@ function resetSection(section) {
232
  saveSettings();
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  function renderOfflineStatus(msg, isError = false) {
236
  const box = document.getElementById('offline-status');
237
  if (!box) return;
@@ -528,3 +604,6 @@ window.setUserMode = setUserMode;
528
  window.runOfflineCheck = runOfflineCheck;
529
  window.createOfflineBackup = createOfflineBackup;
530
  window.restoreOfflineBackup = restoreOfflineBackup;
 
 
 
 
13
  rep_penalty: 1.1,
14
  user_mode: "user",
15
  med_photo_model: "qwen",
16
+ med_photo_prompt: "You are a pharmacy intake assistant on a sailing vessel. Look at the medication photo and return JSON only with keys: generic_name, brand_name, form, strength, expiry_date, batch_lot, storage_location, manufacturer, indication, allergy_warnings, dosage, notes.",
17
+ vaccine_types: ["MMR", "DTaP", "HepB", "HepA", "Td/Tdap", "Influenza", "COVID-19"]
18
  };
19
 
20
  let settingsDirty = false;
 
22
  let settingsAutoSaveTimer = null;
23
  let workspaceListLoaded = false;
24
  let offlineStatusCache = null;
25
+ let vaccineTypeList = [...DEFAULT_SETTINGS.vaccine_types];
26
 
27
  function setUserMode(mode) {
28
  const body = document.body;
 
44
 
45
  function applySettingsToUI(data = {}) {
46
  const merged = { ...DEFAULT_SETTINGS, ...(data || {}) };
47
+ vaccineTypeList = normalizeVaccineTypes(merged.vaccine_types);
48
+ renderVaccineTypes();
49
  Object.keys(merged).forEach(k => {
50
  const el = document.getElementById(k);
51
  if (el) {
 
168
  s[k] = val;
169
  }
170
  });
171
+ s.vaccine_types = normalizeVaccineTypes(vaccineTypeList);
172
  console.log('[settings] saving', { reason, payload: s });
173
  updateSettingsStatus('Saving…', false);
174
  const headers = { 'Content-Type': 'application/json' };
 
193
  const updated = await res.json();
194
  console.log('[settings] save response', updated);
195
  // Preserve the locally selected user_mode to avoid flicker if the server echoes stale data
196
+ const merged = { ...updated, user_mode: s.user_mode || updated.user_mode, vaccine_types: s.vaccine_types || updated.vaccine_types };
197
  applySettingsToUI(merged);
198
  try { localStorage.setItem('user_mode', updated.user_mode || 'user'); } catch (err) { /* ignore */ }
199
  settingsDirty = false;
 
237
  saveSettings();
238
  }
239
 
240
+ function normalizeVaccineTypes(list) {
241
+ if (!Array.isArray(list)) return [...DEFAULT_SETTINGS.vaccine_types];
242
+ const seen = new Set();
243
+ return list
244
+ .map((v) => (typeof v === 'string' ? v.trim() : ''))
245
+ .filter((v) => !!v)
246
+ .filter((v) => {
247
+ const key = v.toLowerCase();
248
+ if (seen.has(key)) return false;
249
+ seen.add(key);
250
+ return true;
251
+ });
252
+ }
253
+
254
+ function renderVaccineTypes() {
255
+ const container = document.getElementById('vaccine-types-list');
256
+ if (!container) return;
257
+ if (!vaccineTypeList.length) {
258
+ container.innerHTML = '<div style="color:#666; font-size:12px;">No vaccine types defined. Add at least one to enable the dropdown.</div>';
259
+ return;
260
+ }
261
+ container.innerHTML = vaccineTypeList
262
+ .map((v, idx) => `
263
+ <div style="display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid #eee;">
264
+ <div style="width:28px; text-align:right; font-weight:700; color:#666;">${idx + 1}.</div>
265
+ <div style="flex:1; font-weight:600;">${v}</div>
266
+ <div style="display:flex; gap:6px; align-items:center;">
267
+ <button class="btn btn-sm" style="background:#607d8b;" ${idx === 0 ? 'disabled' : ''} onclick="moveVaccineType(${idx}, -1)">↑</button>
268
+ <button class="btn btn-sm" style="background:#607d8b;" ${idx === vaccineTypeList.length - 1 ? 'disabled' : ''} onclick="moveVaccineType(${idx}, 1)">↓</button>
269
+ <button class="btn btn-sm" style="background:var(--red);" onclick="removeVaccineType(${idx})">🗑 Remove</button>
270
+ </div>
271
+ </div>
272
+ `).join('');
273
+ }
274
+
275
+ function addVaccineType() {
276
+ const input = document.getElementById('vaccine-type-input');
277
+ if (!input) return;
278
+ const val = input.value.trim();
279
+ if (!val) {
280
+ alert('Enter a vaccine type before adding.');
281
+ return;
282
+ }
283
+ vaccineTypeList = normalizeVaccineTypes([...vaccineTypeList, val]);
284
+ input.value = '';
285
+ renderVaccineTypes();
286
+ settingsDirty = true;
287
+ scheduleAutoSave('vaccine-type-add');
288
+ }
289
+
290
+ function removeVaccineType(idx) {
291
+ if (idx < 0 || idx >= vaccineTypeList.length) return;
292
+ vaccineTypeList.splice(idx, 1);
293
+ vaccineTypeList = normalizeVaccineTypes(vaccineTypeList);
294
+ renderVaccineTypes();
295
+ settingsDirty = true;
296
+ scheduleAutoSave('vaccine-type-remove');
297
+ }
298
+
299
+ function moveVaccineType(idx, delta) {
300
+ const newIndex = idx + delta;
301
+ if (newIndex < 0 || newIndex >= vaccineTypeList.length) return;
302
+ const nextList = [...vaccineTypeList];
303
+ const [item] = nextList.splice(idx, 1);
304
+ nextList.splice(newIndex, 0, item);
305
+ vaccineTypeList = normalizeVaccineTypes(nextList);
306
+ renderVaccineTypes();
307
+ settingsDirty = true;
308
+ scheduleAutoSave('vaccine-type-reorder');
309
+ }
310
+
311
  function renderOfflineStatus(msg, isError = false) {
312
  const box = document.getElementById('offline-status');
313
  if (!box) return;
 
604
  window.runOfflineCheck = runOfflineCheck;
605
  window.createOfflineBackup = createOfflineBackup;
606
  window.restoreOfflineBackup = restoreOfflineBackup;
607
+ window.addVaccineType = addVaccineType;
608
+ window.removeVaccineType = removeVaccineType;
609
+ window.moveVaccineType = moveVaccineType;
templates/index.html CHANGED
@@ -184,6 +184,38 @@
184
  .phone-block { display: block; }
185
  .phone-import-panel { width: 100%; min-height: 130vh; }
186
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  </style>
188
  <script>
189
  window.WORKSPACE_LABEL = "{{ workspace.label if workspace else '' }}";
@@ -268,6 +300,12 @@
268
  <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
269
  <span class="dev-tag">dev:triage-message</span>
270
  </div>
 
 
 
 
 
 
271
  <textarea id="msg" style="height:105px;" placeholder="Describe what's happening"></textarea>
272
  <div id="triage-meta-selects" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:10px; margin-top:10px;">
273
  <div>
@@ -368,7 +406,7 @@
368
  </div>
369
  <div style="display:flex; gap:10px; margin-top:10px; align-items:center; flex-wrap:wrap;">
370
  <button onclick="runChat()" id="run-btn" class="btn" style="flex-grow:1; background:var(--triage); min-width:160px;">SUBMIT FOR TRIAGE</button>
371
- <button onclick="repeatLast()" id="repeat-btn" class="btn user-adv-only" style="background:#555; min-width:120px;">REPEAT LAST</button>
372
  </div>
373
  </div>
374
  </div>
@@ -432,6 +470,10 @@
432
  <span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Pharmaceuticals</span><span id="pharmacy-count" style="margin-left:8px; font-weight:700; font-size:12px; color:#1f2d3d;">(0)</span><span class="dev-tag">dev:medicines-shell</span>
433
  </div>
434
  <div class="col-body" style="padding:12px; display:none; background:#f5f8ff; border:1px solid #d7e2ff; border-top:none;">
 
 
 
 
435
  <div class="collapsible" style="margin-bottom:12px;">
436
  <div id="medication-add-header" class="col-header crew-med-header" data-sidebar-id="equipment-medication-add" onclick="toggleSection(this)" style="background:#fff; justify-content:flex-start;">
437
  <span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Add Pharmaceuticals</span><span class="dev-tag">dev:medication-add</span>
@@ -480,6 +522,25 @@
480
  <option value="Damaged/Out of Service">Damaged/Out of Service</option>
481
  </select>
482
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  <div>
484
  <label style="font-weight:700; font-size:12px;">Expiry Date</label>
485
  <input id="med-new-exp" type="date" style="width:100%; padding:8px;">
@@ -571,10 +632,20 @@
571
  </div>
572
  </div>
573
  </div>
574
- <div class="collapsible" style="margin-bottom:12px;">
575
  <div class="col-body" style="padding:12px; background:#f8f9fa; display:block;">
576
  <div class="dev-tag" style="margin-bottom:6px;">dev:pharmacy-inventory</div>
577
  <div class="dev-tag" style="margin-bottom:6px;">dev:pharmacy-controls</div>
 
 
 
 
 
 
 
 
 
 
578
  <div class="dev-tag" style="margin-bottom:6px;">dev:pharmacy-list</div>
579
  <div id="pharmacy-list"></div>
580
  </div>
@@ -693,6 +764,7 @@
693
  <div class="page-body sidebar-open">
694
  <div class="page-main">
695
  <div class="page-shell">
 
696
  <div class="panel-wrapper">
697
  <div class="collapsible" style="margin-bottom:15px;">
698
  <div class="col-header crew-med-header" data-sidebar-id="vessel-info" onclick="toggleSection(this)" style="background:#e3f2fd; border:1px solid #c5ddf8; justify-content:flex-start;">
@@ -702,39 +774,71 @@
702
  <div class="dev-tag" style="margin-bottom:6px;">dev:vessel-fields</div>
703
  <div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:12px; font-size:15px;">
704
  <div>
 
705
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Vessel Name</label>
706
  <input type="text" id="vessel-name" style="padding:8px; width:100%;">
707
  </div>
708
  <div>
 
709
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Registration Number</label>
710
  <input type="text" id="vessel-registration" style="padding:8px; width:100%;">
711
  </div>
712
  <div>
 
713
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Flag Country</label>
714
  <input type="text" id="vessel-flag" style="padding:8px; width:100%;">
715
  </div>
716
  <div>
 
717
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Home Port</label>
718
  <input type="text" id="vessel-homeport" style="padding:8px; width:100%;">
719
  </div>
720
  <div>
 
721
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Gross Tonnage</label>
722
  <input type="text" id="vessel-tonnage" style="padding:8px; width:100%;">
723
  </div>
724
  <div>
 
725
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Net/Register Tonnage</label>
726
  <input type="text" id="vessel-net-tonnage" style="padding:8px; width:100%;">
727
  </div>
728
  <div>
 
729
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Call Sign</label>
730
  <input type="text" id="vessel-callsign" style="padding:8px; width:100%;">
731
  </div>
732
  <div>
 
733
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">MMSI Number</label>
734
  <input type="text" id="vessel-mmsi" style="padding:8px; width:100%;">
735
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  </div>
737
- <button onclick="saveVesselInfo()" class="btn btn-sm" style="background:var(--inquiry); width:100%;">Save Vessel Info</button>
738
  </div>
739
  </div>
740
  <div class="collapsible" style="margin-bottom:15px;">
@@ -749,12 +853,12 @@
749
  <div class="col-body" style="padding:15px; background:#f8f9fa;">
750
  <div class="dev-tag" style="margin-bottom:6px;">dev:crew-add-fields</div>
751
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
752
- <div><label style="font-size:13px; margin-bottom:2px;">First Name *</label><input type="text" id="cn-first" style="padding:6px; width:100%;"></div>
753
- <div><label style="font-size:13px; margin-bottom:2px;">Middle Name(s)</label><input type="text" id="cn-middle" style="padding:6px; width:100%;"></div>
754
- <div><label style="font-size:13px; margin-bottom:2px;">Last Name *</label><input type="text" id="cn-last" style="padding:6px; width:100%;"></div>
755
  </div>
756
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
757
- <div><label style="font-size:13px; margin-bottom:2px;">Sex</label><select id="cn-sex" style="padding:6px; width:100%;">
758
  <option value="">Select...</option>
759
  <option value="Male">Male</option>
760
  <option value="Female">Female</option>
@@ -762,8 +866,8 @@
762
  <option value="Other">Other</option>
763
  <option value="Prefer not to say">Prefer not to say</option>
764
  </select></div>
765
- <div><label style="font-size:13px; margin-bottom:2px;">Birthdate</label><input type="date" id="cn-birthdate" style="padding:6px; width:100%;"></div>
766
- <div><label style="font-size:13px; margin-bottom:2px;">Position</label><select id="cn-position" style="padding:6px; width:100%;">
767
  <option value="">Select...</option>
768
  <option value="Captain">Captain</option>
769
  <option value="Crew">Crew</option>
@@ -771,36 +875,38 @@
771
  </select></div>
772
  </div>
773
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
774
- <div><label style="font-size:13px; margin-bottom:2px;">Citizenship</label><input type="text" id="cn-citizenship" list="countries" style="padding:6px; width:100%;"></div>
775
- <div><label style="font-size:13px; margin-bottom:2px;">Birthplace</label><input type="text" id="cn-birthplace" list="countries" style="padding:6px; width:100%;"></div>
776
- <div><label style="font-size:13px; margin-bottom:2px;">Passport Number</label><input type="text" id="cn-passport" style="padding:6px; width:100%;"></div>
777
- <div><label style="font-size:13px; margin-bottom:2px;">Issue Date</label><input type="date" id="cn-pass-issue" style="padding:6px; width:100%;"></div>
778
- <div><label style="font-size:13px; margin-bottom:2px;">Expiry Date</label><input type="date" id="cn-pass-expiry" style="padding:6px; width:100%;"></div>
779
  </div>
780
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
781
- <div><label style="font-size:13px; margin-bottom:2px;">Cell/WhatsApp</label><input type="text" id="cn-phone" placeholder="+1234567890" style="padding:6px; width:100%;"></div>
782
- <div><label style="font-size:13px; margin-bottom:2px;">Passport Photo/PDF</label><input type="file" id="cn-passport-photo" accept="image/*,.pdf" style="padding:4px; width:100%; font-size:13px;"></div>
783
- <div><label style="font-size:13px; margin-bottom:2px;">Passport Page Photo/PDF</label><input type="file" id="cn-passport-page" accept="image/*,.pdf" style="padding:4px; width:100%; font-size:13px;"></div>
784
  </div>
785
  <div style="margin-bottom:8px;"><label style="font-size:12px; font-weight:bold;">Emergency Contact</label></div>
786
  <div class="dev-tag" style="margin-bottom:6px;">dev:crew-add-emergency</div>
787
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
788
- <div><label style="font-size:13px; margin-bottom:2px;">Name</label><input type="text" id="cn-emerg-name" style="padding:6px; width:100%;"></div>
789
- <div><label style="font-size:13px; margin-bottom:2px;">Relationship</label><input type="text" id="cn-emerg-rel" style="padding:6px; width:100%;"></div>
790
- <div><label style="font-size:13px; margin-bottom:2px;">Phone</label><input type="text" id="cn-emerg-phone" style="padding:6px; width:100%;"></div>
791
- <div><label style="font-size:13px; margin-bottom:2px;">Email</label><input type="email" id="cn-emerg-email" style="padding:6px; width:100%;"></div>
792
  </div>
793
  <div style="margin-bottom:10px; font-size:15px;">
794
  <label style="font-size:13px; margin-bottom:2px;">Emergency Contact Notes</label>
 
795
  <input type="text" id="cn-emerg-notes" placeholder="Additional emergency contact information" style="padding:6px; width:100%;">
796
  </div>
797
  <datalist id="countries">
798
  <option value="USA"><option value="Canada"><option value="UK"><option value="Australia"><option value="New Zealand"><option value="France"><option value="Germany"><option value="Spain"><option value="Italy"><option value="Netherlands"><option value="Singapore"><option value="Malaysia"><option value="Thailand"><option value="Philippines"><option value="Japan"><option value="China"><option value="India">
799
  </datalist>
800
- <button onclick="addCrew()" class="btn btn-sm" style="background:var(--dark); width:100%;">+ Add Crew Member</button>
801
  </div>
802
  </div>
803
 
 
804
  <div id="crew-info-list"></div>
805
  </div>
806
  </div>
@@ -849,6 +955,21 @@
849
  <div style="font-size:12px; color:#555;">Ensures all required models are cached and lets you back up/restore the cache before going offline.</div>
850
  </div>
851
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  <div class="collapsible developer-only" style="margin-bottom:12px; display:none;">
853
  <div class="col-header crew-med-header" onclick="toggleSection(this)" style="background:#fff; justify-content:flex-start;">
854
  <span class="dev-tag">dev:settings-default-export</span><span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Export Default Dataset (Dev)</span>
@@ -1017,11 +1138,19 @@
1017
  </div>
1018
  </div>
1019
 
 
 
 
 
 
 
 
 
1020
  <script src="/static/js/chat.js?v=2017"></script>
1021
- <script src="/static/js/crew.js?v=2024"></script>
1022
  <script src="/static/js/pharmacy.js?v=2026"></script>
1023
  <script src="/static/js/equipment.js?v=2038"></script>
1024
- <script src="/static/js/settings.js?v=2025"></script>
1025
- <script src="/static/js/main.js?v=2019"></script>
1026
  </body>
1027
  </html>
 
184
  .phone-block { display: block; }
185
  .phone-import-panel { width: 100%; min-height: 130vh; }
186
  }
187
+ /* Blocking modal while GPU chat is running */
188
+ #chat-blocker {
189
+ display: none;
190
+ position: fixed;
191
+ inset: 0;
192
+ background: rgba(0,0,0,0.65);
193
+ z-index: 9000;
194
+ align-items: center;
195
+ justify-content: center;
196
+ padding: 16px;
197
+ box-sizing: border-box;
198
+ }
199
+ #chat-blocker .modal {
200
+ background: #fff;
201
+ border-radius: 10px;
202
+ padding: 18px;
203
+ max-width: 520px;
204
+ width: 100%;
205
+ box-shadow: 0 12px 32px rgba(0,0,0,0.2);
206
+ text-align: center;
207
+ }
208
+ #chat-blocker h3 { margin: 0 0 10px 0; color: #1f2d3d; }
209
+ #chat-blocker p { margin: 8px 0; color: #2c3e50; line-height: 1.5; }
210
+ #chat-blocker .spinner {
211
+ width: 40px; height: 40px;
212
+ border-radius: 50%;
213
+ border: 4px solid #e0e0e0;
214
+ border-top-color: var(--inquiry);
215
+ margin: 14px auto;
216
+ animation: spin 1s linear infinite;
217
+ }
218
+ @keyframes spin { to { transform: rotate(360deg); } }
219
  </style>
220
  <script>
221
  window.WORKSPACE_LABEL = "{{ workspace.label if workspace else '' }}";
 
300
  <div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
301
  <span class="dev-tag">dev:triage-message</span>
302
  </div>
303
+ <div style="display:flex; gap:10px; align-items:center; margin-bottom:8px;">
304
+ <label for="triage-sample-select" style="font-size:12px; color:#333; font-weight:700;">Load sample case:</label>
305
+ <select id="triage-sample-select" style="padding:8px; min-width:240px;" onchange="applyTriageSample(this.value)">
306
+ <option value="">Select a sample…</option>
307
+ </select>
308
+ </div>
309
  <textarea id="msg" style="height:105px;" placeholder="Describe what's happening"></textarea>
310
  <div id="triage-meta-selects" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:10px; margin-top:10px;">
311
  <div>
 
406
  </div>
407
  <div style="display:flex; gap:10px; margin-top:10px; align-items:center; flex-wrap:wrap;">
408
  <button onclick="runChat()" id="run-btn" class="btn" style="flex-grow:1; background:var(--triage); min-width:160px;">SUBMIT FOR TRIAGE</button>
409
+ <button onclick="restoreLast()" id="repeat-btn" class="btn user-adv-only" style="background:#555; min-width:120px;">RESTORE LAST</button>
410
  </div>
411
  </div>
412
  </div>
 
470
  <span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Pharmaceuticals</span><span id="pharmacy-count" style="margin-left:8px; font-weight:700; font-size:12px; color:#1f2d3d;">(0)</span><span class="dev-tag">dev:medicines-shell</span>
471
  </div>
472
  <div class="col-body" style="padding:12px; display:none; background:#f5f8ff; border:1px solid #d7e2ff; border-top:none;">
473
+ <div class="developer-only" style="display:none; margin-bottom:8px; display:flex; gap:8px; flex-wrap:wrap;">
474
+ <button class="btn btn-sm" style="background:#0b8457;" onclick="exportPharmacyJSON()">Export Pharmaceuticals JSON</button>
475
+ <button class="btn btn-sm" style="background:#1b4f72;" onclick="importPharmacyJSON()">Import Pharmaceuticals JSON</button>
476
+ </div>
477
  <div class="collapsible" style="margin-bottom:12px;">
478
  <div id="medication-add-header" class="col-header crew-med-header" data-sidebar-id="equipment-medication-add" onclick="toggleSection(this)" style="background:#fff; justify-content:flex-start;">
479
  <span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Add Pharmaceuticals</span><span class="dev-tag">dev:medication-add</span>
 
522
  <option value="Damaged/Out of Service">Damaged/Out of Service</option>
523
  </select>
524
  </div>
525
+ <div>
526
+ <label style="font-weight:700; font-size:12px;">Sort Category</label>
527
+ <select id="med-new-sort" style="width:100%; padding:8px;" onchange="toggleCustomSortField()">
528
+ <option value="">Select...</option>
529
+ <option value="Antibiotic">Antibiotic</option>
530
+ <option value="Analgesic">Analgesic</option>
531
+ <option value="Cardiac">Cardiac</option>
532
+ <option value="Respiratory">Respiratory</option>
533
+ <option value="Gastrointestinal">Gastrointestinal</option>
534
+ <option value="Endocrine">Endocrine</option>
535
+ <option value="Emergency">Emergency</option>
536
+ <option value="Other">Custom...</option>
537
+ </select>
538
+ <input id="med-new-sort-custom" type="text" placeholder="Custom category" style="margin-top:6px; width:100%; padding:8px; display:none;">
539
+ </div>
540
+ <div style="display:flex; align-items:center; gap:6px;">
541
+ <input id="med-new-verified" type="checkbox">
542
+ <label style="font-size:12px; margin:0;">Verified</label>
543
+ </div>
544
  <div>
545
  <label style="font-weight:700; font-size:12px;">Expiry Date</label>
546
  <input id="med-new-exp" type="date" style="width:100%; padding:8px;">
 
632
  </div>
633
  </div>
634
  </div>
635
+ <div class="collapsible" style="margin-bottom:12px;">
636
  <div class="col-body" style="padding:12px; background:#f8f9fa; display:block;">
637
  <div class="dev-tag" style="margin-bottom:6px;">dev:pharmacy-inventory</div>
638
  <div class="dev-tag" style="margin-bottom:6px;">dev:pharmacy-controls</div>
639
+ <div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:8px;">
640
+ <label style="font-size:12px; font-weight:700;">Sort by:</label>
641
+ <select id="pharmacy-sort" style="padding:8px; min-width:180px;" onchange="handlePharmacySortChange()">
642
+ <option value="sortCategory">Sort Category</option>
643
+ <option value="generic">Generic Name</option>
644
+ <option value="brand">Brand Name</option>
645
+ <option value="strength">Strength</option>
646
+ <option value="expiry">Expiry Date</option>
647
+ </select>
648
+ </div>
649
  <div class="dev-tag" style="margin-bottom:6px;">dev:pharmacy-list</div>
650
  <div id="pharmacy-list"></div>
651
  </div>
 
764
  <div class="page-body sidebar-open">
765
  <div class="page-main">
766
  <div class="page-shell">
767
+ <div class="dev-tag" style="margin-bottom:8px;">dev:vessel-crew-tab</div>
768
  <div class="panel-wrapper">
769
  <div class="collapsible" style="margin-bottom:15px;">
770
  <div class="col-header crew-med-header" data-sidebar-id="vessel-info" onclick="toggleSection(this)" style="background:#e3f2fd; border:1px solid #c5ddf8; justify-content:flex-start;">
 
774
  <div class="dev-tag" style="margin-bottom:6px;">dev:vessel-fields</div>
775
  <div style="display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-bottom:12px; font-size:15px;">
776
  <div>
777
+ <div class="dev-tag">dev:vessel-name</div>
778
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Vessel Name</label>
779
  <input type="text" id="vessel-name" style="padding:8px; width:100%;">
780
  </div>
781
  <div>
782
+ <div class="dev-tag">dev:vessel-registration</div>
783
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Registration Number</label>
784
  <input type="text" id="vessel-registration" style="padding:8px; width:100%;">
785
  </div>
786
  <div>
787
+ <div class="dev-tag">dev:vessel-flag</div>
788
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Flag Country</label>
789
  <input type="text" id="vessel-flag" style="padding:8px; width:100%;">
790
  </div>
791
  <div>
792
+ <div class="dev-tag">dev:vessel-homeport</div>
793
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Home Port</label>
794
  <input type="text" id="vessel-homeport" style="padding:8px; width:100%;">
795
  </div>
796
  <div>
797
+ <div class="dev-tag">dev:vessel-tonnage</div>
798
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Gross Tonnage</label>
799
  <input type="text" id="vessel-tonnage" style="padding:8px; width:100%;">
800
  </div>
801
  <div>
802
+ <div class="dev-tag">dev:vessel-net-tonnage</div>
803
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Net/Register Tonnage</label>
804
  <input type="text" id="vessel-net-tonnage" style="padding:8px; width:100%;">
805
  </div>
806
  <div>
807
+ <div class="dev-tag">dev:vessel-callsign</div>
808
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Call Sign</label>
809
  <input type="text" id="vessel-callsign" style="padding:8px; width:100%;">
810
  </div>
811
  <div>
812
+ <div class="dev-tag">dev:vessel-mmsi</div>
813
  <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">MMSI Number</label>
814
  <input type="text" id="vessel-mmsi" style="padding:8px; width:100%;">
815
  </div>
816
+ <div>
817
+ <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Vessel Hull Number</label>
818
+ <input type="text" id="vessel-hull-number" style="padding:8px; width:100%;">
819
+ </div>
820
+ <div>
821
+ <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Starboard Engine Brand/Model/Size</label>
822
+ <input type="text" id="vessel-starboard-engine" style="padding:8px; width:100%;">
823
+ </div>
824
+ <div>
825
+ <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Starboard Engine S/N</label>
826
+ <input type="text" id="vessel-starboard-sn" style="padding:8px; width:100%;">
827
+ </div>
828
+ <div>
829
+ <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Port Engine Brand/Model/Size</label>
830
+ <input type="text" id="vessel-port-engine" style="padding:8px; width:100%;">
831
+ </div>
832
+ <div>
833
+ <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">Port Engine S/N</label>
834
+ <input type="text" id="vessel-port-sn" style="padding:8px; width:100%;">
835
+ </div>
836
+ <div>
837
+ <label style="font-size:12px; font-weight:bold; margin-bottom:4px; display:block;">RIB S/N</label>
838
+ <input type="text" id="vessel-rib-sn" style="padding:8px; width:100%;">
839
+ </div>
840
  </div>
841
+ <button onclick="saveVesselInfo()" class="btn btn-sm" style="background:var(--inquiry); width:100%;"><span class="dev-tag">dev:vessel-save</span>Save Vessel Info</button>
842
  </div>
843
  </div>
844
  <div class="collapsible" style="margin-bottom:15px;">
 
853
  <div class="col-body" style="padding:15px; background:#f8f9fa;">
854
  <div class="dev-tag" style="margin-bottom:6px;">dev:crew-add-fields</div>
855
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
856
+ <div><div class="dev-tag">dev:crew-add-first</div><label style="font-size:13px; margin-bottom:2px;">First Name *</label><input type="text" id="cn-first" style="padding:6px; width:100%;"></div>
857
+ <div><div class="dev-tag">dev:crew-add-middle</div><label style="font-size:13px; margin-bottom:2px;">Middle Name(s)</label><input type="text" id="cn-middle" style="padding:6px; width:100%;"></div>
858
+ <div><div class="dev-tag">dev:crew-add-last</div><label style="font-size:13px; margin-bottom:2px;">Last Name *</label><input type="text" id="cn-last" style="padding:6px; width:100%;"></div>
859
  </div>
860
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
861
+ <div><div class="dev-tag">dev:crew-add-sex</div><label style="font-size:13px; margin-bottom:2px;">Sex</label><select id="cn-sex" style="padding:6px; width:100%;">
862
  <option value="">Select...</option>
863
  <option value="Male">Male</option>
864
  <option value="Female">Female</option>
 
866
  <option value="Other">Other</option>
867
  <option value="Prefer not to say">Prefer not to say</option>
868
  </select></div>
869
+ <div><div class="dev-tag">dev:crew-add-birthdate</div><label style="font-size:13px; margin-bottom:2px;">Birthdate</label><input type="date" id="cn-birthdate" style="padding:6px; width:100%;"></div>
870
+ <div><div class="dev-tag">dev:crew-add-position</div><label style="font-size:13px; margin-bottom:2px;">Position</label><select id="cn-position" style="padding:6px; width:100%;">
871
  <option value="">Select...</option>
872
  <option value="Captain">Captain</option>
873
  <option value="Crew">Crew</option>
 
875
  </select></div>
876
  </div>
877
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
878
+ <div><div class="dev-tag">dev:crew-add-citizenship</div><label style="font-size:13px; margin-bottom:2px;">Citizenship</label><input type="text" id="cn-citizenship" list="countries" style="padding:6px; width:100%;"></div>
879
+ <div><div class="dev-tag">dev:crew-add-birthplace</div><label style="font-size:13px; margin-bottom:2px;">Birthplace</label><input type="text" id="cn-birthplace" list="countries" style="padding:6px; width:100%;"></div>
880
+ <div><div class="dev-tag">dev:crew-add-passport</div><label style="font-size:13px; margin-bottom:2px;">Passport Number</label><input type="text" id="cn-passport" style="padding:6px; width:100%;"></div>
881
+ <div><div class="dev-tag">dev:crew-add-pass-issue</div><label style="font-size:13px; margin-bottom:2px;">Issue Date</label><input type="date" id="cn-pass-issue" style="padding:6px; width:100%;"></div>
882
+ <div><div class="dev-tag">dev:crew-add-pass-expiry</div><label style="font-size:13px; margin-bottom:2px;">Expiry Date</label><input type="date" id="cn-pass-expiry" style="padding:6px; width:100%;"></div>
883
  </div>
884
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
885
+ <div><div class="dev-tag">dev:crew-add-phone</div><label style="font-size:13px; margin-bottom:2px;">Cell/WhatsApp</label><input type="text" id="cn-phone" placeholder="+1234567890" style="padding:6px; width:100%;"></div>
886
+ <div><div class="dev-tag">dev:crew-add-passport-photo</div><label style="font-size:13px; margin-bottom:2px;">Passport Photo/PDF</label><input type="file" id="cn-passport-photo" accept="image/*,.pdf" style="padding:4px; width:100%; font-size:13px;"></div>
887
+ <div><div class="dev-tag">dev:crew-add-passport-page</div><label style="font-size:13px; margin-bottom:2px;">Passport Page Photo/PDF</label><input type="file" id="cn-passport-page" accept="image/*,.pdf" style="padding:4px; width:100%; font-size:13px;"></div>
888
  </div>
889
  <div style="margin-bottom:8px;"><label style="font-size:12px; font-weight:bold;">Emergency Contact</label></div>
890
  <div class="dev-tag" style="margin-bottom:6px;">dev:crew-add-emergency</div>
891
  <div style="display:grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap:8px; margin-bottom:8px; font-size:15px;">
892
+ <div><div class="dev-tag">dev:crew-add-emerg-name</div><label style="font-size:13px; margin-bottom:2px;">Name</label><input type="text" id="cn-emerg-name" style="padding:6px; width:100%;"></div>
893
+ <div><div class="dev-tag">dev:crew-add-emerg-rel</div><label style="font-size:13px; margin-bottom:2px;">Relationship</label><input type="text" id="cn-emerg-rel" style="padding:6px; width:100%;"></div>
894
+ <div><div class="dev-tag">dev:crew-add-emerg-phone</div><label style="font-size:13px; margin-bottom:2px;">Phone</label><input type="text" id="cn-emerg-phone" style="padding:6px; width:100%;"></div>
895
+ <div><div class="dev-tag">dev:crew-add-emerg-email</div><label style="font-size:13px; margin-bottom:2px;">Email</label><input type="email" id="cn-emerg-email" style="padding:6px; width:100%;"></div>
896
  </div>
897
  <div style="margin-bottom:10px; font-size:15px;">
898
  <label style="font-size:13px; margin-bottom:2px;">Emergency Contact Notes</label>
899
+ <div class="dev-tag">dev:crew-add-emerg-notes</div>
900
  <input type="text" id="cn-emerg-notes" placeholder="Additional emergency contact information" style="padding:6px; width:100%;">
901
  </div>
902
  <datalist id="countries">
903
  <option value="USA"><option value="Canada"><option value="UK"><option value="Australia"><option value="New Zealand"><option value="France"><option value="Germany"><option value="Spain"><option value="Italy"><option value="Netherlands"><option value="Singapore"><option value="Malaysia"><option value="Thailand"><option value="Philippines"><option value="Japan"><option value="China"><option value="India">
904
  </datalist>
905
+ <button onclick="addCrew()" class="btn btn-sm" style="background:var(--dark); width:100%;"><span class="dev-tag">dev:crew-add-submit</span>+ Add Crew Member</button>
906
  </div>
907
  </div>
908
 
909
+ <div class="dev-tag" style="margin-bottom:6px;">dev:crew-info-anchor</div>
910
  <div id="crew-info-list"></div>
911
  </div>
912
  </div>
 
955
  <div style="font-size:12px; color:#555;">Ensures all required models are cached and lets you back up/restore the cache before going offline.</div>
956
  </div>
957
  </div>
958
+ <div class="collapsible" style="margin-bottom:12px;">
959
+ <div class="col-header crew-med-header" onclick="toggleSection(this)" style="background:#fff; justify-content:flex-start;">
960
+ <span class="dev-tag">dev:settings-vax-types</span><span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Vaccine Type Dropdown</span>
961
+ </div>
962
+ <div class="col-body" style="padding:12px; background:#f8f9fa; display:none;">
963
+ <div style="margin-bottom:8px; font-size:12px; color:#2c3e50;">Manage the Vaccine Type/Disease options shown in Crew Vaccines. An "Other" option is always available for ad-hoc entries.</div>
964
+ <div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-bottom:10px;">
965
+ <input id="vaccine-type-input" type="text" style="padding:8px; min-width:220px; flex:1;" placeholder="e.g., MMR, DTaP, HepB">
966
+ <button class="btn btn-sm" style="background:var(--inquiry);" onclick="addVaccineType()">Add Type</button>
967
+ </div>
968
+ <div class="dev-tag" style="margin-bottom:6px;">dev:settings-vax-list</div>
969
+ <div id="vaccine-types-list"></div>
970
+ <div id="vaccine-types-status" style="margin-top:8px; font-size:12px; color:#555;">Saved with workspace settings.</div>
971
+ </div>
972
+ </div>
973
  <div class="collapsible developer-only" style="margin-bottom:12px; display:none;">
974
  <div class="col-header crew-med-header" onclick="toggleSection(this)" style="background:#fff; justify-content:flex-start;">
975
  <span class="dev-tag">dev:settings-default-export</span><span class="detail-icon history-arrow" style="font-size:18px; margin-right:8px;">▸</span><span style="font-weight:700;">Export Default Dataset (Dev)</span>
 
1138
  </div>
1139
  </div>
1140
 
1141
+ <div id="chat-blocker">
1142
+ <div class="modal">
1143
+ <div class="spinner"></div>
1144
+ <h3>Processing Triage Chat…</h3>
1145
+ <p>Please wait. Navigation or edits in SailingMedAdvisor are temporarily paused until this response completes.</p>
1146
+ </div>
1147
+ </div>
1148
+
1149
  <script src="/static/js/chat.js?v=2017"></script>
1150
+ <script src="/static/js/crew.js?v=2025"></script>
1151
  <script src="/static/js/pharmacy.js?v=2026"></script>
1152
  <script src="/static/js/equipment.js?v=2038"></script>
1153
+ <script src="/static/js/settings.js?v=2026"></script>
1154
+ <script src="/static/js/main.js?v=2020"></script>
1155
  </body>
1156
  </html>
templates/sidebars/sidebar_chat.html CHANGED
@@ -2,7 +2,7 @@
2
  <div class="sidebar-section">
3
  <div class="sidebar-title">Overview</div>
4
  <div class="sidebar-body">
5
- This app was created out of the need for high quality medical guidance when there are no other options. Our family of six is circumnavigating on a sailboat. We are often in remote and inaccessible locations where there simply are no doctors.<br><br>
6
  For example, we were anchored in a remote part of the Solomon Islands when our oldest son got a large three-prong off-shore fish hook stuck in his left cheek very close to his eye. It had large barbs and was impossible to pull out. Another time, our youngest son was biten by a dog suspected by the locals of having rabies. We needed to quickly cobble together a precautionary treatment from our medical chest. Another time, our son experienced a Shallow Water Blackout at a depth of 10m. He floated to the surface unconscious and was awakened by his younger siblings who were with him.<br><br>
7
  In all cases, we needed timely access to medical assistance to address the situation. We struggled to find it, and hence, this app was born.</div>
8
  </div>
 
2
  <div class="sidebar-section">
3
  <div class="sidebar-title">Overview</div>
4
  <div class="sidebar-body">
5
+ This app was created out of the need for high quality medical guidance when there are no other options. Our family of six is circumnavigating on a sailboat. We are often in remote and inaccessible locations where there simply are no doctors to assist with a medical emergency.<br><br>
6
  For example, we were anchored in a remote part of the Solomon Islands when our oldest son got a large three-prong off-shore fish hook stuck in his left cheek very close to his eye. It had large barbs and was impossible to pull out. Another time, our youngest son was biten by a dog suspected by the locals of having rabies. We needed to quickly cobble together a precautionary treatment from our medical chest. Another time, our son experienced a Shallow Water Blackout at a depth of 10m. He floated to the surface unconscious and was awakened by his younger siblings who were with him.<br><br>
7
  In all cases, we needed timely access to medical assistance to address the situation. We struggled to find it, and hence, this app was born.</div>
8
  </div>
templates/sidebars/sidebar_crew_medical.html CHANGED
@@ -1,7 +1,18 @@
1
  <div class="dev-tag">dev:sidebar-crew-medical</div>
2
  <div class="sidebar-section" data-sidebar-section="crew-medical-section">
3
  <div class="sidebar-title"><span class="sidebar-pill">Health Records</span> Intake Notes</div>
4
- <div class="sidebar-body">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus at arcu sed urna commodo pulvinar.</div>
 
 
 
 
 
 
 
 
 
 
 
5
  </div>
6
  <div class="sidebar-section">
7
  <div class="sidebar-title">Updates & Exports</div>
 
1
  <div class="dev-tag">dev:sidebar-crew-medical</div>
2
  <div class="sidebar-section" data-sidebar-section="crew-medical-section">
3
  <div class="sidebar-title"><span class="sidebar-pill">Health Records</span> Intake Notes</div>
4
+ <div class="sidebar-body">
5
+ When Logging is on the details of Triage and Inquiry chats
6
+ are saved here under each crew member's name. If Logging
7
+ is off then the chats are not saved.<br><br>
8
+ When you reactivate a past chat that chat is loaded into
9
+ the Triage or Inquiry section and you can continue from where
10
+ you last left-off. This is useful when you want to resubmit
11
+ a chat for further consideration by the larger model.<br><br>
12
+ When a crew member has not been selected for a chat the chat
13
+ will be logged under Unnamed Crew as either Triage chat or
14
+ an Inquiry chat.
15
+ </div>
16
  </div>
17
  <div class="sidebar-section">
18
  <div class="sidebar-title">Updates & Exports</div>