/* 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) countUp: { status: 'idle', startedAtMs: null, baseSec: 0, frozenSec: 0 }, }; // ─── 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(); } // Immediately render count-up when switching back to time panel if (name === 'time') { renderCountUpPanel(); } } 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 === 'timezone_changed') { // 控制端改了账户时区 → 立即重拉状态(state.timezone 更新后时钟/闹钟兜底随之生效) fetchState(); } else if (msg.type === 'db_changed') { // 超大事件(NOTIFY 放不下)→ 从数据库重拉完整状态 fetchState(); } 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 = '
暂无活跃倒计时
'; 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 ? '
' + _esc(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 () { Auth.logout(function () { location.href = '/login?next=' + encodeURIComponent(location.pathname + location.search); }); }); } // 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) { Auth.request('POST', '/api/pomodoro/' + action, null, function () { fetchState(); }); } function fetchPomodoro() { Auth.request('GET', '/api/pomodoro', null, function (data, status) { if (status === 200) { state.pomodoro = data || {}; renderPomodoro(); renderPomodoroCard(); } }); } // 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 n = _tzNow(); $('time-value').textContent = _pad2(n.h) + ':' + _pad2(n.mi); } 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 nowStr = _nowLocalKey(); 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 renderAlarmCard() { var n = _tzNow(); var currentMinutes = n.h * 60 + n.mi; var todayWeekday = String(n.wd === 0 ? 7 : n.wd); 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 n = _tzNow(); $('detailed-time').textContent = _pad2(n.h) + ':' + _pad2(n.mi) + ':' + _pad2(n.s); var days = ['日', '一', '二', '三', '四', '五', '六']; $('date-str').textContent = n.y + '年' + n.mo + '月' + n.d + '日 星期' + days[n.wd]; } function _calcCountUpElapsed() { var c = state.countUp; if (!c) return 0; if (c.status !== 'running') return c.frozenSec || 0; try { if (c.startedAtMs == null) return c.frozenSec || 0; return (c.baseSec || 0) + Math.floor((Date.now() - c.startedAtMs) / 1000); } catch (_) { return c.frozenSec || 0; } } function _formatCountUp(totalSec) { if (typeof totalSec !== 'number' || !isFinite(totalSec) || totalSec < 0) { return '00:00:00'; } var h = Math.floor(totalSec / 3600); var m = Math.floor((totalSec % 3600) / 60); var s = totalSec % 60; return _pad2(h) + ':' + _pad2(m) + ':' + _pad2(s); } function renderCountUpPanel() { try { var c = state.countUp || {}; var el = $('countup-value'); if (!el) return; el.textContent = _formatCountUp(_calcCountUpElapsed()); var btnStart = $('countup-start'); var btnStop = $('countup-stop'); var btnReset = $('countup-reset'); var running = c.status === 'running'; if (btnStart) btnStart.style.display = running ? 'none' : ''; if (btnReset) btnReset.style.display = running ? 'none' : ''; if (btnStop) btnStop.style.display = running ? '' : 'none'; } catch (e) { console.warn('countUp render failed:', e); } } function countUpStart() { try { var c = state.countUp; if (c.status === 'running') return; c.baseSec = 0; c.startedAtMs = Date.now(); c.status = 'running'; renderCountUpPanel(); } catch (e) { console.warn('countUp start failed:', e); } } function countUpStop() { try { var c = state.countUp; if (c.status !== 'running') return; c.frozenSec = _calcCountUpElapsed(); c.startedAtMs = null; c.status = 'idle'; renderCountUpPanel(); } catch (e) { console.warn('countUp stop failed:', e); } } function countUpReset() { try { var c = state.countUp; c.frozenSec = 0; c.startedAtMs = null; c.status = 'idle'; renderCountUpPanel(); } catch (e) { console.warn('countUp reset failed:', e); } } function setupCountUpControls() { var btnStart = $('countup-start'); var btnStop = $('countup-stop'); var btnReset = $('countup-reset'); if (btnStart) { btnStart.addEventListener('click', function (e) { e.stopPropagation(); countUpStart(); }); } if (btnStop) { btnStop.addEventListener('click', function (e) { e.stopPropagation(); countUpStop(); }); } if (btnReset) { btnReset.addEventListener('click', function (e) { e.stopPropagation(); countUpReset(); }); } } 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 += '
体感
' + w.feels_like + '°C
'; if (w.humidity !== undefined) details += '
湿度
' + w.humidity + '%
'; if (w.wind_speed !== undefined) details += '
风速
' + w.wind_speed + ' m/s
'; if (w.pressure !== undefined) details += '
气压
' + w.pressure + ' hPa
'; if (w.wind_dir) details += '
风向
' + w.wind_dir + (w.wind_scale ? ' ' + w.wind_scale + '级' : '') + '
'; if (w.precip !== undefined && w.precip != '0') details += '
降水量
' + w.precip + ' mm
'; if (w.visibility) details += '
能见度
' + w.visibility + ' km
'; if (w.dew) details += '
露点温度
' + w.dew + '°C
'; $('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 n = _tzNow(); var nowHour = n.h; var nowMin = n.mi; 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 = '
' + _esc(m.error) + '
'; } else { content.innerHTML = '
' + _esc(m.error) + '
'; } return; } var list = m.minutely || []; var summary = m.summary || ''; var html = ''; if (summary) html += '
' + _esc(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 += '
' + _esc(key) + ': ' + _esc(item[key]) + '
'; } } html += '
' + '
' + _esc(item.title || item.type || '预警') + '
' + fields + '
'; }); content.innerHTML = html; } function _renderWeatherUpdateInfo() { var s = state.update_schedule || {}; // 后端返回的时间字符串均为账户时区本地时间,直接截取显示(勿再按 UTC 解析) var fmt = function (d) { if (!d) return '--'; return String(d).replace('T', ' ').substring(0, 16); }; var html = '
' + '
实况更新: ' + fmt(s.weather_updated_at) + ' 下次: ' + fmt(s.weather_next_update) + '
' + '
逐时下次: ' + fmt(s.hourly_next_update) + '
' + '
'; // 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 n = _tzNow(); var todayKey = String(n.y) + _pad2(n.mo) + _pad2(n.d); // 明天:基于账户时区的年月日 +1 天(跨月/跨年交给 Date 处理) var t = new Date(); t.setFullYear(n.y, n.mo - 1, n.d + 1); var tomorrowKey = String(t.getFullYear()) + _pad2(t.getMonth() + 1) + _pad2(t.getDate()); 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 += '
' + '
' + '
' + _esc(a.time) + '
' + (a.label ? '
' + _esc(a.label) + '
' : '') + '
' + _esc(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 === 'time') renderCountUpPanel(); 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 min = _tzNow().mi; 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'); setupCountUpControls(); document.addEventListener('visibilitychange', function () { try { if (document.visibilityState === 'visible' && state.activePanel === 'time') { renderCountUpPanel(); } } catch (_) {} }); })();