Clean up and normalize prompt output modules

This commit is contained in:
2026-06-12 12:00:40 -05:00
parent c3a051d372
commit e6a4bb2d16
23 changed files with 456 additions and 158 deletions

View File

@@ -64,13 +64,18 @@ subset of discussion fields.
`spc_convective_outlooks` uses collected SPC run metadata and derived `spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, severity rank, valid times, source URL, image URL, and such as risk label, `period_begins`, `period_ends`, image URL, and whether the
whether the outlook contains the configured location. It does not emit GeoJSON outlook contains the configured location. It does not emit GeoJSON geometry,
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,
`issued_at`, `updated_at`, and point-in-time fields remain separate.
`spc_convective_discussion` uses the same derived report-period outlooks and `spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained outlook has discussion records. It is omitted unless at least one retained categorical
severity rank `3` or higher and matching discussion text exists. outlook for the same SPC day has severity rank `3` or higher and matching
discussion text exists.
## External Adapters Used ## External Adapters Used

View File

@@ -78,14 +78,17 @@ validation.
- `outlooks` - `outlooks`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`, Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`severity_rank`, `valid_start`, `valid_end`, `issued_at`, `expires_at`, `period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`contains_location`, `source_url`, and `image_url`. It omits GeoJSON geometry. `image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
`spc_convective_discussion` emits a narrative stanza only when a retained `spc_convective_discussion` emits a narrative stanza only when a retained
report-period outlook has severity rank `3` or higher and matching discussion report-period categorical outlook has severity rank `3` or higher and matching
text is available. Its output includes `included_because` and `discussions`; discussion text is available. Its output includes `included_because` and
each discussion may include `day`, `headline`, `summary`, `discussion`, and `discussions`; each discussion may include `day`, `period_begins`,
`updated_at`. `period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
Discussions are included only for SPC days whose retained categorical outlooks
meet the severity threshold.
## Boundaries ## Boundaries

View File

@@ -64,6 +64,9 @@ weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`. `promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order. Within each category, stanza order follows the module snapshot output order.
Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
Current categories are: Current categories are:

View File

@@ -42,7 +42,7 @@ plan.
- Do not depend on `/outlooks/convective/active` or - Do not depend on `/outlooks/convective/active` or
`/outlooks/convective/location` for initial behavior. `/outlooks/convective/location` for initial behavior.
- Include SPC discussion text only when at least one retained report-period - Include SPC discussion text only when at least one retained report-period
outlook has `severity_rank >= 3`. categorical outlook has `severity_rank >= 3`.
- Define the discussion threshold as an internal constant, initially `3`, not a - Define the discussion threshold as an internal constant, initially `3`, not a
config field. config field.
@@ -305,13 +305,10 @@ Implementation:
- `outlook_type`; - `outlook_type`;
- `label`; - `label`;
- `label_text`; - `label_text`;
- `severity_rank`; - `period_begins`;
- `valid_start`; - `period_ends`;
- `valid_end`;
- `issued_at`; - `issued_at`;
- `expires_at`;
- `contains_location`; - `contains_location`;
- `source_url`;
- `image_url`. - `image_url`.
- Use human-readable local time helpers consistent with current modules. - Use human-readable local time helpers consistent with current modules.
- Do not emit GeoJSON geometry. - Do not emit GeoJSON geometry.
@@ -359,22 +356,25 @@ Implementation:
- Define a package-private constant near the module, for example: - Define a package-private constant near the module, for example:
`defaultSPCConvectiveDiscussionMinimumSeverityRank = 3`. `defaultSPCConvectiveDiscussionMinimumSeverityRank = 3`.
- Build from derived retained outlooks and discussions. - Build from derived retained outlooks and discussions.
- Include discussion text only when at least one retained outlook has - Include discussion text only when at least one retained categorical outlook
has
`severityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank`. `severityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank`.
- When the threshold is not met, return `nil` output so the stanza is omitted. - When the threshold is not met, return `nil` output so the stanza is omitted.
- When threshold is met, include discussions for retained outlook days with: - When threshold is met, include discussions for retained outlook days with:
- `day`; - `day`;
- `period_begins`;
- `period_ends`;
- `headline`; - `headline`;
- `summary`; - `summary`;
- `discussion`; - `discussion`;
- `updated_at`. - `updated_at`.
- Include a concise reason field such as: - Include a concise reason field such as:
`included_because: "severity_rank >= 3"`. `included_because: "categorical severity_rank >= 3"`.
Acceptance criteria: Acceptance criteria:
- Slight Risk or higher retained outlooks include matching discussion records - Slight Risk or higher retained categorical outlooks include matching
when available. discussion records when available.
- Lower-risk retained outlooks still appear in `spc_convective_outlooks` but - Lower-risk retained outlooks still appear in `spc_convective_outlooks` but
do not emit `spc_convective_discussion`. do not emit `spc_convective_discussion`.
- Missing discussion text omits the stanza without failing report generation. - Missing discussion text omits the stanza without failing report generation.
@@ -542,7 +542,8 @@ Manual review:
- Confirm no module builder performs Weather API calls. - Confirm no module builder performs Weather API calls.
- Confirm prompt YAML omits GeoJSON geometry. - Confirm prompt YAML omits GeoJSON geometry.
- Confirm checked-empty outlook data is not represented as missing data. - Confirm checked-empty outlook data is not represented as missing data.
- Confirm SPC discussion text appears only at severity rank `3` or higher. - Confirm SPC discussion text appears only for categorical outlooks at severity
rank `3` or higher.
- Confirm public CLI syntax, output paths, distributor upload behavior, and - Confirm public CLI syntax, output paths, distributor upload behavior, and
Scriptorium argv remain unchanged. Scriptorium argv remain unchanged.

