package generatedtext import ( "fmt" "strings" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type HourlyRenderContext struct { ReportTitle string LocationName string ValidPeriod string GeneratedAt string GeneratedText Hourly CurrentConditions string HourlyForecast []HourlyForecastRow PrecipitationTiming string Alerts []string SPCOutlooks []string ForecastDiscussion ForecastDiscussion SPCDiscussions []string WeatherStory string } type HourlyForecastRow struct { Time string Summary string Temperature string Precipitation string Wind string } type ForecastDiscussion struct { KeyMessages []string ShortTerm string } func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly) (HourlyRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) } if metadata.GeneratedAt.IsZero() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required") } if !metadata.ValidPeriod.IsValid() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required") } current, err := requiredStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions)) if err != nil { return HourlyRenderContext{}, err } hourly, err := requiredStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast)) if err != nil { return HourlyRenderContext{}, err } precip, err := requiredStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming)) if err != nil { return HourlyRenderContext{}, err } alerts, err := requiredStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest)) if err != nil { return HourlyRenderContext{}, err } outlooks, err := requiredStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks)) if err != nil { return HourlyRenderContext{}, err } discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion)) if err != nil { return HourlyRenderContext{}, err } spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion)) if err != nil { return HourlyRenderContext{}, err } story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory)) if err != nil { return HourlyRenderContext{}, err } return HourlyRenderContext{ ReportTitle: "Hourly Report", LocationName: locationName(metadata), ValidPeriod: periodLabel(metadata.ValidPeriod, location), GeneratedAt: timeLabel(metadata.GeneratedAt, location), GeneratedText: generated, CurrentConditions: currentConditionsLabel(current), HourlyForecast: hourlyForecastRows(hourly), PrecipitationTiming: precipitationTimingLabel(precip), Alerts: alertLabels(alerts), SPCOutlooks: outlookLabels(outlooks), ForecastDiscussion: ForecastDiscussion{ KeyMessages: append([]string(nil), discussion.KeyMessages...), ShortTerm: discussion.ShortTerm, }, SPCDiscussions: spcDiscussionLabels(spcDiscussion), WeatherStory: weatherStoryLabel(story), }, nil } func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) { value, ok, err := module.StanzaValue[T](snapshot, name) if err != nil { var zero T return zero, fmt.Errorf("build hourly render context: %w", err) } if !ok { var zero T return zero, fmt.Errorf("build hourly render context requires stanza %q", name) } return value, nil } func optionalStanza[T any](snapshot module.Snapshot, name string) (T, error) { value, _, err := module.StanzaValue[T](snapshot, name) if err != nil { var zero T return zero, fmt.Errorf("build hourly render context: %w", err) } return value, nil } func locationName(metadata briefing.Metadata) string { if metadata.Location != nil { if metadata.Location.Name != "" && metadata.Location.Region != "" { return metadata.Location.Name + ", " + metadata.Location.Region } if metadata.Location.Name != "" { return metadata.Location.Name } } if metadata.SourceLocation != "" { return metadata.SourceLocation } if metadata.SourceLocationID != "" { return metadata.SourceLocationID } return "Unknown location" } func periodLabel(period timeutil.Period, location *time.Location) string { return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location)) } func timeLabel(value time.Time, location *time.Location) string { return value.In(location).Format("2006-01-02 at 3:04 PM") } func currentConditionsLabel(current briefing.CurrentConditionsModule) string { parts := []string{} if current.ConditionText != "" { parts = append(parts, current.ConditionText) } if temperature := temperatureLabel(current.TemperatureF, current.TemperatureC); temperature != "" { parts = append(parts, temperature) } if apparent := temperatureLabel(current.ApparentTemperatureF, current.ApparentTemperatureC); apparent != "" { parts = append(parts, "feels like "+apparent) } if current.RelativeHumidityPercent != nil { parts = append(parts, fmt.Sprintf("humidity %d%%", rounded(*current.RelativeHumidityPercent))) } if wind := windLabel(current.WindDirection, current.WindSpeedMph, current.WindSpeedKmh, nil, nil); wind != "" { parts = append(parts, wind) } if len(parts) == 0 { return "No current conditions available." } return strings.Join(parts, "; ") + "." } func hourlyForecastRows(hourly briefing.HourlyForecastModule) []HourlyForecastRow { rows := make([]HourlyForecastRow, 0, len(hourly.Periods)) for _, period := range hourly.Periods { summary := period.TextDescription if summary == "" { summary = period.Name } rows = append(rows, HourlyForecastRow{ Time: firstNonEmpty(period.PeriodBegins, period.Name), Summary: summary, Temperature: temperatureLabel(period.TemperatureF, period.TemperatureC), Precipitation: precipitationLabel(period.ProbabilityOfPrecipitationPercent), Wind: windLabel(period.WindDirection, period.WindSpeedMph, period.WindSpeedKmh, period.WindGustMph, period.WindGustKmh), }) } return rows } func precipitationTimingLabel(timing briefing.PrecipTimingModule) string { parts := []string{} if timing.MaxPopPercent != nil { max := fmt.Sprintf("Peak precipitation probability %d%%", *timing.MaxPopPercent) if timing.MaxPopTime != "" { max += " at " + timing.MaxPopTime } parts = append(parts, max) } for _, window := range timing.PrecipitationWindows { label := window.PeriodBegins if window.PeriodEnds != "" { label += " to " + window.PeriodEnds } if window.MaxPopPercent != nil { label += fmt.Sprintf(" (max %d%%", *window.MaxPopPercent) if window.MaxPopTime != "" { label += " at " + window.MaxPopTime } label += ")" } parts = append(parts, label) } if timing.ThunderMentioned { parts = append(parts, "Thunder is mentioned in the forecast.") } if len(parts) == 0 { return "No precipitation timing signal above threshold." } return strings.Join(parts, "; ") } func alertLabels(alerts briefing.AlertDigestModule) []string { if alerts.Missing { return []string{"Alert source missing."} } out := make([]string, 0, len(alerts.Relevant)) for _, alert := range alerts.Relevant { main := firstNonEmpty(alert.Event, alert.Headline) if main == "" { continue } if alert.Headline != "" && alert.Headline != main { main += ": " + alert.Headline } if alert.Severity != "" { main += " (" + alert.Severity + ")" } out = append(out, main) } return out } func outlookLabels(outlooks briefing.SPCConvectiveOutlooksModule) []string { out := make([]string, 0, len(outlooks.Outlooks)) for _, outlook := range outlooks.Outlooks { label := firstNonEmpty(outlook.LabelText, outlook.Label, outlook.OutlookType) if label == "" { continue } if outlook.PeriodBegins != "" { label += " from " + outlook.PeriodBegins if outlook.PeriodEnds != "" { label += " to " + outlook.PeriodEnds } } out = append(out, label) } return out } func spcDiscussionLabels(discussion briefing.SPCConvectiveDiscussionModule) []string { out := make([]string, 0, len(discussion.Discussions)) for _, record := range discussion.Discussions { label := firstNonEmpty(record.Headline, record.Summary, record.Discussion) if label == "" { continue } if record.Summary != "" && record.Summary != label { label += ": " + record.Summary } out = append(out, label) } return out } func weatherStoryLabel(story briefing.WeatherStoryModule) string { if !story.Available { return "No weather story available." } parts := []string{} if story.Title != "" { parts = append(parts, story.Title) } if story.Description != "" { parts = append(parts, story.Description) } if len(parts) == 0 { return "Weather story is available." } return strings.Join(parts, " - ") } func temperatureLabel(fahrenheit *float64, celsius *float64) string { if fahrenheit != nil { return fmt.Sprintf("%d F", rounded(*fahrenheit)) } if celsius != nil { return fmt.Sprintf("%d C", rounded(*celsius)) } return "" } func precipitationLabel(percent *float64) string { if percent == nil { return "" } return fmt.Sprintf("%d%% precipitation", rounded(*percent)) } func windLabel(direction string, mph *float64, kmh *float64, gustMph *float64, gustKmh *float64) string { speed := "" if mph != nil { speed = fmt.Sprintf("%d mph", rounded(*mph)) } else if kmh != nil { speed = fmt.Sprintf("%d km/h", rounded(*kmh)) } if direction != "" && speed != "" { speed = direction + " " + speed } else if direction != "" { speed = direction + " wind" } gust := "" if gustMph != nil { gust = fmt.Sprintf("gusts %d mph", rounded(*gustMph)) } else if gustKmh != nil { gust = fmt.Sprintf("gusts %d km/h", rounded(*gustKmh)) } switch { case speed != "" && gust != "": return "wind " + speed + ", " + gust case speed != "": return "wind " + speed case gust != "": return "wind " + gust default: return "" } } func rounded(value float64) int { if value < 0 { return int(value - 0.5) } return int(value + 0.5) } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" }