6 Commits

Author SHA1 Message Date
serversdown 75597ec1c4 feat(mobile): bottom-nav swap Settings → Events
Mobile bottom navigation had Menu / Dashboard / Devices / Settings,
which dated back to before the SFM integration.  Settings is rarely
needed in the field — Events is the more useful day-to-day mobile
destination now that the SFM event firehose lives there.

New mobile nav: Menu / Dashboard / Devices / Events.

Settings, Projects, Job Planner, Tools, and SFM/SLMM admin pages
all remain accessible via the Menu hamburger which opens the full
sidebar drawer, exactly as they were before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:40:18 +00:00
serversdown 4dcfcbdc45 feat(projects): reusable location-map partial + add map to Vibration tab
The map sidebar that replaced Upcoming Actions on the project overview
is now also on the deeper Vibration tab — operators get the same
spatial context when they drill into vibration monitoring locations.

Refactor
- New partial templates/partials/projects/location_map.html.
  Self-contained: includes the map div + a self-fetch script that
  pulls coords from /api/projects/{p}/locations-json on load.
  Accepts:
    - project_id  (required)
    - map_height  (default "320px")
    - location_type ('vibration' | 'sound' | none = all)
- project_dashboard.html: ~150 lines of inline map JS deleted, replaced
  with {% include 'partials/projects/location_map.html' %}.  Identical
  behavior, less duplication.
- projects/detail.html Vibration tab: locations list converted to a
  2/3 + 1/3 grid; right column hosts the same map partial filtered
  to location_type=vibration with a taller 450px viewport.

Bidirectional hover-highlight (card ↔ pin) works on both surfaces
since the partial registers its own document-level mouseover/mouseout
handlers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:36:55 +00:00
serversdown 825c7370b8 feat(project-overview): hover location card to highlight its map pin
Reverse direction of the existing pin→card flash on the project
overview map.  Hovering a location card now enlarges + reddens the
matching pin on the map and opens its tooltip.  Mouse-out reverts.

Why hover instead of click: clicking the card title navigates to the
location detail page, so any flash effect would never be visible.
Hover is the right interaction here.

Event delegation on document means cards that appear after htmx
swaps (e.g. after a reorder, remove/restore, or assign-modal close)
still get the behavior without rewiring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:34:19 +00:00
serversdown 47c65268e3 feat(tools): fleet-wide deployment history calendar (Phase 2)
The per-unit Gantt chart on /unit/{id} (Phase 1, v0.11.0) was scoped
to one unit's deployment timeline.  This adds the fleet-wide view as
a new entry under /tools.

What it shows
- 12-month calendar grid styled like the Job Planner (4 months per
  row, responsive down to single column on mobile).
- Each day cell shows up to 4 colored mini-bars — one per project
  that had ≥1 active UnitAssignment that day, color deterministically
  hashed from project_id.  Days with >4 active projects show "+N".
- KPI strip at the top: project count, distinct unit count, total
  assignment count in the window.
- Collapsible project legend: ordered by first-active date (which
  matches the deployment-history reading order), each row links to
  the project page, shows the assignment count.

Click-a-day side panel
- Click any populated day cell → slide-over panel from the right
- Groups by project, lists every (unit, location) active that day
- Per-deployment: unit link, location link, window dates, active /
  closed badge, "auto-backfilled" tag for metadata_backfill source
- Sources from a new GET /api/admin/deployment-history/day endpoint

Navigation
- Prev / Next month buttons shift the 12-month window by one month
- "Recent" button jumps back to default (12 months ending now)
- Default window is 11 months back from current month — operator
  sees the recent past on first load, not future emptiness

Files
- backend/services/deployment_history.py — data builder + day-detail
  helper.  Walks UnitAssignment windows, intersects with the 12-month
  range, computes per-project active-day sets.
- backend/routers/deployment_history.py — page route + day-detail JSON
  endpoint.  Wired into main.py.
- templates/admin/deployment_history.html — page + side-panel
- templates/tools.html — new card linking to the page

Phase 3 (deferred): drag-to-resize bars to retroactively adjust
assignment windows from inside the calendar; per-unit row view
(complement to the project-row view) for "where has unit X been across
all jobs"; horizontal scroll for >12-month windows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:33:00 +00:00
serversdown ba9cdb4347 chore(release): bump to v0.11.0
Operator-facing polish release on top of v0.10.0's SFM integration:
- Soft-remove monitoring locations (preserves history)
- Per-unit deployment Gantt chart
- Merge consecutive same-location assignments
- Delete assignment for mis-clicks (with safety check)
- Drag-to-reorder location cards (HTML5 native)
- Three-dot kebab menu replaces inline pill buttons
- Event count on vibration cards (instead of "Sessions: 0")
- Project overview location map (replaces Upcoming Actions)
- Stricter backfill location matcher (no false positives on
  boilerplate-shared names like "Area 1" vs "Area 2")
