update main to v0.10.0 #48

Merged
serversdown merged 32 commits from feature/sfm-integration into main 2026-05-14 16:56:43 -04:00
2 changed files with 310 additions and 137 deletions
Showing only changes of commit 18fd0472a5 - Show all commits
+101
View File
@@ -4,11 +4,29 @@ from sqlalchemy import desc
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any from typing import List, Dict, Any
import os
import logging
import httpx
from backend.database import get_db from backend.database import get_db
from backend.models import UnitHistory, Emitter, RosterUnit from backend.models import UnitHistory, Emitter, RosterUnit
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["activity"]) 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") 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, "hours": hours,
"time_threshold": time_threshold.isoformat() "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",
}
+202 -130
View File
@@ -29,7 +29,55 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <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="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')"> <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> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
@@ -121,74 +169,6 @@
</div> </div>
</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> </div>
<!-- Dashboard Filters --> <!-- Dashboard Filters -->
@@ -269,6 +249,36 @@
</div> </div>
</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 --> <!-- 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="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')"> <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); 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> </style>
@@ -654,7 +675,8 @@ function toggleCard(cardName) {
// Restore card states from localStorage on page load // Restore card states from localStorage on page load
function restoreCardStates() { function restoreCardStates() {
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}'); 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 => { cardNames.forEach(cardName => {
const content = document.getElementById(`${cardName}-content`); const content = document.getElementById(`${cardName}-content`);
@@ -839,89 +861,139 @@ async function loadRecentPhotos() {
loadRecentPhotos(); loadRecentPhotos();
setInterval(loadRecentPhotos, 30000); setInterval(loadRecentPhotos, 30000);
// Load and display recent call-ins // Load and display recent call-ins.
let showingAllCallins = false; // Source: SFM events (forwarded by series3-watcher from Blastware ACH).
const DEFAULT_CALLINS_DISPLAY = 5; // Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
// is being phased out but kept as a backup.
async function loadRecentCallins() { async function loadRecentCallins() {
const callinsList = document.getElementById('recent-callins-list');
try { try {
const response = await fetch('/api/recent-callins?hours=6'); const response = await fetch('/api/recent-event-callins?limit=10');
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to load recent call-ins'); throw new Error('Failed to load recent call-ins');
} }
const data = await response.json(); 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) { if (!data.call_ins || data.call_ins.length === 0) {
// Determine how many to show callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length); return;
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;
} }
// 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 += ` 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 justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
<div class="flex items-center space-x-3"> <div class="flex items-center gap-2 min-w-0 flex-1">
<span class="w-2 h-2 rounded-full ${statusClass}"></span> <span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
<div> <div class="min-w-0 flex-1">
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange"> <div class="flex items-center gap-2">
${callin.unit_id} ${unitLink}
</a> ${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''} <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, '&quot;')}">${subtitle}</p>` : ''}
</div> </div>
</div> </div>
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span> <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>`; </div>`;
}); });
html += '</div>';
callinsList.innerHTML = html; 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');
}
} catch (error) { } catch (error) {
console.error('Error loading recent call-ins:', 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 // Load recent call-ins on page load and refresh every 30 seconds.
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
loadRecentCallins(); loadRecentCallins();
setInterval(loadRecentCallins, 30000); 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> </script>
{% endblock %} {% endblock %}