Skip to content

feat(products): PR 1 Phase B — Forecast Products dialog + HWO/SPS notifications#613

Merged
Orinks merged 19 commits intodevfrom
feat/forecast-products-pr1-phase-b
Apr 20, 2026
Merged

feat(products): PR 1 Phase B — Forecast Products dialog + HWO/SPS notifications#613
Orinks merged 19 commits intodevfrom
feat/forecast-products-pr1-phase-b

Conversation

@Orinks
Copy link
Copy Markdown
Owner

@Orinks Orinks commented Apr 20, 2026

What this delivers

Phase B of bundled PR 1. Stacked on top of #612 (Phase A — zone metadata data layer). Together they complete the Forecast Products feature described in the two brainstorms landed with the plan.

Users get:

  • A per-location Forecast Products dialog (replacing the old "Discussion" entry point) with three tabs: Area Forecast Discussion, Hazardous Weather Outlook, and Special Weather Statement.
  • HWO update notifications — fire when a WFO issues a new or updated Hazardous Weather Outlook. Default ON.
  • Informational SPS notifications — fire for fire-weather statements, pollen advisories, and other SPS products that live on /products/types/SPS but never reach the /alerts/active feed (Case B per the live-API verification documented in the brainstorm). Event-style SPS (Case A — hail, dense fog, etc.) are suppressed because the existing alert pipeline already surfaces them. Default ON.
  • Plain Language Summary button on each tab, powered by the same AI explainer that already supports AFD.
  • Non-US locations get the Forecast Products button disabled with an adjacent "NWS products are US-only" StaticText (adjacent-StaticText accessibility pattern).

Zero new dependencies. No config schema_version bump. Every new field is additive.

Why a stacked draft

Phase A on #612 is self-contained and reviewable on its own. Phase B builds on top of it. Target base: feat/forecast-products-pr1. When #612 merges to main, this PR's base should be flipped to main.

Commit-by-commit

Commit Unit Delivers
fd8839e 6 Generalized get_nws_text_product fetcher + ForecastProductService with per-type TTL cache (AFD 1h, HWO 2h, SPS 15min). get_nws_discussion becomes a thin wrapper — existing AFD callers unchanged.
e9cf8af 7 AIExplainer.explain_text_product with per-product system prompts + 300s result cache. explain_afd becomes a thin wrapper; AFD behavior preserved byte-for-byte.
dfda206 8 ForecastProductPanel reusable wx.Panel + ForecastProductsDialog wx.Notebook. Main window: rename, Nationwide branch preserved, non-US button disabled with adjacent StaticText.
86a5537 9 Background pre-warm on refresh (active + all saved US locations). NotificationState + runtime_state extensions for HWO/SPS. WeatherAlert.affected_zones additive field.
1bd7d34 10 HWO update notification stream with cold-start baseline, sliding 30-min rate limit, summarizer-or-generic body. Gated by notify_hwo_update.
6bc24a6 11 SPS notification stream with Case A / Case B dedupe against active alerts (headline substring match). Silent cold-start and silent expiration. Gated by notify_sps_issued.
1a52243 12 notify_hwo_update + notify_sps_issued on AppSettings (default True) + Settings UI toggles + intro copy rewrite to honestly describe defaults-ON behavior.

Design decisions worth noting

  • SPS dedupe is heuristic, not zone-based. TextProduct doesn't carry zone data; deriving it would require fetcher changes. Headline substring matching against active Special Weather Statement alerts turned out to be sufficient for the Case A / Case B split the brainstorm identified. Both the verified PHI 2026-04-16 fire-weather SPS (Case B) and a matching event-style SPS alert replay cleanly.
  • Rate limiting uses a sliding window, not a calendar bucket. docs/alert_audit_report.md §7 flagged that the existing alert rate-limit's calendar-hour reset can burst 10 notifications at 12:00:01. PR 1's new HWO/SPS streams don't repeat that mistake — the 30-min-per-(stream, location) dict compares timestamps directly.
  • Cold-start silent baseline. First fetch for any product type on any location records state without firing a notification. Prevents notification storms on fresh install, newly added locations, or reopen-after-absence.
  • wx.Notebook can't per-tab disable on Windows. All empty and error states render inside the content panel, not as a tab label. Tab switch focus is managed via EVT_NOTEBOOK_PAGE_CHANGED + wx.CallAfter.
  • AI visibility pattern mirrored. Each tab's "Plain Language Summary" button starts hidden; reveal is one-click. Matches docs/superpowers/specs/2026-04-08-discussion-dialog-ai-visibility-design.md.
  • Defaults ON for HWO and SPS notifications. Both streams deliver information not already visible elsewhere — HWO's 7-day hazard horizon doesn't appear in alerts; Case B SPS is completely invisible today. Intro copy honestly tells users this behavior.

