# Changelog

All notable changes to Terra-View will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.15.0] - 2026-06-18

**Operator authentication** — the internal app gets a login. The operator-facing surface had **zero auth**; this adds a deny-by-default login gate and roles, the prerequisite that makes the app safe to put behind a public URL (office-deployment sequencing: auth → expose). Built test-first (10 tasks, 90 passing tests — the project's first auth test suite alongside the portal's).

### Added

- **Operator authentication (login + roles), shipped dark behind `OPERATOR_AUTH_ENABLED`.**  The internal app gains a deny-by-default login gate (one Starlette middleware over every route except an allow-list: `/login` `/logout` `/health` `/static/*` `/portal/*` + the three machine heartbeat endpoints `/emitters/report` `/api/series3/heartbeat` `/api/series4/heartbeat`).  Two roles — `superadmin` (account management at `/admin/users`) and `admin` (full app); `operator` reserved/deferred.  Sessions are a 30-day HMAC-signed `tv_session` cookie re-validated against the DB each request (instant revoke via `active` / `sessions_valid_from`).  Password reset is superadmin-driven: reset-anyone (temp shown once + forced change), self-service `/change-password`, and a `backend/operator_admin.py` seed/break-glass CLI.  Brute-force lockout (5 tries / 15 min) + constant-time login (no user-enumeration).  Reuses the portal's argon2 hasher + a new shared `backend/auth_cookies.py` HMAC signer.  Spec: `docs/superpowers/specs/2026-06-17-operator-auth-design.md`, plan: `docs/superpowers/plans/2026-06-17-operator-auth.md`.
- **`.env.example`** documenting `SECRET_KEY` / `COOKIE_SECURE` / `OPERATOR_AUTH_ENABLED` for deployment.

### Known limitations

- **SLMM proxy WebSocket endpoints bypass the gate.**  `/api/slmm/{id}/stream|live|monitor` are WebSocket upgrades, which a Starlette HTTP middleware never sees — they stay unauthenticated even with the gate on.  Pre-existing (not a regression); close it (in-handler `tv_session` check, as the portal WS already does) before true internet exposure.
- **No TLS yet** — until served over HTTPS the login password crosses the wire in cleartext.  Still a large improvement over the prior zero-auth exposure; real internet exposure needs the deployment-phase TLS.

### Upgrade Notes

