From c8e0c673ca1627daa872ef9a2ea84b2b50d2c674 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 27 May 2026 10:56:21 +0200 Subject: [PATCH 001/381] feat: add animeschedule integration --- integrations/animeschedule/animeschedule.go | 479 ++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 integrations/animeschedule/animeschedule.go diff --git a/integrations/animeschedule/animeschedule.go b/integrations/animeschedule/animeschedule.go new file mode 100644 index 0000000..43cc76b --- /dev/null +++ b/integrations/animeschedule/animeschedule.go @@ -0,0 +1,479 @@ +package animeschedule + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mal/internal/observability" + "mal/pkg/net/limits" + "mal/pkg/net/useragent" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +type AirType string + +const ( + AirTypeJPN AirType = "JPN" + AirTypeSUB AirType = "SUB" + AirTypeDUB AirType = "DUB" +) + +type Entry struct { + Title string + AnimeURL string + ImageURL string + EpisodeText string + AirType AirType + LocalTime string + DateLabel string + Weekday time.Weekday +} + +type WeekSchedule struct { + Year int + Week int + Days map[time.Weekday][]Entry +} + +type HTTPStatusError struct { + StatusCode int + URL string + ContentType string + BodyPreview string +} + +func (e *HTTPStatusError) Error() string { + return fmt.Sprintf("unexpected status %d for %s", e.StatusCode, e.URL) +} + +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 FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int) (WeekSchedule, error) { + debugScrape := os.Getenv("ANIMESCHEDULE_DEBUG_SCRAPE") == "1" + apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN")) + + if apiToken != "" { + return fetchWeekAPI(ctx, httpClient, apiToken, year, week) + } + + u, _ := url.Parse("https://animeschedule.net/") + q := u.Query() + if year > 0 { + q.Set("year", strconv.Itoa(year)) + } + if week > 0 { + q.Set("week", strconv.Itoa(week)) + } + u.RawQuery = q.Encode() + + doc, finalURL, err := fetchDocument(ctx, httpClient, u.String()) + if err != nil { + return WeekSchedule{}, err + } + + resolvedYear := year + resolvedWeek := week + if resolvedWeek == 0 { + if match := reWeek.FindStringSubmatch(finalURL); len(match) == 2 { + if v, err := strconv.Atoi(match[1]); err == nil { + resolvedWeek = v + } + } + } + if resolvedYear == 0 { + if match := reYear.FindStringSubmatch(finalURL); len(match) == 2 { + if v, err := strconv.Atoi(match[1]); err == nil { + resolvedYear = v + } + } + } + + out := WeekSchedule{ + Year: resolvedYear, + Week: resolvedWeek, + Days: map[time.Weekday][]Entry{}, + } + + doc.Find(".timetable-column").Each(func(_ int, column *goquery.Selection) { + h1 := column.Find("h1.timetable-column-date").First() + rawHeader := strings.Join(strings.Fields(strings.TrimSpace(h1.Text())), " ") + weekday := parseWeekdayFromHeader(rawHeader) + if weekday == nil { + return + } + + dayEntries := make([]Entry, 0, 16) + + column.Find(".timetable-column-show").Each(func(_ int, show *goquery.Selection) { + if selectionHasClass(show, "filtered-out") { + return + } + + a := show.Find("a.show-link").First() + title := strings.TrimSpace(a.Find("h2").First().Text()) + if title == "" { + title = strings.TrimSpace(a.Text()) + } + href, _ := a.Attr("href") + animeURL := absolutizeURL("https://animeschedule.net", href) + + imageURL := "" + if img := a.Find("img").First(); img != nil && img.Length() == 1 { + if src, ok := img.Attr("data-src"); ok { + imageURL = strings.TrimSpace(src) + } + if imageURL == "" { + if src, ok := img.Attr("src"); ok && !strings.HasPrefix(src, "data:") { + imageURL = strings.TrimSpace(src) + } + } + } + + meta := show.Find("h3.time-bar").First() + metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ") + + epText, _, airType := parseMeta(metaText) + localTime, rawDatetime, rawRenderedTime := parseLocalTime(meta) + if title == "" || animeURL == "" || localTime == "" || airType == "" { + return + } + + if debugScrape { + observability.LogJSON( + observability.LogLevelInfo, + "animeschedule_scrape_time", + "integrations/animeschedule", + "scraped time info for entry", + map[string]any{ + "title": title, + "anime_url": animeURL, + "meta_text": metaText, + "raw_datetime": rawDatetime, + "raw_renderedTime": rawRenderedTime, + "local_time": localTime, + "week": resolvedWeek, + "year": resolvedYear, + }, + nil, + ) + } + + dayEntries = append(dayEntries, Entry{ + Title: title, + AnimeURL: animeURL, + ImageURL: imageURL, + EpisodeText: epText, + AirType: airType, + LocalTime: localTime, + DateLabel: rawHeader, + Weekday: *weekday, + }) + }) + + if len(dayEntries) == 0 { + return + } + + out.Days[*weekday] = append(out.Days[*weekday], dayEntries...) + }) + + return out, nil +} + +func selectionHasClass(selection *goquery.Selection, className string) bool { + raw, ok := selection.Attr("class") + if !ok { + return false + } + for _, class := range strings.Fields(raw) { + if class == className { + return true + } + } + return false +} + +func parseWeekdayFromHeader(header string) *time.Weekday { + lower := strings.ToLower(header) + candidates := []struct { + key string + val time.Weekday + }{ + {"monday", time.Monday}, + {"tuesday", time.Tuesday}, + {"wednesday", time.Wednesday}, + {"thursday", time.Thursday}, + {"friday", time.Friday}, + {"saturday", time.Saturday}, + {"sunday", time.Sunday}, + } + for _, c := range candidates { + if strings.Contains(lower, c.key) { + v := c.val + return &v + } + } + return nil +} + +func parseMeta(meta string) (episodeText string, localTime string, airType AirType) { + // Example: "Ep 8 04:00 PM SUB" + parts := strings.Fields(meta) + if len(parts) < 4 { + return "", "", "" + } + + // Find the time token(s) + var timeIdx int = -1 + for i := 0; i < len(parts); i++ { + if strings.Contains(parts[i], ":") && len(parts[i]) >= 4 { + timeIdx = i + break + } + } + if timeIdx == -1 || timeIdx+2 >= len(parts) { + return "", "", "" + } + + localTime = strings.TrimSpace(parts[timeIdx] + " " + parts[timeIdx+1]) + typeRaw := strings.TrimSpace(parts[timeIdx+2]) + switch strings.ToUpper(typeRaw) { + case "JPN": + airType = AirTypeJPN + case "SUB": + airType = AirTypeSUB + case "DUB": + airType = AirTypeDUB + default: + return "", "", "" + } + + episodeText = strings.TrimSpace(strings.Join(parts[:timeIdx], " ")) + return episodeText, localTime, airType +} + +func parseLocalTime(meta *goquery.Selection) (localTime string, 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. + t := meta.Find("time").First() + if t.Length() == 1 { + 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 + } + } + } + + fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ") + _, parsedTime, _ := parseMeta(fallback) + return parsedTime, "", "" +} + +func absolutizeURL(base string, href string) string { + href = strings.TrimSpace(href) + if href == "" { + return "" + } + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(href, "/") +} + +func addCommonHeaders(request *http.Request) { + request.Header.Set("User-Agent", useragent.Chrome135) + request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8") + request.Header.Set("Accept-Language", "en-US,en;q=0.9") + request.Header.Set("Referer", "https://animeschedule.net/") + request.Header.Set("Cache-Control", "no-cache") +} + +func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, string, error) { + client := httpClient + if client == nil { + client = http.DefaultClient + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, url, fmt.Errorf("failed to create request: %w", err) + } + addCommonHeaders(request) + + response, err := client.Do(request) + if err != nil { + return nil, url, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(response.Body, limits.Bytes512)) + return nil, url, &HTTPStatusError{ + StatusCode: response.StatusCode, + URL: url, + ContentType: strings.TrimSpace(response.Header.Get("Content-Type")), + BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "), + } + } + + document, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + return nil, url, fmt.Errorf("failed to parse html: %w", err) + } + + return document, response.Request.URL.String(), nil +} + +type timetableAnimeAPI struct { + Title string `json:"title"` + Route string `json:"route"` + EpisodeDate time.Time `json:"episodeDate"` + EpisodeNumber int `json:"episodeNumber"` + SubtractedEpisodeNumber int `json:"subtractedEpisodeNumber"` + AirType string `json:"airType"` + ImageVersionRoute string `json:"imageVersionRoute"` +} + +func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int) (WeekSchedule, error) { + client := httpClient + if client == nil { + client = http.DefaultClient + } + + 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) + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return WeekSchedule{}, fmt.Errorf("create api request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", useragent.Chrome135) + + res, err := client.Do(req) + if err != nil { + return WeekSchedule{}, fmt.Errorf("api request failed: %w", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(res.Body, limits.Bytes512)) + return WeekSchedule{}, &HTTPStatusError{ + StatusCode: res.StatusCode, + URL: u.String(), + ContentType: strings.TrimSpace(res.Header.Get("Content-Type")), + BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "), + } + } + + var payload []timetableAnimeAPI + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + return WeekSchedule{}, fmt.Errorf("decode timetables api: %w", err) + } + + resolvedYear := year + resolvedWeek := week + if resolvedYear == 0 || resolvedWeek == 0 { + resolvedYear, resolvedWeek = time.Now().In(time.Local).ISOWeek() + } + + out := WeekSchedule{ + Year: resolvedYear, + Week: resolvedWeek, + Days: map[time.Weekday][]Entry{}, + } + + for _, item := range payload { + title := strings.TrimSpace(item.Title) + if title == "" { + continue + } + + episodeNumber := item.EpisodeNumber + subtracted := item.SubtractedEpisodeNumber + episodeText := "" + switch { + case subtracted > 0 && subtracted < episodeNumber: + episodeText = fmt.Sprintf("Ep %d-%d", subtracted, episodeNumber) + case episodeNumber > 0: + episodeText = fmt.Sprintf("Ep %d", episodeNumber) + default: + episodeText = "Ep ?" + } + + airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType))) + if airType != AirTypeJPN && airType != AirTypeSUB && airType != AirTypeDUB { + continue + } + + episodeTime := item.EpisodeDate.In(time.Local) + weekday := episodeTime.Weekday() + localTime := episodeTime.Format("03:04 PM") + + imageURL := "" + if strings.TrimSpace(item.ImageVersionRoute) != "" { + imageURL = "https://img.animeschedule.net/production/assets/public/img/" + strings.TrimLeft(strings.TrimSpace(item.ImageVersionRoute), "/") + } + + animeURL := "" + if strings.TrimSpace(item.Route) != "" { + animeURL = "https://animeschedule.net/anime/" + strings.TrimLeft(strings.TrimSpace(item.Route), "/") + } + + out.Days[weekday] = append(out.Days[weekday], Entry{ + Title: title, + AnimeURL: animeURL, + ImageURL: imageURL, + EpisodeText: episodeText, + AirType: airType, + LocalTime: localTime, + Weekday: weekday, + }) + } + + observability.LogJSON( + observability.LogLevelInfo, + "animeschedule_api_timetables_loaded", + "integrations/animeschedule", + "loaded timetable entries via api", + map[string]any{ + "count": len(payload), + "year": out.Year, + "week": out.Week, + "tz": tz, + }, + nil, + ) + + return out, nil +} From c044ebdda096de2afb5c5926dc6da5cb084e503d Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 27 May 2026 10:56:28 +0200 Subject: [PATCH 002/381] feat: add schedule board client logic --- static/schedule_board.ts | 379 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 static/schedule_board.ts diff --git a/static/schedule_board.ts b/static/schedule_board.ts new file mode 100644 index 0000000..18072ba --- /dev/null +++ b/static/schedule_board.ts @@ -0,0 +1,379 @@ +export {}; + +type WeekStart = 'monday' | 'sunday' | 'saturday'; +type TimeFormat = '24' | '12'; +type SortBy = 'time' | 'alpha' | 'score'; + +interface ScheduleSettings { + weekStart: WeekStart; + timeFormat: TimeFormat; + showImages: boolean; + sortBy: SortBy; + weekOffset: number; +} + +const settingsKey = 'schedule_settings_v1'; + +const defaultSettings: ScheduleSettings = { + weekStart: 'monday', + timeFormat: '24', + showImages: true, + sortBy: 'time', + weekOffset: 0, +}; + +const parseJSON = (value: string | null): unknown => { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +}; + +const isWeekStart = (value: unknown): value is WeekStart => + value === 'monday' || value === 'sunday' || value === 'saturday'; + +const isTimeFormat = (value: unknown): value is TimeFormat => value === '24' || value === '12'; + +const isSortBy = (value: unknown): value is SortBy => + value === 'time' || value === 'alpha' || value === 'score'; + +const loadSettings = (): ScheduleSettings => { + const raw = parseJSON(localStorage.getItem(settingsKey)); + if (!raw || typeof raw !== 'object') return { ...defaultSettings }; + const obj = raw as Record; + + const weekStart = isWeekStart(obj.weekStart) ? obj.weekStart : defaultSettings.weekStart; + const timeFormat = isTimeFormat(obj.timeFormat) ? obj.timeFormat : defaultSettings.timeFormat; + const showImages = + typeof obj.showImages === 'boolean' ? obj.showImages : defaultSettings.showImages; + const sortBy = isSortBy(obj.sortBy) ? obj.sortBy : defaultSettings.sortBy; + const weekOffset = Number.isFinite(obj.weekOffset) + ? Number(obj.weekOffset) + : defaultSettings.weekOffset; + + return { weekStart, timeFormat, showImages, sortBy, weekOffset }; +}; + +const saveSettings = (settings: ScheduleSettings): void => { + localStorage.setItem(settingsKey, JSON.stringify(settings)); +}; + +interface ParsedTime { + hour: number; + minute: number; +} + +const parseHHMM = (value: string | null): ParsedTime | null => { + if (!value) return null; + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if ( + Number.isNaN(hour) || + Number.isNaN(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return { hour, minute }; +}; + +const normalizeWeekday = (value: string | null): number | null => { + if (!value) return null; + const key = value.trim().toLowerCase().replace(/s$/, ''); + const map: Record = { + sun: 0, + sunday: 0, + mon: 1, + monday: 1, + tue: 2, + tues: 2, + tuesday: 2, + wed: 3, + wednesday: 3, + thu: 4, + thur: 4, + thurs: 4, + thursday: 4, + fri: 5, + friday: 5, + sat: 6, + saturday: 6, + }; + return typeof map[key] === 'number' ? map[key] : null; +}; + +const isJstTimezone = (tz: string | null): boolean => { + const normalized = (tz ?? '').trim().toLowerCase(); + if (!normalized) return true; + return normalized === 'asia/tokyo' || normalized === 'jst'; +}; + +interface LocalSlot { + localDayIndex: number; + localMinutes: number; + timeLabel: string; +} + +const formatLocalTime = (hour: number, minute: number, 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', + hour12: use12h, + }); + return formatter.format(date); +}; + +const getLocalSlot = ( + broadcastDay: string | null, + broadcastTime: string | null, + broadcastTimezone: string | null, + timeFormat: TimeFormat +): LocalSlot | null => { + const sourceDayIndex = normalizeWeekday(broadcastDay); + const parsed = parseHHMM(broadcastTime); + if (sourceDayIndex === null || !parsed) return null; + if (!isJstTimezone(broadcastTimezone)) return null; + + // Treat the broadcast time as JST (UTC+9) and convert using the user's current local offset. + const jstOffsetMinutes = 9 * 60; + const localOffsetMinutes = -new Date().getTimezoneOffset(); + + const sourceMinutes = parsed.hour * 60 + parsed.minute; + const diff = jstOffsetMinutes - localOffsetMinutes; // JST ahead of local + const localTotal = sourceMinutes - diff; + + const dayShift = Math.floor(localTotal / 1440); + const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440; + const localHour = Math.floor(normalizedMinutes / 60); + const localMinute = normalizedMinutes % 60; + + const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7; + const localMinutes = localHour * 60 + localMinute; + const timeLabel = formatLocalTime(localHour, localMinute, timeFormat); + return { localDayIndex, localMinutes, timeLabel }; +}; + +const orderedDayIndexes = (weekStart: WeekStart): number[] => { + if (weekStart === 'sunday') return [0, 1, 2, 3, 4, 5, 6]; + if (weekStart === 'saturday') return [6, 0, 1, 2, 3, 4, 5]; + return [1, 2, 3, 4, 5, 6, 0]; +}; + +const startOfWeek = (weekStart: WeekStart, weekOffset: number): Date => { + const now = new Date(); + const start = new Date(now); + start.setHours(12, 0, 0, 0); + + const startIndex = orderedDayIndexes(weekStart)[0]; + const diff = (start.getDay() - startIndex + 7) % 7; + start.setDate(start.getDate() - diff + weekOffset * 7); + return start; +}; + +const dayName = (index: number): string => + ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][index] ?? 'Day'; + +const wireControls = ( + root: HTMLElement, + settings: ScheduleSettings, + rerender: () => void +): void => { + const sync = (): void => { + const buttons = root.querySelectorAll( + '[data-schedule-setting][data-schedule-value]' + ); + for (const button of buttons) { + const key = button.getAttribute('data-schedule-setting'); + const value = button.getAttribute('data-schedule-value'); + if (!key || value === null) continue; + + const isActive = + (key === 'timeFormat' && value === settings.timeFormat) || + (key === 'showImages' && value === String(settings.showImages)); + + button.classList.toggle('bg-background-button-hover', isActive); + } + + const selects = root.querySelectorAll('select[data-schedule-setting]'); + for (const select of selects) { + const key = select.getAttribute('data-schedule-setting'); + if (!key) continue; + if (key === 'sortBy') select.value = settings.sortBy; + if (key === 'weekStart') select.value = settings.weekStart; + } + }; + + root.addEventListener('click', event => { + const target = + event.target instanceof Element ? event.target.closest('[data-schedule-setting]') : null; + if (!target) return; + const key = target.getAttribute('data-schedule-setting'); + const value = target.getAttribute('data-schedule-value'); + if (!key || value === null) return; + + if (key === 'timeFormat' && isTimeFormat(value)) settings.timeFormat = value; + if (key === 'showImages') settings.showImages = value === 'true'; + + saveSettings(settings); + sync(); + rerender(); + }); + + root.addEventListener('change', event => { + const target = event.target instanceof HTMLElement ? event.target : null; + if (!(target instanceof HTMLSelectElement)) return; + const key = target.getAttribute('data-schedule-setting'); + if (!key) return; + if (key === 'sortBy' && isSortBy(target.value)) settings.sortBy = target.value; + if (key === 'weekStart' && isWeekStart(target.value)) settings.weekStart = target.value; + saveSettings(settings); + sync(); + rerender(); + }); + + root.addEventListener('click', event => { + const target = + event.target instanceof Element ? event.target.closest('[data-schedule-week-nav]') : null; + if (!target) return; + const delta = Number.parseInt(target.getAttribute('data-schedule-week-nav') ?? '0', 10); + if (!Number.isFinite(delta) || delta === 0) return; + settings.weekOffset += delta; + saveSettings(settings); + rerender(); + }); + + sync(); +}; + +interface Item { + node: HTMLElement; + dayIndex: number; + minutes: number; + title: string; + score: number; + timeLabel: string; +} + +const buildBoard = (section: HTMLElement): void => { + const board = section.querySelector('[data-schedule-board]'); + const source = section.querySelector('[data-schedule-items-source]'); + if (!board || !source) return; + + const settings = loadSettings(); + + const render = (): void => { + const dayOrder = orderedDayIndexes(settings.weekStart); + + const daySections = new Map(); + for (const dayIndex of dayOrder) { + const day = dayName(dayIndex); + const daySection = board.querySelector(`[data-schedule-day="${day}"]`); + if (daySection) { + daySections.set(dayIndex, daySection); + } + } + + // Reorder day columns to match week start + const ordered = dayOrder + .map(i => daySections.get(i)) + .filter((n): n is HTMLElement => n instanceof HTMLElement); + for (const node of ordered) board.appendChild(node); + + const weekStartDate = startOfWeek(settings.weekStart, settings.weekOffset); + const dateFormatter = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }); + for (const dayIndex of dayOrder) { + const daySection = daySections.get(dayIndex); + if (!daySection) continue; + const labelNode = daySection.querySelector('[data-schedule-day-label]'); + if (labelNode) labelNode.textContent = dayName(dayIndex); + const dateNode = daySection.querySelector('[data-schedule-date-label]'); + if (dateNode) { + const date = new Date(weekStartDate); + const dayOffset = (dayIndex - dayOrder[0] + 7) % 7; + date.setDate(date.getDate() + dayOffset); + dateNode.textContent = dateFormatter.format(date); + } + const itemsNode = daySection.querySelector('[data-schedule-day-items]'); + if (itemsNode) itemsNode.textContent = ''; + const countNode = daySection.querySelector('[data-schedule-count]'); + if (countNode) countNode.textContent = '0'; + } + + const rawItems = Array.from(source.querySelectorAll('[data-schedule-item]')); + const parsed: Item[] = []; + + for (const node of rawItems) { + const title = node.getAttribute('data-title') ?? ''; + const score = Number(node.getAttribute('data-score') ?? '0'); + const slot = getLocalSlot( + node.getAttribute('data-broadcast-day'), + node.getAttribute('data-broadcast-time'), + node.getAttribute('data-broadcast-timezone'), + settings.timeFormat + ); + if (!slot) continue; + + const timeNode = node.querySelector('[data-schedule-time]'); + if (timeNode) timeNode.textContent = slot.timeLabel; + + const poster = node.querySelector('[data-schedule-poster]'); + if (poster) poster.classList.toggle('hidden', !settings.showImages); + + parsed.push({ + node, + dayIndex: slot.localDayIndex, + minutes: slot.localMinutes, + title, + score: Number.isFinite(score) ? score : 0, + timeLabel: slot.timeLabel, + }); + } + + const sorter = (a: Item, b: Item): number => { + if (settings.sortBy === 'alpha') return a.title.localeCompare(b.title); + if (settings.sortBy === 'score') + return (b.score || 0) - (a.score || 0) || a.title.localeCompare(b.title); + return a.minutes - b.minutes || a.title.localeCompare(b.title); + }; + + parsed.sort((a, b) => a.dayIndex - b.dayIndex || sorter(a, b)); + + for (const dayIndex of dayOrder) { + const daySection = daySections.get(dayIndex); + if (!daySection) continue; + const itemsNode = daySection.querySelector('[data-schedule-day-items]'); + if (!itemsNode) continue; + + const items = parsed.filter(i => i.dayIndex === dayIndex).sort(sorter); + for (const item of items) itemsNode.appendChild(item.node); + + const countNode = daySection.querySelector('[data-schedule-count]'); + if (countNode) countNode.textContent = String(items.length); + } + }; + + wireControls(section, settings, render); + render(); +}; + +const initScheduleBoard = (): void => { + const run = (): void => { + const sections = document.querySelectorAll('[data-schedule-section]'); + sections.forEach(buildBoard); + }; + + document.addEventListener('DOMContentLoaded', run); + document.body.addEventListener('htmx:afterSwap', run); +}; + +initScheduleBoard(); From 5dd6eedc3fff29b10a96095d8e53ac4c91f38979 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 27 May 2026 10:56:37 +0200 Subject: [PATCH 003/381] 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 @@ + + - + diff --git a/templates/discover.gohtml b/templates/discover.gohtml index 11f6231..3dd1e37 100644 --- a/templates/discover.gohtml +++ b/templates/discover.gohtml @@ -34,17 +34,6 @@ - {{/* For You Section */}} -
-
-

For You

- Based on your watchlist -
-
- {{template "discover_skeleton"}} -
-
- {{/* Upcoming Section */}}
@@ -85,30 +74,6 @@
{{end}} -{{define "discover_row"}} -
- {{range .Animes}} - {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} - {{end}} -
-{{end}} - -{{define "discover_for_you_section"}} -{{if gt (len .Animes) 0}} -
-
-

For You

- Based on your watchlist -
-
- {{range .Animes}} - {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} - {{end}} -
-
-{{end}} -{{end}} - {{define "discover_skeleton"}}
{{range (seq 8)}} diff --git a/templates/index.gohtml b/templates/index.gohtml index cacebe8..1e04ecb 100644 --- a/templates/index.gohtml +++ b/templates/index.gohtml @@ -5,6 +5,10 @@
{{template "continue_watching_skeleton"}}
+ +
+ {{template "top_pick_for_you_skeleton"}} +
{{end}}
@@ -36,6 +40,37 @@ {{end}} +{{define "top_pick_for_you_section"}} + {{if gt (len .Animes) 0}} +
+
+

Top Pick for You

+ + Explore more + + +
+
+
+ {{range .Animes}} +
+ {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} +
+ {{end}} +
+ + + + +
+
+ {{end}} +{{end}} + {{define "catalog_section"}} {{if eq .Section "Continue"}} {{if .ContinueWatching}} @@ -61,6 +96,26 @@
{{end}} +{{define "top_pick_for_you_skeleton"}} +
+
+
+
+
+
+
+ {{range (seq 6)}} +
+
+
+
+
+ {{end}} +
+
+
+{{end}} + {{define "continue_watching_skeleton"}}

Continue Watching

From 97623aad4df4e458b67cbc4a3f39b84c4b122117 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 16:23:50 +0200 Subject: [PATCH 156/381] style: add color-scheme for light and dark themes --- static/assets/style.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/static/assets/style.css b/static/assets/style.css index 2d795e4..522a733 100644 --- a/static/assets/style.css +++ b/static/assets/style.css @@ -57,6 +57,14 @@ --radius: 0px; } +html[data-theme="light"] { + color-scheme: light; +} + +html[data-theme="dark"] { + color-scheme: dark; +} + html, body { background-color: var(--color-background); From 3ea5ea68ff8d40ed0f38dc7692b5965fd4563fbe Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 16:23:53 +0200 Subject: [PATCH 157/381] refactor: remove unused htmx global type declaration --- static/htmx.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/static/htmx.ts b/static/htmx.ts index cadda86..9a2aeb9 100644 --- a/static/htmx.ts +++ b/static/htmx.ts @@ -2,14 +2,6 @@ export {}; import { onReady } from "./utils"; -declare global { - interface Window { - htmx?: { - process: (element: Element) => void; - }; - } -} - type ToastFn = (opts: { message: string; duration?: number }) => void; const getToast = (): ToastFn | null => { @@ -44,8 +36,6 @@ const getTriggerFromHtmxEvent = (event: Event): Element | null => { }; onReady(() => { - window.htmx?.process(document.body); - document.addEventListener("htmx:beforeRequest", (event) => { setBusy(getTriggerFromHtmxEvent(event), true); }); From 60ba1a4fb5debe32521c36763154b8e41984f504 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 16:23:59 +0200 Subject: [PATCH 158/381] refactor: follow system color scheme via matchMedia listener --- static/theme.ts | 50 ++++--------------------------------------------- 1 file changed, 4 insertions(+), 46 deletions(-) diff --git a/static/theme.ts b/static/theme.ts index e2afea6..104937c 100644 --- a/static/theme.ts +++ b/static/theme.ts @@ -1,62 +1,20 @@ type Theme = "light" | "dark"; -const STORAGE_KEY = "theme"; - -const getLocalStorage = (): Storage | null => { - try { - return window.localStorage; - } catch { - return null; - } -}; +const colorSchemeQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null; const getPreferredTheme = (): Theme => { - const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ?? false; + const prefersDark = colorSchemeQuery?.matches ?? false; return prefersDark ? "dark" : "light"; }; -const normalizeTheme = (raw: string | null): Theme | null => { - if (raw === "light" || raw === "dark") return raw; - return null; -}; - -const getSavedTheme = (): Theme => { - const storage = getLocalStorage(); - const fromStorage = normalizeTheme(storage?.getItem(STORAGE_KEY) ?? null); - if (fromStorage) return fromStorage; - - return getPreferredTheme(); -}; - const applyTheme = (theme: Theme): void => { document.documentElement.setAttribute("data-theme", theme); document.documentElement.style.colorScheme = theme; - const storage = getLocalStorage(); - try { - storage?.setItem(STORAGE_KEY, theme); - } catch { - // ignore - } -}; - -const cycleTheme = (): void => { - const current = getSavedTheme(); - const next: Theme = current === "light" ? "dark" : "light"; - applyTheme(next); }; const initTheme = (): void => { - const saved = getSavedTheme(); - applyTheme(saved); - - // delegated click handler on theme buttons - document.addEventListener("click", (e) => { - const target = e.target; - if (!(target instanceof Element)) return; - const btn = target.closest("#theme-toggle, #footer-theme-toggle"); - if (!(btn instanceof HTMLButtonElement)) return; - cycleTheme(); - }); + applyTheme(getPreferredTheme()); + colorSchemeQuery?.addEventListener("change", () => applyTheme(getPreferredTheme())); }; if (document.readyState === "loading") { From 6b43fa7ce5573753b1c21b92f7b9ca37d86dcd04 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 16:24:07 +0200 Subject: [PATCH 159/381] feat: add inline theme script to prevent FOUC --- templates/base.gohtml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/base.gohtml b/templates/base.gohtml index d8a3a8a..3c538ad 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -11,6 +11,13 @@ MyAnimeList: {{template "title" .}} + {{end}} +{{end}} {{define "discover_section"}}
diff --git a/templates/index.gohtml b/templates/index.gohtml index 1e04ecb..c78ee44 100644 --- a/templates/index.gohtml +++ b/templates/index.gohtml @@ -45,7 +45,7 @@

Top Pick for You

- + Explore more From 4bcfc8fdb7414c71fd98e0edc3f0a8ddbb500355 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 15:54:10 +0200 Subject: [PATCH 167/381] refactor: remove docs folder --- docs/recommendation-architecture.md | 183 ---------------------------- 1 file changed, 183 deletions(-) delete mode 100644 docs/recommendation-architecture.md diff --git a/docs/recommendation-architecture.md b/docs/recommendation-architecture.md deleted file mode 100644 index c2a8434..0000000 --- a/docs/recommendation-architecture.md +++ /dev/null @@ -1,183 +0,0 @@ -# Recommendation Architecture - -This document defines the long-term shape of the `Top Pick for You` -recommendation system. -The goal is to keep the current implementation simple enough to operate inside -the existing Go application while preserving a clean path toward a larger -recommender system. - -## Current Serving Model - -The current `Top Pick for You` implementation is a bounded hybrid ranker: - -- builds weighted seeds from user watch history -- uses Jikan recommendation edges as collaborative candidates -- uses watchlist-derived genres, themes, studios, and demographics as profile - search candidates -- excludes anime already present in the watchlist -- boosts candidates that match user taste signals -- reranks the final list to reduce genre pileups - -The online request path stays intentionally small: - -1. load recent watchlist state -2. derive strong seeds -3. build a weighted taste profile from those seeds -4. fetch bounded collaborative and profile-search candidate sets -5. score candidates -6. rerank for diversity -7. return top results - -## Target System Shape - -The future recommender should keep four stable layers: - -1. event collection -2. feature aggregation -3. candidate generation -4. ranking and reranking - -That separation matters more than the specific model used at each stage. - -## Event Collection - -Recommendations should eventually be driven by behavior events, not only by -watchlist state. - -Important events: - -- `impression` -- `click` -- `add_to_watchlist` -- `start_watch` -- `progress_update` -- `complete` -- `drop` -- `hide_recommendation` -- `search` - -Event capture should preserve: - -- `user_id` -- `anime_id` -- `event_type` -- `occurred_at` -- `source` -- contextual metadata as JSON - -## Feature Aggregation - -Online requests should not recompute the full user profile from raw events. -Instead, background jobs should maintain aggregated feature snapshots. - -Useful profile features: - -- genre affinity -- theme affinity -- studio affinity -- demographic affinity -- completion rate by genre -- abandonment rate by genre -- preference for airing vs finished anime -- preference for recent vs older anime -- short-term interest profile -- long-term stable taste profile - -These features should eventually live in a durable profile snapshot table so -the serving path remains cheap. - -## Candidate Generation - -Candidate generation should be modular. Each source should produce: - -- `anime_id` -- `source` -- `source_score` -- explanation metadata - -Primary candidate sources: - -- item-item recommendation edges -- related anime and sequel chains -- content-similar anime from genres, themes, studios, and demographics -- trending titles inside the user taste envelope -- seasonal titles aligned with recent behavior -- editorial or promoted rails when needed - -Candidate generation should stay bounded. Ranking the full catalog online is -not a viable long-term approach. - -## Ranking - -The current ranker is heuristic by design. That is the correct starting point. - -Near-term ranking inputs: - -- collaborative recommendation weight -- watch history status weight -- recency decay -- progress-based engagement -- genre overlap -- theme overlap -- studio overlap -- demographic overlap -- airing or freshness alignment -- popularity moderation - -The ranking API should remain stable even if the scoring model changes later. -That allows a future move to gradient-boosted trees or other learned rankers -without rewriting candidate generation or serving. - -## Reranking - -The final serving stage should apply product constraints that raw ranking will -not handle well on its own: - -- genre diversity -- franchise caps -- duplicate suppression -- hide or negative-feedback suppression -- maturity filtering -- freshness and exploration budget - -This is intentionally a separate concern from relevance scoring. - -## Data Tables - -The first recommendation-specific schema additions should support: - -- append-only event capture -- recommendation impression tracking -- cached user profile snapshots - -These tables are created in migration `024_add_recommendation_foundation.sql`. - -## Roadmap - -### V1 - -- bounded hybrid ranker in request path -- uses watchlist history and Jikan metadata -- no offline jobs required - -### V2 - -- capture user recommendation and watch behavior events -- persist user profile snapshots -- precompute candidate caches -- add explicit feedback controls such as hide or not interested - -### V3 - -- split retrieval from ranking -- precompute similarity graphs and user candidate pools -- run offline evaluation on impressions, clicks, starts, and completes -- introduce learned ranking only when enough behavior data exists - -## Operational Rules - -- keep request-time fanout bounded -- keep scoring explainable -- log recommendation impressions before introducing heavier models -- prefer replaceable modules over one large recommendation function -- treat data collection as the foundation for later ML, not an optional extra From 5019e9fcb7f35dd5a7157aec1175351682d7b3af Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 16:50:03 +0200 Subject: [PATCH 168/381] feat: add onHtmxLoad and closestFocusable utilities --- static/utils.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/static/utils.ts b/static/utils.ts index def6031..07977b0 100644 --- a/static/utils.ts +++ b/static/utils.ts @@ -20,3 +20,23 @@ export const onReady = (fn: () => void): void => { fn(); }; + +export const onHtmxLoad = (fn: (root: Element) => void): void => { + onReady(() => { + fn(document.body); + document.body.addEventListener("htmx:load", (event: Event) => { + const detail = event as CustomEvent<{ elt?: Element }>; + const root = detail.detail?.elt; + if (root instanceof Element) { + fn(root); + } + }); + }); +}; + +export const closestFocusable = (root: ParentNode): HTMLElement | null => { + const selector = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + const node = root.querySelector(selector); + return node instanceof HTMLElement ? node : null; +}; From 392bc10b99ea3b1b624ea95e27b31e38a210fc2c Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 16:51:07 +0200 Subject: [PATCH 169/381] refactor: replace DOMContentLoaded with onReady utility --- static/sort_filter.ts | 4 +++- static/theme.ts | 8 +++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/static/sort_filter.ts b/static/sort_filter.ts index 0183a0c..03f777d 100644 --- a/static/sort_filter.ts +++ b/static/sort_filter.ts @@ -1,3 +1,5 @@ +import { onReady } from "./utils"; + const initSortFilter = (): void => { const sortSelect = document.getElementById("sort-select") as HTMLSelectElement | null; const orderSelect = document.getElementById("order-select") as HTMLSelectElement | null; @@ -21,4 +23,4 @@ const initSortFilter = (): void => { }); }; -document.addEventListener("DOMContentLoaded", initSortFilter); +onReady(initSortFilter); diff --git a/static/theme.ts b/static/theme.ts index 104937c..e654f74 100644 --- a/static/theme.ts +++ b/static/theme.ts @@ -1,3 +1,5 @@ +import { onReady } from "./utils"; + type Theme = "light" | "dark"; const colorSchemeQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null; @@ -17,8 +19,4 @@ const initTheme = (): void => { colorSchemeQuery?.addEventListener("change", () => applyTheme(getPreferredTheme())); }; -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initTheme); -} else { - initTheme(); -} +onReady(initTheme); From 78b36452aef778b5d58347df4090a05d0256dee2 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 16:51:12 +0200 Subject: [PATCH 170/381] refactor: migrate from htmx:afterSwap to onHtmxLoad --- static/player/main.ts | 15 +++++++-------- static/schedule_board.ts | 9 +++++---- static/timezone.ts | 10 +++++----- static/top_pick_carousel.ts | 18 ++++++++---------- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/static/player/main.ts b/static/player/main.ts index 3d28af7..f93300f 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -20,6 +20,7 @@ import { displayTimeFromAbsolute, } from "./timeline"; import { formatTime } from "./controls"; +import { onHtmxLoad, onReady } from "../utils"; let currentContainer: HTMLElement | null = null; let cleanup: (() => void) | null = null; @@ -113,10 +114,9 @@ const initPlayer = (): void => { }; // build video src from mode, token, and saved quality preference - // Only set if not already provided by the inline script during HTML parsing const preferredQuality = safeLocalStorage.getItem("mal:preferred-quality") || "best"; const streamToken = state.modeSources[state.currentMode]?.token; - if (!state.video.src && streamToken) { + if (streamToken) { state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== "best" ? `&quality=${encodeURIComponent(preferredQuality)}` : ""}`; } @@ -205,8 +205,6 @@ const initPlayer = (): void => { }; state.video.addEventListener("loadedmetadata", onLoadedMetadata, { signal }); - // inline script runs during HTML parsing before initPlayer; if metadata - // already loaded, fire the handler immediately if (state.video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { onLoadedMetadata(); } @@ -389,10 +387,11 @@ const initPlayer = (): void => { setupThumbnails(); }; -document.addEventListener("DOMContentLoaded", initPlayer); -document.body.addEventListener("htmx:afterSwap", (e: Event) => { - const target = (e as CustomEvent).detail?.target as HTMLElement | null; - if (target?.querySelector("[data-video-player]")) initPlayer(); +onReady(initPlayer); +onHtmxLoad((root) => { + if (root.matches("[data-video-player]") || root.querySelector("[data-video-player]")) { + initPlayer(); + } }); document.body.addEventListener("htmx:beforeSwap", (e: Event) => { diff --git a/static/schedule_board.ts b/static/schedule_board.ts index 79b04c8..b1ba21a 100644 --- a/static/schedule_board.ts +++ b/static/schedule_board.ts @@ -1,3 +1,4 @@ +import { onHtmxLoad, onReady } from "./utils"; import { isJstTimezone, normalizeWeekday, parseHHMM } from "./shared/broadcast"; export {}; @@ -328,13 +329,13 @@ const buildBoard = (section: HTMLElement): void => { }; const initScheduleBoard = (): void => { - const run = (): void => { - const sections = document.querySelectorAll("[data-schedule-section]"); + const run = (root: ParentNode): void => { + const sections = root.querySelectorAll("[data-schedule-section]"); sections.forEach(buildBoard); }; - document.addEventListener("DOMContentLoaded", run); - document.body.addEventListener("htmx:afterSwap", run); + onReady(() => run(document)); + onHtmxLoad((root) => run(root)); }; initScheduleBoard(); diff --git a/static/timezone.ts b/static/timezone.ts index f8b8f6e..e80dcb0 100644 --- a/static/timezone.ts +++ b/static/timezone.ts @@ -7,6 +7,8 @@ import { export {}; +import { onHtmxLoad } from "./utils"; + interface ParsedBroadcast { day: string; hour: number; @@ -188,16 +190,14 @@ const updateNode = (node: Element, localOffsetMinutes: number): void => { updateNextAiring(node, parsed); }; -const updateAll = (): void => { +const updateAll = (root: ParentNode): void => { const localOffsetMinutes = -new Date().getTimezoneOffset(); - const nodes = document.querySelectorAll("[data-jst-text]"); + const nodes = root.querySelectorAll("[data-jst-text]"); nodes.forEach((node) => updateNode(node, localOffsetMinutes)); }; const initTimezoneConversion = (): void => { - // run on initial load and after htmx swaps (content may change) - document.addEventListener("DOMContentLoaded", updateAll); - document.body.addEventListener("htmx:afterSwap", updateAll); + onHtmxLoad((root) => updateAll(root)); }; initTimezoneConversion(); diff --git a/static/top_pick_carousel.ts b/static/top_pick_carousel.ts index d23c4d0..47a766a 100644 --- a/static/top_pick_carousel.ts +++ b/static/top_pick_carousel.ts @@ -24,8 +24,8 @@ const getTopPickCarousel = (root: HTMLElement): TopPickCarousel | null => { return { track, previous, next, previousFade, nextFade }; }; -const topPickCarousels = (): HTMLElement[] => - Array.from(document.querySelectorAll("[data-top-pick-carousel]")); +const topPickCarousels = (root: ParentNode = document): HTMLElement[] => + Array.from(root.querySelectorAll("[data-top-pick-carousel]")); const maxScrollLeft = (track: HTMLElement): number => Math.max(0, track.scrollWidth - track.clientWidth); @@ -56,8 +56,8 @@ const updateTopPickCarousel = (root: HTMLElement): void => { carousel.nextFade.classList.toggle("hidden", !hasNext); }; -const updateTopPickCarousels = (): void => { - topPickCarousels().forEach(updateTopPickCarousel); +const updateTopPickCarousels = (root: ParentNode = document): void => { + topPickCarousels(root).forEach(updateTopPickCarousel); }; const carouselScrollAmount = (track: HTMLElement): number => { @@ -145,9 +145,7 @@ document.addEventListener( true, ); -document.addEventListener("DOMContentLoaded", updateTopPickCarousels); -document.addEventListener("htmx:afterSwap", updateTopPickCarousels); -document.addEventListener("htmx:afterSettle", updateTopPickCarousels); -window.addEventListener("resize", updateTopPickCarousels); - -updateTopPickCarousels(); +onReady(() => updateTopPickCarousels()); +onHtmxLoad((root) => updateTopPickCarousels(root)); +window.addEventListener("resize", () => updateTopPickCarousels()); +import { onHtmxLoad, onReady } from "./utils"; From b5fc2dfe4e81bcb2c42ed21ee4ee9db213594f3e Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 16:52:16 +0200 Subject: [PATCH 171/381] feat: add app entry point, password toggle, and schedule modules --- static/app.ts | 15 +++++++++++++++ static/login.ts | 22 ++++++++++++++++++++++ static/schedule.ts | 13 +++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 static/app.ts create mode 100644 static/login.ts create mode 100644 static/schedule.ts diff --git a/static/app.ts b/static/app.ts new file mode 100644 index 0000000..045b8ce --- /dev/null +++ b/static/app.ts @@ -0,0 +1,15 @@ +import "./theme"; +import "./toast"; +import "./htmx"; +import "./dropdown"; +import "./discover"; +import "./anime"; +import "./timezone"; +import "./search"; +import "./sort_filter"; +import "./dedupe"; +import "./shell"; +import "./watchlist"; +import "./top_pick_carousel"; +import "./login"; +import "./schedule"; diff --git a/static/login.ts b/static/login.ts new file mode 100644 index 0000000..2437f4c --- /dev/null +++ b/static/login.ts @@ -0,0 +1,22 @@ +const initPasswordToggle = (): void => { + document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + + const button = target.closest("[data-toggle-password]"); + if (!button) return; + + const field = button.closest("form")?.querySelector("#password"); + const openEye = button.querySelector("[data-eye-open]"); + const closedEye = button.querySelector("[data-eye-closed]"); + if (!(field instanceof HTMLInputElement) || !openEye || !closedEye) return; + + const showPassword = field.type === "password"; + field.type = showPassword ? "text" : "password"; + button.setAttribute("aria-label", showPassword ? "Hide password" : "Show password"); + openEye.classList.toggle("hidden", showPassword); + closedEye.classList.toggle("hidden", !showPassword); + }); +}; + +initPasswordToggle(); diff --git a/static/schedule.ts b/static/schedule.ts new file mode 100644 index 0000000..c35604c --- /dev/null +++ b/static/schedule.ts @@ -0,0 +1,13 @@ +import { onReady } from "./utils"; + +const scheduleTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +const initScheduleLoader = (): void => { + onReady(() => { + const loader = document.querySelector("[data-schedule-loader]"); + if (!loader) return; + loader.setAttribute("hx-vals", JSON.stringify({ timezone: scheduleTimezone() })); + }); +}; + +initScheduleLoader(); From 5cc03579b24279109d78941090c27977d4370f4c Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 6 Jun 2026 16:52:22 +0200 Subject: [PATCH 172/381] refactor: consolidate scripts into single app.js entry point --- templates/base.gohtml | 41 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/templates/base.gohtml b/templates/base.gohtml index 3c538ad..72b85c7 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -12,11 +12,9 @@ - + - - - - - - - - - - - - - - - + {{block "scripts" .}}{{end}}
@@ -123,8 +98,8 @@ {{end}}
-