diff --git a/internal/api/handlers/calendar.go b/internal/api/handlers/calendar.go index 3812c668..cecbb382 100644 --- a/internal/api/handlers/calendar.go +++ b/internal/api/handlers/calendar.go @@ -154,23 +154,23 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv event catalog.CalendarEvent localDate string sourceDate string - airAt *time.Time + localTime time.Time + hasTime bool airAtString *string } + startDate := start.Format("2006-01-02") + endDate := end.Format("2006-01-02") + prepared := make([]preparedCalendarEvent, 0, len(events)) for _, ev := range events { - airAt := catalog.CalendarEventAirAt(ev.AirDate, ev.AirTime, ev.AirTimezone) - localDateTime := ev.AirDate - if airAt != nil { - localDateTime = airAt.In(viewerLocation) - } - localDate := localDateTime.Format("2006-01-02") - if localDate < start.Format("2006-01-02") || localDate > end.Format("2006-01-02") { + localTime, hasTime := catalog.CalendarEventLocalTime(ev.AirDate, ev.AirTime, ev.AirTimezone, viewerLocation) + localDate := localTime.Format("2006-01-02") + if localDate < startDate || localDate > endDate { continue } var airAtString *string - if airAt != nil { + if airAt := catalog.CalendarEventAirAt(ev.AirDate, ev.AirTime, ev.AirTimezone); airAt != nil { formatted := airAt.Format(time.RFC3339) airAtString = &formatted } @@ -178,7 +178,8 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv event: ev, localDate: localDate, sourceDate: ev.AirDate.Format("2006-01-02"), - airAt: airAt, + localTime: localTime, + hasTime: hasTime, airAtString: airAtString, }) } @@ -186,16 +187,18 @@ func groupEventsByDate(events []catalog.CalendarEvent, r *http.Request, detailSv return []calendarDayResponse{} } + // Order each local day by the wall-clock time the viewer actually sees, + // then place date-only entries (no air_time) after timed entries. sort.SliceStable(prepared, func(i, j int) bool { left, right := prepared[i], prepared[j] if left.localDate != right.localDate { return left.localDate < right.localDate } - if left.airAt != nil && right.airAt != nil && !left.airAt.Equal(*right.airAt) { - return left.airAt.Before(*right.airAt) + if left.hasTime != right.hasTime { + return left.hasTime } - if (left.airAt != nil) != (right.airAt != nil) { - return left.airAt != nil + if left.hasTime && !left.localTime.Equal(right.localTime) { + return left.localTime.Before(right.localTime) } if left.event.Title != right.event.Title { return left.event.Title < right.event.Title diff --git a/internal/api/handlers/calendar_test.go b/internal/api/handlers/calendar_test.go index f9d2acd8..7ffe5d62 100644 --- a/internal/api/handlers/calendar_test.go +++ b/internal/api/handlers/calendar_test.go @@ -245,6 +245,176 @@ func TestHandleGetCalendar_GroupsEventsAndBatchResolvesCardPosters(t *testing.T) } } +func TestHandleGetCalendar_OrdersEventsWithoutTimezoneByWallClock(t *testing.T) { + t.Parallel() + + late := "22:00" + early := "10:00" + repo := &stubCalendarRepo{ + events: []catalog.CalendarEvent{ + { + ContentID: "ep-late", + Type: "episode", + Title: "Aaa Show", // alphabetically first, but airs late + SeriesID: ptrString("series-late"), + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + AirTime: &late, + // no AirTimezone -> air_at is nil + }, + { + ContentID: "ep-early", + Type: "episode", + Title: "Zzz Show", // alphabetically last, but airs early + SeriesID: ptrString("series-early"), + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + AirTime: &early, + }, + }, + } + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp calendarResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Events) != 1 { + t.Fatalf("days len = %d, want 1", len(resp.Events)) + } + items := resp.Events[0].Items + if len(items) != 2 { + t.Fatalf("items len = %d, want 2", len(items)) + } + if items[0].ContentID != "ep-early" || items[1].ContentID != "ep-late" { + t.Fatalf("order = [%s, %s], want [ep-early, ep-late] (ascending by air_time)", + items[0].ContentID, items[1].ContentID) + } +} + +func TestHandleGetCalendar_InterleavesZonedAndUnzonedByViewerLocalTime(t *testing.T) { + t.Parallel() + + eightPM := "20:00" + sixPM := "18:00" + londonNine := "21:00" + london := "Europe/London" + repo := &stubCalendarRepo{ + events: []catalog.CalendarEvent{ + { + ContentID: "alpha-8pm", + Type: "episode", + Title: "Alpha", // would sort first alphabetically + SeriesID: ptrString("series-alpha"), + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + AirTime: &eightPM, // displays 8:00 PM ET + }, + { + ContentID: "bravo-4pm", + Type: "episode", + Title: "Bravo", + SeriesID: ptrString("series-bravo"), + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + AirTime: &londonNine, // 21:00 GMT -> 16:00 ET (4:00 PM) + AirTimezone: &london, + }, + { + ContentID: "charlie-6pm", + Type: "episode", + Title: "Charlie", + SeriesID: ptrString("series-charlie"), + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + AirTime: &sixPM, // displays 6:00 PM ET + }, + }, + } + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp calendarResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Events) != 1 { + t.Fatalf("days len = %d, want 1", len(resp.Events)) + } + got := make([]string, 0, len(resp.Events[0].Items)) + for _, item := range resp.Events[0].Items { + got = append(got, item.ContentID) + } + want := []string{"bravo-4pm", "charlie-6pm", "alpha-8pm"} + if len(got) != len(want) { + t.Fatalf("items = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("order = %v, want %v (ascending by viewer-local time)", got, want) + } + } +} + +func TestHandleGetCalendar_DateOnlyEntriesSortAfterTimedEntries(t *testing.T) { + t.Parallel() + + noon := "12:00" + repo := &stubCalendarRepo{ + events: []catalog.CalendarEvent{ + { + ContentID: "movie-1", + Type: "movie", + Title: "Aaa Movie", // alphabetically first, but has no air_time + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + { + ContentID: "ep-noon", + Type: "episode", + Title: "Zzz Show", + SeriesID: ptrString("series-z"), + AirDate: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + AirTime: &noon, + }, + }, + } + handler := &CalendarHandler{repo: repo} + req := httptest.NewRequest(http.MethodGet, "/calendar?start=2026-01-01&end=2026-01-01&timezone=America/New_York", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCalendar(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp calendarResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Events) != 1 { + t.Fatalf("days len = %d, want 1", len(resp.Events)) + } + items := resp.Events[0].Items + if len(items) != 2 { + t.Fatalf("items len = %d, want 2", len(items)) + } + if items[0].ContentID != "ep-noon" || items[1].ContentID != "movie-1" { + t.Fatalf("order = [%s, %s], want [ep-noon, movie-1] (timed before date-only)", + items[0].ContentID, items[1].ContentID) + } +} + func ptrString(value string) *string { return &value } diff --git a/internal/catalog/air_schedule.go b/internal/catalog/air_schedule.go index b4a9bade..c8d15af3 100644 --- a/internal/catalog/air_schedule.go +++ b/internal/catalog/air_schedule.go @@ -87,6 +87,34 @@ func CalendarEventAirAt(airDate time.Time, airTime, airTimezone *string) *time.T return &utc } +// CalendarEventLocalTime returns the wall-clock moment used to display and +// order a calendar event for a viewer in loc, plus whether the event has a +// known time of day. +// +// When the event's source timezone is known, the absolute instant is converted +// into loc. When it is not, the stored wall-clock air_time is interpreted +// directly in loc — mirroring the client, which renders the raw air_time when +// air_at is absent. Date-only entries (no air_time, e.g. movie releases) return +// the start of the source day with hasTime=false so they sort after timed +// entries within the same local day. +func CalendarEventLocalTime(airDate time.Time, airTime, airTimezone *string, loc *time.Location) (time.Time, bool) { + if loc == nil { + loc = time.UTC + } + if at := CalendarEventAirAt(airDate, airTime, airTimezone); at != nil { + return at.In(loc), true + } + if airTime != nil { + if parsed, ok := parseAirTime(*airTime); ok { + return time.Date( + airDate.Year(), airDate.Month(), airDate.Day(), + parsed.Hour(), parsed.Minute(), parsed.Second(), 0, loc, + ), true + } + } + return time.Date(airDate.Year(), airDate.Month(), airDate.Day(), 0, 0, 0, 0, loc), false +} + func CalendarLocation(name string) *time.Location { if strings.TrimSpace(name) == "" { return time.UTC diff --git a/internal/catalog/air_schedule_test.go b/internal/catalog/air_schedule_test.go index 65fa139b..7fd783f3 100644 --- a/internal/catalog/air_schedule_test.go +++ b/internal/catalog/air_schedule_test.go @@ -52,6 +52,65 @@ func TestCalendarEventAirAt_ConvertsSourceTimezoneToUTC(t *testing.T) { } } +func TestCalendarEventLocalTime_ConvertsZonedEventToViewer(t *testing.T) { + t.Parallel() + + ny, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("load location: %v", err) + } + airDate := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + airTime := "21:00" + airTimezone := "Europe/London" + + got, hasTime := CalendarEventLocalTime(airDate, &airTime, &airTimezone, ny) + if !hasTime { + t.Fatal("expected hasTime=true for a zoned event") + } + // 21:00 GMT == 16:00 EST, same calendar day. + want := time.Date(2026, time.January, 1, 16, 0, 0, 0, ny) + if !got.Equal(want) { + t.Fatalf("local time = %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } +} + +func TestCalendarEventLocalTime_UsesRawWallClockWithoutTimezone(t *testing.T) { + t.Parallel() + + ny, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("load location: %v", err) + } + airDate := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + airTime := "20:00" + + got, hasTime := CalendarEventLocalTime(airDate, &airTime, nil, ny) + if !hasTime { + t.Fatal("expected hasTime=true when air_time is present") + } + // No source timezone: the wall-clock time is shown as-is (8:00 PM), not converted. + if got.Hour() != 20 || got.Minute() != 0 { + t.Fatalf("local time = %02d:%02d, want 20:00", got.Hour(), got.Minute()) + } + if got.Format("2006-01-02") != "2026-01-01" { + t.Fatalf("local date = %s, want 2026-01-01", got.Format("2006-01-02")) + } +} + +func TestCalendarEventLocalTime_DateOnlyEntryHasNoTime(t *testing.T) { + t.Parallel() + + airDate := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + + got, hasTime := CalendarEventLocalTime(airDate, nil, nil, nil) + if hasTime { + t.Fatal("expected hasTime=false when air_time is absent") + } + if got.Format("2006-01-02") != "2026-01-01" { + t.Fatalf("local date = %s, want 2026-01-01", got.Format("2006-01-02")) + } +} + func TestCalendarEventAirAt_UsesDSTForSourceDate(t *testing.T) { t.Parallel()