From 3900b3313b7b50e97d2a2683057ede5440b916ca Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 16 Jun 2026 21:42:20 -0500 Subject: [PATCH] Add SPC Outlook summaries to the alert digest template --- docs/internal/briefing.md | 6 +- docs/internal/module.md | 7 ++ docs/internal/reporttemplate.md | 4 +- docs/templates.md | 8 +- internal/app/app_test.go | 2 +- .../spc_convective_discussion_module.go | 4 +- .../spc_convective_outlooks_module.go | 62 +++++++++++++ .../spc_convective_outlooks_module_test.go | 90 ++++++++++++++++++- .../reporttemplate/reporttemplate_test.go | 73 ++++++++++----- .../templates/partials/alert_digest.md.tmpl | 8 +- 10 files changed, 229 insertions(+), 35 deletions(-) diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 484cfa7..8cda4fe 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -96,8 +96,10 @@ subset of discussion fields. Hourly Report defaults this module to report-period outlooks. It emits `checked: true` for a successfully fetched empty run, reports `outlook_count`, and includes prompt-facing outlook fields such as risk label, `period_begins`, `period_ends`, image URL, and whether the -outlook contains the configured location. It does not emit GeoJSON geometry, -source URL, expiration time, or severity rank. +outlook contains the configured location. It also emits a curated `risk_digest` +for categorical outlooks that overlap the report period, contain the location, +and meet the configured-in-code minimum severity for report rendering. It does +not emit GeoJSON geometry, source URL, expiration time, or severity rank. Prompt-facing module intervals use friendly local `period_begins` and `period_ends` labels. Canonical report metadata, source provenance, diff --git a/docs/internal/module.md b/docs/internal/module.md index 51d483b..889d56e 100644 --- a/docs/internal/module.md +++ b/docs/internal/module.md @@ -225,12 +225,19 @@ validation. - `location_name` - `outlook_count` - `outlooks` +- `risk_digest` Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`, `period_begins`, `period_ends`, `issued_at`, `contains_location`, and `image_url`. It omits GeoJSON geometry, source URL, expiration time, and severity rank. +The optional `risk_digest` list is a curated report-rendering subset of +categorical outlooks that overlap the report period, contain the configured +location, and meet the minimum severity threshold. Entries include `label_text`, +`risk_label`, `period_begins`, and `period_ends`; they do not expose severity +rank. + `spc_convective_discussion` emits a narrative stanza only when a retained report-period categorical outlook has severity rank `3` or higher and matching discussion text is available. Its output includes `included_because` and diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index e3f4e1d..76e536b 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -79,7 +79,9 @@ Daily and Tomorrow call the shared `daypart_forecast` partial. Today calls Today, Tomorrow, and Hourly call the shared `alert_digest` and `precipitation_timing` partials. Partial files are parsed with each top-level template at render time and receive the same typed render context as the -caller. +caller. The `alert_digest` partial renders the combined Alerts and Risk +Products section from relevant NWS alerts and curated SPC outlook digest +records. ## Schema Contract diff --git a/docs/templates.md b/docs/templates.md index a5a613d..99a7fab 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -16,7 +16,8 @@ are: Shared named partials live under `internal/reporttemplate/templates/partials/`: -- `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly +- `alert_digest.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly for the + combined Alerts and Risk Products section - `daypart_forecast.md.tmpl`, used by Daily and Tomorrow - `today_daypart_forecast.md.tmpl`, used by Today - `precipitation_timing.md.tmpl`, used by Daily, Today, Tomorrow, and Hourly @@ -369,6 +370,11 @@ YAML. | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. | | `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. | +| `.Modules.SPCConvectiveOutlooks.RiskDigest` | []briefing.SPCConvectiveOutlookDigest | Curated categorical outlooks for the shared Alerts and Risk Products section. | +| `.Modules.SPCConvectiveOutlooks.RiskDigest[].LabelText` | string | Human-readable outlook label. | +| `.Modules.SPCConvectiveOutlooks.RiskDigest[].RiskLabel` | string | Sentence-style risk label for report rendering. | +| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodBegins` | string | Friendly outlook period start. | +| `.Modules.SPCConvectiveOutlooks.RiskDigest[].PeriodEnds` | string | Friendly outlook period end. | | `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. | | `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. | | `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. | diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 85099de..b3e4748 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -566,7 +566,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { for _, want := range []string{ "# Hourly Report", "Storm chances increase through late morning.", - "## Active Alerts", + "## Alert Digest", "- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM. Avoid low-water crossings.", "## Precipitation Timing", "A cold front is moving into the region.", diff --git a/internal/briefing/spc_convective_discussion_module.go b/internal/briefing/spc_convective_discussion_module.go index 28c55dd..27fd9ec 100644 --- a/internal/briefing/spc_convective_discussion_module.go +++ b/internal/briefing/spc_convective_discussion_module.go @@ -8,8 +8,8 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) -const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3 -const spcCategoricalOutlookType = "categorical" +const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank +const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType type SPCConvectiveDiscussionModule struct { IncludedBecause string `json:"included_because"` diff --git a/internal/briefing/spc_convective_outlooks_module.go b/internal/briefing/spc_convective_outlooks_module.go index ba3a2dc..48af4ca 100644 --- a/internal/briefing/spc_convective_outlooks_module.go +++ b/internal/briefing/spc_convective_outlooks_module.go @@ -1,13 +1,18 @@ package briefing import ( + "strings" "time" + "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) +const defaultSPCRiskDigestOutlookType = "categorical" +const defaultSPCRiskDigestMinimumSeverityRank = 3 + type SPCConvectiveOutlooksModule struct { Checked bool `json:"checked"` AsOf string `json:"as_of,omitempty"` @@ -16,6 +21,7 @@ type SPCConvectiveOutlooksModule struct { LocationName string `json:"location_name,omitempty"` OutlookCount int `json:"outlook_count"` Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"` + RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"` } type SPCConvectiveOutlookRecord struct { @@ -30,6 +36,13 @@ type SPCConvectiveOutlookRecord struct { ImageURL string `json:"image_url,omitempty"` } +type SPCConvectiveOutlookDigest struct { + LabelText string `json:"label_text,omitempty"` + RiskLabel string `json:"risk_label,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` +} + func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) { value := SPCConvectiveOutlooksModule{} run := ctx.Collected.SPCConvectiveOutlooks @@ -49,10 +62,23 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, } value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone) + value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy()) value.OutlookCount = len(value.Outlooks) return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil } +type spcRiskDigestPolicy struct { + OutlookType string + MinimumSeverityRank int +} + +func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy { + return spcRiskDigestPolicy{ + OutlookType: defaultSPCRiskDigestOutlookType, + MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank, + } +} + func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord { records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks)) for _, outlook := range outlooks { @@ -75,6 +101,42 @@ func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, repor return records } +func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest { + records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks)) + for _, outlook := range outlooks { + if outlook.OutlookType != policy.OutlookType { + continue + } + if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank { + continue + } + if !outlook.ContainsLocation { + continue + } + outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo} + if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) { + continue + } + records = append(records, SPCConvectiveOutlookDigest{ + LabelText: outlook.LabelText, + RiskLabel: spcRiskDigestLabel(outlook.LabelText), + PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone), + PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone), + }) + } + return records +} + +func spcRiskDigestLabel(labelText string) string { + label := strings.TrimSpace(labelText) + if label == "" { + return "" + } + runes := []rune(strings.ToLower(label)) + runes[0] = unicode.ToUpper(runes[0]) + return string(runes) +} + func friendlyOptionalTime(value *time.Time, timezone string) string { if value == nil { return "" diff --git a/internal/briefing/spc_convective_outlooks_module_test.go b/internal/briefing/spc_convective_outlooks_module_test.go index 975206d..54bf1f9 100644 --- a/internal/briefing/spc_convective_outlooks_module_test.go +++ b/internal/briefing/spc_convective_outlooks_module_test.go @@ -71,12 +71,19 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { if !got.ContainsLocation || got.ImageURL == "" { t.Fatalf("outlook = %#v, want location flag and image URL", got) } + if len(value.RiskDigest) != 1 { + t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest)) + } + digest := value.RiskDigest[0] + if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "2026-05-29 at 11:00 AM" || digest.PeriodEnds != "2026-05-30 at 7:00 AM" { + t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest) + } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("Marshal() error = %v", err) } text := string(data) - for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} { + for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "period_begins", "period_ends", "contains_location", "image_url"} { if !strings.Contains(text, field) { t.Fatalf("json = %s, want field %s", text, field) } @@ -88,6 +95,71 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) { } } +func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) { + if defaultSPCRiskDigestOutlookType != "categorical" { + t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType) + } + if defaultSPCRiskDigestMinimumSeverityRank != 3 { + t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank) + } +} + +func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) { + tests := []struct { + name string + outlook weatherdata.ConvectiveOutlook + wantRisk bool + }{ + { + name: "categorical slight risk included", + outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true, + "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), + wantRisk: true, + }, + { + name: "marginal risk excluded", + outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true, + "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), + }, + { + name: "non categorical high rank excluded", + outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true, + "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), + }, + { + name: "non overlapping excluded", + outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true, + "2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"), + }, + { + name: "location miss excluded", + outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false, + "2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{ + Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook}, + } + ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook} + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks}) + if err != nil { + t.Fatalf("BuildModule() error = %v", err) + } + value := moduleValue[SPCConvectiveOutlooksModule](t, output) + gotRisk := len(value.RiskDigest) > 0 + if gotRisk != tt.wantRisk { + t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk) + } + }) + } +} + func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() @@ -117,6 +189,9 @@ func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) { if value.OutlookCount != 0 || len(value.Outlooks) != 0 { t.Fatalf("value = %#v, want non-overlapping outlook omitted", value) } + if len(value.RiskDigest) != 0 { + t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest) + } } func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) { @@ -167,3 +242,16 @@ func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *tes t.Fatalf("missing source value = %#v, want unchecked empty stanza", value) } } + +func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook { + return weatherdata.ConvectiveOutlook{ + ID: labelText, + Day: 1, + OutlookType: outlookType, + LabelText: labelText, + SeverityRank: &rank, + ValidFrom: mustParseModuleTime(validFrom), + ValidTo: mustParseModuleTime(validTo), + ContainsLocation: containsLocation, + } +} diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 2688d6d..9fb560a 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -243,7 +243,8 @@ func TestRenderHourly(t *testing.T) { Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."}}, }, SPCConvectiveOutlooks: &testSPCOutlooks{ - Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}}, + Outlooks: []testSPCOutlook{{LabelText: "Slight Risk", PeriodBegins: "8 AM", PeriodEnds: "2 PM"}}, + RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "May 29 at 8:00 AM", PeriodEnds: "May 29 at 2:00 PM"}}, }, SPCConvectiveDiscussion: &testSPCDiscussion{ Discussions: []testSPCDiscussionRecord{{Summary: "Strong storms may develop late morning."}}, @@ -268,8 +269,9 @@ func TestRenderHourly(t *testing.T) { "**Updated:** Saturday, June 14, 2026 at 9:14 AM", "Storm chances increase through late morning.", "Currently, it is 74°F and partly cloudy. It feels like 76°F, with a relative humidity of 71% and winds from the south at 8 mph.", - "## Active Alerts", + "## Alert Digest", "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", + "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from May 29 at 8:00 AM to May 29 at 2:00 PM.", "- **9:00 AM:** 74°F and cloudy.", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", "- **10:00 AM** to **12:00 PM**: Expect showers. The peak precipitation chance is 70% at 10:00 AM.", @@ -285,7 +287,7 @@ func TestRenderHourly(t *testing.T) { } assertOrderedText(t, text, []string{ "# Hourly Report", - "## Active Alerts", + "## Alert Digest", "## Current Conditions", "## Hourly Forecast", "## Precipitation Timing", @@ -354,6 +356,9 @@ func TestRenderTomorrow(t *testing.T) { AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}}, }, + SPCConvectiveOutlooks: &testSPCOutlooks{ + RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, + }, }, }) if err != nil { @@ -365,8 +370,9 @@ func TestRenderTomorrow(t *testing.T) { "**Forecast date:** Monday, June 15, 2026", "**Updated:** Sunday, June 14, 2026 at 9:14 AM", "Tomorrow starts dry before showers return later in the day.", - "## Active Alerts", + "## Alert Digest", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", + "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.", @@ -390,7 +396,7 @@ func TestRenderTomorrow(t *testing.T) { assertOrderedText(t, text, []string{ "# Monday's Weather", "Tomorrow starts dry before showers return later in the day.", - "## Active Alerts", + "## Alert Digest", "## Daypart Forecast", "- **Overnight:**", "- **Morning:**", @@ -452,6 +458,9 @@ func TestRenderDaily(t *testing.T) { AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 6:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 3:00 PM", PeriodEnds: "June 15 at 6:00 PM", Description: "Monitor creek levels."}}, }, + SPCConvectiveOutlooks: &testSPCOutlooks{ + RiskDigest: []testSPCRiskDigest{{LabelText: "Enhanced Risk", RiskLabel: "Enhanced risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, + }, }, }) if err != nil { @@ -463,8 +472,9 @@ func TestRenderDaily(t *testing.T) { "**Forecast date:** Monday, June 15, 2026", "**Updated:** Sunday, June 14, 2026 at 9:14 AM", "The selected day starts dry before showers return later in the day.", - "## Active Alerts", + "## Alert Digest", "- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.", + "- **SPC Convective Outlook**: Enhanced risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", "- **3:00 PM** to **6:00 PM**: Expect showers. The peak precipitation chance is 70% at 3:00 PM.", @@ -479,7 +489,7 @@ func TestRenderDaily(t *testing.T) { assertOrderedText(t, text, []string{ "# Monday's Weather", "The selected day starts dry before showers return later in the day.", - "## Active Alerts", + "## Alert Digest", "## Daypart Forecast", "- **Morning:**", "- **Afternoon:**", @@ -552,6 +562,9 @@ func TestRenderToday(t *testing.T) { AlertDigest: &testAlertDigest{ Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate", PeriodBegins: "June 15 at 1:00 PM", PeriodEnds: "June 15 at 8:00 PM", Instruction: "Secure outdoor objects."}}, }, + SPCConvectiveOutlooks: &testSPCOutlooks{ + RiskDigest: []testSPCRiskDigest{{LabelText: "Slight Risk", RiskLabel: "Slight risk", PeriodBegins: "June 15 at 7:00 AM", PeriodEnds: "June 16 at 7:00 AM"}}, + }, TodayPlanning: &testTodayPlanning{ MorningReadiness: []string{"Morning weather looks routine."}, LateDayChangeWatch: []string{"Watch late-day shower timing."}, @@ -567,8 +580,9 @@ func TestRenderToday(t *testing.T) { "**Forecast date:** Monday, June 15, 2026", "**Updated:** Monday, June 15, 2026 at 7:14 AM", "Today starts dry before showers return later in the day.", - "## Active Alerts", + "## Alert Digest", "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", + "- **SPC Convective Outlook**: Slight risk for severe thunderstorms in effect from June 15 at 7:00 AM to June 16 at 7:00 AM.", "Currently, it is 58°F and clear. It feels like 57°F, with a relative humidity of 61% and winds from the northwest at 9 mph.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.", "- **Afternoon:** Showers, with temperatures in the upper 70s. Chance of precipitation is 70%.", @@ -584,7 +598,7 @@ func TestRenderToday(t *testing.T) { assertOrderedText(t, text, []string{ "# Today's Weather", "Today starts dry before showers return later in the day.", - "## Active Alerts", + "## Alert Digest", "## Current Conditions", "## Daypart Forecast", "- **Morning:**", @@ -673,8 +687,8 @@ func TestRenderDaypartTemplatesUseRichHelperFields(t *testing.T) { t.Fatalf("rendered template missing %q:\n%s", want, text) } } - if strings.Contains(text, "## Active Alerts") { - t.Fatalf("rendered template includes empty Active Alerts section:\n%s", text) + if strings.Contains(text, "## Alert Digest") { + t.Fatalf("rendered template includes empty Alerts and Risk Products section:\n%s", text) } if strings.Contains(text, "\n\n\n") { t.Fatalf("rendered template includes excess blank lines:\n%s", text) @@ -752,7 +766,7 @@ func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { t.Fatalf("Render() error = %v", err) } text := string(rendered) - for _, unwanted := range []string{"## Active Alerts", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { + for _, unwanted := range []string{"## Alert Digest", "## Precipitation Timing", "Probability of precipitation is 10%", "wind"} { if strings.Contains(text, unwanted) { t.Fatalf("clear render includes %q:\n%s", unwanted, text) } @@ -928,23 +942,26 @@ type testModules struct { } type testTomorrowModules struct { - Dayparts []testTomorrowDaypart - PrecipTiming *testPrecipTiming - AlertDigest *testAlertDigest + Dayparts []testTomorrowDaypart + PrecipTiming *testPrecipTiming + AlertDigest *testAlertDigest + SPCConvectiveOutlooks *testSPCOutlooks } type testTodayModules struct { - CurrentConditions *testCurrentConditions - Dayparts []testTomorrowDaypart - PrecipTiming *testPrecipTiming - AlertDigest *testAlertDigest - TodayPlanning *testTodayPlanning + CurrentConditions *testCurrentConditions + Dayparts []testTomorrowDaypart + PrecipTiming *testPrecipTiming + AlertDigest *testAlertDigest + SPCConvectiveOutlooks *testSPCOutlooks + TodayPlanning *testTodayPlanning } type testDailyModules struct { - Dayparts []testDailyDaypart - PrecipTiming *testPrecipTiming - AlertDigest *testAlertDigest + Dayparts []testDailyDaypart + PrecipTiming *testPrecipTiming + AlertDigest *testAlertDigest + SPCConvectiveOutlooks *testSPCOutlooks } type testTodayPlanning struct { @@ -1046,7 +1063,8 @@ type testAlert struct { } type testSPCOutlooks struct { - Outlooks []testSPCOutlook + Outlooks []testSPCOutlook + RiskDigest []testSPCRiskDigest } type testSPCOutlook struct { @@ -1057,6 +1075,13 @@ type testSPCOutlook struct { PeriodEnds string } +type testSPCRiskDigest struct { + LabelText string + RiskLabel string + PeriodBegins string + PeriodEnds string +} + type testForecastDiscussion struct { KeyMessages []string ShortTerm string diff --git a/internal/reporttemplate/templates/partials/alert_digest.md.tmpl b/internal/reporttemplate/templates/partials/alert_digest.md.tmpl index 04fa6e6..6537afc 100644 --- a/internal/reporttemplate/templates/partials/alert_digest.md.tmpl +++ b/internal/reporttemplate/templates/partials/alert_digest.md.tmpl @@ -1,6 +1,8 @@ -{{ define "alert_digest" }}{{ if and .Modules.AlertDigest .Modules.AlertDigest.Relevant }}## Active Alerts -{{ range .Modules.AlertDigest.Relevant -}} +{{ define "alert_digest" }}{{ if or (and .Modules.AlertDigest .Modules.AlertDigest.Relevant) (and .Modules.SPCConvectiveOutlooks .Modules.SPCConvectiveOutlooks.RiskDigest) }}## Alert Digest +{{ with .Modules.AlertDigest }}{{ range .Relevant -}} - **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**: {{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }} in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}.{{ with .Instruction }} {{ . }}{{ else }}{{ with .Description }} {{ . }}{{ end }}{{ end }} -{{ end }} +{{ end }}{{ end }}{{ with .Modules.SPCConvectiveOutlooks }}{{ range .RiskDigest -}} +- **SPC Convective Outlook**: {{ with .RiskLabel }}{{ . }}{{ else }}Convective risk{{ end }} for severe thunderstorms in effect{{ with .PeriodBegins }} from {{ . }}{{ end }}{{ with .PeriodEnds }} to {{ . }}{{ end }}. +{{ end }}{{ end }} {{ end }}{{ end }}