Add Weekend Outlook generation
This commit is contained in:
@@ -3,10 +3,10 @@
|
|||||||
`weatherreporter` is a Go application for preparing human-facing weather
|
`weatherreporter` is a Go application for preparing human-facing weather
|
||||||
reports from normalized forecast data.
|
reports from normalized forecast data.
|
||||||
|
|
||||||
The application can currently generate Daily Today, Daily Tomorrow, and 3-Day
|
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
|
||||||
Outlook Markdown reports through `scriptorium`, with inspectable briefing,
|
Outlook, and Weekend Outlook Markdown reports through `scriptorium`, with
|
||||||
prompt input, preflight, report, and metadata artifacts under the configured
|
inspectable briefing, prompt input, preflight, report, and metadata artifacts
|
||||||
workspace.
|
under the configured workspace.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ workspace.
|
|||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
weatherreporter generate tomorrow --out ./tomorrow.md
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
weatherreporter generate three-day --out ./three-day.md
|
||||||
|
weatherreporter generate weekend --out ./weekend.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
30
docs/cli.md
30
docs/cli.md
@@ -1,12 +1,12 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
|
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
|
||||||
`weatherreporter generate three-day`, `weatherreporter run evening`, and the
|
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
|
||||||
Sunday `weatherreporter run morning` path currently write Markdown reports
|
`weatherreporter run morning`, and `weatherreporter run evening` currently
|
||||||
through `scriptorium`, after writing managed preparation artifacts and running
|
write Markdown reports through `scriptorium`, after writing managed preparation
|
||||||
`scriptorium render` as a preflight check. Unsupported report generation and
|
artifacts and running `scriptorium render` as a preflight check. Unsupported
|
||||||
scheduled runs still resolve configuration, report definitions, and valid
|
report generation still resolves configuration, report definitions, and valid
|
||||||
periods, then return a not-implemented error.
|
periods, then returns a not-implemented error.
|
||||||
|
|
||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ For the 3-Day Outlook:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate three-day --out ./three-day.md
|
weatherreporter generate three-day --out ./three-day.md
|
||||||
|
weatherreporter generate weekend --out ./weekend.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
@@ -46,14 +47,13 @@ weatherreporter run morning
|
|||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate daily`, `generate tomorrow`, and `generate three-day` write a briefing
|
`generate daily`, `generate tomorrow`, `generate three-day`, and
|
||||||
snapshot, prompt input data package, render preflight output, Markdown report,
|
`generate weekend` write a briefing snapshot, prompt input data package, render
|
||||||
and metadata file under the configured workspace. `run evening` generates the
|
preflight output, Markdown report, and metadata file under the configured
|
||||||
Tomorrow Planning Brief. `run morning` can generate Daily Today and the 3-Day
|
workspace. `run evening` generates the Tomorrow Planning Brief. `run morning`
|
||||||
Outlook when the resolved batch contains only implemented report types; when
|
generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on
|
||||||
Weekend Outlook is part of the resolved batch, execution stops at the current
|
Sunday. Other `generate` commands resolve one report request and stop before
|
||||||
not-implemented boundary. Other `generate` commands resolve one report request
|
report generation.
|
||||||
and stop before report generation.
|
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ and stop before report generation.
|
|||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units.
|
- `--units VALUE`: override configured Weather API units.
|
||||||
- `--tz NAME`: override configured Weather API timezone.
|
- `--tz NAME`: override configured Weather API timezone.
|
||||||
- `--out PATH`: optional Markdown report copy for `generate daily`, `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.
|
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
|
||||||
- `--start TIME`: required start time for `generate storm`.
|
- `--start TIME`: required start time for `generate storm`.
|
||||||
- `--end TIME`: required end time for `generate storm`.
|
- `--end TIME`: required end time for `generate storm`.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes the implemented briefing package boundary.
|
|||||||
|
|
||||||
`internal/briefing` builds structured report-specific briefing packages from
|
`internal/briefing` builds structured report-specific briefing packages from
|
||||||
forecast summaries and report metadata. The package currently implements Daily
|
forecast summaries and report metadata. The package currently implements Daily
|
||||||
Today, Daily Tomorrow, and 3-Day Outlook briefing content.
|
Today, Daily Tomorrow, 3-Day Outlook, and Weekend Outlook briefing content.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@ Output:
|
|||||||
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
character, temperature range, precipitation, wind, risk, outdoor-window, and
|
||||||
alert inputs, plus broader discussion and weather-story context when
|
alert inputs, plus broader discussion and weather-story context when
|
||||||
available.
|
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`.
|
- Briefing JSON is written atomically by `briefing.Save`.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
@@ -50,6 +53,8 @@ Output:
|
|||||||
daily forecast summary.
|
daily forecast summary.
|
||||||
- 3-Day briefing construction requires a 3-Day report definition and at least
|
- 3-Day briefing construction requires a 3-Day report definition and at least
|
||||||
one derived daily summary in the outlook period.
|
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.
|
- Save failures include path and operation context.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -58,6 +63,7 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/briefing/daily_test.go`
|
- `internal/briefing/daily_test.go`
|
||||||
- `internal/briefing/three_day_test.go`
|
- `internal/briefing/three_day_test.go`
|
||||||
|
- `internal/briefing/weekend_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
- `internal/cli/root_test.go`
|
- `internal/cli/root_test.go`
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ The app maps these config fields into comparison thresholds:
|
|||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
Daily and 3-Day comparison currently detect:
|
Daily, 3-Day, and Weekend comparison currently detect:
|
||||||
|
|
||||||
- temperature changes crossing configured thresholds
|
- temperature changes crossing configured thresholds
|
||||||
- precipitation probability and timing changes
|
- 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
|
When no prior comparable snapshot exists, the app sends an empty Recent Changes
|
||||||
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
||||||
same-valid-date comparison through the report registry. 3-Day Outlook compares
|
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
|
## Failure Behavior
|
||||||
|
|
||||||
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
|
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
|
## Tests
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ Inspect:
|
|||||||
|
|
||||||
- `internal/changes/daily_test.go`
|
- `internal/changes/daily_test.go`
|
||||||
- `internal/changes/three_day_test.go`
|
- `internal/changes/three_day_test.go`
|
||||||
|
- `internal/changes/weekend_test.go`
|
||||||
- `internal/app/app_test.go`
|
- `internal/app/app_test.go`
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ This document describes the implemented prompt input package boundary.
|
|||||||
|
|
||||||
Input:
|
Input:
|
||||||
|
|
||||||
- `briefing.Package` containing Daily-family or 3-Day Outlook content
|
- `briefing.Package` containing Daily-family, 3-Day Outlook, or Weekend Outlook
|
||||||
|
content
|
||||||
|
|
||||||
Output:
|
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
|
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
|
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
|
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
|
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
|
||||||
structured comparison. The store prepares the managed Markdown report path
|
Outlook snapshots for the same weekend window. The store can load a briefing
|
||||||
before `scriptorium run` writes it.
|
snapshot by path for structured comparison. The store prepares the managed
|
||||||
|
Markdown report path before `scriptorium run` writes it.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ The implemented generation workflows are:
|
|||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
weatherreporter generate tomorrow
|
weatherreporter generate tomorrow
|
||||||
weatherreporter generate three-day
|
weatherreporter generate three-day
|
||||||
|
weatherreporter generate weekend
|
||||||
|
weatherreporter run morning
|
||||||
weatherreporter run evening
|
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
|
period, build the prompt input data package, run `scriptorium render`, run
|
||||||
`scriptorium run`, and write inspectable artifacts under the configured
|
`scriptorium run`, and write inspectable artifacts under the configured
|
||||||
workspace. The evening run resolves only the Tomorrow Planning Brief. The
|
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
|
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
|
||||||
batch does not include an unsupported report type.
|
except on Sunday.
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ workspace/
|
|||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.briefing.json
|
<run_id>.briefing.json
|
||||||
<run_id>.metadata.json
|
<run_id>.metadata.json
|
||||||
|
weekend/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.briefing.json
|
||||||
|
<run_id>.metadata.json
|
||||||
data-packages/
|
data-packages/
|
||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
@@ -40,6 +46,9 @@ workspace/
|
|||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.data_package.json
|
<run_id>.data_package.json
|
||||||
|
weekend/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.data_package.json
|
||||||
preflight/
|
preflight/
|
||||||
daily/
|
daily/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
@@ -47,16 +56,21 @@ workspace/
|
|||||||
three-day/
|
three-day/
|
||||||
YYYY-MM-DD/
|
YYYY-MM-DD/
|
||||||
<run_id>.render.json
|
<run_id>.render.json
|
||||||
|
weekend/
|
||||||
|
YYYY-MM-DD/
|
||||||
|
<run_id>.render.json
|
||||||
reports/
|
reports/
|
||||||
daily/
|
daily/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
three-day/
|
three-day/
|
||||||
<run_id>.md
|
<run_id>.md
|
||||||
|
weekend/
|
||||||
|
<run_id>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The Markdown report is written to a RunID-managed report path. When `--out` is
|
The Markdown report is written to a RunID-managed report path. When `--out` is
|
||||||
provided to `generate daily`, `generate tomorrow`, or `generate three-day`, the
|
provided to `generate daily`, `generate tomorrow`, `generate three-day`, or
|
||||||
managed report is also copied to that path.
|
`generate weekend`, the managed report is also copied to that path.
|
||||||
|
|
||||||
## Run Identifiers
|
## 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
|
3-Day Outlook generation compares against a prior compatible 3-Day briefing
|
||||||
snapshot for the same valid local date when one exists.
|
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,
|
When no prior comparable snapshot exists, or no configured threshold is crossed,
|
||||||
the Recent Changes list is empty.
|
the Recent Changes list is empty.
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func RunBatch(ctx context.Context, req BatchRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isGeneratedReport(id report.ID) bool {
|
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 {
|
func isDailyReport(id report.ID) bool {
|
||||||
@@ -443,11 +443,19 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
}, summary)
|
}, summary)
|
||||||
case report.ThreeDay:
|
case report.ThreeDay, report.Weekend:
|
||||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return briefing.Package{}, err
|
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{
|
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||||
Resolved: req.Resolved,
|
Resolved: req.Resolved,
|
||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
@@ -486,6 +494,8 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.
|
|||||||
return changes.CompareDaily(previous, current, thresholds)
|
return changes.CompareDaily(previous, current, thresholds)
|
||||||
case report.ThreeDay:
|
case report.ThreeDay:
|
||||||
return changes.CompareThreeDay(previous, current, thresholds)
|
return changes.CompareThreeDay(previous, current, thresholds)
|
||||||
|
case report.Weekend:
|
||||||
|
return changes.CompareWeekend(previous, current, thresholds)
|
||||||
default:
|
default:
|
||||||
return nil, nil
|
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) {
|
func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) {
|
||||||
cfg := config.Defaults()
|
cfg := config.Defaults()
|
||||||
cfg.WeatherAPI.Timezone = "America/Chicago"
|
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 {
|
type recordingRenderer struct {
|
||||||
renderCalls int
|
renderCalls int
|
||||||
runCalls int
|
runCalls int
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Package struct {
|
|||||||
Metadata Metadata `json:"metadata"`
|
Metadata Metadata `json:"metadata"`
|
||||||
Daily *Daily `json:"daily,omitempty"`
|
Daily *Daily `json:"daily,omitempty"`
|
||||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||||
|
Weekend *Weekend `json:"weekend,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
|
|||||||
@@ -44,11 +44,18 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
|||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
alerts := map[string]forecast.AlertOverlap{}
|
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
day := buildOutlookDay(summary)
|
day := buildOutlookDay(summary)
|
||||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
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
|
key := alert.Event
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = alert.Headline
|
key = alert.Headline
|
||||||
@@ -63,10 +70,11 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
|||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
out := make([]forecast.AlertOverlap, 0, len(keys))
|
||||||
for _, key := range 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 {
|
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
|
var stderr bytes.Buffer
|
||||||
runner := Runner{Clock: fixedClock()}
|
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 {
|
if err == nil {
|
||||||
t.Fatal("Run() error = nil, want app not implemented error")
|
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) {
|
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func Validate(pkg Package) error {
|
|||||||
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
|
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
|
||||||
return fmt.Errorf("briefing.metadata.reportId must match report.id")
|
return fmt.Errorf("briefing.metadata.reportId must match report.id")
|
||||||
}
|
}
|
||||||
if pkg.Briefing.Daily == nil && 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 fmt.Errorf("briefing report content is required")
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func TestMarshalDeterministic(t *testing.T) {
|
||||||
pkg, err := Build(validBriefingPackage())
|
pkg, err := Build(validBriefingPackage())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ func TestWeekendPeriodCalculation(t *testing.T) {
|
|||||||
end string
|
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: "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 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: "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"},
|
{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) {
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
group, err := reportGroup(resolved.Definition.ID)
|
group, err := reportGroup(resolved.Definition.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
paths, err := s.Paths(resolved)
|
dirs, err := s.metadataDirectories(resolved, group)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var candidates []Metadata
|
||||||
for _, entry := range entries {
|
for _, dir := range dirs {
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
entries, err := os.ReadDir(dir)
|
||||||
continue
|
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())
|
for _, entry := range entries {
|
||||||
var metadata Metadata
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
|
||||||
if err := readJSON(path, &metadata); err != nil {
|
continue
|
||||||
return nil, err
|
}
|
||||||
|
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 {
|
if len(candidates) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -212,12 +213,39 @@ func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.R
|
|||||||
}, nil
|
}, 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 {
|
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
|
||||||
switch group {
|
switch group {
|
||||||
case "daily":
|
case "daily":
|
||||||
return prior == report.DailyToday || prior == report.DailyTomorrow
|
return prior == report.DailyToday || prior == report.DailyTomorrow
|
||||||
case "three-day":
|
case "three-day":
|
||||||
return prior == report.ThreeDay && current == report.ThreeDay
|
return prior == report.ThreeDay && current == report.ThreeDay
|
||||||
|
case "weekend":
|
||||||
|
return prior == report.Weekend && current == report.Weekend
|
||||||
default:
|
default:
|
||||||
return false
|
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")
|
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) {
|
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return briefing.Package{}, fmt.Errorf("briefing path is required")
|
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) {
|
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
|
||||||
cfg := config.Defaults().Workspace
|
cfg := config.Defaults().Workspace
|
||||||
cfg.Root = t.TempDir()
|
cfg.Root = t.TempDir()
|
||||||
@@ -287,6 +359,26 @@ func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
|
|||||||
return 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 {
|
func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
||||||
return briefing.Package{
|
return briefing.Package{
|
||||||
Metadata: briefing.Metadata{
|
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 {
|
func pathsString(paths ArtifactPaths) string {
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
paths.Briefing,
|
paths.Briefing,
|
||||||
|
|||||||
Reference in New Issue
Block a user