Correct daypart identity and display handling

This commit is contained in:
2026-08-13 01:08:20 +00:00
parent 13829cc65c
commit 41b86109e3
9 changed files with 224 additions and 24 deletions

View File

@@ -223,6 +223,13 @@ derivation. Every item needs `name`, `start`, and `end`; start and end use
(`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon` (`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon`
(`15:00``17:00`), and `evening` (`17:00``24:00`). (`15:00``17:00`), and `evening` (`17:00``24:00`).
Names remain display text, but each name must have a distinct canonical
identity. Canonicalization trims whitespace, lowercases letters, and collapses
punctuation and whitespace to underscores; for example, `Morning`,
`morning!`, and `morning` conflict. Planning recognizes the canonical
identities `morning`, `afternoon`, `evening`, and `overnight` regardless of
their display capitalization or punctuation.
### `reports` ### `reports`
`reports` optionally overrides a report's ordered deterministic modules and `reports` optionally overrides a report's ordered deterministic modules and

View File

@@ -46,6 +46,12 @@ missing sources, and prompt-safe SPC values omit geometry and other
template-only or source details. The complete module composition is in template-only or source details. The complete module composition is in
[module internals](module.md); fact derivation is in [fact contracts](facts.md). [module internals](module.md); fact derivation is in [fact contracts](facts.md).
Derived daypart-summary maps use the forecast package's canonical daypart
identity and reject any collision instead of replacing an earlier value.
Planning applies the same identity when recognizing morning, afternoon,
evening, and overnight windows; display labels remain separate and preserve
configured text with rune-safe first-letter capitalization.
`area_forecast_discussion` accepts an optional typed section filter. Accepted `area_forecast_discussion` accepts an optional typed section filter. Accepted
typed option pointers are normalized to the declared value type before builder typed option pointers are normalized to the declared value type before builder
execution. Planning modules are report-specific: `daily_planning` supports Daily, execution. Planning modules are report-specific: `daily_planning` supports Daily,

View File

@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"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"
@@ -80,6 +79,9 @@ func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Outpu
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries) prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
for _, daypart := range ctx.Derived.DaypartSummaries { for _, daypart := range ctx.Derived.DaypartSummaries {
key := daypartKey(daypart, prefixDates) key := daypartKey(daypart, prefixDates)
if existing, exists := value[key]; exists {
return nil, fmt.Errorf("daypart summary key %q collides with display name %q", key, existing.DisplayName)
}
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone) value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
} }
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
@@ -135,7 +137,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
temperature := daypartTemperatureDisplay(daypart) 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: capitalizeFirst(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),
@@ -148,7 +150,7 @@ func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature), ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition, DominantCondition: daypart.DominantCondition,
DominantConditionLower: strings.ToLower(daypart.DominantCondition), DominantConditionLower: strings.ToLower(daypart.DominantCondition),
DominantConditionDisplay: sentenceCase(daypart.DominantCondition), DominantConditionDisplay: capitalizeFirst(strings.TrimSpace(daypart.DominantCondition)),
NotableConditions: append([]string(nil), daypart.NotableConditions...), NotableConditions: append([]string(nil), daypart.NotableConditions...),
Snow: daypart.Indicators.Snow, Snow: daypart.Indicators.Snow,
Ice: daypart.Indicators.Ice, Ice: daypart.Indicators.Ice,
@@ -381,16 +383,6 @@ func temperatureBandQualifier(remainder int) string {
} }
} }
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

