diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index cca3cda..dd02326 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -394,6 +394,148 @@ func TestForecastPrecisionTwo(t *testing.T) { } } +func TestForecastTimezoneOffsetUppercaseTZConvertsAllTimes(t *testing.T) { + issuedAt := time.Date(2026, 7, 10, 15, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(30 * time.Minute) + periodStart := issuedAt.Add(time.Hour) + periodEnd := periodStart.Add(time.Hour) + + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + Periods: []model.WeatherForecastPeriod{{ + StartTime: periodStart, + EndTime: periodEnd, + ConditionCode: model.WMOUnknown, + }}, + }, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?TZ=-5", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + payload := decodeForecastTimePayload(t, w) + assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60) + if payload.Data.UpdatedAt == nil { + t.Fatalf("expected updatedAt in payload") + } + assertOffsetSeconds(t, *payload.Data.UpdatedAt, -5*60*60) + assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) + assertOffsetSeconds(t, payload.Data.Periods[0].EndTime, -5*60*60) + if !payload.Data.IssuedAt.UTC().Equal(issuedAt) { + t.Fatalf("expected issuedAt instant to be preserved") + } +} + +func TestForecastTimezoneAbbreviationCDT(t *testing.T) { + issuedAt := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: issuedAt, + Periods: []model.WeatherForecastPeriod{{ + StartTime: issuedAt, + EndTime: issuedAt.Add(time.Hour), + ConditionCode: model.WMOUnknown, + }}, + }, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + payload := decodeForecastTimePayload(t, w) + assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60) + assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) +} + +func TestForecastTimezoneCityAliasChicago(t *testing.T) { + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: issuedAt, + Periods: []model.WeatherForecastPeriod{{ + StartTime: issuedAt, + EndTime: issuedAt.Add(time.Hour), + ConditionCode: model.WMOUnknown, + }}, + }, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=Chicago", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + payload := decodeForecastTimePayload(t, w) + assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60) + assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) +} + +func TestForecastTimezoneInvalidValueRejected(t *testing.T) { + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()}, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=not-a-timezone", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } + + var env apierrors.Envelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode error envelope: %v", err) + } + if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter { + t.Fatalf("expected invalid_parameter code, got %+v", env.Error) + } +} + +func TestForecastTimezoneConflictingKeyValuesRejected(t *testing.T) { + h := newHandler(t, &fakeService{ + forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()}, + }, "/forecast/hourly") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT&TZ=EST", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCurrentConditionsRejectTimezoneQueryParameter(t *testing.T) { + h := newHandler(t, &fakeService{}, "/conditions/current") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/conditions/current?tz=CDT", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") @@ -629,3 +771,35 @@ func testRenderers(t *testing.T) *render.Registry { func float64Ptr(v float64) *float64 { return &v } + +type forecastTimePayload struct { + Data struct { + IssuedAt time.Time `json:"issuedAt"` + UpdatedAt *time.Time `json:"updatedAt"` + Periods []struct { + StartTime time.Time `json:"startTime"` + EndTime time.Time `json:"endTime"` + } `json:"periods"` + } `json:"data"` +} + +func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload { + t.Helper() + + var payload forecastTimePayload + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode forecast payload: %v", err) + } + if len(payload.Data.Periods) == 0 { + t.Fatalf("expected non-empty periods") + } + return payload +} + +func assertOffsetSeconds(t *testing.T, ts time.Time, want int) { + t.Helper() + _, got := ts.Zone() + if got != want { + t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339)) + } +} diff --git a/internal/adapters/inbound/httpapi/forecast_endpoint.go b/internal/adapters/inbound/httpapi/forecast_endpoint.go index 86e7cde..d426412 100644 --- a/internal/adapters/inbound/httpapi/forecast_endpoint.go +++ b/internal/adapters/inbound/httpapi/forecast_endpoint.go @@ -14,13 +14,13 @@ import ( func forecastDefinition(svc Service) endpoint.Definition { return endpoint.GET( "/forecast/hourly", - bindPrecisionQuery, + bindForecastPrecisionQuery, func(ctx context.Context, req precisionQueryRequest) (any, error) { run, err := svc.LatestHourlyForecast(ctx) if err != nil { return nil, err } - return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision)}, nil + return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision, req.Timezone)}, nil }, endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), endpoint.WithTemplate("forecast_hourly.txt.tmpl"), diff --git a/internal/adapters/inbound/httpapi/presenter/forecast.go b/internal/adapters/inbound/httpapi/presenter/forecast.go index d929b93..b429906 100644 --- a/internal/adapters/inbound/httpapi/presenter/forecast.go +++ b/internal/adapters/inbound/httpapi/presenter/forecast.go @@ -51,7 +51,7 @@ type WeatherForecastPeriodUS struct { UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"` } -func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) any { +func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any { if run == nil { return nil } @@ -59,8 +59,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) out := WeatherForecastRunUS{ LocationID: run.LocationID, LocationName: run.LocationName, - IssuedAt: run.IssuedAt, - UpdatedAt: copyTimePtr(run.UpdatedAt), + IssuedAt: inLocationTime(run.IssuedAt, tz), + UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz), Product: run.Product, Latitude: copyFloat64Ptr(run.Latitude), Longitude: copyFloat64Ptr(run.Longitude), @@ -69,8 +69,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) } for _, p := range run.Periods { out.Periods = append(out.Periods, WeatherForecastPeriodUS{ - StartTime: p.StartTime, - EndTime: p.EndTime, + StartTime: inLocationTime(p.StartTime, tz), + EndTime: inLocationTime(p.EndTime, tz), Name: p.Name, IsDay: copyBoolPtr(p.IsDay), ConditionCode: p.ConditionCode, @@ -103,8 +103,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) out := model.WeatherForecastRun{ LocationID: run.LocationID, LocationName: run.LocationName, - IssuedAt: run.IssuedAt, - UpdatedAt: copyTimePtr(run.UpdatedAt), + IssuedAt: inLocationTime(run.IssuedAt, tz), + UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz), Product: run.Product, Latitude: copyFloat64Ptr(run.Latitude), Longitude: copyFloat64Ptr(run.Longitude), @@ -114,8 +114,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) for _, p := range run.Periods { out.Periods = append(out.Periods, model.WeatherForecastPeriod{ - StartTime: p.StartTime, - EndTime: p.EndTime, + StartTime: inLocationTime(p.StartTime, tz), + EndTime: inLocationTime(p.EndTime, tz), Name: p.Name, IsDay: copyBoolPtr(p.IsDay), ConditionCode: p.ConditionCode, diff --git a/internal/adapters/inbound/httpapi/presenter/helpers.go b/internal/adapters/inbound/httpapi/presenter/helpers.go index 35cc532..ebd22c1 100644 --- a/internal/adapters/inbound/httpapi/presenter/helpers.go +++ b/internal/adapters/inbound/httpapi/presenter/helpers.go @@ -47,6 +47,21 @@ func copyTimePtr(v *time.Time) *time.Time { return &out } +func inLocationTime(v time.Time, loc *time.Location) time.Time { + if loc == nil { + return v + } + return v.In(loc) +} + +func inLocationTimePtr(v *time.Time, loc *time.Location) *time.Time { + if v == nil { + return nil + } + out := inLocationTime(*v, loc) + return &out +} + func boolText(v *bool) string { if v == nil { return "" diff --git a/internal/adapters/inbound/httpapi/presenter/payload_test.go b/internal/adapters/inbound/httpapi/presenter/payload_test.go index d8f554a..1a711d8 100644 --- a/internal/adapters/inbound/httpapi/presenter/payload_test.go +++ b/internal/adapters/inbound/httpapi/presenter/payload_test.go @@ -64,7 +64,7 @@ func TestForecastPayloadUS(t *testing.T) { }}, } - payload := ForecastPayload(run, UnitsUS, 2) + payload := ForecastPayload(run, UnitsUS, 2, nil) converted, ok := payload.(WeatherForecastRunUS) if !ok { t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload) @@ -83,6 +83,81 @@ func TestForecastPayloadUS(t *testing.T) { assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001) } +func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) { + loc := time.FixedZone("UTC-05:00", -5*60*60) + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(30 * time.Minute) + run := &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + Periods: []model.WeatherForecastPeriod{{ + StartTime: issuedAt.Add(1 * time.Hour), + EndTime: issuedAt.Add(2 * time.Hour), + ConditionCode: model.WMOUnknown, + }}, + } + + metricPayload := ForecastPayload(run, UnitsMetric, 0, loc) + metric, ok := metricPayload.(*model.WeatherForecastRun) + if !ok { + t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) + } + assertOffsetSeconds(t, metric.IssuedAt, -5*60*60) + assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60) + assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60) + assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60) + if !metric.IssuedAt.UTC().Equal(issuedAt) { + t.Fatalf("expected metric issuedAt to preserve instant") + } + + usPayload := ForecastPayload(run, UnitsUS, 0, loc) + us, ok := usPayload.(WeatherForecastRunUS) + if !ok { + t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload) + } + assertOffsetSeconds(t, us.IssuedAt, -5*60*60) + assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60) + assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60) + assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60) + + // Source model remains untouched. + assertOffsetSeconds(t, run.IssuedAt, 0) + assertOffsetSeconds(t, *run.UpdatedAt, 0) +} + +func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) { + issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + updatedAt := issuedAt.Add(time.Hour) + run := &model.WeatherForecastRun{ + Product: model.ForecastProductHourly, + IssuedAt: issuedAt, + UpdatedAt: &updatedAt, + Periods: []model.WeatherForecastPeriod{{ + StartTime: issuedAt, + EndTime: issuedAt.Add(time.Hour), + ConditionCode: model.WMOUnknown, + }}, + } + + metricPayload := ForecastPayload(run, UnitsMetric, 0, nil) + metric, ok := metricPayload.(*model.WeatherForecastRun) + if !ok { + t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) + } + if metric == run { + t.Fatalf("expected metric payload to be copied") + } + if metric.UpdatedAt == run.UpdatedAt { + t.Fatalf("expected updatedAt pointer to be copied") + } + if !metric.IssuedAt.Equal(run.IssuedAt) { + t.Fatalf("expected issuedAt to remain unchanged without timezone flag") + } + assertOffsetSeconds(t, metric.IssuedAt, 0) + assertOffsetSeconds(t, metric.Periods[0].StartTime, 0) +} + func TestMetricCopyAndNilHandling(t *testing.T) { obs := &model.WeatherObservation{ TemperatureC: float64Ptr(20.6), @@ -104,7 +179,7 @@ func TestMetricCopyAndNilHandling(t *testing.T) { if ObservationPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil observation input to return nil payload") } - if ForecastPayload(nil, UnitsUS, 0) != nil { + if ForecastPayload(nil, UnitsUS, 0, nil) != nil { t.Fatalf("expected nil forecast input to return nil payload") } if AlertsPayload(nil, UnitsUS) != nil { @@ -190,7 +265,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) { IssuedAt: time.Now().UTC(), } - metricPayload := ForecastPayload(run, UnitsMetric, 0) + metricPayload := ForecastPayload(run, UnitsMetric, 0, nil) metric, ok := metricPayload.(*model.WeatherForecastRun) if !ok { t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) @@ -199,7 +274,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) { assertApprox(t, metric.Longitude, -90.199456, 0.000001) assertApprox(t, metric.ElevationMeters, 10, 0.0001) - usPayload := ForecastPayload(run, UnitsUS, 0) + usPayload := ForecastPayload(run, UnitsUS, 0, nil) us, ok := usPayload.(WeatherForecastRunUS) if !ok { t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload) @@ -225,3 +300,11 @@ func assertApprox(t *testing.T, got *float64, want, eps float64) { t.Fatalf("expected %f +/- %f, got %f", want, eps, *got) } } + +func assertOffsetSeconds(t *testing.T, ts time.Time, want int) { + t.Helper() + _, got := ts.Zone() + if got != want { + t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339)) + } +} diff --git a/internal/adapters/inbound/httpapi/query_bind.go b/internal/adapters/inbound/httpapi/query_bind.go index 40b6bc8..55b1067 100644 --- a/internal/adapters/inbound/httpapi/query_bind.go +++ b/internal/adapters/inbound/httpapi/query_bind.go @@ -5,6 +5,7 @@ package httpapi import ( "net/http" "strings" + "time" "gitea.maximumdirect.net/ejr/feedapi/bind" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" @@ -17,6 +18,7 @@ type queryRequest struct { type precisionQueryRequest struct { Units presenter.Units Precision int + Timezone *time.Location } func bindQuery(r *http.Request) (queryRequest, error) { @@ -41,16 +43,29 @@ func bindQuery(r *http.Request) (queryRequest, error) { } func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) { + return bindPrecisionQueryInternal(r, false) +} + +func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) { + return bindPrecisionQueryInternal(r, true) +} + +func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) { normalizeCommonQueryValue(r, "units") normalizeCommonQueryValue(r, "format") normalizeCommonQueryValue(r, "precision") + allowedExtra := []string{"precision"} + if allowTimezone { + allowedExtra = append(allowedExtra, "tz", "TZ") + } + common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ AllowUnits: true, AllowFormat: true, DefaultUnits: string(presenter.UnitsMetric), RejectUnknown: true, - }, "precision") + }, allowedExtra...) if err != nil { return precisionQueryRequest{}, err } @@ -70,5 +85,18 @@ func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) { if units == "" { units = presenter.UnitsMetric } - return precisionQueryRequest{Units: units, Precision: precision}, nil + + var tz *time.Location + if allowTimezone { + tz, err = parseTimezoneQuery(r) + if err != nil { + return precisionQueryRequest{}, err + } + } + + return precisionQueryRequest{ + Units: units, + Precision: precision, + Timezone: tz, + }, nil } diff --git a/internal/adapters/inbound/httpapi/query_timezone.go b/internal/adapters/inbound/httpapi/query_timezone.go new file mode 100644 index 0000000..773ce7e --- /dev/null +++ b/internal/adapters/inbound/httpapi/query_timezone.go @@ -0,0 +1,140 @@ +// query_timezone.go parses and validates timezone query parameters. +// Layer: adapters/inbound/httpapi request binding helpers. +package httpapi + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + apierrors "gitea.maximumdirect.net/ejr/feedapi/errors" +) + +const maxUTCOffsetSeconds = 14 * 60 * 60 + +var usTimezoneAbbreviations = map[string]int{ + "CDT": -5 * 60 * 60, + "CST": -6 * 60 * 60, + "EDT": -4 * 60 * 60, + "EST": -5 * 60 * 60, + "MDT": -6 * 60 * 60, + "MST": -7 * 60 * 60, + "PDT": -7 * 60 * 60, + "PST": -8 * 60 * 60, +} + +var timezoneAliases = map[string]string{ + "chicago": "America/Chicago", + "stl": "America/Chicago", +} + +func parseTimezoneQuery(r *http.Request) (*time.Location, error) { + lower := strings.TrimSpace(r.URL.Query().Get("tz")) + upper := strings.TrimSpace(r.URL.Query().Get("TZ")) + if lower != "" && upper != "" && !strings.EqualFold(lower, upper) { + return nil, apierrors.InvalidParameter("tz and TZ must match when both are provided") + } + + raw := lower + if raw == "" { + raw = upper + } + if raw == "" { + return nil, nil + } + + loc, err := parseTimezoneValue(raw) + if err != nil { + return nil, apierrors.InvalidParameter(err.Error()) + } + return loc, nil +} + +func parseTimezoneValue(raw string) (*time.Location, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + if offsetSeconds, ok, err := parseUTCOffsetSeconds(raw); ok { + if err != nil { + return nil, err + } + return time.FixedZone(formatUTCOffsetName(offsetSeconds), offsetSeconds), nil + } + + if offsetSeconds, ok := usTimezoneAbbreviations[strings.ToUpper(raw)]; ok { + return time.FixedZone(strings.ToUpper(raw), offsetSeconds), nil + } + + if alias, ok := timezoneAliases[strings.ToLower(raw)]; ok { + raw = alias + } + + loc, err := time.LoadLocation(raw) + if err != nil { + return nil, fmt.Errorf("tz must be a valid timezone") + } + return loc, nil +} + +func parseUTCOffsetSeconds(raw string) (int, bool, error) { + if len(raw) < 2 { + return 0, false, nil + } + sign := raw[0] + if sign != '+' && sign != '-' { + return 0, false, nil + } + + remainder := raw[1:] + parts := strings.Split(remainder, ":") + if len(parts) > 2 { + return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") + } + + hours, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") + } + if hours < 0 { + return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") + } + + minutes := 0 + if len(parts) == 2 { + if len(parts[1]) != 2 { + return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") + } + minutes, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format") + } + } + if minutes < 0 || minutes > 59 { + return 0, true, fmt.Errorf("tz offset minutes must be between 00 and 59") + } + + offset := (hours * 60 * 60) + (minutes * 60) + if sign == '-' { + offset = -offset + } + if offset < -maxUTCOffsetSeconds || offset > maxUTCOffsetSeconds { + return 0, true, fmt.Errorf("tz offset must be between -14 and +14 hours") + } + + return offset, true, nil +} + +func formatUTCOffsetName(offsetSeconds int) string { + sign := "+" + if offsetSeconds < 0 { + sign = "-" + offsetSeconds = -offsetSeconds + } + hours := offsetSeconds / 3600 + minutes := (offsetSeconds % 3600) / 60 + return fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes) +}