diff --git a/internal/normalizers/openmeteo/common.go b/internal/normalizers/openmeteo/common.go index e948485..c2b630b 100644 --- a/internal/normalizers/openmeteo/common.go +++ b/internal/normalizers/openmeteo/common.go @@ -2,39 +2,18 @@ package openmeteo import ( - "fmt" - "strings" "time" + + openmeteo "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo" ) // parseOpenMeteoTime parses Open-Meteo timestamps. // -// Open-Meteo commonly returns "YYYY-MM-DDTHH:MM" (no timezone suffix) when timezone -// is provided separately. When a timezone suffix is present (RFC3339), we accept it too. +// The actual parsing logic lives in internal/providers/openmeteo so both the +// source (envelope EffectiveAt / event ID) and normalizer (canonical payload) +// can share identical timestamp behavior. // -// This is provider-specific because it relies on Open-Meteo's timezone and offset fields. +// We keep this thin wrapper to avoid churn in the normalizer package. func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) { - s = strings.TrimSpace(s) - if s == "" { - return time.Time{}, fmt.Errorf("empty time") - } - - // If the server returned an RFC3339 timestamp with timezone, treat it as authoritative. - if t, err := time.Parse(time.RFC3339, s); err == nil { - return t, nil - } - - // Typical Open-Meteo format: "2006-01-02T15:04" - const layout = "2006-01-02T15:04" - - // Best effort: try to load the timezone as an IANA name. - if tz != "" { - if loc, err := time.LoadLocation(tz); err == nil { - return time.ParseInLocation(layout, s, loc) - } - } - - // Fallback: use a fixed zone from the offset seconds. - loc := time.FixedZone("open-meteo", utcOffsetSeconds) - return time.ParseInLocation(layout, s, loc) + return openmeteo.ParseTime(s, tz, utcOffsetSeconds) } diff --git a/internal/providers/openmeteo/time.go b/internal/providers/openmeteo/time.go new file mode 100644 index 0000000..5f32660 --- /dev/null +++ b/internal/providers/openmeteo/time.go @@ -0,0 +1,79 @@ +// FILE: ./internal/providers/openmeteo/time.go +package openmeteo + +import ( + "fmt" + "strings" + "time" +) + +// ParseTime parses timestamps as returned by Open-Meteo. +// +// Open-Meteo commonly returns timestamps in one of these forms: +// +// - RFC3339 / RFC3339Nano with an explicit timezone suffix, e.g. +// "2026-01-10T18:30:00Z" or "2026-01-10T12:30:00-06:00" +// +// - A local time string WITHOUT a timezone suffix, typically: +// "YYYY-MM-DDTHH:MM" (and sometimes "YYYY-MM-DDTHH:MM:SS") +// In that case, the timezone is provided separately via the top-level +// "timezone" field (IANA name) and/or "utc_offset_seconds". +// +// Parsing strategy: +// +// 1. If the timestamp includes a timezone suffix (RFC3339/RFC3339Nano), +// treat it as authoritative. +// +// 2. Otherwise, attempt to interpret the timestamp in the provided IANA +// timezone name (if loadable), then fall back to a fixed zone derived +// from utcOffsetSeconds. +// +// This helper lives in internal/providers so BOTH sources and normalizers can +// share Open-Meteo quirks without duplicating logic. +func ParseTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, fmt.Errorf("empty time") + } + + // If the server returned a timezone-aware RFC3339 timestamp, accept it. + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t, nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + + // Open-Meteo "local timestamp" formats (no timezone suffix). + // Commonly "2006-01-02T15:04"; sometimes seconds are present. + layouts := []string{ + "2006-01-02T15:04:05", + "2006-01-02T15:04", + } + + // Prefer the IANA timezone name if it's valid on this system. + if tz = strings.TrimSpace(tz); tz != "" { + if loc, err := time.LoadLocation(tz); err == nil { + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, s, loc); err == nil { + return t, nil + } + } + } + } + + // Fall back to a fixed zone derived from the offset seconds. + zoneName := tz + if zoneName == "" { + zoneName = "open-meteo" + } + loc := time.FixedZone(zoneName, utcOffsetSeconds) + + for _, layout := range layouts { + if t, err := time.ParseInLocation(layout, s, loc); err == nil { + return t, nil + } + } + + return time.Time{}, fmt.Errorf("unsupported open-meteo timestamp format: %q", s) +} diff --git a/internal/sources/nws/observation.go b/internal/sources/nws/observation.go index 7984444..5d6b01e 100644 --- a/internal/sources/nws/observation.go +++ b/internal/sources/nws/observation.go @@ -17,10 +17,6 @@ import ( // ObservationSource polls an NWS station observation endpoint and emits a RAW observation Event. // -// Key refactor: -// - Source responsibility: fetch bytes + emit a valid event envelope. -// - Normalizer responsibility: interpret raw JSON + map to canonical domain model. -// // This corresponds to URLs like: // // https://api.weather.gov/stations/KSTL/observations/latest @@ -62,7 +58,7 @@ func (s *ObservationSource) Poll(ctx context.Context) ([]event.Event, error) { } // Event.ID must be set BEFORE normalization (feedkit requires it). - // Prefer NWS-provided "id" (stable URL). Fallback to a stable-ish computed key. + // Prefer NWS-provided "id" (stable URL). Fallback to a stable computed key. eventID := strings.TrimSpace(meta.ID) if eventID == "" { ts := meta.ParsedTimestamp diff --git a/internal/sources/openmeteo/observation.go b/internal/sources/openmeteo/observation.go index d10454f..f819beb 100644 --- a/internal/sources/openmeteo/observation.go +++ b/internal/sources/openmeteo/observation.go @@ -11,6 +11,7 @@ import ( "gitea.maximumdirect.net/ejr/feedkit/config" "gitea.maximumdirect.net/ejr/feedkit/event" + "gitea.maximumdirect.net/ejr/weatherfeeder/internal/providers/openmeteo" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/sources/common" "gitea.maximumdirect.net/ejr/weatherfeeder/internal/standards" ) @@ -103,7 +104,9 @@ func (s *ObservationSource) fetchRaw(ctx context.Context) (json.RawMessage, open return raw, openMeteoMeta{}, nil } - if t, err := parseOpenMeteoTime(meta.Current.Time, meta.Timezone, meta.UTCOffsetSeconds); err == nil { + // Best effort: compute a stable EffectiveAt + event ID component. + // If parsing fails, we simply omit EffectiveAt and fall back to time.Now() in buildEventID. + if t, err := openmeteo.ParseTime(meta.Current.Time, meta.Timezone, meta.UTCOffsetSeconds); err == nil { meta.ParsedTimestamp = t.UTC() } @@ -125,25 +128,3 @@ func buildEventID(sourceName string, meta openMeteoMeta) string { return fmt.Sprintf("openmeteo:current:%s:%s:%s", sourceName, locKey, ts.Format(time.RFC3339Nano)) } - -func parseOpenMeteoTime(s string, tz string, utcOffsetSeconds int) (time.Time, error) { - s = strings.TrimSpace(s) - if s == "" { - return time.Time{}, fmt.Errorf("empty time") - } - - if t, err := time.Parse(time.RFC3339, s); err == nil { - return t, nil - } - - const layout = "2006-01-02T15:04" - - if tz != "" { - if loc, err := time.LoadLocation(tz); err == nil { - return time.ParseInLocation(layout, s, loc) - } - } - - loc := time.FixedZone("open-meteo", utcOffsetSeconds) - return time.ParseInLocation(layout, s, loc) -}