16 Commits

73 changed files with 11191 additions and 2 deletions

3
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Compiled application binary # Compiled application binary
weatherreporter /weatherreporter
# ---> Go # ---> Go
# If you prefer the allow list template instead of the deny list, see community template: # If you prefer the allow list template instead of the deny list, see community template:
@@ -71,4 +71,3 @@ Icon
Network Trash Folder Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk

View File

@@ -1,2 +1,28 @@
# weatherreporter # weatherreporter
`weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data.
The application can currently generate Daily Today, Daily Tomorrow, 3-Day
Outlook, Weekend Outlook, and manual Storm Report Markdown reports through `scriptorium`, with
inspectable briefing, prompt input, preflight, report, and metadata artifacts
under the configured workspace.
## Quickstart
```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
```
## Documentation
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md)
- [Architecture policy](docs/policy/architecture.md)
- [Development policy](docs/policy/development.md)
- [Implementation roadmap](docs/roadmap/initial.md)

View File

@@ -0,0 +1,16 @@
package main
import (
"context"
"fmt"
"os"
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
)
func main() {
if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
os.Exit(1)
}
}

107
docs/cli.md Normal file
View File

@@ -0,0 +1,107 @@
# Weatherreporter CLI
`weatherreporter generate daily`, `weatherreporter generate tomorrow`,
`weatherreporter generate three-day`, `weatherreporter generate weekend`,
`weatherreporter generate storm`,
`weatherreporter run morning`, and `weatherreporter run evening` currently
write Markdown reports through `scriptorium`, after writing managed preparation
artifacts and running `scriptorium render` as a preflight check.
## Shortest Useful Command
```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
```
The command parses flags, loads configuration, fetches weather data, builds a
Daily briefing, writes workspace artifacts, invokes
`scriptorium render --input data_package=<managed_path> --format json`, then
invokes `scriptorium run --input data_package=<managed_path> --out <managed_report>`.
When `--out` is supplied, it also writes a copy of the Markdown report to that
path.
For tomorrow planning:
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter run evening
```
For the 3-Day Outlook:
```sh
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
```
For a focused manual Storm Report:
```sh
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
```
## Command Overview
```text
weatherreporter generate daily
weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
weatherreporter run morning
weatherreporter run evening
weatherreporter inspect reports
weatherreporter inspect metadata RUN_ID
weatherreporter inspect briefing RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
`generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, and `generate storm` write a briefing snapshot, prompt
input data package, render preflight output, Markdown report, and metadata file
under the configured workspace. `generate storm` requires explicit `--start`
and `--end` bounds for the event window. `run evening` generates the Tomorrow
Planning Brief. `run morning` generates Daily Today and the 3-Day Outlook, plus
Weekend Outlook except on Sunday. Run commands continue remaining reports after
an independent report failure, print a JSON aggregate summary to stdout, write
compact report status logs to stderr, and return nonzero when any report
failed.
`inspect` commands read the configured workspace and emit JSON to stdout. They
do not fetch weather data or invoke `scriptorium`.
## Flags
- `-h`, `--help`: show help.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone.
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, `generate weekend`, and `generate storm`.
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum report records for `inspect reports`; defaults to 20,
and `0` means no limit.
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
timestamps with explicit offsets.
## Inspection
```sh
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
weatherreporter inspect briefing 20260529T100000.000000000Z_daily_today
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
```
`inspect reports` lists recent generated runs with artifact paths and warning
counts. The other commands require a RunID. `inspect prior` returns the prior
comparable snapshot metadata selected from stored metadata, or `null` when no
prior comparable snapshot exists. `inspect sources` shows source provenance and
source warnings without dumping full weather payloads.

46
docs/config.md Normal file
View File

@@ -0,0 +1,46 @@
# Weatherreporter Configuration
Configuration is loaded from `/usr/local/etc/weatherreporter/config.yml` by
default. Use `--config PATH` to load a different file. CLI flags override file
values.
If the default file is absent, built-in defaults are used.
## Minimal Config
```yaml
weather_api:
base_url: https://weather.api.example.com/
```
## Production-Oriented Config
See [examples/config.yml](../examples/config.yml).
## Reference
- `weather_api.base_url`: single Weather API endpoint base URL, required when fetching weather data.
- `weather_api.timeout`: HTTP timeout duration. Default: `10s`.
- `weather_api.precision`: numeric precision hint. Default: `1`.
- `weather_api.units`: Weather API units. Default: `us`.
- `weather_api.timezone`: report timezone. Accepts IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, and UTC offsets such as `-5` or `+09:30`. Default: `Chicago`.
- `weather_api.format`: Weather API response format. Default: `json`.
- `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`.
- `missing_source.sources`: optional per-source missing-source policy overrides.
- `scriptorium.binary`: `scriptorium` executable name. Default: `scriptorium`.
- `scriptorium.config_path`: optional `scriptorium` config path.
- `scriptorium.profile`: optional `scriptorium` profile.
- `scriptorium.timeout`: subprocess timeout. Default: `2m`.
- `scriptorium.extra_args`: optional extra arguments reserved for the adapter.
- `workspace.root`: workspace root. Default: `workspace`.
- `workspace.snapshots_dir`: snapshot directory under the workspace.
- `workspace.reports_dir`: managed report directory under the workspace.
- `workspace.data_packages_dir`: prompt input package directory under the workspace.
- `workspace.preflight_dir`: preflight output directory under the workspace.
- `reports.output_dir`: report output directory. Default: `reports`.
- `reports.paths`: optional report-specific output paths.
- `dayparts`: named daypart definitions with `start` and `end` `HH:MM` values.
- `recent_change.temperature_degrees`: temperature change threshold.
- `recent_change.precip_probability_points`: precipitation probability threshold.
- `recent_change.wind_gust_miles_per_hour`: wind gust change threshold.
- `recent_change.precip_timing_shift_minutes`: precipitation timing shift threshold.

84
docs/internal/briefing.md Normal file
View File

@@ -0,0 +1,84 @@
# Briefing Internals
This document describes the implemented briefing package boundary.
## Purpose
`internal/briefing` builds structured report-specific briefing packages from
forecast summaries and report metadata. The package currently implements Daily
Today, Daily Tomorrow, 3-Day Outlook, Weekend Outlook, and Storm Report
briefing content.
## Inputs and Outputs
Inputs:
- resolved report definition and valid period
- forecast bundle
- derived forecast summary or summaries
- configured units and timezone
Output:
- `briefing.Package` JSON containing common metadata and report-specific
briefing content
## Boundaries
- Briefings are structured weather facts and context for later prompt input.
- This package does not fetch weather data, compare prior snapshots, build
`scriptorium` data packages, or render final report prose.
## Behavior
- Common metadata includes schema version, RunID, report ID, variant, prompt ID,
generation time, units, timezone, valid period, source location, source
provenance, hashes, and source warnings.
- Daily content includes bottom-line inputs, daypart summaries, relevant alerts,
outdoor window inputs, narrative periods, discussion context, and weather
story context when available.
- Daily Tomorrow also includes planning inputs for morning readiness,
commute/school/workday concerns, and what may change overnight.
- 3-Day content includes one summary per local day or partial day, with overall
character, temperature range, precipitation, wind, risk, outdoor-window, and
alert inputs, plus broader discussion and weather-story context when
available.
- 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.
- Storm content uses the explicit event window and includes event headline
inputs, hazards, most-likely scenario inputs, reasonable worst-case inputs,
confidence and uncertainty inputs, watch items, active alerts, relevant
hourly and narrative forecast periods, and available discussion or weather
story context.
- Briefing JSON is written atomically by `briefing.Save`.
## Failure Behavior
- Daily briefing construction requires a Daily report definition and a derived
daily forecast summary.
- 3-Day briefing construction requires a 3-Day report definition and at least
one derived daily summary in the outlook period.
- Weekend briefing construction requires a Weekend report definition and at
least one derived daily summary in the weekend period.
- Storm briefing construction requires a Storm Report definition and a forecast
bundle.
- Save failures include path and operation context.
## Tests
Inspect:
- `internal/briefing/daily_test.go`
- `internal/briefing/three_day_test.go`
- `internal/briefing/weekend_test.go`
- `internal/briefing/storm_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Weather facts come from normalized and derived source data.
- Briefing output remains JSON-inspectable.
- LLM prompt input packaging and `scriptorium` execution remain outside this
boundary.

74
docs/internal/changes.md Normal file
View File

@@ -0,0 +1,74 @@
# Changes Internals
This document describes the implemented structured change comparison boundary.
## Purpose
`internal/changes` compares current and prior structured briefing snapshots and
produces compact change records for prompt input data packages.
## Inputs and Outputs
Inputs:
- prior briefing package
- current briefing package
- configured Recent Changes thresholds
Output:
- ordered `changes.Change` records with type, message, previous value, and
current value where useful
## Boundaries
- This package compares structured briefing data only.
- It does not read state directly, render Markdown, invoke `scriptorium`, or
compare generated report text.
## Config Fields Used
The app maps these config fields into comparison thresholds:
- `recent_change.temperature_degrees`
- `recent_change.precip_probability_points`
- `recent_change.wind_gust_miles_per_hour`
- `recent_change.precip_timing_shift_minutes`
## Behavior
Daily, 3-Day, and Weekend comparison currently detect:
- temperature changes crossing configured thresholds
- precipitation probability and timing changes
- alert additions and removals
- peak wind gust changes
- snow, ice, and thunder risk 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
same-valid-date comparison through the report registry. 3-Day Outlook compares
with prior 3-Day Outlook snapshots for the same valid local date. Weekend
Outlook compares with prior Weekend Outlook snapshots for the same weekend
window.
## Failure Behavior
Daily comparison requires both inputs to contain Daily briefing content. 3-Day
comparison requires both inputs to contain 3-Day briefing content. Weekend
comparison requires both inputs to contain Weekend briefing content.
## Tests
Inspect:
- `internal/changes/daily_test.go`
- `internal/changes/three_day_test.go`
- `internal/changes/weekend_test.go`
- `internal/app/app_test.go`
## Invariants
- Recent Changes are based on structured snapshots, not Markdown report text.
- Comparison thresholds come from configuration.
- The comparison output remains compact enough for prompt input.

View File

@@ -0,0 +1,68 @@
# Forecast Derivation Internals
This document describes the implemented deterministic forecast summarization
boundary.
## Purpose
`internal/forecast` converts a normalized forecast bundle into inspectable
daily and multi-day daypart summaries. These summaries are structured data for
later briefing builders; they are not rendered report text.
## Inputs and Outputs
Inputs:
- `forecast.Bundle`
- local date and timezone
- report period, for multi-day summaries
- configured daypart definitions with `HH:MM` start and end values
Output:
- `forecast.DailySummary` with a civil-day period, daypart summaries, selected
narrative periods, alert overlaps, discussion context, source warnings, and
source provenance.
- `forecast.BuildPeriodDailySummaries` output with one clipped daily summary
for each local day or partial day in a report period.
## Boundaries
- This package groups and summarizes already-normalized forecast data.
- It does not fetch weather data, resolve report definitions, compare prior
snapshots, build prompt input packages, or call `scriptorium`.
## Behavior
- Daypart windows use half-open intervals.
- Overnight dayparts are supported when the end clock is not after the start
clock.
- Hourly forecast periods are selected by overlap with the daypart window.
- Each daypart computes temperature range, apparent-temperature range, maximum
precipitation probability, peak wind speed, peak wind gust, dominant
condition, notable conditions, and basic weather indicators.
- Alerts are selected by overlap with the daily period and each daypart.
- Narrative periods and discussion context are selected as broader source
context for later briefing builders.
- Multi-day period summaries clip the first and last local days to the resolved
report period before selecting hourly periods and alerts.
## Failure Behavior
- Missing hourly forecast data returns an error.
- Invalid daypart definitions return actionable parse errors.
- Alert records without parseable RFC3339 start/end fields are skipped.
## Tests
Inspect:
- `internal/forecast/derive_test.go`
- `internal/timeutil/periods_test.go`
## Invariants
- Weather facts come from normalized source data, not generated prose.
- Outputs remain JSON-inspectable.
- Forecast derivation remains independent of CLI, HTTP adapters, and report
registry behavior.

View File

@@ -0,0 +1,54 @@
# Prompt Input Internals
This document describes the implemented prompt input package boundary.
## Purpose
`internal/promptinput` converts a structured briefing package into the
`data_package` JSON file passed to `scriptorium` prompts.
## Inputs and Outputs
Input:
- `briefing.Package` containing Daily-family, 3-Day Outlook, Weekend Outlook,
or Storm Report content
Output:
- `promptinput.Package` JSON with report metadata, briefing content, source
warnings, RunID, and a Recent Changes section.
## Boundaries
- This package owns the prompt input schema and required-field validation.
- It does not fetch weather data, compute forecast summaries, compare prior
snapshots, or invoke `scriptorium`.
## Behavior
- `promptinput.Build` copies report metadata from the briefing package.
- `promptinput.Validate` rejects missing or inconsistent required fields before
render preflight.
- `promptinput.Save` writes JSON atomically where practical.
- Recent Changes is present as an `items` list. It is empty when no prior
comparable snapshot exists or no meaningful changes are detected.
## Failure Behavior
Validation errors name the missing or inconsistent field. Save failures include
the filesystem operation and path context.
## Tests
Inspect:
- `internal/promptinput/package_test.go`
- `internal/changes/daily_test.go`
- `internal/app/app_test.go`
## Invariants
- Prompt input data remains structured JSON.
- Briefing metadata and top-level report metadata must agree.
- Recent Changes is not inferred from rendered report text.

View File

@@ -0,0 +1,63 @@
# Report Registry Internals
This document describes the implemented report identity and valid-period
boundary.
## Purpose
`internal/report` centralizes report IDs, prompt IDs, comparison strategies,
valid-period resolution, report metadata, and scheduled batch membership.
## Inputs and Outputs
Inputs:
- report ID or batch name
- generation time
- configured timezone
- optional Daily date override
- optional manual storm start and end times
Outputs:
- `report.Resolved` values with definition metadata and half-open valid periods
- `report.Metadata` values suitable for later persisted run metadata
## Boundaries
- This package defines report identity and time coverage only.
- It does not fetch weather data, build briefings, compare snapshots, write
state, or call `scriptorium`.
## Behavior
- Daily Today covers one configured local civil day.
- Daily Tomorrow covers the next configured local civil day.
- 3-Day Outlook covers generation time through local midnight after the second
following local civil day.
- Weekend Outlook covers Saturday 00:00 to Monday 00:00 Monday through
Thursday; Friday and Saturday cover the remaining weekend from Friday 18:00
or generation time, whichever is later.
- Manual Storm Report uses explicit start and end times.
- Morning batch resolves Daily Today and 3-Day Outlook, plus Weekend Outlook
except on Sunday.
- Evening batch resolves Daily Tomorrow.
## Failure Behavior
- Unknown report and batch names return actionable errors.
- Sunday Weekend Outlook resolution returns an error.
- Storm windows require start and end, with end after start.
## Tests
Inspect:
- `internal/report/period_test.go`
- `internal/app/app_test.go`
## Invariants
- Report selection goes through the registry.
- Valid periods are independent of rendered report text.
- Prompt IDs and comparison strategies are declared with report definitions.

View File

@@ -0,0 +1,71 @@
# Scriptorium Adapter Internals
This document describes the implemented `scriptorium` subprocess adapter.
## Purpose
`internal/adapters/scriptorium` runs `scriptorium render` to preflight prompt
wiring and `scriptorium run` to generate report artifacts.
## Inputs and Outputs
Input:
- prompt ID
- prompt input data package path
- report output path for `run`
- configured binary, config path, profile, timeout, and extra arguments
Output:
- captured stdout, with truncation tracking
- captured stderr, with truncation tracking
- exit code
- full argv used for inspection
## Boundaries
- This adapter owns `scriptorium` CLI flag construction and subprocess
execution.
- It does not choose report types, build prompt input, fetch weather data, or
decide workflow order.
## Behavior
The render invocation shape is:
```text
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
```
The run invocation shape is:
```text
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <artifact_path>
```
Configured `--config` and `--profile` values are added when present. Arguments
are passed directly as argv, not through a shell. Stdout and stderr are captured
separately. `SaveRenderResult` writes the captured result as JSON for inspection.
## Failure Behavior
Nonzero render and run exits return both the captured result and an error
containing the exit code and stderr. Run exit code `2` is treated as an error
but may still produce a report artifact. Command execution respects context
cancellation and the configured timeout.
## Tests
Inspect:
- `internal/adapters/scriptorium/runner_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- `scriptorium` details stay inside the adapter package.
- The input name for prompt packages is always `data_package`.
- Render preflight remains orchestration behavior; this adapter only exposes the
subprocess operations.

81
docs/internal/state.md Normal file
View File

@@ -0,0 +1,81 @@
# State Internals
This document describes the implemented filesystem state boundary.
## Purpose
`internal/state` owns durable artifact paths, atomic JSON writes, metadata, and
prior comparable snapshot lookup.
## Inputs and Outputs
Inputs:
- workspace configuration
- resolved report definition and valid period
- briefing package
- prompt input data package
- `scriptorium render` result
- rendered report path preparation
Outputs:
- briefing snapshot JSON
- prompt input data package JSON
- render preflight JSON
- Markdown report path
- metadata JSON
- prior comparable snapshot metadata when available
- prior briefing package when loaded by path
- recent report records for inspection
- metadata and data package lookup by RunID
## Boundaries
- This package owns managed workspace layout and narrow path validation.
- It does not fetch weather data, derive forecasts, build prompt inputs, invoke
`scriptorium`, or compare briefing contents.
## Config Fields Used
- `workspace.root`
- `workspace.snapshots_dir`
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
## State Behavior
Managed artifact names use RunID, which is generated from report generation time
and report ID. Metadata is stored beside briefing snapshots by report group and
valid local date. Prior snapshot lookup reads metadata for the same valid local
date and returns the latest earlier compatible run. Daily Today and Daily
Tomorrow are compatible with each other; 3-Day Outlook is compatible with prior
3-Day Outlook snapshots; Weekend Outlook is compatible with prior Weekend
Outlook snapshots for the same weekend window. The store can load a briefing
snapshot by path for structured comparison. The store can list metadata-backed
report records and load metadata or data packages by RunID for inspection. The
store prepares the managed Markdown report path before `scriptorium run` writes
it.
## Failure Behavior
Writes are atomic where practical: JSON is written to a temporary file in the
target directory and then renamed into place. Invalid workspace paths and
missing required metadata fields produce actionable errors.
## Tests
Inspect:
- `internal/state/filesystem_test.go`
- `internal/app/app_test.go`
## Invariants
- Managed paths stay under the configured workspace root.
- Metadata links the artifacts produced for a run.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -0,0 +1,69 @@
# Weather Data Internals
This document describes the implemented weather data ingestion boundary.
## Purpose
`internal/adapters/weatherapi` fetches normalized weather data from one
configured weather API endpoint and assembles a `forecast.Bundle`.
## Inputs and Outputs
Input:
- `config.Config` with `weather_api.base_url`, `format`, `units`, `timezone`,
`precision`, timeout, and missing-source policy.
Output:
- `forecast.Bundle` containing observation, current conditions, hourly forecast,
narrative forecast, alerts, discussion, stub source slots, provenance, and
source warnings.
## Boundaries
- The adapter performs HTTP calls and decoding only.
- Forecast derivation, daypart grouping, report periods, report rendering, and
`scriptorium` execution are outside this boundary.
- Hourly forecast data is required. Other missing or malformed source sections
use the configured missing-source policy.
## External Adapter
The adapter calls:
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
Forecast routes use the full-product endpoints, not day-slice endpoints.
## State
`app.FetchAndSaveBundle` can save an inspectable bundle JSON file using an
atomic rename. No report state, snapshots, or prompt input packages are written
yet.
## Failure Behavior
- HTTP and envelope decode failures return actionable errors with endpoint
context.
- Missing hourly data fails the fetch.
- Missing or malformed optional sources follow `error`, `warn`, or `none`.
- Source identity uses SHA-256 over compacted raw `data` JSON.
## Tests
Inspect:
- `internal/adapters/weatherapi/client_test.go`
- `internal/app/app_test.go`
## Invariants
- Weather facts come from normalized source data.
- External API details stay inside `internal/adapters/weatherapi`.
- Source provenance and warnings remain inspectable for later briefing builders.

182
docs/operations.md Normal file
View File

@@ -0,0 +1,182 @@
# Weatherreporter Operations
## Normal Workflow
The implemented generation workflows are:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
weatherreporter run morning
weatherreporter run evening
```
These commands fetch weather data, build a briefing for the resolved valid
period, build the prompt input data package, run `scriptorium render`, run
`scriptorium run`, and write inspectable artifacts under the configured
workspace. The evening run resolves only the Tomorrow Planning Brief. The
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. Storm Report generation is manual and uses the explicit
`--start` and `--end` bounds as its valid period.
Scheduled run commands print a JSON aggregate summary to stdout and compact
per-report status lines to stderr. If one report fails, remaining independent
reports are still attempted. The command returns nonzero after the run when any
report failed.
## Filesystem Layout
The default workspace root is `workspace`.
```text
workspace/
snapshots/
daily/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
three-day/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
storm/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.json
three-day/
YYYY-MM-DD/
<run_id>.data_package.json
weekend/
YYYY-MM-DD/
<run_id>.data_package.json
storm/
YYYY-MM-DD/
<run_id>.data_package.json
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
weekend/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
reports/
daily/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
storm/
<run_id>.md
```
The Markdown report is written to a RunID-managed report path. When `--out` is
provided to `generate daily`, `generate tomorrow`, `generate three-day`,
`generate weekend`, or `generate storm`, the managed report is also copied to
that path.
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
copies using each report definition's default filename, such as `daily.md`,
`three-day.md`, `weekend.md`, or `tomorrow.md`.
## Run Identifiers
Run IDs are based on generation time plus report ID, such as:
```text
20260529T100000.123456789Z_daily_today
```
Managed artifact filenames use the RunID so repeated runs for the same valid
date do not overwrite each other.
## Metadata
Each generated report writes metadata that links:
- RunID
- report ID and prompt ID
- generation time and valid period
- source location, source hashes, and source warnings
- briefing snapshot path
- prompt input data package path
- preflight output path
- rendered report path
Run summaries include each report ID, prompt ID, RunID, status, error text when
applicable, valid period, and artifact paths known to the application.
## Inspection
Use `weatherreporter inspect reports` to list recent generated runs from the
configured workspace. The output includes RunID, report ID, valid period,
metadata path, briefing path, report path, and source warning count.
Run-specific inspection commands emit JSON for a single RunID:
```text
weatherreporter inspect metadata RUN_ID
weatherreporter inspect briefing RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
`inspect prior` shows the prior comparable snapshot selected from stored
metadata, or `null` when none exists. `inspect sources` shows source provenance
and source warnings without dumping full weather payloads.
## Recent Changes
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
data package. Daily Today and Daily Tomorrow can compare with each other when
they cover the same valid local date. Meaningful changes are included under
`recentChanges.items`.
3-Day Outlook generation compares against a prior compatible 3-Day briefing
snapshot for the same valid local date when one exists.
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.
Storm Report generation currently leaves Recent Changes empty. Its explicit
event window is still recorded in briefing and metadata artifacts.
When no prior comparable snapshot exists, or no configured threshold is crossed,
the Recent Changes list is empty.
## Recovery
If render preflight exits nonzero after producing a result, the captured stdout,
stderr, exit code, and command are still written to the preflight artifact, and
metadata is still written for inspection.
If `scriptorium run` exits nonzero after writing a report, the generated report
and metadata remain available for inspection. Exit code `2` is still returned as
an error because it indicates validation failed, even if report output exists.
For scheduled runs, inspect stdout first for the aggregate JSON summary, then
use the per-report artifact paths in that summary to inspect briefing,
data-package, preflight, metadata, and rendered report files.
The application does not currently implement resume, cleanup, archive, or
remote storage behavior.

