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

View File

@@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/
<run_id>.briefing.json
<run_id>.metadata.json
three-day/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.json
three-day/
YYYY-MM-DD/
<run_id>.data_package.json
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
reports/
daily/
<run_id>.md
three-day/
<run_id>.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.

View File

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

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) {
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

View File

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

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
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()

View File

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

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) {
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"}`)

View File

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

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) {
pkg, err := Build(validBriefingPackage())
if err != nil {

View File

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

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) {
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,

View File

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

View File

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