Add 3-Day Outlook generation

This commit is contained in:
2026-05-29 18:08:50 +00:00
parent 19513e42c1
commit 4c9d396f9b
24 changed files with 927 additions and 78 deletions

View File

@@ -3,15 +3,17 @@
`weatherreporter` is a Go application for preparing human-facing weather `weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. reports from normalized forecast data.
The application can currently generate Daily Today and Daily Tomorrow Markdown The application can currently generate Daily Today, Daily Tomorrow, and 3-Day
reports through `scriptorium`, with inspectable briefing, prompt input, Outlook Markdown reports through `scriptorium`, with inspectable briefing,
preflight, report, and metadata artifacts under the configured workspace. prompt input, preflight, report, and metadata artifacts under the configured
workspace.
## Quickstart ## Quickstart
```sh ```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate three-day --out ./three-day.md
``` ```
## Documentation ## Documentation

View File

@@ -1,11 +1,12 @@
# Weatherreporter CLI # Weatherreporter CLI
`weatherreporter generate daily`, `weatherreporter generate tomorrow`, and `weatherreporter generate daily`, `weatherreporter generate tomorrow`,
`weatherreporter run evening` currently write Daily-family Markdown reports `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 through `scriptorium`, after writing managed preparation artifacts and running
`scriptorium render` as a preflight check. Other report generation and scheduled `scriptorium render` as a preflight check. Unsupported report generation and
runs still resolve configuration, report definitions, and valid periods, then scheduled runs still resolve configuration, report definitions, and valid
return a not-implemented error. periods, then return a not-implemented error.
## Shortest Useful Command ## Shortest Useful Command
@@ -27,6 +28,12 @@ weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter run evening weatherreporter run evening
``` ```
For the 3-Day Outlook:
```sh
weatherreporter generate three-day --out ./three-day.md
```
## Command Overview ## Command Overview
```text ```text
@@ -39,12 +46,14 @@ weatherreporter run morning
weatherreporter run evening weatherreporter run evening
``` ```
`generate daily` and `generate tomorrow` write a briefing snapshot, prompt input `generate daily`, `generate tomorrow`, and `generate three-day` write a briefing
data package, render preflight output, Markdown report, and metadata file under snapshot, prompt input data package, render preflight output, Markdown report,
the configured workspace. `run evening` generates the Tomorrow Planning Brief. and metadata file under the configured workspace. `run evening` generates the
Other `generate` commands resolve one report request and stop before report Tomorrow Planning Brief. `run morning` can generate Daily Today and the 3-Day
generation. Other `run` commands resolve a scheduled batch request and stop Outlook when the resolved batch contains only implemented report types; when
before execution. 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 ## Flags
@@ -52,7 +61,7 @@ before execution.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. - `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units. - `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone. - `--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. - `--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`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`. - `--end TIME`: required end time for `generate storm`.

View File

@@ -6,7 +6,7 @@ This document describes the implemented briefing package boundary.
`internal/briefing` builds structured report-specific briefing packages from `internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements Daily 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 ## Inputs and Outputs
@@ -14,12 +14,13 @@ Inputs:
- resolved report definition and valid period - resolved report definition and valid period
- forecast bundle - forecast bundle
- derived daily forecast summary - derived forecast summary or summaries
- configured units and timezone - configured units and timezone
Output: Output:
- `briefing.Package` JSON containing common metadata and Daily briefing content - `briefing.Package` JSON containing common metadata and report-specific
briefing content
## Boundaries ## Boundaries
@@ -37,12 +38,18 @@ Output:
story context when available. story context when available.
- Daily Tomorrow also includes planning inputs for morning readiness, - Daily Tomorrow also includes planning inputs for morning readiness,
commute/school/workday concerns, and what may change overnight. 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`. - Briefing JSON is written atomically by `briefing.Save`.
## Failure Behavior ## Failure Behavior
- Daily briefing construction requires a Daily report definition and a derived - Daily briefing construction requires a Daily report definition and a derived
daily forecast summary. 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. - Save failures include path and operation context.
## Tests ## Tests
@@ -50,6 +57,7 @@ Output:
Inspect: Inspect:
- `internal/briefing/daily_test.go` - `internal/briefing/daily_test.go`
- `internal/briefing/three_day_test.go`
- `internal/app/app_test.go` - `internal/app/app_test.go`
- `internal/cli/root_test.go` - `internal/cli/root_test.go`

View File

