diff --git a/docs/internal/changes.md b/docs/internal/changes.md new file mode 100644 index 0000000..f1f18e5 --- /dev/null +++ b/docs/internal/changes.md @@ -0,0 +1,66 @@ +# Changes Internals + +This document describes the implemented structured change comparison boundary. + +## Purpose + +`internal/changes` compares current and prior structured briefing snapshots and +produces compact change records for prompt input data packages. + +## Inputs and Outputs + +Inputs: + +- prior Daily briefing package +- current Daily briefing package +- configured Recent Changes thresholds + +Output: + +- ordered `changes.Change` records with type, message, previous value, and + current value where useful + +## Boundaries + +- This package compares structured briefing data only. +- It does not read state directly, render Markdown, invoke `scriptorium`, or + compare generated report text. + +## Config Fields Used + +The app maps these config fields into comparison thresholds: + +- `recent_change.temperature_degrees` +- `recent_change.precip_probability_points` +- `recent_change.wind_gust_miles_per_hour` +- `recent_change.precip_timing_shift_minutes` + +## Behavior + +Daily comparison currently detects: + +- temperature changes crossing configured thresholds +- precipitation probability and timing changes +- alert additions and removals +- peak wind gust changes +- snow, ice, and thunder risk changes + +When no prior comparable snapshot exists, the app sends an empty Recent Changes +section in the data package. + +## Failure Behavior + +Daily comparison requires both inputs to contain Daily briefing content. + +## Tests + +Inspect: + +- `internal/changes/daily_test.go` +- `internal/app/app_test.go` + +## Invariants + +- Recent Changes are based on structured snapshots, not Markdown report text. +- Comparison thresholds come from configuration. +- The comparison output remains compact enough for prompt input. diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index c05b99e..c349ad0 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -16,7 +16,7 @@ Input: Output: - `promptinput.Package` JSON with report metadata, briefing content, source - warnings, RunID, and an empty Recent Changes section. + warnings, RunID, and a Recent Changes section. ## Boundaries @@ -30,8 +30,8 @@ Output: - `promptinput.Validate` rejects missing or inconsistent required fields before render preflight. - `promptinput.Save` writes JSON atomically where practical. -- Recent Changes is present as an empty `items` list until structured comparison - is implemented. +- Recent Changes is present as an `items` list. It is empty when no prior + comparable snapshot exists or no meaningful changes are detected. ## Failure Behavior @@ -43,6 +43,7 @@ the filesystem operation and path context. Inspect: - `internal/promptinput/package_test.go` +- `internal/changes/daily_test.go` - `internal/app/app_test.go` ## Invariants diff --git a/docs/internal/state.md b/docs/internal/state.md index dbb2c47..d4e5e1a 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -26,6 +26,7 @@ Outputs: - Markdown report path - metadata JSON - prior Daily snapshot metadata when available +- prior Daily briefing package when loaded by path ## Boundaries @@ -49,7 +50,8 @@ Workspace subdirectories must be relative paths that stay under Managed artifact names use RunID, which is generated from report generation time and report ID. Daily metadata is stored beside Daily briefing snapshots by valid local date. Prior Daily snapshot lookup reads metadata for the same valid local -date and returns the latest earlier run. The store prepares the managed Markdown +date and returns the latest earlier run. The store can load a briefing snapshot +by path for structured comparison. The store prepares the managed Markdown report path before `scriptorium run` writes it. ## Failure Behavior diff --git a/docs/operations.md b/docs/operations.md index d1d54da..c9297cc 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -63,6 +63,15 @@ Each Daily generation writes metadata that links: - preflight output path - rendered report path +## Recent Changes + +When a prior comparable Daily briefing snapshot exists for the same valid local +date, the app compares structured briefing data before writing the prompt input +data package. Meaningful changes are included under `recentChanges.items`. + +When no prior comparable snapshot exists, or no configured threshold is crossed, +the Recent Changes list is empty. + ## Recovery If render preflight exits nonzero after producing a result, the captured stdout, diff --git a/internal/app/app.go b/internal/app/app.go index 0c1e6ba..7872e2f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" @@ -85,6 +86,7 @@ type DailyReportResult struct { Metadata state.Metadata MetadataPath string PriorSnapshot *state.PriorSnapshot + RecentChanges []changes.Change RenderResult *scriptorium.RenderResult RunResult *scriptorium.RunResult } @@ -267,7 +269,11 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep return nil, err } - dataPackage, err := promptinput.Build(briefingPackage) + recentChanges, err := dailyRecentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) + if err != nil { + return nil, err + } + dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges) if err != nil { return nil, err } @@ -352,6 +358,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep Metadata: metadata, MetadataPath: metadataPath, PriorSnapshot: priorSnapshot, + RecentChanges: recentChanges, RenderResult: renderResult, RunResult: runResult, }, nil @@ -386,6 +393,22 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { return state.NewFilesystemStore(cfg.Workspace) } +func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { + if priorSnapshot == nil { + return nil, nil + } + previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath) + if err != nil { + return nil, err + } + return changes.CompareDaily(previous, current, changes.Thresholds{ + TemperatureDegrees: cfg.TemperatureDegrees, + PrecipProbabilityPoints: cfg.PrecipProbabilityPoints, + WindGustMilesPerHour: cfg.WindGustMilesPerHour, + PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, + }) +} + func copyFileAtomic(source string, target string) error { data, err := os.ReadFile(source) if err != nil { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 612fad5..87e27e5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -12,7 +12,9 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/state" ) @@ -222,6 +224,9 @@ func TestGenerateDailyReportWritesReportAndPreflight(t *testing.T) { if result.Metadata.RenderedReportPath != result.ReportPath { t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath) } + if len(result.RecentChanges) != 0 { + t.Fatalf("RecentChanges = %#v, want none without prior snapshot", result.RecentChanges) + } report, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read report output: %v", err) @@ -337,6 +342,78 @@ func TestGenerateDailyReportReturnsRunErrorAfterPreflight(t *testing.T) { } } +func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { + server := dailyBundleServer(t) + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + cfg.WeatherAPI.Timezone = "America/Chicago" + cfg.Workspace.Root = t.TempDir() + store, err := state.NewFilesystemStore(cfg.Workspace) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + priorResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T04:00:00-05:00")) + if err != nil { + 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, + DataPackage: priorPaths.DataPackage, + Preflight: priorPaths.Preflight, + RenderedReport: priorPaths.RenderedReport, + })) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + + currentResolved, err := ResolveGenerate(GenerateRequest{ + Config: cfg, + Report: ReportDaily, + Date: mustParse("2026-05-29T12:00:00-05:00"), + }, mustParse("2026-05-29T05:00:00-05:00")) + if err != nil { + t.Fatalf("ResolveGenerate(current) error = %v", err) + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + runResult: &scriptorium.RunResult{ExitCode: 0}, + runBody: "# Daily Report\n", + } + + result, err := GenerateDailyReport(context.Background(), DailyReportRequest{ + Config: cfg, + Resolved: currentResolved, + Renderer: renderer, + Store: store, + }) + if err != nil { + t.Fatalf("GenerateDailyReport() error = %v", err) + } + if len(result.RecentChanges) == 0 { + t.Fatal("RecentChanges length = 0, want changes from prior snapshot") + } + data, err := os.ReadFile(result.DataPackagePath) + if err != nil { + t.Fatalf("read data package: %v", err) + } + if !strings.Contains(string(data), "alert_added") || !strings.Contains(string(data), "temperature_shift") { + t.Fatalf("data package missing recent changes:\n%s", string(data)) + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" @@ -435,6 +512,37 @@ func mustParse(value string) time.Time { return parsed } +func priorDailyBriefing(resolved report.Resolved) briefing.Package { + low := 50.0 + high := 58.0 + precip := 10.0 + return briefing.Package{ + Metadata: briefing.Metadata{ + SchemaVersion: briefing.SchemaVersion, + RunID: resolved.Metadata().RunID, + ReportID: resolved.Definition.ID, + Variant: "today", + PromptID: resolved.Definition.PromptID, + GeneratedAt: resolved.GeneratedAt, + Units: "us", + Timezone: resolved.Timezone, + ValidPeriod: resolved.ValidPeriod, + }, + Daily: &briefing.Daily{ + BottomLine: briefing.BottomLine{ + Temperature: forecast.Range{Min: &low, Max: &high}, + MaxPrecipProbability: &forecast.TimedValue{ + Value: precip, + Time: resolved.ValidPeriod.Start.Add(6 * time.Hour), + }, + }, + Dayparts: []forecast.DaypartSummary{ + {Name: "morning"}, + }, + }, + } +} + type recordingRenderer struct { renderCalls int runCalls int diff --git a/internal/changes/daily.go b/internal/changes/daily.go new file mode 100644 index 0000000..a1732da --- /dev/null +++ b/internal/changes/daily.go @@ -0,0 +1,201 @@ +// Package changes compares structured briefing snapshots. +package changes + +import ( + "fmt" + "math" + "sort" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" +) + +type Thresholds struct { + TemperatureDegrees float64 + PrecipProbabilityPoints int + WindGustMilesPerHour int + PrecipTimingShiftMinutes int +} + +type Change struct { + Type string `json:"type"` + Message string `json:"message"` + Previous string `json:"previous,omitempty"` + 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") + } + if current.Daily == nil { + return nil, fmt.Errorf("current daily briefing is required") + } + 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))...) + 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 +} + +func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []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) { + 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), + }} +} + +func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change { + previousSet := alertSet(previous) + currentSet := alertSet(current) + var changes []Change + for event := range currentSet { + if _, ok := previousSet[event]; !ok { + changes = append(changes, Change{Type: "alert_added", Message: fmt.Sprintf("Alert added: %s.", event), Current: event}) + } + } + for event := range previousSet { + if _, ok := currentSet[event]; !ok { + changes = append(changes, Change{Type: "alert_removed", Message: fmt.Sprintf("Alert removed: %s.", event), Previous: event}) + } + } + sortChanges(changes) + return changes +} + +func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change { + var changes []Change + for _, item := range []struct { + name string + previous bool + current bool + }{ + {name: "thunder", previous: previous.Thunder, current: current.Thunder}, + {name: "snow", previous: previous.Snow, current: current.Snow}, + {name: "ice", previous: previous.Ice, current: current.Ice}, + } { + if item.previous == item.current { + continue + } + changeType := 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 { + changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is no longer present.", item.name), Previous: "present"}) + } + } + return changes +} + +func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators { + out := forecast.Indicators{} + for _, daypart := range dayparts { + out.Thunder = out.Thunder || daypart.Indicators.Thunder + out.Snow = out.Snow || daypart.Indicators.Snow + out.Ice = out.Ice || daypart.Indicators.Ice + } + return out +} + +func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} { + out := map[string]struct{}{} + for _, alert := range alerts { + event := alert.Event + if event == "" { + event = alert.Headline + } + if event != "" { + out[event] = struct{}{} + } + } + return out +} + +func precipitationCategory(value float64) string { + switch { + case value >= 70: + return "high" + case value >= 50: + return "likely" + case value >= 20: + return "possible" + default: + return "low" + } +} + +func differenceAtLeast(previous float64, current float64, threshold float64) bool { + if threshold <= 0 { + return previous != current + } + 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 { + return items[i].Message < items[j].Message + } + return items[i].Type < items[j].Type + }) +} diff --git a/internal/changes/daily_test.go b/internal/changes/daily_test.go new file mode 100644 index 0000000..416c91a --- /dev/null +++ b/internal/changes/daily_test.go @@ -0,0 +1,123 @@ +package changes + +import ( + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" +) + +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{}) + + changes, err := CompareDaily(previous, current, testThresholds()) + if err != nil { + t.Fatalf("CompareDaily() error = %v", err) + } + if len(changes) != 0 { + t.Fatalf("changes = %#v, want none", changes) + } +} + +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{}) + + changes, err := CompareDaily(previous, current, testThresholds()) + if err != nil { + t.Fatalf("CompareDaily() error = %v", err) + } + if countType(changes, "temperature_shift") != 2 { + t.Fatalf("changes = %#v, want low and high temperature changes", changes) + } +} + +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{}) + + changes, err := CompareDaily(previous, current, testThresholds()) + if err != nil { + t.Fatalf("CompareDaily() error = %v", err) + } + if countType(changes, "precip_timing_shift") != 1 { + t.Fatalf("changes = %#v, want timing shift", changes) + } +} + +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{}) + + changes, err := CompareDaily(previous, current, testThresholds()) + if err != nil { + t.Fatalf("CompareDaily() error = %v", err) + } + if countType(changes, "alert_added") != 1 || countType(changes, "alert_removed") != 1 { + t.Fatalf("changes = %#v, want one alert added and one removed", changes) + } +} + +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{Thunder: true}) + + changes, err := CompareDaily(previous, current, testThresholds()) + if err != nil { + t.Fatalf("CompareDaily() error = %v", err) + } + if countType(changes, "thunder_risk_change") != 1 { + t.Fatalf("changes = %#v, want thunder risk change", changes) + } +} + +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)) + for _, alert := range alerts { + alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{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}, + }, + }, + } +} + +func testThresholds() Thresholds { + return Thresholds{ + TemperatureDegrees: 5, + PrecipProbabilityPoints: 20, + WindGustMilesPerHour: 10, + PrecipTimingShiftMinutes: 120, + } +} + +func countType(changes []Change, changeType string) int { + var count int + for _, change := range changes { + if change.Type == changeType { + count++ + } + } + return count +} + +func at(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index e51f55c..1b097f7 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -9,6 +9,7 @@ import ( "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" + "gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" @@ -35,15 +36,19 @@ type Report struct { } type RecentChanges struct { - Items []Change `json:"items"` -} - -type Change struct { - Type string `json:"type"` - Message string `json:"message"` + Items []changes.Change `json:"items"` } func Build(briefingPackage briefing.Package) (Package, error) { + return BuildWithRecentChanges(briefingPackage, nil) +} + +func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) { + items := make([]changes.Change, len(recentChanges)) + copy(items, recentChanges) + if items == nil { + items = []changes.Change{} + } pkg := Package{ SchemaVersion: SchemaVersion, RunID: briefingPackage.Metadata.RunID, @@ -56,7 +61,7 @@ func Build(briefingPackage briefing.Package) (Package, error) { ValidPeriod: briefingPackage.Metadata.ValidPeriod, }, Briefing: briefingPackage, - RecentChanges: RecentChanges{Items: []Change{}}, + RecentChanges: RecentChanges{Items: items}, SourceWarnings: briefingPackage.Metadata.SourceWarnings, } if err := Validate(pkg); err != nil { diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 7e33c3b..a1b4da3 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -294,3 +294,14 @@ func metadataPathFromStored(metadata Metadata) string { func sameValidDate(metadata Metadata, resolved report.Resolved) bool { return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02") } + +func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) { + if path == "" { + return briefing.Package{}, fmt.Errorf("briefing path is required") + } + var pkg briefing.Package + if err := readJSON(path, &pkg); err != nil { + return briefing.Package{}, err + } + return pkg, nil +} diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 937fcac..2397a82 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -88,6 +88,13 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { t.Fatalf("expected artifact %q: %v", path, err) } } + loadedBriefing, err := store.LoadBriefing(context.Background(), briefingPath) + if err != nil { + t.Fatalf("LoadBriefing() error = %v", err) + } + if loadedBriefing.Metadata.RunID != resolved.Metadata().RunID { + t.Fatalf("loaded briefing RunID = %q, want %q", loadedBriefing.Metadata.RunID, resolved.Metadata().RunID) + } var decoded Metadata data, err := os.ReadFile(metadataPath) if err != nil { @@ -146,6 +153,39 @@ func TestFindPriorDailySnapshot(t *testing.T) { } } +func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) { + store := newTestStore(t) + previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") + currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") + briefingPackage := stateBriefingPackage(previousDate) + briefingPath, err := store.SaveBriefing(context.Background(), previousDate, briefingPackage) + if err != nil { + t.Fatalf("SaveBriefing() error = %v", err) + } + paths, err := store.Paths(previousDate) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + _, err = store.SaveMetadata(context.Background(), BuildMetadata(previousDate, briefingPackage, ArtifactPaths{ + Briefing: briefingPath, + Metadata: paths.Metadata, + DataPackage: paths.DataPackage, + Preflight: paths.Preflight, + RenderedReport: paths.RenderedReport, + })) + if err != nil { + t.Fatalf("SaveMetadata() error = %v", err) + } + + prior, err := store.FindPriorDailySnapshot(context.Background(), currentDate) + if err != nil { + t.Fatalf("FindPriorDailySnapshot() error = %v", err) + } + if prior != nil { + t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior) + } +} + func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) { cfg := config.Defaults().Workspace cfg.Root = t.TempDir() diff --git a/internal/state/store.go b/internal/state/store.go index 1242b94..fb800c3 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -18,6 +18,7 @@ type Store interface { PrepareRenderedReport(context.Context, report.Resolved) (string, error) SaveMetadata(context.Context, Metadata) (string, error) FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) + LoadBriefing(context.Context, string) (briefing.Package, error) } type PriorSnapshot struct {