240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
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 = `
|
||
<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 会让标签里的 & 显示成 &。
|
||
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);
|
||
};
|
||
}
|