- New `operator_users` table **auto-creates on startup — no migration**.
- Set a real `SECRET_KEY` (in a gitignored `.env`; template at `.env.example`) before internet exposure; set `COOKIE_SECURE=true` once on HTTPS (leave `false` on plain HTTP or the browser won't send the cookie).
- **Rollout (no self-lockout):** deploy with `OPERATOR_AUTH_ENABLED=false` (app behaves exactly as before) → seed a superadmin via `docker compose exec web-app python3 backend/operator_admin.py create-superadmin …` → confirm you can log in → set the flag `true` and `docker compose up -d web-app`.  Flipping it back to `false` is the instant escape hatch.

## [0.14.0] - 2026-06-17

Rounds out **sound monitoring** and adds a **client-facing portal**, consolidating four threads since 0.13.x: SLM live monitoring (now on SLMM's shared, cached feed), an automated **FTP night-report pipeline**, a read-only **client portal**, and **per-project password auth** for it. Depends on the matching **SLMM `dev`** build — see Upgrade Notes at the end of each section.

### SLM live monitoring — fan-out feed + cache-first

SLM live monitoring — fan-out feed + cache-first reads.  The throughline: the NL-43 allows exactly **one** TCP connection at a time, so every page that opened its own device stream (or sent its own `Measure?`/DOD on load) was competing for that single connection — a second viewer saw nothing, and dashboard loads stole polling resolution from the live feed.  This release moves Terra-View entirely onto SLMM's shared, cached monitoring: one DOD poll loop per device, fanned out to all viewers; dashboards read SLMM's cache (a DB read on SLMM's side) instead of touching the device; and the live panels populate instantly from cache on open, upgrading to the live WS only on demand.  Paired with the SLMM-side work (adaptive poll rate, unreachable backoff, device-offline alert) on SLMM branch `dev`.

#### Added

- **Fan-out `/monitor` feed consumption.**  The unit live view (`partials/slm_live_view.html`) and the dashboard live tile (`sound_level_meters.html`) now subscribe to SLMM's shared per-device monitor over `WS /api/slmm/{unit}/monitor` instead of each opening its own device stream.  Any number of clients attach without each consuming the NL-43's single connection — the "second viewer sees nothing" contention is gone.  A WS proxy handler for `/monitor` was added to `backend/routers/slmm.py`.
- **L1/L10 percentile lines + cards.**  Both the per-unit live chart and the dashboard card chart now plot L1 (purple) and L10 (orange) alongside Lp/Leq, and the KPI cards show L1/L10.  Sourced from the DOD feed's `ln1`/`ln2` (DRD streaming can't carry percentiles, DOD can).  Missing/`-.-` values leave a gap rather than dropping the line to 0.
- **Live-chart backfill on open.**  Charts seed from SLMM's downsampled DOD trail (`GET /api/slmm/{unit}/history?hours=2`) so a viewer sees recent trend immediately instead of a blank chart that fills one point per second.
- **Live Measurements panel auto-populates from cache.**  Opening the dashboard panel fills the KPI cards from cached `/status` and backfills the chart from `/history` — pure cache reads, no device hit.  Shows a measuring badge (● Measuring / ■ Stopped) and a freshness stamp ("as of 3:48 PM (10s ago)", amber + "cached" when stale).  Re-polls the cache every 15s while open; **Start Live Stream** upgrades to the live WS and no longer wipes the backfilled trail (chart point cap raised 60 → 600).
- **Refresh buttons** — one per device-list row, one in the panel header.  On-demand, user-initiated single device read via `GET /api/slmm/{unit}/live` (which also refreshes SLMM's cache), with a spinner + success/error toast, then reloads the device list.
- **Per-unit live-monitoring (keepalive) toggle on `/admin/slmm`** — turns a device's server-side keepalive feed on/off (`POST /monitor/start|stop`), so alerting can keep a device's feed running with no browser attached.

#### Changed

- **Dashboard device list + command center read SLMM's cache, not the device.**  `slm_dashboard.py`'s `get_slm_units` pulls each unit's cached status from SLMM's `/roster` (one call, a SLMM DB read) for the badge + freshness; the command-center `get_live_view` reads cached `/status` instead of sending `Measure?` + a fresh DOD on every load.  This stops dashboard loads from stealing the device's single connection from the live monitor.  The elapsed-measurement timer still works because `measurement_start_time` is now included in the cached `/status` response.
- **Device-list freshness reflects real monitoring.**  The "Last check" line now uses SLMM's cached `last_seen` (which the monitor advances on every successful poll) via `unit.cache_last_seen`, instead of the `slm_last_check` roster field the monitor never updates.  The status badge also treats `Measure` as Measuring, matching the panel and SLMM's cache.
- **Status badge relocated** to the card's bottom meta row (next to "Last check"), off the top-right corner where it collided with the chart/gear/refresh action icons.

#### Fixed

- **Deploy/bench threw `can't access property "dispatchEvent", e is null`.**  `toggleSLMDeployed()` and the save-config path called `htmx.trigger('#slm-list', 'load')` guarded only by `typeof htmx !== 'undefined'`; no page has a `#slm-list`, so htmx resolved null and called `null.dispatchEvent(...)`.  The deploy POST had already succeeded, so the operator saw both the green success **and** a red error.  Both call sites now guard on the element existing (`slm_settings_modal.html`).
- **Monitor WS proxy leaked `CancelledError` / "task exception never retrieved"** on stream stop — the cleanup awaited pending tasks but only caught `Exception`, missing `CancelledError` (a `BaseException`).
- **"No recent check-in" shown even on an actively-monitored device** — the row read the stale `slm_last_check` roster field instead of SLMM's live cache (see Changed).
- **L1/L10 KPI cards populated but the chart drew no L1/L10 lines** — the card chart only had Lp + Leq datasets.

#### Upgrade Notes

Requires the **matching SLMM build (branch `dev`)** — Terra-View now depends on SLMM's fan-out `/monitor` feed, `/history` trail, `/status` carrying `ln1`/`ln2` + `measurement_start_time`, cached `/roster` status, and the `monitor_enabled` keepalive flag.

```bash
# SLMM (branch dev) — REBUILD + MIGRATE (or you'll get `no such column: nl43_status.ln1` 500s)
cd /home/serversdown/slmm && docker compose build slmm && docker compose up -d slmm
docker exec terra-view-slmm-1 python3 migrate_add_ln_percentiles.py
docker exec terra-view-slmm-1 python3 migrate_add_monitor_enabled.py

# Terra-View — NO migration; templates are baked into the image, so rebuild (don't just restart)
cd /home/serversdown/terra-view && docker compose build terra-view && docker compose up -d terra-view
```

The two builds must ship **together**.  Note the `docker-compose.yml` container was renamed for clarity (now `terra-view-terra-view-1`) — adjust any `docker exec` scripts that referenced the old name.

---

### FTP night-report pipeline *(new)*

Automated daily morning report of last night's noise (7 PM–7 AM) vs a baseline,
per location, for 24/7 remote sound jobs.  The meter records to its SD card
regardless of TCP state, so the report pulls the meter's own stored 15-minute
Leq intervals over FTP (via the SLMM proxy) — accurate, and resilient to a
control-path wedge.  **Field-tested on a real NL-43.**

#### Added

- **Report engine.**  Per-location LAmax / LA01 / LA10 / LA90 / LAeq over Evening
  (7–10 PM) + Nighttime (10 PM–7 AM); Leq energy-averaged, percentiles/Lmax
  arithmetic; the LN→percentile map is read from the device's own `.rnh`.  Two
  baseline modes: *captured* (weekly average) and *reference* (typed per-location
  limits).
- **Renderers.**  HTML email body + Excel attachment (per-NRL interval table +
  line chart + Last/Base/Δ summary).
- **Capture cycle.**  The daily scheduled "24/7 Continuous" cycle stops →
  downloads → ingests → re-indexes → restarts the meter, verifies it resumed
  measuring via a fresh DOD read, and retries the restart once before alerting.
- **Standardized ingest.**  Manual SD upload, manual FTP "Download & Save", and
  the scheduled cycle all funnel through one ingest core: keeps the `.rnh` +
  15-minute Leq, drops the 1-second `_Lp_` files, parses the header, dedupes, and
  derives the session's real recording window from the Leq rows.
- **UI.**  Night Report button/modal (view / run-and-email / recent reports) and a
  per-project Settings panel (enable, time, baseline, recipients, test-email); the
  per-NRL Data Files tab now matches the project-wide tab.
- **Config-driven SMTP** sender (`REPORT_SMTP_*`), dry-run when unconfigured.

#### Fixed

- **NL-43 sessions stamped `now` / zero-duration.**  The NL-43 `.rnh` carries no
  measurement timestamps, so the session window is now derived from the Leq rows.
  Also fixes NL-43 dedupe (it had keyed on an always-empty start time).
- **"Browse Files" did nothing on the NRL Data Files tab** — the FTP-browser
  script's global functions collided with the SLM live-view's (both loaded on that
  page); it's now namespaced behind `window.FtpBrowser`.

#### Upgrade Notes

- **No DB migration** — the `sound_report_configs` table auto-creates on startup.
- Set `REPORT_SMTP_HOST/PORT/SECURITY/USER/PASSWORD/FROM/RECIPIENTS` to send email
  (reports build to `data/reports/…` in dry-run until then).
- To automate a job: a **"24/7 Continuous"** recurring schedule (~7:15 AM) + enable
  the report (~8:00 AM) + set a baseline.

---

### Client portal *(new — read-only client-facing view)*

A scoped, read-only portal at **`/portal/*`** where a client sees only *their*
locations, live.  Built inside Terra-View (no new service), reusing the cached
SLMM feed; every route resolves the client through one swappable
`get_current_client` gate, so the interim magic/open-link auth can be replaced
(M4) without touching routes or templates.  Strictly read-only — no device control.

#### Added

- **Per-client scoping + interim auth.**  New `Client`, `ClientAccessToken`, and a
  `Project.client_id` FK.  A signed (HMAC) session cookie carries the access-token
  id, re-validated against the DB each request (revoke kills live sessions, with
  server-side expiry).  Entry via a magic link (`/portal/enter/{token}`) or a
  dev-only plain link (`/portal/open/{id}`, `PORTAL_OPEN_LINKS`, **default off**).
- **Live location view.**  KPI cards (Lp/Leq/Lmax/L1/L10) + chart populate
  instantly from cache, then upgrade to a real **~1 Hz WebSocket stream** scoped to
  the client's unit (a scrubbed bridge to the SLMM fan-out feed).  The stream
  **auto-closes when the tab is hidden** (Page Visibility) and after a 15-min idle
  cap, so an abandoned tab can't pin the device at 1 Hz / burn cellular.
- **Locations overview.**  Live status map (level-colored dots, dark/light CARTO
  tiles) + a status rollup (live/offline counts, "loudest now").  Leq is the
  headline metric.
- **Alerts (config → surface → 24/7).**  Threshold-rule config on the SLM detail
  page (proxying SLMM's alert CRUD); breach **history + ack** internally and a
  read-only, scrubbed history + current-alarm banner + **"your alert limits"** panel
  in the portal; enabling a rule pins that device's monitor on so alerts evaluate
  round-the-clock.
- **Operator sharing tools.**  A **"View client portal"** preview button and a
  **"Copy client link"** modal (mint / list / revoke magic links) on the project
  page, plus a `backend/portal_admin.py` CLI.
- **Field-instrument design.**  Distinctive themed portal — Hanken Grotesk UI +
  IBM Plex Mono readouts, panel system, pulsing live dot, staggered reveal — with a
  **light/dark toggle** (light default, persisted, no-flash).

#### Security

- All scoping enforced server-side (404-not-403, no existence leak); client
  endpoints return **scrubbed** projections (no device-health/internal ids); WS
  frames whitelisted; operator-set strings HTML-escaped before injection (XSS).
  Pre-merge code review hardened cookie expiry, open-links default, and the slug
  collision.  Remaining hardening (reverse proxy, TLS, `SECRET_KEY`, M4 auth) is
  tracked in `docs/CLIENT_PORTAL.md` → "Security hardening backlog".

#### Upgrade Notes

- **Migration:** `docker compose exec web-app python3 backend/migrate_add_client_portal.py`
  (adds `projects.client_id`; the `clients` / `client_access_tokens` tables
  auto-create).
- Set a real **`SECRET_KEY`** in any internet-facing env (signs session cookies),
  and keep **`PORTAL_OPEN_LINKS=false`** there.
- Portal alerts depend on the **SLMM `dev`** alert engine (rules/events/evaluator +
  cooldown + keepalive coupling) — same build pairing as above.

---

### Portal authentication (Phase 1)
- Each project's client portal is now gated by a **secure per-project link + shared password** (argon2-hashed). Operators manage it from the project page's **Portal access** panel (enable, generate password, copy link).
- Per-project session isolation (a session for one project can't read another's data); brute-force lockout (5 tries / 15 min) on the password gate.
- Retired the interim magic-link / `PORTAL_OPEN_LINKS` open links and the `portal_admin.py mint-link` command.
- **Upgrade:** new `argon2-cffi` dependency → **rebuild the image**, then run `python3 backend/migrate_add_project_portal_auth.py` per DB (adds the `projects.portal_*` columns). `SECRET_KEY` and `COOKIE_SECURE` are now passed through in `docker-compose.yml` (settable via a `.env` file) — set a real `SECRET_KEY` (and `COOKIE_SECURE=true` once on HTTPS) before the portal faces the internet.

---

## [0.13.3] - 2026-06-05

Calibration sync from SFM events.  Closes the manual data-entry loop on calibration dates — Terra-View now pulls `device.calibration_date` from each seismograph's most recent event sidecar once a day and updates `RosterUnit.last_calibrated` when the device reports something fresher than what's stored.  Manual edits still win when they're newer than the latest event; a fresh event arriving later supersedes the manual edit.  Adds a "Sync now" button under Settings → Advanced → Calibration Defaults for on-demand runs, and a `docs/ROADMAP.md` to track in-flight + deferred work.

### Added

- **Calibration sync service** (`backend/services/calibration_sync.py`).  Per-unit: fetches `/db/events?serial={id}&limit=1` then `/db/events/{event_id}/sidecar` via the SFM proxy, reads `device.calibration_date`, and writes it to `RosterUnit.last_calibrated` with `next_calibration_due` recomputed from `UserPreferences.calibration_interval_days`.  Every change is logged in `UnitHistory` with `source='sfm_event'` and `notes="Synced from event {id}"` so the unit detail history timeline reflects auto-sync activity alongside manual edits.
- **Conflict rule: events-as-truth, manual wins when newer.**  Three outcomes per unit:
  - `already_in_sync` — stored date already matches the event's calibration date.
  - `skipped_manual_newer` — the latest `UnitHistory` change for `last_calibrated` happened *after* the event's timestamp, so the manual edit is preserved.  Only a future event can supersede it.
  - `updated` — the event is newer (or no manual edit exists), so the stored date is replaced.
- **Daily background job at 03:15 local** via the `schedule` library + a worker thread (modeled on `backup_scheduler.py`).  Started in `main.py`'s startup hook, stopped on shutdown.  Does not run on boot — first sync after a server start fires at the next 03:15.
- **`POST /api/calibration/sync`** — runs a full sync immediately and returns a summary `{checked, updated, skipped_manual_newer, already_in_sync, no_event, no_sidecar, no_cal_in_sidecar, errors, results: [...]}`.  Powers the Settings button.
- **`GET /api/calibration/sync/status`** — returns scheduler state + the last run's summary including per-unit `{unit_id, action, old, new, event_id}` rows.  Useful for diagnostics: `curl localhost:8001/api/calibration/sync/status | jq`.
- **Settings UI: "Sync from SFM events" section** under the Calibration Defaults card (Advanced tab).  Click "Sync now" → result line shows counts: `Checked N · Updated N · Already in sync N · Manual kept N · No event N`.
- **`docs/ROADMAP.md`** — first-pass roadmap pulling deferred items from `CLAUDE.md`'s focus block, in-code TODOs (`photos.py` GPS migration → `MonitoringLocation`, `device_controller.py` SFM Phase 2 stubs, `modem_dashboard.py` ModemManager backend, `dashboard.html` geocoding), and the README's long-standing "Future Enhancements" wishlist.  Grouped into In Flight / Near-Term / Medium-Term / Wishlist; intended as a living document.

### Fixed

- **Prod startup crash: `ModuleNotFoundError: No module named 'schedule'`**.  The `schedule` library wasn't pinned in `requirements.txt` even though `backend/services/backup_scheduler.py` has been using it since v0.4.x — the dev image happened to have it from an earlier manual `pip install`, but a clean prod rebuild dropped it.  Added `schedule==1.2.2` so the new calibration scheduler (and the existing backup scheduler) survive a clean rebuild.

### Upgrade Notes

No DB migration required — `UnitHistory.source` and `RosterUnit.last_calibrated`/`next_calibration_due` already exist.  Rebuild only:

```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```

After rebuild, Settings → Advanced → "Sync from SFM events" → "Sync now" to backfill in one shot; otherwise wait for the 03:15 job.

---

## [0.13.2] - 2026-05-30

PWA-cache fix for mobile operators.  v0.13.0 added the inline PDF preview, `.TXT` download, and Review form to `event-modal.js`, but mobile devices using Terra-View as a PWA never saw any of it — the service worker had `CACHE_VERSION = 'v1'` (unchanged since v0.12.x), so the activate handler never evicted the stale cache and mobile users kept getting served the pre-v0.13.0 modal forever.

### Fixed

- **Service worker cache version bumped + tied to the app version**.  `CACHE_VERSION` in `backend/static/sw.js` is now `'v0.13.2'`, which causes the SW's activate handler to delete the old `sfm-static-v1` / `sfm-dynamic-v1` / `sfm-data-v1` caches on first visit after the upgrade.  Going forward the convention is: any release that touches a static asset must bump `CACHE_VERSION` to match `backend/main.py`'s `VERSION`.  Comment in `sw.js` documents this.
- **`event-modal.js` precached** alongside `mobile.js` / `offline-db.js` etc.  Lifecycle is now tied to the SW version bump explicitly — old modal JS gets evicted on activate, new modal JS is fetched and cached during install.

### What mobile users will see after deploy

On next page navigation the SW update check fires, the new SW installs (skipWaiting), activate evicts the v1 caches, `controllerchange` fires, the page reloads with the v0.13.x modal.  On the worst-case device (no recent visit), it might take up to an hour for `registration.update()` to pick up the new SW — operators can force-refresh by closing and re-opening the PWA, or by clearing site data once.

---

## [0.13.1] - 2026-05-29

Same-day patch on top of v0.13.0.  Fixes the mic-chart unit default — v0.13.0 shipped with `dBL` as the default, but the PDF report renders the mic axis in psi, so the website chart and the printed report didn't match.  Operator caught it within an hour of rollout.  Also relabels the modal's "Captured at" field to "Time received" so it isn't mistaken for the device's trigger time.

### Fixed

- **Event-detail modal: mic chart now defaults to psi**, matching the PDF report's mic axis.  The waveform/histogram chart's mic channel now renders in raw psi by default; operators who specifically prefer dB(L) on charts can flip it via Settings → General → "Event Report — Mic Channel Units".  Peaks everywhere else (table tiles, modal Peaks section, KPI summaries) stay in dB(L) as before — this is strictly a chart-axis change.
- **Modal label: "Captured at" → "Time received"** (+ tooltip clarifying it's the SFM ingestion time, not the unit-local trigger time at the top of the modal).  Same change in seismo-relay's standalone webapp for consistency.

### Migration Notes

The bundled `backend/migrate_add_mic_unit_pref.py` is now idempotent across both the v0.13.0 "add column" path and the v0.13.0 → v0.13.1 default flip.  Existing rows sitting at the original `'dBL'` default (i.e. nobody touched the setting yet — true for almost everyone) get bumped to `'psi'` on migration.

```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py
```

If you _did_ deliberately set the chart to dB(L) via Settings between v0.13.0 rollout and this patch, the migration will reset it — one click in Settings to restore.  Trade-off considered acceptable given the very small user base and the freshness of the v0.13.0 release.

---

## [0.13.0] - 2026-05-29

The "SFM integration Phase 1" release.  Closes the gap between Terra-View and the standalone SFM webapp on port 8200 — operators no longer need to bounce between the two for routine event review.  The shared event-detail modal (used on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`) gains a Chart.js waveform/histogram chart, inline PDF preview, original `.TXT` download, and a review form with false-trigger flag + reviewer + notes.  `/admin/events` finally gets the modal too.  A new Settings field controls the mic chart's display unit.

### Added — Event-detail modal: Chart.js waveform/histogram panels

- **4-channel stacked plots** (MicL → Long → Vert → Tran, matching BW Event Report layout) inside the existing `partials/event_detail_modal.html` shell.  Ported from seismo-relay's standalone `sfm/sfm_webapp.html:2555-2880`; theme-aware grid + tick colors (light/dark mode via Tailwind's `dark` class on `<html>`).
- **Waveform mode**: line plot, symmetric Y-axis around zero for geo channels, dashed trigger overlay at `t=0` with triangle markers above and below, zero-baseline dashed line + "0.0" label on the right margin.  Downsamples at >3000 samples to keep render time bounded.
- **Histogram mode**: bar plot, zero-anchored Y with minimum range (`0.05 in/s` geo, `0.001 psi` mic) so quiet events don't fill the panel.  X-axis uses `time_axis.interval_times` (HH:MM:SS labels emitted by seismo-relay v0.20.0+) when available, otherwise falls back to interval index.  Trigger/zero-baseline overlays suppressed (no trigger concept on histograms).
- **Mic conversion** — converts raw psi samples to dB(L) for the chart when the operator's `mic_unit_pref` is "dBL" (the default).  Rectifies the AC waveform (`abs()`) and floors at `MIC_DBL_FLOOR = 60` so the chart reads as an SPL-vs-time curve instead of a sparse pattern of isolated spikes above the floor.  Peak label uses the unrectified value.
- **Chart cleanup** — `_destroyCharts()` runs on modal close so repeated open/close doesn't leak Chart.js instances.
- Chart.js 4.4.1 pinned via cdn.jsdelivr at the bottom of the modal partial; matches the standalone webapp's reference version.

### Added — Event-detail modal: PDF preview + downloads + review form

- **"Show Event Report PDF"** toggle opens an inline iframe inside the modal (no second-layer modal, no new browser tab).  Iframe lazy-loads on first reveal — closing the modal without opening the PDF never spends bandwidth on the fetch.  Sized 80vh / 600px min so a typical letter-portrait single-page report fits with browser-native zoom + download + print controls available.  Companion "Download PDF" button for direct save.
- **"Original .TXT report"** download link, rendered only when `sidecar.source.txt_filename` is present (events ingested with seismo-relay's `.TXT` preservation pattern, post-2026-05-27).  Hidden for legacy events to avoid 404 dead links.
- **Inline Review form** — `false_trigger` checkbox + reviewer text input + notes textarea + Save button.  Persists via `PATCH /api/sfm/db/events/{id}/sidecar` with `{review: {...}}`.  Status line shows last-reviewed timestamp + save success/failure feedback.  On save fires a `sfm-event-review-saved` `CustomEvent` on `window` so the host page's table can refresh without a full reload — wired up on `/sfm`, `/unit/{id}`, `/admin/events`, and `/projects/{p}/nrl/{l}`.

### Added — `/admin/events` row click opens the modal

- The SFM Event DB Manager at `/admin/events` previously had no detail view — admins had to copy an event ID and load the standalone webapp on port 8200.  Now table rows are clickable: `onclick` on `<tr>` calls `showEventDetail(id)`, with `event.stopPropagation()` on the checkbox cell so bulk-selection clicks don't also open the modal.
- `partials/event_detail_modal.html` + `event-modal.js` are now included on this page, matching the existing pattern on `/sfm`, `/unit/{id}`, and `/projects/{p}/nrl/{l}`.

### Added — `mic_unit_pref` user setting (Settings → General)

- **New `user_preferences.mic_unit_pref` column**, "dBL" default with "psi" as the alternate value.  Controls only the event-report modal's waveform chart mic axis — peak values in every other surface (event tables, KPI tiles, modal Peaks section) stay in dB(L) regardless.
- Surfaced as a single dropdown on Settings → General, below the auto-refresh interval.  Round-trips through `GET/PUT /api/settings/preferences`.
- New `backend/migrate_add_mic_unit_pref.py` script for existing databases — idempotent ALTER TABLE.

### Fixed — Docker Compose: SFM container can finally read the DB

- `../seismo-relay-prod-snap` is now bind-mounted into the SFM container at the same absolute host path it had outside, so the symlinked `seismo_relay.db` + `waveforms/` directory inside `bridges/captures/` resolve.  Without it, SFM 500'd on every `/db/*` proxy call because the symlink target wasn't visible from inside the container.  Read-write (not `:ro`) because SFM opens the DB in WAL mode, which requires creating `-wal` and `-shm` sidecar files even for reads.

### Migration Notes

```bash
cd /home/serversdown/terra-view
# Apply the new column to the database — required.  Idempotent.
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_mic_unit_pref.py

# Rebuild + restart both Terra-View and SFM (compose mounts changed).
docker compose build terra-view && docker compose up -d
```

Set Settings → General → "Event Report — Mic Channel Units" if "psi" is preferred over the default "dB(L)".  Setting persists in the DB and is fetched once per modal open.

### What's NOT in this release

Device-control endpoints (`/device/*` — start/stop monitoring, push compliance config, erase events, etc.) remain unexposed in the Terra-View UI.  They proxy through transparently but no page calls them.  Phase 2 of the SFM integration will bring them online once the SFM auth layer lands (a hard prerequisite — anything reachable through Terra-View's URL needs to be gated against unauthenticated callers).

---

## [0.12.1] - 2026-05-20

Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0.  The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.

### Added — Unit Swap wizard (`/tools/unit-swap`)

- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm.  Designed for tap-driven use on a phone in the field; works on desktop too.
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf.  Each row carries a `deployed` boolean for badge rendering.  Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
- **`POST /locations/{loc}/swap` enhancements**:
    - Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
    - Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.

### Added — Editable deployment timeline on `/unit/{id}`

- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline.  Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes.  Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment.  Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows.  Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.

### Fixed

- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`).  The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever.  All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit.  Unassign and swap also clear the modem's back-reference.  Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
- **Deployment timeline now respects user timezone for display *and* edits.**  Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen.  Two-sided fix:
    - **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing.  Frontend slicing keeps working — it just slices a local-time string now.
    - **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`.  Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.

### Migration Notes

No schema changes.  Static code-only release — pull and restart:

```bash
cd /home/serversdown/terra-view
docker compose build terra-view && docker compose up -d terra-view
```

---

## [0.12.0] - 2026-05-17

Field-deployment workflow + fleet-wide deployment views + SFM event DB management.  The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper.  EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.

### Added — field-deployment workflow

- **`/deploy` — mobile-first 3-step capture wizard**: pick unit → take photo (opens phone camera via `<input capture="environment">`) → optional note → submit.  Designed for under-90-seconds-on-site.  Success page shows captured coords and links back to "Deploy another" or the pending hopper.
- **`/tools/pending-deployments` — the hopper**: filter pills Awaiting / Assigned / Cancelled.  Each card has photo thumbnail, unit link, coords, operator note, status-appropriate actions.
- **Classify modal**: two modes — assign to existing project+location, OR create new location with new-or-existing project + a "use captured coords" checkbox that writes the pending row's coords onto the new location record.
- **`PendingDeployment` data model** (`pending_deployments` table): lifecycle `awaiting → assigned | cancelled`.  Photo file lives under `data/photos/{unit_id}/install_YYYYMMDD_HHMMSS_<uuid8>.<ext>`.  Migration: `backend/migrate_add_pending_deployments.py` (idempotent).
- **Backend endpoints**:
    - `POST /api/deployments/capture` — multipart upload (unit_id, photo, optional note), EXIF GPS extraction, seismograph-only (rejects others with 400)
    - `GET  /api/deployments/pending` — list by status
    - `GET  /api/deployments/pending/{id}` — single row detail
    - `POST /api/deployments/pending/{id}/promote` — classify and create `UnitAssignment`; events in the assignment window get retroactively attributed via the existing metadata-backfill mechanism
    - `POST /api/deployments/pending/{id}/cancel` — abandon with optional reason
    - `GET  /api/deployments/seismograph-picker` — JSON list for the `/deploy` picker, annotated with `has_pending`
- **Discovery surfaces**: orange "Field Deploy" button on the desktop dashboard header (md+), bottom-nav slot 3 on mobile (Menu / Dashboard / Deploy / Events; Devices moved into the Menu drawer), `/tools` cards for both Field Deploy and Pending Deployments, dashboard banner that auto-shows when awaiting captures exist (polled every 30s, hides at 0).
- **Full audit trail**: every capture / promote / cancel writes a `UnitHistory` row (`pending_deployment_captured` / `_promoted` / `_cancelled`).

### Added — fleet-wide deployment history

- **`/tools/deployment-history` — fleet-wide 12-month calendar** (Phase 2 of the per-unit Gantt from v0.11.0).  4-month-per-row grid styled like the Job Planner, responsive to single column on mobile.  Each day cell shows up to 4 deterministically-colored mini-bars (one per active project that day), with "+N" overflow.  KPI strip across the top: project count, distinct unit count, total assignment count in the window.  Collapsible project legend ordered by first-active date.
- **Click-a-day side panel**: slide-over from the right, groups by project, lists every (unit, location) active that day with auto-backfilled tags, sourced from new `GET /api/admin/deployment-history/day`.
- **Prev / Next / Recent month navigation**: shifts the 12-month window by 1 month.  Default window is 11 months back from current → operator sees recent past on first load, not future emptiness.
- **Gantt by Project tab**: horizontal time-axis bars per project, hover for tooltip with unit + location + window.  Reduced opacity for closed assignments, blue outline for metadata-backfilled, today dashed-orange line.
- **Gantt by Unit tab**: same idea inverted — one row per seismograph, bars colored by project.  Natural for "where has BE11529 been across all my jobs?"  Service layer returns a `units` array with bars carrying baked-in `project_color`.
- **Tab switcher with hash sync**: `#gantt` / `#byunit` preserved across month-paging.  Tab registry (`_DH_TABS`) makes adding future views a one-line addition.

### Added — SFM event DB management

- **`/admin/events` — SFM Event DB Manager** under Developer Tools.  Cross-unit event browser with filters (serial, from/to, false_trigger, limit), checkbox selection with select-all, and bulk actions:
    - **Delete selected** — hard-delete chosen rows from SFM's `events` table
    - **Delete ALL matching current filter** — dry-run first to show match count + sample serials in confirm dialog; only proceeds on explicit confirmation
    - Same Flag-as-FT / Clear-FT bulk actions for convenience
- **Destructive operations also clean up on-disk files**: associated `.AB0*` blastware binary, `.a5.pkl`, `.sfm.json` sidecar, and `.h5` files are unlinked alongside the DB row.  Cannot be undone — the manager has a prominent red warning banner and a `max_rows` safety cap (10,000) that refuses oversized deletes without explicit acknowledgment.
- **Designed for cleaning bogus events from a misbehaving unit** — a stuck-triggered seismograph can dump hundreds of junk events into SFM before it's recovered; this is the operator's broom.
- **`unit_detail.html` also gains bulk false-trigger flagging**: same checkbox UX as the DB manager, but with **🚩 Flag as false trigger** / **✓ Clear false trigger** instead of delete (delete is admin-only via `/admin/events`).  Concurrent fan-out (8 in flight) for fast bulk PATCH.

### Added — maps, navigation, polish

- **Reusable location-map partial** (`templates/partials/projects/location_map.html`): self-contained map div + self-fetch script.  Accepts `project_id`, `map_height`, `location_type` filter.  Project overview's inline map (~150 lines of JS) replaced with a 1-line include; Vibration tab on the project detail page now uses the same partial with `location_type='vibration'` at 450px height.
- **Hover location card → highlight matching map pin** on the project overview map.  Enlarges + reddens the pin, opens its tooltip.  Bidirectional with the existing pin → card flash.  Event delegation on `document` so cards from htmx swaps keep the behavior without rewiring.
- **Mobile bottom-nav swap Settings → Events**: Settings (rarely needed in the field) replaced by Events (the daily mobile destination since the SFM integration).  Settings/Projects/Tools/admin pages still in the Menu drawer.

### Fixed

- **`/deploy` photo input now allows gallery picks**: `capture="environment"` was forcing mobile browsers to open the camera and skip "Photo Library" / "Choose File".  Useful at the install site, problematic when uploading a photo taken earlier.  Attribute removed; chooser now offers both options.  EXIF extraction works identically.

### Migration Notes

One new migration this release.  Idempotent and non-destructive.

```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_pending_deployments.py
```

Or sweep all migrations at once (safe — already-applied ones no-op):

```bash
for f in backend/migrate_*.py; do
  docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```

New table: `pending_deployments` — capture rows for the field-deployment workflow.  Empty after migration; populated as field techs use `/deploy`.

**Deploy order matters**: run the migration BEFORE the new code is up, or the running app will 500 on the missing table.  Same gotcha that bit the v0.10.0 → v0.11.0 deploy.

**SFM bump pairing**: this release pairs with seismo-relay v0.17.0, which adds the `DELETE /db/events/{id}` and `POST /db/events/delete_bulk` endpoints that `/admin/events` consumes.  An older SFM will return 405/404 for those routes; the manager will surface the error in its result alert.

---

## [0.11.0] - 2026-05-15

Operator-facing polish release.  All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.

### Added
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events.  Cascade-closes active unit assignments and cancels pending scheduled actions at the location.  Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming).  Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page.  Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups.  Click a bar to scroll the matching list row into view with a flash highlight.
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record).  Now auto-detected and surfaceable in the timeline header — one click combines them into a single record.  Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment.  Trash icon in each row of the location's Deployment History panel.  Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history.  Writes an `assignment_deleted` UnitHistory row.
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left.  Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`.  Implementation uses native HTML5 drag-and-drop (no library).  New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu.  Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline).  Sound locations still show session counts.
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map.  One pin per active monitoring location (parsed from the `coordinates` field).  Click pin → scrolls + flashes the matching card.  Tooltip on hover.  Locations without coordinates surface as an inline hint below the map.  If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.

### Changed
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores.  Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio.  Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens.  Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90.  Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).

### Fixed
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler.  Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
    - **Location Remove button** on the project page
    - **Metadata-backfill typeahead dropdown** (existing project + location pickers)
    - **Project-merge typeahead dropdown** (in the per-project header)
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`.  Fixed by adding `isolation: isolate` to the map container.
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`.  Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.

### Migration Notes
Run on each database before deploying.  Both migrations are idempotent and non-destructive.

```bash
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
```

Or sweep all migrations at once (safe — already-applied ones no-op):

```bash
for f in backend/migrate_*.py; do
  docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```

New columns added this release:
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
- `monitoring_locations.removal_reason` (TEXT, nullable)
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration

**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns.  Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.

---

## [0.10.0] - 2026-05-14

This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.

### Added
- **SFM Integration**: New fleet-wide events page at `/sfm` listing every event ingested by SFM, with filters for serial, date range, false-trigger flag, and limit. Unit detail pages and project-location pages show their own attributed subsets of the same event stream.
- **Event Detail Modal**: Shared across `/sfm`, unit detail, and project-location pages — clicking any event opens a rich modal showing peaks per channel (PVS color-coded by magnitude), microphone dB(L) + ZC frequency + time of peak, sensor self-check table with pass/fail per channel, device/recording metadata (firmware, battery, calibration date, geo range), and download buttons for the original Blastware binary and the sidecar JSON. Includes an inline pretty-printed JSON viewer with copy-to-clipboard.
- **Events Attribution Engine** (`backend/services/sfm_events.py`): Per-event attribution against `UnitAssignment` time windows. Events outside any assignment window surface in an "Unattributed" bucket with the nearest-assignment diagnostic (which location, signed delta in days).
- **Metadata Backfill Tool** (`/tools` → Backfill from event metadata): Scans operator-typed `project` and `sensor_location` strings in event sidecars, fuzzy-clusters them via `rapidfuzz.WRatio`, and proposes retroactive `UnitAssignment` records to attribute orphan events. Tracks operator decisions per cluster across re-scans.
- **Project Tidy Tool** (`/tools` → Project Tidy): Fuzzy-detect duplicate projects and bulk-merge them with a single click. Source projects soft-deleted with full audit trail.
- **Vibration Summary on Project Pages**: New roll-up card on vibration project detail pages showing per-location event counts, the project's "Overall Peak" PVS (false triggers excluded), last event timestamp, and a Top Locations by Activity list.
- **SFM-Primary Seismograph Status**: `emit_status_snapshot()` now consults SFM's `/db/units` (cached 15s) before falling back to `Emitter.last_seen` for each seismograph. The fresher signal wins; the choice is recorded in a new per-unit `last_seen_source` field. A small `SFM` (orange) or `HB` (gray) badge on each unit's active-table row shows which path is currently driving the status.
- **Dashboard Rework**: Top row reordered to Recent Alerts → Recent Call-Ins (double-wide) → Fleet Summary. Today's Schedule moved to a horizontal collapsible card below the Fleet Map, auto-expanding only when pending actions exist. Recent Call-Ins now sources from a new `/api/recent-event-callins` endpoint backed by SFM event forwards instead of the watcher-heartbeat endpoint.
- **Sortable Events Tables**: `/sfm` and unit-detail SFM Events tables now have clickable column headers with ↕/↓/↑ indicators. Default sort is Timestamp DESC. Click same column to toggle direction; click different column to switch and reset to DESC. Pure client-side over cached rows — no re-fetches.
- **Developer → SFM Admin** (`/admin/sfm`): Health banner with reachability indicator, terra-view↔SFM connection panel, 4 KPI tiles (known units, total events, stale `monitor_log` rows, stale `ach_sessions` rows), per-unit roll-up table, recent-events table with color-coded forwarding latency (so stale watcher forwards stand out), and a raw API tester for any `/api/sfm/*` path.
- **Developer → SLMM Admin** (`/admin/slmm`): Stripped-down companion page — health, connection info, raw API tester.
- **Tools Workflow Hub** (`/tools`): New top-level sidebar entry consolidating Pair Devices, Project Tidy, Metadata Backfill, Reports (info card), and Swap Detection (placeholder).
- **Sidebar Reorganization**: Devices → Projects → Events → Tools → Job Planner → Settings. Devices is now a single entry with internal tabs (All Devices / Seismographs / Sound Level Meters / Modems / Pair Devices) replacing five separate sidebar items.
- **Synology Deployment Doc** (`docs/SYNOLOGY_DEPLOYMENT.md`): End-to-end playbook for migrating the stack to an always-on office NAS — phased rollout (pre-stage, data rsync, watcher repoint, external access, decommission), Tailscale vs reverse-proxy options, rollback plan, and gotchas.

### Changed
- **Overall Peak excludes false triggers**: The project-level "Overall Peak" KPI tile (and the underlying `_compute_stats()` function in `sfm_events.py`) now skip events flagged as false triggers when computing the highest PVS, so operators see the highest real event rather than the biggest sensor glitch. `false_trigger_count` still includes flagged events so operators can see how many were filtered out.
- **`RosterUnit.note` Editing**: Inline edit on seismograph cards is more forgiving and now auto-saves on blur.
- **Sidebar Nav Renamed**: Old "Fleet" sidebar entry → "Devices" (renamed because it always meant the device list, not the broader fleet view).

### Fixed
- **Status drift between watcher heartbeat and actual event arrivals**: Seismographs are now reported with whichever signal is more recent — eliminates the case where a unit had recent SFM events but a stale heartbeat (or vice-versa) showed the wrong status.
- **Event modal: Record Type always showed "Waveform"**: Workaround client-side — Record Type now derived from the Blastware filename's last-char code (`H`=Histogram, `W`=Waveform, `M`=Manual, `E`=Event, `C`=Combo). The proper fix lives in SFM's sidecar parser; tracked separately.
- **Event modal: Mic PSI tile removed**: Operators only care about dB(L); the redundant PSI tile was dropped.

### Migration Notes
Run on each database before deploying. Every migration is idempotent.

```bash
# Cleanest: re-run all migrations in chronological order.
# Already-applied migrations no-op safely.
for f in backend/migrate_*.py; do
  docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
done
```

Migrations new in this release:
- `migrate_add_metadata_backfill.py` — adds `unit_assignments.source` column and `metadata_backfill_decisions` table for the Metadata Backfill tool

### Deployment Notes
- **`SFM_BASE_URL`**: Confirm prod's `docker-compose.yml` sets this for the terra-view service (typically `http://sfm:8200` for the in-stack SFM container, or an external URL if SFM lives elsewhere).
- **Watcher repoint**: series3-watcher's `sfm_forward_url` should point at `https://<your-terra-view-host>/api/sfm` (proxy-based — no second port forward needed). Watcher composes the full path `/db/import/blastware_file` itself.

---

## [0.9.4] - 2026-04-06

### Added
- **Modular Project Types**: Projects now support optional modules (Sound Monitoring, Vibration Monitoring) selectable at creation time. The project header and dashboard dynamically show/hide tabs and actions based on which modules are enabled, and modules can be added or removed after creation.
- **Deleted Project Management**: Settings page now includes a section for soft-deleted projects with options to restore or permanently delete each one. Deleted projects load automatically when the Data tab is opened.

### Changed
- **Swap Modal Search**: The unit/modem swap modal on vibration location detail pages now includes live search filtering for both seismographs and modems, making it easier to find the right unit in large fleets.

### Fixed
- **Roster Auto-Refresh No Longer Disrupts Scroll/Sort**: The roster page's 30-second background refresh now updates status, age, and last-seen values in-place via a lightweight JSON poll instead of replacing the entire table HTML. Sort order, scroll position, and active filters are all preserved across refreshes.

### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_project_modules.py
```

---

## [0.9.3] - 2026-03-28

### Added
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.

### Fixed
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.

### Changed
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.

### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
```

---

## [0.9.2] - 2026-03-27

### Added
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.

### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_allocated.py
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
```

---

## [0.9.1] - 2026-03-20

### Fixed
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.

### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
```

---

## [0.9.0] - 2026-03-19

### Added
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
  - **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
  - **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
  - **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
  - **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
  - **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
  - **Available Units panel**: Shows units available for the job's date range when assigning
  - **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
  - **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
  - **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
  - **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
  - **Calendar bar tooltips**: Hover any job bar to see job name and date range
  - **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
  - **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
  - **Upcoming project status**: New `upcoming` status for projects promoted from reservations
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable

### Changed
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)

### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 -c "
import sqlite3
conn = sqlite3.connect('/app/data/seismo_fleet.db')
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
conn.commit()
conn.close()
"
```

---

## [0.8.0] - 2026-03-18

### Added
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
  - Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
  - Trigger Update button to queue a self-update on the agent's next heartbeat
  - Expand/collapse log tail with full-log expand mode
  - Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open

### Changed
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing

### Fixed
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`

---

## [0.7.1] - 2026-03-12

### Added
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations

### Changed
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks

### Fixed
- **Migration Scripts**: Fixed database path references in several migration scripts
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate

### Migration Notes
Run the following migration script once per database before deploying:
```bash
python backend/migrate_add_out_for_calibration.py
```

---

## [0.7.0] - 2026-03-07

### Added
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
  - Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
  - Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
  - Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
  - Wizard UI grouped by location with period type badges (day/night)
  - Each selected session produces one `.xlsx` in a ZIP archive
  - Period type filtering: day sessions keep last calendar date (7AM–6:59PM); night sessions span both days (7PM–6:59AM)
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
- **Chart Preview**: Live chart preview in the report generator matching final report styling
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration

### Changed
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
  - Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views

### Fixed
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose

### Technical Details
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)

### Migration Notes
Run the following migration scripts once per database before deploying:
```bash
python backend/migrate_rename_recording_to_monitoring_sessions.py
python backend/migrate_add_session_period_type.py
python backend/migrate_add_session_device_model.py
```

---

## [0.6.1] - 2026-02-16

### Added
- **One-Off Recording Schedules**: Support for scheduling single recordings with specific start and end datetimes
- **Bidirectional Pairing Sync**: Pairing a device with a modem now automatically updates both sides, clearing stale pairings when reassigned
- **Auto-Fill Notes from Modem**: Notes are now copied from modem to paired device when fields are empty
- **SLMM Download Requests**: New `_download_request` method in SLMM client for binary file downloads with local save

### Fixed
- **Scheduler Timezone**: One-off scheduler times now use local time instead of UTC
- **Pairing Consistency**: Old device references are properly cleared when a modem is re-paired to a new device

## [0.6.0] - 2026-02-06

### Added
- **Calendar & Reservation Mode**: Fleet calendar view with reservation system for scheduling device deployments
- **Device Pairing Interface**: New two-column pairing page (`/pair-devices`) for linking recorders (seismographs/SLMs) with modems
  - Visual pairing interface with drag-and-drop style interactions
  - Fuzzy-search modem pairing for SLMs
  - Pairing options now accessible from modem page
  - Improved pair status sharing across views
- **Modem Dashboard Enhancements**:
  - Modem model number now a dedicated configuration field with per-model options
  - Direct link to modem login page from unit detail view
  - Modem view converted to list format
- **Seismograph List Improvements**:
  - Enhanced visibility with better filtering and sorting
  - Calibration dates now color-coded for quick status assessment
  - User sets date of previous calibration (not expiry) for clearer workflow
- **SLMM Device Control Lock**: Prevents command flooding to NL-43 devices

### Changed
- **Calibration Date UX**: Users now set the date of the previous calibration rather than upcoming expiry dates - more intuitive workflow
- **Settings Persistence**: Settings save no longer reloads the page
- **Tab State**: Tab state now persists in URL hash for better navigation
- **Scheduler Management**: Schedule changes now cascade to individual events
- **Dashboard Filtering**: Enhanced dashboard with additional filtering options and SLM status sync
- **SLMM Polling Intervals**: Fixed and improved polling intervals for better responsiveness
- **24-Hour Scheduler Cycle**: Improved cycle handling to prevent issues with scheduled downloads

### Fixed
- **SLM Modal Fields**: Modal now only contains correct device-specific fields
- **IP Address Handling**: IP address correctly passed via modem pairing
- **Mobile Type Display**: Fixed incorrect device type display in roster and device tables
- **SLMM Scheduled Downloads**: Fixed issues with scheduled download operations

## [0.5.1] - 2026-01-27

### Added
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
  - New "Today's Actions" panel showing upcoming and past scheduled events
  - Schedule list partial for project-specific schedule views
  - API endpoint for fetching today's schedule data
- **New Branding Assets**: Complete logo rework for Terra-View
  - New Terra-View logos for light and dark themes
  - Retina-ready (@2x) logo variants
  - Updated favicons (16px and 32px)
  - Refreshed PWA icons (72px through 512px)

### Changed
- **Dashboard Layout**: Reorganized to include schedule information panel
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching

## [0.5.0] - 2026-01-23

_Note: This version was not formally released; changes were included in v0.5.1._

## [0.4.4] - 2026-01-23

### Added
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.

### Changed
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.

### Fixed
- **Scheduler actions**: Strict command definitions so actions run reliably.
- **Project view title**: Resolved JSON string rendering in project headers.

## [0.4.3] - 2026-01-14

### Added
- **Sound Level Meter roster tooling**: Roster manager surfaces SLM metadata, supports rename unit flows, and adds return-to-project navigation to keep SLM dashboard users oriented.
- **Project management templates**: New schedule and unit list templates plus file/session lists show what each project stores before teams dive into deployments.

### Changed
- **Project view refresh**: FTP browser now downloads folders locally, the countdown timer was rebuilt, and project/device templates gained edit modals for projects and locations so navigation feels smoother.
- **SLM control sync & accuracy**: Control center groundwork now runs inside the dev UI, configuration edits propagate to SLMM (which caches configs for faster responses), and the SLM live view reads the correct DRD fields after the refactor.

### Fixed
- **SLM UI syntax bug**: Resolved the unexpected token error that appeared in the refreshed SLM components.

## [0.4.2] - 2026-01-05

### Added
- **SLM Configuration Interface**: Sound Level Meters can now be configured directly from the SLM dashboard
  - Configuration modal with comprehensive SLM parameter editing
  - TCP port configuration for SLM control connections (default: 2255)
  - FTP port configuration for SLM data retrieval (default: 21)
  - Modem assignment for network access or direct IP connection support
  - Test Modem button with ping-based connectivity verification (shows IP and response time)
  - Test SLM Connection button for end-to-end connectivity validation
  - Dynamic form fields that hide/show based on modem selection
- **SLM Dashboard Endpoints**: New API routes for SLM management
  - `GET /api/slm-dashboard/config/{unit_id}` - Load SLM configuration form
  - `POST /api/slm-dashboard/config/{unit_id}` - Save SLM configuration
  - `GET /api/slm-dashboard/test-modem/{modem_id}` - Ping modem for connectivity test
- **Database Schema Updates**: Added `slm_ftp_port` column to roster table
  - Migration script: `scripts/add_slm_ftp_port.py`
  - Supports both TCP (control) and FTP (data) port configuration per SLM unit
- **Docker Environment Enhancements**:
  - Added `iputils-ping` and `curl` packages to Docker image for network diagnostics
  - Health check endpoint support via curl

### Fixed
- **Form Validation**: Fixed 400 Bad Request error when adding modem units
  - Form fields for device-specific parameters now properly disabled when hidden
  - Empty string values for integer fields no longer cause validation failures
  - JavaScript now disables hidden form sections to prevent unwanted data submission
- **Unit Status Accuracy**: Fixed issue where unit status was loading from a saved cache instead of actual last-heard time
  - Unit status now accurately reflects real-time connectivity
  - Status determination based on actual `slm_last_check` timestamp

### Changed
- **Roster Form Behavior**: Device-specific form fields are now disabled (not just hidden) when not applicable
  - Prevents SLM fields from submitting when adding modems
  - Prevents modem fields from submitting when adding SLMs
  - Cleaner form submissions with only relevant data
- **Port Field Handling**: Backend now accepts port fields as strings and converts to integers
  - Handles empty string values gracefully
  - Proper type conversion with None fallback for empty values

### Technical Details
- Added `setFieldsDisabled()` helper function for managing form field state
- Updated `toggleDeviceFields()` and `toggleEditDeviceFields()` to disable/enable fields
- Backend type conversion: `slm_tcp_port` and `slm_ftp_port` accept strings, convert to int with empty string handling
- Modem ping uses subprocess with 1 packet, 2-second timeout, returns response time in milliseconds
- Configuration form uses 3-column grid layout for TCP Port, FTP Port, and Direct IP fields

## [0.4.1] - 2026-01-05
### Added
- **SLM Integration**: Sound Level Meters are now manageable in SFM

### Fixed
- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.


## [0.4.0] - 2025-12-16

### Added
- **Database Management System**: Comprehensive backup and restore capabilities
  - **Manual Snapshots**: Create on-demand backups of the entire database with optional descriptions
  - **Restore from Snapshot**: Restore database from any snapshot with automatic safety backup
  - **Upload/Download Snapshots**: Transfer database snapshots to/from the server
  - **Database Tab**: New dedicated tab in Settings for all database management operations
  - **Database Statistics**: View database size, row counts by table, and last modified time
  - **Snapshot Metadata**: Each snapshot includes creation time, description, size, and type (manual/automatic)
  - **Safety Backups**: Automatic backup created before any restore operation
- **Remote Database Cloning**: Dev tools for cloning production database to remote development servers
  - **Clone Script**: `scripts/clone_db_to_dev.py` for copying database over WAN
  - **Network Upload**: Upload snapshots via HTTP to remote servers
  - **Auto-restore**: Automatically restore uploaded database on target server
  - **Authentication Support**: Optional token-based authentication for secure transfers
- **Automatic Backup Scheduler**: Background service for automated database backups
  - **Configurable Intervals**: Set backup frequency (default: 24 hours)
  - **Retention Management**: Automatically delete old backups (configurable keep count)
  - **Manual Trigger**: Force immediate backup via API
  - **Status Monitoring**: Check scheduler status and next scheduled run time
  - **Background Thread**: Non-blocking operation using Python threading
- **Settings Reorganization**: Improved tab structure for better organization
  - Renamed "Data Management" tab to "Roster Management"
  - Moved CSV Replace Mode from Advanced tab to Roster Management tab
  - Created dedicated Database tab for all backup/restore operations
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` guide covering:
  - Manual snapshot creation and restoration workflows
  - Download/upload procedures for off-site backups
  - Remote database cloning setup and usage
  - Automatic backup configuration and integration
  - API reference for all database endpoints
  - Best practices and troubleshooting guide

### Changed
- **Settings Tab Organization**: Restructured for better logical grouping
  - **General**: Display preferences (timezone, theme, auto-refresh)
  - **Roster Management**: CSV operations and roster table (now includes Replace Mode)
  - **Database**: All backup/restore operations (NEW)
  - **Advanced**: Power user settings (calibration, thresholds)
  - **Danger Zone**: Destructive operations
- CSV Replace Mode warnings enhanced and moved to Roster Management context

### Technical Details
- **SQLite Backup API**: Uses native SQLite backup API for concurrent-safe snapshots
- **Metadata Tracking**: JSON sidecar files store snapshot metadata alongside database files
- **Atomic Operations**: Database restoration is atomic with automatic rollback on failure
- **File Structure**: Snapshots stored in `./data/backups/` with timestamped filenames
- **API Endpoints**: 7 new endpoints for database management operations
- **Backup Service**: `backend/services/database_backup.py` - Core backup/restore logic
- **Scheduler Service**: `backend/services/backup_scheduler.py` - Automatic backup automation
- **Clone Utility**: `scripts/clone_db_to_dev.py` - Remote database synchronization tool

### Security Considerations
- Snapshots contain full database data and should be secured appropriately
- Remote cloning supports optional authentication tokens
- Restore operations require safety backup creation by default
- All destructive operations remain in Danger Zone with warnings

### Migration Notes
No database migration required for v0.4.0. All new features use existing database structure and add new backup management capabilities without modifying the core schema.

## [0.3.3] - 2025-12-12

### Changed
- **Mobile Navigation**: Moved hamburger menu button from floating top-right to bottom navigation bar
  - Bottom nav now shows: Menu (hamburger), Dashboard, Roster, Settings
  - Removed "Add Unit" from bottom nav (still accessible via sidebar menu)
  - Hamburger no longer floats over content on mobile
- **Status Dot Visibility**: Increased status dot size from 12px to 16px (w-3/h-3 → w-4/h-4) in dashboard fleet overview for better at-a-glance visibility
  - Affects both Active and Benched tabs in dashboard
  - Makes status colors (green/yellow/red) easier to spot during quick scroll

### Fixed
- **Location Navigation**: Moved tap-to-navigate functionality from roster card view to unit detail modal only
  - Roster cards now show simple location text with pin emoji
  - Navigation links (opening Maps app) only appear in the modal when tapping a unit
  - Reduces visual clutter and accidental navigation triggers

### Technical Details
- Bottom navigation remains at 4 buttons, first button now triggers sidebar menu
- Removed standalone hamburger button element and associated CSS
- Modal already had navigation links, no changes needed there

## [0.3.2] - 2025-12-12

### Added
- **Progressive Web App (PWA) Mobile Optimization**: Complete mobile-first redesign for field deployment usage
  - **Responsive Navigation**: Hamburger menu with slide-in sidebar for mobile, always-visible sidebar for desktop
  - **Bottom Navigation Bar**: Quick access to Dashboard, Roster, Add Unit, and Settings (mobile only)
  - **Mobile Card View**: Compact card layout for roster units with status dots, location, and project ID
  - **Tap-to-Navigate**: Location addresses and coordinates are clickable and open in user's default navigation app (Google Maps, Apple Maps, Waze, etc.)
  - **Unit Detail Modal**: Bottom sheet modal showing full unit details with edit capabilities (tap any unit card to open)
  - **Touch Optimization**: 44x44px minimum button targets following iOS/Android accessibility guidelines
  - **Service Worker**: Network-first caching strategy for offline-capable operation
  - **IndexedDB Storage**: Offline data persistence for unit information and pending edits
  - **Background Sync**: Queues edits made while offline and syncs automatically when connection returns
  - **Offline Indicator**: Visual banner showing offline status with manual sync button
  - **PWA Manifest**: Installable as a standalone app on mobile devices with custom icons
  - **Hard Reload Button**: "Clear Cache & Reload" utility in sidebar menu to force fresh JavaScript/CSS
- **Mobile-Specific Files**:
  - `backend/static/mobile.css` - Mobile UI styles, hamburger menu, bottom nav, cards, modals
  - `backend/static/mobile.js` - Mobile interactions, offline sync, modal management
  - `backend/static/sw.js` - Service worker for PWA functionality
  - `backend/static/offline-db.js` - IndexedDB wrapper for offline storage
  - `backend/static/manifest.json` - PWA configuration
  - `backend/static/icons/` - 8 PWA icon sizes (72px-512px)

### Changed
- **Dashboard Alerts**: Only show Missing units in notifications (Pending units no longer appear in alerts)
- **Roster Template**: Mobile card view shows status from server-side render instead of fetching separately
- **Mobile Status Display**: Benched units show "Benched" label instead of "Unknown" or "N/A"
- **Base Template**: Added cache-busting query parameters to JavaScript files (e.g., `mobile.js?v=0.3.2`)
- **Sidebar Menu**: Added utility section with "Toggle theme" and "Clear Cache & Reload" buttons

### Fixed
- **Modal Status Display**: Fixed unit detail modal showing "Unknown" status by passing status data from card to modal
- **Mobile Card Status**: Fixed grey dot with "Unknown" label for benched units - now properly shows deployment state
- **Status Data Passing**: Roster cards now pass status and age to modal via function parameters and global status map
- **Service Worker Caching**: Aggressive browser caching issue resolved with version query parameters and hard reload function

### Technical Details
- Mobile breakpoint at 768px (`md:` prefix in TailwindCSS)
- PWA installable via Add to Home Screen on iOS/Android
- Service worker caches all static assets with network-first strategy
- Google Maps search API used for universal navigation links (works across all map apps)
- Status map stored in `window.rosterStatusMap` from server-side rendered data
- Hard reload function clears service worker caches, unregisters workers, and deletes IndexedDB

## [0.3.1] - 2025-12-12

### Fixed
- **Dashboard Notifications**: Removed Pending units from alert list - only Missing units now trigger notifications
- **Status Dots**: Verified deployed units display correct status dots (OK=green, Pending=yellow, Missing=red) in both active and benched tables
- **Mobile Card View**: Fixed roster cards showing "Unknown" status by using `.get()` with defaults in backend routes
- **Backend Status Handling**: Added default values for status, age, last_seen fields to prevent KeyError exceptions

### Changed
- Backend roster partial routes (`/partials/roster-deployed`, `/partials/roster-benched`) now use `.get()` method with sensible defaults
- Deployed units default to "Unknown" status when data unavailable
- Benched units default to "N/A" status when data unavailable

## [0.3.0] - 2025-12-09

### Added
- **Series 4 (Micromate) Support**: New `/api/series4/heartbeat` endpoint for receiving telemetry from Series 4 Micromate units
  - Auto-detection of Series 4 units via UM##### ID pattern
  - Stores project hints from emitter payload in unit notes
  - Automatic unit type classification across both Series 3 and Series 4 endpoints
- **Development Environment Labels**: Visual indicators to distinguish dev from production deployments
  - Yellow "DEV" badge in sidebar navigation
  - "[DEV]" prefix in browser title
  - Yellow banner on dashboard when running in development mode
  - Environment variable support in docker-compose.yml (ENVIRONMENT=production|development)
- **Quality of Life Improvements**:
  - Human-readable relative timestamps (e.g., "2h 15m ago", "3d ago") with full date in tooltips
  - "Last Updated" timestamp indicator on dashboard
  - Status icons for colorblind accessibility (checkmark for OK, clock for Pending, X for Missing)
  - Breadcrumb navigation on unit detail pages
  - Copy-to-clipboard buttons for unit IDs
  - Search/filter functionality for fleet roster table
  - Improved empty state messages with icons
- **Timezone Support**: Comprehensive timezone handling across the application
  - Timezone selector in Settings (defaults to America/New_York EST)
  - Human-readable timestamp format (e.g., "9/10/2020 8:00 AM EST")
  - Timezone-aware display for all timestamps site-wide
  - Settings stored in localStorage for immediate effect
- **Settings Page Redesign**: Complete overhaul with tabbed interface and persistent preferences
  - **General Tab**: Display preferences (timezone, theme, auto-refresh interval)
  - **Data Management Tab**: Safe operations (CSV export, merge import, roster table)
  - **Advanced Tab**: Power user settings (replace mode import, calibration defaults, status thresholds)
  - **Danger Zone Tab**: Destructive operations isolated with enhanced warnings
  - Backend preferences storage via new UserPreferences model
  - Tab state persistence in localStorage
  - Smooth animations and consistent styling with existing pages
- **User Preferences API**: New backend endpoints for persistent settings storage
  - `GET /api/settings/preferences` - Retrieve all user preferences
  - `PUT /api/settings/preferences` - Update preferences (supports partial updates)
  - Database-backed storage for cross-device preference sync
  - Migration script: `backend/migrate_add_user_preferences.py`

### Changed
- Timestamps now display in user-selected timezone with human-readable format throughout the application
- Settings page reorganized from 644-line flat layout to clean 4-tab interface
- CSV Replace Mode moved from Data Management to Advanced tab with additional warnings
- Import operations separated: safe merge in Data Management tab, destructive replace in Advanced tab
- Page title changed from "Roster Manager" to "Settings" for better clarity
- All preferences now persist to backend database instead of relying solely on localStorage

### Fixed
- Unit type classification now consistent across Series 3 and Series 4 heartbeat endpoints
- Auto-correction of misclassified unit types when they report to wrong endpoint

### Technical Details
- New `detect_unit_type()` helper function for pattern-based unit classification
- UserPreferences model with single-row table pattern (id=1) for global settings
- Series 4 units identified by UM prefix followed by digits (e.g., UM11719)
- JavaScript Intl API used for client-side timezone conversion
- Pydantic schema for partial preference updates (PreferencesUpdate model)
- Environment context injection via custom FastAPI template response wrapper

## [0.2.1] - 2025-12-03

### Added
- `/settings` roster manager page with CSV export/import, live stats, and danger-zone reset controls.
- `/api/settings` router that exposes `export-csv`, `stats`, `roster-units`, `import-csv-replace`, and the clear-* endpoints backing the UI.
- Dedicated HTMX partials/tabs for deployed, benched, retired, and ignored units plus new ignored-table UI to unignore or delete entries.

### Changed
- Roster and unit detail templates now display device-type specific metadata (calibration windows, modem pairings, IP/phone fields) alongside inline actions.
- Base navigation highlights the new settings workflow and routes retired/ignored buckets through dedicated endpoints + partials.

### Fixed
- Snapshot summary counts only consider deployed units, preventing dashboard alerts from including benched hardware.
- Snapshot payloads now include address/coordinate metadata so map widgets and CSV exports stay accurate.

## [0.2.0] - 2025-12-03

### Added
- Device-type aware roster schema (seismographs vs modems) with new metadata columns plus `backend/migrate_add_device_types.py` for upgrading existing SQLite files.
- `create_test_db.py` helper that generates a ready-to-use demo database with sample seismographs, modems, and emitter rows.
- Ignore list persistence/API so noisy legacy emitters can be quarantined via `/api/roster/ignore` and surfaced in the UI.
- Roster page enhancements: Add Unit modal, CSV import modal, and HTMX-powered table fed by `/partials/roster-table`.
- Unit detail view rewritten to fetch data via API, expose deployment status, and allow edits to all metadata.

### Changed
- Snapshot service now merges roster + emitter data into active/benched/retired/unknown buckets and includes device-specific metadata in each record.
- Roster edit endpoints parse date fields, manage modem/seismograph specific attributes, and guarantee records exist when toggling deployed/retired states.
- Dashboard partial endpoints are grouped under `/dashboard/*` so HTMX tabs stay in sync with the consolidated snapshot payload.

### Fixed
- Toggling deployed/retired flags no longer fails when a unit does not exist because the router now auto-creates placeholder roster rows.
- CSV import applies address/coordinate updates instead of silently dropping unknown columns.

## [0.1.1] - 2025-12-02

### Added
- **Roster Editing API**: Full CRUD operations for roster management
  - `POST /api/roster/add` - Add new units to roster
  - `POST /api/roster/set-deployed/{unit_id}` - Toggle deployment status
  - `POST /api/roster/set-retired/{unit_id}` - Toggle retired status
  - `POST /api/roster/set-note/{unit_id}` - Update unit notes
- **CSV Import**: Bulk roster import functionality
  - `POST /api/roster/import-csv` - Import units from CSV file
  - Support for all roster fields: unit_id, unit_type, deployed, retired, note, project_id, location
  - Optional update_existing parameter to control duplicate handling
  - Detailed import summary with added/updated/skipped/error counts
- **Enhanced Database Models**:
  - Added `project_id` field to RosterUnit model
  - Added `location` field to RosterUnit model
  - Added `last_updated` timestamp tracking
- **Dashboard Enhancements**:
  - Separate views for Active, Benched, and Retired units
  - New endpoints: `/dashboard/active` and `/dashboard/benched`

### Fixed
- Database session management bug in `emit_status_snapshot()`
  - Added `get_db_session()` helper function for direct session access
  - Implemented proper session cleanup with try/finally blocks
- Database schema synchronization issues
  - Database now properly recreates when model changes are detected

### Changed
- Updated RosterUnit model to include additional metadata fields
- Improved error handling in CSV import with row-level error reporting
- Enhanced snapshot service to properly manage database connections

### Technical Details
- All roster editing endpoints use Form data for better HTML form compatibility
- CSV import uses multipart/form-data for file uploads
- Boolean fields in CSV accept: 'true', '1', 'yes' (case-insensitive)
- Database sessions now properly closed to prevent connection leaks

## [0.1.0] - 2024-11-20

### Added
- Initial release of Seismo Fleet Manager
- FastAPI-based REST API for fleet management
- SQLite database with SQLAlchemy ORM
- Emitter reporting endpoints
- Basic fleet status monitoring
- Docker and Docker Compose support
- Web-based dashboard with HTMX
- Dark/light mode toggle
- Interactive maps with Leaflet
- Photo management per unit
- Automated status categorization (OK/Pending/Missing)

[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/serversdwn/seismo-fleet-manager/releases/tag/v0.1.0
