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();