feat(sfm): project-level vibration events roll-up

Phase 3 of the SFM integration. Adds a "Project-wide vibration events"
KPI card to the Vibration tab of every project detail page, summarising
event activity across all of that project's vibration MonitoringLocations.

Backend:
- backend/services/sfm_events.py: vibration_summary_for_project() helper.
  Concurrently fans out events_for_location() across every vibration
  location in the project; aggregates total events, peak PVS (with the
  location it occurred at), last-event timestamp, false-trigger count;
  and produces a per-location breakdown sorted by event count.

- backend/routers/project_locations.py: new GET /api/projects/{p}/
  vibration_summary endpoint returning an HTML partial (HTMX-friendly,
  matches the locations-list HTMX pattern already used on this page).

Frontend:
- templates/partials/projects/vibration_summary.html: new partial with
  four KPI tiles (total, peak PVS + linked location + date, last event,
  false triggers) and a "Top locations by activity" mini-list showing
  the top 5 by event count.  Empty-state copy when the project has no
  vibration locations yet.

- templates/projects/detail.html: HTMX-load the new summary above the
  locations list inside the Vibration tab.

Verified against terra-view-alpha: 24 events across "Loc 1 - 78 poop
street", peak PVS 14.1351 in/s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 00:09:02 +00:00
parent bc5a151faa
commit 63bd6ad8a2
4 changed files with 246 additions and 0 deletions
+40
View File
@@ -776,6 +776,46 @@ async def get_nrl_sessions(
})
@router.get("/vibration_summary", response_class=HTMLResponse)
async def get_project_vibration_summary(
project_id: str,
request: Request,
from_dt: Optional[datetime] = Query(None),
to_dt: Optional[datetime] = Query(None),
db: Session = Depends(get_db),
):
"""
Render a small HTML partial summarising vibration-event activity
across every vibration MonitoringLocation in the project.
Returned to the Vibration tab of the project detail page via HTMX.
Fans out concurrently across all locations (which in turn fan out
across each location's UnitAssignment windows). Total queries to
SFM = sum of assignments across the project.
404 if the project doesn't exist. Empty-state partial if the
project has no vibration locations.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found.")
from backend.services.sfm_events import vibration_summary_for_project
summary = await vibration_summary_for_project(
db, project_id, from_dt=from_dt, to_dt=to_dt
)
return templates.TemplateResponse(
"partials/projects/vibration_summary.html",
{
"request": request,
"project_id": project_id,
"summary": summary,
},
)
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
async def get_location_events(
project_id: str,