Skip to content

National Alert Browser: browse active weather alerts by service/country#88

Merged
kellylford merged 14 commits into
mainfrom
feature/alert-browser
Jul 5, 2026
Merged

National Alert Browser: browse active weather alerts by service/country#88
kellylford merged 14 commits into
mainfrom
feature/alert-browser

Conversation

@kellylford

Copy link
Copy Markdown
Owner

Closes #87.

Adds a National Alert Browser to the iOS app: browse active government weather alerts independent of any saved city, by service/country, with a VoiceOver-first digest. Behind the alertBrowserEnabled feature flag (default on), reached from a new Weather Alerts section in the Browse tab.

What's included

Sources (all free, keyless — none touch the paid Open-Meteo account):

  • United States — NWS api.weather.gov
  • Canada — Environment Canada api.weather.gc.ca
  • Europe — MeteoAlarm, 36 countries, with per-country active-alert counts

Digest UI (NationalAlertDigestView):

  • Alerts collapsed by event type, grouped under severity headers with counts.
  • Exclusive severity buttons (Extreme / Severe / Moderate / All) — each shows only that severity; counts announced per VoiceOver segment.
  • Hazard-type "Type" menu (Storms, Tropical, Flooding, Rain, Heat, Winter, Wind, Fire, Fog, Marine & Coastal, Air Quality, Other) — only present families shown, each with a count.
  • Tap a category → affected areas → existing AlertDetailView.

Design: each provider normalizes into the existing WeatherAlert model, so AlertDetailView + severity coloring + a11y labels are reused. New AlertSource cases .eccc / .meteoalarm. No new dependencies.

New / changed files

  • New: Services/AlertBrowserService.swift (providers, fetch, digest grouping, hazard classifier), Views/AlertBrowserView.swift (region picker, Europe country list, digest, group detail)
  • Edited: Models/WeatherAlert.swift (source cases), Views/AlertDetailView.swift (generalized source link), Services/FeatureFlags.swift + Views/DeveloperSettingsView.swift (flag + toggle), Views/BrowseCitiesView.swift (entry point)

Notable fixes surfaced during development

  • NWS /alerts/active rejects a limit param (HTTP 400) — removed.
  • Air Quality (NWS "Unknown" severity) remapped to Moderate.
  • Dropped the land/marine toggle — Small Craft Advisories are Minor, so severity buttons already handle them.
  • Repaired feed data-quality issues: Austria's glued "Heatwarning" names, and raw awareness_type=… event dumps decoded via the structured awareness code.
  • Broadened the hazard classifier for non-US vocabulary — "Other" fell from 35% to 0.7% across live Canada + Europe data.

Testing

  • Builds clean for simulator and device (Xcode 26.6); installed and exercised on a physical iPhone throughout.
  • Cross-checked digest counts against raw API (e.g. the single US Severe Thunderstorm Warning matched the feed).
  • Hazard classifier validated against live CA + 18 EU feeds.

Follow-ups (not in this PR)

  • Full VoiceOver walkthrough of every new screen before merge.
  • Greece's generic "Yellow Warning" stays in "Other."
  • Possible additional sources (Australia BOM, Japan JMA, NZ MetService).

🤖 Generated with Claude Code

kellylford and others added 11 commits July 5, 2026 09:08
New feature (behind alertBrowserEnabled flag, default on) to browse active
government weather alerts independent of any saved city, entered from a new
"Weather Alerts" section in the Browse tab.

The centerpiece is a scannable "national digest": raw alert feeds are noisy
(dozens of identical county-level products), so alerts are collapsed by event
type + severity into one row each ("Flood Warning — 29 areas · expires 6 PM"),
sorted extreme-first, with a severity floor (default Severe+) and an NWS
land/marine toggle. Tap a group → affected areas → existing AlertDetailView.

Sources, all normalized into the shared WeatherAlert model so AlertDetailView
and severity coloring work unchanged:
  - United States — NWS (api.weather.gov/alerts/active)
  - Canada        — Environment Canada (api.weather.gc.ca weather-alerts)
  - Europe        — MeteoAlarm per-country CAP feeds (18 countries)

