8 Commits

Author SHA1 Message Date
serversdown ba1f28ee53 fix(backfill): typeahead picks broken by JSON.stringify quote collision in onclick
The inline onclick on each typeahead dropdown item was:

  onclick="onTypeaheadPick(event, 'cid', 'location', 'loc-id', ${JSON.stringify(m.name)})"

For any name with spaces/punctuation (i.e. every real location name like
"Area 1 - Loc 1 - 87 Jenks"), JSON.stringify emits double quotes around
the value, which collide with the onclick attribute's own double quotes
and terminate the attribute early.  The dropdown rendered fine via
.innerHTML, but the browser's HTML parser saw a broken attribute and
never bound the click handler — clicks on dropdown items silently did
nothing.

Same pattern that broke the location Remove button yesterday.  Same fix:
move args into data-* attributes and dispatch through a tiny trampoline
that reads from this.dataset.  Robust against any character in
project/location names.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 03:59:38 +00:00
serversdown c48c6e5bca fix(assignments): delete_assignment used wrong column name on MonitoringSession
The safety check that refuses to delete assignments with real recording
history referenced MonitoringSession.start_time, but the actual column
is MonitoringSession.started_at.  Every DELETE call to /assignments/{id}
crashed with AttributeError before doing anything.

Now uses started_at correctly.  Verified end-to-end on dev.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 02:28:52 +00:00
serversdown ef0008822e feat(timeline): merge consecutive same-location assignments + per-unit Gantt chart
When a unit had its assignment closed-then-reopened (e.g. via the
recent location remove/restore flow) or had metadata-backfill auto-
create a retroactive window adjacent to a manual one, the deployment
timeline showed N stacked rows that represented one continuous
deployment.  Visual noise that didn't match reality.

Merge feature
- New endpoint POST /api/projects/{p}/assignments/merge
  - Body: { assignment_ids: [uuid, ...] }
  - Keeps earliest record, extends its window to span all inputs,
    deletes the others, logs `assignment_merged` to UnitHistory
  - Validates: all assignments share same unit + location, all
    belong to the same project
- deployment_timeline_for_unit() now auto-detects mergeable groups
  (consecutive same-location assignments within 7-day gap tolerance)
  and returns them in `merge_groups` as a list of id-lists
- Unit detail page shows a blue banner above the timeline list when
  groups exist, with one "Merge into one" button per group.  Each
  mergeable row gets a small "mergeable" badge to make the
  relationship obvious.

Per-unit Gantt chart (Phase 1 of the deployment-history calendar)
- Plain-SVG horizontal timeline rendered above the existing Deployment
  Timeline list, ~140px tall
- One colored bar per assignment, color-keyed by location (auto-
  assigned palette + legend)
- Reduced opacity for closed bars; small white dot at the right edge
  of active bars; today marker as a dashed orange vertical line
- Month gridlines (or every-3-month gridlines when domain > 24 months)
- Metadata-backfilled assignments get a blue outline so you spot
  which were auto-attributed
- Mergeable groups get a dashed blue underline tying their bars
  together visually
- Click any bar → smooth-scrolls the matching list row into view
  and flashes a ring around it
- Hover any bar → tooltip with location + window + event count
- Auto-hides on units with no deployment history

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:29:51 +00:00
serversdown f13158e7bf feat(locations): delete assignment record for mis-clicks / duplicates
When an operator accidentally clicks Assign multiple times on the same
location (or assigns the wrong unit), the resulting bogus assignment
rows cluttered the location's deployment history with no way to clean
them up — Unassign just sets assigned_until to now, which preserves
the row.

New DELETE /api/projects/{p}/assignments/{a} endpoint hard-deletes the
row entirely, intended for mis-clicks that never represented a real
deployment.

Safety:
  - Refuses if any MonitoringSession exists in the assignment's window
    for the same (unit, location).  If there's a recording session
    backing it, this isn't a mis-click — operator should Edit or
    Unassign instead.
  - Records UnitHistory `assignment_deleted` so the unit's deployment
    timeline still shows the deletion happened, even though the row
    itself is gone.

UI: trash icon added next to the existing pencil (Edit) icon on each
row of the vibration location's "Deployment History" panel.  Confirms
intent with a descriptive prompt that explains the consequence
(attribution becomes unattributed for that window) and points to
Edit/Unassign as alternatives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:11:29 +00:00
serversdown 3f0ec8f30b fix(locations): Remove/Restore buttons broken by quote collision in onclick
The buttons used inline `onclick="...({{ name | tojson }})"`, which
emits the location name as a JSON-quoted string with double quotes —
those double quotes collide with the onclick attribute's own double
quotes, terminating the attribute early.  Result: the browser parses
the attribute as broken HTML and the click handler never fires.

Switched both Remove and Restore to the data-attribute pattern the
Edit button already uses (data-loc-id / data-loc-name read via
this.dataset in the onclick).  Robust against any character in the
location name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:42:39 +00:00
serversdown d5a0163852 feat(locations): soft-remove monitoring locations without destroying history
When a client drops a location from scope mid-project (e.g. the office
half of a museum+office monitoring job), operators couldn't previously
mark it as no-longer-active without either deleting it (which would
orphan historical events) or leaving it in the active list looking
deployable.  Now there's a proper middle ground.

Data model
- MonitoringLocation gets two new nullable columns:
  - removed_at      — NULL means active; set means soft-removed
  - removal_reason  — optional operator note
  Migration: backend/migrate_add_location_removed.py (idempotent)

Endpoints
- POST /api/projects/{p}/locations/{l}/remove
    Body: { effective_date?: ISO-datetime, reason?: str }
    Side effects (cascade):
      1. Closes active UnitAssignment rows at this location
         (assigned_until = effective_date, status = "completed")
      2. Cancels pending ScheduledActions at this location
      3. Marks location.removed_at = effective_date
    Returns counts of assignments closed + actions cancelled.