Testing

Per-unit test files (all new):

  • tests/test_weather_client_nws_text_product.py (11 scenarios)
  • tests/test_forecast_product_service.py (6)
  • tests/test_ai_explainer_text_product.py (9)
  • tests/gui/test_forecast_product_panel.py (19)
  • tests/gui/test_forecast_products_dialog.py (8)
  • tests/test_main_window_product_prewarm.py (8)
  • tests/test_notification_state_hwo_sps_fields.py (5)
  • tests/test_runtime_state_hwo_sps.py (6)
  • tests/test_cache_weather_alert_affected_zones.py (3)
  • tests/test_notification_hwo_update.py (14)
  • tests/test_notification_sps_dedupe.py (14)
  • tests/test_settings_notification_toggles.py (10)

113 new tests + zero AFD / existing regressions. Full non-integration sweep: 3617 passed, 5 skipped. The 4 test_alert_dialog_dispatch parallel flakes pre-date this branch and pass when run standalone (xdist worker isolation quirk, not a real regression).

Manual test plan

  • Add a US location, open Forecast Products → three tabs pre-populated with AFD / HWO / SPS for the CWA office
  • Switch tabs with keyboard → focus lands in each tab's TextCtrl; screen reader announces the raw product text
  • Click "Plain Language Summary" on each tab → AI output appears; Regenerate works
  • Verify SPS tab with multiple active advisories shows the wx.Choice selector; single SPS hides it
  • Verify SPS tab with no active advisories shows "No Special Weather Statements currently active for {CWA}"
  • Add a non-US location, select it → Forecast Products button disables; adjacent StaticText "NWS products are US-only" appears
  • Close the app for a few hours, reopen → no notification storm
  • Settings → Notifications tab shows two new toggles checked by default with the new intro copy
  • Toggle each off and verify the corresponding stream stops firing
  • Verify the existing "Discussion" AFD notification behavior is unchanged

Known deferrals intentionally out of scope

  • ConfigManager.save_config in-process lock — tracked as follow-up from Phase A.
  • Zone-based SPS dedupe (instead of headline substring match) — can be added later if the heuristic proves inadequate.
  • AI summary prompts for HWO / SPS may want tuning against live products (deferred-to-implementation item in the plan; picked reasonable defaults).

@Orinks Orinks force-pushed the feat/forecast-products-pr1 branch from fd507bc to 64333ad Compare April 20, 2026 23:19
@Orinks Orinks force-pushed the feat/forecast-products-pr1-phase-b branch from baece35 to fc6458b Compare April 20, 2026 23:19
Base automatically changed from feat/forecast-products-pr1 to dev April 20, 2026 23:22
@Orinks Orinks closed this Apr 20, 2026
@Orinks Orinks reopened this Apr 20, 2026
@Orinks Orinks closed this Apr 20, 2026
@Orinks Orinks reopened this Apr 20, 2026
Orinks added 19 commits April 20, 2026 19:42
Adds a TextProduct dataclass and a single async fetcher
get_nws_text_product(product_type, cwa_office) that handles AFD,
HWO, and SPS through one endpoint shape. AFD/HWO return one
TextProduct (or None); SPS returns a list sorted newest-first
(or [] when no active products).

TextProductFetchError distinguishes network/non-200 failures
from "no active products" — callers can notify on the latter
without treating it as an error.