46
examples/config.yml Normal file
View File

@@ -0,0 +1,46 @@
weather_api:
base_url: https://weather.api.example.com/
timeout: 15s
precision: 1
units: us
timezone: Chicago
format: json
missing_source:
default: warn
sources:
alerts: none
scriptorium:
binary: scriptorium
timeout: 2m
workspace:
root: workspace
snapshots_dir: snapshots
reports_dir: reports
data_packages_dir: data-packages
preflight_dir: preflight
reports:
output_dir: reports
dayparts:
- name: overnight
start: "00:00"
end: "06:00"
- name: morning
start: "06:00"
end: "12:00"
- name: afternoon
start: "12:00"
end: "18:00"
- name: evening
start: "18:00"
end: "24:00"
recent_change:
temperature_degrees: 5
precip_probability_points: 20
wind_gust_miles_per_hour: 10
precip_timing_shift_minutes: 120

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module gitea.maximumdirect.net/eric/weatherreporter
go 1.26
require gopkg.in/yaml.v3 v3.0.1

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,274 @@
// Package scriptorium adapts the external scriptorium CLI.
package scriptorium
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"time"
)
const maxCapturedOutputBytes = 1024 * 1024
type CommandRunner interface {
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
}
type CommandResult struct {
Stdout []byte
Stderr []byte
StdoutTruncated bool
StderrTruncated bool
ExitCode int
}
type ExecRunner struct{}
func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
runCtx := ctx
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
}
defer cancel()
cmd := exec.CommandContext(runCtx, name, args...)
stdout := &limitedBuffer{limit: maxCapturedOutputBytes}
stderr := &limitedBuffer{limit: maxCapturedOutputBytes}
cmd.Stdout = stdout
cmd.Stderr = stderr
err := cmd.Run()
result := CommandResult{
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
StdoutTruncated: stdout.Truncated(),
StderrTruncated: stderr.Truncated(),
ExitCode: 0,
}
if err == nil {
return result, nil
}
if runCtx.Err() != nil {
return result, runCtx.Err()
}
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
return result, nil
}
return result, err
}
type Runner struct {
Binary string
ConfigPath string
Profile string
Timeout time.Duration
ExtraArgs []string
Commands CommandRunner
}
type RenderRequest struct {
PromptID string
DataPackagePath string
}
type RunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
}
type RunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
OutputPath string `json:"outputPath"`
}
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
binary := r.Binary
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
args := r.renderArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil {
return nil, fmt.Errorf("run scriptorium render: %w", err)
}
result := &RenderResult{
Command: append([]string{binary}, args...),
Stdout: string(commandResult.Stdout),
Stderr: string(commandResult.Stderr),
StdoutTruncated: commandResult.StdoutTruncated,
StderrTruncated: commandResult.StderrTruncated,
ExitCode: commandResult.ExitCode,
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", commandResult.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
binary := r.Binary
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
args := r.runArgs(req)
commandResult, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
Command: append([]string{binary}, args...),
Stdout: string(commandResult.Stdout),
Stderr: string(commandResult.Stderr),
StdoutTruncated: commandResult.StdoutTruncated,
StderrTruncated: commandResult.StderrTruncated,
ExitCode: commandResult.ExitCode,
OutputPath: req.OutputPath,
}
if commandResult.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", commandResult.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) renderArgs(req RenderRequest) []string {
args := []string{"render"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)
}
if r.Profile != "" {
args = append(args, "--profile", r.Profile)
}
args = append(args,
"--prompt", req.PromptID,
"--input", "data_package="+req.DataPackagePath,
"--format", "json",
)
args = append(args, r.ExtraArgs...)
return args
}
func (r Runner) runArgs(req RunRequest) []string {
args := []string{"run"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)
}
if r.Profile != "" {
args = append(args, "--profile", r.Profile)
}
args = append(args,
"--prompt", req.PromptID,
"--input", "data_package="+req.DataPackagePath,
"--out", req.OutputPath,
)
args = append(args, r.ExtraArgs...)
return args
}
func SaveRenderResult(path string, result *RenderResult) error {
if result == nil {
return fmt.Errorf("render result is required")
}
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("marshal render result: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create preflight directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary preflight file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary preflight file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary preflight file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save preflight %q: %w", path, err)
}
return nil
}
type limitedBuffer struct {
data []byte
limit int
truncated bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
b.truncated = true
return len(p), nil
}
remaining := b.limit - len(b.data)
if remaining <= 0 {
b.truncated = true
return len(p), nil
}
if len(p) > remaining {
b.data = append(b.data, p[:remaining]...)
b.truncated = true
return len(p), nil
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *limitedBuffer) Bytes() []byte {
return append([]byte{}, b.data...)
}
func (b *limitedBuffer) Truncated() bool {
return b.truncated
}
var _ io.Writer = (*limitedBuffer)(nil)

View File

@@ -0,0 +1,163 @@
package scriptorium
import (
"context"
"reflect"
"strings"
"testing"
"time"
)
func TestRenderConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: time.Minute,
Commands: commands,
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
wantArgs := []string{
"render",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json",
"--format", "json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
}
func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stderr: []byte("missing input"),
ExitCode: 1,
},
},
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
})
if err == nil {
t.Fatal("Render() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Render() result = nil, want captured result")
}
if result.ExitCode != 1 {
t.Fatalf("ExitCode = %d, want 1", result.ExitCode)
}
if !strings.Contains(err.Error(), "missing input") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestRunConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 45 * time.Second,
Commands: commands,
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
OutputPath: "/tmp/daily.md",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.json",
"--out", "/tmp/daily.md",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if commands.timeout != 45*time.Second {
t.Fatalf("timeout = %s, want 45s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.OutputPath != "/tmp/daily.md" {
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
}
}
func TestRunReturnsResultForValidationExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte("# Daily Report\n"),
Stderr: []byte("validation failed"),
ExitCode: 2,
},
},
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.json",
OutputPath: "/tmp/daily.md",
})
if err == nil {
t.Fatal("Run() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Run() result = nil, want captured result")
}
if result.ExitCode != 2 {
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
}
if !strings.Contains(err.Error(), "validation failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
type fakeCommands struct {
name string
args []string
timeout time.Duration
result CommandResult
err error
}
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.name = name
f.args = append([]string{}, args...)
f.timeout = timeout
return f.result, f.err
}

View File

