feat(dashboard): reorder top row, move schedule below map, source call-ins from SFM
- Top row left→right: Recent Alerts | Recent Call-Ins (2 cols) | Fleet Summary - Today's Schedule becomes a horizontal collapsible card below Fleet Map. Collapsed by default; auto-expands when pending actions are detected in the rendered partial; manual toggle sticks via localStorage. - New /api/recent-event-callins proxies SFM /db/events and bulk-joins each serial against RosterUnit for in-roster annotation. Phases the heartbeat-derived /api/recent-callins out of the UI while keeping it as a backup endpoint for now. - Call-ins card renders a dense 2-column grid (last 10 events) showing PVS, sensor_location, false-trigger badge, event timestamp, and links to the unit page when rostered. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,29 @@ from sqlalchemy import desc
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Dict, Any
|
||||
import os
|
||||
import logging
|
||||
import httpx
|
||||
from backend.database import get_db
|
||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["activity"])
|
||||
|
||||
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||
|
||||
|
||||
def _humanize_age(seconds: float) -> str:
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
if seconds < 3600:
|
||||
return f"{int(seconds / 60)}m ago"
|
||||
if seconds < 86400:
|
||||
hrs = seconds / 3600
|
||||
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
|
||||
return f"{int(seconds / 86400)}d ago"
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
@@ -144,3 +162,86 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
||||
"hours": hours,
|
||||
"time_threshold": time_threshold.isoformat()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recent-event-callins")
|
||||
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Recent unit call-ins derived from SFM event forwards.
|
||||
|
||||
Architecture context: the live ACH replacement is on hold, so call-homes
|
||||
arrive as Blastware ACH event files forwarded by series3-watcher and
|
||||
landed in the SFM events store. One event ≈ one call-in. This is the
|
||||
forward-looking source of "recent call-ins" that will eventually replace
|
||||
the heartbeat-based /recent-callins endpoint entirely.
|
||||
|
||||
Each row represents one event; multiple consecutive events from the same
|
||||
serial are intentionally NOT collapsed — each one is a distinct call-home.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{SFM_BASE_URL}/db/events",
|
||||
params={"limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
|
||||
return {"call_ins": [], "total": 0, "error": str(e)}
|
||||
|
||||
events = payload.get("events", []) or []
|
||||
|
||||
# Bulk-resolve serials → roster (single query, no N+1)
|
||||
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
|
||||
roster_map: Dict[str, RosterUnit] = {}
|
||||
if serials:
|
||||
roster_map = {
|
||||
r.id: r
|
||||
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
call_ins: List[Dict[str, Any]] = []
|
||||
|
||||
for ev in events:
|
||||
serial = ev.get("serial")
|
||||
if not serial:
|
||||
continue
|
||||
|
||||
roster = roster_map.get(serial)
|
||||
|
||||
# created_at = when SFM received the forward. Falls back to the event
|
||||
# timestamp if the SFM payload didn't carry created_at (older rows).
|
||||
created_at_str = ev.get("created_at") or ev.get("timestamp")
|
||||
time_ago = "—"
|
||||
if created_at_str:
|
||||
try:
|
||||
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
time_ago = _humanize_age((now - ts).total_seconds())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
call_ins.append({
|
||||
"unit_id": serial,
|
||||
"serial": serial,
|
||||
"event_id": ev.get("id"),
|
||||
"event_timestamp": ev.get("timestamp"),
|
||||
"created_at": ev.get("created_at"),
|
||||
"time_ago": time_ago,
|
||||
"peak_vector_sum": ev.get("peak_vector_sum"),
|
||||
"false_trigger": bool(ev.get("false_trigger")),
|
||||
"sensor_location": ev.get("sensor_location") or "",
|
||||
"project": ev.get("project") or "",
|
||||
"device_type": roster.device_type if roster else "seismograph",
|
||||
"in_roster": roster is not None,
|
||||
"note": (roster.note if roster else "") or "",
|
||||
})
|
||||
|
||||
return {
|
||||
"call_ins": call_ins,
|
||||
"total": len(call_ins),
|
||||
"source": "sfm-events",
|
||||
}
|
||||
|
||||
+209
-137
@@ -29,7 +29,55 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
|
||||
<!-- Fleet Summary Card -->
|
||||
<!-- Recent Alerts Card (col 1) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Call-Ins Card (cols 2-3, double-wide) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 md:col-span-2 lg:col-span-2" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline">from SFM event forwards</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<a href="/sfm" class="block mt-3 text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
View all events →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fleet Summary Card (col 4) -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
@@ -121,74 +169,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Alerts Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recently Called In Units Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
Show all recent call-ins
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Scheduled Actions Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-orange" 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">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" id="todays-actions-content"
|
||||
hx-get="/dashboard/todays-actions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Filters -->
|
||||
@@ -269,6 +249,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Schedule — horizontal collapsible card.
|
||||
Default collapsed; auto-expands when an upcoming action is detected
|
||||
(pending + scheduled within the next 4h). JS reads
|
||||
data-has-upcoming on the inner partial after htmx swap. -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-8" id="todays-actions-card">
|
||||
<div class="flex items-center justify-between cursor-pointer" onclick="toggleTodaysSchedule()">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-seismo-orange" 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"></path>
|
||||
</svg>
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||
<span id="todays-actions-badge"
|
||||
class="hidden text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
|
||||
</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform collapsed" id="todays-actions-chevron"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content collapsed mt-4" id="todays-actions-content"
|
||||
hx-get="/dashboard/todays-actions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-swap="onTodaysActionsSwap(this)">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Photos Section -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||
@@ -364,6 +374,17 @@
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Today's Schedule — horizontal collapsible at all breakpoints. */
|
||||
#todays-actions-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
#todays-actions-chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
#todays-actions-chevron {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -654,7 +675,8 @@ function toggleCard(cardName) {
|
||||
// Restore card states from localStorage on page load
|
||||
function restoreCardStates() {
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
||||
// Note: todays-actions has its own collapse handling (see toggleTodaysSchedule / onTodaysActionsSwap)
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||
|
||||
cardNames.forEach(cardName => {
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
@@ -839,89 +861,139 @@ async function loadRecentPhotos() {
|
||||
loadRecentPhotos();
|
||||
setInterval(loadRecentPhotos, 30000);
|
||||
|
||||
// Load and display recent call-ins
|
||||
let showingAllCallins = false;
|
||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||
|
||||
// Load and display recent call-ins.
|
||||
// Source: SFM events (forwarded by series3-watcher from Blastware ACH).
|
||||
// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
|
||||
// is being phased out but kept as a backup.
|
||||
async function loadRecentCallins() {
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
try {
|
||||
const response = await fetch('/api/recent-callins?hours=6');
|
||||
const response = await fetch('/api/recent-event-callins?limit=10');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent call-ins');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
|
||||
if (data.call_ins && data.call_ins.length > 0) {
|
||||
// Determine how many to show
|
||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||
|
||||
// Build HTML for call-ins list
|
||||
let html = '';
|
||||
callinsToDisplay.forEach(callin => {
|
||||
// Status color
|
||||
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
// Build location/note line
|
||||
let subtitle = '';
|
||||
if (callin.location) {
|
||||
subtitle = callin.location;
|
||||
} else if (callin.note) {
|
||||
subtitle = callin.note;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||
<div>
|
||||
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||
${callin.unit_id}
|
||||
</a>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
callinsList.innerHTML = html;
|
||||
|
||||
// Show/hide the "Show all" button
|
||||
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||
showAllButton.classList.remove('hidden');
|
||||
showAllButton.textContent = showingAllCallins
|
||||
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||
: `Show all (${data.call_ins.length})`;
|
||||
} else {
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||
showAllButton.classList.add('hidden');
|
||||
if (!data.call_ins || data.call_ins.length === 0) {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Two-column dense grid on lg+, single column below.
|
||||
let html = '<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-1">';
|
||||
data.call_ins.forEach(c => {
|
||||
const isFalse = c.false_trigger;
|
||||
const pvs = c.peak_vector_sum;
|
||||
const pvsStr = (pvs !== null && pvs !== undefined)
|
||||
? Number(pvs).toFixed(3) + ' in/s'
|
||||
: '—';
|
||||
|
||||
// Subtitle: prefer sensor_location, fallback to project.
|
||||
const subtitle = c.sensor_location || c.project || '';
|
||||
|
||||
// Status dot: amber for false trigger, green for real event,
|
||||
// gray if unit not in roster.
|
||||
const dotClass = !c.in_roster
|
||||
? 'bg-gray-400'
|
||||
: (isFalse ? 'bg-amber-400' : 'bg-green-500');
|
||||
|
||||
// Format event timestamp short (e.g. "05-13 05:00").
|
||||
let tsShort = '';
|
||||
if (c.event_timestamp) {
|
||||
const ts = c.event_timestamp.replace('T', ' ');
|
||||
// "2026-05-13 05:00:13" → "05-13 05:00"
|
||||
tsShort = ts.length >= 16 ? ts.slice(5, 16) : ts;
|
||||
}
|
||||
|
||||
const unitLink = c.in_roster
|
||||
? `<a href="/unit/${c.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">${c.unit_id}</a>`
|
||||
: `<span class="font-medium text-gray-500 dark:text-gray-400" title="Not in roster">${c.unit_id}</span>`;
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
${unitLink}
|
||||
${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">${pvsStr}</span>
|
||||
</div>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${subtitle.replace(/"/g, '"')}">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right ml-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 block">${c.time_ago}</span>
|
||||
${tsShort ? `<span class="text-[10px] text-gray-400 dark:text-gray-500 block font-mono">${tsShort}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
callinsList.innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading recent call-ins:', error);
|
||||
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
callinsList.innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle show all/show fewer
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
showAllButton.addEventListener('click', function() {
|
||||
showingAllCallins = !showingAllCallins;
|
||||
loadRecentCallins();
|
||||
});
|
||||
});
|
||||
|
||||
// Load recent call-ins on page load and refresh every 30 seconds
|
||||
// Load recent call-ins on page load and refresh every 30 seconds.
|
||||
loadRecentCallins();
|
||||
setInterval(loadRecentCallins, 30000);
|
||||
|
||||
// ===== Today's Schedule horizontal card =====
|
||||
function toggleTodaysSchedule() {
|
||||
const content = document.getElementById('todays-actions-content');
|
||||
const chevron = document.getElementById('todays-actions-chevron');
|
||||
if (!content || !chevron) return;
|
||||
const isCollapsed = content.classList.toggle('collapsed');
|
||||
chevron.classList.toggle('collapsed', isCollapsed);
|
||||
// Remember the user's explicit choice so we don't fight them on the next
|
||||
// 30s htmx refresh.
|
||||
localStorage.setItem('todaysScheduleUserToggled', '1');
|
||||
localStorage.setItem('todaysScheduleCollapsed', isCollapsed ? '1' : '0');
|
||||
}
|
||||
|
||||
function onTodaysActionsSwap(el) {
|
||||
// Read pending/total counts from the rendered partial to drive
|
||||
// auto-expand + the header badge.
|
||||
const badge = document.getElementById('todays-actions-badge');
|
||||
const content = document.getElementById('todays-actions-content');
|
||||
const chevron = document.getElementById('todays-actions-chevron');
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Count yellow status indicators in the rendered partial as a proxy for
|
||||
// "pending action present today".
|
||||
const pendingDots = el.querySelectorAll('.bg-yellow-400').length;
|
||||
const pendingTimes = el.querySelectorAll('.text-yellow-600').length;
|
||||
const hasPending = pendingDots > 0 || pendingTimes > 0;
|
||||
|
||||
if (badge) {
|
||||
if (hasPending) {
|
||||
const n = Math.max(pendingDots, pendingTimes);
|
||||
badge.textContent = `${n} pending today`;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-expand only if the user hasn't manually toggled this session AND
|
||||
// there's something pending. Once the user collapses/expands manually,
|
||||
// their preference sticks.
|
||||
const userToggled = localStorage.getItem('todaysScheduleUserToggled') === '1';
|
||||
if (!userToggled && hasPending) {
|
||||
content.classList.remove('collapsed');
|
||||
chevron.classList.remove('collapsed');
|
||||
} else if (!userToggled && !hasPending) {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
} else if (userToggled) {
|
||||
const stored = localStorage.getItem('todaysScheduleCollapsed') === '1';
|
||||
content.classList.toggle('collapsed', stored);
|
||||
chevron.classList.toggle('collapsed', stored);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user