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 @@
+