Skip to content

Commit 14552fc

Browse files
committed
v7.5.0 — Emergency Mode (capstone)
The feature that orchestrates the rest. NOMAD already had every component you need for a live event — SITREP, watch rotation, incidents, contacts, AI, Situation Room, proximity alerts — but nothing that glued them together when the balloon went up. Emergency Mode does. Enter flow: - Sidebar "EMERGENCY" button (red, always visible) opens a modal - User types a brief reason (e.g. "Tornado warning") - On confirm: writes emergency_active=true to settings, creates a critical incident in the timeline, broadcasts SSE event to every open tab, Voice Copilot announces the activation via TTS Active state: - Persistent red banner across every tab (fixed, top) - Live duration counter (updates every minute) - Body gets .emergency-mode class → 3px red glow inset on shell - Animated pulse on banner (respects prefers-reduced-motion) - Sidebar enter button hides - Banner survives page reloads (state read from settings on DOMContentLoaded) - Multi-tab safe — every open NOMAD window syncs via SSE Exit flow: - Exit button opens a closeout modal with a free-form note - On confirm: clears state, writes an "Emergency mode exited (Xh): <reason>" incident with the closeout note appended, broadcasts SSE exit, TTS announces, banner dismisses - Closeout incident gives you an auditable after-action review entry without any separate form All operations idempotent: - Entering while already active → returns existing state, no mutation - Exiting while inactive → no-op Files: - web/blueprints/emergency.py (new) — 3 endpoints, settings-backed state - web/templates/index_partials/_shell.html — sidebar button, banner, enter + exit modals - web/templates/index_partials/js/_app_init_runtime.js — state apply, tick interval, enter/exit handlers - web/templates/index_partials/js/_app_shell_router.js — 6 shell actions - web/static/js/events.js — SSE subscriptions for cross-tab sync - web/blueprints/system.py — 4 new keys in SETTINGS_WHITELIST for config-restore round-trip safety - web/static/css/app/40_preparedness_media.css — banner, button, modal styling 9 new tests: status-default, enter-then-status, idempotent enter, idempotent exit, enter-then-exit, creates-incident, creates-closeout- incident, default-reason-if-blank, reason-truncated-to-500. Tests: 870 passed (was 861). This closes the 5-feature arc (v7.1 Voice Copilot, v7.2 Location-aware SR, v7.3 Kit Builder, v7.4 Route Plan, v7.5 Emergency Mode). Each feature stands alone but they compound: Emergency Mode uses the Voice Copilot for audible announcements, the Situation Room proximity for relevant context, and the Kit Builder's resource model lives nearby for quick reference during a live event.
1 parent 75e7b16 commit 14552fc

12 files changed

Lines changed: 645 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div align="center">
22
<img src="nomad-mark.png" width="140" height="140"/>
33

4-
# NOMAD Field Desk v7.4.0
4+
# NOMAD Field Desk v7.5.0
55

66
### Your Personal Intelligence & Preparedness Command Center
77

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Config:
3636
"""Central configuration with environment variable overrides."""
3737

3838
# --- App Identity ---
39-
VERSION = os.environ.get('NOMAD_VERSION', '7.4.0')
39+
VERSION = os.environ.get('NOMAD_VERSION', '7.5.0')
4040

4141
# --- Upload / Content Limits ---
4242
MAX_CONTENT_LENGTH = int(os.environ.get('NOMAD_MAX_CONTENT_LENGTH', 100 * 1024 * 1024)) # 100 MB

installer.iss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
; NOMAD Field Desk Inno Setup Script
2-
; AppVersion: 7.4.0
2+
; AppVersion: 7.5.0
33

44
#define MyAppName "NOMAD Field Desk"
5-
#define MyAppVersion "7.4.0"
5+
#define MyAppVersion "7.5.0"
66
#define MyAppPublisher "SysAdminDoc"
77
#define MyAppURL "https://github.com/SysAdminDoc/project-nomad-desktop"
88
#define MyAppSupportURL "https://github.com/SysAdminDoc/project-nomad-desktop/issues"