@@ -355,6 +355,52 @@ func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
} }
} }
func TestDerivedDaypartSummariesRejectCanonicalKeyCollisions(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
first := ctx.Derived.DaypartSummaries[0]
first.Name = "Morning"
second := first
second.Name = "morning!"
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err == nil || !strings.Contains(err.Error(), "collides") {
t.Fatalf("BuildModule() error = %v, want canonical daypart-key collision", err)
}
}
func TestDerivedDaypartSummaryDisplayCapitalizesUnicodeNames(t *testing.T) {
value := derivedDaypartSummaryValue(forecast.DaypartSummary{
Name: "mañana",
DominantCondition: "llovizna",
}, "UTC")
if value.DisplayName != "Mañana" || value.DominantConditionDisplay != "Llovizna" {
t.Fatalf("daypart display = %#v, want rune-safe capitalization", value)
}
}
func TestDerivedDaypartSummariesKeepDistinctUnicodeKeys(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
first := ctx.Derived.DaypartSummaries[0]
first.Name = "mañana"
second := first
second.Name = "manana"
ctx.Derived.DaypartSummaries = []forecast.DaypartSummary{first, second}
ctx.Derived.DailySummaries = []forecast.DailySummary{{Date: first.Period.Start.Format(timeutil.DateLayout)}}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
if len(value) != 2 || value["mañana"].DisplayName != "Mañana" || value["manana"].DisplayName != "Manana" {
t.Fatalf("daypart summaries = %#v, want distinct Unicode canonical keys", value)
}
}
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) { func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily) ctx := derivedModuleContext(report.Daily)
@@ -670,6 +716,65 @@ func TestOutdoorWindowsScoreSnowIceAndFog(t *testing.T) {
} }
} }
func TestPlanningUsesCanonicalDaypartIdentities(t *testing.T) {
timedValue := func(value float64) *forecast.TimedValue {
return &forecast.TimedValue{Value: value}
}
containsText := func(values []string, text string) bool {
return strings.Contains(strings.Join(values, "\n"), text)
}
summary := &forecast.DailySummary{Dayparts: []forecast.DaypartSummary{
{Name: "Overnight!", MaxPrecipitationProbability: timedValue(60)},
{Name: "MORNING", MaxPrecipitationProbability: timedValue(60)},
{Name: "Afternoon!!!", MaxPrecipitationProbability: timedValue(60)},
{Name: "EVENING!", MaxPrecipitationProbability: timedValue(60)},
}}
today := buildTodayPlanning(summary)
if !containsText(today.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("today morning readiness = %#v, want canonical morning window", today.MorningReadiness)
}
if !containsText(today.CommuteSchoolWorkdayConcerns, "Afternoon!!! precipitation chance reaches 60%.") ||
containsText(today.CommuteSchoolWorkdayConcerns, "Overnight!") ||
containsText(today.CommuteSchoolWorkdayConcerns, "EVENING!") {
t.Fatalf("today workday concerns = %#v, want only canonical workday windows", today.CommuteSchoolWorkdayConcerns)
}
if !containsText(today.LateDayChangeWatch, "Afternoon!!! precipitation timing may shift") ||
!containsText(today.LateDayChangeWatch, "EVENING! precipitation timing may shift") {
t.Fatalf("today late-day watch = %#v, want canonical afternoon and evening windows", today.LateDayChangeWatch)
}
base := buildMorningCommuteOvernightPlanning(summary)
if !containsText(base.MorningReadiness, "Morning precipitation chance peaks near 60%.") ||
!containsText(base.OvernightChangeWatch, "Overnight precipitation timing may shift") ||
containsText(base.CommuteSchoolWorkdayConcerns, "Overnight!") ||
containsText(base.CommuteSchoolWorkdayConcerns, "EVENING!") {
t.Fatalf("daily/tomorrow planning = %#v, want canonical daypart treatment", base)
}
}
func TestCapitalizeFirst(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "empty", input: "", want: ""},
{name: "ASCII", input: "morning", want: "Morning"},
{name: "multibyte", input: "mañana", want: "Mañana"},
{name: "already uppercase", input: "Morning", want: "Morning"},
{name: "leading space", input: " morning", want: " morning"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := capitalizeFirst(tt.input); got != tt.want {
t.Fatalf("capitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) { func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry() registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Tomorrow) ctx := derivedModuleContext(report.Tomorrow)

View File

@@ -100,7 +100,7 @@ func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType s
case maxPopPercent >= precipTimingExpectLowerBound: case maxPopPercent >= precipTimingExpectLowerBound:
return fmt.Sprintf("Expect %s.", precipitationType) return fmt.Sprintf("Expect %s.", precipitationType)
case maxPopPercent >= precipTimingLikelyLowerBound: case maxPopPercent >= precipTimingLikelyLowerBound:
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType)) return fmt.Sprintf("%s likely.", capitalizeFirst(strings.TrimSpace(precipitationType)))
case maxPopPercent >= precipTimingChanceLowerBound: case maxPopPercent >= precipTimingChanceLowerBound:
return fmt.Sprintf("Chance of %s.", precipitationType) return fmt.Sprintf("Chance of %s.", precipitationType)
default: default:

View File

@@ -5,6 +5,7 @@ import (
"math" "math"
"sort" "sort"
"strings" "strings"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
@@ -43,6 +44,13 @@ type TodayPlanning struct {
const outdoorIndicatorRiskScore = 25 const outdoorIndicatorRiskScore = 25
const (
morningDaypartIdentity = "morning"
afternoonDaypartIdentity = "afternoon"
eveningDaypartIdentity = "evening"
overnightDaypartIdentity = "overnight"
)
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows { func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow var best *OutdoorWindow
var worst *OutdoorWindow var worst *OutdoorWindow
@@ -74,7 +82,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
} }
for _, daypart := range summary.Dayparts { for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" { if isOutsideWorkday(daypart) {
continue continue
} }
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...) planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
@@ -93,7 +101,7 @@ func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.") planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
} }
for _, name := range []string{"afternoon", "evening"} { for _, name := range []string{afternoonDaypartIdentity, eveningDaypartIdentity} {
daypart := daypartNamed(summary.Dayparts, name) daypart := daypartNamed(summary.Dayparts, name)
if daypart != nil { if daypart != nil {
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...) planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
@@ -126,7 +134,7 @@ func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morni
} }
for _, daypart := range summary.Dayparts { for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" { if isOutsideWorkday(daypart) {
continue continue
} }
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...) planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
@@ -155,10 +163,10 @@ func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
windows := buildOutdoorWindows(dayparts) windows := buildOutdoorWindows(dayparts)
var notes []string var notes []string
if windows.Best != nil { if windows.Best != nil {
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", "))) notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", capitalizeFirst(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
} }
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) { if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", "))) notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", capitalizeFirst(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
} }
return appendUnique(nil, notes...) return appendUnique(nil, notes...)
} }
@@ -185,7 +193,7 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string { func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
notes := []string{} notes := []string{}
prefix := titleWord(daypart.Name) prefix := capitalizeFirst(daypart.Name)
if prefix == "" { if prefix == "" {
prefix = "Late-day" prefix = "Late-day"
} }
@@ -206,7 +214,7 @@ func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
func concernNotes(daypart forecast.DaypartSummary) []string { func concernNotes(daypart forecast.DaypartSummary) []string {
notes := []string{} notes := []string{}
prefix := titleWord(daypart.Name) prefix := capitalizeFirst(daypart.Name)
if prefix == "" { if prefix == "" {
prefix = "Daytime" prefix = "Daytime"
} }
@@ -249,8 +257,9 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
} }
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary { func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
identity := forecast.CanonicalDaypartKey(name)
for i := range dayparts { for i := range dayparts {
if strings.EqualFold(dayparts[i].Name, name) { if forecast.CanonicalDaypartKey(dayparts[i].Name) == identity {
return &dayparts[i] return &dayparts[i]
} }
} }
@@ -397,9 +406,20 @@ func appendUnique(values []string, candidates ...string) []string {
return values return values
} }
func titleWord(value string) string { func isOutsideWorkday(daypart forecast.DaypartSummary) bool {
switch forecast.CanonicalDaypartKey(daypart.Name) {
case overnightDaypartIdentity, eveningDaypartIdentity:
return true
default:
return false
}
}
func capitalizeFirst(value string) string {
if value == "" { if value == "" {
return "" return ""
} }
return strings.ToUpper(value[:1]) + value[1:] runes := []rune(value)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
} }

View File

@@ -82,6 +82,40 @@ func TestDefaults(t *testing.T) {
} }
} }
func TestDaypartNamesMustHaveDistinctCanonicalIdentities(t *testing.T) {
tests := []struct {
name string
names []string
wantErr string
}{
{name: "exact duplicate", names: []string{"morning", "morning"}, wantErr: "conflicts with"},
{name: "case-only duplicate", names: []string{"morning", "MORNING"}, wantErr: "conflicts with"},
{name: "punctuation-normalized duplicate", names: []string{"morning", "morning!"}, wantErr: "conflicts with"},
{name: "distinct Unicode names", names: []string{"mañana", "manana"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Defaults()
cfg.Dayparts = make([]DaypartConfig, 0, len(tt.names))
for _, name := range tt.names {
cfg.Dayparts = append(cfg.Dayparts, DaypartConfig{Name: name, Start: "06:00", End: "12:00"})
}
err := Validate(cfg)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr)
}
})
}
}
func TestWeatherAPIBaseURLValidation(t *testing.T) { func TestWeatherAPIBaseURLValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -5,6 +5,7 @@ import (
"net/url" "net/url"
"strings" "strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
) )
@@ -74,10 +75,16 @@ func Validate(cfg Config) error {
if len(cfg.Dayparts) == 0 { if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry") return fmt.Errorf("dayparts must contain at least one entry")
} }
daypartNames := make(map[string]int, len(cfg.Dayparts))
for i, daypart := range cfg.Dayparts { for i, daypart := range cfg.Dayparts {
if strings.TrimSpace(daypart.Name) == "" { if strings.TrimSpace(daypart.Name) == "" {
return fmt.Errorf("dayparts[%d].name is required", i) return fmt.Errorf("dayparts[%d].name is required", i)
} }
key := forecast.CanonicalDaypartKey(daypart.Name)
if previous, exists := daypartNames[key]; exists {
return fmt.Errorf("dayparts[%d].name %q conflicts with dayparts[%d].name after canonicalization", i, daypart.Name, previous)
}
daypartNames[key] = i
if _, err := timeutil.ParseClock(daypart.Start); err != nil { if _, err := timeutil.ParseClock(daypart.Start); err != nil {
return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err) return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err)
} }