@@ -11,8 +11,8 @@ produces compact change records for prompt input data packages.
Inputs: Inputs:
- prior Daily briefing package - prior briefing package
- current Daily briefing package - current briefing package
- configured Recent Changes thresholds - configured Recent Changes thresholds
Output: Output:
@@ -37,7 +37,7 @@ The app maps these config fields into comparison thresholds:
## Behavior ## Behavior
Daily comparison currently detects: Daily and 3-Day comparison currently detect:
- temperature changes crossing configured thresholds - temperature changes crossing configured thresholds
- precipitation probability and timing changes - 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 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 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 ## 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 ## Tests
Inspect: Inspect:
- `internal/changes/daily_test.go` - `internal/changes/daily_test.go`
- `internal/changes/three_day_test.go`
- `internal/app/app_test.go` - `internal/app/app_test.go`
## Invariants ## Invariants

View File

@@ -6,8 +6,8 @@ boundary.
## Purpose ## Purpose
`internal/forecast` converts a normalized forecast bundle into inspectable `internal/forecast` converts a normalized forecast bundle into inspectable
daily daypart summaries. These summaries are structured data for later briefing daily and multi-day daypart summaries. These summaries are structured data for
builders; they are not rendered report text. later briefing builders; they are not rendered report text.
## Inputs and Outputs ## Inputs and Outputs
@@ -15,6 +15,7 @@ Inputs:
- `forecast.Bundle` - `forecast.Bundle`
- local date and timezone - local date and timezone
- report period, for multi-day summaries
- configured daypart definitions with `HH:MM` start and end values - configured daypart definitions with `HH:MM` start and end values
Output: Output:
@@ -22,6 +23,8 @@ Output:
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected - `forecast.DailySummary` with a civil-day period, daypart summaries, selected
narrative periods, alert overlaps, discussion context, source warnings, and narrative periods, alert overlaps, discussion context, source warnings, and
source provenance. source provenance.
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
for each local day or partial day in a report period.
## Boundaries ## Boundaries
@@ -41,6 +44,8 @@ Output:
- Alerts are selected by overlap with the daily period and each daypart. - Alerts are selected by overlap with the daily period and each daypart.
- Narrative periods and discussion context are selected as broader source - Narrative periods and discussion context are selected as broader source
context for later briefing builders. 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 ## Failure Behavior

View File

@@ -11,7 +11,7 @@ This document describes the implemented prompt input package boundary.
Input: Input:
- `briefing.Package` - `briefing.Package` containing Daily-family or 3-Day Outlook content
Output: Output:

View File

