Add daypart presentation facts
This commit is contained in:
@@ -11,24 +11,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DerivedDaypartSummaryModule struct {
|
type DerivedDaypartSummaryModule struct {
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
PeriodBegins string `json:"period_begins,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
PeriodEnds string `json:"period_ends,omitempty"`
|
PeriodBegins string `json:"period_begins,omitempty"`
|
||||||
TempRangeF string `json:"temp_range_f,omitempty"`
|
PeriodEnds string `json:"period_ends,omitempty"`
|
||||||
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
TempRangeF string `json:"temp_range_f,omitempty"`
|
||||||
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
|
||||||
MaxPopTime string `json:"max_pop_time,omitempty"`
|
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
|
||||||
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
|
||||||
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
MaxPopTime string `json:"max_pop_time,omitempty"`
|
||||||
DominantCondition string `json:"dominant_condition,omitempty"`
|
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
|
||||||
NotableConditions []string `json:"notable_conditions,omitempty"`
|
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
|
||||||
Fog bool `json:"fog,omitempty"`
|
DominantCondition string `json:"dominant_condition,omitempty"`
|
||||||
Heat bool `json:"heat,omitempty"`
|
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
|
||||||
Cold bool `json:"cold,omitempty"`
|
NotableConditions []string `json:"notable_conditions,omitempty"`
|
||||||
Wind bool `json:"wind,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
RelevantAlertCount int `json:"relevant_alert_count,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) {
|
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 {
|
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
|
||||||
value := DerivedDaypartSummaryModule{
|
value := DerivedDaypartSummaryModule{
|
||||||
Date: localDateLabel(daypart.Period.Start, timezone),
|
Date: localDateLabel(daypart.Period.Start, timezone),
|
||||||
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
|
||||||
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
|
||||||
TempRangeF: rangeLabel(daypart.Temperature),
|
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
|
||||||
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
TempRangeF: rangeLabel(daypart.Temperature),
|
||||||
DominantCondition: daypart.DominantCondition,
|
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
|
||||||
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
|
||||||
Snow: daypart.Indicators.Snow,
|
DominantCondition: daypart.DominantCondition,
|
||||||
Ice: daypart.Indicators.Ice,
|
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
|
||||||
Fog: daypart.Indicators.Fog,
|
NotableConditions: append([]string(nil), daypart.NotableConditions...),
|
||||||
Heat: daypart.Indicators.Heat,
|
Snow: daypart.Indicators.Snow,
|
||||||
Cold: daypart.Indicators.Cold,
|
Ice: daypart.Indicators.Ice,
|
||||||
Wind: daypart.Indicators.Wind,
|
Fog: daypart.Indicators.Fog,
|
||||||
RelevantAlertCount: len(daypart.AlertOverlaps),
|
Heat: daypart.Indicators.Heat,
|
||||||
|
Cold: daypart.Indicators.Cold,
|
||||||
|
Wind: daypart.Indicators.Wind,
|
||||||
|
RelevantAlertCount: len(daypart.AlertOverlaps),
|
||||||
}
|
}
|
||||||
if daypart.MaxPrecipitationProbability != nil {
|
if daypart.MaxPrecipitationProbability != nil {
|
||||||
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
|
||||||
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
|
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 {
|
if daypart.PeakWindGust != nil {
|
||||||
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
|
||||||
@@ -72,6 +82,53 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
|
|||||||
return value
|
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 {
|
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
|
|||||||
@@ -169,14 +169,20 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
|
||||||
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
|
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" {
|
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)
|
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"]
|
afternoon := value["afternoon"]
|
||||||
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
|
||||||
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
|
||||||
}
|
}
|
||||||
overnight := value["overnight"]
|
|
||||||
if !overnight.Cold {
|
if !overnight.Cold {
|
||||||
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
t.Fatalf("overnight = %#v, want cold hazard", overnight)
|
||||||
}
|
}
|
||||||
@@ -185,7 +191,7 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
|
|||||||
t.Fatalf("marshal daypart summaries: %v", err)
|
t.Fatalf("marshal daypart summaries: %v", err)
|
||||||
}
|
}
|
||||||
jsonText := string(data)
|
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) {
|
if !strings.Contains(jsonText, field) {
|
||||||
t.Fatalf("daypart json = %s, want field %s", 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) {
|
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
|
||||||
registry := MustDefaultModuleRegistry()
|
registry := MustDefaultModuleRegistry()
|
||||||
ctx := derivedModuleContext(report.Tomorrow)
|
ctx := derivedModuleContext(report.Tomorrow)
|
||||||
|
|||||||
Reference in New Issue
Block a user