2026-05-29 17:23:49 +00:00
package briefing
import (
"fmt"
"math"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
2026-06-12 12:00:40 -05:00
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
2026-05-29 17:23:49 +00:00
)
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
2026-06-12 12:00:40 -05:00
Period timeutil . Period
2026-06-09 21:33:34 +00:00
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
}
2026-06-15 16:17:22 +00:00
type morningCommuteOvernightPlanning struct {
MorningReadiness [ ] string
CommuteSchoolWorkdayConcerns [ ] string
OvernightChangeWatch [ ] string
}
2026-06-15 14:37:08 +00:00
type TodayPlanning struct {
MorningReadiness [ ] string
CommuteSchoolWorkdayConcerns [ ] string
OutdoorPlanning [ ] string
LateDayChangeWatch [ ] 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-06-15 14:37:08 +00:00
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 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." )
}
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 { "afternoon" , "evening" } {
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
}
2026-05-29 18:00:27 +00:00
func buildTomorrowPlanning ( summary * forecast . DailySummary ) * TomorrowPlanning {
2026-06-15 16:17:22 +00:00
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 { }
2026-05-29 18:00:27 +00:00
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
}
2026-06-15 14:37:08 +00:00
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)." , titleWord ( 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)." , titleWord ( windows . Worst . Daypart ) , strings . Join ( windows . Worst . Reasons , ", " ) ) )
}
return appendUnique ( nil , notes ... )
}
2026-05-29 18:00:27 +00:00
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 ... )
}
2026-06-15 14:37:08 +00:00
func lateDayWatchNotes ( daypart forecast . DaypartSummary ) [ ] string {
notes := [ ] string { }
prefix := titleWord ( 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 ... )
}
2026-05-29 18:00:27 +00:00
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 ,
2026-06-12 12:00:40 -05:00
Period : daypart . Period ,
2026-05-29 17:23:49 +00:00
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 : ]
}