const ODA_KEY = 'tccc360-oda-v1';
const ODA_TWEAKS = /*EDITMODE-BEGIN*/{
"theme": "tactical",
"accent": "op",
"density": "regular",
"showSparklines": true,
"showGrid": true,
"timeMode": "both"
}/*EDITMODE-END*/;
const MECHANISMS = ['Artillery','Blast','Blunt','Burn','Chemical','Drone','Electrical','Fall','Grenade','GSW','IED','Landmine','MVC','RPG','Other'];
const ACUTE_FINDINGS = ['Impalement','Evisceration','Amputation','Open Chest','Sucking Chest','Flail Chest','Open Abdomen','Crush','Avulsion','Degloving','Penetrating Head','Spinal Injury'];
function loadTeam() {
try { const raw = localStorage.getItem(ODA_KEY); if (raw) { const s = JSON.parse(raw); if (s && Array.isArray(s.team) && s.team.length) { s.team = s.team.map(x => x.id === '18B1' && JSON.stringify(x.injuries) === '[{"view":"front","x":70,"y":130,"type":"gsw"},{"view":"front","x":132,"y":280,"type":"gsw"}]' ? { ...x, injuries: [{view:'front',x:78,y:295,type:'gsw'},{view:'front',x:130,y:200,type:'gsw'}] } : x); return { ...s, settings: { ...ODA_SETTINGS_DEFAULT, ...(s.settings || {}) } }; } } } catch (e) {}
return { team: ODA_ROSTER, selectedId: '18B1', opName: 'ENDURING SHIELD', medic: 'SSG R. PHAM', teamSgt: 'MSG T. BOUDREAUX', nine: {}, settings: { ...ODA_SETTINGS_DEFAULT } };
}
function App() {
const [S, setS] = React.useState(loadTeam);
const [tab, setTab] = React.useState('march');
const [now, setNow] = React.useState(new Date());
const [t, setTweak] = useTweaks(ODA_TWEAKS);
const [drawer, setDrawer] = React.useState(null);
const focus = Math.min(3, Math.max(0, +S.focus || 0));
const setFocus = (v) => { odaSnd(v ? 'open' : 'close', st); setS(p => ({ ...p, focus: v })); };
const cycleFocus = () => setFocus((focus + 1) % 4);
const st = S.settings || ODA_SETTINGS_DEFAULT;
const setSt = (patch) => setS(p => ({ ...p, settings: { ...ODA_SETTINGS_DEFAULT, ...(p.settings || {}), ...patch } }));
const openDrawer = (k) => { odaSnd(k ? 'open' : 'close', st); setDrawer(k); };
React.useEffect(() => {
if (!st.tickAll || !st.sound) return;
const h = (e) => { if (e.target.closest('.dz, .dz-scrim')) return; if (e.target.closest('button, .chip, .fchip, .man, .rung, .proto .row, .cyc, .wbb .d, .tabs button')) odaSnd('tick', st); };
document.addEventListener('click', h, true);
return () => document.removeEventListener('click', h, true);
}, [st.tickAll, st.sound, st.vol]);
const ecgOn = !!(st.sound && st.ecgAudio);
React.useEffect(() => { try { localStorage.setItem('tccc-ecg-muted', ecgOn ? '0' : '1'); } catch (e) {} }, [ecgOn]);
React.useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', t.theme || 'tactical');
root.setAttribute('data-accent', t.accent || 'op');
root.setAttribute('data-density', t.density || 'regular');
root.style.setProperty('--grid-op', t.showGrid && t.theme !== 'paper' ? 0.18 : 0);
}, [t.theme, t.accent, t.density, t.showGrid]);
React.useEffect(() => { try { localStorage.setItem(ODA_KEY, JSON.stringify(S)); } catch (e) {} }, [S]);
React.useEffect(() => { const id = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(id); }, []);
const team = S.team, mb = team.find(x => x.id === S.selectedId) || team[0];
const setTop = (k) => (v) => setS(p => ({ ...p, [k]: typeof v === 'function' ? v(p[k]) : v }));
const upd = (k, v) => setS(p => ({ ...p, team: p.team.map(x => x.id !== p.selectedId ? x : { ...x, [k]: typeof v === 'function' ? v(x[k]) : v }) }));
const set = (k) => (v) => upd(k, v);
const setMember = (fnOrObj) => setS(p => ({ ...p, team: p.team.map(x => x.id !== p.selectedId ? x : (typeof fnOrObj === 'function' ? fnOrObj(x) : { ...x, ...fnOrObj })) }));
const select = (id) => setS(p => ({ ...p, selectedId: id }));
const toggleIn = (k, val) => upd(k, arr => (arr||[]).includes(val) ? arr.filter(x => x !== val) : [...(arr||[]), val]);
const z = (n) => String(n).padStart(2, '0');
const zuluTime = `${z(now.getUTCHours())}${z(now.getUTCMinutes())}${z(now.getUTCSeconds())}Z`;
const localTime = `${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}`;
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
const activeTQs = Object.values(mb.tourniquets).filter(q => q.active).length;
const lastVital = mb.vitals[mb.vitals.length - 1] || {};
const stat = memberStatus(mb);
const cas = team.filter(x => memberStatus(x) !== 'green').length;
const tbsa = estimateBurnTBSA(mb.injuries, mb.burnedRegions || []);
const nowMin = now.getHours() * 60 + now.getMinutes();
const dueMap = {};
team.forEach(x => {
if (memberStatus(x) === 'green') return;
const flags = [];
if (st.reminder) { const lv = x.vitals[x.vitals.length - 1]; const age = lv ? (nowMin - parseHHMM(lv.time) + 1440) % 1440 : Infinity; if (age >= st.interval) flags.push('VS DUE'); }
if (st.tqWarn) { Object.values(x.tourniquets || {}).forEach(q => { if (q.active && q.time) { const el = (nowMin - parseHHMM(q.time) + 1440) % 1440; if (el >= st.tqWarnMin && el < 120) flags.push('TQ 2H'); } }); }
if (flags.length) dueMap[x.id] = flags.join(' · ');
});
const dueKey = Object.entries(dueMap).map(([k, v]) => k + ':' + v).sort().join('|');
const alarmedRef = React.useRef('');
React.useEffect(() => {
if (!dueKey) { alarmedRef.current = ''; return; }
if (st.alarms && st.sound && alarmedRef.current !== dueKey) { alarmedRef.current = dueKey; odaSnd('alarm', st); }
}, [dueKey, st.alarms, st.sound]);
const redKey = team.filter(x => memberStatus(x) === 'red').map(x => x.id).join(',');
const prevRed = React.useRef(redKey);
React.useEffect(() => { if (st.autoSelectRed) { const fresh = redKey.split(',').filter(id => id && !prevRed.current.split(',').includes(id)); if (fresh.length) select(fresh[0]); } prevRed.current = redKey; }, [redKey]);
const confirmIf = (msg) => !st.confirmClear || confirm(msg);
const clearMember = () => { if (confirmIf(`Clear casualty data for ${mb.role} ${mb.name}? Profile (name, blood type, position) is kept.`)) setMember(x => ({ ...BLANK_MEMBER(), id: x.id, role: x.role, title: x.title, name: x.name, last4: x.last4, bloodType: x.bloodType, pos: x.pos, weight: x.weight, allergies: x.allergies })); };
const resetTeam = () => { if (confirmIf('Reset the whole team to the sample roster?')) { const keep = S.settings; localStorage.removeItem(ODA_KEY); setS({ ...loadTeam(), settings: keep }); setDrawer(null); } };
const blankTeam = () => { if (confirmIf('Start a blank 12-man ODA? All names, blood types and casualty data will be cleared.')) { const roles = [['18A','Detachment Cdr'],['180A','Asst Det Cdr'],['18Z','Team Sergeant'],['18F','Intel Sgt'],['18B','Weapons Sgt'],['18B','Weapons Sgt'],['18C','Engineer Sgt'],['18C','Engineer Sgt'],['18D','Medical Sgt'],['18D','Medical Sgt'],['18E','Commo Sgt'],['18E','Commo Sgt']]; const seen = {}; const team = roles.map(([role, title], i) => { seen[role] = (seen[role] || 0) + 1; const dup = roles.filter(r => r[0] === role).length > 1; return { ...BLANK_MEMBER(), id: dup ? role + seen[role] : role, role, title, name: '', last4: '', bloodType: '—', pos: i + 1, weight: 0, unit: '' }; }); setS(p => ({ ...p, team, selectedId: team[0].id, nine: {}, focus: 0 })); setDrawer(null); } };
const dueCount = Object.keys(dueMap).length;
const stepList = (team.some(x => memberStatus(x) !== 'green') ? team.filter(x => memberStatus(x) !== 'green') : team).sort((a, b) => a.pos - b.pos);
const stepIdx = stepList.findIndex(x => x.id === mb.id);
const step = (d) => { odaSnd('seg', st); const n = stepList.length; select(stepList[((stepIdx < 0 ? 0 : stepIdx) + d + n) % n].id); };
const [voiceOn, setVoiceOn] = React.useState(false);
const [hudOpen, setHudOpen] = React.useState(false);
const [interim, setInterim] = React.useState('');
const [vlog, setVlog] = React.useState([]);
const [vErr, setVErr] = React.useState('');
const undoRef = React.useRef([]);
const ctxRef = React.useRef({});
ctxRef.current = { team, mb, tab, focus, st, drawer, S, step };
const marchItems = React.useMemo(() => MARCH_PAWS.flatMap(sec => sec.groups.flatMap(g => g[1].map(it => sec.k + ':' + it))), []);
const logV = (heard, kind, did) => setVlog(l => [...l.slice(-30), { id: Date.now() + Math.random(), t: nowHHMM(), heard, kind, did }]);
const applyVoice = (cmds, heard) => {
const c = ctxRef.current;
if (!cmds.length) { logV(heard, 'warn', ''); if (c.st.voiceChime) odaSnd('off', c.st); return; }
undoRef.current = [...undoRef.current.slice(-9), c.S];
const did = [];
cmds.forEach(cmd => {
did.push(cmd.label);
switch (cmd.type) {
case 'focus': setFocus(cmd.level); break;
case 'step': c.step(cmd.d); break;
case 'drawer': setDrawer(cmd.k); break;
case 'tab': setTab(cmd.k); break;
case 'sound': setSt({ sound: cmd.on }); break;
case 'voiceOff': setVoiceOn(false); break;
case 'undo': { const prev = undoRef.current.pop(); undoRef.current.pop(); if (prev) setS(prev); break; }
case 'select': select(cmd.id); break;
case 'vitals': upd('draftVitals', d => ({ ...d, ...cmd.vit })); break;
case 'avpu': upd('avpu', cmd.v); break;
case 'recordVitals': setMember(x => { const d = x.draftVitals || {}; if (!(d.hr || d.sbp || d.rr || d.spo2)) return x; return { ...x, vitals: [...x.vitals, { time: nowHHMM(), hr: d.hr || '', sbp: d.sbp || '', dbp: d.dbp || '', rr: d.rr || '', spo2: d.spo2 || '', pain: d.pain || '' }] }; }); break;
case 'set': upd(cmd.k, cmd.v); break;
case 'toggleIn': upd(cmd.k, arr => { const a = arr || []; return cmd.on ? (a.includes(cmd.v) ? a : [...a, cmd.v]) : a.filter(v => v !== cmd.v); }); break;
case 'tq': upd('tourniquets', tq => ({ ...tq, [cmd.limb]: cmd.off ? { active: false, type: '', time: '', position: '' } : { active: true, type: cmd.tqType || tq[cmd.limb].type || 'CAT', time: cmd.time || tq[cmd.limb].time || nowHHMM(), position: cmd.pos || tq[cmd.limb].position || '' } })); break;
case 'med': upd('medsGiven', a => [...a, { id: Date.now(), time: nowHHMM(), name: cmd.name.replace(/\b\w/g, ch => ch.toUpperCase()), dose: cmd.dose }]); break;
case 'fluid': upd('fluidsGiven', a => [...a, { id: Date.now(), time: nowHHMM(), type: cmd.name.replace(/\b\w/g, ch => ch.toUpperCase()).replace(/^Lr$/, 'LR').replace(/Ltowb/, 'LTOWB').replace(/Prbc/, 'PRBC'), vol: cmd.vol }]); break;
case 'burn': upd('burn', b => ({ ...(b || {}), ...cmd.patch })); break;
case 'uop': upd('burn', b => ({ ...(b || {}), uop: [...((b && b.uop) || []), { t: nowHHMM(), ml: cmd.ml }] })); break;
case 'nine': setTop('nine')(n => ({ ...(n || {}), ['l' + cmd.n]: cmd.v })); break;
case 'note': upd('notes', n => (n ? n + '\n' : '') + `${nowHHMM()}: ${cmd.text}`); break;
}
});
logV(heard, 'ok', did.join(' · '));
if (c.st.voiceChime) odaSnd('log', c.st);
};
const onFinal = (text) => {
const c = ctxRef.current; let t = text.trim(); if (!t) return;
if (c.st.voiceWake) { const mm = t.match(/^\s*(?:hey |ok |okay )?medic[,.!]?\s*(.*)$/i); if (!mm) { logV(t, 'warn', ''); return; } t = mm[1]; }
applyVoice(parseVoice(t, { team: c.team, marchItems, mechs: MECHANISMS, findings: ACUTE_FINDINGS }), t);
setInterim('');
};
const { supported: voiceSupported } = useVoice({ enabled: voiceOn, lang: st.voiceLang, onFinal, onInterim: setInterim, onError: (e) => { setVErr(e); if (e === 'not-allowed') setVoiceOn(false); } });
const toggleVoice = () => { if (!voiceSupported) { setHudOpen(true); return; } const v = !voiceOn; setVoiceOn(v); setVErr(''); if (v && st.voiceHud) setHudOpen(true); odaSnd(v ? 'open' : 'close', st); };
React.useEffect(() => {
if (!focus) return;
const h = (e) => { if (e.target.closest('input, textarea, select')) return; if (e.key === 'Escape') setFocus(0); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); else if (e.key === 'ArrowUp') setFocus(Math.min(3, focus + 1)); else if (e.key === 'ArrowDown') setFocus(Math.max(1, focus - 1)); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [focus, stepIdx, stepList.length]);
return (
<div className={'app oda' + (focus ? ' focus focus-' + focus : '')}>
<div className="topbar">
<div className="brand"><span className="dot"></span><b>TCCC</b> · ODA TEAM VIEW · 360</div>
<div className="session">
<span>OP <input className="session-input" value={S.opName} onChange={e => setTop('opName')(e.target.value)} style={{ width: `${Math.max(8, (S.opName||'').length)}ch` }}/></span>
<span>18Z <input className="session-input" value={S.teamSgt} onChange={e => setTop('teamSgt')(e.target.value)} style={{ width: `${Math.max(8, (S.teamSgt||'').length)}ch` }}/></span>
<span>18D <input className="session-input" value={S.medic} onChange={e => setTop('medic')(e.target.value)} style={{ width: `${Math.max(8, (S.medic||'').length)}ch` }}/></span>
<span>DTG <b>{t.timeMode === 'local' ? `${dateStr} ${localTime}` : `${dateStr} ${zuluTime}`}</b></span>
<button className={'tb-btn' + (focus ? ' on' : '')} onClick={cycleFocus} onContextMenu={(e) => { e.preventDefault(); setFocus(0); }} title="Focus: ×1 casualty only · ×2 injury map central · ×3 map + treatment only (Esc exits · ↑↓ level · ←→ casualty)">⛶ {focus ? `FOCUS ×${focus}` : 'FOCUS'}</button>
<button className={'tb-btn' + (voiceOn ? ' listening' : '')} onClick={toggleVoice} onContextMenu={(e) => { e.preventDefault(); setHudOpen(h => !h); }} title={voiceSupported ? (voiceOn ? 'Listening — click to stop · right-click for HUD' : 'Voice control — click to start listening') : 'Speech recognition not available in this browser'}>◉ VOICE<span className="led"></span></button>
<button className={'tb-btn' + (st.sound ? ' on' : '')} onClick={() => { const v = !st.sound; setSt({ sound: v }); if (v) odaSnd('on', { ...st, sound: true }, true); }} title={st.sound ? `Sound on · ${Math.round(st.vol * 100)} % · click to mute` : 'Sound muted · click to enable'}>{st.sound ? '♪ SND' : '× MUTE'}</button>
<button className={'tb-btn' + (drawer === 'display' ? ' on' : '')} onClick={() => openDrawer(drawer === 'display' ? null : 'display')} title="Display: theme, accent, density, clock">◐ DISPLAY<span className="led" style={{ '--led': ({ op: 'var(--op)', amber: 'var(--amber)', crit: 'var(--crit)', info: 'var(--info)' })[t.accent] || 'var(--op)' }}></span></button>
<button className={'tb-btn' + (drawer === 'export' ? ' on' : '')} onClick={() => openDrawer(drawer === 'export' ? null : 'export')} title="Export DD1380 / MIST / status board / CSV / JSON">⇪ EXPORT</button>
<button className={'tb-btn' + (drawer === 'settings' ? ' on' : '') + (dueCount ? ' due' : '')} onClick={() => openDrawer(drawer === 'settings' ? null : 'settings')} title="Settings"><span className="spin">⚙</span> SETTINGS<span className="led" style={{ '--led': dueCount ? 'var(--crit)' : st.sound ? 'var(--op)' : 'var(--fg-3)' }}></span></button>
</div>
</div>
{hudOpen && <VoiceHUD listening={voiceOn} interim={interim} log={vlog} onClose={() => setHudOpen(false)} supported={voiceSupported} error={vErr} wake={st.voiceWake ? 'medic' : ''}/>}
{drawer === 'display' && <DisplayDrawer t={t} setTweak={setTweak} st={st} onClose={() => openDrawer(null)}/>}
{drawer === 'settings' && <SettingsDrawer st={st} setSt={setSt} onClose={() => openDrawer(null)} team={team} S={S} onResetTeam={resetTeam} onBlankTeam={blankTeam} onResetSettings={() => setSt({ ...ODA_SETTINGS_DEFAULT })}/>}
{drawer === 'export' && <ExportDrawer st={st} S={S} team={team} mb={mb} onClose={() => openDrawer(null)} onImport={(d) => setS({ ...d, settings: { ...ODA_SETTINGS_DEFAULT, ...(d.settings || {}) } })}/>}
{focus
? <div className={'focus-bar st-' + stat} data-screen-label="Focus bar">
<button className="fb-nav" onClick={() => step(-1)} title="Previous casualty (←)">◂</button>
<span className="fb-who"><b>{mb.role}</b> {mb.name}<span className="fb-pos">P{mb.pos} · {STATUS_LABEL[stat]}</span></span>
<span className="fb-dots">{stepList.map(x => <i key={x.id} className={'st-' + memberStatus(x) + (x.id === mb.id ? ' cur' : '')} onClick={() => { odaSnd('seg', st); select(x.id); }} title={x.role + ' ' + x.name}></i>)}</span>
<span className="fb-lvl">×{focus} {focus === 1 ? 'casualty' : focus === 2 ? 'injury map' : 'map + treatment'}</span>
{focus === 3 && <span className="fb-vitals">{[['HR', lastVital.hr, +lastVital.hr > 120 || +lastVital.hr < 50 ? 'crit' : +lastVital.hr > 100 ? 'warn' : ''], ['BP', lastVital.sbp ? `${lastVital.sbp}/${lastVital.dbp}` : null, +lastVital.sbp < 90 ? 'crit' : +lastVital.sbp < 100 ? 'warn' : ''], ['RR', lastVital.rr, +lastVital.rr > 24 || +lastVital.rr < 10 ? 'crit' : ''], ['SpO₂', lastVital.spo2, +lastVital.spo2 < 90 ? 'crit' : +lastVital.spo2 < 94 ? 'warn' : ''], ['AVPU', mb.avpu, mb.avpu && mb.avpu !== 'A' ? 'warn' : '']].map(([k, v, c]) => <span key={k} className={c}>{k} <b>{v || '—'}</b></span>)}<span>@ <b>{lastVital.time || '—'}</b></span></span>}
<span className="fb-hint">{stepIdx + 1}/{stepList.length} {stepList.length < team.length ? 'casualties' : 'team'} · ← → step · ↑ ↓ level · Esc exits</span>
{dueMap[mb.id] && <span className="fb-due">⚠ {dueMap[mb.id]}</span>}
<button className="fb-nav" onClick={() => step(1)} title="Next casualty (→)">▸</button>
</div>
: <TeamStrip team={team} selectedId={mb.id} onSelect={select} opts={st} dueMap={dueMap}/>}
{/* ===== CASUALTY HEADER ===== */}
{focus < 3 && <div className="roster" data-screen-label={'Casualty ' + mb.role}>
<div className="cell">
<div className="lbl">{mb.role} · {mb.title} · Last4</div>
<div className="val lg" style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<input value={mb.name} onChange={e => set('name')(e.target.value)} placeholder="LAST, F." style={{ width: '180px' }}/>
<span className="small">/</span>
<input value={mb.last4} onChange={e => set('last4')(e.target.value)} placeholder="0000" maxLength="4" style={{ width: '84px' }} className="last4-input"/>
</div>
<div className="findings-h"><span className="lbl" style={{ margin: 0 }}>Acute Findings</span><span className="findings-count">{mb.acuteFindings.length ? `${mb.acuteFindings.length} flagged` : 'tap to flag'}</span></div>
<div className="findings-chips">{ACUTE_FINDINGS.map(f => <div key={f} className={'fchip ' + (mb.acuteFindings.includes(f) ? 'active' : '')} onClick={() => toggleIn('acuteFindings', f)}>{f}</div>)}</div>
</div>
<div className="cell">
<div className="lbl">Readiness</div>
<div className="stat-row">{['auto', ...STATUS_ORDER].map(k => { const eff = k === 'auto' ? stat : k; return <button key={k} className={'st-' + eff + (mb.status === k ? ' active' : '')} onClick={() => set('status')(k)} title={k === 'auto' ? 'Derived from triage / evac / injuries' : STATUS_LABEL[k]}>{k === 'auto' ? 'Auto' : k}</button>; })}</div>
<div className="lbl" style={{ marginTop: '8px' }}>Ammo · Equipment · Position</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span className={'cyc st-' + mb.ammo} onClick={() => set('ammo')(cycle(mb.ammo))}><i></i>Ammo {mb.ammo}</span>
<span className={'cyc st-' + mb.equip} onClick={() => set('equip')(cycle(mb.equip))}><i></i>Eqp {mb.equip}</span>
<span className="val mono" style={{ fontSize: 12 }}>P<input type="number" min="1" max="12" value={mb.pos} onChange={e => set('pos')(Number(e.target.value) || 1)} style={{ width: 44 }}/></span>
</div>
</div>
<div className="cell">
<div className="lbl">Sex · Weight</div>
<div className="val" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ display: 'inline-flex', gap: '4px' }}>{['M', 'F'].map(g => <button key={g} onClick={() => set('gender')(g)} style={{ background: mb.gender === g ? 'var(--op)' : 'transparent', border: '1px solid ' + (mb.gender === g ? 'var(--op)' : 'var(--line-2)'), color: mb.gender === g ? '#0c1410' : 'var(--fg-1)', fontFamily: 'var(--mono)', fontSize: '12px', width: '24px', height: '24px', borderRadius: '2px', cursor: 'pointer' }}>{g}</button>)}</span>
<span className="mono" style={{ fontSize: 13 }}><input type="number" value={mb.weight} onChange={e => set('weight')(Number(e.target.value) || 0)} style={{ width: '60px' }}/> kg</span>
</div>
<div className="lbl" style={{ marginTop: '6px' }}>Service · Unit</div>
<div className="val mono" style={{ fontSize: '12px' }}><input value={mb.service} onChange={e => set('service')(e.target.value)} style={{ width: '48px' }}/><span className="muted"> · </span><input value={mb.unit} onChange={e => set('unit')(e.target.value)} style={{ width: '90px' }}/></div>
</div>
<div className="cell">
<div className="lbl">Blood Type</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '2px' }}><span className="blood-chip" title="Set in the Blood tab" style={{ cursor: 'pointer' }} onClick={() => setTab('blood')}>{mb.bloodType}</span>{(!mb.bloodType || mb.bloodType === '—') && <span className="muted mono tiny">set in Blood tab →</span>}</div>
<div className="lbl" style={{ marginTop: '6px' }}>Allergies</div>
<div className="val mono" style={{ fontSize: '12px', color: mb.allergies === 'NKDA' ? 'var(--op)' : 'var(--amber)' }}><input value={mb.allergies} onChange={e => set('allergies')(e.target.value)}/></div>
</div>
<div className="cell">
<div className="lbl">EVAC Priority</div>
<div className="evac">{['urgent', 'priority', 'routine'].map(k => <button key={k} className={mb.evac === k ? 'active ' + k : ''} onClick={() => set('evac')(mb.evac === k ? '' : k)}>{k}</button>)}</div>
<div className="lbl" style={{ marginTop: '8px' }}>Triage</div>
<div className="triage">{[{ k: 'Immediate', c: 'var(--crit)' }, { k: 'Delayed', c: 'var(--amber)' }, { k: 'Minimal', c: 'var(--op)' }, { k: 'Expectant', c: '#555' }].map(opt => { const a = mb.triage === opt.k.toLowerCase(); return <button key={opt.k} className={'tri-btn ' + (a ? 'active' : '')} style={{ '--tri-c': opt.c, color: a ? '#fff' : opt.c, background: a ? opt.c : 'transparent', borderColor: opt.c }} onClick={() => set('triage')(a ? '' : opt.k.toLowerCase())} title={opt.k}>{opt.k.slice(0, 3)}</button>; })}</div>
</div>
</div>}
{/* ===== MAIN ===== */}
<div className="main">
<div className="col col-body"><div className="scrollable">
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>INJURY MAP</b><span className="right">{mb.injuries.length} marks · {activeTQs} TQ{tbsa > 0 ? ` · ${tbsa.toFixed(1)}% TBSA` : ''}</span></div>
<BodyDiagram key={mb.id} injuries={mb.injuries} setInjuries={set('injuries')} tourniquets={mb.tourniquets} setTourniquets={set('tourniquets')} burnedRegions={mb.burnedRegions || []} setBurnedRegions={set('burnedRegions')} weightKg={mb.weight}/>
</div>
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>MECHANISM OF INJURY</b></div>
<div className="panel-body"><div className="chips">{MECHANISMS.map(x => <div key={x} className={'chip ' + (mb.mechanism.includes(x) ? 'active' : '')} onClick={() => toggleIn('mechanism', x)}>{x}</div>)}</div></div>
</div>
</div></div>
<div className="col col-vitals"><div className="scrollable">
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>SIGNS &amp; SYMPTOMS · LIVE</b><span className="right">{mb.role} {mb.name} · latest: {lastVital.time || '—'}</span></div>
<VitalsPanel key={mb.id + (ecgOn ? 'a' : 'm')} vitals={mb.vitals} setVitals={set('vitals')} draft={mb.draftVitals} setDraft={set('draftVitals')} avpu={mb.avpu} setAVPU={set('avpu')} showSparklines={t.showSparklines}/>
</div>
<div className="panel"><div className="panel-h"><span className="tick"></span><b>TQ &amp; MED · TIMELINE</b></div><Timeline state={mb} setState={setMember}/></div>
<div className="panel"><div className="panel-h"><span className="tick"></span><b>FIELD NOTES · MIST</b><span className="right">auto-fills as data is entered</span></div><MistPanel state={mb} setNotes={set('notes')} notes={mb.notes}/></div>
</div></div>
<div className="col col-tabs">
<div className="tabs">{[['march', 'MARCH'], ['burn', 'Burn'], ['meds', 'Meds'], ['drip', 'Drip'], ['blood', 'Blood'], ['nine', '9-Line'], ['ref', 'Ref']].map(([k, l]) => <button key={k} className={tab === k ? 'active' : ''} onClick={() => setTab(k)}>{l}</button>)}</div>
{tab === 'march' && <MarchTab key={mb.id} mb={mb} upd={upd} now={now}/>}
{tab === 'burn'  && <BurnTab key={mb.id} mb={mb} upd={upd} now={now}/>}
{tab === 'meds'  && <MedsTab medsGiven={mb.medsGiven} setMedsGiven={set('medsGiven')} weight={mb.weight}/>}
{tab === 'drip'  && <DripTab key={mb.id} mb={mb} upd={upd} now={now}/>}
{tab === 'blood' && <div className="scrollable"><TeamBloodBank team={team} mb={mb} onSelect={select}/><BloodTab bloodType={mb.bloodType} setBloodType={set('bloodType')} fluidsGiven={mb.fluidsGiven} setFluidsGiven={set('fluidsGiven')}/></div>}
{tab === 'nine'  && <NineLine team={team} nine={S.nine || {}} setNine={setTop('nine')}/>}
{tab === 'ref'   && <RefTab/>}
</div>
</div>
<div className="statusbar">
<div className="seg"><span>●</span><b style={{ color: 'var(--op)' }}>SECURE</b></div>
<div className="seg">TEAM <b style={{ color: cas ? 'var(--crit)' : 'var(--op)' }}>{12 - cas}/12 UP</b></div>
<div className="seg">SEL <b>{mb.role} {mb.name}</b></div>
<div className="seg">EVAC <b style={{ color: mb.evac === 'urgent' ? 'var(--crit)' : mb.evac === 'priority' ? 'var(--amber)' : 'var(--op)' }}>{(mb.evac || '—').toUpperCase()}</b></div>
<div className="seg">TQ <b>{activeTQs}/4</b></div>
<div className="seg">VITALS <b>{mb.vitals.length} rec</b></div>
{dueCount > 0 && <div className="seg"><b style={{ color: 'var(--crit)', animation: 'pulse 1.2s ease-in-out infinite' }}>⚠ {dueCount} DUE</b></div>}
<div style={{ marginLeft: 'auto' }}></div>
<div className="seg">{(t.timeMode === 'local' || t.timeMode === 'both') && <><b>{localTime}</b> LOC</>}{t.timeMode === 'both' && ' · '}{(t.timeMode === 'zulu' || t.timeMode === 'both') && <b>{zuluTime}</b>}</div>
<div className="seg"><button onClick={clearMember} style={{ background: 'transparent', border: 'none', color: 'var(--fg-3)', fontFamily: 'var(--mono)', fontSize: '10px', letterSpacing: '0.15em', cursor: 'pointer', textTransform: 'uppercase' }}>⨯ CLEAR {mb.role}</button></div>
<div className="seg"><button onClick={resetTeam} style={{ background: 'transparent', border: 'none', color: 'var(--fg-3)', fontFamily: 'var(--mono)', fontSize: '10px', letterSpacing: '0.15em', cursor: 'pointer', textTransform: 'uppercase' }}>↺ RESET TEAM</button></div>
</div>
<TweaksPanel title="Tweaks">
<TweakSection label="Theme" />
<TweakSelect label="Mode" value={t.theme} options={[{ value: 'tactical', label: 'Tactical (night)' }, { value: 'day', label: 'Day mode' }, { value: 'paper', label: 'DD1380 Paper' }]} onChange={(v) => setTweak('theme', v)} />
<TweakSelect label="Accent" value={t.accent} options={[{ value: 'op', label: 'Operational Green' }, { value: 'amber', label: 'Caution Amber' }, { value: 'crit', label: 'Critical Red' }, { value: 'info', label: 'Tactical Blue' }]} onChange={(v) => setTweak('accent', v)} />
<TweakSection label="Layout" />
<TweakRadio label="Density" value={t.density} options={['compact', 'regular', 'roomy']} onChange={(v) => setTweak('density', v)} />
<TweakToggle label="Grid backdrop" value={t.showGrid} onChange={(v) => setTweak('showGrid', v)} />
<TweakToggle label="Vital sparklines" value={t.showSparklines} onChange={(v) => setTweak('showSparklines', v)} />
<TweakSection label="Time" />
<TweakRadio label="Display" value={t.timeMode} options={[{ value: 'local', label: 'Local' }, { value: 'zulu', label: 'Zulu' }, { value: 'both', label: 'Both' }]} onChange={(v) => setTweak('timeMode', v)} />
</TweaksPanel>
</div>
);
}
function Timeline({ state, setState }) {
const events = [];
state.medsGiven.forEach(m => events.push({
t: m.time, k: 'med', label: m.name, sub: m.dose,
onClear: () => setState(p => ({ ...p, medsGiven: p.medsGiven.filter(x => x.id !== m.id) }))
}));
state.fluidsGiven.forEach(m => events.push({
t: m.time, k: 'fluid', label: m.type, sub: m.vol ? m.vol + ' mL' : '',
onClear: () => setState(p => ({ ...p, fluidsGiven: p.fluidsGiven.filter(x => x.id !== m.id) }))
}));
state.vitals.forEach((v, idx) => events.push({
t: v.time, k: 'vital',
label: `HR ${v.hr} · BP ${v.sbp}/${v.dbp}`,
sub: `RR ${v.rr} SpO₂ ${v.spo2}`,
onClear: () => setState(p => ({ ...p, vitals: p.vitals.filter((_, i) => i !== idx) }))
}));
Object.entries(state.tourniquets).forEach(([k, tq]) => {
if (tq.active && tq.time) {
const side = { rArm:'R ARM', lArm:'L ARM', rLeg:'R LEG', lLeg:'L LEG' }[k];
events.push({
t: tq.time, k: 'tq', label: `TQ ${side}`, sub: [tq.type, tq.position].filter(Boolean).join(' · ') || 'applied',
onClear: () => setState(p => ({
...p,
tourniquets: { ...p.tourniquets, [k]: { active: false, type: '', time: '', position: '' } }
}))
});
}
});
events.sort((a, b) => (a.t || '').localeCompare(b.t || ''));
if (events.length === 0) {
return <div style={{padding:'14px', fontFamily:'var(--mono)', fontSize:'11px', color:'var(--fg-3)'}}>No events yet.</div>;
}
const times = events.map(e => parseTime(e.t)).filter(n => !isNaN(n));
const t0 = Math.min(...times);
const t1 = Math.max(...times);
const span = Math.max(1, t1 - t0);
return (
<div style={{padding:'4px 14px 14px'}}>
<div style={{position:'relative', height: events.length * 26 + 30}}>
{/* axis */}
<div style={{position:'absolute', left:'72px', right:'24px', top:14, height:1, background:'var(--line-2)'}}></div>
{/* tick labels */}
<div style={{position:'absolute', left:'72px', right:'24px', top:0, display:'flex', justifyContent:'space-between', fontFamily:'var(--mono)', fontSize:'9px', color:'var(--fg-3)'}}>
<span>{events[0]?.t || ''}</span>
<span>{events[events.length-1]?.t || ''}</span>
</div>
{events.map((e, i) => {
const tx = parseTime(e.t);
const pct = isNaN(tx) ? 0 : ((tx - t0) / span) * 100;
const color = e.k === 'tq' ? 'var(--op)'
: e.k === 'med' ? 'var(--amber)'
: e.k === 'fluid' ? 'var(--info)'
: 'var(--fg-2)';
return (
<div key={i} className="tl-row" style={{
position:'absolute', top: 26 + i*22, left:0, right:0,
display:'grid', gridTemplateColumns:'56px 1fr 22px',
alignItems:'center', gap:'8px',
fontFamily:'var(--mono)', fontSize:'11px'
}}>
<span style={{color:'var(--fg-2)', textAlign:'right'}}>{e.t || '----'}</span>
<span style={{position:'relative', display:'block', height:'14px'}}>
<span style={{
position:'absolute', top:'3px',
width:8, height:8, background: color, borderRadius: e.k==='tq'?'1px':'50%',
left: `calc(${pct}% - 4px)`,
boxShadow:`0 0 6px ${color}`
}}></span>
<span style={{position:'absolute', left:0, top:'-2px', color:'var(--fg-0)', whiteSpace:'nowrap'}}>
{e.label} <span style={{color:'var(--fg-2)'}}>· {e.sub}</span>
</span>
</span>
<button className="tl-clear" onClick={e.onClear} title="Clear this event">×</button>
</div>
);
})}
</div>
</div>
);
}
function parseTime(t) {
if (!t) return NaN;
const s = String(t).replace(':','');
const h = parseInt(s.slice(0,2));
const m = parseInt(s.slice(2,4));
if (isNaN(h) || isNaN(m)) return NaN;
return h * 60 + m;
}
function injurySite(i) {
const { x, y, view } = i;
const patientR = view === 'front' ? x < 100 : x >= 100;
const side = patientR ? 'R' : 'L';
const post = view === 'back' ? ' (post)' : '';
const midline = x >= 88 && x <= 112;
if (y < 70) return `${midline ? '' : side + ' '}head/neck${post}`;
if (y < 245 && (x < 55 || x > 145)) return `${side} ${y < 150 ? 'upper' : 'lower'} arm${post}`;
if (y < 158) return view === 'back' ? `${midline ? '' : side + ' '}upper back` : `${midline ? 'central ' : side + ' '}chest`;
if (y < 232) return view === 'back' ? `${midline ? '' : side + ' '}lower back` : `${midline ? '' : side + ' '}${x < 70 || x > 130 ? 'flank' : 'abdomen'}`;
if (y < 262) return view === 'back' ? `${side} buttock` : (midline ? 'pelvis/perineum' : `${side} groin`);
if (y < 345) return `${side} thigh${post}`;
if (y < 420) return `${side} lower leg${post}`;
return `${side} foot${post}`;
}
function buildMIST(state) {
const M = state.mechanism && state.mechanism.length
? state.mechanism.join(' · ')
: '—';
const counts = { gsw: 0, burn: 0, frac: 0, shrap: 0 };
(state.injuries || []).forEach(i => { counts[i.type] = (counts[i.type] || 0) + 1; });
const front = (state.injuries || []).filter(i => i.view === 'front').length;
const back  = (state.injuries || []).filter(i => i.view === 'back').length;
const injParts = [];
const sites = (state.injuries || []).filter(i => i.type !== 'burn' && i.x != null).map(i => `${({gsw:'GSW',frac:'Frac',shrap:'Shrap'})[i.type] || i.type} ${injurySite(i)}`);
if (sites.length) injParts.push(...sites);
if (counts.burn || (state.burnedRegions && state.burnedRegions.length)) {
const burnTbsa = (typeof estimateBurnTBSA === 'function') ? estimateBurnTBSA(state.injuries, state.burnedRegions) : 0;
if (burnTbsa > 0) injParts.push(`Burn ~${burnTbsa.toFixed(1)}% TBSA`);
else if (counts.burn) injParts.push(`${counts.burn}× Burn`);
}
if (front || back) injParts.push(`(ant ${front}/post ${back})`);
const tqActive = Object.entries(state.tourniquets || {})
.filter(([_,t]) => t.active)
.map(([k,t]) => {
const side = { rArm:'R arm', lArm:'L arm', rLeg:'R leg', lLeg:'L leg' }[k];
return `TQ ${side}${t.type ? ' '+t.type : ''}${t.position ? ' ('+t.position+')' : ''}${t.time ? ' @'+t.time : ''}`;
});
if (tqActive.length) injParts.push(...tqActive);
if (state.acuteFindings && state.acuteFindings.length) {
injParts.unshift(state.acuteFindings.join(' · '));
}
const I = injParts.length ? injParts.join(', ') : '—';
const draft = state.draftVitals || {};
const draftHasData = !!(draft.hr || draft.sbp || draft.dbp || draft.rr || draft.spo2 || draft.pain);
const v = draftHasData
? { ...draft, time: '' }
: (state.vitals || [])[state.vitals.length - 1];
let S = '—';
if (v) {
const bits = [];
if (v.hr)  bits.push(`HR ${v.hr}`);
if (v.sbp || v.dbp) bits.push(`BP ${v.sbp||'?'}/${v.dbp||'?'}`);
if (v.rr)  bits.push(`RR ${v.rr}`);
if (v.spo2)bits.push(`SpO₂ ${v.spo2}`);
if (v.pain)bits.push(`Pain ${v.pain}/10`);
if (state.avpu) bits.push(`AVPU ${state.avpu}`);
S = (v.time ? `@${v.time} ` : '') + bits.join(' · ');
} else if (state.avpu) {
S = `AVPU ${state.avpu}`;
}
const tx = [];
(state.interventions || []).forEach(s => {
const [, label] = s.split(':');
if (label) tx.push(label);
});
(state.medsGiven || []).forEach(m => {
tx.push(`${m.name} ${m.dose}${m.time ? ' @'+m.time : ''}`);
});
(state.fluidsGiven || []).forEach(f => {
tx.push(`${f.type}${f.vol ? ' '+f.vol+'mL' : ''}${f.time ? ' @'+f.time : ''}`);
});
const T = tx.length ? tx.join('; ') : '—';
return { M, I, S, T };
}
function MistPanel({ state, notes, setNotes }) {
const mist = buildMIST(state);
const fullText = `M: ${mist.M}\nI: ${mist.I}\nS: ${mist.S}\nT: ${mist.T}`;
const copyToNotes = () => {
const sep = notes && notes.trim() ? '\n\n' : '';
setNotes((notes || '') + sep + `--- MIST @ ${nowHHMM()} ---\n` + fullText);
};
return (
<div style={{padding:'8px 14px 14px'}}>
<div className="mist-grid">
{[
['M', 'Mechanism',  mist.M],
['I', 'Injuries',   mist.I],
['S', 'Signs',      mist.S],
['T', 'Treatments', mist.T],
].map(([k, label, val]) => (
<div className="mist-row" key={k}>
<span className="mist-k"><b>{k}</b><span className="mist-lbl">{label}</span></span>
<span className="mist-v">{val}</span>
</div>
))}
</div>
<div style={{display:'flex', gap:'6px', marginTop:'10px', alignItems:'center'}}>
<button className="mist-btn" onClick={copyToNotes} title="Snapshot current MIST to narrative notes">
⇣ APPEND TO NOTES
</button>
<span style={{flex:1}}></span>
<span className="muted mono tiny" style={{letterSpacing:'0.1em'}}>NARRATIVE</span>
</div>
<textarea
className="notes"
value={notes}
onChange={e=>setNotes(e.target.value)}
placeholder="Free-text narrative. Click APPEND TO NOTES to stamp current MIST + time."
style={{marginTop:'4px'}}
/>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);