/* 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,
timezone: 'Asia/Shanghai', // 账户独立时区(来自 /api/display/state)
};
// ─── 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, ''');
}
// ─── 账户时区时间 ─────────────────────────
// 后端所有时间字符串均为账户配置时区的本地时间;显示端时钟与日期判断
// 也必须用该时区,而不是设备本地时间(设备时区可能与账户设置不一致)。
function _pad2(n) { return String(n).padStart(2, '0'); }
function _tzNow() {
var tz = state.timezone || 'Asia/Shanghai';
var out = { y: 1970, mo: 1, d: 1, h: 0, mi: 0, s: 0, wd: 0 };
try {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23', weekday: 'short'
}).formatToParts(new Date());
var wdMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
parts.forEach(function (p) {
switch (p.type) {
case 'year': out.y = parseInt(p.value, 10); break;
case 'month': out.mo = parseInt(p.value, 10); break; // 1-12
case 'day': out.d = parseInt(p.value, 10); break;
case 'hour': out.h = parseInt(p.value, 10); break;
case 'minute': out.mi = parseInt(p.value, 10); break;
case 'second': out.s = parseInt(p.value, 10); break;
case 'weekday': out.wd = wdMap[p.value] || 0; break;
}
});
} catch (e) {
var l = new Date();
out = { y: l.getFullYear(), mo: l.getMonth() + 1, d: l.getDate(),
h: l.getHours(), mi: l.getMinutes(), s: l.getSeconds(), wd: l.getDay() };
}
return out;
}
// 生成 "YYYYMMDDTHHMMSS" 比较键(与后端本地时间字符串可比)
function _nowLocalKey() {
var n = _tzNow();
return String(n.y) + _pad2(n.mo) + _pad2(n.d) + 'T' +
_pad2(n.h) + _pad2(n.mi) + _pad2(n.s);
}
// ─── 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() {
Auth.request('GET', '/api/display/state', null, function (data, status) {
if (status !== 200) return;
state.weather = data.weather || {};
state.hourly = data.hourly || [];
state.events = data.events || [];
state.alarms = data.alarms || [];
state.countdowns = data.countdowns || [];
state.pomodoro = data.pomodoro || state.pomodoro || {};
state.update_schedule = data.update_schedule || {};
state.minutely = data.minutely || null;
state.warnings = data.warnings || null;
state.groups = data.groups || [];
state.timezone = data.timezone || state.timezone || 'Asia/Shanghai';
renderAll();
});
}
// ─── SSE for real-time events ─────────────
function connectSSE() {
// EventSource 不能带 Authorization header → 先换一次性 ticket
Auth.request('POST', '/api/events/ticket', null, function (res, status) {
if (status !== 201 || !res.ticket) {
setTimeout(connectSSE, 5000);
return;
}
openSSE('/api/events?ticket=' + encodeURIComponent(res.ticket));
});
}
// ─── Local due-check fallback ─────────────
// 多 Gunicorn worker 下,后台 scheduler 与显示端 SSE 连接可能落在不同进程
// (进程内 EventBus 无法跨进程投递),SSE 提醒事件可能丢失。这里每秒本地
// 检测一次,保证闹钟/倒计时/番茄钟到点必定提醒;SSE 正常时由 fireOnce 去重。
state._firedLocal = {};
function fireOnce(key) {
if (state._firedLocal[key]) return false;
state._firedLocal[key] = true;
return true;
}
function localDueCheck() {
var nowSec = Date.now() / 1000;
var todayKey = new Date().toDateString();
// 1) 闹钟:按账户时区匹配 HH:MM + 星期
if (state.timezone) {
try {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: state.timezone, hour: '2-digit', minute: '2-digit',
weekday: 'short', hourCycle: 'h23',
}).formatToParts(new Date());
var hm = '', wd = '';
parts.forEach(function (p) {
if (p.type === 'hour') hm = p.value;
else if (p.type === 'minute') hm += ':' + p.value;
else if (p.type === 'weekday') wd = p.value;
});
var wdNum = { Sun: 7, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }[wd];
(state.alarms || []).forEach(function (a) {
if (!a.enabled || String(a.time) !== hm) return;
var days = String(a.days || '1,2,3,4,5,6,7').split(',');
if (days.indexOf(String(wdNum)) === -1) return;
if (fireOnce('alarm-' + a.id + '-' + todayKey)) {
showAlarmOverlay({ alarms: [a], ts: new Date().toISOString(), source: 'local' });
}
});
} catch (e) {}
}
// 2) 倒计时:end_time(Unix 秒)到期
(state.countdowns || []).forEach(function (cd) {
if (!cd.enabled || !cd.end_time) return;
if (parseFloat(cd.end_time) > nowSec) return;
if (fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''))) {
showAlarmOverlay({ countdowns: [cd], ts: new Date().toISOString(), source: 'local' });
switchPanel('countdown');
}
});
// 3) 番茄钟:running 且 end_time(Unix 秒)到期
var p = state.pomodoro || {};
if (p.running && p.end_time && parseFloat(p.end_time) <= nowSec) {
if (fireOnce('pomo-' + p.end_time)) {
showPomodoroOverlay();
}
}
}
function startLocalDueCheck() {
if (!state._localCheckTimer) {
state._localCheckTimer = setInterval(localDueCheck, 1000);
}
}
function openSSE(url) {
var es = new EventSource(url);
// SSE 与后台 scheduler 可能不在同一 Gunicorn worker(进程内事件总线无法跨
// 进程投递):本地到期检测 + 周期性重拉完整状态作为兜底,保证最终一致。
startLocalDueCheck();
if (!state._stateTimer) {
state._stateTimer = setInterval(fetchState, 15000);
}
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
handleEvent(msg);
} catch (_) {}
};
es.onerror = function () {
es.close();
clearInterval(state._sseGuard);
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 () {
Auth.request('GET', '/api/display/state', null, function (data, st) {
if (st === 401) {
es.close();
clearInterval(state._sseGuard);
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
}
});
}, 30000);
}
function handleEvent(msg) {
if (msg.type === 'alarm_triggered') {
fetchState();
var todayKey = new Date().toDateString();
var anyNew = (msg.data.alarms || []).some(function (a) {
return fireOnce('alarm-' + a.id + '-' + todayKey);
});
if (anyNew) 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();
var anyCd = (msg.data.countdowns || []).some(function (cd) {
return fireOnce('cd-' + cd.id + '-' + (cd.end_time || ''));
});
if (anyCd) {
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') {
Auth.logout(function () {
location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search);
});
}
}
// ─── Alarm Overlay & Sound ────────────────
function showAlarmOverlay(data) {
if (state.alarmActive) return; // already showing
state.alarmActive = true;
// Build info text (supports both alarm and countdown data).
// 用户可控文本(label/time/target_time)一律转义,防止存储型 XSS。
var info = '';
(data.alarms || []).forEach(function (a) {
info += _esc(a.time) + (a.label ? ' ' + _esc(a.label) : '') + '
';
});
(data.countdowns || []).forEach(function (cd) {
info += _esc(cd.target_time) + (cd.label ? ' ' + _esc(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) {
Auth.request(method, url, data, function (res, status) {
if (callback) callback(res, status);
});
}
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 = '