From a885959d39828d50bb7213b3a0fcc61305828ffa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 29 May 2026 17:06:39 +0000 Subject: [PATCH] Add weather API bundle adapter --- docs/config.md | 4 +- docs/internal/weather-data.md | 69 +++ internal/adapters/weatherapi/client.go | 426 ++++++++++++++++++ internal/adapters/weatherapi/client_test.go | 348 ++++++++++++++ .../adapters/weatherapi/testdata/alerts.json | 6 + .../adapters/weatherapi/testdata/current.json | 10 + .../weatherapi/testdata/discussion.json | 16 + .../adapters/weatherapi/testdata/hourly.json | 25 + .../weatherapi/testdata/narrative.json | 20 + .../weatherapi/testdata/observations.json | 13 + internal/app/app.go | 33 ++ internal/app/app_test.go | 64 +++ internal/config/config_test.go | 6 +- internal/config/validate.go | 16 +- internal/forecast/bundle.go | 161 +++++++ internal/timeutil/parse.go | 54 +++ internal/timeutil/parse_test.go | 11 + 17 files changed, 1264 insertions(+), 18 deletions(-) create mode 100644 docs/internal/weather-data.md create mode 100644 internal/adapters/weatherapi/client.go create mode 100644 internal/adapters/weatherapi/client_test.go create mode 100644 internal/adapters/weatherapi/testdata/alerts.json create mode 100644 internal/adapters/weatherapi/testdata/current.json create mode 100644 internal/adapters/weatherapi/testdata/discussion.json create mode 100644 internal/adapters/weatherapi/testdata/hourly.json create mode 100644 internal/adapters/weatherapi/testdata/narrative.json create mode 100644 internal/adapters/weatherapi/testdata/observations.json create mode 100644 internal/app/app_test.go create mode 100644 internal/forecast/bundle.go diff --git a/docs/config.md b/docs/config.md index 840c9d6..1addf9d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -19,11 +19,11 @@ See [examples/config.yml](../examples/config.yml). ## Reference -- `weather_api.base_url`: single Weather API endpoint base URL. +- `weather_api.base_url`: single Weather API endpoint base URL, required when fetching weather data. - `weather_api.timeout`: HTTP timeout duration. Default: `10s`. - `weather_api.precision`: numeric precision hint. Default: `1`. - `weather_api.units`: Weather API units. Default: `us`. -- `weather_api.timezone`: report timezone. Default: `Chicago`. +- `weather_api.timezone`: report timezone. Accepts IANA names, configured aliases such as `Chicago` and `Stl`, US timezone abbreviations, and UTC offsets such as `-5` or `+09:30`. Default: `Chicago`. - `weather_api.format`: Weather API response format. Default: `json`. - `missing_source.default`: one of `error`, `warn`, or `none`. Default: `warn`. - `missing_source.sources`: optional per-source missing-source policy overrides. diff --git a/docs/internal/weather-data.md b/docs/internal/weather-data.md new file mode 100644 index 0000000..62a1419 --- /dev/null +++ b/docs/internal/weather-data.md @@ -0,0 +1,69 @@ +# Weather Data Internals + +This document describes the implemented weather data ingestion boundary. + +## Purpose + +`internal/adapters/weatherapi` fetches normalized weather data from one +configured weather API endpoint and assembles an `internal/forecast.Bundle`. + +## Inputs and Outputs + +Input: + +- `config.Config` with `weather_api.base_url`, `format`, `units`, `timezone`, + `precision`, timeout, and missing-source policy. + +Output: + +- `forecast.Bundle` containing observation, current conditions, hourly forecast, + narrative forecast, alerts, discussion, stub source slots, provenance, and + source warnings. + +## Boundaries + +- The adapter performs HTTP calls and decoding only. +- Forecast derivation, daypart grouping, report periods, report rendering, and + `scriptorium` execution are outside this boundary. +- Hourly forecast data is required. Other missing or malformed source sections + use the configured missing-source policy. + +## External Adapter + +The adapter calls: + +- `/observations` +- `/conditions/current` +- `/forecast/hourly` +- `/forecast/narrative` +- `/alerts/active` +- `/discussion` + +Forecast routes use the full-product endpoints, not day-slice endpoints. + +## State + +`app.FetchAndSaveBundle` can save an inspectable bundle JSON file using an +atomic rename. No report state, snapshots, or prompt input packages are written +yet. + +## Failure Behavior + +- HTTP and envelope decode failures return actionable errors with endpoint + context. +- Missing hourly data fails the fetch. +- Missing or malformed optional sources follow `error`, `warn`, or `none`. +- Source identity uses SHA-256 over compacted raw `data` JSON. + +## Tests + +Inspect: + +- `internal/adapters/weatherapi/client_test.go` +- `internal/app/app_test.go` + +## Invariants + +- Weather facts come from normalized source data. +- External API details stay inside `internal/adapters/weatherapi`. +- Source provenance and warnings remain inspectable for later briefing builders. diff --git a/internal/adapters/weatherapi/client.go b/internal/adapters/weatherapi/client.go new file mode 100644 index 0000000..b2de647 --- /dev/null +++ b/internal/adapters/weatherapi/client.go @@ -0,0 +1,426 @@ +// Package weatherapi adapts the internal weather API to forecast bundles. +package weatherapi + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" +) + +type Client struct { + baseURL *url.URL + httpClient *http.Client + units string + format string + timezone string + precision int + missingSource config.MissingSourceConfig + now func() time.Time +} + +type Option func(*Client) + +func WithHTTPClient(httpClient *http.Client) Option { + return func(c *Client) { + if httpClient != nil { + c.httpClient = httpClient + } + } +} + +func WithClock(now func() time.Time) Option { + return func(c *Client) { + if now != nil { + c.now = now + } + } +} + +func New(cfg config.Config, opts ...Option) (*Client, error) { + if strings.TrimSpace(cfg.WeatherAPI.BaseURL) == "" { + return nil, fmt.Errorf("weather_api.base_url is required") + } + baseURL, err := url.Parse(cfg.WeatherAPI.BaseURL) + if err != nil || baseURL.Scheme == "" || baseURL.Host == "" { + return nil, fmt.Errorf("weather_api.base_url must be an absolute URL") + } + + timeout := cfg.WeatherAPI.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + + client := &Client{ + baseURL: baseURL, + httpClient: &http.Client{Timeout: timeout}, + units: cfg.WeatherAPI.Units, + format: cfg.WeatherAPI.Format, + timezone: cfg.WeatherAPI.Timezone, + precision: cfg.WeatherAPI.Precision, + missingSource: config.MissingSourceConfig{ + Default: cfg.MissingSource.Default, + Sources: cfg.MissingSource.Sources, + }, + now: time.Now, + } + for _, opt := range opts { + opt(client) + } + return client, nil +} + +func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) { + fetchedAt := c.now() + builder := bundleBuilder{ + client: c, + bundle: &forecast.Bundle{FetchedAt: fetchedAt}, + fetchedAt: fetchedAt, + } + + if err := builder.fetchObservation(ctx); err != nil { + return nil, err + } + if err := builder.fetchCurrent(ctx); err != nil { + return nil, err + } + if err := builder.fetchHourly(ctx); err != nil { + return nil, err + } + if err := builder.fetchNarrative(ctx); err != nil { + return nil, err + } + if err := builder.fetchAlerts(ctx); err != nil { + return nil, err + } + if err := builder.fetchDiscussion(ctx); err != nil { + return nil, err + } + if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil { + return nil, err + } + if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil { + return nil, err + } + + return builder.bundle, nil +} + +type bundleBuilder struct { + client *Client + bundle *forecast.Bundle + fetchedAt time.Time +} + +func (b *bundleBuilder) fetchObservation(ctx context.Context) error { + raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true}) + if err != nil { + return err + } + if raw == nil { + return b.handleMissing(&source, "observation data is missing", false) + } + var observation forecast.Observation + if err := decodeSource(raw, &observation); err != nil { + return b.handleMalformed(&source, err, false) + } + source.IssuedAt = &observation.Timestamp + b.bundle.Observation = &observation + b.addSource(source) + return nil +} + +func (b *bundleBuilder) fetchCurrent(ctx context.Context) error { + raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true}) + if err != nil { + return err + } + if raw == nil { + return b.handleMissing(&source, "current conditions data is missing", false) + } + var current forecast.Current + if err := decodeSource(raw, ¤t); err != nil { + return b.handleMalformed(&source, err, false) + } + b.bundle.Current = ¤t + b.addSource(source) + return nil +} + +func (b *bundleBuilder) fetchHourly(ctx context.Context) error { + raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true}) + if err != nil { + return err + } + if raw == nil { + return b.handleMissing(&source, "hourly forecast data is missing", true) + } + var hourly forecast.ForecastRun + if err := decodeSource(raw, &hourly); err != nil { + return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err) + } + if len(hourly.Periods) == 0 { + return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint) + } + source.IssuedAt = &hourly.IssuedAt + source.UpdatedAt = hourly.UpdatedAt + b.bundle.Hourly = &hourly + b.addSource(source) + return nil +} + +func (b *bundleBuilder) fetchNarrative(ctx context.Context) error { + raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true}) + if err != nil { + return err + } + if raw == nil { + return b.handleMissing(&source, "narrative forecast data is missing", false) + } + var narrative forecast.ForecastRun + if err := decodeSource(raw, &narrative); err != nil { + return b.handleMalformed(&source, err, false) + } + source.IssuedAt = &narrative.IssuedAt + source.UpdatedAt = narrative.UpdatedAt + b.bundle.Narrative = &narrative + b.addSource(source) + return nil +} + +func (b *bundleBuilder) fetchAlerts(ctx context.Context) error { + raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{}) + if err != nil { + return err + } + if raw == nil { + return b.handleMissing(&source, "active alerts data is missing", false) + } + var alerts forecast.AlertRun + if err := decodeSource(raw, &alerts); err != nil { + return b.handleMalformed(&source, err, false) + } + alerts.Raw = append(json.RawMessage(nil), raw...) + if alerts.AsOf != nil { + source.IssuedAt = alerts.AsOf + } + b.bundle.Alerts = &alerts + b.addSource(source) + return nil +} + +func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error { + raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true}) + if err != nil { + return err + } + if raw == nil { + return b.handleMissing(&source, "forecast discussion data is missing", false) + } + var discussion forecast.Discussion + if err := decodeSource(raw, &discussion); err != nil { + return b.handleMalformed(&source, err, false) + } + source.IssuedAt = &discussion.IssuedAt + source.UpdatedAt = discussion.UpdatedAt + b.bundle.Discussion = &discussion + b.addSource(source) + return nil +} + +func (b *bundleBuilder) addStub(sourceName string, message string) error { + source := forecast.Source{ + Name: sourceName, + FetchedAt: b.fetchedAt, + Missing: true, + } + return b.applyMissingPolicy(&source, "missing_source", message) +} + +func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error { + source.Missing = true + if required { + return fmt.Errorf("%s from %s is required", message, source.Endpoint) + } + return b.applyMissingPolicy(source, "missing_source", message) +} + +func (b *bundleBuilder) handleMalformed(source *forecast.Source, err error, required bool) error { + if required { + return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err) + } + source.Missing = true + return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err)) +} + +func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error { + policy := b.client.policyFor(source.Name) + if policy == config.MissingSourceError { + return fmt.Errorf("%s: %s", source.Name, message) + } + if policy == config.MissingSourceWarn { + warning := forecast.SourceWarning{ + Source: source.Name, + Code: code, + Severity: "warning", + Message: message, + Endpoint: source.Endpoint, + CompletenessImpact: "source omitted from bundle", + } + source.Warnings = append(source.Warnings, warning) + b.bundle.Warnings = append(b.bundle.Warnings, warning) + } + b.addSource(*source) + return nil +} + +func (b *bundleBuilder) addSource(source forecast.Source) { + b.bundle.Sources = append(b.bundle.Sources, source) +} + +func (c *Client) policyFor(source string) config.MissingSourcePolicy { + if policy, ok := c.missingSource.Sources[source]; ok { + return policy + } + return c.missingSource.Default +} + +type queryOptions struct { + precision bool + timezone bool +} + +type envelope struct { + Data json.RawMessage `json:"data"` +} + +func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) { + reqURL := c.endpointURL(endpoint, opts) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, forecast.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + if err != nil { + return nil, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body))) + } + + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err) + } + + source := forecast.Source{ + Name: sourceName, + Endpoint: endpoint, + Query: queryMap(reqURL.Query()), + FetchedAt: c.now(), + } + if len(env.Data) == 0 || bytes.Equal(bytes.TrimSpace(env.Data), []byte("null")) { + source.Missing = true + return nil, source, nil + } + hash, err := sourceHash(env.Data) + if err != nil { + return env.Data, source, nil + } + source.DataSHA256 = hash + return env.Data, source, nil +} + +func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL { + reqURL := *c.baseURL + reqURL.Path = path.Join(c.baseURL.Path, endpoint) + query := reqURL.Query() + query.Set("format", c.format) + query.Set("units", c.units) + if opts.precision { + query.Set("precision", strconv.Itoa(c.precision)) + } + if opts.timezone { + query.Set("tz", c.timezone) + } + reqURL.RawQuery = query.Encode() + return &reqURL +} + +func queryMap(values url.Values) map[string]string { + if len(values) == 0 { + return nil + } + out := make(map[string]string, len(values)) + for key, value := range values { + if len(value) > 0 { + out[key] = value[0] + } + } + return out +} + +func decodeSource(raw json.RawMessage, target any) error { + if err := json.Unmarshal(raw, target); err != nil { + return err + } + return nil +} + +func sourceHash(raw json.RawMessage) (string, error) { + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + return "", err + } + sum := sha256.Sum256(compact.Bytes()) + return hex.EncodeToString(sum[:]), nil +} + +func SaveBundle(path string, bundle *forecast.Bundle) error { + data, err := json.MarshalIndent(bundle, "", " ") + if err != nil { + return fmt.Errorf("marshal forecast bundle: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create bundle directory %q: %w", filepath.Dir(path), err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temporary bundle file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temporary bundle file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary bundle file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("save bundle %q: %w", path, err) + } + return nil +} diff --git a/internal/adapters/weatherapi/client_test.go b/internal/adapters/weatherapi/client_test.go new file mode 100644 index 0000000..ca1db34 --- /dev/null +++ b/internal/adapters/weatherapi/client_test.go @@ -0,0 +1,348 @@ +package weatherapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" +) + +func TestFetchBundleFromFixtures(t *testing.T) { + var requested []string + server := fixtureServer(t, nil, &requested) + client := newTestClient(t, server.URL+"/", nil) + + bundle, err := client.FetchBundle(context.Background()) + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + + if bundle.Observation == nil || bundle.Observation.StationID != "KSTL" { + t.Fatalf("Observation = %#v, want KSTL observation", bundle.Observation) + } + if bundle.Current == nil || bundle.Current.ConditionText != "Partly cloudy" { + t.Fatalf("Current = %#v, want current conditions", bundle.Current) + } + if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 { + t.Fatalf("Hourly = %#v, want one hourly period", bundle.Hourly) + } + if bundle.Narrative == nil || bundle.Narrative.Product != "narrative" { + t.Fatalf("Narrative = %#v, want narrative product", bundle.Narrative) + } + if bundle.Alerts == nil || bundle.Alerts.AsOf == nil { + t.Fatalf("Alerts = %#v, want alert run", bundle.Alerts) + } + if bundle.Discussion == nil || len(bundle.Discussion.KeyMessages) != 2 { + t.Fatalf("Discussion = %#v, want key messages", bundle.Discussion) + } + if len(bundle.Sources) != 8 { + t.Fatalf("Sources length = %d, want 8", len(bundle.Sources)) + } + if len(bundle.Warnings) != 2 { + t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings)) + } + if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") { + t.Fatalf("requested paths = %v, want full hourly endpoint only", requested) + } + if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") { + t.Fatalf("requested paths = %v, want full narrative endpoint only", requested) + } +} + +func TestFetchBundleBuildsExpectedQueries(t *testing.T) { + var requested []string + server := fixtureServer(t, nil, &requested) + client := newTestClient(t, server.URL+"/", nil) + + _, err := client.FetchBundle(context.Background()) + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + + for _, rawURL := range requested { + if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") { + t.Fatalf("request %q missing format=json or units=us", rawURL) + } + if strings.HasPrefix(rawURL, "/forecast/") { + if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=Chicago") { + t.Fatalf("forecast request %q missing precision or tz", rawURL) + } + } + } +} + +func TestFetchBundleRecordsSourceHash(t *testing.T) { + server := fixtureServer(t, nil, nil) + client := newTestClient(t, server.URL+"/", nil) + + bundle, err := client.FetchBundle(context.Background()) + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + + observation := sourceByName(t, bundle.Sources, "observations") + want := hashFixtureData(t, "observations.json") + if observation.DataSHA256 != want { + t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want) + } +} + +func TestHTTPErrorIsActionable(t *testing.T) { + server := fixtureServer(t, map[string]handlerOverride{ + "/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`}, + }, nil) + client := newTestClient(t, server.URL+"/", nil) + + _, err := client.FetchBundle(context.Background()) + if err == nil { + t.Fatal("FetchBundle() error = nil, want HTTP error") + } + if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") { + t.Fatalf("error = %q, want endpoint and status", err.Error()) + } +} + +func TestRequiredHourlyForecast(t *testing.T) { + server := fixtureServer(t, map[string]handlerOverride{ + "/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`}, + }, nil) + client := newTestClient(t, server.URL+"/", nil) + + _, err := client.FetchBundle(context.Background()) + if err == nil { + t.Fatal("FetchBundle() error = nil, want required hourly error") + } + if !strings.Contains(err.Error(), "hourly forecast data") { + t.Fatalf("error = %q, want hourly context", err.Error()) + } +} + +func TestMissingSourcePolicyWarnNoneError(t *testing.T) { + tests := []struct { + name string + policy config.MissingSourcePolicy + wantErr bool + wantWarns int + wantSource bool + }{ + {name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true}, + {name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true}, + {name: "error", policy: config.MissingSourceError, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := fixtureServer(t, map[string]handlerOverride{ + "/observations": {status: http.StatusOK, body: `{"data": null}`}, + }, nil) + cfg := testConfig(server.URL + "/") + cfg.MissingSource.Default = tt.policy + cfg.MissingSource.Sources = map[string]config.MissingSourcePolicy{ + "hourly": tt.policy, + } + client, err := New(cfg, WithClock(fixedNow)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + bundle, err := client.FetchBundle(context.Background()) + if tt.wantErr { + if err == nil { + t.Fatal("FetchBundle() error = nil, want policy error") + } + return + } + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + if len(bundle.Warnings) != tt.wantWarns { + t.Fatalf("Warnings length = %d, want %d", len(bundle.Warnings), tt.wantWarns) + } + if tt.wantSource { + source := sourceByName(t, bundle.Sources, "observations") + if !source.Missing { + t.Fatalf("observations source Missing = false, want true") + } + } + }) + } +} + +func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) { + server := fixtureServer(t, map[string]handlerOverride{ + "/conditions/current": {status: http.StatusOK, body: `{"data": {"temperatureF": "hot"}}`}, + }, nil) + client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{ + "current": config.MissingSourceWarn, + }) + + bundle, err := client.FetchBundle(context.Background()) + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + source := sourceByName(t, bundle.Sources, "current") + if !source.Missing || len(source.Warnings) != 1 { + t.Fatalf("current source = %#v, want missing source warning", source) + } +} + +func TestContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + client := newTestClient(t, server.URL+"/", nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := client.FetchBundle(ctx) + if err == nil { + t.Fatal("FetchBundle() error = nil, want cancellation error") + } +} + +func TestHTTPTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + defer server.Close() + + cfg := testConfig(server.URL + "/") + cfg.WeatherAPI.Timeout = time.Nanosecond + client, err := New(cfg, WithClock(fixedNow)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + _, err = client.FetchBundle(context.Background()) + if err == nil { + t.Fatal("FetchBundle() error = nil, want timeout error") + } + if !strings.Contains(err.Error(), "/observations") { + t.Fatalf("error = %q, want endpoint context", err.Error()) + } +} + +func TestSaveBundle(t *testing.T) { + server := fixtureServer(t, nil, nil) + client := newTestClient(t, server.URL+"/", nil) + bundle, err := client.FetchBundle(context.Background()) + if err != nil { + t.Fatalf("FetchBundle() error = %v", err) + } + + path := filepath.Join(t.TempDir(), "nested", "bundle.json") + if err := SaveBundle(path, bundle); err != nil { + t.Fatalf("SaveBundle() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved bundle: %v", err) + } + if !strings.Contains(string(data), `"hourly"`) { + t.Fatalf("saved bundle missing hourly source:\n%s", string(data)) + } +} + +type handlerOverride struct { + status int + body string +} + +func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server { + t.Helper() + fixtures := map[string]string{ + "/observations": "observations.json", + "/conditions/current": "current.json", + "/forecast/hourly": "hourly.json", + "/forecast/narrative": "narrative.json", + "/alerts/active": "alerts.json", + "/discussion": "discussion.json", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requested != nil { + *requested = append(*requested, r.URL.String()) + } + if override, ok := overrides[r.URL.Path]; ok { + w.WriteHeader(override.status) + _, _ = w.Write([]byte(override.body)) + return + } + name, ok := fixtures[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, filepath.Join("testdata", name)) + })) + t.Cleanup(server.Close) + return server +} + +func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]config.MissingSourcePolicy) *Client { + t.Helper() + cfg := testConfig(baseURL) + for source, policy := range sourcePolicies { + cfg.MissingSource.Sources[source] = policy + } + client, err := New(cfg, WithClock(fixedNow)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return client +} + +func testConfig(baseURL string) config.Config { + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = baseURL + return cfg +} + +func fixedNow() time.Time { + return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC) +} + +func containsPath(requested []string, path string) bool { + for _, rawURL := range requested { + if strings.HasPrefix(rawURL, path+"?") || rawURL == path { + return true + } + } + return false +} + +func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source { + t.Helper() + for _, source := range sources { + if source.Name == name { + return source + } + } + t.Fatalf("source %q not found in %#v", name, sources) + return forecast.Source{} +} + +func hashFixtureData(t *testing.T, fixture string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", fixture)) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var env envelope + if err := json.Unmarshal(data, &env); err != nil { + t.Fatalf("decode fixture envelope: %v", err) + } + hash, err := sourceHash(env.Data) + if err != nil { + t.Fatalf("hash fixture data: %v", err) + } + return hash +} diff --git a/internal/adapters/weatherapi/testdata/alerts.json b/internal/adapters/weatherapi/testdata/alerts.json new file mode 100644 index 0000000..1d6ba72 --- /dev/null +++ b/internal/adapters/weatherapi/testdata/alerts.json @@ -0,0 +1,6 @@ +{ + "data": { + "asOf": "2026-05-29T14:00:00Z", + "alerts": [] + } +} diff --git a/internal/adapters/weatherapi/testdata/current.json b/internal/adapters/weatherapi/testdata/current.json new file mode 100644 index 0000000..548dc79 --- /dev/null +++ b/internal/adapters/weatherapi/testdata/current.json @@ -0,0 +1,10 @@ +{ + "data": { + "conditionText": "Partly cloudy", + "isDay": true, + "temperatureF": 75.9, + "apparentTemperatureF": 76.1, + "windSpeedMph": 10.7, + "relativeHumidityPercent": 56 + } +} diff --git a/internal/adapters/weatherapi/testdata/discussion.json b/internal/adapters/weatherapi/testdata/discussion.json new file mode 100644 index 0000000..c72769e --- /dev/null +++ b/internal/adapters/weatherapi/testdata/discussion.json @@ -0,0 +1,16 @@ +{ + "data": { + "officeId": "LSX", + "officeName": "St. Louis", + "product": "discussion", + "issuedAt": "2026-05-29T09:25:00-05:00", + "keyMessages": [ + "Scattered showers possible this evening.", + "Warmer temperatures this weekend." + ], + "shortTerm": { + "title": "Short Term", + "narrative": "A weak boundary may trigger isolated showers." + } + } +} diff --git a/internal/adapters/weatherapi/testdata/hourly.json b/internal/adapters/weatherapi/testdata/hourly.json new file mode 100644 index 0000000..28af3c5 --- /dev/null +++ b/internal/adapters/weatherapi/testdata/hourly.json @@ -0,0 +1,25 @@ +{ + "data": { + "locationId": "nws-lsx-grid-90-74", + "locationName": "St. Louis, MO", + "issuedAt": "2026-05-29T10:30:00-05:00", + "updatedAt": "2026-05-29T10:45:00-05:00", + "product": "hourly", + "latitude": 38.63, + "longitude": -90.2, + "elevationFeet": 466, + "periods": [ + { + "startTime": "2026-05-29T13:00:00-05:00", + "endTime": "2026-05-29T14:00:00-05:00", + "isDay": true, + "conditionCode": 3, + "textDescription": "Partly sunny", + "temperatureF": 81, + "windSpeedMph": 12, + "windGustMph": 20, + "probabilityOfPrecipitationPercent": 10 + } + ] + } +} diff --git a/internal/adapters/weatherapi/testdata/narrative.json b/internal/adapters/weatherapi/testdata/narrative.json new file mode 100644 index 0000000..e1b4ee4 --- /dev/null +++ b/internal/adapters/weatherapi/testdata/narrative.json @@ -0,0 +1,20 @@ +{ + "data": { + "locationId": "nws-lsx-grid-90-74", + "locationName": "St. Louis, MO", + "issuedAt": "2026-05-29T10:30:00-05:00", + "product": "narrative", + "periods": [ + { + "startTime": "2026-05-29T13:00:00-05:00", + "endTime": "2026-05-29T19:00:00-05:00", + "name": "Today", + "isDay": true, + "textDescription": "Partly sunny, with a high near 81.", + "temperatureF": 81, + "windSpeedMph": 12, + "probabilityOfPrecipitationPercent": 10 + } + ] + } +} diff --git a/internal/adapters/weatherapi/testdata/observations.json b/internal/adapters/weatherapi/testdata/observations.json new file mode 100644 index 0000000..71fc857 --- /dev/null +++ b/internal/adapters/weatherapi/testdata/observations.json @@ -0,0 +1,13 @@ +{ + "data": { + "stationId": "KSTL", + "stationName": "St. Louis", + "timestamp": "2026-05-29T14:00:00Z", + "conditionCode": 3, + "isDay": true, + "textDescription": "Partly cloudy", + "temperatureF": 75.9, + "windSpeedMph": 10.7, + "relativeHumidityPercent": 56 + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 0fa1b85..39201ee 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,7 +6,9 @@ import ( "fmt" "time" + "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/weatherapi" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" ) type ReportKind string @@ -40,6 +42,11 @@ type BatchRequest struct { Batch BatchKind } +type FetchBundleRequest struct { + Config config.Config + OutputPath string +} + func Generate(ctx context.Context, req GenerateRequest) error { _ = ctx _ = req @@ -51,3 +58,29 @@ func RunBatch(ctx context.Context, req BatchRequest) error { _ = req return fmt.Errorf("run is not implemented") } + +func FetchBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { + client, err := weatherapi.New(req.Config) + if err != nil { + return nil, err + } + bundle, err := client.FetchBundle(ctx) + if err != nil { + return nil, err + } + return bundle, nil +} + +func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*forecast.Bundle, error) { + if req.OutputPath == "" { + return nil, fmt.Errorf("output path is required") + } + bundle, err := FetchBundle(ctx, req) + if err != nil { + return nil, err + } + if err := weatherapi.SaveBundle(req.OutputPath, bundle); err != nil { + return nil, err + } + return bundle, nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..afbc27a --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" +) + +func TestFetchAndSaveBundle(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/observations": + _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) + case "/conditions/current": + _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) + case "/forecast/hourly": + _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`)) + case "/forecast/narrative": + _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`)) + case "/alerts/active": + _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) + case "/discussion": + _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[]}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + cfg := config.Defaults() + cfg.WeatherAPI.BaseURL = server.URL + "/" + path := filepath.Join(t.TempDir(), "bundle.json") + + bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path}) + if err != nil { + t.Fatalf("FetchAndSaveBundle() error = %v", err) + } + if bundle.Hourly == nil { + t.Fatal("Hourly = nil, want fetched bundle") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved bundle: %v", err) + } + if !strings.Contains(string(data), `"product": "hourly"`) { + t.Fatalf("saved bundle missing hourly product:\n%s", string(data)) + } +} + +func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { + _, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()}) + if err == nil { + t.Fatal("FetchAndSaveBundle() error = nil, want output path error") + } + if !strings.Contains(err.Error(), "output path") { + t.Fatalf("error = %q, want output path context", err.Error()) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e3acc24..f35611a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,15 +72,15 @@ func TestInvalidConfigProducesActionableError(t *testing.T) { } func TestLoadAppliesOverrides(t *testing.T) { - cfg, err := Load(LoadOptions{Units: "metric", Timezone: "UTC", Output: "./out"}) + cfg, err := Load(LoadOptions{Units: "metric", Timezone: "+09:30", Output: "./out"}) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.WeatherAPI.Units != "metric" { t.Fatalf("Units = %q, want metric", cfg.WeatherAPI.Units) } - if cfg.WeatherAPI.Timezone != "UTC" { - t.Fatalf("Timezone = %q, want UTC", cfg.WeatherAPI.Timezone) + if cfg.WeatherAPI.Timezone != "+09:30" { + t.Fatalf("Timezone = %q, want +09:30", cfg.WeatherAPI.Timezone) } if cfg.Reports.OutputDir != "./out" { t.Fatalf("OutputDir = %q, want ./out", cfg.Reports.OutputDir) diff --git a/internal/config/validate.go b/internal/config/validate.go index 756ebd8..3080f9d 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -5,7 +5,8 @@ import ( "net/url" "strconv" "strings" - "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) func Validate(cfg Config) error { @@ -27,7 +28,7 @@ func Validate(cfg Config) error { if cfg.WeatherAPI.Timezone == "" { return fmt.Errorf("weather_api.timezone is required") } - if _, err := loadLocation(cfg.WeatherAPI.Timezone); err != nil { + if _, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone); err != nil { return fmt.Errorf("weather_api.timezone %q is invalid: %w", cfg.WeatherAPI.Timezone, err) } if cfg.WeatherAPI.Format == "" { @@ -78,17 +79,6 @@ func Validate(cfg Config) error { return nil } -func loadLocation(name string) (*time.Location, error) { - location, err := time.LoadLocation(name) - if err == nil { - return location, nil - } - if strings.Contains(name, "/") { - return nil, err - } - return time.LoadLocation("America/" + name) -} - func validatePolicy(name string, policy MissingSourcePolicy) error { switch policy { case MissingSourceError, MissingSourceWarn, MissingSourceNone: diff --git a/internal/forecast/bundle.go b/internal/forecast/bundle.go new file mode 100644 index 0000000..9235b6c --- /dev/null +++ b/internal/forecast/bundle.go @@ -0,0 +1,161 @@ +// Package forecast defines normalized weather data consumed by report builders. +package forecast + +import ( + "encoding/json" + "time" +) + +type Bundle struct { + FetchedAt time.Time `json:"fetchedAt"` + Observation *Observation `json:"observation,omitempty"` + Current *Current `json:"current,omitempty"` + Hourly *ForecastRun `json:"hourly,omitempty"` + Narrative *ForecastRun `json:"narrative,omitempty"` + Alerts *AlertRun `json:"alerts,omitempty"` + Discussion *Discussion `json:"discussion,omitempty"` + Daily *ForecastRun `json:"daily,omitempty"` + WeatherStory *WeatherStory `json:"weatherStory,omitempty"` + Sources []Source `json:"sources"` + Warnings []SourceWarning `json:"warnings,omitempty"` +} + +type Source struct { + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + Query map[string]string `json:"query,omitempty"` + FetchedAt time.Time `json:"fetchedAt"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + DataSHA256 string `json:"dataSha256,omitempty"` + Missing bool `json:"missing,omitempty"` + Warnings []SourceWarning `json:"warnings,omitempty"` +} + +type SourceWarning struct { + Source string `json:"source"` + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message"` + Endpoint string `json:"endpoint,omitempty"` + CompletenessImpact string `json:"completenessImpact,omitempty"` +} + +type Observation struct { + StationID string `json:"stationId,omitempty"` + StationName string `json:"stationName,omitempty"` + Timestamp time.Time `json:"timestamp"` + ConditionCode *int `json:"conditionCode,omitempty"` + IsDay *bool `json:"isDay,omitempty"` + TextDescription string `json:"textDescription,omitempty"` + TemperatureC *float64 `json:"temperatureC,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty"` + DewpointC *float64 `json:"dewpointC,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty"` + WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty"` + WindGustKmh *float64 `json:"windGustKmh,omitempty"` + WindGustMph *float64 `json:"windGustMph,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"` + BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"` + BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"` + VisibilityMeters *float64 `json:"visibilityMeters,omitempty"` + VisibilityMiles *float64 `json:"visibilityMiles,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` + ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"` + PresentWeather []json.RawMessage `json:"presentWeather,omitempty"` +} + +type Current struct { + ConditionText string `json:"conditionText,omitempty"` + IsDay *bool `json:"isDay,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"` + TemperatureC *float64 `json:"temperatureC,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty"` + ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"` + DewpointC *float64 `json:"dewpointC,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty"` + WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty"` +} + +type ForecastRun struct { + LocationID string `json:"locationId,omitempty"` + LocationName string `json:"locationName,omitempty"` + IssuedAt time.Time `json:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Product string `json:"product"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + ElevationMeters *float64 `json:"elevationMeters,omitempty"` + ElevationFeet *float64 `json:"elevationFeet,omitempty"` + Periods []ForecastPeriod `json:"periods"` +} + +type ForecastPeriod struct { + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + Name string `json:"name,omitempty"` + IsDay *bool `json:"isDay,omitempty"` + ConditionCode *int `json:"conditionCode,omitempty"` + TextDescription string `json:"textDescription,omitempty"` + TemperatureC *float64 `json:"temperatureC,omitempty"` + TemperatureF *float64 `json:"temperatureF,omitempty"` + TemperatureCMin *float64 `json:"temperatureCMin,omitempty"` + TemperatureFMin *float64 `json:"temperatureFMin,omitempty"` + TemperatureCMax *float64 `json:"temperatureCMax,omitempty"` + TemperatureFMax *float64 `json:"temperatureFMax,omitempty"` + DewpointC *float64 `json:"dewpointC,omitempty"` + DewpointF *float64 `json:"dewpointF,omitempty"` + WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"` + WindSpeedMph *float64 `json:"windSpeedMph,omitempty"` + WindGustKmh *float64 `json:"windGustKmh,omitempty"` + WindGustMph *float64 `json:"windGustMph,omitempty"` + WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"` + BarometricPressurePa *float64 `json:"barometricPressurePa,omitempty"` + BarometricPressureInHg *float64 `json:"barometricPressureInHg,omitempty"` + VisibilityMeters *float64 `json:"visibilityMeters,omitempty"` + VisibilityMiles *float64 `json:"visibilityMiles,omitempty"` + ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"` + ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"` + CloudCoverPercent *float64 `json:"cloudCoverPercent,omitempty"` + ProbabilityOfPrecipitationPercent *float64 `json:"probabilityOfPrecipitationPercent,omitempty"` + PrecipitationAmountMm *float64 `json:"precipitationAmountMm,omitempty"` + PrecipitationAmountIn *float64 `json:"precipitationAmountIn,omitempty"` + SnowfallDepthMM *float64 `json:"snowfallDepthMM,omitempty"` + SnowfallDepthIn *float64 `json:"snowfallDepthIn,omitempty"` + UVIndex *float64 `json:"uvIndex,omitempty"` + RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"` +} + +type AlertRun struct { + AsOf *time.Time `json:"asOf,omitempty"` + Alerts []json.RawMessage `json:"alerts,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +type Discussion struct { + OfficeID string `json:"officeId,omitempty"` + OfficeName string `json:"officeName,omitempty"` + Product string `json:"product"` + IssuedAt time.Time `json:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + KeyMessages []string `json:"keyMessages,omitempty"` + ShortTerm *DiscussionSection `json:"shortTerm,omitempty"` + LongTerm *DiscussionSection `json:"longTerm,omitempty"` +} + +type DiscussionSection struct { + Title string `json:"title,omitempty"` + Narrative string `json:"narrative,omitempty"` + IssuedAt *time.Time `json:"issuedAt,omitempty"` +} + +type WeatherStory struct { + IssuedAt *time.Time `json:"issuedAt,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} diff --git a/internal/timeutil/parse.go b/internal/timeutil/parse.go index 08d073d..b609456 100644 --- a/internal/timeutil/parse.go +++ b/internal/timeutil/parse.go @@ -2,6 +2,8 @@ package timeutil import ( "fmt" + "regexp" + "strconv" "strings" "time" ) @@ -10,6 +12,12 @@ const DateLayout = "2006-01-02" const LocalDateTimeLayout = "2006-01-02T15:04" func LoadLocation(name string) (*time.Location, error) { + if location, ok := timezoneAliases[name]; ok { + return location, nil + } + if location, ok := parseUTCOffset(name); ok { + return location, nil + } location, err := time.LoadLocation(name) if err == nil { return location, nil @@ -24,6 +32,52 @@ func LoadLocation(name string) (*time.Location, error) { return nil, fmt.Errorf("load timezone %q: %w", name, err) } +var timezoneAliases = map[string]*time.Location{ + "Chicago": mustLocation("America/Chicago"), + "Stl": mustLocation("America/Chicago"), + "EST": time.FixedZone("EST", -5*60*60), + "EDT": time.FixedZone("EDT", -4*60*60), + "CST": time.FixedZone("CST", -6*60*60), + "CDT": time.FixedZone("CDT", -5*60*60), + "MST": time.FixedZone("MST", -7*60*60), + "MDT": time.FixedZone("MDT", -6*60*60), + "PST": time.FixedZone("PST", -8*60*60), + "PDT": time.FixedZone("PDT", -7*60*60), +} + +func mustLocation(name string) *time.Location { + location, err := time.LoadLocation(name) + if err != nil { + panic(err) + } + return location +} + +var utcOffsetPattern = regexp.MustCompile(`^([+-])(\d{1,2})(?::?(\d{2}))?$`) + +func parseUTCOffset(value string) (*time.Location, bool) { + matches := utcOffsetPattern.FindStringSubmatch(value) + if matches == nil { + return nil, false + } + hours, err := strconv.Atoi(matches[2]) + if err != nil || hours > 23 { + return nil, false + } + minutes := 0 + if matches[3] != "" { + minutes, err = strconv.Atoi(matches[3]) + if err != nil || minutes > 59 { + return nil, false + } + } + offset := (hours*60 + minutes) * 60 + if matches[1] == "-" { + offset = -offset + } + return time.FixedZone(value, offset), true +} + func LocalDate(now time.Time, location *time.Location) time.Time { local := now.In(location) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) diff --git a/internal/timeutil/parse_test.go b/internal/timeutil/parse_test.go index 6be4843..00d01bd 100644 --- a/internal/timeutil/parse_test.go +++ b/internal/timeutil/parse_test.go @@ -15,6 +15,17 @@ func TestLoadLocationAcceptsChicagoAlias(t *testing.T) { } } +func TestLoadLocationAcceptsWeatherAPITimezones(t *testing.T) { + tests := []string{"Stl", "CDT", "-5", "+09:30"} + for _, tt := range tests { + t.Run(tt, func(t *testing.T) { + if _, err := LoadLocation(tt); err != nil { + t.Fatalf("LoadLocation(%q) error = %v", tt, err) + } + }) + } +} + func TestParseLocalDate(t *testing.T) { location := time.FixedZone("Test", -5*60*60) got, err := ParseLocalDate("2026-05-29", location)