package briefing import ( "fmt" "strings" "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) 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"` } func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { if len(ctx.Derived.DaypartSummaries) == 0 { return nil, fmt.Errorf("daypart summary facts are required") } value := map[string]DerivedDaypartSummaryModule{} prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries) for _, daypart := range ctx.Derived.DaypartSummaries { key := daypartKey(daypart, prefixDates) value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone) } return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil } 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), } if daypart.MaxPrecipitationProbability != nil { value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) } if daypart.PeakWindGust != nil { value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone) } return value } func multipleSummaryDates(summaries []forecast.DailySummary) bool { seen := map[string]struct{}{} for _, summary := range summaries { seen[summary.Date] = struct{}{} } return len(seen) > 1 } func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string { key := normalizedKey(daypart.Name) if key == "" { key = "unnamed" } if !prefixDate { return key } return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key } func normalizedKey(value string) string { lower := strings.ToLower(strings.TrimSpace(value)) var out strings.Builder lastUnderscore := false for _, r := range lower { if unicode.IsLetter(r) || unicode.IsDigit(r) { out.WriteRune(r) lastUnderscore = false continue } if !lastUnderscore { out.WriteByte('_') lastUnderscore = true } } return strings.Trim(out.String(), "_") }