- POST /api/projects/{p}/locations/{l}/restore
    Clears removed_at + removal_reason.  Does NOT auto-reopen
    assignments — operator creates new ones if resuming monitoring.

Active-surface filters
- locations-json defaults to active-only; pass include_removed=true
  for historical / reporting views.  Schedule modal dropdowns now
  exclude removed locations automatically.
- Metadata-backfill fuzzy matcher excludes removed locations from
  proposed targets (don't want backfill creating new assignments at
  decommissioned locations).
- Vibration-summary per_location rollup includes removed locations
  (so historical event totals stay accurate) but tags each with
  removed_at so the UI can show a badge.

UI
- Project detail page's Monitoring Locations section now splits into:
    Active locations (full card with Assign / Edit / Remove / Delete)
    Removed locations (collapsed <details>, greyed cards, Restore button,
                       shows removal date + reason)
- New per-card "Remove" button → opens confirmation modal explaining
  the cascade, with optional effective-date (defaults to now,
  backdateable) and reason fields.
- Unit detail's SFM Events attribution cell shows a small "removed"
  badge next to historical attributions whose location is no longer
  active.  Same pattern in vibration_summary's top-locations list.
- Soft-removal indicator surfaced through the events_for_unit
  attribution payload as location_removed_at.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:22:40 +00:00