@@ -5,7 +5,7 @@ This document describes the implemented filesystem state boundary.
## Purpose ## Purpose
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and `internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
prior Daily snapshot lookup. prior comparable snapshot lookup.
## Inputs and Outputs ## Inputs and Outputs
@@ -25,8 +25,8 @@ Outputs:
- render preflight JSON - render preflight JSON
- Markdown report path - Markdown report path
- metadata JSON - metadata JSON
- prior Daily snapshot metadata when available - prior comparable snapshot metadata when available
- prior Daily briefing package when loaded by path - prior briefing package when loaded by path
## Boundaries ## Boundaries
@@ -48,12 +48,13 @@ Workspace subdirectories must be relative paths that stay under
## State Behavior ## State Behavior
Managed artifact names use RunID, which is generated from report generation time 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 and report ID. Metadata is stored beside briefing snapshots by report group and
local date. Prior Daily snapshot lookup reads metadata for the same valid local valid local date. Prior snapshot lookup reads metadata for the same valid local
date and returns the latest earlier compatible Daily Today or Daily Tomorrow date and returns the latest earlier compatible run. Daily Today and Daily
run. The store can load a briefing snapshot by path for structured comparison. Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
The store prepares the managed Markdown report path before `scriptorium run` 3-Day Outlook snapshots. The store can load a briefing snapshot by path for
writes it. structured comparison. The store prepares the managed Markdown report path
before `scriptorium run` writes it.
## Failure Behavior ## Failure Behavior

View File

@@ -2,18 +2,21 @@
## Normal Workflow ## Normal Workflow
The implemented Daily-family generation workflows are: The implemented generation workflows are:
```text ```text
weatherreporter generate daily --date 2026-05-29 weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter run evening weatherreporter run evening
``` ```
These commands fetch weather data, build a Daily briefing for the resolved These commands fetch weather data, build a briefing for the resolved valid
valid date, build the prompt input data package, run `scriptorium render`, run period, build the prompt input data package, run `scriptorium render`, run
`scriptorium run`, and write inspectable artifacts under the configured `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 ## Filesystem Layout
@@ -26,22 +29,34 @@ workspace/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.briefing.json <run_id>.briefing.json
<run_id>.metadata.json <run_id>.metadata.json
three-day/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
data-packages/ data-packages/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.data_package.json <run_id>.data_package.json
three-day/
YYYY-MM-DD/
<run_id>.data_package.json
preflight/ preflight/
daily/ daily/
YYYY-MM-DD/ YYYY-MM-DD/
<run_id>.render.json <run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
reports/ reports/
daily/ daily/
<run_id>.md <run_id>.md
three-day/
<run_id>.md
``` ```
The Markdown report is written to a RunID-managed report path. When `--out` is 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 provided to `generate daily`, `generate tomorrow`, or `generate three-day`, the
copied to that path. managed report is also copied to that path.
## Run Identifiers ## Run Identifiers
@@ -56,7 +71,7 @@ date do not overwrite each other.
## Metadata ## Metadata
Each Daily generation writes metadata that links: Each generated report writes metadata that links:
- RunID - RunID
- report ID and prompt ID - 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 they cover the same valid local date. Meaningful changes are included under
`recentChanges.items`. `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, When no prior comparable snapshot exists, or no configured threshold is crossed,
the Recent Changes list is empty. the Recent Changes list is empty.

View File

@@ -58,13 +58,15 @@ type FetchBundleRequest struct {
OutputPath string OutputPath string
} }
type DailyBriefingRequest struct { type BriefingRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
OutputPath string OutputPath string
} }
type DailyReportRequest struct { type DailyBriefingRequest = BriefingRequest
type ReportRequest struct {
Config config.Config Config config.Config
Resolved report.Resolved Resolved report.Resolved
OutputPath string OutputPath string
@@ -72,12 +74,16 @@ type DailyReportRequest struct {
Store state.Store Store state.Store
} }
type DailyBriefingResult struct { type DailyReportRequest = ReportRequest
type BriefingResult struct {
Package briefing.Package Package briefing.Package
OutputPath string OutputPath string
} }
type DailyReportResult struct { type DailyBriefingResult = BriefingResult
type ReportResult struct {
Briefing briefing.Package Briefing briefing.Package
BriefingPath string BriefingPath string
DataPackage promptinput.Package DataPackage promptinput.Package
@@ -93,6 +99,8 @@ type DailyReportResult struct {
RunResult *scriptorium.RunResult RunResult *scriptorium.RunResult
} }
type DailyReportResult = ReportResult
type Renderer interface { type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
@@ -107,8 +115,8 @@ func Generate(ctx context.Context, req GenerateRequest) error {
if err != nil { if err != nil {
return err return err
} }
if isDailyReport(resolved.Definition.ID) { if isGeneratedReport(resolved.Definition.ID) {
_, err := GenerateDailyReport(ctx, DailyReportRequest{ _, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
OutputPath: req.OutputPath, OutputPath: req.OutputPath,
@@ -127,12 +135,14 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
if err != nil { if err != nil {
return err return err
} }
if req.Batch == BatchEvening { if req.Batch == BatchEvening || req.Batch == BatchMorning {
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
if !isDailyReport(resolved.Definition.ID) { if !isGeneratedReport(resolved.Definition.ID) {
return fmt.Errorf("run is not implemented") return fmt.Errorf("run is not implemented")
} }
if _, err := GenerateDailyReport(ctx, DailyReportRequest{ }
for _, resolved := range resolvedReports {
if _, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
}); err != nil { }); err != nil {
@@ -144,6 +154,10 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
return fmt.Errorf("run is not implemented") 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 { func isDailyReport(id report.ID) bool {
return id == report.DailyToday || id == report.DailyTomorrow 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) { 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}) bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil { if err != nil {
return nil, err return nil, err
} }
pkg, err := BuildDailyBriefing(req, bundle) pkg, err := BuildBriefing(req, bundle)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -259,10 +277,14 @@ func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*Dail
if err := briefing.Save(outputPath, pkg); err != nil { if err := briefing.Save(outputPath, pkg); err != nil {
return nil, err 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) { 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 store := req.Store
if store == nil { if store == nil {
defaultStore, err := defaultStore(req.Config) defaultStore, err := defaultStore(req.Config)
@@ -275,7 +297,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
if err != nil { if err != nil {
return nil, err return nil, err
} }
priorSnapshot, err := store.FindPriorDailySnapshot(ctx, req.Resolved) priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -284,7 +306,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
if err != nil { if err != nil {
return nil, err return nil, err
} }
briefingPackage, err := BuildDailyBriefing(DailyBriefingRequest{ briefingPackage, err := BuildBriefing(BriefingRequest{
Config: req.Config, Config: req.Config,
Resolved: req.Resolved, Resolved: req.Resolved,
}, bundle) }, bundle)
@@ -297,7 +319,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -375,7 +397,7 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
return nil, runErr return nil, runErr
} }
return &DailyReportResult{ return &ReportResult{
Briefing: briefingPackage, Briefing: briefingPackage,
BriefingPath: briefingPath, BriefingPath: briefingPath,
DataPackage: dataPackage, DataPackage: dataPackage,
@@ -393,6 +415,10 @@ func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyRep
} }
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) { 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) location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil { if err != nil {
return briefing.Package{}, err return briefing.Package{}, err
@@ -405,16 +431,32 @@ func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (brie
End: daypart.End, End: daypart.End,
}) })
} }
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts) switch req.Resolved.Definition.ID {
if err != nil { case report.DailyToday, report.DailyTomorrow:
return briefing.Package{}, err 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) { 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) { 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 { if priorSnapshot == nil {
return nil, nil return nil, nil
} }
@@ -429,12 +475,20 @@ func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *s
if err != nil { if err != nil {
return nil, err return nil, err
} }
return changes.CompareDaily(previous, current, changes.Thresholds{ thresholds := changes.Thresholds{
TemperatureDegrees: cfg.TemperatureDegrees, TemperatureDegrees: cfg.TemperatureDegrees,
PrecipProbabilityPoints: cfg.PrecipProbabilityPoints, PrecipProbabilityPoints: cfg.PrecipProbabilityPoints,
WindGustMilesPerHour: cfg.WindGustMilesPerHour, WindGustMilesPerHour: cfg.WindGustMilesPerHour,
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes, 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 { func copyFileAtomic(source string, target string) error {

View File

@@ -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) { func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
cfg := config.Defaults() cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago" 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 { type recordingRenderer struct {
renderCalls int renderCalls int
runCalls int runCalls int

View File

@@ -16,8 +16,9 @@ import (
const SchemaVersion = "weatherreporter.briefing.v1" const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct { type Package struct {
Metadata Metadata `json:"metadata"` Metadata Metadata `json:"metadata"`
Daily *Daily `json:"daily,omitempty"` Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
} }
type Metadata struct { type Metadata struct {

View File

@@ -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, ". ") + "."
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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: &currentTemp},
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)
}
}

View File

@@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()} 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 { if err == nil {
t.Fatal("Run() error = nil, want app not implemented error") 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) { func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()

View File

@@ -100,6 +100,62 @@ func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location,
return summary, nil 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 { func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
if run == nil { if run == nil {
return nil return nil

View File

@@ -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) { func TestAlertOverlap(t *testing.T) {
location := time.FixedZone("Test", -5*60*60) 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"}`) 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"}`)

