From 6532e8824a81930f1ded6574997ea27fca661940 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 16 Jun 2026 21:09:17 -0500 Subject: [PATCH] Update the alert digest wording --- docs/templates.md | 4 +++ internal/app/app_test.go | 4 +-- internal/briefing/alert_digest_module.go | 24 +++++++++----- internal/briefing/base_modules_test.go | 33 +++++++++++++++++++ internal/briefing/metadata_module.go | 2 +- internal/briefing/module_format_helpers.go | 11 +++++++ internal/forecast/derive.go | 6 +++- internal/forecast/derive_test.go | 8 ++++- internal/generatedtext/render_context_test.go | 4 +-- .../reporttemplate/reporttemplate_test.go | 26 ++++++++------- .../templates/partials/alert_digest.md.tmpl | 2 +- 11 files changed, 97 insertions(+), 27 deletions(-) diff --git a/docs/templates.md b/docs/templates.md index 1c2c7eb..a5a613d 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -349,6 +349,10 @@ YAML. | `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. | | `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. | | `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. | +| `.Modules.AlertDigest.Relevant[].PeriodBegins` | string | Friendly local alert applicability start. | +| `.Modules.AlertDigest.Relevant[].PeriodEnds` | string | Friendly local alert applicability end. | +| `.Modules.AlertDigest.Relevant[].Instruction` | string | Alert instruction text, when provided. | +| `.Modules.AlertDigest.Relevant[].Description` | string | Alert description text, when provided. | ### SPC Outlooks And Discussion diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 71a66fe..85099de 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -567,7 +567,7 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { "# Hourly Report", "Storm chances increase through late morning.", "## Active Alerts", - "- **Flood Watch**: Flooding possible", + "- **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.", "A front will keep the region unsettled.", @@ -2046,7 +2046,7 @@ func hourlyBundleServer(t *testing.T) *httptest.Server { case "/forecast/narrative": _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`)) case "/alerts/active": - _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`)) + _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","instruction":"Avoid low-water crossings.","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`)) case "/discussion": _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for hourly report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}}`)) case "/weatherstories/latest": diff --git a/internal/briefing/alert_digest_module.go b/internal/briefing/alert_digest_module.go index 0c4d2fa..682b170 100644 --- a/internal/briefing/alert_digest_module.go +++ b/internal/briefing/alert_digest_module.go @@ -16,20 +16,24 @@ type AlertDigestModule struct { } type AlertSummary struct { - Event string `json:"event,omitempty"` - Headline string `json:"headline,omitempty"` - Severity string `json:"severity,omitempty"` + Event string `json:"event,omitempty"` + Headline string `json:"headline,omitempty"` + Severity string `json:"severity,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + Instruction string `json:"instruction,omitempty"` + Description string `json:"description,omitempty"` } func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) { - value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps) + value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone) if value == nil { value = &AlertDigestModule{} } return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil } -func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule { +func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule { missing := sourceMissing(collected.SourceProvenance, "alerts") if collected.Alerts == nil && !missing { return nil @@ -42,9 +46,13 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla value.RelevantCount = len(overlaps) for _, overlap := range overlaps { value.Relevant = append(value.Relevant, AlertSummary{ - Event: overlap.Event, - Headline: overlap.Headline, - Severity: overlap.Severity, + Event: overlap.Event, + Headline: overlap.Headline, + Severity: overlap.Severity, + PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone), + PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone), + Instruction: overlap.Instruction, + Description: overlap.Description, }) } return value diff --git a/internal/briefing/base_modules_test.go b/internal/briefing/base_modules_test.go index c0634ac..35e12dc 100644 --- a/internal/briefing/base_modules_test.go +++ b/internal/briefing/base_modules_test.go @@ -367,6 +367,39 @@ func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) { } } +func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) { + registry := MustDefaultModuleRegistry() + ctx := testModuleContext() + ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}} + ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{ + Event: "Wind Advisory", + Headline: "Wind Advisory until 8 PM", + Severity: "Moderate", + Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")}, + Instruction: "Secure outdoor objects.", + Description: "Gusty winds may blow around unsecured objects.", + }} + + output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest}) + if err != nil { + t.Fatalf("BuildModule(alert digest) error = %v", err) + } + value := moduleValue[AlertDigestModule](t, output) + if len(value.Relevant) != 1 { + t.Fatalf("Relevant length = %d, want 1", len(value.Relevant)) + } + alert := value.Relevant[0] + if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" { + t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert) + } + if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" { + t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds) + } + if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." { + t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert) + } +} + func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := testModuleContext() diff --git a/internal/briefing/metadata_module.go b/internal/briefing/metadata_module.go index c62df4a..f079faf 100644 --- a/internal/briefing/metadata_module.go +++ b/internal/briefing/metadata_module.go @@ -44,7 +44,7 @@ func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) { ValidPeriod: metadata.ValidPeriod, Location: copyLocation(ctx.Location), SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings), - Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps), + Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone), } return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil } diff --git a/internal/briefing/module_format_helpers.go b/internal/briefing/module_format_helpers.go index 83807c1..8509d73 100644 --- a/internal/briefing/module_format_helpers.go +++ b/internal/briefing/module_format_helpers.go @@ -122,6 +122,17 @@ func friendlyDateTimeLabel(value time.Time, timezone string) string { return value.In(location).Format("2006-01-02 at 3:04 PM") } +func friendlyMonthDayTimeLabel(value time.Time, timezone string) string { + if value.IsZero() { + return "" + } + location, err := timeutil.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + return value.In(location).Format("January 2 at 3:04 PM") +} + func friendlyDateLabel(date string, timezone string) string { location, err := timeutil.LoadLocation(timezone) if err != nil { diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index 11fd918..8dee24c 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -65,6 +65,7 @@ type AlertOverlap struct { Period timeutil.Period `json:"period"` Overlap timeutil.Period `json:"overlap"` Description string `json:"description,omitempty"` + Instruction string `json:"instruction,omitempty"` } type PrecipTiming struct { @@ -463,6 +464,7 @@ func AlertOverlaps(alertRun *weatherdata.AlertRun, period timeutil.Period) []Ale Period: alert.Period, Overlap: intersect(alert.Period, period), Description: alert.Description, + Instruction: alert.Instruction, }) } sort.SliceStable(overlaps, func(i int, j int) bool { @@ -476,6 +478,7 @@ type parsedAlert struct { Headline string Severity string Description string + Instruction string Period timeutil.Period } @@ -488,7 +491,8 @@ func parseAlert(raw json.RawMessage) (parsedAlert, bool) { Event: stringField(fields, "event"), Headline: firstStringField(fields, "headline", "title"), Severity: stringField(fields, "severity"), - Description: firstStringField(fields, "description", "instruction"), + Description: stringField(fields, "description"), + Instruction: stringField(fields, "instruction"), } start, startOK := firstTimeField(fields, "onset", "startsAt", "startTime", "effective", "sent") end, endOK := firstTimeField(fields, "ends", "endsAt", "endTime", "expires") diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index 820c537..c0df4f7 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -211,7 +211,7 @@ func TestAlertOverlap(t *testing.T) { } func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) { - raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`) + raw := json.RawMessage(`{"event":"Wind Advisory","headline":"Wind Advisory issued June 16 at 12:39PM CDT until June 17 at 8:00PM CDT by NWS St Louis MO","severity":"Moderate","description":"Southwest winds 20 to 30 mph with gusts up to 45 mph expected.","instruction":"Secure outdoor objects.","effective":"2026-06-16T17:39:00Z","onset":"2026-06-17T18:00:00Z","ends":"2026-06-18T01:00:00Z","expires":"2026-06-17T08:45:00Z"}`) alertRun := &weatherdata.AlertRun{Alerts: []json.RawMessage{raw}} period := timeutil.Period{ Start: mustParse("2026-06-17T12:00:00-05:00"), @@ -234,6 +234,12 @@ func TestAlertOverlapUsesOnsetAndEnds(t *testing.T) { if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-06-18T01:00:00Z" { t.Fatalf("overlap end = %s, want alert ends", overlaps[0].Overlap.End.Format(time.RFC3339)) } + if overlaps[0].Description != "Southwest winds 20 to 30 mph with gusts up to 45 mph expected." { + t.Fatalf("Description = %q, want preserved description", overlaps[0].Description) + } + if overlaps[0].Instruction != "Secure outdoor objects." { + t.Fatalf("Instruction = %q, want preserved instruction", overlaps[0].Instruction) + } } func TestAlertOverlapUsesOnsetAndEndsForLocalDateRelevance(t *testing.T) { diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 4b37919..e234a47 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -91,7 +91,7 @@ func TestBuildHourlyRenderContext(t *testing.T) { "**Updated:** Friday, May 29, 2026 at 8:30 AM", "Storm chances increase through late morning.", "- **10:00 AM:** 75°F and showers. Probability of precipitation is 70%.", - "- **Flood Watch**: Flood Watch until early afternoon", + "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", "A cold front is moving into the region.", "A front will keep the region unsettled.", } { @@ -1190,7 +1190,7 @@ func testSnapshot(t *testing.T) module.Snapshot { ActiveCount: 1, RelevantCount: 1, Relevant: []briefing.AlertSummary{ - {Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate"}, + {Event: "Flood Watch", Headline: "Flood Watch until early afternoon", Severity: "Moderate", PeriodBegins: "May 29 at 10:00 AM", PeriodEnds: "May 29 at 2:30 PM", Instruction: "Avoid low-water crossings."}, }, }, }, diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 40b3af1..2688d6d 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -240,7 +240,7 @@ func TestRenderHourly(t *testing.T) { }, }, AlertDigest: &testAlertDigest{ - Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 2:30 PM", Severity: "Moderate"}}, + 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"}}, @@ -269,7 +269,7 @@ func TestRenderHourly(t *testing.T) { "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", - "- **Flood Watch**: Flood Watch until 2:30 PM", + "- **Flood Watch**: Flood Watch in effect from May 29 at 10:00 AM to May 29 at 2:30 PM. Avoid low-water crossings.", "- **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.", @@ -352,7 +352,7 @@ func TestRenderTomorrow(t *testing.T) { }, }, AlertDigest: &testAlertDigest{ - Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate"}}, + 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."}}, }, }, }) @@ -366,7 +366,7 @@ func TestRenderTomorrow(t *testing.T) { "**Updated:** Sunday, June 14, 2026 at 9:14 AM", "Tomorrow starts dry before showers return later in the day.", "## Active Alerts", - "- **Wind Advisory**: Wind Advisory until 8:00 PM", + "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", "- **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%.", @@ -450,7 +450,7 @@ func TestRenderDaily(t *testing.T) { }, }, AlertDigest: &testAlertDigest{ - Relevant: []testAlert{{Event: "Flood Watch", Headline: "Flood Watch until 6:00 PM", Severity: "Moderate"}}, + 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."}}, }, }, }) @@ -464,7 +464,7 @@ func TestRenderDaily(t *testing.T) { "**Updated:** Sunday, June 14, 2026 at 9:14 AM", "The selected day starts dry before showers return later in the day.", "## Active Alerts", - "- **Flood Watch**: Flood Watch until 6:00 PM", + "- **Flood Watch**: Flood Watch in effect from June 15 at 3:00 PM to June 15 at 6:00 PM. Monitor creek levels.", "- **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.", @@ -550,7 +550,7 @@ func TestRenderToday(t *testing.T) { }, }, AlertDigest: &testAlertDigest{ - Relevant: []testAlert{{Event: "Wind Advisory", Headline: "Wind Advisory until 8:00 PM", Severity: "Moderate"}}, + 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."}}, }, TodayPlanning: &testTodayPlanning{ MorningReadiness: []string{"Morning weather looks routine."}, @@ -568,7 +568,7 @@ func TestRenderToday(t *testing.T) { "**Updated:** Monday, June 15, 2026 at 7:14 AM", "Today starts dry before showers return later in the day.", "## Active Alerts", - "- **Wind Advisory**: Wind Advisory until 8:00 PM", + "- **Wind Advisory**: Wind Advisory in effect from June 15 at 1:00 PM to June 15 at 8:00 PM. Secure outdoor objects.", "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%.", @@ -1036,9 +1036,13 @@ type testAlertDigest struct { } type testAlert struct { - Event string - Headline string - Severity string + Event string + Headline string + Severity string + PeriodBegins string + PeriodEnds string + Instruction string + Description string } type testSPCOutlooks struct { diff --git a/internal/reporttemplate/templates/partials/alert_digest.md.tmpl b/internal/reporttemplate/templates/partials/alert_digest.md.tmpl index bd0423e..04fa6e6 100644 --- a/internal/reporttemplate/templates/partials/alert_digest.md.tmpl +++ b/internal/reporttemplate/templates/partials/alert_digest.md.tmpl @@ -1,6 +1,6 @@ {{ define "alert_digest" }}{{ if and .Modules.AlertDigest .Modules.AlertDigest.Relevant }}## Active Alerts {{ range .Modules.AlertDigest.Relevant -}} -- **{{ if .Event }}{{ .Event }}{{ else }}{{ .Headline }}{{ end }}**{{ with .Headline }}: {{ . }}{{ end }} +- **{{ 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 }}