package briefing import ( "fmt" "math" "sort" "strings" "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type OutdoorWindows struct { Best *OutdoorWindow Worst *OutdoorWindow } type OutdoorWindow struct { Daypart string Period timeutil.Period Reasons []string Score float64 } type TomorrowPlanning struct { MorningReadiness []string CommuteSchoolWorkdayConcerns []string OvernightChangeWatch []string } type morningCommuteOvernightPlanning struct { MorningReadiness []string CommuteSchoolWorkdayConcerns []string OvernightChangeWatch []string } type TodayPlanning struct { MorningReadiness []string CommuteSchoolWorkdayConcerns []string OutdoorPlanning []string LateDayChangeWatch []string } const outdoorIndicatorRiskScore = 25 const ( morningDaypartIdentity = "morning" afternoonDaypartIdentity = "afternoon" eveningDaypartIdentity = "evening" overnightDaypartIdentity = "overnight" ) func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { var best *OutdoorWindow var worst *OutdoorWindow for _, daypart := range dayparts { if len(daypart.HourlyPeriods) == 0 { continue } window := scoreOutdoorWindow(daypart) if best == nil || window.Score < best.Score { copied := window best = &copied } if worst == nil || window.Score > worst.Score { copied := window worst = &copied } } return OutdoorWindows{Best: best, Worst: worst} } func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning { planning := &TodayPlanning{} morning := daypartNamed(summary.Dayparts, "morning") if morning != nil { planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) } if len(planning.MorningReadiness) == 0 { planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.") } for _, daypart := range summary.Dayparts { if isOutsideWorkday(daypart) { continue } planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...) } for _, alert := range summary.AlertOverlaps { if alert.Event != "" { planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".") } } if len(planning.CommuteSchoolWorkdayConcerns) == 0 { planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.") } planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...) if len(planning.OutdoorPlanning) == 0 { planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.") } for _, name := range []string{afternoonDaypartIdentity, eveningDaypartIdentity} { daypart := daypartNamed(summary.Dayparts, name) if daypart != nil { planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...) } } if len(planning.LateDayChangeWatch) == 0 { planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.") } return planning } func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning { base := buildMorningCommuteOvernightPlanning(summary) return &TomorrowPlanning{ MorningReadiness: append([]string(nil), base.MorningReadiness...), CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...), OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...), } } func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning { planning := &morningCommuteOvernightPlanning{} morning := daypartNamed(summary.Dayparts, "morning") if morning != nil { planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...) } if len(planning.MorningReadiness) == 0 { planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.") } for _, daypart := range summary.Dayparts { if isOutsideWorkday(daypart) { continue } planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...) } for _, alert := range summary.AlertOverlaps { if alert.Event != "" { planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".") } } if len(planning.CommuteSchoolWorkdayConcerns) == 0 { planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.") } overnight := daypartNamed(summary.Dayparts, "overnight") if overnight != nil { planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, overnightWatchNotes(*overnight)...) } if len(planning.OvernightChangeWatch) == 0 { planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") } return planning } func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string { windows := buildOutdoorWindows(dayparts) var notes []string if windows.Best != nil { notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", capitalizeFirst(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", "))) } if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) { notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", capitalizeFirst(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", "))) } return appendUnique(nil, notes...) } func readinessNotes(daypart forecast.DaypartSummary) []string { notes := []string{} if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 { notes = append(notes, fmt.Sprintf("Morning precipitation chance peaks near %.0f%%.", daypart.MaxPrecipitationProbability.Value)) } if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value)) } if daypart.Indicators.Snow || daypart.Indicators.Ice { notes = append(notes, "Morning wintry weather could affect surfaces and travel.") } if daypart.Indicators.Fog { notes = append(notes, "Morning fog could reduce visibility.") } if daypart.Temperature.Min != nil && *daypart.Temperature.Min <= 32 { notes = append(notes, "Morning temperatures may be at or below freezing.") } return appendUnique(nil, notes...) } func lateDayWatchNotes(daypart forecast.DaypartSummary) []string { notes := []string{} prefix := capitalizeFirst(daypart.Name) if prefix == "" { prefix = "Late-day" } if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 { notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value)) } if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value)) } if daypart.Indicators.Snow || daypart.Indicators.Ice { notes = append(notes, prefix+" wintry weather could affect late-day travel.") } if len(daypart.AlertOverlaps) > 0 { notes = append(notes, prefix+" alert timing could affect late-day plans.") } return appendUnique(nil, notes...) } func concernNotes(daypart forecast.DaypartSummary) []string { notes := []string{} prefix := capitalizeFirst(daypart.Name) if prefix == "" { prefix = "Daytime" } if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 40 { notes = append(notes, fmt.Sprintf("%s precipitation chance reaches %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value)) } if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value)) } if daypart.Indicators.Snow || daypart.Indicators.Ice { notes = append(notes, prefix+" wintry weather may affect travel.") } if daypart.Indicators.Heat { notes = append(notes, prefix+" heat may require extra hydration and breaks.") } if daypart.Indicators.Cold { notes = append(notes, prefix+" cold may require extra layers.") } if len(daypart.AlertOverlaps) > 0 { notes = append(notes, prefix+" alert overlap needs attention.") } return appendUnique(nil, notes...) } func overnightWatchNotes(daypart forecast.DaypartSummary) []string { notes := []string{} if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 { notes = append(notes, fmt.Sprintf("Overnight precipitation timing may shift; current peak is near %.0f%%.", daypart.MaxPrecipitationProbability.Value)) } if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value)) } if daypart.Indicators.Snow || daypart.Indicators.Ice { notes = append(notes, "Overnight wintry weather could leave morning travel impacts.") } if len(daypart.AlertOverlaps) > 0 { notes = append(notes, "Overnight alert timing could affect the morning setup.") } return appendUnique(nil, notes...) } func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary { identity := forecast.CanonicalDaypartKey(name) for i := range dayparts { if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity { return &dayparts[i] } } return nil } func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow { score := 0.0 reasons := []string{} if daypart.MaxPrecipitationProbability != nil { score += daypart.MaxPrecipitationProbability.Value if daypart.MaxPrecipitationProbability.Value >= 50 { reasons = append(reasons, "high precipitation chance") } } if daypart.PeakWindGust != nil { score += daypart.PeakWindGust.Value * 1.5 if daypart.PeakWindGust.Value >= 30 { reasons = append(reasons, "gusty wind") } } if len(daypart.AlertOverlaps) > 0 { score += float64(len(daypart.AlertOverlaps)) * 100 reasons = append(reasons, "alert overlap") } if daypart.Indicators.Heat || daypart.Indicators.Cold { score += outdoorIndicatorRiskScore if daypart.Indicators.Heat { reasons = append(reasons, "heat risk") } if daypart.Indicators.Cold { reasons = append(reasons, "cold risk") } } for _, hazard := range []struct { present bool reason string }{ {present: daypart.Indicators.Snow, reason: "snow risk"}, {present: daypart.Indicators.Ice, reason: "ice risk"}, {present: daypart.Indicators.Fog, reason: "fog risk"}, } { if hazard.present { score += outdoorIndicatorRiskScore reasons = append(reasons, hazard.reason) } } if len(reasons) == 0 { reasons = append(reasons, "quiet weather") } return OutdoorWindow{ Daypart: daypart.Name, Period: daypart.Period, Reasons: dedupe(reasons), Score: math.Round(score*10) / 10, } } func hazardsForIndicators(indicators forecast.Indicators) []string { var hazards []string if indicators.Snow { hazards = append(hazards, "snow") } if indicators.Ice { hazards = append(hazards, "ice") } if indicators.Fog { hazards = append(hazards, "fog") } if indicators.Heat { hazards = append(hazards, "heat") } if indicators.Cold { hazards = append(hazards, "cold") } if indicators.Wind { hazards = append(hazards, "wind") } return hazards } func addRange(target *forecast.Range, value forecast.Range) { if value.Min != nil { if target.Min == nil || *value.Min < *target.Min { copied := *value.Min target.Min = &copied } } if value.Max != nil { if target.Max == nil || *value.Max > *target.Max { copied := *value.Max target.Max = &copied } } } func maxTimedValue(target **forecast.TimedValue, value *forecast.TimedValue) { if value == nil { return } if *target == nil || value.Value > (*target).Value { copied := *value *target = &copied } } func sortedSet(values map[string]struct{}) []string { out := make([]string, 0, len(values)) for value := range values { out = append(out, value) } sort.Strings(out) return out } func dedupe(values []string) []string { seen := map[string]struct{}{} out := []string{} for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} out = append(out, value) } return out } func appendUnique(values []string, candidates ...string) []string { seen := map[string]struct{}{} for _, value := range values { seen[value] = struct{}{} } for _, candidate := range candidates { if candidate == "" { continue } if _, ok := seen[candidate]; ok { continue } seen[candidate] = struct{}{} values = append(values, candidate) } return values } func isOutsideWorkday(daypart forecast.DaypartSummary) bool { switch forecast.CanonicalDaypartKey(daypart.Name) { case overnightDaypartIdentity, eveningDaypartIdentity: return true default: return false } } func capitalizeFirst(value string) string { if value == "" { return "" } runes := []rune(value) runes[0] = unicode.ToUpper(runes[0]) return string(runes) }