From 55ee13d4ebc2fe1a9888a2f06b7c96c5a7222429 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 15:42:23 +0200 Subject: [PATCH] feat: timezone-aware schedule with browser tz and JST client conversion --- integrations/animeschedule/animeschedule.go | 114 ++++++++++++++---- .../animeschedule/animeschedule_test.go | 101 ++++++++++++++++ internal/anime/handler.go | 8 +- internal/anime/schedule.go | 27 +++-- static/schedule_board.ts | 46 ++++--- templates/schedule.gohtml | 7 +- 6 files changed, 255 insertions(+), 48 deletions(-) create mode 100644 integrations/animeschedule/animeschedule_test.go diff --git a/integrations/animeschedule/animeschedule.go b/integrations/animeschedule/animeschedule.go index 9febb58..e21c181 100644 --- a/integrations/animeschedule/animeschedule.go +++ b/integrations/animeschedule/animeschedule.go @@ -33,6 +33,7 @@ type Entry struct { ImageURL string EpisodeText string AirType AirType + AirsAt time.Time LocalTime string DateLabel string Weekday time.Weekday @@ -58,16 +59,28 @@ func (e *HTTPStatusError) Error() string { var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`) var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`) -func scheduleLocation() *time.Location { - // Use the host's local timezone (e.g. CEST) so the schedule matches the user's environment. - return time.Local +func scheduleLocation(timezone string) (*time.Location, error) { + timezone = strings.TrimSpace(timezone) + if timezone == "" { + timezone = "UTC" + } + location, err := time.LoadLocation(timezone) + if err != nil { + return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err) + } + return location, nil } -func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) (WeekSchedule, error) { +func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) { apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN")) if apiToken != "" { - return fetchWeekAPI(ctx, httpClient, apiToken, year, week) + return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone) + } + + location, err := scheduleLocation(timezone) + if err != nil { + return WeekSchedule{}, err } u, _ := url.Parse("https://animeschedule.net/") @@ -147,8 +160,8 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ") epText, _, airType := parseMeta(metaText) - localTime, _, _ := parseLocalTime(meta) - if title == "" || animeURL == "" || localTime == "" || airType == "" { + localTime, airsAt, _, _ := parseLocalTime(meta, location) + if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB { return } @@ -158,6 +171,7 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) ImageURL: imageURL, EpisodeText: epText, AirType: airType, + AirsAt: airsAt, LocalTime: localTime, DateLabel: rawHeader, Weekday: *weekday, @@ -168,7 +182,7 @@ func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) return } - out.Days[*weekday] = append(out.Days[*weekday], dayEntries...) + out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...) }) return out, nil @@ -241,7 +255,50 @@ func parseMeta(meta string) (episodeText string, localTime string, airType AirTy return episodeText, localTime, airType } -func parseLocalTime(meta *goquery.Selection) (localTime string, rawDatetime string, rawRenderedTime string) { +func preferredReleaseEntries(entries []Entry) []Entry { + type keyedEntry struct { + index int + entry Entry + } + + selected := map[string]keyedEntry{} + for i, entry := range entries { + key := entry.AnimeURL + "\x00" + entry.EpisodeText + current, ok := selected[key] + if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) { + selected[key] = keyedEntry{index: i, entry: entry} + } + } + + out := make([]keyedEntry, 0, len(selected)) + for _, entry := range selected { + out = append(out, entry) + } + slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int { + return a.index - b.index + }) + + preferred := make([]Entry, 0, len(out)) + for _, entry := range out { + preferred = append(preferred, entry.entry) + } + return preferred +} + +func airTypePriority(airType AirType) int { + switch airType { + case AirTypeSUB: + return 3 + case AirTypeDUB: + return 2 + case AirTypeJPN: + return 1 + default: + return 0 + } +} + +func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) { // AnimeSchedule updates rendered time client-side based on the viewer's timezone. // The server-rendered HTML can show a different time string, so we prefer the `datetime` // attribute when available. @@ -250,16 +307,27 @@ func parseLocalTime(meta *goquery.Selection) (localTime string, rawDatetime stri rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ") if raw, ok := t.Attr("datetime"); ok { rawDatetime = raw - if parsed, err := time.Parse(time.RFC3339, rawDatetime); err == nil { - localTime = parsed.In(scheduleLocation()).Format("03:04 PM") - return localTime, rawDatetime, rawRenderedTime + if parsed, err := parseScheduleDatetime(rawDatetime); err == nil { + airsAt = parsed.In(location) + localTime = airsAt.Format("15:04") + return localTime, airsAt, rawDatetime, rawRenderedTime } } } fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ") _, parsedTime, _ := parseMeta(fallback) - return parsedTime, "", "" + return parsedTime, time.Time{}, "", "" +} + +func parseScheduleDatetime(value string) (time.Time, error) { + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} { + parsed, err := time.Parse(layout, strings.TrimSpace(value)) + if err == nil { + return parsed, nil + } + } + return time.Time{}, fmt.Errorf("parse schedule datetime %q", value) } func absolutizeURL(base string, href string) string { @@ -304,23 +372,24 @@ type timetableAnimeAPI struct { ImageVersionRoute string `json:"imageVersionRoute"` } -func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int) (WeekSchedule, error) { +func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) { client := httpClient if client == nil { client = http.DefaultClient } + location, err := scheduleLocation(timezone) + if err != nil { + return WeekSchedule{}, err + } + u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub") q := u.Query() if year > 0 && week > 0 { q.Set("year", strconv.Itoa(year)) q.Set("week", strconv.Itoa(week)) } - tz := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_TZ")) - if tz == "" { - tz = "Europe/Copenhagen" - } - q.Set("tz", tz) + q.Set("tz", location.String()) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -386,13 +455,13 @@ func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, ye } airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType))) - if airType != AirTypeJPN && airType != AirTypeSUB && airType != AirTypeDUB { + if airType != AirTypeSUB { continue } - episodeTime := item.EpisodeDate.In(time.Local) + episodeTime := item.EpisodeDate.In(location) weekday := episodeTime.Weekday() - localTime := episodeTime.Format("03:04 PM") + localTime := episodeTime.Format("15:04") imageURL := "" if strings.TrimSpace(item.ImageVersionRoute) != "" { @@ -410,6 +479,7 @@ func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, ye ImageURL: imageURL, EpisodeText: episodeText, AirType: airType, + AirsAt: episodeTime, LocalTime: localTime, Weekday: weekday, }) diff --git a/integrations/animeschedule/animeschedule_test.go b/integrations/animeschedule/animeschedule_test.go new file mode 100644 index 0000000..178a72f --- /dev/null +++ b/integrations/animeschedule/animeschedule_test.go @@ -0,0 +1,101 @@ +package animeschedule + +import ( + "strings" + "testing" + "time" + + "github.com/PuerkitoBio/goquery" +) + +func TestParseLocalTimeUsesRequestedTimezone(t *testing.T) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(` +

+ Ep 9 + + SUB +

+ `)) + if err != nil { + t.Fatalf("parse document: %v", err) + } + + location, err := time.LoadLocation("Europe/Copenhagen") + if err != nil { + t.Fatalf("load location: %v", err) + } + + localTime, airsAt, _, rendered := parseLocalTime(doc.Find(".time-bar").First(), location) + + if localTime != "17:00" { + t.Fatalf("localTime = %q, want %q", localTime, "17:00") + } + if rendered != "04:00 PM" { + t.Fatalf("rendered = %q, want %q", rendered, "04:00 PM") + } + if airsAt.Location().String() != "Europe/Copenhagen" { + t.Fatalf("airsAt location = %q, want Europe/Copenhagen", airsAt.Location().String()) + } +} + +func TestParseLocalTimeUsesExactAngelNextDoorSubRelease(t *testing.T) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(` +

+ Ep 10 + + SUB +

+ `)) + if err != nil { + t.Fatalf("parse document: %v", err) + } + + location, err := time.LoadLocation("Europe/Copenhagen") + if err != nil { + t.Fatalf("load location: %v", err) + } + + localTime, _, _, _ := parseLocalTime(doc.Find(".time-bar").First(), location) + + if localTime != "16:30" { + t.Fatalf("localTime = %q, want %q", localTime, "16:30") + } +} + +func TestPreferredReleaseEntriesPrefersSubForSameEpisode(t *testing.T) { + entries := []Entry{ + { + Title: "Tensei shitara Slime Datta Ken 4th Season", + AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season", + EpisodeText: "Ep 9", + AirType: AirTypeJPN, + LocalTime: "16:00", + }, + { + Title: "Tensei shitara Slime Datta Ken 4th Season", + AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season", + EpisodeText: "Ep 9", + AirType: AirTypeSUB, + LocalTime: "17:00", + }, + { + Title: "Tensei shitara Slime Datta Ken 4th Season", + AnimeURL: "https://animeschedule.net/anime/tensei-shitara-slime-datta-ken-4th-season", + EpisodeText: "Ep 6", + AirType: AirTypeDUB, + LocalTime: "17:00", + }, + } + + got := preferredReleaseEntries(entries) + + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0].AirType != AirTypeSUB { + t.Fatalf("first air type = %q, want %q", got[0].AirType, AirTypeSUB) + } + if got[1].AirType != AirTypeDUB { + t.Fatalf("second air type = %q, want %q", got[1].AirType, AirTypeDUB) + } +} diff --git a/internal/anime/handler.go b/internal/anime/handler.go index d1d9c48..849af81 100644 --- a/internal/anime/handler.go +++ b/internal/anime/handler.go @@ -357,8 +357,9 @@ func (h *AnimeHandler) HandleSchedule(c *gin.Context) { func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) { year, week := parseYearWeek(c) + timezone := scheduleTimezone(c) - schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week) + schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone) if err != nil { prevYear, prevWeek := adjacentISOWeek(year, week, -1) nextYear, nextWeek := adjacentISOWeek(year, week, 1) @@ -367,8 +368,9 @@ func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) { "anime", "", map[string]any{ - "year": year, - "week": week, + "year": year, + "week": week, + "timezone": timezone, }, err, ) diff --git a/internal/anime/schedule.go b/internal/anime/schedule.go index a716c31..415fccb 100644 --- a/internal/anime/schedule.go +++ b/internal/anime/schedule.go @@ -33,8 +33,16 @@ func parseYearWeek(c *gin.Context) (int, int) { return year, week } -func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int) (animeschedule.WeekSchedule, error) { - cacheKey := fmt.Sprintf("%d-%02d", year, week) +func scheduleTimezone(c *gin.Context) string { + timezone := strings.TrimSpace(c.Query("timezone")) + if timezone == "" { + return "UTC" + } + return timezone +} + +func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) { + cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone) const ttl = 10 * time.Minute h.scheduleCacheMu.Lock() @@ -45,7 +53,7 @@ func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, return cached.value, nil } - value, err := animeschedule.FetchWeek(ctx, nil, year, week) + value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone) if err != nil { return animeschedule.WeekSchedule{}, err } @@ -71,6 +79,9 @@ func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) date := start.AddDate(0, 0, i) entries := schedule.Days[wd] sort.SliceStable(entries, func(i, j int) bool { + if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() { + return entries[i].AirsAt.Before(entries[j].AirsAt) + } return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime) }) out = append(out, scheduleDayView{ @@ -83,11 +94,13 @@ func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) } func localTimeMinutes(localTime string) int { - t, err := time.Parse("03:04 PM", localTime) - if err != nil { - return 0 + for _, layout := range []string{"15:04", "03:04 PM"} { + t, err := time.Parse(layout, localTime) + if err == nil { + return t.Hour()*60 + t.Minute() + } } - return t.Hour()*60 + t.Minute() + return 0 } func isoWeekStartMonday(year int, week int) time.Time { diff --git a/static/schedule_board.ts b/static/schedule_board.ts index 400966e..8b70dd5 100644 --- a/static/schedule_board.ts +++ b/static/schedule_board.ts @@ -1,5 +1,4 @@ import { - convertJstToLocalTime, isJstTimezone, normalizeWeekday, parseHHMM, @@ -73,10 +72,8 @@ interface LocalSlot { timeLabel: string; } -const formatLocalTime = (hour: number, minute: number, format: TimeFormat): string => { +const formatLocalTime = (date: Date, format: TimeFormat): string => { const use12h = format === "12"; - const date = new Date(); - date.setHours(hour, minute, 0, 0); const formatter = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", @@ -85,10 +82,33 @@ const formatLocalTime = (hour: number, minute: number, format: TimeFormat): stri return formatter.format(date); }; +const dateForDayIndex = (weekStartDate: Date, weekStart: WeekStart, dayIndex: number): Date => { + const dayOrder = orderedDayIndexes(weekStart); + const startIndex = dayOrder[0] ?? 1; + const date = new Date(weekStartDate); + date.setDate(date.getDate() + ((dayIndex - startIndex + 7) % 7)); + return date; +}; + +const jstBroadcastInstant = (sourceDate: Date, hour: number, minute: number): Date => + new Date( + Date.UTC( + sourceDate.getFullYear(), + sourceDate.getMonth(), + sourceDate.getDate(), + hour - 9, + minute, + 0, + 0, + ), + ); + const getLocalSlot = ( broadcastDay: string | null, broadcastTime: string | null, broadcastTimezone: string | null, + weekStartDate: Date, + weekStart: WeekStart, timeFormat: TimeFormat, ): LocalSlot | null => { const sourceDayIndex = normalizeWeekday(broadcastDay); @@ -96,18 +116,12 @@ const getLocalSlot = ( if (sourceDayIndex === null || !parsed) return null; if (!isJstTimezone(broadcastTimezone)) return null; - const localOffsetMinutes = -new Date().getTimezoneOffset(); - - const localTime = convertJstToLocalTime( - sourceDayIndex, - parsed.hour, - parsed.minute, - localOffsetMinutes, - ); - const timeLabel = formatLocalTime(localTime.hour, localTime.minute, timeFormat); + const sourceDate = dateForDayIndex(weekStartDate, weekStart, sourceDayIndex); + const localDate = jstBroadcastInstant(sourceDate, parsed.hour, parsed.minute); + const timeLabel = formatLocalTime(localDate, timeFormat); return { - localDayIndex: localTime.dayIndex, - localMinutes: localTime.totalMinutes, + localDayIndex: localDate.getDay(), + localMinutes: localDate.getHours() * 60 + localDate.getMinutes(), timeLabel, }; }; @@ -268,6 +282,8 @@ const buildBoard = (section: HTMLElement): void => { node.getAttribute("data-broadcast-day"), node.getAttribute("data-broadcast-time"), node.getAttribute("data-broadcast-timezone"), + weekStartDate, + settings.weekStart, settings.timeFormat, ); if (!slot) continue; diff --git a/templates/schedule.gohtml b/templates/schedule.gohtml index 6f86b9b..e8f58d2 100644 --- a/templates/schedule.gohtml +++ b/templates/schedule.gohtml @@ -7,7 +7,12 @@ -
+
{{template "schedule_skeleton"}}