settings: add mic_unit_pref for event-report chart

New UserPreferences field controls the mic channel's unit on the
SFM event-detail modal's waveform chart only.  "dBL" default,
"psi" alternate.  Peaks everywhere else (tables, KPI tiles, modal
summary) stay in dBL regardless — this is strictly a chart-axis
preference.

Surfaced as a single dropdown on Settings → General, below the
auto-refresh interval.

Setting up the storage half ahead of the chart port in the next
commit, so the chart can read the value from /api/settings/preferences
on first render instead of needing a follow-up wiring pass.

Includes idempotent backend/migrate_add_mic_unit_pref.py for fleets
already on an older schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 00:56:41 +00:00
parent b2bfa6d268
commit db8d666aa1
4 changed files with 83 additions and 1 deletions
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""
Database migration: Add mic_unit_pref column to user_preferences.
Adds a single field controlling the mic channel's unit on the event-
report waveform chart in the SFM event detail modal. "dBL" (default)
or "psi". Peaks and KPI tiles elsewhere are always dBL regardless.
Idempotent — safe to re-run.
"""
import sqlite3
from pathlib import Path
def migrate():
possible_paths = [
Path("data/seismo_fleet.db"),
Path("data/sfm.db"),
Path("data/seismo.db"),
]
db_path = next((p for p in possible_paths if p.exists()), None)
if db_path is None:
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
print("Will be created with the new column when models.py initialises.")
return
print(f"Using database: {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA table_info(user_preferences)")
existing = {row[1] for row in cur.fetchall()}
if "mic_unit_pref" in existing:
print("mic_unit_pref already exists — nothing to do.")
conn.close()
return
cur.execute(
"ALTER TABLE user_preferences "
"ADD COLUMN mic_unit_pref TEXT DEFAULT 'dBL'"
)
# Backfill the single row that should exist (id=1) to the default,
# in case the column ends up NULL on existing rows.
cur.execute(
"UPDATE user_preferences SET mic_unit_pref = 'dBL' "
"WHERE mic_unit_pref IS NULL"
)
conn.commit()
conn.close()
print("Added mic_unit_pref to user_preferences (default 'dBL').")
if __name__ == "__main__":
migrate()
+3
View File
@@ -135,6 +135,9 @@ class UserPreferences(Base):
calibration_warning_days = Column(Integer, default=30) calibration_warning_days = Column(Integer, default=30)
status_ok_threshold_hours = Column(Integer, default=12) status_ok_threshold_hours = Column(Integer, default=12)
status_pending_threshold_hours = Column(Integer, default=24) status_pending_threshold_hours = Column(Integer, default=24)
# Mic display units on the event-report waveform chart only — peaks
# and KPI tiles elsewhere are always dBL. "dBL" (default) or "psi".
mic_unit_pref = Column(String, default="dBL")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+3
View File
@@ -267,6 +267,7 @@ class PreferencesUpdate(BaseModel):
calibration_warning_days: Optional[int] = None calibration_warning_days: Optional[int] = None
status_ok_threshold_hours: Optional[int] = None status_ok_threshold_hours: Optional[int] = None
status_pending_threshold_hours: Optional[int] = None status_pending_threshold_hours: Optional[int] = None
mic_unit_pref: Optional[str] = None
@router.get("/preferences") @router.get("/preferences")
@@ -293,6 +294,7 @@ def get_preferences(db: Session = Depends(get_db)):
"calibration_warning_days": prefs.calibration_warning_days, "calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
@@ -334,6 +336,7 @@ def update_preferences(
"calibration_warning_days": prefs.calibration_warning_days, "calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours, "status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"mic_unit_pref": prefs.mic_unit_pref or "dBL",
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
+21 -1
View File
@@ -122,6 +122,21 @@
How often the dashboard should refresh automatically How often the dashboard should refresh automatically
</p> </p>
</div> </div>
<!-- Event-Report Mic Units -->
<div>
<label for="mic-unit-pref" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Event Report — Mic Channel Units
</label>
<select id="mic-unit-pref"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<option value="dBL" selected>dB(L) — sound pressure level</option>
<option value="psi">psi — raw pressure</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Applies only to the waveform chart inside the event detail modal. Peak values everywhere else (tables, KPIs, modal summary) stay in dB(L) regardless.
</p>
</div>
</div> </div>
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"> <button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
@@ -771,6 +786,9 @@ async function loadPreferences() {
// Load auto-refresh interval // Load auto-refresh interval
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10; document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
// Load event-report mic units
document.getElementById('mic-unit-pref').value = prefs.mic_unit_pref || 'dBL';
// Load status thresholds // Load status thresholds
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12; document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24; document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
@@ -788,6 +806,7 @@ async function saveGeneralSettings() {
const timezone = document.getElementById('timezone-select').value; const timezone = document.getElementById('timezone-select').value;
const theme = document.querySelector('input[name="theme"]:checked').value; const theme = document.querySelector('input[name="theme"]:checked').value;
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value); const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
const micUnitPref = document.getElementById('mic-unit-pref').value;
try { try {
const response = await fetch('/api/settings/preferences', { const response = await fetch('/api/settings/preferences', {
@@ -796,7 +815,8 @@ async function saveGeneralSettings() {
body: JSON.stringify({ body: JSON.stringify({
timezone, timezone,
theme, theme,
auto_refresh_interval: autoRefreshInterval auto_refresh_interval: autoRefreshInterval,
mic_unit_pref: micUnitPref
}) })
}); });