@@ -0,0 +1,426 @@
// Package weatherapi adapts the internal weather API to forecast bundles.
package weatherapi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
type Client struct {
baseURL *url.URL
httpClient *http.Client
units string
format string
timezone string
precision int
missingSource config.MissingSourceConfig
now func() time.Time
}
type Option func(*Client)
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *Client) {
if httpClient != nil {
c.httpClient = httpClient
}
}
}
func WithClock(now func() time.Time) Option {
return func(c *Client) {
if now != nil {
c.now = now
}
}
}
func New(cfg config.Config, opts ...Option) (*Client, error) {
if strings.TrimSpace(cfg.WeatherAPI.BaseURL) == "" {
return nil, fmt.Errorf("weather_api.base_url is required")
}
baseURL, err := url.Parse(cfg.WeatherAPI.BaseURL)
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
}
timeout := cfg.WeatherAPI.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
client := &Client{
baseURL: baseURL,
httpClient: &http.Client{Timeout: timeout},
units: cfg.WeatherAPI.Units,
format: cfg.WeatherAPI.Format,
timezone: cfg.WeatherAPI.Timezone,
precision: cfg.WeatherAPI.Precision,
missingSource: config.MissingSourceConfig{
Default: cfg.MissingSource.Default,
Sources: cfg.MissingSource.Sources,
},
now: time.Now,
}
for _, opt := range opts {
opt(client)
}
return client, nil
}
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
if err := builder.fetchObservation(ctx); err != nil {
return nil, err
}
if err := builder.fetchCurrent(ctx); err != nil {
return nil, err
}
if err := builder.fetchHourly(ctx); err != nil {
return nil, err
}
if err := builder.fetchNarrative(ctx); err != nil {
return nil, err
}
if err := builder.fetchAlerts(ctx); err != nil {
return nil, err
}
if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err
}
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
return nil, err
}
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
return nil, err
}
return builder.bundle, nil
}
type bundleBuilder struct {
client *Client
bundle *forecast.Bundle
fetchedAt time.Time
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "observation data is missing", false)
}
var observation forecast.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &observation.Timestamp
b.bundle.Observation = &observation
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current forecast.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
b.bundle.Current = &current
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly forecast.ForecastRun
if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
}
source.IssuedAt = &hourly.IssuedAt
source.UpdatedAt = hourly.UpdatedAt
b.bundle.Hourly = &hourly
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative forecast.ForecastRun
if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &narrative.IssuedAt
source.UpdatedAt = narrative.UpdatedAt
b.bundle.Narrative = &narrative
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false)
}
var alerts forecast.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
}
alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil {
source.IssuedAt = alerts.AsOf
}
b.bundle.Alerts = &alerts
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
if err != nil {
return err
}
if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion forecast.Discussion
if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false)
}
source.IssuedAt = &discussion.IssuedAt
source.UpdatedAt = discussion.UpdatedAt
b.bundle.Discussion = &discussion
b.addSource(source)
return nil
}
func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{
Name: sourceName,
FetchedAt: b.fetchedAt,
Missing: true,
}
return b.applyMissingPolicy(&source, "missing_source", message)
}
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
source.Missing = true
if required {
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
}
return b.applyMissingPolicy(source, "missing_source", message)
}
func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error {
if required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
}
source.Missing = true
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
}
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
policy := b.client.policyFor(source.Name)
if policy == config.MissingSourceError {
return fmt.Errorf("%s: %s", source.Name, message)
}
if policy == config.MissingSourceWarn {
warning := forecast.SourceWarning{
Source: source.Name,
Code: code,
Severity: "warning",
Message: message,
Endpoint: source.Endpoint,
CompletenessImpact: "source omitted from bundle",
}
source.Warnings = append(source.Warnings, warning)
b.bundle.Warnings = append(b.bundle.Warnings, warning)
}
b.addSource(*source)
return nil
}
func (b *bundleBuilder) addSource(source forecast.Source) {
b.bundle.Sources = append(b.bundle.Sources, source)
}
func (c *Client) policyFor(source string) config.MissingSourcePolicy {
if policy, ok := c.missingSource.Sources[source]; ok {
return policy
}
return c.missingSource.Default
}
type queryOptions struct {
precision bool
timezone bool
}
type envelope struct {
Data json.RawMessage `json:"data"`
}
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
}
source := forecast.Source{
Name: sourceName,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
FetchedAt: c.now(),
}
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
source.Missing = true
return nil, source, nil
}
hash, err := sourceHash(env.Data)
if err != nil {
return env.Data, source, nil
}
source.DataSHA256 = hash
return env.Data, source, nil
}
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
reqURL := *c.baseURL
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
query := reqURL.Query()
query.Set("format", c.format)
query.Set("units", c.units)
if opts.precision {
query.Set("precision", strconv.Itoa(c.precision))
}
if opts.timezone {
query.Set("tz", c.timezone)
}
reqURL.RawQuery = query.Encode()
return &reqURL
}
func queryMap(values url.Values) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
if len(value) > 0 {
out[key] = value[0]
}
}
return out
}
func decodeSource(raw json.RawMessage, target any) error {
if err := json.Unmarshal(raw, target); err != nil {
return err
}
return nil
}
func sourceHash(raw json.RawMessage) (string, error) {
var compact bytes.Buffer
if err := json.Compact(&compact, raw); err != nil {
return "", err
}
sum := sha256.Sum256(compact.Bytes())
return hex.EncodeToString(sum[:]), nil
}
func SaveBundle(path string, bundle *forecast.Bundle) error {
data, err := json.MarshalIndent(bundle, "", " ")
if err != nil {
return fmt.Errorf("marshal forecast bundle: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary bundle file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary bundle file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary bundle file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save bundle %q: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,348 @@
package weatherapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
func TestFetchBundleFromFixtures(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Observation == nil || bundle.Observation.StationID != "KSTL" {
t.Fatalf("Observation = %#v, want KSTL observation", bundle.Observation)
}
if bundle.Current == nil || bundle.Current.ConditionText != "Partly cloudy" {
t.Fatalf("Current = %#v, want current conditions", bundle.Current)
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 {
t.Fatalf("Hourly = %#v, want one hourly period", bundle.Hourly)
}
if bundle.Narrative == nil || bundle.Narrative.Product != "narrative" {
t.Fatalf("Narrative = %#v, want narrative product", bundle.Narrative)
}
if bundle.Alerts == nil || bundle.Alerts.AsOf == nil {
t.Fatalf("Alerts = %#v, want alert run", bundle.Alerts)
}
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
}
if len(bundle.Warnings) != 2 {
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
}
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
for _, rawURL := range requested {
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing format=json or units=us", rawURL)
}
if strings.HasPrefix(rawURL, "/forecast/") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
t.Fatalf("forecast request %q missing precision or tz", rawURL)
}
}
}
}
func TestFetchBundleRecordsSourceHash(t *testing.T) {
server := fixtureServer(t, nil, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
observation := sourceByName(t, bundle.Sources, "observations")
want := hashFixtureData(t, "observations.json")
if observation.DataSHA256 != want {
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
}
}
func TestHTTPErrorIsActionable(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want HTTP error")
}
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error())
}
}
func TestRequiredHourlyForecast(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want required hourly error")
}
if !strings.Contains(err.Error(), "hourly forecast data") {
t.Fatalf("error = %q, want hourly context", err.Error())
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct {
name string
policy config.MissingSourcePolicy
wantErr bool
wantWarns int
wantSource bool
}{
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
{name: "error", policy: config.MissingSourceError, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/observations": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
cfg := testConfig(server.URL + "/")
cfg.MissingSource.Default = tt.policy
cfg.MissingSource.Sources = map[string]config.MissingSourcePolicy{
"hourly": tt.policy,
}
client, err := New(cfg, WithClock(fixedNow))
if err != nil {
t.Fatalf("New() error = %v", err)
}
bundle, err := client.FetchBundle(context.Background())
if tt.wantErr {
if err == nil {
t.Fatal("FetchBundle() error = nil, want policy error")
}
return
}
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if len(bundle.Warnings) != tt.wantWarns {
t.Fatalf("Warnings length = %d, want %d", len(bundle.Warnings), tt.wantWarns)
}
if tt.wantSource {
source := sourceByName(t, bundle.Sources, "observations")
if !source.Missing {
t.Fatalf("observations source Missing = false, want true")
}
}
})
}
}
func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusOK, body: `{"data": {"temperatureF": "hot"}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"current": config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
source := sourceByName(t, bundle.Sources, "current")
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("current source = %#v, want missing source warning", source)
}
}
func TestContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer server.Close()
client := newTestClient(t, server.URL+"/", nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.FetchBundle(ctx)
if err == nil {
t.Fatal("FetchBundle() error = nil, want cancellation error")
}
}
func TestHTTPTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
defer server.Close()
cfg := testConfig(server.URL + "/")
cfg.WeatherAPI.Timeout = time.Nanosecond
client, err := New(cfg, WithClock(fixedNow))
if err != nil {
t.Fatalf("New() error = %v", err)
}
_, err = client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want timeout error")
}
if !strings.Contains(err.Error(), "/observations") {
t.Fatalf("error = %q, want endpoint context", err.Error())
}
}
func TestSaveBundle(t *testing.T) {
server := fixtureServer(t, nil, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
if err := SaveBundle(path, bundle); err != nil {
t.Fatalf("SaveBundle() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved bundle: %v", err)
}
if !strings.Contains(string(data), `"hourly"`) {
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
}
}
type handlerOverride struct {
status int
body string
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
fixtures := map[string]string{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
*requested = append(*requested, r.URL.String())
}
if override, ok := overrides[r.URL.Path]; ok {
w.WriteHeader(override.status)
_, _ = w.Write([]byte(override.body))
return
}
name, ok := fixtures[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, filepath.Join("testdata", name))
}))
t.Cleanup(server.Close)
return server
}
func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]config.MissingSourcePolicy) *Client {
t.Helper()
cfg := testConfig(baseURL)
for source, policy := range sourcePolicies {
cfg.MissingSource.Sources[source] = policy
}
client, err := New(cfg, WithClock(fixedNow))
if err != nil {
t.Fatalf("New() error = %v", err)
}
return client
}
func testConfig(baseURL string) config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = baseURL
return cfg
}
func fixedNow() time.Time {
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
}
func containsPath(requested []string, path string) bool {
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
return true
}
}
return false
}
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
t.Helper()
for _, source := range sources {
if source.Name == name {
return source
}
}
t.Fatalf("source %q not found in %#v", name, sources)
return forecast.Source{}
}
func hashFixtureData(t *testing.T, fixture string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", fixture))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var env envelope
if err := json.Unmarshal(data, &env); err != nil {
t.Fatalf("decode fixture envelope: %v", err)
}
hash, err := sourceHash(env.Data)
if err != nil {
t.Fatalf("hash fixture data: %v", err)
}
return hash
}

View File

@@ -0,0 +1,6 @@
{
"data": {
"asOf": "2026-05-29T14:00:00Z",
"alerts": []
}
}

View File

@@ -0,0 +1,10 @@
{
"data": {
"conditionText": "Partly cloudy",
"isDay": true,
"temperatureF": 75.9,
"apparentTemperatureF": 76.1,
"windSpeedMph": 10.7,
"relativeHumidityPercent": 56
}
}

View File

@@ -0,0 +1,16 @@
{
"data": {
"officeId": "LSX",
"officeName": "St. Louis",
"product": "discussion",
"issuedAt": "2026-05-29T09:25:00-05:00",
"keyMessages": [
"Scattered showers possible this evening.",
"Warmer temperatures this weekend."
],
"shortTerm": {
"title": "Short Term",
"narrative": "A weak boundary may trigger isolated showers."
}
}
}

View File

@@ -0,0 +1,25 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"issuedAt": "2026-05-29T10:30:00-05:00",
"updatedAt": "2026-05-29T10:45:00-05:00",
"product": "hourly",
"latitude": 38.63,
"longitude": -90.2,
"elevationFeet": 466,
"periods": [
{
"startTime": "2026-05-29T13:00:00-05:00",
"endTime": "2026-05-29T14:00:00-05:00",
"isDay": true,
"conditionCode": 3,
"textDescription": "Partly sunny",
"temperatureF": 81,
"windSpeedMph": 12,
"windGustMph": 20,
"probabilityOfPrecipitationPercent": 10
}
]
}
}

View File

@@ -0,0 +1,20 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"issuedAt": "2026-05-29T10:30:00-05:00",
"product": "narrative",
"periods": [
{
"startTime": "2026-05-29T13:00:00-05:00",
"endTime": "2026-05-29T19:00:00-05:00",
"name": "Today",
"isDay": true,
"textDescription": "Partly sunny, with a high near 81.",
"temperatureF": 81,
"windSpeedMph": 12,
"probabilityOfPrecipitationPercent": 10
}
]
}
}

View File

@@ -0,0 +1,13 @@
{
"data": {
"stationId": "KSTL",
"stationName": "St. Louis",
"timestamp": "2026-05-29T14:00:00Z",
"conditionCode": 3,
"isDay": true,
"textDescription": "Partly cloudy",
"temperatureF": 75.9,
"windSpeedMph": 10.7,
"relativeHumidityPercent": 56
}
}

646
internal/app/app.go Normal file
View File

@@ -0,0 +1,646 @@
// Package app owns application orchestration and top-level use cases.
package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ReportKind string
const (
ReportDaily ReportKind = "daily"
ReportTomorrow ReportKind = "tomorrow"
ReportThreeDay ReportKind = "three-day"
ReportWeekend ReportKind = "weekend"
ReportStorm ReportKind = "storm"
)
type BatchKind string
const (
BatchMorning BatchKind = "morning"
BatchEvening BatchKind = "evening"
)
type GenerateRequest struct {
Config config.Config
Report ReportKind
OutputPath string
Now time.Time
Date time.Time
StormStart time.Time
StormEnd time.Time
}
type BatchRequest struct {
Config config.Config
Batch BatchKind
Now time.Time
OutputDir string
Renderer Renderer
Store state.Store
}
type FetchBundleRequest struct {
Config config.Config
OutputPath string
}
type BriefingRequest struct {
Config config.Config
Resolved report.Resolved
OutputPath string
}
type DailyBriefingRequest = BriefingRequest
type ReportRequest struct {
Config config.Config
Resolved report.Resolved
OutputPath string
Renderer Renderer
Store state.Store
}
type DailyReportRequest = ReportRequest
type BriefingResult struct {
Package briefing.Package
OutputPath string
}
type DailyBriefingResult = BriefingResult
type ReportResult struct {
Briefing briefing.Package
BriefingPath string
DataPackage promptinput.Package
DataPackagePath string
PreflightPath string
ReportPath string
OutputPath string
Metadata state.Metadata
MetadataPath string
PriorSnapshot *state.PriorSnapshot
RecentChanges []changes.Change
RenderResult *scriptorium.RenderResult
RunResult *scriptorium.RunResult
}
type DailyReportResult = ReportResult
type BatchResult struct {
Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Reports []BatchReportResult `json:"reports"`
}
type BatchReportResult struct {
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
}
type BatchError struct {
Result *BatchResult
}
func (e BatchError) Error() string {
if e.Result == nil {
return "batch failed"
}
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
}
type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
}
func Generate(ctx context.Context, req GenerateRequest) error {
now := req.Now
if now.IsZero() {
now = time.Now()
}
resolved, err := ResolveGenerate(req, now)
if err != nil {
return err
}
if isGeneratedReport(resolved.Definition.ID) {
_, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: req.OutputPath,
})
return err
}
return fmt.Errorf("generate is not implemented")
}
func RunBatch(ctx context.Context, req BatchRequest) error {
result, err := RunBatchDetailed(ctx, req)
if err != nil {
return err
}
if result.Failed > 0 {
return BatchError{Result: result}
}
return nil
}
func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, error) {
now := req.Now
if now.IsZero() {
now = time.Now()
}
resolvedReports, err := ResolveBatch(req, now)
if err != nil {
return nil, err
}
if req.Batch == BatchEvening || req.Batch == BatchMorning {
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) {
return nil, fmt.Errorf("run is not implemented")
}
}
for _, resolved := range resolvedReports {
item := batchReportResult(resolved)
if paths, err := store.Paths(resolved); err == nil {
item.BriefingPath = paths.Briefing
item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata
}
outputPath := batchOutputPath(req.OutputDir, resolved.Definition)
reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
OutputPath: outputPath,
Renderer: req.Renderer,
Store: store,
})
if err != nil {
item.Status = "failed"
item.Error = err.Error()
result.Failed++
} else {
item.Status = "succeeded"
item.BriefingPath = reportResult.BriefingPath
item.DataPackagePath = reportResult.DataPackagePath
item.PreflightPath = reportResult.PreflightPath
item.ReportPath = reportResult.ReportPath
item.OutputPath = reportResult.OutputPath
item.MetadataPath = reportResult.MetadataPath
result.Succeeded++
}
result.Reports = append(result.Reports, item)
}
result.Total = len(result.Reports)
result.FinishedAt = time.Now()
return result, nil
}
return nil, fmt.Errorf("run is not implemented")
}
func batchReportResult(resolved report.Resolved) BatchReportResult {
metadata := resolved.Metadata()
return BatchReportResult{
ReportID: resolved.Definition.ID,
ReportName: resolved.Definition.Name,
PromptID: resolved.Definition.PromptID,
RunID: metadata.RunID,
GeneratedAt: metadata.GeneratedAt,
ValidPeriod: metadata.ValidPeriod,
}
}
func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" {
return ""
}
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
return filepath.Join(outputDir, name)
}
func isGeneratedReport(id report.ID) bool {
return isDailyReport(id) || id == report.ThreeDay || id == report.Weekend || id == report.Storm
}
func isDailyReport(id report.ID) bool {
return id == report.DailyToday || id == report.DailyTomorrow
}
func ResolveGenerate(req GenerateRequest, now time.Time) (report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return report.Resolved{}, err
}
id, err := reportIDForCommand(req.Report)
if err != nil {
return report.Resolved{}, err
}
return report.DefaultRegistry().Resolve(id, report.ResolveRequest{
Now: now,
Location: location,
Date: req.Date,
StormStart: req.StormStart,
StormEnd: req.StormEnd,
})
}
func ResolveBatch(req BatchRequest, now time.Time) ([]report.Resolved, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := reportBatchForCommand(req.Batch)
if err != nil {
return nil, err
}
return report.DefaultRegistry().BatchReports(batch, report.ResolveRequest{
Now: now,
Location: location,
})
}
func reportIDForCommand(kind ReportKind) (report.ID, error) {
switch kind {
case ReportDaily:
return report.DailyToday, nil
case ReportTomorrow:
return report.DailyTomorrow, nil
case ReportThreeDay:
return report.ThreeDay, nil
case ReportWeekend:
return report.Weekend, nil
case ReportStorm:
return report.Storm, nil
default:
return "", fmt.Errorf("unknown report command %q", kind)
}
}
func reportBatchForCommand(kind BatchKind) (report.Batch, error) {
switch kind {
case BatchMorning:
return report.Morning, nil
case BatchEvening:
return report.Evening, nil
default:
return "", fmt.Errorf("unknown batch command %q", kind)
}
}
func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
client, err := weatherapi.New(req.Config)
if err != nil {
return nil, err
}
bundle, err := client.FetchBundle(ctx)
if err != nil {
return nil, err
}
return bundle, nil
}
func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) {
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
bundle, err := FetchBundle(ctx, req)
if err != nil {
return nil, err
}
if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil {
return nil, err
}
return bundle, nil
}
func GenerateDailyBriefing(ctx context.Context, req DailyBriefingRequest) (*DailyBriefingResult, error) {
return GenerateBriefing(ctx, req)
}
func GenerateBriefing(ctx context.Context, req BriefingRequest) (*BriefingResult, error) {
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
pkg, err := BuildBriefing(req, bundle)
if err != nil {
return nil, err
}
outputPath := req.OutputPath
if outputPath == "" {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
outputPath = paths.Briefing
}
if err := briefing.Save(outputPath, pkg); err != nil {
return nil, err
}
return &BriefingResult{Package: pkg, OutputPath: outputPath}, nil
}
func GenerateDailyReport(ctx context.Context, req DailyReportRequest) (*DailyReportResult, error) {
return GenerateReport(ctx, req)
}
func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, error) {
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
paths, err := store.Paths(req.Resolved)
if err != nil {
return nil, err
}
priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved)
if err != nil {
return nil, err
}
bundle, err := FetchBundle(ctx, FetchBundleRequest{Config: req.Config})
if err != nil {
return nil, err
}
briefingPackage, err := BuildBriefing(BriefingRequest{
Config: req.Config,
Resolved: req.Resolved,
}, bundle)
if err != nil {
return nil, err
}
briefingPath, err := store.SaveBriefing(ctx, req.Resolved, briefingPackage)
if err != nil {
return nil, err
}
recentChanges, err := recentChanges(ctx, store, priorSnapshot, briefingPackage, req.Config.RecentChange)
if err != nil {
return nil, err
}
dataPackage, err := promptinput.BuildWithRecentChanges(briefingPackage, recentChanges)
if err != nil {
return nil, err
}
dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage)
if err != nil {
return nil, err
}
renderer := req.Renderer
if renderer == nil {
renderer = scriptorium.Runner{
Binary: req.Config.Scriptorium.Binary,
ConfigPath: req.Config.Scriptorium.ConfigPath,
Profile: req.Config.Scriptorium.Profile,
Timeout: req.Config.Scriptorium.Timeout,
ExtraArgs: req.Config.Scriptorium.ExtraArgs,
}
}
renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
})
preflightPath := paths.Preflight
if renderResult != nil {
var err error
preflightPath, err = store.SavePreflight(ctx, req.Resolved, renderResult)
if err != nil {
return nil, err
}
}
metadata := state.BuildMetadata(req.Resolved, briefingPackage, state.ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: paths.RenderedReport,
})
metadataPath, metadataErr := store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if renderErr != nil {
return nil, renderErr
}
reportPath, err := store.PrepareRenderedReport(ctx, req.Resolved)
if err != nil {
return nil, err
}
runResult, runErr := renderer.Run(ctx, scriptorium.RunRequest{
PromptID: req.Resolved.Definition.PromptID,
DataPackagePath: dataPackagePath,
OutputPath: reportPath,
})
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath {
if err := copyFileAtomic(reportPath, req.OutputPath); err != nil {
return nil, err
}
}
outputPath := reportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
}
metadata.RenderedReportPath = reportPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if runErr != nil {
return nil, runErr
}
return &ReportResult{
Briefing: briefingPackage,
BriefingPath: briefingPath,
DataPackage: dataPackage,
DataPackagePath: dataPackagePath,
PreflightPath: preflightPath,
ReportPath: reportPath,
OutputPath: outputPath,
Metadata: metadata,
MetadataPath: metadataPath,
PriorSnapshot: priorSnapshot,
RecentChanges: recentChanges,
RenderResult: renderResult,
RunResult: runResult,
}, nil
}
func BuildDailyBriefing(req DailyBriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
return BuildBriefing(req, bundle)
}
func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Package, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return briefing.Package{}, err
}
dayparts := make([]forecast.DaypartDefinition, 0, len(req.Config.Dayparts))
for _, daypart := range req.Config.Dayparts {
dayparts = append(dayparts, forecast.DaypartDefinition{
Name: daypart.Name,
Start: daypart.Start,
End: daypart.End,
})
}
switch req.Resolved.Definition.ID {
case report.DailyToday, report.DailyTomorrow:
summary, err := forecast.BuildDailySummary(bundle, req.Resolved.ValidPeriod.Start, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
return briefing.BuildDaily(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summary)
case report.ThreeDay, report.Weekend:
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
if err != nil {
return briefing.Package{}, err
}
if req.Resolved.Definition.ID == report.Weekend {
return briefing.BuildWeekend(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summaries)
}
return briefing.BuildThreeDay(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
}, summaries)
case report.Storm:
return briefing.BuildStorm(briefing.BuildContext{
Resolved: req.Resolved,
Bundle: bundle,
Units: req.Config.WeatherAPI.Units,
Timezone: req.Config.WeatherAPI.Timezone,
})
default:
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
}
}
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
return state.NewFilesystemStore(cfg.Workspace)
}
func dailyRecentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
return recentChanges(ctx, store, priorSnapshot, current, cfg)
}
func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state.PriorSnapshot, current briefing.Package, cfg config.RecentChangeConfig) ([]changes.Change, error) {
if priorSnapshot == nil {
return nil, nil
}
previous, err := store.LoadBriefing(ctx, priorSnapshot.BriefingPath)
if err != nil {
return nil, err
}
thresholds := changes.Thresholds{
TemperatureDegrees: cfg.TemperatureDegrees,
PrecipProbabilityPoints: cfg.PrecipProbabilityPoints,
WindGustMilesPerHour: cfg.WindGustMilesPerHour,
PrecipTimingShiftMinutes: cfg.PrecipTimingShiftMinutes,
}
switch current.Metadata.ReportID {
case report.DailyToday, report.DailyTomorrow:
return changes.CompareDaily(previous, current, thresholds)
case report.ThreeDay:
return changes.CompareThreeDay(previous, current, thresholds)
case report.Weekend:
return changes.CompareWeekend(previous, current, thresholds)
default:
return nil, nil
}
}
func copyFileAtomic(source string, target string) error {
data, err := os.ReadFile(source)
if err != nil {
return fmt.Errorf("read rendered report %q: %w", source, err)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create report output directory %q: %w", filepath.Dir(target), err)
}
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary report output file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary report output file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary report output file: %w", err)
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("save report output %q: %w", target, err)
}
return nil
}

1168
internal/app/app_test.go Normal file

File diff suppressed because it is too large Load Diff

123
internal/app/inspect.go Normal file
View File

@@ -0,0 +1,123 @@
package app
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type InspectReportsRequest struct {
Config config.Config
Limit int
}
type InspectRunRequest struct {
Config config.Config
RunID string
}
type SourceInspection struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
}
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
return store.ListReports(ctx, req.Limit)
}
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
store, err := defaultStore(req.Config)
if err != nil {
return state.Metadata{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
return metadata, err
}
func InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
store, err := defaultStore(req.Config)
if err != nil {
return briefing.Package{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return briefing.Package{}, err
}
return store.LoadBriefing(ctx, metadata.BriefingPath)
}
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
store, err := defaultStore(req.Config)
if err != nil {
return promptinput.Package{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return promptinput.Package{}, err
}
return store.LoadDataPackage(ctx, metadata.DataPackagePath)
}
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return nil, err
}
resolved, err := resolvedFromMetadata(metadata)
if err != nil {
return nil, err
}
return store.FindPriorSnapshot(ctx, resolved)
}
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
metadata, err := InspectMetadata(ctx, req)
if err != nil {
return SourceInspection{}, err
}
return SourceInspection{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
SourceLocation: metadata.SourceLocation,
Sources: metadata.Sources,
Warnings: metadata.SourceWarnings,
}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil {
return report.Resolved{}, err
}
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return report.Resolved{}, err
}
if !metadata.ValidPeriod.IsValid() {
return report.Resolved{}, fmt.Errorf("metadata valid period for run id %q is invalid", metadata.RunID)
}
return report.Resolved{
Definition: definition,
GeneratedAt: metadata.GeneratedAt,
Timezone: location.String(),
ValidPeriod: metadata.ValidPeriod,
}, nil
}

431
internal/briefing/daily.go Normal file
View File

@@ -0,0 +1,431 @@
package briefing
import (
"fmt"
"math"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type Daily struct {
BottomLine BottomLine `json:"bottomLine"`
Dayparts []forecast.DaypartSummary `json:"dayparts"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
Planning *TomorrowPlanning `json:"planning,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
ForecastSummaryDate string `json:"forecastSummaryDate"`
}
type BottomLine struct {
Summary string `json:"summary"`
Hazards []string `json:"hazards,omitempty"`
Temperature forecast.Range `json:"temperature,omitempty"`
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
}
type OutdoorWindows struct {
Best *OutdoorWindow `json:"best,omitempty"`
Worst *OutdoorWindow `json:"worst,omitempty"`
}
type OutdoorWindow struct {
Daypart string `json:"daypart"`
Start string `json:"start"`
End string `json:"end"`
Reasons []string `json:"reasons,omitempty"`
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 {
Product string `json:"product,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"`
ShortTerm string `json:"shortTerm,omitempty"`
LongTerm string `json:"longTerm,omitempty"`
}
type WeatherStoryContext struct {
Available bool `json:"available"`
Summary string `json:"summary,omitempty"`
}
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
}
if summary == nil {
return Package{}, fmt.Errorf("daily forecast summary is required")
}
pkg := Package{
Metadata: BuildMetadata(ctx),
Daily: &Daily{
BottomLine: buildBottomLine(summary),
Dayparts: summary.Dayparts,
RelevantAlerts: summary.AlertOverlaps,
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
NarrativePeriods: summary.NarrativePeriods,
Discussion: buildDiscussion(summary.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
ForecastSummaryDate: summary.Date,
},
}
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary)
}
return pkg, nil
}
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
bottomLine := BottomLine{}
conditions := map[string]struct{}{}
hazards := map[string]struct{}{}
for _, daypart := range summary.Dayparts {
addRange(&bottomLine.Temperature, daypart.Temperature)
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
hazards[hazard] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
bottomLine.Hazards = sortedSet(hazards)
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
return bottomLine
}
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
var best *OutdoorWindow
var worst *OutdoorWindow
for _, daypart := range dayparts {
if len(daypart.HourlyPeriods) == 0 {
continue
}
window := scoreOutdoorWindow(daypart)
if best == nil || window.Score < best.Score {
copied := window
best = &copied
}
if worst == nil || window.Score > worst.Score {
copied := window
worst = &copied
}
}
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 {
if discussion == nil {
return DiscussionContext{}
}
ctx := DiscussionContext{
Product: discussion.Product,
KeyMessages: discussion.KeyMessages,
}
if discussion.ShortTerm != nil {
ctx.ShortTerm = discussion.ShortTerm.Narrative
}
if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Narrative
}
return ctx
}
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
return nil
}
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
}
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
score := 0.0
reasons := []string{}
if daypart.MaxPrecipitationProbability != nil {
score += daypart.MaxPrecipitationProbability.Value
if daypart.MaxPrecipitationProbability.Value >= 50 {
reasons = append(reasons, "high precipitation chance")
}
}
if daypart.PeakWindGust != nil {
score += daypart.PeakWindGust.Value * 1.5
if daypart.PeakWindGust.Value >= 30 {
reasons = append(reasons, "gusty wind")
}
}
if len(daypart.AlertOverlaps) > 0 {
score += float64(len(daypart.AlertOverlaps)) * 100
reasons = append(reasons, "alert overlap")
}
if daypart.Indicators.Thunder {
score += 75
reasons = append(reasons, "thunder risk")
}
if daypart.Indicators.Heat || daypart.Indicators.Cold {
score += 25
if daypart.Indicators.Heat {
reasons = append(reasons, "heat risk")
}
if daypart.Indicators.Cold {
reasons = append(reasons, "cold risk")
}
}
if len(reasons) == 0 {
reasons = append(reasons, "quiet weather")
}
return OutdoorWindow{
Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"),
End: daypart.Period.End.Format("15:04"),
Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10,
}
}
func bottomLineText(conditions []string, hazards []string) string {
if len(conditions) == 0 && len(hazards) == 0 {
return "Quiet weather is expected."
}
parts := []string{}
if len(conditions) > 0 {
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
}
if len(hazards) > 0 {
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
}
return strings.Join(parts, ". ") + "."
}
func hazardsForIndicators(indicators forecast.Indicators) []string {
var hazards []string
if indicators.Thunder {
hazards = append(hazards, "thunder")
}
if indicators.Snow {
hazards = append(hazards, "snow")
}
if indicators.Ice {
hazards = append(hazards, "ice")
}
if indicators.Fog {
hazards = append(hazards, "fog")
}
if indicators.Heat {
hazards = append(hazards, "heat")
}
if indicators.Cold {
hazards = append(hazards, "cold")
}
if indicators.Wind {
hazards = append(hazards, "wind")
}
return hazards
}
func addRange(target *forecast.Range, value forecast.Range) {
if value.Min != nil {
if target.Min == nil || *value.Min < *target.Min {
copied := *value.Min
target.Min = &copied
}
}
if value.Max != nil {
if target.Max == nil || *value.Max > *target.Max {
copied := *value.Max
target.Max = &copied
}
}
}
func maxTimedValue(target **forecast.TimedValue, value *forecast.TimedValue) {
if value == nil {
return
}
if *target == nil || value.Value > (*target).Value {
copied := *value
*target = &copied
}
}
func sortedSet(values map[string]struct{}) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}
func dedupe(values []string) []string {
seen := map[string]struct{}{}
out := []string{}
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
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:]
}

View File

@@ -0,0 +1,270 @@
package briefing
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
bundle := loadBundleFixture(t)
bundle.Sources[0].DataSHA256 = "abc123"
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{
Resolved: resolved,
Bundle: bundle,
Units: "us",
Timezone: "America/Chicago",
}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Metadata.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
}
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
}
if pkg.Metadata.ReportID != report.DailyToday {
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
}
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
}
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
}
if len(pkg.Metadata.SourceWarnings) != 1 {
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
}
if pkg.Daily == nil {
t.Fatal("Daily = nil")
}
if len(pkg.Daily.Dayparts) != 4 {
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
}
if len(pkg.Daily.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
}
if len(pkg.Daily.NarrativePeriods) != 1 {
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
}
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
}
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
}
if pkg.Daily.BottomLine.Summary == "" {
t.Fatal("BottomLine summary is empty")
}
if _, err := json.Marshal(pkg); err != nil {
t.Fatalf("briefing package is not JSON inspectable: %v", err)
}
}
func TestDailyBriefingQuietWeather(t *testing.T) {
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
}
if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
}
func TestDailyBriefingAlertExclusion(t *testing.T) {
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
bundle := loadBundleFixture(t)
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
}}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
}
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) {
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
if err := Save(path, pkg); err != nil {
t.Fatalf("Save() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read briefing: %v", err)
}
if !strings.Contains(string(data), SchemaVersion) {
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
}
}
func loadBundleFixture(t *testing.T) *forecast.Bundle {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
if err != nil {
t.Fatalf("read bundle fixture: %v", err)
}
var bundle forecast.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode bundle fixture: %v", err)
}
return &bundle
}
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve daily: %v", err)
}
return resolved
}
func defaultDayparts() []forecast.DaypartDefinition {
return []forecast.DaypartDefinition{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
}
}
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
return forecast.ForecastPeriod{
StartTime: mustParse(start),
EndTime: mustParse(end),
TextDescription: "Clear",
TemperatureF: &temperature,
}
}
func mustLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,159 @@
// Package briefing builds report-specific structured briefing packages.
package briefing
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct {
Metadata Metadata `json:"metadata"`
Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
}
type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
}
type SourceMetadata struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
}
type BuildContext struct {
Resolved report.Resolved
Bundle *forecast.Bundle
Units string
Timezone string
}
func BuildMetadata(ctx BuildContext) Metadata {
metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return Metadata{
SchemaVersion: SchemaVersion,
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Units: ctx.Units,
Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod,
SourceLocationID: sourceLocationID,
SourceLocation: sourceLocation,
Sources: sourceMetadata(ctx.Bundle),
SourceWarnings: sourceWarnings(ctx.Bundle),
}
}
func Save(path string, pkg Package) error {
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal briefing package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary briefing file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary briefing file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary briefing file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save briefing %q: %w", path, err)
}
return nil
}
func sourceLocation(bundle *forecast.Bundle) (string, string) {
if bundle == nil {
return "", ""
}
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
if run == nil {
continue
}
if run.LocationID != "" || run.LocationName != "" {
return run.LocationID, run.LocationName
}
}
return "", ""
}
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
if bundle == nil {
return nil
}
out := make([]SourceMetadata, 0, len(bundle.Sources))
for _, source := range bundle.Sources {
out = append(out, SourceMetadata{
Name: source.Name,
Endpoint: source.Endpoint,
FetchedAt: source.FetchedAt,
IssuedAt: source.IssuedAt,
UpdatedAt: source.UpdatedAt,
DataSHA256: source.DataSHA256,
Missing: source.Missing,
Warnings: source.Warnings,
})
}
return out
}
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
if bundle == nil {
return nil
}
return bundle.Warnings
}
func variantForReport(id report.ID) string {
switch id {
case report.DailyToday:
return "today"
case report.DailyTomorrow:
return "tomorrow"
default:
return ""
}
}

200
internal/briefing/storm.go Normal file
View File

@@ -0,0 +1,200 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Storm struct {
TimingWindow timeutil.Period `json:"timingWindow"`
EventHeadlines []string `json:"eventHeadlines,omitempty"`
Hazards []string `json:"hazards,omitempty"`
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
func BuildStorm(ctx BuildContext) (Package, error) {
if ctx.Resolved.Definition.ID != report.Storm {
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
}
if ctx.Bundle == nil {
return Package{}, fmt.Errorf("forecast bundle is required")
}
period := ctx.Resolved.ValidPeriod
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
summary := forecast.SummarizeDaypart("storm window", period, hourly)
summary.AlertOverlaps = alerts
storm := &Storm{
TimingWindow: period,
EventHeadlines: stormHeadlines(alerts),
Hazards: stormHazards(alerts, summary),
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
RelevantAlerts: alerts,
HourlyPeriods: hourly,
DailyPeriods: daily,
NarrativePeriods: narrative,
WindowSummary: summary,
Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
return Package{
Metadata: BuildMetadata(ctx),
Storm: storm,
}, nil
}
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
var headlines []string
for _, alert := range alerts {
if alert.Headline != "" {
headlines = appendUnique(headlines, alert.Headline)
continue
}
if alert.Event != "" {
headlines = appendUnique(headlines, alert.Event)
}
}
if len(headlines) == 0 {
return []string{"No active alert headline overlaps the selected storm window."}
}
return headlines
}
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
hazards := map[string]struct{}{}
for _, alert := range alerts {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
for _, hazard := range hazardsForIndicators(summary.Indicators) {
hazards[hazard] = struct{}{}
}
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
hazards["precipitation"] = struct{}{}
}
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
hazards["wind"] = struct{}{}
}
out := sortedSet(hazards)
if len(out) == 0 {
return []string{"No storm-specific hazard signal stands out in the selected source data."}
}
return out
}
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
var items []string
if summary.DominantCondition != "" {
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
}
if summary.PeakWindGust != nil {
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
}
for _, period := range narrative {
if period.TextDescription != "" {
items = append(items, "Narrative guidance: "+period.TextDescription)
break
}
}
if len(items) == 0 && len(hourly) > 0 {
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
}
if len(items) == 0 {
items = append(items, "No active storm signal is evident from the selected forecast window.")
}
return items
}
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
var items []string
for _, alert := range alerts {
label := alert.Event
if label == "" {
label = alert.Headline
}
if label != "" {
items = appendUnique(items, "Alert scenario to consider: "+label+".")
}
}
if summary.Indicators.Thunder {
items = appendUnique(items, "Thunderstorm timing or intensity could be more disruptive than the baseline forecast.")
}
if summary.Indicators.Wind {
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
}
if summary.Indicators.Snow || summary.Indicators.Ice {
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
}
if len(items) == 0 {
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
}
return items
}
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
var items []string
if bundle == nil {
return []string{"No source bundle was available for confidence context."}
}
if bundle.Discussion != nil {
items = appendUnique(items, bundle.Discussion.KeyMessages...)
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.")
}
}
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
items = appendUnique(items, "Weather story source is available.")
}
for _, warning := range bundle.Warnings {
if warning.Code != "" {
items = appendUnique(items, "Source warning: "+warning.Code+".")
}
}
if len(items) == 0 {
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
}
return items
}
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
var items []string
if len(alerts) > 0 {
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, "Watch precipitation timing and probability trends.")
}
if summary.PeakWindGust != nil {
items = append(items, "Watch wind gust trends.")
}
if bundle != nil && bundle.Discussion != nil {
items = append(items, "Watch the next forecast discussion update for confidence changes.")
}
if len(items) == 0 {
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
}
return appendUnique(nil, items...)
}

View File

@@ -0,0 +1,147 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestStormBriefingWithActiveAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
precip := 80.0
gust := 42.0
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
TextDescription: "Severe thunderstorms and gusty wind",
ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust,
}}},
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Storms likely.",
}}},
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Damaging wind possible in stronger storms.",
}}},
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Storms may intensify quickly."}},
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if pkg.Metadata.ReportID != report.Storm {
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
}
if pkg.Storm == nil {
t.Fatal("Storm = nil")
}
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
}
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
}
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
}
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
}
if pkg.Storm.WeatherStory == nil {
t.Fatal("WeatherStory = nil, want available story context")
}
if len(pkg.Storm.WhatToWatchNext) == 0 {
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
}
}
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
Alerts: &forecast.AlertRun{},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
}
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
}
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
}
if len(pkg.Storm.MostLikelyScenario) == 0 {
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
}
}
func TestStormBriefingQuietWindow(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
}
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
}
}

View File

@@ -0,0 +1,129 @@
package briefing
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ThreeDay struct {
Days []OutlookDay `json:"days"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
type OutlookDay struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
OverallCharacter string `json:"overallCharacter"`
Temperature forecast.Range `json:"temperature,omitempty"`
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
Risks []string `json:"risks,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Dayparts []forecast.DaypartSummary `json:"dayparts"`
}
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.ThreeDay {
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
}
if len(summaries) == 0 {
return Package{}, fmt.Errorf("3-day forecast summaries are required")
}
pkg := Package{
Metadata: BuildMetadata(ctx),
ThreeDay: &ThreeDay{
Discussion: buildDiscussion(summaries[0].Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
},
}
for _, summary := range summaries {
day := buildOutlookDay(summary)
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
}
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
return pkg, nil
}
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
alerts := map[string]forecast.AlertOverlap{}
for _, day := range days {
for _, alert := range day.RelevantAlerts {
key := alert.Event
if key == "" {
key = alert.Headline
}
if key != "" {
alerts[key] = alert
}
}
}
keys := make([]string, 0, len(alerts))
for key := range alerts {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]forecast.AlertOverlap, 0, len(keys))
for _, key := range keys {
out = append(out, alerts[key])
}
return out
}
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
day := OutlookDay{
Date: summary.Date,
Period: summary.Period,
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
RelevantAlerts: summary.AlertOverlaps,
Dayparts: summary.Dayparts,
}
conditions := map[string]struct{}{}
risks := map[string]struct{}{}
for _, daypart := range summary.Dayparts {
addRange(&day.Temperature, daypart.Temperature)
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, risk := range hazardsForIndicators(daypart.Indicators) {
risks[risk] = struct{}{}
}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
risks["precipitation"] = struct{}{}
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
risks["wind"] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
risks[alert.Event] = struct{}{}
}
}
day.Risks = sortedSet(risks)
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
return day
}
func outlookCharacter(conditions []string, risks []string) string {
if len(conditions) == 0 && len(risks) == 0 {
return "Quiet weather is expected."
}
parts := []string{}
if len(conditions) > 0 {
parts = append(parts, strings.Join(conditions, "; "))
}
if len(risks) > 0 {
parts = append(parts, "risks: "+strings.Join(risks, "; "))
}
return strings.Join(parts, ". ") + "."
}

View File

@@ -0,0 +1,83 @@
package briefing
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve 3-day: %v", err)
}
precip := 70.0
gust := 35.0
summaries := []forecast.DailySummary{
{
Date: "2026-05-29",
Period: timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00"),
End: mustParse("2026-05-30T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{
{
Name: "morning",
DominantCondition: "Showers and thunderstorms",
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: mustParse("2026-05-29T09:00:00-05:00"),
},
PeakWindGust: &forecast.TimedValue{
Value: gust,
Time: mustParse("2026-05-29T10:00:00-05:00"),
},
Indicators: forecast.Indicators{Thunder: true, Wind: true},
},
},
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
},
{
Date: "2026-05-30",
Period: timeutil.Period{
Start: mustParse("2026-05-30T00:00:00-05:00"),
End: mustParse("2026-05-31T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
},
}
pkg, err := BuildThreeDay(BuildContext{
Resolved: resolved,
Units: "us",
Timezone: "America/Chicago",
}, summaries)
if err != nil {
t.Fatalf("BuildThreeDay() error = %v", err)
}
if pkg.Metadata.ReportID != report.ThreeDay {
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
}
if pkg.ThreeDay == nil {
t.Fatal("ThreeDay = nil")
}
if len(pkg.ThreeDay.Days) != 2 {
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
}
first := pkg.ThreeDay.Days[0]
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
t.Fatalf("first day = %#v, want conditions and risks", first)
}
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
}
}

View File

@@ -0,0 +1,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)
}

View 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")
}
}

201
internal/changes/daily.go Normal file
View File

@@ -0,0 +1,201 @@
// Package changes compares structured briefing snapshots.
package changes
import (
"fmt"
"math"
"sort"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
type Thresholds struct {
TemperatureDegrees float64
PrecipProbabilityPoints int
WindGustMilesPerHour int
PrecipTimingShiftMinutes int
}
type Change struct {
Type string `json:"type"`
Message string `json:"message"`
Previous string `json:"previous,omitempty"`
Current string `json:"current,omitempty"`
}
func CompareDaily(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
if previous.Daily == nil {
return nil, fmt.Errorf("previous daily briefing is required")
}
if current.Daily == nil {
return nil, fmt.Errorf("current daily briefing is required")
}
var changes []Change
changes = append(changes, compareTemperature(previous.Daily.BottomLine.Temperature, current.Daily.BottomLine.Temperature, thresholds.TemperatureDegrees)...)
changes = append(changes, comparePrecipitation(previous.Daily.BottomLine.MaxPrecipProbability, current.Daily.BottomLine.MaxPrecipProbability, thresholds)...)
changes = append(changes, compareWind(previous.Daily.BottomLine.PeakWindGust, current.Daily.BottomLine.PeakWindGust, float64(thresholds.WindGustMilesPerHour))...)
changes = append(changes, compareAlerts(previous.Daily.RelevantAlerts, current.Daily.RelevantAlerts)...)
changes = append(changes, compareIndicators(aggregateIndicators(previous.Daily.Dayparts), aggregateIndicators(current.Daily.Dayparts))...)
return changes, nil
}
func compareTemperature(previous forecast.Range, current forecast.Range, threshold float64) []Change {
var changes []Change
if previous.Min != nil && current.Min != nil && differenceAtLeast(*previous.Min, *current.Min, threshold) {
changes = append(changes, Change{
Type: "temperature_shift",
Message: fmt.Sprintf("Low temperature changed from %.0f to %.0f.", *previous.Min, *current.Min),
Previous: fmt.Sprintf("%.0f", *previous.Min),
Current: fmt.Sprintf("%.0f", *current.Min),
})
}
if previous.Max != nil && current.Max != nil && differenceAtLeast(*previous.Max, *current.Max, threshold) {
changes = append(changes, Change{
Type: "temperature_shift",
Message: fmt.Sprintf("High temperature changed from %.0f to %.0f.", *previous.Max, *current.Max),
Previous: fmt.Sprintf("%.0f", *previous.Max),
Current: fmt.Sprintf("%.0f", *current.Max),
})
}
return changes
}
func comparePrecipitation(previous *forecast.TimedValue, current *forecast.TimedValue, thresholds Thresholds) []Change {
if previous == nil || current == nil {
return nil
}
var changes []Change
previousCategory := precipitationCategory(previous.Value)
currentCategory := precipitationCategory(current.Value)
if previousCategory != currentCategory || differenceAtLeast(previous.Value, current.Value, float64(thresholds.PrecipProbabilityPoints)) {
changes = append(changes, Change{
Type: "precip_probability_change",
Message: fmt.Sprintf("Peak precipitation chance changed from %.0f%% (%s) to %.0f%% (%s).", previous.Value, previousCategory, current.Value, currentCategory),
Previous: fmt.Sprintf("%.0f%% %s", previous.Value, previousCategory),
Current: fmt.Sprintf("%.0f%% %s", current.Value, currentCategory),
})
}
shiftMinutes := int(math.Abs(current.Time.Sub(previous.Time).Minutes()))
if thresholds.PrecipTimingShiftMinutes > 0 && shiftMinutes >= thresholds.PrecipTimingShiftMinutes {
changes = append(changes, Change{
Type: "precip_timing_shift",
Message: fmt.Sprintf("Peak precipitation timing shifted from %s to %s.", clock(previous.Time), clock(current.Time)),
Previous: clock(previous.Time),
Current: clock(current.Time),
})
}
return changes
}
func compareWind(previous *forecast.TimedValue, current *forecast.TimedValue, threshold float64) []Change {
if previous == nil || current == nil || !differenceAtLeast(previous.Value, current.Value, threshold) {
return nil
}
return []Change{{
Type: "wind_gust_change",
Message: fmt.Sprintf("Peak wind gust changed from %.0f mph to %.0f mph.", previous.Value, current.Value),
Previous: fmt.Sprintf("%.0f mph", previous.Value),
Current: fmt.Sprintf("%.0f mph", current.Value),
}}
}
func compareAlerts(previous []forecast.AlertOverlap, current []forecast.AlertOverlap) []Change {
previousSet := alertSet(previous)
currentSet := alertSet(current)
var changes []Change
for event := range currentSet {
if _, ok := previousSet[event]; !ok {
changes = append(changes, Change{Type: "alert_added", Message: fmt.Sprintf("Alert added: %s.", event), Current: event})
}
}
for event := range previousSet {
if _, ok := currentSet[event]; !ok {
changes = append(changes, Change{Type: "alert_removed", Message: fmt.Sprintf("Alert removed: %s.", event), Previous: event})
}
}
sortChanges(changes)
return changes
}
func compareIndicators(previous forecast.Indicators, current forecast.Indicators) []Change {
var changes []Change
for _, item := range []struct {
name string
previous bool
current bool
}{
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
{name: "snow", previous: previous.Snow, current: current.Snow},
{name: "ice", previous: previous.Ice, current: current.Ice},
} {
if item.previous == item.current {
continue
}
changeType := item.name + "_risk_change"
if item.current {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is now present.", item.name), Current: "present"})
} else {
changes = append(changes, Change{Type: changeType, Message: fmt.Sprintf("%s risk is no longer present.", item.name), Previous: "present"})
}
}
return changes
}
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
out := forecast.Indicators{}
for _, daypart := range dayparts {
out.Thunder = out.Thunder || daypart.Indicators.Thunder
out.Snow = out.Snow || daypart.Indicators.Snow
out.Ice = out.Ice || daypart.Indicators.Ice
}
return out
}
func alertSet(alerts []forecast.AlertOverlap) map[string]struct{} {
out := map[string]struct{}{}
for _, alert := range alerts {
event := alert.Event
if event == "" {
event = alert.Headline
}
if event != "" {
out[event] = struct{}{}
}
}
return out
}
func precipitationCategory(value float64) string {
switch {
case value >= 70:
return "high"
case value >= 50:
return "likely"
case value >= 20:
return "possible"
default:
return "low"
}
}
func differenceAtLeast(previous float64, current float64, threshold float64) bool {
if threshold <= 0 {
return previous != current
}
return math.Abs(current-previous) >= threshold
}
func clock(t time.Time) string {
return t.Format("15:04")
}
func sortChanges(items []Change) {
sort.SliceStable(items, func(i, j int) bool {
if items[i].Type == items[j].Type {
return items[i].Message < items[j].Message
}
return items[i].Type < items[j].Type
})
}

View File

@@ -0,0 +1,123 @@
package changes
import (
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
)
func TestCompareDailyNoMeaningfulChanges(t *testing.T) {
previous := dailyBriefing(60, 70, 30, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(61, 71, 35, at("2026-05-29T08:30:00Z"), nil, forecast.Indicators{})
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if len(changes) != 0 {
t.Fatalf("changes = %#v, want none", changes)
}
}
func TestCompareDailyTemperatureThreshold(t *testing.T) {
previous := dailyBriefing(50, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(58, 79, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "temperature_shift") != 2 {
t.Fatalf("changes = %#v, want low and high temperature changes", changes)
}
}
func TestCompareDailyPrecipTimingShift(t *testing.T) {
previous := dailyBriefing(60, 70, 60, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(60, 70, 60, at("2026-05-29T11:00:00Z"), nil, forecast.Indicators{})
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "precip_timing_shift") != 1 {
t.Fatalf("changes = %#v, want timing shift", changes)
}
}
func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Wind Advisory"}, forecast.Indicators{})
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), []string{"Flood Watch"}, forecast.Indicators{})
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "alert_added") != 1 || countType(changes, "alert_removed") != 1 {
t.Fatalf("changes = %#v, want one alert added and one removed", changes)
}
}
func TestCompareDailyIndicatorChange(t *testing.T) {
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Thunder: true})
changes, err := CompareDaily(previous, current, testThresholds())
if err != nil {
t.Fatalf("CompareDaily() error = %v", err)
}
if countType(changes, "thunder_risk_change") != 1 {
t.Fatalf("changes = %#v, want thunder risk change", changes)
}
}
func dailyBriefing(low float64, high float64, precip float64, precipTime time.Time, alerts []string, indicators forecast.Indicators) briefing.Package {
alertOverlaps := make([]forecast.AlertOverlap, 0, len(alerts))
for _, alert := range alerts {
alertOverlaps = append(alertOverlaps, forecast.AlertOverlap{Event: alert})
}
return briefing.Package{
Daily: &briefing.Daily{
BottomLine: briefing.BottomLine{
Temperature: forecast.Range{Min: &low, Max: &high},
MaxPrecipProbability: &forecast.TimedValue{
Value: precip,
Time: precipTime,
},
},
RelevantAlerts: alertOverlaps,
Dayparts: []forecast.DaypartSummary{
{Name: "morning", Indicators: indicators},
},
},
}
}
func testThresholds() Thresholds {
return Thresholds{
TemperatureDegrees: 5,
PrecipProbabilityPoints: 20,
WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120,
}
}
func countType(changes []Change, changeType string) int {
var count int
for _, change := range changes {
if change.Type == changeType {
count++
}
}
return count
}
func at(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,78 @@
package changes
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
)
func CompareThreeDay(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
if previous.ThreeDay == nil {
return nil, fmt.Errorf("previous 3-day briefing is required")
}
if current.ThreeDay == nil {
return nil, fmt.Errorf("current 3-day briefing is required")
}
previousDays := outlookDaysByDate(previous.ThreeDay.Days)
currentDays := outlookDaysByDate(current.ThreeDay.Days)
var changes []Change
for date, currentDay := range currentDays {
previousDay, ok := previousDays[date]
if !ok {
changes = append(changes, Change{Type: "outlook_day_added", Message: fmt.Sprintf("Outlook day added: %s.", date), Current: date})
continue
}
changes = append(changes, compareOutlookDay(date, previousDay, currentDay, thresholds)...)
}
for date := range previousDays {
if _, ok := currentDays[date]; !ok {
changes = append(changes, Change{Type: "outlook_day_removed", Message: fmt.Sprintf("Outlook day removed: %s.", date), Previous: date})
}
}
sortChanges(changes)
return changes, nil
}
func compareOutlookDay(date string, previous briefing.OutlookDay, current briefing.OutlookDay, thresholds Thresholds) []Change {
var changes []Change
for _, change := range compareTemperature(previous.Temperature, current.Temperature, thresholds.TemperatureDegrees) {
change.Message = date + ": " + change.Message
changes = append(changes, change)
}
for _, change := range comparePrecipitation(previous.MaxPrecipitationProbability, current.MaxPrecipitationProbability, thresholds) {
change.Message = date + ": " + change.Message
changes = append(changes, change)
changes[len(changes)-1].Type = "outlook_" + change.Type
}
for _, change := range compareWind(previous.PeakWindGust, current.PeakWindGust, float64(thresholds.WindGustMilesPerHour)) {
change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareAlerts(previous.RelevantAlerts, current.RelevantAlerts) {
change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change)
}
for _, change := range compareIndicators(aggregateIndicators(previous.Dayparts), aggregateIndicators(current.Dayparts)) {
change.Message = date + ": " + change.Message
change.Type = "outlook_" + change.Type
changes = append(changes, change)
}
return changes
}
func outlookDaysByDate(days []briefing.OutlookDay) map[string]briefing.OutlookDay {
out := map[string]briefing.OutlookDay{}
for _, day := range days {
date := day.Date
if date == "" && !day.Period.Start.IsZero() {
date = day.Period.Start.Format(time.DateOnly)
}
if date != "" {
out[date] = day
}
}
return out
}

View File

@@ -0,0 +1,66 @@
package changes
import (
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
previousTemp := 70.0
currentTemp := 78.0
previousPrecip := 20.0
currentPrecip := 70.0
previous := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &previousTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: previousPrecip,
Time: time.Date(2026, 5, 29, 9, 0, 0, 0, time.UTC),
},
}}},
}
current := briefing.Package{
Metadata: briefing.Metadata{ReportID: report.ThreeDay},
ThreeDay: &briefing.ThreeDay{Days: []briefing.OutlookDay{{
Date: "2026-05-29",
Temperature: forecast.Range{Max: &currentTemp},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: currentPrecip,
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
},
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
}}},
}
changes, err := CompareThreeDay(previous, current, Thresholds{
TemperatureDegrees: 5,
PrecipProbabilityPoints: 20,
PrecipTimingShiftMinutes: 120,
})
if err != nil {
t.Fatalf("CompareThreeDay() error = %v", err)
}
if len(changes) == 0 {
t.Fatal("changes length = 0, want detected 3-day changes")
}
var foundPrecip bool
var foundThunder bool
for _, change := range changes {
if change.Type == "outlook_precip_probability_change" {
foundPrecip = true
}
if change.Type == "outlook_thunder_risk_change" {
foundThunder = true
}
}
if !foundPrecip || !foundThunder {
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
}
}

View File

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

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

436
internal/cli/root.go Normal file
View File

@@ -0,0 +1,436 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const helpText = `weatherreporter prepares weather reports from normalized forecast data.
Usage:
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect briefing [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
Options:
-h, --help Show this help message.
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy for generate commands.
--out-dir PATH Write extra Markdown report copies for run commands.
`
type Runner struct {
Clock timeutil.Clock
}
func Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
return Runner{Clock: timeutil.SystemClock{}}.Run(ctx, args, stdout, stderr)
}
func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
_ = stderr
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_, err := fmt.Fprint(stdout, helpText)
return err
}
switch args[0] {
case "generate":
req, err := r.resolveGenerate(args[1:])
if err != nil {
return err
}
return app.Generate(ctx, req)
case "run":
req, err := r.resolveRun(args[1:])
if err != nil {
return err
}
result, err := app.RunBatchDetailed(ctx, req)
if result != nil {
writeRunLogs(stderr, result)
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
return encodeErr
}
if result.Failed > 0 {
return app.BatchError{Result: result}
}
}
return err
case "inspect":
return r.runInspect(ctx, args[1:], stdout)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
type commonOptions struct {
ConfigPath string
Units string
Timezone string
Output string
OutputDir string
}
type generateOptions struct {
commonOptions
Date string
Start string
End string
}
type inspectOptions struct {
ConfigPath string
Limit int
RunID string
}
func (r Runner) runInspect(ctx context.Context, args []string, stdout io.Writer) error {
if len(args) == 0 {
return fmt.Errorf("inspect requires a command")
}
command := args[0]
switch command {
case "reports":
opts, err := parseInspectReportsFlags(args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
records, err := app.InspectReports(ctx, app.InspectReportsRequest{Config: cfg, Limit: opts.Limit})
if err != nil {
return err
}
return writeJSON(stdout, records)
case "metadata":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
metadata, err := app.InspectMetadata(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, metadata)
case "briefing":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectBriefing(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "data-package":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
pkg, err := app.InspectDataPackage(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, pkg)
case "prior":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
prior, err := app.InspectPriorSnapshot(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, prior)
case "sources":
opts, err := parseInspectRunFlags(command, args[1:])
if err != nil {
return err
}
cfg, err := config.Load(config.LoadOptions{Path: opts.ConfigPath})
if err != nil {
return err
}
sources, err := app.InspectSources(ctx, app.InspectRunRequest{Config: cfg, RunID: opts.RunID})
if err != nil {
return err
}
return writeJSON(stdout, sources)
default:
return fmt.Errorf("unknown inspect command %q", command)
}
}
func (r Runner) resolveGenerate(args []string) (app.GenerateRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 {
return app.GenerateRequest{}, fmt.Errorf("generate requires a report name")
}
report, ok := reportKind(args[0])
if !ok {
return app.GenerateRequest{}, fmt.Errorf("unknown generate report %q", args[0])
}
opts, err := parseGenerateFlags(report, args[1:])
if err != nil {
return app.GenerateRequest{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
Output: opts.Output,
})
if err != nil {
return app.GenerateRequest{}, err
}
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return app.GenerateRequest{}, err
}
req := app.GenerateRequest{
Config: cfg,
Report: report,
OutputPath: opts.Output,
Now: r.Clock.Now(),
}
switch report {
case app.ReportDaily:
if opts.Date == "" {
req.Date = timeutil.LocalDate(r.Clock.Now(), location)
} else {
req.Date, err = timeutil.ParseLocalDate(opts.Date, location)
if err != nil {
return app.GenerateRequest{}, err
}
}
case app.ReportStorm:
if opts.Start == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --start")
}
if opts.End == "" {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end")
}
req.StormStart, err = timeutil.ParseStormTime(opts.Start, location)
if err != nil {
return app.GenerateRequest{}, err
}
req.StormEnd, err = timeutil.ParseStormTime(opts.End, location)
if err != nil {
return app.GenerateRequest{}, err
}
if !req.StormEnd.After(req.StormStart) {
return app.GenerateRequest{}, fmt.Errorf("generate storm requires --end after --start")
}
}
return req, nil
}
func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
if r.Clock == nil {
r.Clock = timeutil.SystemClock{}
}
if len(args) == 0 {
return app.BatchRequest{}, fmt.Errorf("run requires a batch name")
}
batch, ok := batchKind(args[0])
if !ok {
return app.BatchRequest{}, fmt.Errorf("unknown run batch %q", args[0])
}
opts, err := parseRunFlags(args[1:])
if err != nil {
return app.BatchRequest{}, err
}
cfg, err := config.Load(config.LoadOptions{
Path: opts.ConfigPath,
Units: opts.Units,
Timezone: opts.Timezone,
})
if err != nil {
return app.BatchRequest{}, err
}
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil
}
func resolveRun(args []string) (app.BatchRequest, error) {
return Runner{Clock: timeutil.SystemClock{}}.resolveRun(args)
}
func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, error) {
fs := flag.NewFlagSet("generate "+string(report), flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := generateOptions{}
addCommonFlags(fs, &opts.commonOptions, true)
if report == app.ReportDaily {
fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD")
}
if report == app.ReportStorm {
fs.StringVar(&opts.Start, "start", "", "storm start time")
fs.StringVar(&opts.End, "end", "", "storm end time")
}
if err := fs.Parse(args); err != nil {
return generateOptions{}, err
}
if fs.NArg() > 0 {
return generateOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
return opts, nil
}
func parseRunFlags(args []string) (commonOptions, error) {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := commonOptions{}
addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
if err := fs.Parse(args); err != nil {
return commonOptions{}, err
}
if fs.NArg() > 0 {
return commonOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
return opts, nil
}
func parseInspectReportsFlags(args []string) (inspectOptions, error) {
fs := flag.NewFlagSet("inspect reports", flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := inspectOptions{Limit: 20}
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.IntVar(&opts.Limit, "limit", 20, "maximum reports to list")
if err := fs.Parse(args); err != nil {
return inspectOptions{}, err
}
if fs.NArg() > 0 {
return inspectOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
if opts.Limit < 0 {
return inspectOptions{}, fmt.Errorf("limit must be zero or greater")
}
return opts, nil
}
func parseInspectRunFlags(command string, args []string) (inspectOptions, error) {
fs := flag.NewFlagSet("inspect "+command, flag.ContinueOnError)
fs.SetOutput(io.Discard)
opts := inspectOptions{}
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
if err := fs.Parse(args); err != nil {
return inspectOptions{}, err
}
if fs.NArg() != 1 {
return inspectOptions{}, fmt.Errorf("inspect %s requires a run id", command)
}
opts.RunID = fs.Arg(0)
return opts, nil
}
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func writeJSON(stdout io.Writer, value any) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(value)
}
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
if stderr == nil || result == nil {
return
}
for _, item := range result.Reports {
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")
fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone")
if includeOutput {
fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path")
}
}
func reportKind(value string) (app.ReportKind, bool) {
switch value {
case string(app.ReportDaily):
return app.ReportDaily, true
case string(app.ReportTomorrow):
return app.ReportTomorrow, true
case string(app.ReportThreeDay):
return app.ReportThreeDay, true
case string(app.ReportWeekend):
return app.ReportWeekend, true
case string(app.ReportStorm):
return app.ReportStorm, true
default:
return "", false
}
}
func batchKind(value string) (app.BatchKind, bool) {
switch value {
case string(app.BatchMorning):
return app.BatchMorning, true
case string(app.BatchEvening):
return app.BatchEvening, true
default:
return "", false
}
}

811
internal/cli/root_test.go Normal file
View File

@@ -0,0 +1,811 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestRunHelpLongFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), []string{"--help"}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(stdout.String(), "generate daily") {
t.Fatalf("help output missing generate command:\n%s", stdout.String())
}
}
func TestRunHelpShortFlag(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), []string{"-h"}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(stdout.String(), "weatherreporter run evening") {
t.Fatalf("help output missing run command:\n%s", stdout.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
err := Run(context.Background(), []string{"unknown"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want unknown command error")
}
if !strings.Contains(err.Error(), `unknown command "unknown"`) {
t.Fatalf("Run() error = %q, want unknown command message", err.Error())
}
}
func TestRunGenerateStormWritesMarkdownReport(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, "storm.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"generate", "storm",
"--config", configPath,
"--start", "2026-05-29T06:00",
"--end", "2026-05-29T10:00",
"--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", "storm", "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), `"storm"`) || !strings.Contains(string(data), `"weather.storm_report"`) {
t.Fatalf("data package output missing storm content:\n%s", string(data))
}
}
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 TestRunGenerateThreeDayWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
outPath := filepath.Join(tempDir, "three-day.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"generate", "three-day",
"--config", configPath,
"--out", outPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
report, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
if !strings.Contains(string(report), "# Daily Report") {
t.Fatalf("report output missing markdown:\n%s", string(report))
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob data package: %v", err)
}
if len(dataPackageMatches) != 1 {
t.Fatalf("data package files = %#v, want one", dataPackageMatches)
}
data, err := os.ReadFile(dataPackageMatches[0])
if err != nil {
t.Fatalf("read managed data package: %v", err)
}
if !strings.Contains(string(data), `"three_day"`) || !strings.Contains(string(data), `"threeDay"`) {
t.Fatalf("data package output missing 3-day content:\n%s", string(data))
}
}
func 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 TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFailingScriptorium(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.Fatal("Run() error = nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "1 of 3 reports failed") {
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
}
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob daily packages: %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(dailyPackages) != 1 || len(weekendPackages) != 1 {
t.Fatalf("daily packages = %#v, weekend packages = %#v; want successful reports to continue", dailyPackages, weekendPackages)
}
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
outputDir := filepath.Join(tempDir, "copies")
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,
"--out-dir", outputDir,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if summary.Total != 1 || summary.Failed != 0 {
t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed)
}
if _, err := os.Stat(filepath.Join(outputDir, "tomorrow.md")); err != nil {
t.Fatalf("expected copied report: %v", err)
}
if len(summary.Reports) != 1 || summary.Reports[0].OutputPath != filepath.Join(outputDir, "tomorrow.md") {
t.Fatalf("summary reports = %#v, want output path", summary.Reports)
}
}
func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}}
err := runner.Run(context.Background(), []string{
"run", "morning",
"--config", configPath,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-31", "*.data_package.json"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
threeDayPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "three-day", "2026-05-31", "*.data_package.json"))
if err != nil {
t.Fatalf("glob 3-day packages: %v", err)
}
if len(dailyPackages) != 1 || len(threeDayPackages) != 1 {
t.Fatalf("daily packages = %#v, 3-day packages = %#v; want one each", dailyPackages, threeDayPackages)
}
}
func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
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, "daily.md")
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"generate", "daily",
"--config", configPath,
"--date", "2026-05-29",
"--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-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), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
t.Fatalf("data package output missing expected content:\n%s", string(data))
}
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
if err != nil {
t.Fatalf("glob preflight: %v", err)
}
if len(preflightMatches) != 1 {
t.Fatalf("preflight files = %#v, want one render output", preflightMatches)
}
preflight, err := os.ReadFile(preflightMatches[0])
if err != nil {
t.Fatalf("read preflight: %v", err)
}
if !strings.Contains(string(preflight), `ok`) {
t.Fatalf("preflight missing fake render output:\n%s", string(preflight))
}
reportMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "reports", "daily", "*.md"))
if err != nil {
t.Fatalf("glob managed report: %v", err)
}
if len(reportMatches) != 1 {
t.Fatalf("managed reports = %#v, want one", reportMatches)
}
}
func TestRunInspectGeneratedArtifacts(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)
}
runner := Runner{Clock: fixedClock()}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runner.Run(context.Background(), []string{
"generate", "daily",
"--config", configPath,
"--date", "2026-05-29",
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(generate) error = %v", err)
}
dataPackageMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "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)
}
runID := strings.TrimSuffix(filepath.Base(dataPackageMatches[0]), ".data_package.json")
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--limit", "1"}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(inspect reports) error = %v", err)
}
if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"metadataPath"`) {
t.Fatalf("inspect reports output missing run:\n%s", stdout.String())
}
for _, command := range []string{"metadata", "briefing", "data-package", "sources"} {
stdout.Reset()
err = runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, runID}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run(inspect %s) error = %v", command, err)
}
if !strings.Contains(stdout.String(), runID) {
t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String())
}
}
if !strings.Contains(stdout.String(), `"warnings"`) {
t.Fatalf("inspect sources output missing warnings:\n%s", stdout.String())
}
}
func TestRunInspectMissingMetadata(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yml")
configBody := "workspace:\n root: " + filepath.Join(tempDir, "workspace") + "\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{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr)
if err == nil {
t.Fatal("Run(inspect metadata) error = nil, want missing metadata error")
}
if !strings.Contains(err.Error(), "metadata for run id") {
t.Fatalf("error = %q, want missing run id context", err.Error())
}
}
func TestResolveGenerateCommands(t *testing.T) {
runner := Runner{Clock: fixedClock()}
tests := []struct {
name string
args []string
want app.ReportKind
}{
{name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily},
{name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow},
{name: "three-day", args: []string{"three-day"}, want: app.ReportThreeDay},
{name: "weekend", args: []string{"weekend"}, want: app.ReportWeekend},
{name: "storm", args: []string{"storm", "--start", "2026-05-29T18:00", "--end", "2026-05-30T06:00"}, want: app.ReportStorm},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := runner.resolveGenerate(tt.args)
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Report != tt.want {
t.Fatalf("Report = %q, want %q", req.Report, tt.want)
}
})
}
}
func TestResolveGenerateDailyDefaultsDateInConfiguredTimezone(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" {
t.Fatalf("Date = %s, want 2026-05-29", got)
}
}
func TestResolveGenerateAppliesSharedFlags(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{"daily", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if req.Config.WeatherAPI.Units != "metric" {
t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units)
}
if req.Config.WeatherAPI.Timezone != "UTC" {
t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone)
}
if req.OutputPath != "./daily.md" {
t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath)
}
}
func TestResolveGenerateStormRequiresStartAndEnd(t *testing.T) {
runner := Runner{Clock: fixedClock()}
_, err := runner.resolveGenerate([]string{"storm", "--start", "2026-05-29T18:00"})
if err == nil {
t.Fatal("resolveGenerate() error = nil, want missing end error")
}
if !strings.Contains(err.Error(), "requires --end") {
t.Fatalf("error = %q, want missing end", err.Error())
}
}
func TestResolveGenerateStormParsesRFC3339(t *testing.T) {
runner := Runner{Clock: fixedClock()}
req, err := runner.resolveGenerate([]string{
"storm",
"--start", "2026-05-29T18:00:00-05:00",
"--end", "2026-05-30T06:00:00-05:00",
})
if err != nil {
t.Fatalf("resolveGenerate() error = %v", err)
}
if !req.StormEnd.After(req.StormStart) {
t.Fatalf("StormEnd = %s, want after %s", req.StormEnd, req.StormStart)
}
}
func TestResolveRunCommands(t *testing.T) {
tests := []struct {
name string
args []string
want app.BatchKind
}{
{name: "morning", args: []string{"morning"}, want: app.BatchMorning},
{name: "evening", args: []string{"evening", "--tz", "UTC"}, want: app.BatchEvening},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := resolveRun(tt.args)
if err != nil {
t.Fatalf("resolveRun() error = %v", err)
}
if req.Batch != tt.want {
t.Fatalf("Batch = %q, want %q", req.Batch, tt.want)
}
})
}
}
func TestResolveRunRejectsOutputFlag(t *testing.T) {
_, err := resolveRun([]string{"morning", "--out", "./report.md"})
if err == nil {
t.Fatal("resolveRun() error = nil, want flag error")
}
if !strings.Contains(err.Error(), "flag provided but not defined") {
t.Fatalf("error = %q, want undefined flag error", err.Error())
}
}
func TestResolveRunAppliesOutputDirectory(t *testing.T) {
req, err := resolveRun([]string{"evening", "--out-dir", "./reports"})
if err != nil {
t.Fatalf("resolveRun() error = %v", err)
}
if req.OutputDir != "./reports" {
t.Fatalf("OutputDir = %q, want ./reports", req.OutputDir)
}
}
func fixedClock() timeutil.Clock {
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
}
func dailyServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/observations":
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
case "/conditions/current":
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
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},{"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":
_, _ = 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":
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
case "/discussion":
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server
}
func writeFakeScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")
body := `#!/bin/sh
if [ "$1" = "render" ]; then
printf '{"ok":true,"argv":"%s"}' "$*"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
fi
shift
done
printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out"
printf 'wrote report\n' >&2
exit 0
fi
printf 'unexpected command\n' >&2
exit 1
`
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatalf("write fake scriptorium: %v", err)
}
return path
}
func writeFailingScriptorium(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "scriptorium")
body := `#!/bin/sh
if [ "$1" = "render" ]; then
prompt=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--prompt" ]; then
shift
prompt="$1"
fi
shift
done
if [ "$prompt" = "weather.three_day_outlook" ]; then
printf 'render failed\n' >&2
exit 1
fi
printf '{"ok":true,"prompt":"%s"}' "$prompt"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
fi
shift
done
printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out"
exit 0
fi
printf 'unexpected command\n' >&2
exit 1
`
if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
t.Fatalf("write fake scriptorium: %v", err)
}
return path
}

