3 Commits

Author SHA1 Message Date
serversdown 17c988c1ee feat(projects): location map sidebar replaces Upcoming Actions on overview
The right column of every project's overview page now shows a Leaflet
map of its monitoring locations instead of the Upcoming Actions panel.
Operators get an immediate visual of where their locations sit relative
to each other and to nearby sites — much more useful at-a-glance than
the list of pending schedule actions, which sits one tab deeper anyway.

Map behavior
- Pin per active monitoring location with parseable "lat,lon" coords.
  Removed locations don't pin (their state is historical).
- Auto-fits bounds to show all pins, with 20px padding.  Single-pin
  projects center at zoom 14.
- Tooltip on pin hover: location name.
- Click pin → scrolls the matching card into view in the locations list
  and flashes an orange ring around it (uses the same data-location-id
  the drag-handle code added in commit 52dd6c3).
- scrollWheelZoom disabled to prevent accidental zoom-in when scrolling
  the page.
- Locations without coordinates surface as a small inline hint below
  the map ("N locations not shown: name1, name2").
- All-coords-missing projects hide the map block entirely and show a
  "set coordinates" hint instead.

Discovery preserved: if the project has pending scheduled actions, a
small "{N} upcoming actions →" link appears in the map card header
that switches to the Schedules tab.  Operators who care about the
queue still find it instantly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:27:27 +00:00
serversdown d297412d8a feat(locations): show event count on vibration cards instead of sessions
For vibration projects, "Sessions: 0" on every location card was
misleading — monitoring sessions don't exist under the watcher-forward
pipeline.  The relevant number is how many SFM events have been
attributed to the location.

get_project_locations now fans out events_for_location() concurrently
across all vibration locations in the project (via asyncio.gather) and
injects event_count into each item's payload.  Sound locations are
unchanged — they still show session_count.

The template already had the conditional rendering ready from the
previous commit:

    {% if item.event_count is defined and item.location.location_type == 'vibration' %}
        <span><strong>{{ event_count }}</strong> events</span>
    {% else %}
        <span>Sessions: {{ session_count }}</span>
    {% endif %}

so this commit is purely the data-layer change that activates it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:25:19 +00:00
serversdown 52dd6c3e32 feat(locations): drag-to-reorder + three-dot kebab menu on cards
Project location cards now reorderable via drag-and-drop, and the
four inline action buttons (Unassign/Edit/Remove/Delete) collapse into
a single three-dot kebab menu — much cleaner card layout, especially
for projects with many locations.

Data
- MonitoringLocation.sort_order: nullable Integer, default 0.
  Migration `migrate_add_location_sort_order.py` adds the column and
  seeds existing rows with sort_order = alphabetical index per project
  (so the post-migration display order matches what operators see
  today — no surprise reordering).
- get_project_locations + locations-json: ORDER BY sort_order, name.
- Location-create: assigns max(sort_order) + 1 so new locations land
  at the END of the list rather than being interleaved alphabetically.

Reorder endpoint
- POST /api/projects/{p}/locations/reorder
  Body: { location_ids: [uuid, uuid, ...] }
  Validates: all ids belong to this project; raises 404 on missing.
  Applies 0-indexed sort_order matching the provided order.

UI changes (templates/partials/projects/location_list.html)
- Active cards get a draggable="true" attribute + native HTML5
  drag/drop handlers.  Drop reorders the DOM immediately, then posts
  the new order to the reorder endpoint.  Drop-zone visual feedback
  (orange ring on hover, opacity on source during drag).
- Six-dot drag handle icon on the left of each active card; whole
  card body is the drag source but the handle is the visual cue.
- Right side: small Assign pill (only shown when unassigned) +
  three-dot kebab menu containing Unassign/Edit/Remove/Delete.
  Click ⋮ to toggle; click outside or Escape to close.  Only one
  menu open at a time.
- Removed locations are NOT draggable (their order is historical) and
  keep their existing Restore button visible.

