package briefing import ( "encoding/json" "strings" "testing" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[DerivedDailySummaryModule](t, output) if value.Date != "Friday, May 29, 2026" { t.Fatalf("Date = %q, want friendly local date", value.Date) } if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 { t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF) } if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 { t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability) } if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned { t.Fatalf("precip timing = %#v, want most likely hour and thunder", value) } if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") { t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions) } if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 { t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph) } if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 { t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal daily summary: %v", err) } jsonText := string(data) for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} { if !strings.Contains(jsonText, field) { t.Fatalf("daily json = %s, want field %s", jsonText, field) } } for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} { if strings.Contains(jsonText, removed) { t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed) } } if strings.Contains(jsonText, "qpf") { t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText) } } func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) ctx.Derived.DailySummaries[0].NarrativePeriods = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[DerivedDailySummaryModule](t, output) if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 { t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF) } if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 { t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability) } if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") { t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions) } } func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) if err != nil { t.Fatalf("BuildModule(rainy) error = %v", err) } rainy := moduleValue[PrecipTimingModule](t, output) if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned { t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy) } if len(rainy.PrecipitationWindows) != 2 { t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows) } if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" { t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0]) } if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" { t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1]) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal precip timing: %v", err) } if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") { 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") { t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data)) } ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)}) output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) if err != nil { t.Fatalf("BuildModule(dry) error = %v", err) } dry := moduleValue[PrecipTimingModule](t, output) if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned { t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry) } if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 { t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent) } } func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.Hourly) ctx.Derived.DailySummaries = nil ctx.Derived.DaypartSummaries = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[PrecipTimingModule](t, output) if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 { t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value) } } func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}) if err != nil { t.Fatalf("BuildModule() error = %v", err) } value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output) morning, ok := value["morning"] if !ok { t.Fatalf("daypart keys = %#v, want configured morning key", value) } if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { t.Fatalf("morning = %#v, want temp range and precip peak", morning) } if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" { t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning) } 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/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds) } overnight := value["overnight"] if overnight.MentionPrecipitation { t.Fatalf("overnight MentionPrecipitation = true, want false below threshold") } afternoon := value["afternoon"] if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) } if !overnight.Cold { t.Fatalf("overnight = %#v, want cold hazard", overnight) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal daypart summaries: %v", err) } jsonText := string(data) for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} { if !strings.Contains(jsonText, field) { t.Fatalf("daypart json = %s, want field %s", jsonText, field) } } 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) } } func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) { tests := []struct { name string temps []float64 wantTrend string wantStart string wantEnd string wantPeak string wantSteady string }{ { name: "rising", temps: []float64{58, 68}, wantTrend: "rising", wantStart: "upper 50s", wantEnd: "upper 60s", }, { name: "falling", temps: []float64{65, 58}, wantTrend: "falling", wantStart: "mid 60s", wantEnd: "upper 50s", }, { name: "peaking", temps: []float64{62, 78, 65}, wantTrend: "peaking", wantPeak: "upper 70s", }, { name: "steady same band", temps: []float64{77, 78}, wantTrend: "steady", wantSteady: "upper 70s", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...) value := derivedDaypartSummaryValue(summary, "America/Chicago") if value.DominantConditionDisplay != "Sunny" { t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay) } if value.TemperatureTrend != test.wantTrend || value.TemperatureStartPhraseF != test.wantStart || value.TemperatureEndPhraseF != test.wantEnd || value.TemperaturePeakPhraseF != test.wantPeak || value.TemperatureSteadyPhraseF != test.wantSteady { t.Fatalf("temperature presentation = %#v", value) } }) } } func TestTemperaturePhraseF(t *testing.T) { tests := []struct { name string value forecast.Range want string }{ { name: "single low band", value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)}, want: "low 70s", }, { name: "single upper value", value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)}, want: "upper 60s", }, { name: "range across bands", value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)}, want: "upper 60s to mid 70s", }, { name: "max only", value: forecast.Range{Max: floatPtr(84)}, want: "mid 80s", }, { name: "empty", value: forecast.Range{}, want: "", }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := temperaturePhraseF(test.value); got != test.want { t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want) } }) } } func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.Tomorrow) outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) if err != nil { t.Fatalf("BuildModule(outdoor windows) error = %v", err) } outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput) if outdoor.Best == nil || outdoor.Worst == nil { t.Fatalf("outdoor windows = %#v, want best and worst", outdoor) } if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" { 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}) if err != nil { t.Fatalf("BuildModule(tomorrow planning) error = %v", err) } planning := moduleValue[TomorrowPlanningModule](t, planningOutput) if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 { t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning) } data, err := json.Marshal(planningOutput.Value) if err != nil { t.Fatalf("marshal tomorrow planning: %v", err) } if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") { t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data)) } } func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := dailyModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) if err != nil { t.Fatalf("BuildModule(daily planning) error = %v", err) } if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" { t.Fatalf("output = %#v, want daily planning stanza", output) } planning := moduleValue[DailyPlanningModule](t, output) if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 { t.Fatalf("daily planning = %#v, want populated planning fields", planning) } if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") { t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness) } if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") { t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns) } if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") { t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal daily planning: %v", err) } jsonText := string(data) for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} { if !strings.Contains(jsonText, field) { t.Fatalf("daily planning json = %s, want field %s", jsonText, field) } } if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") { t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText) } } func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() for _, id := range []report.ID{report.DailyToday, report.Today, report.Tomorrow, report.Hourly, report.ThreeDay, report.Weekend, report.Storm} { t.Run(string(id), func(t *testing.T) { ctx := derivedModuleContext(id) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) { t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err) } }) } } func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := dailyModuleContext() ctx.Derived.DailySummaries = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning}) if err != nil { t.Fatalf("BuildModule(daily planning) error = %v", err) } planning := moduleValue[DailyPlanningModule](t, output) if len(planning.MorningReadiness) != 0 || len(planning.CommuteSchoolWorkdayConcerns) != 0 || len(planning.OvernightChangeWatch) != 0 { t.Fatalf("daily planning = %#v, want empty output without daily summary", planning) } } func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := todayModuleContext() output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) if err != nil { t.Fatalf("BuildModule(today planning) error = %v", err) } if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" { t.Fatalf("output = %#v, want today planning stanza", output) } planning := moduleValue[TodayPlanningModule](t, output) if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OutdoorPlanning) == 0 || len(planning.LateDayChangeWatch) == 0 { t.Fatalf("today planning = %#v, want populated planning fields", planning) } if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") { t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness) } if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") || !containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") { t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning) } if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") { t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch) } data, err := json.Marshal(output.Value) if err != nil { t.Fatalf("marshal today planning: %v", err) } jsonText := string(data) for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} { if !strings.Contains(jsonText, field) { t.Fatalf("today planning json = %s, want field %s", jsonText, field) } } if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") { t.Fatalf("today planning json = %s, want snake_case fields", jsonText) } } func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) { registry := MustDefaultModuleRegistry() for _, id := range []report.ID{report.Tomorrow, report.DailyToday} { t.Run(string(id), func(t *testing.T) { ctx := derivedModuleContext(id) _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) { t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err) } }) } } func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := todayModuleContext() ctx.Derived.DailySummaries = nil output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning}) if err != nil { t.Fatalf("BuildModule(today planning) error = %v", err) } planning := moduleValue[TodayPlanningModule](t, output) if len(planning.MorningReadiness) != 0 || len(planning.CommuteSchoolWorkdayConcerns) != 0 || len(planning.OutdoorPlanning) != 0 || len(planning.LateDayChangeWatch) != 0 { t.Fatalf("today planning = %#v, want empty output without daily summary", planning) } } func TestDerivedModulesHandleMissingData(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.DailyToday) ctx.Derived.DailySummaries = nil ctx.Derived.DaypartSummaries = nil if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil { t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error") } if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil { t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error") } output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows}) if err != nil { t.Fatalf("BuildModule(outdoor windows) error = %v", err) } windows := moduleValue[OutdoorWindowsModule](t, output) if windows.Best != nil || windows.Worst != nil { t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows) } } func derivedModuleContext(id report.ID) ModuleContext { generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00") definition := report.DefaultRegistry().MustLookup(id) summary := forecast.DailySummary{ Date: "2026-05-29", Period: timeutil.Period{ Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"), End: mustParseModuleTime("2026-05-30T00:00:00-05:00"), }, Dayparts: []forecast.DaypartSummary{ derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5), derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15), derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42), }, } hours := []weatherdata.ForecastPeriod{ derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5), derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15), derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10), derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42), derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30), derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12), } narrative := []weatherdata.ForecastPeriod{ { Name: "Today", StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), IsDay: boolPtr(true), TextDescription: "Morning storms, then partly sunny.", TemperatureFMax: floatPtr(88), ProbabilityOfPrecipitationPercent: floatPtr(55), }, { Name: "Tonight", StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"), EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"), IsDay: boolPtr(false), TextDescription: "Clouds linger tonight.", TemperatureFMin: floatPtr(64), ProbabilityOfPrecipitationPercent: floatPtr(30), }, } summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}} summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...) return ModuleContext{ Resolved: report.Resolved{ Definition: definition, GeneratedAt: generatedAt, Timezone: "America/Chicago", ValidPeriod: summary.Period, }, Collected: facts.CollectedFacts{ Narrative: &weatherdata.ForecastRun{ IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"), Product: "narrative", Periods: append([]weatherdata.ForecastPeriod(nil), narrative...), }, }, Derived: facts.DerivedFacts{ ValidPeriodHourlyPeriods: hours, ValidPeriodNarrativePeriods: narrative, DailySummaries: []forecast.DailySummary{summary}, DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...), PrecipTiming: forecast.BuildPrecipTiming(hours), }, Units: "us", Timezone: "America/Chicago", } } func todayModuleContext() ModuleContext { ctx := derivedModuleContext(report.DailyToday) ctx.Resolved.Definition = report.Definition{ ID: report.Today, Name: "Today Report", PromptID: "weather.today_generated_text", } return ctx } func dailyModuleContext() ModuleContext { ctx := derivedModuleContext(report.Tomorrow) ctx.Resolved.Definition = report.Definition{ ID: report.ID("daily"), Name: "Daily Report", PromptID: "weather.daily_generated_text", } return ctx } func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary { hour := derivedHour(start, text, precip, temperature, apparent, gust) return forecast.SummarizeDaypart(name, timeutil.Period{ Start: mustParseModuleTime(start), End: mustParseModuleTime(end), }, []weatherdata.ForecastPeriod{hour}) } func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary { startTime := mustParseModuleTime(start) periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures)) for index, temperature := range temperatures { periodStart := startTime.Add(time.Duration(index) * time.Hour) periods = append(periods, weatherdata.ForecastPeriod{ StartTime: periodStart, EndTime: periodStart.Add(time.Hour), TextDescription: text, TemperatureF: floatPtr(temperature), }) } return forecast.SummarizeDaypart(name, timeutil.Period{ Start: startTime, End: startTime.Add(time.Duration(len(temperatures)) * time.Hour), }, periods) } func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod { startTime := mustParseModuleTime(start) endTime := startTime.Add(time.Hour) return weatherdata.ForecastPeriod{ StartTime: startTime, EndTime: endTime, TextDescription: text, TemperatureF: floatPtr(temperature), ApparentTemperatureF: apparent, ProbabilityOfPrecipitationPercent: floatPtr(precip), WindGustMph: floatPtr(gust), } } func floatPtr(value float64) *float64 { return &value } func boolPtr(value bool) *bool { return &value } func containsString(values []string, want string) bool { for _, value := range values { if value == want { return true } } return false }