fix(calendar): order events by viewer-local wall-clock time
The local-airtime change re-sorted calendar events in Go using air_at, the absolute UTC instant, which is nil whenever air_timezone is unset. Since air_timezone is only inferred for a few networks/countries, most events fell through to the alphabetical title tiebreak while still displaying their raw air_time, so each day appeared scrambled. Sort each local day by the wall-clock time the viewer actually sees, mirroring the client: zoned events convert air_at into the viewer timezone, unzoned events use the raw air_time, and date-only entries (no air_time) sort last. The timezone reasoning lives in the new catalog.CalendarEventLocalTime helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user