A client-side dashboard for San Antonio Fire Department (SAFD) and EMS active incident data, pulled live from the city's public feeds. React + Vite + Tailwind, no backend server code — just a same-origin proxy for the two source pages.
AlamoWatch polls San Antonio's live active-incident pages every 30 seconds, parses them into a clean data model, stores history locally in IndexedDB, geocodes incident addresses, and shows the result as a live map, an active-calls table, a searchable history list, and an analytics dashboard. There is no synthetic or sample data anywhere in the app: if a fetch fails, the UI shows an honest error or stale state instead of fabricating incidents.
Analytics — problem-type, ZIP, TAC, and hourly breakdowns over stored history:
Incident history — searchable, filterable, sortable, with CSV export:
Incident details — per-call details with a geocoded location map and nearby history:
Two ASP.NET pages maintained by the City of San Antonio, each rendering an HTML table
(id="GridView2") of currently active calls:
- Fire:
https://webapp3.sanantonio.gov/activefire/Fire.aspx - EMS:
https://webapp3.sanantonio.gov/activefire/EMS.aspx
Columns: # of Units | Response Time | Problem Type | Addrs (Google Maps link) | Cross Street | Location Type | ZipCode | TAC. Response Time is America/Chicago local time with no timezone
offset in the string (e.g. 8/1/2026 4:04:22 AM); AlamoWatch converts it to a real ISO 8601
timestamp. The address cell contains an <a href="http://maps.google.com/maps?q=..."> link with
no coordinates — just a Google Maps search query — so real coordinates come from geocoding, not
from the source page.
Real captured HTML fixtures are checked in at test/fixtures/fire.aspx.html and
test/fixtures/ems.aspx.html.
webapp3.sanantonio.gov does not send CORS headers, so the browser can't fetch it cross-origin
directly. AlamoWatch never routes through a third-party CORS-proxy service. Instead, the client
always requests same-origin relative paths, and each hosting environment proxies them
server-side:
| Path | Proxies to |
|---|---|
/sa/fire |
https://webapp3.sanantonio.gov/activefire/Fire.aspx |
/sa/ems |
https://webapp3.sanantonio.gov/activefire/EMS.aspx |
- Dev:
vite.config.jsserver.proxyrewrites/sa/fireand/sa/ems. - Vercel:
vercel.jsonrewrites. - Netlify:
netlify.toml200-status redirects (a proxy, not a client-side redirect).
See DEPLOYMENT.md for full details, including a Docker/nginx note.
- IndexedDB (
alamowatchdatabase, via theidblibrary) stores every incident ever seen (incidentsstore), a geocode result cache (geocachestore keyed by normalizedaddress|zip), and small metadata (metastore). Incidents are merged by their stable id: first sighting setsfirstSeenAt, every subsequent sighting bumpslastSeenAt. - Geocoding resolves lat/lng for the map in this order: IndexedDB cache → US Census
onelineaddressAPI → Nominatim (OpenStreetMap, rate-limited to 1 request/sec) → a ZIP-code centroid fallback (flagged as approximate). A miss on all three is left aslat: null, lng: null— coordinates are never guessed or randomized.
The dashboard is honest about feed health instead of always looking "live":
loading— first fetch in flight.live— last fetch succeeded within the last 90 seconds.stale— we have prior data, but the last successful fetch was more than 90 seconds ago.error— we have never successfully fetched data.
Polling pauses automatically when the tab is hidden and resumes (with an immediate refresh) when it becomes visible again; manual refresh and pause/resume controls are also available.
.claude/skills/saf-data/ packages the data-acquisition logic (feed URLs, table schema, the
Incident model, the geocoding strategy) independently of this React app, plus a standalone Node
script, scripts/fetch-incidents.mjs, that fetches and parses the feeds with zero project
dependencies:
node .claude/skills/saf-data/scripts/fetch-incidents.mjs --type=fire --prettySee .claude/skills/saf-data/SKILL.md for details.
npm install
npm run dev # http://localhost:3000, with the /sa/* proxy active
npm run build # production build to dist/
npm run preview # preview the production build
npm run test # parses test/fixtures/*.html and asserts the model is saneThere is no ESLint or Prettier configuration in this repo — formatting/linting is not enforced by tooling here.
src/
lib/
utils.js cn() class helper, escapeHtml()
feed.js getFeedUrl('Fire' | 'EMS') -> '/sa/fire' | '/sa/ems'
services/
saDataSource.js parseIncidents(html, type), fetchActiveIncidents()
geocoder.js geocode(), geocodeIncidents() — Census -> Nominatim -> ZIP fallback
zipCentroids.js real lat/lng centroids for San Antonio ZIP codes
incidentStore.js IndexedDB layer (upsert, query, stats, migration from legacy localStorage)
hooks/
useIncidents.js polling, pause/resume, status, background geocoding
components/
MapView.jsx Leaflet map, fire=red / EMS=blue markers, real coords only
ActiveIncidentsTable.jsx current active calls
IncidentList.jsx full stored history: search/filter/sort/CSV export
AnalyticsDashboard.jsx Recharts breakdowns over stored history
IncidentDialog.jsx per-incident details/location/history tabs
Notification*.jsx new-incident alerts (diffed by id, not by timestamp)
Data flow: useIncidents polls saDataSource.fetchActiveIncidents() every 30s → results are
upserted into IndexedDB via incidentStore → the active set is geocoded in the background via
geocoder → components read from hook state (active) and incidentStore (full history).
Incident {
id: string // stable hash of type|address|problemType|responseTimeRaw
type: 'Fire' | 'EMS'
units: number
responseTime: string // ISO 8601
responseTimeRaw: string // original source string
problemType: string
address: string
crossStreet: string
locationType: string
zip: string
tac: string
lat: number | null
lng: number | null
firstSeenAt: string
lastSeenAt: string
}See DEPLOYMENT.md for Vercel, Netlify, and Docker/nginx instructions. No environment variables
are required — the proxy config is static and there are no API keys.
All incident data is fetched from the City of San Antonio's public active-incident pages and is displayed as-is. AlamoWatch is an unofficial, best-effort visualization for situational awareness and is not affiliated with or endorsed by the City of San Antonio, SAFD, or San Antonio EMS. Do not rely on it for emergency response. Geocoded map positions are approximate.
MIT — see LICENSE.



