diff --git a/internal/briefing/derived_daypart_summaries_module.go b/internal/briefing/derived_daypart_summaries_module.go index 3859fa9..d0a2bdc 100644 --- a/internal/briefing/derived_daypart_summaries_module.go +++ b/internal/briefing/derived_daypart_summaries_module.go @@ -11,24 +11,29 @@ import ( ) type DerivedDaypartSummaryModule struct { - Date string `json:"date,omitempty"` - PeriodBegins string `json:"period_begins,omitempty"` - PeriodEnds string `json:"period_ends,omitempty"` - TempRangeF string `json:"temp_range_f,omitempty"` - ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopTime string `json:"max_pop_time,omitempty"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` - MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` - DominantCondition string `json:"dominant_condition,omitempty"` - NotableConditions []string `json:"notable_conditions,omitempty"` - Snow bool `json:"snow,omitempty"` - Ice bool `json:"ice,omitempty"` - Fog bool `json:"fog,omitempty"` - Heat bool `json:"heat,omitempty"` - Cold bool `json:"cold,omitempty"` - Wind bool `json:"wind,omitempty"` - RelevantAlertCount int `json:"relevant_alert_count,omitempty"` + Date string `json:"date,omitempty"` + DisplayName string `json:"display_name,omitempty"` + PeriodBegins string `json:"period_begins,omitempty"` + PeriodEnds string `json:"period_ends,omitempty"` + TempRangeF string `json:"temp_range_f,omitempty"` + TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"` + ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"` + MentionPrecipitation bool `json:"mention_precipitation,omitempty"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` + MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` + DominantCondition string `json:"dominant_condition,omitempty"` + DominantConditionLower string `json:"dominant_condition_lower,omitempty"` + NotableConditions []string `json:"notable_conditions,omitempty"` + Snow bool `json:"snow,omitempty"` + Ice bool `json:"ice,omitempty"` + Fog bool `json:"fog,omitempty"` + Heat bool `json:"heat,omitempty"` + Cold bool `json:"cold,omitempty"` + Wind bool `json:"wind,omitempty"` + RelevantAlertCount int `json:"relevant_alert_count,omitempty"` } func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { @@ -46,24 +51,29 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { value := DerivedDaypartSummaryModule{ - Date: localDateLabel(daypart.Period.Start, timezone), - PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone), - PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone), - TempRangeF: rangeLabel(daypart.Temperature), - ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), - DominantCondition: daypart.DominantCondition, - NotableConditions: append([]string(nil), daypart.NotableConditions...), - Snow: daypart.Indicators.Snow, - Ice: daypart.Indicators.Ice, - Fog: daypart.Indicators.Fog, - Heat: daypart.Indicators.Heat, - Cold: daypart.Indicators.Cold, - Wind: daypart.Indicators.Wind, - RelevantAlertCount: len(daypart.AlertOverlaps), + Date: localDateLabel(daypart.Period.Start, timezone), + DisplayName: titleWord(strings.TrimSpace(daypart.Name)), + PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone), + PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone), + TempRangeF: rangeLabel(daypart.Temperature), + TemperaturePhraseF: temperaturePhraseF(daypart.Temperature), + ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), + DominantCondition: daypart.DominantCondition, + DominantConditionLower: strings.ToLower(daypart.DominantCondition), + NotableConditions: append([]string(nil), daypart.NotableConditions...), + Snow: daypart.Indicators.Snow, + Ice: daypart.Indicators.Ice, + Fog: daypart.Indicators.Fog, + Heat: daypart.Indicators.Heat, + Cold: daypart.Indicators.Cold, + Wind: daypart.Indicators.Wind, + RelevantAlertCount: len(daypart.AlertOverlaps), } if daypart.MaxPrecipitationProbability != nil { value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) + value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone) + value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold) } if daypart.PeakWindGust != nil { value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) @@ -72,6 +82,53 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string return value } +func temperaturePhraseF(value forecast.Range) string { + if value.Min == nil && value.Max == nil { + return "" + } + if value.Min != nil && value.Max != nil { + low := roundedInt(value.Min) + high := roundedInt(value.Max) + if low == nil || high == nil { + return "" + } + lowPhrase := temperatureBandPhrase(*low) + highPhrase := temperatureBandPhrase(*high) + if lowPhrase == highPhrase { + return lowPhrase + } + return lowPhrase + " to " + highPhrase + } + if value.Min != nil { + low := roundedInt(value.Min) + if low == nil { + return "" + } + return temperatureBandPhrase(*low) + } + high := roundedInt(value.Max) + if high == nil { + return "" + } + return temperatureBandPhrase(*high) +} + +func temperatureBandPhrase(value int) string { + decade := (value / 10) * 10 + remainder := value - decade + if remainder < 0 { + remainder = -remainder + } + qualifier := "mid" + switch { + case remainder <= 3: + qualifier = "low" + case remainder >= 7: + qualifier = "upper" + } + return fmt.Sprintf("%s %ds", qualifier, decade) +} + func multipleSummaryDates(summaries []forecast.DailySummary) bool { seen := map[string]struct{}{} for _, summary := range summaries { diff --git a/internal/briefing/derived_modules_test.go b/internal/briefing/derived_modules_test.go index e3b7c93..15cffa3 100644 --- a/internal/briefing/derived_modules_test.go +++ b/internal/briefing/derived_modules_test.go @@ -169,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { t.Fatalf("morning = %#v, want temp range and precip peak", morning) } + if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.TemperaturePhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" { + t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning) + } if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" { t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds) } + overnight := value["overnight"] + if overnight.MentionPrecipitation { + t.Fatalf("overnight MentionPrecipitation = true, want false below threshold") + } afternoon := value["afternoon"] if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 { t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon) } - overnight := value["overnight"] if !overnight.Cold { t.Fatalf("overnight = %#v, want cold hazard", overnight) } @@ -185,7 +191,7 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { t.Fatalf("marshal daypart summaries: %v", err) } jsonText := string(data) - for _, field := range []string{"date", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_wind_gust_mph", "dominant_condition"} { + for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower"} { if !strings.Contains(jsonText, field) { t.Fatalf("daypart json = %s, want field %s", jsonText, field) } @@ -195,6 +201,47 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) { } } +func TestTemperaturePhraseF(t *testing.T) { + tests := []struct { + name string + value forecast.Range + want string + }{ + { + name: "single low band", + value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)}, + want: "low 70s", + }, + { + name: "single upper value", + value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)}, + want: "upper 60s", + }, + { + name: "range across bands", + value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)}, + want: "upper 60s to mid 70s", + }, + { + name: "max only", + value: forecast.Range{Max: floatPtr(84)}, + want: "mid 80s", + }, + { + name: "empty", + value: forecast.Range{}, + want: "", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := temperaturePhraseF(test.value); got != test.want { + t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want) + } + }) + } +} + func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { registry := MustDefaultModuleRegistry() ctx := derivedModuleContext(report.Tomorrow)