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"` Planning *TomorrowPlanning `json:"planning,omitempty"` 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"` } type TomorrowPlanning struct { MorningReadiness []string `json:"morningReadiness,omitempty"` CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"` OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"` } 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, }, } if ctx.Resolved.Definition.ID == report.DailyTomorrow { pkg.Daily.Planning = buildTomorrowPlanning(summary) } 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} } 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 } 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 } 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:] }