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
@@ -0,0 +1,76 @@
{# Project-wide vibration events roll-up. Loaded via HTMX. #}
{% if summary.vibration_location_count == 0 %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">
No vibration monitoring locations yet. Add one to start collecting events.
</div>
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Project-wide vibration events
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
across {{ summary.vibration_location_count }} location{{ '' if summary.vibration_location_count == 1 else 's' }}
</span>
</div>
<!-- KPI tiles -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</span>
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
{% if summary.peak_pvs is not none %}
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.4f"|format(summary.peak_pvs) }} <span class="text-sm font-normal">in/s</span></span>
<a href="/projects/{{ summary.project_id }}/nrl/{{ summary.peak_pvs_location_id }}"
class="text-xs text-seismo-orange hover:text-seismo-navy truncate mt-1"
title="{{ summary.peak_pvs_location_name }}">
{{ summary.peak_pvs_location_name }}
{% if summary.peak_pvs_at %} · {{ summary.peak_pvs_at[:10] }}{% endif %}
</a>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
{% if summary.last_event %}
<span class="text-base font-bold text-gray-900 dark:text-white mt-1">{{ summary.last_event[:19].replace('T', ' ') }}</span>
{% else %}
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
{% endif %}
</div>
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
<span class="text-2xl font-bold {% if summary.false_trigger_count > 0 %}text-amber-600 dark:text-amber-400{% else %}text-gray-900 dark:text-white{% endif %} mt-1">{{ "{:,}".format(summary.false_trigger_count) }}</span>
</div>
</div>
{% if summary.per_location and summary.total_events > 0 %}
<!-- Top locations by activity -->
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Top locations by activity</h4>
<div class="space-y-1.5">
{% for loc in summary.per_location[:5] %}
<a href="/projects/{{ summary.project_id }}/nrl/{{ loc.location_id }}"
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 }}
</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>
{% if loc.peak_pvs is not none %}
<span class="text-xs text-gray-500">peak {{ "%.4f"|format(loc.peak_pvs) }}</span>
{% endif %}
</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}