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
|
||||
weatherreporter
|
||||
/weatherreporter
|
||||
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
@@ -71,4 +71,3 @@ Icon
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
|
||||
26
README.md
26
README.md
@@ -1,2 +1,28 @@
|
||||
# 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