From 121f28fd291304f4aa0674d4cf3989f44af26640 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 16 Jun 2026 15:03:35 +0000 Subject: [PATCH] Share day-style render context and template blocks --- internal/generatedtext/render_context.go | 408 +++++++++--------- internal/generatedtext/render_context_test.go | 186 ++++++++ internal/reporttemplate/reporttemplate.go | 18 +- .../reporttemplate/reporttemplate_test.go | 6 +- .../reporttemplate/templates/daily.md.tmpl | 14 +- .../partials/daypart_forecast.md.tmpl | 5 + .../partials/precipitation_timing.md.tmpl | 8 + .../partials/today_daypart_forecast.md.tmpl | 5 + .../reporttemplate/templates/today.md.tmpl | 14 +- .../reporttemplate/templates/tomorrow.md.tmpl | 14 +- 10 files changed, 426 insertions(+), 252 deletions(-) create mode 100644 internal/reporttemplate/templates/partials/daypart_forecast.md.tmpl create mode 100644 internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl create mode 100644 internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index 9c1b045..c56d9c3 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -165,6 +165,31 @@ type DailyDaypartContext struct { Summary briefing.DerivedDaypartSummaryModule } +type dayStyleReportContext struct { + Title string + ForecastDate time.Time + ForecastDateLabel string + ForecastDayName string + GeneratedAt time.Time + GeneratedAtLabel string + ValidPeriod timeutil.Period + Timezone string +} + +type dayStyleTemplateModules struct { + Metadata *briefing.MetadataModule + CurrentConditions *briefing.CurrentConditionsModule + HourlyForecast *briefing.HourlyForecastModule + DerivedDailySummary *briefing.DerivedDailySummaryModule + DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule + PrecipTiming *briefing.PrecipTimingModule + AlertDigest *briefing.AlertDigestModule + SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule + AreaForecastDiscussion *briefing.AreaForecastDiscussionModule + SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule + WeatherStory *briefing.WeatherStoryModule +} + 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 { @@ -198,33 +223,18 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh } func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { - location, err := timeutil.LoadLocation(metadata.Timezone) + reportContext, err := buildDayStyleReportContext(metadata, "daily", func(dayName string) string { + return dayName + "'s Weather" + }) if err != nil { - return DailyRenderContext{}, fmt.Errorf("build daily render context: %w", err) - } - if metadata.GeneratedAt.IsZero() { - return DailyRenderContext{}, fmt.Errorf("build daily render context: generatedAt is required") - } - if !metadata.ValidPeriod.IsValid() { - return DailyRenderContext{}, fmt.Errorf("build daily render context: valid period is required") + return DailyRenderContext{}, err } modules, err := dailyTemplateModules(snapshot, derived) if err != nil { return DailyRenderContext{}, err } - forecastDate := metadata.ValidPeriod.Start.In(location) - forecastDayName := forecastDate.Format("Monday") return DailyRenderContext{ - Report: DailyReportContext{ - 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, - }, + Report: reportContext.dailyReportContext(), GeneratedText: generated, Modules: modules, Collected: collected, @@ -233,32 +243,18 @@ func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapsho } func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { - location, err := timeutil.LoadLocation(metadata.Timezone) + reportContext, err := buildDayStyleReportContext(metadata, "today", func(string) string { + return "Today's Weather" + }) if err != nil { - return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err) - } - if metadata.GeneratedAt.IsZero() { - return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required") - } - if !metadata.ValidPeriod.IsValid() { - return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required") + return TodayRenderContext{}, err } modules, err := todayTemplateModules(snapshot, derived) if err != nil { return TodayRenderContext{}, err } - forecastDate := metadata.ValidPeriod.Start.In(location) return TodayRenderContext{ - Report: TodayReportContext{ - Title: "Today's Weather", - ForecastDate: forecastDate, - ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"), - ForecastDayName: forecastDate.Format("Monday"), - GeneratedAt: metadata.GeneratedAt, - GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), - ValidPeriod: metadata.ValidPeriod, - Timezone: metadata.Timezone, - }, + Report: reportContext.todayReportContext(), GeneratedText: generated, Modules: modules, Collected: collected, @@ -267,33 +263,18 @@ func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapsho } func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { - location, err := timeutil.LoadLocation(metadata.Timezone) + reportContext, err := buildDayStyleReportContext(metadata, "tomorrow", func(dayName string) string { + return dayName + "'s Weather" + }) 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") + return TomorrowRenderContext{}, err } 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, - }, + Report: reportContext.tomorrowReportContext(), GeneratedText: generated, Modules: modules, Collected: collected, @@ -301,49 +282,73 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap }, nil } +func buildDayStyleReportContext(metadata briefing.Metadata, name string, title func(string) string) (dayStyleReportContext, error) { + location, err := timeutil.LoadLocation(metadata.Timezone) + if err != nil { + return dayStyleReportContext{}, fmt.Errorf("build %s render context: %w", name, err) + } + if metadata.GeneratedAt.IsZero() { + return dayStyleReportContext{}, fmt.Errorf("build %s render context: generatedAt is required", name) + } + if !metadata.ValidPeriod.IsValid() { + return dayStyleReportContext{}, fmt.Errorf("build %s render context: valid period is required", name) + } + + forecastDate := metadata.ValidPeriod.Start.In(location) + forecastDayName := forecastDate.Format("Monday") + return dayStyleReportContext{ + Title: title(forecastDayName), + 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, + }, nil +} + +func (ctx dayStyleReportContext) dailyReportContext() DailyReportContext { + return DailyReportContext{ + Title: ctx.Title, + ForecastDate: ctx.ForecastDate, + ForecastDateLabel: ctx.ForecastDateLabel, + ForecastDayName: ctx.ForecastDayName, + GeneratedAt: ctx.GeneratedAt, + GeneratedAtLabel: ctx.GeneratedAtLabel, + ValidPeriod: ctx.ValidPeriod, + Timezone: ctx.Timezone, + } +} + +func (ctx dayStyleReportContext) todayReportContext() TodayReportContext { + return TodayReportContext{ + Title: ctx.Title, + ForecastDate: ctx.ForecastDate, + ForecastDateLabel: ctx.ForecastDateLabel, + ForecastDayName: ctx.ForecastDayName, + GeneratedAt: ctx.GeneratedAt, + GeneratedAtLabel: ctx.GeneratedAtLabel, + ValidPeriod: ctx.ValidPeriod, + Timezone: ctx.Timezone, + } +} + +func (ctx dayStyleReportContext) tomorrowReportContext() TomorrowReportContext { + return TomorrowReportContext{ + Title: ctx.Title, + ForecastDate: ctx.ForecastDate, + ForecastDateLabel: ctx.ForecastDateLabel, + ForecastDayName: ctx.ForecastDayName, + GeneratedAt: ctx.GeneratedAt, + GeneratedAtLabel: ctx.GeneratedAtLabel, + ValidPeriod: ctx.ValidPeriod, + Timezone: ctx.Timezone, + } +} + func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (DailyTemplateModules, error) { - lookup := newModuleSnapshotLookup(snapshot) - metadata, err := lookup.metadata() - if err != nil { - return DailyTemplateModules{}, err - } - current, err := lookup.currentConditions() - if err != nil { - return DailyTemplateModules{}, err - } - hourly, err := lookup.hourlyForecast() - if err != nil { - return DailyTemplateModules{}, err - } - daily, err := lookup.derivedDailySummary() - if err != nil { - return DailyTemplateModules{}, err - } - dayparts, err := lookup.derivedDaypartSummaries() - if err != nil { - return DailyTemplateModules{}, err - } - precip, err := lookup.precipTiming() - if err != nil { - return DailyTemplateModules{}, err - } - alerts, err := lookup.alertDigest() - if err != nil { - return DailyTemplateModules{}, err - } - outlooks, err := lookup.spcConvectiveOutlooks() - if err != nil { - return DailyTemplateModules{}, err - } - discussion, err := lookup.areaForecastDiscussion() - if err != nil { - return DailyTemplateModules{}, err - } - spcDiscussion, err := lookup.spcConvectiveDiscussion() - if err != nil { - return DailyTemplateModules{}, err - } - story, err := lookup.weatherStory() + common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot) if err != nil { return DailyTemplateModules{}, err } @@ -356,66 +361,25 @@ func dailyTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) return DailyTemplateModules{}, err } return DailyTemplateModules{ - Metadata: metadata, - CurrentConditions: current, - HourlyForecast: hourly, - DerivedDailySummary: daily, - DerivedDaypartSummaries: dayparts, - Dayparts: orderedDailyDayparts(dayparts, derived.DaypartSummaries), - PrecipTiming: precip, - AlertDigest: alerts, - SPCConvectiveOutlooks: outlooks, - AreaForecastDiscussion: discussion, - SPCConvectiveDiscussion: spcDiscussion, - WeatherStory: story, + Metadata: common.Metadata, + CurrentConditions: common.CurrentConditions, + HourlyForecast: common.HourlyForecast, + DerivedDailySummary: common.DerivedDailySummary, + DerivedDaypartSummaries: common.DerivedDaypartSummaries, + Dayparts: orderedDailyDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries), + PrecipTiming: common.PrecipTiming, + AlertDigest: common.AlertDigest, + SPCConvectiveOutlooks: common.SPCConvectiveOutlooks, + AreaForecastDiscussion: common.AreaForecastDiscussion, + SPCConvectiveDiscussion: common.SPCConvectiveDiscussion, + WeatherStory: common.WeatherStory, OutdoorWindows: outdoor, DailyPlanning: planning, }, nil } func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) { - lookup := newModuleSnapshotLookup(snapshot) - metadata, err := lookup.metadata() - if err != nil { - return TodayTemplateModules{}, err - } - current, err := lookup.currentConditions() - if err != nil { - return TodayTemplateModules{}, err - } - hourly, err := lookup.hourlyForecast() - if err != nil { - return TodayTemplateModules{}, err - } - daily, err := lookup.derivedDailySummary() - if err != nil { - return TodayTemplateModules{}, err - } - dayparts, err := lookup.derivedDaypartSummaries() - if err != nil { - return TodayTemplateModules{}, err - } - precip, err := lookup.precipTiming() - if err != nil { - return TodayTemplateModules{}, err - } - alerts, err := lookup.alertDigest() - if err != nil { - return TodayTemplateModules{}, err - } - outlooks, err := lookup.spcConvectiveOutlooks() - if err != nil { - return TodayTemplateModules{}, err - } - discussion, err := lookup.areaForecastDiscussion() - if err != nil { - return TodayTemplateModules{}, err - } - spcDiscussion, err := lookup.spcConvectiveDiscussion() - if err != nil { - return TodayTemplateModules{}, err - } - story, err := lookup.weatherStory() + common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot) if err != nil { return TodayTemplateModules{}, err } @@ -424,18 +388,18 @@ func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) return TodayTemplateModules{}, err } return TodayTemplateModules{ - Metadata: metadata, - CurrentConditions: current, - HourlyForecast: hourly, - DerivedDailySummary: daily, - DerivedDaypartSummaries: dayparts, - Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries), - PrecipTiming: precip, - AlertDigest: alerts, - SPCConvectiveOutlooks: outlooks, - AreaForecastDiscussion: discussion, - SPCConvectiveDiscussion: spcDiscussion, - WeatherStory: story, + Metadata: common.Metadata, + CurrentConditions: common.CurrentConditions, + HourlyForecast: common.HourlyForecast, + DerivedDailySummary: common.DerivedDailySummary, + DerivedDaypartSummaries: common.DerivedDaypartSummaries, + Dayparts: orderedTodayDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries), + PrecipTiming: common.PrecipTiming, + AlertDigest: common.AlertDigest, + SPCConvectiveOutlooks: common.SPCConvectiveOutlooks, + AreaForecastDiscussion: common.AreaForecastDiscussion, + SPCConvectiveDiscussion: common.SPCConvectiveDiscussion, + WeatherStory: common.WeatherStory, TodayPlanning: planning, }, nil } @@ -492,48 +456,7 @@ func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, err } func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) { - lookup := newModuleSnapshotLookup(snapshot) - metadata, err := lookup.metadata() - if err != nil { - return TomorrowTemplateModules{}, err - } - current, err := lookup.currentConditions() - if err != nil { - return TomorrowTemplateModules{}, err - } - hourly, err := lookup.hourlyForecast() - if err != nil { - return TomorrowTemplateModules{}, err - } - daily, err := lookup.derivedDailySummary() - if err != nil { - return TomorrowTemplateModules{}, err - } - dayparts, err := lookup.derivedDaypartSummaries() - if err != nil { - return TomorrowTemplateModules{}, err - } - precip, err := lookup.precipTiming() - if err != nil { - return TomorrowTemplateModules{}, err - } - alerts, err := lookup.alertDigest() - if err != nil { - return TomorrowTemplateModules{}, err - } - outlooks, err := lookup.spcConvectiveOutlooks() - if err != nil { - return TomorrowTemplateModules{}, err - } - discussion, err := lookup.areaForecastDiscussion() - if err != nil { - return TomorrowTemplateModules{}, err - } - spcDiscussion, err := lookup.spcConvectiveDiscussion() - if err != nil { - return TomorrowTemplateModules{}, err - } - story, err := lookup.weatherStory() + common, lookup, err := dayStyleTemplateModuleSnapshot(snapshot) if err != nil { return TomorrowTemplateModules{}, err } @@ -542,20 +465,81 @@ func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFact return TomorrowTemplateModules{}, err } return TomorrowTemplateModules{ + Metadata: common.Metadata, + CurrentConditions: common.CurrentConditions, + HourlyForecast: common.HourlyForecast, + DerivedDailySummary: common.DerivedDailySummary, + DerivedDaypartSummaries: common.DerivedDaypartSummaries, + Dayparts: orderedTomorrowDayparts(common.DerivedDaypartSummaries, derived.DaypartSummaries), + PrecipTiming: common.PrecipTiming, + AlertDigest: common.AlertDigest, + SPCConvectiveOutlooks: common.SPCConvectiveOutlooks, + AreaForecastDiscussion: common.AreaForecastDiscussion, + SPCConvectiveDiscussion: common.SPCConvectiveDiscussion, + WeatherStory: common.WeatherStory, + TomorrowPlanning: planning, + }, nil +} + +func dayStyleTemplateModuleSnapshot(snapshot module.Snapshot) (dayStyleTemplateModules, moduleSnapshotLookup, error) { + lookup := newModuleSnapshotLookup(snapshot) + metadata, err := lookup.metadata() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + current, err := lookup.currentConditions() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + hourly, err := lookup.hourlyForecast() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + daily, err := lookup.derivedDailySummary() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + dayparts, err := lookup.derivedDaypartSummaries() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + precip, err := lookup.precipTiming() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + alerts, err := lookup.alertDigest() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + outlooks, err := lookup.spcConvectiveOutlooks() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + discussion, err := lookup.areaForecastDiscussion() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + spcDiscussion, err := lookup.spcConvectiveDiscussion() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + story, err := lookup.weatherStory() + if err != nil { + return dayStyleTemplateModules{}, moduleSnapshotLookup{}, err + } + return dayStyleTemplateModules{ 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 + }, lookup, nil } type moduleSnapshotLookup struct { diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 3ceac29..d06d7e5 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -272,6 +272,192 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) { } } +func TestBuildDayStyleRenderContextsPopulateSharedFieldsAndPlanningModules(t *testing.T) { + type result struct { + title string + forecastDateLabel string + generatedAtLabel string + timezone string + validPeriod timeutil.Period + metadata bool + current bool + hourly bool + dailySummary bool + daypartSummaries bool + dayparts int + precip bool + alerts bool + outlooks bool + discussion bool + spcDiscussion bool + story bool + planning bool + collected bool + derived bool + } + tests := []struct { + name string + wantTitle string + wantGeneratedLabel string + wantDayparts int + build func(t *testing.T) result + }{ + { + name: "daily", + wantTitle: "Monday's Weather", + wantGeneratedLabel: "Saturday, June 13, 2026 at 9:14 AM", + wantDayparts: 3, + build: func(t *testing.T) result { + t.Helper() + collected := testCollected() + derived := testDailyDerived() + ctx, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), Daily{ + Summary: "Daily summary.", + ForecastDiscussion: []string{"Daily discussion."}, + }, collected, derived) + if err != nil { + t.Fatalf("BuildDailyRenderContext() error = %v", err) + } + return result{ + title: ctx.Report.Title, + forecastDateLabel: ctx.Report.ForecastDateLabel, + generatedAtLabel: ctx.Report.GeneratedAtLabel, + timezone: ctx.Report.Timezone, + validPeriod: ctx.Report.ValidPeriod, + metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Daily, + current: ctx.Modules.CurrentConditions != nil, + hourly: ctx.Modules.HourlyForecast != nil, + dailySummary: ctx.Modules.DerivedDailySummary != nil, + daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil, + dayparts: len(ctx.Modules.Dayparts), + precip: ctx.Modules.PrecipTiming != nil, + alerts: ctx.Modules.AlertDigest != nil, + outlooks: ctx.Modules.SPCConvectiveOutlooks != nil, + discussion: ctx.Modules.AreaForecastDiscussion != nil, + spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, + story: ctx.Modules.WeatherStory != nil, + planning: ctx.Modules.DailyPlanning != nil, + collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt), + derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries), + } + }, + }, + { + name: "today", + wantTitle: "Today's Weather", + wantGeneratedLabel: "Monday, June 15, 2026 at 7:14 AM", + wantDayparts: 2, + build: func(t *testing.T) result { + t.Helper() + collected := testCollected() + derived := testTodayDerived() + ctx, err := BuildTodayRenderContext(testTodayMetadata(), testTodaySnapshot(t), Today{ + Summary: "Today summary.", + ForecastDiscussion: []string{"Today discussion."}, + }, collected, derived) + if err != nil { + t.Fatalf("BuildTodayRenderContext() error = %v", err) + } + return result{ + title: ctx.Report.Title, + forecastDateLabel: ctx.Report.ForecastDateLabel, + generatedAtLabel: ctx.Report.GeneratedAtLabel, + timezone: ctx.Report.Timezone, + validPeriod: ctx.Report.ValidPeriod, + metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Today, + current: ctx.Modules.CurrentConditions != nil, + hourly: ctx.Modules.HourlyForecast != nil, + dailySummary: ctx.Modules.DerivedDailySummary != nil, + daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil, + dayparts: len(ctx.Modules.Dayparts), + precip: ctx.Modules.PrecipTiming != nil, + alerts: ctx.Modules.AlertDigest != nil, + outlooks: ctx.Modules.SPCConvectiveOutlooks != nil, + discussion: ctx.Modules.AreaForecastDiscussion != nil, + spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, + story: ctx.Modules.WeatherStory != nil, + planning: ctx.Modules.TodayPlanning != nil, + collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt), + derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries), + } + }, + }, + { + name: "tomorrow", + wantTitle: "Monday's Weather", + wantGeneratedLabel: "Sunday, June 14, 2026 at 9:14 AM", + wantDayparts: 2, + build: func(t *testing.T) result { + t.Helper() + collected := testCollected() + derived := testTomorrowDerived() + ctx, err := BuildTomorrowRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), Tomorrow{ + Summary: "Tomorrow summary.", + ForecastDiscussion: []string{"Tomorrow discussion."}, + }, collected, derived) + if err != nil { + t.Fatalf("BuildTomorrowRenderContext() error = %v", err) + } + return result{ + title: ctx.Report.Title, + forecastDateLabel: ctx.Report.ForecastDateLabel, + generatedAtLabel: ctx.Report.GeneratedAtLabel, + timezone: ctx.Report.Timezone, + validPeriod: ctx.Report.ValidPeriod, + metadata: ctx.Modules.Metadata != nil && ctx.Modules.Metadata.ReportID == report.Tomorrow, + current: ctx.Modules.CurrentConditions != nil, + hourly: ctx.Modules.HourlyForecast != nil, + dailySummary: ctx.Modules.DerivedDailySummary != nil, + daypartSummaries: ctx.Modules.DerivedDaypartSummaries != nil, + dayparts: len(ctx.Modules.Dayparts), + precip: ctx.Modules.PrecipTiming != nil, + alerts: ctx.Modules.AlertDigest != nil, + outlooks: ctx.Modules.SPCConvectiveOutlooks != nil, + discussion: ctx.Modules.AreaForecastDiscussion != nil, + spcDiscussion: ctx.Modules.SPCConvectiveDiscussion != nil, + story: ctx.Modules.WeatherStory != nil, + planning: ctx.Modules.TomorrowPlanning != nil, + collected: ctx.Collected.FetchedAt.Equal(collected.FetchedAt), + derived: len(ctx.Derived.DaypartSummaries) == len(derived.DaypartSummaries), + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := test.build(t) + if result.title != test.wantTitle { + t.Fatalf("title = %q, want %q", result.title, test.wantTitle) + } + if result.forecastDateLabel != "Monday, June 15, 2026" { + t.Fatalf("forecastDateLabel = %q, want Monday, June 15, 2026", result.forecastDateLabel) + } + if result.generatedAtLabel != test.wantGeneratedLabel { + t.Fatalf("generatedAtLabel = %q, want %q", result.generatedAtLabel, test.wantGeneratedLabel) + } + if result.timezone != "America/Chicago" { + t.Fatalf("timezone = %q, want America/Chicago", result.timezone) + } + if !result.validPeriod.IsValid() { + t.Fatalf("validPeriod = %#v, want valid period", result.validPeriod) + } + if !result.metadata || !result.current || !result.hourly || !result.dailySummary || !result.daypartSummaries || !result.precip || !result.alerts || !result.outlooks || !result.discussion || !result.spcDiscussion || !result.story { + t.Fatalf("common modules = %#v, want all shared modules populated", result) + } + if result.dayparts != test.wantDayparts { + t.Fatalf("dayparts = %d, want %d", result.dayparts, test.wantDayparts) + } + if !result.planning { + t.Fatalf("planning = false, want %s planning module populated", test.name) + } + if !result.collected || !result.derived { + t.Fatalf("facts passthrough = collected:%t derived:%t, want both true", result.collected, result.derived) + } + }) + } +} + func TestBuildDailyRenderContext(t *testing.T) { metadata := testDailyMetadata() snapshot := testDailySnapshot(t) diff --git a/internal/reporttemplate/reporttemplate.go b/internal/reporttemplate/reporttemplate.go index ea36929..d5c8d63 100644 --- a/internal/reporttemplate/reporttemplate.go +++ b/internal/reporttemplate/reporttemplate.go @@ -8,7 +8,7 @@ import ( "text/template" ) -//go:embed templates/*.md.tmpl schemas/*.schema.json +//go:embed templates/*.md.tmpl templates/partials/*.md.tmpl schemas/*.schema.json var assets embed.FS var templates = map[string]string{ @@ -18,6 +18,12 @@ var templates = map[string]string{ "tomorrow": "templates/tomorrow.md.tmpl", } +var templatePartials = []string{ + "templates/partials/daypart_forecast.md.tmpl", + "templates/partials/precipitation_timing.md.tmpl", + "templates/partials/today_daypart_forecast.md.tmpl", +} + var schemas = map[string]string{ "daily": "schemas/daily.generated_text.schema.json", "hourly": "schemas/hourly.generated_text.schema.json", @@ -58,6 +64,16 @@ func Render(id string, data any) ([]byte, error) { if err != nil { return nil, fmt.Errorf("parse report template %q: %w", id, err) } + for _, path := range templatePartials { + partial, err := assets.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read report template partial %q: %w", path, err) + } + tmpl, err = tmpl.Parse(string(partial)) + if err != nil { + return nil, fmt.Errorf("parse report template partial %q: %w", path, err) + } + } var out bytes.Buffer if err := tmpl.Execute(&out, data); err != nil { return nil, fmt.Errorf("render report template %q: %w", id, err) diff --git a/internal/reporttemplate/reporttemplate_test.go b/internal/reporttemplate/reporttemplate_test.go index a60fe17..a5f932b 100644 --- a/internal/reporttemplate/reporttemplate_test.go +++ b/internal/reporttemplate/reporttemplate_test.go @@ -24,7 +24,7 @@ func TestTomorrowTemplateLookup(t *testing.T) { if err != nil { t.Fatalf("Template() error = %v", err) } - for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} { + for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } @@ -36,7 +36,7 @@ func TestDailyTemplateLookup(t *testing.T) { if err != nil { t.Fatalf("Template() error = %v", err) } - for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Daypart Forecast", "## Precipitation Timing", "## Forecast Discussion"} { + for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", `{{ template "daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } @@ -48,7 +48,7 @@ func TestTodayTemplateLookup(t *testing.T) { if err != nil { t.Fatalf("Template() error = %v", err) } - for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", "## Daypart Forecast", "## Forecast Discussion"} { + for _, want := range []string{"# {{ .Report.Title }}", "**Forecast date:**", "## Current Conditions", `{{ template "today_daypart_forecast" . }}`, `{{ template "precipitation_timing" . }}`, "## Forecast Discussion"} { if !strings.Contains(source, want) { t.Fatalf("template missing %q:\n%s", want, source) } diff --git a/internal/reporttemplate/templates/daily.md.tmpl b/internal/reporttemplate/templates/daily.md.tmpl index 43bed95..46a5bc4 100644 --- a/internal/reporttemplate/templates/daily.md.tmpl +++ b/internal/reporttemplate/templates/daily.md.tmpl @@ -5,19 +5,9 @@ {{ .GeneratedText.Summary }} -## Daypart Forecast -{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }} -{{ end }}{{ else }}- No daypart forecast details are available. -{{ end }} +{{ template "daypart_forecast" . }} -{{ 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 }} +{{ template "precipitation_timing" . }} ## Forecast Discussion {{ range .GeneratedText.ForecastDiscussion }} {{ . }} diff --git a/internal/reporttemplate/templates/partials/daypart_forecast.md.tmpl b/internal/reporttemplate/templates/partials/daypart_forecast.md.tmpl new file mode 100644 index 0000000..5a1f43c --- /dev/null +++ b/internal/reporttemplate/templates/partials/daypart_forecast.md.tmpl @@ -0,0 +1,5 @@ +{{ define "daypart_forecast" }}## Daypart Forecast +{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }} +{{ end }}{{ else }}- No daypart forecast details are available. +{{ end }} +{{ end }} diff --git a/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl b/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl new file mode 100644 index 0000000..8ebd292 --- /dev/null +++ b/internal/reporttemplate/templates/partials/precipitation_timing.md.tmpl @@ -0,0 +1,8 @@ +{{ define "precipitation_timing" }}{{ 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 }}{{ end }} diff --git a/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl b/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl new file mode 100644 index 0000000..842db3f --- /dev/null +++ b/internal/reporttemplate/templates/partials/today_daypart_forecast.md.tmpl @@ -0,0 +1,5 @@ +{{ define "today_daypart_forecast" }}## Daypart Forecast +{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}{{ if or .Summary.DominantConditionDisplay .Summary.DominantCondition }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }} +{{ end }}{{ end }}{{ else }}- No daypart forecast details are available. +{{ end }} +{{ end }} diff --git a/internal/reporttemplate/templates/today.md.tmpl b/internal/reporttemplate/templates/today.md.tmpl index 05be3a0..19365dd 100644 --- a/internal/reporttemplate/templates/today.md.tmpl +++ b/internal/reporttemplate/templates/today.md.tmpl @@ -10,19 +10,9 @@ {{ with .TemperatureF }}Currently, it is {{ . }}°F{{ with $.Modules.CurrentConditions.ConditionTextLower }} and {{ . }}{{ end }}.{{ else }}{{ with .ConditionText }}Currently, it is {{ . }}.{{ else }}Current conditions are unavailable.{{ end }}{{ end }}{{ with .ApparentTemperatureF }} It feels like {{ . }}°F{{ with $.Modules.CurrentConditions.RelativeHumidityPercent }}, with a relative humidity of {{ . }}%{{ end }}{{ with $.Modules.CurrentConditions.WindDirectionText }} and winds from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}{{ end }}.{{ else }}{{ with .RelativeHumidityPercent }} Relative humidity is {{ . }}%.{{ end }}{{ with .WindDirectionText }} Winds are from the {{ . }}{{ with $.Modules.CurrentConditions.WindSpeedMph }} at {{ . }} mph{{ end }}.{{ end }}{{ end }} {{ end }} -## Daypart Forecast -{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}{{ if or .Summary.DominantConditionDisplay .Summary.DominantCondition }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }} -{{ end }}{{ end }}{{ else }}- No daypart forecast details are available. -{{ end }} +{{ template "today_daypart_forecast" . }} -{{ 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 }} +{{ template "precipitation_timing" . }} ## Forecast Discussion {{ range .GeneratedText.ForecastDiscussion }} {{ . }} diff --git a/internal/reporttemplate/templates/tomorrow.md.tmpl b/internal/reporttemplate/templates/tomorrow.md.tmpl index 43bed95..46a5bc4 100644 --- a/internal/reporttemplate/templates/tomorrow.md.tmpl +++ b/internal/reporttemplate/templates/tomorrow.md.tmpl @@ -5,19 +5,9 @@ {{ .GeneratedText.Summary }} -## Daypart Forecast -{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }} -{{ end }}{{ else }}- No daypart forecast details are available. -{{ end }} +{{ template "daypart_forecast" . }} -{{ 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 }} +{{ template "precipitation_timing" . }} ## Forecast Discussion {{ range .GeneratedText.ForecastDiscussion }} {{ . }}