package briefing import ( "fmt" "sort" "strings" "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata" ) type DerivedDaypartSummaryModule struct { Date string `json:"date,omitempty"` DisplayName string `json:"display_name,omitempty"` PeriodBegins string `json:"period_begins,omitempty"` PeriodEnds string `json:"period_ends,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"` TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"` MentionPrecipitation bool `json:"mention_precipitation,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` DominantCondition string `json:"dominant_condition,omitempty"` DominantConditionLower string `json:"dominant_condition_lower,omitempty"` DominantConditionDisplay string `json:"dominant_condition_display,omitempty"` TemperatureTrend string `json:"temperature_trend,omitempty"` TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"` TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"` TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"` TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,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 DerivedDaypartSummaryPromptExport struct { Date string `json:"date,omitempty"` DisplayName string `json:"display_name,omitempty"` PeriodBegins string `json:"period_begins,omitempty"` PeriodEnds string `json:"period_ends,omitempty"` 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"` MentionPrecipitation bool `json:"mention_precipitation,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` DominantCondition string `json:"dominant_condition,omitempty"` TemperatureTrend string `json:"temperature_trend,omitempty"` TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"` TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"` TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"` TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,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 exportDerivedDaypartSummariesPromptValue(value any) (any, error) { rich, ok := value.(map[string]DerivedDaypartSummaryModule) if !ok { return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{}) } out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich)) for key, daypart := range rich { out[key] = derivedDaypartSummaryPromptValue(daypart) } return out, nil } func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport { maxPopTime := rich.MaxPopTime if rich.MaxPopTimeLabel != "" { maxPopTime = rich.MaxPopTimeLabel } return DerivedDaypartSummaryPromptExport{ Date: rich.Date, DisplayName: rich.DisplayName, PeriodBegins: rich.PeriodBegins, PeriodEnds: rich.PeriodEnds, TempRangeF: rich.TempRangeF, ApparentTempRangeF: rich.ApparentTempRangeF, MaxPopPercent: copyInt(rich.MaxPopPercent), MaxPopTime: maxPopTime, MentionPrecipitation: rich.MentionPrecipitation, MaxWindGustMph: copyInt(rich.MaxWindGustMph), MaxWindGustTime: rich.MaxWindGustTime, DominantCondition: rich.DominantCondition, TemperatureTrend: rich.TemperatureTrend, TemperatureStartPhraseF: rich.TemperatureStartPhraseF, TemperatureEndPhraseF: rich.TemperatureEndPhraseF, TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF, TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF, NotableConditions: append([]string(nil), rich.NotableConditions...), Snow: rich.Snow, Ice: rich.Ice, Fog: rich.Fog, Heat: rich.Heat, Cold: rich.Cold, Wind: rich.Wind, RelevantAlertCount: rich.RelevantAlertCount, } } func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { temperature := daypartTemperatureDisplay(daypart) value := DerivedDaypartSummaryModule{ Date: localDateLabel(daypart.Period.Start, timezone), DisplayName: titleWord(strings.TrimSpace(daypart.Name)), PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone), PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone), TempRangeF: rangeLabel(daypart.Temperature), TemperaturePhraseF: temperaturePhraseF(daypart.Temperature), TemperatureTrend: temperature.Trend, TemperatureStartPhraseF: temperature.StartPhrase, TemperatureEndPhraseF: temperature.EndPhrase, TemperaturePeakPhraseF: temperature.PeakPhrase, TemperatureSteadyPhraseF: temperature.SteadyPhrase, ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), DominantCondition: daypart.DominantCondition, DominantConditionLower: strings.ToLower(daypart.DominantCondition), DominantConditionDisplay: sentenceCase(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) value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone) value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold) } if daypart.PeakWindGust != nil { value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value) value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone) } return value } type daypartTemperaturePresentation struct { Trend string StartPhrase string EndPhrase string PeakPhrase string SteadyPhrase string } type temperaturePoint struct { value int bandIndex int phrase string } const ( temperatureTrendRising = "rising" temperatureTrendFalling = "falling" temperatureTrendPeaking = "peaking" temperatureTrendSteady = "steady" ) func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation { points := daypartTemperaturePoints(daypart.HourlyPeriods) if len(points) == 0 { return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature)) } if len(points) == 1 { return daypartSteadyTemperatureDisplay(points[0].phrase) } first := points[0] last := points[len(points)-1] peak, peakIndex := peakTemperaturePoint(points) if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex { return daypartTemperaturePresentation{ Trend: temperatureTrendPeaking, PeakPhrase: peak.phrase, } } switch { case first.bandIndex < last.bandIndex: return daypartTemperaturePresentation{ Trend: temperatureTrendRising, StartPhrase: first.phrase, EndPhrase: last.phrase, } case first.bandIndex > last.bandIndex: return daypartTemperaturePresentation{ Trend: temperatureTrendFalling, StartPhrase: first.phrase, EndPhrase: last.phrase, } default: return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature)) } } func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation { if phrase == "" { return daypartTemperaturePresentation{} } return daypartTemperaturePresentation{ Trend: temperatureTrendSteady, SteadyPhrase: phrase, } } func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint { sorted := append([]weatherdata.ForecastPeriod(nil), periods...) sort.SliceStable(sorted, func(i int, j int) bool { return sorted[i].StartTime.Before(sorted[j].StartTime) }) points := make([]temperaturePoint, 0, len(sorted)) for _, period := range sorted { temperature := forecastPeriodTemperatureF(period) if temperature == nil { continue } rounded := roundedInt(temperature) if rounded == nil { continue } points = append(points, temperaturePoint{ value: *rounded, bandIndex: temperatureBandIndex(*rounded), phrase: temperatureBandPhrase(*rounded), }) } return points } func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) { peak := points[0] peakIndex := 0 for index, point := range points[1:] { if point.value > peak.value { peak = point peakIndex = index + 1 } } return peak, peakIndex } func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 { switch { case period.TemperatureF != nil: return period.TemperatureF case period.TemperatureFMax != nil: return period.TemperatureFMax case period.TemperatureFMin != nil: return period.TemperatureFMin case period.TemperatureC != nil: value := celsiusToFahrenheit(*period.TemperatureC) return &value case period.TemperatureCMax != nil: value := celsiusToFahrenheit(*period.TemperatureCMax) return &value case period.TemperatureCMin != nil: value := celsiusToFahrenheit(*period.TemperatureCMin) return &value default: return nil } } func celsiusToFahrenheit(value float64) float64 { return value*9/5 + 32 } func temperatureBandIndex(value int) int { decade := (value / 10) * 10 remainder := value - decade if remainder < 0 { remainder = -remainder } band := 1 switch { case remainder <= 3: band = 0 case remainder >= 7: band = 2 } return decade*3 + band } func temperaturePhraseF(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 { return "" } lowPhrase := temperatureBandPhrase(*low) highPhrase := temperatureBandPhrase(*high) if lowPhrase == highPhrase { return lowPhrase } return lowPhrase + " to " + highPhrase } if value.Min != nil { low := roundedInt(value.Min) if low == nil { return "" } return temperatureBandPhrase(*low) } high := roundedInt(value.Max) if high == nil { return "" } return temperatureBandPhrase(*high) } func temperatureBandPhrase(value int) string { decade := (value / 10) * 10 remainder := value - decade if remainder < 0 { remainder = -remainder } qualifier := "mid" switch { case remainder <= 3: qualifier = "low" case remainder >= 7: qualifier = "upper" } return fmt.Sprintf("%s %ds", qualifier, decade) } func sentenceCase(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { return "" } runes := []rune(trimmed) runes[0] = unicode.ToUpper(runes[0]) return string(runes) } 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(), "_") }