From 4c9d396f9b1377493714ef713c0fa539c51cc590 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 18:08:50 +0000 Subject: [PATCH] Add 3-Day Outlook generation --- README.md | 8 +- docs/cli.md | 33 +++++--- docs/internal/briefing.md | 14 +++- docs/internal/changes.md | 13 +-- docs/internal/forecast-derivation.md | 9 +- docs/internal/prompt-input.md | 2 +- docs/internal/state.md | 19 +++-- docs/operations.md | 32 +++++-- internal/app/app.go | 106 +++++++++++++++++------ internal/app/app_test.go | 104 +++++++++++++++++++++++ internal/briefing/package.go | 5 +- internal/briefing/three_day.go | 121 +++++++++++++++++++++++++++ internal/briefing/three_day_test.go | 83 ++++++++++++++++++ internal/changes/three_day.go | 78 +++++++++++++++++ internal/changes/three_day_test.go | 66 +++++++++++++++ internal/cli/root_test.go | 82 +++++++++++++++++- internal/forecast/derive.go | 56 +++++++++++++ internal/forecast/derive_test.go | 35 ++++++++ internal/promptinput/package.go | 4 +- internal/promptinput/package_test.go | 23 +++++ internal/state/filesystem.go | 22 +++-- internal/state/filesystem_test.go | 74 ++++++++++++++++ internal/state/store.go | 1 + internal/timeutil/periods.go | 15 ++++ 24 files changed, 927 insertions(+), 78 deletions(-) create mode 100644 internal/briefing/three_day.go create mode 100644 internal/briefing/three_day_test.go create mode 100644 internal/changes/three_day.go create mode 100644 internal/changes/three_day_test.go diff --git a/README.md b/README.md index 6f05562..ff2c69c 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,17 @@ `weatherreporter` is a Go application for preparing human-facing weather reports from normalized forecast data. -The application can currently generate Daily Today and Daily Tomorrow Markdown -reports through `scriptorium`, with inspectable briefing, prompt input, -preflight, report, and metadata artifacts under the configured workspace. +The application can currently generate Daily Today, Daily Tomorrow, and 3-Day +Outlook Markdown reports through `scriptorium`, with inspectable briefing, +prompt input, preflight, report, and metadata artifacts under the configured +workspace. ## Quickstart ```sh weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate tomorrow --out ./tomorrow.md +weatherreporter generate three-day --out ./three-day.md ``` ## Documentation diff --git a/docs/cli.md b/docs/cli.md index 3b0c31c..45f214c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,11 +1,12 @@ # Weatherreporter CLI -`weatherreporter generate daily`, `weatherreporter generate tomorrow`, and -`weatherreporter run evening` currently write Daily-family Markdown reports +`weatherreporter generate daily`, `weatherreporter generate tomorrow`, +`weatherreporter generate three-day`, `weatherreporter run evening`, and the +Sunday `weatherreporter run morning` path currently write Markdown reports through `scriptorium`, after writing managed preparation artifacts and running -`scriptorium render` as a preflight check. Other report generation and scheduled -runs still resolve configuration, report definitions, and valid periods, then -return a not-implemented error. +`scriptorium render` as a preflight check. Unsupported report generation and +scheduled runs still resolve configuration, report definitions, and valid +periods, then return a not-implemented error. ## Shortest Useful Command @@ -27,6 +28,12 @@ weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter run evening ``` +For the 3-Day Outlook: + +```sh +weatherreporter generate three-day --out ./three-day.md +``` + ## Command Overview ```text @@ -39,12 +46,14 @@ weatherreporter run morning weatherreporter run evening ``` -`generate daily` and `generate tomorrow` write a briefing snapshot, prompt input -data package, render preflight output, Markdown report, and metadata file under -the configured workspace. `run evening` generates the Tomorrow Planning Brief. -Other `generate` commands resolve one report request and stop before report -generation. Other `run` commands resolve a scheduled batch request and stop -before execution. +`generate daily`, `generate tomorrow`, and `generate three-day` write a briefing +snapshot, prompt input data package, render preflight output, Markdown report, +and metadata file under the configured workspace. `run evening` generates the +Tomorrow Planning Brief. `run morning` can generate Daily Today and the 3-Day +Outlook when the resolved batch contains only implemented report types; when +Weekend Outlook is part of the resolved batch, execution stops at the current +not-implemented boundary. Other `generate` commands resolve one report request +and stop before report generation. ## Flags @@ -52,7 +61,7 @@ before execution. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--units VALUE`: override configured Weather API units. - `--tz NAME`: override configured Weather API timezone. -- `--out PATH`: optional Markdown report copy for `generate daily` and `generate tomorrow`; reserved for later generated report output on other `generate` commands. +- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, and `generate three-day`; reserved for later generated report output on other `generate` commands. - `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--start TIME`: required start time for `generate storm`. - `--end TIME`: required end time for `generate storm`. diff --git a/docs/internal/briefing.md b/docs/internal/briefing.md index 03748fa..56505af 100644 --- a/docs/internal/briefing.md +++ b/docs/internal/briefing.md @@ -6,7 +6,7 @@ This document describes the implemented briefing package boundary. `internal/briefing` builds structured report-specific briefing packages from forecast summaries and report metadata. The package currently implements Daily -Today and Daily Tomorrow briefing content. +Today, Daily Tomorrow, and 3-Day Outlook briefing content. ## Inputs and Outputs @@ -14,12 +14,13 @@ Inputs: - resolved report definition and valid period - forecast bundle -- derived daily forecast summary +- derived forecast summary or summaries - configured units and timezone Output: -- `briefing.Package` JSON containing common metadata and Daily briefing content +- `briefing.Package` JSON containing common metadata and report-specific + briefing content ## Boundaries @@ -37,12 +38,18 @@ Output: story context when available. - Daily Tomorrow also includes planning inputs for morning readiness, commute/school/workday concerns, and what may change overnight. +- 3-Day content includes one summary per local day or partial day, with overall + character, temperature range, precipitation, wind, risk, outdoor-window, and + alert inputs, plus broader discussion and weather-story context when + available. - Briefing JSON is written atomically by `briefing.Save`. ## Failure Behavior - Daily briefing construction requires a Daily report definition and a derived daily forecast summary. +- 3-Day briefing construction requires a 3-Day report definition and at least + one derived daily summary in the outlook period. - Save failures include path and operation context. ## Tests @@ -50,6 +57,7 @@ Output: Inspect: - `internal/briefing/daily_test.go` +- `internal/briefing/three_day_test.go` - `internal/app/app_test.go` - `internal/cli/root_test.go` diff --git a/docs/internal/changes.md b/docs/internal/changes.md index 0089ef5..2fc919d 100644 --- a/docs/internal/changes.md +++ b/docs/internal/changes.md @@ -11,8 +11,8 @@ produces compact change records for prompt input data packages. Inputs: -- prior Daily briefing package -- current Daily briefing package +- prior briefing package +- current briefing package - configured Recent Changes thresholds Output: @@ -37,7 +37,7 @@ The app maps these config fields into comparison thresholds: ## Behavior -Daily comparison currently detects: +Daily and 3-Day comparison currently detect: - temperature changes crossing configured thresholds - precipitation probability and timing changes @@ -47,17 +47,20 @@ Daily comparison currently detects: When no prior comparable snapshot exists, the app sends an empty Recent Changes section in the data package. Daily Today and Daily Tomorrow are compatible for -same-valid-date comparison through the report registry. +same-valid-date comparison through the report registry. 3-Day Outlook compares +with prior 3-Day Outlook snapshots for the same valid local date. ## Failure Behavior -Daily comparison requires both inputs to contain Daily briefing content. +Daily comparison requires both inputs to contain Daily briefing content. 3-Day +comparison requires both inputs to contain 3-Day briefing content. ## Tests Inspect: - `internal/changes/daily_test.go` +- `internal/changes/three_day_test.go` - `internal/app/app_test.go` ## Invariants diff --git a/docs/internal/forecast-derivation.md b/docs/internal/forecast-derivation.md index 98b9f29..ae49553 100644 --- a/docs/internal/forecast-derivation.md +++ b/docs/internal/forecast-derivation.md @@ -6,8 +6,8 @@ boundary. ## Purpose `internal/forecast` converts a normalized forecast bundle into inspectable -daily daypart summaries. These summaries are structured data for later briefing -builders; they are not rendered report text. +daily and multi-day daypart summaries. These summaries are structured data for +later briefing builders; they are not rendered report text. ## Inputs and Outputs @@ -15,6 +15,7 @@ Inputs: - `forecast.Bundle` - local date and timezone +- report period, for multi-day summaries - configured daypart definitions with `HH:MM` start and end values Output: @@ -22,6 +23,8 @@ Output: - `forecast.DailySummary` with a civil-day period, daypart summaries, selected narrative periods, alert overlaps, discussion context, source warnings, and source provenance. +- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary + for each local day or partial day in a report period. ## Boundaries @@ -41,6 +44,8 @@ Output: - Alerts are selected by overlap with the daily period and each daypart. - Narrative periods and discussion context are selected as broader source context for later briefing builders. +- Multi-day period summaries clip the first and last local days to the resolved + report period before selecting hourly periods and alerts. ## Failure Behavior diff --git a/docs/internal/prompt-input.md b/docs/internal/prompt-input.md index c349ad0..4dd125b 100644 --- a/docs/internal/prompt-input.md +++ b/docs/internal/prompt-input.md @@ -11,7 +11,7 @@ This document describes the implemented prompt input package boundary. Input: -- `briefing.Package` +- `briefing.Package` containing Daily-family or 3-Day Outlook content Output: diff --git a/docs/internal/state.md b/docs/internal/state.md index 9a397a6..4d303a5 100644 --- a/docs/internal/state.md +++ b/docs/internal/state.md @@ -5,7 +5,7 @@ This document describes the implemented filesystem state boundary. ## Purpose `internal/state` owns durable artifact paths, atomic JSON writes, metadata, and -prior Daily snapshot lookup. +prior comparable snapshot lookup. ## Inputs and Outputs @@ -25,8 +25,8 @@ Outputs: - render preflight JSON - Markdown report path - metadata JSON -- prior Daily snapshot metadata when available -- prior Daily briefing package when loaded by path +- prior comparable snapshot metadata when available +- prior briefing package when loaded by path ## Boundaries @@ -48,12 +48,13 @@ Workspace subdirectories must be relative paths that stay under ## State Behavior 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 compatible Daily Today or Daily Tomorrow -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. +and report ID. Metadata is stored beside briefing snapshots by report group and +valid local date. Prior snapshot lookup reads metadata for the same valid local +date and returns the latest earlier compatible run. Daily Today and Daily +Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior +3-Day Outlook snapshots. 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 dd25e23..6c1ee55 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,18 +2,21 @@ ## Normal Workflow -The implemented Daily-family generation workflows are: +The implemented generation workflows are: ```text weatherreporter generate daily --date 2026-05-29 weatherreporter generate tomorrow +weatherreporter generate three-day weatherreporter run evening ``` -These commands fetch weather data, build a Daily briefing for the resolved -valid date, build the prompt input data package, run `scriptorium render`, run +These commands fetch weather data, build a briefing for the resolved valid +period, build the prompt input data package, run `scriptorium render`, run `scriptorium run`, and write inspectable artifacts under the configured -workspace. The evening run resolves only the Tomorrow Planning Brief. +workspace. The evening run resolves only the Tomorrow Planning Brief. The +morning run can generate Daily Today and the 3-Day Outlook when the resolved +batch does not include an unsupported report type. ## Filesystem Layout @@ -26,22 +29,34 @@ workspace/ YYYY-MM-DD/ .briefing.json .metadata.json + three-day/ + YYYY-MM-DD/ + .briefing.json + .metadata.json data-packages/ daily/ YYYY-MM-DD/ .data_package.json + three-day/ + YYYY-MM-DD/ + .data_package.json preflight/ daily/ YYYY-MM-DD/ .render.json + three-day/ + YYYY-MM-DD/ + .render.json reports/ daily/ .md + three-day/ + .md ``` The Markdown report is written to a RunID-managed report path. When `--out` is -provided to `generate daily` or `generate tomorrow`, the managed report is also -copied to that path. +provided to `generate daily`, `generate tomorrow`, or `generate three-day`, the +managed report is also copied to that path. ## Run Identifiers @@ -56,7 +71,7 @@ date do not overwrite each other. ## Metadata -Each Daily generation writes metadata that links: +Each generated report writes metadata that links: - RunID - report ID and prompt ID @@ -75,6 +90,9 @@ data package. Daily Today and Daily Tomorrow can compare with each other when they cover the same valid local date. Meaningful changes are included under `recentChanges.items`. +3-Day Outlook generation compares against a prior compatible 3-Day briefing +snapshot for the same valid local date when one exists. + When no prior comparable snapshot exists, or no configured threshold is crossed, the Recent Changes list is empty. diff --git a/internal/app/app.go b/internal/app/app.go index bf642fe..3e4271b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -58,13 +58,15 @@ type FetchBundleRequest struct { OutputPath string } -type DailyBriefingRequest struct { +type BriefingRequest struct { Config config.Config Resolved report.Resolved OutputPath string } -type DailyReportRequest struct { +type DailyBriefingRequest = BriefingRequest + +type ReportRequest struct { Config config.Config Resolved report.Resolved OutputPath string @@ -72,12 +74,16 @@ type DailyReportRequest struct { Store state.Store } -type DailyBriefingResult struct { +type DailyReportRequest = ReportRequest + +type BriefingResult struct { Package briefing.Package OutputPath string } -type DailyReportResult struct { +type DailyBriefingResult = BriefingResult + +type ReportResult struct { Briefing briefing.Package BriefingPath string DataPackage promptinput.Package @@ -93,6 +99,8 @@ type DailyReportResult struct { RunResult *scriptorium.RunResult } +type DailyReportResult = ReportResult + type Renderer interface { Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) @@ -107,8 +115,8 @@ func Generate(ctx context.Context, req GenerateRequest) error { if err != nil { return err } - if isDailyReport(resolved.Definition.ID) { - _, err := GenerateDailyReport(ctx, DailyReportRequest{ + if isGeneratedReport(resolved.Definition.ID) { + _, err := GenerateReport(ctx, ReportRequest{ Config: req.Config, Resolved: resolved, OutputPath: req.OutputPath, @@ -127,12 +135,14 @@ func RunBatch(ctx context.Context, req BatchRequest) error { if err != nil { return err } - if req.Batch == BatchEvening { + if req.Batch == BatchEvening || req.Batch == BatchMorning { for _, resolved := range resolvedReports { - if !isDailyReport(resolved.Definition.ID) { + if !isGeneratedReport(resolved.Definition.ID) { return fmt.Errorf("run is not implemented") } - if _, err := GenerateDailyReport(ctx, DailyReportRequest{ + } + for _, resolved := range resolvedReports { + if _, err := GenerateReport(ctx, ReportRequest{ Config: req.Config, Resolved: resolved, }); err != nil { @@ -144,6 +154,10 @@ func RunBatch(ctx context.Context, req BatchRequest) error { return fmt.Errorf("run is not implemented") } +func isGeneratedReport(id report.ID) bool { + return isDailyReport(id) || id == report.ThreeDay +} + func isDailyReport(id report.ID) bool { return id == report.DailyToday || id == report.DailyTomorrow } @@ -236,11 +250,15 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast. } func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) { + return GenerateBriefing(ctx, req) +} + +func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) { bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config}) if err != nil { return nil, err } - pkg, err := BuildDailyBriefing(req, bundle) + pkg, err := BuildBriefing(req, bundle) if err != nil { return nil, err } @@ -259,10 +277,14 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail if err := briefing.Save(outputPath, pkg); err != nil { return nil, err } - return &DailyBriefingResult{Package: pkg, OutputPath: outputPath}, nil + return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil } func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) { + return GenerateReport(ctx, req) +} + +func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { store := req.Store if store == nil { defaultStore, err := defaultStore(req.Config) @@ -275,7 +297,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep if err != nil { return nil, err } - priorSnapshot, err := store.FindPriorDailySnapshot(ctx, req.Resolved) + priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved) if err != nil { return nil, err } @@ -284,7 +306,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep if err != nil { return nil, err } - briefingPackage, err := BuildDailyBriefing(DailyBriefingRequest{ + briefingPackage, err := BuildBriefing(BriefingRequest{ Config: req.Config, Resolved: req.Resolved, }, bundle) @@ -297,7 +319,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep return nil, err } - recentChanges, err := dailyRecentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) + recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange) if err != nil { return nil, err } @@ -375,7 +397,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep return nil, runErr } - return &DailyReportResult{ + return &ReportResult{ Briefing: briefingPackage, BriefingPath: briefingPath, DataPackage: dataPackage, @@ -393,6 +415,10 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep } func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { + return BuildBriefing(req, bundle) +} + +func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) if err != nil { return briefing.Package{}, err @@ -405,16 +431,32 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie End: daypart.End, }) } - summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts) - if err != nil { - return briefing.Package{}, err + switch req.Resolved.Definition.ID { + case report.DailyToday, report.DailyTomorrow: + summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts) + if err != nil { + return briefing.Package{}, err + } + return briefing.BuildDaily(briefing.BuildContext{ + Resolved: req.Resolved, + Bundle: bundle, + Units: req.Config.WeatherAPI.Units, + Timezone: req.Config.WeatherAPI.Timezone, + }, summary) + case report.ThreeDay: + summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts) + if err != nil { + return briefing.Package{}, err + } + return briefing.BuildThreeDay(briefing.BuildContext{ + Resolved: req.Resolved, + Bundle: bundle, + Units: req.Config.WeatherAPI.Units, + Timezone: req.Config.WeatherAPI.Timezone, + }, summaries) + default: + return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID) } - return briefing.BuildDaily(briefing.BuildContext{ - Resolved: req.Resolved, - Bundle: bundle, - Units: req.Config.WeatherAPI.Units, - Timezone: req.Config.WeatherAPI.Timezone, - }, summary) } func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { @@ -422,6 +464,10 @@ func defaultStore(cfg config.Config) (*state.FilesystemStore, error) { } func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { + return recentChanges(ctx, store, priorSnapshot, current, cfg) +} + +func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) { if priorSnapshot == nil { return nil, nil } @@ -429,12 +475,20 @@ func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *s if err != nil { return nil, err } - return changes.CompareDaily(previous, current, changes.Thresholds{ + thresholds := changes.Thresholds{ TemperatureDegrees: cfg.TemperatureDegrees, PrecipProbabilityPoints: cfg.PrecipProbabilityPoints, WindGustMilesPerHour: cfg.WindGustMilesPerHour, PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, - }) + } + switch current.Metadata.ReportID { + case report.DailyToday, report.DailyTomorrow: + return changes.CompareDaily(previous, current, thresholds) + case report.ThreeDay: + return changes.CompareThreeDay(previous, current, thresholds) + default: + return nil, nil + } } func copyFileAtomic(source string, target string) error { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d973b90..bb2bf69 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -523,6 +523,81 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) { } } +func TestGenerateThreeDayReportWritesReportAndRecentChanges(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: ReportThreeDay, + }, mustParse("2026-05-29T04:00:00-05:00")) + if err != nil { + 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, + 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: ReportThreeDay, + }, 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: "# 3-Day Outlook\n", + } + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: currentResolved, + Renderer: renderer, + Store: store, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + + if result.Briefing.ThreeDay == nil { + t.Fatal("ThreeDay = nil") + } + if len(result.Briefing.ThreeDay.Days) != 3 { + t.Fatalf("ThreeDay days = %d, want 3", len(result.Briefing.ThreeDay.Days)) + } + if renderer.renderRequest.PromptID != "weather.three_day_outlook" { + t.Fatalf("render PromptID = %q, want weather.three_day_outlook", renderer.renderRequest.PromptID) + } + if result.PriorSnapshot == nil { + t.Fatal("PriorSnapshot = nil, want prior 3-day snapshot") + } + if len(result.RecentChanges) == 0 { + t.Fatal("RecentChanges length = 0, want changes from prior 3-day snapshot") + } +} + func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { cfg := config.Defaults() cfg.WeatherAPI.Timezone = "America/Chicago" @@ -652,6 +727,35 @@ func priorDailyBriefing(resolved report.Resolved) briefing.Package { } } +func priorThreeDayBriefing(resolved report.Resolved) briefing.Package { + high := 58.0 + precip := 10.0 + return briefing.Package{ + Metadata: briefing.Metadata{ + SchemaVersion: briefing.SchemaVersion, + RunID: resolved.Metadata().RunID, + ReportID: resolved.Definition.ID, + PromptID: resolved.Definition.PromptID, + GeneratedAt: resolved.GeneratedAt, + Units: "us", + Timezone: resolved.Timezone, + ValidPeriod: resolved.ValidPeriod, + }, + ThreeDay: &briefing.ThreeDay{ + Days: []briefing.OutlookDay{ + { + Date: "2026-05-29", + Temperature: forecast.Range{Max: &high}, + MaxPrecipitationProbability: &forecast.TimedValue{ + Value: precip, + Time: resolved.ValidPeriod.Start.Add(6 * time.Hour), + }, + }, + }, + }, + } +} + type recordingRenderer struct { renderCalls int runCalls int diff --git a/internal/briefing/package.go b/internal/briefing/package.go index 5a68929..4e495b8 100644 --- a/internal/briefing/package.go +++ b/internal/briefing/package.go @@ -16,8 +16,9 @@ import ( const SchemaVersion = "weatherreporter.briefing.v1" type Package struct { - Metadata Metadata `json:"metadata"` - Daily *Daily `json:"daily,omitempty"` + Metadata Metadata `json:"metadata"` + Daily *Daily `json:"daily,omitempty"` + ThreeDay *ThreeDay `json:"threeDay,omitempty"` } type Metadata struct { diff --git a/internal/briefing/three_day.go b/internal/briefing/three_day.go new file mode 100644 index 0000000..30a84f2 --- /dev/null +++ b/internal/briefing/three_day.go @@ -0,0 +1,121 @@ +package briefing + +import ( + "fmt" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +type ThreeDay struct { + Days []OutlookDay `json:"days"` + RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` + Discussion DiscussionContext `json:"discussion,omitempty"` + WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"` +} + +type OutlookDay struct { + Date string `json:"date"` + Period timeutil.Period `json:"period"` + OverallCharacter string `json:"overallCharacter"` + Temperature forecast.Range `json:"temperature,omitempty"` + MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"` + PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"` + Risks []string `json:"risks,omitempty"` + OutdoorWindows OutdoorWindows `json:"outdoorWindows"` + RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"` + Dayparts []forecast.DaypartSummary `json:"dayparts"` +} + +func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) { + if ctx.Resolved.Definition.ID != report.ThreeDay { + return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition") + } + if len(summaries) == 0 { + return Package{}, fmt.Errorf("3-day forecast summaries are required") + } + pkg := Package{ + Metadata: BuildMetadata(ctx), + ThreeDay: &ThreeDay{ + Discussion: buildDiscussion(summaries[0].Discussion), + WeatherStory: buildWeatherStory(ctx.Bundle), + }, + } + alerts := map[string]forecast.AlertOverlap{} + for _, summary := range summaries { + day := buildOutlookDay(summary) + pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day) + for _, alert := range summary.AlertOverlaps { + key := alert.Event + if key == "" { + key = alert.Headline + } + if key != "" { + alerts[key] = alert + } + } + } + keys := make([]string, 0, len(alerts)) + for key := range alerts { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + pkg.ThreeDay.RelevantAlerts = append(pkg.ThreeDay.RelevantAlerts, alerts[key]) + } + return pkg, nil +} + +func buildOutlookDay(summary forecast.DailySummary) OutlookDay { + day := OutlookDay{ + Date: summary.Date, + Period: summary.Period, + OutdoorWindows: buildOutdoorWindows(summary.Dayparts), + RelevantAlerts: summary.AlertOverlaps, + Dayparts: summary.Dayparts, + } + conditions := map[string]struct{}{} + risks := map[string]struct{}{} + for _, daypart := range summary.Dayparts { + addRange(&day.Temperature, daypart.Temperature) + maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability) + maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust) + if daypart.DominantCondition != "" { + conditions[daypart.DominantCondition] = struct{}{} + } + for _, risk := range hazardsForIndicators(daypart.Indicators) { + risks[risk] = struct{}{} + } + if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 { + risks["precipitation"] = struct{}{} + } + if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 { + risks["wind"] = struct{}{} + } + } + for _, alert := range summary.AlertOverlaps { + if alert.Event != "" { + risks[alert.Event] = struct{}{} + } + } + day.Risks = sortedSet(risks) + day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks) + return day +} + +func outlookCharacter(conditions []string, risks []string) string { + if len(conditions) == 0 && len(risks) == 0 { + return "Quiet weather is expected." + } + parts := []string{} + if len(conditions) > 0 { + parts = append(parts, strings.Join(conditions, "; ")) + } + if len(risks) > 0 { + parts = append(parts, "risks: "+strings.Join(risks, "; ")) + } + return strings.Join(parts, ". ") + "." +} diff --git a/internal/briefing/three_day_test.go b/internal/briefing/three_day_test.go new file mode 100644 index 0000000..9a75e9b --- /dev/null +++ b/internal/briefing/three_day_test.go @@ -0,0 +1,83 @@ +package briefing + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" + "gitea.maximumdirect.net/eric/weatherreporter/internal/report" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) { + location := mustLocation(t) + resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{ + Now: mustParse("2026-05-29T06:00:00-05:00"), + Location: location, + }) + if err != nil { + t.Fatalf("resolve 3-day: %v", err) + } + precip := 70.0 + gust := 35.0 + summaries := []forecast.DailySummary{ + { + Date: "2026-05-29", + Period: timeutil.Period{ + Start: mustParse("2026-05-29T06:00:00-05:00"), + End: mustParse("2026-05-30T00:00:00-05:00"), + }, + Dayparts: []forecast.DaypartSummary{ + { + Name: "morning", + DominantCondition: "Showers and thunderstorms", + MaxPrecipitationProbability: &forecast.TimedValue{ + Value: precip, + Time: mustParse("2026-05-29T09:00:00-05:00"), + }, + PeakWindGust: &forecast.TimedValue{ + Value: gust, + Time: mustParse("2026-05-29T10:00:00-05:00"), + }, + Indicators: forecast.Indicators{Thunder: true, Wind: true}, + }, + }, + AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}}, + Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}}, + }, + { + Date: "2026-05-30", + Period: timeutil.Period{ + Start: mustParse("2026-05-30T00:00:00-05:00"), + End: mustParse("2026-05-31T00:00:00-05:00"), + }, + Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}}, + }, + } + + pkg, err := BuildThreeDay(BuildContext{ + Resolved: resolved, + Units: "us", + Timezone: "America/Chicago", + }, summaries) + if err != nil { + t.Fatalf("BuildThreeDay() error = %v", err) + } + + if pkg.Metadata.ReportID != report.ThreeDay { + t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID) + } + if pkg.ThreeDay == nil { + t.Fatal("ThreeDay = nil") + } + if len(pkg.ThreeDay.Days) != 2 { + t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days)) + } + first := pkg.ThreeDay.Days[0] + if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") { + t.Fatalf("first day = %#v, want conditions and risks", first) + } + if len(pkg.ThreeDay.RelevantAlerts) != 1 { + t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts)) + } +} diff --git a/internal/changes/three_day.go b/internal/changes/three_day.go new file mode 100644 index 0000000..f527c86 --- /dev/null +++ b/internal/changes/three_day.go @@ -0,0 +1,78 @@ +package changes + +import ( + "fmt" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" +) + +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") + } + if current.ThreeDay == nil { + return nil, fmt.Errorf("current 3-day briefing is required") + } + previousDays := outlookDaysByDate(previous.ThreeDay.Days) + currentDays := outlookDaysByDate(current.ThreeDay.Days) + 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}) + continue + } + changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...) + } + 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}) + } + } + sortChanges(changes) + return changes, nil +} + +func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change { + var changes []Change + for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) { + change.Message = date + ": " + change.Message + changes = append(changes, change) + } + for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) { + 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)) { + change.Message = date + ": " + change.Message + change.Type = "outlook_" + change.Type + changes = append(changes, change) + } + for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) { + 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) + } + if date != "" { + out[date] = day + } + } + return out +} diff --git a/internal/changes/three_day_test.go b/internal/changes/three_day_test.go new file mode 100644 index 0000000..9931c88 --- /dev/null +++ b/internal/changes/three_day_test.go @@ -0,0 +1,66 @@ +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" +) + +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{Thunder: true}}}, + }}}, + } + + changes, err := CompareThreeDay(previous, current, Thresholds{ + TemperatureDegrees: 5, + PrecipProbabilityPoints: 20, + PrecipTimingShiftMinutes: 120, + }) + if err != nil { + t.Fatalf("CompareThreeDay() error = %v", err) + } + + if len(changes) == 0 { + t.Fatal("changes length = 0, want detected 3-day changes") + } + var foundPrecip bool + var foundThunder bool + for _, change := range changes { + if change.Type == "outlook_precip_probability_change" { + foundPrecip = true + } + if change.Type == "outlook_thunder_risk_change" { + foundThunder = true + } + } + if !foundPrecip || !foundThunder { + t.Fatalf("changes = %#v, want precipitation and thunder changes", changes) + } +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 36b82d2..e0cbb8c 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) { var stderr bytes.Buffer runner := Runner{Clock: fixedClock()} - err := runner.Run(context.Background(), []string{"generate", "three-day", "--units", "metric"}, &stdout, &stderr) + err := runner.Run(context.Background(), []string{"generate", "weekend", "--units", "metric"}, &stdout, &stderr) if err == nil { t.Fatal("Run() error = nil, want app not implemented error") } @@ -161,6 +161,86 @@ func TestRunEveningGeneratesTomorrowReport(t *testing.T) { } } +func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + outPath := filepath.Join(tempDir, "three-day.md") + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: fixedClock()} + + err := runner.Run(context.Background(), []string{ + "generate", "three-day", + "--config", configPath, + "--out", outPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + report, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("read report: %v", err) + } + if !strings.Contains(string(report), "# Daily Report") { + t.Fatalf("report output missing markdown:\n%s", string(report)) + } + dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json")) + if err != nil { + t.Fatalf("glob data package: %v", err) + } + if len(dataPackageMatches) != 1 { + t.Fatalf("data package files = %#v, want one", dataPackageMatches) + } + data, err := os.ReadFile(dataPackageMatches[0]) + if err != nil { + t.Fatalf("read managed data package: %v", err) + } + if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) { + t.Fatalf("data package output missing 3-day content:\n%s", string(data)) + } +} + +func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) { + server := dailyServer(t) + tempDir := t.TempDir() + scriptoriumPath := writeFakeScriptorium(t, tempDir) + configPath := filepath.Join(tempDir, "config.yml") + workspaceRoot := filepath.Join(tempDir, "workspace") + configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" + if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}} + + err := runner.Run(context.Background(), []string{ + "run", "morning", + "--config", configPath, + }, &stdout, &stderr) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json")) + if err != nil { + t.Fatalf("glob daily packages: %v", err) + } + threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json")) + if err != nil { + t.Fatalf("glob 3-day packages: %v", err) + } + if len(dailyPackages) != 1 || len(threeDayPackages) != 1 { + t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages) + } +} + func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { server := dailyServer(t) tempDir := t.TempDir() diff --git a/internal/forecast/derive.go b/internal/forecast/derive.go index c4755b1..9a48d82 100644 --- a/internal/forecast/derive.go +++ b/internal/forecast/derive.go @@ -100,6 +100,62 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, return summary, nil } +func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) { + if !period.IsValid() { + return nil, fmt.Errorf("valid forecast period is required") + } + if location == nil { + location = time.UTC + } + var summaries []DailySummary + for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) { + overlap, ok := day.Intersection(period) + if !ok { + continue + } + summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts) + if err != nil { + return nil, err + } + summaries = append(summaries, *summary) + } + return summaries, nil +} + +func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) { + if bundle == nil { + return nil, fmt.Errorf("forecast bundle is required") + } + if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 { + return nil, fmt.Errorf("hourly forecast data is required") + } + windows, err := ResolveDayparts(period.Start, location, dayparts) + if err != nil { + return nil, err + } + alerts := AlertOverlaps(bundle.Alerts, period) + summary := &DailySummary{ + Date: period.Start.In(location).Format(timeutil.DateLayout), + Period: period, + NarrativePeriods: SelectNarrativePeriods(bundle, period), + AlertOverlaps: alerts, + Discussion: SelectDiscussion(bundle), + SourceWarnings: bundle.Warnings, + SourceProvenance: bundle.Sources, + } + for _, window := range windows { + clipped, ok := window.Period.Intersection(period) + if !ok { + continue + } + periods := SelectHourlyPeriods(bundle.Hourly, clipped) + daypartSummary := SummarizeDaypart(window.Name, clipped, periods) + daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped) + summary.Dayparts = append(summary.Dayparts, daypartSummary) + } + return summary, nil +} + func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod { if run == nil { return nil diff --git a/internal/forecast/derive_test.go b/internal/forecast/derive_test.go index 84b6962..ed490a0 100644 --- a/internal/forecast/derive_test.go +++ b/internal/forecast/derive_test.go @@ -153,6 +153,41 @@ func TestBuildDailySummaryRequiresHourlyData(t *testing.T) { } } +func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) { + location := time.FixedZone("Test", -5*60*60) + bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{ + hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil), + hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil), + hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil), + hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)), + }}} + period := timeutil.Period{ + Start: mustParse("2026-05-29T07:00:00-05:00").In(location), + End: mustParse("2026-06-01T00:00:00-05:00").In(location), + } + + summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{ + {Name: "morning", Start: "06:00", End: "12:00"}, + {Name: "afternoon", Start: "12:00", End: "18:00"}, + {Name: "evening", Start: "18:00", End: "24:00"}, + }) + if err != nil { + t.Fatalf("BuildPeriodDailySummaries() error = %v", err) + } + if len(summaries) != 3 { + t.Fatalf("summaries length = %d, want 3", len(summaries)) + } + if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" { + t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339)) + } + if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" { + t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods) + } + if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 { + t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust) + } +} + func TestAlertOverlap(t *testing.T) { location := time.FixedZone("Test", -5*60*60) raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`) diff --git a/internal/promptinput/package.go b/internal/promptinput/package.go index 1b097f7..36dae17 100644 --- a/internal/promptinput/package.go +++ b/internal/promptinput/package.go @@ -107,8 +107,8 @@ func Validate(pkg Package) error { if pkg.Briefing.Metadata.ReportID != pkg.Report.ID { return fmt.Errorf("briefing.metadata.reportId must match report.id") } - if pkg.Briefing.Daily == nil { - return fmt.Errorf("briefing.daily is required") + if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil { + return fmt.Errorf("briefing report content is required") } return nil } diff --git a/internal/promptinput/package_test.go b/internal/promptinput/package_test.go index 675f641..f29877c 100644 --- a/internal/promptinput/package_test.go +++ b/internal/promptinput/package_test.go @@ -52,6 +52,29 @@ func TestValidateRequiresFields(t *testing.T) { } } +func TestBuildThreeDayDataPackage(t *testing.T) { + briefingPackage := validBriefingPackage() + briefingPackage.Metadata.RunID = "20260529T100000Z_three_day" + briefingPackage.Metadata.ReportID = report.ThreeDay + briefingPackage.Metadata.PromptID = "weather.three_day_outlook" + briefingPackage.Daily = nil + briefingPackage.ThreeDay = &briefing.ThreeDay{ + Days: []briefing.OutlookDay{{Date: "2026-05-29"}}, + } + + pkg, err := Build(briefingPackage) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if pkg.Report.ID != report.ThreeDay { + t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID) + } + if pkg.Briefing.ThreeDay == nil { + t.Fatal("Briefing.ThreeDay = nil") + } +} + func TestMarshalDeterministic(t *testing.T) { pkg, err := Build(validBriefingPackage()) if err != nil { diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index a1b4da3..61c80fd 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -151,7 +151,11 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st return path, nil } -func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) { +func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) { + return s.FindPriorSnapshot(ctx, resolved) +} + +func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) { if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate { return nil, nil } @@ -159,9 +163,6 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep if err != nil { return nil, err } - if group != "daily" { - return nil, nil - } paths, err := s.Paths(resolved) if err != nil { return nil, err @@ -188,7 +189,7 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep if metadata.RunID == resolved.Metadata().RunID { continue } - if metadata.ReportID != report.DailyToday && metadata.ReportID != report.DailyTomorrow { + if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) { continue } if !sameValidDate(metadata, resolved) { @@ -211,6 +212,17 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep }, nil } +func compatiblePriorReport(group string, prior report.ID, current report.ID) bool { + switch group { + case "daily": + return prior == report.DailyToday || prior == report.DailyTomorrow + case "three-day": + return prior == report.ThreeDay && current == report.ThreeDay + default: + return false + } +} + func (s *FilesystemStore) join(parts ...string) string { all := append([]string{s.root}, parts...) return filepath.Join(all...) diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go index 2397a82..f6d1165 100644 --- a/internal/state/filesystem_test.go +++ b/internal/state/filesystem_test.go @@ -186,6 +186,42 @@ func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) { } } +func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) { + store := newTestStore(t) + first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00") + second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00") + briefingPackage := stateThreeDayBriefingPackage(first) + briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage) + if err != nil { + t.Fatalf("SaveBriefing() error = %v", err) + } + paths, err := store.Paths(first) + if err != nil { + t.Fatalf("Paths() error = %v", err) + } + _, err = store.SaveMetadata(context.Background(), BuildMetadata(first, 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.FindPriorSnapshot(context.Background(), second) + if err != nil { + t.Fatalf("FindPriorSnapshot() error = %v", err) + } + if prior == nil { + t.Fatal("FindPriorSnapshot() = nil, want prior 3-day snapshot") + } + if prior.Metadata.RunID != first.Metadata().RunID { + t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) + } +} + func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) { cfg := config.Defaults().Workspace cfg.Root = t.TempDir() @@ -231,6 +267,26 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved { return resolved } +func resolveThreeDayAt(t *testing.T, value string) report.Resolved { + t.Helper() + location, err := timeutil.LoadLocation("America/Chicago") + if err != nil { + t.Fatalf("LoadLocation() error = %v", err) + } + now, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + resolved, err := report.DefaultRegistry().Resolve(report.ThreeDay, report.ResolveRequest{ + Now: now, + Location: location, + }) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + return resolved +} + func stateBriefingPackage(resolved report.Resolved) briefing.Package { return briefing.Package{ Metadata: briefing.Metadata{ @@ -248,6 +304,24 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package { } } +func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package { + return briefing.Package{ + Metadata: briefing.Metadata{ + SchemaVersion: briefing.SchemaVersion, + RunID: resolved.Metadata().RunID, + ReportID: resolved.Definition.ID, + PromptID: resolved.Definition.PromptID, + GeneratedAt: resolved.GeneratedAt, + Units: "us", + Timezone: resolved.Timezone, + ValidPeriod: resolved.ValidPeriod, + }, + ThreeDay: &briefing.ThreeDay{ + Days: []briefing.OutlookDay{{Date: "2026-05-29"}}, + }, + } +} + func pathsString(paths ArtifactPaths) string { return strings.Join([]string{ paths.Briefing, diff --git a/internal/state/store.go b/internal/state/store.go index fb800c3..91a0c0a 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -17,6 +17,7 @@ type Store interface { SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error) SaveMetadata(context.Context, Metadata) (string, error) + FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) LoadBriefing(context.Context, string) (briefing.Package, error) } diff --git a/internal/timeutil/periods.go b/internal/timeutil/periods.go index bd700f3..449be0d 100644 --- a/internal/timeutil/periods.go +++ b/internal/timeutil/periods.go @@ -24,6 +24,21 @@ func (p Period) Contains(t time.Time) bool { return !t.Before(p.Start) && t.Before(p.End) } +func (p Period) Intersection(other Period) (Period, bool) { + if !p.Overlaps(other) { + return Period{}, false + } + start := p.Start + if other.Start.After(start) { + start = other.Start + } + end := p.End + if other.End.Before(end) { + end = other.End + } + return Period{Start: start, End: end}, true +} + func ParseClock(value string) (time.Duration, error) { parts := strings.Split(value, ":") if len(parts) != 2 {