- 3× JSON.stringify quote-collision bug fixes (Remove button,
  backfill typeahead, project-merge dropdown)
- Merge-project modal min-height fix
- Leaflet stacking-context fix (no more map-over-modal)
- delete_assignment column name fix (start_time → started_at)

Migrations added this release:
- migrate_add_location_removed.py
- migrate_add_location_sort_order.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:27:38 +00:00
serversdown f063383e61 fix(project-overview): Leaflet map z-index leak covered modals
The location map's tile-pane (z-index 200), marker-pane (600), and
control-pane (800) outranked the page modals' z-50 because the map's
container didn't establish its own stacking context.  Modals opened
over the page rendered BEHIND the map tiles (visible in the Edit
Location, Assign, Remove, etc. modals — anywhere overlapping the
right column).

Fixed with `isolation: isolate` on the map container.  That CSS
property forces a new stacking context without needing to rewrite
Leaflet's internal z-indexes, so all the map's panes stay contained
inside the card and z-50 modals correctly render on top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 06:22:08 +00:00
11 changed files with 1057 additions and 143 deletions
+51
View File
@@ -5,6 +5,57 @@ 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).
## [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 200800 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.
+18 -3
View File
@@ -1,4 +1,4 @@
# Terra-View v0.10.0
# Terra-View v0.11.0
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features
@@ -496,6 +496,19 @@ docker compose down -v
## Release Highlights
### v0.11.0 — 2026-05-15
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
- **Merge Consecutive Deployments**: Auto-detects runs of same-location assignments within a 7-day gap and offers a one-click "Merge into one" button. Preserves notes, ingest source, and writes an `assignment_merged` audit entry.
- **Delete Assignment for Mis-Clicks**: Trash icon on each row of the location's Deployment History panel. Hard-deletes the assignment with a safety check that refuses if real MonitoringSessions sit inside the window (those should go through Unassign instead).
- **Drag-to-Reorder Location Cards**: Six-dot drag handle on each card; drop order persists via a new `/locations/reorder` endpoint. Removed locations stay sorted by removal date (their order is historical).
- **Three-Dot Kebab Menu**: Replaces the inline Unassign / Edit / Remove / Delete pill row with a single ⋮ menu. Much cleaner card layout, especially for projects with many locations.
- **Event Count on Vibration Cards**: Vibration locations now show "{N} events" instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations are unchanged.
- **Project Location Map**: Right column of the project overview is now a Leaflet map with a pin per location. Click pin → scrolls + flashes the matching card. Replaces the lightly-used Upcoming Actions panel (still discoverable via a link to the Schedules tab when actions exist).
- **Stricter Location Fuzzy Matching**: Metadata-backfill no longer suggests obviously-wrong matches. WRatio was over-confident on location names ("Area 2 - Brookville Dam - Loc 2" vs "Area 1 - Loc 1 - 87 Jenks" used to score 86%); now uses `token_set_ratio` + a multi-digit penalty so disjoint address numbers correctly demote the score.
- **Fixed: Multiple typeahead dropdowns weren't clickable**: Same JSON.stringify quote-collision bug surfaced in three places (location Remove button, backfill typeahead, project-merge dropdown). All three fixed by switching to `data-*` attributes + trampoline functions.
- **Fixed: Merge-project modal had to be scrolled to see options**: Modal body's `flex-1 overflow-y-auto` collapsed too tight; added `min-height` so the dropdown has room to render below the input.
### v0.10.0 — 2026-05-14
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
@@ -612,9 +625,11 @@ MIT
## Version
**Current: 0.10.0** — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
Previous: 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
+4 -1
View File
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.10.0"
VERSION = "0.11.0"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
@@ -109,6 +109,9 @@ app.include_router(watcher_manager.router)
from backend.routers import admin_modules
app.include_router(admin_modules.router)
from backend.routers import deployment_history
app.include_router(deployment_history.router)
# Projects system routers
app.include_router(projects.router)
app.include_router(project_locations.router)
+99
View File
@@ -0,0 +1,99 @@
"""
Fleet-wide deployment-history calendar — Phase 2 of the
deployment-history visualisation work (Phase 1 is the per-unit Gantt
on /unit/{id}).
Renders all UnitAssignment windows across all projects on a 12-month
calendar grid styled like the Job Planner. Each day cell shows one
mini-bar per project that had ≥1 active assignment that day. Click a
day → side panel with the (unit, location) pairs active.
Routes:
GET /tools/deployment-history — HTML page
GET /api/admin/deployment-history/day — JSON list of deployments
on a specific date (used
by the day-detail panel)
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.services.deployment_history import (
get_deployment_history_data,
get_deployments_on_day,
)
from backend.templates_config import templates
router = APIRouter()
@router.get("/tools/deployment-history", response_class=HTMLResponse)
def deployment_history_page(
request: Request,
year: Optional[int] = Query(None),
month: Optional[int] = Query(None),
db: Session = Depends(get_db),
):
"""Fleet-wide deployment history calendar.
Defaults to a 12-month window ending in the current month (so the
operator sees the recent past, not the future). ?year=&month= can
override the START of the window to scroll backward or forward.
"""
today = date.today()
# Default: 12-month window ending this month → start = 11 months back.
if year is None or month is None:
# 11 months back from current month.
m = today.month - 11
y = today.year
while m < 1:
m += 12
y -= 1
start_year, start_month = y, m
else:
start_year, start_month = year, month
calendar = get_deployment_history_data(db, start_year, start_month)
# Build prev/next navigation values.
prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1)
next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1)
return templates.TemplateResponse("admin/deployment_history.html", {
"request": request,
"calendar": calendar,
"today": today.isoformat(),
"prev_year": prev_y,
"prev_month": prev_m,
"next_year": next_y,
"next_month": next_m,
})
@router.get("/api/admin/deployment-history/day")
def deployment_history_day(
target_date: str = Query(..., description="YYYY-MM-DD"),
db: Session = Depends(get_db),
):
"""Return assignments active on a specific calendar day."""
try:
d = date.fromisoformat(target_date)
except ValueError:
return JSONResponse(
{"error": f"Invalid date: {target_date!r}"},
status_code=400,
)
deployments = get_deployments_on_day(db, d)
return JSONResponse({
"date": target_date,
"count": len(deployments),
"deployments": deployments,
})
+316
View File
@@ -0,0 +1,316 @@
"""
Deployment-history calendar service — builds the data structure for the
fleet-wide deployment-history grid (`/tools/deployment-history`).
For each calendar day in a 12-month window, computes which projects had
at least one unit assigned to a location on that day. Renders as
multi-month grid (job-planner style) with project-colored bars per day.
Distinct from `services/fleet_calendar_service.py` which renders
forward-looking RESERVATIONS for the planner. This one is purely
historical / current — it walks `unit_assignments` instead of
`job_reservations`.
"""
from __future__ import annotations
import hashlib
from datetime import date, datetime, timedelta
from calendar import monthrange
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import Project, UnitAssignment
# Color palette for projects without an explicit color attribute. Chosen
# to have decent contrast on both light and dark backgrounds; cycles
# deterministically by SHA1(project_id).
_PROJECT_COLOR_PALETTE = [
"#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d",
"#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8",
"#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c",
]
def _color_for_project(project_id: str) -> str:
"""Deterministic color assignment from a fixed palette."""
h = hashlib.sha1(project_id.encode("utf-8")).digest()[0]
return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)]
def _month_short(m: int) -> str:
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
def _month_full(m: int) -> str:
return ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"][m - 1]
def get_deployment_history_data(
db: Session,
start_year: int,
start_month: int,
) -> dict:
"""
Build the calendar data structure for a 12-month window starting at
(start_year, start_month).
Returns:
{
"months": [
{
"year": int,
"month": int, # 1-12
"name": "January",
"short_name": "Jan",
"year_short": "26",
"num_days": int,
"first_weekday": int, # 0=Mon..6=Sun (datetime.weekday())
"active_days": {
day_num: [project_id, project_id, ...] # projects with
# ≥1 active assignment
# on that day
},
},
... # 12 entries
],
"projects": [
{
"id": str,
"name": str,
"color": str,
"status": str,
"client_name": str | None,
"assignment_count": int, # total assignments contributing to
# this 12-month window
"first_active": "YYYY-MM-DD" | None,
"last_active": "YYYY-MM-DD" | None,
},
... # only projects with
# ≥1 assignment in the
# window, sorted by
# first_active ASC
],
"total_assignments": int,
"total_active_units": int, # distinct unit_ids across the window
"window": {
"start_year": int,
"start_month": int,
"end_year": int,
"end_month": int,
"first_date": "YYYY-MM-DD",
"last_date": "YYYY-MM-DD",
},
}
"""
# Compute window edges.
first_date = date(start_year, start_month, 1)
# 12 months → end on day-1 of (start + 12)
end_year = start_year + ((start_month + 10) // 12)
end_month = ((start_month + 10) % 12) + 1
last_date = date(end_year, end_month, monthrange(end_year, end_month)[1])
now = datetime.utcnow()
# Fetch every assignment that overlaps the window. An assignment
# overlaps if assigned_at <= last_date AND (assigned_until is NULL
# OR assigned_until >= first_date).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time()))
.filter(
or_(
UnitAssignment.assigned_until == None, # noqa: E711 — active
UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()),
)
)
.all()
)
# Resolve referenced projects in one query.
proj_ids = {a.project_id for a in assignments}
proj_map = {
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
} if proj_ids else {}
# Compute "active days per project" by walking each assignment and
# adding every day in its [start, end] ∩ [first_date, last_date].
# O(N_assignments × avg_window_days); for a typical fleet this is
# bounded (hundreds of assignments × hundreds of days = manageable).
project_active_days: dict[str, set[date]] = {}
project_first_active: dict[str, date] = {}
project_last_active: dict[str, date] = {}
project_assignment_count: dict[str, int] = {}
distinct_units: set[str] = set()
for a in assignments:
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
end_dt = a.assigned_until or now
end = min(end_dt.date(), last_date)
if end < start:
continue
days = project_active_days.setdefault(a.project_id, set())
d = start
while d <= end:
days.add(d)
d += timedelta(days=1)
project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1
distinct_units.add(a.unit_id)
# Track first/last active dates in the window.
prev_first = project_first_active.get(a.project_id)
if prev_first is None or start < prev_first:
project_first_active[a.project_id] = start
prev_last = project_last_active.get(a.project_id)
if prev_last is None or end > prev_last:
project_last_active[a.project_id] = end
# Build the projects array (sorted by first_active ascending so the
# legend reads in deployment-order).
projects_data = []
for pid, days in project_active_days.items():
p = proj_map.get(pid)
if not p:
# Assignment references a deleted project — surface it anyway
# with a placeholder name, since the bars still need a label.
projects_data.append({
"id": pid,
"name": "(deleted project)",
"color": _color_for_project(pid),
"status": "deleted",
"client_name": None,
"assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
})
continue
projects_data.append({
"id": pid,
"name": p.name,
"color": _color_for_project(pid),
"status": p.status or "active",
"client_name": p.client_name,
"assignment_count": project_assignment_count.get(pid, 0),
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
})
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
# Now build the months array.
months_data = []
cur_year, cur_month = start_year, start_month
for _ in range(12):
num_days = monthrange(cur_year, cur_month)[1]
first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun
active_days: dict[int, list[str]] = {}
for day_num in range(1, num_days + 1):
d = date(cur_year, cur_month, day_num)
day_projects = [
pid for pid, days in project_active_days.items()
if d in days
]
if day_projects:
# Sort by the project's color-stable order so bars don't
# jitter between days.
day_projects.sort()
active_days[day_num] = day_projects
months_data.append({
"year": cur_year,
"month": cur_month,
"name": _month_full(cur_month),
"short_name": _month_short(cur_month),
"year_short": f"{cur_year % 100:02d}",
"num_days": num_days,
"first_weekday": first_weekday,
"active_days": active_days,
})
# Advance one month.
if cur_month == 12:
cur_year += 1
cur_month = 1
else:
cur_month += 1
return {
"months": months_data,
"projects": projects_data,
"total_assignments": len(assignments),
"total_active_units": len(distinct_units),
"window": {
"start_year": start_year,
"start_month": start_month,
"end_year": end_year,
"end_month": end_month,
"first_date": first_date.isoformat(),
"last_date": last_date.isoformat(),
},
}
def get_deployments_on_day(
db: Session,
target_date: date,
) -> list[dict]:
"""
Return the list of (unit, location, project) tuples that were
actively assigned on a specific calendar date. Used for the
day-detail side panel when an operator clicks a day cell.
"""
from backend.models import MonitoringLocation, RosterUnit
day_start = datetime.combine(target_date, datetime.min.time())
day_end = datetime.combine(target_date, datetime.max.time())
rows = (
db.query(UnitAssignment)
.filter(UnitAssignment.assigned_at <= day_end)
.filter(
or_(
UnitAssignment.assigned_until == None, # noqa: E711
UnitAssignment.assigned_until >= day_start,
)
)
.order_by(UnitAssignment.project_id, UnitAssignment.unit_id)
.all()
)
if not rows:
return []
loc_ids = {a.location_id for a in rows}
proj_ids = {a.project_id for a in rows}
loc_map = {
l.id: l for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
}
proj_map = {
p.id: p for p in db.query(Project).filter(
Project.id.in_(proj_ids)
).all()
}
results = []
for a in rows:
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
results.append({
"assignment_id": a.id,
"unit_id": a.unit_id,
"location_id": a.location_id,
"location_name": loc.name if loc else "(unknown location)",
"project_id": a.project_id,
"project_name": proj.name if proj else "(deleted project)",
"project_color": _color_for_project(a.project_id),
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
"assigned_until": a.assigned_until.isoformat() if a.assigned_until else None,
"is_active": a.assigned_until is None,
"source": a.source,
})
return results
+346
View File
@@ -0,0 +1,346 @@
{% extends "base.html" %}
{% block title %}Deployment History - Seismo Fleet Manager{% endblock %}
{% block extra_head %}
<style>
.dh-calendar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) { .dh-calendar-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .dh-calendar-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 480px) { .dh-calendar-grid { grid-template-columns: 1fr; } }
.dh-month-card {
background: white;
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.dark .dh-month-card { background: rgb(30 41 59); }
.dh-day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.dh-day-header {
text-align: center;
font-weight: 600;
color: #6b7280;
padding: 0.25rem 0;
}
.dark .dh-day-header { color: #9ca3af; }
.dh-day-cell {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border-radius: 0.25rem;
position: relative;
transition: all 0.15s ease;
font-size: 0.7rem;
padding-top: 2px;
background-color: #f3f4f6;
color: #374151;
cursor: pointer;
}
.dh-day-cell:hover {
transform: scale(1.1);
z-index: 10;
}
.dh-day-cell.empty {
background: transparent;
cursor: default;
}
.dh-day-cell.empty:hover { transform: none; }
.dh-day-cell.today {
ring-color: #f48b1c;
font-weight: 700;
color: #b84a12;
}
.dark .dh-day-cell {
background-color: rgba(55, 65, 81, 0.5);
color: #d1d5db;
}
.dh-day-cell .dh-day-num {
text-align: center;
flex-shrink: 0;
}
.dh-day-cell .dh-bars {
display: flex;
flex-direction: column;
gap: 1px;
margin: 2px 2px 0 2px;
}
.dh-bar {
height: 3px;
border-radius: 1px;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between flex-wrap gap-3">
<div>
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Deployment History</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Where every unit has been — actual assignment windows, color-coded by project.
For future / planned deployments use the <a href="/fleet-calendar" class="text-seismo-orange hover:text-seismo-burgundy">Job Planner</a>.
</p>
</div>
</div>
<!-- KPI strip -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
<span class="text-gray-500 dark:text-gray-400">{{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }}</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_active_units }} unique units</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window</span>
</div>
{% if calendar.projects %}
<details class="bg-white dark:bg-slate-800 rounded-lg shadow px-4 py-2">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 select-none">Project legend</summary>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
{% for p in calendar.projects %}
<a href="/projects/{{ p.id }}"
class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-seismo-orange">
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ p.color }};"></span>
{{ p.name }}
<span class="text-xs text-gray-400 dark:text-gray-500">·</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.assignment_count }}</span>
</a>
{% endfor %}
</div>
</details>
{% endif %}
</div>
<!-- Calendar grid -->
{% if calendar.projects %}
<div class="dh-calendar-grid mb-6">
{% for month_data in calendar.months %}
<div class="dh-month-card">
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
{{ month_data.short_name }} '{{ month_data.year_short }}
</h3>
<div class="dh-day-grid">
<div class="dh-day-header">S</div>
<div class="dh-day-header">M</div>
<div class="dh-day-header">T</div>
<div class="dh-day-header">W</div>
<div class="dh-day-header">T</div>
<div class="dh-day-header">F</div>
<div class="dh-day-header">S</div>
{# Sunday-first alignment: shift Monday=0 → Sunday=0 #}
{% set first_offset = (month_data.first_weekday + 1) % 7 %}
{% for i in range(first_offset) %}
<div class="dh-day-cell empty"></div>
{% endfor %}
{% for day_num in range(1, month_data.num_days + 1) %}
{% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %}
{% set is_today = date_str == today %}
{% set day_proj_ids = month_data.active_days.get(day_num, []) %}
<div class="dh-day-cell{% if is_today %} today ring-2 ring-seismo-orange{% endif %}"
onclick="openDhDay('{{ date_str }}')"
title="{{ date_str }} — {{ day_proj_ids | length }} project{{ '' if day_proj_ids | length == 1 else 's' }}">
<span class="dh-day-num">{{ day_num }}</span>
{% if day_proj_ids %}
<span class="dh-bars">
{% for pid in day_proj_ids[:4] %}
{% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %}
{% if p %}
<span class="dh-bar" style="background-color: {{ p.color }};"
title="{{ p.name }}"></span>
{% endif %}
{% endfor %}
{% if day_proj_ids | length > 4 %}
<span class="text-[8px] text-gray-500 leading-none">+{{ day_proj_ids | length - 4 }}</span>
{% endif %}
</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Month navigation -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
</span>
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<svg class="w-5 h-5" 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>
</a>
<a href="/tools/deployment-history"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Recent
</a>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm">No deployments in this window.</p>
<p class="text-xs mt-1">Try the navigation buttons below to look at a different range.</p>
</div>
{% endif %}
<!-- Day-detail side panel -->
<div id="dh-day-panel-backdrop"
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
onclick="closeDhDayPanel()"></div>
<div id="dh-day-panel"
class="fixed top-0 right-0 h-screen w-full max-w-md bg-white dark:bg-slate-800 shadow-2xl z-50 hidden transform translate-x-full transition-transform duration-300 overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" id="dh-day-panel-title">Deployments on…</h2>
<p class="text-xs text-gray-500 dark:text-gray-400" id="dh-day-panel-subtitle"></p>
</div>
<button onclick="closeDhDayPanel()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="dh-day-panel-body" class="p-5 space-y-3">
<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
Loading…
</div>
</div>
</div>
<script>
function _dhEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function openDhDay(dateStr) {
const panel = document.getElementById('dh-day-panel');
const backdrop = document.getElementById('dh-day-panel-backdrop');
const title = document.getElementById('dh-day-panel-title');
const sub = document.getElementById('dh-day-panel-subtitle');
const body = document.getElementById('dh-day-panel-body');
title.textContent = 'Deployments on ' + dateStr;
sub.textContent = '';
body.innerHTML = `<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
Loading…
</div>`;
panel.classList.remove('hidden');
backdrop.classList.remove('hidden');
requestAnimationFrame(() => panel.classList.remove('translate-x-full'));
try {
const r = await fetch(`/api/admin/deployment-history/day?target_date=${encodeURIComponent(dateStr)}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
renderDhDayPanel(d, dateStr);
} catch (e) {
body.innerHTML = `<p class="text-sm text-red-500">Failed to load: ${_dhEsc(e.message)}</p>`;
}
}
function renderDhDayPanel(d, dateStr) {
const sub = document.getElementById('dh-day-panel-subtitle');
const body = document.getElementById('dh-day-panel-body');
sub.textContent = `${d.count} active deployment${d.count === 1 ? '' : 's'}`;
if (d.count === 0) {
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No active deployments on this date.</p>';
return;
}
// Group by project for cleaner display.
const byProj = {};
for (const dep of d.deployments) {
const key = dep.project_id || '_none';
if (!byProj[key]) byProj[key] = { name: dep.project_name, color: dep.project_color, items: [] };
byProj[key].items.push(dep);
}
let html = '';
for (const pid of Object.keys(byProj)) {
const proj = byProj[pid];
html += `<div class="space-y-1">
<div class="flex items-center justify-between">
<a href="/projects/${_dhEsc(pid)}" class="flex items-center gap-2 font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
<span class="w-3 h-3 rounded-full" style="background-color: ${proj.color};"></span>
${_dhEsc(proj.name)}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">${proj.items.length} unit${proj.items.length === 1 ? '' : 's'}</span>
</div>
<div class="space-y-1 ml-5">`;
for (const dep of proj.items) {
const activeBadge = dep.is_active
? '<span class="text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
const sourceTag = dep.source === 'metadata_backfill'
? '<span class="text-[10px] text-blue-600 dark:text-blue-400 italic">auto-backfilled</span>'
: '';
const windowEnd = dep.is_active ? 'present' : (dep.assigned_until || '').slice(0, 10);
const windowStart = (dep.assigned_at || '').slice(0, 10);
html += `<div class="flex items-center justify-between gap-2 py-1 text-sm">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a href="/unit/${_dhEsc(dep.unit_id)}" class="font-mono font-medium text-seismo-orange hover:text-seismo-navy">${_dhEsc(dep.unit_id)}</a>
${activeBadge}
${sourceTag}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
📍 <a href="/projects/${_dhEsc(pid)}/nrl/${_dhEsc(dep.location_id)}" class="hover:text-seismo-orange">${_dhEsc(dep.location_name)}</a>
</div>
<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">${windowStart}${windowEnd}</div>
</div>
</div>`;
}
html += `</div></div>`;
}
body.innerHTML = html;
}
function closeDhDayPanel() {
const panel = document.getElementById('dh-day-panel');
const backdrop = document.getElementById('dh-day-panel-backdrop');
panel.classList.add('translate-x-full');
setTimeout(() => {
panel.classList.add('hidden');
backdrop.classList.add('hidden');
}, 300);
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDhDayPanel();
});
</script>
{% endblock %}
+6 -5
View File
@@ -210,7 +210,9 @@
</main>
</div>
<!-- Bottom Navigation (Mobile Only) -->
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
Settings + Projects + Job Planner are reachable via the
hamburger Menu (slot 1) which opens the full sidebar drawer. -->
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
<div class="grid grid-cols-4 h-16">
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
@@ -231,12 +233,11 @@
</svg>
<span>Devices</span>
</button>
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span>Settings</span>
<span>Events</span>
</button>
</div>
</nav>
@@ -0,0 +1,159 @@
<!-- Reusable project-location map.
Renders a Leaflet map with one pin per active monitoring location for
the given project. Fetches data from /api/projects/{p}/locations-json
on load.
Required context variable:
project_id — UUID string
Optional context variable:
map_height — CSS height (default "320px")
location_type — filter the fetched list (default: all types)
Hover any .location-card on the page with a matching
data-location-id → highlights the pin. Click a pin → scrolls
the matching card into view + flashes an orange ring.
isolation:isolate on the container forces a new stacking context so
Leaflet's internal z-indexes (200-800) stay contained inside the
card instead of leaking out and rendering over modals (z-50).
-->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
<span class="text-xs text-gray-400 dark:text-gray-500" id="loc-map-status-{{ project_id }}"></span>
</div>
<div id="loc-map-{{ project_id }}"
class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
style="height: {{ map_height | default('320px') }}; background: rgba(0,0,0,0.05); isolation: isolate;">
</div>
<div id="loc-map-empty-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
</div>
<div id="loc-map-missing-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
</div>
<script>
(function () {
const projectId = {{ project_id | tojson }};
const locationType = {{ (location_type | default(none)) | tojson }};
const mapEl = document.getElementById('loc-map-' + projectId);
const emptyMsg = document.getElementById('loc-map-empty-' + projectId);
const missingMsg = document.getElementById('loc-map-missing-' + projectId);
const statusEl = document.getElementById('loc-map-status-' + projectId);
if (!mapEl) return;
function parseCoords(s) {
if (!s) return null;
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
if (parts.length !== 2 || parts.some(isNaN)) return null;
const [lat, lon] = parts;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return [lat, lon];
}
async function init() {
statusEl.textContent = 'loading…';
let locs;
try {
const qs = locationType ? `?location_type=${encodeURIComponent(locationType)}` : '';
const r = await fetch(`/api/projects/${projectId}/locations-json${qs}`);
if (!r.ok) throw new Error('HTTP ' + r.status);
locs = await r.json();
} catch (e) {
statusEl.textContent = 'failed';
mapEl.innerHTML = `<div class="flex items-center justify-center h-full text-sm text-red-500">Map load failed: ${e.message}</div>`;
return;
}
statusEl.textContent = '';
const withCoords = [];
const withoutCoords = [];
for (const loc of locs) {
const xy = parseCoords(loc.coordinates);
if (xy) withCoords.push({ ...loc, latlon: xy });
else withoutCoords.push(loc);
}
if (withCoords.length === 0) {
mapEl.classList.add('hidden');
emptyMsg.classList.remove('hidden');
return;
}
const map = L.map(mapEl, { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(map);
const markersById = {};
const bounds = [];
withCoords.forEach(loc => {
const marker = L.circleMarker(loc.latlon, {
radius: 8, fillColor: '#f48b1c', color: '#fff',
weight: 2, opacity: 1, fillOpacity: 0.9,
}).addTo(map);
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
marker.on('click', () => _lmFlashCard(loc.id));
markersById[loc.id] = marker;
bounds.push(loc.latlon);
});
if (bounds.length === 1) map.setView(bounds[0], 14);
else map.fitBounds(bounds, { padding: [20, 20] });
setTimeout(() => map.invalidateSize(), 100);
if (withoutCoords.length > 0) {
const names = withoutCoords.map(l => l.name).join(', ');
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
missingMsg.classList.remove('hidden');
}
// Hover any .location-card on the page → highlight matching pin.
let hoverPinId = null;
document.addEventListener('mouseover', (e) => {
const card = e.target.closest('.location-card');
if (!card) return;
const locId = card.dataset.locationId;
if (!markersById[locId] || locId === hoverPinId) return;
if (hoverPinId) _unhighlight(hoverPinId);
_highlight(locId);
hoverPinId = locId;
});
document.addEventListener('mouseout', (e) => {
const card = e.target.closest('.location-card');
if (!card) return;
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
if (related === card) return;
if (hoverPinId) {
_unhighlight(hoverPinId);
hoverPinId = null;
}
});
function _highlight(locId) {
const m = markersById[locId]; if (!m) return;
m.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
m.openTooltip();
m.bringToFront();
}
function _unhighlight(locId) {
const m = markersById[locId]; if (!m) return;
m.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
m.closeTooltip();
}
}
function _lmFlashCard(locId) {
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('ring-2', 'ring-seismo-orange');
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
}
init();
})();
</script>
@@ -78,124 +78,19 @@
</div>
</div>
<!-- Location Map — replaces the old Upcoming Actions panel for the
overview. Operators get a quick visual of where their locations
sit relative to each other. Pins clickable → scroll to + flash
the matching card. Locations without coordinates land in a
"missing coords" hint below the map.
For projects with scheduled monitoring activity, the full
Upcoming Actions list is still available on the Schedules tab. -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
{% if upcoming_actions %}
<a href="javascript:void(0)" onclick="switchTab('schedules')"
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
</a>
{% endif %}
</div>
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
style="height: 320px; background: rgba(0,0,0,0.05);"></div>
<div id="project-location-map-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
</div>
<div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
</div>
{# Location map — uses the reusable partial that fetches from
/api/projects/{p}/locations-json. Same render is reused on the
deeper Vibration tab so both surfaces stay in sync. #}
{% with project_id=project.id %}
{% include 'partials/projects/location_map.html' %}
{% endwith %}
</div>
<script>
(function () {
// Build location data from server-side render. Skip removed
// locations (their pins would clutter the active operations view)
// and skip ones without parseable coordinates.
const locationsRaw = [
{% for loc in locations %}
{% if not loc.removed_at %}
{
id: {{ loc.id | tojson }},
name: {{ loc.name | tojson }},
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
}{% if not loop.last %},{% endif %}
{% endif %}
{% endfor %}
];
function parseCoords(s) {
if (!s) return null;
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
if (parts.length !== 2 || parts.some(isNaN)) return null;
const [lat, lon] = parts;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return [lat, lon];
}
const withCoords = [];
const withoutCoords = [];
for (const loc of locationsRaw) {
const xy = parseCoords(loc.coords);
if (xy) withCoords.push({ ...loc, latlon: xy });
else withoutCoords.push(loc);
}
const emptyMsg = document.getElementById('project-location-map-empty');
const missingMsg = document.getElementById('project-location-map-missing');
const mapEl = document.getElementById('project-location-map');
if (!mapEl) return;
if (withCoords.length === 0) {
// Hide the map block and show a hint. Don't init Leaflet at all.
mapEl.classList.add('hidden');
emptyMsg.classList.remove('hidden');
return;
}
// Initialise Leaflet. `L` is loaded globally by base.html.
const map = L.map(mapEl, { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(map);
const markers = [];
const bounds = [];
withCoords.forEach(loc => {
const marker = L.circleMarker(loc.latlon, {
radius: 8,
fillColor: '#f48b1c',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.9,
}).addTo(map);
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
marker.on('click', () => _flashLocationCard(loc.id));
markers.push(marker);
bounds.push(loc.latlon);
});
if (bounds.length === 1) {
map.setView(bounds[0], 14);
} else {
map.fitBounds(bounds, { padding: [20, 20] });
}
// Without this the map renders into a 0×0 area when the partial
// first lands via htmx (container size not yet stable).
setTimeout(() => map.invalidateSize(), 100);
if (withoutCoords.length > 0) {
const names = withoutCoords.map(l => l.name).join(', ');
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
missingMsg.classList.remove('hidden');
}
// Briefly highlight the matching card to confirm the click.
function _flashLocationCard(locId) {
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('ring-2', 'ring-seismo-orange');
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
}
})();
</script>
{% if upcoming_actions %}
<div class="mt-3 text-xs text-right text-gray-500 dark:text-gray-400">
<a href="javascript:void(0)" onclick="switchTab('schedules')"
class="text-seismo-orange hover:text-seismo-navy">
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
</a>
</div>
{% endif %}
+26 -15
View File
@@ -101,22 +101,33 @@
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
<button onclick="openLocationModal('vibration')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Location
</button>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
<button onclick="openLocationModal('vibration')"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add Location
</button>
</div>
<div id="vibration-locations"
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading locations...</div>
</div>
</div>
<div id="vibration-locations"
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading locations...</div>
{# Reusable location map — fetches from /locations-json
on its own. Hovering any of the location cards on the
left highlights the matching pin on this map. #}
<div>
{% with project_id=project_id, location_type='vibration', map_height='450px' %}
{% include 'partials/projects/location_map.html' %}
{% endwith %}
</div>
</div>
</div>
+18
View File
@@ -68,6 +68,24 @@
</div>
</a>
<!-- Deployment History -->
<a href="/tools/deployment-history"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
<div class="flex items-start gap-3">
<div class="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center shrink-0">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">Deployment History</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
12-month calendar of every unit assignment across every project. Visual bars per project per day; click a day for the full active-deployments list.
</p>
</div>
</div>
</a>
<!-- Reports (per-project) -->
<a href="/projects"
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">