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