Add Tomorrow planning brief generation
This commit is contained in:
@@ -3,14 +3,15 @@
|
|||||||
`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 a Daily Markdown report through
|
The application can currently generate Daily Today and Daily Tomorrow Markdown
|
||||||
`scriptorium`, with inspectable briefing, prompt input, preflight, report, and
|
reports through `scriptorium`, with inspectable briefing, prompt input,
|
||||||
metadata artifacts under the configured workspace.
|
preflight, report, and metadata artifacts under the configured workspace.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
|
||||||
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
21
docs/cli.md
21
docs/cli.md
@@ -1,6 +1,7 @@
|
|||||||
# Weatherreporter CLI
|
# Weatherreporter CLI
|
||||||
|
|
||||||
`weatherreporter generate daily` currently writes a Daily Markdown report
|
`weatherreporter generate daily`, `weatherreporter generate tomorrow`, and
|
||||||
|
`weatherreporter run evening` currently write Daily-family Markdown reports
|
||||||
through `scriptorium`, after writing managed preparation artifacts and running
|
through `scriptorium`, after writing managed preparation artifacts and running
|
||||||
`scriptorium render` as a preflight check. Other report generation and scheduled
|
`scriptorium render` as a preflight check. Other report generation and scheduled
|
||||||
runs still resolve configuration, report definitions, and valid periods, then
|
runs still resolve configuration, report definitions, and valid periods, then
|
||||||
@@ -19,6 +20,13 @@ invokes `scriptorium run --input data_package=<managed_path> --out <managed_repo
|
|||||||
When `--out` is supplied, it also writes a copy of the Markdown report to that
|
When `--out` is supplied, it also writes a copy of the Markdown report to that
|
||||||
path.
|
path.
|
||||||
|
|
||||||
|
For tomorrow planning:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
weatherreporter generate tomorrow --out ./tomorrow.md
|
||||||
|
weatherreporter run evening
|
||||||
|
```
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -31,10 +39,11 @@ weatherreporter run morning
|
|||||||
weatherreporter run evening
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
`generate daily` writes a briefing snapshot, prompt input data package, render
|
`generate daily` and `generate tomorrow` write a briefing snapshot, prompt input
|
||||||
preflight output, Markdown report, and metadata file under the configured
|
data package, render preflight output, Markdown report, and metadata file under
|
||||||
workspace. Other `generate` commands resolve one report request and stop before
|
the configured workspace. `run evening` generates the Tomorrow Planning Brief.
|
||||||
report generation. `run` commands resolve a scheduled batch request and stop
|
Other `generate` commands resolve one report request and stop before report
|
||||||
|
generation. Other `run` commands resolve a scheduled batch request and stop
|
||||||
before execution.
|
before execution.
|
||||||
|
|
||||||
## Flags
|
## Flags
|
||||||
@@ -43,7 +52,7 @@ before execution.
|
|||||||
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
|
||||||
- `--units VALUE`: override configured Weather API units.
|
- `--units VALUE`: override configured Weather API units.
|
||||||
- `--tz NAME`: override configured Weather API timezone.
|
- `--tz NAME`: override configured Weather API timezone.
|
||||||
- `--out PATH`: optional Markdown report copy for `generate daily`; reserved for later generated report output on other `generate` commands.
|
- `--out PATH`: optional Markdown report copy for `generate daily` and `generate tomorrow`; 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`.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ This document describes the implemented briefing package boundary.
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
`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 the
|
forecast summaries and report metadata. The package currently implements Daily
|
||||||
Daily briefing only.
|
Today and Daily Tomorrow briefing content.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ Output:
|
|||||||
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
|
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
|
||||||
outdoor window inputs, narrative periods, discussion context, and weather
|
outdoor window inputs, narrative periods, discussion context, and weather
|
||||||
story context when available.
|
story context when available.
|
||||||
|
- Daily Tomorrow also includes planning inputs for morning readiness,
|
||||||
|
commute/school/workday concerns, and what may change overnight.
|
||||||
- Briefing JSON is written atomically by `briefing.Save`.
|
- Briefing JSON is written atomically by `briefing.Save`.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ Daily comparison currently detects:
|
|||||||
- snow, ice, and thunder risk changes
|
- snow, ice, and thunder risk changes
|
||||||
|
|
||||||
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.
|
section in the data package. Daily Today and Daily Tomorrow are compatible for
|
||||||
|
same-valid-date comparison through the report registry.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ Workspace subdirectories must be relative paths that stay under
|
|||||||
Managed artifact names use RunID, which is generated from report generation time
|
Managed artifact names use RunID, which is generated from report generation time
|
||||||
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
and report ID. Daily metadata is stored beside Daily briefing snapshots by valid
|
||||||
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
local date. Prior Daily snapshot lookup reads metadata for the same valid local
|
||||||
date and returns the latest earlier run. The store can load a briefing snapshot
|
date and returns the latest earlier compatible Daily Today or Daily Tomorrow
|
||||||
by path for structured comparison. The store prepares the managed Markdown
|
run. The store can load a briefing snapshot by path for structured comparison.
|
||||||
report path before `scriptorium run` writes it.
|
The store prepares the managed Markdown report path before `scriptorium run`
|
||||||
|
writes it.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
## Normal Workflow
|
## Normal Workflow
|
||||||
|
|
||||||
The implemented Daily generation workflow is:
|
The implemented Daily-family generation workflows are:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
weatherreporter generate daily --date 2026-05-29
|
weatherreporter generate daily --date 2026-05-29
|
||||||
|
weatherreporter generate tomorrow
|
||||||
|
weatherreporter run evening
|
||||||
```
|
```
|
||||||
|
|
||||||
The command fetches weather data, builds the Daily briefing, builds the prompt
|
These commands fetch weather data, build a Daily briefing for the resolved
|
||||||
input data package, runs `scriptorium render`, runs `scriptorium run`, and
|
valid date, build the prompt input data package, run `scriptorium render`, run
|
||||||
writes inspectable artifacts under the configured workspace.
|
`scriptorium run`, and write inspectable artifacts under the configured
|
||||||
|
workspace. The evening run resolves only the Tomorrow Planning Brief.
|
||||||
|
|
||||||
## Filesystem Layout
|
## Filesystem Layout
|
||||||
|
|
||||||
@@ -36,8 +39,9 @@ workspace/
|
|||||||
<run_id>.md
|
<run_id>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The Markdown report is written to the managed report path. When `--out` is
|
The Markdown report is written to a RunID-managed report path. When `--out` is
|
||||||
provided, the managed report is also copied to that path.
|
provided to `generate daily` or `generate tomorrow`, the managed report is also
|
||||||
|
copied to that path.
|
||||||
|
|
||||||
## Run Identifiers
|
## Run Identifiers
|
||||||
|
|
||||||
@@ -67,7 +71,9 @@ Each Daily generation writes metadata that links:
|
|||||||
|
|
||||||
When a prior comparable Daily briefing snapshot exists for the same valid local
|
When a prior comparable Daily briefing snapshot exists for the same valid local
|
||||||
date, the app compares structured briefing data before writing the prompt input
|
date, the app compares structured briefing data before writing the prompt input
|
||||||
data package. Meaningful changes are included under `recentChanges.items`.
|
data package. Daily Today and Daily Tomorrow can compare with each other when
|
||||||
|
they cover the same valid local date. Meaningful changes are included under
|
||||||
|
`recentChanges.items`.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type GenerateRequest struct {
|
|||||||
Config config.Config
|
Config config.Config
|
||||||
Report ReportKind
|
Report ReportKind
|
||||||
OutputPath string
|
OutputPath string
|
||||||
|
Now time.Time
|
||||||
Date time.Time
|
Date time.Time
|
||||||
StormStart time.Time
|
StormStart time.Time
|
||||||
StormEnd time.Time
|
StormEnd time.Time
|
||||||
@@ -49,6 +50,7 @@ type GenerateRequest struct {
|
|||||||
type BatchRequest struct {
|
type BatchRequest struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Batch BatchKind
|
Batch BatchKind
|
||||||
|
Now time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchBundleRequest struct {
|
type FetchBundleRequest struct {
|
||||||
@@ -97,11 +99,15 @@ type Renderer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Generate(ctx context.Context, req GenerateRequest) error {
|
func Generate(ctx context.Context, req GenerateRequest) error {
|
||||||
resolved, err := ResolveGenerate(req, time.Now())
|
now := req.Now
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
resolved, err := ResolveGenerate(req, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resolved.Definition.ID == report.DailyToday {
|
if isDailyReport(resolved.Definition.ID) {
|
||||||
_, err := GenerateDailyReport(ctx, DailyReportRequest{
|
_, err := GenerateDailyReport(ctx, DailyReportRequest{
|
||||||
Config: req.Config,
|
Config: req.Config,
|
||||||
Resolved: resolved,
|
Resolved: resolved,
|
||||||
@@ -113,13 +119,35 @@ func Generate(ctx context.Context, req GenerateRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunBatch(ctx context.Context, req BatchRequest) error {
|
func RunBatch(ctx context.Context, req BatchRequest) error {
|
||||||
_ = ctx
|
now := req.Now
|
||||||
if _, err := ResolveBatch(req, time.Now()); err != nil {
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
resolvedReports, err := ResolveBatch(req, now)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if req.Batch == BatchEvening {
|
||||||
|
for _, resolved := range resolvedReports {
|
||||||
|
if !isDailyReport(resolved.Definition.ID) {
|
||||||
|
return fmt.Errorf("run is not implemented")
|
||||||
|
}
|
||||||
|
if _, err := GenerateDailyReport(ctx, DailyReportRequest{
|
||||||
|
Config: req.Config,
|
||||||
|
Resolved: resolved,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("run is not implemented")
|
return fmt.Errorf("run is not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isDailyReport(id report.ID) bool {
|
||||||
|
return id == report.DailyToday || id == report.DailyTomorrow
|
||||||
|
}
|
||||||
|
|
||||||
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
|
||||||
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -414,6 +414,115 @@ func TestGenerateDailyReportIncludesRecentChangesFromPriorSnapshot(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) {
|
||||||
|
server := dailyBundleServer(t)
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.WeatherAPI.BaseURL = server.URL + "/"
|
||||||
|
cfg.WeatherAPI.Timezone = "America/Chicago"
|
||||||
|
cfg.Workspace.Root = t.TempDir()
|
||||||
|
resolved, err := ResolveGenerate(GenerateRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Report: ReportTomorrow,
|
||||||
|
}, mustParse("2026-05-29T18:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate() error = %v", err)
|
||||||
|
}
|
||||||
|
renderer := &recordingRenderer{
|
||||||
|
renderResult: &scriptorium.RenderResult{ExitCode: 0},
|
||||||
|
runResult: &scriptorium.RunResult{ExitCode: 0},
|
||||||
|
runBody: "# Tomorrow Planning Brief\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: resolved,
|
||||||
|
Renderer: renderer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Briefing.Metadata.ReportID != report.DailyTomorrow || result.Briefing.Metadata.Variant != "tomorrow" {
|
||||||
|
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Briefing.Metadata.ReportID, result.Briefing.Metadata.Variant)
|
||||||
|
}
|
||||||
|
if result.Briefing.Daily.ForecastSummaryDate != "2026-05-30" {
|
||||||
|
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", result.Briefing.Daily.ForecastSummaryDate)
|
||||||
|
}
|
||||||
|
if result.Briefing.Daily.Planning == nil {
|
||||||
|
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
||||||
|
}
|
||||||
|
if !strings.Contains(filepath.Base(result.ReportPath), "daily_tomorrow") {
|
||||||
|
t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(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: ReportDaily,
|
||||||
|
Date: mustParse("2026-05-30T12:00:00-05:00"),
|
||||||
|
}, mustParse("2026-05-29T17:00:00-05:00"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveGenerate(prior) error = %v", err)
|
||||||
|
}
|
||||||
|
priorBriefing := priorDailyBriefing(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: ReportTomorrow,
|
||||||
|
}, mustParse("2026-05-29T18: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: "# Tomorrow Planning Brief\n",
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := GenerateDailyReport(context.Background(), DailyReportRequest{
|
||||||
|
Config: cfg,
|
||||||
|
Resolved: currentResolved,
|
||||||
|
Renderer: renderer,
|
||||||
|
Store: store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDailyReport() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.PriorSnapshot == nil {
|
||||||
|
t.Fatal("PriorSnapshot = nil, want compatible prior daily snapshot")
|
||||||
|
}
|
||||||
|
if len(result.RecentChanges) == 0 {
|
||||||
|
t.Fatal("RecentChanges length = 0, want changes from compatible prior daily 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"
|
||||||
@@ -446,9 +555,9 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/conditions/current":
|
case "/conditions/current":
|
||||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||||
case "/forecast/hourly":
|
case "/forecast/hourly":
|
||||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
||||||
case "/forecast/narrative":
|
case "/forecast/narrative":
|
||||||
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`))
|
||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Daily struct {
|
|||||||
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
Dayparts []forecast.DaypartSummary `json:"dayparts"`
|
||||||
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||||
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
|
||||||
|
Planning *TomorrowPlanning `json:"planning,omitempty"`
|
||||||
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
|
||||||
Discussion DiscussionContext `json:"discussion,omitempty"`
|
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||||
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||||
@@ -42,6 +43,12 @@ type OutdoorWindow struct {
|
|||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TomorrowPlanning struct {
|
||||||
|
MorningReadiness []string `json:"morningReadiness,omitempty"`
|
||||||
|
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
|
||||||
|
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type DiscussionContext struct {
|
type DiscussionContext struct {
|
||||||
Product string `json:"product,omitempty"`
|
Product string `json:"product,omitempty"`
|
||||||
KeyMessages []string `json:"keyMessages,omitempty"`
|
KeyMessages []string `json:"keyMessages,omitempty"`
|
||||||
@@ -74,6 +81,9 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
|||||||
ForecastSummaryDate: summary.Date,
|
ForecastSummaryDate: summary.Date,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||||
|
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||||
|
}
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +132,124 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
|
|||||||
return OutdoorWindows{Best: best, Worst: worst}
|
return OutdoorWindows{Best: best, Worst: worst}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
|
||||||
|
planning := &TomorrowPlanning{}
|
||||||
|
morning := daypartNamed(summary.Dayparts, "morning")
|
||||||
|
if morning != nil {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
|
||||||
|
}
|
||||||
|
if len(planning.MorningReadiness) == 0 {
|
||||||
|
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, daypart := range summary.Dayparts {
|
||||||
|
if daypart.Name == "overnight" || daypart.Name == "evening" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
|
||||||
|
}
|
||||||
|
for _, alert := range summary.AlertOverlaps {
|
||||||
|
if alert.Event != "" {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
|
||||||
|
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
|
||||||
|
}
|
||||||
|
|
||||||
|
overnight := daypartNamed(summary.Dayparts, "overnight")
|
||||||
|
if overnight != nil {
|
||||||
|
planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, overnightWatchNotes(*overnight)...)
|
||||||
|
}
|
||||||
|
if len(planning.OvernightChangeWatch) == 0 {
|
||||||
|
planning.OvernightChangeWatch = append(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return planning
|
||||||
|
}
|
||||||
|
|
||||||
|
func readinessNotes(daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
|
||||||
|
notes = append(notes, fmt.Sprintf("Morning precipitation chance peaks near %.0f%%.", daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Thunder {
|
||||||
|
notes = append(notes, "Morning thunder could affect departure timing.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
|
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Fog {
|
||||||
|
notes = append(notes, "Morning fog could reduce visibility.")
|
||||||
|
}
|
||||||
|
if daypart.Temperature.Min != nil && *daypart.Temperature.Min <= 32 {
|
||||||
|
notes = append(notes, "Morning temperatures may be at or below freezing.")
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func concernNotes(daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
prefix := titleWord(daypart.Name)
|
||||||
|
if prefix == "" {
|
||||||
|
prefix = "Daytime"
|
||||||
|
}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 40 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s precipitation chance reaches %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Thunder {
|
||||||
|
notes = append(notes, prefix+" thunder may disrupt outdoor plans.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
|
notes = append(notes, prefix+" wintry weather may affect travel.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Heat {
|
||||||
|
notes = append(notes, prefix+" heat may require extra hydration and breaks.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Cold {
|
||||||
|
notes = append(notes, prefix+" cold may require extra layers.")
|
||||||
|
}
|
||||||
|
if len(daypart.AlertOverlaps) > 0 {
|
||||||
|
notes = append(notes, prefix+" alert overlap needs attention.")
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("Overnight precipitation timing may shift; current peak is near %.0f%%.", daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("Overnight gusts may reach %.0f mph before morning plans begin.", daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Thunder {
|
||||||
|
notes = append(notes, "Overnight storms could change morning impacts.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
|
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
||||||
|
}
|
||||||
|
if len(daypart.AlertOverlaps) > 0 {
|
||||||
|
notes = append(notes, "Overnight alert timing could affect the morning setup.")
|
||||||
|
}
|
||||||
|
return appendUnique(nil, notes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.DaypartSummary {
|
||||||
|
for i := range dayparts {
|
||||||
|
if strings.EqualFold(dayparts[i].Name, name) {
|
||||||
|
return &dayparts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
||||||
if discussion == nil {
|
if discussion == nil {
|
||||||
return DiscussionContext{}
|
return DiscussionContext{}
|
||||||
@@ -276,3 +404,28 @@ func dedupe(values []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendUnique(values []string, candidates ...string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, value := range values {
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[candidate]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[candidate] = struct{}{}
|
||||||
|
values = append(values, candidate)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleWord(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToUpper(value[:1]) + value[1:]
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||||
@@ -122,6 +123,77 @@ func TestDailyBriefingAlertExclusion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
||||||
|
location := mustLocation(t)
|
||||||
|
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
|
||||||
|
Now: mustParse("2026-05-29T18:00:00-05:00"),
|
||||||
|
Location: location,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve tomorrow: %v", err)
|
||||||
|
}
|
||||||
|
precip := 70.0
|
||||||
|
wind := 34.0
|
||||||
|
summary := &forecast.DailySummary{
|
||||||
|
Date: "2026-05-30",
|
||||||
|
Period: resolved.ValidPeriod,
|
||||||
|
Dayparts: []forecast.DaypartSummary{
|
||||||
|
{
|
||||||
|
Name: "overnight",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||||
|
End: mustParse("2026-05-30T06:00:00-05:00"),
|
||||||
|
},
|
||||||
|
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||||
|
Value: 40,
|
||||||
|
Time: mustParse("2026-05-30T03:00:00-05:00"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "morning",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: mustParse("2026-05-30T06:00:00-05:00"),
|
||||||
|
End: mustParse("2026-05-30T12:00:00-05:00"),
|
||||||
|
},
|
||||||
|
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||||
|
Value: precip,
|
||||||
|
Time: mustParse("2026-05-30T08:00:00-05:00"),
|
||||||
|
},
|
||||||
|
PeakWindGust: &forecast.TimedValue{
|
||||||
|
Value: wind,
|
||||||
|
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Indicators: forecast.Indicators{Thunder: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := BuildDaily(BuildContext{
|
||||||
|
Resolved: resolved,
|
||||||
|
Units: "us",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
}, summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildDaily() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
|
||||||
|
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
|
||||||
|
}
|
||||||
|
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
|
||||||
|
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
|
||||||
|
}
|
||||||
|
if pkg.Daily.Planning == nil {
|
||||||
|
t.Fatal("Planning = nil, want tomorrow planning inputs")
|
||||||
|
}
|
||||||
|
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
|
||||||
|
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
|
||||||
|
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveBriefingPackage(t *testing.T) {
|
func TestSaveBriefingPackage(t *testing.T) {
|
||||||
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
|
||||||
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Options:
|
|||||||
--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 weather API units.
|
--units VALUE Override weather API units.
|
||||||
--tz NAME Override weather API timezone.
|
--tz NAME Override weather API timezone.
|
||||||
--out PATH Write an extra Markdown report copy for generate daily.
|
--out PATH Write an extra Markdown report copy for generate daily or tomorrow.
|
||||||
`
|
`
|
||||||
|
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
@@ -57,7 +57,7 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
|
|||||||
}
|
}
|
||||||
return app.Generate(ctx, req)
|
return app.Generate(ctx, req)
|
||||||
case "run":
|
case "run":
|
||||||
req, err := resolveRun(args[1:])
|
req, err := r.resolveRun(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,9 @@ type generateOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
||||||
|
if r.Clock == nil {
|
||||||
|
r.Clock = timeutil.SystemClock{}
|
||||||
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
|
||||||
}
|
}
|
||||||
@@ -112,6 +115,7 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Report: report,
|
Report: report,
|
||||||
OutputPath: opts.Output,
|
OutputPath: opts.Output,
|
||||||
|
Now: r.Clock.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch report {
|
switch report {
|
||||||
@@ -147,7 +151,10 @@ func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRun(args []string) (app.BatchRequest, error) {
|
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
|
||||||
|
if r.Clock == nil {
|
||||||
|
r.Clock = timeutil.SystemClock{}
|
||||||
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
|
||||||
}
|
}
|
||||||
@@ -167,7 +174,11 @@ func resolveRun(args []string) (app.BatchRequest, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return app.BatchRequest{}, err
|
return app.BatchRequest{}, err
|
||||||
}
|
}
|
||||||
return app.BatchRequest{Config: cfg, Batch: batch}, nil
|
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRun(args []string) (app.BatchRequest, error) {
|
||||||
|
return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) {
|
func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) {
|
||||||
|
|||||||
@@ -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", "tomorrow", "--units", "metric"}, &stdout, &stderr)
|
err := runner.Run(context.Background(), []string{"generate", "three-day", "--units", "metric"}, &stdout, &stderr)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Run() error = nil, want app not implemented error")
|
t.Fatal("Run() error = nil, want app not implemented error")
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,96 @@ func TestRunGenerateReturnsNotImplementedAfterResolution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunGenerateTomorrowWritesMarkdownReport(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, "tomorrow.md")
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
runner := Runner{Clock: fixedClock()}
|
||||||
|
|
||||||
|
err := runner.Run(context.Background(), []string{
|
||||||
|
"generate", "tomorrow",
|
||||||
|
"--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", "daily", "2026-05-30", "*.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), `"daily_tomorrow"`) || !strings.Contains(string(data), `"planning"`) {
|
||||||
|
t.Fatalf("data package output missing tomorrow content:\n%s", string(data))
|
||||||
|
}
|
||||||
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob managed report: %v", err)
|
||||||
|
}
|
||||||
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
||||||
|
t.Fatalf("managed reports = %#v, want tomorrow report", reportMatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEveningGeneratesTomorrowReport(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", "evening",
|
||||||
|
"--config", configPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-30", "*.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)
|
||||||
|
}
|
||||||
|
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("glob managed report: %v", err)
|
||||||
|
}
|
||||||
|
if len(reportMatches) != 1 || !strings.Contains(filepath.Base(reportMatches[0]), "daily_tomorrow") {
|
||||||
|
t.Fatalf("managed reports = %#v, want only tomorrow report", reportMatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
||||||
server := dailyServer(t)
|
server := dailyServer(t)
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -272,9 +362,9 @@ func dailyServer(t *testing.T) *httptest.Server {
|
|||||||
case "/conditions/current":
|
case "/conditions/current":
|
||||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
||||||
case "/forecast/hourly":
|
case "/forecast/hourly":
|
||||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
||||||
case "/forecast/narrative":
|
case "/forecast/narrative":
|
||||||
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`))
|
||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
|
|||||||
Reference in New Issue
Block a user