New files:
  - Services/AlertBrowserService.swift — providers, fetch, digest grouping
  - Views/AlertBrowserView.swift — region picker, digest, group detail
Edits: AlertSource gains .eccc/.meteoalarm; AlertDetailView link generalized;
alertBrowserEnabled flag + Developer toggle; BrowseCitiesView entry point.

Accessibility: digest rows carry explicit combined labels
("Severe. Flood Warning. 29 areas. Soonest expires 6 PM.") and hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
api.weather.gov/alerts/active rejects the "limit" parameter with HTTP 400
("Query parameter 'limit' is not recognized"), which surfaced as "Couldn't
load alerts" for the United States. The endpoint returns all active alerts
with no pagination, so the parameter was unnecessary. Canada (ECCC) and
Europe (MeteoAlarm) were unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Default severity floor is now Moderate+ (was Severe+) so watches — which NWS
  tags as Moderate (Tornado Watch, Severe Thunderstorm Watch, Flood/Heat Watch)
  — are no longer hidden. "Unknown" severity (Air Quality) sorts below Minor, so
  it stays gated to the "All" level and doesn't flood the default view.
- Removed the severity-breakdown summary header. VoiceOver read it as one long
  run-on string ("119 active alerts. 43 severe · 41 unknown · …"). The per-event
  counts now live on each category button as a prominent trailing number, which
  reads cleanly as part of that button's label ("Severe. Flood Warning. 25
  areas. Soonest expires 6 PM.").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per testing feedback:
- Removed the severity floor and land/marine filters entirely. The digest now
  shows every active alert grouped by severity (Extreme → Unknown), including
  marine. Grouping by event type keeps it scannable without a filter.
- Each severity section header now carries its total count and reads as a
  heading in VoiceOver ("Extreme, 3 alerts"). This was the missing number the
  user expected on the Extreme/Severe/Moderate labels.