serversdown fd37425f1c Merge pull request 'update main to v0.10.0' (#48) from feature/sfm-integration into main
## [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.
2026-05-14 16:56:40 -04:00
serversdown 32d2a57bc9 update to 0.9.4.
Refactors project creation and management to support modular project types. Adds the unit swap modal for fast swapping field units.
2026-04-13 22:28:16 -04:00
12 changed files with 1177 additions and 51 deletions
+63
View File
@@ -0,0 +1,63 @@
"""
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
Lets operators mark a location as no longer actively monitored without
deleting it (so historical events stay attributed correctly). Mirrors
the timestamp-based "closed state" pattern already used by
`unit_assignments.assigned_until`.
Behavior:
- `removed_at IS NULL` → location is active (default for all existing
rows after this migration)
- `removed_at` set → location is removed; historical events still
attribute to it but it's hidden from active
surfaces (assign dropdowns, calendar, etc.)
- `removal_reason` → optional operator note (e.g. "client dropped
from scope")
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == column for row in cur.fetchall())
def migrate_database() -> None:
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
added = []
if not _has_column(cur, "monitoring_locations", "removed_at"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
added.append("removed_at")
if not _has_column(cur, "monitoring_locations", "removal_reason"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
added.append("removal_reason")
conn.commit()
conn.close()
if added:
print(f" Added columns to monitoring_locations: {', '.join(added)}")
else:
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
if __name__ == "__main__":
print("Running migration: add removed_at + removal_reason to monitoring_locations")
migrate_database()
print("Done.")
+8
View File
@@ -235,6 +235,14 @@ class MonitoringLocation(Base):
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
location_metadata = Column(Text, nullable=True)
# Soft-removal: NULL means active. When set, the location is hidden from
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
# vibration summary) but historical events generated before this time
# still attribute to it. Mirrors the closed-state pattern used by
# UnitAssignment.assigned_until.
removed_at = Column(DateTime, nullable=True)
removal_reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+3
View File
@@ -361,6 +361,9 @@ def locations_search(
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == project_id)
.filter(MonitoringLocation.location_type == "vibration")
# Don't propose creating assignments at removed locations — they
# were intentionally decommissioned and shouldn't be backfill targets.
.filter(MonitoringLocation.removed_at == None) # noqa: E711
.all()
)
+409 -7
View File
@@ -31,6 +31,7 @@ from backend.models import (
MonitoringSession,
DataFile,
UnitHistory,
ScheduledAction,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
@@ -138,7 +139,7 @@ async def get_project_locations(
):
"""
Get all monitoring locations for a project.
Returns HTML partial with location list.
Returns HTML partial with location list, split into active + removed.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
@@ -152,10 +153,14 @@ async def get_project_locations(
locations = query.order_by(MonitoringLocation.name).all()
# Enrich with assignment info
locations_data = []
# Enrich with assignment info, splitting active vs removed.
active_data: list = []
removed_data: list = []
for location in locations:
# Get active assignment (active = assigned_until IS NULL)
# Get active assignment (active = assigned_until IS NULL). For
# removed locations this will normally be None because the
# /remove cascade closes them, but check anyway for resilience
# against legacy data.
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location.id,
@@ -172,17 +177,23 @@ async def get_project_locations(
location_id=location.id
).count()
locations_data.append({
item = {
"location": location,
"assignment": assignment,
"assigned_unit": assigned_unit,
"session_count": session_count,
})
}
if location.removed_at is None:
active_data.append(item)
else:
removed_data.append(item)
return templates.TemplateResponse("partials/projects/location_list.html", {
"request": request,
"project": project,
"locations": locations_data,
"locations": active_data, # back-compat alias
"active_locations": active_data,
"removed_locations": removed_data,
})
@@ -191,10 +202,15 @@ async def get_project_locations_json(
project_id: str,
db: Session = Depends(get_db),
location_type: Optional[str] = Query(None),
include_removed: bool = Query(False),
):
"""
Get all monitoring locations for a project as JSON.
Used by the schedule modal to populate location dropdown.
Removed locations are filtered out by default (you can't schedule
a new action at a removed location). Pass `include_removed=true`
to get them too — useful for historical / reporting views.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
@@ -205,6 +221,9 @@ async def get_project_locations_json(
if location_type:
query = query.filter_by(location_type=location_type)
if not include_removed:
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
locations = query.order_by(MonitoringLocation.name).all()
return [
@@ -215,6 +234,8 @@ async def get_project_locations_json(
"description": loc.description,
"address": loc.address,
"coordinates": loc.coordinates,
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
"removal_reason": loc.removal_reason,
}
for loc in locations
]
@@ -335,6 +356,165 @@ async def delete_location(
return {"success": True, "message": "Location deleted successfully"}
@router.post("/locations/{location_id}/remove")
async def remove_location(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Soft-remove a monitoring location — mark it as no longer actively
monitored without destroying it.
Use case: a client drops a location from scope mid-project, but the
historical events recorded there should remain attributed. Deleting
would orphan those events; this preserves them.
Cascading side-effects:
1. All active UnitAssignment rows at this location are closed
(assigned_until = effective_date, status = "completed").
Units become available for other deployments.
2. All pending ScheduledAction rows at this location are cancelled
(execution_status = "cancelled").
3. Historical events stay attributed (attribution is window-based;
events with timestamp < effective_date still match the
now-closed assignment windows).
Accepts JSON body:
- effective_date: ISO datetime (optional, defaults to now)
- reason: operator note (optional)
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if location.removed_at is not None:
raise HTTPException(
status_code=400,
detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
)
# Body is optional — POST with no body is fine and means "remove now,
# no reason given."
try:
payload = await request.json()
except Exception:
payload = {}
# Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
# full ISO. Defaults to now if absent/empty.
raw_eff = payload.get("effective_date")
if raw_eff:
try:
effective_date = datetime.fromisoformat(raw_eff)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid effective_date: {raw_eff!r}",
)
else:
effective_date = datetime.utcnow()
reason = (payload.get("reason") or "").strip() or None
# 1. Close active assignments at this location.
active_assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL
)
).all()
for a in active_assignments:
a.status = "completed"
a.assigned_until = effective_date
_record_assignment_history(
db,
unit_id=a.unit_id,
change_type="assignment_ended",
old_value=location.name,
new_value="location removed",
notes=f"Location '{location.name}' marked as removed"
+ (f"{reason}" if reason else ""),
)
# 2. Cancel pending scheduled actions at this location.
pending_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.location_id == location_id,
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time >= effective_date,
)
).all()
for sa in pending_actions:
sa.execution_status = "cancelled"
sa.error_message = (
f"Cancelled: location '{location.name}' marked as removed"
+ (f"{reason}" if reason else "")
)
# 3. Mark the location itself as removed.
location.removed_at = effective_date
location.removal_reason = reason
location.updated_at = datetime.utcnow()
db.commit()
return {
"success": True,
"message": f"Location '{location.name}' marked as removed",
"effective_date": effective_date.isoformat(),
"assignments_closed": len(active_assignments),
"actions_cancelled": len(pending_actions),
}
@router.post("/locations/{location_id}/restore")
async def restore_location(
project_id: str,
location_id: str,
db: Session = Depends(get_db),
):
"""
Restore a previously-removed monitoring location to active.
Clears `removed_at` and `removal_reason`. Does NOT automatically
re-open the assignments or scheduled actions that were closed when
the location was removed — those stay closed and the operator can
create new ones if they want to resume monitoring.
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if location.removed_at is None:
raise HTTPException(
status_code=400,
detail="Location is already active.",
)
location.removed_at = None
location.removal_reason = None
location.updated_at = datetime.utcnow()
db.commit()
return {
"success": True,
"message": f"Location '{location.name}' restored to active",
}
# ============================================================================
# Unit Assignments
# ============================================================================
@@ -650,6 +830,228 @@ async def update_assignment(
}
@router.delete("/assignments/{assignment_id}")
async def delete_assignment(
project_id: str,
assignment_id: str,
db: Session = Depends(get_db),
):
"""
Hard-delete an assignment record.
Use case: operator clicked Assign by mistake (or 8 times in a row) and
wants the bogus records gone — not just closed with an `assigned_until`
timestamp. The standard close-via-unassign path is for legitimate
deployments that ended; this is for mis-clicks that never actually
happened.
Safety:
- Refuses if any MonitoringSession exists for the same (unit, location)
within this assignment's window — that suggests the deployment was
real, and the operator should use unassign instead.
- Refuses if the assignment is the ONLY active assignment for a unit
currently shown as deployed AND a recording session is in progress.
Audit:
- Records UnitHistory `assignment_deleted` so the unit's deployment
timeline shows the deletion happened (even though the row itself
is gone).
"""
assignment = db.query(UnitAssignment).filter_by(
id=assignment_id,
project_id=project_id,
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Safety: is there a real recording history for this (unit, location)
# within the assignment's time window? If so, this isn't a mis-click —
# the operator should close it via unassign, not delete it.
window_start = assignment.assigned_at
window_end = assignment.assigned_until or datetime.utcnow()
real_sessions = db.query(MonitoringSession).filter(
and_(
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
MonitoringSession.started_at >= window_start,
MonitoringSession.started_at <= window_end,
)
).count()
if real_sessions > 0:
raise HTTPException(
status_code=400,
detail=(
f"Cannot delete this assignment — {real_sessions} monitoring "
f"session(s) were recorded under it. Use Unassign to close "
f"the window instead, which preserves the audit trail."
),
)
# Resolve location name for audit log before deletion.
location = db.query(MonitoringLocation).filter_by(
id=assignment.location_id
).first()
location_label = location.name if location else assignment.location_id
_record_assignment_history(
db,
unit_id=assignment.unit_id,
change_type="assignment_deleted",
old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d}"
f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})",
new_value="deleted",
notes=(
"Assignment row removed — created in error or accidental duplicate."
),
)
db.delete(assignment)
db.commit()
return {
"success": True,
"message": "Assignment deleted.",
}
@router.post("/assignments/merge")
async def merge_assignments(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Merge multiple consecutive UnitAssignment rows for the same (unit, location)
into a single record spanning their combined window.
Use case: a unit's deployment timeline shows 3 stacked rows for the
same location because the assignment was closed-and-reopened (e.g. via
location remove + restore) or because metadata-backfill auto-created
a retroactive window adjacent to a manual one. Operator sees three
rows but they represent one continuous deployment.
Body JSON:
{ "assignment_ids": ["<uuid>", "<uuid>", ...] }
Validation:
- All assignments must belong to project_id
- All must share the same unit_id AND location_id
- At least 2 ids must be provided
Merge rules:
- Keeps the EARLIEST-starting assignment as the surviving row
- assigned_at = min(assigned_at across all)
- assigned_until = max(assigned_until), or NULL if any input was active
- status = "active" if any input was active, else "completed"
- source = source of the earliest record (preserves original ingest provenance)
- notes = earliest's notes + "Merged N records (<sources>)"
- Other records are DELETED
- One UnitHistory `assignment_merged` row is written for audit
No tolerance check — the operator is asking for the merge, so we trust
the intent. The UI can pre-filter to only offer "consecutive" merges
if it wants.
"""
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
ids = payload.get("assignment_ids") or []
if not isinstance(ids, list) or len(ids) < 2:
raise HTTPException(
status_code=400,
detail="Need at least 2 assignment_ids to merge.",
)
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.project_id == project_id)
.filter(UnitAssignment.id.in_(ids))
.all()
)
if len(assignments) != len(set(ids)):
raise HTTPException(
status_code=404,
detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
)
unit_ids = {a.unit_id for a in assignments}
loc_ids = {a.location_id for a in assignments}
if len(unit_ids) > 1 or len(loc_ids) > 1:
raise HTTPException(
status_code=400,
detail="Can only merge assignments that share the same unit and location.",
)
# Order chronologically.
assignments.sort(key=lambda a: a.assigned_at)
earliest = assignments[0]
others = assignments[1:]
# Compute merged window.
any_active = any(a.assigned_until is None for a in assignments)
if any_active:
merged_until = None
else:
merged_until = max(a.assigned_until for a in assignments)
# Build a brief audit-style note describing what got merged.
bits = []
for a in assignments:
win = (
f"{a.assigned_at:%Y-%m-%d}"
f"{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
)
bits.append(f"{win} [{a.source}]")
merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
new_notes = (earliest.notes + "" + merge_note_suffix) if earliest.notes else merge_note_suffix
# Resolve names for the audit log before mutating.
location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
location_label = location.name if location else earliest.location_id
# Mutate the survivor.
earliest.assigned_at = min(a.assigned_at for a in assignments)
earliest.assigned_until = merged_until
earliest.status = "active" if any_active else "completed"
earliest.notes = new_notes
# Delete the rest.
deleted_ids = [a.id for a in others]
for a in others:
db.delete(a)
_record_assignment_history(
db,
unit_id=earliest.unit_id,
change_type="assignment_merged",
old_value=f"{len(assignments)} rows at {location_label}",
new_value=(
f"1 row {earliest.assigned_at:%Y-%m-%d}"
f"{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
),
notes=merge_note_suffix,
)
db.commit()
db.refresh(earliest)
return {
"success": True,
"message": f"Merged {len(assignments)} assignments into one.",
"kept_id": earliest.id,
"deleted_ids": deleted_ids,
"merged_window": {
"assigned_at": earliest.assigned_at.isoformat(),
"assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
},
}
@router.post("/locations/{location_id}/swap")
async def swap_unit_on_location(
project_id: str,
+37 -1
View File
@@ -46,6 +46,13 @@ log = logging.getLogger("backend.services.deployment_timeline")
# clutter from a sub-second handoff during a swap workflow.
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
# When detecting "mergeable" groups of consecutive same-location assignments,
# treat assignments separated by no more than this many seconds as adjacent.
# Generous enough to catch overnight handoffs and weekend gaps where the
# operator forgot to log, but tight enough that genuinely separate
# deployments months apart don't get suggested for merging.
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
# Per-call timeout when querying SFM for the event overlay.
_SFM_TIMEOUT = 10.0
_SFM_FETCH_CEILING = 5000
@@ -245,7 +252,32 @@ async def deployment_timeline_for_unit(
"history_notes": h.notes,
})
# 6. Sort newest first. Active assignments (no end) sort by start time,
# 6. Detect mergeable groups — runs of consecutive assignments to the
# same location with small gaps between them. Each group becomes a
# list of assignment_ids; the UI offers a "Merge into one" action
# on any group >= 2.
merge_groups: list[list[str]] = []
if len(assignments) >= 2:
# Sort ascending for the linear scan.
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
for a in sorted_assignments[1:]:
prev = cur_group[-1]
same_location = a.location_id == prev.location_id
prev_end = prev.assigned_until or now
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
# Within tolerance and same location → extend the current group.
# Negative gaps (overlap) also count as adjacent.
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
cur_group.append(a)
else:
if len(cur_group) >= 2:
merge_groups.append([x.id for x in cur_group])
cur_group = [a]
if len(cur_group) >= 2:
merge_groups.append([x.id for x in cur_group])
# 7. Sort newest first. Active assignments (no end) sort by start time,
# same as everything else.
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
@@ -253,4 +285,8 @@ async def deployment_timeline_for_unit(
"unit_id": unit.id,
"device_type": unit.device_type,
"entries": entries,
# List of assignment_id lists; each inner list is a mergeable group.
# Empty if nothing is mergeable. UI shows a "Merge" button on any
# row whose assignment_id appears in a group.
"merge_groups": merge_groups,
}
+9
View File
@@ -321,6 +321,11 @@ async def events_for_unit(
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
# Soft-removal indicator so the UI can render a "(removed)"
# badge next to historical attributions whose location is no
# longer actively monitored.
"location_removed_at": (loc.removed_at.isoformat()
if loc and loc.removed_at else None),
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
"event_count": ec,
"peak_pvs": ev_peak,
"last_event": ev_last,
# Soft-removal state — UI can show a "(removed)" badge in the
# per-location list so operators see at a glance that a row's
# numbers are historical-only.
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
})
per_location.sort(key=lambda r: r["event_count"], reverse=True)
+29 -2
View File
@@ -275,7 +275,13 @@ async function _fetchTypeahead(input, fieldKind) {
return;
}
// Args go in data-* attributes (not inline onclick) to avoid the quote
// collision when location names contain characters JSON.stringify quotes
// (e.g. anything with spaces/punctuation — basically every real name).
// _esc() escapes for HTML attribute context (entities for <>&"), then
// the browser decodes them when reading the dataset value.
dropdown.innerHTML = items.map((it, idx) => {
const cid = _esc(input.dataset.clusterId);
if (it.kind === 'match') {
const m = it.payload;
const scoreBadge = m.score >= 0.99
@@ -291,16 +297,24 @@ async function _fetchTypeahead(input, fieldKind) {
}
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
return `<button type="button"
data-cid="${cid}"
data-field-kind="${fieldKind}"
data-entity-id="${_esc(m.id)}"
data-entity-name="${_esc(m.name)}"
onmousedown="event.preventDefault()"
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
onclick="_typeaheadPickFromButton(this)"
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
${metaLine}
</button>`;
}
return `<button type="button"
data-cid="${cid}"
data-field-kind="${fieldKind}"
data-entity-id=""
data-entity-name="${_esc(it.name)}"
onmousedown="event.preventDefault()"
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
onclick="_typeaheadPickFromButton(this)"
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
+ ${_esc(it.label)}
</button>`;
@@ -308,6 +322,19 @@ async function _fetchTypeahead(input, fieldKind) {
dropdown.classList.remove('hidden');
}
// Trampoline — reads the click target's data-* attributes and forwards
// to onTypeaheadPick. Keeps the inline onclick attribute free of any
// string interpolation that could collide with HTML quoting.
function _typeaheadPickFromButton(btn) {
onTypeaheadPick(
null,
btn.dataset.cid,
btn.dataset.fieldKind,
btn.dataset.entityId || '',
btn.dataset.entityName || ''
);
}
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
// entityId is empty string for "create new", or a UUID for matched existing.
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
+95 -13
View File
@@ -1,7 +1,21 @@
<!-- Project Locations List -->
{% if locations %}
<!-- Project Locations List — split into Active + Removed sections.
Active locations get the full card with Assign/Edit/Delete/Remove
actions. Removed locations get a greyed-out card with a
Removed-on date, optional reason, and a Restore button. -->
{% if not active_locations and not removed_locations %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p>No locations added yet</p>
</div>
{% else %}
{# ─── Active locations ─── #}
{% if active_locations %}
<div class="space-y-3">
{% for item in locations %}
{% for item in active_locations %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
@@ -24,11 +38,13 @@
<div class="flex items-center gap-2">
{% if item.assignment %}
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<button onclick="unassignUnit('{{ item.assignment.id }}')"
class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Unassign
</button>
{% else %}
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
Assign
</button>
{% endif %}
@@ -37,7 +53,16 @@
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
Edit
</button>
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
<button data-loc-id="{{ item.location.id }}"
data-loc-name="{{ item.location.name | e }}"
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName)"
class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100"
title="Mark as no longer actively monitored — preserves historical events">
Remove
</button>
<button onclick="deleteLocation('{{ item.location.id }}')"
class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
title="Permanently delete — only available if there's no history">
Delete
</button>
</div>
@@ -54,11 +79,68 @@
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p>No locations added yet</p>
</div>
{% endif %}
{# ─── Removed locations (collapsed by default) ─── #}
{% if removed_locations %}
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
<span class="inline-flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
Removed locations
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
</span>
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
</summary>
<div class="space-y-3 mt-3">
{% for item in removed_locations %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
{{ item.location.name }}
</a>
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
Removed
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
</span>
</div>
{% if item.location.removal_reason %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
{% endif %}
{% if item.location.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
{% endif %}
{% if item.location.address %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
<button data-loc-id="{{ item.location.id }}"
data-loc-name="{{ item.location.name | e }}"
onclick="restoreLocation(this.dataset.locId, this.dataset.locName)"
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
title="Restore to active monitoring">
Restore
</button>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
<span>Historical sessions: {{ item.session_count }}</span>
</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% endif %}
@@ -60,6 +60,10 @@
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
📍 {{ loc.location_name }}
{% if loc.removed_at %}
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
title="Location no longer actively monitored — events shown are historical">removed</span>
{% endif %}
</span>
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
+143
View File
@@ -778,6 +778,61 @@
</div>
</div>
<!-- Remove Location Confirmation Modal —
Soft-removal: preserves historical events, closes active assignments,
cancels pending scheduled actions. Distinct from Delete (which is
permanent and only allowed when there's no history). -->
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white"></span> as no longer actively monitored.
</p>
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
<li>Closes any active unit assignment at this location</li>
<li>Cancels pending scheduled actions at this location</li>
<li>Historical events stay attributed (visible in reports + event lists)</li>
<li>Can be restored later if needed</li>
</ul>
<input type="hidden" id="remove-location-id">
<div class="mb-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
<input type="datetime-local" id="remove-location-effective"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
<input type="text" id="remove-location-reason" maxlength="200"
placeholder="e.g. client dropped from scope"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
<div class="flex justify-end gap-2">
<button onclick="closeRemoveLocationModal()"
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button onclick="confirmRemoveLocation()"
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
Remove
</button>
</div>
</div>
</div>
<!-- Delete Project Confirmation Modal -->
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
@@ -1213,6 +1268,94 @@ async function deleteLocation(locationId) {
}
}
// ── Remove / Restore location ────────────────────────────────────────
// Soft-removal: marks a location as no longer actively monitored without
// destroying it. Historical events stay attributed; active assignments
// are auto-closed and pending scheduled actions are auto-cancelled.
function openRemoveLocationModal(locationId, locationName) {
document.getElementById('remove-location-id').value = locationId;
document.getElementById('remove-location-name').textContent = locationName;
document.getElementById('remove-location-reason').value = '';
// Default effective_date to "now" in local datetime-input format.
const now = new Date();
const tzOffsetMin = now.getTimezoneOffset();
const local = new Date(now.getTime() - tzOffsetMin * 60000);
document.getElementById('remove-location-effective').value =
local.toISOString().slice(0, 16);
document.getElementById('remove-location-error').classList.add('hidden');
document.getElementById('remove-location-modal').classList.remove('hidden');
}
function closeRemoveLocationModal() {
document.getElementById('remove-location-modal').classList.add('hidden');
}
async function confirmRemoveLocation() {
const locationId = document.getElementById('remove-location-id').value;
const reason = document.getElementById('remove-location-reason').value.trim();
const effective = document.getElementById('remove-location-effective').value;
const errBox = document.getElementById('remove-location-error');
errBox.classList.add('hidden');
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/remove`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: reason || null,
effective_date: effective || null,
}),
}
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to remove location');
}
const result = await response.json();
closeRemoveLocationModal();
refreshLocationLists();
refreshProjectDashboard();
// Lightweight feedback — the UI refresh already shows the location
// moving to the Removed section, but a toast confirms the cascade.
if (typeof showToast === 'function') {
const bits = [];
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
const tail = bits.length ? ` (${bits.join(', ')})` : '';
showToast(`Location removed${tail}`, 'success');
}
} catch (err) {
errBox.textContent = err.message || 'Failed to remove location.';
errBox.classList.remove('hidden');
}
}
async function restoreLocation(locationId, locationName) {
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
return;
}
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/restore`,
{ method: 'POST' }
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to restore location');
}
refreshLocationLists();
refreshProjectDashboard();
if (typeof showToast === 'function') {
showToast(`"${locationName}" restored to active`, 'success');
}
} catch (err) {
alert(err.message || 'Failed to restore location.');
}
}
// Assign modal functions
function openAssignModal(locationId, locationType) {
const safeType = locationType || 'sound';
+321 -7
View File
@@ -287,6 +287,16 @@
↻ Refresh
</button>
</div>
<!-- Gantt chart — visual timeline of all deployments. Click
a bar to jump to its row in the list below. -->
<div id="deploymentGantt" class="mb-4 hidden">
<div class="bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
<svg id="deploymentGanttSvg" class="w-full" style="height: 140px;" preserveAspectRatio="none"></svg>
<div id="deploymentGanttLegend" class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400"></div>
</div>
</div>
<div id="deploymentTimeline" class="space-y-3">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
</div>
@@ -1986,6 +1996,10 @@ loadUnitData().then(() => {
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
// Derives entries from unit_assignments + unit_history + SFM event overlay.
// Cache the most recent timeline payload so the merge action can look up
// which assignment_ids belong together in a mergeable group.
let _dtCurrentTimeline = { entries: [], merge_groups: [] };
async function loadDeploymentTimeline() {
const container = document.getElementById('deploymentTimeline');
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
@@ -1994,12 +2008,58 @@ async function loadDeploymentTimeline() {
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
renderDeploymentTimeline(d.entries || [], container);
_dtCurrentTimeline = {
entries: d.entries || [],
merge_groups: d.merge_groups || [],
};
renderDeploymentTimeline(_dtCurrentTimeline.entries, container, _dtCurrentTimeline.merge_groups);
} catch (e) {
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
}
}
// Returns the merge_group (list of assignment_ids) that this assignment is
// part of, or null if it isn't in any mergeable group.
function _dtFindMergeGroup(assignmentId) {
for (const group of _dtCurrentTimeline.merge_groups || []) {
if (group.includes(assignmentId)) return group;
}
return null;
}
async function mergeAssignmentGroup(assignmentIds) {
if (!Array.isArray(assignmentIds) || assignmentIds.length < 2) return;
const msg = `Merge ${assignmentIds.length} consecutive assignment records into one?\n\n`
+ `The earliest record is kept and its window extended to span all `
+ `of them. The other ${assignmentIds.length - 1} record(s) are deleted.\n\n`
+ `Original metadata (notes + ingest source) is preserved. This is `
+ `logged to the unit's history as "assignment_merged".`;
if (!confirm(msg)) return;
try {
// All assignments share the same project_id (validated server-side).
// Pick the first entry's project_id from the cache.
const first = (_dtCurrentTimeline.entries || []).find(e =>
e.kind === 'assignment' && assignmentIds.includes(e.assignment_id)
);
const projectId = first ? first.project_id : null;
if (!projectId) throw new Error('Could not resolve project id for this group');
const r = await fetch(`/api/projects/${projectId}/assignments/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignment_ids: assignmentIds }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
await loadDeploymentTimeline();
} catch (e) {
alert(e.message || 'Failed to merge assignments.');
}
}
function _dtFmtDate(iso) {
if (!iso) return '—';
return iso.slice(0, 10);
@@ -2044,6 +2104,17 @@ function _dtRenderAssignment(e) {
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
// If this assignment belongs to a mergeable group, show a small
// indicator badge — the group-level "Merge" action lives in the
// banner at the top of the section to avoid N redundant buttons.
const mergeGroup = _dtFindMergeGroup(e.assignment_id);
const mergeableBadge = mergeGroup
? `<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
title="This row is part of a ${mergeGroup.length}-record consecutive group at the same location — see the Merge banner above to combine them.">
mergeable
</span>`
: '';
const overlay = evCount > 0
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
@@ -2056,7 +2127,7 @@ function _dtRenderAssignment(e) {
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
: '';
return `<div class="flex gap-3">
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
<div class="flex flex-col items-center pt-1">
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
</div>
@@ -2065,8 +2136,11 @@ function _dtRenderAssignment(e) {
<div class="text-sm text-gray-700 dark:text-gray-300">
<strong>${start}</strong> → <strong>${end}</strong>${dur}
</div>
<div class="flex items-center gap-2">
${mergeableBadge}
${activeBadge}
</div>
</div>
<div class="mt-1">${locLink}</div>
${projLine}
${overlay}
@@ -2118,18 +2192,253 @@ function _dtRenderStateChange(e) {
</div>`;
}
function renderDeploymentTimeline(entries, container) {
if (!entries.length) {
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
// ── Gantt chart ─────────────────────────────────────────────────────────────
// Renders all assignment windows as colored horizontal bars on an SVG
// timeline. Click a bar to scroll its detail row into view in the list
// below. Color per location, opacity reduced for closed assignments.
// "Mergeable" groups get a unifying outline overlay so they're visible at
// a glance as one logical deployment.
const _ganttColorPalette = [
'#f48b1c', '#142a66', '#7d234d', '#0e7490', '#15803d', '#a16207',
'#9333ea', '#dc2626', '#0d9488', '#1d4ed8', '#be185d', '#65a30d',
];
function _ganttColorFor(locId, locColorMap) {
if (locColorMap[locId]) return locColorMap[locId];
const idx = Object.keys(locColorMap).length % _ganttColorPalette.length;
locColorMap[locId] = _ganttColorPalette[idx];
return locColorMap[locId];
}
function _ganttParseDate(iso) {
if (!iso) return null;
const d = new Date(iso.replace(' ', 'T'));
return isNaN(d.getTime()) ? null : d;
}
function _ganttFmtMonth(d) {
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
}
function renderDeploymentGantt(entries, mergeGroups) {
const wrapper = document.getElementById('deploymentGantt');
const svg = document.getElementById('deploymentGanttSvg');
const legend = document.getElementById('deploymentGanttLegend');
if (!wrapper || !svg) return;
const assignments = (entries || []).filter(e => e.kind === 'assignment' && e.starts_at);
if (assignments.length === 0) {
wrapper.classList.add('hidden');
return;
}
wrapper.classList.remove('hidden');
// Compute time domain. Pad the end by a few days when an active
// assignment is present so the "active" bar doesn't reach the very
// edge of the chart.
const now = new Date();
let minDate = null, maxDate = null;
for (const a of assignments) {
const start = _ganttParseDate(a.starts_at);
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
if (start && (!minDate || start < minDate)) minDate = start;
if (end && (!maxDate || end > maxDate)) maxDate = end;
}
if (!minDate || !maxDate) { wrapper.classList.add('hidden'); return; }
// Tiny padding at both ends (3% of total span).
const span = maxDate - minDate;
const pad = Math.max(span * 0.03, 24 * 3600 * 1000); // at least 1 day
minDate = new Date(minDate.getTime() - pad);
maxDate = new Date(maxDate.getTime() + pad);
// Build a quick "which mergeGroup is this id in?" map.
const idToGroup = {};
(mergeGroups || []).forEach((g, idx) => g.forEach(id => { idToGroup[id] = idx; }));
// Compute SVG geometry.
const width = Math.max(svg.clientWidth || svg.parentElement.clientWidth || 800, 400);
const height = 140;
const padLeft = 8;
const padRight = 8;
const padTop = 32; // room for month labels above the bars
const padBottom = 18; // room for assignment-count axis below
const usableW = width - padLeft - padRight;
const usableH = height - padTop - padBottom;
const totalRange = maxDate - minDate;
const xFor = (d) => padLeft + (d - minDate) / totalRange * usableW;
// Choose one-row-per-bar OR stack overlapping bars. Since same-unit
// assignments rarely overlap (only via the brief unassign/reassign
// race), a single row is usually fine. But just in case, stack with
// simple top-down packing.
const lanes = []; // each lane = [{x1, x2, ...}, ...]
function placeInLane(start, end) {
for (let i = 0; i < lanes.length; i++) {
const last = lanes[i][lanes[i].length - 1];
if (last.x2 + 2 < start) {
lanes[i].push({ x1: start, x2: end });
return i;
}
}
lanes.push([{ x1: start, x2: end }]);
return lanes.length - 1;
}
const placed = assignments.map(a => {
const start = _ganttParseDate(a.starts_at);
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
const x1 = xFor(start);
const x2 = xFor(end);
const lane = placeInLane(x1, x2);
return { a, x1, x2, lane };
});
const laneCount = Math.max(lanes.length, 1);
const barH = Math.max(14, Math.min(28, Math.floor(usableH / laneCount) - 4));
const laneSpacing = barH + 4;
// Month gridlines + labels. Tick on the 1st of each month inside the
// domain. If span > 24mo, tick every 3 months instead.
const months = [];
let monthCursor = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
const tickEveryMonths = (totalRange > 24 * 30 * 86400 * 1000) ? 3 : 1;
while (monthCursor <= maxDate) {
if (monthCursor >= minDate) months.push(new Date(monthCursor));
monthCursor.setMonth(monthCursor.getMonth() + tickEveryMonths);
}
// Build the SVG string.
const isDark = document.documentElement.classList.contains('dark');
const gridColor = isDark ? '#374151' : '#e5e7eb';
const labelColor = isDark ? '#9ca3af' : '#6b7280';
const todayColor = '#f48b1c';
const locColorMap = {};
const usedLocs = {};
let parts = [];
// Month gridlines.
months.forEach(m => {
const x = xFor(m);
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
parts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${_ganttFmtMonth(m)}</text>`);
});
// Today marker.
if (now >= minDate && now <= maxDate) {
const x = xFor(now);
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.8"/>`);
parts.push(`<text x="${x + 3}" y="${height - padBottom + 12}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`);
}
// Bars.
placed.forEach(p => {
const a = p.a;
const color = _ganttColorFor(a.location_id || '_', locColorMap);
usedLocs[a.location_name || '(no location)'] = color;
const y = padTop + p.lane * laneSpacing;
const opacity = a.is_active ? 1.0 : 0.85;
const stroke = (a.source === 'metadata_backfill') ? '#3b82f6' : 'none';
const strokeWidth = (a.source === 'metadata_backfill') ? 2 : 0;
const barW = Math.max(p.x2 - p.x1, 3);
const tipDates = `${(a.starts_at || '').slice(0,10)}${a.is_active ? 'active' : (a.ends_at || '').slice(0,10)}`;
const tip = `${(a.location_name || '?').replace(/"/g, '&quot;')} (${tipDates})${a.event_overlay && a.event_overlay.event_count ? ' • ' + a.event_overlay.event_count + ' events' : ''}`;
parts.push(`<g style="cursor: pointer;" onclick="_ganttScrollTo('${a.assignment_id}')">
<title>${tip}</title>
<rect x="${p.x1}" y="${y}" width="${barW}" height="${barH}" rx="3"
fill="${color}" opacity="${opacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>
${a.is_active ? `<circle cx="${p.x2 - 4}" cy="${y + barH / 2}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
</g>`);
// Mergeable highlight — thin dashed underline below the bar.
if (idToGroup[a.assignment_id] !== undefined) {
const uy = y + barH + 1;
parts.push(`<line x1="${p.x1}" y1="${uy}" x2="${p.x2}" y2="${uy}" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.7"/>`);
}
});
svg.innerHTML = parts.join('');
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
// Build legend (one swatch per distinct location).
const legendItems = Object.entries(usedLocs).map(([name, color]) =>
`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded" style="background:${color}"></span>${_dtEsc(name)}</span>`
);
if (mergeGroups && mergeGroups.length > 0) {
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 border-b-2 border-dashed border-blue-500"></span>mergeable group</span>`);
}
if (placed.some(p => p.a.source === 'metadata_backfill')) {
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded border-2 border-blue-500"></span>auto-backfilled</span>`);
}
legend.innerHTML = legendItems.join('');
}
// Click-on-bar handler. Just scroll the matching list row into view +
// briefly flash it so the eye finds it.
function _ganttScrollTo(assignmentId) {
const target = document.querySelector(`[data-assignment-row="${assignmentId}"]`);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('ring-2', 'ring-seismo-orange');
setTimeout(() => target.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
}
function renderDeploymentTimeline(entries, container, mergeGroups) {
if (!entries.length) {
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
// Hide the Gantt block too.
const g = document.getElementById('deploymentGantt');
if (g) g.classList.add('hidden');
return;
}
// Render the Gantt chart first (above the list).
renderDeploymentGantt(entries, mergeGroups);
// Build the mergeable-groups banner. Each group offers one "Merge into
// one" button. Skipped when no groups exist.
let bannerHtml = '';
if (mergeGroups && mergeGroups.length > 0) {
const rows = mergeGroups.map(group => {
// Look up the entries to describe what we're merging.
const groupEntries = (entries || []).filter(e =>
e.kind === 'assignment' && group.includes(e.assignment_id)
);
if (groupEntries.length === 0) return '';
const locName = groupEntries[0].location_name || 'unnamed location';
const earliest = groupEntries.map(e => e.starts_at).filter(Boolean).sort()[0] || '';
const latest = groupEntries.map(e => e.ends_at).filter(Boolean).sort().reverse()[0] || 'present';
const idsJson = JSON.stringify(group).replace(/"/g, '&quot;');
return `<div class="flex items-center justify-between gap-3 py-1.5">
<div class="text-sm text-blue-900 dark:text-blue-200 min-w-0 flex-1">
<strong>${group.length} consecutive records</strong> at <strong>${_dtEsc(locName)}</strong>
<span class="text-xs text-blue-700 dark:text-blue-300 ml-2">${_dtFmtDate(earliest)}${_dtFmtDate(latest)}</span>
</div>
<button onclick='mergeAssignmentGroup(${JSON.stringify(group)})'
class="px-3 py-1 text-xs rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium whitespace-nowrap">
Merge into one
</button>
</div>`;
}).join('');
bannerHtml = `<div class="mb-4 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-2 mb-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="text-xs text-blue-800 dark:text-blue-300">
Consecutive deployments at the same location detected. Combine them into a single record to clean up the view (notes + ingest sources are preserved).
</div>
</div>
${rows}
</div>`;
}
const html = entries.map(e => {
if (e.kind === 'assignment') return _dtRenderAssignment(e);
if (e.kind === 'gap') return _dtRenderGap(e);
if (e.kind === 'state_change') return _dtRenderStateChange(e);
return '';
}).join('');
container.innerHTML = html;
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
}
// ── SFM Events section ──────────────────────────────────────────────────────
@@ -2286,12 +2595,17 @@ function _ueAttrCell(ev) {
if (a) {
const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—');
// If the attributed location has since been soft-removed, badge
// it so operators see at a glance this is historical attribution.
const removedBadge = a.location_removed_at
? '<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold" title="Location no longer actively monitored">removed</span>'
: '';
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
onclick="event.stopPropagation()"
class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel}${locLabel}">
📍 ${locLabel}
</a>
</a>${removedBadge}
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
}
const n = ev.nearest_assignment;
+35
View File
@@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button type="button"
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start} → ${end}')"
title="Delete this assignment record (for mis-clicks / duplicates)"
class="text-gray-400 hover:text-red-600 transition-colors p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`;
@@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) {
document.getElementById('assignment-edit-modal').classList.remove('hidden');
}
async function deleteAssignment(assignmentId, unitId, windowLabel) {
// For mis-clicks / accidental duplicate assignments. Backend refuses
// if there's a real recording session inside the window — those should
// go through Edit or Unassign instead.
const msg = `Delete this assignment?\n\n`
+ `Unit: ${unitId}\n`
+ `Window: ${windowLabel}\n\n`
+ `This is for assignments created in error. Events that fell `
+ `in this window will become unattributed. The unit's deployment `
+ `history will log the deletion for audit.\n\n`
+ `If the unit actually was deployed here, use Edit or Unassign instead.`;
if (!confirm(msg)) return;
try {
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
method: 'DELETE',
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
await loadLocationEvents(); // Refresh stats + table without this assignment.
} catch (err) {
alert(err.message || 'Failed to delete assignment.');
}
}
function closeAssignmentEditModal() {
document.getElementById('assignment-edit-modal').classList.add('hidden');
}