71
internal/config/config.go Normal file
View File

@@ -0,0 +1,71 @@
// Package config owns application configuration structures, defaults, loading,
// precedence, and validation.
package config
import "time"
type MissingSourcePolicy string
const (
MissingSourceError MissingSourcePolicy = "error"
MissingSourceWarn MissingSourcePolicy = "warn"
MissingSourceNone MissingSourcePolicy = "none"
)
type Config struct {
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
MissingSource MissingSourceConfig `yaml:"missing_source"`
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
Workspace WorkspaceConfig `yaml:"workspace"`
Reports ReportOutputConfig `yaml:"reports"`
Dayparts []DaypartConfig `yaml:"dayparts"`
RecentChange RecentChangeConfig `yaml:"recent_change"`
}
type WeatherAPIConfig struct {
BaseURL string `yaml:"base_url"`
Timeout time.Duration `yaml:"timeout"`
Precision int `yaml:"precision"`
Units string `yaml:"units"`
Timezone string `yaml:"timezone"`
Format string `yaml:"format"`
}
type MissingSourceConfig struct {
Default MissingSourcePolicy `yaml:"default"`
Sources map[string]MissingSourcePolicy `yaml:"sources"`
}
type ScriptoriumConfig struct {
Binary string `yaml:"binary"`
ConfigPath string `yaml:"config_path"`
Profile string `yaml:"profile"`
Timeout time.Duration `yaml:"timeout"`
ExtraArgs []string `yaml:"extra_args"`
}
type WorkspaceConfig struct {
Root string `yaml:"root"`
SnapshotsDir string `yaml:"snapshots_dir"`
ReportsDir string `yaml:"reports_dir"`
DataPackagesDir string `yaml:"data_packages_dir"`
PreflightDir string `yaml:"preflight_dir"`
}
type ReportOutputConfig struct {
OutputDir string `yaml:"output_dir"`
Paths map[string]string `yaml:"paths"`
}
type DaypartConfig struct {
Name string `yaml:"name"`
Start string `yaml:"start"`
End string `yaml:"end"`
}
type RecentChangeConfig struct {
TemperatureDegrees float64 `yaml:"temperature_degrees"`
PrecipProbabilityPoints int `yaml:"precip_probability_points"`
WindGustMilesPerHour int `yaml:"wind_gust_miles_per_hour"`
PrecipTimingShiftMinutes int `yaml:"precip_timing_shift_minutes"`
}

View File

@@ -0,0 +1,88 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDefaults(t *testing.T) {
cfg, err := Load(LoadOptions{})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.WeatherAPI.Units != "us" {
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
}
if cfg.WeatherAPI.Timezone != "Chicago" {
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
}
if cfg.WeatherAPI.Format != "json" {
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
}
if cfg.MissingSource.Default != MissingSourceWarn {
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
}
}
func TestLoadExampleConfig(t *testing.T) {
cfg, err := LoadFile(filepath.Join("..", "..", "examples", "config.yml"))
if err != nil {
t.Fatalf("LoadFile() error = %v", err)
}
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
}
if cfg.WeatherAPI.Timeout != 15*time.Second {
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
}
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
}
}
func TestExplicitMissingConfigReturnsError(t *testing.T) {
_, err := LoadFile(filepath.Join(t.TempDir(), "missing.yml"))
if err == nil {
t.Fatal("LoadFile() error = nil, want missing file error")
}
if !strings.Contains(err.Error(), "read config") {
t.Fatalf("error = %q, want read config context", err.Error())
}
}
func TestInvalidConfigProducesActionableError(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yml")
if err := os.WriteFile(path, []byte("missing_source:\n default: explode\n"), 0o600); err != nil {
t.Fatalf("write config fixture: %v", err)
}
_, err := LoadFile(path)
if err == nil {
t.Fatal("LoadFile() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "missing_source.default") {
t.Fatalf("error = %q, want field path", err.Error())
}
}
func TestLoadAppliesOverrides(t *testing.T) {
cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.WeatherAPI.Units != "metric" {
t.Fatalf("Units = %q, want metric", cfg.WeatherAPI.Units)
}
if cfg.WeatherAPI.Timezone != "+09:30" {
t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone)
}
if cfg.Reports.OutputDir != "./out" {
t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir)
}
}

View File

@@ -0,0 +1,48 @@
package config
import "time"
const DefaultPath = "/usr/local/etc/weatherreporter/config.yml"
func Defaults() Config {
return Config{
WeatherAPI: WeatherAPIConfig{
Timeout: 10 * time.Second,
Precision: 1,
Units: "us",
Timezone: "Chicago",
Format: "json",
},
MissingSource: MissingSourceConfig{
Default: MissingSourceWarn,
Sources: map[string]MissingSourcePolicy{},
},
Scriptorium: ScriptoriumConfig{
Binary: "scriptorium",
Timeout: 2 * time.Minute,
},
Workspace: WorkspaceConfig{
Root: "workspace",
SnapshotsDir: "snapshots",
ReportsDir: "reports",
DataPackagesDir: "data-packages",
PreflightDir: "preflight",
},
Reports: ReportOutputConfig{
OutputDir: "reports",
Paths: map[string]string{},
},
Dayparts: []DaypartConfig{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
},
RecentChange: RecentChangeConfig{
TemperatureDegrees: 5,
PrecipProbabilityPoints: 20,
WindGustMilesPerHour: 10,
PrecipTimingShiftMinutes: 120,
},
}
}

68
internal/config/load.go Normal file
View File

@@ -0,0 +1,68 @@
package config
import (
"errors"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type LoadOptions struct {
Path string
Units string
Timezone string
Output string
}
func Load(opts LoadOptions) (Config, error) {
cfg := Defaults()
path := opts.Path
if path == "" {
path = DefaultPath
}
if err := mergeFile(&cfg, path); err != nil {
if opts.Path != "" || !errors.Is(err, os.ErrNotExist) {
return Config{}, err
}
}
if opts.Units != "" {
cfg.WeatherAPI.Units = opts.Units
}
if opts.Timezone != "" {
cfg.WeatherAPI.Timezone = opts.Timezone
}
if opts.Output != "" {
cfg.Reports.OutputDir = opts.Output
}
if err := Validate(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func LoadFile(path string) (Config, error) {
return Load(LoadOptions{Path: path})
}
func mergeFile(cfg *Config, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %q: %w", path, err)
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("parse config %q: %w", path, err)
}
if cfg.MissingSource.Sources == nil {
cfg.MissingSource.Sources = map[string]MissingSourcePolicy{}
}
if cfg.Reports.Paths == nil {
cfg.Reports.Paths = map[string]string{}
}
return nil
}

View File

@@ -0,0 +1,88 @@
package config
import (
"fmt"
"net/url"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func Validate(cfg Config) error {
if cfg.WeatherAPI.BaseURL != "" {
parsed, err := url.Parse(cfg.WeatherAPI.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("weather_api.base_url must be an absolute URL")
}
}
if cfg.WeatherAPI.Timeout <= 0 {
return fmt.Errorf("weather_api.timeout must be greater than zero")
}
if cfg.WeatherAPI.Precision < 0 {
return fmt.Errorf("weather_api.precision must be zero or greater")
}
if cfg.WeatherAPI.Units == "" {
return fmt.Errorf("weather_api.units is required")
}
if cfg.WeatherAPI.Timezone == "" {
return fmt.Errorf("weather_api.timezone is required")
}
if _, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone); err != nil {
return fmt.Errorf("weather_api.timezone %q is invalid: %w", cfg.WeatherAPI.Timezone, err)
}
if cfg.WeatherAPI.Format == "" {
return fmt.Errorf("weather_api.format is required")
}
if cfg.WeatherAPI.Format != "json" {
return fmt.Errorf("weather_api.format must be json")
}
if err := validatePolicy("missing_source.default", cfg.MissingSource.Default); err != nil {
return err
}
for source, policy := range cfg.MissingSource.Sources {
if strings.TrimSpace(source) == "" {
return fmt.Errorf("missing_source.sources contains an empty source name")
}
if err := validatePolicy("missing_source.sources."+source, policy); err != nil {
return err
}
}
if cfg.Scriptorium.Binary == "" {
return fmt.Errorf("scriptorium.binary is required")
}
if cfg.Scriptorium.Timeout <= 0 {
return fmt.Errorf("scriptorium.timeout must be greater than zero")
}
if cfg.Workspace.Root == "" {
return fmt.Errorf("workspace.root is required")
}
if cfg.Reports.OutputDir == "" {
return fmt.Errorf("reports.output_dir is required")
}
if len(cfg.Dayparts) == 0 {
return fmt.Errorf("dayparts must contain at least one entry")
}
for i, daypart := range cfg.Dayparts {
if strings.TrimSpace(daypart.Name) == "" {
return fmt.Errorf("dayparts[%d].name is required", i)
}
if _, err := timeutil.ParseClock(daypart.Start); err != nil {
return fmt.Errorf("dayparts[%d].start is invalid: %w", i, err)
}
if _, err := timeutil.ParseClock(daypart.End); err != nil {
return fmt.Errorf("dayparts[%d].end is invalid: %w", i, err)
}
}
return nil
}
func validatePolicy(name string, policy MissingSourcePolicy) error {
switch policy {
case MissingSourceError, MissingSourceWarn, MissingSourceNone:
return nil
default:
return fmt.Errorf("%s must be one of error, warn, or none", name)
}
}

161
internal/forecast/bundle.go Normal file
View File

@@ -0,0 +1,161 @@
// Package forecast defines normalized weather data consumed by report builders.
package forecast
import (
"encoding/json"
"time"
)
type Bundle struct {
FetchedAt time.Time `json:"fetchedAt"`
Observation *Observation `json:"observation,omitempty"`
Current *Current `json:"current,omitempty"`
Hourly *ForecastRun `json:"hourly,omitempty"`
Narrative *ForecastRun `json:"narrative,omitempty"`
Alerts *AlertRun `json:"alerts,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
Daily *ForecastRun `json:"daily,omitempty"`
WeatherStory *WeatherStory `json:"weatherStory,omitempty"`
Sources []Source `json:"sources"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type Source struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
Query map[string]string `json:"query,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []SourceWarning `json:"warnings,omitempty"`
}
type SourceWarning struct {
Source string `json:"source"`
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message"`
Endpoint string `json:"endpoint,omitempty"`
CompletenessImpact string `json:"completenessImpact,omitempty"`
}
type Observation struct {
StationID string `json:"stationId,omitempty"`
StationName string `json:"stationName,omitempty"`
Timestamp time.Time `json:"timestamp"`
ConditionCode *int `json:"conditionCode,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TextDescription string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
PresentWeather []json.RawMessage `json:"presentWeather,omitempty"`
}
type Current struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
}
type ForecastRun struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Product string `json:"product"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
ElevationMeters *float64 `json:"elevationMeters,omitempty"`
ElevationFeet *float64 `json:"elevationFeet,omitempty"`
Periods []ForecastPeriod `json:"periods"`
}
type ForecastPeriod struct {
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
ConditionCode *int `json:"conditionCode,omitempty"`
TextDescription string `json:"textDescription,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
TemperatureCMin *float64 `json:"temperatureCMin,omitempty"`
TemperatureFMin *float64 `json:"temperatureFMin,omitempty"`
TemperatureCMax *float64 `json:"temperatureCMax,omitempty"`
TemperatureFMax *float64 `json:"temperatureFMax,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindGustKmh *float64 `json:"windGustKmh,omitempty"`
WindGustMph *float64 `json:"windGustMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"`
BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"`
VisibilityMeters *float64 `json:"visibilityMeters,omitempty"`
VisibilityMiles *float64 `json:"visibilityMiles,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty"`
SnowfallDepthMM *float64 `json:"snowfallDepthMM,omitempty"`
SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty"`
UVIndex *float64 `json:"uvIndex,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
}
type AlertRun struct {
AsOf *time.Time `json:"asOf,omitempty"`
Alerts []json.RawMessage `json:"alerts,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
type Discussion struct {
OfficeID string `json:"officeId,omitempty"`
OfficeName string `json:"officeName,omitempty"`
Product string `json:"product"`
IssuedAt time.Time `json:"issuedAt"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"`
ShortTerm *DiscussionSection `json:"shortTerm,omitempty"`
LongTerm *DiscussionSection `json:"longTerm,omitempty"`
}
type DiscussionSection struct {
Title string `json:"title,omitempty"`
Narrative string `json:"narrative,omitempty"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
}
type WeatherStory struct {
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}

View File

@@ -0,0 +1,53 @@
package forecast
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type DaypartDefinition struct {
Name string `json:"name"`
Start string `json:"start"`
End string `json:"end"`
}
type DaypartWindow struct {
Name string `json:"name"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
Period timeutil.Period `json:"period"`
}
func ResolveDayparts(date time.Time, location *time.Location, definitions []DaypartDefinition) ([]DaypartWindow, error) {
if len(definitions) == 0 {
return nil, fmt.Errorf("daypart definitions are required")
}
windows := make([]DaypartWindow, 0, len(definitions))
for i, def := range definitions {
if def.Name == "" {
return nil, fmt.Errorf("daypart[%d].name is required", i)
}
startClock, err := timeutil.ParseClock(def.Start)
if err != nil {
return nil, fmt.Errorf("daypart[%d].start: %w", i, err)
}
endClock, err := timeutil.ParseClock(def.End)
if err != nil {
return nil, fmt.Errorf("daypart[%d].end: %w", i, err)
}
period := timeutil.ClockWindow(date, location, startClock, endClock)
windows = append(windows, DaypartWindow{
Name: def.Name,
Start: period.Start,
End: period.End,
Period: period,
})
}
return windows, nil
}
func PeriodForForecastPeriod(period ForecastPeriod) timeutil.Period {
return timeutil.Period{Start: period.StartTime, End: period.EndTime}
}

440
internal/forecast/derive.go Normal file
View File

@@ -0,0 +1,440 @@
package forecast
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type DailySummary struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
Dayparts []DaypartSummary `json:"dayparts"`
NarrativePeriods []ForecastPeriod `json:"narrativePeriods,omitempty"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
Discussion *Discussion `json:"discussion,omitempty"`
SourceWarnings []SourceWarning `json:"sourceWarnings,omitempty"`
SourceProvenance []Source `json:"sourceProvenance,omitempty"`
}
type DaypartSummary struct {
Name string `json:"name"`
Period timeutil.Period `json:"period"`
HourlyPeriods []ForecastPeriod `json:"hourlyPeriods"`
Temperature Range `json:"temperature,omitempty"`
ApparentTemperature Range `json:"apparentTemperature,omitempty"`
MaxPrecipitationProbability *TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindSpeed *TimedValue `json:"peakWindSpeed,omitempty"`
PeakWindGust *TimedValue `json:"peakWindGust,omitempty"`
DominantCondition string `json:"dominantCondition,omitempty"`
NotableConditions []string `json:"notableConditions,omitempty"`
Indicators Indicators `json:"indicators"`
AlertOverlaps []AlertOverlap `json:"alertOverlaps,omitempty"`
}
type Range struct {
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
}
type TimedValue struct {
Value float64 `json:"value"`
Time time.Time `json:"time"`
}
type Indicators struct {
Thunder bool `json:"thunder,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
}
type AlertOverlap struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
Severity string `json:"severity,omitempty"`
Period timeutil.Period `json:"period"`
Overlap timeutil.Period `json:"overlap"`
Description string `json:"description,omitempty"`
}
func BuildDailySummary(bundle *Bundle, date time.Time, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required")
}
if location == nil {
location = time.UTC
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
return nil, fmt.Errorf("hourly forecast data is required")
}
day := timeutil.CivilDay(date, location)
windows, err := ResolveDayparts(date, location, dayparts)
if err != nil {
return nil, err
}
alerts := AlertOverlaps(bundle.Alerts, day)
summary := &DailySummary{
Date: day.Start.Format(timeutil.DateLayout),
Period: day,
NarrativePeriods: SelectNarrativePeriods(bundle, day),
AlertOverlaps: alerts,
Discussion: SelectDiscussion(bundle),
SourceWarnings: bundle.Warnings,
SourceProvenance: bundle.Sources,
}
for _, window := range windows {
periods := SelectHourlyPeriods(bundle.Hourly, window.Period)
daypartSummary := SummarizeDaypart(window.Name, window.Period, periods)
daypartSummary.AlertOverlaps = overlapsWithin(alerts, window.Period)
summary.Dayparts = append(summary.Dayparts, daypartSummary)
}
return summary, nil
}
func BuildPeriodDailySummaries(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) ([]DailySummary, error) {
if !period.IsValid() {
return nil, fmt.Errorf("valid forecast period is required")
}
if location == nil {
location = time.UTC
}
var summaries []DailySummary
for day := timeutil.CivilDay(period.Start, location); day.Start.Before(period.End); day = timeutil.CivilDay(day.Start.AddDate(0, 0, 1), location) {
overlap, ok := day.Intersection(period)
if !ok {
continue
}
summary, err := buildDailySummaryForPeriod(bundle, overlap, location, dayparts)
if err != nil {
return nil, err
}
summaries = append(summaries, *summary)
}
return summaries, nil
}
func buildDailySummaryForPeriod(bundle *Bundle, period timeutil.Period, location *time.Location, dayparts []DaypartDefinition) (*DailySummary, error) {
if bundle == nil {
return nil, fmt.Errorf("forecast bundle is required")
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) == 0 {
return nil, fmt.Errorf("hourly forecast data is required")
}
windows, err := ResolveDayparts(period.Start, location, dayparts)
if err != nil {
return nil, err
}
alerts := AlertOverlaps(bundle.Alerts, period)
summary := &DailySummary{
Date: period.Start.In(location).Format(timeutil.DateLayout),
Period: period,
NarrativePeriods: SelectNarrativePeriods(bundle, period),
AlertOverlaps: alerts,
Discussion: SelectDiscussion(bundle),
SourceWarnings: bundle.Warnings,
SourceProvenance: bundle.Sources,
}
for _, window := range windows {
clipped, ok := window.Period.Intersection(period)
if !ok {
continue
}
periods := SelectHourlyPeriods(bundle.Hourly, clipped)
daypartSummary := SummarizeDaypart(window.Name, clipped, periods)
daypartSummary.AlertOverlaps = overlapsWithin(alerts, clipped)
summary.Dayparts = append(summary.Dayparts, daypartSummary)
}
return summary, nil
}
func SelectHourlyPeriods(run *ForecastRun, period timeutil.Period) []ForecastPeriod {
if run == nil {
return nil
}
var selected []ForecastPeriod
for _, forecastPeriod := range run.Periods {
if PeriodForForecastPeriod(forecastPeriod).Overlaps(period) {
selected = append(selected, forecastPeriod)
}
}
sort.SliceStable(selected, func(i int, j int) bool {
return selected[i].StartTime.Before(selected[j].StartTime)
})
return selected
}
func SelectNarrativePeriods(bundle *Bundle, period timeutil.Period) []ForecastPeriod {
if bundle == nil || bundle.Narrative == nil {
return nil
}
return SelectHourlyPeriods(bundle.Narrative, period)
}
func SelectDiscussion(bundle *Bundle) *Discussion {
if bundle == nil {
return nil
}
return bundle.Discussion
}
func SummarizeDaypart(name string, period timeutil.Period, periods []ForecastPeriod) DaypartSummary {
summary := DaypartSummary{
Name: name,
Period: period,
HourlyPeriods: periods,
}
conditionCounts := map[string]int{}
conditions := map[string]struct{}{}
for _, forecastPeriod := range periods {
addRangeValue(&summary.Temperature, periodTemperatureValues(forecastPeriod)...)
addRangeValue(&summary.ApparentTemperature, valueFromPointers(forecastPeriod.ApparentTemperatureF, forecastPeriod.ApparentTemperatureC)...)
setMaxTimedValue(&summary.MaxPrecipitationProbability, forecastPeriod.ProbabilityOfPrecipitationPercent, forecastPeriod.StartTime)
setMaxTimedValue(&summary.PeakWindSpeed, firstValue(forecastPeriod.WindSpeedMph, forecastPeriod.WindSpeedKmh), forecastPeriod.StartTime)
setMaxTimedValue(&summary.PeakWindGust, firstValue(forecastPeriod.WindGustMph, forecastPeriod.WindGustKmh), forecastPeriod.StartTime)
text := strings.TrimSpace(forecastPeriod.TextDescription)
if text != "" {
conditionCounts[text]++
conditions[text] = struct{}{}
summary.Indicators = mergeIndicators(summary.Indicators, indicatorsForText(text))
}
summary.Indicators = mergeIndicators(summary.Indicators, numericIndicators(forecastPeriod))
}
summary.DominantCondition = dominantCondition(conditionCounts)
summary.NotableConditions = sortedKeys(conditions)
return summary
}
func periodTemperatureValues(period ForecastPeriod) []*float64 {
values := []*float64{}
values = append(values, valueFromPointers(period.TemperatureF, period.TemperatureC)...)
values = append(values, valueFromPointers(period.TemperatureFMin, period.TemperatureCMin)...)
values = append(values, valueFromPointers(period.TemperatureFMax, period.TemperatureCMax)...)
return values
}
func valueFromPointers(values ...*float64) []*float64 {
for _, value := range values {
if value != nil {
return []*float64{value}
}
}
return nil
}
func firstValue(values ...*float64) *float64 {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func addRangeValue(target *Range, values ...*float64) {
for _, value := range values {
if value == nil {
continue
}
if target.Min == nil || *value < *target.Min {
copied := *value
target.Min = &copied
}
if target.Max == nil || *value > *target.Max {
copied := *value
target.Max = &copied
}
}
}
func setMaxTimedValue(target **TimedValue, value *float64, at time.Time) {
if value == nil {
return
}
if *target == nil || value != nil && *value > (*target).Value {
*target = &TimedValue{Value: *value, Time: at}
}
}
func dominantCondition(counts map[string]int) string {
var dominant string
var dominantCount int
for condition, count := range counts {
if count > dominantCount || count == dominantCount && condition < dominant {
dominant = condition
dominantCount = count
}
}
return dominant
}
func sortedKeys(values map[string]struct{}) []string {
out := make([]string, 0, len(values))
for value := range values {
out = append(out, value)
}
sort.Strings(out)
return out
}
func indicatorsForText(text string) Indicators {
lower := strings.ToLower(text)
return Indicators{
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
Snow: strings.Contains(lower, "snow"),
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
Fog: strings.Contains(lower, "fog"),
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
}
}
func numericIndicators(period ForecastPeriod) Indicators {
windGust := firstValue(period.WindGustMph, period.WindGustKmh)
windSpeed := firstValue(period.WindSpeedMph, period.WindSpeedKmh)
indicators := Indicators{}
if period.TemperatureF != nil {
indicators.Heat = *period.TemperatureF >= 95
indicators.Cold = *period.TemperatureF <= 32
} else if period.TemperatureC != nil {
indicators.Heat = *period.TemperatureC >= 35
indicators.Cold = *period.TemperatureC <= 0
}
if windGust != nil && *windGust >= 35 || windSpeed != nil && *windSpeed >= 25 {
indicators.Wind = true
}
return indicators
}
func mergeIndicators(left Indicators, right Indicators) Indicators {
return Indicators{
Thunder: left.Thunder || right.Thunder,
Snow: left.Snow || right.Snow,
Ice: left.Ice || right.Ice,
Fog: left.Fog || right.Fog,
Heat: left.Heat || right.Heat,
Cold: left.Cold || right.Cold,
Wind: left.Wind || right.Wind,
}
}
func AlertOverlaps(alertRun *AlertRun, period timeutil.Period) []AlertOverlap {
if alertRun == nil {
return nil
}
var overlaps []AlertOverlap
for _, rawAlert := range alertRun.Alerts {
alert, ok := parseAlert(rawAlert)
if !ok || !alert.Period.IsValid() || !alert.Period.Overlaps(period) {
continue
}
overlaps = append(overlaps, AlertOverlap{
Event: alert.Event,
Headline: alert.Headline,
Severity: alert.Severity,
Period: alert.Period,
Overlap: intersect(alert.Period, period),
Description: alert.Description,
})
}
sort.SliceStable(overlaps, func(i int, j int) bool {
return overlaps[i].Period.Start.Before(overlaps[j].Period.Start)
})
return overlaps
}
type parsedAlert struct {
Event string
Headline string
Severity string
Description string
Period timeutil.Period
}
func parseAlert(raw json.RawMessage) (parsedAlert, bool) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return parsedAlert{}, false
}
alert := parsedAlert{
Event: stringField(fields, "event"),
Headline: firstStringField(fields, "headline", "title"),
Severity: stringField(fields, "severity"),
Description: firstStringField(fields, "description", "instruction"),
}
start, startOK := firstTimeField(fields, "effective", "onset", "startsAt", "startTime", "sent")
end, endOK := firstTimeField(fields, "expires", "ends", "endsAt", "endTime")
if !startOK || !endOK {
return parsedAlert{}, false
}
alert.Period = timeutil.Period{Start: start, End: end}
return alert, true
}
func stringField(fields map[string]json.RawMessage, name string) string {
value, ok := fields[name]
if !ok {
return ""
}
var out string
if err := json.Unmarshal(value, &out); err != nil {
return ""
}
return out
}
func firstStringField(fields map[string]json.RawMessage, names ...string) string {
for _, name := range names {
if value := stringField(fields, name); value != "" {
return value
}
}
return ""
}
func firstTimeField(fields map[string]json.RawMessage, names ...string) (time.Time, bool) {
for _, name := range names {
value := stringField(fields, name)
if value == "" {
continue
}
parsed, err := time.Parse(time.RFC3339, value)
if err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func intersect(left timeutil.Period, right timeutil.Period) timeutil.Period {
start := left.Start
if right.Start.After(start) {
start = right.Start
}
end := left.End
if right.End.Before(end) {
end = right.End
}
return timeutil.Period{Start: start, End: end}
}
func overlapsWithin(alerts []AlertOverlap, period timeutil.Period) []AlertOverlap {
var out []AlertOverlap
for _, alert := range alerts {
if alert.Period.Overlaps(period) {
alert.Overlap = intersect(alert.Period, period)
out = append(out, alert)
}
}
return out
}

View File

@@ -0,0 +1,280 @@
package forecast
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
bundle := testBundle(location)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
dayparts := []DaypartDefinition{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
}
summary, err := BuildDailySummary(bundle, date, location, dayparts)
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
if len(summary.Dayparts) != 4 {
t.Fatalf("Dayparts length = %d, want 4", len(summary.Dayparts))
}
morning := summary.Dayparts[1]
if len(morning.HourlyPeriods) != 2 {
t.Fatalf("morning periods = %d, want 2", len(morning.HourlyPeriods))
}
assertRange(t, "morning temperature", morning.Temperature, 58, 72)
if morning.MaxPrecipitationProbability == nil || morning.MaxPrecipitationProbability.Value != 70 {
t.Fatalf("morning max precip = %#v, want 70", morning.MaxPrecipitationProbability)
}
if morning.PeakWindGust == nil || morning.PeakWindGust.Value != 40 {
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
}
if morning.DominantCondition != "Thunderstorms and gusty wind" {
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition)
}
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
}
afternoon := summary.Dayparts[2]
assertRange(t, "afternoon apparent", afternoon.ApparentTemperature, 100, 100)
if !afternoon.Indicators.Heat {
t.Fatalf("afternoon indicators = %#v, want heat", afternoon.Indicators)
}
if len(summary.NarrativePeriods) != 1 {
t.Fatalf("NarrativePeriods length = %d, want 1", len(summary.NarrativePeriods))
}
if len(summary.AlertOverlaps) != 1 {
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
}
if len(morning.AlertOverlaps) != 1 {
t.Fatalf("morning AlertOverlaps length = %d, want 1", len(morning.AlertOverlaps))
}
if _, err := json.Marshal(summary.Dayparts); err != nil {
t.Fatalf("daypart summaries are not JSON inspectable: %v", err)
}
}
func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", "daily_bundle.json"))
if err != nil {
t.Fatalf("read fixture bundle: %v", err)
}
var bundle Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode fixture bundle: %v", err)
}
location := time.FixedZone("Test", -5*60*60)
summary, err := BuildDailySummary(&bundle, mustParse("2026-05-29T12:00:00-05:00"), location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
})
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
if len(summary.Dayparts) != 2 {
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
}
if !summary.Dayparts[0].Indicators.Thunder {
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
}
if len(summary.AlertOverlaps) != 1 {
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
}
}
func TestOvernightGroupingAcrossMidnight(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
hour(location, "2026-05-29T23:00:00-05:00", "2026-05-30T00:00:00-05:00", "Snow", 31, nil, nil, nil, nil),
hour(location, "2026-05-30T05:00:00-05:00", "2026-05-30T06:00:00-05:00", "Fog", 30, nil, nil, nil, nil),
hour(location, "2026-05-30T06:00:00-05:00", "2026-05-30T07:00:00-05:00", "Clear", 35, nil, nil, nil, nil),
}}}
summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{
{Name: "night", Start: "22:00", End: "06:00"},
})
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
night := summary.Dayparts[0]
if len(night.HourlyPeriods) != 2 {
t.Fatalf("night periods = %d, want 2", len(night.HourlyPeriods))
}
if !night.Indicators.Snow || !night.Indicators.Fog || !night.Indicators.Cold {
t.Fatalf("night indicators = %#v, want snow, fog, and cold", night.Indicators)
}
}
func TestBoundaryTimestampsAtDaypartEdges(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 55, nil, nil, nil, nil),
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Start", 56, nil, nil, nil, nil),
hour(location, "2026-05-29T12:00:00-05:00", "2026-05-29T13:00:00-05:00", "After", 70, nil, nil, nil, nil),
}}}
summary, err := BuildDailySummary(bundle, date, location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
})
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
morning := summary.Dayparts[0]
if len(morning.HourlyPeriods) != 1 {
t.Fatalf("morning periods = %d, want only start-boundary period", len(morning.HourlyPeriods))
}
if morning.HourlyPeriods[0].TextDescription != "Start" {
t.Fatalf("selected period = %q, want Start", morning.HourlyPeriods[0].TextDescription)
}
}
func TestBuildDailySummaryRequiresHourlyData(t *testing.T) {
location := time.UTC
_, err := BuildDailySummary(&Bundle{}, time.Now(), location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
})
if err == nil {
t.Fatal("BuildDailySummary() error = nil, want missing hourly error")
}
}
func TestBuildPeriodDailySummariesClipsPartialDays(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
bundle := &Bundle{Hourly: &ForecastRun{Periods: []ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Before", 50, nil, nil, nil, nil),
hour(location, "2026-05-29T08:00:00-05:00", "2026-05-29T09:00:00-05:00", "Showers", 60, nil, ptr(60), nil, nil),
hour(location, "2026-05-30T14:00:00-05:00", "2026-05-30T15:00:00-05:00", "Hot", 95, nil, nil, nil, nil),
hour(location, "2026-05-31T20:00:00-05:00", "2026-05-31T21:00:00-05:00", "Wind", 70, nil, nil, nil, ptr(35)),
}}}
period := timeutil.Period{
Start: mustParse("2026-05-29T07:00:00-05:00").In(location),
End: mustParse("2026-06-01T00:00:00-05:00").In(location),
}
summaries, err := BuildPeriodDailySummaries(bundle, period, location, []DaypartDefinition{
{Name: "morning", Start: "06:00", End: "12:00"},
{Name: "afternoon", Start: "12:00", End: "18:00"},
{Name: "evening", Start: "18:00", End: "24:00"},
})
if err != nil {
t.Fatalf("BuildPeriodDailySummaries() error = %v", err)
}
if len(summaries) != 3 {
t.Fatalf("summaries length = %d, want 3", len(summaries))
}
if summaries[0].Period.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
t.Fatalf("first period start = %s, want clipped start", summaries[0].Period.Start.Format(time.RFC3339))
}
if len(summaries[0].Dayparts[0].HourlyPeriods) != 1 || summaries[0].Dayparts[0].HourlyPeriods[0].TextDescription != "Showers" {
t.Fatalf("first morning periods = %#v, want only post-start hour", summaries[0].Dayparts[0].HourlyPeriods)
}
if summaries[2].Dayparts[2].PeakWindGust == nil || summaries[2].Dayparts[2].PeakWindGust.Value != 35 {
t.Fatalf("third evening gust = %#v, want 35", summaries[2].Dayparts[2].PeakWindGust)
}
}
func TestAlertOverlap(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
raw := json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","effective":"2026-05-29T07:00:00-05:00","expires":"2026-05-29T10:00:00-05:00"}`)
alertRun := &AlertRun{Alerts: []json.RawMessage{raw}}
period := timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00").In(location),
End: mustParse("2026-05-29T09:00:00-05:00").In(location),
}
overlaps := AlertOverlaps(alertRun, period)
if len(overlaps) != 1 {
t.Fatalf("overlaps length = %d, want 1", len(overlaps))
}
if overlaps[0].Overlap.Start.Format(time.RFC3339) != "2026-05-29T07:00:00-05:00" {
t.Fatalf("overlap start = %s, want alert start", overlaps[0].Overlap.Start.Format(time.RFC3339))
}
if overlaps[0].Overlap.End.Format(time.RFC3339) != "2026-05-29T09:00:00-05:00" {
t.Fatalf("overlap end = %s, want period end", overlaps[0].Overlap.End.Format(time.RFC3339))
}
}
func TestThresholdHelpers(t *testing.T) {
if !DifferenceAtLeast(50, 56, 5) {
t.Fatal("DifferenceAtLeast = false, want true")
}
if !CrossesAtOrAbove(29, 32, 32) {
t.Fatal("CrossesAtOrAbove = false, want true")
}
if !CrossesBelow(35, 31, 32) {
t.Fatal("CrossesBelow = false, want true")
}
}
func testBundle(location *time.Location) *Bundle {
return &Bundle{
Hourly: &ForecastRun{Periods: []ForecastPeriod{
hour(location, "2026-05-29T05:00:00-05:00", "2026-05-29T06:00:00-05:00", "Cloudy", 55, nil, nil, nil, nil),
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T07:00:00-05:00", "Thunderstorms and gusty wind", 58, ptr(57), ptr(70), ptr(22), ptr(40)),
hour(location, "2026-05-29T11:00:00-05:00", "2026-05-29T12:00:00-05:00", "Thunderstorms and gusty wind", 72, ptr(74), ptr(60), ptr(18), ptr(35)),
hour(location, "2026-05-29T14:00:00-05:00", "2026-05-29T15:00:00-05:00", "Hot and sunny", 96, ptr(100), ptr(5), ptr(10), ptr(12)),
}},
Narrative: &ForecastRun{Periods: []ForecastPeriod{
hour(location, "2026-05-29T06:00:00-05:00", "2026-05-29T18:00:00-05:00", "Storms early, hot later.", 96, nil, nil, nil, nil),
}},
Alerts: &AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Watch","headline":"Storms possible","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T11:30:00-05:00"}`),
}},
Discussion: &Discussion{Product: "discussion", KeyMessages: []string{"Storms possible."}},
Sources: []Source{{Name: "hourly"}},
Warnings: []SourceWarning{{Source: "daily", Code: "missing_source"}},
}
}
func hour(location *time.Location, start string, end string, text string, temperature float64, apparent *float64, precip *float64, wind *float64, gust *float64) ForecastPeriod {
startTime := mustParse(start).In(location)
endTime := mustParse(end).In(location)
temp := temperature
return ForecastPeriod{
StartTime: startTime,
EndTime: endTime,
TextDescription: text,
TemperatureF: &temp,
ApparentTemperatureF: apparent,
ProbabilityOfPrecipitationPercent: precip,
WindSpeedMph: wind,
WindGustMph: gust,
}
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}
func ptr(value float64) *float64 {
return &value
}
func assertRange(t *testing.T, name string, got Range, wantMin float64, wantMax float64) {
t.Helper()
if got.Min == nil || got.Max == nil {
t.Fatalf("%s = %#v, want min and max", name, got)
}
if *got.Min != wantMin || *got.Max != wantMax {
t.Fatalf("%s = [%v,%v], want [%v,%v]", name, *got.Min, *got.Max, wantMin, wantMax)
}
}

View File

@@ -0,0 +1,66 @@
{
"fetchedAt": "2026-05-29T15:00:00Z",
"hourly": {
"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,
"apparentTemperatureF": 67,
"probabilityOfPrecipitationPercent": 80,
"windSpeedMph": 18,
"windGustMph": 32
},
{
"startTime": "2026-05-29T14:00:00-05:00",
"endTime": "2026-05-29T15:00:00-05:00",
"textDescription": "Mostly sunny",
"temperatureF": 88,
"apparentTemperatureF": 91,
"probabilityOfPrecipitationPercent": 10,
"windSpeedMph": 10,
"windGustMph": 16
}
]
},
"narrative": {
"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",
"name": "Today",
"textDescription": "Morning storms, then partly sunny."
}
]
},
"alerts": {
"alerts": [
{
"event": "Flood Watch",
"headline": "Flooding possible",
"severity": "Moderate",
"effective": "2026-05-29T05:00:00-05:00",
"expires": "2026-05-29T09:00:00-05:00"
}
]
},
"discussion": {
"product": "discussion",
"issuedAt": "2026-05-29T09:25:00-05:00",
"keyMessages": [
"Storms are most likely during the morning."
]
},
"sources": [
{
"name": "hourly",
"endpoint": "/forecast/hourly",
"fetchedAt": "2026-05-29T15:00:00Z"
}
]
}

View File

@@ -0,0 +1,20 @@
package forecast
func DifferenceAtLeast(previous float64, current float64, threshold float64) bool {
return abs(current-previous) >= threshold
}
func CrossesAtOrAbove(previous float64, current float64, threshold float64) bool {
return previous < threshold && current >= threshold
}
func CrossesBelow(previous float64, current float64, threshold float64) bool {
return previous >= threshold && current < threshold
}
func abs(value float64) float64 {
if value < 0 {
return -value
}
return value
}

View File

@@ -0,0 +1,145 @@
// Package promptinput builds prompt data packages from briefing packages.
package promptinput
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/changes"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const SchemaVersion = "weatherreporter.data_package.v1"
type Package struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
Report Report `json:"report"`
Briefing briefing.Package `json:"briefing"`
RecentChanges RecentChanges `json:"recentChanges"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
}
type Report struct {
ID report.ID `json:"id"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
}
type RecentChanges struct {
Items []changes.Change `json:"items"`
}
func Build(briefingPackage briefing.Package) (Package, error) {
return BuildWithRecentChanges(briefingPackage, nil)
}
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
items := make([]changes.Change, len(recentChanges))
copy(items, recentChanges)
if items == nil {
items = []changes.Change{}
}
pkg := Package{
SchemaVersion: SchemaVersion,
RunID: briefingPackage.Metadata.RunID,
Report: Report{
ID: briefingPackage.Metadata.ReportID,
Variant: briefingPackage.Metadata.Variant,
PromptID: briefingPackage.Metadata.PromptID,
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
Timezone: briefingPackage.Metadata.Timezone,
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
},
Briefing: briefingPackage,
RecentChanges: RecentChanges{Items: items},
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
}
if err := Validate(pkg); err != nil {
return Package{}, err
}
return pkg, nil
}
func Validate(pkg Package) error {
if pkg.SchemaVersion == "" {
return fmt.Errorf("schemaVersion is required")
}
if pkg.RunID == "" {
return fmt.Errorf("runId is required")
}
if pkg.Report.ID == "" {
return fmt.Errorf("report.id is required")
}
if pkg.Report.PromptID == "" {
return fmt.Errorf("report.promptId is required")
}
if pkg.Report.GeneratedAt.IsZero() {
return fmt.Errorf("report.generatedAt is required")
}
if pkg.Report.Timezone == "" {
return fmt.Errorf("report.timezone is required")
}
if !pkg.Report.ValidPeriod.IsValid() {
return fmt.Errorf("report.validPeriod must be valid")
}
if pkg.Briefing.Metadata.RunID == "" {
return fmt.Errorf("briefing.metadata.runId is required")
}
if pkg.Briefing.Metadata.RunID != pkg.RunID {
return fmt.Errorf("briefing.metadata.runId must match runId")
}
if pkg.Briefing.Metadata.SchemaVersion == "" {
return fmt.Errorf("briefing.metadata.schemaVersion is required")
}
if pkg.Briefing.Metadata.PromptID != pkg.Report.PromptID {
return fmt.Errorf("briefing.metadata.promptId must match report.promptId")
}
if pkg.Briefing.Metadata.ReportID != pkg.Report.ID {
return fmt.Errorf("briefing.metadata.reportId must match report.id")
}
if pkg.Briefing.Daily == nil && pkg.Briefing.ThreeDay == nil && pkg.Briefing.Weekend == nil && pkg.Briefing.Storm == nil {
return fmt.Errorf("briefing report content is required")
}
return nil
}
func Save(path string, pkg Package) error {
if err := Validate(pkg); err != nil {
return err
}
data, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
return fmt.Errorf("marshal data package: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create data package directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary data package file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary data package file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary data package file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save data package %q: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,167 @@
package promptinput
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestBuildDailyDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.SchemaVersion, SchemaVersion)
}
if pkg.RunID != "20260529T100000Z_daily_today" {
t.Fatalf("RunID = %q, want briefing run id", pkg.RunID)
}
if pkg.Report.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
}
if pkg.Briefing.Daily == nil {
t.Fatal("Briefing.Daily = nil")
}
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
}
}
func TestValidateRequiresFields(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
pkg.RunID = ""
err = Validate(pkg)
if err == nil {
t.Fatal("Validate() error = nil, want required field error")
}
if !strings.Contains(err.Error(), "runId") {
t.Fatalf("error = %q, want runId context", err.Error())
}
}
func TestBuildThreeDayDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
briefingPackage.Metadata.ReportID = report.ThreeDay
briefingPackage.Metadata.PromptID = "weather.three_day_outlook"
briefingPackage.Daily = nil
briefingPackage.ThreeDay = &briefing.ThreeDay{
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
}
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.ThreeDay {
t.Fatalf("Report.ID = %q, want three_day", pkg.Report.ID)
}
if pkg.Briefing.ThreeDay == nil {
t.Fatal("Briefing.ThreeDay = nil")
}
}
func 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 TestBuildStormDataPackage(t *testing.T) {
briefingPackage := validBriefingPackage()
briefingPackage.Metadata.RunID = "20260529T100000Z_storm"
briefingPackage.Metadata.ReportID = report.Storm
briefingPackage.Metadata.PromptID = "weather.storm_report"
briefingPackage.Daily = nil
briefingPackage.Storm = &briefing.Storm{
TimingWindow: briefingPackage.Metadata.ValidPeriod,
Hazards: []string{"Thunderstorms"},
}
pkg, err := Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if pkg.Report.ID != report.Storm {
t.Fatalf("Report.ID = %q, want storm", pkg.Report.ID)
}
if pkg.Report.PromptID != "weather.storm_report" {
t.Fatalf("PromptID = %q, want weather.storm_report", pkg.Report.PromptID)
}
if pkg.Briefing.Storm == nil {
t.Fatal("Briefing.Storm = nil")
}
}
func TestMarshalDeterministic(t *testing.T) {
pkg, err := Build(validBriefingPackage())
if err != nil {
t.Fatalf("Build() error = %v", err)
}
first, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
t.Fatalf("first marshal: %v", err)
}
second, err := json.MarshalIndent(pkg, "", " ")
if err != nil {
t.Fatalf("second marshal: %v", err)
}
if string(first) != string(second) {
t.Fatalf("JSON output changed between marshals:\n%s\n---\n%s", string(first), string(second))
}
}
func validBriefingPackage() briefing.Package {
generatedAt := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: "20260529T100000Z_daily_today",
ReportID: report.DailyToday,
PromptID: "weather.daily_report",
GeneratedAt: generatedAt,
Units: "us",
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
},
},
Daily: &briefing.Daily{
ForecastSummaryDate: "2026-05-29",
},
}
}

View File

