From c044ebdda096de2afb5c5926dc6da5cb084e503d Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 27 May 2026 10:56:28 +0200 Subject: [PATCH] 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();