The card also shows "{N} events" instead of "Sessions: N" when the
location_type is vibration AND the backend passes event_count in
the payload — which lands in commit 2 of this redesign.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 05:23:25 +00:00
5 changed files with 491 additions and 71 deletions
@@ -0,0 +1,76 @@
"""
Migration: add `sort_order` column to `monitoring_locations` and seed
existing rows.
Lets operators reorder location cards via drag-and-drop on the project
detail page. Lower sort_order renders first; ties fall back to name.
Seed strategy: for each existing project, assign sort_order = 0, 1, 2, …
to its locations in their current alphabetical-by-name order. After
this migration, the visible card order on every existing project will
be unchanged.
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.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_column = False
if not _has_column(cur, "monitoring_locations", "sort_order"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0")
added_column = True
print(" Added column: monitoring_locations.sort_order")
# Seed: for each project, set sort_order to its alphabetical index.
# Re-runs are harmless — operator-edited orderings can be re-seeded by
# passing FORCE_RESEED=1, but the default behavior leaves existing
# nonzero sort_order values alone so we don't clobber user choices.
force_reseed = os.environ.get("FORCE_RESEED") == "1"
if added_column or force_reseed:
cur.execute("SELECT DISTINCT project_id FROM monitoring_locations")
projects = [r[0] for r in cur.fetchall()]
seeded = 0
for project_id in projects:
cur.execute(
"SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name",
(project_id,),
)
for idx, (loc_id,) in enumerate(cur.fetchall()):
cur.execute(
"UPDATE monitoring_locations SET sort_order = ? WHERE id = ?",
(idx, loc_id),
)
seeded += 1
print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).")
else:
print(" monitoring_locations.sort_order already present — leaving existing values alone.")
print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)")
conn.commit()
conn.close()
if __name__ == "__main__":
print("Running migration: add sort_order to monitoring_locations")
migrate_database()
print("Done.")
+6
View File
@@ -243,6 +243,12 @@ class MonitoringLocation(Base):
removed_at = Column(DateTime, nullable=True)
removal_reason = Column(Text, nullable=True)
# Display order within the project's location list. Operators can
# drag-and-drop to reorder cards on the project detail page. Lower
# values render first; ties fall back to name (alphabetical). Seeded
# to alphabetical-index on migration; new locations get max+1.
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+82 -2
View File
@@ -151,7 +151,26 @@ async def get_project_locations(
if location_type:
query = query.filter_by(location_type=location_type)
locations = query.order_by(MonitoringLocation.name).all()
# Order by operator-set sort_order, then name as a stable tie-breaker.
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
# For vibration locations, fan out event counts via SFM concurrently
# so the card layout can show "{N} events" instead of "Sessions: 0"
# (sessions don't really exist for the watcher-forward pipeline).
# Sound locations skip this and keep showing session counts.
event_counts: dict[str, int] = {}
vibration_locations = [l for l in locations if l.location_type == "vibration"]
if vibration_locations:
import asyncio
from backend.services.sfm_events import events_for_location
results = await asyncio.gather(
*(events_for_location(db, l.id, limit=1) for l in vibration_locations),
return_exceptions=True,
)
for loc, res in zip(vibration_locations, results):
if isinstance(res, Exception):
continue # leave event_counts[loc.id] unset → template falls back
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
# Enrich with assignment info, splitting active vs removed.
active_data: list = []
@@ -183,6 +202,8 @@ async def get_project_locations(
"assigned_unit": assigned_unit,
"session_count": session_count,
}
if location.id in event_counts:
item["event_count"] = event_counts[location.id]
if location.removed_at is None:
active_data.append(item)
else:
@@ -224,7 +245,7 @@ async def get_project_locations_json(
if not include_removed:
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
locations = query.order_by(MonitoringLocation.name).all()
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
return [
{
@@ -256,6 +277,13 @@ async def create_location(
form_data = await request.form()
# Compute next sort_order so new locations land at the END of the
# project's list rather than getting interleaved alphabetically.
from sqlalchemy import func
max_sort = db.query(func.max(MonitoringLocation.sort_order))\
.filter_by(project_id=project_id).scalar()
next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0
location = MonitoringLocation(
id=str(uuid.uuid4()),
project_id=project_id,
@@ -265,6 +293,7 @@ async def create_location(
coordinates=form_data.get("coordinates"),
address=form_data.get("address"),
location_metadata=form_data.get("location_metadata"), # JSON string
sort_order=next_sort_order,
)
db.add(location)
@@ -356,6 +385,57 @@ async def delete_location(
return {"success": True, "message": "Location deleted successfully"}
@router.post("/locations/reorder")
async def reorder_locations(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Persist a new sort order for a project's monitoring locations.
Body JSON: { "location_ids": [uuid, uuid, ...] }
The list MUST contain location ids in the desired display order.
Locations not included in the list keep their current sort_order
(useful for the "active locations only — leave removed alone"
drag-and-drop UX).
Updates `sort_order` to the index of each id in the list. Ties
between included and excluded locations fall back to the existing
sort_order.
"""
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body")
ids = payload.get("location_ids") or []
if not isinstance(ids, list) or len(ids) == 0:
raise HTTPException(status_code=400, detail="location_ids must be a non-empty list")
# Fetch only the locations being reordered and validate ownership.
locations = db.query(MonitoringLocation).filter(
MonitoringLocation.project_id == project_id,
MonitoringLocation.id.in_(ids),
).all()
found_ids = {l.id for l in locations}
missing = [i for i in ids if i not in found_ids]
if missing:
raise HTTPException(
status_code=404,
detail=f"Some locations not found in this project: {missing[:3]}",
)
# Apply 0-indexed sort_order matching the operator's chosen order.
by_id = {l.id: l for l in locations}
for idx, loc_id in enumerate(ids):
by_id[loc_id].sort_order = idx
db.commit()
return {"success": True, "reordered": len(ids)}
@router.post("/locations/{location_id}/remove")
async def remove_location(
project_id: str,
+188 -32
View File
@@ -1,7 +1,18 @@
<!-- 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. -->
<!-- Project Locations List — Active + Removed sections.
Card layout:
[drag handle] [location info] [unit pill] [⋮ menu]
(name link, description, address, sessions/events, coords)
Active cards are draggable to reorder. Drop reorders the DOM
immediately and posts the new order to /api/projects/{p}/locations/reorder.
Removed cards are NOT reorderable (their order is historical) but
show a Restore button.
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
pill buttons. Click ⋮ to open; click outside closes.
-->
{% if not active_locations and not removed_locations %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
@@ -12,19 +23,36 @@
</div>
{% else %}
{# ─── Active locations ─── #}
{# ─── Active locations (draggable) ─── #}
{% if active_locations %}
<div class="space-y-3">
<div class="space-y-3" id="active-locations-list" data-project-id="{{ project.id }}">
{% 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="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors location-card"
draggable="true"
data-location-id="{{ item.location.id }}"
data-location-type="{{ item.location.location_type or 'sound' }}"
data-location-name="{{ item.location.name | e }}"
data-coordinates="{{ item.location.coordinates or '' }}"
ondragstart="onLocationDragStart(event)"
ondragover="onLocationDragOver(event)"
ondragleave="onLocationDragLeave(event)"
ondrop="onLocationDrop(event)"
ondragend="onLocationDragEnd(event)">
<div class="flex items-start justify-between gap-3">
<!-- Drag handle + info -->
<div class="flex items-start gap-3 min-w-0 flex-1">
<div class="shrink-0 pt-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing select-none"
title="Drag to reorder">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 4a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 9a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 14a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2z"/>
</svg>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
{{ item.location.name }}
</a>
</div>
{% if item.location.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
{% endif %}
@@ -34,47 +62,71 @@
{% if item.location.coordinates %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
{% endif %}
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
<span><strong class="text-gray-700 dark:text-gray-300">{{ "{:,}".format(item.event_count) }}</strong> event{{ '' if item.event_count == 1 else 's' }}</span>
{% else %}
<span>Sessions: {{ item.session_count }}</span>
{% endif %}
{% if item.assignment and item.assigned_unit %}
<span>Assigned: <a href="/unit/{{ item.assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-mono">{{ item.assigned_unit.id }}</a></span>
{% else %}
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
{% endif %}
</div>
</div>
</div>
<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">
Unassign
</button>
{% else %}
<!-- Right column: small assign/unassign pill + 3-dot menu -->
<div class="flex items-center gap-2 shrink-0">
{% if not item.assignment %}
<!-- Primary action: visible because the unassigned card
is most likely getting clicked on right after creation -->
<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 %}
<!-- Three-dot kebab menu -->
<div class="relative inline-block location-menu-wrapper">
<button onclick="toggleLocationMenu(event, this)"
class="p-1.5 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
title="More actions">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z"/>
</svg>
</button>
<div class="location-menu hidden absolute right-0 mt-1 w-40 z-30 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
{% if item.assignment %}
<button onclick="unassignUnit('{{ item.assignment.id }}'); closeAllLocationMenus()"
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Unassign
</button>
{% endif %}
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
onclick="openEditLocationModal(this)"
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
onclick="openEditLocationModal(this); closeAllLocationMenus()"
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Edit
</button>
<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">
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName); closeAllLocationMenus()"
class="w-full text-left px-3 py-1.5 text-sm text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20"
title="Mark as no longer monitored — preserves 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">
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<button onclick="deleteLocation('{{ item.location.id }}'); closeAllLocationMenus()"
class="w-full text-left px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
title="Permanently delete — only allowed if no history">
Delete
</button>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
<span>Sessions: {{ item.session_count }}</span>
{% if item.assignment and item.assigned_unit %}
<span>Assigned: {{ item.assigned_unit.id }}</span>
{% else %}
<span>No active assignment</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
@@ -135,7 +187,11 @@
</div>
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
<span>{{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }}</span>
{% else %}
<span>Historical sessions: {{ item.session_count }}</span>
{% endif %}
</div>
</div>
{% endfor %}
@@ -144,3 +200,103 @@
{% endif %}
{% endif %}
<!-- Drag-and-drop + menu handlers, scoped to this partial (re-defined
on every htmx swap, which is harmless — function declarations
overwrite). -->
<script>
let _dragSrcCard = null;
function onLocationDragStart(e) {
_dragSrcCard = e.currentTarget;
e.dataTransfer.effectAllowed = 'move';
// Required for Firefox to start the drag.
e.dataTransfer.setData('text/plain', _dragSrcCard.dataset.locationId);
e.currentTarget.classList.add('opacity-40');
}
function onLocationDragOver(e) {
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.currentTarget.classList.add('ring-2', 'ring-seismo-orange');
}
function onLocationDragLeave(e) {
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
}
function onLocationDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
const list = document.getElementById('active-locations-list');
if (!list) return;
// Drop AFTER the target by default; if mouse is in top half, drop BEFORE.
const rect = e.currentTarget.getBoundingClientRect();
const dropBefore = (e.clientY - rect.top) < rect.height / 2;
if (dropBefore) {
list.insertBefore(_dragSrcCard, e.currentTarget);
} else {
list.insertBefore(_dragSrcCard, e.currentTarget.nextSibling);
}
_persistLocationOrder(list);
}
function onLocationDragEnd(e) {
e.currentTarget.classList.remove('opacity-40');
document.querySelectorAll('.location-card').forEach(c => {
c.classList.remove('ring-2', 'ring-seismo-orange');
});
_dragSrcCard = null;
}
async function _persistLocationOrder(list) {
const projectId = list.dataset.projectId;
const ids = Array.from(list.querySelectorAll('.location-card'))
.map(c => c.dataset.locationId);
try {
const r = await fetch(`/api/projects/${projectId}/locations/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location_ids: ids }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({ detail: 'HTTP ' + r.status }));
throw new Error(err.detail || 'reorder failed');
}
} catch (err) {
console.error('Failed to save new order:', err);
if (typeof showToast === 'function') showToast('Failed to save new order: ' + err.message, 'error');
}
}
// ── Three-dot menu ─────────────────────────────────────────────────
function toggleLocationMenu(e, btn) {
e.stopPropagation();
const menu = btn.parentElement.querySelector('.location-menu');
const wasOpen = !menu.classList.contains('hidden');
closeAllLocationMenus();
if (!wasOpen) {
menu.classList.remove('hidden');
}
}
function closeAllLocationMenus() {
document.querySelectorAll('.location-menu').forEach(m => m.classList.add('hidden'));
}
// Close menus on outside click (only register once globally).
if (!window._locationMenuOutsideClickRegistered) {
document.addEventListener('click', (e) => {
if (!e.target.closest('.location-menu-wrapper')) closeAllLocationMenus();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllLocationMenus();
});
window._locationMenuOutsideClickRegistered = true;
}
</script>
@@ -78,22 +78,124 @@
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
<!-- 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 %}
<div class="space-y-3">
{% for action in upcoming_actions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
{% if action.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
<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>
</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 %}
</div>
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
{% endif %}
</div>
</div>
];
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>