package briefing import ( "fmt" "strings" "time" "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type DerivedDailySummaryModule struct { Date string `json:"date,omitempty"` HighTempF *int `json:"high_temp_f,omitempty"` LowTempF *int `json:"low_temp_f,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopWindow string `json:"max_pop_window,omitempty"` FirstPrecipHour string `json:"first_precip_hour,omitempty"` LastPrecipHour string `json:"last_precip_hour,omitempty"` ThunderMentioned bool `json:"thunder_mentioned"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"` DominantConditions []string `json:"dominant_conditions,omitempty"` Hazards []string `json:"hazards,omitempty"` } type DerivedDaypartSummaryModule struct { Period timeutil.Period `json:"period"` 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"` } type PrecipTimingModule struct { MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"` FirstPrecipHour string `json:"first_precip_hour,omitempty"` LastPrecipHour string `json:"last_precip_hour,omitempty"` ThunderMentioned bool `json:"thunder_mentioned"` } type OutdoorWindowsModule struct { Best *OutdoorWindowModule `json:"best,omitempty"` Worst *OutdoorWindowModule `json:"worst,omitempty"` } type OutdoorWindowModule struct { Daypart string `json:"daypart"` Start string `json:"start"` End string `json:"end"` Reasons []string `json:"reasons,omitempty"` Score float64 `json:"score"` } type TomorrowPlanningModule struct { MorningReadiness []string `json:"morning_readiness,omitempty"` CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"` OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"` } func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) { summary := ctx.Derived.FirstDailySummary() if summary == nil { return nil, fmt.Errorf("daily summary facts are required") } value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone) if err != nil { return nil, err } return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil } 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 buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone) return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil } func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) value := OutdoorWindowsModule{ Best: outdoorWindowValue(windows.Best), Worst: outdoorWindowValue(windows.Worst), } return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil } func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) { summary := ctx.Derived.FirstDailySummary() if summary == nil { return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil } planning := buildTomorrowPlanning(summary) value := TomorrowPlanningModule{} if planning != nil { value.MorningReadiness = append([]string(nil), planning.MorningReadiness...) value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...) value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...) } return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil } func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) { value := DerivedDailySummaryModule{ Date: summary.Date, ThunderMentioned: timing.ThunderMentioned, } conditions := map[string]struct{}{} hazards := map[string]struct{}{} var temperature forecast.Range var apparent forecast.Range var maxPop *forecast.TimedValue var maxGust *forecast.TimedValue var maxPopWindow timeutil.Period for _, daypart := range summary.Dayparts { addRange(&temperature, daypart.Temperature) addRange(&apparent, daypart.ApparentTemperature) if daypart.MaxPrecipitationProbability != nil { if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value { copied := *daypart.MaxPrecipitationProbability maxPop = &copied maxPopWindow = daypart.Period } } maxTimedValue(&maxGust, daypart.PeakWindGust) if daypart.DominantCondition != "" { conditions[daypart.DominantCondition] = struct{}{} } for _, hazard := range hazardsForIndicators(daypart.Indicators) { hazards[hazard] = struct{}{} } } for _, alert := range summary.AlertOverlaps { if alert.Event != "" { hazards[alert.Event] = struct{}{} } } value.HighTempF = roundedInt(temperature.Max) value.LowTempF = roundedInt(temperature.Min) value.HeatIndexMaxF = roundedInt(apparent.Max) if maxPop != nil { value.MaxPopPercent = roundedInt(&maxPop.Value) value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone) } if maxGust != nil { value.MaxWindGustMph = roundedInt(&maxGust.Value) } value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) value.DominantConditions = sortedSet(conditions) value.Hazards = sortedSet(hazards) return value, nil } func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { value := DerivedDaypartSummaryModule{ Period: daypart.Period, 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 precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule { value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned} if timing.MaxPrecipitationProbability != nil { value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value) value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone) } value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) return value } func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { if window == nil { return nil } return &OutdoorWindowModule{ Daypart: window.Daypart, Start: window.Start, End: window.End, Reasons: append([]string(nil), window.Reasons...), Score: window.Score, } } 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(), "_") } func rangeLabel(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 && *low == *high { return fmt.Sprintf("%d", *low) } return fmt.Sprintf("%d-%d", *low, *high) } if value.Min != nil { low := roundedInt(value.Min) return fmt.Sprintf("%d", *low) } high := roundedInt(value.Max) return fmt.Sprintf("%d", *high) } func daypartApparentRangeLabel(value forecast.Range) string { if value.Min == nil && value.Max == nil { return "" } return rangeLabel(value) } func roundedInt(value *float64) *int { if value == nil { return nil } rounded := int(*value + 0.5) if *value < 0 { rounded = int(*value - 0.5) } return &rounded } func timedClockLabel(value *forecast.TimedValue, timezone string) string { if value == nil { return "" } return clockLabel(value.Time, timezone) } func periodClockLabel(period timeutil.Period, timezone string) string { if !period.IsValid() { return "" } return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) } func clockLabel(value time.Time, timezone string) string { location, err := timeutil.LoadLocation(timezone) if err != nil { location = time.UTC } label := value.In(location).Format("3 PM") if label == "12 AM" && value.In(location).Minute() == 0 { return "12 AM" } return label }