Skip to content

Repository files navigation

AlamoWatch

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 — live incident map with geocoded active calls

What it is

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.

Screenshots

Analytics — problem-type, ZIP, TAC, and hourly breakdowns over stored history:

Analytics dashboard

Incident history — searchable, filterable, sortable, with CSV export:

Incident history table

Incident details — per-call details with a geocoded location map and nearby history:

Incident details dialog

Data sources

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.

Same-origin proxy (no third-party CORS proxy)

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.js server.proxy rewrites /sa/fire and /sa/ems.
  • Vercel: vercel.json rewrites.
  • Netlify: netlify.toml 200-status redirects (a proxy, not a client-side redirect).

See DEPLOYMENT.md for full details, including a Docker/nginx note.

Caching and geocoding

  • IndexedDB (alamowatch database, via the idb library) stores every incident ever seen (incidents store), a geocode result cache (geocache store keyed by normalized address|zip), and small metadata (meta store). Incidents are merged by their stable id: first sighting sets firstSeenAt, every subsequent sighting bumps lastSeenAt.
  • Geocoding resolves lat/lng for the map in this order: IndexedDB cache → US Census onelineaddress API → Nominatim (OpenStreetMap, rate-limited to 1 request/sec) → a ZIP-code centroid fallback (flagged as approximate). A miss on all three is left as lat: null, lng: null — coordinates are never guessed or randomized.

Status states

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.

The saf-data Claude Code skill

.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 --pretty

See .claude/skills/saf-data/SKILL.md for details.

Development

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 sane

There is no ESLint or Prettier configuration in this repo — formatting/linting is not enforced by tooling here.

Architecture overview

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).

Data model

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
}

Deployment

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.

Data & disclaimer

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.

License

MIT — see LICENSE.

About

Live San Antonio Fire (SAFD) & EMS active-incident dashboard — real-time map, analytics, and searchable history built from the city's public feeds. React + Vite, IndexedDB caching, real address geocoding, and honest live/stale/error states.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages