Revise the report template for the tomorrow report.

This commit is contained in:
2026-06-14 23:26:45 -05:00
parent 74bd69834c
commit 473f252aee
6 changed files with 409 additions and 93 deletions

View File

@@ -2,38 +2,46 @@ package briefing
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"unicode" "unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
) )
type DerivedDaypartSummaryModule struct { type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"` PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"` PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"` TempRangeF string `json:"temp_range_f,omitempty"`
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"` TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"` ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"` MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"` MaxPopTime string `json:"max_pop_time,omitempty"`
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"` MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"` MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"` MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"` DominantCondition string `json:"dominant_condition,omitempty"`
DominantConditionLower string `json:"dominant_condition_lower,omitempty"` DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"` DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
Snow bool `json:"snow,omitempty"` TemperatureTrend string `json:"temperature_trend,omitempty"`
Ice bool `json:"ice,omitempty"` TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
Fog bool `json:"fog,omitempty"` TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
Heat bool `json:"heat,omitempty"` TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
Cold bool `json:"cold,omitempty"` TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
Wind bool `json:"wind,omitempty"` NotableConditions []string `json:"notable_conditions,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,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) { func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
@@ -50,24 +58,31 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
} }
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule { func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
temperature := daypartTemperatureDisplay(daypart)
value := DerivedDaypartSummaryModule{ value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone), Date: localDateLabel(daypart.Period.Start, timezone),
DisplayName: titleWord(strings.TrimSpace(daypart.Name)), DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone), PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone), PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature), TempRangeF: rangeLabel(daypart.Temperature),
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature), TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), TemperatureTrend: temperature.Trend,
DominantCondition: daypart.DominantCondition, TemperatureStartPhraseF: temperature.StartPhrase,
DominantConditionLower: strings.ToLower(daypart.DominantCondition), TemperatureEndPhraseF: temperature.EndPhrase,
NotableConditions: append([]string(nil), daypart.NotableConditions...), TemperaturePeakPhraseF: temperature.PeakPhrase,
Snow: daypart.Indicators.Snow, TemperatureSteadyPhraseF: temperature.SteadyPhrase,
Ice: daypart.Indicators.Ice, ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
Fog: daypart.Indicators.Fog, DominantCondition: daypart.DominantCondition,
Heat: daypart.Indicators.Heat, DominantConditionLower: strings.ToLower(daypart.DominantCondition),
Cold: daypart.Indicators.Cold, DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
Wind: daypart.Indicators.Wind, NotableConditions: append([]string(nil), daypart.NotableConditions...),
RelevantAlertCount: len(daypart.AlertOverlaps), 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 { if daypart.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value) value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
@@ -82,6 +97,151 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
return value 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 { func temperaturePhraseF(value forecast.Range) string {
if value.Min == nil && value.Max == nil { if value.Min == nil && value.Max == nil {
return "" return ""
@@ -129,6 +289,16 @@ func temperatureBandPhrase(value int) string {
return fmt.Sprintf("%s %ds", qualifier, decade) 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 { func multipleSummaryDates(summaries []forecast.DailySummary) bool {
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, summary := range summaries { for _, summary := range summaries {

View File

@@ -169,7 +169,7 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 { if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning) t.Fatalf("morning = %#v, want temp range and precip peak", morning)
} }
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.TemperaturePhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" { if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning) t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
} }
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" { if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
@@ -191,7 +191,7 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
t.Fatalf("marshal daypart summaries: %v", err) t.Fatalf("marshal daypart summaries: %v", err)
} }
jsonText := string(data) jsonText := string(data)
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower"} { for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
if !strings.Contains(jsonText, field) { if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field) t.Fatalf("daypart json = %s, want field %s", jsonText, field)
} }
@@ -201,6 +201,62 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
} }
} }
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
tests := []struct {
name string
temps []float64
wantTrend string
wantStart string
wantEnd string
wantPeak string
wantSteady string
}{
{
name: "rising",
temps: []float64{58, 68},
wantTrend: "rising",
wantStart: "upper 50s",
wantEnd: "upper 60s",
},
{
name: "falling",
temps: []float64{65, 58},
wantTrend: "falling",
wantStart: "mid 60s",
wantEnd: "upper 50s",
},
{
name: "peaking",
temps: []float64{62, 78, 65},
wantTrend: "peaking",
wantPeak: "upper 70s",
},
{
name: "steady same band",
temps: []float64{77, 78},
wantTrend: "steady",
wantSteady: "upper 70s",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
value := derivedDaypartSummaryValue(summary, "America/Chicago")
if value.DominantConditionDisplay != "Sunny" {
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
}
if value.TemperatureTrend != test.wantTrend ||
value.TemperatureStartPhraseF != test.wantStart ||
value.TemperatureEndPhraseF != test.wantEnd ||
value.TemperaturePeakPhraseF != test.wantPeak ||
value.TemperatureSteadyPhraseF != test.wantSteady {
t.Fatalf("temperature presentation = %#v", value)
}
})
}
}
func TestTemperaturePhraseF(t *testing.T) { func TestTemperaturePhraseF(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -382,6 +438,24 @@ func derivedDaypart(name string, start string, end string, text string, temperat
}, []weatherdata.ForecastPeriod{hour}) }, []weatherdata.ForecastPeriod{hour})
} }
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
startTime := mustParseModuleTime(start)
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
for index, temperature := range temperatures {
periodStart := startTime.Add(time.Duration(index) * time.Hour)
periods = append(periods, weatherdata.ForecastPeriod{
StartTime: periodStart,
EndTime: periodStart.Add(time.Hour),
TextDescription: text,
TemperatureF: floatPtr(temperature),
})
}
return forecast.SummarizeDaypart(name, timeutil.Period{
Start: startTime,
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
}, periods)
}
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod { func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
startTime := mustParseModuleTime(start) startTime := mustParseModuleTime(start)
endTime := startTime.Add(time.Hour) endTime := startTime.Add(time.Hour)

View File

@@ -178,8 +178,8 @@ func TestBuildTomorrowRenderContext(t *testing.T) {
"# Monday's Weather", "# Monday's Weather",
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"Tomorrow starts quiet, then showers become more likely later in the day.", "Tomorrow starts quiet, then showers become more likely later in the day.",
"- **Morning:** low 60s and partly cloudy.", "- **Morning:** Partly cloudy, with temperatures in the low 60s.",
"- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.", "- **Afternoon:** Showers, with temperatures in the mid 70s. Chance of precipitation is 70%.",
"## Precipitation Timing", "## Precipitation Timing",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"The most likely rain window is from midafternoon into early evening.", "The most likely rain window is from midafternoon into early evening.",
@@ -478,17 +478,21 @@ func testTomorrowSnapshot(t *testing.T) module.Snapshot {
StanzaName: string(module.DerivedDaypartSummaries), StanzaName: string(module.DerivedDaypartSummaries),
Value: map[string]briefing.DerivedDaypartSummaryModule{ Value: map[string]briefing.DerivedDaypartSummaryModule{
"afternoon": { "afternoon": {
DisplayName: "Afternoon", DisplayName: "Afternoon",
TemperaturePhraseF: "mid 70s", TemperatureTrend: "steady",
DominantConditionLower: "showers", TemperatureSteadyPhraseF: "mid 70s",
MaxPopPercent: intPtr(70), DominantConditionDisplay: "Showers",
MaxPopTimeLabel: "3:00 PM", DominantConditionLower: "showers",
MentionPrecipitation: true, MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
}, },
"morning": { "morning": {
DisplayName: "Morning", DisplayName: "Morning",
TemperaturePhraseF: "low 60s", TemperatureTrend: "steady",
DominantConditionLower: "partly cloudy", TemperatureSteadyPhraseF: "low 60s",
DominantConditionDisplay: "Partly cloudy",
DominantConditionLower: "partly cloudy",
}, },
}, },
}, },

View File

@@ -1,18 +1,54 @@
# Tomorrow Generated Text TASK: You are writing structured prose slots for a short-term hourly weather report.
Create structured prose for the Tomorrow Report using the supplied The calling application will render the final Markdown report. Your job is not to write the full report. Return only a JSON object matching the configured schema.
weatherreporter data package.
Return JSON that matches the Scriptorium-registered schema for Use only the supplied `data_package`. Do not invent weather details, times, hazards, probabilities, or impacts that are not supported by the data.
`weather.tomorrow_generated_text`.
Fields: The report focuses on the valid period in `report.valid_period`, typically the next several hours for the configured location.
- `summary`: concise overview of tomorrow's weather. Return these fields:
- `forecast_discussion`: one or more paragraph strings explaining the main
weather drivers and forecast reasoning.
- `precipitation_timing`: optional plain-language precipitation timing context.
- `confidence`: optional uncertainty or confidence note.
Use deterministic facts from the data package for weather details. Do not - `summary`: required. 1-2 sentences summarizing the main weather story for the valid period.
invent watches, warnings, precipitation windows, temperatures, or timing. - `forecast_discussion`: required. 2-3 sentences explaining the broader setup, trend, or forecast reasoning most relevant to the valid period.
- `precipitation_timing`: optional. Include only when the deterministic `precip_timing` module contains precipitation windows.
- `confidence`: optional. Include only if uncertainty, timing spread, or conflicting signals materially affect how the reader should interpret the forecast.
Return JSON only.
# summary
The summary should typically consist of two sentences.
If an active warning is relevant during the report period, lead with the hazard. Otherwise, the first sentence should state the most likely local weather outcome for the valid period, including the overall character of the weather and expected temperature/temperature range.
The second sentence should state the most important active hazard, caveat, uncertainty, or alternate outcome, if one exists. If there is no meaningful caveat, the second sentence may be omitted, or may briefly say that no major complications are apparent.
In the lead, distinguish the main weather outcome from the caveat. If showers and thunderstorms have different timing, state that difference rather than combining them as a single risk throughout the valid period. If the main caveat is a regional severe-weather or precipitation risk displaced from the report location, state that limitation clearly.
Example style:
- “Sunday is expected to be warm and dry, with mostly clear skies. There is a slight chance of isolated showers and thunderstorms developing from late afternoon into early evening.”
# forecast_discussion
Use narrative products to explain the “why” behind the local forecast when useful.
Useful context may include:
- synoptic pattern
- fronts or boundaries
- shortwaves, troughs, or ridges
- instability, moisture, shear, forcing, or capping
- regional placement of precipitation or severe-weather chances
- hazard types and timing windows
- confidence or uncertainty
- conditional outcomes
- relevant notes about the following day or days
# precipitation_timing
Use 1-2 sentences to add practical context, including:
- Whether the precipitation is associated with a moving frontal boundary, convective initiation, or wide stratiform rain (if this can be determined from the data package);
- The expected type, intensity, and duration of the precipitation; and
- Any caveats or uncertainty with respect to the onset, duration, or occurrance of the precipitation.

View File

@@ -220,23 +220,39 @@ func TestRenderTomorrow(t *testing.T) {
}, },
Modules: testTomorrowModules{ Modules: testTomorrowModules{
Dayparts: []testTomorrowDaypart{ Dayparts: []testTomorrowDaypart{
{
Key: "overnight",
Summary: testDaypartSummary{
DisplayName: "Overnight",
TemperatureTrend: "falling",
TemperatureStartPhraseF: "mid 60s",
TemperatureEndPhraseF: "upper 50s",
DominantConditionDisplay: "Partly cloudy",
DominantConditionLower: "partly cloudy",
},
},
{ {
Key: "morning", Key: "morning",
Summary: testDaypartSummary{ Summary: testDaypartSummary{
DisplayName: "Morning", DisplayName: "Morning",
TemperaturePhraseF: "low 60s", TemperatureTrend: "rising",
DominantConditionLower: "partly cloudy", TemperatureStartPhraseF: "upper 50s",
TemperatureEndPhraseF: "upper 60s",
DominantConditionDisplay: "Sunny",
DominantConditionLower: "sunny",
}, },
}, },
{ {
Key: "afternoon", Key: "afternoon",
Summary: testDaypartSummary{ Summary: testDaypartSummary{
DisplayName: "Afternoon", DisplayName: "Afternoon",
TemperaturePhraseF: "mid 70s", TemperatureTrend: "steady",
DominantConditionLower: "showers", TemperatureSteadyPhraseF: "upper 70s",
MaxPopPercent: intPtr(70), DominantConditionDisplay: "Sunny",
MaxPopTimeLabel: "3:00 PM", DominantConditionLower: "sunny",
MentionPrecipitation: true, MaxPopPercent: intPtr(70),
MaxPopTimeLabel: "3:00 PM",
MentionPrecipitation: true,
}, },
}, },
}, },
@@ -256,8 +272,9 @@ func TestRenderTomorrow(t *testing.T) {
"**Forecast date:** Monday, June 15, 2026", "**Forecast date:** Monday, June 15, 2026",
"**Updated:** Sunday, June 14, 2026 at 9:14 AM", "**Updated:** Sunday, June 14, 2026 at 9:14 AM",
"Tomorrow starts dry before showers return later in the day.", "Tomorrow starts dry before showers return later in the day.",
"- **Morning:** low 60s and partly cloudy.", "- **Overnight:** Partly cloudy, with temperatures falling from the mid 60s to the upper 50s.",
"- **Afternoon:** mid 70s and showers. Precipitation chances peak at 70% at 3:00 PM.", "- **Morning:** Sunny, with temperatures rising from the upper 50s to the upper 60s.",
"- **Afternoon:** Sunny, with temperatures in the upper 70s. Chance of precipitation is 70%.",
"- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.", "- **3:00 PM** to **6:00 PM**: Precipitation is expected during this period. The peak precipitation chance is 70% at 3:00 PM.",
"A few showers may linger into early evening.", "A few showers may linger into early evening.",
"Clouds increase after sunrise.", "Clouds increase after sunrise.",
@@ -267,9 +284,18 @@ func TestRenderTomorrow(t *testing.T) {
t.Fatalf("rendered template missing %q:\n%s", want, text) t.Fatalf("rendered template missing %q:\n%s", want, text)
} }
} }
for _, unwanted := range []string{
"upper 50s.\n\n- **Morning:**",
"upper 60s.\n\n- **Afternoon:**",
} {
if strings.Contains(text, unwanted) {
t.Fatalf("rendered template includes blank lines between daypart bullets:\n%s", text)
}
}
assertOrderedText(t, text, []string{ assertOrderedText(t, text, []string{
"# Monday's Weather", "# Monday's Weather",
"## Daypart Forecast", "## Daypart Forecast",
"- **Overnight:**",
"- **Morning:**", "- **Morning:**",
"- **Afternoon:**", "- **Afternoon:**",
"## Precipitation Timing", "## Precipitation Timing",
@@ -296,9 +322,11 @@ func TestRenderTomorrowOmitsPrecipitationTimingWithoutWindows(t *testing.T) {
{ {
Key: "morning", Key: "morning",
Summary: testDaypartSummary{ Summary: testDaypartSummary{
DisplayName: "Morning", DisplayName: "Morning",
TemperaturePhraseF: "low 60s", TemperatureTrend: "steady",
DominantConditionLower: "clear", TemperatureSteadyPhraseF: "low 60s",
DominantConditionDisplay: "Clear",
DominantConditionLower: "clear",
}, },
}, },
}, },
@@ -436,15 +464,21 @@ type testTomorrowDaypart struct {
} }
type testDaypartSummary struct { type testDaypartSummary struct {
DisplayName string DisplayName string
TempRangeF string TempRangeF string
TemperaturePhraseF string TemperaturePhraseF string
MaxPopPercent *int TemperatureTrend string
MaxPopTime string TemperatureStartPhraseF string
MaxPopTimeLabel string TemperatureEndPhraseF string
MentionPrecipitation bool TemperaturePeakPhraseF string
DominantCondition string TemperatureSteadyPhraseF string
DominantConditionLower string MaxPopPercent *int
MaxPopTime string
MaxPopTimeLabel string
MentionPrecipitation bool
DominantCondition string
DominantConditionLower string
DominantConditionDisplay string
} }
type testCurrentConditions struct { type testCurrentConditions struct {

View File

@@ -6,10 +6,8 @@
{{ .GeneratedText.Summary }} {{ .GeneratedText.Summary }}
## Daypart Forecast ## Daypart Forecast
{{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }} {{ with .Modules.Dayparts }}{{ range . }}{{ $daypart := . }}- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ with .Summary.DominantConditionDisplay }}{{ . }}{{ else }}{{ with .Summary.DominantCondition }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}{{ if eq .Summary.TemperatureTrend "rising" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures rising from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "falling" }}{{ with .Summary.TemperatureStartPhraseF }}, with temperatures falling from the {{ . }}{{ with $daypart.Summary.TemperatureEndPhraseF }} to the {{ . }}{{ end }}{{ end }}{{ else if eq .Summary.TemperatureTrend "peaking" }}{{ with .Summary.TemperaturePeakPhraseF }}, with temperatures peaking in the {{ . }}{{ end }}{{ else }}{{ with .Summary.TemperatureSteadyPhraseF }}, with temperatures in the {{ . }}{{ else }}{{ with .Summary.TemperaturePhraseF }}, with temperatures in the {{ . }}{{ end }}{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Chance of precipitation is {{ . }}%.{{ end }}{{ end }}
- **{{ if .Summary.DisplayName }}{{ .Summary.DisplayName }}{{ else }}{{ .Key }}{{ end }}:** {{ if .Summary.TemperaturePhraseF }}{{ .Summary.TemperaturePhraseF }}{{ with .Summary.DominantConditionLower }} and {{ . }}{{ end }}{{ else }}{{ with .Summary.DominantConditionLower }}{{ . }}{{ else }}Forecast details are limited{{ end }}{{ end }}.{{ if .Summary.MentionPrecipitation }}{{ with .Summary.MaxPopPercent }} Precipitation chances peak at {{ . }}%{{ with $daypart.Summary.MaxPopTimeLabel }} at {{ . }}{{ else }}{{ with $daypart.Summary.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}{{ end }} {{ end }}{{ else }}- No daypart forecast details are available.
{{ end }}{{ else }}
- No daypart forecast details are available.
{{ end }} {{ end }}
{{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }} {{ with .Modules.PrecipTiming }}{{ with .PrecipitationWindows }}