From 5dd6eedc3fff29b10a96095d8e53ac4c91f38979 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 27 May 2026 10:56:37 +0200 Subject: [PATCH] feat: wire scraped schedule into handler with caching and week nav --- internal/anime/handler/handler.go | 142 ++++++++++++++++++++--- templates/base.gohtml | 1 + templates/renderer.go | 3 + templates/schedule.gohtml | 186 ++++++++++++++++++++++++------ 4 files changed, 279 insertions(+), 53 deletions(-) diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go index 3f967dc..79ae397 100644 --- a/internal/anime/handler/handler.go +++ b/internal/anime/handler/handler.go @@ -3,6 +3,7 @@ package handler import ( "context" "fmt" + "mal/integrations/animeschedule" "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" @@ -12,6 +13,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" @@ -20,6 +22,14 @@ import ( type AnimeHandler struct { svc domain.AnimeService watchlistSvc domain.WatchlistService + + scheduleCacheMu sync.Mutex + scheduleCache map[string]cachedWeekSchedule +} + +type cachedWeekSchedule struct { + fetchedAt time.Time + value animeschedule.WeekSchedule } func wrapAnimes(in []jikan.Anime) []domain.Anime { @@ -32,8 +42,9 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime { func NewAnimeHandler(svc domain.AnimeService, watchlistSvc domain.WatchlistService) *AnimeHandler { return &AnimeHandler{ - svc: svc, - watchlistSvc: watchlistSvc, + svc: svc, + watchlistSvc: watchlistSvc, + scheduleCache: map[string]cachedWeekSchedule{}, } } @@ -310,43 +321,140 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { func (h *AnimeHandler) HandleSchedule(c *gin.Context) { user, _ := c.Get("User") + year, week := parseYearWeek(c) c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ - "CurrentPath": "/schedule", - "User": user, + "CurrentPath": "/schedule", + "User": user, + "ScheduleYear": year, + "ScheduleWeek": week, }) } func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) { - user, _ := c.Get("User") - userID := "" - if u, ok := user.(*domain.User); ok { - userID = u.ID - } + year, week := parseYearWeek(c) - animes, err := h.svc.GetAiringSchedule(c.Request.Context(), userID) + schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week) if err != nil { + prevYear, prevWeek := adjacentISOWeek(year, week, -1) + nextYear, nextWeek := adjacentISOWeek(year, week, 1) observability.Warn( - "schedule_fetch_failed", + "animeschedule_fetch_failed", "anime", "", map[string]any{ - "user_id": userID, + "year": year, + "week": week, }, err, ) - c.AbortWithStatus(http.StatusInternalServerError) + c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ + "_fragment": "schedule_section_scraped", + "ScheduleDays": []any{}, + "ScheduleYear": year, + "ScheduleWeek": week, + "PrevYear": prevYear, + "PrevWeek": prevWeek, + "NextYear": nextYear, + "NextWeek": nextWeek, + "ScheduleError": true, + }) return } - watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) + days := buildScheduleDays(schedule, schedule.Year, schedule.Week) + prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1) + nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1) c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ - "_fragment": "schedule_section", - "Animes": animes, - "WatchlistMap": watchlistMap, + "_fragment": "schedule_section_scraped", + "ScheduleDays": days, + "ScheduleYear": schedule.Year, + "ScheduleWeek": schedule.Week, + "PrevYear": prevYear, + "PrevWeek": prevWeek, + "NextYear": nextYear, + "NextWeek": nextWeek, }) } +func parseYearWeek(c *gin.Context) (int, int) { + year, _ := strconv.Atoi(c.Query("year")) + week, _ := strconv.Atoi(c.Query("week")) + if year <= 0 || week <= 0 { + now := time.Now() + y, w := now.ISOWeek() + if year <= 0 { + year = y + } + if week <= 0 { + week = w + } + } + return year, week +} + +func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int) (animeschedule.WeekSchedule, error) { + cacheKey := fmt.Sprintf("%d-%02d", year, week) + const ttl = 10 * time.Minute + + h.scheduleCacheMu.Lock() + cached, ok := h.scheduleCache[cacheKey] + h.scheduleCacheMu.Unlock() + + if ok && time.Since(cached.fetchedAt) < ttl { + return cached.value, nil + } + + value, err := animeschedule.FetchWeek(ctx, nil, year, week) + if err != nil { + return animeschedule.WeekSchedule{}, err + } + + h.scheduleCacheMu.Lock() + h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value} + h.scheduleCacheMu.Unlock() + + return value, nil +} + +type scheduleDayView struct { + DateLabel string + WeekdayLabel string + Entries []animeschedule.Entry +} + +func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView { + start := isoWeekStartMonday(year, week) + order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday} + out := make([]scheduleDayView, 0, 7) + for i, wd := range order { + date := start.AddDate(0, 0, i) + out = append(out, scheduleDayView{ + DateLabel: strings.ToUpper(date.Format("02 Jan")), + WeekdayLabel: wd.String(), + Entries: schedule.Days[wd], + }) + } + return out +} + +func isoWeekStartMonday(year int, week int) time.Time { + // ISO week 1 is the week with the year's first Thursday in it. + jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local) + // Move back to Monday + offset := int(time.Monday - jan4.Weekday()) + if offset > 0 { + offset -= 7 + } + week1Monday := jan4.AddDate(0, 0, offset) + return week1Monday.AddDate(0, 0, (week-1)*7) +} + +func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) { + target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7) + return target.ISOWeek() +} + func (h *AnimeHandler) HandleBrowse(c *gin.Context) { q := c.Query("q") animeType := c.Query("type") diff --git a/templates/base.gohtml b/templates/base.gohtml index 625c355..7808a4e 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -83,6 +83,7 @@ +