tests/test_emergency.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Tests for Emergency Mode (v7.5.0).
2+
3+
Covers idempotency (enter-while-active, exit-while-inactive), state
4+
persistence across status/enter/exit cycles, and the incident side-
5+
effects that make the feature auditable.
6+
"""
7+
8+
9+
class TestEmergencyMode:
10+
def test_status_defaults_inactive(self, client):
11+
resp = client.get('/api/emergency/status')
12+
assert resp.status_code == 200
13+
data = resp.get_json()
14+
assert data['active'] is False
15+
assert data['started_at'] is None
16+
assert data['reason'] == ''
17+
18+
def test_enter_then_status_shows_active(self, client):
19+
resp = client.post('/api/emergency/enter', json={'reason': 'Severe weather'})
20+
assert resp.status_code == 201
21+
data = resp.get_json()
22+
assert data['active'] is True
23+
assert data['reason'] == 'Severe weather'
24+
assert data['started_at'] is not None
25+
26+
# Status endpoint now reflects it
27+
status = client.get('/api/emergency/status').get_json()
28+
assert status['active'] is True
29+
assert status['reason'] == 'Severe weather'
30+
assert status['duration_hours'] is not None
31+
assert status['duration_hours'] >= 0
32+
33+
def test_enter_when_already_active_is_idempotent(self, client):
34+
client.post('/api/emergency/enter', json={'reason': 'First reason'})
35+
resp = client.post('/api/emergency/enter', json={'reason': 'Second reason'})
36+
# Doesn't 400 or 500, doesn't overwrite the existing state
37+
assert resp.status_code == 200
38+
data = resp.get_json()
39+
assert data.get('already_active') is True
40+
# Original reason preserved
41+
assert data['reason'] == 'First reason'
42+
43+
def test_exit_when_not_active_is_idempotent(self, client):
44+
resp = client.post('/api/emergency/exit', json={})
45+
assert resp.status_code == 200
46+
data = resp.get_json()
47+
assert data['active'] is False
48+
assert data.get('already_inactive') is True
49+
50+
def test_enter_then_exit(self, client):
51+
client.post('/api/emergency/enter', json={'reason': 'Test'})
52+
resp = client.post('/api/emergency/exit', json={'closeout_note': 'All clear'})
53+
assert resp.status_code == 200
54+
data = resp.get_json()
55+
assert data['active'] is False
56+
assert data['duration_hours'] is not None
57+
# Status now reports inactive
58+
status = client.get('/api/emergency/status').get_json()
59+
assert status['active'] is False
60+
61+
def test_enter_creates_incident(self, client):
62+
client.post('/api/emergency/enter', json={'reason': 'Tornado warning'})
63+
# There should now be at least one critical incident
64+
incidents = client.get('/api/incidents').get_json()
65+
assert any(
66+
i.get('severity') == 'critical' and 'Tornado warning' in (i.get('description') or '')
67+
for i in incidents
68+
), 'Expected a critical incident for emergency entry'
69+
70+
def test_exit_creates_closeout_incident(self, client):
71+
client.post('/api/emergency/enter', json={'reason': 'Fire'})
72+
client.post('/api/emergency/exit', json={'closeout_note': 'Fire extinguished'})
73+
incidents = client.get('/api/incidents').get_json()
74+
closeouts = [
75+
i for i in incidents
76+
if 'exited' in (i.get('description') or '').lower()
77+
]
78+
assert closeouts, 'Expected a closeout incident on exit'
79+
assert any('Fire extinguished' in (i.get('description') or '') for i in closeouts)
80+
81+
def test_default_reason_if_blank(self, client):
82+
resp = client.post('/api/emergency/enter', json={})
83+
data = resp.get_json()
84+
assert data['reason'] == 'Emergency' # default
85+
86+
def test_reason_truncated_to_500(self, client):
87+
"""Reason longer than 500 chars should be truncated, not 500 error."""
88+
long_reason = 'x' * 1000
89+
resp = client.post('/api/emergency/enter', json={'reason': long_reason})
90+
assert resp.status_code == 201
91+
assert len(resp.get_json()['reason']) == 500

web/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,6 +1281,10 @@ def api_offline_changes_since():
12811281
from web.blueprints.kit_builder import kit_builder_bp
12821282
app.register_blueprint(kit_builder_bp)
12831283

1284+
# ─── Emergency Mode (v7.5.0 — crisis orchestrator) ───────────────
1285+
from web.blueprints.emergency import emergency_bp
1286+
app.register_blueprint(emergency_bp)
1287+
12841288
# ─── User Plugins ─────────────────────────────────────────────────
12851289
from web.plugins import load_plugins
12861290
load_plugins(app)

web/blueprints/emergency.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Emergency Mode — the "balloon went up" orchestrator (v7.5.0).
2+
3+
NOMAD has every component you need in an active crisis (SITREP, watch
4+
rotation, incidents, contacts, AI, Situation Room alerts, proximity,
5+
etc.) but nothing that *orchestrates* them for a live event. The user
6+
shouldn't have to remember to start a watch schedule, log an incident,
7+
and generate a SITREP separately. Emergency Mode does it for you.
8+
9+
Entering Emergency Mode:
10+
1. Writes a persistent state flag to settings (emergency_active=true,
11+
emergency_started_at, emergency_reason).
12+
2. Auto-creates an incident log entry with severity=critical and the
13+
user-supplied reason.
14+
3. Broadcasts an SSE event so every open tab can enter red-mode UI
15+
without polling.
16+
17+
Exiting Emergency Mode:
18+
1. Clears the state flags.
19+
2. Appends a close-out incident log entry with total duration.
20+
3. Broadcasts an SSE event so UI drops out of red mode.
21+
22+
All operations are idempotent — entering while already active is a
23+
no-op (returns the existing state); exiting while inactive is a no-op.
24+
This matters because a page reload during active emergency mode must
25+
restore the banner without double-entering.
26+
"""
27+
28+
import logging
29+
from datetime import datetime, timezone
30+
31+
from flask import Blueprint, request, jsonify, current_app
32+
33+
from db import db_session, log_activity
34+
35+
emergency_bp = Blueprint('emergency', __name__)
36+
log = logging.getLogger('nomad.emergency')
37+
38+
_STATE_KEYS = {
39+
'emergency_active': 'False',
40+
'emergency_started_at': '',
41+
'emergency_reason': '',
42+
'emergency_incident_id': '',
43+
}
44+
45+
46+
def _read_state(db):
47+
"""Load the emergency state dict from settings. Missing keys default."""
48+
keys = tuple(_STATE_KEYS.keys())
49+
placeholders = ','.join('?' * len(keys))
50+
rows = db.execute(
51+
f'SELECT key, value FROM settings WHERE key IN ({placeholders})',
52+
keys,
53+
).fetchall()
54+
got = {r['key']: r['value'] for r in rows}
55+
return {
56+
'active': (got.get('emergency_active', 'False') or '').lower() == 'true',
57+
'started_at': got.get('emergency_started_at') or None,
58+
'reason': got.get('emergency_reason') or '',
59+
'incident_id': _parse_int(got.get('emergency_incident_id')),
60+
}
61+
62+
63+
def _write_state(db, **kwargs):
64+
"""Upsert any subset of the emergency_* settings keys."""
65+
for key, val in kwargs.items():
66+
full_key = f'emergency_{key}'
67+
if full_key not in _STATE_KEYS:
68+
continue
69+
db.execute(
70+
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
71+
(full_key, str(val) if val is not None else ''),
72+
)
73+
74+
75+
def _parse_int(v):
76+
try: return int(v) if v not in (None, '') else None
77+
except (TypeError, ValueError): return None
78+
79+
80+
def _duration_hours(started_iso):
81+
"""Hours (float) between started_iso and now. None on bad input."""
82+
if not started_iso:
83+
return None
84+
try:
85+
started = datetime.fromisoformat(started_iso.replace('Z', '+00:00'))
86+
except (TypeError, ValueError):
87+
return None
88+
if started.tzinfo is None:
89+
started = started.replace(tzinfo=timezone.utc)
90+
return round((datetime.now(timezone.utc) - started).total_seconds() / 3600, 2)
91+
92+
93+
def _broadcast(event_type, payload):
94+
"""Fire an SSE event so every open tab syncs without polling."""
95+
try:
96+
from web.app import _broadcast_event # circular-safe: only imported at call-time
97+
_broadcast_event(event_type, payload)
98+
except Exception:
99+
# SSE is a nice-to-have; never let a broadcast error block state change
100+
pass
101+
102+
103+
# ─── Routes ─────────────────────────────────────────────────────────
104+
105+
@emergency_bp.route('/api/emergency/status')
106+
def api_emergency_status():
107+
"""Return the current emergency state + derived duration.
108+
109+
Also used on page load so every tab can pick up the banner after
110+
a reload without separately querying settings.
111+
"""
112+
with db_session() as db:
113+
state = _read_state(db)
114+
state['duration_hours'] = _duration_hours(state['started_at'])
115+
return jsonify(state)
116+
117+
118+
@emergency_bp.route('/api/emergency/enter', methods=['POST'])
119+
def api_emergency_enter():
120+
"""Enter emergency mode. Idempotent — returns current state if
121+
already active. Body: ``{reason}`` (optional, default 'Emergency').
122+
"""
123+
data = request.get_json() or {}
124+
reason = (data.get('reason') or 'Emergency').strip()[:500]
125+
now_iso = datetime.now(timezone.utc).isoformat()
126+
127+
with db_session() as db:
128+
state = _read_state(db)
129+
if state['active']:
130+
# Already active — return current state without mutation
131+
state['duration_hours'] = _duration_hours(state['started_at'])
132+
return jsonify({**state, 'already_active': True})
133+
134+
# Create a critical incident for the timeline
135+
incident_id = None
136+
try:
137+
cur = db.execute(
138+
'INSERT INTO incidents (severity, category, description) VALUES (?, ?, ?)',
139+
('critical', 'emergency', f'Emergency mode entered: {reason}'),
140+
)
141+
incident_id = cur.lastrowid
142+
except Exception as e:
143+
log.warning(f'Could not create incident on emergency enter: {e}')
144+
145+
_write_state(db,
146+
active='True',
147+
started_at=now_iso,
148+
reason=reason,
149+
incident_id=incident_id if incident_id is not None else '',
150+
)
151+
db.commit()
152+
try:
153+
log_activity('emergency_enter', f'Emergency mode activated: {reason}')
154+
except Exception:
155+
pass
156+
157+
_broadcast('emergency_enter', {'reason': reason, 'started_at': now_iso})
158+
return jsonify({
159+
'active': True,
160+
'started_at': now_iso,
161+
'reason': reason,
162+
'incident_id': incident_id,
163+
'duration_hours': 0.0,
164+
}), 201
165+
166+
167+
@emergency_bp.route('/api/emergency/exit', methods=['POST'])
168+
def api_emergency_exit():
169+
"""Exit emergency mode. Idempotent — no-op if not currently active.
170+
Body: ``{closeout_note}`` (optional) gets logged to the incident.
171+
"""
172+
data = request.get_json() or {}
173+
closeout = (data.get('closeout_note') or '').strip()[:2000]
174+
175+
with db_session() as db:
176+
state = _read_state(db)
177+
if not state['active']:
178+
return jsonify({**state, 'already_inactive': True})
179+
180+
duration = _duration_hours(state['started_at'])
181+
duration_str = f'{duration}h' if duration is not None else 'unknown duration'
182+
exit_reason = state['reason'] or 'Emergency'
183+
184+
# Log the closeout as a second incident entry for the timeline
185+
try:
186+
msg = f'Emergency mode exited ({duration_str}): {exit_reason}'
187+
if closeout:
188+
msg += f' — {closeout}'
189+
db.execute(
190+
'INSERT INTO incidents (severity, category, description) VALUES (?, ?, ?)',
191+
('info', 'emergency', msg),
192+
)
193+
except Exception as e:
194+
log.warning(f'Could not create incident on emergency exit: {e}')
195+
196+
_write_state(db, active='False', started_at='', reason='', incident_id='')
197+
db.commit()
198+
try:
199+
log_activity('emergency_exit', f'Emergency mode deactivated ({duration_str})')
200+
except Exception:
201+
pass
202+
203+
_broadcast('emergency_exit', {'duration_hours': duration})
204+
return jsonify({
205+
'active': False,
206+
'duration_hours': duration,
207+
'reason': exit_reason,
208+
})

web/blueprints/system.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ def api_settings():
112112
# the Situation Room "Proximity Board" to filter global feeds to threats
113113
# within a given distance of the user's location.
114114
'latitude', 'longitude', 'proximity_radius_km',
115+
# Emergency Mode state — managed by /api/emergency/enter and /exit,
116+
# whitelisted here so a factory-reset / config-restore round-trip
117+
# can include them.
118+
'emergency_active', 'emergency_started_at', 'emergency_reason',
119+
'emergency_incident_id',
115120
}
116121

117122
@system_bp.route('/api/settings', methods=['PUT'])

0 commit comments

Comments
 (0)