View File

@@ -57,8 +57,8 @@ Important response semantics:
- Place `spc_convective_outlooks` under `applicable_risk_products`. - Place `spc_convective_outlooks` under `applicable_risk_products`.
- Place `spc_convective_discussion` under `narrative_products`, immediately - Place `spc_convective_discussion` under `narrative_products`, immediately
after `area_forecast_discussion` in report module order when both are present. after `area_forecast_discussion` in report module order when both are present.
- Include SPC outlook discussion text only when at least one retained outlook - Include SPC outlook discussion text only when at least one retained
for the report valid period has `severity_rank >= 3`. categorical outlook for the report valid period has `severity_rank >= 3`.
- Define that threshold as an internal constant so it can be adjusted later - Define that threshold as an internal constant so it can be adjusted later
without searching through module code. without searching through module code.
- Treat a non-null run with empty arrays as checked empty data, not missing - Treat a non-null run with empty arrays as checked empty data, not missing
@@ -104,11 +104,9 @@ briefing:
outlook_type: categorical outlook_type: categorical
label: SLGT label: SLGT
label_text: Slight Risk label_text: Slight Risk
severity_rank: 3 period_begins: "2026-06-12 at 8:00 AM"
valid_start: "2026-06-12 at 8:00 AM" period_ends: "2026-06-13 at 7:00 AM"
valid_end: "2026-06-13 at 7:00 AM"
contains_location: true contains_location: true
source_url: "https://..."
image_url: "https://..." image_url: "https://..."
``` ```
@@ -119,9 +117,11 @@ briefing:
narrative_products: narrative_products:
area_forecast_discussion: {} area_forecast_discussion: {}
spc_convective_discussion: spc_convective_discussion:
included_because: "severity_rank >= 3" included_because: "categorical severity_rank >= 3"
discussions: discussions:
- day: 1 - day: 1
period_begins: "2026-06-12 at 8:00 AM"
period_ends: "2026-06-13 at 7:00 AM"
headline: "Severe storms possible" headline: "Severe storms possible"
summary: "Scattered severe storms are possible." summary: "Scattered severe storms are possible."
discussion: "SPC discussion text." discussion: "SPC discussion text."

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -204,6 +205,7 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok { if _, ok := savedDataPackage.Briefing.Values["metadata"]; !ok {
t.Fatal("data package metadata stanza missing") t.Fatal("data package metadata stanza missing")
} }
assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values)
spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any) spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any)
if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 { if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 {
t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"]) t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"])
@@ -274,15 +276,21 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
" spc_convective_outlooks:", " spc_convective_outlooks:",
" spc_convective_discussion:", " spc_convective_discussion:",
" included_because: severity_rank >= 3", " included_because: categorical severity_rank >= 3",
" label_text: Slight Risk", " label_text: Slight Risk",
" severity_rank: 3", " period_begins:",
" period_ends:",
" discussion: Severe thunderstorms may produce damaging winds during the afternoon.", " discussion: Severe thunderstorms may produce damaging winds during the afternoon.",
} { } {
if !strings.Contains(text, want) { if !strings.Contains(text, want) {
t.Fatalf("data package missing %q:\n%s", want, text) t.Fatalf("data package missing %q:\n%s", want, text)
} }
} }
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
}
}
riskIndex := strings.Index(text, " applicable_risk_products:") riskIndex := strings.Index(text, " applicable_risk_products:")
alertIndex := strings.Index(text, " alert_digest:") alertIndex := strings.Index(text, " alert_digest:")
@@ -304,6 +312,7 @@ func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("LoadYAML() error = %v", err) t.Fatalf("LoadYAML() error = %v", err)
} }
assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values)
if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok { if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok {
t.Fatal("loaded package missing spc_convective_outlooks stanza") t.Fatal("loaded package missing spc_convective_outlooks stanza")
} }
@@ -324,9 +333,14 @@ func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T)
t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold") t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold")
} }
text := string(readDataPackageForTest(t, result)) text := string(readDataPackageForTest(t, result))
if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") || !strings.Contains(text, " severity_rank: 2") { if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") {
t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text) t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text)
} }
for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} {
if strings.Contains(text, omitted) {
t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text)
}
}
if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") { if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") {
t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text) t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text)
} }
@@ -1470,6 +1484,34 @@ func readDataPackageForTest(t *testing.T, result *ReportResult) []byte {
return data return data
} }
func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) {
t.Helper()
for name, value := range values {
if name == "metadata" {
continue
}
assertNoStaleIntervalKeys(t, "briefing."+name, value)
}
}
func assertNoStaleIntervalKeys(t *testing.T, path string, value any) {
t.Helper()
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
switch key {
case "start_time", "end_time", "period", "start", "end":
t.Fatalf("%s has stale interval key %q in %#v", path, key, typed)
}
assertNoStaleIntervalKeys(t, path+"."+key, child)
}
case []any:
for i, child := range typed {
assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child)
}
}
}
func assertPathsExist(t *testing.T, paths ...string) { func assertPathsExist(t *testing.T, paths ...string) {
t.Helper() t.Helper()
for _, path := range paths { for _, path := range paths {
@@ -1515,9 +1557,10 @@ func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Sna
}}, }},
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
"morning": map[string]any{ "morning": map[string]any{
"date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout),
"period": resolved.ValidPeriod.Start.Add(6*time.Hour).Format("2006-01-02 at 3:04 PM") + " to " + resolved.ValidPeriod.Start.Add(10*time.Hour).Format("2006-01-02 at 3:04 PM"), "period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58", "period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"),
"temp_range_f": "50-58",
}, },
}}, }},
{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{ {ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{
@@ -1539,7 +1582,8 @@ func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot {
{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{
date + "_morning": map[string]any{ date + "_morning": map[string]any{
"date": date, "date": date,
"period": date + " at 6:00 AM to " + date + " at 10:00 AM", "period_begins": date + " at 6:00 AM",
"period_ends": date + " at 10:00 AM",
"temp_range_f": "50-58", "temp_range_f": "50-58",
"max_pop_percent": precip, "max_pop_percent": precip,
"max_pop_time": "6 AM", "max_pop_time": "6 AM",

View File

@@ -62,8 +62,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 { if period.TextDescription != "Showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period) t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
} }
if period.StartTime != "2026-05-29 at 8:00 AM" || period.EndTime != "2026-05-29 at 9:00 AM" { if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
} }
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 { if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period) t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
@@ -73,7 +73,7 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
t.Fatalf("Marshal hourly forecast: %v", err) t.Fatalf("Marshal hourly forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} { for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field) t.Fatalf("hourly json = %s, want field %s", jsonText, field)
} }
@@ -81,8 +81,8 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") { if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText) t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("hourly json = %s, want friendly local start/end times", jsonText) t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
} }
} }
@@ -113,8 +113,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." { if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period) t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
} }
if period.StartTime != "2026-05-29 at 6:00 AM" || period.EndTime != "2026-05-29 at 6:00 PM" { if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.StartTime, period.EndTime) t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
} }
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 { if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period) t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
@@ -127,7 +127,7 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
t.Fatalf("Marshal narrative forecast: %v", err) t.Fatalf("Marshal narrative forecast: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"source_location_id", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} { for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field) t.Fatalf("narrative json = %s, want field %s", jsonText, field)
} }
@@ -135,8 +135,8 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
if strings.Contains(jsonText, "wind_direction_degrees") { if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText) t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
} }
if strings.Contains(jsonText, `"start_time":"2026-05-29T`) || strings.Contains(jsonText, `"end_time":"2026-05-29T`) { if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want friendly local start/end times", jsonText) t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
} }
if strings.Contains(jsonText, "Tomorrow night") { if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText) t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
@@ -285,13 +285,19 @@ func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." { if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story) t.Fatalf("WeatherStory = %#v, want structured story fields", story)
} }
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
}
data, err := json.Marshal(storyOutput.Value) data, err := json.Marshal(storyOutput.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal weather story: %v", err) t.Fatalf("Marshal weather story: %v", err)
} }
if !strings.Contains(string(data), "download_url") { if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data)) t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
} }
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
}
} }
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) { func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {

View File

@@ -12,7 +12,8 @@ import (
type DerivedDaypartSummaryModule struct { type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
@@ -46,7 +47,8 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
value := DerivedDaypartSummaryModule{ value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone), Date: localDateLabel(daypart.Period.Start, timezone),
Period: friendlyPeriodLabel(daypart.Period, timezone), PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature), TempRangeF: rangeLabel(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition, DominantCondition: daypart.DominantCondition,

View File

@@ -102,10 +102,10 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if len(rainy.PrecipitationWindows) != 2 { if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
} }
if rainy.PrecipitationWindows[0].Start != "8 AM" || rainy.PrecipitationWindows[0].End != "9 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 { if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
} }
if rainy.PrecipitationWindows[1].Start != "12 PM" || rainy.PrecipitationWindows[1].End != "2 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 { if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
} }
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
@@ -115,6 +115,9 @@ func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") { if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data)) t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
} }
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
}
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") { if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
} }
@@ -150,8 +153,8 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning) t.Fatalf("morning = %#v, want temp range and precip peak", morning)
} }
if morning.Date != "2026-05-29" || morning.Period != "2026-05-29 at 6:00 AM to 2026-05-29 at 12:00 PM" { if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q, want friendly local date and period labels", morning.Date, morning.Period) t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
} }
afternoon := value["afternoon"] afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
@@ -166,12 +169,12 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err) t.Fatalf("marshal daypart summaries: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"date", "period", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field) t.Fatalf("daypart json = %s, want field %s", jsonText, field)
} }
} }
if strings.Contains(jsonText, `"period":{"start"`) || strings.Contains(jsonText, `T06:00:00`) { if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText) t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
} }
} }
@@ -191,6 +194,12 @@ func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor) t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
} }
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
}
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
}
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning}) planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -17,8 +18,8 @@ type HourlyForecastModule struct {
} }
type HourlyForecastPeriod struct { type HourlyForecastPeriod struct {
StartTime string `json:"start_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"` ConditionCode *int `json:"condition_code,omitempty"`
@@ -74,9 +75,10 @@ func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error)
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod { func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods)) out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, HourlyForecastPeriod{ out = append(out, HourlyForecastPeriod{
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
Name: period.Name, Name: period.Name,
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode), ConditionCode: copyInt(period.ConditionCode),

View File

@@ -67,18 +67,18 @@ func timedClockLabel(value *forecast.TimedValue, timezone string) string {
return clockLabel(value.Time, timezone) return clockLabel(value.Time, timezone)
} }
func periodClockLabel(period timeutil.Period, timezone string) string { func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) return friendlyDateTimeLabel(period.Start, timezone)
} }
func friendlyPeriodLabel(period timeutil.Period, timezone string) string { func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() { if !period.IsValid() {
return "" return ""
} }
return friendlyDateTimeLabel(period.Start, timezone) + " to " + friendlyDateTimeLabel(period.End, timezone) return friendlyDateTimeLabel(period.End, timezone)
} }
func friendlyDateTimeLabel(value time.Time, timezone string) string { func friendlyDateTimeLabel(value time.Time, timezone string) string {

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -18,8 +19,8 @@ type NarrativeForecastModule struct {
type NarrativeForecastPeriod struct { type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
StartTime string `json:"start_time,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime string `json:"end_time,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
IsDay *bool `json:"is_day,omitempty"` IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"` TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"` TemperatureC *float64 `json:"temperature_c,omitempty"`
@@ -58,10 +59,11 @@ func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, err
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod { func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods)) out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods { for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, NarrativeForecastPeriod{ out = append(out, NarrativeForecastPeriod{
Name: period.Name, Name: period.Name,
StartTime: friendlyDateTimeLabel(period.StartTime, timezone), PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
EndTime: friendlyDateTimeLabel(period.EndTime, timezone), PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
IsDay: copyBool(period.IsDay), IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription, TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC), TemperatureC: copyFloat(period.TemperatureC),

View File

@@ -8,31 +8,31 @@ type OutdoorWindowsModule struct {
} }
type OutdoorWindowModule struct { type OutdoorWindowModule struct {
Daypart string `json:"daypart"` Daypart string `json:"daypart"`
Start string `json:"start"` PeriodBegins string `json:"period_begins,omitempty"`
End string `json:"end"` PeriodEnds string `json:"period_ends,omitempty"`
Reasons []string `json:"reasons,omitempty"` Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"` Score float64 `json:"score"`
} }
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{ value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best), Best: outdoorWindowValue(windows.Best, ctx.Timezone),
Worst: outdoorWindowValue(windows.Worst), Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
} }
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
} }
func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
if window == nil { if window == nil {
return nil return nil
} }
return &OutdoorWindowModule{ return &OutdoorWindowModule{
Daypart: window.Daypart, Daypart: window.Daypart,
Start: window.Start, PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
End: window.End, PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
Reasons: append([]string(nil), window.Reasons...), Reasons: append([]string(nil), window.Reasons...),
Score: window.Score, Score: window.Score,
} }
} }