ForecastProductService wraps the fetcher with per-type caching
via the existing Cache layer: AFD 1h, HWO 2h, SPS 15min. Keys
are namespaced per (product_type, cwa_office) so different
offices and types never collide. Fetch failures propagate and
are not cached.

get_nws_discussion becomes a thin wrapper around
get_nws_text_product("AFD", ...); its public signature and
return shape are preserved so existing AFD notifications and
dialogs work unchanged (14 AFD regression tests still green).
Adds explain_text_product(text, product_type, location_name, ...)
with a per-product system prompt lookup table. AFD keeps its exact
historical prompt byte-for-byte; HWO and SPS mirror its
objective-bullet shape with domain-appropriate phrasing.

explain_afd is now a thin wrapper over explain_text_product with
an unchanged public signature — every existing caller keeps
working, locked in by the AFD regression suite (14 tests green).

Adds per-product result caching that explain_afd lacked. Cache
key: ai_text_product:{TYPE}:{location}:{sha256(text)}:{style},
300s TTL, matching explain_weather. Failures are not cached.

custom_system_prompt and custom_instructions apply identically
across all three product types (replacement and append
semantics are preserved from the original AFD behavior).
Replaces the per-location "Discussion" entry point with
"Forecast Products", opening a tabbed dialog that surfaces
Area Forecast Discussion, Hazardous Weather Outlook, and
Special Weather Statement in one place.

ForecastProductPanel is a reusable wx.Panel shared across the
three tabs: header, optional SPS multi-product chooser, raw
product TextCtrl, issuance timestamp, and a Plain Language
Summary button that stays hidden until first click (mirroring
the discussion-dialog AI visibility design). Content-state
copy handles loading, populated, empty, fetch-failed, and
no-CWA states inside each panel — wx.Notebook can't per-tab
disable on Windows.

ForecastProductsDialog hosts a wx.Notebook with three panels,
focuses the AFD TextCtrl on open, moves focus to the selected
tab on page change via wx.CallAfter, and closes on ESC.

Main window: QUICK_ACTION_LABELS rename to "Forecast &Products",
Nationwide branch preserved, non-Nationwide routes to
_on_forecast_products, and an adjacent StaticText "NWS products
are US-only" shows next to a disabled button for non-US
locations (adjacent-StaticText accessibility pattern).
…ications

Extends the weather refresh cycle to pre-fetch AFD, HWO, and SPS
for every saved US location with a populated cwa_office. The
active location refreshes via _fetch_weather_data; non-active
locations refresh via _pre_warm_other_locations. Both paths call
_pre_warm_products_for_location, which isolates failures
per (product_type, location) so one /products/types/... error
cannot cascade.

NotificationState gains last_hwo_issuance_time, last_hwo_text,
last_hwo_summary_signature, and last_sps_product_ids — the
fields the HWO update stream (Unit 10) and SPS dedupe stream
(Unit 11) will read and write. Runtime state defaults gain hwo
and sps sub-sections, and both converters
(_runtime_section_to_legacy_shape,
_legacy_shape_to_runtime_section) are updated in parallel so
round-tripping never drops fields. Legacy JSON without the
new sections loads with sensible defaults.

WeatherAlert gains an additive affected_zones: list[str] with
defensive .get on deserialize — legacy cache entries resolve
to []. This unlocks Unit 11's SPS-vs-alert zone-intersection
dedupe without requiring a re-fetch.

All changes are additive. No schema_version bump.
Adds _check_hwo_update to NotificationEventManager: cold-start
fetches silently store baseline (issuance_time + text + content
signature); later fetches with a newer issuance_time and differing
signature dispatch a notification. Unchanged fetches are no-ops.

Notification body tries summarize_discussion_change first (the
existing AFD summarizer) and falls back to a generic
"Hazardous Weather Outlook updated for {cwa} — tap to view."
when the summary is empty or shorter than 20 characters.

