diff --git a/docs/config.md b/docs/config.md index bc7ffbe..9b1d239 100644 --- a/docs/config.md +++ b/docs/config.md @@ -171,7 +171,7 @@ midday, afternoon, and evening. - `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`. - `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`. -Recent Changes are added to prompt input when a prior comparable briefing +Recent Changes are added to prompt input when a prior comparable module snapshot exists and a threshold is crossed. ### `reports` diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index 4448b4a..d0d7f42 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -70,19 +70,20 @@ Single-report generation follows this order: 2. Create or use a filesystem store. 3. Locate any prior compatible snapshot through `internal/state`. 4. Fetch a Weather API bundle. -5. Build a report-specific briefing package. -6. Save the briefing snapshot. -7. Compute Recent Changes from structured prior and current briefings. -8. Build and save the Scriptorium `data_package`. -9. Run Scriptorium render preflight. -10. Save preflight JSON when a render result is available. -11. Save metadata for inspection. -12. Run Scriptorium report generation to the managed report path. -13. Copy the managed report to the requested `--out` path when provided. -14. Save metadata with the managed report path. -15. If distributor notification is enabled, notify using the managed report +5. Build collected and derived facts once. +6. Execute configured modules and save the module snapshot. +7. Build and save the compatibility briefing snapshot. +8. Compute Recent Changes from structured prior and current module snapshots. +9. Build and save the YAML Scriptorium `data_package`. +10. Run Scriptorium render preflight. +11. Save preflight JSON when a render result is available. +12. Save metadata for inspection. +13. Run Scriptorium report generation to the managed report path. +14. Copy the managed report to the requested `--out` path when provided. +15. Save metadata with the managed report path. +16. If distributor notification is enabled, notify using the managed report path as the source file. -16. Save a distributor notification debug artifact and update metadata with its +17. Save a distributor notification debug artifact and update metadata with its path. If render preflight returns both a result and an error, preflight JSON and @@ -141,7 +142,7 @@ Inspect: - Generated reports use the same app request and result types regardless of report ID. - Render preflight precedes Scriptorium report generation. -- Recent Changes are computed from structured briefing snapshots. +- Recent Changes are computed from structured module snapshots. - Metadata links artifacts produced for a run. - Distributor notification maps the managed Markdown report path to configured bundle paths; extra output copies are not upload sources. diff --git a/docs/internal/changes.md b/docs/internal/changes.md index c5644c7..cc48614 100644 --- a/docs/internal/changes.md +++ b/docs/internal/changes.md @@ -4,15 +4,15 @@ This document describes structured Recent Changes comparison. ## Purpose -`internal/changes` compares current and prior briefing packages and emits +`internal/changes` compares current and prior module snapshots and emits compact change records for prompt input data packages. ## Inputs And Outputs Inputs: -- prior briefing package -- current briefing package +- prior module snapshot +- current module snapshot - comparison thresholds from configuration Outputs: @@ -22,7 +22,7 @@ Outputs: ## Boundaries -- This package compares structured briefing data only. +- This package compares structured module snapshot data only. - It does not read filesystem state, find prior snapshots, render Markdown, invoke Scriptorium, or compare generated report text. @@ -41,7 +41,7 @@ None. ## State Or Manifest Behavior -None directly. The app loads prior briefing snapshots through `internal/state` +None directly. The app loads prior module snapshots through `internal/state` before calling comparison functions. ## Skip And Resume Behavior @@ -51,9 +51,11 @@ empty Recent Changes list without calling a comparison function. ## Failure Behavior -- Daily comparison requires both inputs to contain Daily briefing content. -- 3-Day comparison requires both inputs to contain 3-Day briefing content. -- Weekend comparison requires both inputs to contain Weekend briefing content. +- Daily comparison requires `derived_daily_summary` and + `derived_daypart_summaries` stanzas. It also uses `alert_digest` and + `precip_timing` when present. +- 3-Day comparison requires `derived_daypart_summaries`. +- Weekend comparison requires `derived_daypart_summaries`. - Storm Report currently has no comparison implementation, so the app leaves Recent Changes empty for Storm reports. diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index a748a05..d7896f5 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -4,26 +4,24 @@ This document describes prompt input data package construction. ## Purpose -`internal/promptinput` converts a structured briefing package and optional -Recent Changes into the `data_package` JSON passed to Scriptorium prompts. +`internal/promptinput` converts report metadata, module snapshots, and optional +Recent Changes into the YAML `data_package` passed to Scriptorium prompts. ## Inputs And Outputs Inputs: -- `briefing.Package` +- report metadata +- `module.Snapshot` - optional `[]changes.Change` Outputs: - `promptinput.Package` containing schema version, RunID, report metadata, - briefing content, Recent Changes, and source warnings. Briefing content - includes configured location context, current conditions when available, - structured weather story context when available, discussion key messages, and - short/long-term AFD narratives when the Weather API provides them. + named briefing stanzas, Recent Changes, and source warnings. - report metadata includes `currentLocalDate`, the generation date formatted as `YYYY-MM-DD` in the effective report timezone. -- optional JSON file written by `promptinput.Save` +- optional YAML file written by `promptinput.Save` ## Boundaries @@ -34,7 +32,8 @@ Outputs: ## Config Fields Used None directly. Config-derived values, including timezone and prompt location -context, are already present in briefing metadata before this package runs. +context, are already present in report metadata and module stanzas before this +package runs. ## External Adapters Used @@ -42,7 +41,7 @@ None. ## State Or Manifest Behavior -`promptinput.Save` writes JSON atomically. Managed workspace paths are owned by +`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by `internal/state`. ## Skip And Resume Behavior @@ -51,9 +50,9 @@ None. Recent Changes is always present as an `items` list and may be empty. ## Failure Behavior -Validation fails before render preflight when required top-level or briefing -metadata fields are missing or inconsistent, or when no report content is -present. Save failures include filesystem operation and path context. +Validation fails before render preflight when required top-level fields are +missing or inconsistent, or when no named briefing stanzas are present. Save +failures include filesystem operation and path context. ## Tests @@ -64,6 +63,6 @@ Inspect: ## Invariants -- Scriptorium receives structured `data_package` JSON. -- Briefing metadata and top-level report metadata must agree. +- Scriptorium receives structured `data_package` YAML. +- Module stanza order is deterministic for generated snapshots. - Recent Changes are not inferred from rendered report text. diff --git a/docs/operations.md b/docs/operations.md index 03e5862..c887723 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -216,8 +216,8 @@ source provenance and warnings without dumping full weather payloads. ## Recent Changes -Recent Changes are computed from structured briefing snapshots, not rendered -Markdown text. +Recent Changes are computed from structured module snapshots, not rendered +Markdown or YAML text. Daily Today and Daily Tomorrow can compare with each other when they cover the same valid local date. 3-Day Outlook compares with prior compatible 3-Day diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index e0cac3f..9fabfe1 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -3,7 +3,7 @@ This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the project’s shape, boundaries, and invariants as the code evolves. ## weatherreporter -`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific briefing packages, compares those packages against prior snapshots, and invokes an external prompt runner to produce human-facing reports. +`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports. The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated briefing packages rather than raw unbounded source payloads wherever practical. diff --git a/internal/app/app.go b/internal/app/app.go index ea2f266..052ffde 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -530,7 +530,7 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro return nil, err } - recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) + recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange) if err != nil { return nil, err } @@ -988,11 +988,11 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { return state.NewFilesystemStore(cfg.Workspace) } -func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { +func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, reportID report.ID, current module.Snapshot, cfg config.RecentChangeConfig) ([]changes.Change, error) { if priorSnapshot == nil { return nil, nil } - previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath) + previous, err := store.LoadModuleSnapshot(ctx, priorSnapshot.ModuleSnapshotPath) if err != nil { return nil, err } @@ -1002,7 +1002,7 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state. WindGustMilesPerHour: cfg.WindGustMilesPerHour, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, } - switch current.Metadata.ReportID { + switch reportID { case report.DailyToday, report.DailyTomorrow: return changes.CompareDaily(previous, current, thresholds) case report.ThreeDay: diff --git a/internal/app/app_test.go b/internal/app/app_test.go index da2eb96..edd7d01 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -20,6 +20,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestFetchAndSaveBundle(t *testing.T) { @@ -655,25 +656,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorDailyBriefing(priorResolved) - priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) - if err != nil { - t.Fatalf("SaveBriefing() error = %v", err) - } - priorPaths, err := store.Paths(priorResolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ - Briefing: priorBriefingPath, - ModuleSnapshot: priorPaths.ModuleSnapshot, - Metadata: priorPaths.Metadata, - DataPackage: priorPaths.DataPackage, - Preflight: priorPaths.Preflight, - RenderedReport: priorPaths.RenderedReport, - })) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } + savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved)) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, @@ -771,25 +754,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorDailyBriefing(priorResolved) - priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) - if err != nil { - t.Fatalf("SaveBriefing() error = %v", err) - } - priorPaths, err := store.Paths(priorResolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ - Briefing: priorBriefingPath, - ModuleSnapshot: priorPaths.ModuleSnapshot, - Metadata: priorPaths.Metadata, - DataPackage: priorPaths.DataPackage, - Preflight: priorPaths.Preflight, - RenderedReport: priorPaths.RenderedReport, - })) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } + savePriorRun(t, store, priorResolved, priorBriefing, priorDailyModuleSnapshot(t, priorResolved)) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, @@ -839,25 +804,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorThreeDayBriefing(priorResolved) - priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) - if err != nil { - t.Fatalf("SaveBriefing() error = %v", err) - } - priorPaths, err := store.Paths(priorResolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ - Briefing: priorBriefingPath, - ModuleSnapshot: priorPaths.ModuleSnapshot, - Metadata: priorPaths.Metadata, - DataPackage: priorPaths.DataPackage, - Preflight: priorPaths.Preflight, - RenderedReport: priorPaths.RenderedReport, - })) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } + savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30")) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportThreeDay, @@ -916,25 +863,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) { t.Fatalf("ResolveGenerate(prior) error = %v", err) } priorBriefing := priorWeekendBriefing(priorResolved) - priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing) - if err != nil { - t.Fatalf("SaveBriefing() error = %v", err) - } - priorPaths, err := store.Paths(priorResolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{ - Briefing: priorBriefingPath, - ModuleSnapshot: priorPaths.ModuleSnapshot, - Metadata: priorPaths.Metadata, - DataPackage: priorPaths.DataPackage, - Preflight: priorPaths.Preflight, - RenderedReport: priorPaths.RenderedReport, - })) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } + savePriorRun(t, store, priorResolved, priorBriefing, priorOutlookModuleSnapshot(t, "2026-05-30")) currentResolved, err := ResolveGenerate(GenerateRequest{ Config: cfg, Report: ReportWeekend, @@ -1475,6 +1404,82 @@ func priorDailyBriefing(resolved report.Resolved) briefing.Package { } } +func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, briefingPackage briefing.Package, snapshot module.Snapshot) { + t.Helper() + briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage) + if err != nil { + t.Fatalf("SaveBriefing() error = %v", err) + } + moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot) + if err != nil { + t.Fatalf("SaveModuleSnapshot() error = %v", err) + } + paths, err := store.Paths(resolved) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + _, err = store.SaveMetadata(context.Background(), state.BuildMetadata(resolved, briefingPackage, state.ArtifactPaths{ + Briefing: briefingPath, + ModuleSnapshot: moduleSnapshotPath, + Metadata: paths.Metadata, + DataPackage: paths.DataPackage, + Preflight: paths.Preflight, + RenderedReport: paths.RenderedReport, + })) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } +} + +func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot { + t.Helper() + low := 50 + high := 58 + precip := 10 + snapshot, err := module.NewSnapshot([]module.Output{ + {ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{ + "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), + "low_temp_f": low, + "high_temp_f": high, + "max_pop_percent": precip, + }}, + {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ + "morning": map[string]any{ + "period": timeutil.Period{Start: resolved.ValidPeriod.Start.Add(6 * time.Hour), End: resolved.ValidPeriod.Start.Add(10 * time.Hour)}, + "temp_range_f": "50-58", + }, + }}, + {ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{ + "max_pop_percent": precip, + "max_pop_time": "6 AM", + }}, + {ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}}, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + return snapshot +} + +func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot { + t.Helper() + precip := 10 + snapshot, err := module.NewSnapshot([]module.Output{ + {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ + date + "_morning": map[string]any{ + "period": timeutil.Period{Start: mustParse(date + "T06:00:00Z"), End: mustParse(date + "T10:00:00Z")}, + "temp_range_f": "50-58", + "max_pop_percent": precip, + "max_pop_time": "6 AM", + }, + }}, + }) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) + } + return snapshot +} + func priorThreeDayBriefing(resolved report.Resolved) briefing.Package { high := 58.0 precip := 10.0 diff --git a/internal/changes/daily.go b/internal/changes/daily.go index 7ba3843..897c4d7 100644 --- a/internal/changes/daily.go +++ b/internal/changes/daily.go @@ -1,14 +1,16 @@ -// Package changes compares structured briefing snapshots. +// Package changes compares structured module snapshots. package changes import ( "fmt" "math" "sort" + "strconv" + "strings" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type Thresholds struct { @@ -25,83 +27,168 @@ type Change struct { Current string `json:"current,omitempty"` } -func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { - if previous.Daily == nil { - return nil, fmt.Errorf("previous daily briefing is required") +func CompareDaily(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) { + previousSummary, err := requiredStanza[dailySummaryStanza](previous, "derived_daily_summary") + if err != nil { + return nil, fmt.Errorf("previous daily summary: %w", err) } - if current.Daily == nil { - return nil, fmt.Errorf("current daily briefing is required") + currentSummary, err := requiredStanza[dailySummaryStanza](current, "derived_daily_summary") + if err != nil { + return nil, fmt.Errorf("current daily summary: %w", err) } + previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries") + if err != nil { + return nil, fmt.Errorf("previous daypart summaries: %w", err) + } + currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries") + if err != nil { + return nil, fmt.Errorf("current daypart summaries: %w", err) + } + previousAlerts, _, err := module.StanzaValue[alertDigestStanza](previous, "alert_digest") + if err != nil { + return nil, err + } + currentAlerts, _, err := module.StanzaValue[alertDigestStanza](current, "alert_digest") + if err != nil { + return nil, err + } + previousTiming, previousHasTiming, err := module.StanzaValue[precipTimingStanza](previous, "precip_timing") + if err != nil { + return nil, err + } + currentTiming, currentHasTiming, err := module.StanzaValue[precipTimingStanza](current, "precip_timing") + if err != nil { + return nil, err + } + var changes []Change - changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...) - changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...) - changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...) - changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...) - changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...) + changes = append(changes, compareTemperatureValues("Low", previousSummary.LowTempF, currentSummary.LowTempF, thresholds.TemperatureDegrees)...) + changes = append(changes, compareTemperatureValues("High", previousSummary.HighTempF, currentSummary.HighTempF, thresholds.TemperatureDegrees)...) + changes = append(changes, comparePrecipitationValues(previousSummary.MaxPopPercent, currentSummary.MaxPopPercent, thresholds.PrecipProbabilityPoints, "")...) + if previousHasTiming && currentHasTiming { + changes = append(changes, comparePrecipTiming(previousTiming.MaxPopTime, currentTiming.MaxPopTime, thresholds.PrecipTimingShiftMinutes, "")...) + } + changes = append(changes, compareWindValues(previousSummary.MaxWindGustMph, currentSummary.MaxWindGustMph, thresholds.WindGustMilesPerHour, "")...) + changes = append(changes, compareAlerts(previousAlerts.Relevant, currentAlerts.Relevant)...) + changes = append(changes, compareIndicators(aggregateIndicators(previousDayparts), aggregateIndicators(currentDayparts), "")...) + sortChanges(changes) return changes, nil } -func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change { - var changes []Change - if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) { - changes = append(changes, Change{ - Type: "temperature_shift", - Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min), - Previous: fmt.Sprintf("%.0f", *previous.Min), - Current: fmt.Sprintf("%.0f", *current.Min), - }) - } - if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) { - changes = append(changes, Change{ - Type: "temperature_shift", - Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max), - Previous: fmt.Sprintf("%.0f", *previous.Max), - Current: fmt.Sprintf("%.0f", *current.Max), - }) - } - return changes +type dailySummaryStanza 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"` + MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"` } -func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change { +type daypartSummaryStanza struct { + Period timeutil.Period `json:"period"` + TempRangeF string `json:"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"` + Snow bool `json:"snow,omitempty"` + Ice bool `json:"ice,omitempty"` +} + +type precipTimingStanza struct { + MaxPopPercent *int `json:"max_pop_percent,omitempty"` + MaxPopTime string `json:"max_pop_time,omitempty"` +} + +type alertDigestStanza struct { + Relevant []alertSummaryStanza `json:"relevant,omitempty"` +} + +type alertSummaryStanza struct { + Event string `json:"event,omitempty"` + Headline string `json:"headline,omitempty"` +} + +type indicators struct { + Snow bool + Ice bool +} + +func requiredStanza[T any](snapshot module.Snapshot, name string) (T, error) { + value, ok, err := module.StanzaValue[T](snapshot, name) + if err != nil { + return value, err + } + if !ok { + return value, fmt.Errorf("stanza %q is required", name) + } + return value, nil +} + +func compareTemperatureValues(label string, previous *int, current *int, threshold float64) []Change { if previous == nil || current == nil { return nil } - var changes []Change - previousCategory := precipitationCategory(previous.Value) - currentCategory := precipitationCategory(current.Value) - if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) { - changes = append(changes, Change{ - Type: "precip_probability_change", - Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory), - Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory), - Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory), - }) - } - shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes())) - if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes { - changes = append(changes, Change{ - Type: "precip_timing_shift", - Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)), - Previous: clock(previous.Time), - Current: clock(current.Time), - }) - } - return changes -} - -func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change { - if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) { + if !differenceAtLeast(float64(*previous), float64(*current), threshold) { return nil } return []Change{{ - Type: "wind_gust_change", - Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value), - Previous: fmt.Sprintf("%.0f mph", previous.Value), - Current: fmt.Sprintf("%.0f mph", current.Value), + Type: "temperature_shift", + Message: fmt.Sprintf("%s temperature changed from %d to %d.", label, *previous, *current), + Previous: fmt.Sprintf("%d", *previous), + Current: fmt.Sprintf("%d", *current), }} } -func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change { +func comparePrecipitationValues(previous *int, current *int, threshold int, prefix string) []Change { + if previous == nil || current == nil { + return nil + } + previousCategory := precipitationCategory(float64(*previous)) + currentCategory := precipitationCategory(float64(*current)) + if previousCategory == currentCategory && !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) { + return nil + } + changeType := prefix + "precip_probability_change" + return []Change{{ + Type: changeType, + Message: fmt.Sprintf("Peak precipitation chance changed from %d%% (%s) to %d%% (%s).", *previous, previousCategory, *current, currentCategory), + Previous: fmt.Sprintf("%d%% %s", *previous, previousCategory), + Current: fmt.Sprintf("%d%% %s", *current, currentCategory), + }} +} + +func comparePrecipTiming(previous string, current string, thresholdMinutes int, prefix string) []Change { + if thresholdMinutes <= 0 || previous == "" || current == "" || previous == current { + return nil + } + previousTime, previousOK := parseClock(previous) + currentTime, currentOK := parseClock(current) + if !previousOK || !currentOK { + return nil + } + if int(math.Abs(currentTime.Sub(previousTime).Minutes())) < thresholdMinutes { + return nil + } + return []Change{{ + Type: prefix + "precip_timing_shift", + Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", previous, current), + Previous: previous, + Current: current, + }} +} + +func compareWindValues(previous *int, current *int, threshold int, prefix string) []Change { + if previous == nil || current == nil || !differenceAtLeast(float64(*previous), float64(*current), float64(threshold)) { + return nil + } + return []Change{{ + Type: prefix + "wind_gust_change", + Message: fmt.Sprintf("Peak wind gust changed from %d mph to %d mph.", *previous, *current), + Previous: fmt.Sprintf("%d mph", *previous), + Current: fmt.Sprintf("%d mph", *current), + }} +} + +func compareAlerts(previous []alertSummaryStanza, current []alertSummaryStanza) []Change { previousSet := alertSet(previous) currentSet := alertSet(current) var changes []Change @@ -119,7 +206,7 @@ func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOve return changes } -func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change { +func compareIndicators(previous indicators, current indicators, prefix string) []Change { var changes []Change for _, item := range []struct { name string @@ -132,7 +219,7 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators if item.previous == item.current { continue } - changeType := item.name + "_risk_change" + changeType := prefix + item.name + "_risk_change" if item.current { changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"}) } else { @@ -142,16 +229,16 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators return changes } -func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators { - out := forecast.Indicators{} +func aggregateIndicators(dayparts map[string]daypartSummaryStanza) indicators { + out := indicators{} for _, daypart := range dayparts { - out.Snow = out.Snow || daypart.Indicators.Snow - out.Ice = out.Ice || daypart.Indicators.Ice + out.Snow = out.Snow || daypart.Snow + out.Ice = out.Ice || daypart.Ice } return out } -func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} { +func alertSet(alerts []alertSummaryStanza) map[string]struct{} { out := map[string]struct{}{} for _, alert := range alerts { event := alert.Event @@ -185,10 +272,6 @@ func differenceAtLeast(previous float64, current float64, threshold float64) boo return math.Abs(current-previous) >= threshold } -func clock(t time.Time) string { - return t.Format("15:04") -} - func sortChanges(items []Change) { sort.SliceStable(items, func(i, j int) bool { if items[i].Type == items[j].Type { @@ -197,3 +280,33 @@ func sortChanges(items []Change) { return items[i].Type < items[j].Type }) } + +func parseClock(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + for _, layout := range []string{"3 PM", "3:04 PM", "15:04"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func parseTempRange(value string) (*int, *int) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + parts := strings.Split(value, "-") + if len(parts) == 1 { + if parsed, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + return &parsed, &parsed + } + return nil, nil + } + minValue, minErr := strconv.Atoi(strings.TrimSpace(parts[0])) + maxValue, maxErr := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1])) + if minErr != nil || maxErr != nil { + return nil, nil + } + return &minValue, &maxValue +} diff --git a/internal/changes/daily_test.go b/internal/changes/daily_test.go index 89585ad..5cab971 100644 --- a/internal/changes/daily_test.go +++ b/internal/changes/daily_test.go @@ -1,16 +1,17 @@ package changes import ( + "strings" "testing" "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func TestCompareDailyNoMeaningfulChanges(t *testing.T) { - previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) - current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{}) + previous := dailySnapshot(t, 60, 70, 30, "8 AM", nil, false) + current := dailySnapshot(t, 61, 71, 35, "8:30 AM", nil, false) changes, err := CompareDaily(previous, current, testThresholds()) if err != nil { @@ -22,8 +23,8 @@ func TestCompareDailyNoMeaningfulChanges(t *testing.T) { } func TestCompareDailyTemperatureThreshold(t *testing.T) { - previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) - current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) + previous := dailySnapshot(t, 50, 70, 10, "8 AM", nil, false) + current := dailySnapshot(t, 58, 79, 10, "8 AM", nil, false) changes, err := CompareDaily(previous, current, testThresholds()) if err != nil { @@ -35,8 +36,8 @@ func TestCompareDailyTemperatureThreshold(t *testing.T) { } func TestCompareDailyPrecipTimingShift(t *testing.T) { - previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) - current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{}) + previous := dailySnapshot(t, 60, 70, 60, "8 AM", nil, false) + current := dailySnapshot(t, 60, 70, 60, "11 AM", nil, false) changes, err := CompareDaily(previous, current, testThresholds()) if err != nil { @@ -48,8 +49,8 @@ func TestCompareDailyPrecipTimingShift(t *testing.T) { } func TestCompareDailyAlertAddedAndRemoved(t *testing.T) { - previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{}) - current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{}) + previous := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Wind Advisory"}, false) + current := dailySnapshot(t, 60, 70, 10, "8 AM", []string{"Flood Watch"}, false) changes, err := CompareDaily(previous, current, testThresholds()) if err != nil { @@ -61,8 +62,8 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) { } func TestCompareDailyIndicatorChange(t *testing.T) { - previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{}) - current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true}) + previous := dailySnapshot(t, 60, 70, 10, "8 AM", nil, false) + current := dailySnapshot(t, 60, 70, 10, "8 AM", nil, true) changes, err := CompareDaily(previous, current, testThresholds()) if err != nil { @@ -73,26 +74,48 @@ func TestCompareDailyIndicatorChange(t *testing.T) { } } -func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package { - alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts)) +func TestCompareDailyRequiresComparisonStanzas(t *testing.T) { + _, err := CompareDaily(snapshot(t), dailySnapshot(t, 60, 70, 10, "8 AM", nil, false), testThresholds()) + if err == nil { + t.Fatal("CompareDaily() error = nil, want missing stanza error") + } + if !strings.Contains(err.Error(), "derived_daily_summary") { + t.Fatalf("error = %q, want derived_daily_summary context", err.Error()) + } +} + +func dailySnapshot(t *testing.T, low int, high int, precip int, precipTime string, alerts []string, snow bool) module.Snapshot { + t.Helper() + relevant := make([]alertSummaryStanza, 0, len(alerts)) for _, alert := range alerts { - alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert}) + relevant = append(relevant, alertSummaryStanza{Event: alert}) } - return briefing.Package{ - Daily: &briefing.Daily{ - BottomLine: briefing.BottomLine{ - Temperature: forecast.Range{Min: &low, Max: &high}, - MaxPrecipProbability: &forecast.TimedValue{ - Value: precip, - Time: precipTime, - }, - }, - RelevantAlerts: alertOverlaps, - Dayparts: []forecast.DaypartSummary{ - {Name: "morning", Indicators: indicators}, - }, - }, + return snapshot(t, + module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: dailySummaryStanza{ + Date: "2026-05-29", + HighTempF: &high, + LowTempF: &low, + MaxPopPercent: &precip, + }}, + module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ + "morning": {Period: period("2026-05-29T06:00:00Z", "2026-05-29T10:00:00Z"), TempRangeF: "60-70", Snow: snow}, + }}, + module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: precipTimingStanza{MaxPopPercent: &precip, MaxPopTime: precipTime}}, + module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: alertDigestStanza{Relevant: relevant}}, + ) +} + +func snapshot(t *testing.T, outputs ...module.Output) module.Snapshot { + t.Helper() + snapshot, err := module.NewSnapshot(outputs) + if err != nil { + t.Fatalf("NewSnapshot() error = %v", err) } + return snapshot +} + +func period(start string, end string) timeutil.Period { + return timeutil.Period{Start: at(start), End: at(end)} } func testThresholds() Thresholds { diff --git a/internal/changes/three_day.go b/internal/changes/three_day.go index f527c86..f5901f3 100644 --- a/internal/changes/three_day.go +++ b/internal/changes/three_day.go @@ -2,77 +2,147 @@ package changes import ( "fmt" - "time" + "sort" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) -func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { - if previous.ThreeDay == nil { - return nil, fmt.Errorf("previous 3-day briefing is required") +func CompareThreeDay(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) { + previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries") + if err != nil { + return nil, fmt.Errorf("previous daypart summaries: %w", err) } - if current.ThreeDay == nil { - return nil, fmt.Errorf("current 3-day briefing is required") + currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries") + if err != nil { + return nil, fmt.Errorf("current daypart summaries: %w", err) } - previousDays := outlookDaysByDate(previous.ThreeDay.Days) - currentDays := outlookDaysByDate(current.ThreeDay.Days) + previousDays := outlookDaysFromDayparts(previousDayparts) + currentDays := outlookDaysFromDayparts(currentDayparts) + return compareOutlookDays(previousDays, currentDays, thresholds, "") +} + +type outlookDay struct { + Date string + LowTempF *int + HighTempF *int + MaxPopPercent *int + MaxPopTime string + MaxWindGustMph *int + Indicators indicators +} + +func compareOutlookDays(previousDays map[string]outlookDay, currentDays map[string]outlookDay, thresholds Thresholds, prefix string) ([]Change, error) { var changes []Change for date, currentDay := range currentDays { previousDay, ok := previousDays[date] if !ok { - changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date}) + changes = append(changes, Change{Type: prefix + "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date}) continue } - changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...) + changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds, prefix)...) } for date := range previousDays { if _, ok := currentDays[date]; !ok { - changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date}) + changes = append(changes, Change{Type: prefix + "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date}) } } sortChanges(changes) return changes, nil } -func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change { +func compareOutlookDay(date string, previous outlookDay, current outlookDay, thresholds Thresholds, prefix string) []Change { var changes []Change - for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) { + for _, change := range compareTemperatureValues("Low", previous.LowTempF, current.LowTempF, thresholds.TemperatureDegrees) { + change.Message = date + ": " + change.Message + change.Type = prefix + "outlook_" + change.Type + changes = append(changes, change) + } + for _, change := range compareTemperatureValues("High", previous.HighTempF, current.HighTempF, thresholds.TemperatureDegrees) { + change.Message = date + ": " + change.Message + change.Type = prefix + "outlook_" + change.Type + changes = append(changes, change) + } + for _, change := range comparePrecipitationValues(previous.MaxPopPercent, current.MaxPopPercent, thresholds.PrecipProbabilityPoints, prefix+"outlook_") { change.Message = date + ": " + change.Message changes = append(changes, change) } - for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) { + for _, change := range comparePrecipTiming(previous.MaxPopTime, current.MaxPopTime, thresholds.PrecipTimingShiftMinutes, prefix+"outlook_") { change.Message = date + ": " + change.Message changes = append(changes, change) - changes[len(changes)-1].Type = "outlook_" + change.Type } - for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) { + for _, change := range compareWindValues(previous.MaxWindGustMph, current.MaxWindGustMph, thresholds.WindGustMilesPerHour, prefix+"outlook_") { change.Message = date + ": " + change.Message - change.Type = "outlook_" + change.Type changes = append(changes, change) } - for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) { + for _, change := range compareIndicators(previous.Indicators, current.Indicators, prefix+"outlook_") { change.Message = date + ": " + change.Message - change.Type = "outlook_" + change.Type - changes = append(changes, change) - } - for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) { - change.Message = date + ": " + change.Message - change.Type = "outlook_" + change.Type changes = append(changes, change) } return changes } -func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay { - out := map[string]briefing.OutlookDay{} - for _, day := range days { - date := day.Date - if date == "" && !day.Period.Start.IsZero() { - date = day.Period.Start.Format(time.DateOnly) +func outlookDaysFromDayparts(dayparts map[string]daypartSummaryStanza) map[string]outlookDay { + out := map[string]outlookDay{} + var keys []string + for key := range dayparts { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + daypart := dayparts[key] + date := daypartDate(daypart) + if date == "" { + continue } - if date != "" { - out[date] = day + day := out[date] + day.Date = date + low, high := parseTempRange(daypart.TempRangeF) + day.LowTempF = minInt(day.LowTempF, low) + day.HighTempF = maxInt(day.HighTempF, high) + day.MaxPopPercent = maxInt(day.MaxPopPercent, daypart.MaxPopPercent) + if daypart.MaxPopPercent != nil && day.MaxPopPercent != nil && *daypart.MaxPopPercent == *day.MaxPopPercent { + day.MaxPopTime = daypart.MaxPopTime } + day.MaxWindGustMph = maxInt(day.MaxWindGustMph, daypart.MaxWindGustMph) + day.Indicators.Snow = day.Indicators.Snow || daypart.Snow + day.Indicators.Ice = day.Indicators.Ice || daypart.Ice + out[date] = day } return out } + +func daypartDate(daypart daypartSummaryStanza) string { + if !daypart.Period.Start.IsZero() { + return daypart.Period.Start.Format(timeutil.DateLayout) + } + return "" +} + +func minInt(a *int, b *int) *int { + if a == nil { + return copyInt(b) + } + if b != nil && *b < *a { + return copyInt(b) + } + return a +} + +func maxInt(a *int, b *int) *int { + if a == nil { + return copyInt(b) + } + if b != nil && *b > *a { + return copyInt(b) + } + return a +} + +func copyInt(value *int) *int { + if value == nil { + return nil + } + copied := *value + return &copied +} diff --git a/internal/changes/three_day_test.go b/internal/changes/three_day_test.go index 88eacef..ce31d1a 100644 --- a/internal/changes/three_day_test.go +++ b/internal/changes/three_day_test.go @@ -2,41 +2,13 @@ package changes import ( "testing" - "time" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" ) func TestCompareThreeDayDetectsDayChanges(t *testing.T) { - previousTemp := 70.0 - currentTemp := 78.0 - previousPrecip := 20.0 - currentPrecip := 70.0 - previous := briefing.Package{ - Metadata: briefing.Metadata{ReportID: report.ThreeDay}, - ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{ - Date: "2026-05-29", - Temperature: forecast.Range{Max: &previousTemp}, - MaxPrecipitationProbability: &forecast.TimedValue{ - Value: previousPrecip, - Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC), - }, - }}}, - } - current := briefing.Package{ - Metadata: briefing.Metadata{ReportID: report.ThreeDay}, - ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{ - Date: "2026-05-29", - Temperature: forecast.Range{Max: ¤tTemp}, - MaxPrecipitationProbability: &forecast.TimedValue{ - Value: currentPrecip, - Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), - }, - Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}}, - }}}, - } + previous := outlookSnapshot(t, "2026-05-29", "70", 20, "9 AM", false) + current := outlookSnapshot(t, "2026-05-29", "78", 70, "12 PM", true) changes, err := CompareThreeDay(previous, current, Thresholds{ TemperatureDegrees: 5, @@ -50,17 +22,20 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) { if len(changes) == 0 { t.Fatal("changes length = 0, want detected 3-day changes") } - var foundPrecip bool - var foundSnow bool - for _, change := range changes { - if change.Type == "outlook_precip_probability_change" { - foundPrecip = true - } - if change.Type == "outlook_snow_risk_change" { - foundSnow = true - } - } - if !foundPrecip || !foundSnow { + if countType(changes, "outlook_precip_probability_change") == 0 || countType(changes, "outlook_snow_risk_change") == 0 { t.Fatalf("changes = %#v, want precipitation and snow changes", changes) } } + +func outlookSnapshot(t *testing.T, date string, tempRange string, precip int, precipTime string, snow bool) module.Snapshot { + t.Helper() + return snapshot(t, module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]daypartSummaryStanza{ + date + "_morning": { + Period: period(date+"T06:00:00Z", date+"T10:00:00Z"), + TempRangeF: tempRange, + MaxPopPercent: &precip, + MaxPopTime: precipTime, + Snow: snow, + }, + }}) +} diff --git a/internal/changes/weekend.go b/internal/changes/weekend.go index 6082219..df45f05 100644 --- a/internal/changes/weekend.go +++ b/internal/changes/weekend.go @@ -3,24 +3,17 @@ package changes import ( "fmt" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/module" ) -func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) { - if previous.Weekend == nil { - return nil, fmt.Errorf("previous weekend briefing is required") - } - if current.Weekend == nil { - return nil, fmt.Errorf("current weekend briefing is required") - } - previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}} - currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}} - changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds) +func CompareWeekend(previous module.Snapshot, current module.Snapshot, thresholds Thresholds) ([]Change, error) { + previousDayparts, err := requiredStanza[map[string]daypartSummaryStanza](previous, "derived_daypart_summaries") if err != nil { - return nil, err + return nil, fmt.Errorf("previous weekend daypart summaries: %w", err) } - for i := range changes { - changes[i].Type = "weekend_" + changes[i].Type + currentDayparts, err := requiredStanza[map[string]daypartSummaryStanza](current, "derived_daypart_summaries") + if err != nil { + return nil, fmt.Errorf("current weekend daypart summaries: %w", err) } - return changes, nil + return compareOutlookDays(outlookDaysFromDayparts(previousDayparts), outlookDaysFromDayparts(currentDayparts), thresholds, "weekend_") } diff --git a/internal/changes/weekend_test.go b/internal/changes/weekend_test.go index 3a2977f..78ba784 100644 --- a/internal/changes/weekend_test.go +++ b/internal/changes/weekend_test.go @@ -1,31 +1,10 @@ package changes -import ( - "testing" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" -) +import "testing" func TestCompareWeekendDetectsOutlookChanges(t *testing.T) { - previousTemp := 70.0 - currentTemp := 78.0 - previous := briefing.Package{ - Metadata: briefing.Metadata{ReportID: report.Weekend}, - Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{ - Date: "2026-05-30", - Temperature: forecast.Range{Max: &previousTemp}, - }}}, - } - current := briefing.Package{ - Metadata: briefing.Metadata{ReportID: report.Weekend}, - Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{ - Date: "2026-05-30", - Temperature: forecast.Range{Max: ¤tTemp}, - Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}}, - }}}, - } + previous := outlookSnapshot(t, "2026-05-30", "70", 10, "9 AM", false) + current := outlookSnapshot(t, "2026-05-30", "78", 10, "9 AM", true) changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5}) if err != nil { @@ -34,10 +13,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) { if len(changes) == 0 { t.Fatal("changes length = 0, want weekend changes") } - for _, change := range changes { - if change.Type == "weekend_outlook_snow_risk_change" { - return - } + if countType(changes, "weekend_outlook_snow_risk_change") == 0 { + t.Fatalf("changes = %#v, want snow risk change", changes) } - t.Fatalf("changes = %#v, want snow risk change", changes) } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index fc1be21..b6a25af 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -252,8 +252,9 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt) }) return &PriorSnapshot{ - Metadata: candidates[0], - BriefingPath: candidates[0].BriefingPath, + Metadata: candidates[0], + BriefingPath: candidates[0].BriefingPath, + ModuleSnapshotPath: candidates[0].ModuleSnapshotPath, }, nil } diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 1d9a82a..fba7b4f 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -268,6 +268,9 @@ func TestFindPriorSnapshot(t *testing.T) { if prior.BriefingPath != briefingPath { t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath) } + if prior.ModuleSnapshotPath != paths.ModuleSnapshot { + t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot) + } } func TestFindPriorSnapshotUsesValidDate(t *testing.T) { diff --git a/internal/state/store.go b/internal/state/store.go index c7f7a3d..91c74e0 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -27,8 +27,9 @@ type Store interface { } type PriorSnapshot struct { - Metadata Metadata - BriefingPath string + Metadata Metadata + BriefingPath string + ModuleSnapshotPath string } type PreflightArtifact struct {