View File

@@ -14,8 +14,8 @@ type PrecipTimingModule struct {
} }
type PrecipitationWindowModule struct { type PrecipitationWindowModule struct {
Start string `json:"start"` PeriodBegins string `json:"period_begins"`
End string `json:"end,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
} }
@@ -36,10 +36,10 @@ func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimi
} }
for _, window := range timing.PrecipitationWindows { for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{ item := PrecipitationWindowModule{
Start: clockLabel(window.Start, timezone), PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
} }
if window.End != nil { if window.End != nil {
item.End = clockLabel(*window.End, timezone) item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
} }
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value) item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone) item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)

View File

@@ -4,10 +4,12 @@ import (
"fmt" "fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3 const defaultSPCConvectiveDiscussionMinimumSeverityRank = 3
const spcCategoricalOutlookType = "categorical"
type SPCConvectiveDiscussionModule struct { type SPCConvectiveDiscussionModule struct {
IncludedBecause string `json:"included_because"` IncludedBecause string `json:"included_because"`
@@ -15,56 +17,77 @@ type SPCConvectiveDiscussionModule struct {
} }
type SPCConvectiveDiscussionRecord struct { type SPCConvectiveDiscussionRecord struct {
Day int `json:"day,omitempty"` Day int `json:"day,omitempty"`
Headline string `json:"headline,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
Summary string `json:"summary,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
Discussion string `json:"discussion,omitempty"` Headline string `json:"headline,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"` Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
} }
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
if !hasSPCConvectiveDiscussionThreshold(ctx.Derived.SPCConvectiveOutlooks) { qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
if len(qualifyingPeriods) == 0 {
return nil, nil return nil, nil
} }
records := spcConvectiveDiscussionRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone) records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
if len(records) == 0 { if len(records) == 0 {
return nil, nil return nil, nil
} }
value := SPCConvectiveDiscussionModule{ value := SPCConvectiveDiscussionModule{
IncludedBecause: fmt.Sprintf("severity_rank >= %d", defaultSPCConvectiveDiscussionMinimumSeverityRank), IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
Discussions: records, Discussions: records,
} }
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
} }
func hasSPCConvectiveDiscussionThreshold(outlooks []weatherdata.ConvectiveOutlook) bool { func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
periods := map[int]timeutil.Period{}
for _, outlook := range outlooks { for _, outlook := range outlooks {
if outlook.SeverityRank != nil && *outlook.SeverityRank >= defaultSPCConvectiveDiscussionMinimumSeverityRank { if outlook.OutlookType != spcCategoricalOutlookType {
return true continue
} }
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
continue
}
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
if existing, ok := periods[outlook.Day]; ok {
if outlookPeriod.Start.Before(existing.Start) {
existing.Start = outlookPeriod.Start
}
if outlookPeriod.End.After(existing.End) {
existing.End = outlookPeriod.End
}
periods[outlook.Day] = existing
continue
}
periods[outlook.Day] = outlookPeriod
} }
return false return periods
} }
func spcConvectiveDiscussionRecords(outlooks []weatherdata.ConvectiveOutlook, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord { func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
retainedDays := map[int]struct{}{}
for _, outlook := range outlooks {
retainedDays[outlook.Day] = struct{}{}
}
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions)) records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
for _, discussion := range discussions { for _, discussion := range discussions {
if _, ok := retainedDays[discussion.Day]; !ok { period, ok := qualifyingPeriods[discussion.Day]
if !ok {
continue continue
} }
if discussion.Discussion == "" { if discussion.Discussion == "" {
continue continue
} }
records = append(records, SPCConvectiveDiscussionRecord{ records = append(records, SPCConvectiveDiscussionRecord{
Day: discussion.Day, Day: discussion.Day,
Headline: discussion.Headline, PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
Summary: discussion.Summary, PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
Discussion: discussion.Discussion, Headline: discussion.Headline,
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone), Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
}) })
} }
return records return records

View File

@@ -48,13 +48,16 @@ func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testin
t.Fatalf("output = %#v, want spc convective discussion stanza", output) t.Fatalf("output = %#v, want spc convective discussion stanza", output)
} }
value := moduleValue[SPCConvectiveDiscussionModule](t, output) value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if value.IncludedBecause != "severity_rank >= 3" || len(value.Discussions) != 1 { if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
t.Fatalf("value = %#v, want threshold reason and one discussion", value) t.Fatalf("value = %#v, want threshold reason and one discussion", value)
} }
discussion := value.Discussions[0] discussion := value.Discussions[0]
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" { if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion) t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
} }
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
}
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" { if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt) t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
} }
@@ -63,11 +66,14 @@ func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testin
t.Fatalf("Marshal() error = %v", err) t.Fatalf("Marshal() error = %v", err)
} }
text := string(data) text := string(data)
for _, field := range []string{"included_because", "discussions", "headline", "summary", "discussion", "updated_at"} { for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
if !strings.Contains(text, field) { if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field) t.Fatalf("json = %s, want field %s", text, field)
} }
} }
if strings.Contains(text, `"period":`) {
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
}
} }
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) { func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
@@ -86,6 +92,162 @@ func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testin
} }
} }
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank := 30
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-wind-30",
Day: 1,
OutlookType: "wind",
Label: "30%",
LabelText: "30% Wind Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
}
}
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank2 := 2
rank30 := 30
ctx := testModuleContext()
categorical := weatherdata.ConvectiveOutlook{
ID: "day1-marginal",
Day: 1,
OutlookType: "categorical",
Label: "MRGL",
LabelText: "Marginal Risk",
SeverityRank: &rank2,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
wind := weatherdata.ConvectiveOutlook{
ID: "day1-wind-30",
Day: 1,
OutlookType: "wind",
Label: "30%",
LabelText: "30% Wind Risk",
SeverityRank: &rank30,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
}
}
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank2 := 2
rank4 := 4
ctx := testModuleContext()
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
day1Outlook := weatherdata.ConvectiveOutlook{
ID: "day1-marginal",
Day: 1,
OutlookType: "categorical",
Label: "MRGL",
LabelText: "Marginal Risk",
SeverityRank: &rank2,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
day2Outlook := weatherdata.ConvectiveOutlook{
ID: "day2-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank4,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if len(value.Discussions) != 1 {
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
}
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
}
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
}
}
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank := 5
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day2-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) { func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{ ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
@@ -22,13 +23,10 @@ type SPCConvectiveOutlookRecord struct {
OutlookType string `json:"outlook_type,omitempty"` OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"` LabelText string `json:"label_text,omitempty"`
SeverityRank *int `json:"severity_rank,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
ValidStart string `json:"valid_start,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
ValidEnd string `json:"valid_end,omitempty"`
IssuedAt string `json:"issued_at,omitempty"` IssuedAt string `json:"issued_at,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
ContainsLocation bool `json:"contains_location"` ContainsLocation bool `json:"contains_location"`
SourceURL string `json:"source_url,omitempty"`
ImageURL string `json:"image_url,omitempty"` ImageURL string `json:"image_url,omitempty"`
} }
@@ -50,26 +48,27 @@ func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output,
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone) value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
} }
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Timezone) value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
value.OutlookCount = len(value.Outlooks) value.OutlookCount = len(value.Outlooks)
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
} }
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, timezone string) []SPCConvectiveOutlookRecord { func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks)) records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
for _, outlook := range outlooks { for _, outlook := range outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
records = append(records, SPCConvectiveOutlookRecord{ records = append(records, SPCConvectiveOutlookRecord{
Day: outlook.Day, Day: outlook.Day,
OutlookType: outlook.OutlookType, OutlookType: outlook.OutlookType,
Label: outlook.Label, Label: outlook.Label,
LabelText: outlook.LabelText, LabelText: outlook.LabelText,
SeverityRank: copyInt(outlook.SeverityRank), PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
ValidStart: friendlyDateTimeLabel(outlook.ValidFrom, timezone), PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
ValidEnd: friendlyDateTimeLabel(outlook.ValidTo, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone), IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ExpiresAt: friendlyOptionalTime(outlook.ExpiresAt, timezone),
ContainsLocation: outlook.ContainsLocation, ContainsLocation: outlook.ContainsLocation,
SourceURL: outlook.SourceURL,
ImageURL: outlook.ImageURL, ImageURL: outlook.ImageURL,
}) })
} }

View File

@@ -65,32 +65,60 @@ func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" { if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got) t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
} }
if got.SeverityRank == nil || *got.SeverityRank != 3 { if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("SeverityRank = %#v, want 3", got.SeverityRank)
}
if got.ValidStart != "2026-05-29 at 11:00 AM" || got.ValidEnd != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" || got.ExpiresAt != "2026-05-30 at 7:00 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got) t.Fatalf("outlook times = %#v, want friendly local labels", got)
} }
if !got.ContainsLocation || got.SourceURL == "" || got.ImageURL == "" { if !got.ContainsLocation || got.ImageURL == "" {
t.Fatalf("outlook = %#v, want location flag and source/image URLs", got) t.Fatalf("outlook = %#v, want location flag and image URL", got)
} }
data, err := json.Marshal(output.Value) data, err := json.Marshal(output.Value)
if err != nil { if err != nil {
t.Fatalf("Marshal() error = %v", err) t.Fatalf("Marshal() error = %v", err)
} }
text := string(data) text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "valid_start", "valid_end", "contains_location", "source_url", "image_url"} { for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) { if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field) t.Fatalf("json = %s, want field %s", text, field)
} }
} }
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider"} { for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
if strings.Contains(text, omitted) { if strings.Contains(text, omitted) {
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted) t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
} }
} }
} }
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
rank := 5
outlook := weatherdata.ConvectiveOutlook{
ID: "tomorrow-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
ContainsLocation: true,
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{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)
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
}
}
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) { func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := testModuleContext() ctx := testModuleContext()

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type OutdoorWindows struct { type OutdoorWindows struct {
@@ -16,8 +17,7 @@ type OutdoorWindows struct {
type OutdoorWindow struct { type OutdoorWindow struct {
Daypart string Daypart string
Start string Period timeutil.Period
End string
Reasons []string Reasons []string
Score float64 Score float64
} }
@@ -190,8 +190,7 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
} }
return OutdoorWindow{ return OutdoorWindow{
Daypart: daypart.Name, Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"), Period: daypart.Period,
End: daypart.Period.End.Format("15:04"),
Reasons: dedupe(reasons), Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10, Score: math.Round(score*10) / 10,
} }

View File

@@ -4,20 +4,21 @@ import (
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
type WeatherStoryModule struct { type WeatherStoryModule struct {
Available bool `json:"available"` Available bool `json:"available"`
OfficeID string `json:"office_id,omitempty"` OfficeID string `json:"office_id,omitempty"`
StartTime time.Time `json:"start_time"` PeriodBegins string `json:"period_begins,omitempty"`
EndTime time.Time `json:"end_time"` PeriodEnds string `json:"period_ends,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
AltText string `json:"alt_text,omitempty"` AltText string `json:"alt_text,omitempty"`
Priority bool `json:"priority"` Priority bool `json:"priority"`
Order int `json:"order"` Order int `json:"order"`
DownloadURL string `json:"download_url,omitempty"` DownloadURL string `json:"download_url,omitempty"`
} }
func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -25,18 +26,19 @@ func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) {
if story == nil { if story == nil {
return nil, nil return nil, nil
} }
period := timeutil.Period{Start: story.StartTime, End: story.EndTime}
value := WeatherStoryModule{ value := WeatherStoryModule{
Available: true, Available: true,
OfficeID: story.OfficeID, OfficeID: story.OfficeID,
StartTime: story.StartTime, PeriodBegins: friendlyPeriodBeginsLabel(period, ctx.Timezone),
EndTime: story.EndTime, PeriodEnds: friendlyPeriodEndsLabel(period, ctx.Timezone),
UpdatedAt: copyTime(story.UpdatedAt), UpdatedAt: copyTime(story.UpdatedAt),
Title: story.Title, Title: story.Title,
Description: story.Description, Description: story.Description,
AltText: story.AltText, AltText: story.AltText,
Priority: story.Priority, Priority: story.Priority,
Order: story.Order, Order: story.Order,
DownloadURL: story.DownloadURL, DownloadURL: story.DownloadURL,
} }
return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil
} }

