Add timezone support to /forecast/hourly endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user