From 5203440ba005a653798df78c50ce501cfc7a85be Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 15 Jun 2026 16:29:18 +0000 Subject: [PATCH] Add daily render context --- docs/internal/generatedtext.md | 8 +- docs/internal/reporttemplate.md | 15 +- internal/generatedtext/catalog.go | 15 +- internal/generatedtext/catalog_test.go | 32 +- internal/generatedtext/render_context.go | 165 ++++++++++ internal/generatedtext/render_context_test.go | 298 ++++++++++++++++++ 6 files changed, 512 insertions(+), 21 deletions(-) diff --git a/docs/internal/generatedtext.md b/docs/internal/generatedtext.md index c6f00e7..ed1b3d6 100644 --- a/docs/internal/generatedtext.md +++ b/docs/internal/generatedtext.md @@ -9,8 +9,8 @@ This document describes structured generated-text handling in generated-text-template reports and builds curated render contexts for templates. It also owns the generated-text catalog that connects report definitions to validators, render-context builders, schema assets, and template -assets. The implemented contracts are Daily validation and asset lookup, Today -Report, Tomorrow Report, and Hourly Report. +assets. The implemented contracts are Daily render context, Today Report, +Tomorrow Report, and Hourly Report. ## Inputs And Outputs @@ -28,6 +28,7 @@ Outputs: - typed `Tomorrow` generated text - typed `Hourly` generated text - normalized stable JSON for validated generated text +- typed `DailyRenderContext` values for `internal/reporttemplate` - typed `TodayRenderContext` values for `internal/reporttemplate` - typed `TomorrowRenderContext` values for `internal/reporttemplate` - typed `HourlyRenderContext` values for `internal/reporttemplate` @@ -36,8 +37,7 @@ Outputs: The Daily generated text JSON accepts the same public fields and validation rules as Tomorrow. Its catalog entry is selected through schema ID `daily` and -template ID `daily`; render-context construction is not implemented until the -Daily report context exists. +template ID `daily`. ```json { diff --git a/docs/internal/reporttemplate.md b/docs/internal/reporttemplate.md index 27a013d..3f50b45 100644 --- a/docs/internal/reporttemplate.md +++ b/docs/internal/reporttemplate.md @@ -53,7 +53,7 @@ render-context builder, and embedded assets. ## Template Contracts -Today, Tomorrow, and Hourly rendering use typed render contexts with: +Daily, Today, Tomorrow, and Hourly rendering use typed render contexts with: - report metadata labels such as title, location, valid period, and generation time @@ -62,14 +62,13 @@ Today, Tomorrow, and Hourly rendering use typed render contexts with: conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks, forecast discussion, SPC discussion, and weather story -Today and Tomorrow additionally expose forecast-date labels, ordered daypart -forecast rows, daily/daypart summaries, planning facts, and a multi-paragraph -forecast discussion generated-text slot. The ordered daypart slice is built in -Go so templates do not range over maps. +Daily, Today, and Tomorrow additionally expose forecast-date labels, ordered +daypart forecast rows, daily/daypart summaries, planning facts, and a +multi-paragraph forecast discussion generated-text slot. The ordered daypart +slice is built in Go so templates do not range over maps. -The Daily template asset uses the same Markdown structure and field surface as -Tomorrow's template. Its typed render context in `internal/generatedtext` is -not implemented yet. +The Daily template asset uses the same Markdown structure as Tomorrow's +template and renders from `generatedtext.DailyRenderContext`. Templates use `text/template` with `missingkey=error`, so missing context fields fail rendering instead of producing incomplete Markdown. diff --git a/internal/generatedtext/catalog.go b/internal/generatedtext/catalog.go index 5534bd6..f291416 100644 --- a/internal/generatedtext/catalog.go +++ b/internal/generatedtext/catalog.go @@ -49,9 +49,10 @@ var catalog = []catalogEntry{ renderContextBuilder: buildHourlyContext, }, { - schemaID: schemaIDDaily, - templateID: templateIDDaily, - validate: validateDaily, + schemaID: schemaIDDaily, + templateID: templateIDDaily, + validate: validateDaily, + renderContextBuilder: buildDailyContext, }, { schemaID: schemaIDToday, @@ -169,6 +170,14 @@ func buildHourlyContext(reportID report.ID, templateID string, metadata briefing return BuildHourlyRenderContext(metadata, snapshot, hourly, collected, derived) } +func buildDailyContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { + daily, ok := generated.(Daily) + if !ok { + return nil, fmt.Errorf("report template %q requires daily generated text for report %q", templateID, reportID) + } + return BuildDailyRenderContext(metadata, snapshot, daily, collected, derived) +} + func buildTodayContext(reportID report.ID, templateID string, metadata briefing.Metadata, snapshot module.Snapshot, collected facts.CollectedFacts, derived facts.DerivedFacts, generated any) (any, error) { today, ok := generated.(Today) if !ok { diff --git a/internal/generatedtext/catalog_test.go b/internal/generatedtext/catalog_test.go index e5c9259..6de7e5d 100644 --- a/internal/generatedtext/catalog_test.go +++ b/internal/generatedtext/catalog_test.go @@ -279,9 +279,29 @@ func TestCatalogBuildRenderContextRejectsMismatchedGeneratedText(t *testing.T) { if !strings.Contains(err.Error(), `requires today generated text`) { t.Fatalf("BuildRenderContext(today) error = %v, want today generated text requirement", err) } + + dailyHandler, err := LookupDefinition(report.Definition{ + ID: report.ID("daily"), + GenerationMode: report.GenerationModeGeneratedTextTemplate, + GeneratedTextSchemaID: "daily", + TemplateID: "daily", + }) + if err != nil { + t.Fatalf("LookupDefinition(daily) error = %v", err) + } + _, err = dailyHandler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Tomorrow{ + Summary: "Storms become more likely tomorrow.", + ForecastDiscussion: []string{"A front will keep showers in the forecast."}, + }) + if err == nil { + t.Fatal("BuildRenderContext(daily) error = nil, want type mismatch") + } + if !strings.Contains(err.Error(), `requires daily generated text`) { + t.Fatalf("BuildRenderContext(daily) error = %v, want daily generated text requirement", err) + } } -func TestCatalogBuildRenderContextRejectsDailyUntilContextExists(t *testing.T) { +func TestCatalogBuildRenderContextSupportsDaily(t *testing.T) { handler, err := LookupDefinition(report.Definition{ ID: report.ID("daily"), GenerationMode: report.GenerationModeGeneratedTextTemplate, @@ -291,14 +311,14 @@ func TestCatalogBuildRenderContextRejectsDailyUntilContextExists(t *testing.T) { if err != nil { t.Fatalf("LookupDefinition(daily) error = %v", err) } - _, err = handler.BuildRenderContext(testTomorrowMetadata(), testTomorrowSnapshot(t), testCollected(), testTomorrowDerived(), Daily{ + ctx, err := handler.BuildRenderContext(testDailyMetadata(), testDailySnapshot(t), testCollected(), testDailyDerived(), Daily{ Summary: "Showers are possible during the selected day.", ForecastDiscussion: []string{"A front will keep rain chances in the forecast."}, }) - if err == nil { - t.Fatal("BuildRenderContext(daily) error = nil, want missing builder") + if err != nil { + t.Fatalf("BuildRenderContext(daily) error = %v", err) } - if !strings.Contains(err.Error(), `render-context builder is not registered for template "daily"`) { - t.Fatalf("BuildRenderContext(daily) error = %v, want missing daily builder", err) + if _, ok := ctx.(DailyRenderContext); !ok { + t.Fatalf("BuildRenderContext(daily) = %T, want DailyRenderContext", ctx) } } diff --git a/internal/generatedtext/render_context.go b/internal/generatedtext/render_context.go index 0e9078e..9c1b045 100644 --- a/internal/generatedtext/render_context.go +++ b/internal/generatedtext/render_context.go @@ -52,6 +52,14 @@ type TomorrowRenderContext struct { Derived facts.DerivedFacts } +type DailyRenderContext struct { + Report DailyReportContext + GeneratedText Daily + Modules DailyTemplateModules + Collected facts.CollectedFacts + Derived facts.DerivedFacts +} + type TodayRenderContext struct { Report TodayReportContext GeneratedText Today @@ -124,6 +132,39 @@ type TomorrowDaypartContext struct { Summary briefing.DerivedDaypartSummaryModule } +type DailyReportContext struct { + Title string + ForecastDate time.Time + ForecastDateLabel string + ForecastDayName string + GeneratedAt time.Time + GeneratedAtLabel string + ValidPeriod timeutil.Period + Timezone string +} + +type DailyTemplateModules struct { + Metadata *briefing.MetadataModule + CurrentConditions *briefing.CurrentConditionsModule + HourlyForecast *briefing.HourlyForecastModule + DerivedDailySummary *briefing.DerivedDailySummaryModule + DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule + Dayparts []DailyDaypartContext + PrecipTiming *briefing.PrecipTimingModule + AlertDigest *briefing.AlertDigestModule + SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule + AreaForecastDiscussion *briefing.AreaForecastDiscussionModule + SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule + WeatherStory *briefing.WeatherStoryModule + OutdoorWindows *briefing.OutdoorWindowsModule + DailyPlanning *briefing.DailyPlanningModule +} + +type DailyDaypartContext 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 { @@ -156,6 +197,41 @@ func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapsh }, nil } +func BuildDailyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Daily, collected facts.CollectedFacts, derived facts.DerivedFacts) (DailyRenderContext, error) { + location, err := timeutil.LoadLocation(metadata.Timezone) + 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") + } + 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, + }, + GeneratedText: generated, + Modules: modules, + Collected: collected, + Derived: derived, + }, nil +} + func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { @@ -225,6 +301,78 @@ func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snap }, nil } +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() + if err != nil { + return DailyTemplateModules{}, err + } + outdoor, err := lookup.outdoorWindows() + if err != nil { + return DailyTemplateModules{}, err + } + planning, err := lookup.dailyPlanning() + if err != nil { + 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, + OutdoorWindows: outdoor, + DailyPlanning: planning, + }, nil +} + func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) { lookup := newModuleSnapshotLookup(snapshot) metadata, err := lookup.metadata() @@ -470,6 +618,14 @@ func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory) } +func (lookup moduleSnapshotLookup) outdoorWindows() (*briefing.OutdoorWindowsModule, error) { + return optionalStanza[briefing.OutdoorWindowsModule](lookup, module.OutdoorWindows) +} + +func (lookup moduleSnapshotLookup) dailyPlanning() (*briefing.DailyPlanningModule, error) { + return optionalStanza[briefing.DailyPlanningModule](lookup, module.DailyPlanning) +} + func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) { return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning) } @@ -500,6 +656,15 @@ func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryMod return out } +func orderedDailyDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []DailyDaypartContext { + orderedRows := orderedDaypartRows(dayparts, ordered) + out := make([]DailyDaypartContext, 0, len(orderedRows)) + for _, row := range orderedRows { + out = append(out, DailyDaypartContext{Key: row.Key, Summary: row.Summary}) + } + return out +} + func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext { orderedRows := orderedDaypartRows(dayparts, ordered) out := make([]TomorrowDaypartContext, 0, len(orderedRows)) diff --git a/internal/generatedtext/render_context_test.go b/internal/generatedtext/render_context_test.go index 5592136..c8cb676 100644 --- a/internal/generatedtext/render_context_test.go +++ b/internal/generatedtext/render_context_test.go @@ -260,6 +260,132 @@ func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) { } } +func TestBuildDailyRenderContext(t *testing.T) { + metadata := testDailyMetadata() + snapshot := testDailySnapshot(t) + generated := Daily{ + Summary: "The selected day 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 := testDailyDerived() + + ctx, err := BuildDailyRenderContext(metadata, snapshot, generated, collected, derived) + if err != nil { + t.Fatalf("BuildDailyRenderContext() 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 != "Saturday, June 13, 2026 at 9:14 AM" { + t.Fatalf("Report.GeneratedAtLabel = %q, want friendly generated-at label", ctx.Report.GeneratedAtLabel) + } + if !ctx.Report.ValidPeriod.Start.Equal(metadata.ValidPeriod.Start) || !ctx.Report.ValidPeriod.End.Equal(metadata.ValidPeriod.End) || ctx.Report.Timezone != "America/Chicago" { + t.Fatalf("period/timezone = %#v/%q, want metadata passthrough", ctx.Report.ValidPeriod, ctx.Report.Timezone) + } + if ctx.Modules.Metadata == nil || ctx.Modules.Metadata.ReportID != report.ID("daily") { + t.Fatalf("Modules.Metadata = %#v, want daily 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) != 3 { + t.Fatalf("Modules.DerivedDaypartSummaries = %#v, want daypart map", ctx.Modules.DerivedDaypartSummaries) + } + if len(ctx.Modules.Dayparts) != 3 || ctx.Modules.Dayparts[0].Key != "morning" || ctx.Modules.Dayparts[1].Key != "afternoon" || ctx.Modules.Dayparts[2].Key != "evening" { + t.Fatalf("Modules.Dayparts = %#v, want derived order followed by remaining keys", 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.OutdoorWindows == nil || ctx.Modules.DailyPlanning == nil { + t.Fatalf("optional modules missing from render context: %#v", ctx.Modules) + } + if ctx.Modules.OutdoorWindows.Best == nil || ctx.Modules.OutdoorWindows.Best.Daypart != "morning" { + t.Fatalf("Modules.OutdoorWindows = %#v, want morning best window", ctx.Modules.OutdoorWindows) + } + if ctx.Modules.DailyPlanning.MorningReadiness[0] != "Take sunglasses early." { + t.Fatalf("Modules.DailyPlanning = %#v, want daily planning facts", ctx.Modules.DailyPlanning) + } + 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("daily", 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", + "**Updated:** Saturday, June 13, 2026 at 9:14 AM", + "The selected day starts quiet, then showers become more likely later in the day.", + "- **Morning:** Partly cloudy, with temperatures in the low 60s.", + "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.", + "- **Evening:** Mostly cloudy, with temperatures in the upper 60s.", + "## 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:**", + "- **Evening:**", + "## Precipitation Timing", + "## Forecast Discussion", + }) +} + +func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) { + snapshot, err := module.NewSnapshot(nil) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + ctx, err := BuildDailyRenderContext(testDailyMetadata(), snapshot, Daily{ + Summary: "Dry weather is expected for the selected day.", + ForecastDiscussion: []string{"High pressure keeps conditions quiet."}, + }, testCollected(), facts.DerivedFacts{}) + if err != nil { + t.Fatalf("BuildDailyRenderContext() error = %v", err) + } + if ctx.Modules.Metadata != nil || ctx.Modules.CurrentConditions != nil || ctx.Modules.PrecipTiming != nil || ctx.Modules.OutdoorWindows != nil || ctx.Modules.DailyPlanning != nil || len(ctx.Modules.Dayparts) != 0 { + t.Fatalf("Modules = %#v, want omitted optional modules", ctx.Modules) + } + rendered, err := reporttemplate.Render("daily", 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 TestBuildTomorrowRenderContext(t *testing.T) { metadata := testTomorrowMetadata() snapshot := testTomorrowSnapshot(t) @@ -414,6 +540,26 @@ func testTomorrowMetadata() briefing.Metadata { } } +func testDailyMetadata() briefing.Metadata { + generatedAt := time.Date(2026, 6, 13, 14, 14, 0, 0, time.UTC) + return briefing.Metadata{ + RunID: "run-daily", + ReportID: report.ID("daily"), + PromptID: "weather.daily_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 testTodayMetadata() briefing.Metadata { generatedAt := time.Date(2026, 6, 15, 12, 14, 0, 0, time.UTC) return briefing.Metadata{ @@ -446,6 +592,10 @@ func testTomorrowDerived() facts.DerivedFacts { return testCivilDayDerived() } +func testDailyDerived() facts.DerivedFacts { + return testCivilDayDerived() +} + func testTodayDerived() facts.DerivedFacts { return testCivilDayDerived() } @@ -605,6 +755,154 @@ func testTodaySnapshot(t *testing.T) module.Snapshot { return snapshot } +func testDailySnapshot(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-daily", + ReportID: report.ID("daily"), + PromptID: "weather.daily_generated_text", + GeneratedAt: testDailyMetadata().GeneratedAt, + Units: "imperial", + Timezone: "America/Chicago", + ValidPeriod: testDailyMetadata().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", + TemperatureTrend: "steady", + TemperatureSteadyPhraseF: "mid 70s", + DominantConditionDisplay: "Showers", + DominantConditionLower: "showers", + MaxPopPercent: intPtr(70), + MaxPopTimeLabel: "3:00 PM", + MentionPrecipitation: true, + }, + "evening": { + DisplayName: "Evening", + TemperatureTrend: "steady", + TemperatureSteadyPhraseF: "upper 60s", + DominantConditionDisplay: "Mostly cloudy", + DominantConditionLower: "mostly cloudy", + }, + "morning": { + DisplayName: "Morning", + TemperatureTrend: "steady", + TemperatureSteadyPhraseF: "low 60s", + DominantConditionDisplay: "Partly cloudy", + 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.OutdoorWindows, + StanzaName: string(module.OutdoorWindows), + Value: briefing.OutdoorWindowsModule{ + Best: &briefing.OutdoorWindowModule{ + Daypart: "morning", + Reasons: []string{"quiet weather"}, + }, + Worst: &briefing.OutdoorWindowModule{ + Daypart: "afternoon", + Reasons: []string{"high precipitation chance"}, + }, + }, + }, + { + ID: module.DailyPlanning, + StanzaName: string(module.DailyPlanning), + Value: briefing.DailyPlanningModule{ + MorningReadiness: []string{"Take sunglasses early."}, + }, + }, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + return snapshot +} + func testSnapshot(t *testing.T) module.Snapshot { t.Helper() snapshot, err := module.NewSnapshot([]module.Output{