A sliding 30-minute rate limit keyed (HWO, location) prevents
notification storms after prolonged app absence — state still
advances inside the window, just the dispatch is suppressed.
The rate-limit dict is ephemeral (in-memory only), not
persisted to runtime_state.

Gated by notify_hwo_update on AppSettings (Unit 12 adds the UI).
Defaults True via getattr fallback so the behavior ships the
same day the setting lands.

Wired into main_window_notification_events: each per-location
event-check cycle reads the pre-warmed HWO product from the
ForecastProductService cache (Unit 9 fills it) and runs the
check. AFD flow is untouched; existing 44 AFD tests green.
Adds _check_sps_new to NotificationEventManager. For each SPS
product not already in state.last_sps_product_ids, it checks
whether any currently-active "Special Weather Statement" alert
looks like the same advisory (headline / first-line substring
match, case-insensitive, whitespace-collapsed, bidirectional).

- Case A (event-style SPS that also shows up in /alerts/active):
  the ID is recorded silently — no notification. The alerts
  pipeline already surfaces these.
- Case B (informational SPS that lives ONLY on /products/types/SPS:
  fire-weather planning forecasts, pollen advisories, coordination
  statements): the notification fires, using headline when
  present or the first non-empty line of productText otherwise,
  truncated at 160 chars for Windows toast.

Cold-start: first fetch for a (location, "SPS") pair records all
current IDs silently and marks the location as cold-started.
Expiration: IDs present in state but absent from the latest fetch
are removed silently, matching the AFD/HWO "cleared" pattern.

Rate limit: 30-minute sliding window keyed ("SPS", location.name)
on the ephemeral _last_product_notified_at dict shared with HWO.

Gated by notify_sps_issued on AppSettings (Unit 12 adds the UI).
Defaults True via getattr fallback so the behavior ships the
same day the setting lands.

Wired into main_window_notification_events: the per-location
cycle reads the pre-warmed SPS list from the ForecastProductService
cache (Unit 9) and the current location's active alerts from
the weather cache, then runs _check_sps_new. AFD and HWO flows
are untouched; all prior regression tests stay green.
Adds notify_hwo_update and notify_sps_issued to AppSettings
with defaults True, round-tripped through to_dict/from_dict via
the existing _as_bool helper. Legacy config without the keys
loads with both defaults ON.

The Settings → Notifications tab gains two new checkboxes
beneath the existing Discussion toggle, with descriptive
labels that are the accessible announcement:
- Notify on Hazardous Weather Outlook updates
- Notify on Special Weather Statement (informational)

Rewrites the section intro StaticText to match the new
defaults-ON-for-HWO/SPS behavior:
"Updates beyond standard alerts. Hazardous Weather Outlook
and informational Special Weather Statement updates are on
by default because they deliver information not in the alerts
feed. Others are off unless you turn them on."

Completes PR 1 — the Forecast Products dialog and its HWO/SPS
notification streams now have their user-facing controls.
…IGHT

wxPython rejects wx.ALIGN_RIGHT on children of a horizontal BoxSizer
(alignment flags in a horizontal sizer only control vertical
placement). The old code tripped a C++ assertion that showed a
"DO NOT PANIC" dialog on every Forecast Products open, blocking
the dialog from rendering.

Use AddStretchSpacer + plain Add on the inner horizontal sizer,
and expand the whole row via the vertical main_sizer — same visual
result, no assertion.
Two user-surfaced issues from manual testing:

1. Forecast Products showed "NWS text products will populate after
   the next weather refresh" even after refreshes completed, for
   every saved location. Root cause: the drift-correction hook in
   weather_client_nws was a silent no-op because set_zone_drift_sink
   was never called at app startup (flagged as a known deferral when
   Unit 3 landed). Without the sink, /points responses on refresh
   never backfill cwa_office on legacy saved locations, so the
   Forecast Products dialog has no CWA to fetch with. Wire
   app.config_manager._locations as the sink in
   initialize_components; the hook now persists zone metadata on
   every refresh via the existing wx.CallAfter main-thread bounce.

