diff --git a/internal/briefing/alert_digest_module.go b/internal/briefing/alert_digest_module.go new file mode 100644 index 0000000..0c4d2fa --- /dev/null +++ b/internal/briefing/alert_digest_module.go @@ -0,0 +1,60 @@ +package briefing + +import ( + "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +type AlertDigestModule struct { + Checked bool `json:"checked"` + ActiveCount int `json:"active_count"` + RelevantCount int `json:"relevant_count"` + Missing bool `json:"missing,omitempty"` + Relevant []AlertSummary `json:"relevant,omitempty"` +} + +type AlertSummary struct { + Event string `json:"event,omitempty"` + Headline string `json:"headline,omitempty"` + Severity string `json:"severity,omitempty"` +} + +func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) { + value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps) + if value == nil { + value = &AlertDigestModule{} + } + return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil +} + +func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule { + missing := sourceMissing(collected.SourceProvenance, "alerts") + if collected.Alerts == nil && !missing { + return nil + } + value := &AlertDigestModule{Missing: missing} + if collected.Alerts != nil { + value.Checked = true + value.ActiveCount = len(collected.Alerts.Alerts) + } + value.RelevantCount = len(overlaps) + for _, overlap := range overlaps { + value.Relevant = append(value.Relevant, AlertSummary{ + Event: overlap.Event, + Headline: overlap.Headline, + Severity: overlap.Severity, + }) + } + return value +} + +func sourceMissing(sources []weatherdata.Source, name string) bool { + for _, source := range sources { + if source.Name == name && source.Missing { + return true + } + } + return false +} diff --git a/internal/briefing/area_forecast_discussion_module.go b/internal/briefing/area_forecast_discussion_module.go new file mode 100644 index 0000000..51b528f --- /dev/null +++ b/internal/briefing/area_forecast_discussion_module.go @@ -0,0 +1,67 @@ +package briefing + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" +) + +type AreaForecastDiscussionModule struct { + Product string `json:"product,omitempty"` + KeyMessages []string `json:"key_messages,omitempty"` + ShortTerm string `json:"short_term,omitempty"` + LongTerm string `json:"long_term,omitempty"` +} + +func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) { + discussion := ctx.Collected.Discussion + if discussion == nil { + return nil, nil + } + opts, ok := options.(module.AreaForecastDiscussionOptions) + if !ok { + return nil, fmt.Errorf("area forecast discussion options have type %T", options) + } + sections, err := areaForecastDiscussionSections(opts) + if err != nil { + return nil, err + } + value := AreaForecastDiscussionModule{} + if sections["product"] { + value.Product = discussion.Product + } + if sections["key_messages"] { + value.KeyMessages = append([]string(nil), discussion.KeyMessages...) + } + if sections["short_term"] && discussion.ShortTerm != nil { + value.ShortTerm = discussion.ShortTerm.Text + } + if sections["long_term"] && discussion.LongTerm != nil { + value.LongTerm = discussion.LongTerm.Text + } + if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" { + return nil, nil + } + return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil +} + +func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) { + if len(options.Sections) == 0 { + return map[string]bool{ + "product": true, + "key_messages": true, + "short_term": true, + "long_term": true, + }, nil + } + sections := map[string]bool{} + for _, section := range options.Sections { + switch section { + case "product", "key_messages", "short_term", "long_term": + sections[section] = true + default: + return nil, fmt.Errorf("area forecast discussion section %q is not supported", section) + } + } + return sections, nil +} diff --git a/internal/briefing/base_modules.go b/internal/briefing/base_modules.go deleted file mode 100644 index 76dc955..0000000 --- a/internal/briefing/base_modules.go +++ /dev/null @@ -1,269 +0,0 @@ -package briefing - -import ( - "fmt" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" - "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" -) - -type MetadataModule struct { - RunID string `json:"run_id"` - ReportID report.ID `json:"report_id"` - Variant string `json:"variant,omitempty"` - PromptID string `json:"prompt_id"` - GeneratedAt time.Time `json:"generated_at"` - Units string `json:"units"` - Timezone string `json:"timezone"` - ValidPeriod timeutil.Period `json:"valid_period"` - Location *LocationContext `json:"location,omitempty"` - SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"` - Alerts *AlertDigestModule `json:"alerts,omitempty"` -} - -type SourceWarningSummary struct { - Source string `json:"source"` - Code string `json:"code"` - Severity string `json:"severity"` - Message string `json:"message"` - CompletenessImpact string `json:"completeness_impact,omitempty"` -} - -type CurrentConditionsModule struct { - ConditionText string `json:"condition_text,omitempty"` - IsDay *bool `json:"is_day,omitempty"` - TemperatureC *float64 `json:"temperature_c,omitempty"` - TemperatureF *float64 `json:"temperature_f,omitempty"` - ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"` - ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` - DewpointC *float64 `json:"dewpoint_c,omitempty"` - DewpointF *float64 `json:"dewpoint_f,omitempty"` - RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` - WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` - WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` - WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"` -} - -type AlertDigestModule struct { - Checked bool `json:"checked"` - ActiveCount int `json:"active_count"` - RelevantCount int `json:"relevant_count"` - Missing bool `json:"missing,omitempty"` - Relevant []AlertSummary `json:"relevant,omitempty"` -} - -type AlertSummary struct { - Event string `json:"event,omitempty"` - Headline string `json:"headline,omitempty"` - Severity string `json:"severity,omitempty"` -} - -type AreaForecastDiscussionModule struct { - Product string `json:"product,omitempty"` - KeyMessages []string `json:"key_messages,omitempty"` - ShortTerm string `json:"short_term,omitempty"` - LongTerm string `json:"long_term,omitempty"` -} - -type WeatherStoryModule struct { - Available bool `json:"available"` - OfficeID string `json:"office_id,omitempty"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - AltText string `json:"alt_text,omitempty"` - Priority bool `json:"priority"` - Order int `json:"order"` - DownloadURL string `json:"download_url,omitempty"` -} - -func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) { - metadata := ctx.Resolved.Metadata() - value := MetadataModule{ - RunID: metadata.RunID, - ReportID: metadata.ReportID, - Variant: variantForReport(metadata.ReportID), - PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt, - Units: ctx.Units, - Timezone: ctx.Timezone, - ValidPeriod: metadata.ValidPeriod, - Location: copyLocation(ctx.Location), - SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings), - Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps), - } - return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil -} - -func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { - current := ctx.Collected.Current - if current == nil { - return nil, nil - } - value := CurrentConditionsModule{ - ConditionText: current.ConditionText, - IsDay: copyBool(current.IsDay), - TemperatureC: copyFloat(current.TemperatureC), - TemperatureF: copyFloat(current.TemperatureF), - ApparentTemperatureC: copyFloat(current.ApparentTemperatureC), - ApparentTemperatureF: copyFloat(current.ApparentTemperatureF), - DewpointC: copyFloat(current.DewpointC), - DewpointF: copyFloat(current.DewpointF), - RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), - WindSpeedKmh: copyFloat(current.WindSpeedKmh), - WindSpeedMph: copyFloat(current.WindSpeedMph), - WindDirectionDegrees: copyFloat(current.WindDirectionDegrees), - } - if value.isEmpty() { - return nil, nil - } - return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil -} - -func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) { - value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps) - if value == nil { - value = &AlertDigestModule{} - } - return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil -} - -func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) { - discussion := ctx.Collected.Discussion - if discussion == nil { - return nil, nil - } - opts, ok := options.(module.AreaForecastDiscussionOptions) - if !ok { - return nil, fmt.Errorf("area forecast discussion options have type %T", options) - } - sections, err := areaForecastDiscussionSections(opts) - if err != nil { - return nil, err - } - value := AreaForecastDiscussionModule{} - if sections["product"] { - value.Product = discussion.Product - } - if sections["key_messages"] { - value.KeyMessages = append([]string(nil), discussion.KeyMessages...) - } - if sections["short_term"] && discussion.ShortTerm != nil { - value.ShortTerm = discussion.ShortTerm.Text - } - if sections["long_term"] && discussion.LongTerm != nil { - value.LongTerm = discussion.LongTerm.Text - } - if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" { - return nil, nil - } - return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil -} - -func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) { - if len(options.Sections) == 0 { - return map[string]bool{ - "product": true, - "key_messages": true, - "short_term": true, - "long_term": true, - }, nil - } - sections := map[string]bool{} - for _, section := range options.Sections { - switch section { - case "product", "key_messages", "short_term", "long_term": - sections[section] = true - default: - return nil, fmt.Errorf("area forecast discussion section %q is not supported", section) - } - } - return sections, nil -} - -func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { - story := ctx.Collected.WeatherStory - if story == nil { - return nil, nil - } - value := WeatherStoryModule{ - Available: true, - OfficeID: story.OfficeID, - StartTime: story.StartTime, - EndTime: story.EndTime, - UpdatedAt: copyTime(story.UpdatedAt), - Title: story.Title, - Description: story.Description, - AltText: story.AltText, - Priority: story.Priority, - Order: story.Order, - DownloadURL: story.DownloadURL, - } - return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil -} - -func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary { - out := make([]SourceWarningSummary, 0, len(warnings)) - for _, warning := range warnings { - out = append(out, SourceWarningSummary{ - Source: warning.Source, - Code: warning.Code, - Severity: warning.Severity, - Message: warning.Message, - CompletenessImpact: warning.CompletenessImpact, - }) - } - return out -} - -func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule { - missing := sourceMissing(collected.SourceProvenance, "alerts") - if collected.Alerts == nil && !missing { - return nil - } - value := &AlertDigestModule{Missing: missing} - if collected.Alerts != nil { - value.Checked = true - value.ActiveCount = len(collected.Alerts.Alerts) - } - value.RelevantCount = len(overlaps) - for _, overlap := range overlaps { - value.Relevant = append(value.Relevant, AlertSummary{ - Event: overlap.Event, - Headline: overlap.Headline, - Severity: overlap.Severity, - }) - } - return value -} - -func sourceMissing(sources []weatherdata.Source, name string) bool { - for _, source := range sources { - if source.Name == name && source.Missing { - return true - } - } - return false -} - -func (v CurrentConditionsModule) isEmpty() bool { - return v.ConditionText == "" && - v.IsDay == nil && - v.TemperatureC == nil && - v.TemperatureF == nil && - v.ApparentTemperatureC == nil && - v.ApparentTemperatureF == nil && - v.DewpointC == nil && - v.DewpointF == nil && - v.RelativeHumidityPercent == nil && - v.WindSpeedKmh == nil && - v.WindSpeedMph == nil && - v.WindDirectionDegrees == nil -} diff --git a/internal/briefing/current_conditions_module.go b/internal/briefing/current_conditions_module.go new file mode 100644 index 0000000..cce4a64 --- /dev/null +++ b/internal/briefing/current_conditions_module.go @@ -0,0 +1,58 @@ +package briefing + +import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + +type CurrentConditionsModule struct { + ConditionText string `json:"condition_text,omitempty"` + IsDay *bool `json:"is_day,omitempty"` + TemperatureC *float64 `json:"temperature_c,omitempty"` + TemperatureF *float64 `json:"temperature_f,omitempty"` + ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"` + ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"` + DewpointC *float64 `json:"dewpoint_c,omitempty"` + DewpointF *float64 `json:"dewpoint_f,omitempty"` + RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"` + WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"` + WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"` + WindDirectionDegrees *float64 `json:"wind_direction_degrees,omitempty"` +} + +func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) { + current := ctx.Collected.Current + if current == nil { + return nil, nil + } + value := CurrentConditionsModule{ + ConditionText: current.ConditionText, + IsDay: copyBool(current.IsDay), + TemperatureC: copyFloat(current.TemperatureC), + TemperatureF: copyFloat(current.TemperatureF), + ApparentTemperatureC: copyFloat(current.ApparentTemperatureC), + ApparentTemperatureF: copyFloat(current.ApparentTemperatureF), + DewpointC: copyFloat(current.DewpointC), + DewpointF: copyFloat(current.DewpointF), + RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent), + WindSpeedKmh: copyFloat(current.WindSpeedKmh), + WindSpeedMph: copyFloat(current.WindSpeedMph), + WindDirectionDegrees: copyFloat(current.WindDirectionDegrees), + } + if value.isEmpty() { + return nil, nil + } + return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil +} + +func (v CurrentConditionsModule) isEmpty() bool { + return v.ConditionText == "" && + v.IsDay == nil && + v.TemperatureC == nil && + v.TemperatureF == nil && + v.ApparentTemperatureC == nil && + v.ApparentTemperatureF == nil && + v.DewpointC == nil && + v.DewpointF == nil && + v.RelativeHumidityPercent == nil && + v.WindSpeedKmh == nil && + v.WindSpeedMph == nil && + v.WindDirectionDegrees == nil +} diff --git a/internal/briefing/derived_daily_summary_module.go b/internal/briefing/derived_daily_summary_module.go new file mode 100644 index 0000000..ee243ec --- /dev/null +++ b/internal/briefing/derived_daily_summary_module.go @@ -0,0 +1,88 @@ +package briefing + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +type DerivedDailySummaryModule struct { + Date string `json:"date,omitempty"` + HighTempF *int `json:"high_temp_f,omitempty"` + LowTempF *int `json:"low_temp_f,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopWindow string `json:"max_pop_window,omitempty"` + FirstPrecipHour string `json:"first_precip_hour,omitempty"` + LastPrecipHour string `json:"last_precip_hour,omitempty"` + ThunderMentioned bool `json:"thunder_mentioned"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` + HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"` + DominantConditions []string `json:"dominant_conditions,omitempty"` + Hazards []string `json:"hazards,omitempty"` +} + +func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) { + summary := ctx.Derived.FirstDailySummary() + if summary == nil { + return nil, fmt.Errorf("daily summary facts are required") + } + value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone) + if err != nil { + return nil, err + } + return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil +} + +func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) { + value := DerivedDailySummaryModule{ + Date: summary.Date, + ThunderMentioned: timing.ThunderMentioned, + } + conditions := map[string]struct{}{} + hazards := map[string]struct{}{} + var temperature forecast.Range + var apparent forecast.Range + var maxPop *forecast.TimedValue + var maxGust *forecast.TimedValue + var maxPopWindow timeutil.Period + for _, daypart := range summary.Dayparts { + addRange(&temperature, daypart.Temperature) + addRange(&apparent, daypart.ApparentTemperature) + if daypart.MaxPrecipitationProbability != nil { + if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value { + copied := *daypart.MaxPrecipitationProbability + maxPop = &copied + maxPopWindow = daypart.Period + } + } + maxTimedValue(&maxGust, 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{}{} + } + } + value.HighTempF = roundedInt(temperature.Max) + value.LowTempF = roundedInt(temperature.Min) + value.HeatIndexMaxF = roundedInt(apparent.Max) + if maxPop != nil { + value.MaxPopPercent = roundedInt(&maxPop.Value) + value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone) + } + if maxGust != nil { + value.MaxWindGustMph = roundedInt(&maxGust.Value) + } + value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) + value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) + value.DominantConditions = sortedSet(conditions) + value.Hazards = sortedSet(hazards) + return value, nil +} diff --git a/internal/briefing/derived_daypart_summaries_module.go b/internal/briefing/derived_daypart_summaries_module.go new file mode 100644 index 0000000..91a6009 --- /dev/null +++ b/internal/briefing/derived_daypart_summaries_module.go @@ -0,0 +1,106 @@ +package briefing + +import ( + "fmt" + "strings" + "unicode" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +type DerivedDaypartSummaryModule struct { + Period timeutil.Period `json:"period"` + TempRangeF string `json:"temp_range_f,omitempty"` + ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` + MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` + DominantCondition string `json:"dominant_condition,omitempty"` + NotableConditions []string `json:"notable_conditions,omitempty"` + Snow bool `json:"snow,omitempty"` + Ice bool `json:"ice,omitempty"` + Fog bool `json:"fog,omitempty"` + Heat bool `json:"heat,omitempty"` + Cold bool `json:"cold,omitempty"` + Wind bool `json:"wind,omitempty"` + RelevantAlertCount int `json:"relevant_alert_count,omitempty"` +} + +func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { + if len(ctx.Derived.DaypartSummaries) == 0 { + return nil, fmt.Errorf("daypart summary facts are required") + } + value := map[string]DerivedDaypartSummaryModule{} + prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries) + for _, daypart := range ctx.Derived.DaypartSummaries { + key := daypartKey(daypart, prefixDates) + value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone) + } + return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil +} + +func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { + value := DerivedDaypartSummaryModule{ + Period: daypart.Period, + TempRangeF: rangeLabel(daypart.Temperature), + ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), + DominantCondition: daypart.DominantCondition, + NotableConditions: append([]string(nil), daypart.NotableConditions...), + Snow: daypart.Indicators.Snow, + Ice: daypart.Indicators.Ice, + Fog: daypart.Indicators.Fog, + Heat: daypart.Indicators.Heat, + Cold: daypart.Indicators.Cold, + Wind: daypart.Indicators.Wind, + RelevantAlertCount: len(daypart.AlertOverlaps), + } + if daypart.MaxPrecipitationProbability != nil { + value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) + value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) + } + if daypart.PeakWindGust != nil { + value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) + value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone) + } + return value +} + +func multipleSummaryDates(summaries []forecast.DailySummary) bool { + seen := map[string]struct{}{} + for _, summary := range summaries { + seen[summary.Date] = struct{}{} + } + return len(seen) > 1 +} + +func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string { + key := normalizedKey(daypart.Name) + if key == "" { + key = "unnamed" + } + if !prefixDate { + return key + } + return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key +} + +func normalizedKey(value string) string { + lower := strings.ToLower(strings.TrimSpace(value)) + var out strings.Builder + lastUnderscore := false + for _, r := range lower { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + out.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore { + out.WriteByte('_') + lastUnderscore = true + } + } + return strings.Trim(out.String(), "_") +} diff --git a/internal/briefing/derived_modules.go b/internal/briefing/derived_modules.go deleted file mode 100644 index 6f7d207..0000000 --- a/internal/briefing/derived_modules.go +++ /dev/null @@ -1,330 +0,0 @@ -package briefing - -import ( - "fmt" - "strings" - "time" - "unicode" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" -) - -type DerivedDailySummaryModule struct { - Date string `json:"date,omitempty"` - HighTempF *int `json:"high_temp_f,omitempty"` - LowTempF *int `json:"low_temp_f,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopWindow string `json:"max_pop_window,omitempty"` - FirstPrecipHour string `json:"first_precip_hour,omitempty"` - LastPrecipHour string `json:"last_precip_hour,omitempty"` - ThunderMentioned bool `json:"thunder_mentioned"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` - HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"` - DominantConditions []string `json:"dominant_conditions,omitempty"` - Hazards []string `json:"hazards,omitempty"` -} - -type DerivedDaypartSummaryModule struct { - Period timeutil.Period `json:"period"` - TempRangeF string `json:"temp_range_f,omitempty"` - ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopTime string `json:"max_pop_time,omitempty"` - MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` - MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` - DominantCondition string `json:"dominant_condition,omitempty"` - NotableConditions []string `json:"notable_conditions,omitempty"` - Snow bool `json:"snow,omitempty"` - Ice bool `json:"ice,omitempty"` - Fog bool `json:"fog,omitempty"` - Heat bool `json:"heat,omitempty"` - Cold bool `json:"cold,omitempty"` - Wind bool `json:"wind,omitempty"` - RelevantAlertCount int `json:"relevant_alert_count,omitempty"` -} - -type PrecipTimingModule struct { - MaxPopPercent *int `json:"max_pop_percent,omitempty"` - MaxPopTime string `json:"max_pop_time,omitempty"` - FirstPrecipHour string `json:"first_precip_hour,omitempty"` - LastPrecipHour string `json:"last_precip_hour,omitempty"` - ThunderMentioned bool `json:"thunder_mentioned"` -} - -type OutdoorWindowsModule struct { - Best *OutdoorWindowModule `json:"best,omitempty"` - Worst *OutdoorWindowModule `json:"worst,omitempty"` -} - -type OutdoorWindowModule struct { - Daypart string `json:"daypart"` - Start string `json:"start"` - End string `json:"end"` - Reasons []string `json:"reasons,omitempty"` - Score float64 `json:"score"` -} - -type TomorrowPlanningModule struct { - MorningReadiness []string `json:"morning_readiness,omitempty"` - CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"` - OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"` -} - -func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) { - summary := ctx.Derived.FirstDailySummary() - if summary == nil { - return nil, fmt.Errorf("daily summary facts are required") - } - value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone) - if err != nil { - return nil, err - } - return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil -} - -func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) { - if len(ctx.Derived.DaypartSummaries) == 0 { - return nil, fmt.Errorf("daypart summary facts are required") - } - value := map[string]DerivedDaypartSummaryModule{} - prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries) - for _, daypart := range ctx.Derived.DaypartSummaries { - key := daypartKey(daypart, prefixDates) - value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone) - } - return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil -} - -func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { - value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone) - return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil -} - -func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { - windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) - value := OutdoorWindowsModule{ - Best: outdoorWindowValue(windows.Best), - Worst: outdoorWindowValue(windows.Worst), - } - return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil -} - -func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) { - summary := ctx.Derived.FirstDailySummary() - if summary == nil { - return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil - } - planning := buildTomorrowPlanning(summary) - value := TomorrowPlanningModule{} - if planning != nil { - value.MorningReadiness = append([]string(nil), planning.MorningReadiness...) - value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...) - value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...) - } - return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil -} - -func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) { - value := DerivedDailySummaryModule{ - Date: summary.Date, - ThunderMentioned: timing.ThunderMentioned, - } - conditions := map[string]struct{}{} - hazards := map[string]struct{}{} - var temperature forecast.Range - var apparent forecast.Range - var maxPop *forecast.TimedValue - var maxGust *forecast.TimedValue - var maxPopWindow timeutil.Period - for _, daypart := range summary.Dayparts { - addRange(&temperature, daypart.Temperature) - addRange(&apparent, daypart.ApparentTemperature) - if daypart.MaxPrecipitationProbability != nil { - if maxPop == nil || daypart.MaxPrecipitationProbability.Value > maxPop.Value { - copied := *daypart.MaxPrecipitationProbability - maxPop = &copied - maxPopWindow = daypart.Period - } - } - maxTimedValue(&maxGust, 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{}{} - } - } - value.HighTempF = roundedInt(temperature.Max) - value.LowTempF = roundedInt(temperature.Min) - value.HeatIndexMaxF = roundedInt(apparent.Max) - if maxPop != nil { - value.MaxPopPercent = roundedInt(&maxPop.Value) - value.MaxPopWindow = periodClockLabel(maxPopWindow, timezone) - } - if maxGust != nil { - value.MaxWindGustMph = roundedInt(&maxGust.Value) - } - value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) - value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) - value.DominantConditions = sortedSet(conditions) - value.Hazards = sortedSet(hazards) - return value, nil -} - -func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { - value := DerivedDaypartSummaryModule{ - Period: daypart.Period, - TempRangeF: rangeLabel(daypart.Temperature), - ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), - DominantCondition: daypart.DominantCondition, - NotableConditions: append([]string(nil), daypart.NotableConditions...), - Snow: daypart.Indicators.Snow, - Ice: daypart.Indicators.Ice, - Fog: daypart.Indicators.Fog, - Heat: daypart.Indicators.Heat, - Cold: daypart.Indicators.Cold, - Wind: daypart.Indicators.Wind, - RelevantAlertCount: len(daypart.AlertOverlaps), - } - if daypart.MaxPrecipitationProbability != nil { - value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) - value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone) - } - if daypart.PeakWindGust != nil { - value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) - value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone) - } - return value -} - -func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule { - value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned} - if timing.MaxPrecipitationProbability != nil { - value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value) - value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone) - } - value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) - value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) - return value -} - -func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { - if window == nil { - return nil - } - return &OutdoorWindowModule{ - Daypart: window.Daypart, - Start: window.Start, - End: window.End, - Reasons: append([]string(nil), window.Reasons...), - Score: window.Score, - } -} - -func multipleSummaryDates(summaries []forecast.DailySummary) bool { - seen := map[string]struct{}{} - for _, summary := range summaries { - seen[summary.Date] = struct{}{} - } - return len(seen) > 1 -} - -func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string { - key := normalizedKey(daypart.Name) - if key == "" { - key = "unnamed" - } - if !prefixDate { - return key - } - return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key -} - -func normalizedKey(value string) string { - lower := strings.ToLower(strings.TrimSpace(value)) - var out strings.Builder - lastUnderscore := false - for _, r := range lower { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - out.WriteRune(r) - lastUnderscore = false - continue - } - if !lastUnderscore { - out.WriteByte('_') - lastUnderscore = true - } - } - return strings.Trim(out.String(), "_") -} - -func rangeLabel(value forecast.Range) string { - if value.Min == nil && value.Max == nil { - return "" - } - if value.Min != nil && value.Max != nil { - low := roundedInt(value.Min) - high := roundedInt(value.Max) - if low != nil && high != nil && *low == *high { - return fmt.Sprintf("%d", *low) - } - return fmt.Sprintf("%d-%d", *low, *high) - } - if value.Min != nil { - low := roundedInt(value.Min) - return fmt.Sprintf("%d", *low) - } - high := roundedInt(value.Max) - return fmt.Sprintf("%d", *high) -} - -func daypartApparentRangeLabel(value forecast.Range) string { - if value.Min == nil && value.Max == nil { - return "" - } - return rangeLabel(value) -} - -func roundedInt(value *float64) *int { - if value == nil { - return nil - } - rounded := int(*value + 0.5) - if *value < 0 { - rounded = int(*value - 0.5) - } - return &rounded -} - -func timedClockLabel(value *forecast.TimedValue, timezone string) string { - if value == nil { - return "" - } - return clockLabel(value.Time, timezone) -} - -func periodClockLabel(period timeutil.Period, timezone string) string { - if !period.IsValid() { - return "" - } - return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) -} - -func clockLabel(value time.Time, timezone string) string { - location, err := timeutil.LoadLocation(timezone) - if err != nil { - location = time.UTC - } - label := value.In(location).Format("3 PM") - if label == "12 AM" && value.In(location).Minute() == 0 { - return "12 AM" - } - return label -} diff --git a/internal/briefing/metadata_module.go b/internal/briefing/metadata_module.go new file mode 100644 index 0000000..c62df4a --- /dev/null +++ b/internal/briefing/metadata_module.go @@ -0,0 +1,64 @@ +package briefing + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" + "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" +) + +type MetadataModule struct { + RunID string `json:"run_id"` + ReportID report.ID `json:"report_id"` + Variant string `json:"variant,omitempty"` + PromptID string `json:"prompt_id"` + GeneratedAt time.Time `json:"generated_at"` + Units string `json:"units"` + Timezone string `json:"timezone"` + ValidPeriod timeutil.Period `json:"valid_period"` + Location *LocationContext `json:"location,omitempty"` + SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"` + Alerts *AlertDigestModule `json:"alerts,omitempty"` +} + +type SourceWarningSummary struct { + Source string `json:"source"` + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + CompletenessImpact string `json:"completeness_impact,omitempty"` +} + +func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) { + metadata := ctx.Resolved.Metadata() + value := MetadataModule{ + RunID: metadata.RunID, + ReportID: metadata.ReportID, + Variant: variantForReport(metadata.ReportID), + PromptID: metadata.PromptID, + GeneratedAt: metadata.GeneratedAt, + Units: ctx.Units, + Timezone: ctx.Timezone, + ValidPeriod: metadata.ValidPeriod, + Location: copyLocation(ctx.Location), + SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings), + Alerts: alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps), + } + return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil +} + +func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary { + out := make([]SourceWarningSummary, 0, len(warnings)) + for _, warning := range warnings { + out = append(out, SourceWarningSummary{ + Source: warning.Source, + Code: warning.Code, + Severity: warning.Severity, + Message: warning.Message, + CompletenessImpact: warning.CompletenessImpact, + }) + } + return out +} diff --git a/internal/briefing/module_format_helpers.go b/internal/briefing/module_format_helpers.go new file mode 100644 index 0000000..9433137 --- /dev/null +++ b/internal/briefing/module_format_helpers.go @@ -0,0 +1,73 @@ +package briefing + +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func rangeLabel(value forecast.Range) string { + if value.Min == nil && value.Max == nil { + return "" + } + if value.Min != nil && value.Max != nil { + low := roundedInt(value.Min) + high := roundedInt(value.Max) + if low != nil && high != nil && *low == *high { + return fmt.Sprintf("%d", *low) + } + return fmt.Sprintf("%d-%d", *low, *high) + } + if value.Min != nil { + low := roundedInt(value.Min) + return fmt.Sprintf("%d", *low) + } + high := roundedInt(value.Max) + return fmt.Sprintf("%d", *high) +} + +func daypartApparentRangeLabel(value forecast.Range) string { + if value.Min == nil && value.Max == nil { + return "" + } + return rangeLabel(value) +} + +func roundedInt(value *float64) *int { + if value == nil { + return nil + } + rounded := int(*value + 0.5) + if *value < 0 { + rounded = int(*value - 0.5) + } + return &rounded +} + +func timedClockLabel(value *forecast.TimedValue, timezone string) string { + if value == nil { + return "" + } + return clockLabel(value.Time, timezone) +} + +func periodClockLabel(period timeutil.Period, timezone string) string { + if !period.IsValid() { + return "" + } + return clockLabel(period.Start, timezone) + "-" + clockLabel(period.End, timezone) +} + +func clockLabel(value time.Time, timezone string) string { + location, err := timeutil.LoadLocation(timezone) + if err != nil { + location = time.UTC + } + label := value.In(location).Format("3 PM") + if label == "12 AM" && value.In(location).Minute() == 0 { + return "12 AM" + } + return label +} diff --git a/internal/briefing/outdoor_windows_module.go b/internal/briefing/outdoor_windows_module.go new file mode 100644 index 0000000..acd8d08 --- /dev/null +++ b/internal/briefing/outdoor_windows_module.go @@ -0,0 +1,38 @@ +package briefing + +import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + +type OutdoorWindowsModule struct { + Best *OutdoorWindowModule `json:"best,omitempty"` + Worst *OutdoorWindowModule `json:"worst,omitempty"` +} + +type OutdoorWindowModule struct { + Daypart string `json:"daypart"` + Start string `json:"start"` + End string `json:"end"` + Reasons []string `json:"reasons,omitempty"` + Score float64 `json:"score"` +} + +func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) { + windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries) + value := OutdoorWindowsModule{ + Best: outdoorWindowValue(windows.Best), + Worst: outdoorWindowValue(windows.Worst), + } + return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil +} + +func outdoorWindowValue(window *OutdoorWindow) *OutdoorWindowModule { + if window == nil { + return nil + } + return &OutdoorWindowModule{ + Daypart: window.Daypart, + Start: window.Start, + End: window.End, + Reasons: append([]string(nil), window.Reasons...), + Score: window.Score, + } +} diff --git a/internal/briefing/precip_timing_module.go b/internal/briefing/precip_timing_module.go new file mode 100644 index 0000000..2965395 --- /dev/null +++ b/internal/briefing/precip_timing_module.go @@ -0,0 +1,30 @@ +package briefing + +import ( + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" +) + +type PrecipTimingModule struct { + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` + FirstPrecipHour string `json:"first_precip_hour,omitempty"` + LastPrecipHour string `json:"last_precip_hour,omitempty"` + ThunderMentioned bool `json:"thunder_mentioned"` +} + +func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) { + value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone) + return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil +} + +func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule { + value := PrecipTimingModule{ThunderMentioned: timing.ThunderMentioned} + if timing.MaxPrecipitationProbability != nil { + value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value) + value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone) + } + value.FirstPrecipHour = timedClockLabel(timing.FirstPrecipitation, timezone) + value.LastPrecipHour = timedClockLabel(timing.LastPrecipitation, timezone) + return value +} diff --git a/internal/briefing/tomorrow_planning_module.go b/internal/briefing/tomorrow_planning_module.go new file mode 100644 index 0000000..2693008 --- /dev/null +++ b/internal/briefing/tomorrow_planning_module.go @@ -0,0 +1,24 @@ +package briefing + +import "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + +type TomorrowPlanningModule struct { + MorningReadiness []string `json:"morning_readiness,omitempty"` + CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"` + OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"` +} + +func buildTomorrowPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) { + summary := ctx.Derived.FirstDailySummary() + if summary == nil { + return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: TomorrowPlanningModule{}}, nil + } + planning := buildTomorrowPlanning(summary) + value := TomorrowPlanningModule{} + if planning != nil { + value.MorningReadiness = append([]string(nil), planning.MorningReadiness...) + value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...) + value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...) + } + return &module.Output{ID: module.TomorrowPlanning, StanzaName: "tomorrow_planning", Value: value}, nil +} diff --git a/internal/briefing/weather_story_module.go b/internal/briefing/weather_story_module.go new file mode 100644 index 0000000..75d3217 --- /dev/null +++ b/internal/briefing/weather_story_module.go @@ -0,0 +1,42 @@ +package briefing + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" +) + +type WeatherStoryModule struct { + Available bool `json:"available"` + OfficeID string `json:"office_id,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + AltText string `json:"alt_text,omitempty"` + Priority bool `json:"priority"` + Order int `json:"order"` + DownloadURL string `json:"download_url,omitempty"` +} + +func buildWeatherStoryModule(ctx ModuleContext, _ any) (*module.Output, error) { + story := ctx.Collected.WeatherStory + if story == nil { + return nil, nil + } + value := WeatherStoryModule{ + Available: true, + OfficeID: story.OfficeID, + StartTime: story.StartTime, + EndTime: story.EndTime, + UpdatedAt: copyTime(story.UpdatedAt), + Title: story.Title, + Description: story.Description, + AltText: story.AltText, + Priority: story.Priority, + Order: story.Order, + DownloadURL: story.DownloadURL, + } + return &module.Output{ID: module.WeatherStory, StanzaName: "weather_story", Value: value}, nil +} diff --git a/internal/report/daily_report.go b/internal/report/daily_report.go new file mode 100644 index 0000000..0651dd9 --- /dev/null +++ b/internal/report/daily_report.go @@ -0,0 +1,69 @@ +package report + +import ( + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func dailyTodayDefinition() Definition { + return Definition{ + ID: DailyToday, + Name: "Daily Report", + PromptID: "weather.daily_report", + ComparisonStrategy: CompareSameValidDate, + ArtifactGroup: "daily", + BatchOutputName: "daily.md", + Generated: true, + CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, + Modules: dailyTodayModules(), + Morning: true, + resolve: resolveDailyToday, + } +} + +func dailyTomorrowDefinition() Definition { + return Definition{ + ID: DailyTomorrow, + Name: "Tomorrow Planning Brief", + PromptID: "weather.daily_report", + ComparisonStrategy: CompareSameValidDate, + ArtifactGroup: "daily", + BatchOutputName: "tomorrow.md", + Generated: true, + CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, + Modules: dailyTomorrowModules(), + Evening: true, + resolve: resolveDailyTomorrow, + } +} + +func dailyTodayModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.DerivedDailySummary, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + ) +} + +func dailyTomorrowModules() []module.ConfigItem { + items := dailyTodayModules() + items = append(items, module.ConfigItem{ID: module.TomorrowPlanning}) + return items +} + +func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) { + if !req.Date.IsZero() { + return timeutil.CivilDay(req.Date, req.Location), nil + } + return timeutil.CivilDay(req.Now, req.Location), nil +} + +func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) { + return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil +} diff --git a/internal/report/period.go b/internal/report/period.go index e890e72..c84672b 100644 --- a/internal/report/period.go +++ b/internal/report/period.go @@ -3,8 +3,6 @@ package report import ( "fmt" "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func Resolve(id ID, req ResolveRequest) (Resolved, error) { @@ -70,75 +68,3 @@ func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) ( ValidPeriod: period, }, nil } - -func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) { - if !req.Date.IsZero() { - return timeutil.CivilDay(req.Date, req.Location), nil - } - return timeutil.CivilDay(req.Now, req.Location), nil -} - -func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) { - if location == nil { - location = time.UTC - } - startTime, err := timeutil.ParseStormTime(start, location) - if err != nil { - return timeutil.Period{}, err - } - endTime, err := timeutil.ParseStormTime(end, location) - if err != nil { - return timeutil.Period{}, err - } - period := timeutil.Period{Start: startTime, End: endTime} - if !period.IsValid() { - return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time") - } - return period, nil -} - -func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) { - return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil -} - -func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) { - localNow := req.Now.In(req.Location) - endDate := localNow.AddDate(0, 0, 3) - end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location) - return timeutil.Period{Start: localNow, End: end}, nil -} - -func resolveWeekend(req ResolveRequest) (timeutil.Period, error) { - localNow := req.Now.In(req.Location) - weekday := localNow.Weekday() - if weekday == time.Sunday { - return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning") - } - - daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7 - saturday := localNow.AddDate(0, 0, daysUntilSaturday) - start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location) - if weekday == time.Friday || weekday == time.Saturday { - friday := start.AddDate(0, 0, -1) - fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location) - start = fridayEvening - if localNow.After(start) { - start = localNow - } - } - end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2) - return timeutil.Period{Start: start, End: end}, nil -} - -func resolveStorm(req ResolveRequest) (timeutil.Period, error) { - if req.StormStart.IsZero() { - return timeutil.Period{}, fmt.Errorf("storm report requires a start time") - } - if req.StormEnd.IsZero() { - return timeutil.Period{}, fmt.Errorf("storm report requires an end time") - } - if !req.StormEnd.After(req.StormStart) { - return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time") - } - return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil -} diff --git a/internal/report/registry.go b/internal/report/registry.go index 967e928..7497f7d 100644 --- a/internal/report/registry.go +++ b/internal/report/registry.go @@ -12,70 +12,11 @@ type Registry struct { func DefaultRegistry() Registry { definitions := []Definition{ - { - ID: DailyToday, - Name: "Daily Report", - PromptID: "weather.daily_report", - ComparisonStrategy: CompareSameValidDate, - ArtifactGroup: "daily", - BatchOutputName: "daily.md", - Generated: true, - CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, - Modules: dailyTodayModules(), - Morning: true, - resolve: resolveDailyToday, - }, - { - ID: DailyTomorrow, - Name: "Tomorrow Planning Brief", - PromptID: "weather.daily_report", - ComparisonStrategy: CompareSameValidDate, - ArtifactGroup: "daily", - BatchOutputName: "tomorrow.md", - Generated: true, - CompatiblePriorIDs: []ID{DailyToday, DailyTomorrow}, - Modules: dailyTomorrowModules(), - Evening: true, - resolve: resolveDailyTomorrow, - }, - { - ID: ThreeDay, - Name: "3-Day Outlook", - PromptID: "weather.three_day_outlook", - ComparisonStrategy: CompareSameValidDate, - ArtifactGroup: "three-day", - BatchOutputName: "three-day.md", - Generated: true, - CompatiblePriorIDs: []ID{ThreeDay}, - Modules: threeDayModules(), - Morning: true, - resolve: resolveThreeDay, - }, - { - ID: Weekend, - Name: "Weekend Outlook", - PromptID: "weather.weekend_outlook", - ComparisonStrategy: CompareWeekendWindow, - ArtifactGroup: "weekend", - BatchOutputName: "weekend.md", - Generated: true, - CompatiblePriorIDs: []ID{Weekend}, - Modules: weekendModules(), - Morning: true, - resolve: resolveWeekend, - }, - { - ID: Storm, - Name: "Storm Report", - PromptID: "weather.storm_report", - ComparisonStrategy: CompareExplicitWindow, - ArtifactGroup: "storm", - BatchOutputName: "storm.md", - Generated: true, - CompatiblePriorIDs: []ID{Storm}, - Modules: stormModules(), - resolve: resolveStorm, - }, + dailyTodayDefinition(), + dailyTomorrowDefinition(), + threeDayDefinition(), + weekendDefinition(), + stormDefinition(), } registry := Registry{definitions: map[ID]Definition{}} for _, definition := range definitions { @@ -101,63 +42,6 @@ func (r Registry) WithModuleOverrides(overrides map[ID][]module.ConfigItem) (Reg return next, nil } -func dailyTodayModules() []module.ConfigItem { - return moduleItems( - module.Metadata, - module.CurrentConditions, - module.DerivedDailySummary, - module.DerivedDaypartSummaries, - module.PrecipTiming, - module.AlertDigest, - module.AreaForecastDiscussion, - module.WeatherStory, - module.OutdoorWindows, - ) -} - -func dailyTomorrowModules() []module.ConfigItem { - items := dailyTodayModules() - items = append(items, module.ConfigItem{ID: module.TomorrowPlanning}) - return items -} - -func threeDayModules() []module.ConfigItem { - return moduleItems( - module.Metadata, - module.CurrentConditions, - module.DerivedDaypartSummaries, - module.PrecipTiming, - module.AlertDigest, - module.AreaForecastDiscussion, - module.WeatherStory, - module.OutdoorWindows, - ) -} - -func weekendModules() []module.ConfigItem { - return moduleItems( - module.Metadata, - module.CurrentConditions, - module.DerivedDaypartSummaries, - module.PrecipTiming, - module.AlertDigest, - module.AreaForecastDiscussion, - module.WeatherStory, - module.OutdoorWindows, - ) -} - -func stormModules() []module.ConfigItem { - return moduleItems( - module.Metadata, - module.CurrentConditions, - module.PrecipTiming, - module.AlertDigest, - module.AreaForecastDiscussion, - module.WeatherStory, - ) -} - func moduleItems(ids ...module.ID) []module.ConfigItem { items := make([]module.ConfigItem, 0, len(ids)) for _, id := range ids { diff --git a/internal/report/storm_report.go b/internal/report/storm_report.go new file mode 100644 index 0000000..4aa4243 --- /dev/null +++ b/internal/report/storm_report.go @@ -0,0 +1,67 @@ +package report + +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func stormDefinition() Definition { + return Definition{ + ID: Storm, + Name: "Storm Report", + PromptID: "weather.storm_report", + ComparisonStrategy: CompareExplicitWindow, + ArtifactGroup: "storm", + BatchOutputName: "storm.md", + Generated: true, + CompatiblePriorIDs: []ID{Storm}, + Modules: stormModules(), + resolve: resolveStorm, + } +} + +func stormModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + ) +} + +func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) { + if location == nil { + location = time.UTC + } + startTime, err := timeutil.ParseStormTime(start, location) + if err != nil { + return timeutil.Period{}, err + } + endTime, err := timeutil.ParseStormTime(end, location) + if err != nil { + return timeutil.Period{}, err + } + period := timeutil.Period{Start: startTime, End: endTime} + if !period.IsValid() { + return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time") + } + return period, nil +} + +func resolveStorm(req ResolveRequest) (timeutil.Period, error) { + if req.StormStart.IsZero() { + return timeutil.Period{}, fmt.Errorf("storm report requires a start time") + } + if req.StormEnd.IsZero() { + return timeutil.Period{}, fmt.Errorf("storm report requires an end time") + } + if !req.StormEnd.After(req.StormStart) { + return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time") + } + return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil +} diff --git a/internal/report/three_day_report.go b/internal/report/three_day_report.go new file mode 100644 index 0000000..1f05bb6 --- /dev/null +++ b/internal/report/three_day_report.go @@ -0,0 +1,44 @@ +package report + +import ( + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func threeDayDefinition() Definition { + return Definition{ + ID: ThreeDay, + Name: "3-Day Outlook", + PromptID: "weather.three_day_outlook", + ComparisonStrategy: CompareSameValidDate, + ArtifactGroup: "three-day", + BatchOutputName: "three-day.md", + Generated: true, + CompatiblePriorIDs: []ID{ThreeDay}, + Modules: threeDayModules(), + Morning: true, + resolve: resolveThreeDay, + } +} + +func threeDayModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + ) +} + +func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) { + localNow := req.Now.In(req.Location) + endDate := localNow.AddDate(0, 0, 3) + end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location) + return timeutil.Period{Start: localNow, End: end}, nil +} diff --git a/internal/report/weekend_report.go b/internal/report/weekend_report.go new file mode 100644 index 0000000..d1f7b52 --- /dev/null +++ b/internal/report/weekend_report.go @@ -0,0 +1,60 @@ +package report + +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func weekendDefinition() Definition { + return Definition{ + ID: Weekend, + Name: "Weekend Outlook", + PromptID: "weather.weekend_outlook", + ComparisonStrategy: CompareWeekendWindow, + ArtifactGroup: "weekend", + BatchOutputName: "weekend.md", + Generated: true, + CompatiblePriorIDs: []ID{Weekend}, + Modules: weekendModules(), + Morning: true, + resolve: resolveWeekend, + } +} + +func weekendModules() []module.ConfigItem { + return moduleItems( + module.Metadata, + module.CurrentConditions, + module.DerivedDaypartSummaries, + module.PrecipTiming, + module.AlertDigest, + module.AreaForecastDiscussion, + module.WeatherStory, + module.OutdoorWindows, + ) +} + +func resolveWeekend(req ResolveRequest) (timeutil.Period, error) { + localNow := req.Now.In(req.Location) + weekday := localNow.Weekday() + if weekday == time.Sunday { + return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning") + } + + daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7 + saturday := localNow.AddDate(0, 0, daysUntilSaturday) + start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location) + if weekday == time.Friday || weekday == time.Saturday { + friday := start.AddDate(0, 0, -1) + fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location) + start = fridayEvening + if localNow.After(start) { + start = localNow + } + } + end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2) + return timeutil.Period{Start: start, End: end}, nil +}