(function () { function byId(id) { return document.getElementById(id); } function updateClock() { const dateEl = byId('realtime-date'); const timeEl = byId('realtime-clock'); if (!dateEl || !timeEl) return; const now = new Date(); const options = { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }; dateEl.textContent = now.toLocaleDateString(undefined, options).toUpperCase(); timeEl.textContent = now.toLocaleTimeString(); } setInterval(updateClock, 1000); updateClock(); // Last.fm now-playing widget const LASTFM_CACHE_KEY = 'lastfmLastGood'; const LASTFM_BASE_INTERVAL = 10000; const LASTFM_MAX_INTERVAL = 60000; let lastfmFailures = 0; let lastfmTimer = null; function renderLastFmTrack(track) { const statusEl = byId('lastfm-status'); const widgetEl = byId('lastfm-widget'); if (!statusEl || !track || !track.name || !track.artist) return; const prefix = track.nowplaying ? 'Now playing: ' : 'Last played: '; statusEl.innerHTML = ''; const prefixEl = document.createElement('span'); prefixEl.className = 'lastfm-prefix'; prefixEl.textContent = prefix; const nameEl = document.createElement('span'); nameEl.className = 'lastfm-track-name'; nameEl.textContent = track.name; const sepEl = document.createElement('span'); sepEl.className = 'lastfm-sep'; sepEl.textContent = ' — '; const artistEl = document.createElement('span'); artistEl.className = 'lastfm-track-artist'; artistEl.textContent = track.artist; statusEl.appendChild(prefixEl); statusEl.appendChild(nameEl); statusEl.appendChild(sepEl); statusEl.appendChild(artistEl); if (widgetEl) widgetEl.style.display = ''; } (function primeFromCache() { try { const raw = localStorage.getItem(LASTFM_CACHE_KEY); if (raw) renderLastFmTrack(JSON.parse(raw)); } catch (_) {} })(); function scheduleLastFm() { const backoff = Math.min( LASTFM_MAX_INTERVAL, LASTFM_BASE_INTERVAL * Math.pow(2, lastfmFailures) ); clearTimeout(lastfmTimer); lastfmTimer = setTimeout(updateLastFm, backoff); } function updateLastFm() { const statusEl = byId('lastfm-status'); if (!statusEl) return; fetch('/api/lastfm.php') .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) .then(function (data) { if (data && data.error) throw new Error(data.message || 'api error'); const tracks = Array.isArray(data) ? data : []; // Prefer a currently-playing track; otherwise fall back to the most // recent scrobble so the widget always has something to show. const track = tracks.find(function (t) { return t && t.nowplaying; }) || tracks.find(function (t) { return t && t.name; }) || null; if (track) { renderLastFmTrack(track); try { localStorage.setItem(LASTFM_CACHE_KEY, JSON.stringify(track)); } catch (_) {} } lastfmFailures = 0; }) .catch(function () { // Never show an error message. Keep whatever is currently rendered // (primed from localStorage on load), or leave the slot blank. lastfmFailures++; }) .finally(scheduleLastFm); } updateLastFm(); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') { lastfmFailures = 0; updateLastFm(); } }); // Unified search engine switcher const SEARCH_ENGINES = [ { key: 'google', label: 'G', title: 'Google Search..', placeholder: 'Search Google...', action: 'https://www.google.ca/search', param: 'q', site: 'https://www.google.ca' }, { key: 'brave', label: 'B', title: 'Brave Search..', placeholder: 'Search Brave...', action: 'https://search.brave.com/search', param: 'q', site: 'https://search.brave.com' }, { key: 'startpage', label: 'SP', title: 'Startpage Search..', placeholder: 'Search Startpage...', action: 'https://www.startpage.com/sp/search', param: 'query', site: 'https://www.startpage.com' }, { key: 'youtube', label: 'YT', title: 'YouTube Search..', placeholder: 'Search YouTube...', action: 'https://www.youtube.com/results', param: 'search_query', site: 'https://www.youtube.com' }, { key: 'sillylaird',label: 'SL', title: 'SillyLaird Search..', placeholder: 'Search SillyLaird...', action: 'https://search.sillylaird.ca/search', param: 'q', site: 'https://search.sillylaird.ca' }, { key: '4get', label: '4G', title: '4get Search..', placeholder: 'Search 4get...', action: 'https://4get.sillylaird.ca/web', param: 's', site: 'https://4get.sillylaird.ca' } ]; const SEARCH_ENGINE_KEY = 'startpageSearchEngine_v2'; (function initUnifiedSearch() { const form = byId('unified-search-form'); const input = byId('unified-search-input'); const toggle = byId('search-engine-toggle'); const menu = byId('search-engine-menu'); const title = byId('unified-search-title'); const clearBtn = byId('unified-search-clear'); if (!form || !input || !toggle || !menu) return; function syncClear() { if (!clearBtn) return; clearBtn.hidden = input.value.length === 0; } if (clearBtn) { input.addEventListener('input', syncClear); clearBtn.addEventListener('click', function () { input.value = ''; syncClear(); input.focus(); }); syncClear(); } let idx = 0; try { const saved = localStorage.getItem(SEARCH_ENGINE_KEY); if (saved) { const savedIdx = SEARCH_ENGINES.findIndex(function (e) { return e.key === saved; }); if (savedIdx >= 0) idx = savedIdx; } else { idx = SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; }); } } catch (_) { idx = SEARCH_ENGINES.findIndex(function (e) { return e.key === 'google'; }); } if (idx < 0) idx = 0; SEARCH_ENGINES.forEach(function (e, i) { const li = document.createElement('li'); li.setAttribute('role', 'option'); li.dataset.index = String(i); li.innerHTML = ''; li.querySelector('.engine-icon').textContent = e.label; li.querySelector('.engine-name').textContent = e.title.replace(/\.\.$/, ''); li.addEventListener('click', function () { idx = i; apply(idx); closeMenu(); input.focus(); }); menu.appendChild(li); }); function apply(i) { const e = SEARCH_ENGINES[i]; form.setAttribute('action', e.action); input.setAttribute('name', e.param); input.setAttribute('placeholder', e.placeholder); toggle.innerHTML = ''; toggle.appendChild(document.createTextNode(e.label + ' ')); const caret = document.createElement('span'); caret.className = 'search-engine-caret'; caret.textContent = '▾'; toggle.appendChild(caret); toggle.setAttribute('title', 'Search engine: ' + e.title); if (title) { title.innerHTML = '' + e.title + ''; } Array.prototype.forEach.call(menu.children, function (li, li_i) { li.setAttribute('aria-selected', li_i === i ? 'true' : 'false'); }); try { localStorage.setItem(SEARCH_ENGINE_KEY, e.key); } catch (_) {} } function openMenu() { menu.hidden = false; toggle.setAttribute('aria-expanded', 'true'); } function closeMenu() { menu.hidden = true; toggle.setAttribute('aria-expanded', 'false'); } toggle.addEventListener('click', function (ev) { ev.stopPropagation(); if (menu.hidden) openMenu(); else closeMenu(); }); document.addEventListener('click', function (ev) { if (menu.hidden) return; if (ev.target === toggle || toggle.contains(ev.target)) return; if (menu.contains(ev.target)) return; closeMenu(); }); document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && !menu.hidden) closeMenu(); }); apply(idx); })(); })(); // close the main outer IIFE that wraps clock/lastfm/unified/etc. // Live card filter (works on data-attrs emitted by render_cards.php) (function initCardFilter() { const byId = (id) => document.getElementById(id); const filterInput = byId('card-filter'); const grid = byId('cards-grid'); const countEl = byId('card-filter-count'); if (!filterInput || !grid) return; const cards = Array.from(grid.querySelectorAll('.card')); if (!cards.length) return; // Ensure clean state (in case of previous inline or classes) cards.forEach(function (c) { c.classList.remove('hidden'); }); // Debug (open console if needed): console.log('[startpage filter] initialized with', cards.length, 'cards'); function normalize(s) { return (s || '').toLowerCase().trim(); } function applyFilter() { const q = normalize(filterInput.value); let visible = 0; cards.forEach(function (card) { const title = normalize(card.dataset.title); const labels = normalize(card.dataset.labels); const hay = title + ' ' + labels; const match = !q || hay.indexOf(q) !== -1; card.classList.toggle('hidden', !match); if (match) visible++; }); if (countEl) { countEl.textContent = q ? (visible + ' / ' + cards.length) : ''; } } filterInput.addEventListener('input', applyFilter); // Keyboard: '/' focuses the card filter (when not in input already) document.addEventListener('keydown', function (e) { const ae = document.activeElement; const isTyping = ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable); if (e.key === '/' && !isTyping) { e.preventDefault(); filterInput.focus(); filterInput.select(); } if (e.key === 'Escape' && ae === filterInput) { filterInput.value = ''; applyFilter(); filterInput.blur(); } }); // Initial applyFilter(); })(); // Keyboard help overlay (?) (function initKeyboardHelp() { document.addEventListener('keydown', function (e) { if (e.key === '?' && !e.shiftKey) { const ae = document.activeElement; if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) return; e.preventDefault(); let help = document.getElementById('kbd-help'); if (!help) { help = document.createElement('div'); help.id = 'kbd-help'; help.style.cssText = 'position:fixed;bottom:12px;right:12px;background:#111;border:1px solid #8b0000;padding:10px 14px;font-size:0.8rem;z-index:999;color:#8b0000;max-width:280px;line-height:1.5;'; help.innerHTML = 'Keyboard
/ — focus card filter
Esc — clear / close
? — toggle this
Click engine picker or cards
Theme toggle in header'; document.body.appendChild(help); const remover = function (ev) { if (ev.key === '?' || ev.key === 'Escape') { help.remove(); document.removeEventListener('keydown', remover); } }; document.addEventListener('keydown', remover); } else { help.remove(); } } }); })(); /* Click-to-open cascading Sports menu (left click toggles, like a context menu) */ document.querySelectorAll('.ctx-menu').forEach(function (menu) { function closeAll() { menu.querySelectorAll('.ctx-parent.open').forEach(function (p) { p.classList.remove('open'); }); } // A "trigger" is any element that owns a flyout: the Sports root or a category label menu.querySelectorAll('.ctx-trigger, .ctx-has-sub').forEach(function (trigger) { trigger.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); const parent = trigger.closest('.ctx-parent'); const wasOpen = parent.classList.contains('open'); // close siblings at the same level const siblings = parent.parentElement.querySelectorAll(':scope > .ctx-parent.open'); siblings.forEach(function (s) { if (s !== parent) s.classList.remove('open'); }); // toggle this one (also closes its descendants when collapsing) if (wasOpen) { parent.classList.remove('open'); parent.querySelectorAll('.ctx-parent.open').forEach(function (d) { d.classList.remove('open'); }); } else { parent.classList.add('open'); positionFlyout(parent); } }); }); // Place a category's (fixed-position) link list beside its label, cascading right, // flipping left / clamping when it would run off a screen edge. // Skips the root dropdown (direct child of .ctx-root), which CSS drops straight down. function positionFlyout(parent) { const flyout = parent.querySelector(':scope > .ctx-flyout'); if (!flyout || parent.parentElement.classList.contains('ctx-root')) return; const label = parent.getBoundingClientRect(); const box = parent.parentElement.getBoundingClientRect(); // the whole menu box (incl. scrollbar) const fw = flyout.offsetWidth; const fh = flyout.offsetHeight; const margin = 4; const gap = 2; // Top-right menus cascade LEFT (toward screen centre); everything else cascades right. // Flip to the other side only when the preferred side runs off the viewport edge. const openLeft = parent.closest('.top-right-links') !== null; let left; if (openLeft) { left = box.left - fw - gap; // open to the left of the whole box if (left < margin) { left = box.right + gap; // no room left: flip to the right } } else { left = box.right + gap; // top-left menus only ever cascade right (match the ▶ arrow) } if (left < margin) left = margin; if (left + fw > window.innerWidth - margin) left = window.innerWidth - fw - margin; // align top with the label, clamp into the viewport let top = label.top; if (top + fh > window.innerHeight - margin) { top = Math.max(margin, window.innerHeight - fh - margin); } flyout.style.left = left + 'px'; flyout.style.top = top + 'px'; } // Clicking a real link closes the whole menu menu.querySelectorAll('a.ctx-item').forEach(function (a) { a.addEventListener('click', closeAll); }); // Scrolling the category list closes any open (fixed) submenu so it can't float detached const rootFlyout = menu.querySelector('.ctx-root > .ctx-parent > .ctx-flyout'); if (rootFlyout) { rootFlyout.addEventListener('scroll', function () { rootFlyout.querySelectorAll(':scope > .ctx-parent.open').forEach(function (p) { p.classList.remove('open'); }); }); } // Click outside closes everything document.addEventListener('click', function (e) { if (!menu.contains(e.target)) closeAll(); }); }); /* Auto-extend the red section box so it always contains the absolutely-positioned link columns */ (function () { function fitSectionToLinks() { const section = document.querySelector('.search-section'); if (!section) return; const groups = section.querySelectorAll('.top-left-links, .top-right-links'); let needed = 0; groups.forEach(function (g) { // offsetTop (relative to section) + full height of the link column needed = Math.max(needed, g.offsetTop + g.offsetHeight); }); // small breathing room below the last link section.style.minHeight = needed > 0 ? (needed + 20) + 'px' : ''; } window.addEventListener('load', fitSectionToLinks); window.addEventListener('resize', fitSectionToLinks); if (document.readyState === 'complete') fitSectionToLinks(); })(); /* Subtle terminal boot sequence on first visit per session */ (function () { var overlay = document.getElementById('boot-overlay'); var logEl = document.getElementById('boot-log'); var body = document.body; if (!overlay || !logEl) return; var skipBoot = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (skipBoot || sessionStorage.getItem('startpageBooted') === '1') { overlay.hidden = true; body.classList.remove('startpage-booting'); body.classList.add('startpage-ready'); return; } /* OpenBSD /etc/rc prints plain lines, no OpenRC stars or [ ok ] brackets. */ var steps = [ 'savecore: no core dump', 'checking quotas: done.', 'mounting /startpage/cards.txt', 'starting network daemons: sshd httpd sndiod.', 'starting local daemons: cron scrobbler.', 'bringing up the Wired.' ]; var idx = 0; function el(tag, cls, text) { var n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; } /* The overlay never scrolls (CSS overflow:hidden), so the log has to fit. Measure the untransformed layout size (scrollWidth/Height ignore the transform) against the overlay's content box and scale down to fit. Runs after every append and on resize/orientation change. */ /* body{overflow:hidden} only reaches the viewport if the UA propagates it, and here it does not — the document stayed scrollable behind the overlay. Lock the root directly; CSS does the same via :has for pre-JS paint. */ var rootStyle = document.documentElement.style; var prevRootOverflow = rootStyle.overflow; rootStyle.overflow = 'hidden'; var fitRaf = 0; function fitBootLog() { fitRaf = 0; var cs = window.getComputedStyle(overlay); var availW = overlay.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); var availH = overlay.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); if (availW <= 0 || availH <= 0) return; logEl.style.transform = 'none'; var w = logEl.scrollWidth; var h = logEl.scrollHeight; if (!w || !h) return; var scale = Math.min(1, availW / w, availH / h); logEl.style.transform = scale < 1 ? 'scale(' + scale + ')' : 'none'; } function scheduleFit() { if (fitRaf) return; fitRaf = window.requestAnimationFrame(fitBootLog); } window.addEventListener('resize', scheduleFit); window.addEventListener('orientationchange', scheduleFit); var OPENBSD_WORDMARK = ' ____ ____ _____ ____' + '\n' + ' / __ \\____ ___ ____ / __ ) ___// __ \\' + '\n' + ' / / / / __ \\/ _ \\/ __ \\/ __ \\__ \\/ / / /' + '\n' + '/ /_/ / /_/ / __/ / / / /_/ /__/ / /_/ /' + '\n' + '\\____/ .___/\\___/_/ /_/_____/____/_____/' + '\n' + ' /_/'; var PUFFY_ASCII = ' _' + '\n' + ' (_)' + '\n' + ' | .' + '\n' + ' . |L /| . _' + '\n' + ' _ . |\\ _| \\--+._/| . (_)' + '\n' + ' / ||\\| Y J ) / |/| ./' + '\n' + ' J |)\'( | ` F`.\'/ _' + '\n' + ' -<| F __ .-< (_)' + '\n' + ' | / .-\'. `. /-. L___' + '\n' + ' J \\ < \\ | | O\\|.-\' _' + '\n' + ' _J \\ .- \\/ O | | \\ |F (_)' + '\n' + ' \'-F -<_. \\ .-\' `-\' L__' + '\n' + '__J _ _. >-\' )._. |-\'' + '\n' + ' `-|.\' /_. \\_| F' + '\n' + ' /.- . _.<' + '\n' + ' /\' /.\' .\' `\\' + '\n' + ' /L /\' |/ _.-\'-\\' + '\n' + ' /\'J ___.---\'\\|' + '\n' + ' |\\ .--\' V | `. `' + '\n' + ' |/`. `-. `._)' + '\n' + ' / .-.\\' + '\n' + ' \\ ( `\\' + '\n' + ' `.\\'; var avatar = el('img', 'boot-avatar'); avatar.src = '/assets/img/lain.png'; avatar.alt = ''; avatar.width = 96; avatar.height = 96; /* Lain sits directly above Puffy's bubble column (the `(_)` marks on the right of the ASCII), so the bubbles read as rising toward her. */ var puffyWrap = el('div', 'boot-puffy-wrap'); puffyWrap.appendChild(avatar); puffyWrap.appendChild(el('pre', 'boot-puffy-ascii', PUFFY_ASCII)); var asciiWrap = el('div', 'boot-ascii-wrap'); asciiWrap.appendChild(puffyWrap); asciiWrap.appendChild(el('pre', 'boot-openbsd-wordmark', OPENBSD_WORDMARK)); logEl.appendChild(asciiWrap); fitBootLog(); /* lain.png may land after first layout and change the log's height. */ avatar.addEventListener('load', scheduleFit); /* Prefer the version cached by tools/update_openbsd_version.sh (cron pulls openbsd.org). Fallback: OpenBSD ships ~1 May and ~1 Nov, rolling x.9 -> (x+1).0; anchor 7.9 = May 2026 so the banner never goes stale. */ function openbsdRelease() { var cached = (typeof window.OPENBSD_RELEASE === 'string') ? window.OPENBSD_RELEASE.trim() : ''; if (/^\d+\.\d+$/.test(cached)) return cached; var now = new Date(); var m = now.getMonth(); var halves = (now.getFullYear() - 2026) * 2 + (m >= 10 ? 1 : (m >= 4 ? 0 : -1)); var minor = 9 + halves; return (7 + Math.floor(minor / 10)) + '.' + (minor % 10); } logEl.appendChild(el('div', 'boot-title', 'OpenBSD ' + openbsdRelease() + ' (GENERIC.MP) #1: sillylaird.ca — amd64')); fitBootLog(); function finishBoot() { if (fitRaf) window.cancelAnimationFrame(fitRaf); rootStyle.overflow = prevRootOverflow; window.removeEventListener('resize', scheduleFit); window.removeEventListener('orientationchange', scheduleFit); sessionStorage.setItem('startpageBooted', '1'); overlay.hidden = true; body.classList.remove('startpage-booting'); body.classList.add('startpage-ready'); } function fadeOut() { overlay.classList.add('boot-fade'); window.setTimeout(finishBoot, 380); } function showPrompt() { var prompt = el('div', 'boot-login'); prompt.appendChild(el('span', 'boot-user', 'sillylaird@Navi')); prompt.appendChild(el('span', null, ':')); prompt.appendChild(el('span', 'boot-path', '~')); prompt.appendChild(el('span', null, '$ ')); prompt.appendChild(el('span', 'boot-cursor', '█')); logEl.appendChild(prompt); fitBootLog(); window.setTimeout(fadeOut, 420); } function typeLogin() { var login = el('div', 'boot-login'); var label = el('span', 'boot-dim', 'Navi login: '); var typed = el('span', null, ''); var cursor = el('span', 'boot-cursor', '█'); login.appendChild(label); login.appendChild(typed); login.appendChild(cursor); logEl.appendChild(login); fitBootLog(); var name = 'sillylaird'; var i = 0; function typeChar() { if (i < name.length) { typed.textContent += name.charAt(i); fitBootLog(); i += 1; window.setTimeout(typeChar, 70); return; } cursor.remove(); window.setTimeout(function () { logEl.appendChild(el('div', 'boot-login boot-dim', 'Password: ')); logEl.appendChild(el('div', 'boot-dim', 'Welcome to OpenBSD: The proactively secure Unix-like operating system.')); fitBootLog(); window.setTimeout(showPrompt, 240); }, 180); } window.setTimeout(typeChar, 150); } function nextLine() { if (idx >= steps.length) { typeLogin(); return; } var line = el('div', 'boot-line'); var left = el('span', 'boot-svc', steps[idx]); line.appendChild(left); logEl.appendChild(line); fitBootLog(); idx += 1; window.setTimeout(nextLine, idx === 1 ? 110 : 130); } window.setTimeout(nextLine, 140); })(); /* Comfortable line spacing — synced with site-wide comfort toggle (header ↕) */ (function () { var KEY = 'siteComfortSpacing_v1'; var btn = document.getElementById('comfort-toggle'); var body = document.body; if (!btn) return; function apply(on) { body.classList.toggle('startpage-comfort', on); body.classList.toggle('comfort-spacing', on); // Keep the header icon button compact (do not replace ↕ with long text). btn.setAttribute('aria-pressed', on ? 'true' : 'false'); btn.setAttribute('title', on ? 'Comfort spacing: ON' : 'Comfort spacing: OFF'); btn.setAttribute('aria-label', on ? 'Comfort spacing on' : 'Comfort spacing off'); } // Use comfortable spacing unless this browser has explicitly opted out. apply(localStorage.getItem(KEY) !== '0'); btn.addEventListener('click', function () { var next = !body.classList.contains('startpage-comfort'); localStorage.setItem(KEY, next ? '1' : '0'); apply(next); }); })(); /* Konaste / BEMANI PC maintenance status — computed client-side from the fixed e-amusement schedule (shared backend, so both games share windows): - Daily maintenance 05:00–07:00 JST - Extended maintenance 02:00–07:00 JST on the 3rd Tuesday of the month JST is a fixed UTC+9 offset (no DST). Display times use the timezone configured on the viewer's computer/browser. */ (function () { var root = document.getElementById('konaste-status'); if (!root) return; var JST_OFFSET_MS = 9 * 3600 * 1000; var DISPLAY_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; // JST wall-clock fields for a real Date. function jstFields(date) { var j = new Date(date.getTime() + JST_OFFSET_MS); return { y: j.getUTCFullYear(), mo: j.getUTCMonth(), d: j.getUTCDate(), dow: j.getUTCDay() }; } // Real epoch ms for a JST wall-clock instant. function jstToEpoch(y, mo, d, h, m) { return Date.UTC(y, mo, d, h, m) - JST_OFFSET_MS; } // 3rd Tuesday => Tuesday (dow 2) with day-of-month 15..21. function isThirdTuesday(f) { return f.dow === 2 && f.d >= 15 && f.d <= 21; } // Maintenance window [start, end) in real epoch ms for the JST calendar day // that a given JST-field set falls on. offsetDays shifts that day. function windowFor(baseFields, offsetDays) { var day = new Date(Date.UTC(baseFields.y, baseFields.mo, baseFields.d + offsetDays)); var f = { y: day.getUTCFullYear(), mo: day.getUTCMonth(), d: day.getUTCDate(), dow: day.getUTCDay() }; var startHour = isThirdTuesday(f) ? 2 : 5; return { start: jstToEpoch(f.y, f.mo, f.d, startHour, 0), end: jstToEpoch(f.y, f.mo, f.d, 7, 0), extended: isThirdTuesday(f) }; } function pad2(n) { return n < 10 ? '0' + n : '' + n; } function fmtLocal(epoch) { var d = new Date(epoch); try { var formatted = new Intl.DateTimeFormat(navigator.language || undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit', timeZone: DISPLAY_TIME_ZONE }).format(d); return formatted; } catch (e) { return d.toLocaleString(); } } function fmtCountdown(ms) { var total = Math.max(0, Math.floor(ms / 60000)); var h = Math.floor(total / 60); var m = total % 60; if (h > 0) return h + 'h ' + m + 'm'; return m + 'm'; } // Determine status at the current moment. function compute(now) { var f = jstFields(now); var t = now.getTime(); // Today's window plus the previous day's (guards a window that we might // still be inside just after JST midnight — none currently cross it, but // this keeps the logic correct if hours ever change). var candidates = [windowFor(f, -1), windowFor(f, 0)]; for (var i = 0; i < candidates.length; i++) { var w = candidates[i]; if (t >= w.start && t < w.end) { return { online: false, window: w, until: w.end }; } } // Online: find the next upcoming window start. var next = null; for (var d = 0; d <= 40; d++) { var w2 = windowFor(f, d); if (w2.start > t) { next = w2; break; } } return { online: true, next: next }; } // Live local clock — hour / minute / second kept in separate fields, ticks in // real time using the computer's connected timezone. function renderClock(now) { var el = document.getElementById('konaste-clock'); if (!el) return; var h = el.querySelector('[data-clock="h"]'); var m = el.querySelector('[data-clock="m"]'); var s = el.querySelector('[data-clock="s"]'); var ap = el.querySelector('[data-clock="ap"]'); var parts = new Intl.DateTimeFormat('en-US', { timeZone: DISPLAY_TIME_ZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }).formatToParts(now); var clockParts = {}; for (var i = 0; i < parts.length; i++) clockParts[parts[i].type] = parts[i].value; if (h) h.textContent = clockParts.hour || '--'; if (m) m.textContent = clockParts.minute || '--'; if (s) s.textContent = clockParts.second || '--'; if (ap) ap.textContent = clockParts.dayPeriod || ''; var tz = root.querySelector('[data-clock="tz"]'); if (tz) tz.textContent = ''; } // Single shared online/offline line — pop'n Lively and GITADORA use the same // e-amusement maintenance window, so one status covers both. function renderStatus(now) { var state = compute(now); var row = root.querySelector('.konaste-game'); if (!row) return; var dot = row.querySelector('[data-role="dot"]'); var text = row.querySelector('[data-role="text"]'); var cd = row.querySelector('[data-role="countdown"]'); dot.className = 'konaste-dot'; cd.textContent = ''; if (!state.online) { dot.classList.add('is-maintenance'); text.textContent = (state.window.extended ? 'Extended maintenance' : 'Under maintenance') + '\nStarted: ' + fmtLocal(state.window.start) + '\nEnds: ' + fmtLocal(state.window.end); cd.textContent = 'Remaining: ' + fmtCountdown(state.until - now.getTime()); } else { dot.classList.add('is-online'); text.textContent = 'Online • play now'; if (state.next) { text.textContent += '\nNext maintenance: ' + fmtLocal(state.next.start) + ' to ' + fmtLocal(state.next.end); cd.textContent = 'Starts in: ' + fmtCountdown(state.next.start - now.getTime()); } } } function tick() { var now = new Date(); renderClock(now); renderStatus(now); } tick(); setInterval(tick, 1000); document.addEventListener('visibilitychange', function () { if (!document.hidden) tick(); }); })();