- Group rows no longer hide the count behind a possibly-ignored combined label:
  the visible number carries its own "N areas" accessibility label, and the
  row label drops the now-redundant severity (it's in the section header).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The Europe country list now shows each country's active-alert count as a
  trailing badge, loaded concurrently on appear (URLSession caps ~6 conns/host
  so the ~36 feeds queue politely) and cached 5 min in the service. VoiceOver
  reads "Italy, 4 alerts" / "Malta, no active alerts".
- Expanded MeteoAlarm coverage from 18 to 36 member countries (added Baltics,
  Balkans, Cyprus, Czechia, Hungary, Iceland, Ireland already present, Israel,
  Luxembourg, Malta, Moldova, Romania, etc.).

Note: none of the alert sources (NWS, ECCC, MeteoAlarm) use the paid Open-Meteo
API — all three are free, keyless public services. MeteoAlarm != Open-Meteo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the "remove all filtering" overcorrection. The segmented severity
filter (Extreme / Severe+ / Moderate+ / All) and the NWS land/marine toggle
are back, defaulting to Moderate+. Each segment's VoiceOver label now carries
its count — "Extreme, 3 alerts" (VoiceOver appends "1 of 4") — which is what
the user asked for. Category rows and severity section-header counts from the
prior fix are kept, so numbers are spoken at every level.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ggle

- Severity buttons are now exclusive, not a floor: "Extreme" shows only
  Extreme, "Moderate" shows only Moderate. Only "All" is inclusive (every
  alert, including Minor Small Craft Advisories and Unknown-severity Air
  Quality). Renamed segments Severe+/Moderate+ -> Severe/Moderate.
- Default is now All so nothing is hidden on open; the buttons narrow to a
  single severity to reduce clutter. No data is gated.
- Removed the separate land/marine toggle: Small Craft Advisories are tagged
  Minor, so they only surface under All anyway — the severity buttons already
  handle marine noise, making the second control redundant.
- Segment VoiceOver labels still carry per-level counts ("Extreme, 3 alerts").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Austria's feed (GeoSphere Austria) supplies English event names as glued
compounds — "Heatwarning", "Thunderstormwarning", "Stormwarning", "Rainwarning"
— translating the German compound word without a space. Insert the missing
space before "warning" so these read "Heat warning", "Thunderstorm warning",
etc. Correctly-spaced names from other members are left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. Air Quality Alerts (NWS tags them severity "Unknown") now surface as
   Moderate instead of being stranded in the Unknown bucket.

2. New hazard-type filter (a "Type" menu next to the severity buttons): pick
   a family — Storms, Tropical, Flooding, Heat, Winter, Wind, Fire, Fog,
   Marine & Coastal, Air Quality, Other — to see just those alerts across all
   severities. "Storms" gathers tornado/thunderstorm/severe-weather products
   that NWS otherwise scatters across Extreme (Tornado Warning), Severe (Severe
   Thunderstorm Warning) and Moderate (the watches). Only families actually
   present are offered, each with a count; severity-segment counts respect the
   chosen type. Default is All types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sanity-checked the hazard classifier against live Canada (ECCC) and 18
European (MeteoAlarm) feeds: 1,334 alerts were landing in "Other" because
European vocabulary differs from NWS. Fixes:

- Classifier keywords now cover both vocabularies: "high-temperature" -> Heat,
  "low-temperature" -> Winter, "lightning" -> Storms, bare "storm" -> Wind.
- New "Rain" hazard family (rain / heavy rain / downpour / showers), which
  MeteoAlarm issues heavily and NWS does not.
- Repair broken MeteoAlarm event names: some feeds emit an empty event or a raw
  "awareness_type=3, awareness_level=2" dump. Decode the structured
  awareness_type ("3; Thunderstorm" or the numeric code) into a real name like
  "Thunderstorm warning" so it both displays and classifies correctly.

Result: "Other" fell from 1,334 (35%) to 29 (0.7%) across CA + EU; the only
remainder is Greece's generic unlabeled "Yellow Warning". Distribution now
reads sensibly (Storms 40%, Heat 30%, Wind 13%, Winter 6%, Rain 4%, Fog 3%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Browse tab now covers more than cities (weather alerts by country, and
more to come), so the page title changes from "Browse Cities" to
"Weather Browser".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kellylford

Copy link
Copy Markdown
Owner Author

Code review — National Alert Browser

Reviewed the diff (NWS/ECCC/MeteoAlarm alert browser behind alertBrowserEnabled). Overall this is clean, well-factored work: each provider normalizes into the shared WeatherAlert model so AlertDetailView, severity coloring, and a11y labels are all reused, and the a11y labeling on the new screens is thorough. No correctness bugs that produce a crash or clearly-wrong output survived verification. Findings below are cleanup/efficiency plus two minor correctness notes, most-actionable first.

1. Dead land/marine filter machinery (cleanup)

Services/AlertBrowserService.swift

  • AlertRegion.supportsLandMarineFilter (line 38) is never referenced anywhere in the tree.
  • fetchAlerts(for:landOnly:) defaults landOnly: true, but both callers pass false (AlertBrowserService.swift:192 and AlertBrowserView.swift:312), so the if landOnly { region_type=land } branch (:293) is unreachable.

The PR description notes the land/marine toggle was dropped — this is its leftover skeleton. Suggest removing supportsLandMarineFilter, the landOnly parameter, and the branch so the fetch path has one behavior.

2. Dead selectedAlert state + sheet in NationalAlertDigestView (cleanup)

Views/AlertBrowserView.swift:128 declares @State private var selectedAlert and :145 attaches .sheet(item: $selectedAlert), but nothing in that view ever assigns selectedAlert (rows use NavigationLink to AlertGroupDetailView, which has its own selectedAlert/sheet at :326/:362). The digest view's state + sheet are dead and can be deleted.

3. parseISO re-allocates ISO8601 formatters on every call (efficiency)

Services/AlertBrowserService.swift:266-272 — each parseISO call constructs two ISO8601DateFormatter instances (fractional + plain). It's called ~3× per alert (ends/expires/onset), and a US NWS /alerts/active fetch can be hundreds of alerts → ~600–900 formatter allocations per load. ISO8601DateFormatter construction is notably expensive. Hoist the two formatters into static let singletons (the codebase already caches formatters this way in SettingsManager.swift).

4. HazardType.classify recomputed repeatedly per render (efficiency/simplification)

Views/AlertBrowserView.swift — the scoped array (alerts.filter { … classify($0.event) … }) is computed in both loadedContent and filterControls, and classify is called again in the presentFamilies filter and both picker loops. Every SwiftUI re-render re-runs all of it (lowercasing + substring scans per alert). Compute the classification/scoped set once and reuse.

5. MeteoAlarm id fallback uses array index — unstable (minor correctness)

Services/AlertBrowserService.swift:431id: warning.alert.identifier ?? "meteoalarm-\(slug)-\(index)". When a feed omits an identifier, the id is the position in the decoded array, so the same alert can get a different id across refetches if the feed reorders/adds entries, churning SwiftUI ForEach identity (flicker/re-render). A content-derived key (e.g. hash of event+area+onset) would be stable. Low impact since most CAP alerts carry an identifier.

6. "Special Weather Statement" always classified as Storms (minor, judgment call)

Services/AlertBrowserService.swift:128 folds "special weather statement" into .storms, but NWS issues SPS products for fog, wind, etc. as well. Not wrong enough to block, but it will mis-bucket non-storm statements under the Storms hazard filter.

Also minor: shortTime (AlertBrowserView.swift:302) allocates a DateFormatter per call — same static-caching fix as #3.

🤖 Review by Claude Code

- Remove dead land/marine filter machinery (supportsLandMarineFilter,
  landOnly param, and the unreachable region_type=land branch).
- Delete unused selectedAlert @State + .sheet in NationalAlertDigestView
  (rows navigate via NavigationLink; the group detail owns its own sheet).
- Cache the two ISO8601DateFormatters as static singletons in parseISO
  (was allocating ~600-900 formatters per US load).
- Classify hazard families once per render and thread the result through
  loadedContent/filterControls instead of re-running classify repeatedly.
- Derive a stable, content-based id for MeteoAlarm alerts lacking an
  identifier (was array index → churned ForEach identity on refetch).
- Cache shortTime's DateFormatter as a static singleton.
- Bump CURRENT_PROJECT_VERSION 1 → 2 (MARKETING_VERSION stays 1.5.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kellylford

Copy link
Copy Markdown
Owner Author

Review feedback addressed — resolved in 36a31b2

# Finding Resolution
1 Dead land/marine filter machinery Removed supportsLandMarineFilter, the landOnly param, and the unreachable region_type=land branch; both callers updated.
2 Dead selectedAlert state + sheet in NationalAlertDigestView Deleted — rows use NavigationLink; the group-detail view owns its own sheet.
3 parseISO re-allocating ISO8601 formatters Hoisted both formatters into static let singletons.
4 HazardType.classify recomputed per render Classified once in loadedContent, threaded through filterControls.
5 MeteoAlarm array-index id fallback Now derives a stable content-based id (slug + event + area + onset + expires) when the feed omits an identifier.
shortTime allocating a DateFormatter per call Cached as a static let singleton.
6 "Special Weather Statement" → Storms Left as-is by design — correctly re-bucketing needs the alert description text; the storms default is the reasonable choice.

🤖 Generated with Claude Code

kellylford and others added 2 commits July 5, 2026 11:59
load() no longer flips to .loading when content is already loaded, so a
manual refresh shows the standard .refreshable spinner over the existing
rows instead of flashing the full-screen "Loading active alerts…" row.
A transient refresh failure now keeps the valid alerts visible rather
than replacing them with the failure screen; the failure screen still
shows on the initial load when there's nothing to preserve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NWS areaDesc lists zone-based products (e.g. Special Weather Statements)
as bare county/zone names with no state, which read ambiguously — a
single statement can span multiple states. Zip areaDesc with the
parallel geocode.UGC codes (first two letters = state postal code) and
append ", <state>" to each area that lacks one. County-based products
that already carry ", OH" are left untouched, and marine-zone prefixes
(ANZ, GMZ, …) are skipped via a valid-state-abbreviation set.

Adds geocode (UGC/SAME) decoding to NWSAlertProperties.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kellylford kellylford merged commit ae2591a into main Jul 5, 2026
2 checks passed
@kellylford kellylford deleted the feature/alert-browser branch July 5, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

National Alert Browser: browse active weather alerts by service/country (US, Canada, Europe)

1 participant