diff --git a/internal/dashboard/period.go b/internal/dashboard/period.go index 168c49d..74c3f20 100644 --- a/internal/dashboard/period.go +++ b/internal/dashboard/period.go @@ -6,7 +6,7 @@ import ( ) func PeriodWindows(eventTime time.Time, statZone *time.Location) []PeriodWindow { - localDate := startOfDay(eventTime.In(statZone)) + localDate := startOfDay(wallClockInLocation(eventTime, statZone)) dayEnd := localDate.AddDate(0, 0, 1) weekday := int(localDate.Weekday()) @@ -59,8 +59,8 @@ func SamePeriod(left time.Time, right time.Time, window PeriodWindow, statZone * if window.Type == PeriodAll { return true } - leftLocal := left.In(statZone) - rightLocal := right.In(statZone) + leftLocal := wallClockInLocation(left, statZone) + rightLocal := wallClockInLocation(right, statZone) switch window.Type { case PeriodDay: return startOfDay(leftLocal).Equal(startOfDay(rightLocal)) @@ -76,7 +76,7 @@ func SamePeriod(left time.Time, right time.Time, window PeriodWindow, statZone * } func DailyDate(eventTime time.Time, storageZone *time.Location) time.Time { - return startOfDay(eventTime.In(storageZone)) + return startOfDay(wallClockInLocation(eventTime, storageZone)) } func DateNumber(day time.Time) int { @@ -93,3 +93,15 @@ func startOfDay(value time.Time) time.Time { year, month, date := value.Date() return time.Date(year, month, date, 0, 0, 0, 0, value.Location()) } + +func wallClockInLocation(value time.Time, location *time.Location) time.Time { + if value.IsZero() { + return value + } + if location == nil { + location = time.UTC + } + year, month, day := value.Date() + hour, minute, second := value.Clock() + return time.Date(year, month, day, hour, minute, second, value.Nanosecond(), location) +} diff --git a/internal/dashboard/period_test.go b/internal/dashboard/period_test.go new file mode 100644 index 0000000..a8cc88c --- /dev/null +++ b/internal/dashboard/period_test.go @@ -0,0 +1,50 @@ +package dashboard + +import ( + "testing" + "time" +) + +func TestDailyDateKeepsDatabaseDatetimeCalendarDay(t *testing.T) { + riyadh := mustLoadLocation(t, "Asia/Riyadh") + eventTime := time.Date(2026, 5, 26, 1, 30, 0, 0, time.UTC) + + got := DailyDate(eventTime, riyadh) + + if got.Format("2006-01-02") != "2026-05-26" { + t.Fatalf("DailyDate shifted database DATETIME calendar day: got %s", got.Format("2006-01-02")) + } + if got.Location() != riyadh { + t.Fatalf("DailyDate location = %s, want %s", got.Location(), riyadh) + } +} + +func TestPeriodWindowsKeepsDatabaseDatetimeCalendarDay(t *testing.T) { + riyadh := mustLoadLocation(t, "Asia/Riyadh") + eventTime := time.Date(2026, 5, 26, 1, 30, 0, 0, time.UTC) + + windows := PeriodWindows(eventTime, riyadh) + + if windows[0].Type != PeriodDay || windows[0].Key != "2026-05-26" { + t.Fatalf("day window = %+v, want key 2026-05-26", windows[0]) + } +} + +func TestSamePeriodKeepsDatabaseDatetimeCalendarDay(t *testing.T) { + riyadh := mustLoadLocation(t, "Asia/Riyadh") + left := time.Date(2026, 5, 26, 0, 10, 0, 0, time.UTC) + right := time.Date(2026, 5, 26, 23, 50, 0, 0, time.UTC) + + if !SamePeriod(left, right, PeriodWindow{Type: PeriodDay}, riyadh) { + t.Fatal("SamePeriod shifted database DATETIME values out of the same calendar day") + } +} + +func mustLoadLocation(t *testing.T, name string) *time.Location { + t.Helper() + location, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("load location %s: %v", name, err) + } + return location +}