Server-side batched /api/v1/services/status endpoint replaces N individual browser probes with a single API call (HEAD-first with GET fallback, concurrency-limited, CA-aware HTTPS agent). Frontend: clock reuses DOM instead of rebuilding innerHTML every second with drift-correcting timer that pauses on hidden tabs. Card animations use CSS transitionDelay + requestAnimationFrame. Internet dot blink moved from JS intervals to CSS keyframes with prefers-reduced-motion support. Service worker rewritten with network-first navigation, stale-while-revalidate assets, and navigation preload. Font faces drop TTF fallbacks, use font-display swap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
// ========== DIGITAL CLOCK WIDGET ==========
|
|
(function() {
|
|
const widget = document.getElementById('clock-widget');
|
|
const render = document.getElementById('clock-render');
|
|
if (!widget || !render) return;
|
|
|
|
const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
|
|
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
const ROMAN = ['XII','I','II','III','IV','V','VI','VII','VIII','IX','X','XI'];
|
|
|
|
let currentStyle = safeGet('clock-style') || 'default';
|
|
let lastChimeHour = -1;
|
|
let chimePlaying = false;
|
|
let prevFlipDigits = '';
|
|
let activeLayout = '';
|
|
let digitalRefs = null;
|
|
let tickTimer = null;
|
|
|
|
// ===== CHIMES =====
|
|
function playChimes(count) {
|
|
if (chimePlaying) return;
|
|
if (safeGet('clock-chimes') !== 'true') return;
|
|
chimePlaying = true;
|
|
const vol = parseInt(safeGet('clock-chime-volume') || '50', 10) / 100;
|
|
let i = 0;
|
|
function strike() {
|
|
if (i >= count) { chimePlaying = false; return; }
|
|
const bell = new Audio('/assets/sounds/church-bell.mp3');
|
|
bell.volume = vol;
|
|
bell.play().catch(() => {});
|
|
i++;
|
|
if (i < count) setTimeout(strike, 2500);
|
|
else setTimeout(() => { chimePlaying = false; }, 2500);
|
|
}
|
|
strike();
|
|
}
|
|
|
|
// ===== DATE STRING =====
|
|
function dateStr(now) {
|
|
return DAYS[now.getDay()] + ', ' + MONTHS[now.getMonth()] + ' ' + now.getDate() + ', ' + now.getFullYear();
|
|
}
|
|
|
|
function resetRenderLayout() {
|
|
activeLayout = '';
|
|
digitalRefs = null;
|
|
}
|
|
|
|
function ensureDigitalLayout() {
|
|
if (activeLayout !== 'digital') {
|
|
render.innerHTML =
|
|
'<div class="clock-time"><span class="clock-main"></span><span class="clock-seconds"></span><span class="clock-ampm"></span></div>' +
|
|
'<div class="clock-date"></div>';
|
|
digitalRefs = {
|
|
main: render.querySelector('.clock-main'),
|
|
seconds: render.querySelector('.clock-seconds'),
|
|
ampm: render.querySelector('.clock-ampm'),
|
|
date: render.querySelector('.clock-date')
|
|
};
|
|
activeLayout = 'digital';
|
|
}
|
|
return digitalRefs;
|
|
}
|
|
|
|
// ===== STYLE RENDERERS =====
|
|
|
|
function renderDefault(now) {
|
|
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
|
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
|
const h = h24 % 12 || 12;
|
|
const refs = ensureDigitalLayout();
|
|
refs.main.textContent = `${h}:${String(m).padStart(2,'0')}`;
|
|
refs.seconds.textContent = `:${String(s).padStart(2,'0')}`;
|
|
refs.ampm.textContent = ampm;
|
|
refs.date.textContent = dateStr(now);
|
|
}
|
|
|
|
function renderLcd(now, cls) {
|
|
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
|
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
|
const h = h24 % 12 || 12;
|
|
const refs = ensureDigitalLayout();
|
|
refs.main.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
|
|
refs.seconds.textContent = `:${String(s).padStart(2,'0')}`;
|
|
refs.ampm.textContent = ampm;
|
|
refs.date.textContent = dateStr(now);
|
|
}
|
|
|
|
function renderFlip(now) {
|
|
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
|
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
|
const h = h24 % 12 || 12;
|
|
const digits = String(h).padStart(2,' ') + String(m).padStart(2,'0') + String(s).padStart(2,'0');
|
|
|
|
let html = '<div class="flip-clock-row">';
|
|
// Hours
|
|
html += flipCard(digits[0], 0);
|
|
html += flipCard(digits[1], 1);
|
|
html += '<span class="flip-colon">:</span>';
|
|
// Minutes
|
|
html += flipCard(digits[2], 2);
|
|
html += flipCard(digits[3], 3);
|
|
html += '<span class="flip-colon">:</span>';
|
|
// Seconds
|
|
html += flipCard(digits[4], 4);
|
|
html += flipCard(digits[5], 5);
|
|
html += `<span class="flip-ampm">${ampm}</span>`;
|
|
html += '</div>';
|
|
html += `<div class="clock-date">${dateStr(now)}</div>`;
|
|
render.innerHTML = html;
|
|
activeLayout = 'flip';
|
|
|
|
// Trigger flip animation on changed digits
|
|
if (prevFlipDigits) {
|
|
for (let i = 0; i < 6; i++) {
|
|
if (digits[i] !== prevFlipDigits[i]) {
|
|
const card = render.querySelector(`.flip-card[data-idx="${i}"]`);
|
|
if (card) card.classList.add('flipping');
|
|
}
|
|
}
|
|
}
|
|
prevFlipDigits = digits;
|
|
}
|
|
|
|
function flipCard(digit, idx) {
|
|
const d = digit === ' ' ? '' : digit;
|
|
return `<div class="flip-card" data-idx="${idx}"><div class="flip-top">${d}</div><div class="flip-bottom">${d}</div></div>`;
|
|
}
|
|
|
|
function renderBinary(now) {
|
|
const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
|
const h = h24 % 12 || 12;
|
|
const ampm = h24 >= 12 ? 'PM' : 'AM';
|
|
// 6 columns: H tens, H ones, M tens, M ones, S tens, S ones
|
|
const cols = [
|
|
Math.floor(h / 10), h % 10,
|
|
Math.floor(m / 10), m % 10,
|
|
Math.floor(s / 10), s % 10
|
|
];
|
|
|
|
let html = '<div class="binary-clock">';
|
|
// Labels
|
|
html += '<div class="binary-labels"><span>H</span><span>H</span><span>M</span><span>M</span><span>S</span><span>S</span></div>';
|
|
// 4 rows for bits 8,4,2,1
|
|
for (let bit = 3; bit >= 0; bit--) {
|
|
html += '<div class="binary-row">';
|
|
for (let col = 0; col < 6; col++) {
|
|
const on = (cols[col] >> bit) & 1;
|
|
html += `<div class="binary-dot ${on ? 'on' : ''}"></div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
// Value row
|
|
html += '<div class="binary-values">';
|
|
for (let col = 0; col < 6; col++) {
|
|
html += `<span>${cols[col]}</span>`;
|
|
}
|
|
html += '</div>';
|
|
html += `<div class="binary-ampm">${ampm}</div>`;
|
|
html += '</div>';
|
|
html += `<div class="clock-date">${dateStr(now)}</div>`;
|
|
render.innerHTML = html;
|
|
activeLayout = 'binary';
|
|
}
|
|
|
|
function renderAnalog(now, useRoman) {
|
|
const h = now.getHours(), m = now.getMinutes(), s = now.getSeconds();
|
|
const size = 120;
|
|
const cx = size / 2, cy = size / 2;
|
|
|
|
// Angles
|
|
const sAngle = (s / 60) * 360 - 90;
|
|
const mAngle = ((m + s / 60) / 60) * 360 - 90;
|
|
const hAngle = (((h % 12) + m / 60) / 12) * 360 - 90;
|
|
|
|
// Number labels
|
|
let labels = '';
|
|
for (let i = 1; i <= 12; i++) {
|
|
const angle = (i / 12) * 2 * Math.PI - Math.PI / 2;
|
|
const r = 47;
|
|
const x = cx + r * Math.cos(angle);
|
|
const y = cy + r * Math.sin(angle);
|
|
const label = useRoman ? ROMAN[i % 12] : i;
|
|
const fs = useRoman ? '7' : '9';
|
|
labels += `<text x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central" fill="var(--fg)" font-size="${fs}" font-weight="600" font-family="'Sami Grotesk',sans-serif">${label}</text>`;
|
|
}
|
|
|
|
// Tick marks
|
|
let ticks = '';
|
|
for (let i = 0; i < 60; i++) {
|
|
const angle = (i / 60) * 2 * Math.PI - Math.PI / 2;
|
|
const outer = 56;
|
|
const inner = i % 5 === 0 ? 52 : 54;
|
|
const x1 = cx + inner * Math.cos(angle);
|
|
const y1 = cy + inner * Math.sin(angle);
|
|
const x2 = cx + outer * Math.cos(angle);
|
|
const y2 = cy + outer * Math.sin(angle);
|
|
const w = i % 5 === 0 ? 1.5 : 0.5;
|
|
ticks += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="var(--muted)" stroke-width="${w}" stroke-linecap="round"/>`;
|
|
}
|
|
|
|
const svg = `<svg class="analog-clock-svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
|
<circle cx="${cx}" cy="${cy}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
${ticks}
|
|
${labels}
|
|
<line x1="${cx}" y1="${cy}" x2="${cx + 28 * Math.cos(hAngle * Math.PI / 180)}" y2="${cy + 28 * Math.sin(hAngle * Math.PI / 180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
<line x1="${cx}" y1="${cy}" x2="${cx + 38 * Math.cos(mAngle * Math.PI / 180)}" y2="${cy + 38 * Math.sin(mAngle * Math.PI / 180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
<line x1="${cx}" y1="${cy}" x2="${cx + 42 * Math.cos(sAngle * Math.PI / 180)}" y2="${cy + 42 * Math.sin(sAngle * Math.PI / 180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
<circle cx="${cx}" cy="${cy}" r="3" fill="var(--fg)"/>
|
|
</svg>`;
|
|
|
|
const ampm = now.getHours() >= 12 ? 'PM' : 'AM';
|
|
render.innerHTML = `<div class="analog-clock-wrap">${svg}<div class="analog-info"><span class="analog-digital">${(now.getHours() % 12 || 12)}:${String(m).padStart(2,'0')} ${ampm}</span><span class="analog-date-sm">${dateStr(now)}</span></div></div>`;
|
|
activeLayout = 'analog';
|
|
}
|
|
|
|
// ===== MAIN TICK =====
|
|
function tick() {
|
|
const now = new Date();
|
|
const h = now.getHours() % 12 || 12;
|
|
const m = now.getMinutes();
|
|
const s = now.getSeconds();
|
|
|
|
// Set style class on widget
|
|
const nextClassName = 'clock-widget' + (currentStyle !== 'default' ? ' ' + currentStyle : '');
|
|
if (widget.className !== nextClassName) {
|
|
widget.className = nextClassName;
|
|
}
|
|
|
|
switch (currentStyle) {
|
|
case 'lcd': renderLcd(now); break;
|
|
case 'lcd-blue': renderLcd(now); break;
|
|
case 'lcd-amber': renderLcd(now); break;
|
|
case 'lcd-retro': renderLcd(now); break;
|
|
case 'lcd-taxi': renderLcd(now); break;
|
|
case 'flip': renderFlip(now); break;
|
|
case 'binary': renderBinary(now); break;
|
|
case 'analog': renderAnalog(now, false); break;
|
|
case 'roman': renderAnalog(now, true); break;
|
|
default: renderDefault(now);
|
|
}
|
|
|
|
// Hourly chimes
|
|
if (m === 0 && s === 0 && h !== lastChimeHour) {
|
|
lastChimeHour = h;
|
|
playChimes(h);
|
|
}
|
|
if (m !== 0) lastChimeHour = -1;
|
|
}
|
|
|
|
function scheduleNextTick() {
|
|
clearTimeout(tickTimer);
|
|
const interval = document.hidden ? 60000 : 1000;
|
|
const delay = interval - (Date.now() % interval) + 25;
|
|
tickTimer = setTimeout(() => {
|
|
tick();
|
|
scheduleNextTick();
|
|
}, delay);
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
prevFlipDigits = '';
|
|
resetRenderLayout();
|
|
tick();
|
|
scheduleNextTick();
|
|
});
|
|
|
|
tick();
|
|
scheduleNextTick();
|
|
|
|
// ===== SETTINGS MODAL =====
|
|
const STYLES = [
|
|
{ id: 'default', label: 'Default', icon: '🕐' },
|
|
{ id: 'lcd', label: 'LCD Green', icon: '💚' },
|
|
{ id: 'lcd-blue', label: 'LCD Blue', icon: '💙' },
|
|
{ id: 'lcd-amber', label: 'LCD Amber', icon: '🟠' },
|
|
{ id: 'lcd-retro', label: 'LCD Retro', icon: '🟩' },
|
|
{ id: 'lcd-taxi', label: 'LCD Taxi', icon: '🟡' },
|
|
{ id: 'flip', label: 'Flip Clock', icon: '📟' },
|
|
{ id: 'binary', label: 'Binary', icon: '💻' },
|
|
{ id: 'analog', label: 'Analog', icon: '⏰' },
|
|
{ id: 'roman', label: 'Roman', icon: '🏛️' },
|
|
];
|
|
|
|
let styleOptionsHtml = '<div class="clock-style-grid">';
|
|
STYLES.forEach(s => {
|
|
styleOptionsHtml += `<label class="clock-style-option">
|
|
<input type="radio" name="clock-style-radio" value="${s.id}">
|
|
<span class="clock-style-card"><span class="clock-style-icon">${s.icon}</span><span class="clock-style-label">${s.label}</span></span>
|
|
</label>`;
|
|
});
|
|
styleOptionsHtml += '</div>';
|
|
|
|
injectModal('clock-settings-modal', `<div id="clock-settings-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="max-width: 420px;">
|
|
<h3>Clock Settings</h3>
|
|
<div style="margin-bottom: 16px;">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
|
${styleOptionsHtml}
|
|
</div>
|
|
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
|
<input type="checkbox" id="clock-chimes-toggle"> Hourly church bell chimes
|
|
</label>
|
|
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px; margin-left: 24px;">
|
|
Strikes the number of the hour (e.g., 3 bells at 3:00)
|
|
</div>
|
|
</div>
|
|
<div style="margin-bottom: 16px;" id="clock-volume-section">
|
|
<label style="display: block; margin-bottom: 6px; font-weight: 600; color: var(--fg);">Volume</label>
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<span style="font-size: 0.85rem;">🔈</span>
|
|
<input type="range" id="clock-chime-volume" min="0" max="100" value="50" style="flex: 1; accent-color: var(--ok-fg);">
|
|
<span style="font-size: 0.85rem;">🔊</span>
|
|
<button id="clock-chime-test" style="padding: 4px 10px; font-size: 0.78rem; border-radius: 4px; cursor: pointer;">Test</button>
|
|
</div>
|
|
</div>
|
|
<div class="weather-modal-buttons">
|
|
<button id="clock-settings-cancel">Cancel</button>
|
|
<button id="clock-settings-save">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>`);
|
|
|
|
const modal = document.getElementById('clock-settings-modal');
|
|
const chimesToggle = document.getElementById('clock-chimes-toggle');
|
|
const volumeSlider = document.getElementById('clock-chime-volume');
|
|
const volumeSection = document.getElementById('clock-volume-section');
|
|
|
|
function loadSettings() {
|
|
const style = safeGet('clock-style') || 'default';
|
|
const radio = modal.querySelector(`input[value="${style}"]`);
|
|
if (radio) radio.checked = true;
|
|
chimesToggle.checked = safeGet('clock-chimes') === 'true';
|
|
volumeSlider.value = safeGet('clock-chime-volume') || '50';
|
|
volumeSection.style.opacity = chimesToggle.checked ? '1' : '0.4';
|
|
}
|
|
|
|
chimesToggle?.addEventListener('change', () => {
|
|
volumeSection.style.opacity = chimesToggle.checked ? '1' : '0.4';
|
|
});
|
|
|
|
document.getElementById('clock-settings')?.addEventListener('click', () => {
|
|
loadSettings();
|
|
modal.classList.add('show');
|
|
});
|
|
|
|
document.getElementById('clock-chime-test')?.addEventListener('click', () => {
|
|
const vol = parseInt(volumeSlider.value, 10) / 100;
|
|
const bell = new Audio('/assets/sounds/church-bell.mp3');
|
|
bell.volume = vol;
|
|
bell.play().catch(() => {});
|
|
});
|
|
|
|
document.getElementById('clock-settings-save')?.addEventListener('click', () => {
|
|
const radio = modal.querySelector('input[name="clock-style-radio"]:checked');
|
|
const style = radio ? radio.value : 'default';
|
|
safeSet('clock-style', style);
|
|
safeSet('clock-chimes', String(chimesToggle.checked));
|
|
safeSet('clock-chime-volume', volumeSlider.value);
|
|
currentStyle = style;
|
|
prevFlipDigits = '';
|
|
resetRenderLayout();
|
|
tick();
|
|
scheduleNextTick();
|
|
modal.classList.remove('show');
|
|
showNotification('Clock settings saved', 'success', 2000);
|
|
});
|
|
|
|
document.getElementById('clock-settings-cancel')?.addEventListener('click', () => {
|
|
modal.classList.remove('show');
|
|
});
|
|
|
|
wireModal(modal);
|
|
|
|
// Live preview when clicking style options
|
|
modal?.querySelectorAll('input[name="clock-style-radio"]').forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
currentStyle = radio.value;
|
|
prevFlipDigits = '';
|
|
resetRenderLayout();
|
|
tick();
|
|
});
|
|
});
|
|
})();
|