View File

@@ -84,7 +84,8 @@ type dailySummaryStanza struct {
type daypartSummaryStanza struct { type daypartSummaryStanza struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Period string `json:"period,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`

View File

@@ -96,7 +96,13 @@ func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime strin
DailyPrecipitationProbability: &precip, DailyPrecipitationProbability: &precip,
}}, }},
module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
"morning": {Date: "2026-05-29", Period: "2026-05-29 at 6:00 AM to 2026-05-29 at 10:00 AM", TempRangeF: "60-70", Snow: snow}, "morning": {
Date: "2026-05-29",
PeriodBegins: "2026-05-29 at 6:00 AM",
PeriodEnds: "2026-05-29 at 10:00 AM",
TempRangeF: "60-70",
Snow: snow,
},
}}, }},
module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}},
module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}},

View File

@@ -32,7 +32,8 @@ func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, pr
return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{
date + "_morning": { date + "_morning": {
Date: date, Date: date,
Period: date + " at 6:00 AM to " + date + " at 10:00 AM", PeriodBegins: date + " at 6:00 AM",
PeriodEnds: date + " at 10:00 AM",
TempRangeF: tempRange, TempRangeF: tempRange,
MaxPopPercent: &precip, MaxPopPercent: &precip,
MaxPopTime: precipTime, MaxPopTime: precipTime,