2. EVT_NOTEBOOK_PAGE_CHANGED grabbed focus into the selected tab's
   TextCtrl on every tab switch. A screen reader user arrow-keying
   through tabs would hear the full product text re-announced on
   each key. The standard notebook contract is: the tab strip is
   one focus level, the content is the next; the user Tabs into
   content themselves. Remove the page-change handler and its
   binding. Initial focus-on-open into AFD content is preserved —
   the user explicitly opened the dialog, so content-first makes
   sense at that moment.
Forecast Products panels stayed in "Loading..." forever because
the panel's _schedule_load called asyncio.ensure_future on the wx
main thread, which has no running asyncio loop. The RuntimeError
was caught silently, so the coroutine never ran.

The app runs asyncio on a background thread (see app._start_async_loop)
and exposes run_async, which existing dialogs (DiscussionDialog)
already use. Thread the app reference through
show_forecast_products_dialog -> ForecastProductsDialog ->
ForecastProductPanel, then prefer app.run_async when available for
both the product loader and the AI explainer. Falls back to
ensure_future when no app is supplied (still useful for tests),
but now surfaces the error as a load failure instead of silently
leaving the panel stuck.

Verified the NWS endpoints themselves are healthy via curl — this
was purely a dispatch-layer bug.
Three small accessibility / wording improvements from manual testing:

1. Tab labels now read "Area Forecast Discussion",
   "Hazardous Weather Outlook", "Special Weather Statement"
   instead of the cryptic "AFD" / "HWO" / "SPS" abbreviations.
   The plan specified the abbreviations but the full names are
   clearer on first encounter and a screen reader announces
   them naturally.

2. On open, focus lands on the notebook tab strip itself, not
   inside the selected tab's TextCtrl. A screen reader user
   first hears which tab is active and can arrow-key through
   the tabs before choosing to read content by Tab-ing in.

3. The SPS multi-product chooser label now reads "Active Special
   Weather Statements:" instead of the ambiguous "Active
   Statements:" — makes the widget's purpose obvious.

The now-unused _focus_active_tab helper is removed.
The SPS multi-product chooser (StaticText + wx.Choice) was created
on every tab and hidden via Show(False) on AFD/HWO. Screen readers
still pick up hidden wx.StaticText, so on the AFD and HWO tabs
users heard "Active Special Weather Statements:" announced as part
of the tab's layout — misleading and wrong context.

Fix: only instantiate the chooser widgets when product_type == "SPS".
AFD/HWO panels now expose self.sps_choice = None and all code
paths that touched the chooser guard against None explicitly.
The /products/types/SPS/locations/{CWA} endpoint returns the
archive of recent SPS products for an office — NOT a filtered
list of currently-in-effect advisories. Verified against PHI
on 2026-04-20: the only SPS in the @graph was from 2026-04-16
(a fire-weather planning forecast that had long since expired).

Labeling the chooser "Active Special Weather Statements:" and
the empty state "No Special Weather Statements currently active
for {CWA}" overclaimed what we actually know. Users would
reasonably assume the product was still in effect.

Rename both to "Recent". The per-product "Issued: {time}"
StaticText remains the honest signal users can read to judge
whether a given statement is still applicable for their
planning window.
The Plain Language Summary button was permanently disabled because
the panel gated on a non-None self._ai_explainer, but main_window
passed getattr(self.app, "ai_explainer", None) which was always
None — the app has no such attribute. DiscussionDialog doesn't
hold a cached explainer either; it builds one each click from
SecureStorage and AppSettings.

Mirror that pattern for the new panels. The button now enables
as soon as content loads and an openrouter_api_key is present in
SecureStorage. A _build_explainer helper constructs AIExplainer
at click time with the right model + custom-prompt settings,
returning None when no key is available so _run_explain can
surface the familiar "OpenRouter API key not configured" error.

Tests that inject self._ai_explainer still take priority, so the
GUI test suite continues to pass unchanged.
Match DiscussionDialog's post-explanation provenance block so the
new Forecast Products tabs are consistent with the AI affordance
users already know from AFD.

