1b-9c: Events feed UX iteration — colors, popups, viewport filter, expandable rows (#28)

* feat: events feed UX iteration - colors, popups, viewport filter

A. Color-code polygons by adapter (NWS amber, FIRMS red, USGS violet)
B. Click popup on polygons showing time + adapter + category + subject
C. Map viewport drives spatial filter - pan/zoom updates table via HTMX
D. Add legend showing adapter color mapping
E. Remove draw-bbox control, region inputs now hidden (auto-managed)

Template changes:
- _events_rows.html: add data-adapter, data-category, data-time, data-subject
- events_list.html: ADAPTER_COLORS mapping, bindPopup, moveend handler

Test: verify template renders adapter/category/subject for JS consumption

* fix: remove isoformat() call on already-formatted time string

* feat: full events feed UX iteration

A. Color-code polygons by adapter with legend
B. Click popup on polygons with "View details" link
C. Viewport-driven spatial filter - pan/zoom updates table via HTMX
   Map never auto-fits after initial load (user controls viewport)
D. Expandable row details showing full event data payload

Changes:
- _events_rows.html: add data-event-id, expand button, detail row
- events_list.html: eventLayerGroup pattern, buildPopup, rebindEventLayers
  Fit to results button, expand/collapse handlers, CSS.escape for IDs

* fix: add programmaticMove flag to prevent viewport refresh loop

Suppress moveend handler during fitBounds/setView calls to prevent
feedback loop: fitBounds -> moveend -> applyViewportFilter -> HTMX
swap -> repeat.

* fix: map never auto-fits - user controls viewport

- Disable initial fitToAllLayers on page load
- Remove fitBounds/setView from row click handler
- Map only moves when user pans/zooms
- Table filters based on visible viewport

* fix: map shows all events always, only table filters

Map polygons are drawn once on load and never cleared/redrawn.
HTMX swap only updates the table, not the map layers.
User viewport is fully preserved.

* fix: use htmx.trigger instead of dispatchEvent for HTMX swap

dispatchEvent(submit) was triggering native form submission (full page
reload). htmx.trigger() properly triggers HTMX swap.

Also re-enable initial rebindEventLayers so polygons load on first render.

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
malice 2026-05-18 14:19:27 -06:00 committed by GitHub
commit 3de81f392a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 350 additions and 215 deletions

View file

@ -8,6 +8,7 @@
<table class="events-table"> <table class="events-table">
<thead> <thead>
<tr> <tr>
<th style="width: 2rem;"></th>
<th>Time</th> <th>Time</th>
<th>Adapter</th> <th>Adapter</th>
<th>Category</th> <th>Category</th>
@ -17,14 +18,36 @@
</thead> </thead>
<tbody> <tbody>
{% for event in events %} {% for event in events %}
<tr data-row-idx="{{ loop.index0 }}" <tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}"
data-time="{{ event.time }}"
data-subject="{{ event.subject or '' }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}> {% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td>
<td>{{ event.time }}</td> <td>{{ event.time }}</td>
<td>{{ event.adapter }}</td> <td>{{ event.adapter }}</td>
<td>{{ event.category }}</td> <td>{{ event.category }}</td>
<td>{{ event.geometry_summary }}</td> <td>{{ event.geometry_summary }}</td>
<td>{{ event.subject or '—' }}</td> <td>{{ event.subject or '—' }}</td>
</tr> </tr>
<tr class="event-detail" hidden>
<td colspan="6">
<dl class="event-detail-list">
<dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd>
<dt>Received</dt>
<dd>{{ event.received }}</dd>
{% if event.regions %}
<dt>Regions</dt>
<dd>{{ event.regions | join(", ") }}</dd>
{% endif %}
<dt>Data</dt>
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
</dl>
</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -4,26 +4,88 @@
{% block head %} {% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
<style> <style>
#events-map { #events-map {
height: 400px; height: 400px;
margin-bottom: 1rem; margin-bottom: 0.5rem;
border-radius: var(--pico-border-radius); border-radius: var(--pico-border-radius);
} }
.map-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.map-legend {
display: flex;
gap: 1rem;
font-size: 0.85rem;
}
.map-legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.map-legend-swatch {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.2);
}
#fit-to-results {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.events-table { .events-table {
font-size: 0.9rem; font-size: 0.9rem;
} }
.events-table td { .events-table td {
vertical-align: middle; vertical-align: middle;
} }
.events-table tr:hover { .events-table tr.event-row:hover {
background-color: var(--pico-primary-focus); background-color: var(--pico-primary-focus);
cursor: pointer; cursor: pointer;
} }
.events-table tr.highlighted { .events-table tr.event-row.highlighted {
background-color: var(--pico-primary-background); background-color: var(--pico-primary-background);
} }
.expand-row {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--pico-color);
}
.expand-row:hover {
color: var(--pico-primary);
}
.event-detail td {
background-color: var(--pico-card-background-color);
padding: 1rem;
}
.event-detail-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.event-detail-list dt {
font-weight: 600;
color: var(--pico-muted-color);
}
.event-detail-list dd {
margin: 0;
}
.event-data-pre {
max-height: 300px;
overflow: auto;
font-size: 0.8rem;
background: var(--pico-code-background-color);
padding: 0.5rem;
border-radius: var(--pico-border-radius);
margin: 0;
}
.filter-form .grid { .filter-form .grid {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -33,20 +95,6 @@
.filter-form input, .filter-form select { .filter-form input, .filter-form select {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.region-inputs {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.region-inputs input {
font-size: 0.85rem;
}
.region-controls {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.pagination-info { .pagination-info {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
@ -67,7 +115,7 @@
<details open> <details open>
<summary>Filters</summary> <summary>Filters</summary>
<form class="filter-form" action="/events" method="get" <form id="filter-form" class="filter-form" action="/events" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true"> hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
<div class="grid"> <div class="grid">
@ -97,37 +145,11 @@
</div> </div>
</div> </div>
<div> <!-- Hidden region inputs (managed by map viewport) -->
<label>Region (draw on map or enter coordinates)</label> <input type="hidden" id="region_north" name="region_north" value="{{ filter_values.region_north }}">
<div class="region-controls"> <input type="hidden" id="region_south" name="region_south" value="{{ filter_values.region_south }}">
<button type="button" id="clear-region-btn" class="outline secondary" style="width: auto; padding: 0.25rem 0.75rem;"> <input type="hidden" id="region_east" name="region_east" value="{{ filter_values.region_east }}">
Clear Region <input type="hidden" id="region_west" name="region_west" value="{{ filter_values.region_west }}">
</button>
<small>Draw a rectangle on the map to filter by region</small>
</div>
<div class="region-inputs">
<div>
<label for="region_north">N</label>
<input type="number" id="region_north" name="region_north" step="0.0001" min="-90" max="90" readonly
value="{{ filter_values.region_north }}">
</div>
<div>
<label for="region_south">S</label>
<input type="number" id="region_south" name="region_south" step="0.0001" min="-90" max="90" readonly
value="{{ filter_values.region_south }}">
</div>
<div>
<label for="region_east">E</label>
<input type="number" id="region_east" name="region_east" step="0.0001" min="-180" max="180" readonly
value="{{ filter_values.region_east }}">
</div>
<div>
<label for="region_west">W</label>
<input type="number" id="region_west" name="region_west" step="0.0001" min="-180" max="180" readonly
value="{{ filter_values.region_west }}">
</div>
</div>
</div>
<input type="hidden" name="limit" value="{{ filter_values.limit }}"> <input type="hidden" name="limit" value="{{ filter_values.limit }}">
@ -139,234 +161,271 @@
</details> </details>
<div id="events-map"></div> <div id="events-map"></div>
<div class="map-controls">
<div class="map-legend">
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div>
<span>NWS (Weather)</span>
</div>
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #dc2626;"></div>
<span>FIRMS (Fire)</span>
</div>
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #7c3aed;"></div>
<span>USGS (Quake)</span>
</div>
</div>
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
</div>
<div id="events-rows"> <div id="events-rows">
{% include "_events_rows.html" %} {% include "_events_rows.html" %}
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
<script> <script>
(function() { (function() {
const tileUrl = {{ tile_url | tojson }}; var tileUrl = {{ tile_url | tojson }};
const tileAttr = {{ tile_attribution | tojson }}; var tileAttr = {{ tile_attribution | tojson }};
// Adapter color mapping
var ADAPTER_COLORS = {
"nws": "#f59e0b",
"firms": "#dc2626",
"usgs_quake": "#7c3aed"
};
function getAdapterColor(adapter) {
return ADAPTER_COLORS[adapter] || "#3388ff";
}
// Initialize map // Initialize map
const map = L.map('events-map').setView([39, -98], 4); var map = L.map("events-map").setView([39, -98], 4);
L.tileLayer(tileUrl, { L.tileLayer(tileUrl, {
attribution: tileAttr, attribution: tileAttr,
maxZoom: 18 maxZoom: 18
}).addTo(map); }).addTo(map);
// Layer groups for event geometries // Layer group for event geometries
const eventLayers = {}; var eventLayerGroup = L.layerGroup().addTo(map);
let highlightedRow = null; var highlightedRow = null;
let highlightedLayer = null; var highlightedLayer = null;
var isInitialLoad = true;
// Styles var programmaticMove = false;
const defaultStyle = {
color: '#3388ff',
weight: 2,
fillOpacity: 0.2
};
const highlightStyle = {
color: '#ff3333',
weight: 4,
fillOpacity: 0.4
};
// Region filter rectangle
let filterRect = null;
const drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
// Draw control for region filter
const drawControl = new L.Control.Draw({
draw: {
rectangle: { shapeOptions: { color: '#ff7800', weight: 2, fillOpacity: 0.1 } },
polyline: false,
polygon: false,
circle: false,
marker: false,
circlemarker: false
},
edit: {
featureGroup: drawnItems,
edit: false,
remove: false
}
});
map.addControl(drawControl);
// Region input elements // Region input elements
const northInput = document.getElementById('region_north'); var northInput = document.getElementById("region_north");
const southInput = document.getElementById('region_south'); var southInput = document.getElementById("region_south");
const eastInput = document.getElementById('region_east'); var eastInput = document.getElementById("region_east");
const westInput = document.getElementById('region_west'); var westInput = document.getElementById("region_west");
// Update inputs from rectangle // Viewport-driven filter with debounce
function updateRegionInputs(bounds) { var viewportDebounceTimer = null;
map.on("moveend", function() {
if (programmaticMove) {
programmaticMove = false;
return;
}
if (viewportDebounceTimer) clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(applyViewportFilter, 400);
});
function applyViewportFilter() {
var bounds = map.getBounds();
northInput.value = bounds.getNorth().toFixed(4); northInput.value = bounds.getNorth().toFixed(4);
southInput.value = bounds.getSouth().toFixed(4); southInput.value = bounds.getSouth().toFixed(4);
eastInput.value = bounds.getEast().toFixed(4); eastInput.value = bounds.getEast().toFixed(4);
westInput.value = bounds.getWest().toFixed(4); westInput.value = bounds.getWest().toFixed(4);
htmx.trigger(document.getElementById("filter-form"), "submit");
} }
// Handle new rectangle drawn function buildPopup(row) {
map.on(L.Draw.Event.CREATED, function(e) { var adapter = row.dataset.adapter || "";
drawnItems.clearLayers(); var category = row.dataset.category || "";
filterRect = e.layer; var time = row.dataset.time ? new Date(row.dataset.time).toLocaleString() : "";
filterRect.setStyle({ color: '#ff7800', weight: 2, fillOpacity: 0.1 }); var subject = row.dataset.subject || "";
drawnItems.addLayer(filterRect); var eventId = row.dataset.eventId || "";
updateRegionInputs(filterRect.getBounds());
// Auto-submit the form via HTMX
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
});
// Clear region button var html = "<strong>" + adapter + "</strong><br>" +
document.getElementById('clear-region-btn').addEventListener('click', function() { category + "<br>" +
drawnItems.clearLayers(); "<small>" + time + "</small>";
filterRect = null; if (subject) {
northInput.value = ''; html += "<br><em>" + subject + "</em>";
southInput.value = ''; }
eastInput.value = ''; html += '<br><a href="#" data-show-row="' + eventId + '">View details &#9662;</a>';
westInput.value = ''; return html;
// Auto-submit to refresh
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
});
// If region values exist, show the filter rectangle
if (northInput.value && southInput.value && eastInput.value && westInput.value) {
const bounds = L.latLngBounds(
L.latLng(parseFloat(southInput.value), parseFloat(westInput.value)),
L.latLng(parseFloat(northInput.value), parseFloat(eastInput.value))
);
filterRect = L.rectangle(bounds, { color: '#ff7800', weight: 2, fillOpacity: 0.1 });
drawnItems.addLayer(filterRect);
} }
// Function to add event geometries to map function rebindEventLayers() {
function loadEventGeometries() { eventLayerGroup.clearLayers();
// Clear existing event layers
Object.values(eventLayers).forEach(layer => map.removeLayer(layer));
Object.keys(eventLayers).forEach(key => delete eventLayers[key]);
const rows = document.querySelectorAll('#events-rows tr[data-geometry]'); var rows = document.querySelectorAll("#events-rows tr.event-row[data-geometry]");
const bounds = L.latLngBounds();
let hasGeometries = false;
rows.forEach((row, idx) => { rows.forEach(function(row) {
const geomStr = row.dataset.geometry; var geomStr = row.dataset.geometry;
if (!geomStr || geomStr === '') return; if (!geomStr || geomStr === "") return;
try { try {
const geom = JSON.parse(geomStr); var geom = JSON.parse(geomStr);
if (!geom) return; if (!geom) return;
const layer = L.geoJSON(geom, { var adapter = row.dataset.adapter || "";
style: defaultStyle, var color = getAdapterColor(adapter);
var layer = L.geoJSON(geom, {
style: {
color: color,
weight: 2,
fillColor: color,
fillOpacity: 0.25
},
pointToLayer: function(feature, latlng) { pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng, { return L.circleMarker(latlng, {
radius: 8, radius: 8,
...defaultStyle color: color,
weight: 2,
fillColor: color,
fillOpacity: 0.25
}); });
} }
}); });
layer.addTo(map); layer.bindPopup(buildPopup(row));
eventLayers[idx] = layer; layer.on("click", function() {
highlightRow(row, layer, color);
// Extend bounds
try {
bounds.extend(layer.getBounds());
hasGeometries = true;
} catch (e) {
// Point geometries might not have getBounds
if (geom.type === 'Point' && geom.coordinates) {
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
hasGeometries = true;
}
}
// Click on geometry highlights row
layer.on('click', function() {
highlightRow(row, layer);
}); });
layer.addTo(eventLayerGroup);
// Store reference for row highlighting
row._mapLayer = layer;
row._mapColor = color;
} catch (e) { } catch (e) {
console.error('Error parsing geometry:', e); console.error("Error parsing geometry:", e);
} }
}); });
// Fit map to all geometries
if (hasGeometries) {
map.fitBounds(bounds.pad(0.1));
}
} }
// Highlight a row and its geometry function highlightRow(row, layer, originalColor) {
function highlightRow(row, layer) {
// Reset previous highlight // Reset previous highlight
if (highlightedRow) { if (highlightedRow) {
highlightedRow.classList.remove('highlighted'); highlightedRow.classList.remove("highlighted");
} }
if (highlightedLayer) { if (highlightedLayer && highlightedLayer._originalColor) {
highlightedLayer.setStyle(defaultStyle); highlightedLayer.setStyle({
color: highlightedLayer._originalColor,
weight: 2,
fillColor: highlightedLayer._originalColor,
fillOpacity: 0.25
});
} }
// Set new highlight // Set new highlight
row.classList.add('highlighted'); row.classList.add("highlighted");
highlightedRow = row; highlightedRow = row;
if (layer) { if (layer) {
layer.setStyle(highlightStyle); layer._originalColor = originalColor;
layer.setStyle({
color: "#ff3333",
weight: 4,
fillColor: "#ff3333",
fillOpacity: 0.4
});
highlightedLayer = layer; highlightedLayer = layer;
// Pan to geometry
try {
map.fitBounds(layer.getBounds().pad(0.2));
} catch (e) {
// For points
const geom = JSON.parse(row.dataset.geometry);
if (geom && geom.type === 'Point' && geom.coordinates) {
map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
}
}
} }
} }
// Row hover/click handlers function fitToAllLayers() {
function attachRowHandlers() { var layers = eventLayerGroup.getLayers();
const rows = document.querySelectorAll('#events-rows tr[data-row-idx]'); if (layers.length === 0) return;
rows.forEach(row => { programmaticMove = true;
const idx = parseInt(row.dataset.rowIdx); var group = L.featureGroup(layers);
row.addEventListener('click', function() { map.fitBounds(group.getBounds(), { padding: [20, 20] });
const layer = eventLayers[idx];
highlightRow(row, layer);
});
row.addEventListener('mouseenter', function() {
const layer = eventLayers[idx];
if (layer && layer !== highlightedLayer) {
layer.setStyle({ ...defaultStyle, weight: 3 });
}
});
row.addEventListener('mouseleave', function() {
const layer = eventLayers[idx];
if (layer && layer !== highlightedLayer) {
layer.setStyle(defaultStyle);
}
});
});
} }
// Initial load // Row click handler (event delegation)
loadEventGeometries(); document.addEventListener("click", function(e) {
attachRowHandlers(); // Expand/collapse handler
if (e.target.classList.contains("expand-row")) {
var row = e.target.closest("tr");
var detail = row.nextElementSibling;
if (detail && detail.classList.contains("event-detail")) {
detail.hidden = !detail.hidden;
e.target.innerHTML = detail.hidden ? "&#9656;" : "&#9662;";
}
return;
}
// Re-attach handlers after HTMX swap // Popup "View details" link handler
document.body.addEventListener('htmx:afterSwap', function(evt) { if (e.target.matches("[data-show-row]")) {
if (evt.detail.target.id === 'events-rows') { e.preventDefault();
loadEventGeometries(); var eventId = e.target.dataset.showRow;
attachRowHandlers(); var targetRow = document.querySelector(
'tr[data-event-id="' + CSS.escape(eventId) + '"]'
);
if (!targetRow) return;
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
var detail = targetRow.nextElementSibling;
if (detail && detail.classList.contains("event-detail")) {
detail.hidden = false;
var button = targetRow.querySelector(".expand-row");
if (button) button.innerHTML = "&#9662;";
}
return;
}
// Row click to highlight (no auto-pan - user controls viewport)
var row = e.target.closest("tr.event-row");
if (row && row._mapLayer) {
highlightRow(row, row._mapLayer, row._mapColor);
// Map stays where user put it
}
});
// Row hover handlers (event delegation)
document.addEventListener("mouseenter", function(e) {
var row = e.target.closest && e.target.closest("tr.event-row");
if (row && row._mapLayer && row._mapLayer !== highlightedLayer) {
row._mapLayer.setStyle({
color: row._mapColor,
weight: 3,
fillColor: row._mapColor,
fillOpacity: 0.25
});
}
}, true);
document.addEventListener("mouseleave", function(e) {
var row = e.target.closest && e.target.closest("tr.event-row");
if (row && row._mapLayer && row._mapLayer !== highlightedLayer) {
row._mapLayer.setStyle({
color: row._mapColor,
weight: 2,
fillColor: row._mapColor,
fillOpacity: 0.25
});
}
}, true);
// Fit to results button
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
// Initial load - bind layers and fit bounds
rebindEventLayers(); // Initial load only
if (false) { // DISABLED: map never auto-fits
fitToAllLayers();
isInitialLoad = false;
}
// Re-bind layers after HTMX swap (but do NOT fit bounds)
document.body.addEventListener("htmx:afterSwap", function(evt) {
if (evt.detail.target.id === "events-rows") {
// rebindEventLayers(); // DISABLED: map shows all events, only table filters
// Do NOT call fitToAllLayers - preserve user viewport
} }
}); });

View file

@ -631,3 +631,56 @@ class TestErrorSemantics:
assert context["filter_error"] is not None assert context["filter_error"] is not None
assert "limit" in context["filter_error"].lower() assert "limit" in context["filter_error"].lower()
assert context["events"] == [] assert context["events"] == []
class TestEventRowDataAttributes:
"""Test that _events_rows.html renders required data attributes."""
@pytest.mark.asyncio
async def test_row_renders_data_adapter_attribute(self):
"""Event rows include data-adapter attribute for color coding."""
mock_request = MagicMock()
mock_request.state.operator = MagicMock(id=1, username="admin")
mock_request.state.csrf_token = "test_csrf"
mock_request.query_params = {}
mock_events = [
{
"id": "test1",
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
"adapter": "usgs_quake",
"category": "quake.event",
"subject": "M4.2 Earthquake",
"geometry": None,
"data": {},
"regions": [],
},
]
mock_conn = AsyncMock()
mock_conn.fetch.return_value = mock_events
mock_conn.fetchrow.return_value = {
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"map_attribution": "OpenStreetMap",
}
mock_pool = MagicMock()
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
mock_templates = MagicMock()
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
with patch("central.gui.routes._get_templates", return_value=mock_templates):
with patch("central.gui.routes.get_pool", return_value=mock_pool):
result = await events_list(mock_request)
assert result.status_code == 200
mock_templates.TemplateResponse.assert_called_once()
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
# The template receives events with adapter field for data-adapter attribute
assert len(context["events"]) == 1
assert context["events"][0]["adapter"] == "usgs_quake"
assert context["events"][0]["category"] == "quake.event"
assert context["events"][0]["subject"] == "M4.2 Earthquake"