Files
Time/src/renderer/components/ClockPanel.ts
2026-09-12 13:49:44 +08:00

240 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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居中下方一行小卡片。
* - HeroHH: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 = `
<section class="clock-hero" aria-label="${t('clock.subtitle')}">
<div class="clock-display mono" id="home-time"><span class="hh">${initParts[0]}</span><span class="colon">:</span><span class="mm">${initParts[1]}</span><span class="colon">:</span><span class="sec">${initParts[2]}</span></div>
<div class="clock-hero-meta">
<span class="clock-date" id="home-date">—</span>
<span class="clock-meta-sep" aria-hidden="true">·</span>
<span class="clock-next" id="home-next">${t('clock.nextNone')}</span>
</div>
</section>
<section class="home-grid" role="list" aria-label="状态卡片">
<div class="home-card" role="listitem" data-card="pomo">
<div class="home-card-label">${t('nav.pomodoro')}</div>
<div class="home-card-value" id="home-pomo-state">${t('clock.pomoStateIdle')}</div>
<div class="home-card-sub mono" id="home-pomo-time">--:--</div>
</div>
<div class="home-card" role="listitem" data-card="countdown">
<div class="home-card-label">${t('nav.countdown')}</div>
<div class="home-card-value" id="home-cd-state">${t('countdown.stateIdle')}</div>
<div class="home-card-sub mono" id="home-cd-time">--:--</div>
</div>
<div class="home-card" role="listitem" data-card="alarms">
<div class="home-card-label">${t('nav.alarms')}</div>
<div class="home-card-value mono" id="home-enabled">0</div>
<div class="home-card-sub">${t('clock.statEnabledSub')}</div>
</div>
</section>
`;
const timeEl = root.querySelector<HTMLElement>('#home-time')!;
// 缓存 hh/mm/sec 子节点引用 —— 每秒只改 textContent不再重建 innerHTML
// 每秒重建一次 innerHTML 既贵又会让任何依赖节点身份的 CSS 动画/变换从头重放。
const hhEl = timeEl.querySelector<HTMLElement>('.hh')!;
const mmEl = timeEl.querySelector<HTMLElement>('.mm')!;
const ssEl = timeEl.querySelector<HTMLElement>('.sec')!;
const dateEl = root.querySelector<HTMLElement>('#home-date')!;
const nextEl = root.querySelector<HTMLElement>('#home-next')!;
const pomoCard = root.querySelector<HTMLElement>('[data-card="pomo"]')!;
const pomoStateEl = root.querySelector<HTMLElement>('#home-pomo-state')!;
const pomoTimeEl = root.querySelector<HTMLElement>('#home-pomo-time')!;
const enabledEl = root.querySelector<HTMLElement>('#home-enabled')!;
const cdCard = root.querySelector<HTMLElement>('[data-card="countdown"]')!;
const cdStateEl = root.querySelector<HTMLElement>('#home-cd-state')!;
const cdTimeEl = root.querySelector<HTMLElement>('#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 会让标签里的 & 显示成 &amp;。
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<string, string>)[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);
};
}