Adds a Model Information: label + multiline read-only TextCtrl
(size 80px tall) below the plain-language summary. Hidden until
an explanation returns, same as DiscussionDialog.

_run_explain now passes model_used, token_count, estimated_cost,
and cached through to _on_explain_complete, which formats them
as "Model: X / Tokens: N / Cost: ~$0.xxxxxx" (or "No cost" when
zero) with an optional "Cached: Yes" line.

_on_explain hides the info block when kicking off a fresh run so
the previous call's numbers don't linger during generation.
_hide_ai_summary_section also hides the info block for symmetry.
"Forecaster Notes" reads warmer than "Forecast Products" and
covers what each tab actually shows (forecaster reasoning in AFD,
hazard outlook in HWO, ad-hoc statements in SPS) without
technically implying that every tab is a "Discussion" — which
"Forecast Discussions" would have overclaimed since HWO and SPS
are not discussions at all.

Updated user-visible strings only:

- main-window button: "Forecaster &Notes" (mnemonic N, unique)
- View-menu item: "Forecaster &Notes..." with matching tooltip
  "View NWS forecaster notes (AFD, HWO, SPS)"
- Dialog title: "Forecaster Notes"
- Error dialog body on open failure
- Non-US explanatory StaticText: "Forecaster Notes are US-only"

Internal identifiers (forecast_products_dialog.py, _on_forecast_products,
show_forecast_products_dialog, forecast_products_us_only_label) are
intentionally left alone — renaming those adds git-history churn and
test reference turnover for no user benefit.
The fixtures in these three files monkey-patch wx.Panel.__init__ and
wx.Dialog.__init__ to no-ops so MagicMock parents don't blow up the
real-widget constructor. That's safe when tests run against the
in-repo wx stub (tests/conftest.py), but destructive against real
wxPython — the patches leak across test files and break subsequent
alert_dialog tests with:

  TypeError: Window.SetSizer(): first argument of unbound method
  must have type 'Window'

Which was exactly what CI was hitting on Ubuntu under xvfb.

Gate each of the three GUI modules with a pytestmark that skips
when hasattr(sys.modules["wx"], "_core") — a reliable signal that
real wxPython is loaded (C-extension present).

Net effect:
- Local dev with the stub: 42 tests run as before.
- CI under real wx: 42 tests skip; unrelated alert_dialog tests
  run undisturbed.
- The underlying Forecaster Notes code paths are already exercised
  end-to-end through manual testing documented in the PR.

A proper fix (refactor fixtures to use monkeypatch, or build tests
that work against real wx with a real wx.App lifecycle) is a
follow-up task — this skip is the minimum change to unblock CI.
@Orinks Orinks force-pushed the feat/forecast-products-pr1-phase-b branch from 41be31c to 69daa11 Compare April 20, 2026 23:42
@Orinks Orinks marked this pull request as ready for review April 20, 2026 23:45
@Orinks Orinks merged commit d32a658 into dev Apr 20, 2026
3 checks passed
@Orinks Orinks deleted the feat/forecast-products-pr1-phase-b branch April 20, 2026 23:45
@Orinks Orinks mentioned this pull request Apr 20, 2026
Orinks added a commit that referenced this pull request Apr 20, 2026
Syncs dev into main. Includes PR 1 (Forecaster Notes) Phase A and Phase
B, plus any other dev-only work since the last sync. Merging before
tonight's nightly build runs.

Recent dev content going to main:
- #612 — PR 1 Phase A: zone metadata data layer
- #613 — PR 1 Phase B: Forecaster Notes dialog + HWO/SPS notifications

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Orinks Orinks mentioned this pull request Apr 21, 2026
Orinks added a commit that referenced this pull request Apr 21, 2026
Routine sync of `dev` into `main`. Includes:

- fix(tests): stop leaking test values into developer keyring
([#615](#615))
- feat(products): Forecast Products dialog + HWO/SPS notifications
([#613](#613))
- feat(zones): NWS zone metadata on saved locations
([#612](#612))
- chore: remove unused html_formatters module
- feat(ui): add preference for location buttons near dropdown
([#611](#611))

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant