Compare commits
16 Commits
05b56d6ea6
...
88aaae3661
| Author | SHA1 | Date | |
|---|---|---|---|
| 88aaae3661 | |||
| 18fe82f441 | |||
| 108a1618f6 | |||
| 5d543b6b4d | |||
| 4c9d396f9b | |||
| 19513e42c1 | |||
| 53a4abd508 | |||
| b17a3591e0 | |||
| 7ac73f758b | |||
| e556ca5edc | |||
| cf8e1de3ff | |||
| caf21dfedd | |||
| d494550b20 | |||
| a885959d39 | |||
| 8c065751c2 | |||
| e5cd23de48 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -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)
|
||||||
|
|||||||
16
cmd/weatherreporter/main.go
Normal file
16
cmd/weatherreporter/main.go
Normal 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
107
docs/cli.md
Normal 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
46
docs/config.md
Normal 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
84
docs/internal/briefing.md
Normal 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
74
docs/internal/changes.md
Normal 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.
|
||||||
68
docs/internal/forecast-derivation.md
Normal file
68
docs/internal/forecast-derivation.md
Normal 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.
|
||||||
54
docs/internal/prompt-input.md
Normal file
54
docs/internal/prompt-input.md
Normal 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.
|
||||||
63
docs/internal/report-registry.md
Normal file
63
docs/internal/report-registry.md
Normal 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.
|
||||||
71
docs/internal/scriptorium-adapter.md
Normal file
71
docs/internal/scriptorium-adapter.md
Normal 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
81
docs/internal/state.md
Normal 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.
|
||||||
69
docs/internal/weather-data.md
Normal file
69
docs/internal/weather-data.md
Normal 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
182
docs/operations.md
Normal 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
46
examples/config.yml
Normal 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
5
go.mod
Normal 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
4
go.sum
Normal 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=
|
||||||
274
internal/adapters/scriptorium/runner.go
Normal file
274
internal/adapters/scriptorium/runner.go
Normal 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)
|
||||||
163
internal/adapters/scriptorium/runner_test.go
Normal file
163
internal/adapters/scriptorium/runner_test.go
Normal 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
|
||||||
|
}
|
||||||
426
internal/adapters/weatherapi/client.go
Normal file
426
internal/adapters/weatherapi/client.go
Normal 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, ¤t); err != nil {
|
||||||
|
return b.handleMalformed(&source, err, false)
|
||||||
|
}
|
||||||
|
b.bundle.Current = ¤t
|
||||||
|
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
|
||||||
|
}
|
||||||
348
internal/adapters/weatherapi/client_test.go
Normal file
348
internal/adapters/weatherapi/client_test.go
Normal 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
|
||||||
|
}
|
||||||
6
internal/adapters/weatherapi/testdata/alerts.json
vendored
Normal file
6
internal/adapters/weatherapi/testdata/alerts.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"asOf": "2026-05-29T14:00:00Z",
|
||||||
|
"alerts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
10
internal/adapters/weatherapi/testdata/current.json
vendored
Normal file
10
internal/adapters/weatherapi/testdata/current.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"conditionText": "Partly cloudy",
|
||||||
|
"isDay": true,
|
||||||
|
"temperatureF": 75.9,
|
||||||
|
"apparentTemperatureF": 76.1,
|
||||||
|
"windSpeedMph": 10.7,
|
||||||
|
"relativeHumidityPercent": 56
|
||||||
|
}
|
||||||
|
}
|
||||||
16
internal/adapters/weatherapi/testdata/discussion.json
vendored
Normal file
16
internal/adapters/weatherapi/testdata/discussion.json
vendored
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/adapters/weatherapi/testdata/hourly.json
vendored
Normal file
25
internal/adapters/weatherapi/testdata/hourly.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
20
internal/adapters/weatherapi/testdata/narrative.json
vendored
Normal file
20
internal/adapters/weatherapi/testdata/narrative.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/adapters/weatherapi/testdata/observations.json
vendored
Normal file
13
internal/adapters/weatherapi/testdata/observations.json
vendored
Normal 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
646
internal/app/app.go
Normal 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
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
123
internal/app/inspect.go
Normal 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
431
internal/briefing/daily.go
Normal 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:]
|
||||||
|
}
|
||||||
270
internal/briefing/daily_test.go
Normal file
270
internal/briefing/daily_test.go
Normal 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
|
||||||
|
}
|
||||||
159
internal/briefing/package.go
Normal file
159
internal/briefing/package.go
Normal 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
200
internal/briefing/storm.go
Normal 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...)
|
||||||
|
}
|
||||||
147
internal/briefing/storm_test.go
Normal file
147
internal/briefing/storm_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/briefing/three_day.go
Normal file
129
internal/briefing/three_day.go
Normal 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, ". ") + "."
|
||||||
|
}
|
||||||
83
internal/briefing/three_day_test.go
Normal file
83
internal/briefing/three_day_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
125
internal/briefing/weekend.go
Normal file
125
internal/briefing/weekend.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Weekend struct {
|
||||||
|
Days []OutlookDay `json:"days"`
|
||||||
|
Planning WeekendPlanning `json:"planning"`
|
||||||
|
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
|
||||||
|
Discussion DiscussionContext `json:"discussion,omitempty"`
|
||||||
|
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeekendPlanning struct {
|
||||||
|
BestOutdoorWindows []OutdoorWindow `json:"bestOutdoorWindows,omitempty"`
|
||||||
|
WorstWeatherWindows []OutdoorWindow `json:"worstWeatherWindows,omitempty"`
|
||||||
|
RainStormTiming []string `json:"rainStormTiming,omitempty"`
|
||||||
|
ComfortConcerns []string `json:"comfortConcerns,omitempty"`
|
||||||
|
UncertaintyInputs []string `json:"uncertaintyInputs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
|
||||||
|
if ctx.Resolved.Definition.ID != report.Weekend {
|
||||||
|
return Package{}, fmt.Errorf("weekend briefing requires a weekend report definition")
|
||||||
|
}
|
||||||
|
if len(summaries) == 0 {
|
||||||
|
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||||
|
}
|
||||||
|
pkg := Package{
|
||||||
|
Metadata: BuildMetadata(ctx),
|
||||||
|
Weekend: &Weekend{
|
||||||
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, summary := range summaries {
|
||||||
|
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||||
|
}
|
||||||
|
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||||
|
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWeekendPlanning(days []OutlookDay, discussion DiscussionContext, bundle *forecast.Bundle) WeekendPlanning {
|
||||||
|
planning := WeekendPlanning{}
|
||||||
|
for _, day := range days {
|
||||||
|
if day.OutdoorWindows.Best != nil {
|
||||||
|
window := *day.OutdoorWindows.Best
|
||||||
|
window.Daypart = day.Date + " " + window.Daypart
|
||||||
|
planning.BestOutdoorWindows = append(planning.BestOutdoorWindows, window)
|
||||||
|
}
|
||||||
|
if day.OutdoorWindows.Worst != nil {
|
||||||
|
window := *day.OutdoorWindows.Worst
|
||||||
|
window.Daypart = day.Date + " " + window.Daypart
|
||||||
|
planning.WorstWeatherWindows = append(planning.WorstWeatherWindows, window)
|
||||||
|
}
|
||||||
|
for _, daypart := range day.Dayparts {
|
||||||
|
planning.RainStormTiming = appendUnique(planning.RainStormTiming, weekendRainStormNotes(day.Date, daypart)...)
|
||||||
|
planning.ComfortConcerns = appendUnique(planning.ComfortConcerns, weekendComfortNotes(day.Date, daypart)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, discussion.KeyMessages...)
|
||||||
|
if discussion.ShortTerm != "" {
|
||||||
|
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Short-term discussion available for confidence context.")
|
||||||
|
}
|
||||||
|
if discussion.LongTerm != "" {
|
||||||
|
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Long-term discussion available for uncertainty context.")
|
||||||
|
}
|
||||||
|
if bundle != nil {
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Code != "" {
|
||||||
|
planning.UncertaintyInputs = appendUnique(planning.UncertaintyInputs, "Source warning: "+warning.Code+".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(planning.RainStormTiming) == 0 {
|
||||||
|
planning.RainStormTiming = append(planning.RainStormTiming, "No focused rain or storm timing stands out in the available weekend forecast.")
|
||||||
|
}
|
||||||
|
if len(planning.ComfortConcerns) == 0 {
|
||||||
|
planning.ComfortConcerns = append(planning.ComfortConcerns, "No major heat, cold, or wind comfort concern stands out in the available weekend forecast.")
|
||||||
|
}
|
||||||
|
if len(planning.UncertaintyInputs) == 0 {
|
||||||
|
planning.UncertaintyInputs = append(planning.UncertaintyInputs, "No explicit confidence or uncertainty signal was available from the selected source context.")
|
||||||
|
}
|
||||||
|
return planning
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
label := weekendWindowLabel(date, daypart.Name)
|
||||||
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Thunder {
|
||||||
|
notes = append(notes, label+" thunder risk is present.")
|
||||||
|
}
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekendComfortNotes(date string, daypart forecast.DaypartSummary) []string {
|
||||||
|
notes := []string{}
|
||||||
|
label := weekendWindowLabel(date, daypart.Name)
|
||||||
|
if daypart.Indicators.Heat {
|
||||||
|
notes = append(notes, label+" heat may affect outdoor comfort.")
|
||||||
|
}
|
||||||
|
if daypart.Indicators.Cold {
|
||||||
|
notes = append(notes, label+" cold may affect outdoor comfort.")
|
||||||
|
}
|
||||||
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 25 {
|
||||||
|
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", label, daypart.PeakWindGust.Value))
|
||||||
|
}
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekendWindowLabel(date string, daypart string) string {
|
||||||
|
if daypart == "" {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(date + " " + daypart)
|
||||||
|
}
|
||||||
88
internal/briefing/weekend_test.go
Normal file
88
internal/briefing/weekend_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package briefing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
||||||
|
location := mustLocation(t)
|
||||||
|
resolved, err := report.Resolve(report.Weekend, report.ResolveRequest{
|
||||||
|
Now: mustParse("2026-05-29T08:00:00-05:00"),
|
||||||
|
Location: location,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve weekend: %v", err)
|
||||||
|
}
|
||||||
|
precip := 70.0
|
||||||
|
gust := 32.0
|
||||||
|
summaries := []forecast.DailySummary{
|
||||||
|
{
|
||||||
|
Date: "2026-05-30",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: mustParse("2026-05-30T00:00:00-05:00"),
|
||||||
|
End: mustParse("2026-05-31T00:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Dayparts: []forecast.DaypartSummary{
|
||||||
|
{
|
||||||
|
Name: "afternoon",
|
||||||
|
DominantCondition: "Showers and thunderstorms",
|
||||||
|
Period: timeutil.Period{
|
||||||
|
Start: mustParse("2026-05-30T12:00:00-05:00"),
|
||||||
|
End: mustParse("2026-05-30T18:00:00-05:00"),
|
||||||
|
},
|
||||||
|
MaxPrecipitationProbability: &forecast.TimedValue{
|
||||||
|
Value: precip,
|
||||||
|
Time: mustParse("2026-05-30T15:00:00-05:00"),
|
||||||
|
},
|
||||||
|
PeakWindGust: &forecast.TimedValue{
|
||||||
|
Value: gust,
|
||||||
|
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||||
|
},
|
||||||
|
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
||||||
|
HourlyPeriods: []forecast.ForecastPeriod{
|
||||||
|
{
|
||||||
|
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||||
|
EndTime: mustParse("2026-05-30T16:00:00-05:00"),
|
||||||
|
TextDescription: "Showers and thunderstorms",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
|
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := BuildWeekend(BuildContext{
|
||||||
|
Resolved: resolved,
|
||||||
|
Units: "us",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
}, summaries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildWeekend() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.Metadata.ReportID != report.Weekend {
|
||||||
|
t.Fatalf("ReportID = %q, want weekend", pkg.Metadata.ReportID)
|
||||||
|
}
|
||||||
|
if pkg.Weekend == nil {
|
||||||
|
t.Fatal("Weekend = nil")
|
||||||
|
}
|
||||||
|
if len(pkg.Weekend.Days) != 1 {
|
||||||
|
t.Fatalf("Days length = %d, want 1", len(pkg.Weekend.Days))
|
||||||
|
}
|
||||||
|
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||||
|
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
||||||
|
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
||||||
|
}
|
||||||
|
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||||
|
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||||
|
}
|
||||||
|
}
|
||||||
201
internal/changes/daily.go
Normal file
201
internal/changes/daily.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
123
internal/changes/daily_test.go
Normal file
123
internal/changes/daily_test.go
Normal 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
|
||||||
|
}
|
||||||
78
internal/changes/three_day.go
Normal file
78
internal/changes/three_day.go
Normal 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
|
||||||
|
}
|
||||||
66
internal/changes/three_day_test.go
Normal file
66
internal/changes/three_day_test.go
Normal 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: ¤tTemp},
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/changes/weekend.go
Normal file
26
internal/changes/weekend.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package changes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CompareWeekend(previous briefing.Package, current briefing.Package, thresholds Thresholds) ([]Change, error) {
|
||||||
|
if previous.Weekend == nil {
|
||||||
|
return nil, fmt.Errorf("previous weekend briefing is required")
|
||||||
|
}
|
||||||
|
if current.Weekend == nil {
|
||||||
|
return nil, fmt.Errorf("current weekend briefing is required")
|
||||||
|
}
|
||||||
|
previousOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: previous.Weekend.Days}}
|
||||||
|
currentOutlook := briefing.Package{ThreeDay: &briefing.ThreeDay{Days: current.Weekend.Days}}
|
||||||
|
changes, err := CompareThreeDay(previousOutlook, currentOutlook, thresholds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range changes {
|
||||||
|
changes[i].Type = "weekend_" + changes[i].Type
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
43
internal/changes/weekend_test.go
Normal file
43
internal/changes/weekend_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package changes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
|
||||||
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
||||||
|
previousTemp := 70.0
|
||||||
|
currentTemp := 78.0
|
||||||
|
previous := briefing.Package{
|
||||||
|
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||||
|
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||||
|
Date: "2026-05-30",
|
||||||
|
Temperature: forecast.Range{Max: &previousTemp},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
current := briefing.Package{
|
||||||
|
Metadata: briefing.Metadata{ReportID: report.Weekend},
|
||||||
|
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||||
|
Date: "2026-05-30",
|
||||||
|
Temperature: forecast.Range{Max: ¤tTemp},
|
||||||
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
changes, err := CompareWeekend(previous, current, Thresholds{TemperatureDegrees: 5})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompareWeekend() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
t.Fatal("changes length = 0, want weekend changes")
|
||||||
|
}
|
||||||
|
for _, change := range changes {
|
||||||
|
if change.Type == "weekend_outlook_thunder_risk_change" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
||||||
|
}
|
||||||
436
internal/cli/root.go
Normal file
436
internal/cli/root.go
Normal 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
811
internal/cli/root_test.go
Normal 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
71
internal/config/config.go
Normal 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"`
|
||||||
|
}
|
||||||
88
internal/config/config_test.go
Normal file
88
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/config/defaults.go
Normal file
48
internal/config/defaults.go
Normal 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
68
internal/config/load.go
Normal 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
|
||||||
|
}
|
||||||
88
internal/config/validate.go
Normal file
88
internal/config/validate.go
Normal 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
161
internal/forecast/bundle.go
Normal 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"`
|
||||||
|
}
|
||||||
53
internal/forecast/dayparts.go
Normal file
53
internal/forecast/dayparts.go
Normal 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
440
internal/forecast/derive.go
Normal 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
|
||||||
|
}
|
||||||
280
internal/forecast/derive_test.go
Normal file
280
internal/forecast/derive_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
internal/forecast/testdata/daily_bundle.json
vendored
Normal file
66
internal/forecast/testdata/daily_bundle.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
20
internal/forecast/thresholds.go
Normal file
20
internal/forecast/thresholds.go
Normal 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
|
||||||
|
}
|
||||||
145
internal/promptinput/package.go
Normal file
145
internal/promptinput/package.go
Normal 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
|
||||||
|
}
|
||||||
167
internal/promptinput/package_test.go
Normal file
167
internal/promptinput/package_test.go
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/report/definition.go
Normal file
87
internal/report/definition.go
Normal 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
144
internal/report/period.go
Normal 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
|
||||||
|
}
|
||||||
234
internal/report/period_test.go
Normal file
234
internal/report/period_test.go
Normal 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
|
||||||
|
}
|
||||||
88
internal/report/registry.go
Normal file
88
internal/report/registry.go
Normal 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
|
||||||
|
}
|
||||||
467
internal/state/filesystem.go
Normal file
467
internal/state/filesystem.go
Normal 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
|
||||||
|
}
|
||||||
443
internal/state/filesystem_test.go
Normal file
443
internal/state/filesystem_test.go
Normal 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")
|
||||||
|
}
|
||||||
53
internal/state/metadata.go
Normal file
53
internal/state/metadata.go
Normal 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
28
internal/state/store.go
Normal 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
|
||||||
|
}
|
||||||
22
internal/timeutil/clock.go
Normal file
22
internal/timeutil/clock.go
Normal 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
103
internal/timeutil/parse.go
Normal 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
|
||||||
|
}
|
||||||
60
internal/timeutil/parse_test.go
Normal file
60
internal/timeutil/parse_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
81
internal/timeutil/periods.go
Normal file
81
internal/timeutil/periods.go
Normal 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}
|
||||||
|
}
|
||||||
35
internal/timeutil/periods_test.go
Normal file
35
internal/timeutil/periods_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user