From ddda42453ff73f5e42d36795a32306224c2804a0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 14 Jun 2026 23:36:47 +0000 Subject: [PATCH] Add Tomorrow render context and template --- internal/generatedtext/render_context.go | 210 ++++++++++++- internal/generatedtext/render_context_test.go | 295 ++++++++++++++++++ internal/reporttemplate/reporttemplate.go | 3 +- .../reporttemplate/reporttemplate_test.go | 166 ++++++++++ .../reporttemplate/templates/tomorrow.md.tmpl | 26 ++ 5 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 internal/reporttemplate/templates/tomorrow.md.tmpl diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index f33b08b..abb7e98 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -2,10 +2,14 @@ package generatedtext import ( "fmt" + "sort" + "strings" "time" + "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "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/timeutil" ) @@ -40,6 +44,46 @@ type HourlyTemplateModules struct { WeatherStory *briefing.WeatherStoryModule } +type TomorrowRenderContext struct { + Report TomorrowReportContext + GeneratedText Tomorrow + Modules TomorrowTemplateModules + Collected facts.CollectedFacts + Derived facts.DerivedFacts +} + +type TomorrowReportContext struct { + Title string + ForecastDate time.Time + ForecastDateLabel string + ForecastDayName string + GeneratedAt time.Time + GeneratedAtLabel string + ValidPeriod timeutil.Period + Timezone string +} + +type TomorrowTemplateModules struct { + Metadata *briefing.MetadataModule + CurrentConditions *briefing.CurrentConditionsModule + HourlyForecast *briefing.HourlyForecastModule + DerivedDailySummary *briefing.DerivedDailySummaryModule + DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule + Dayparts []TomorrowDaypartContext + PrecipTiming *briefing.PrecipTimingModule + AlertDigest *briefing.AlertDigestModule + SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule + AreaForecastDiscussion *briefing.AreaForecastDiscussionModule + SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule + WeatherStory *briefing.WeatherStoryModule + TomorrowPlanning *briefing.TomorrowPlanningModule +} + +type TomorrowDaypartContext struct { + Key string + Summary briefing.DerivedDaypartSummaryModule +} + func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { @@ -72,6 +116,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh }, nil } +func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { + location, err := timeutil.LoadLocation(metadata.Timezone) + if err != nil { + return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err) + } + if metadata.GeneratedAt.IsZero() { + return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required") + } + if !metadata.ValidPeriod.IsValid() { + return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required") + } + modules, err := tomorrowTemplateModules(snapshot, derived) + if err != nil { + return TomorrowRenderContext{}, err + } + forecastDate := metadata.ValidPeriod.Start.In(location) + forecastDayName := forecastDate.Format("Monday") + return TomorrowRenderContext{ + Report: TomorrowReportContext{ + Title: forecastDayName + "'s Weather", + ForecastDate: forecastDate, + ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"), + ForecastDayName: forecastDayName, + GeneratedAt: metadata.GeneratedAt, + GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), + ValidPeriod: metadata.ValidPeriod, + Timezone: metadata.Timezone, + }, + GeneratedText: generated, + Modules: modules, + Collected: collected, + Derived: derived, + }, nil +} + func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) { metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata)) if err != nil { @@ -122,6 +201,72 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err }, nil } +func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) { + metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata)) + if err != nil { + return TomorrowTemplateModules{}, err + } + current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions)) + if err != nil { + return TomorrowTemplateModules{}, err + } + hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast)) + if err != nil { + return TomorrowTemplateModules{}, err + } + daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary)) + if err != nil { + return TomorrowTemplateModules{}, err + } + dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries)) + if err != nil { + return TomorrowTemplateModules{}, err + } + precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming)) + if err != nil { + return TomorrowTemplateModules{}, err + } + alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest)) + if err != nil { + return TomorrowTemplateModules{}, err + } + outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks)) + if err != nil { + return TomorrowTemplateModules{}, err + } + discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion)) + if err != nil { + return TomorrowTemplateModules{}, err + } + spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion)) + if err != nil { + return TomorrowTemplateModules{}, err + } + story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory)) + if err != nil { + return TomorrowTemplateModules{}, err + } + planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.TomorrowPlanning)) + if err != nil { + return TomorrowTemplateModules{}, err + } + return TomorrowTemplateModules{ + Metadata: metadata, + CurrentConditions: current, + HourlyForecast: hourly, + DerivedDailySummary: daily, + DerivedDaypartSummaries: dayparts, + Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries), + PrecipTiming: precip, + AlertDigest: alerts, + SPCConvectiveOutlooks: outlooks, + AreaForecastDiscussion: discussion, + SPCConvectiveDiscussion: spcDiscussion, + WeatherStory: story, + TomorrowPlanning: planning, + }, nil +} + func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) { output, ok := snapshot.LookupStanza(name) if !ok || output.Value == nil { @@ -129,11 +274,74 @@ func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) { } value, _, err := module.StanzaValue[T](snapshot, name) if err != nil { - return nil, fmt.Errorf("build hourly render context: %w", err) + return nil, fmt.Errorf("build render context: %w", err) } return &value, nil } +func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext { + if dayparts == nil || len(*dayparts) == 0 { + return nil + } + out := make([]TomorrowDaypartContext, 0, len(*dayparts)) + seen := map[string]struct{}{} + for _, daypart := range ordered { + for _, key := range daypartModuleKeyCandidates(daypart) { + value, ok := (*dayparts)[key] + if !ok { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + out = append(out, TomorrowDaypartContext{Key: key, Summary: value}) + break + } + } + + var remaining []string + for key := range *dayparts { + if _, ok := seen[key]; !ok { + remaining = append(remaining, key) + } + } + sort.Strings(remaining) + for _, key := range remaining { + out = append(out, TomorrowDaypartContext{Key: key, Summary: (*dayparts)[key]}) + } + return out +} + +func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string { + key := normalizedDaypartKey(daypart.Name) + if key == "" { + key = "unnamed" + } + if daypart.Period.Start.IsZero() { + return []string{key} + } + return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key} +} + +func normalizedDaypartKey(value string) string { + lower := strings.ToLower(strings.TrimSpace(value)) + var out strings.Builder + lastUnderscore := false + for _, r := range lower { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + out.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore { + out.WriteByte('_') + lastUnderscore = true + } + } + return strings.Trim(out.String(), "_") +} + func locationName(metadata briefing.Metadata) string { if metadata.Location != nil { if metadata.Location.Name != "" && metadata.Location.Region != "" { diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 71bdbd7..12f89bb 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -114,6 +114,120 @@ func TestBuildHourlyRenderContextAllowsOmittedOptionalModules(t *testing.T) { } } +func TestBuildTomorrowRenderContext(t *testing.T) { + metadata := testTomorrowMetadata() + snapshot := testTomorrowSnapshot(t) + generated := Tomorrow{ + Summary: "Tomorrow starts quiet, then showers become more likely later in the day.", + ForecastDiscussion: []string{ + "Morning conditions should stay mostly dry.", + "Rain chances increase during the afternoon as deeper moisture arrives.", + }, + PrecipitationTiming: "The most likely rain window is from midafternoon into early evening.", + } + collected := testCollected() + derived := testTomorrowDerived() + + ctx, err := BuildTomorrowRenderContext(metadata, snapshot, generated, collected, derived) + if err != nil { + t.Fatalf("BuildTomorrowRenderContext() error = %v", err) + } + if ctx.Report.Title != "Monday's Weather" { + t.Fatalf("Report.Title = %q, want Monday's Weather", ctx.Report.Title) + } + if ctx.Report.ForecastDateLabel != "Monday, June 15, 2026" || ctx.Report.ForecastDayName != "Monday" { + t.Fatalf("forecast date labels = %q/%q, want Monday labels", ctx.Report.ForecastDateLabel, ctx.Report.ForecastDayName) + } + if ctx.Report.GeneratedAtLabel != "Sunday, June 14, 2026 at 9:14 AM" { + t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) + } + if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.Tomorrow { + t.Fatalf("Modules.Metadata = %#v, want tomorrow metadata", ctx.Modules.Metadata) + } + if ctx.Modules.CurrentConditions == nil || ctx.Modules.HourlyForecast == nil { + t.Fatalf("current/hourly modules = %#v/%#v, want available in render context", ctx.Modules.CurrentConditions, ctx.Modules.HourlyForecast) + } + if ctx.Modules.DerivedDailySummary == nil || ctx.Modules.DerivedDailySummary.HighTempF == nil || *ctx.Modules.DerivedDailySummary.HighTempF != 74 { + t.Fatalf("Modules.DerivedDailySummary = %#v, want daily summary", ctx.Modules.DerivedDailySummary) + } + if ctx.Modules.DerivedDaypartSummaries == nil || len(*ctx.Modules.DerivedDaypartSummaries) != 2 { + t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries) + } + if len(ctx.Modules.Dayparts) != 2 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" { + t.Fatalf("Modules.Dayparts = %#v, want configured order", ctx.Modules.Dayparts) + } + if ctx.Modules.PrecipTiming == nil || len(ctx.Modules.PrecipTiming.PrecipitationWindows) != 1 { + t.Fatalf("Modules.PrecipTiming = %#v, want precipitation window", ctx.Modules.PrecipTiming) + } + if ctx.Modules.AlertDigest == nil || ctx.Modules.SPCConvectiveOutlooks == nil || ctx.Modules.AreaForecastDiscussion == nil || ctx.Modules.SPCConvectiveDiscussion == nil || ctx.Modules.WeatherStory == nil || ctx.Modules.TomorrowPlanning == nil { + t.Fatalf("optional modules missing from render context: %#v", ctx.Modules) + } + if !ctx.Collected.FetchedAt.Equal(collected.FetchedAt) { + t.Fatalf("Collected.FetchedAt = %s, want %s", ctx.Collected.FetchedAt, collected.FetchedAt) + } + if len(ctx.Derived.DaypartSummaries) != 2 { + t.Fatalf("Derived.DaypartSummaries = %#v, want passthrough facts", ctx.Derived.DaypartSummaries) + } + + rendered, err := reporttemplate.Render("tomorrow", ctx) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range []string{ + "# Monday's Weather", + "**Forecast date:** Monday, June 15, 2026", + "Tomorrow starts quiet, then showers become more likely later in the day.", + "- **Morning:** low 60s and partly cloudy.", + "- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.", + "## Precipitation Timing", + "- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", + "The most likely rain window is from midafternoon into early evening.", + "Morning conditions should stay mostly dry.", + "Rain chances increase during the afternoon as deeper moisture arrives.", + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } + assertOrderedText(t, text, []string{ + "# Monday's Weather", + "## Daypart Forecast", + "- **Morning:**", + "- **Afternoon:**", + "## Precipitation Timing", + "## Forecast Discussion", + }) +} + +func TestBuildTomorrowRenderContextAllowsOmittedOptionalModules(t *testing.T) { + snapshot, err := module.NewSnapshot(nil) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), snapshot, Tomorrow{ + Summary: "Dry weather is expected tomorrow.", + ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, + }, testCollected(), facts.DerivedFacts{}) + if err != nil { + t.Fatalf("BuildTomorrowRenderContext() error = %v", err) + } + if ctx.Modules.PrecipTiming != nil || len(ctx.Modules.Dayparts) != 0 { + t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) + } + rendered, err := reporttemplate.Render("tomorrow", ctx) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + if strings.Contains(text, "## Precipitation Timing") { + t.Fatalf("rendered template included precipitation section without windows:\n%s", text) + } + if !strings.Contains(text, "- No daypart forecast details are available.") { + t.Fatalf("rendered template missing daypart fallback:\n%s", text) + } +} + func testMetadata() briefing.Metadata { generatedAt := time.Date(2026, 5, 29, 13, 30, 0, 0, time.UTC) return briefing.Metadata{ @@ -134,6 +248,26 @@ func testMetadata() briefing.Metadata { } } +func testTomorrowMetadata() briefing.Metadata { + generatedAt := time.Date(2026, 6, 14, 14, 14, 0, 0, time.UTC) + return briefing.Metadata{ + RunID: "run-tomorrow", + ReportID: report.Tomorrow, + PromptID: "weather.tomorrow_generated_text", + GeneratedAt: generatedAt, + Units: "imperial", + Timezone: "America/Chicago", + ValidPeriod: timeutil.Period{ + Start: time.Date(2026, 6, 15, 5, 0, 0, 0, time.UTC), + End: time.Date(2026, 6, 16, 5, 0, 0, 0, time.UTC), + }, + Location: &briefing.LocationContext{ + Name: "Brentwood", + Region: "MO", + }, + } +} + func testCollected() facts.CollectedFacts { return facts.CollectedFacts{FetchedAt: time.Date(2026, 5, 29, 13, 31, 0, 0, time.UTC)} } @@ -142,6 +276,29 @@ func testDerived() facts.DerivedFacts { return facts.DerivedFacts{PrecipTiming: forecast.PrecipTiming{ThunderMentioned: true}} } +func testTomorrowDerived() facts.DerivedFacts { + morningStart := time.Date(2026, 6, 15, 11, 0, 0, 0, time.UTC) + afternoonStart := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC) + return facts.DerivedFacts{ + DaypartSummaries: []forecast.DaypartSummary{ + { + Name: "morning", + Period: timeutil.Period{ + Start: morningStart, + End: afternoonStart, + }, + }, + { + Name: "afternoon", + Period: timeutil.Period{ + Start: afternoonStart, + End: time.Date(2026, 6, 15, 23, 0, 0, 0, time.UTC), + }, + }, + }, + } +} + func testSnapshot(t *testing.T) module.Snapshot { t.Helper() snapshot, err := module.NewSnapshot([]module.Output{ @@ -273,6 +430,129 @@ func testSnapshot(t *testing.T) module.Snapshot { return snapshot } +func testTomorrowSnapshot(t *testing.T) module.Snapshot { + t.Helper() + snapshot, err := module.NewSnapshot([]module.Output{ + { + ID: module.Metadata, + StanzaName: string(module.Metadata), + Value: briefing.MetadataModule{ + RunID: "run-tomorrow", + ReportID: report.Tomorrow, + PromptID: "weather.tomorrow_generated_text", + GeneratedAt: testTomorrowMetadata().GeneratedAt, + Units: "imperial", + Timezone: "America/Chicago", + ValidPeriod: testTomorrowMetadata().ValidPeriod, + }, + }, + { + ID: module.CurrentConditions, + StanzaName: string(module.CurrentConditions), + Value: briefing.CurrentConditionsModule{ + ConditionTextLower: "clear", + TemperatureF: intPtr(58), + }, + }, + { + ID: module.HourlyForecast, + StanzaName: string(module.HourlyForecast), + Value: briefing.HourlyForecastModule{ + Periods: []briefing.HourlyForecastPeriod{ + {HourLabel: "7:00 AM", TextDescriptionLower: "partly cloudy", TemperatureF: floatPtr(63)}, + }, + }, + }, + { + ID: module.DerivedDailySummary, + StanzaName: string(module.DerivedDailySummary), + Value: briefing.DerivedDailySummaryModule{ + Date: "Monday, June 15, 2026", + HighTempF: intPtr(74), + LowTempF: intPtr(61), + DailyPrecipitationProbability: intPtr(70), + }, + }, + { + ID: module.DerivedDaypartSummaries, + StanzaName: string(module.DerivedDaypartSummaries), + Value: map[string]briefing.DerivedDaypartSummaryModule{ + "afternoon": { + DisplayName: "Afternoon", + TemperaturePhraseF: "mid 70s", + DominantConditionLower: "showers", + MaxPopPercent: intPtr(70), + MaxPopTimeLabel: "3:00 PM", + MentionPrecipitation: true, + }, + "morning": { + DisplayName: "Morning", + TemperaturePhraseF: "low 60s", + DominantConditionLower: "partly cloudy", + }, + }, + }, + { + ID: module.PrecipTiming, + StanzaName: string(module.PrecipTiming), + Value: briefing.PrecipTimingModule{ + MaxPopPercent: intPtr(70), + MaxPopTime: "3 PM", + PrecipitationWindows: []briefing.PrecipitationWindowModule{ + {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, + }, + }, + }, + { + ID: module.AlertDigest, + StanzaName: string(module.AlertDigest), + Value: briefing.AlertDigestModule{ + Checked: true, + }, + }, + { + ID: module.SPCConvectiveOutlooks, + StanzaName: string(module.SPCConvectiveOutlooks), + Value: briefing.SPCConvectiveOutlooksModule{ + Checked: true, + }, + }, + { + ID: module.AreaForecastDiscussion, + StanzaName: string(module.AreaForecastDiscussion), + Value: briefing.AreaForecastDiscussionModule{ + KeyMessages: []string{"Rain chances increase late."}, + }, + }, + { + ID: module.SPCConvectiveDiscussion, + StanzaName: string(module.SPCConvectiveDiscussion), + Value: briefing.SPCConvectiveDiscussionModule{ + IncludedBecause: "convective outlook", + }, + }, + { + ID: module.WeatherStory, + StanzaName: string(module.WeatherStory), + Value: briefing.WeatherStoryModule{ + Available: true, + Title: "Rain returns", + }, + }, + { + ID: module.TomorrowPlanning, + StanzaName: string(module.TomorrowPlanning), + Value: briefing.TomorrowPlanningModule{ + MorningReadiness: []string{"Take sunglasses early."}, + }, + }, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + return snapshot +} + func floatPtr(value float64) *float64 { return &value } @@ -280,3 +560,18 @@ func floatPtr(value float64) *float64 { func intPtr(value int) *int { return &value } + +func assertOrderedText(t *testing.T, text string, ordered []string) { + t.Helper() + previousIndex := -1 + for _, want := range ordered { + index := strings.Index(text, want) + if index < 0 { + t.Fatalf("text missing %q:\n%s", want, text) + } + if index <= previousIndex { + t.Fatalf("%q appears out of order in:\n%s", want, text) + } + previousIndex = index + } +} diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index 94cfb76..d8080d7 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -12,7 +12,8 @@ import ( var assets embed.FS var templates = map[string]string{ - "hourly": "templates/hourly.md.tmpl", + "hourly": "templates/hourly.md.tmpl", + "tomorrow": "templates/tomorrow.md.tmpl", } var schemas = map[string]string{ diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index 07d7ad6..c5c7a34 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -18,6 +18,18 @@ func TestTemplateLookup(t *testing.T) { } } +func TestTomorrowTemplateLookup(t *testing.T) { + source, err := Template("tomorrow") + if err != nil { + t.Fatalf("Template() error = %v", err) + } + for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} { + if !strings.Contains(source, want) { + t.Fatalf("template missing %q:\n%s", want, source) + } + } +} + func TestSchemaLookup(t *testing.T) { data, err := Schema("hourly") if err != nil { @@ -191,6 +203,119 @@ func TestRenderHourly(t *testing.T) { }) } +func TestRenderTomorrow(t *testing.T) { + rendered, err := Render("tomorrow", testTomorrowRenderContext{ + Report: testTomorrowReportContext{ + Title: "Monday's Weather", + ForecastDateLabel: "Monday, June 15, 2026", + GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM", + }, + GeneratedText: testTomorrowGeneratedText{ + Summary: "Tomorrow starts dry before showers return later in the day.", + ForecastDiscussion: []string{ + "Clouds increase after sunrise.", + "Rain chances peak during the afternoon.", + }, + PrecipitationTiming: "A few showers may linger into early evening.", + }, + Modules: testTomorrowModules{ + Dayparts: []testTomorrowDaypart{ + { + Key: "morning", + Summary: testDaypartSummary{ + DisplayName: "Morning", + TemperaturePhraseF: "low 60s", + DominantConditionLower: "partly cloudy", + }, + }, + { + Key: "afternoon", + Summary: testDaypartSummary{ + DisplayName: "Afternoon", + TemperaturePhraseF: "mid 70s", + DominantConditionLower: "showers", + MaxPopPercent: intPtr(70), + MaxPopTimeLabel: "3:00 PM", + MentionPrecipitation: true, + }, + }, + }, + PrecipTiming: &testPrecipTiming{ + PrecipitationWindows: []testPrecipWindow{ + {PeriodBeginsHourLabel: "3:00 PM", PeriodEndsHourLabel: "6:00 PM", MaxPopPercent: intPtr(70), MaxPopHourLabel: "3:00 PM"}, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, want := range []string{ + "# Monday's Weather", + "**Forecast date:** Monday, June 15, 2026", + "**Updated:** Sunday, June 14, 2026 at 9:14 AM", + "Tomorrow starts dry before showers return later in the day.", + "- **Morning:** low 60s and partly cloudy.", + "- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.", + "- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", + "A few showers may linger into early evening.", + "Clouds increase after sunrise.", + "Rain chances peak during the afternoon.", + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered template missing %q:\n%s", want, text) + } + } + assertOrderedText(t, text, []string{ + "# Monday's Weather", + "## Daypart Forecast", + "- **Morning:**", + "- **Afternoon:**", + "## Precipitation Timing", + "## Forecast Discussion", + "Clouds increase after sunrise.", + "Rain chances peak during the afternoon.", + }) +} + +func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) { + rendered, err := Render("tomorrow", testTomorrowRenderContext{ + Report: testTomorrowReportContext{ + Title: "Monday's Weather", + ForecastDateLabel: "Monday, June 15, 2026", + GeneratedAtLabel: "Sunday, June 14, 2026 at 9:14 AM", + }, + GeneratedText: testTomorrowGeneratedText{ + Summary: "Dry weather is expected tomorrow.", + ForecastDiscussion: []string{"High pressure keeps rain chances low."}, + PrecipitationTiming: "Any stray shower chance is too low to highlight.", + }, + Modules: testTomorrowModules{ + Dayparts: []testTomorrowDaypart{ + { + Key: "morning", + Summary: testDaypartSummary{ + DisplayName: "Morning", + TemperaturePhraseF: "low 60s", + DominantConditionLower: "clear", + }, + }, + }, + PrecipTiming: &testPrecipTiming{}, + }, + }) + if err != nil { + t.Fatalf("Render() error = %v", err) + } + text := string(rendered) + for _, unwanted := range []string{"## Precipitation Timing", "Any stray shower chance is too low to highlight."} { + if strings.Contains(text, unwanted) { + t.Fatalf("dry render includes %q:\n%s", unwanted, text) + } + } +} + func TestRenderHourlyOmitsConditionalSectionsForClearWeather(t *testing.T) { rendered, err := Render("hourly", testRenderContext{ Report: testReportContext{ @@ -256,6 +381,12 @@ type testRenderContext struct { Modules testModules } +type testTomorrowRenderContext struct { + Report testTomorrowReportContext + GeneratedText testTomorrowGeneratedText + Modules testTomorrowModules +} + type testReportContext struct { Title string LocationName string @@ -270,6 +401,19 @@ type testGeneratedText struct { Confidence string } +type testTomorrowReportContext struct { + Title string + ForecastDateLabel string + GeneratedAtLabel string +} + +type testTomorrowGeneratedText struct { + Summary string + ForecastDiscussion []string + PrecipitationTiming string + Confidence string +} + type testModules struct { CurrentConditions *testCurrentConditions HourlyForecast *testHourlyForecast @@ -281,6 +425,28 @@ type testModules struct { WeatherStory *testWeatherStory } +type testTomorrowModules struct { + Dayparts []testTomorrowDaypart + PrecipTiming *testPrecipTiming +} + +type testTomorrowDaypart struct { + Key string + Summary testDaypartSummary +} + +type testDaypartSummary struct { + DisplayName string + TempRangeF string + TemperaturePhraseF string + MaxPopPercent *int + MaxPopTime string + MaxPopTimeLabel string + MentionPrecipitation bool + DominantCondition string + DominantConditionLower string +} + type testCurrentConditions struct { ConditionText string ConditionTextLower string diff --git a/internal/reporttemplate/templates/tomorrow.md.tmpl b/internal/reporttemplate/templates/tomorrow.md.tmpl new file mode 100644 index 0000000..4bd34b4 --- /dev/null +++ b/internal/reporttemplate/templates/tomorrow.md.tmpl @@ -0,0 +1,26 @@ +# {{ .Report.Title }} + +**Forecast date:** {{ .Report.ForecastDateLabel }} +**Updated:** {{ .Report.GeneratedAtLabel }} + +{{ .GeneratedText.Summary }} + +## Daypart Forecast +{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }} +- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ if .Summary.TemperaturePhraseF }}{{ .Summary.TemperaturePhraseF }}{{ with .Summary.DominantConditionLower }} and {{ . }}{{ end }}{{ else }}{{ with .Summary.DominantConditionLower }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Precipitation chances peak at {{ . }}%{{ with $daypart.Summary.MaxPopTimeLabel }} at {{ . }}{{ else }}{{ with $daypart.Summary.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}{{ end }} +{{ end }}{{ else }} +- No daypart forecast details are available. +{{ end }} + +{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} +## Precipitation Timing +{{ range . }}{{ $window := . }} +- **{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: Precipitation is expected during this period.{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }} +{{ end }}{{ with $.GeneratedText.PrecipitationTiming }} +{{ . }} +{{ end }} +{{ end }}{{ end }} +## Forecast Discussion +{{ range .GeneratedText.ForecastDiscussion }} +{{ . }} +{{ end }}