/* Display page - pure vanilla JS, minimal and fast */
(function () {
'use strict';
// 自定义提示(替代浏览器 alert):轻量浮层,自动消失
function showAlert(msg) {
var el = document.createElement('div');
el.style.cssText = 'position:fixed;top:56px;left:50%;transform:translateX(-50%);z-index:9999;' +
'background:#fff;color:#E05555;border:1px solid #f0c2c2;border-radius:12px;' +
'padding:12px 24px;font-size:15px;box-shadow:0 6px 24px rgba(0,0,0,.18);';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 2200);
}
// ─── State ────────────────────────────────
var state = {
weather: {},
events: [],
clocks: [],
alarms: [],
groups: [],
pomodoro: {},
activePanel: 'time',
alarmActive: false,
pomodoroRinging: false,
customNotifyActive: false,
audioCtx: null,
editingAlarm: null, // alarm object being edited, or null for new
_alarmModalOpen: false,
countdowns: [],
hourly: [],
minutely: null,
warnings: null,
update_schedule: {},
_cdModalMode: false, // true = countdown modal, false = alarm modal
_editingCountdown: null,
};
// ─── DOM refs ─────────────────────────────
var $ = function (id) { return document.getElementById(id); };
var cards = document.querySelectorAll('.card');
var panels = document.querySelectorAll('.panel-content');
// HTML escape for untrusted data — prevents XSS in innerHTML
function _esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
}
// ─── Panel switching ──────────────────────
function switchPanel(name) {
if (state.alarmActive && name !== 'alarm') return; // lock during alarm
state.activePanel = name;
cards.forEach(function (c) { c.classList.remove('active'); });
panels.forEach(function (p) { p.classList.remove('active'); });
var card = document.querySelector('[data-card="' + name + '"]');
var panel = $('panel-' + name);
if (card) card.classList.add('active');
if (panel) panel.classList.add('active');
// Re-render pomodoro card to show/hide buttons based on active panel
renderPomodoroCard();
// Refresh pomodoro state when switching to pomodoro panel
if (name === 'pomodoro') {
fetchPomodoro();
}
}
cards.forEach(function (card) {
card.addEventListener('click', function () {
switchPanel(this.dataset.card);
});
});
// ─── Data fetching ────────────────────────
function fetchState() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/display/state');
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
state.weather = data.weather || {};
state.hourly = data.hourly || [];
state.events = data.events || [];
state.alarms = data.alarms || [];
state.countdowns = data.countdowns || [];
state.update_schedule = data.update_schedule || {};
state.minutely = data.minutely || null;
state.warnings = data.warnings || null;
state.groups = data.groups || [];
renderAll();
}
};
xhr.send();
}
// ─── SSE for real-time events ─────────────
function connectSSE() {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
handleEvent(msg);
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
// SSE doesn't give us status codes; if auth fails the browser will error-loop.
// We add a small guard: if we get no messages for 30s, try a normal fetch to check auth.
state._sseGuard = setInterval(function () {
var g = new XMLHttpRequest();
g.open('GET', '/api/display/state');
g.onload = function () {
if (g.status === 401) {
var res = JSON.parse(g.responseText || '{}');
if (res.login_required) {
es.close();
clearInterval(state._sseGuard);
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}
}
};
g.send();
}, 30000);
}
function handleEvent(msg) {
if (msg.type === 'alarm_triggered') {
fetchState();
showAlarmOverlay(msg.data);
} else if (msg.type === 'pomodoro_updated') {
state.pomodoro = msg.data || {};
renderPomodoro();
} else if (msg.type === 'pomodoro_due') {
state.pomodoro = msg.data || {};
renderPomodoro();
showPomodoroOverlay();
} else if (msg.type === 'calendar_updated') {
fetchState();
var card = $('card-calendar');
if (card) card.classList.add('alert');
setTimeout(function () { if (card) card.classList.remove('alert'); }, 6000);
switchPanel('calendar');
} else if (msg.type === 'weather_updated') {
fetchState();
} else if (msg.type === 'reminder_due') {
var card = $('card-calendar');
if (card) card.classList.add('alert');
switchPanel('calendar');
fetchState();
playReminderSound();
// 5秒后自动跳回时间页面并移除高亮
setTimeout(function () {
if (card) card.classList.remove('alert');
switchPanel('time');
}, 5000);
} else if (msg.type === 'custom_notification') {
showCustomNotification(msg.data);
} else if (msg.type === 'countdown_triggered') {
fetchState();
showAlarmOverlay(msg.data); // reuse alarm overlay UI
switchPanel('countdown');
} else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') {
location.reload();
} else if (msg.type === 'theme_changed') {
// 控制端修改了主题 → 立即刷新生效(ThemeBridge 带平滑过渡动画)
if (window.ThemeBridge) ThemeBridge.fetch();
} else if (msg.type === 'kick_all') {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
};
xhr.send();
}
}
// ─── Alarm Overlay & Sound ────────────────
function showAlarmOverlay(data) {
if (state.alarmActive) return; // already showing
state.alarmActive = true;
// Build info text (supports both alarm and countdown data)
var info = '';
(data.alarms || []).forEach(function (a) {
info += a.time + (a.label ? ' ' + a.label : '') + ' ';
});
(data.countdowns || []).forEach(function (cd) {
info += cd.target_time + (cd.label ? ' ' + cd.label : '') + ' ';
});
var overlay = $('alarm-overlay');
var infoEl = $('alarm-overlay-info');
var audio = $('alarm-audio');
if (infoEl) infoEl.innerHTML = info || '闹钟响了';
if (overlay) overlay.classList.remove('hidden');
// Highlight the alarm card
var card = $('card-alarm');
if (card) card.classList.add('alert');
// Switch panel
switchPanel('alarm');
// Try custom ringtone first
var hasCustomRingtone = false;
(data.alarms || []).forEach(function (a) {
if (a.sound && a.sound !== 'default') {
audio.src = '/static/ringtones/' + encodeURIComponent(a.sound);
audio.play().catch(function () { playDefaultAlarm(); });
hasCustomRingtone = true;
}
});
if (!hasCustomRingtone) {
playDefaultAlarm();
}
}
function playDefaultAlarm() {
// Generate alarm sound with Web Audio API
try {
state.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (_) {
return;
}
// Create a two-tone alarm beep pattern
var ctx = state.audioCtx;
var beepDuration = 0.15; // seconds per beep
var beepGap = 0.1;
var cycleTime = (beepDuration * 2 + beepGap * 2);
var startTime = ctx.currentTime;
function scheduleBeep(t, freq) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'square';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + beepDuration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + beepDuration);
}
function scheduleCycle(cycleStart) {
scheduleBeep(cycleStart, 880); // A5
scheduleBeep(cycleStart + beepDuration + beepGap, 660); // E5
}
// Schedule 60 seconds of alarm (will be stopped on dismiss)
var cycles = Math.floor(60 / cycleTime);
for (var i = 0; i < cycles; i++) {
scheduleCycle(startTime + i * cycleTime);
}
// Re-schedule if still active
state._alarmTimer = setInterval(function () {
if (!state.alarmActive) { clearInterval(state._alarmTimer); return; }
var t = state.audioCtx.currentTime;
for (var i = 0; i < 10; i++) {
scheduleCycle(t + i * cycleTime);
}
}, 5000);
}
function playReminderSound() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
var t = ctx.currentTime;
// 轻柔的三声提示音 (增益 1.0)
[0, 0.3, 0.6].forEach(function (offset) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(1.0, t + offset);
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t + offset);
osc.stop(t + offset + 0.15);
});
setTimeout(function () { ctx.close(); }, 2000);
} catch (_) {}
}
function dismissAlarm() {
state.alarmActive = false;
var overlay = $('alarm-overlay');
var audio = $('alarm-audio');
var card = $('card-alarm');
if (overlay) overlay.classList.add('hidden');
if (card) card.classList.remove('alert');
// Stop audio
if (audio) { audio.pause(); audio.src = ''; }
if (state.audioCtx) {
try { state.audioCtx.close(); } catch (_) {}
state.audioCtx = null;
}
if (state._alarmTimer) {
clearInterval(state._alarmTimer);
state._alarmTimer = null;
}
// Return to time panel
switchPanel('time');
fetchState();
}
// ─── Pomodoro Overlay & Sound ─────────────
function showPomodoroOverlay() {
if (state.pomodoroRinging) return;
state.pomodoroRinging = true;
var overlay = $('pomodoro-overlay');
var titleEl = $('pomodoro-overlay-title');
var infoEl = $('pomodoro-overlay-info');
// dismiss() has already advanced phase, so invert the mapping:
// phase=="work" means break just finished, phase=="break" means work just finished.
var phase = state.pomodoro.phase === 'work' ? '休息结束' : '工作结束';
var nextPhase = state.pomodoro.phase === 'work' ? '进入工作' : '进入休息';
if (titleEl) titleEl.textContent = '番茄钟 - ' + phase;
if (infoEl) infoEl.textContent = nextPhase;
if (overlay) overlay.classList.remove('hidden');
var card = $('card-pomodoro');
if (card) card.classList.add('alert');
switchPanel('pomodoro');
playPomodoroRingtone();
// Auto dismiss after 3 rings (~3 seconds)
state._pomodoroAutoDismiss = setTimeout(function () {
dismissPomodoro();
}, 3500);
}
function playPomodoroRingtone() {
// Play calendar-like reminder sound: three gentle beeps, gain 1.0
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
state._pomodoroAudioCtx = ctx;
var t = ctx.currentTime;
[0, 0.3, 0.6].forEach(function (offset) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(1.0, t + offset);
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.4);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t + offset);
osc.stop(t + offset + 0.4);
});
} catch (_) {}
}
function dismissPomodoro() {
state.pomodoroRinging = false;
var overlay = $('pomodoro-overlay');
var card = $('card-pomodoro');
if (overlay) overlay.classList.add('hidden');
if (card) card.classList.remove('alert');
if (state._pomodoroAudioCtx) {
try { state._pomodoroAudioCtx.close(); } catch (_) {}
state._pomodoroAudioCtx = null;
}
if (state._pomodoroAutoDismiss) {
clearTimeout(state._pomodoroAutoDismiss);
state._pomodoroAutoDismiss = null;
}
// Phase advancement is now handled by the backend when the timer expires.
switchPanel('pomodoro');
}
var pomodoroDismissBtn = $('pomodoro-dismiss');
if (pomodoroDismissBtn) {
pomodoroDismissBtn.addEventListener('click', dismissPomodoro);
}
// ─── Alarm CRUD ───────────────────────────
function apiJson(method, url, data, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
if (data) { xhr.setRequestHeader('Content-Type', 'application/json'); }
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
if (callback) callback(JSON.parse(xhr.responseText || '{}'));
};
xhr.send(data ? JSON.stringify(data) : null);
}
function deleteAlarm(id) {
apiJson('DELETE', '/api/alarms/' + id, null, function () {
fetchState();
});
}
function _nextAlarmName() {
// Find next available "闹钟 N" name
var used = {};
state.alarms.forEach(function (a) {
var m = (a.label || '').match(/^闹钟 (\d+)$/);
if (m) used[parseInt(m[1], 10)] = true;
});
var n = 1;
while (used[n]) { n++; }
return '闹钟 ' + n;
}
// ─── Alarm Modal (mouse-only time picker) ─
function openAlarmModal(alarm) {
if (state._alarmModalOpen) return;
state._alarmModalOpen = true;
state.editingAlarm = alarm || null;
state._cdModalMode = false;
var modal = $('alarm-modal');
var titleEl = $('alarm-modal-title');
var nameEl = $('alarm-modal-name');
var saveBtn = $('alarm-modal-save');
// Title and name
if (alarm) {
titleEl.textContent = '编辑闹钟';
nameEl.textContent = alarm.label || '';
saveBtn.textContent = '保存修改';
} else {
titleEl.textContent = '添加闹钟';
nameEl.textContent = _nextAlarmName();
saveBtn.textContent = '添加';
}
// Set time
var timeStr = alarm ? alarm.time : '08:00';
var parts = timeStr.split(':');
$('tp-hours').textContent = parts[0] || '08';
$('tp-minutes').textContent = parts[1] || '00';
$('tp-seconds').textContent = '00'; // not used for alarms
// Set days
var days = alarm ? (alarm.days || '1,2,3,4,5,6,7').split(',') : ['1','2','3','4','5','6','7'];
document.querySelectorAll('#alarm-modal-days .day-btn').forEach(function (btn) {
btn.classList.toggle('active', days.indexOf(btn.dataset.day) !== -1);
});
// Set enabled + once
$('alarm-modal-enabled').checked = alarm ? alarm.enabled !== false : true;
$('alarm-modal-once').checked = alarm ? !!alarm.once : false;
_updateAlarmDaysVisibility();
// Show alarm-only fields, hide countdown-only fields
$('alarm-once-field').style.display = '';
$('alarm-days-field').style.display = '';
document.querySelector('#alarm-modal .modal-label[data-cd-label]') &&
(document.querySelector('#alarm-modal .modal-label[data-cd-label]').style.display = 'none');
modal.classList.remove('hidden');
}
function _updateAlarmDaysVisibility() {
var isOnce = $('alarm-modal-once').checked;
$('alarm-days-field').style.display = isOnce ? 'none' : '';
}
function closeAlarmModal() {
state._alarmModalOpen = false;
state.editingAlarm = null;
$('alarm-modal').classList.add('hidden');
}
function saveAlarmFromModal() {
var hours = parseInt($('tp-hours').textContent, 10);
var minutes = parseInt($('tp-minutes').textContent, 10);
if (isNaN(hours) || isNaN(minutes)) return;
var time = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0');
var days = [];
document.querySelectorAll('#alarm-modal-days .day-btn.active').forEach(function (btn) {
days.push(btn.dataset.day);
});
if (days.length === 0) { showAlert('请至少选择一天'); return; }
var enabled = $('alarm-modal-enabled').checked;
var once = $('alarm-modal-once').checked;
// For once-only alarms, days are irrelevant
if (once) days = ['1','2','3','4','5','6','7'];
var editing = state.editingAlarm;
if (editing) {
// Edit: update existing alarm (name NOT changed)
apiJson('PUT', '/api/alarms/' + editing.id, {
time: time,
days: days.join(','),
enabled: enabled,
once: once,
}, function () {
closeAlarmModal();
fetchState();
});
} else {
// Add: create new alarm with auto-generated name
apiJson('POST', '/api/alarms', {
label: _nextAlarmName(),
time: time,
days: days.join(','),
enabled: enabled,
once: once,
}, function () {
closeAlarmModal();
fetchState();
});
}
}
// ─── Countdown (duration-based, independent of alarms) ─
function _parseEndTime(endStr) {
if (!endStr) return null;
try {
// end_time is a Unix timestamp (e.g. "1785285282.447")
return new Date(parseFloat(endStr) * 1000);
} catch (_) { return null; }
}
function renderCountdownCard() {
var now = new Date();
var nearest = null;
var nearestSec = null;
state.countdowns.forEach(function (cd) {
if (!cd.enabled) return;
var endDate = _parseEndTime(cd.end_time);
if (!endDate) return;
var remaining = Math.max(0, Math.floor((endDate - now) / 1000));
if (nearestSec === null || remaining < nearestSec) {
nearest = cd;
nearestSec = remaining;
}
});
var el = $('countdown-next');
if (nearest && nearestSec !== null && nearestSec > 0) {
if (nearestSec < 60) {
el.textContent = nearestSec + '秒';
} else if (nearestSec < 3600) {
el.textContent = Math.floor(nearestSec / 60) + '分';
} else {
el.textContent = Math.floor(nearestSec / 3600) + '时' +
String(Math.floor((nearestSec % 3600) / 60)).padStart(2, '0') + '分';
}
} else {
el.textContent = '无';
}
}
function renderCountdownPanel() {
var list = $('countdown-list');
if (!list) return;
var now = new Date();
var items = [];
state.countdowns.forEach(function (cd) {
if (!cd.enabled) return;
var endDate = _parseEndTime(cd.end_time);
if (!endDate) return;
var remaining = Math.max(0, Math.floor((endDate - now) / 1000));
items.push({ countdown: cd, remainingSec: remaining, endDate: endDate });
});
// Sort by soonest first
items.sort(function (x, y) { return x.remainingSec - y.remainingSec; });
if (items.length === 0) {
list.innerHTML = '
暂无活跃倒计时
';
return;
}
var html = '';
items.forEach(function (item) {
var cd = item.countdown;
var remainingSec = item.remainingSec;
var hours = Math.floor(remainingSec / 3600);
var mins = Math.floor((remainingSec % 3600) / 60);
var secs = remainingSec % 60;
var remainStr;
var overdue = false;
if (remainingSec <= 0) {
remainStr = '已结束';
overdue = true;
} else {
remainStr =
String(hours).padStart(2, '0') + ':' +
String(mins).padStart(2, '0') + ':' +
String(secs).padStart(2, '0');
}
var dur = cd.duration_seconds || 0;
var durStr = String(Math.floor(dur / 3600)).padStart(2, '0') + ':' +
String(Math.floor((dur % 3600) / 60)).padStart(2, '0') + ':' +
String(dur % 60).padStart(2, '0');
html += '' +
'
' +
'
' + durStr + '
' +
(cd.label ? '
' + cd.label + '
' : '') +
'
' +
'
' + remainStr + '
' +
'
' +
'编辑 ' +
'删除 ' +
'
';
});
list.innerHTML = html;
// Bind edit buttons
list.querySelectorAll('[data-action="edit-cd"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var id = parseInt(btn.dataset.id, 10);
var cd = null;
for (var i = 0; i < state.countdowns.length; i++) {
if (state.countdowns[i].id === id) { cd = state.countdowns[i]; break; }
}
if (cd) openCountdownModal(cd);
});
});
// Bind delete buttons (two-step inline confirmation)
list.querySelectorAll('[data-action="delete-cd"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (btn.dataset.confirm === 'yes') {
deleteCountdown(parseInt(btn.dataset.id, 10));
} else {
btn.dataset.confirm = 'yes';
btn.textContent = '确认删除?';
btn.style.background = '#E05555';
btn.style.color = '#fff';
setTimeout(function () {
btn.dataset.confirm = '';
btn.textContent = '删除';
btn.style.background = '';
btn.style.color = '';
}, 3000);
}
});
});
}
// ─── Countdown CRUD (via /api/countdowns) ─
function deleteCountdown(id) {
apiJson('DELETE', '/api/countdowns/' + id, null, function () {
fetchState();
});
}
function _nextCountdownName() {
var used = {};
state.countdowns.forEach(function (cd) {
var m = (cd.label || '').match(/^倒计时 (\d+)$/);
if (m) used[parseInt(m[1], 10)] = true;
});
var n = 1;
while (used[n]) { n++; }
return '倒计时 ' + n;
}
// ─── Countdown Modal (duration-based, no repeat) ─
function openCountdownModal(cd) {
if (state._alarmModalOpen) return;
state._alarmModalOpen = true;
state._cdModalMode = true;
state._editingCountdown = cd || null;
var modal = $('alarm-modal');
var titleEl = $('alarm-modal-title');
var nameEl = $('alarm-modal-name');
var saveBtn = $('alarm-modal-save');
if (cd) {
titleEl.textContent = '编辑倒计时';
nameEl.textContent = cd.label || '';
saveBtn.textContent = '保存修改';
// Parse existing duration_seconds into hours:minutes:seconds
var totalSec = cd.duration_seconds || 0;
var h = Math.floor(totalSec / 3600);
var m = Math.floor((totalSec % 3600) / 60);
var s = totalSec % 60;
$('tp-hours').textContent = String(h).padStart(2, '0');
$('tp-minutes').textContent = String(m).padStart(2, '0');
$('tp-seconds').textContent = String(s).padStart(2, '0');
} else {
titleEl.textContent = '添加倒计时';
nameEl.textContent = _nextCountdownName();
saveBtn.textContent = '添加';
$('tp-hours').textContent = '00';
$('tp-minutes').textContent = '05';
$('tp-seconds').textContent = '00';
}
$('alarm-modal-enabled').checked = cd ? cd.enabled !== false : true;
// Hide alarm-only fields (once toggle + days picker)
$('alarm-once-field').style.display = 'none';
$('alarm-days-field').style.display = 'none';
// Show duration label
var labelEl = document.querySelector('#alarm-modal .time-picker').previousElementSibling;
if (labelEl) labelEl.textContent = '时长';
modal.classList.remove('hidden');
}
function closeCountdownModal() {
state._alarmModalOpen = false;
state._cdModalMode = false;
state._editingCountdown = null;
$('alarm-modal').classList.add('hidden');
}
function saveCountdownFromModal() {
var hours = parseInt($('tp-hours').textContent, 10);
var minutes = parseInt($('tp-minutes').textContent, 10);
var seconds = parseInt($('tp-seconds').textContent, 10);
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return;
var totalSec = hours * 3600 + minutes * 60 + seconds;
if (totalSec <= 0) { showAlert('时长必须大于 0'); return; }
var enabled = $('alarm-modal-enabled').checked;
var editing = state._editingCountdown;
if (editing) {
apiJson('PUT', '/api/countdowns/' + editing.id, {
duration_seconds: totalSec,
enabled: enabled,
}, function () {
closeCountdownModal();
fetchState();
});
} else {
apiJson('POST', '/api/countdowns', {
label: _nextCountdownName(),
duration_seconds: totalSec,
enabled: enabled,
}, function () {
closeCountdownModal();
fetchState();
});
}
}
// Custom notification overlay
function showCustomNotification(data) {
if (state.customNotifyActive) return;
state.customNotifyActive = true;
var overlay = $('custom-notify-overlay');
var iconEl = $('custom-notify-icon');
var titleEl = $('custom-notify-title');
var infoEl = $('custom-notify-info');
if (iconEl) iconEl.innerHTML = data.icon || '🔔';
if (titleEl) titleEl.textContent = data.title || '提醒';
if (infoEl) infoEl.textContent = data.message || '';
if (overlay) overlay.classList.remove('hidden');
switchPanel('time');
if (data.sound !== false) {
playReminderSound();
}
state._customNotifyAutoDismiss = setTimeout(function () {
dismissCustomNotification();
}, 5000);
}
function dismissCustomNotification() {
state.customNotifyActive = false;
var overlay = $('custom-notify-overlay');
if (overlay) overlay.classList.add('hidden');
if (state._customNotifyAutoDismiss) {
clearTimeout(state._customNotifyAutoDismiss);
state._customNotifyAutoDismiss = null;
}
}
var customNotifyDismissBtn = $('custom-notify-dismiss');
if (customNotifyDismissBtn) {
customNotifyDismissBtn.addEventListener('click', dismissCustomNotification);
}
// Dismiss button
var dismissBtn = $('alarm-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', dismissAlarm);
}
// Refresh button
var refreshBtn = $('refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', function () {
location.reload();
});
}
// Lock button — log out current display session
var lockBtn = $('lock-btn');
if (lockBtn) {
lockBtn.addEventListener('click', function () {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
};
xhr.send();
});
}
// Fullscreen button (enter only, hides when already fullscreen)
var fullscreenBtn = $('fullscreen-btn');
if (fullscreenBtn) {
function updateFsBtn() {
fullscreenBtn.style.display = document.fullscreenElement ? 'none' : '';
}
document.addEventListener('fullscreenchange', updateFsBtn);
fullscreenBtn.addEventListener('click', function () {
document.documentElement.requestFullscreen();
});
}
// ─── Alarm modal wiring ───────────────────
var alarmAddBtn = $('alarm-add-btn');
if (alarmAddBtn) {
alarmAddBtn.addEventListener('click', function () { openAlarmModal(null); });
}
var countdownAddBtn = $('countdown-add-btn');
if (countdownAddBtn) {
countdownAddBtn.addEventListener('click', function () { openCountdownModal(null); });
}
// Modal save/cancel — route to alarm or countdown based on _cdModalMode
var modalSave = $('alarm-modal-save');
if (modalSave) {
modalSave.addEventListener('click', function () {
if (state._cdModalMode) {
saveCountdownFromModal();
} else {
saveAlarmFromModal();
}
});
}
var modalCancel = $('alarm-modal-cancel');
if (modalCancel) {
modalCancel.addEventListener('click', function () {
if (state._cdModalMode) {
closeCountdownModal();
} else {
closeAlarmModal();
}
});
}
// Time picker buttons (mouse-only)
function setupTimePicker() {
// Up/down for hours
document.querySelectorAll('.tp-up, .tp-down').forEach(function (btn) {
btn.addEventListener('click', function () {
var target = btn.dataset.target; // 'hours' or 'minutes'
var isUp = btn.classList.contains('tp-up');
var el = $('tp-' + target);
var val = parseInt(el.textContent, 10);
if (target === 'hours') {
val = isUp ? (val + 1) % 24 : (val + 23) % 24;
} else {
val = isUp ? (val + 1) % 60 : (val + 59) % 60;
}
el.textContent = String(val).padStart(2, '0');
});
});
// Hold-to-repeat for up/down buttons
document.querySelectorAll('.tp-btn').forEach(function (btn) {
var timer = null;
btn.addEventListener('mousedown', function () {
if (timer) clearInterval(timer);
timer = setInterval(function () {
btn.click();
}, 180);
});
btn.addEventListener('mouseup', function () { if (timer) { clearInterval(timer); timer = null; } });
btn.addEventListener('mouseleave', function () { if (timer) { clearInterval(timer); timer = null; } });
});
}
setupTimePicker();
// Day picker toggle
document.querySelectorAll('#alarm-modal-days .day-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
btn.classList.toggle('active');
});
});
// Once-only toggle — hide day picker
var onceCb = $('alarm-modal-once');
if (onceCb) {
onceCb.addEventListener('change', _updateAlarmDaysVisibility);
}
// ─── Pomodoro controls (reuse /control endpoints) ─────────────
function pomodoroApi(action) {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/pomodoro/' + action);
xhr.onload = function () {
if (xhr.status === 401) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.login_required) {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
return;
}
}
fetchState();
};
xhr.send();
}
function fetchPomodoro() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/pomodoro');
xhr.onload = function () {
if (xhr.status === 200) {
try {
state.pomodoro = JSON.parse(xhr.responseText);
renderPomodoro();
renderPomodoroCard();
} catch (_) {}
}
};
xhr.send();
}
// Card pomodoro buttons (in sidebar card, not the panel)
var cardBtns = $('pomo-sidebar-btns');
if (cardBtns) {
['start', 'pause', 'skip', 'stop'].forEach(function (action) {
var btn = $('pomo-card-' + action);
if (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
pomodoroApi(action);
});
}
});
}
// ─── Rendering ────────────────────────────
function renderAll() {
renderTimeCard();
renderWeatherCard();
renderCalendarCard();
renderAlarmCard();
renderCountdownCard();
renderPomodoroCard();
renderTimePanel();
renderWeatherPanel();
renderCalendarPanel();
renderAlarmPanel();
renderCountdownPanel();
renderPomodoro();
}
function renderTimeCard() {
var now = new Date();
var h = String(now.getHours()).padStart(2, '0');
var m = String(now.getMinutes()).padStart(2, '0');
$('time-value').textContent = h + ':' + m;
}
function renderWeatherCard() {
var w = state.weather || {};
if (w.temp !== undefined) {
$('weather-temp').textContent = w.temp + '°';
}
var iconEl = $('weather-card-icon');
if (w.icon && iconEl) {
iconEl.src = '/static/icons/' + w.icon + '.svg';
iconEl.style.display = '';
iconEl.alt = w.description || '';
} else if (iconEl) {
iconEl.style.display = 'none';
}
}
function renderCalendarCard() {
var now = new Date();
var nowStr = formatDateTimeLocal(now);
var upcoming = null;
for (var i = 0; i < state.events.length; i++) {
var ev = state.events[i];
var dtend = ev.dtend || ev.dtstart;
if (dtend >= nowStr) {
upcoming = ev;
break;
}
}
if (upcoming) {
var text = upcoming.summary || '日程';
var startTime = upcoming.is_all_day ? '全天' : formatTime(upcoming.dtstart);
var endTime = upcoming.is_all_day ? '' : '-' + formatTime(upcoming.dtend || upcoming.dtstart);
$('calendar-next').textContent = text + ' ' + startTime + endTime;
} else {
$('calendar-next').textContent = '无日程';
}
}
function formatTime(dtStr) {
if (!dtStr || dtStr.length < 13) return '';
return dtStr.substring(9, 11) + ':' + dtStr.substring(11, 13);
}
function formatDateTimeLocal(d) {
return d.getFullYear() +
String(d.getMonth() + 1).padStart(2, '0') +
String(d.getDate()).padStart(2, '0') + 'T' +
String(d.getHours()).padStart(2, '0') +
String(d.getMinutes()).padStart(2, '0') +
String(d.getSeconds()).padStart(2, '0');
}
function renderAlarmCard() {
var now = new Date();
var currentMinutes = now.getHours() * 60 + now.getMinutes();
var todayWeekday = String(now.getDay() === 0 ? 7 : now.getDay());
var upcoming = null;
var upcomingDiff = null;
state.alarms.forEach(function (a) {
if (!a.enabled) return;
var days = (a.days || '').split(',');
if (days.indexOf(todayWeekday) === -1) return;
var parts = a.time.split(':');
var alarmMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
var diff = alarmMinutes - currentMinutes;
if (diff > 0) {
if (upcomingDiff === null || diff < upcomingDiff) {
upcoming = a;
upcomingDiff = diff;
}
}
});
// If no upcoming alarm today, look for the earliest alarm on future days
if (!upcoming) {
state.alarms.forEach(function (a) {
if (!a.enabled) return;
var days = (a.days || '').split(',');
var parts = a.time.split(':');
var alarmMinutes = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10);
var minDayDiff = null;
days.forEach(function (d) {
var dayNum = parseInt(d, 10);
var todayNum = parseInt(todayWeekday, 10);
var dayDiff = dayNum - todayNum;
if (dayDiff <= 0) dayDiff += 7;
if (minDayDiff === null || dayDiff < minDayDiff) {
minDayDiff = dayDiff;
}
});
if (minDayDiff !== null) {
var totalDiff = (minDayDiff * 24 * 60) + alarmMinutes - currentMinutes;
if (upcomingDiff === null || totalDiff < upcomingDiff) {
upcoming = a;
upcomingDiff = totalDiff;
}
}
});
}
if (upcoming) {
$('alarm-next').textContent = upcoming.time;
} else {
$('alarm-next').textContent = '无闹钟';
}
}
function renderPomodoroCard() {
var p = state.pomodoro || {};
var el = $('pomodoro-value');
var btnStart = $('pomo-card-start');
var btnPause = $('pomo-card-pause');
var btnSkip = $('pomo-card-skip');
var btnStop = $('pomo-card-stop');
if (!el || !btnStart) return;
// Time display
if (!p.enabled) {
el.textContent = '未启动';
} else {
var remaining = _calcPomodoroRemaining(p);
el.textContent = String(Math.floor(remaining / 60)).padStart(2, '0') + ':' +
String(remaining % 60).padStart(2, '0');
}
// Button visibility
var isPomodoroPanel = state.activePanel === 'pomodoro';
var showTime = !isPomodoroPanel;
var showBtns = isPomodoroPanel;
el.style.display = showTime ? '' : 'none';
if (btnStart) btnStart.style.display = 'none';
if (btnPause) btnPause.style.display = 'none';
if (btnSkip) btnSkip.style.display = 'none';
if (btnStop) btnStop.style.display = 'none';
if (showBtns && p.enabled) {
if (p.running) {
// Running → 只显示暂停
if (btnPause) btnPause.style.display = '';
} else {
// Paused → 显示继续/跳过/停止
if (btnStart) { btnStart.textContent = '继续'; btnStart.style.display = ''; }
if (btnSkip) btnSkip.style.display = '';
if (btnStop) btnStop.style.display = '';
}
} else if (showBtns && !p.enabled) {
// Stopped → 只显示开始
if (btnStart) { btnStart.textContent = '开始'; btnStart.style.display = ''; }
}
// When NOT on pomodoro panel: show nothing for buttons (already hidden above)
}
function _calcPomodoroRemaining(p) {
if (!p || !p.enabled) return 0;
if (!p.running) return p.remaining_seconds || 0;
var end = p.end_time;
if (!end) return p.remaining_seconds || 0;
try {
// end_time is a Unix timestamp string (e.g. "1785285282.447")
var remaining = Math.floor(parseFloat(end) - Date.now() / 1000);
return Math.max(0, remaining);
} catch (_) {
return p.remaining_seconds || 0;
}
}
function renderPomodoro() {
var p = state.pomodoro || {};
var phaseEl = $('pomodoro-phase');
var timerEl = $('pomodoro-timer');
var cyclesEl = $('pomodoro-cycles');
var nextBreakEl = $('pomodoro-next-break');
if (!phaseEl || !timerEl || !cyclesEl) return;
if (!p.enabled) {
phaseEl.textContent = '未启动';
timerEl.textContent = '--:--';
cyclesEl.textContent = '';
if (nextBreakEl) nextBreakEl.textContent = '';
return;
}
phaseEl.textContent = p.phase === 'work' ? (p.work_label || '工作中') : (p.break_label || '休息中');
// Show/hide card buttons based on display_controls_enabled
var cardBtns = $('pomo-sidebar-btns');
if (cardBtns) {
cardBtns.style.display = (p.display_controls_enabled !== false) ? '' : 'none';
}
var remaining = _calcPomodoroRemaining(p);
var m = String(Math.floor(remaining / 60)).padStart(2, '0');
var s = String(remaining % 60).padStart(2, '0');
timerEl.textContent = m + ':' + s;
cyclesEl.textContent = '已完成 ' + (p.cycles || 0) + ' 组';
if (nextBreakEl) {
nextBreakEl.textContent = '下次: ' + (p.next_break_type === 'long' ? '长休息' : '短休息');
}
}
function renderTimePanel() {
var now = new Date();
$('detailed-time').textContent =
String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0');
var days = ['日', '一', '二', '三', '四', '五', '六'];
$('date-str').textContent =
now.getFullYear() + '年' + (now.getMonth() + 1) + '月' + now.getDate() + '日 星期' + days[now.getDay()];
}
function renderWeatherPanel() {
var w = state.weather || {};
if (w.temp === undefined) {
$('weather-temp-big').textContent = '--°';
$('weather-desc').textContent = w.error || '无数据';
$('weather-details').innerHTML = '';
return;
}
$('weather-temp-big').textContent = w.temp + '°C';
$('weather-desc').textContent = w.description || '';
var iconEl = $('weather-icon');
if (w.icon && iconEl) {
iconEl.src = '/static/icons/' + w.icon + '.svg';
iconEl.style.display = 'inline';
iconEl.alt = w.description || '';
} else if (iconEl) {
iconEl.style.display = 'none';
}
var details = '';
if (w.feels_like !== undefined) details += '';
if (w.humidity !== undefined) details += '';
if (w.wind_speed !== undefined) details += '风速
' + w.wind_speed + ' m/s
';
if (w.pressure !== undefined) details += '';
if (w.wind_dir) details += '风向
' + w.wind_dir + (w.wind_scale ? ' ' + w.wind_scale + '级' : '') + '
';
if (w.precip !== undefined && w.precip != '0') details += '';
if (w.visibility) details += '能见度
' + w.visibility + ' km
';
if (w.dew) details += '';
$('weather-details').innerHTML = details;
// Render hourly forecast
_renderHourly();
// Render minutely + warnings
_renderMinutely();
_renderWarnings();
// Render update schedule
_renderWeatherUpdateInfo();
}
function _renderHourly() {
var list = $('hourly-list');
if (!list) return;
var hourly = state.hourly || [];
if (hourly.length === 0) {
list.innerHTML = '暂无逐小时数据
';
return;
}
var html = '';
var nowHour = new Date().getHours();
var nowMin = new Date().getMinutes();
hourly.forEach(function (h) {
var hh = parseInt(h.time.split(':')[0], 10);
var hm = parseInt(h.time.split(':')[1], 10);
var isNow = (hh === nowHour && hm <= nowMin + 5);
var dayLabel = h.day || '';
html += '' +
'
' + (dayLabel === '明日' ? '明 ' : '') + h.time + '
' +
'
' + _qwIcon(h.icon) + '
' +
'
' + h.temp + '°
' +
'
' + (parseInt(h.pop, 10) > 0 ? h.pop + '%' : '') + '
' +
'
';
});
list.innerHTML = html;
}
function _qwIcon(code) {
if (code) return ' ';
return '';
}
function _renderMinutely() {
var section = $('minutely-section');
var content = $('minutely-content');
if (!section || !content) return;
// Always show the section (title + refresh button visible)
section.style.display = '';
var m = state.minutely;
if (!m) {
content.innerHTML = '点击「刷新」获取分钟级降水预报
';
return;
}
if (m.error) {
if (m.cooldown) {
content.innerHTML = '' + m.error + '
';
} else {
content.innerHTML = '' + m.error + '
';
}
return;
}
var list = m.minutely || [];
var summary = m.summary || '';
var html = '';
if (summary) html += '' + summary + '
';
if (list.length > 0) {
html += '';
list.forEach(function (item) {
var pct = Math.min(parseFloat(item.precip) || 0, 5) / 5 * 100;
html += '
' + (item.fxTime ? item.fxTime.substring(11, 16) : '') + '
';
});
html += '
';
}
content.innerHTML = html;
}
function _renderWarnings() {
var section = $('warning-section');
var content = $('warning-content');
if (!section || !content) return;
// Always show the section (title + refresh button visible)
section.style.display = '';
var w = state.warnings;
if (!w) {
content.innerHTML = '点击「刷新」获取天气预警
';
return;
}
var html = '';
if (w.unavailable) {
html = '天气预警为付费功能,当前订阅不可用
';
content.innerHTML = html;
return;
}
if (w.error && !w.cooldown) {
html = '' + w.error + '
';
content.innerHTML = html;
return;
}
if (w.zero_result || !w.warnings || w.warnings.length === 0) {
html = '暂无预警
';
content.innerHTML = html;
return;
}
// Per QWeather docs: do NOT enumerate severity/type values — just output
// raw alert data as-is, since these values can change without notice.
w.warnings.forEach(function (item) {
var fields = '';
for (var key in item) {
if (item.hasOwnProperty(key) && item[key]) {
fields += '' + key + ': ' + item[key] + '
';
}
}
html += '' +
'
' + (item.title || item.type || '预警') + '
' +
fields +
'
';
});
content.innerHTML = html;
}
function _renderWeatherUpdateInfo() {
var s = state.update_schedule || {};
var wUpdated = s.weather_updated_at || '';
// Compute next update times on the frontend from current local time
var now = new Date();
var m = now.getMinutes();
// Next weather: next :00 or :30
var nextW = new Date(now);
if (m < 30) { nextW.setMinutes(30, 0, 0); }
else { nextW.setHours(now.getHours() + 1, 0, 0, 0); }
// Next hourly: next :01
var nextH = new Date(now);
if (m < 1) { nextH.setMinutes(1, 0, 0); }
else { nextH.setHours(now.getHours() + 1, 1, 0, 0); }
var fmt = function (d) {
if (!d) return '--';
// Server sends UTC datetime — parse as UTC, display in local timezone
var s = String(d).replace('T', ' ');
// Safari needs 'T' not space for ISO parsing
var utcDate = new Date(String(d).replace(' ', 'T') + 'Z');
if (!isNaN(utcDate.getTime())) {
var pad = function (n) { return String(n).padStart(2, '0'); };
return utcDate.getFullYear() + '-' + pad(utcDate.getMonth() + 1) + '-' + pad(utcDate.getDate()) +
' ' + pad(utcDate.getHours()) + ':' + pad(utcDate.getMinutes());
}
// Fallback to raw string
return s.substring(0, 16);
};
var fmtDate = function (d) {
return String(d.getFullYear()) + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0') + ' ' +
String(d.getHours()).padStart(2, '0') + ':' +
String(d.getMinutes()).padStart(2, '0');
};
var html = '' +
'
实况更新: ' + fmt(wUpdated) +
' 下次: ' + fmtDate(nextW) + '
' +
'
逐时下次: ' + fmtDate(nextH) + '
' +
'
';
// Append after hourly list
var list = $('hourly-list');
if (list) {
var existing = list.parentNode.querySelector('.update-info');
if (existing) existing.remove();
list.insertAdjacentHTML('afterend', html);
}
}
// ─── State update from fetch ──────────────
function renderCalendarPanel() {
var list = $('event-list');
if (state.events.length === 0) {
list.innerHTML = '暂无日程
';
return;
}
var groups = {};
state.events.forEach(function (ev) {
var dk = ev.dtstart ? ev.dtstart.substring(0, 8) : 'unknown';
if (!groups[dk]) groups[dk] = [];
groups[dk].push(ev);
});
var todayKey = new Date().toISOString().slice(0, 10).replace(/-/g, '');
var t = new Date(); t.setDate(t.getDate() + 1);
var tomorrowKey = t.toISOString().slice(0, 10).replace(/-/g, '');
var html = '';
Object.keys(groups).sort().forEach(function (dk) {
var label = dk === todayKey ? '今天' : dk === tomorrowKey ? '明天' :
dk.substring(0, 4) + '/' + dk.substring(4, 6) + '/' + dk.substring(6, 8);
html += '' + label + '
';
groups[dk].forEach(function (ev) {
var startTime = ev.is_all_day ? '全天' : (ev.dtstart ? ev.dtstart.substring(9, 11) + ':' + ev.dtstart.substring(11, 13) : '--:--');
var endTime = ev.is_all_day ? '' : (ev.dtend ? ' - ' + ev.dtend.substring(9, 11) + ':' + ev.dtend.substring(11, 13) : '');
var timeStr = startTime + endTime;
var color = '#4A90D9';
if (state.groups) {
var g = state.groups.find(function (x) { return x.id === ev.group_id; });
if (g) color = g.color || color;
}
html += '
' +
'
' + timeStr + '
' +
'
' + _esc(ev.summary || '无标题') + '
' +
(ev.location ? '
' + _esc(ev.location) + '
' : '') + '
';
});
});
list.innerHTML = html;
}
function renderAlarmPanel() {
var list = $('alarm-list');
var dayNames = ['', '一', '二', '三', '四', '五', '六', '日'];
if (state.alarms.length === 0) {
list.innerHTML = '暂无闹钟
';
return;
}
var html = '';
state.alarms.forEach(function (a) {
var days = a.once ? '仅一次' : (a.days || '').split(',').map(function (d) { return dayNames[parseInt(d)]; }).join(' ');
html += '' +
'
' +
'
' + a.time + '
' +
(a.label ? '
' + a.label + '
' : '') +
'
' + days + '
' +
'
' +
'编辑 ' +
'删除 ' +
'
';
});
list.innerHTML = html;
// Bind edit buttons
list.querySelectorAll('[data-action="edit-alarm"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var id = parseInt(btn.dataset.id, 10);
var alarm = null;
for (var i = 0; i < state.alarms.length; i++) {
if (state.alarms[i].id === id) { alarm = state.alarms[i]; break; }
}
if (alarm) openAlarmModal(alarm);
});
});
// Bind delete buttons (two-step inline confirmation)
list.querySelectorAll('[data-action="delete-alarm"]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (btn.dataset.confirm === 'yes') {
deleteAlarm(parseInt(btn.dataset.id, 10));
} else {
btn.dataset.confirm = 'yes';
btn.textContent = '确认删除?';
btn.style.background = '#E05555';
btn.style.color = '#fff';
var _reset = function () { setTimeout(function () {
btn.dataset.confirm = '';
btn.textContent = '删除';
btn.style.background = '';
btn.style.color = '';
}, 3000); };
_reset();
}
});
});
}
// ─── Simple toast popup ──────────────────
function showToast(msg, duration) {
duration = duration || 2500;
var el = document.createElement('div');
el.className = 'display-toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, duration);
}
// ─── Minutely / Warning refresh buttons ───
$('minutely-refresh-btn').addEventListener('click', function () {
apiJson('POST', '/api/weather/minutely', null, function (data) {
if (data.error) {
var remaining = data.cooldown;
var msg = data.error;
if (remaining) msg += '(' + remaining + '秒)';
showToast(msg);
} else {
state.minutely = data;
_renderMinutely();
}
});
});
$('warning-refresh-btn').addEventListener('click', function () {
apiJson('POST', '/api/weather/warnings', null, function (data) {
if (data.error) {
var remaining = data.cooldown;
var msg = data.error;
if (remaining) msg += '(' + remaining + '秒)';
showToast(msg);
} else {
state.warnings = data;
_renderWarnings();
}
});
});
// ─── Clock tick ───────────────────────────
setInterval(function () {
renderTimeCard();
renderCountdownCard();
renderPomodoroCard();
if (state.activePanel === 'time') renderTimePanel();
if (state.activePanel === 'pomodoro') renderPomodoro();
if (state.activePanel === 'countdown') renderCountdownPanel();
// Refresh update schedule info every 10s to keep next-update times current
if ((state._tickCount || 0) % 10 === 0 && state.activePanel === 'weather') {
_renderWeatherUpdateInfo();
}
// When weather panel is open, check if hourly needs refresh (past :01, stale)
if ((state._tickCount || 0) % 30 === 0 && state.activePanel === 'weather') {
var now = new Date();
var min = now.getMinutes();
var s = state.update_schedule || {};
// If it's past :05 and the hourly was likely last fetched before this hour
if (min > 5) {
apiJson('GET', '/api/weather/hourly', null, function (data) {
if (data && data.length > 0) {
state.hourly = data;
_renderHourly();
}
});
}
}
state._tickCount = (state._tickCount || 0) + 1;
}, 1000);
// ─── Init ─────────────────────────────────
fetchState();
connectSSE();
switchPanel('time');
})();