View File

@@ -244,6 +244,35 @@ func TestBuildTodayRenderContext(t *testing.T) {
} }
} }
func TestTodayRenderContextPreservesUnicodeDaypartDisplayNames(t *testing.T) {
metadata := testTodayMetadata()
snapshot := testTodaySnapshot(t)
for i := range snapshot.Outputs {
if snapshot.Outputs[i].ID != module.DerivedDaypartSummaries {
continue
}
dayparts := snapshot.Outputs[i].Value.(map[string]briefing.DerivedDaypartSummaryModule)
morning := dayparts["morning"]
morning.DisplayName = "Mañana"
dayparts["morning"] = morning
}
ctx, err := BuildTodayRenderContext(metadata, snapshot, Today{
Summary: "Today summary.",
ForecastDiscussion: []string{"Morning conditions stay quiet."},
}, testCollected(), testTodayDerived())
if err != nil {
t.Fatalf("BuildTodayRenderContext() error = %v", err)
}
rendered, err := reporttemplate.Render("today", ctx)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if !strings.Contains(string(rendered), "Mañana") {
t.Fatalf("rendered report = %q, want Unicode daypart display name", rendered)
}
}
func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) { func TestBuildTodayRenderContextAllowsOmittedOptionalModules(t *testing.T) {
snapshot, err := module.NewSnapshot(nil) snapshot, err := module.NewSnapshot(nil)
if err != nil { if err != nil {