@@ -0,0 +1,87 @@
// Package report defines report identities, registry metadata, and valid periods.
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ID string
const (
DailyToday ID = "daily_today"
DailyTomorrow ID = "daily_tomorrow"
ThreeDay ID = "three_day"
Weekend ID = "weekend"
Storm ID = "storm"
)
type ComparisonStrategy string
const (
CompareSameValidDate ComparisonStrategy = "same_valid_date"
CompareWeekendWindow ComparisonStrategy = "same_weekend_window"
CompareExplicitWindow ComparisonStrategy = "explicit_event_window"
)
type Batch string
const (
Morning Batch = "morning"
Evening Batch = "evening"
)
type Definition struct {
ID ID
Name string
PromptID string
ComparisonStrategy ComparisonStrategy
DefaultOutputName string
Morning bool
Evening bool
resolve func(ResolveRequest) (timeutil.Period, error)
}
func (d Definition) ResolvePeriod(req ResolveRequest) (timeutil.Period, error) {
if d.resolve == nil {
return timeutil.Period{}, fmt.Errorf("report %q has no valid-period resolver", d.ID)
}
return d.resolve(req)
}
type ResolveRequest struct {
Now time.Time
Location *time.Location
Date time.Time
StormStart time.Time
StormEnd time.Time
}
type Resolved struct {
Definition Definition
GeneratedAt time.Time
Timezone string
ValidPeriod timeutil.Period
}
type Metadata struct {
RunID string `json:"runId"`
ReportID ID `json:"reportId"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
}
func (r Resolved) Metadata() Metadata {
return Metadata{
RunID: r.GeneratedAt.UTC().Format("20060102T150405.000000000Z") + "_" + string(r.Definition.ID),
ReportID: r.Definition.ID,
PromptID: r.Definition.PromptID,
GeneratedAt: r.GeneratedAt,
Timezone: r.Timezone,
ValidPeriod: r.ValidPeriod,
}
}

144
internal/report/period.go Normal file
View File

@@ -0,0 +1,144 @@
package report
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func Resolve(id ID, req ResolveRequest) (Resolved, error) {
return DefaultRegistry().Resolve(id, req)
}
func (r Registry) Resolve(id ID, req ResolveRequest) (Resolved, error) {
definition, err := r.Lookup(id)
if err != nil {
return Resolved{}, err
}
return r.resolveDefinition(definition, req)
}
func (r Registry) BatchReports(batch Batch, req ResolveRequest) ([]Resolved, error) {
if req.Location == nil {
req.Location = time.UTC
}
if req.Now.IsZero() {
req.Now = time.Now()
}
switch batch {
case Morning:
ids := []ID{DailyToday, ThreeDay}
if req.Now.In(req.Location).Weekday() != time.Sunday {
ids = append(ids, Weekend)
}
return r.resolveIDs(ids, req)
case Evening:
return r.resolveIDs([]ID{DailyTomorrow}, req)
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
}
func (r Registry) resolveIDs(ids []ID, req ResolveRequest) ([]Resolved, error) {
resolved := make([]Resolved, 0, len(ids))
for _, id := range ids {
item, err := r.Resolve(id, req)
if err != nil {
return nil, err
}
resolved = append(resolved, item)
}
return resolved, nil
}
func (r Registry) resolveDefinition(definition Definition, req ResolveRequest) (Resolved, error) {
if req.Location == nil {
req.Location = time.UTC
}
if req.Now.IsZero() {
req.Now = time.Now()
}
period, err := definition.ResolvePeriod(req)
if err != nil {
return Resolved{}, err
}
return Resolved{
Definition: definition,
GeneratedAt: req.Now,
Timezone: req.Location.String(),
ValidPeriod: period,
}, nil
}
func resolveDailyToday(req ResolveRequest) (timeutil.Period, error) {
if !req.Date.IsZero() {
return timeutil.CivilDay(req.Date, req.Location), nil
}
return timeutil.CivilDay(req.Now, req.Location), nil
}
func ParseStormPeriod(start string, end string, location *time.Location) (timeutil.Period, error) {
if location == nil {
location = time.UTC
}
startTime, err := timeutil.ParseStormTime(start, location)
if err != nil {
return timeutil.Period{}, err
}
endTime, err := timeutil.ParseStormTime(end, location)
if err != nil {
return timeutil.Period{}, err
}
period := timeutil.Period{Start: startTime, End: endTime}
if !period.IsValid() {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return period, nil
}
func resolveDailyTomorrow(req ResolveRequest) (timeutil.Period, error) {
return timeutil.CivilDay(req.Now.In(req.Location).AddDate(0, 0, 1), req.Location), nil
}
func resolveThreeDay(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
endDate := localNow.AddDate(0, 0, 3)
end := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, req.Location)
return timeutil.Period{Start: localNow, End: end}, nil
}
func resolveWeekend(req ResolveRequest) (timeutil.Period, error) {
localNow := req.Now.In(req.Location)
weekday := localNow.Weekday()
if weekday == time.Sunday {
return timeutil.Period{}, fmt.Errorf("weekend outlook is not scheduled on Sunday morning")
}
daysUntilSaturday := (int(time.Saturday) - int(weekday) + 7) % 7
saturday := localNow.AddDate(0, 0, daysUntilSaturday)
start := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location)
if weekday == time.Friday || weekday == time.Saturday {
friday := start.AddDate(0, 0, -1)
fridayEvening := time.Date(friday.Year(), friday.Month(), friday.Day(), 18, 0, 0, 0, req.Location)
start = fridayEvening
if localNow.After(start) {
start = localNow
}
}
end := time.Date(saturday.Year(), saturday.Month(), saturday.Day(), 0, 0, 0, 0, req.Location).AddDate(0, 0, 2)
return timeutil.Period{Start: start, End: end}, nil
}
func resolveStorm(req ResolveRequest) (timeutil.Period, error) {
if req.StormStart.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires a start time")
}
if req.StormEnd.IsZero() {
return timeutil.Period{}, fmt.Errorf("storm report requires an end time")
}
if !req.StormEnd.After(req.StormStart) {
return timeutil.Period{}, fmt.Errorf("storm report requires end time after start time")
}
return timeutil.Period{Start: req.StormStart, End: req.StormEnd}, nil
}

View File

@@ -0,0 +1,234 @@
package report
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestDailyValidPeriod(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T00:00:00-05:00", "2026-05-30T00:00:00-05:00")
}
func TestDailyValidPeriodCanUseExplicitDate(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T17:45:00-05:00")
date := mustParse("2026-05-31T12:00:00-05:00")
resolved, err := Resolve(DailyToday, ResolveRequest{Now: now, Location: location, Date: date})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestTomorrowValidPeriodFromEveningGeneration(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T20:00:00-05:00")
resolved, err := Resolve(DailyTomorrow, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-30T00:00:00-05:00", "2026-05-31T00:00:00-05:00")
if resolved.Definition.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", resolved.Definition.PromptID)
}
}
func TestThreeDayPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
now := mustParse("2026-05-29T05:00:00-05:00")
resolved, err := Resolve(ThreeDay, ResolveRequest{Now: now, Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T05:00:00-05:00", "2026-06-01T00:00:00-05:00")
}
func TestWeekendPeriodCalculation(t *testing.T) {
location := mustLoadLocation(t)
tests := []struct {
name string
now string
start 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: "tuesday", now: "2026-05-26T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "wednesday", now: "2026-05-27T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "thursday", now: "2026-05-28T05:00:00-05:00", start: "2026-05-30T00:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "friday before evening", now: "2026-05-29T05:00:00-05:00", start: "2026-05-29T18:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "friday after evening", now: "2026-05-29T19:30:00-05:00", start: "2026-05-29T19:30:00-05:00", end: "2026-06-01T00:00:00-05:00"},
{name: "saturday", now: "2026-05-30T08:00:00-05:00", start: "2026-05-30T08:00:00-05:00", end: "2026-06-01T00:00:00-05:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolved, err := Resolve(Weekend, ResolveRequest{Now: mustParse(tt.now), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, tt.start, tt.end)
})
}
}
func TestWeekendSundayErrors(t *testing.T) {
location := mustLoadLocation(t)
_, err := Resolve(Weekend, ResolveRequest{Now: mustParse("2026-05-31T08:00:00-05:00"), Location: location})
if err == nil {
t.Fatal("Resolve() error = nil, want Sunday weekend error")
}
if !strings.Contains(err.Error(), "Sunday") {
t.Fatalf("error = %q, want Sunday context", err.Error())
}
}
func TestStormManualPeriodParsingAndValidation(t *testing.T) {
location := mustLoadLocation(t)
period, err := ParseStormPeriod("2026-05-29T18:00", "2026-05-30T06:00:00-05:00", location)
if err != nil {
t.Fatalf("ParseStormPeriod() error = %v", err)
}
assertPeriod(t, period, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
_, err = ParseStormPeriod("2026-05-30T06:00", "2026-05-29T18:00", location)
if err == nil {
t.Fatal("ParseStormPeriod() error = nil, want invalid period error")
}
}
func TestStormResolve(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(Storm, ResolveRequest{
Now: mustParse("2026-05-29T12:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T18:00:00-05:00"),
StormEnd: mustParse("2026-05-30T06:00:00-05:00"),
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
assertPeriod(t, resolved.ValidPeriod, "2026-05-29T18:00:00-05:00", "2026-05-30T06:00:00-05:00")
if resolved.Definition.ComparisonStrategy != CompareExplicitWindow {
t.Fatalf("ComparisonStrategy = %q, want explicit event window", resolved.Definition.ComparisonStrategy)
}
}
func TestMorningBatchSkipsWeekendOnSunday(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Morning, ResolveRequest{
Now: mustParse("2026-05-31T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_today,three_day" {
t.Fatalf("ids = %v, want daily_today and three_day", ids)
}
}
func TestEveningBatchIncludesTomorrow(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := DefaultRegistry().BatchReports(Evening, ResolveRequest{
Now: mustParse("2026-05-29T18:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("BatchReports() error = %v", err)
}
ids := resolvedIDs(resolved)
if strings.Join(ids, ",") != "daily_tomorrow" {
t.Fatalf("ids = %v, want daily_tomorrow", ids)
}
}
func TestRegistryLookupErrorIsActionable(t *testing.T) {
_, err := DefaultRegistry().Lookup(ID("unknown"))
if err == nil {
t.Fatal("Lookup() error = nil, want unknown report error")
}
if !strings.Contains(err.Error(), `unknown report "unknown"`) {
t.Fatalf("error = %q, want unknown report context", err.Error())
}
}
func TestRegistryDefinitionsHavePromptIDsAndComparisonStrategies(t *testing.T) {
for _, definition := range DefaultRegistry().All() {
if definition.PromptID == "" {
t.Fatalf("%s PromptID is empty", definition.ID)
}
if definition.ComparisonStrategy == "" {
t.Fatalf("%s ComparisonStrategy is empty", definition.ID)
}
}
}
func TestResolvedMetadata(t *testing.T) {
location := mustLoadLocation(t)
resolved, err := Resolve(DailyToday, ResolveRequest{Now: mustParse("2026-05-29T05:00:00-05:00"), Location: location})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
metadata := resolved.Metadata()
if metadata.ReportID != DailyToday {
t.Fatalf("ReportID = %q, want daily_today", metadata.ReportID)
}
if metadata.PromptID != "weather.daily_report" {
t.Fatalf("PromptID = %q, want weather.daily_report", metadata.PromptID)
}
if !strings.Contains(metadata.RunID, "daily_today") {
t.Fatalf("RunID = %q, want report id", metadata.RunID)
}
}
func assertPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
t.Fatalf("period = %#v, want valid", period)
}
if period.Start.Format(time.RFC3339) != wantStart {
t.Fatalf("Start = %s, want %s", period.Start.Format(time.RFC3339), wantStart)
}
if period.End.Format(time.RFC3339) != wantEnd {
t.Fatalf("End = %s, want %s", period.End.Format(time.RFC3339), wantEnd)
}
}
func resolvedIDs(resolved []Resolved) []string {
ids := make([]string, 0, len(resolved))
for _, item := range resolved {
ids = append(ids, string(item.Definition.ID))
}
return ids
}
func mustLoadLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,88 @@
package report
import "fmt"
type Registry struct {
definitions map[ID]Definition
}
func DefaultRegistry() Registry {
definitions := []Definition{
{
ID: DailyToday,
Name: "Daily Report",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "daily.md",
Morning: true,
resolve: resolveDailyToday,
},
{
ID: DailyTomorrow,
Name: "Tomorrow Planning Brief",
PromptID: "weather.daily_report",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "tomorrow.md",
Evening: true,
resolve: resolveDailyTomorrow,
},
{
ID: ThreeDay,
Name: "3-Day Outlook",
PromptID: "weather.three_day_outlook",
ComparisonStrategy: CompareSameValidDate,
DefaultOutputName: "three_day.md",
Morning: true,
resolve: resolveThreeDay,
},
{
ID: Weekend,
Name: "Weekend Outlook",
PromptID: "weather.weekend_outlook",
ComparisonStrategy: CompareWeekendWindow,
DefaultOutputName: "weekend.md",
Morning: true,
resolve: resolveWeekend,
},
{
ID: Storm,
Name: "Storm Report",
PromptID: "weather.storm_report",
ComparisonStrategy: CompareExplicitWindow,
DefaultOutputName: "storm.md",
resolve: resolveStorm,
},
}
registry := Registry{definitions: map[ID]Definition{}}
for _, definition := range definitions {
registry.definitions[definition.ID] = definition
}
return registry
}
func (r Registry) Lookup(id ID) (Definition, error) {
definition, ok := r.definitions[id]
if !ok {
return Definition{}, fmt.Errorf("unknown report %q", id)
}
return definition, nil
}
func (r Registry) MustLookup(id ID) Definition {
definition, err := r.Lookup(id)
if err != nil {
panic(err)
}
return definition
}
func (r Registry) All() []Definition {
ids := []ID{DailyToday, DailyTomorrow, ThreeDay, Weekend, Storm}
out := make([]Definition, 0, len(ids))
for _, id := range ids {
if definition, ok := r.definitions[id]; ok {
out = append(out, definition)
}
}
return out
}

View File

@@ -0,0 +1,467 @@
package state
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type FilesystemStore struct {
root string
snapshotsDir string
reportsDir string
dataPackagesDir string
preflightDir string
}
type ArtifactPaths struct {
Briefing string `json:"briefing"`
Metadata string `json:"metadata"`
DataPackage string `json:"dataPackage"`
Preflight string `json:"preflight"`
RenderedReport string `json:"renderedReport,omitempty"`
}
type ReportRecord struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt string `json:"generatedAt"`
ValidStart string `json:"validStart"`
ValidEnd string `json:"validEnd"`
MetadataPath string `json:"metadataPath"`
BriefingPath string `json:"briefingPath"`
ReportPath string `json:"reportPath,omitempty"`
Warnings int `json:"warnings"`
metadata Metadata
}
func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) {
if cfg.Root == "" {
return nil, fmt.Errorf("workspace root is required")
}
for name, value := range map[string]string{
"snapshots_dir": cfg.SnapshotsDir,
"reports_dir": cfg.ReportsDir,
"data_packages_dir": cfg.DataPackagesDir,
"preflight_dir": cfg.PreflightDir,
} {
if err := validateRelativeDir(name, value); err != nil {
return nil, err
}
}
return &FilesystemStore{
root: filepath.Clean(cfg.Root),
snapshotsDir: filepath.Clean(cfg.SnapshotsDir),
reportsDir: filepath.Clean(cfg.ReportsDir),
dataPackagesDir: filepath.Clean(cfg.DataPackagesDir),
preflightDir: filepath.Clean(cfg.PreflightDir),
}, nil
}
func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) {
if s == nil {
return ArtifactPaths{}, fmt.Errorf("state store is required")
}
metadata := resolved.Metadata()
if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required")
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return ArtifactPaths{}, err
}
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID
return ArtifactPaths{
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.json"),
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
}, nil
}
func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
return "", err
}
return paths.Briefing, nil
}
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := promptinput.Validate(pkg); err != nil {
return "", err
}
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err
}
return paths.DataPackage, nil
}
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
if result == nil {
return "", fmt.Errorf("render result is required")
}
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
return "", err
}
return paths.Preflight, nil
}
func (s *FilesystemStore) PrepareRenderedReport(_ context.Context, resolved report.Resolved) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(paths.RenderedReport), 0o755); err != nil {
return "", fmt.Errorf("create rendered report directory %q: %w", filepath.Dir(paths.RenderedReport), err)
}
return paths.RenderedReport, nil
}
func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) {
if metadata.RunID == "" {
return "", fmt.Errorf("metadata run id is required")
}
if metadata.BriefingPath == "" {
return "", fmt.Errorf("metadata briefing path is required")
}
if metadata.DataPackagePath == "" {
return "", fmt.Errorf("metadata data package path is required")
}
if metadata.PreflightPath == "" {
return "", fmt.Errorf("metadata preflight path is required")
}
path := metadataPathFromStored(metadata)
if path == "" {
return "", fmt.Errorf("metadata path cannot be resolved")
}
if err := writeJSONAtomic(path, metadata); err != nil {
return "", err
}
return path, nil
}
func (s *FilesystemStore) FindPriorDailySnapshot(ctx context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
return s.FindPriorSnapshot(ctx, resolved)
}
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate && resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return nil, nil
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return nil, err
}
dirs, err := s.metadataDirectories(resolved, group)
if err != nil {
return nil, err
}
var candidates []Metadata
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
continue
}
path := filepath.Join(dir, entry.Name())
var metadata Metadata
if err := readJSON(path, &metadata); err != nil {
return nil, err
}
if metadata.RunID == resolved.Metadata().RunID {
continue
}
if !compatiblePriorReport(group, metadata.ReportID, resolved.Definition.ID) {
continue
}
if !comparablePeriod(metadata, resolved) {
continue
}
if !metadata.GeneratedAt.Before(resolved.GeneratedAt) {
continue
}
candidates = append(candidates, metadata)
}
}
if len(candidates) == 0 {
return nil, nil
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt)
})
return &PriorSnapshot{
Metadata: candidates[0],
BriefingPath: candidates[0].BriefingPath,
}, nil
}
func (s *FilesystemStore) ListReports(_ context.Context, limit int) ([]ReportRecord, error) {
if s == nil {
return nil, fmt.Errorf("state store is required")
}
root := s.join(s.snapshotsDir)
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("inspect %q: %w", root, err)
}
var records []ReportRecord
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("inspect %q: %w", path, err)
}
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
return nil
}
record, err := s.reportRecord(path)
if err != nil {
return err
}
records = append(records, record)
return nil
})
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
sort.Slice(records, func(i, j int) bool {
return records[i].metadata.GeneratedAt.After(records[j].metadata.GeneratedAt)
})
if limit > 0 && len(records) > limit {
records = records[:limit]
}
return records, nil
}
func (s *FilesystemStore) LoadMetadataByRunID(ctx context.Context, runID string) (Metadata, string, error) {
if strings.TrimSpace(runID) == "" {
return Metadata{}, "", fmt.Errorf("run id is required")
}
records, err := s.ListReports(ctx, 0)
if err != nil {
return Metadata{}, "", err
}
for _, record := range records {
if record.RunID == runID {
return record.metadata, record.MetadataPath, nil
}
}
return Metadata{}, "", fmt.Errorf("metadata for run id %q was not found", runID)
}
func (s *FilesystemStore) LoadDataPackage(_ context.Context, path string) (promptinput.Package, error) {
if path == "" {
return promptinput.Package{}, fmt.Errorf("data package path is required")
}
var pkg promptinput.Package
if err := readJSON(path, &pkg); err != nil {
return promptinput.Package{}, err
}
return pkg, nil
}
func (s *FilesystemStore) reportRecord(path string) (ReportRecord, error) {
var metadata Metadata
if err := readJSON(path, &metadata); err != nil {
return ReportRecord{}, err
}
return ReportRecord{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: metadata.Variant,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt.Format(time.RFC3339Nano),
ValidStart: metadata.ValidPeriod.Start.Format(time.RFC3339Nano),
ValidEnd: metadata.ValidPeriod.End.Format(time.RFC3339Nano),
MetadataPath: path,
BriefingPath: metadata.BriefingPath,
ReportPath: metadata.RenderedReportPath,
Warnings: len(metadata.SourceWarnings),
metadata: metadata,
}, nil
}
func (s *FilesystemStore) metadataDirectories(resolved report.Resolved, group string) ([]string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return nil, err
}
if resolved.Definition.ComparisonStrategy != report.CompareWeekendWindow {
return []string{filepath.Dir(paths.Metadata)}, nil
}
root := s.join(s.snapshotsDir, group)
entries, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read snapshot group directory %q: %w", root, err)
}
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, filepath.Join(root, entry.Name()))
}
}
return dirs, nil
}
func compatiblePriorReport(group string, prior report.ID, current report.ID) bool {
switch group {
case "daily":
return prior == report.DailyToday || prior == report.DailyTomorrow
case "three-day":
return prior == report.ThreeDay && current == report.ThreeDay
case "weekend":
return prior == report.Weekend && current == report.Weekend
default:
return false
}
}
func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...)
return filepath.Join(all...)
}
func validateRelativeDir(name string, value string) error {
if value == "" {
return fmt.Errorf("%s is required", name)
}
if filepath.IsAbs(value) {
return fmt.Errorf("%s must be relative to workspace root", name)
}
cleaned := filepath.Clean(value)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return fmt.Errorf("%s must stay within workspace root", name)
}
return nil
}
func reportGroup(id report.ID) (string, error) {
switch id {
case report.DailyToday, report.DailyTomorrow:
return "daily", nil
case report.ThreeDay:
return "three-day", nil
case report.Weekend:
return "weekend", nil
case report.Storm:
return "storm", nil
default:
return "", fmt.Errorf("unknown report %q", id)
}
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func readJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %q: %w", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("decode %q: %w", path, err)
}
return nil
}
func metadataPathFromStored(metadata Metadata) string {
if metadata.BriefingPath == "" {
return ""
}
filename := metadata.RunID + ".metadata.json"
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
}
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
}
func comparablePeriod(metadata Metadata, resolved report.Resolved) bool {
switch resolved.Definition.ComparisonStrategy {
case report.CompareSameValidDate:
return sameValidDate(metadata, resolved)
case report.CompareWeekendWindow:
return sameWeekendWindow(metadata, resolved)
default:
return false
}
}
func sameWeekendWindow(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.End.Equal(resolved.ValidPeriod.End) && !metadata.ValidPeriod.Start.After(resolved.ValidPeriod.Start)
}
func (s *FilesystemStore) LoadBriefing(_ context.Context, path string) (briefing.Package, error) {
if path == "" {
return briefing.Package{}, fmt.Errorf("briefing path is required")
}
var pkg briefing.Package
if err := readJSON(path, &pkg); err != nil {
return briefing.Package{}, err
}
return pkg, nil
}

View File

@@ -0,0 +1,443 @@
package state
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestPathsUseRunIDAndWorkspace(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
for _, want := range []string{
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.briefing.json"),
filepath.Join("snapshots", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.metadata.json"),
filepath.Join("data-packages", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.data_package.json"),
filepath.Join("preflight", "daily", "2026-05-29", "20260529T100000.000000000Z_daily_today.render.json"),
filepath.Join("reports", "daily", "20260529T100000.000000000Z_daily_today.md"),
} {
if !strings.Contains(pathsString(paths), want) {
t.Fatalf("paths = %#v, want component %q", paths, want)
}
}
}
func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
store := newTestStore(t)
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
briefingPackage := stateBriefingPackage(resolved)
dataPackage, err := promptinput.Build(briefingPackage)
if err != nil {
t.Fatalf("Build() error = %v", err)
}
briefingPath, err := store.SaveBriefing(context.Background(), resolved, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage)
if err != nil {
t.Fatalf("SaveDataPackage() error = %v", err)
}
preflightPath, err := store.SavePreflight(context.Background(), resolved, &scriptorium.RenderResult{Stdout: `{"ok":true}`})
if err != nil {
t.Fatalf("SavePreflight() error = %v", err)
}
renderedReportPath, err := store.PrepareRenderedReport(context.Background(), resolved)
if err != nil {
t.Fatalf("PrepareRenderedReport() error = %v", err)
}
if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil {
t.Fatalf("write rendered report: %v", err)
}
paths, err := store.Paths(resolved)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: dataPackagePath,
Preflight: preflightPath,
RenderedReport: renderedReportPath,
})
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
for _, path := range []string{briefingPath, dataPackagePath, preflightPath, renderedReportPath, metadataPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %q: %v", path, err)
}
}
loadedBriefing, err := store.LoadBriefing(context.Background(), briefingPath)
if err != nil {
t.Fatalf("LoadBriefing() error = %v", err)
}
if loadedBriefing.Metadata.RunID != resolved.Metadata().RunID {
t.Fatalf("loaded briefing RunID = %q, want %q", loadedBriefing.Metadata.RunID, resolved.Metadata().RunID)
}
var decoded Metadata
data, err := os.ReadFile(metadataPath)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("decode metadata: %v", err)
}
if decoded.RunID != resolved.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID)
}
if decoded.BriefingPath != briefingPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath {
t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded)
}
if decoded.RenderedReportPath != renderedReportPath {
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
}
}
func TestFindPriorDailySnapshot(t *testing.T) {
store := newTestStore(t)
first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00")
briefingPackage := stateBriefingPackage(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.FindPriorDailySnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorDailySnapshot() = nil, want prior snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
if prior.BriefingPath != briefingPath {
t.Fatalf("BriefingPath = %q, want %q", prior.BriefingPath, briefingPath)
}
}
func TestFindPriorDailySnapshotUsesValidDate(t *testing.T) {
store := newTestStore(t)
previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00")
currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
briefingPackage := stateBriefingPackage(previousDate)
briefingPath, err := store.SaveBriefing(context.Background(), previousDate, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(previousDate)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(previousDate, 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.FindPriorDailySnapshot(context.Background(), currentDate)
if err != nil {
t.Fatalf("FindPriorDailySnapshot() error = %v", err)
}
if prior != nil {
t.Fatalf("FindPriorDailySnapshot() = %#v, want nil for different valid date", prior)
}
}
func TestFindPriorSnapshotSupportsThreeDay(t *testing.T) {
store := newTestStore(t)
first := resolveThreeDayAt(t, "2026-05-29T05:00:00-05:00")
second := resolveThreeDayAt(t, "2026-05-29T08:00:00-05:00")
briefingPackage := stateThreeDayBriefingPackage(first)
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorSnapshot() = nil, want prior 3-day snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
}
func TestFindPriorSnapshotSupportsWeekend(t *testing.T) {
store := newTestStore(t)
first := resolveWeekendAt(t, "2026-05-29T05:00:00-05:00")
second := resolveWeekendAt(t, "2026-05-29T08:00:00-05:00")
briefingPackage := stateWeekendBriefingPackage(first)
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorSnapshot() = nil, want prior weekend snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
}
func TestFindPriorSnapshotSupportsNarrowedWeekendPeriod(t *testing.T) {
store := newTestStore(t)
first := resolveWeekendAt(t, "2026-05-29T19:00:00-05:00")
second := resolveWeekendAt(t, "2026-05-30T08:00:00-05:00")
briefingPackage := stateWeekendBriefingPackage(first)
briefingPath, err := store.SaveBriefing(context.Background(), first, briefingPackage)
if err != nil {
t.Fatalf("SaveBriefing() error = %v", err)
}
paths, err := store.Paths(first)
if err != nil {
t.Fatalf("Paths() error = %v", err)
}
_, err = store.SaveMetadata(context.Background(), BuildMetadata(first, briefingPackage, ArtifactPaths{
Briefing: briefingPath,
Metadata: paths.Metadata,
DataPackage: paths.DataPackage,
Preflight: paths.Preflight,
RenderedReport: paths.RenderedReport,
}))
if err != nil {
t.Fatalf("SaveMetadata() error = %v", err)
}
prior, err := store.FindPriorSnapshot(context.Background(), second)
if err != nil {
t.Fatalf("FindPriorSnapshot() error = %v", err)
}
if prior == nil {
t.Fatal("FindPriorSnapshot() = nil, want prior narrowed weekend snapshot")
}
if prior.Metadata.RunID != first.Metadata().RunID {
t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID)
}
}
func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) {
cfg := config.Defaults().Workspace
cfg.Root = t.TempDir()
cfg.SnapshotsDir = "../snapshots"
_, err := NewFilesystemStore(cfg)
if err == nil {
t.Fatal("NewFilesystemStore() error = nil, want unsafe path error")
}
if !strings.Contains(err.Error(), "within workspace root") {
t.Fatalf("error = %q, want path safety context", err.Error())
}
}
func newTestStore(t *testing.T) *FilesystemStore {
t.Helper()
cfg := config.Defaults().Workspace
cfg.Root = t.TempDir()
store, err := NewFilesystemStore(cfg)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
return store
}
func resolveDailyAt(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.DailyToday, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func resolveThreeDayAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.ThreeDay, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func resolveWeekendAt(t *testing.T, value string) report.Resolved {
t.Helper()
location, err := timeutil.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
now, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("parse time: %v", err)
}
resolved, err := report.DefaultRegistry().Resolve(report.Weekend, report.ResolveRequest{
Now: now,
Location: location,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func stateBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
Variant: "today",
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
}
}
func stateThreeDayBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
ThreeDay: &briefing.ThreeDay{
Days: []briefing.OutlookDay{{Date: "2026-05-29"}},
},
}
}
func stateWeekendBriefingPackage(resolved report.Resolved) briefing.Package {
return briefing.Package{
Metadata: briefing.Metadata{
SchemaVersion: briefing.SchemaVersion,
RunID: resolved.Metadata().RunID,
ReportID: resolved.Definition.ID,
PromptID: resolved.Definition.PromptID,
GeneratedAt: resolved.GeneratedAt,
Units: "us",
Timezone: resolved.Timezone,
ValidPeriod: resolved.ValidPeriod,
},
Weekend: &briefing.Weekend{
Days: []briefing.OutlookDay{{Date: "2026-05-30"}},
},
}
}
func pathsString(paths ArtifactPaths) string {
return strings.Join([]string{
paths.Briefing,
paths.Metadata,
paths.DataPackage,
paths.Preflight,
paths.RenderedReport,
}, "\n")
}

View File

@@ -0,0 +1,53 @@
package state
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const MetadataSchemaVersion = "weatherreporter.metadata.v1"
type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
BriefingPath string `json:"briefingPath"`
DataPackagePath string `json:"dataPackagePath"`
PreflightPath string `json:"preflightPath"`
RenderedReportPath string `json:"renderedReportPath,omitempty"`
}
func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, paths ArtifactPaths) Metadata {
metadata := resolved.Metadata()
return Metadata{
SchemaVersion: MetadataSchemaVersion,
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: briefingPackage.Metadata.Variant,
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Timezone: metadata.Timezone,
ValidPeriod: metadata.ValidPeriod,
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
SourceLocation: briefingPackage.Metadata.SourceLocation,
Sources: briefingPackage.Metadata.Sources,
SourceWarnings: briefingPackage.Metadata.SourceWarnings,
BriefingPath: paths.Briefing,
DataPackagePath: paths.DataPackage,
PreflightPath: paths.Preflight,
RenderedReportPath: paths.RenderedReport,
}
}

28
internal/state/store.go Normal file
View File

@@ -0,0 +1,28 @@
// Package state persists report artifacts and metadata.
package state
import (
"context"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type Store interface {
Paths(report.Resolved) (ArtifactPaths, error)
SaveBriefing(context.Context, report.Resolved, briefing.Package) (string, error)
SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error)
SavePreflight(context.Context, report.Resolved, *scriptorium.RenderResult) (string, error)
PrepareRenderedReport(context.Context, report.Resolved) (string, error)
SaveMetadata(context.Context, Metadata) (string, error)
FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
FindPriorDailySnapshot(context.Context, report.Resolved) (*PriorSnapshot, error)
LoadBriefing(context.Context, string) (briefing.Package, error)
}
type PriorSnapshot struct {
Metadata Metadata
BriefingPath string
}

View File

@@ -0,0 +1,22 @@
// Package timeutil provides time-zone, clock, and period helpers.
package timeutil
import "time"
type Clock interface {
Now() time.Time
}
type SystemClock struct{}
func (SystemClock) Now() time.Time {
return time.Now()
}
type FixedClock struct {
Time time.Time
}
func (c FixedClock) Now() time.Time {
return c.Time
}

103
internal/timeutil/parse.go Normal file
View File

@@ -0,0 +1,103 @@
package timeutil
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
const DateLayout = "2006-01-02"
const LocalDateTimeLayout = "2006-01-02T15:04"
func LoadLocation(name string) (*time.Location, error) {
if location, ok := timezoneAliases[name]; ok {
return location, nil
}
if location, ok := parseUTCOffset(name); ok {
return location, nil
}
location, err := time.LoadLocation(name)
if err == nil {
return location, nil
}
if strings.Contains(name, "/") {
return nil, fmt.Errorf("load timezone %q: %w", name, err)
}
location, chicagoErr := time.LoadLocation("America/" + name)
if chicagoErr == nil {
return location, nil
}
return nil, fmt.Errorf("load timezone %q: %w", name, err)
}
var timezoneAliases = map[string]*time.Location{
"Chicago": mustLocation("America/Chicago"),
"Stl": mustLocation("America/Chicago"),
"EST": time.FixedZone("EST", -5*60*60),
"EDT": time.FixedZone("EDT", -4*60*60),
"CST": time.FixedZone("CST", -6*60*60),
"CDT": time.FixedZone("CDT", -5*60*60),
"MST": time.FixedZone("MST", -7*60*60),
"MDT": time.FixedZone("MDT", -6*60*60),
"PST": time.FixedZone("PST", -8*60*60),
"PDT": time.FixedZone("PDT", -7*60*60),
}
func mustLocation(name string) *time.Location {
location, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return location
}
var utcOffsetPattern = regexp.MustCompile(`^([+-])(\d{1,2})(?::?(\d{2}))?$`)
func parseUTCOffset(value string) (*time.Location, bool) {
matches := utcOffsetPattern.FindStringSubmatch(value)
if matches == nil {
return nil, false
}
hours, err := strconv.Atoi(matches[2])
if err != nil || hours > 23 {
return nil, false
}
minutes := 0
if matches[3] != "" {
minutes, err = strconv.Atoi(matches[3])
if err != nil || minutes > 59 {
return nil, false
}
}
offset := (hours*60 + minutes) * 60
if matches[1] == "-" {
offset = -offset
}
return time.FixedZone(value, offset), true
}
func LocalDate(now time.Time, location *time.Location) time.Time {
local := now.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}
func ParseLocalDate(value string, location *time.Location) (time.Time, error) {
parsed, err := time.ParseInLocation(DateLayout, value, location)
if err != nil {
return time.Time{}, fmt.Errorf("parse date %q as YYYY-MM-DD: %w", value, err)
}
return parsed, nil
}
func ParseStormTime(value string, location *time.Location) (time.Time, error) {
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
parsed, err := time.ParseInLocation(LocalDateTimeLayout, value, location)
if err != nil {
return time.Time{}, fmt.Errorf("parse storm time %q as YYYY-MM-DDTHH:MM or RFC3339: %w", value, err)
}
return parsed, nil
}

View File

@@ -0,0 +1,60 @@
package timeutil
import (
"testing"
"time"
)
func TestLoadLocationAcceptsChicagoAlias(t *testing.T) {
location, err := LoadLocation("Chicago")
if err != nil {
t.Fatalf("LoadLocation() error = %v", err)
}
if location.String() != "America/Chicago" {
t.Fatalf("location = %q, want America/Chicago", location.String())
}
}
func TestLoadLocationAcceptsWeatherAPITimezones(t *testing.T) {
tests := []string{"Stl", "CDT", "-5", "+09:30"}
for _, tt := range tests {
t.Run(tt, func(t *testing.T) {
if _, err := LoadLocation(tt); err != nil {
t.Fatalf("LoadLocation(%q) error = %v", tt, err)
}
})
}
}
func TestParseLocalDate(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
got, err := ParseLocalDate("2026-05-29", location)
if err != nil {
t.Fatalf("ParseLocalDate() error = %v", err)
}
if got.Format(DateLayout) != "2026-05-29" {
t.Fatalf("date = %s, want 2026-05-29", got.Format(DateLayout))
}
if got.Location() != location {
t.Fatalf("location = %v, want test location", got.Location())
}
}
func TestParseStormTime(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
local, err := ParseStormTime("2026-05-29T18:00", location)
if err != nil {
t.Fatalf("ParseStormTime(local) error = %v", err)
}
if local.Location() != location {
t.Fatalf("local location = %v, want test location", local.Location())
}
rfc3339, err := ParseStormTime("2026-05-29T18:00:00-05:00", location)
if err != nil {
t.Fatalf("ParseStormTime(rfc3339) error = %v", err)
}
if rfc3339.Format(time.RFC3339) != "2026-05-29T18:00:00-05:00" {
t.Fatalf("rfc3339 = %s, want preserved offset time", rfc3339.Format(time.RFC3339))
}
}

View File

@@ -0,0 +1,81 @@
package timeutil
import (
"fmt"
"strconv"
"strings"
"time"
)
type Period struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
func (p Period) IsValid() bool {
return p.End.After(p.Start)
}
func (p Period) Overlaps(other Period) bool {
return p.Start.Before(other.End) && other.Start.Before(p.End)
}
func (p Period) Contains(t time.Time) bool {
return !t.Before(p.Start) && t.Before(p.End)
}
func (p Period) Intersection(other Period) (Period, bool) {
if !p.Overlaps(other) {
return Period{}, false
}
start := p.Start
if other.Start.After(start) {
start = other.Start
}
end := p.End
if other.End.Before(end) {
end = other.End
}
return Period{Start: start, End: end}, true
}
func ParseClock(value string) (time.Duration, error) {
parts := strings.Split(value, ":")
if len(parts) != 2 {
return 0, fmt.Errorf("expected HH:MM")
}
hour, err := strconv.Atoi(parts[0])
if err != nil {
return 0, fmt.Errorf("invalid hour")
}
minute, err := strconv.Atoi(parts[1])
if err != nil {
return 0, fmt.Errorf("invalid minute")
}
if hour < 0 || hour > 24 {
return 0, fmt.Errorf("hour must be between 00 and 24")
}
if minute < 0 || minute > 59 {
return 0, fmt.Errorf("minute must be between 00 and 59")
}
if hour == 24 && minute != 0 {
return 0, fmt.Errorf("24 is only valid as 24:00")
}
return time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute, nil
}
func CivilDay(date time.Time, location *time.Location) Period {
local := date.In(location)
start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
return Period{Start: start, End: start.AddDate(0, 0, 1)}
}
func ClockWindow(date time.Time, location *time.Location, startClock time.Duration, endClock time.Duration) Period {
day := CivilDay(date, location)
start := day.Start.Add(startClock)
end := day.Start.Add(endClock)
if !end.After(start) {
end = end.AddDate(0, 0, 1)
}
return Period{Start: start, End: end}
}

View File

@@ -0,0 +1,35 @@
package timeutil
import (
"testing"
"time"
)
func TestPeriodOverlapUsesHalfOpenIntervals(t *testing.T) {
start := time.Date(2026, 5, 29, 6, 0, 0, 0, time.UTC)
left := Period{Start: start, End: start.Add(time.Hour)}
touching := Period{Start: start.Add(time.Hour), End: start.Add(2 * time.Hour)}
overlapping := Period{Start: start.Add(30 * time.Minute), End: start.Add(90 * time.Minute)}
if left.Overlaps(touching) {
t.Fatal("touching half-open periods overlap, want false")
}
if !left.Overlaps(overlapping) {
t.Fatal("overlapping periods do not overlap, want true")
}
}
func TestClockWindowHandlesOvernight(t *testing.T) {
location := time.FixedZone("Test", -5*60*60)
date := time.Date(2026, 5, 29, 12, 0, 0, 0, location)
start, _ := ParseClock("22:00")
end, _ := ParseClock("06:00")
window := ClockWindow(date, location, start, end)
if got := window.Start.Format("2006-01-02T15:04"); got != "2026-05-29T22:00" {
t.Fatalf("Start = %s, want 2026-05-29T22:00", got)
}
if got := window.End.Format("2006-01-02T15:04"); got != "2026-05-30T06:00" {
t.Fatalf("End = %s, want 2026-05-30T06:00", got)
}
}