2026-05-29 17:23:49 +00:00
package briefing
import (
"fmt"
"math"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type Daily struct {
BottomLine BottomLine ` json:"bottomLine" `
Dayparts [ ] forecast . DaypartSummary ` json:"dayparts" `
RelevantAlerts [ ] forecast . AlertOverlap ` json:"relevantAlerts,omitempty" `
OutdoorWindows OutdoorWindows ` json:"outdoorWindows" `
2026-05-29 18:00:27 +00:00
Planning * TomorrowPlanning ` json:"planning,omitempty" `
2026-05-29 17:23:49 +00:00
NarrativePeriods [ ] forecast . ForecastPeriod ` json:"narrativePeriods,omitempty" `
Discussion DiscussionContext ` json:"discussion,omitempty" `
WeatherStory * WeatherStoryContext ` json:"weatherStory,omitempty" `
ForecastSummaryDate string ` json:"forecastSummaryDate" `
}
type BottomLine struct {
Summary string ` json:"summary" `
Hazards [ ] string ` json:"hazards,omitempty" `
Temperature forecast . Range ` json:"temperature,omitempty" `
MaxPrecipProbability * forecast . TimedValue ` json:"maxPrecipitationProbability,omitempty" `
PeakWindGust * forecast . TimedValue ` json:"peakWindGust,omitempty" `
}
type OutdoorWindows struct {
Best * OutdoorWindow ` json:"best,omitempty" `
Worst * OutdoorWindow ` json:"worst,omitempty" `
}
type OutdoorWindow struct {
Daypart string ` json:"daypart" `
Start string ` json:"start" `
End string ` json:"end" `
Reasons [ ] string ` json:"reasons,omitempty" `
Score float64 ` json:"score" `
}
2026-05-29 18:00:27 +00:00
type TomorrowPlanning struct {
MorningReadiness [ ] string ` json:"morningReadiness,omitempty" `
CommuteSchoolWorkdayConcerns [ ] string ` json:"commuteSchoolWorkdayConcerns,omitempty" `
OvernightChangeWatch [ ] string ` json:"overnightChangeWatch,omitempty" `
}
2026-05-29 17:23:49 +00:00
type DiscussionContext struct {
Product string ` json:"product,omitempty" `
KeyMessages [ ] string ` json:"keyMessages,omitempty" `
ShortTerm string ` json:"shortTerm,omitempty" `
LongTerm string ` json:"longTerm,omitempty" `
}
type WeatherStoryContext struct {
Available bool ` json:"available" `
Summary string ` json:"summary,omitempty" `
}
func BuildDaily ( ctx BuildContext , summary * forecast . DailySummary ) ( Package , error ) {
if ctx . Resolved . Definition . ID != report . DailyToday && ctx . Resolved . Definition . ID != report . DailyTomorrow {
return Package { } , fmt . Errorf ( "daily briefing requires a daily report definition" )
}
if summary == nil {
return Package { } , fmt . Errorf ( "daily forecast summary is required" )
}
pkg := Package {
Metadata : BuildMetadata ( ctx ) ,
Daily : & Daily {
BottomLine : buildBottomLine ( summary ) ,
Dayparts : summary . Dayparts ,
RelevantAlerts : summary . AlertOverlaps ,
OutdoorWindows : buildOutdoorWindows ( summary . Dayparts ) ,
NarrativePeriods : summary . NarrativePeriods ,
Discussion : buildDiscussion ( summary . Discussion ) ,
WeatherStory : buildWeatherStory ( ctx . Bundle ) ,
ForecastSummaryDate : summary . Date ,
} ,
}
2026-05-29 18:00:27 +00:00
if ctx . Resolved . Definition . ID == report . DailyTomorrow {
pkg . Daily . Planning = buildTomorrowPlanning ( summary )
}
2026-05-29 17:23:49 +00:00
return pkg , nil
}
func buildBottomLine ( summary * forecast . DailySummary ) BottomLine {
bottomLine := BottomLine { }
conditions := map [ string ] struct { } { }
hazards := map [ string ] struct { } { }
for _ , daypart := range summary . Dayparts {
addRange ( & bottomLine . Temperature , daypart . Temperature )
maxTimedValue ( & bottomLine . MaxPrecipProbability , daypart . MaxPrecipitationProbability )
maxTimedValue ( & bottomLine . PeakWindGust , 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 { } { }
}
}
bottomLine . Hazards = sortedSet ( hazards )
bottomLine . Summary = bottomLineText ( sortedSet ( conditions ) , bottomLine . Hazards )
return bottomLine
}
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 . Thunder {
notes = append ( notes , "Morning thunder could affect departure timing." )
}
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 . Thunder {
notes = append ( notes , prefix + " thunder may disrupt outdoor plans." )
}
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 . Thunder {
notes = append ( notes , "Overnight storms could change morning impacts." )
}
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 buildDiscussion ( discussion * forecast . Discussion ) DiscussionContext {
if discussion == nil {
return DiscussionContext { }
}
ctx := DiscussionContext {
Product : discussion . Product ,
KeyMessages : discussion . KeyMessages ,
}
if discussion . ShortTerm != nil {
ctx . ShortTerm = discussion . ShortTerm . Narrative
}
if discussion . LongTerm != nil {
ctx . LongTerm = discussion . LongTerm . Narrative
}
return ctx
}
func buildWeatherStory ( bundle * forecast . Bundle ) * WeatherStoryContext {
if bundle == nil || bundle . WeatherStory == nil || len ( bundle . WeatherStory . Raw ) == 0 {
return nil
}
return & WeatherStoryContext { Available : true , Summary : string ( bundle . WeatherStory . Raw ) }
}
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 . Thunder {
score += 75
reasons = append ( reasons , "thunder risk" )
}
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 bottomLineText ( conditions [ ] string , hazards [ ] string ) string {
if len ( conditions ) == 0 && len ( hazards ) == 0 {
return "Quiet weather is expected."
}
parts := [ ] string { }
if len ( conditions ) > 0 {
parts = append ( parts , "Conditions: " + strings . Join ( conditions , "; " ) )
}
if len ( hazards ) > 0 {
parts = append ( parts , "Watch points: " + strings . Join ( hazards , "; " ) )
}
return strings . Join ( parts , ". " ) + "."
}
func hazardsForIndicators ( indicators forecast . Indicators ) [ ] string {
var hazards [ ] string
if indicators . Thunder {
hazards = append ( hazards , "thunder" )
}
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 : ]
}