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:
Quick
2026-05-29 12:16:18 -04:00
co-authored by Claude Opus 4.8
parent 3fe38c8c63
commit 58fc4abca8
4 changed files with 274 additions and 14 deletions
+17 -14
View File
@@ -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
+170
View File
@@ -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
}
+28
View File
@@ -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
+59
View File
@@ -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()