Compare commits
9 Commits
8089f62806
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 942e8ff591 | |||
| 0b050256f9 | |||
| 8a762bf34f | |||
| 42defcf4b9 | |||
| 3e93a97d10 | |||
| 26e6f33cde | |||
| 1bc0739d31 | |||
| 8476dab844 | |||
| 745992886c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Compiled application binary
|
# Compiled application binary and testing workspace
|
||||||
/weatherreporter
|
/weatherreporter
|
||||||
|
/workspace
|
||||||
|
|
||||||
# ---> Go
|
# ---> Go
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
|||||||
@@ -45,12 +45,25 @@ config test suite.
|
|||||||
- `timeout`: HTTP timeout duration. Default: `10s`.
|
- `timeout`: HTTP timeout duration. Default: `10s`.
|
||||||
- `precision`: numeric precision query value. Default: `1`.
|
- `precision`: numeric precision query value. Default: `1`.
|
||||||
- `units`: Weather API units query value. Default: `us`.
|
- `units`: Weather API units query value. Default: `us`.
|
||||||
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `Chicago`.
|
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
|
||||||
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
- `format`: Weather API response format. Must be `json`. Default: `json`.
|
||||||
|
|
||||||
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
Timezone values may be IANA names, configured aliases such as `Chicago` and
|
||||||
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
|
||||||
|
|
||||||
|
### `location`
|
||||||
|
|
||||||
|
`location` is descriptive prompt context included in briefing metadata and
|
||||||
|
Scriptorium data packages. It does not select a Weather API endpoint or enable
|
||||||
|
multiple configured forecast locations.
|
||||||
|
|
||||||
|
- `id`: short local identifier. Default: `home`.
|
||||||
|
- `name`: human-readable location name. Default: `Brentwood`.
|
||||||
|
- `region`: broader forecast area context. Default: `St. Louis Metro`.
|
||||||
|
|
||||||
|
The prompt-facing location object also includes `timezone`, derived from the
|
||||||
|
effective `weather_api.timezone` after CLI overrides such as `--tz`.
|
||||||
|
|
||||||
### `missing_source`
|
### `missing_source`
|
||||||
|
|
||||||
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
|
||||||
@@ -88,7 +101,7 @@ Each entry has:
|
|||||||
- `end`
|
- `end`
|
||||||
|
|
||||||
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
|
||||||
afternoon, and evening.
|
midday, afternoon, and evening.
|
||||||
|
|
||||||
### `recent_change`
|
### `recent_change`
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,14 @@ Every response used by the adapter must be JSON with a top-level `data` field:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`data: null` is treated as a missing source. Missing optional sources follow the
|
For most sources, `data: null` is treated as a missing source. Missing optional
|
||||||
configured missing-source policy. Missing hourly forecast data fails bundle
|
sources follow the configured missing-source policy. Missing hourly forecast
|
||||||
fetching because hourly periods are required for report generation.
|
data fails bundle fetching because hourly periods are required for report
|
||||||
|
generation.
|
||||||
|
|
||||||
|
`/alerts/active` is the exception: a successful response with `data: null`
|
||||||
|
means the endpoint was checked and there are no current active alerts. The
|
||||||
|
adapter records a non-missing alerts source and an empty alert run.
|
||||||
|
|
||||||
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
Malformed JSON envelopes, non-2xx statuses, and response read failures include
|
||||||
endpoint context in returned errors. Decode errors include source context when
|
endpoint context in returned errors. Decode errors include source context when
|
||||||
@@ -92,9 +97,14 @@ Policy behavior:
|
|||||||
- `warn`: omit the source data, add a warning, and continue
|
- `warn`: omit the source data, add a warning, and continue
|
||||||
- `none`: omit the source data and continue without a warning
|
- `none`: omit the source data and continue without a warning
|
||||||
|
|
||||||
|
For `/alerts/active`, an HTTP error or missing `data` field still fails or
|
||||||
|
follows the relevant error path, but explicit `data: null` is not a
|
||||||
|
missing-source condition.
|
||||||
|
|
||||||
## Source Identity
|
## Source Identity
|
||||||
|
|
||||||
For non-null source payloads, the adapter records:
|
For source payloads accepted into the bundle, including the explicit `null`
|
||||||
|
alerts payload, the adapter records:
|
||||||
|
|
||||||
- source name
|
- source name
|
||||||
- endpoint path
|
- endpoint path
|
||||||
@@ -115,7 +125,7 @@ types in `internal/forecast/bundle.go`, including:
|
|||||||
- current condition values
|
- current condition values
|
||||||
- forecast run metadata and `periods`
|
- forecast run metadata and `periods`
|
||||||
- active alert run data
|
- active alert run data
|
||||||
- discussion metadata, key messages, and short/long-term sections
|
- discussion metadata, key messages, and short/long-term section text
|
||||||
|
|
||||||
The adapter intentionally keeps upstream transport and envelope details inside
|
The adapter intentionally keeps upstream transport and envelope details inside
|
||||||
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
`internal/adapters/weatherapi`; downstream packages consume the normalized
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ Inputs:
|
|||||||
- resolved report definition, generation time, timezone, and valid period
|
- resolved report definition, generation time, timezone, and valid period
|
||||||
- forecast bundle with source provenance and warnings
|
- forecast bundle with source provenance and warnings
|
||||||
- derived daily or period summaries where required
|
- derived daily or period summaries where required
|
||||||
- configured units and timezone
|
- configured units, timezone, and descriptive location context
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `briefing.Package` with common metadata and one report-specific content
|
- `briefing.Package` with common metadata and one report-specific content
|
||||||
object for Daily, 3-Day, Weekend, or Storm Report
|
object for Daily, 3-Day, Weekend, or Storm Report
|
||||||
|
- optional `currentConditions` prompt context from normalized
|
||||||
|
`/conditions/current` data when available
|
||||||
- optional JSON file written by `briefing.Save`
|
- optional JSON file written by `briefing.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -34,6 +36,10 @@ Outputs:
|
|||||||
|
|
||||||
The package receives configured units and timezone from the app layer. Daypart
|
The package receives configured units and timezone from the app layer. Daypart
|
||||||
configuration is consumed by `internal/forecast` before briefing builders run.
|
configuration is consumed by `internal/forecast` before briefing builders run.
|
||||||
|
Configured `location` values are prompt context only; Weather API
|
||||||
|
`sourceLocationId` and `sourceLocation` remain source provenance.
|
||||||
|
Current conditions are copied from the normalized `/conditions/current` bundle
|
||||||
|
source only; observation station and timestamp fields remain provenance.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
@@ -74,6 +80,6 @@ Inspect:
|
|||||||
|
|
||||||
- Briefings contain structured weather facts and source context.
|
- Briefings contain structured weather facts and source context.
|
||||||
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
- Common metadata includes RunID, report ID, prompt ID, valid period, source
|
||||||
provenance, source hashes, and source warnings.
|
provenance, source hashes, source warnings, and configured prompt location.
|
||||||
- LLM prompt input packaging and Scriptorium execution remain outside this
|
- LLM prompt input packaging and Scriptorium execution remain outside this
|
||||||
boundary.
|
boundary.
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
|
|
||||||
- `promptinput.Package` containing schema version, RunID, report metadata,
|
- `promptinput.Package` containing schema version, RunID, report metadata,
|
||||||
briefing content, Recent Changes, and source warnings
|
briefing content, Recent Changes, and source warnings. Briefing content
|
||||||
|
includes configured location context, current conditions when available,
|
||||||
|
discussion key messages, and short/long-term AFD narratives when the Weather
|
||||||
|
API provides them.
|
||||||
|
- report metadata includes `currentLocalDate`, the generation date formatted as
|
||||||
|
`YYYY-MM-DD` in the effective report timezone.
|
||||||
- optional JSON file written by `promptinput.Save`
|
- optional JSON file written by `promptinput.Save`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -28,8 +33,8 @@ Outputs:
|
|||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
|
|
||||||
None directly. Config-derived values are already present in briefing metadata
|
None directly. Config-derived values, including timezone and prompt location
|
||||||
before this package runs.
|
context, are already present in briefing metadata before this package runs.
|
||||||
|
|
||||||
## External Adapters Used
|
## External Adapters Used
|
||||||
|
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ valid-period start date for JSON artifacts, and the RunID.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Metadata is stored beside briefing snapshots and links the briefing, data
|
Metadata is stored beside briefing snapshots and links the briefing, data
|
||||||
package, preflight, and report paths. Report listing walks metadata files under
|
package, preflight, report paths, and configured prompt location. Report
|
||||||
the snapshots directory.
|
listing walks metadata files under the snapshots directory.
|
||||||
|
|
||||||
## Prior Lookup
|
## Prior Lookup
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ This document describes Weather API ingestion into `forecast.Bundle`.
|
|||||||
|
|
||||||
`internal/adapters/weatherapi` fetches normalized weather data from one
|
`internal/adapters/weatherapi` fetches normalized weather data from one
|
||||||
configured Weather API endpoint and assembles the bundle consumed by forecast
|
configured Weather API endpoint and assembles the bundle consumed by forecast
|
||||||
derivation and briefing builders.
|
derivation and briefing builders. Briefing builders expose normalized current
|
||||||
|
conditions as prompt context when `/conditions/current` is available.
|
||||||
|
|
||||||
## Inputs And Outputs
|
## Inputs And Outputs
|
||||||
|
|
||||||
@@ -53,8 +54,9 @@ contract used by this project.
|
|||||||
|
|
||||||
The adapter records source name, endpoint, query, fetch time, source timestamps
|
The adapter records source name, endpoint, query, fetch time, source timestamps
|
||||||
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
|
||||||
source warnings. `app.FetchAndSaveBundle` can write bundle JSON atomically for
|
source warnings. Successful `data: null` responses from `/alerts/active`
|
||||||
inspection.
|
represent a checked empty active-alert list, not a missing source.
|
||||||
|
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
|
||||||
|
|
||||||
## Skip And Resume Behavior
|
## Skip And Resume Behavior
|
||||||
|
|
||||||
@@ -69,6 +71,8 @@ data is required and cannot be skipped.
|
|||||||
endpoint context.
|
endpoint context.
|
||||||
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
|
||||||
- Optional and stub sources follow missing-source policy.
|
- Optional and stub sources follow missing-source policy.
|
||||||
|
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
|
||||||
|
alert run.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,553 +0,0 @@
|
|||||||
# Code Quality And Deduplication Audit
|
|
||||||
|
|
||||||
## 1. Executive summary
|
|
||||||
|
|
||||||
Overall code quality is good. The implementation is small, direct, and aligned
|
|
||||||
with the documented architecture: CLI parsing is isolated in `internal/cli`,
|
|
||||||
configuration is centralized in `internal/config`, report definitions live in
|
|
||||||
`internal/report`, external systems sit behind adapters, and most domain logic
|
|
||||||
is covered by focused tests.
|
|
||||||
|
|
||||||
The codebase appears ready for a limited cleanup pass before the next major
|
|
||||||
release. I did not find a major architectural risk that requires a broad
|
|
||||||
rewrite.
|
|
||||||
|
|
||||||
Top three refactoring targets:
|
|
||||||
|
|
||||||
1. Centralize atomic file and JSON artifact writes. Similar write patterns now
|
|
||||||
exist in state, briefing, prompt input, Weather API bundle saving,
|
|
||||||
Scriptorium render saving, and report-copy code.
|
|
||||||
2. Move report artifact naming, grouping, and compatibility policy closer to
|
|
||||||
the report registry. Related decisions are split across `internal/report`,
|
|
||||||
`internal/state`, and `internal/app`.
|
|
||||||
3. Reduce repeated CLI/app inspection and config-loading scaffolding. The
|
|
||||||
inspect commands repeat parse/load/call/write patterns, and app inspection
|
|
||||||
repeats store and metadata lookup flows.
|
|
||||||
|
|
||||||
Recommended cleanup should be incremental and behavior-preserving. Avoid a
|
|
||||||
generic workflow engine, plugin layer, CLI redesign, or broad storage rewrite.
|
|
||||||
|
|
||||||
## 2. Repository map reviewed
|
|
||||||
|
|
||||||
Reviewed documentation and policy:
|
|
||||||
|
|
||||||
- `README.md`
|
|
||||||
- `docs/policy/architecture.md`
|
|
||||||
- `docs/policy/development.md`
|
|
||||||
- `docs/config.md`
|
|
||||||
- `docs/cli.md`
|
|
||||||
- `docs/operations.md`
|
|
||||||
- `docs/troubleshooting.md`
|
|
||||||
- `docs/internal/*`
|
|
||||||
- `docs/integrations/*`
|
|
||||||
- `docs/roadmap/*`
|
|
||||||
- `examples/config.yml`
|
|
||||||
- `examples/minimal-config.yml`
|
|
||||||
|
|
||||||
Reviewed implementation packages:
|
|
||||||
|
|
||||||
- `cmd/weatherreporter`
|
|
||||||
- `internal/cli`
|
|
||||||
- `internal/config`
|
|
||||||
- `internal/app`
|
|
||||||
- `internal/report`
|
|
||||||
- `internal/forecast`
|
|
||||||
- `internal/briefing`
|
|
||||||
- `internal/promptinput`
|
|
||||||
- `internal/changes`
|
|
||||||
- `internal/state`
|
|
||||||
- `internal/timeutil`
|
|
||||||
- `internal/adapters/weatherapi`
|
|
||||||
- `internal/adapters/scriptorium`
|
|
||||||
|
|
||||||
Reviewed major execution paths:
|
|
||||||
|
|
||||||
- `weatherreporter generate daily`
|
|
||||||
- `weatherreporter generate tomorrow`
|
|
||||||
- `weatherreporter generate three-day`
|
|
||||||
- `weatherreporter generate weekend`
|
|
||||||
- `weatherreporter generate storm`
|
|
||||||
- `weatherreporter run morning`
|
|
||||||
- `weatherreporter run evening`
|
|
||||||
- `weatherreporter inspect reports`
|
|
||||||
- `weatherreporter inspect metadata`
|
|
||||||
- `weatherreporter inspect briefing`
|
|
||||||
- `weatherreporter inspect data-package`
|
|
||||||
- `weatherreporter inspect prior`
|
|
||||||
- `weatherreporter inspect sources`
|
|
||||||
|
|
||||||
Reviewed tests:
|
|
||||||
|
|
||||||
- CLI parser and workflow tests under `internal/cli`
|
|
||||||
- config/default/example tests under `internal/config`
|
|
||||||
- app workflow tests under `internal/app`
|
|
||||||
- adapter tests under `internal/adapters/*`
|
|
||||||
- state path and prior-snapshot tests under `internal/state`
|
|
||||||
- report period tests under `internal/report`
|
|
||||||
- package-level tests for forecast, briefing, prompt input, and changes
|
|
||||||
|
|
||||||
Areas not present in this repository: `internal/stage`, `internal/modules`,
|
|
||||||
`internal/validators`, `internal/storage`, `internal/artifacts`,
|
|
||||||
`internal/manifest`, `internal/schema`, `pkg`, remote storage, object-store
|
|
||||||
keys, and persistent progress manifests.
|
|
||||||
|
|
||||||
## 3. High-confidence deduplication opportunities
|
|
||||||
|
|
||||||
### Centralize atomic file and JSON artifact writes
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/state/filesystem.go`,
|
|
||||||
`internal/briefing/package.go`, `internal/promptinput/package.go`,
|
|
||||||
`internal/adapters/weatherapi/client.go`,
|
|
||||||
`internal/adapters/scriptorium/runner.go`, `internal/app/app.go`.
|
|
||||||
- Duplicated or near-duplicated behavior: multiple functions marshal JSON,
|
|
||||||
create parent directories, create temp files in the target directory, write,
|
|
||||||
close, rename, and defer temp-file cleanup. `copyFileAtomic` repeats the
|
|
||||||
same write path for Markdown report copies. `scriptorium.SaveRenderResult`
|
|
||||||
duplicates state preflight saving and does not appear to be used by the app.
|
|
||||||
- Why it matters: atomic write behavior is part of the state and recovery
|
|
||||||
contract. A future bug fix around permissions, fsync behavior, temp-file
|
|
||||||
cleanup, Windows rename behavior, or error context would need to be applied
|
|
||||||
in several places.
|
|
||||||
- Recommended refactor: add a small internal file helper, likely under
|
|
||||||
`internal/state` if kept state-specific or a narrow `internal/fileutil`
|
|
||||||
package if used by adapters too. Provide helpers such as `WriteFileAtomic`,
|
|
||||||
`WriteJSONAtomic`, and possibly `CopyFileAtomic`. Then remove unused
|
|
||||||
duplicate save helpers or route them through the shared helper.
|
|
||||||
- Suggested tests: keep existing state, app, adapter, briefing, and prompt input
|
|
||||||
save tests. Add one focused helper test for parent-directory creation,
|
|
||||||
overwrite behavior, and temp-file cleanup on write errors if the helper is in
|
|
||||||
a new package.
|
|
||||||
- Risk level: low. This is a behavior-preserving mechanical cleanup if error
|
|
||||||
messages are kept compatible where tests assert them.
|
|
||||||
|
|
||||||
### Centralize report artifact grouping, naming, and compatibility policy
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/report`, `internal/state/filesystem.go`,
|
|
||||||
`internal/app/app.go`, state and app tests.
|
|
||||||
- Duplicated or near-duplicated behavior: `internal/report` owns report IDs,
|
|
||||||
prompt IDs, default output names, batches, and comparison strategies.
|
|
||||||
`internal/state` separately maps report IDs to artifact groups such as
|
|
||||||
`daily`, `three-day`, `weekend`, and `storm`. `internal/app` separately
|
|
||||||
converts `DefaultOutputName` underscores to hyphens for batch output copies.
|
|
||||||
`internal/state` also hardcodes compatible prior report matching instead of
|
|
||||||
asking the report catalog.
|
|
||||||
- Why it matters: adding or renaming a report requires updates in several
|
|
||||||
packages. A bug in grouping or compatibility could affect state lookup,
|
|
||||||
Recent Changes, managed paths, and batch output names.
|
|
||||||
- Recommended refactor: extend `report.Definition` or add registry helpers for
|
|
||||||
artifact group, managed output group, batch copy filename, and compatible
|
|
||||||
prior report IDs. Keep filesystem path joining in `internal/state`, but move
|
|
||||||
report identity policy out of state/app. Preserve current path strings and
|
|
||||||
output filenames.
|
|
||||||
- Suggested tests: strengthen `internal/report` tests to assert each definition
|
|
||||||
exposes group, copy filename, and compatibility policy. Keep existing
|
|
||||||
`internal/state` path tests and batch output tests as regression coverage.
|
|
||||||
- Risk level: medium-low. The behavior is user-visible through artifact paths
|
|
||||||
and `--out-dir` names, so preserve exact strings.
|
|
||||||
|
|
||||||
### Collapse repeated inspect command and app lookup flow
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/cli/root.go`, `internal/app/inspect.go`,
|
|
||||||
CLI and app inspect tests.
|
|
||||||
- Duplicated or near-duplicated behavior: each inspect subcommand repeats flag
|
|
||||||
parsing, `config.Load`, app call, error handling, and JSON writing. The app
|
|
||||||
inspect functions repeatedly create the default store, load metadata by
|
|
||||||
RunID, and then load or derive a specific result.
|
|
||||||
- Why it matters: adding another inspect view or changing config-loading/error
|
|
||||||
behavior would require edits in multiple cases. It also increases the chance
|
|
||||||
that one inspect command gains different output or error behavior.
|
|
||||||
- Recommended refactor: add a small inspect command table in `internal/cli`
|
|
||||||
mapping command name to parser and handler. In `internal/app`, add an
|
|
||||||
internal helper that returns the store, metadata, and metadata path for a
|
|
||||||
RunID, then build briefing/data-package/prior/source views from that helper.
|
|
||||||
- Suggested tests: keep the existing `TestRunInspectGeneratedArtifacts` and
|
|
||||||
missing metadata tests. Add a focused table test that each registered inspect
|
|
||||||
command accepts `--config` and rejects missing RunID where applicable.
|
|
||||||
- Risk level: low. The refactor is local and should preserve public CLI output.
|
|
||||||
|
|
||||||
### Remove or resolve unused report output config surface
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/config/config.go`,
|
|
||||||
`internal/config/load.go`, `internal/config/defaults.go`,
|
|
||||||
`internal/config/validate.go`, docs and examples if behavior changes.
|
|
||||||
- Duplicated or near-duplicated behavior: `ReportOutputConfig` contains
|
|
||||||
`OutputDir` and `Paths`, and `LoadOptions.Output` writes to
|
|
||||||
`cfg.Reports.OutputDir`. Current command behavior uses `GenerateRequest`
|
|
||||||
`OutputPath` and `BatchRequest.OutputDir`; docs correctly say `--out` and
|
|
||||||
`--out-dir` control extra copies without changing config files. The report
|
|
||||||
output config fields do not appear to drive implemented behavior.
|
|
||||||
- Why it matters: unused config fields are a maintenance hazard. They invite
|
|
||||||
future docs drift and make it unclear whether output policy belongs in config,
|
|
||||||
CLI requests, report definitions, or state.
|
|
||||||
- Recommended refactor: decide one behavior before release. Either remove the
|
|
||||||
unused config surface and the `LoadOptions.Output` mutation, or implement
|
|
||||||
`reports.output_dir`/`reports.paths` as real defaults for generated extra
|
|
||||||
copies. Given current docs and CLI behavior, removal is the lower-risk option.
|
|
||||||
- Suggested tests: config tests should assert only implemented config fields.
|
|
||||||
CLI tests should continue to cover `--out` and `--out-dir`.
|
|
||||||
- Risk level: medium. This touches config structures and may affect users if
|
|
||||||
anyone has already copied old config fields, so pair the change with clear
|
|
||||||
release notes if removed.
|
|
||||||
|
|
||||||
## 4. Medium-confidence opportunities
|
|
||||||
|
|
||||||
### Reduce Weather API source fetch boilerplate
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/adapters/weatherapi/client.go`,
|
|
||||||
weather adapter tests.
|
|
||||||
- Duplicated or near-duplicated behavior: optional source fetch methods repeat
|
|
||||||
request construction, missing handling, JSON decode, malformed-source policy,
|
|
||||||
source timestamp assignment, bundle assignment, and source recording. Hourly
|
|
||||||
is intentionally different because it is required and validates periods.
|
|
||||||
- Why it matters: adding another Weather API source will likely copy this
|
|
||||||
structure and may accidentally diverge on missing-source policy or provenance.
|
|
||||||
- Recommended refactor: introduce a small source specification/helper for
|
|
||||||
optional typed sources. Keep hourly special. Do not build a generic HTTP
|
|
||||||
ingestion framework.
|
|
||||||
- Suggested tests: keep existing endpoint/query/missing-source tests. Add one
|
|
||||||
test that optional malformed data follows the configured missing-source
|
|
||||||
policy across at least two source specs.
|
|
||||||
- Risk level: medium. The current explicit functions are readable; only refactor
|
|
||||||
if adding more source types or touching missing-source behavior.
|
|
||||||
|
|
||||||
### Share Scriptorium render/run execution scaffolding
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/adapters/scriptorium/runner.go`,
|
|
||||||
Scriptorium adapter tests.
|
|
||||||
- Duplicated or near-duplicated behavior: `Render` and `Run` both validate
|
|
||||||
common inputs, resolve binary/runner defaults, execute a command, copy
|
|
||||||
stdout/stderr/truncation/exit fields, and turn nonzero exit codes into
|
|
||||||
result-plus-error. Argument construction is already separated.
|
|
||||||
- Why it matters: future changes to capture limits, redaction, timeout handling,
|
|
||||||
or nonzero exit formatting could drift between render and run.
|
|
||||||
- Recommended refactor: introduce a private `execute` helper returning the
|
|
||||||
shared command result fields and preserving command-specific validation and
|
|
||||||
result structs.
|
|
||||||
- Suggested tests: existing render/run argv and nonzero exit tests should remain
|
|
||||||
sufficient; add a timeout/non-exit error test if one is missing.
|
|
||||||
- Risk level: low-medium. Keep the public adapter API and result JSON stable.
|
|
||||||
|
|
||||||
### Consolidate storm time parsing and validation
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/cli/root.go`, `internal/report/period.go`,
|
|
||||||
CLI and report period tests.
|
|
||||||
- Duplicated or near-duplicated behavior: CLI validates `--start`, `--end`,
|
|
||||||
parses both storm timestamps, and checks end after start. Report resolution
|
|
||||||
also validates storm start/end and has a `ParseStormPeriod` helper.
|
|
||||||
- Why it matters: error wording and accepted timestamp behavior could drift
|
|
||||||
between CLI and report-level validation.
|
|
||||||
- Recommended refactor: keep CLI-specific missing-flag errors in `internal/cli`,
|
|
||||||
but delegate parse/order validation to one helper after both values are
|
|
||||||
present.
|
|
||||||
- Suggested tests: keep CLI tests for missing flags and RFC3339 parsing; keep
|
|
||||||
report period tests for invalid bounds.
|
|
||||||
- Risk level: low.
|
|
||||||
|
|
||||||
### Add a briefing-level weather signal helper, but keep prose local
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/briefing/daily.go`,
|
|
||||||
`internal/briefing/three_day.go`, `internal/briefing/weekend.go`,
|
|
||||||
`internal/briefing/storm.go`.
|
|
||||||
- Duplicated or near-duplicated behavior: several builders aggregate ranges,
|
|
||||||
max precipitation, peak gusts, hazards, alert events, and risk labels from
|
|
||||||
daypart summaries. The report-specific prose and planning notes are
|
|
||||||
intentionally different.
|
|
||||||
- Why it matters: threshold changes for "wind", "precipitation", or hazard
|
|
||||||
labels may need multiple edits.
|
|
||||||
- Recommended refactor: add a narrow unexported helper in `internal/briefing`
|
|
||||||
for aggregating common signals from dayparts and alerts. Do not centralize
|
|
||||||
report-specific language, planning sections, or prompt-facing structure.
|
|
||||||
- Suggested tests: briefing tests should assert unchanged daily, 3-day, weekend,
|
|
||||||
and storm package shape for representative fixtures.
|
|
||||||
- Risk level: medium. This is useful only if kept small; over-consolidating
|
|
||||||
report prose would make the builders harder to read.
|
|
||||||
|
|
||||||
### Clean up legacy daily-specific wrappers after generic report support
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/app/app.go`, `internal/state/store.go`,
|
|
||||||
`internal/state/filesystem.go`, app and state tests.
|
|
||||||
- Duplicated or near-duplicated behavior: `DailyBriefingRequest`,
|
|
||||||
`DailyReportRequest`, `GenerateDailyBriefing`, `GenerateDailyReport`,
|
|
||||||
`BuildDailyBriefing`, `FindPriorDailySnapshot`, and `dailyRecentChanges`
|
|
||||||
mostly alias or forward to generic report functions.
|
|
||||||
- Why it matters: wrappers create two names for the same behavior and encourage
|
|
||||||
future code to depend on the older daily-specific path.
|
|
||||||
- Recommended refactor: remove unused wrappers or mark them as test-only
|
|
||||||
migration targets, then update tests to call the generic functions directly.
|
|
||||||
Keep any wrapper that is intentionally part of a public package contract, but
|
|
||||||
this repository uses `internal/`, so that concern is limited.
|
|
||||||
- Suggested tests: app and state tests should continue to cover daily behavior
|
|
||||||
through generic generation and prior snapshot paths.
|
|
||||||
- Risk level: low-medium. This is a small internal API cleanup, but it touches
|
|
||||||
many test call sites.
|
|
||||||
|
|
||||||
### Reduce duplicated end-to-end test setup
|
|
||||||
|
|
||||||
- Affected files/packages: `internal/cli/root_test.go`,
|
|
||||||
`internal/app/app_test.go`, adapter tests.
|
|
||||||
- Duplicated or near-duplicated behavior: tests repeatedly create temp
|
|
||||||
workspaces, write YAML config strings, start representative Weather API
|
|
||||||
servers, create fake Scriptorium scripts, and glob managed artifact paths.
|
|
||||||
- Why it matters: setup duplication makes behavior-preserving refactors noisier
|
|
||||||
and increases the chance that new tests accidentally use a subtly different
|
|
||||||
fixture.
|
|
||||||
- Recommended refactor: add small package-local helpers for config writing,
|
|
||||||
test server setup, fake Scriptorium setup, and artifact glob/assertion.
|
|
||||||
Avoid a cross-package test framework unless duplication becomes painful in
|
|
||||||
more packages.
|
|
||||||
- Suggested tests: this is test-only; existing tests should pass unchanged in
|
|
||||||
behavior.
|
|
||||||
- Risk level: low.
|
|
||||||
|
|
||||||
## 5. Boundary and responsibility concerns
|
|
||||||
|
|
||||||
The main boundary concern is that `internal/state` imports the Scriptorium
|
|
||||||
adapter package and exposes `SavePreflight(context.Context, report.Resolved,
|
|
||||||
*scriptorium.RenderResult)` through `state.Store`. State needs to persist a
|
|
||||||
preflight artifact, but it does not need to know that the artifact type comes
|
|
||||||
from an external CLI adapter. This is a small leak across the documented adapter
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
Recommended home: keep subprocess result construction in
|
|
||||||
`internal/adapters/scriptorium`; have `internal/app` convert or pass the result
|
|
||||||
to state through a state-owned preflight artifact type, a generic JSON artifact
|
|
||||||
writer, or an app-owned persistence helper. This keeps adapter result types from
|
|
||||||
becoming part of the state interface.
|
|
||||||
|
|
||||||
The second boundary concern is report policy in state/app. `internal/state`
|
|
||||||
should own filesystem layout mechanics, but report grouping and compatible
|
|
||||||
prior report selection are report catalog decisions. `internal/app` should
|
|
||||||
orchestrate batch copies, but batch copy filename policy should come from report
|
|
||||||
definitions.
|
|
||||||
|
|
||||||
The third concern is unused config surface. `internal/config` owns
|
|
||||||
`ReportOutputConfig`, but current command behavior does not consume it. Either
|
|
||||||
the app should use it explicitly or the config fields should be removed before
|
|
||||||
they become accidental public API.
|
|
||||||
|
|
||||||
## 6. Path, key, and naming construction review
|
|
||||||
|
|
||||||
Local managed artifact paths are mostly centralized in
|
|
||||||
`FilesystemStore.Paths`, which is good. That function owns the paths for
|
|
||||||
briefing snapshots, metadata, data packages, preflight output, and managed
|
|
||||||
Markdown reports.
|
|
||||||
|
|
||||||
Cleanup targets:
|
|
||||||
|
|
||||||
- `reportGroup` is private to `internal/state`, while report identity policy is
|
|
||||||
canonical in `internal/report`.
|
|
||||||
- Batch extra-copy names are built in `internal/app` by replacing underscores in
|
|
||||||
`DefaultOutputName`. This derives a public filename by convention rather than
|
|
||||||
declaring it.
|
|
||||||
- Metadata path resolution uses `metadataPathFromStored`, derived from
|
|
||||||
`BriefingPath`. This works today, but it means metadata path identity is
|
|
||||||
partially reconstructed from another artifact path instead of coming directly
|
|
||||||
from `ArtifactPaths`.
|
|
||||||
- Tests in CLI/app/state hard-code path fragments in many places. These are
|
|
||||||
useful regression checks, but after report group/name helpers exist, tests
|
|
||||||
should assert through the helper or explicitly state they are path-contract
|
|
||||||
tests.
|
|
||||||
|
|
||||||
No remote keys, cache paths, lock files, schema paths, or object-store paths are
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
## 7. Resolution and catalog review
|
|
||||||
|
|
||||||
Report resolution is mostly consistent. `internal/report` owns report
|
|
||||||
definitions, prompt IDs, valid-period resolution, batches, and comparison
|
|
||||||
strategy declarations.
|
|
||||||
|
|
||||||
Places that should be brought closer to the catalog:
|
|
||||||
|
|
||||||
- command-to-report mapping in `internal/app` (`ReportKind` to `report.ID`);
|
|
||||||
- command-to-batch mapping in `internal/app`;
|
|
||||||
- artifact group mapping in `internal/state`;
|
|
||||||
- compatible prior report matching in `internal/state`;
|
|
||||||
- batch copy filename behavior in `internal/app`;
|
|
||||||
- "generated report" eligibility in `internal/app`.
|
|
||||||
|
|
||||||
Recommendation: keep the public CLI command names in `internal/cli`/`app`, but
|
|
||||||
let the report registry expose enough metadata that app and state do not need
|
|
||||||
parallel switches over report IDs.
|
|
||||||
|
|
||||||
Prompt and profile resolution is clean. Prompt IDs are declared in
|
|
||||||
`internal/report`; Scriptorium profile/config/binary/extra args stay in config
|
|
||||||
and the Scriptorium adapter. Data package naming is consistently
|
|
||||||
`data_package=<path>`.
|
|
||||||
|
|
||||||
Weather API source resolution is explicit but somewhat repetitive. A source
|
|
||||||
catalog or source spec table could help if the number of upstream sources grows.
|
|
||||||
|
|
||||||
## 8. Config and command-loading review
|
|
||||||
|
|
||||||
Configuration loading is centralized in `internal/config`, and the documented
|
|
||||||
precedence is reflected in code: CLI overrides, config file, built-in defaults.
|
|
||||||
There is no duplicated YAML parsing or validation outside `internal/config`.
|
|
||||||
|
|
||||||
Command loading is consistent but repetitive:
|
|
||||||
|
|
||||||
- generate commands load config with `--config`, `--units`, `--tz`, and `--out`;
|
|
||||||
- run commands load config with `--config`, `--units`, and `--tz`;
|
|
||||||
- inspect commands load config with `--config` only.
|
|
||||||
|
|
||||||
The inspect difference appears intentional because inspect commands do not
|
|
||||||
fetch weather data or generate reports. The generate/run repetition is modest,
|
|
||||||
but a small helper for building `config.LoadOptions` would reduce drift if more
|
|
||||||
shared flags are added.
|
|
||||||
|
|
||||||
The likely accidental issue is `LoadOptions.Output`: it mutates
|
|
||||||
`cfg.Reports.OutputDir`, but current generation behavior separately uses
|
|
||||||
`GenerateRequest.OutputPath`, and docs say output flags do not change config.
|
|
||||||
This should be removed or given real semantics.
|
|
||||||
|
|
||||||
## 9. State, manifest, or progress handling review
|
|
||||||
|
|
||||||
The project has filesystem state but no manifest, checkpoint, resume, force, or
|
|
||||||
dry-run system.
|
|
||||||
|
|
||||||
State handling is generally consistent:
|
|
||||||
|
|
||||||
- `FilesystemStore.Paths` centralizes managed artifact paths.
|
|
||||||
- Store methods persist briefing, data package, preflight, metadata, and managed
|
|
||||||
report path preparation.
|
|
||||||
- `GenerateReport` persists metadata before returning render errors when a
|
|
||||||
render result exists.
|
|
||||||
- `RunBatchDetailed` continues independent reports and returns aggregate
|
|
||||||
results for CLI summarization.
|
|
||||||
- Inspection loads metadata by RunID and then follows metadata paths.
|
|
||||||
|
|
||||||
Cleanup targets:
|
|
||||||
|
|
||||||
- State prior-snapshot lookup uses filesystem discovery plus hardcoded report
|
|
||||||
compatibility. This is currently acceptable, but compatibility policy should
|
|
||||||
be registry-driven before adding more report types.
|
|
||||||
- `LoadMetadataByRunID` scans all reports through `ListReports`. This is simple
|
|
||||||
and fine at current scale, but it is the place to revisit if workspace size
|
|
||||||
grows.
|
|
||||||
- `SaveMetadata` reconstructs the metadata path from `BriefingPath`, while most
|
|
||||||
other artifact paths come from `Paths`. Prefer carrying the metadata path
|
|
||||||
explicitly to reduce path coupling.
|
|
||||||
|
|
||||||
No progress tracking drift exists because progress tracking is not implemented.
|
|
||||||
|
|
||||||
## 10. Refactors to avoid
|
|
||||||
|
|
||||||
Avoid these refactors for now:
|
|
||||||
|
|
||||||
- A generic workflow engine for generation stages. The current explicit
|
|
||||||
orchestration is readable and well tested.
|
|
||||||
- A plugin architecture for reports, sources, validators, or adapters. The
|
|
||||||
current registry and adapter packages are enough.
|
|
||||||
- A broad CLI redesign or Cobra migration. The standard-library CLI is adequate
|
|
||||||
and documented.
|
|
||||||
- A sweeping manifest or resume-system rewrite. There is no implemented resume
|
|
||||||
behavior to consolidate yet.
|
|
||||||
- A generic source ingestion framework for the Weather API. A small helper or
|
|
||||||
spec table is enough if new sources are added.
|
|
||||||
- Consolidating all report briefing prose. Similar thresholds can be factored,
|
|
||||||
but report-specific prompt inputs should remain readable and explicit.
|
|
||||||
- Moving all test helpers into a global test package. Prefer package-local
|
|
||||||
helpers until duplication crosses package boundaries in a way that blocks
|
|
||||||
refactoring.
|
|
||||||
- Removing path-contract tests merely because they duplicate strings. Some
|
|
||||||
hard-coded expected paths are valuable regression coverage.
|
|
||||||
|
|
||||||
## 11. Recommended implementation sequence
|
|
||||||
|
|
||||||
1. Path/key/naming helpers
|
|
||||||
- Add report registry metadata or helpers for artifact group, batch copy
|
|
||||||
filename, generated-report eligibility, and compatible prior report IDs.
|
|
||||||
- Preserve existing paths and filenames.
|
|
||||||
- Update state/app tests first or in the same commit.
|
|
||||||
|
|
||||||
2. Atomic file and JSON helpers
|
|
||||||
- Introduce a narrow atomic file helper.
|
|
||||||
- Route state, briefing, prompt input, Weather API bundle saving,
|
|
||||||
Scriptorium preflight saving, and report-copy code through it.
|
|
||||||
- Remove unused save helpers if they no longer have callers.
|
|
||||||
|
|
||||||
3. Config loading context
|
|
||||||
- Remove or implement `ReportOutputConfig` and `LoadOptions.Output`.
|
|
||||||
- Keep documented CLI output behavior stable.
|
|
||||||
- Add config regression tests around examples and CLI output flags.
|
|
||||||
|
|
||||||
4. Command preflight/shared CLI parsing
|
|
||||||
- Add small helpers for common config-load options and inspect command
|
|
||||||
dispatch.
|
|
||||||
- Keep command errors and JSON output stable.
|
|
||||||
|
|
||||||
5. Shared state lookup helpers
|
|
||||||
- Add app or state helpers for "load metadata by RunID, then load artifact".
|
|
||||||
- Keep inspect output unchanged.
|
|
||||||
|
|
||||||
6. Adapter workflow cleanup
|
|
||||||
- Factor Scriptorium shared execution internals.
|
|
||||||
- Consider a Weather API optional-source helper only if source work is
|
|
||||||
already planned.
|
|
||||||
|
|
||||||
7. Formatting/reporting cleanup
|
|
||||||
- Collapse `writeRunSummary` and `writeJSON` into one JSON writer helper.
|
|
||||||
- Leave stderr run logs as-is unless operator output requirements change.
|
|
||||||
|
|
||||||
8. Test helper and fixture cleanup
|
|
||||||
- Add package-local helpers for repeated CLI/app setup.
|
|
||||||
- Keep fixtures small and deterministic.
|
|
||||||
|
|
||||||
9. Dead-code/legacy sweep
|
|
||||||
- Remove daily-specific wrapper aliases and `FindPriorDailySnapshot` after
|
|
||||||
tests call generic paths.
|
|
||||||
- Re-run full tests and update internal docs if any package contracts change.
|
|
||||||
|
|
||||||
## 12. Test strategy
|
|
||||||
|
|
||||||
Tests to run before refactoring:
|
|
||||||
|
|
||||||
- `go test ./internal/report ./internal/state`
|
|
||||||
- `go test ./internal/app ./internal/cli`
|
|
||||||
- `go test ./internal/config`
|
|
||||||
- `go test ./internal/adapters/weatherapi ./internal/adapters/scriptorium`
|
|
||||||
|
|
||||||
Tests to add before or during cleanup:
|
|
||||||
|
|
||||||
- Report registry tests for artifact group, batch copy filename, generated
|
|
||||||
eligibility, and compatible prior report IDs.
|
|
||||||
- Atomic file helper tests for parent directory creation, overwrite behavior,
|
|
||||||
and cleanup after write failure.
|
|
||||||
- Config tests proving output flags do not mutate config, or tests defining the
|
|
||||||
implemented behavior if `reports.output_dir` is kept.
|
|
||||||
- CLI inspect table tests that cover `--config` parsing and missing RunID
|
|
||||||
validation for each run-specific inspect command.
|
|
||||||
- State tests that assert metadata path is taken from explicit artifact paths if
|
|
||||||
`metadataPathFromStored` is removed.
|
|
||||||
- Scriptorium adapter tests for shared nonzero exit and timeout handling after
|
|
||||||
render/run execution is factored.
|
|
||||||
|
|
||||||
Tests that can accompany refactors:
|
|
||||||
|
|
||||||
- Weather API optional-source helper tests if source fetch boilerplate is
|
|
||||||
factored.
|
|
||||||
- Briefing fixture tests if common signal aggregation is factored.
|
|
||||||
- Test-helper cleanup can rely on existing package tests if no production code
|
|
||||||
changes.
|
|
||||||
|
|
||||||
## 13. Appendix: findings not worth acting on
|
|
||||||
|
|
||||||
- `internal/app.BuildBriefing` uses a switch over report IDs. This is acceptable
|
|
||||||
because app orchestration needs to dispatch to report-specific builders. A
|
|
||||||
registry of builder functions could be useful later, but it is not necessary
|
|
||||||
for the current report count.
|
|
||||||
- CLI command parsing is explicit and somewhat repetitive. Do not replace it
|
|
||||||
with a framework. Small helpers are enough.
|
|
||||||
- Daily, 3-day, weekend, and storm briefing builders have similar weather
|
|
||||||
thresholds. Some signal aggregation can be shared, but report-specific
|
|
||||||
sections and wording should stay local.
|
|
||||||
- Weather API fetch methods are explicit. A helper is worthwhile only around
|
|
||||||
repeated optional-source mechanics; endpoint-specific decode and timestamp
|
|
||||||
behavior should remain clear.
|
|
||||||
- Hard-coded expected artifact path fragments in tests duplicate path strings,
|
|
||||||
but several of those tests intentionally protect the on-disk contract.
|
|
||||||
- `LoadMetadataByRunID` scans filesystem metadata. That is acceptable at the
|
|
||||||
current scale and should not be optimized without evidence of operational
|
|
||||||
pain.
|
|
||||||
- Batch stderr logs are simple text. Do not introduce a logging subsystem unless
|
|
||||||
operator requirements become more complex.
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
# Cleanup Roadmap
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the staged implementation plan for the code quality and
|
|
||||||
deduplication cleanup identified in `docs/roadmap/audit.md`.
|
|
||||||
|
|
||||||
The target audience is an LLM coding agent. Implement each stage in order.
|
|
||||||
Each stage should leave the repository buildable, tested, and behaviorally
|
|
||||||
equivalent unless the stage explicitly removes unused public-facing surface.
|
|
||||||
|
|
||||||
## Cleanup Principles
|
|
||||||
|
|
||||||
- Preserve public CLI syntax and output behavior unless a stage explicitly says
|
|
||||||
otherwise.
|
|
||||||
- Preserve current managed artifact paths, report group names, RunID naming,
|
|
||||||
and batch output copy filenames.
|
|
||||||
- Keep domain policy in `internal/report`, `internal/forecast`,
|
|
||||||
`internal/briefing`, and `internal/changes`, not in CLI or adapter packages.
|
|
||||||
- Keep external system details behind adapter boundaries.
|
|
||||||
- Prefer narrow, behavior-preserving helpers over broad framework-style
|
|
||||||
abstractions.
|
|
||||||
- Add or update focused tests in the package that owns the behavior being
|
|
||||||
cleaned up.
|
|
||||||
- Update non-roadmap documentation only after implemented behavior changes.
|
|
||||||
- Do not revert unrelated worktree changes.
|
|
||||||
|
|
||||||
## Decisions Locked
|
|
||||||
|
|
||||||
- Add a narrow `internal/fileutil` package for reusable atomic file helpers.
|
|
||||||
- Remove the unused `reports` config surface instead of implementing
|
|
||||||
config-driven output defaults.
|
|
||||||
- Move report artifact group, batch copy filename, generated-report eligibility,
|
|
||||||
and compatible-prior policy into `internal/report`.
|
|
||||||
- Keep public CLI command names and command-to-report mapping in `internal/app`
|
|
||||||
for now.
|
|
||||||
- Do not include Weather API source-spec refactoring in the main cleanup
|
|
||||||
sequence.
|
|
||||||
- Do not include broad briefing signal consolidation in the main cleanup
|
|
||||||
sequence.
|
|
||||||
- Do not introduce Cobra, a workflow engine, plugin system,
|
|
||||||
manifest/resume/progress system, logging subsystem, or global test framework.
|
|
||||||
- Treat the current unrelated deletion of `docs/roadmap/documentation.md` as
|
|
||||||
out of scope for cleanup implementation. Do not restore or further modify it
|
|
||||||
unless a later prompt explicitly asks for that.
|
|
||||||
|
|
||||||
## Stage 1: Report Catalog And Path Policy
|
|
||||||
|
|
||||||
Goal: make `internal/report` the canonical source for report identity policy.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Extend `report.Definition` with:
|
|
||||||
- `ArtifactGroup string`
|
|
||||||
- `BatchOutputName string`
|
|
||||||
- `Generated bool`
|
|
||||||
- `CompatiblePriorIDs []report.ID`
|
|
||||||
- Populate current exact values:
|
|
||||||
- Daily Today: artifact group `daily`, batch output `daily.md`, generated
|
|
||||||
true, compatible with Daily Today and Daily Tomorrow.
|
|
||||||
- Daily Tomorrow: artifact group `daily`, batch output `tomorrow.md`,
|
|
||||||
generated true, compatible with Daily Today and Daily Tomorrow.
|
|
||||||
- 3-Day: artifact group `three-day`, batch output `three-day.md`, generated
|
|
||||||
true, compatible with 3-Day.
|
|
||||||
- Weekend: artifact group `weekend`, batch output `weekend.md`, generated
|
|
||||||
true, compatible with Weekend.
|
|
||||||
- Storm: artifact group `storm`, batch output `storm.md`, generated true,
|
|
||||||
compatible with Storm.
|
|
||||||
- Add small methods or helpers in `internal/report` for compatibility checks if
|
|
||||||
direct slice checks would duplicate logic in callers.
|
|
||||||
- Update `internal/state` to use the resolved report definition's
|
|
||||||
`ArtifactGroup` instead of private `reportGroup`.
|
|
||||||
- Update prior snapshot lookup to use `CompatiblePriorIDs` instead of a
|
|
||||||
state-owned compatibility switch.
|
|
||||||
- Update `internal/app` to use `BatchOutputName` directly and remove
|
|
||||||
underscore-to-hyphen derivation.
|
|
||||||
- Update generated-report eligibility checks to use `Definition.Generated`.
|
|
||||||
- Preserve all existing managed artifact paths and batch copy filenames.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add or update report registry tests that assert each definition's
|
|
||||||
`ArtifactGroup`, `BatchOutputName`, `Generated`, and `CompatiblePriorIDs`.
|
|
||||||
- Keep state path tests as path-contract tests and preserve their expected path
|
|
||||||
strings.
|
|
||||||
- Keep app batch output tests and preserve expected filenames such as
|
|
||||||
`tomorrow.md`.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/report ./internal/state ./internal/app
|
|
||||||
```
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- No report grouping switch remains in `internal/state`.
|
|
||||||
- No batch output filename derivation by underscore replacement remains in
|
|
||||||
`internal/app`.
|
|
||||||
- Existing artifact paths and output copy names are unchanged.
|
|
||||||
|
|
||||||
## Stage 2: Atomic Artifact Writes And Adapter Boundary
|
|
||||||
|
|
||||||
Goal: centralize durable write mechanics and remove adapter type leakage from
|
|
||||||
state.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Add `internal/fileutil` with:
|
|
||||||
- `WriteFileAtomic(path string, data []byte) error`
|
|
||||||
- `WriteJSONAtomic(path string, value any) error`
|
|
||||||
- `CopyFileAtomic(source string, target string) error`
|
|
||||||
- Implement helpers with the current behavior:
|
|
||||||
- create parent directories with `0o755`;
|
|
||||||
- create temp files in the target directory;
|
|
||||||
- write, close, rename, and defer temp-file cleanup;
|
|
||||||
- preserve useful path context in errors.
|
|
||||||
- Route these through `internal/fileutil`:
|
|
||||||
- state JSON writes;
|
|
||||||
- briefing package save;
|
|
||||||
- prompt input package save;
|
|
||||||
- Weather API bundle save;
|
|
||||||
- Scriptorium render result save if the helper remains;
|
|
||||||
- generated report extra-copy writes.
|
|
||||||
- Remove duplicate private atomic write helpers once callers are migrated.
|
|
||||||
- Remove `scriptorium.SaveRenderResult` if no caller still needs it after the
|
|
||||||
refactor.
|
|
||||||
- Add `state.PreflightArtifact` with the same persisted JSON shape currently
|
|
||||||
produced from `scriptorium.RenderResult`.
|
|
||||||
- Change `state.Store.SavePreflight` to accept `state.PreflightArtifact`, not
|
|
||||||
`*scriptorium.RenderResult`.
|
|
||||||
- Convert `scriptorium.RenderResult` to `state.PreflightArtifact` in
|
|
||||||
`internal/app` immediately before saving preflight output.
|
|
||||||
- Keep subprocess result construction and interpretation in
|
|
||||||
`internal/adapters/scriptorium`.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Add `internal/fileutil` tests for parent directory creation, overwrite
|
|
||||||
behavior, and cleanup/error behavior.
|
|
||||||
- Keep state, app, adapter, briefing, and prompt input save tests.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/fileutil ./internal/state ./internal/app ./internal/briefing ./internal/promptinput ./internal/adapters/weatherapi ./internal/adapters/scriptorium
|
|
||||||
```
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Durable JSON and Markdown copy writes share one implementation.
|
|
||||||
- `internal/state` no longer imports `internal/adapters/scriptorium`.
|
|
||||||
- Persisted preflight JSON remains shape-compatible with current artifacts.
|
|
||||||
|
|
||||||
## Stage 3: Remove Unused Report Output Config
|
|
||||||
|
|
||||||
Goal: remove config fields that do not affect implemented behavior.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Remove these unused config surfaces:
|
|
||||||
- `Config.Reports`
|
|
||||||
- `ReportOutputConfig`
|
|
||||||
- `LoadOptions.Output`
|
|
||||||
- default report output config values;
|
|
||||||
- `reports.output_dir` validation;
|
|
||||||
- `reports.paths` map initialization.
|
|
||||||
- Update CLI generation config loading so `--out` remains only
|
|
||||||
`GenerateRequest.OutputPath`.
|
|
||||||
- Keep `--out` and `--out-dir` behavior unchanged.
|
|
||||||
- Update config tests and examples so they mention only implemented config
|
|
||||||
fields.
|
|
||||||
- Update documentation in the same stage if non-roadmap docs still mention the
|
|
||||||
removed `reports` config surface.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Update config tests to assert config loading no longer has output-related
|
|
||||||
config behavior.
|
|
||||||
- Keep CLI tests for `--out` and `--out-dir`.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/config ./internal/cli ./internal/app
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
```
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- No `ReportOutputConfig` or `LoadOptions.Output` symbols remain.
|
|
||||||
- Example config files load through existing config tests.
|
|
||||||
- CLI output-copy behavior remains command request behavior, not config behavior.
|
|
||||||
|
|
||||||
## Stage 4: Inspect Command And State Lookup Cleanup
|
|
||||||
|
|
||||||
Goal: remove repeated inspect scaffolding while preserving inspect output.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Add a small inspect command table in `internal/cli`.
|
|
||||||
- Keep `inspect reports` separate because it accepts `--limit` and does not
|
|
||||||
require a RunID.
|
|
||||||
- For run-specific inspect commands, centralize:
|
|
||||||
- command name;
|
|
||||||
- flag parsing;
|
|
||||||
- config loading;
|
|
||||||
- app handler invocation;
|
|
||||||
- JSON output writing.
|
|
||||||
- In `internal/app`, add an unexported helper that loads the default store and
|
|
||||||
metadata for a RunID.
|
|
||||||
- Use the app helper for metadata, briefing, data-package, prior, and sources
|
|
||||||
inspection.
|
|
||||||
- Collapse `writeRunSummary` and `writeJSON` into one JSON writer helper.
|
|
||||||
- Preserve current JSON indentation and output shapes.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Keep `TestRunInspectGeneratedArtifacts`.
|
|
||||||
- Keep missing metadata tests.
|
|
||||||
- Add a table test proving run-specific inspect commands reject missing RunID
|
|
||||||
and accept `--config`.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/cli ./internal/app ./internal/state
|
|
||||||
```
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Inspect command output is unchanged.
|
|
||||||
- Config loading remains consistent across inspect commands.
|
|
||||||
- Repeated store and metadata lookup code in app inspection paths is removed.
|
|
||||||
|
|
||||||
## Stage 5: Small Adapter And Parser Deduplication
|
|
||||||
|
|
||||||
Goal: reduce low-risk repeated validation and execution logic.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- In `internal/adapters/scriptorium`, add a private execution helper shared by
|
|
||||||
`Render` and `Run`.
|
|
||||||
- Keep command-specific request validation and result structs.
|
|
||||||
- Preserve:
|
|
||||||
- argv order;
|
|
||||||
- result JSON fields;
|
|
||||||
- stdout/stderr capture;
|
|
||||||
- truncation fields;
|
|
||||||
- timeout behavior;
|
|
||||||
- nonzero exit behavior and error text.
|
|
||||||
- In storm CLI parsing, keep CLI-specific missing `--start` and `--end` errors.
|
|
||||||
- After both storm bounds are present, delegate timestamp parsing and
|
|
||||||
end-after-start validation to `report.ParseStormPeriod`.
|
|
||||||
- Do not introduce a generic Weather API ingestion framework in this stage.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Keep or add tests for Scriptorium render/run nonzero exits.
|
|
||||||
- Keep storm tests for local timestamps, RFC3339 timestamps, missing flags, and
|
|
||||||
invalid bounds.
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/adapters/scriptorium ./internal/cli ./internal/report
|
|
||||||
```
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Scriptorium render/run behavior remains byte-for-byte compatible where tests
|
|
||||||
assert argv or output shape.
|
|
||||||
- Storm accepted timestamp formats and error behavior remain stable.
|
|
||||||
|
|
||||||
## Stage 6: Legacy Wrapper And Test Helper Cleanup
|
|
||||||
|
|
||||||
Goal: remove stale generic-vs-daily duplication and reduce noisy test setup.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Replace tests and internal callers of:
|
|
||||||
- `GenerateDailyBriefing`
|
|
||||||
- `GenerateDailyReport`
|
|
||||||
- `BuildDailyBriefing`
|
|
||||||
- `DailyBriefingRequest`
|
|
||||||
- `DailyReportRequest`
|
|
||||||
- Use generic functions and types instead:
|
|
||||||
- `GenerateBriefing`
|
|
||||||
- `GenerateReport`
|
|
||||||
- `BuildBriefing`
|
|
||||||
- `BriefingRequest`
|
|
||||||
- `ReportRequest`
|
|
||||||
- Remove `FindPriorDailySnapshot` from `state.Store` and
|
|
||||||
`FilesystemStore` after tests use `FindPriorSnapshot`.
|
|
||||||
- Remove `dailyRecentChanges` if no callers remain.
|
|
||||||
- Add package-local test helpers in `internal/cli` and `internal/app` for:
|
|
||||||
- writing test config files;
|
|
||||||
- fake Scriptorium setup;
|
|
||||||
- representative Weather API test server setup;
|
|
||||||
- artifact glob and assertion helpers.
|
|
||||||
- Do not create a cross-package test framework.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
|
|
||||||
- Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./internal/app ./internal/state ./internal/cli
|
|
||||||
go test ./internal/...
|
|
||||||
```
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Daily-specific app/state wrapper symbols listed above are gone.
|
|
||||||
- Daily behavior remains covered through generic report-generation paths.
|
|
||||||
- Test helper extraction does not reduce workflow coverage.
|
|
||||||
|
|
||||||
## Stage 7: Documentation And Final Validation
|
|
||||||
|
|
||||||
Goal: align implemented documentation after cleanup.
|
|
||||||
|
|
||||||
Implementation:
|
|
||||||
|
|
||||||
- Update non-roadmap docs only for behavior or internal contracts actually
|
|
||||||
changed by stages 1 through 6.
|
|
||||||
- Inspect and update, as needed:
|
|
||||||
- `docs/config.md`
|
|
||||||
- `docs/internal/state.md`
|
|
||||||
- `docs/internal/scriptorium-adapter.md`
|
|
||||||
- `docs/internal/report-registry.md`
|
|
||||||
- `docs/policy/development.md`
|
|
||||||
- relevant files under `docs/integrations/`
|
|
||||||
- Keep future or deferred cleanup ideas only under `docs/roadmap/`.
|
|
||||||
- Do not document deferred Weather API source-spec or briefing signal refactors
|
|
||||||
as implemented.
|
|
||||||
|
|
||||||
Validation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Run stale-symbol searches:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg -n "ReportOutputConfig|LoadOptions\\.Output|FindPriorDailySnapshot|GenerateDailyReport|DailyReportRequest|SaveRenderResult" .
|
|
||||||
```
|
|
||||||
|
|
||||||
Review any matches manually. Matches under roadmap files may be acceptable
|
|
||||||
because they describe planned or completed cleanup work.
|
|
||||||
|
|
||||||
Acceptance criteria:
|
|
||||||
|
|
||||||
- Non-roadmap docs describe only implemented behavior.
|
|
||||||
- Config examples still load through config tests.
|
|
||||||
- CLI help remains accurate.
|
|
||||||
- No removed production symbols remain outside tests or roadmap references.
|
|
||||||
|
|
||||||
## Deferred Refactors
|
|
||||||
|
|
||||||
Do not include these in the main cleanup sequence:
|
|
||||||
|
|
||||||
- Weather API optional-source spec/helper refactor.
|
|
||||||
- Broad briefing weather-signal consolidation.
|
|
||||||
- Generic workflow engine.
|
|
||||||
- Plugin architecture.
|
|
||||||
- Cobra migration.
|
|
||||||
- Manifest/resume/progress system.
|
|
||||||
- Global test helper package.
|
|
||||||
- Logging subsystem.
|
|
||||||
|
|
||||||
These can be revisited only when new source types, report types, or operational
|
|
||||||
requirements make the duplication materially more expensive.
|
|
||||||
|
|
||||||
## Global Validation Checklist
|
|
||||||
|
|
||||||
Run focused tests after each stage, then run full validation after Stage 7.
|
|
||||||
|
|
||||||
Required final checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go test ./...
|
|
||||||
go run ./cmd/weatherreporter --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Required manual checks:
|
|
||||||
|
|
||||||
- Public CLI syntax remains stable.
|
|
||||||
- Managed artifact paths remain stable.
|
|
||||||
- Batch output copy filenames remain stable.
|
|
||||||
- `scriptorium` argv construction remains stable.
|
|
||||||
- Weather API request query behavior remains stable.
|
|
||||||
- Removed config fields are also removed from current-behavior docs and
|
|
||||||
examples.
|
|
||||||
- Roadmap files are the only docs that describe deferred cleanup work.
|
|
||||||
- No unrelated worktree changes are reverted.
|
|
||||||
@@ -49,3 +49,38 @@ These ideas are not current behavior:
|
|||||||
|
|
||||||
Each item needs its own design note before implementation. Non-roadmap docs
|
Each item needs its own design note before implementation. Non-roadmap docs
|
||||||
must not describe these as available behavior.
|
must not describe these as available behavior.
|
||||||
|
|
||||||
|
## Deferred: Cleanup Refactors
|
||||||
|
|
||||||
|
The initial cleanup pass intentionally left these refactors out because the
|
||||||
|
current implementation does not yet make them worth the added abstraction.
|
||||||
|
|
||||||
|
Revisit these only when new source types, report types, operational
|
||||||
|
requirements, or recurring maintenance costs make the duplication materially
|
||||||
|
more expensive:
|
||||||
|
|
||||||
|
- Weather API optional-source specification/helper refactor: consider when
|
||||||
|
additional Weather API sources make per-source fan-out, policy handling, and
|
||||||
|
provenance wiring repetitive enough to obscure adapter behavior.
|
||||||
|
- Broad briefing weather-signal consolidation: consider when multiple briefing
|
||||||
|
builders repeatedly derive the same weather signals and tests begin to need
|
||||||
|
coordinated fixture updates.
|
||||||
|
- Generic workflow engine: defer unless generation, inspection, recovery, or
|
||||||
|
future background workflows gain enough shared step semantics to justify a
|
||||||
|
declared execution model.
|
||||||
|
- Plugin architecture: defer until there is a concrete external extension
|
||||||
|
contract and at least one implemented extension point.
|
||||||
|
- Cobra migration: defer while the standard-library CLI remains small,
|
||||||
|
explicit, and covered by parser tests.
|
||||||
|
- Manifest, resume, or progress system: defer until operators need resumable
|
||||||
|
runs, checkpoint recovery, or richer audit trails than the current durable
|
||||||
|
artifacts and metadata provide.
|
||||||
|
- Global test helper package: defer while package-local helpers keep tests
|
||||||
|
clear; revisit only if setup duplication starts to hide behavior.
|
||||||
|
- Logging subsystem: defer until there are recurring operator diagnostics that
|
||||||
|
cannot be handled with current errors, metadata, inspection commands, and
|
||||||
|
artifact output.
|
||||||
|
|
||||||
|
Any future implementation should preserve the existing public CLI, artifact
|
||||||
|
paths, report identities, and adapter boundaries unless a separate roadmap
|
||||||
|
explicitly changes them.
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
weather_api:
|
weather_api:
|
||||||
base_url: https://weather.api.example.com/
|
base_url: https://weather.api.rakestrawhome.com/
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
precision: 1
|
precision: 1
|
||||||
units: us
|
units: us
|
||||||
timezone: Chicago
|
timezone: "America/Chicago"
|
||||||
format: json
|
format: json
|
||||||
|
|
||||||
|
location:
|
||||||
|
id: home
|
||||||
|
name: Brentwood
|
||||||
|
region: St. Louis Metro
|
||||||
|
|
||||||
missing_source:
|
missing_source:
|
||||||
default: warn
|
default: warn
|
||||||
sources:
|
sources:
|
||||||
@@ -28,12 +33,15 @@ dayparts:
|
|||||||
end: "06:00"
|
end: "06:00"
|
||||||
- name: morning
|
- name: morning
|
||||||
start: "06:00"
|
start: "06:00"
|
||||||
end: "12:00"
|
end: "10:00"
|
||||||
|
- name: midday
|
||||||
|
start: "10:00"
|
||||||
|
end: "15:00"
|
||||||
- name: afternoon
|
- name: afternoon
|
||||||
start: "12:00"
|
start: "15:00"
|
||||||
end: "18:00"
|
end: "17:00"
|
||||||
- name: evening
|
- name: evening
|
||||||
start: "18:00"
|
start: "17:00"
|
||||||
end: "24:00"
|
end: "24:00"
|
||||||
|
|
||||||
recent_change:
|
recent_change:
|
||||||
|
|||||||
@@ -202,13 +202,18 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
|
||||||
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{})
|
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if raw == nil {
|
if raw == nil {
|
||||||
return b.handleMissing(&source, "active alerts data is missing", false)
|
return b.handleMissing(&source, "active alerts data is missing", false)
|
||||||
}
|
}
|
||||||
|
if isJSONNull(raw) {
|
||||||
|
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
|
||||||
|
b.addSource(source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var alerts forecast.AlertRun
|
var alerts forecast.AlertRun
|
||||||
if err := decodeSource(raw, &alerts); err != nil {
|
if err := decodeSource(raw, &alerts); err != nil {
|
||||||
return b.handleMalformed(&source, err, false)
|
return b.handleMalformed(&source, err, false)
|
||||||
@@ -301,6 +306,7 @@ func (c *Client) policyFor(source string) config.MissingSourcePolicy {
|
|||||||
type queryOptions struct {
|
type queryOptions struct {
|
||||||
precision bool
|
precision bool
|
||||||
timezone bool
|
timezone bool
|
||||||
|
allowNull bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
@@ -339,7 +345,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
Query: queryMap(reqURL.Query()),
|
Query: queryMap(reqURL.Query()),
|
||||||
FetchedAt: c.now(),
|
FetchedAt: c.now(),
|
||||||
}
|
}
|
||||||
if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) {
|
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
|
||||||
source.Missing = true
|
source.Missing = true
|
||||||
return nil, source, nil
|
return nil, source, nil
|
||||||
}
|
}
|
||||||
@@ -351,6 +357,10 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
|
|||||||
return env.Data, source, nil
|
return env.Data, source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isJSONNull(raw json.RawMessage) bool {
|
||||||
|
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
|
||||||
reqURL := *c.baseURL
|
reqURL := *c.baseURL
|
||||||
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ func TestFetchBundleFromFixtures(t *testing.T) {
|
|||||||
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 {
|
||||||
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion)
|
||||||
}
|
}
|
||||||
|
if bundle.Discussion.ShortTerm == nil || bundle.Discussion.ShortTerm.Text != "A weak boundary may trigger isolated showers." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %#v, want short-term AFD text", bundle.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
|
||||||
|
}
|
||||||
if len(bundle.Sources) != 8 {
|
if len(bundle.Sources) != 8 {
|
||||||
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
|
||||||
}
|
}
|
||||||
@@ -72,7 +78,7 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
|
|||||||
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
t.Fatalf("request %q missing format=json or units=us", rawURL)
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rawURL, "/forecast/") {
|
if strings.HasPrefix(rawURL, "/forecast/") {
|
||||||
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") {
|
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
|
||||||
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
t.Fatalf("forecast request %q missing precision or tz", rawURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +131,36 @@ func TestRequiredHourlyForecast(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
|
||||||
|
server := fixtureServer(t, map[string]handlerOverride{
|
||||||
|
"/alerts/active": {status: http.StatusOK, body: `{"data": null}`},
|
||||||
|
}, nil)
|
||||||
|
client := newTestClient(t, server.URL+"/", nil)
|
||||||
|
|
||||||
|
bundle, err := client.FetchBundle(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBundle() error = %v", err)
|
||||||
|
}
|
||||||
|
if bundle.Alerts == nil {
|
||||||
|
t.Fatal("Alerts = nil, want checked empty alert run")
|
||||||
|
}
|
||||||
|
if len(bundle.Alerts.Alerts) != 0 {
|
||||||
|
t.Fatalf("Alerts length = %d, want no active alerts", len(bundle.Alerts.Alerts))
|
||||||
|
}
|
||||||
|
source := sourceByName(t, bundle.Sources, "alerts")
|
||||||
|
if source.Missing {
|
||||||
|
t.Fatalf("alerts source Missing = true, want false")
|
||||||
|
}
|
||||||
|
if source.DataSHA256 == "" {
|
||||||
|
t.Fatal("alerts DataSHA256 is empty, want hash for explicit null payload")
|
||||||
|
}
|
||||||
|
for _, warning := range bundle.Warnings {
|
||||||
|
if warning.Source == "alerts" {
|
||||||
|
t.Fatalf("warnings = %#v, want no alerts warning", bundle.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
"Warmer temperatures this weekend."
|
"Warmer temperatures this weekend."
|
||||||
],
|
],
|
||||||
"shortTerm": {
|
"shortTerm": {
|
||||||
"title": "Short Term",
|
"qualifier": "(Through This Evening)",
|
||||||
"narrative": "A weak boundary may trigger isolated showers."
|
"text": "A weak boundary may trigger isolated showers."
|
||||||
|
},
|
||||||
|
"longTerm": {
|
||||||
|
"qualifier": "(This Weekend)",
|
||||||
|
"text": "Warmer temperatures and periodic rain chances continue into the weekend."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summary)
|
}, summary)
|
||||||
case report.ThreeDay, report.Weekend:
|
case report.ThreeDay, report.Weekend:
|
||||||
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
summaries, err := forecast.BuildPeriodDailySummaries(bundle, req.Resolved.ValidPeriod, location, dayparts)
|
||||||
@@ -534,6 +535,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summaries)
|
}, summaries)
|
||||||
}
|
}
|
||||||
return briefing.BuildThreeDay(briefing.BuildContext{
|
return briefing.BuildThreeDay(briefing.BuildContext{
|
||||||
@@ -541,6 +543,7 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
}, summaries)
|
}, summaries)
|
||||||
case report.Storm:
|
case report.Storm:
|
||||||
return briefing.BuildStorm(briefing.BuildContext{
|
return briefing.BuildStorm(briefing.BuildContext{
|
||||||
@@ -548,12 +551,26 @@ func BuildBriefing(req BriefingRequest, bundle *forecast.Bundle) (briefing.Packa
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: req.Config.WeatherAPI.Units,
|
Units: req.Config.WeatherAPI.Units,
|
||||||
Timezone: req.Config.WeatherAPI.Timezone,
|
Timezone: req.Config.WeatherAPI.Timezone,
|
||||||
|
Location: briefingLocation(req.Config),
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
return briefing.Package{}, fmt.Errorf("briefing is not implemented for report %q", req.Resolved.Definition.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func briefingLocation(cfg config.Config) *briefing.LocationContext {
|
||||||
|
location := briefing.LocationContext{
|
||||||
|
ID: cfg.Location.ID,
|
||||||
|
Name: cfg.Location.Name,
|
||||||
|
Region: cfg.Location.Region,
|
||||||
|
Timezone: cfg.WeatherAPI.Timezone,
|
||||||
|
}
|
||||||
|
if location.ID == "" && location.Name == "" && location.Region == "" && location.Timezone == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &location
|
||||||
|
}
|
||||||
|
|
||||||
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
func defaultStore(cfg config.Config) (*state.FilesystemStore, error) {
|
||||||
return state.NewFilesystemStore(cfg.Workspace)
|
return state.NewFilesystemStore(cfg.Workspace)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -33,7 +34,7 @@ func TestFetchAndSaveBundle(t *testing.T) {
|
|||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -195,6 +196,29 @@ func TestGenerateReportWritesReportAndPreflight(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
if !strings.Contains(string(data), `"recentChanges"`) || !strings.Contains(string(data), `data_package.v1`) {
|
||||||
t.Fatalf("data package missing expected content:\n%s", string(data))
|
t.Fatalf("data package missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
var savedDataPackage struct {
|
||||||
|
Report struct {
|
||||||
|
CurrentLocalDate string `json:"currentLocalDate"`
|
||||||
|
} `json:"report"`
|
||||||
|
Briefing briefing.Package `json:"briefing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &savedDataPackage); err != nil {
|
||||||
|
t.Fatalf("decode data package: %v", err)
|
||||||
|
}
|
||||||
|
if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
|
location := savedDataPackage.Briefing.Metadata.Location
|
||||||
|
if location == nil || location.ID != "home" || location.Name != "Brentwood" || location.Region != "St. Louis Metro" || location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("data package location = %#v, want configured prompt location", location)
|
||||||
|
}
|
||||||
|
current := savedDataPackage.Briefing.CurrentConditions
|
||||||
|
if current == nil || current.ConditionText != "Clear" || current.TemperatureF == nil || *current.TemperatureF != 75 {
|
||||||
|
t.Fatalf("data package current conditions = %#v, want current conditions", current)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "Short-term AFD narrative for generated report.") || !strings.Contains(string(data), "Long-term AFD narrative for generated report.") {
|
||||||
|
t.Fatalf("data package missing AFD short/long-term discussion:\n%s", string(data))
|
||||||
|
}
|
||||||
preflight, err := os.ReadFile(result.PreflightPath)
|
preflight, err := os.ReadFile(result.PreflightPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read preflight: %v", err)
|
t.Fatalf("read preflight: %v", err)
|
||||||
@@ -358,6 +382,7 @@ func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -472,6 +497,7 @@ func TestTomorrowReportCanCompareAgainstPriorDailySnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -538,6 +564,7 @@ func TestGenerateThreeDayReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -613,6 +640,7 @@ func TestGenerateWeekendReportWritesReportAndRecentChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
_, err = store.SaveMetadata(context.Background(), state.BuildMetadata(priorResolved, priorBriefing, state.ArtifactPaths{
|
||||||
Briefing: priorBriefingPath,
|
Briefing: priorBriefingPath,
|
||||||
|
Metadata: priorPaths.Metadata,
|
||||||
DataPackage: priorPaths.DataPackage,
|
DataPackage: priorPaths.DataPackage,
|
||||||
Preflight: priorPaths.Preflight,
|
Preflight: priorPaths.Preflight,
|
||||||
RenderedReport: priorPaths.RenderedReport,
|
RenderedReport: priorPaths.RenderedReport,
|
||||||
@@ -869,7 +897,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/observations":
|
case "/observations":
|
||||||
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
_, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`))
|
||||||
case "/conditions/current":
|
case "/conditions/current":
|
||||||
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`))
|
_, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`))
|
||||||
case "/forecast/hourly":
|
case "/forecast/hourly":
|
||||||
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"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}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`))
|
||||||
case "/forecast/narrative":
|
case "/forecast/narrative":
|
||||||
@@ -877,7 +905,7 @@ func dailyBundleServer(t *testing.T) *httptest.Server {
|
|||||||
case "/alerts/active":
|
case "/alerts/active":
|
||||||
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
_, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`))
|
||||||
case "/discussion":
|
case "/discussion":
|
||||||
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`))
|
_, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,19 +68,18 @@ func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, erro
|
|||||||
if summary == nil {
|
if summary == nil {
|
||||||
return Package{}, fmt.Errorf("daily forecast summary is required")
|
return Package{}, fmt.Errorf("daily forecast summary is required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Daily = &Daily{
|
||||||
Daily: &Daily{
|
BottomLine: buildBottomLine(summary),
|
||||||
BottomLine: buildBottomLine(summary),
|
Dayparts: summary.Dayparts,
|
||||||
Dayparts: summary.Dayparts,
|
RelevantAlerts: summary.AlertOverlaps,
|
||||||
RelevantAlerts: summary.AlertOverlaps,
|
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
||||||
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
|
NarrativePeriods: summary.NarrativePeriods,
|
||||||
NarrativePeriods: summary.NarrativePeriods,
|
Discussion: buildDiscussion(summary.Discussion),
|
||||||
Discussion: buildDiscussion(summary.Discussion),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
ForecastSummaryDate: summary.Date,
|
||||||
ForecastSummaryDate: summary.Date,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
|
||||||
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
|
||||||
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
pkg.Daily.Planning = buildTomorrowPlanning(summary)
|
||||||
}
|
}
|
||||||
@@ -176,9 +175,6 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("Morning gusts may reach %.0f mph.", daypart.PeakWindGust.Value))
|
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 {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
notes = append(notes, "Morning wintry weather could affect surfaces and travel.")
|
||||||
}
|
}
|
||||||
@@ -203,9 +199,6 @@ func concernNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
|
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 {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, prefix+" wintry weather may affect travel.")
|
notes = append(notes, prefix+" wintry weather may affect travel.")
|
||||||
}
|
}
|
||||||
@@ -229,9 +222,6 @@ func overnightWatchNotes(daypart forecast.DaypartSummary) []string {
|
|||||||
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
|
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))
|
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 {
|
if daypart.Indicators.Snow || daypart.Indicators.Ice {
|
||||||
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
notes = append(notes, "Overnight wintry weather could leave morning travel impacts.")
|
||||||
}
|
}
|
||||||
@@ -259,10 +249,10 @@ func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
|
|||||||
KeyMessages: discussion.KeyMessages,
|
KeyMessages: discussion.KeyMessages,
|
||||||
}
|
}
|
||||||
if discussion.ShortTerm != nil {
|
if discussion.ShortTerm != nil {
|
||||||
ctx.ShortTerm = discussion.ShortTerm.Narrative
|
ctx.ShortTerm = discussion.ShortTerm.Text
|
||||||
}
|
}
|
||||||
if discussion.LongTerm != nil {
|
if discussion.LongTerm != nil {
|
||||||
ctx.LongTerm = discussion.LongTerm.Narrative
|
ctx.LongTerm = discussion.LongTerm.Text
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -293,10 +283,6 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
|
|||||||
score += float64(len(daypart.AlertOverlaps)) * 100
|
score += float64(len(daypart.AlertOverlaps)) * 100
|
||||||
reasons = append(reasons, "alert overlap")
|
reasons = append(reasons, "alert overlap")
|
||||||
}
|
}
|
||||||
if daypart.Indicators.Thunder {
|
|
||||||
score += 75
|
|
||||||
reasons = append(reasons, "thunder risk")
|
|
||||||
}
|
|
||||||
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
if daypart.Indicators.Heat || daypart.Indicators.Cold {
|
||||||
score += 25
|
score += 25
|
||||||
if daypart.Indicators.Heat {
|
if daypart.Indicators.Heat {
|
||||||
@@ -334,9 +320,6 @@ func bottomLineText(conditions []string, hazards []string) string {
|
|||||||
|
|
||||||
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
func hazardsForIndicators(indicators forecast.Indicators) []string {
|
||||||
var hazards []string
|
var hazards []string
|
||||||
if indicators.Thunder {
|
|
||||||
hazards = append(hazards, "thunder")
|
|
||||||
}
|
|
||||||
if indicators.Snow {
|
if indicators.Snow {
|
||||||
hazards = append(hazards, "snow")
|
hazards = append(hazards, "snow")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ import (
|
|||||||
|
|
||||||
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
||||||
bundle := loadBundleFixture(t)
|
bundle := loadBundleFixture(t)
|
||||||
|
currentIsDay := true
|
||||||
|
currentTemp := 75.9
|
||||||
|
currentFeelsLike := 76.1
|
||||||
|
currentHumidity := 56.0
|
||||||
|
currentWind := 10.7
|
||||||
|
bundle.Current = &forecast.Current{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
IsDay: ¤tIsDay,
|
||||||
|
TemperatureF: ¤tTemp,
|
||||||
|
ApparentTemperatureF: ¤tFeelsLike,
|
||||||
|
RelativeHumidityPercent: ¤tHumidity,
|
||||||
|
WindSpeedMph: ¤tWind,
|
||||||
|
}
|
||||||
bundle.Sources[0].DataSHA256 = "abc123"
|
bundle.Sources[0].DataSHA256 = "abc123"
|
||||||
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
|
||||||
location := mustLocation(t)
|
location := mustLocation(t)
|
||||||
@@ -29,6 +42,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
Bundle: bundle,
|
Bundle: bundle,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
|
Location: &LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
}, summary)
|
}, summary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildDaily() error = %v", err)
|
t.Fatalf("BuildDaily() error = %v", err)
|
||||||
@@ -46,6 +65,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
|
||||||
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
|
||||||
}
|
}
|
||||||
|
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
|
||||||
|
}
|
||||||
|
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
|
||||||
|
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
|
||||||
|
}
|
||||||
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
|
||||||
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
|
||||||
}
|
}
|
||||||
@@ -55,8 +80,8 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if pkg.Daily == nil {
|
if pkg.Daily == nil {
|
||||||
t.Fatal("Daily = nil")
|
t.Fatal("Daily = nil")
|
||||||
}
|
}
|
||||||
if len(pkg.Daily.Dayparts) != 4 {
|
if len(pkg.Daily.Dayparts) != 5 {
|
||||||
t.Fatalf("Dayparts length = %d, want 4", len(pkg.Daily.Dayparts))
|
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
|
||||||
}
|
}
|
||||||
if len(pkg.Daily.RelevantAlerts) != 1 {
|
if len(pkg.Daily.RelevantAlerts) != 1 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
|
||||||
@@ -67,6 +92,12 @@ func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
|
|||||||
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
|
||||||
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
|
||||||
}
|
}
|
||||||
|
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
|
||||||
|
}
|
||||||
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
|
||||||
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
|
||||||
}
|
}
|
||||||
@@ -85,7 +116,13 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
|||||||
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
|
||||||
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
|
||||||
}},
|
}},
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
Alerts: &forecast.AlertRun{},
|
||||||
|
Sources: []forecast.Source{
|
||||||
|
{Name: "hourly", FetchedAt: time.Now()},
|
||||||
|
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
|
||||||
|
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
|
||||||
|
},
|
||||||
|
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
|
||||||
}
|
}
|
||||||
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -98,9 +135,28 @@ func TestDailyBriefingQuietWeather(t *testing.T) {
|
|||||||
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
|
||||||
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
|
||||||
}
|
}
|
||||||
|
if pkg.CurrentConditions != nil {
|
||||||
|
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
|
||||||
|
}
|
||||||
|
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
|
||||||
|
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
|
||||||
|
}
|
||||||
if len(pkg.Daily.RelevantAlerts) != 0 {
|
if len(pkg.Daily.RelevantAlerts) != 0 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
|
||||||
}
|
}
|
||||||
|
if pkg.Metadata.Alerts == nil {
|
||||||
|
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
|
||||||
|
}
|
||||||
|
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
|
||||||
|
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(pkg.Metadata.Alerts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal alert metadata: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), `"missing"`) {
|
||||||
|
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
func TestDailyBriefingAlertExclusion(t *testing.T) {
|
||||||
@@ -163,7 +219,7 @@ func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
|
|||||||
Value: wind,
|
Value: wind,
|
||||||
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
Time: mustParse("2026-05-30T09:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true},
|
Indicators: forecast.Indicators{Snow: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -237,9 +293,10 @@ func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
|
|||||||
func defaultDayparts() []forecast.DaypartDefinition {
|
func defaultDayparts() []forecast.DaypartDefinition {
|
||||||
return []forecast.DaypartDefinition{
|
return []forecast.DaypartDefinition{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||||
|
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import (
|
|||||||
const SchemaVersion = "weatherreporter.briefing.v1"
|
const SchemaVersion = "weatherreporter.briefing.v1"
|
||||||
|
|
||||||
type Package struct {
|
type Package struct {
|
||||||
Metadata Metadata `json:"metadata"`
|
Metadata Metadata `json:"metadata"`
|
||||||
Daily *Daily `json:"daily,omitempty"`
|
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
|
||||||
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
Daily *Daily `json:"daily,omitempty"`
|
||||||
Weekend *Weekend `json:"weekend,omitempty"`
|
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
|
||||||
Storm *Storm `json:"storm,omitempty"`
|
Weekend *Weekend `json:"weekend,omitempty"`
|
||||||
|
Storm *Storm `json:"storm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
@@ -31,10 +32,34 @@ type Metadata struct {
|
|||||||
Units string `json:"units"`
|
Units string `json:"units"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
Location *LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []SourceMetadata `json:"sources,omitempty"`
|
Sources []SourceMetadata `json:"sources,omitempty"`
|
||||||
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
|
||||||
|
Alerts *AlertStatus `json:"alerts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocationContext struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrentConditionsContext struct {
|
||||||
|
ConditionText string `json:"conditionText,omitempty"`
|
||||||
|
IsDay *bool `json:"isDay,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"`
|
||||||
|
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
|
||||||
|
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
|
||||||
|
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
|
||||||
|
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceMetadata struct {
|
type SourceMetadata struct {
|
||||||
@@ -48,11 +73,19 @@ type SourceMetadata struct {
|
|||||||
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlertStatus struct {
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
ActiveCount int `json:"activeCount"`
|
||||||
|
RelevantCount int `json:"relevantCount"`
|
||||||
|
Missing bool `json:"missing,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type BuildContext struct {
|
type BuildContext struct {
|
||||||
Resolved report.Resolved
|
Resolved report.Resolved
|
||||||
Bundle *forecast.Bundle
|
Bundle *forecast.Bundle
|
||||||
Units string
|
Units string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
Location *LocationContext
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildMetadata(ctx BuildContext) Metadata {
|
func BuildMetadata(ctx BuildContext) Metadata {
|
||||||
@@ -68,13 +101,82 @@ func BuildMetadata(ctx BuildContext) Metadata {
|
|||||||
Units: ctx.Units,
|
Units: ctx.Units,
|
||||||
Timezone: ctx.Timezone,
|
Timezone: ctx.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(ctx.Location),
|
||||||
SourceLocationID: sourceLocationID,
|
SourceLocationID: sourceLocationID,
|
||||||
SourceLocation: sourceLocation,
|
SourceLocation: sourceLocation,
|
||||||
Sources: sourceMetadata(ctx.Bundle),
|
Sources: sourceMetadata(ctx.Bundle),
|
||||||
SourceWarnings: sourceWarnings(ctx.Bundle),
|
SourceWarnings: sourceWarnings(ctx.Bundle),
|
||||||
|
Alerts: alertStatus(ctx.Bundle),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildPackage(ctx BuildContext) Package {
|
||||||
|
return Package{
|
||||||
|
Metadata: BuildMetadata(ctx),
|
||||||
|
CurrentConditions: currentConditions(ctx.Bundle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyLocation(location *LocationContext) *LocationContext {
|
||||||
|
if location == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *location
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
|
||||||
|
if bundle == nil || bundle.Current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
current := bundle.Current
|
||||||
|
context := CurrentConditionsContext{
|
||||||
|
ConditionText: current.ConditionText,
|
||||||
|
IsDay: copyBool(current.IsDay),
|
||||||
|
TemperatureC: copyFloat(current.TemperatureC),
|
||||||
|
TemperatureF: copyFloat(current.TemperatureF),
|
||||||
|
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
|
||||||
|
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
|
||||||
|
DewpointC: copyFloat(current.DewpointC),
|
||||||
|
DewpointF: copyFloat(current.DewpointF),
|
||||||
|
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
|
||||||
|
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
|
||||||
|
WindSpeedMph: copyFloat(current.WindSpeedMph),
|
||||||
|
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
|
||||||
|
}
|
||||||
|
if context.ConditionText == "" &&
|
||||||
|
context.IsDay == nil &&
|
||||||
|
context.TemperatureC == nil &&
|
||||||
|
context.TemperatureF == nil &&
|
||||||
|
context.ApparentTemperatureC == nil &&
|
||||||
|
context.ApparentTemperatureF == nil &&
|
||||||
|
context.DewpointC == nil &&
|
||||||
|
context.DewpointF == nil &&
|
||||||
|
context.RelativeHumidityPercent == nil &&
|
||||||
|
context.WindSpeedKmh == nil &&
|
||||||
|
context.WindSpeedMph == nil &&
|
||||||
|
context.WindDirectionDegrees == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &context
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBool(value *bool) *bool {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFloat(value *float64) *float64 {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *value
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|
||||||
func Save(path string, pkg Package) error {
|
func Save(path string, pkg Package) error {
|
||||||
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
|
||||||
return fmt.Errorf("save briefing package: %w", err)
|
return fmt.Errorf("save briefing package: %w", err)
|
||||||
@@ -124,6 +226,37 @@ func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
|
|||||||
return bundle.Warnings
|
return bundle.Warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
|
||||||
|
if bundle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
status := &AlertStatus{}
|
||||||
|
if bundle.Alerts != nil {
|
||||||
|
status.Checked = true
|
||||||
|
status.ActiveCount = len(bundle.Alerts.Alerts)
|
||||||
|
}
|
||||||
|
for _, source := range bundle.Sources {
|
||||||
|
if source.Name == "alerts" && source.Missing {
|
||||||
|
status.Missing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !status.Checked && !status.Missing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRelevantAlertCount(metadata *Metadata, count int) {
|
||||||
|
if metadata.Alerts == nil {
|
||||||
|
if count == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata.Alerts = &AlertStatus{}
|
||||||
|
}
|
||||||
|
metadata.Alerts.RelevantCount = count
|
||||||
|
}
|
||||||
|
|
||||||
func variantForReport(id report.ID) string {
|
func variantForReport(id report.ID) string {
|
||||||
switch id {
|
switch id {
|
||||||
case report.DailyToday:
|
case report.DailyToday:
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ func BuildStorm(ctx BuildContext) (Package, error) {
|
|||||||
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
Discussion: buildDiscussion(ctx.Bundle.Discussion),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
}
|
}
|
||||||
return Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Storm = storm
|
||||||
Storm: storm,
|
setRelevantAlertCount(&pkg.Metadata, len(alerts))
|
||||||
}, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
|
||||||
@@ -139,9 +139,6 @@ func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.Daypar
|
|||||||
items = appendUnique(items, "Alert scenario to consider: "+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 {
|
if summary.Indicators.Wind {
|
||||||
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
|
||||||
}
|
}
|
||||||
@@ -161,7 +158,7 @@ func stormConfidenceInputs(bundle *forecast.Bundle) []string {
|
|||||||
}
|
}
|
||||||
if bundle.Discussion != nil {
|
if bundle.Discussion != nil {
|
||||||
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
items = appendUnique(items, bundle.Discussion.KeyMessages...)
|
||||||
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Narrative != "" {
|
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
|
||||||
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
items = appendUnique(items, "Short-term discussion is available for confidence context.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
|
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"}`),
|
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."}},
|
Discussion: &forecast.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Storms may intensify quickly."},
|
||||||
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
|
||||||
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
|
||||||
|
},
|
||||||
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
|
||||||
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
|
||||||
}
|
}
|
||||||
@@ -75,6 +80,12 @@ func TestStormBriefingWithActiveAlert(t *testing.T) {
|
|||||||
if pkg.Storm.WeatherStory == nil {
|
if pkg.Storm.WeatherStory == nil {
|
||||||
t.Fatal("WeatherStory = nil, want available story context")
|
t.Fatal("WeatherStory = nil, want available story context")
|
||||||
}
|
}
|
||||||
|
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
|
||||||
|
}
|
||||||
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
if len(pkg.Storm.WhatToWatchNext) == 0 {
|
||||||
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,18 +37,17 @@ func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package
|
|||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
return Package{}, fmt.Errorf("3-day forecast summaries are required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.ThreeDay = &ThreeDay{
|
||||||
ThreeDay: &ThreeDay{
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
day := buildOutlookDay(summary)
|
day := buildOutlookDay(summary)
|
||||||
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
|
||||||
}
|
}
|
||||||
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|||||||
Value: gust,
|
Value: gust,
|
||||||
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
Time: mustParse("2026-05-29T10:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
Indicators: forecast.Indicators{Wind: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Unsettled stretch."}},
|
Discussion: &forecast.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Unsettled stretch."},
|
||||||
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
|
||||||
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Date: "2026-05-30",
|
Date: "2026-05-30",
|
||||||
@@ -74,10 +79,16 @@ func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
|
|||||||
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
|
||||||
}
|
}
|
||||||
first := pkg.ThreeDay.Days[0]
|
first := pkg.ThreeDay.Days[0]
|
||||||
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "thunder") {
|
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
|
||||||
t.Fatalf("first day = %#v, want conditions and risks", first)
|
t.Fatalf("first day = %#v, want conditions and risks", first)
|
||||||
}
|
}
|
||||||
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
|
||||||
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
|
||||||
}
|
}
|
||||||
|
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,16 @@ func BuildWeekend(ctx BuildContext, summaries []forecast.DailySummary) (Package,
|
|||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
return Package{}, fmt.Errorf("weekend forecast summaries are required")
|
||||||
}
|
}
|
||||||
pkg := Package{
|
pkg := buildPackage(ctx)
|
||||||
Metadata: BuildMetadata(ctx),
|
pkg.Weekend = &Weekend{
|
||||||
Weekend: &Weekend{
|
Discussion: buildDiscussion(summaries[0].Discussion),
|
||||||
Discussion: buildDiscussion(summaries[0].Discussion),
|
WeatherStory: buildWeatherStory(ctx.Bundle),
|
||||||
WeatherStory: buildWeatherStory(ctx.Bundle),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
pkg.Weekend.Days = append(pkg.Weekend.Days, buildOutlookDay(summary))
|
||||||
}
|
}
|
||||||
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
pkg.Weekend.RelevantAlerts = collectOutlookAlerts(pkg.Weekend.Days)
|
||||||
|
setRelevantAlertCount(&pkg.Metadata, len(pkg.Weekend.RelevantAlerts))
|
||||||
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
pkg.Weekend.Planning = buildWeekendPlanning(pkg.Weekend.Days, pkg.Weekend.Discussion, ctx.Bundle)
|
||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
@@ -96,9 +95,6 @@ func weekendRainStormNotes(date string, daypart forecast.DaypartSummary) []strin
|
|||||||
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
|
||||||
notes = append(notes, fmt.Sprintf("%s precipitation chance peaks near %.0f%%.", label, daypart.MaxPrecipitationProbability.Value))
|
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
|
return notes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
Value: gust,
|
Value: gust,
|
||||||
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
Time: mustParse("2026-05-30T16:00:00-05:00"),
|
||||||
},
|
},
|
||||||
Indicators: forecast.Indicators{Thunder: true, Wind: true},
|
Indicators: forecast.Indicators{Wind: true},
|
||||||
HourlyPeriods: []forecast.ForecastPeriod{
|
HourlyPeriods: []forecast.ForecastPeriod{
|
||||||
{
|
{
|
||||||
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
StartTime: mustParse("2026-05-30T15:00:00-05:00"),
|
||||||
@@ -54,7 +54,12 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
|
||||||
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Timing may shift."}},
|
Discussion: &forecast.Discussion{
|
||||||
|
Product: "discussion",
|
||||||
|
KeyMessages: []string{"Timing may shift."},
|
||||||
|
ShortTerm: &forecast.DiscussionSection{Text: "Short-term showers exit before the weekend."},
|
||||||
|
LongTerm: &forecast.DiscussionSection{Text: "Long-term weekend rain timing remains uncertain."},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,10 +84,16 @@ func TestWeekendBriefingBuildsPlanningInputs(t *testing.T) {
|
|||||||
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
if len(pkg.Weekend.Planning.WorstWeatherWindows) == 0 {
|
||||||
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
t.Fatalf("WorstWeatherWindows = %#v, want weather window", pkg.Weekend.Planning.WorstWeatherWindows)
|
||||||
}
|
}
|
||||||
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "thunder") {
|
if !strings.Contains(strings.Join(pkg.Weekend.Planning.RainStormTiming, " "), "precipitation") {
|
||||||
t.Fatalf("RainStormTiming = %#v, want thunder timing", pkg.Weekend.Planning.RainStormTiming)
|
t.Fatalf("RainStormTiming = %#v, want precipitation timing", pkg.Weekend.Planning.RainStormTiming)
|
||||||
}
|
}
|
||||||
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
if len(pkg.Weekend.Planning.UncertaintyInputs) == 0 {
|
||||||
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
t.Fatal("UncertaintyInputs length = 0, want discussion context")
|
||||||
}
|
}
|
||||||
|
if pkg.Weekend.Discussion.ShortTerm != "Short-term showers exit before the weekend." {
|
||||||
|
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Weekend.Discussion.ShortTerm)
|
||||||
|
}
|
||||||
|
if pkg.Weekend.Discussion.LongTerm != "Long-term weekend rain timing remains uncertain." {
|
||||||
|
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Weekend.Discussion.LongTerm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
previous bool
|
previous bool
|
||||||
current bool
|
current bool
|
||||||
}{
|
}{
|
||||||
{name: "thunder", previous: previous.Thunder, current: current.Thunder},
|
|
||||||
{name: "snow", previous: previous.Snow, current: current.Snow},
|
{name: "snow", previous: previous.Snow, current: current.Snow},
|
||||||
{name: "ice", previous: previous.Ice, current: current.Ice},
|
{name: "ice", previous: previous.Ice, current: current.Ice},
|
||||||
} {
|
} {
|
||||||
@@ -146,7 +145,6 @@ func compareIndicators(previous forecast.Indicators, current forecast.Indicators
|
|||||||
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
func aggregateIndicators(dayparts []forecast.DaypartSummary) forecast.Indicators {
|
||||||
out := forecast.Indicators{}
|
out := forecast.Indicators{}
|
||||||
for _, daypart := range dayparts {
|
for _, daypart := range dayparts {
|
||||||
out.Thunder = out.Thunder || daypart.Indicators.Thunder
|
|
||||||
out.Snow = out.Snow || daypart.Indicators.Snow
|
out.Snow = out.Snow || daypart.Indicators.Snow
|
||||||
out.Ice = out.Ice || daypart.Indicators.Ice
|
out.Ice = out.Ice || daypart.Indicators.Ice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,14 @@ func TestCompareDailyAlertAddedAndRemoved(t *testing.T) {
|
|||||||
|
|
||||||
func TestCompareDailyIndicatorChange(t *testing.T) {
|
func TestCompareDailyIndicatorChange(t *testing.T) {
|
||||||
previous := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{})
|
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})
|
current := dailyBriefing(60, 70, 10, at("2026-05-29T08:00:00Z"), nil, forecast.Indicators{Snow: true})
|
||||||
|
|
||||||
changes, err := CompareDaily(previous, current, testThresholds())
|
changes, err := CompareDaily(previous, current, testThresholds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CompareDaily() error = %v", err)
|
t.Fatalf("CompareDaily() error = %v", err)
|
||||||
}
|
}
|
||||||
if countType(changes, "thunder_risk_change") != 1 {
|
if countType(changes, "snow_risk_change") != 1 {
|
||||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
Value: currentPrecip,
|
Value: currentPrecip,
|
||||||
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,16 +51,16 @@ func TestCompareThreeDayDetectsDayChanges(t *testing.T) {
|
|||||||
t.Fatal("changes length = 0, want detected 3-day changes")
|
t.Fatal("changes length = 0, want detected 3-day changes")
|
||||||
}
|
}
|
||||||
var foundPrecip bool
|
var foundPrecip bool
|
||||||
var foundThunder bool
|
var foundSnow bool
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change.Type == "outlook_precip_probability_change" {
|
if change.Type == "outlook_precip_probability_change" {
|
||||||
foundPrecip = true
|
foundPrecip = true
|
||||||
}
|
}
|
||||||
if change.Type == "outlook_thunder_risk_change" {
|
if change.Type == "outlook_snow_risk_change" {
|
||||||
foundThunder = true
|
foundSnow = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !foundPrecip || !foundThunder {
|
if !foundPrecip || !foundSnow {
|
||||||
t.Fatalf("changes = %#v, want precipitation and thunder changes", changes)
|
t.Fatalf("changes = %#v, want precipitation and snow changes", changes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
Weekend: &briefing.Weekend{Days: []briefing.OutlookDay{{
|
||||||
Date: "2026-05-30",
|
Date: "2026-05-30",
|
||||||
Temperature: forecast.Range{Max: ¤tTemp},
|
Temperature: forecast.Range{Max: ¤tTemp},
|
||||||
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Thunder: true}}},
|
Dayparts: []forecast.DaypartSummary{{Indicators: forecast.Indicators{Snow: true}}},
|
||||||
}}},
|
}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ func TestCompareWeekendDetectsOutlookChanges(t *testing.T) {
|
|||||||
t.Fatal("changes length = 0, want weekend changes")
|
t.Fatal("changes length = 0, want weekend changes")
|
||||||
}
|
}
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change.Type == "weekend_outlook_thunder_risk_change" {
|
if change.Type == "weekend_outlook_snow_risk_change" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.Fatalf("changes = %#v, want thunder risk change", changes)
|
t.Fatalf("changes = %#v, want snow risk change", changes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
"generate", "daily",
|
"generate", "daily",
|
||||||
"--config", configPath,
|
"--config", configPath,
|
||||||
"--date", "2026-05-29",
|
"--date", "2026-05-29",
|
||||||
|
"--tz", "UTC",
|
||||||
"--out", outPath,
|
"--out", outPath,
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -463,6 +464,24 @@ func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) {
|
|||||||
if !strings.Contains(string(data), `data_package.v1`) || !strings.Contains(string(data), `"daily_today"`) {
|
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))
|
t.Fatalf("data package output missing expected content:\n%s", string(data))
|
||||||
}
|
}
|
||||||
|
var decoded struct {
|
||||||
|
Briefing struct {
|
||||||
|
Metadata struct {
|
||||||
|
Location struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
} `json:"location"`
|
||||||
|
} `json:"metadata"`
|
||||||
|
} `json:"briefing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatalf("decode data package: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.Briefing.Metadata.Location.ID != "home" || decoded.Briefing.Metadata.Location.Name != "Brentwood" || decoded.Briefing.Metadata.Location.Region != "St. Louis Metro" || decoded.Briefing.Metadata.Location.Timezone != "UTC" {
|
||||||
|
t.Fatalf("location = %#v, want configured location with overridden timezone", decoded.Briefing.Metadata.Location)
|
||||||
|
}
|
||||||
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
preflightMatches, err := filepath.Glob(filepath.Join(workspaceRoot, "preflight", "daily", "2026-05-29", "*.render.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("glob preflight: %v", err)
|
t.Fatalf("glob preflight: %v", err)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
WeatherAPI WeatherAPIConfig `yaml:"weather_api"`
|
||||||
|
Location LocationConfig `yaml:"location"`
|
||||||
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
MissingSource MissingSourceConfig `yaml:"missing_source"`
|
||||||
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
@@ -30,6 +31,12 @@ type WeatherAPIConfig struct {
|
|||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LocationConfig struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Region string `yaml:"region"`
|
||||||
|
}
|
||||||
|
|
||||||
type MissingSourceConfig struct {
|
type MissingSourceConfig struct {
|
||||||
Default MissingSourcePolicy `yaml:"default"`
|
Default MissingSourcePolicy `yaml:"default"`
|
||||||
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
Sources map[string]MissingSourcePolicy `yaml:"sources"`
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ func TestDefaults(t *testing.T) {
|
|||||||
if cfg.WeatherAPI.Units != "us" {
|
if cfg.WeatherAPI.Units != "us" {
|
||||||
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
t.Fatalf("Units = %q, want us", cfg.WeatherAPI.Units)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Timezone != "Chicago" {
|
if cfg.WeatherAPI.Timezone != "America/Chicago" {
|
||||||
t.Fatalf("Timezone = %q, want Chicago", cfg.WeatherAPI.Timezone)
|
t.Fatalf("Timezone = %q, want America/Chicago", cfg.WeatherAPI.Timezone)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Format != "json" {
|
if cfg.WeatherAPI.Format != "json" {
|
||||||
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
t.Fatalf("Format = %q, want json", cfg.WeatherAPI.Format)
|
||||||
}
|
}
|
||||||
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
|
t.Fatalf("Location = %#v, want home/Brentwood/St. Louis Metro", cfg.Location)
|
||||||
|
}
|
||||||
if cfg.MissingSource.Default != MissingSourceWarn {
|
if cfg.MissingSource.Default != MissingSourceWarn {
|
||||||
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
t.Fatalf("MissingSource.Default = %q, want warn", cfg.MissingSource.Default)
|
||||||
}
|
}
|
||||||
@@ -34,8 +37,8 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
t.Fatalf("LoadFile() error = %v", err)
|
t.Fatalf("LoadFile() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.WeatherAPI.BaseURL != "https://weather.api.example.com/" {
|
if cfg.WeatherAPI.BaseURL != "https://weather.api.rakestrawhome.com/" {
|
||||||
t.Fatalf("BaseURL = %q, want example URL", cfg.WeatherAPI.BaseURL)
|
t.Fatalf("BaseURL = %q, want configured example URL", cfg.WeatherAPI.BaseURL)
|
||||||
}
|
}
|
||||||
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
if cfg.WeatherAPI.Timeout != 15*time.Second {
|
||||||
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
t.Fatalf("Timeout = %s, want 15s", cfg.WeatherAPI.Timeout)
|
||||||
@@ -43,6 +46,9 @@ func TestLoadExampleConfig(t *testing.T) {
|
|||||||
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
if cfg.MissingSource.Sources["alerts"] != MissingSourceNone {
|
||||||
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
t.Fatalf("alerts policy = %q, want none", cfg.MissingSource.Sources["alerts"])
|
||||||
}
|
}
|
||||||
|
if cfg.Location.ID != "home" || cfg.Location.Name != "Brentwood" || cfg.Location.Region != "St. Louis Metro" {
|
||||||
|
t.Fatalf("Location = %#v, want example location", cfg.Location)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadMinimalExampleConfig(t *testing.T) {
|
func TestLoadMinimalExampleConfig(t *testing.T) {
|
||||||
@@ -63,6 +69,9 @@ func TestLoadMinimalExampleConfig(t *testing.T) {
|
|||||||
if cfg.Workspace.Root != "workspace" {
|
if cfg.Workspace.Root != "workspace" {
|
||||||
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root)
|
||||||
}
|
}
|
||||||
|
if cfg.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Location.Name = %q, want default Brentwood", cfg.Location.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
func TestExplicitMissingConfigReturnsError(t *testing.T) {
|
||||||
|
|||||||
@@ -10,9 +10,14 @@ func Defaults() Config {
|
|||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
Precision: 1,
|
Precision: 1,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "Chicago",
|
Timezone: "America/Chicago",
|
||||||
Format: "json",
|
Format: "json",
|
||||||
},
|
},
|
||||||
|
Location: LocationConfig{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
},
|
||||||
MissingSource: MissingSourceConfig{
|
MissingSource: MissingSourceConfig{
|
||||||
Default: MissingSourceWarn,
|
Default: MissingSourceWarn,
|
||||||
Sources: map[string]MissingSourcePolicy{},
|
Sources: map[string]MissingSourcePolicy{},
|
||||||
@@ -30,9 +35,10 @@ func Defaults() Config {
|
|||||||
},
|
},
|
||||||
Dayparts: []DaypartConfig{
|
Dayparts: []DaypartConfig{
|
||||||
{Name: "overnight", Start: "00:00", End: "06:00"},
|
{Name: "overnight", Start: "00:00", End: "06:00"},
|
||||||
{Name: "morning", Start: "06:00", End: "12:00"},
|
{Name: "morning", Start: "06:00", End: "10:00"},
|
||||||
{Name: "afternoon", Start: "12:00", End: "18:00"},
|
{Name: "midday", Start: "10:00", End: "15:00"},
|
||||||
{Name: "evening", Start: "18:00", End: "24:00"},
|
{Name: "afternoon", Start: "15:00", End: "17:00"},
|
||||||
|
{Name: "evening", Start: "17:00", End: "24:00"},
|
||||||
},
|
},
|
||||||
RecentChange: RecentChangeConfig{
|
RecentChange: RecentChangeConfig{
|
||||||
TemperatureDegrees: 5,
|
TemperatureDegrees: 5,
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ type Discussion struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiscussionSection struct {
|
type DiscussionSection struct {
|
||||||
Title string `json:"title,omitempty"`
|
Qualifier string `json:"qualifier,omitempty"`
|
||||||
Narrative string `json:"narrative,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
IssuedAt *time.Time `json:"issuedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,12 @@ type TimedValue struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Indicators struct {
|
type Indicators struct {
|
||||||
Thunder bool `json:"thunder,omitempty"`
|
Snow bool `json:"snow,omitempty"`
|
||||||
Snow bool `json:"snow,omitempty"`
|
Ice bool `json:"ice,omitempty"`
|
||||||
Ice bool `json:"ice,omitempty"`
|
Fog bool `json:"fog,omitempty"`
|
||||||
Fog bool `json:"fog,omitempty"`
|
Heat bool `json:"heat,omitempty"`
|
||||||
Heat bool `json:"heat,omitempty"`
|
Cold bool `json:"cold,omitempty"`
|
||||||
Cold bool `json:"cold,omitempty"`
|
Wind bool `json:"wind,omitempty"`
|
||||||
Wind bool `json:"wind,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertOverlap struct {
|
type AlertOverlap struct {
|
||||||
@@ -291,11 +290,10 @@ func sortedKeys(values map[string]struct{}) []string {
|
|||||||
func indicatorsForText(text string) Indicators {
|
func indicatorsForText(text string) Indicators {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Thunder: strings.Contains(lower, "thunder") || strings.Contains(lower, "storm"),
|
Snow: strings.Contains(lower, "snow"),
|
||||||
Snow: strings.Contains(lower, "snow"),
|
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
||||||
Ice: strings.Contains(lower, "ice") || strings.Contains(lower, "freezing") || strings.Contains(lower, "sleet"),
|
Fog: strings.Contains(lower, "fog"),
|
||||||
Fog: strings.Contains(lower, "fog"),
|
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
||||||
Wind: strings.Contains(lower, "wind") || strings.Contains(lower, "gust"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,13 +316,12 @@ func numericIndicators(period ForecastPeriod) Indicators {
|
|||||||
|
|
||||||
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
func mergeIndicators(left Indicators, right Indicators) Indicators {
|
||||||
return Indicators{
|
return Indicators{
|
||||||
Thunder: left.Thunder || right.Thunder,
|
Snow: left.Snow || right.Snow,
|
||||||
Snow: left.Snow || right.Snow,
|
Ice: left.Ice || right.Ice,
|
||||||
Ice: left.Ice || right.Ice,
|
Fog: left.Fog || right.Fog,
|
||||||
Fog: left.Fog || right.Fog,
|
Heat: left.Heat || right.Heat,
|
||||||
Heat: left.Heat || right.Heat,
|
Cold: left.Cold || right.Cold,
|
||||||
Cold: left.Cold || right.Cold,
|
Wind: left.Wind || right.Wind,
|
||||||
Wind: left.Wind || right.Wind,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ func TestBuildDailySummaryGroupsDaypartsAndComputesMetrics(t *testing.T) {
|
|||||||
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
t.Fatalf("morning peak gust = %#v, want 40", morning.PeakWindGust)
|
||||||
}
|
}
|
||||||
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
if morning.DominantCondition != "Thunderstorms and gusty wind" {
|
||||||
t.Fatalf("morning dominant = %q, want thunderstorm condition", morning.DominantCondition)
|
t.Fatalf("morning dominant = %q, want raw forecast condition", morning.DominantCondition)
|
||||||
}
|
}
|
||||||
if !morning.Indicators.Thunder || !morning.Indicators.Wind {
|
if !morning.Indicators.Wind {
|
||||||
t.Fatalf("morning indicators = %#v, want thunder and wind", morning.Indicators)
|
t.Fatalf("morning indicators = %#v, want wind", morning.Indicators)
|
||||||
}
|
}
|
||||||
|
|
||||||
afternoon := summary.Dayparts[2]
|
afternoon := summary.Dayparts[2]
|
||||||
@@ -87,8 +87,8 @@ func TestBuildDailySummaryFromFixtureBundle(t *testing.T) {
|
|||||||
if len(summary.Dayparts) != 2 {
|
if len(summary.Dayparts) != 2 {
|
||||||
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
t.Fatalf("Dayparts length = %d, want 2", len(summary.Dayparts))
|
||||||
}
|
}
|
||||||
if !summary.Dayparts[0].Indicators.Thunder {
|
if summary.Dayparts[0].DominantCondition != "Showers and thunderstorms" {
|
||||||
t.Fatalf("morning indicators = %#v, want thunder", summary.Dayparts[0].Indicators)
|
t.Fatalf("morning dominant = %q, want raw forecast condition", summary.Dayparts[0].DominantCondition)
|
||||||
}
|
}
|
||||||
if len(summary.AlertOverlaps) != 1 {
|
if len(summary.AlertOverlaps) != 1 {
|
||||||
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
t.Fatalf("AlertOverlaps length = %d, want 1", len(summary.AlertOverlaps))
|
||||||
|
|||||||
10
internal/forecast/testdata/daily_bundle.json
vendored
10
internal/forecast/testdata/daily_bundle.json
vendored
@@ -54,7 +54,15 @@
|
|||||||
"issuedAt": "2026-05-29T09:25:00-05:00",
|
"issuedAt": "2026-05-29T09:25:00-05:00",
|
||||||
"keyMessages": [
|
"keyMessages": [
|
||||||
"Storms are most likely during the morning."
|
"Storms are most likely during the morning."
|
||||||
]
|
],
|
||||||
|
"shortTerm": {
|
||||||
|
"qualifier": "(Through This Evening)",
|
||||||
|
"text": "Morning showers taper as a weak boundary shifts east."
|
||||||
|
},
|
||||||
|
"longTerm": {
|
||||||
|
"qualifier": "(This Weekend)",
|
||||||
|
"text": "Warmer and more humid conditions return with periodic rain chances."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,12 +25,13 @@ type Package struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Report struct {
|
type Report struct {
|
||||||
ID report.ID `json:"id"`
|
ID report.ID `json:"id"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
CurrentLocalDate string `json:"currentLocalDate"`
|
||||||
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecentChanges struct {
|
type RecentChanges struct {
|
||||||
@@ -42,6 +43,10 @@ func Build(briefingPackage briefing.Package) (Package, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []changes.Change) (Package, error) {
|
||||||
|
localDate, err := currentLocalDate(briefingPackage.Metadata.GeneratedAt, briefingPackage.Metadata.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return Package{}, err
|
||||||
|
}
|
||||||
items := make([]changes.Change, len(recentChanges))
|
items := make([]changes.Change, len(recentChanges))
|
||||||
copy(items, recentChanges)
|
copy(items, recentChanges)
|
||||||
if items == nil {
|
if items == nil {
|
||||||
@@ -51,12 +56,13 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
|||||||
SchemaVersion: SchemaVersion,
|
SchemaVersion: SchemaVersion,
|
||||||
RunID: briefingPackage.Metadata.RunID,
|
RunID: briefingPackage.Metadata.RunID,
|
||||||
Report: Report{
|
Report: Report{
|
||||||
ID: briefingPackage.Metadata.ReportID,
|
ID: briefingPackage.Metadata.ReportID,
|
||||||
Variant: briefingPackage.Metadata.Variant,
|
Variant: briefingPackage.Metadata.Variant,
|
||||||
PromptID: briefingPackage.Metadata.PromptID,
|
PromptID: briefingPackage.Metadata.PromptID,
|
||||||
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
GeneratedAt: briefingPackage.Metadata.GeneratedAt,
|
||||||
Timezone: briefingPackage.Metadata.Timezone,
|
Timezone: briefingPackage.Metadata.Timezone,
|
||||||
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
CurrentLocalDate: localDate,
|
||||||
|
ValidPeriod: briefingPackage.Metadata.ValidPeriod,
|
||||||
},
|
},
|
||||||
Briefing: briefingPackage,
|
Briefing: briefingPackage,
|
||||||
RecentChanges: RecentChanges{Items: items},
|
RecentChanges: RecentChanges{Items: items},
|
||||||
@@ -68,6 +74,14 @@ func BuildWithRecentChanges(briefingPackage briefing.Package, recentChanges []ch
|
|||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentLocalDate(generatedAt time.Time, timezone string) (string, error) {
|
||||||
|
location, err := timeutil.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("load report timezone %q: %w", timezone, err)
|
||||||
|
}
|
||||||
|
return generatedAt.In(location).Format(timeutil.DateLayout), nil
|
||||||
|
}
|
||||||
|
|
||||||
func Validate(pkg Package) error {
|
func Validate(pkg Package) error {
|
||||||
if pkg.SchemaVersion == "" {
|
if pkg.SchemaVersion == "" {
|
||||||
return fmt.Errorf("schemaVersion is required")
|
return fmt.Errorf("schemaVersion is required")
|
||||||
@@ -87,6 +101,9 @@ func Validate(pkg Package) error {
|
|||||||
if pkg.Report.Timezone == "" {
|
if pkg.Report.Timezone == "" {
|
||||||
return fmt.Errorf("report.timezone is required")
|
return fmt.Errorf("report.timezone is required")
|
||||||
}
|
}
|
||||||
|
if pkg.Report.CurrentLocalDate == "" {
|
||||||
|
return fmt.Errorf("report.currentLocalDate is required")
|
||||||
|
}
|
||||||
if !pkg.Report.ValidPeriod.IsValid() {
|
if !pkg.Report.ValidPeriod.IsValid() {
|
||||||
return fmt.Errorf("report.validPeriod must be valid")
|
return fmt.Errorf("report.validPeriod must be valid")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,51 @@ func TestBuildDailyDataPackage(t *testing.T) {
|
|||||||
if pkg.Report.PromptID != "weather.daily_report" {
|
if pkg.Report.PromptID != "weather.daily_report" {
|
||||||
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
t.Fatalf("PromptID = %q, want weather.daily_report", pkg.Report.PromptID)
|
||||||
}
|
}
|
||||||
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("CurrentLocalDate = %q, want 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
if pkg.Briefing.Daily == nil {
|
if pkg.Briefing.Daily == nil {
|
||||||
t.Fatal("Briefing.Daily = nil")
|
t.Fatal("Briefing.Daily = nil")
|
||||||
}
|
}
|
||||||
|
if pkg.Briefing.Metadata.Location == nil || pkg.Briefing.Metadata.Location.Name != "Brentwood" {
|
||||||
|
t.Fatalf("Briefing.Metadata.Location = %#v, want configured location", pkg.Briefing.Metadata.Location)
|
||||||
|
}
|
||||||
|
if pkg.Briefing.CurrentConditions == nil || pkg.Briefing.CurrentConditions.ConditionText != "Partly cloudy" {
|
||||||
|
t.Fatalf("Briefing.CurrentConditions = %#v, want current conditions", pkg.Briefing.CurrentConditions)
|
||||||
|
}
|
||||||
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
if pkg.RecentChanges.Items == nil || len(pkg.RecentChanges.Items) != 0 {
|
||||||
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
t.Fatalf("RecentChanges.Items = %#v, want empty slice", pkg.RecentChanges.Items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildCurrentLocalDateUsesReportTimezone(t *testing.T) {
|
||||||
|
briefingPackage := validBriefingPackage()
|
||||||
|
briefingPackage.Metadata.GeneratedAt = time.Date(2026, 5, 30, 2, 30, 0, 0, time.UTC)
|
||||||
|
briefingPackage.Metadata.Timezone = "America/Chicago"
|
||||||
|
|
||||||
|
pkg, err := Build(briefingPackage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.Report.CurrentLocalDate != "2026-05-29" {
|
||||||
|
t.Fatalf("CurrentLocalDate = %q, want local Chicago date 2026-05-29", pkg.Report.CurrentLocalDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRejectsInvalidReportTimezone(t *testing.T) {
|
||||||
|
briefingPackage := validBriefingPackage()
|
||||||
|
briefingPackage.Metadata.Timezone = "Not/AZone"
|
||||||
|
|
||||||
|
_, err := Build(briefingPackage)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Build() error = nil, want invalid timezone error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "report timezone") {
|
||||||
|
t.Fatalf("error = %q, want report timezone context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateRequiresFields(t *testing.T) {
|
func TestValidateRequiresFields(t *testing.T) {
|
||||||
pkg, err := Build(validBriefingPackage())
|
pkg, err := Build(validBriefingPackage())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,6 +89,22 @@ func TestValidateRequiresFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRequiresCurrentLocalDate(t *testing.T) {
|
||||||
|
pkg, err := Build(validBriefingPackage())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Build() error = %v", err)
|
||||||
|
}
|
||||||
|
pkg.Report.CurrentLocalDate = ""
|
||||||
|
|
||||||
|
err = Validate(pkg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Validate() error = nil, want required field error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "currentLocalDate") {
|
||||||
|
t.Fatalf("error = %q, want currentLocalDate context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildThreeDayDataPackage(t *testing.T) {
|
func TestBuildThreeDayDataPackage(t *testing.T) {
|
||||||
briefingPackage := validBriefingPackage()
|
briefingPackage := validBriefingPackage()
|
||||||
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
briefingPackage.Metadata.RunID = "20260529T100000Z_three_day"
|
||||||
@@ -155,11 +208,20 @@ func validBriefingPackage() briefing.Package {
|
|||||||
GeneratedAt: generatedAt,
|
GeneratedAt: generatedAt,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: "America/Chicago",
|
Timezone: "America/Chicago",
|
||||||
|
Location: &briefing.LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: "America/Chicago",
|
||||||
|
},
|
||||||
ValidPeriod: timeutil.Period{
|
ValidPeriod: timeutil.Period{
|
||||||
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
Start: time.Date(2026, 5, 29, 5, 0, 0, 0, time.UTC),
|
||||||
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
End: time.Date(2026, 5, 30, 5, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
CurrentConditions: &briefing.CurrentConditionsContext{
|
||||||
|
ConditionText: "Partly cloudy",
|
||||||
|
},
|
||||||
Daily: &briefing.Daily{
|
Daily: &briefing.Daily{
|
||||||
ForecastSummaryDate: "2026-05-29",
|
ForecastSummaryDate: "2026-05-29",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ type Definition struct {
|
|||||||
Name string
|
Name string
|
||||||
PromptID string
|
PromptID string
|
||||||
ComparisonStrategy ComparisonStrategy
|
ComparisonStrategy ComparisonStrategy
|
||||||
DefaultOutputName string
|
|
||||||
ArtifactGroup string
|
ArtifactGroup string
|
||||||
BatchOutputName string
|
BatchOutputName string
|
||||||
Generated bool
|
Generated bool
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Daily Report",
|
Name: "Daily Report",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "daily.md",
|
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "daily.md",
|
BatchOutputName: "daily.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
@@ -26,7 +25,6 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Tomorrow Planning Brief",
|
Name: "Tomorrow Planning Brief",
|
||||||
PromptID: "weather.daily_report",
|
PromptID: "weather.daily_report",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "tomorrow.md",
|
|
||||||
ArtifactGroup: "daily",
|
ArtifactGroup: "daily",
|
||||||
BatchOutputName: "tomorrow.md",
|
BatchOutputName: "tomorrow.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
@@ -39,7 +37,6 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "3-Day Outlook",
|
Name: "3-Day Outlook",
|
||||||
PromptID: "weather.three_day_outlook",
|
PromptID: "weather.three_day_outlook",
|
||||||
ComparisonStrategy: CompareSameValidDate,
|
ComparisonStrategy: CompareSameValidDate,
|
||||||
DefaultOutputName: "three_day.md",
|
|
||||||
ArtifactGroup: "three-day",
|
ArtifactGroup: "three-day",
|
||||||
BatchOutputName: "three-day.md",
|
BatchOutputName: "three-day.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
@@ -52,7 +49,6 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Weekend Outlook",
|
Name: "Weekend Outlook",
|
||||||
PromptID: "weather.weekend_outlook",
|
PromptID: "weather.weekend_outlook",
|
||||||
ComparisonStrategy: CompareWeekendWindow,
|
ComparisonStrategy: CompareWeekendWindow,
|
||||||
DefaultOutputName: "weekend.md",
|
|
||||||
ArtifactGroup: "weekend",
|
ArtifactGroup: "weekend",
|
||||||
BatchOutputName: "weekend.md",
|
BatchOutputName: "weekend.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
@@ -65,7 +61,6 @@ func DefaultRegistry() Registry {
|
|||||||
Name: "Storm Report",
|
Name: "Storm Report",
|
||||||
PromptID: "weather.storm_report",
|
PromptID: "weather.storm_report",
|
||||||
ComparisonStrategy: CompareExplicitWindow,
|
ComparisonStrategy: CompareExplicitWindow,
|
||||||
DefaultOutputName: "storm.md",
|
|
||||||
ArtifactGroup: "storm",
|
ArtifactGroup: "storm",
|
||||||
BatchOutputName: "storm.md",
|
BatchOutputName: "storm.md",
|
||||||
Generated: true,
|
Generated: true,
|
||||||
|
|||||||
@@ -154,14 +154,13 @@ func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (st
|
|||||||
if metadata.PreflightPath == "" {
|
if metadata.PreflightPath == "" {
|
||||||
return "", fmt.Errorf("metadata preflight path is required")
|
return "", fmt.Errorf("metadata preflight path is required")
|
||||||
}
|
}
|
||||||
path := metadataPathFromStored(metadata)
|
if metadata.MetadataPath == "" {
|
||||||
if path == "" {
|
return "", fmt.Errorf("metadata path is required")
|
||||||
return "", fmt.Errorf("metadata path cannot be resolved")
|
|
||||||
}
|
}
|
||||||
if err := fileutil.WriteJSONAtomic(path, metadata); err != nil {
|
if err := fileutil.WriteJSONAtomic(metadata.MetadataPath, metadata); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return path, nil
|
return metadata.MetadataPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
func (s *FilesystemStore) FindPriorSnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
|
||||||
@@ -366,14 +365,6 @@ func readJSON(path string, target any) error {
|
|||||||
return nil
|
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 {
|
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
|
||||||
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,46 @@ func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) {
|
|||||||
if decoded.RenderedReportPath != renderedReportPath {
|
if decoded.RenderedReportPath != renderedReportPath {
|
||||||
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath)
|
||||||
}
|
}
|
||||||
|
if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" {
|
||||||
|
t.Fatalf("metadata location = %#v, want briefing location", decoded.Location)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") {
|
||||||
|
t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) {
|
||||||
|
store := newTestStore(t)
|
||||||
|
resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00")
|
||||||
|
briefingPackage := stateBriefingPackage(resolved)
|
||||||
|
paths, err := store.Paths(resolved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Paths() error = %v", err)
|
||||||
|
}
|
||||||
|
otherDir := filepath.Join(t.TempDir(), "other-artifacts")
|
||||||
|
otherBriefingPath := filepath.Join(otherDir, resolved.Metadata().RunID+".briefing.json")
|
||||||
|
derivedMetadataPath := filepath.Join(otherDir, resolved.Metadata().RunID+".metadata.json")
|
||||||
|
|
||||||
|
metadata := BuildMetadata(resolved, briefingPackage, ArtifactPaths{
|
||||||
|
Briefing: otherBriefingPath,
|
||||||
|
Metadata: paths.Metadata,
|
||||||
|
DataPackage: paths.DataPackage,
|
||||||
|
Preflight: paths.Preflight,
|
||||||
|
RenderedReport: paths.RenderedReport,
|
||||||
|
})
|
||||||
|
metadataPath, err := store.SaveMetadata(context.Background(), metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveMetadata() error = %v", err)
|
||||||
|
}
|
||||||
|
if metadataPath != paths.Metadata {
|
||||||
|
t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(paths.Metadata); err != nil {
|
||||||
|
t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("derived metadata path stat error = %v, want not exist", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindPriorSnapshot(t *testing.T) {
|
func TestFindPriorSnapshot(t *testing.T) {
|
||||||
@@ -400,7 +440,13 @@ func stateBriefingPackage(resolved report.Resolved) briefing.Package {
|
|||||||
GeneratedAt: resolved.GeneratedAt,
|
GeneratedAt: resolved.GeneratedAt,
|
||||||
Units: "us",
|
Units: "us",
|
||||||
Timezone: resolved.Timezone,
|
Timezone: resolved.Timezone,
|
||||||
ValidPeriod: resolved.ValidPeriod,
|
Location: &briefing.LocationContext{
|
||||||
|
ID: "home",
|
||||||
|
Name: "Brentwood",
|
||||||
|
Region: "St. Louis Metro",
|
||||||
|
Timezone: resolved.Timezone,
|
||||||
|
},
|
||||||
|
ValidPeriod: resolved.ValidPeriod,
|
||||||
},
|
},
|
||||||
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
Daily: &briefing.Daily{ForecastSummaryDate: "2026-05-29"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ const MetadataSchemaVersion = "weatherreporter.metadata.v1"
|
|||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
RunID string `json:"runId"`
|
RunID string `json:"runId"`
|
||||||
|
MetadataPath string `json:"-"`
|
||||||
ReportID report.ID `json:"reportId"`
|
ReportID report.ID `json:"reportId"`
|
||||||
Variant string `json:"variant,omitempty"`
|
Variant string `json:"variant,omitempty"`
|
||||||
PromptID string `json:"promptId"`
|
PromptID string `json:"promptId"`
|
||||||
GeneratedAt time.Time `json:"generatedAt"`
|
GeneratedAt time.Time `json:"generatedAt"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
ValidPeriod timeutil.Period `json:"validPeriod"`
|
ValidPeriod timeutil.Period `json:"validPeriod"`
|
||||||
|
Location *briefing.LocationContext `json:"location,omitempty"`
|
||||||
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
SourceLocationID string `json:"sourceLocationId,omitempty"`
|
||||||
SourceLocation string `json:"sourceLocation,omitempty"`
|
SourceLocation string `json:"sourceLocation,omitempty"`
|
||||||
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
|
||||||
@@ -35,12 +37,14 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
|||||||
return Metadata{
|
return Metadata{
|
||||||
SchemaVersion: MetadataSchemaVersion,
|
SchemaVersion: MetadataSchemaVersion,
|
||||||
RunID: metadata.RunID,
|
RunID: metadata.RunID,
|
||||||
|
MetadataPath: paths.Metadata,
|
||||||
ReportID: metadata.ReportID,
|
ReportID: metadata.ReportID,
|
||||||
Variant: briefingPackage.Metadata.Variant,
|
Variant: briefingPackage.Metadata.Variant,
|
||||||
PromptID: metadata.PromptID,
|
PromptID: metadata.PromptID,
|
||||||
GeneratedAt: metadata.GeneratedAt,
|
GeneratedAt: metadata.GeneratedAt,
|
||||||
Timezone: metadata.Timezone,
|
Timezone: metadata.Timezone,
|
||||||
ValidPeriod: metadata.ValidPeriod,
|
ValidPeriod: metadata.ValidPeriod,
|
||||||
|
Location: copyLocation(briefingPackage.Metadata.Location),
|
||||||
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
SourceLocationID: briefingPackage.Metadata.SourceLocationID,
|
||||||
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
SourceLocation: briefingPackage.Metadata.SourceLocation,
|
||||||
Sources: briefingPackage.Metadata.Sources,
|
Sources: briefingPackage.Metadata.Sources,
|
||||||
@@ -51,3 +55,11 @@ func BuildMetadata(resolved report.Resolved, briefingPackage briefing.Package, p
|
|||||||
RenderedReportPath: paths.RenderedReport,
|
RenderedReportPath: paths.RenderedReport,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyLocation(location *briefing.LocationContext) *briefing.LocationContext {
|
||||||
|
if location == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := *location
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user