const ANILIST_API = 'https://graphql.anilist.co'; const CACHE_NAME = 'waifuboard-schedule-v1'; const CACHE_DURATION = 5 * 60 * 1000; const state = { currentDate: new Date(), viewType: 'MONTH', mode: 'SUB', loading: false, abortController: null, refreshInterval: null }; document.addEventListener('DOMContentLoaded', () => { renderHeader(); fetchSchedule(); state.refreshInterval = setInterval(() => { console.log("Auto-refreshing schedule..."); fetchSchedule(true); }, CACHE_DURATION); }); async function getCache(key) { try { const cache = await caches.open(CACHE_NAME); const response = await cache.match(`/schedule-cache/${key}`); if (!response) return null; const cached = await response.json(); const age = Date.now() - cached.timestamp; if (age < CACHE_DURATION) { console.log(`[Cache Hit] Loaded ${key} (Age: ${Math.round(age / 1000)}s)`); return cached.data; } console.log(`[Cache Stale] ${key} expired.`); cache.delete(`/schedule-cache/${key}`); return null; } catch (e) { console.error("Cache read failed", e); return null; } } async function setCache(key, data) { try { const cache = await caches.open(CACHE_NAME); const payload = JSON.stringify({ timestamp: Date.now(), data: data }); const response = new Response(payload, { headers: { 'Content-Type': 'application/json' } }); await cache.put(`/schedule-cache/${key}`, response); } catch (e) { console.warn("Cache write failed", e); } } function getCacheKey() { if (state.viewType === 'MONTH') { return `M_${state.currentDate.getFullYear()}_${state.currentDate.getMonth()}`; } else { const start = getWeekStart(state.currentDate); return `W_${start.toISOString().split('T')[0]}`; } } function navigate(delta) { if (state.abortController) state.abortController.abort(); if (state.viewType === 'MONTH') { state.currentDate.setMonth(state.currentDate.getMonth() + delta); } else { state.currentDate.setDate(state.currentDate.getDate() + (delta * 7)); } renderHeader(); fetchSchedule(); } function setViewType(type) { if (state.viewType === type) return; state.viewType = type; document.getElementById('btnViewMonth').classList.toggle('active', type === 'MONTH'); document.getElementById('btnViewWeek').classList.toggle('active', type === 'WEEK'); if (state.abortController) state.abortController.abort(); renderHeader(); fetchSchedule(); } function setMode(mode) { if (state.mode === mode) return; state.mode = mode; document.getElementById('btnSub').classList.toggle('active', mode === 'SUB'); document.getElementById('btnDub').classList.toggle('active', mode === 'DUB'); fetchSchedule(); } function renderHeader() { const options = { month: 'long', year: 'numeric' }; let title = state.currentDate.toLocaleDateString('en-US', options); if (state.viewType === 'WEEK') { const start = getWeekStart(state.currentDate); const end = new Date(start); end.setDate(end.getDate() + 6); const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); title = `Week of ${startStr} - ${endStr}`; } document.getElementById('monthTitle').textContent = title; } function getWeekStart(date) { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1); return new Date(d.setDate(diff)); } async function fetchSchedule(forceRefresh = false) { const key = getCacheKey(); if (!forceRefresh) { const cachedData = await getCache(key); if (cachedData) { renderGrid(cachedData); updateAmbient(cachedData); return; } } if (state.abortController) state.abortController.abort(); state.abortController = new AbortController(); const signal = state.abortController.signal; if (!forceRefresh) setLoading(true); let startTs, endTs; if (state.viewType === 'MONTH') { const year = state.currentDate.getFullYear(); const month = state.currentDate.getMonth(); startTs = Math.floor(new Date(year, month, 1).getTime() / 1000); endTs = Math.floor(new Date(year, month + 1, 0, 23, 59, 59).getTime() / 1000); } else { const start = getWeekStart(state.currentDate); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 7); startTs = Math.floor(start.getTime() / 1000); endTs = Math.floor(end.getTime() / 1000); } const query = ` query ($start: Int, $end: Int, $page: Int) { Page(page: $page, perPage: 50) { pageInfo { hasNextPage } airingSchedules(airingAt_greater: $start, airingAt_lesser: $end, sort: TIME) { airingAt episode media { id title { userPreferred english } coverImage { large } bannerImage isAdult countryOfOrigin popularity } } } } `; let allData = []; let page = 1; let hasNext = true; let retries = 0; try { while (hasNext && page <= 6) { if (signal.aborted) throw new DOMException("Aborted", "AbortError"); try { const res = await fetch(ANILIST_API, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ query, variables: { start: startTs, end: endTs, page } }), signal: signal }); if (res.status === 429) { if (retries > 2) throw new Error("Rate Limited"); console.warn("429 Hit. Waiting..."); await delay(4000); retries++; continue; } const json = await res.json(); if (json.errors) throw new Error("API Error"); const data = json.data.Page; allData = [...allData, ...data.airingSchedules]; hasNext = data.pageInfo.hasNextPage; page++; await delay(600); } catch (e) { if (e.name === 'AbortError') throw e; console.error(e); break; } } if (!signal.aborted) { await setCache(key, allData); renderGrid(allData); updateAmbient(allData); } } catch (e) { if (e.name !== 'AbortError') console.error("Fetch failed:", e); } finally { if (!signal.aborted) { setLoading(false); state.abortController = null; } } } function renderGrid(data) { const grid = document.getElementById('daysGrid'); grid.innerHTML = ''; let items = data.filter(i => !i.media.isAdult && i.media.countryOfOrigin === 'JP'); if (state.mode === 'DUB') { items = items.filter(i => i.media.popularity > 20000); } if (state.viewType === 'MONTH') { const year = state.currentDate.getFullYear(); const month = state.currentDate.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); let firstDayIndex = new Date(year, month, 1).getDay() - 1; if (firstDayIndex === -1) firstDayIndex = 6; for (let i = 0; i < firstDayIndex; i++) { const empty = document.createElement('div'); empty.className = 'day-cell empty'; grid.appendChild(empty); } for (let day = 1; day <= daysInMonth; day++) { const dateObj = new Date(year, month, day); renderDayCell(dateObj, items, grid); } } else { const start = getWeekStart(state.currentDate); for (let i = 0; i < 7; i++) { const dateObj = new Date(start); dateObj.setDate(start.getDate() + i); renderDayCell(dateObj, items, grid); } } } function renderDayCell(dateObj, items, grid) { const cell = document.createElement('div'); cell.className = 'day-cell'; if (state.viewType === 'WEEK') cell.style.minHeight = '300px'; const day = dateObj.getDate(); const month = dateObj.getMonth(); const year = dateObj.getFullYear(); const now = new Date(); if (day === now.getDate() && month === now.getMonth() && year === now.getFullYear()) { cell.classList.add('today'); } const dayEvents = items.filter(i => { const eventDate = new Date(i.airingAt * 1000); return eventDate.getDate() === day && eventDate.getMonth() === month && eventDate.getFullYear() === year; }); dayEvents.sort((a, b) => b.media.popularity - a.media.popularity); if (dayEvents.length > 0) { const top = dayEvents[0].media; const bg = document.createElement('div'); bg.className = 'cell-backdrop'; bg.style.backgroundImage = `url('${top.coverImage.large}')`; cell.appendChild(bg); } const header = document.createElement('div'); header.className = 'day-header'; header.innerHTML = ` ${day} Today `; cell.appendChild(header); const list = document.createElement('div'); list.className = 'events-list'; dayEvents.forEach(evt => { const title = evt.media.title.english || evt.media.title.userPreferred; const link = `/anime/${evt.media.id}`; const chip = document.createElement('a'); chip.className = 'anime-chip'; chip.href = link; chip.innerHTML = ` ${title} Ep ${evt.episode} `; list.appendChild(chip); }); cell.appendChild(list); grid.appendChild(cell); } function setLoading(bool) { state.loading = bool; const loader = document.getElementById('loader'); if (bool) loader.classList.add('active'); else loader.classList.remove('active'); } function delay(ms) { return new Promise(r => setTimeout(r, ms)); } function updateAmbient(data) { if (!data || !data.length) return; const top = data.reduce((prev, curr) => (prev.media.popularity > curr.media.popularity) ? prev : curr); const img = top.media.bannerImage || top.media.coverImage.large; if (img) document.getElementById('ambientBg').style.backgroundImage = `url('${img}')`; }