Add Weekend Outlook generation
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
`weatherreporter` is a Go application for preparing human-facing weather
|
||||
reports from normalized forecast data.
|
||||
|
||||
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.
|
||||
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
|
||||
Outlook, and Weekend Outlook Markdown reports through `scriptorium`, with
|
||||
inspectable briefing, prompt input, preflight, report, and metadata artifacts
|
||||
under the configured workspace.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace.
|
||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
30
docs/cli.md
30
docs/cli.md
@@ -1,12 +1,12 @@
|
||||
# Weatherreporter CLI
|
||||
|
||||
`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. Unsupported report generation and
|
||||
scheduled runs still resolve configuration, report definitions, and valid
|
||||
periods, then return a not-implemented error.
|
||||
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
|
||||
`weatherreporter run morning`, and `weatherreporter run evening` currently
|
||||
write Markdown reports through `scriptorium`, after writing managed preparation
|
||||
artifacts and running `scriptorium render` as a preflight check. Unsupported
|
||||
report generation still resolves configuration, report definitions, and valid
|
||||
periods, then returns a not-implemented error.
|
||||
|
||||
## Shortest Useful Command
|
||||
|
||||
@@ -32,6 +32,7 @@ For the 3-Day Outlook:
|
||||
|
||||
```sh
|
||||
weatherreporter generate three-day --out ./three-day.md
|
||||
weatherreporter generate weekend --out ./weekend.md
|
||||
```
|
||||
|
||||
## Command Overview
|
||||
@@ -46,14 +47,13 @@ weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
`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.
|
||||
`generate daily`, `generate tomorrow`, `generate three-day`, and
|
||||
`generate weekend` 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`
|
||||
generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on
|
||||
Sunday. Other `generate` commands resolve one report request and stop before
|
||||
report generation.
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -61,7 +61,7 @@ and stop before report generation.
|
||||
- `--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`, `generate tomorrow`, and `generate three-day`; reserved for later generated report output on other `generate` commands.
|
||||
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, and `generate weekend`; 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`.
|
||||
|
||||
@@ -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, Daily Tomorrow, and 3-Day Outlook briefing content.
|
||||
Today, Daily Tomorrow, 3-Day Outlook, and Weekend Outlook briefing content.
|
||||
|
||||
## Inputs and Outputs
|
||||
|
||||
@@ -42,6 +42,9 @@ Output:
|
||||
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
||||
alert inputs, plus broader discussion and weather-story context when
|
||||
available.
|
||||
- Weekend content uses the same daily outlook summaries and adds planning
|
||||
inputs for best outdoor windows, worst weather windows, rain/storm timing,
|
||||
comfort concerns, and confidence or uncertainty context.
|
||||
- Briefing JSON is written atomically by `briefing.Save`.
|
||||
|
||||
## Failure Behavior
|
||||
@@ -50,6 +53,8 @@ Output:
|
||||
daily forecast summary.
|
||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||
one derived daily summary in the outlook period.
|
||||
- Weekend briefing construction requires a Weekend report definition and at
|
||||
least one derived daily summary in the weekend period.
|
||||
- Save failures include path and operation context.
|
||||
|
||||
## Tests
|
||||
@@ -58,6 +63,7 @@ Inspect:
|
||||
|
||||
- `internal/briefing/daily_test.go`
|
||||
- `internal/briefing/three_day_test.go`
|
||||
- `internal/briefing/weekend_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
- `internal/cli/root_test.go`
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ The app maps these config fields into comparison thresholds:
|
||||
|
||||
## Behavior
|
||||
|
||||
Daily and 3-Day comparison currently detect:
|
||||
Daily, 3-Day, and Weekend comparison currently detect:
|
||||
|
||||
- temperature changes crossing configured thresholds
|
||||
- precipitation probability and timing changes
|
||||
@@ -48,12 +48,15 @@ Daily and 3-Day comparison currently detect:
|
||||
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. 3-Day Outlook compares
|
||||
with prior 3-Day Outlook snapshots for the same valid local date.
|
||||
with prior 3-Day Outlook snapshots for the same valid local date. Weekend
|
||||
Outlook compares with prior Weekend Outlook snapshots for the same weekend
|
||||
window.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
|
||||
comparison requires both inputs to contain 3-Day briefing content.
|
||||
comparison requires both inputs to contain 3-Day briefing content. Weekend
|
||||
comparison requires both inputs to contain Weekend briefing content.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -61,6 +64,7 @@ Inspect:
|
||||
|
||||
- `internal/changes/daily_test.go`
|
||||
- `internal/changes/three_day_test.go`
|
||||
- `internal/changes/weekend_test.go`
|
||||
- `internal/app/app_test.go`
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -11,7 +11,8 @@ This document describes the implemented prompt input package boundary.
|
||||
|
||||
Input:
|
||||
|
||||
- `briefing.Package` containing Daily-family or 3-Day Outlook content
|
||||
- `briefing.Package` containing Daily-family, 3-Day Outlook, or Weekend Outlook
|
||||
content
|
||||
|
||||
Output:
|
||||
|
||||
|
||||
@@ -52,9 +52,10 @@ 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.
|
||||
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
||||
Outlook snapshots for the same weekend window. 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
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ The implemented generation workflows are:
|
||||
weatherreporter generate daily --date 2026-05-29
|
||||
weatherreporter generate tomorrow
|
||||
weatherreporter generate three-day
|
||||
weatherreporter generate weekend
|
||||
weatherreporter run morning
|
||||
weatherreporter run evening
|
||||
```
|
||||
|
||||
@@ -15,8 +17,8 @@ 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. The
|
||||
morning run can generate Daily Today and the 3-Day Outlook when the resolved
|
||||
batch does not include an unsupported report type.
|
||||
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||
except on Sunday.
|
||||
|
||||
## Filesystem Layout
|
||||
|
||||
@@ -33,6 +35,10 @@ workspace/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.briefing.json
|
||||
<run_id>.metadata.json
|
||||
data-packages/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
@@ -40,6 +46,9 @@ workspace/
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.data_package.json
|
||||
preflight/
|
||||
daily/
|
||||
YYYY-MM-DD/
|
||||
@@ -47,16 +56,21 @@ workspace/
|
||||
three-day/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
weekend/
|
||||
YYYY-MM-DD/
|
||||
<run_id>.render.json
|
||||
reports/
|
||||
daily/
|
||||
<run_id>.md
|
||||
three-day/
|
||||
<run_id>.md
|
||||
weekend/
|
||||
<run_id>.md
|
||||
```
|
||||
|
||||
The Markdown report is written to a RunID-managed report path. When `--out` is
|
||||
provided to `generate daily`, `generate tomorrow`, or `generate three-day`, the
|
||||
managed report is also copied to that path.
|
||||
provided to `generate daily`, `generate tomorrow`, `generate three-day`, or
|
||||
`generate weekend`, the managed report is also copied to that path.
|
||||
|
||||
## Run Identifiers
|
||||
|
||||
@@ -93,6 +107,10 @@ they cover the same valid local date. Meaningful changes are included under
|
||||
3-Day Outlook generation compares against a prior compatible 3-Day briefing
|
||||
snapshot for the same valid local date when one exists.
|
||||
|
||||
Weekend Outlook generation compares against a prior compatible Weekend briefing
|
||||
snapshot for the same weekend window when one exists. Friday evening and
|
||||
Saturday runs may narrow the valid start while keeping the same Monday endpoint.
|
||||
|
||||
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||
the Recent Changes list is empty.
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||
}
|
||||
|
||||
func isGeneratedReport(id report.ID) bool {
|
||||
return isDailyReport(id) || id == report.ThreeDay
|
||||
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend
|
||||
}
|
||||
|
||||
func isDailyReport(id report.ID) bool {
|
||||
@@ -443,11 +443,19 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
}, summary)
|
||||
case report.ThreeDay:
|
||||
case report.ThreeDay, report.Weekend:
|
||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||
if err != nil {
|
||||
return briefing.Package{}, err
|
||||
}
|
||||
if req.Resolved.Definition.ID == report.Weekend {
|
||||
return briefing.BuildWeekend(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
Units: req.Config.WeatherAPI.Units,
|
||||
Timezone: req.Config.WeatherAPI.Timezone,
|
||||
}, summaries)
|
||||
}
|
||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||
Resolved: req.Resolved,
|
||||
Bundle: bundle,
|
||||
@@ -486,6 +494,8 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
||||
return changes.CompareDaily(previous, current, thresholds)
|
||||
case report.ThreeDay:
|
||||
return changes.CompareThreeDay(previous, current, thresholds)
|
||||
case report.Weekend:
|
||||
return changes.CompareWeekend(previous, current, thresholds)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -598,6 +598,81 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWeekendReportWritesReportAndRecentChanges(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: ReportWeekend,
|
||||
}, mustParse("2026-05-29T04:00:00-05:00"))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||
}
|
||||
priorBriefing := priorWeekendBriefing(priorResolved)
|
||||
priorBriefingPath, err := store.SaveBriefing(context.Background(), priorResolved, priorBriefing)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveBriefing() error = %v", err)
|
||||
}
|
||||
priorPaths, err := store.Paths(priorResolved)
|
||||
if err != nil {
|
||||
t.Fatalf("Paths() error = %v", err)
|
||||
}
|
||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||
Briefing: priorBriefingPath,
|
||||
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: ReportWeekend,
|
||||
}, 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: "# Weekend 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.Weekend == nil {
|
||||
t.Fatal("Weekend = nil")
|
||||
}
|
||||
if len(result.Briefing.Weekend.Days) != 3 {
|
||||
t.Fatalf("Weekend days = %d, want 3", len(result.Briefing.Weekend.Days))
|
||||
}
|
||||
if renderer.renderRequest.PromptID != "weather.weekend_outlook" {
|
||||
t.Fatalf("render PromptID = %q, want weather.weekend_outlook", renderer.renderRequest.PromptID)
|
||||
}
|
||||
if result.PriorSnapshot == nil {
|
||||
t.Fatal("PriorSnapshot = nil, want prior weekend snapshot")
|
||||
}
|
||||
if len(result.RecentChanges) == 0 {
|
||||
t.Fatal("RecentChanges length = 0, want changes from prior weekend snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||
@@ -756,6 +831,35 @@ func priorThreeDayBriefing(resolved report.Resolved) briefing.Package {
|
||||
}
|
||||
}
|
||||
|
||||
func priorWeekendBriefing(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,
|
||||
},
|
||||
Weekend: &briefing.Weekend{
|
||||
Days: []briefing.OutlookDay{
|
||||
{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: &high},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: resolved.ValidPeriod.Start.Add(12 * time.Hour),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type recordingRenderer struct {
|
||||
renderCalls int
|
||||
runCalls int
|
||||
|
||||
@@ -19,6 +19,7 @@ type Package struct {
|
||||
Metadata Metadata `json:"metadata"`
|
||||
Daily *Daily `json:"daily,omitempty"`
|
||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||
Weekend *Weekend `json:"weekend,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
|
||||
@@ -44,11 +44,18 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
||||
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 {
|
||||
}
|
||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
|
||||
alerts := map[string]forecast.AlertOverlap{}
|
||||
for _, day := range days {
|
||||
for _, alert := range day.RelevantAlerts {
|
||||
key := alert.Event
|
||||
if key == "" {
|
||||
key = alert.Headline
|
||||
@@ -63,10 +70,11 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]forecast.AlertOverlap, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
pkg.ThreeDay.RelevantAlerts = append(pkg.ThreeDay.RelevantAlerts, alerts[key])
|
||||
out = append(out, alerts[key])
|
||||
}
|
||||
return pkg, nil
|
||||
return out
|
||||
}
|
||||
|
||||
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
|
||||
|
||||
125
internal/briefing/weekend.go
Normal file
125
internal/briefing/weekend.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package briefing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
type Weekend struct {
|
||||
Days []OutlookDay `json:"days"`
|
||||
Planning WeekendPlanning `json:"planning"`
|
||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||
}
|
||||
|
||||
type WeekendPlanning struct {
|
||||
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
||||
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
||||
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
||||
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
||||
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
||||
}
|
||||
|
||||
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||
if ctx.Resolved.Definition.ID != report.Weekend {
|
||||
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||
}
|
||||
pkg := Package{
|
||||
Metadata: BuildMetadata(ctx),
|
||||
Weekend: &Weekend{
|
||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||
},
|
||||
}
|
||||
for _, summary := range summaries {
|
||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||
}
|
||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
|
||||
planning := WeekendPlanning{}
|
||||
for _, day := range days {
|
||||
if day.OutdoorWindows.Best != nil {
|
||||
window := *day.OutdoorWindows.Best
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
||||
}
|
||||
if day.OutdoorWindows.Worst != nil {
|
||||
window := *day.OutdoorWindows.Worst
|
||||
window.Daypart = day.Date + " " + window.Daypart
|
||||
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
||||
}
|
||||
for _, daypart := range day.Dayparts {
|
||||
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
||||
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
||||
}
|
||||
}
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
||||
if discussion.ShortTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
||||
}
|
||||
if discussion.LongTerm != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
||||
}
|
||||
if bundle != nil {
|
||||
for _, warning := range bundle.Warnings {
|
||||
if warning.Code != "" {
|
||||
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(planning.RainStormTiming) == 0 {
|
||||
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.ComfortConcerns) == 0 {
|
||||
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
||||
}
|
||||
if len(planning.UncertaintyInputs) == 0 {
|
||||
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||
}
|
||||
return planning
|
||||
}
|
||||
|
||||
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||
}
|
||||
if daypart.Indicators.Thunder {
|
||||
notes = append(notes, label+" thunder risk is present.")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||
notes := []string{}
|
||||
label := weekendWindowLabel(date, daypart.Name)
|
||||
if daypart.Indicators.Heat {
|
||||
notes = append(notes, label+" heat may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.Indicators.Cold {
|
||||
notes = append(notes, label+" cold may affect outdoor comfort.")
|
||||
}
|
||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
func weekendWindowLabel(date string, daypart string) string {
|
||||
if daypart == "" {
|
||||
return date
|
||||
}
|
||||
return strings.TrimSpace(date + " " + daypart)
|
||||
}
|
||||
88
internal/briefing/weekend_test.go
Normal file
88
internal/briefing/weekend_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
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 TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||
location := mustLocation(t)
|
||||
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
||||
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||
Location: location,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve weekend: %v", err)
|
||||
}
|
||||
precip := 70.0
|
||||
gust := 32.0
|
||||
summaries := []forecast.DailySummary{
|
||||
{
|
||||
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: "Showers and thunderstorms",
|
||||
Period: timeutil.Period{
|
||||
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
||||
End: mustParse("2026-05-30T18:00:00-05:00"),
|
||||
},
|
||||
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||
Value: precip,
|
||||
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
},
|
||||
PeakWindGust: &forecast.TimedValue{
|
||||
Value: gust,
|
||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
},
|
||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||
HourlyPeriods: []forecast.ForecastPeriod{
|
||||
{
|
||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
||||
TextDescription: "Showers and thunderstorms",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := BuildWeekend(BuildContext{
|
||||
Resolved: resolved,
|
||||
Units: "us",
|
||||
Timezone: "America/Chicago",
|
||||
}, summaries)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildWeekend() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Metadata.ReportID != report.Weekend {
|
||||
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
||||
}
|
||||
if pkg.Weekend == nil {
|
||||
t.Fatal("Weekend = nil")
|
||||
}
|
||||
if len(pkg.Weekend.Days) != 1 {
|
||||
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
||||
}
|
||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||
}
|
||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
||||
}
|
||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||
}
|
||||
}
|
||||
26
internal/changes/weekend.go
Normal file
26
internal/changes/weekend.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
)
|
||||
|
||||
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||
if previous.Weekend == nil {
|
||||
return nil, fmt.Errorf("previous weekend briefing is required")
|
||||
}
|
||||
if current.Weekend == nil {
|
||||
return nil, fmt.Errorf("current weekend briefing is required")
|
||||
}
|
||||
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
|
||||
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
|
||||
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range changes {
|
||||
changes[i].Type = "weekend_" + changes[i].Type
|
||||
}
|
||||
return changes, nil
|
||||
}
|
||||
43
internal/changes/weekend_test.go
Normal file
43
internal/changes/weekend_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package changes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||
)
|
||||
|
||||
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||
previousTemp := 70.0
|
||||
currentTemp := 78.0
|
||||
previous := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: &previousTemp},
|
||||
}}},
|
||||
}
|
||||
current := briefing.Package{
|
||||
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||
Date: "2026-05-30",
|
||||
Temperature: forecast.Range{Max: ¤tTemp},
|
||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
||||
}}},
|
||||
}
|
||||
|
||||
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("CompareWeekend() error = %v", err)
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
t.Fatal("changes length = 0, want weekend changes")
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type == "weekend_outlook_thunder_risk_change" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{"generate", "weekend", "--units", "metric"}, &stdout, &stderr)
|
||||
err := runner.Run(context.Background(), []string{"generate", "storm", "--units", "metric", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, &stdout, &stderr)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want app not implemented error")
|
||||
}
|
||||
@@ -207,6 +207,82 @@ func TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunGenerateWeekendWritesMarkdownReport(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, "weekend.md")
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
runner := Runner{Clock: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"generate", "weekend",
|
||||
"--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", "weekend", "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), `"weekend"`) || !strings.Contains(string(data), `"planning"`) {
|
||||
t.Fatalf("data package output missing weekend content:\n%s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningIncludesWeekendExceptSunday(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: fixedClock()}
|
||||
|
||||
err := runner.Run(context.Background(), []string{
|
||||
"run", "morning",
|
||||
"--config", configPath,
|
||||
}, &stdout, &stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob weekend packages: %v", err)
|
||||
}
|
||||
if len(weekendPackages) != 1 {
|
||||
t.Fatalf("weekend packages = %#v, want one", weekendPackages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||
server := dailyServer(t)
|
||||
tempDir := t.TempDir()
|
||||
|
||||
@@ -107,7 +107,7 @@ 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 && pkg.Briefing.ThreeDay == nil {
|
||||
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil {
|
||||
return fmt.Errorf("briefing report content is required")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -75,6 +75,29 @@ func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWeekendDataPackage(t *testing.T) {
|
||||
briefingPackage := validBriefingPackage()
|
||||
briefingPackage.Metadata.RunID = "20260529T100000Z_weekend"
|
||||
briefingPackage.Metadata.ReportID = report.Weekend
|
||||
briefingPackage.Metadata.PromptID = "weather.weekend_outlook"
|
||||
briefingPackage.Daily = nil
|
||||
briefingPackage.Weekend = &briefing.Weekend{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
|
||||
}
|
||||
|
||||
pkg, err := Build(briefingPackage)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
|
||||
if pkg.Report.ID != report.Weekend {
|
||||
t.Fatalf("Report.ID = %q, want weekend", pkg.Report.ID)
|
||||
}
|
||||
if pkg.Briefing.Weekend == nil {
|
||||
t.Fatal("Briefing.Weekend = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalDeterministic(t *testing.T) {
|
||||
pkg, err := Build(validBriefingPackage())
|
||||
if err != nil {
|
||||
|
||||
@@ -65,6 +65,9 @@ func TestWeekendPeriodCalculation(t *testing.T) {
|
||||
end string
|
||||
}{
|
||||
{name: "monday", now: "2026-05-25T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
{name: "tuesday", now: "2026-05-26T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
{name: "wednesday", now: "2026-05-27T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
{name: "thursday", now: "2026-05-28T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
{name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
{name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
{name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
|
||||
|
||||
@@ -156,49 +156,50 @@ func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved r
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate {
|
||||
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
||||
return nil, nil
|
||||
}
|
||||
group, err := reportGroup(resolved.Definition.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths, err := s.Paths(resolved)
|
||||
dirs, err := s.metadataDirectories(resolved, group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Dir(paths.Metadata)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
var candidates []Metadata
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
||||
continue
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
var metadata Metadata
|
||||
if err := readJSON(path, &metadata); err != nil {
|
||||
return nil, err
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
var metadata Metadata
|
||||
if err := readJSON(path, &metadata); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metadata.RunID == resolved.Metadata().RunID {
|
||||
continue
|
||||
}
|
||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
||||
continue
|
||||
}
|
||||
if !comparablePeriod(metadata, resolved) {
|
||||
continue
|
||||
}
|
||||
if !metadata.GeneratedAt.Before(resolved.GeneratedAt) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, metadata)
|
||||
}
|
||||
if metadata.RunID == resolved.Metadata().RunID {
|
||||
continue
|
||||
}
|
||||
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
|
||||
continue
|
||||
}
|
||||
if !sameValidDate(metadata, resolved) {
|
||||
continue
|
||||
}
|
||||
if !metadata.GeneratedAt.Before(resolved.GeneratedAt) {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, metadata)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
@@ -212,12 +213,39 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group string) ([]string, error) {
|
||||
paths, err := s.Paths(resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
|
||||
return []string{filepath.Dir(paths.Metadata)}, nil
|
||||
}
|
||||
root := s.join(s.snapshotsDir, group)
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read snapshot group directory %q: %w", root, err)
|
||||
}
|
||||
var dirs []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, filepath.Join(root, entry.Name()))
|
||||
}
|
||||
}
|
||||
return dirs, 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
|
||||
case "weekend":
|
||||
return prior == report.Weekend && current == report.Weekend
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -307,6 +335,21 @@ func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func comparablePeriod(metadata Metadata, resolved report.Resolved) bool {
|
||||
switch resolved.Definition.ComparisonStrategy {
|
||||
case report.CompareSameValidDate:
|
||||
return sameValidDate(metadata, resolved)
|
||||
case report.CompareWeekendWindow:
|
||||
return sameWeekendWindow(metadata, resolved)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
|
||||
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
|
||||
}
|
||||
|
||||
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
|
||||
if path == "" {
|
||||
return briefing.Package{}, fmt.Errorf("briefing path is required")
|
||||
|
||||
@@ -222,6 +222,78 @@ func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
|
||||
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
|
||||
briefingPackage := stateWeekendBriefingPackage(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 weekend snapshot")
|
||||
}
|
||||
if prior.Metadata.RunID != first.Metadata().RunID {
|
||||
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
|
||||
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
|
||||
briefingPackage := stateWeekendBriefingPackage(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 narrowed weekend 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()
|
||||
@@ -287,6 +359,26 @@ func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveWeekendAt(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.Weekend, 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{
|
||||
@@ -322,6 +414,24 @@ func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||
}
|
||||
}
|
||||
|
||||
func stateWeekendBriefingPackage(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,
|
||||
},
|
||||
Weekend: &briefing.Weekend{
|
||||
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pathsString(paths ArtifactPaths) string {
|
||||
return strings.Join([]string{
|
||||
paths.Briefing,
|
||||
|
||||
Reference in New Issue
Block a user