2026-05-29 17:23:49 +00:00
package briefing
import (
"fmt"
"math"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
type OutdoorWindows struct {
2026-06-09 21:33:34 +00:00
Best * OutdoorWindow
Worst * OutdoorWindow
2026-05-29 17:23:49 +00:00
}
type OutdoorWindow struct {
2026-06-09 21:33:34 +00:00
Daypart string
Start string
End string
Reasons [ ] string
Score float64
2026-05-29 17:23:49 +00:00
}
2026-05-29 18:00:27 +00:00
type TomorrowPlanning struct {
2026-06-09 21:33:34 +00:00
MorningReadiness [ ] string
CommuteSchoolWorkdayConcerns [ ] string
OvernightChangeWatch [ ] string
2026-05-29 17:23:49 +00:00
}
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 }
}
2026-05-29 18:00:27 +00:00
func buildTomorrowPlanning ( summary * forecast . DailySummary ) * TomorrowPlanning {
planning := & TomorrowPlanning { }
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 daypart . Name == "overnight" || daypart . Name == "evening" {
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 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 concernNotes ( daypart forecast . DaypartSummary ) [ ] string {
notes := [ ] string { }
prefix := titleWord ( 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 {
for i := range dayparts {
if strings . EqualFold ( dayparts [ i ] . Name , name ) {
return & dayparts [ i ]
}
}
return nil
}
2026-05-29 17:23:49 +00:00
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 += 25
if daypart . Indicators . Heat {
reasons = append ( reasons , "heat risk" )
}
if daypart . Indicators . Cold {
reasons = append ( reasons , "cold risk" )
}
}
if len ( reasons ) == 0 {
reasons = append ( reasons , "quiet weather" )
}
return OutdoorWindow {
Daypart : daypart . Name ,
Start : daypart . Period . Start . Format ( "15:04" ) ,
End : daypart . Period . End . Format ( "15:04" ) ,
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
}
2026-05-29 18:00:27 +00:00
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 titleWord ( value string ) string {
if value == "" {
return ""
}
return strings . ToUpper ( value [ : 1 ] ) + value [ 1 : ]
}