View File

@@ -107,8 +107,8 @@ func Validate(pkg Package) error {
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID { if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id") return fmt.Errorf("briefing.metadata.reportId must match report.id")
} }
if pkg.Briefing.Daily == nil { if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil {
return fmt.Errorf("briefing.daily is required") return fmt.Errorf("briefing report content is required")
} }
return nil return nil
} }

View File

@@ -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) { func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage()) pkg, err := Build(validBriefingPackage())
if err != nil { if err != nil {

View File

@@ -151,7 +151,11 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
return path, nil 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 { if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate {
return nil, nil return nil, nil
} }
@@ -159,9 +163,6 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep
if err != nil { if err != nil {
return nil, err return nil, err
} }
if group != "daily" {
return nil, nil
}
paths, err := s.Paths(resolved) paths, err := s.Paths(resolved)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -188,7 +189,7 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep
if metadata.RunID == resolved.Metadata().RunID { if metadata.RunID == resolved.Metadata().RunID {
continue continue
} }
if metadata.ReportID != report.DailyToday && metadata.ReportID != report.DailyTomorrow { if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
continue continue
} }
if !sameValidDate(metadata, resolved) { if !sameValidDate(metadata, resolved) {
@@ -211,6 +212,17 @@ func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved rep
}, nil }, 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 { func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...) all := append([]string{s.root}, parts...)
return filepath.Join(all...) return filepath.Join(all...)

View File

@@ -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) { func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
cfg := config.Defaults().Workspace cfg := config.Defaults().Workspace
cfg.Root = t.TempDir() cfg.Root = t.TempDir()
@@ -231,6 +267,26 @@ func resolveDailyAt(t *testing.T, value string) report.Resolved {
return 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 { func stateBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{ return briefing.Package{
Metadata: briefing.Metadata{ 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 { func pathsString(paths ArtifactPaths) string {
return strings.Join([]string{ return strings.Join([]string{
paths.Briefing, paths.Briefing,

View File

@@ -17,6 +17,7 @@ type Store interface {
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error) SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error) SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadBriefing(context.Context, string) (briefing.Package, error) LoadBriefing(context.Context, string) (briefing.Package, error)
} }

View File

@@ -24,6 +24,21 @@ func (p Period) Contains(t time.Time) bool {
return !t.Before(p.Start) && t.Before(p.End) 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) { func ParseClock(value string) (time.Duration, error) {
parts := strings.Split(value, ":") parts := strings.Split(value, ":")
if len(parts) != 2 { if len(parts) != 2 {