import { store } from '../store'; import { pickNextAlarm } from '../../shared/time'; import { t } from '../i18n'; import { localDateKey, POMODORO_DURATIONS_MS } from '../../shared/constants'; /** 番茄钟 idle 时卡片上显示的 MM:SS —— 从时长常量派生,别写死 25:00。 */ function idlePomoTime(): string { const sec = Math.round(POMODORO_DURATIONS_MS.focus / 1000); return `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`; } /** * 首页:上方一个巨字时钟(hero,居中),下方一行小卡片。 * - Hero:HH:MM:SS(实时)+ 日期 + 下一个闹钟的胶囊提示 * - 卡片行:番茄钟状态 / 倒计时状态 / 启用的闹钟数 * * 巨字时钟是首页的视觉锚点;小卡片只承担辅助信息,整体高度保持紧凑。 */ export function renderClockPanel(root: HTMLElement): () => void { root.classList.add('clock-panel'); // 首屏 HTML 直接写入本地时间,而不是 "--:--:--" 占位符。 // 这样即便 JS 在第一帧 paint 之前还没跑到 tickClock(),用户看到的也是真实时钟, // 而不是一秒以内的占位符闪烁。浮窗时钟(floating-clock/main.ts)走的是同一思路。 // store.clockStr 在 store.ts 模块加载时已经由 localHMS() 初始化, // 这里直接 get() 拿到的就是"模块加载时刻"的本地时间,与 IPC 首推相差最多 1s。 const initialClock = store.clockStr.get(); const initParts = /^\d{2}:\d{2}:\d{2}$/.test(initialClock) ? initialClock.split(':') : (() => { const d = new Date(); const p = (n: number): string => String(n).padStart(2, '0'); return [p(d.getHours()), p(d.getMinutes()), p(d.getSeconds())]; })(); root.innerHTML = ` ${initParts[0]}:${initParts[1]}:${initParts[2]} — · ${t('clock.nextNone')} ${t('nav.pomodoro')} ${t('clock.pomoStateIdle')} --:-- ${t('nav.countdown')} ${t('countdown.stateIdle')} --:-- ${t('nav.alarms')} 0 ${t('clock.statEnabledSub')} `; const timeEl = root.querySelector('#home-time')!; // 缓存 hh/mm/sec 子节点引用 —— 每秒只改 textContent,不再重建 innerHTML: // 每秒重建一次 innerHTML 既贵又会让任何依赖节点身份的 CSS 动画/变换从头重放。 const hhEl = timeEl.querySelector('.hh')!; const mmEl = timeEl.querySelector('.mm')!; const ssEl = timeEl.querySelector('.sec')!; const dateEl = root.querySelector('#home-date')!; const nextEl = root.querySelector('#home-next')!; const pomoCard = root.querySelector('[data-card="pomo"]')!; const pomoStateEl = root.querySelector('#home-pomo-state')!; const pomoTimeEl = root.querySelector('#home-pomo-time')!; const enabledEl = root.querySelector('#home-enabled')!; const cdCard = root.querySelector('[data-card="countdown"]')!; const cdStateEl = root.querySelector('#home-cd-state')!; const cdTimeEl = root.querySelector('#home-cd-time')!; const weekdayChars = ['日', '一', '二', '三', '四', '五', '六']; // 把本地 Date 序列化为 HH:MM:SS。本面板用它做两件事: // 1) 首屏 mount 时立刻填一次,避免 0~1s 内"--:--:--"占位符闪烁; // 2) IPC clockStr 拿到空串 / 格式异常时兜底,不让 hh/mm/sec 被写成 '' 导致"::"显示。 // 与 shared/time.formatLocalHMS 行为一致,但放在组件内避免再绕一道跨模块依赖。 function localHMS(d: Date = new Date()): string { const p = (n: number): string => String(n).padStart(2, '0'); return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; } // 校验 "HH:MM:SS" 形状:必须恰好两段冒号、每段两位数字。空串、'::'、'NaN:NaN'、'12:34' 都判负。 // 早先用 parts[N] ?? '00' 兜底,空串 '' 并不触发 ??,于是 hhEl 会被写成 '' → 视觉上变成 "::"。 const HHMMSS_RE = /^\d{2}:\d{2}:\d{2}$/; function isValidHHMMSS(s: string): boolean { return typeof s === 'string' && HHMMSS_RE.test(s); } function tickClock(): void { // 优先级:有效 IPC clockStr > 本地时间。store 初始值(localHMS)可能因模块加载时刻 // 与 IPC 首推之间错位而比当前慢几秒,但仍然是合法 HH:MM:SS,这里就用它,不重新 new Date()。 const raw = store.clockStr.get(); const t = isValidHHMMSS(raw) ? raw : localHMS(); const parts = t.split(':'); const hh = parts[0]!; const mm = parts[1]!; const ss = parts[2]!; if (hhEl.textContent !== hh) hhEl.textContent = hh; if (mmEl.textContent !== mm) mmEl.textContent = mm; if (ssEl.textContent !== ss) ssEl.textContent = ss; const now = new Date(); const w = weekdayChars[now.getDay()] ?? ''; const dateText = `星期${w} ${now.getMonth() + 1}月${now.getDate()}日`; if (dateEl.textContent !== dateText) dateEl.textContent = dateText; } function syncNext(): void { // 用 pickNextAlarm 拿真实"下一个要响的" —— 多个 enabled 闹钟时显示最近的, // 避免早先 enabledAlarms[0] 把最远的那条当成"下一个"。 const enabledAlarms = store.alarms.get().filter(x => x.enabled); const { alarm, nextAt } = pickNextAlarm(enabledAlarms, new Date()); if (!alarm) { nextEl.textContent = t('clock.nextNone'); nextEl.classList.remove('has-alarm'); } else { const diffMin = Math.max(1, Math.round((nextAt - Date.now()) / 60000)); // 不要 escapeHtml:下面是 textContent 赋值,本身就不解释 HTML。 // 先转义再写 textContent 会让标签里的 & 显示成 &。 const label = alarm.label || t('floating.alarms.tagDefault'); nextEl.classList.add('has-alarm'); nextEl.textContent = diffMin < 60 ? t('clock.nextFmtMin', alarm.time, diffMin, label) : t('clock.nextFmtHm', alarm.time, Math.floor(diffMin / 60), diffMin % 60, label); } enabledEl.textContent = String(enabledAlarms.length); } function syncPomo(): void { const { active } = store.pomodoro.get(); if (!active) { pomoStateEl.textContent = t('clock.pomoStateIdle'); pomoTimeEl.textContent = idlePomoTime(); pomoCard.dataset.state = 'idle'; return; } const remain = active.paused ? active.remainingMs : Math.max(0, active.endsAt - Date.now()); const sec = Math.ceil(remain / 1000); const mm = String(Math.floor(sec / 60)).padStart(2, '0'); const ss = String(sec % 60).padStart(2, '0'); pomoTimeEl.textContent = `${mm}:${ss}`; const phaseLabel = ({ focus: t('pomo.focus'), shortBreak: t('pomo.shortBreak'), longBreak: t('pomo.longBreak') } as Record)[active.phase] ?? ''; pomoStateEl.textContent = active.paused ? t('pomo.paused') : phaseLabel; if (active.paused) pomoCard.dataset.state = 'paused'; else if (active.phase !== 'focus') pomoCard.dataset.state = 'break'; else pomoCard.dataset.state = 'running'; } function syncCountdown(): void { const { active } = store.countdown.get(); if (!active) { cdStateEl.textContent = t('countdown.stateIdle'); cdTimeEl.textContent = '--:--'; cdCard.dataset.state = 'idle'; return; } const remain = active.paused ? active.remainingMs : Math.max(0, active.endsAt - Date.now()); const totalSec = Math.ceil(remain / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; cdTimeEl.textContent = h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; if (active.paused) { cdStateEl.textContent = t('countdown.statePaused'); cdCard.dataset.state = 'paused'; } else { cdStateEl.textContent = t('countdown.stateRunning'); cdCard.dataset.state = 'running'; } } // 首屏立刻画一次:挂载前 store.clockStr 已经在模块加载时由 localHMS() 初始化, // 跟着 IPC clockTick 在 0~1s 内覆盖。若不主动画,DOM 会停留在 "--:--:--" 占位符 // 直到下一个 IPC tick(最多 1s 的空窗),用户能看到闪现。Float-clock 已经做了同样处理。 tickClock(); // clockStr 由主进程每秒推一次,本面板所有随时间漂移的读数都挂在这一个驱动上, // 不再另开 1Hz 的 driftTimer(两个同频定时器做同类事,白白多一次唤醒)。 // 但 idle 时(无 enabled 闹钟 + pomo/countdown 都没在跑)每秒重画是空转: // tickClock 已经在比 textContent,没变就跳过;其它三个同步函数也按需短路, // 让首页在 idle 状态下每秒只做 4 个 textContent 比较,不走 pickNextAlarm / Date 构造。 let lastPomoActive = store.pomodoro.get().active; let lastCountdownActive = store.countdown.get().active; let lastEnabledCount = store.alarms.get().filter(a => a.enabled).length; const unsubClock = store.clockStr.subscribe(() => { tickClock(); // 仅在有内容时才走昂贵的同步路径 const enabledCount = lastEnabledCount; if (enabledCount > 0) syncNext(); if (lastPomoActive) syncPomo(); if (lastCountdownActive) syncCountdown(); }); // 状态变化后刷新"是否要同步"标志位:减少 1Hz tick 时的判断开销 const unsubAlarms = store.alarms.subscribe(() => { lastEnabledCount = store.alarms.get().filter(a => a.enabled).length; syncNext(); }); const unsubPomo = store.pomodoro.subscribe((p) => { lastPomoActive = p.active; syncPomo(); }); const unsubCountdown = store.countdown.subscribe((c) => { lastCountdownActive = c.active; syncCountdown(); }); syncNext(); syncPomo(); syncCountdown(); // 跨天后日期会变 const dayTimer = window.setInterval(() => { const today = localDateKey(); if (dateEl.dataset.day !== today) { dateEl.dataset.day = today; tickClock(); } }, 60_000); return () => { unsubClock(); unsubAlarms(); unsubPomo(); unsubCountdown(); window.clearInterval(dayTimer); }; }