Skip to content

Commit 875831c

Browse files
committed
fix(pomodoro): 计时全局调度解耦、墙钟校准与沉浸双视图叠加修复
- 新增 pomodoroScheduler 全局调度器(App 启动注册),tick 驱动与页面生命周期解耦,切离页面倒计时不停摆 - store 引入 endAt 墙钟截止点,tick 与墙钟误差 >1s 时吸附校准,休眠唤醒/interval 漂移后自愈(附单测) - 沉浸/普通切换改为视图状态机串行过渡:两个独立 AnimatePresence 实例间 mode=wait 无效,此前双视图叠加约 0.6s 双曝光残帧 - 附知识库 bug 复盘文档
1 parent caf7cef commit 875831c

6 files changed

Lines changed: 220 additions & 25 deletions

File tree

‎client/src/App.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,14 @@ import { RETURN_THRESHOLD_MS } from '@/features/assistant/constants';
2828
import { useRetentionInit } from '@/features/retention/hooks/useRetentionInit';
2929
import { DiscoveryReveal } from '@/features/retention/components/DiscoveryReveal';
3030
import '@/stores/useSettingsStore'; // 导入以触发音效设置初始化
31+
import { startPomodoroScheduler } from '@/features/pomodoro/lib/pomodoroScheduler';
3132

3233
// 启动时预加载所有音效(不阻塞渲染)
3334
soundPlayer.preloadAll();
3435

36+
// 番茄钟全局计时调度器:tick 驱动与页面生命周期解耦(切页后计时不停摆)
37+
startPomodoroScheduler();
38+
3539
const queryClient = new QueryClient({
3640
defaultOptions: {
3741
queries: {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* 番茄钟全局计时调度器
3+
*
4+
* @ai-context: tick 驱动与页面生命周期解耦——历史缺陷:计时曾仅由 PomodoroPage
5+
* 组件内 setInterval 驱动,而 AppLayout 的路由切换(AnimatePresence mode="wait")
6+
* 会真实卸载旧页面,导致用户切到其他模块时倒计时静默停摆。
7+
* 本模块以模块级 store.subscribe 监听 isRunning 翻转,保证任何页面下计时持续推进;
8+
* 前台恢复(visibilitychange)时立即 tick 一次,配合 store 内 endAt 墙钟校准
9+
* 自愈系统休眠/后台期间累积的计时误差。
10+
*
11+
* @ai-context: 纯副作用模块(无 React 依赖),由 App 启动时调用一次 startPomodoroScheduler()。
12+
* 测试环境不调用本函数,故不会在单测中产生游离 interval。
13+
*/
14+
15+
import { usePomodoroStore } from '../store/usePomodoroStore';
16+
17+
const TICK_INTERVAL_MS = 1000;
18+
19+
/** 当前活跃的 interval 句柄(null = 计时未运行) */
20+
let timer: ReturnType<typeof setInterval> | null = null;
21+
22+
/** 幂等守卫:防止重复注册订阅与监听 */
23+
let initialized = false;
24+
25+
/**
26+
* 按 isRunning 同步 interval 生命周期(幂等)
27+
*
28+
* @ai-context: 每次调用读取最新 store 快照而非闭包值,
29+
* 避免订阅回调里的陈旧状态导致 interval 泄漏或重复创建。
30+
*/
31+
function syncTimer(): void {
32+
const { isRunning } = usePomodoroStore.getState();
33+
if (isRunning && timer === null) {
34+
timer = setInterval(() => usePomodoroStore.getState().tick(), TICK_INTERVAL_MS);
35+
} else if (!isRunning && timer !== null) {
36+
clearInterval(timer);
37+
timer = null;
38+
}
39+
}
40+
41+
/**
42+
* 启动全局计时调度器(应用启动时调用一次,内部幂等)
43+
*
44+
* @returns void
45+
*/
46+
export function startPomodoroScheduler(): void {
47+
if (initialized) return;
48+
initialized = true;
49+
50+
// 仅在 isRunning 翻转时同步 interval,避免其他高频状态变化(如 remainingSeconds)触发多余判断
51+
usePomodoroStore.subscribe((state, prevState) => {
52+
if (state.isRunning !== prevState.isRunning) syncTimer();
53+
});
54+
55+
// 回前台立即校准一次:休眠/后台期间错过的时间由 endAt 墙钟吸附补齐
56+
document.addEventListener('visibilitychange', () => {
57+
if (document.visibilityState === 'visible') {
58+
usePomodoroStore.getState().tick();
59+
}
60+
});
61+
62+
// 应用启动时若已有运行中的计时(如热更新后),立即接管
63+
syncTimer();
64+
}

‎client/src/features/pomodoro/pages/PomodoroPage.tsx‎

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @ai-context: pomodoro 功能模块页面:PomodoroPage。
33
*/
4-
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
4+
import { useEffect, useState, useMemo, useCallback } from 'react';
55
import { createPortal } from 'react-dom';
66
import { AnimatePresence, motion } from 'framer-motion';
77
import { Play, Pause, RotateCcw, SkipForward, Clock, Volume2, VolumeX, Focus } from 'lucide-react';
@@ -29,21 +29,19 @@ import type { AudioPreferences } from '@/lib/audio/audioConfig';
2929

3030
const WHITE_NOISE_FADE_MS = 1000;
3131
const WHITE_NOISE_FADE_OUT_MS = 1500;
32-
const TIMER_TICK_INTERVAL_MS = 1000;
3332

3433
export default function PomodoroPage() {
3534
const {
3635
phase, isRunning, isPaused, remainingSeconds, totalSeconds,
3736
completedCount, settings, currentGoal, isImmersive,
3837
activePreset, presets,
3938
start, pause, resume, reset, skip, setPreset, setCurrentGoal,
40-
enterImmersive, exitImmersive, tick, createPreset,
39+
enterImmersive, exitImmersive, createPreset,
4140
showCompletionOverlay, dismissCompletionOverlay, lastSessionActualDuration,
4241
} = usePomodoroStore(useShallow(s => s));
4342

4443
usePomodoroEffects();
4544

46-
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
4745
const [goalModalOpen, setGoalModalOpen] = useState(false);
4846
const [rememberGoal, setRememberGoal] = useState(false);
4947
const [presetEditorOpen, setPresetEditorOpen] = useState(false);
@@ -77,14 +75,8 @@ export default function PomodoroPage() {
7775
whiteNoisePlayer.setVolume(vol);
7876
};
7977

80-
useEffect(() => {
81-
if (isRunning) {
82-
intervalRef.current = setInterval(() => tick(), TIMER_TICK_INTERVAL_MS);
83-
} else {
84-
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
85-
}
86-
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
87-
}, [isRunning, tick]);
78+
// @ai-context: tick 驱动已上移至全局调度器 pomodoroScheduler.ts(App 启动时注册),
79+
// 不再由本页面组件级 setInterval 驱动——切离页面后计时仍持续推进
8880

8981
useEffect(() => {
9082
if (isRunning || isPaused) {
@@ -141,13 +133,34 @@ export default function PomodoroPage() {
141133
// 循环标记数据 — 由活动预设的 longBreakInterval 驱动
142134
const cycleTotal = activePreset?.longBreakInterval ?? settings.longBreakInterval;
143135

136+
// 双模式过渡排序:沉浸层(portal)与普通视图分属两个独立 AnimatePresence 实例,
137+
// mode="wait" 跨实例无效——此前切换时沉浸退场(0.5s)与普通进场(0.3s)同屏叠加
138+
// 约 0.6s,表现为组件重复的双曝光残帧。此处用视图状态机显式串行化
139+
// “新视图必须等旧视图退场动画完成后才进场”:切换期间仅渲染旧视图退场动画,
140+
// onExitComplete 后再迁至目标视图。全部动画参数保留,仅消除叠加。
141+
const [view, setView] = useState<'normal' | 'immersive' | 'switching-to-immersive' | 'switching-to-normal'>(
142+
() => (isImmersive ? 'immersive' : 'normal'),
143+
);
144+
145+
useEffect(() => {
146+
if (isImmersive) {
147+
// 'switching-to-normal' → 'immersive':沉浸退场中用户又重新进入,直接重新挂载
148+
setView(v => (v === 'immersive' || v === 'switching-to-immersive')
149+
? v
150+
: v === 'switching-to-normal' ? 'immersive' : 'switching-to-immersive');
151+
} else {
152+
setView(v => (v === 'normal' || v === 'switching-to-normal')
153+
? v
154+
: v === 'switching-to-immersive' ? 'normal' : 'switching-to-normal');
155+
}
156+
}, [isImmersive]);
157+
144158
return (
145159
<>
146-
{/* 沉浸模式 — portal 到 body,AnimatePresence 在 portal 内部管理动效 */}
147-
{/* 沉浸模式切换:mode="wait" 确保沉浸模式完全退出后再显示普通模式,避免双视图叠加残帧 */}
160+
{/* 沉浸层 — createPortal 挂到 body(z-40 盖住覆盖层);挂载时机由上方视图状态机门控 */}
148161
{createPortal(
149-
<AnimatePresence mode="wait">
150-
{isImmersive && (
162+
<AnimatePresence onExitComplete={() => setView(v => (v === 'switching-to-normal' ? 'normal' : v))}>
163+
{view === 'immersive' && (
151164
<motion.div
152165
key="immersive"
153166
className="fixed inset-0 z-40 flex flex-col overflow-hidden"
@@ -169,9 +182,9 @@ export default function PomodoroPage() {
169182
document.body,
170183
)}
171184

172-
{/* 普通模式 */}
173-
<AnimatePresence mode="popLayout">
174-
{!isImmersive && (
185+
{/* 普通视图 — 仅在沉浸层退场完成后(或初始即普通模式时)挂载 */}
186+
<AnimatePresence mode="popLayout" onExitComplete={() => setView(v => (v === 'switching-to-immersive' ? 'immersive' : v))}>
187+
{view === 'normal' && (
175188
<motion.div
176189
key="normal"
177190
className="flex flex-col items-center justify-center min-h-0 flex-1 px-4 py-12 relative"
@@ -358,7 +371,7 @@ export default function PomodoroPage() {
358371
}}
359372
/>
360373
</motion.div>
361-
)}
374+
)}
362375
</AnimatePresence>
363376

364377
{/* v0.29: 深潜完成庆祝覆盖层 */}

‎client/src/features/pomodoro/store/usePomodoroStore.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const DEFAULT_STATE = {
7777
isPaused: false,
7878
remainingSeconds: 25 * 60,
7979
totalSeconds: 25 * 60,
80+
endAt: null as number | null,
8081
completedCount: 0,
8182
mode: 'self_study' as const,
8283
settings: DEFAULT_SETTINGS,
@@ -130,6 +131,33 @@ describe('Pomodoro Store', () => {
130131
usePomodoroStore.getState().tick();
131132
expect(usePomodoroStore.getState().remainingSeconds).toBe(before);
132133
});
134+
135+
it('should snap remainingSeconds to endAt wall clock when drift exceeds 1s', () => {
136+
// Arrange:模拟休眠唤醒——递减模型认为还剩 500s,但墙钟显示实际只剩 100s
137+
usePomodoroStore.setState({
138+
isRunning: true,
139+
remainingSeconds: 500,
140+
endAt: Date.now() + 100 * 1000,
141+
});
142+
143+
// Act
144+
usePomodoroStore.getState().tick();
145+
146+
// Assert:吸附到墙钟值而非继续 -1
147+
expect(usePomodoroStore.getState().remainingSeconds).toBe(100);
148+
});
149+
150+
it('should keep -1 rhythm when wall clock drift is within 1s', () => {
151+
// Arrange:刚 start,墙钟与递减模型误差 ≤1s,不应跳秒
152+
usePomodoroStore.getState().start();
153+
const before = usePomodoroStore.getState().remainingSeconds;
154+
155+
// Act
156+
usePomodoroStore.getState().tick();
157+
158+
// Assert
159+
expect(usePomodoroStore.getState().remainingSeconds).toBe(before - 1);
160+
});
133161
});
134162

135163
// ── phase rotation ────────────────────────────────────────

‎client/src/features/pomodoro/store/usePomodoroStore.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ interface PomodoroState {
5454
isPaused: boolean;
5555
remainingSeconds: number;
5656
totalSeconds: number;
57+
/** 当前运行阶段的绝对截止时间戳(ms)——墙钟校准源,休眠唤醒/计时漂移后自愈 */
58+
endAt: number | null;
5759
completedCount: number;
5860
/** @deprecated 兼容层:由 activePreset 派生,下版本移除 */
5961
mode: Mode;
@@ -197,6 +199,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
197199
isPaused: false,
198200
remainingSeconds: defaultSettings.workDuration * 60,
199201
totalSeconds: defaultSettings.workDuration * 60,
202+
endAt: null,
200203
completedCount: 0,
201204
mode: 'self_study',
202205
settings: defaultSettings,
@@ -254,6 +257,8 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
254257
start: () => {
255258
set((s) => ({
256259
isRunning: true, isPaused: false, sessionStartTime: Date.now(),
260+
// 墙钟截止点:tick 据此校准,避免 interval 漂移/后台节流累积误差
261+
endAt: Date.now() + s.remainingSeconds * 1000,
257262
lastAction: 'start' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
258263
}));
259264
soundPlayer.play('pomodoro_start');
@@ -264,6 +269,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
264269
set((s) => ({
265270
mode: 'self_study', phase: 'work',
266271
remainingSeconds: MINI_DIVE_SECONDS, totalSeconds: MINI_DIVE_SECONDS,
272+
endAt: Date.now() + MINI_DIVE_SECONDS * 1000,
267273
isMiniDive: true, isRunning: true, isPaused: false,
268274
sessionStartTime: Date.now(), currentGoal: '首潜 · 3 分钟体验',
269275
lastAction: 'start' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
@@ -276,6 +282,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
276282
set((s) => ({
277283
mode: 'self_study', phase: 'work',
278284
remainingSeconds: COMMIT_DIVE_SECONDS, totalSeconds: COMMIT_DIVE_SECONDS,
285+
endAt: Date.now() + COMMIT_DIVE_SECONDS * 1000,
279286
isMiniDive: true, isRunning: true, isPaused: false,
280287
sessionStartTime: Date.now(), currentGoal: '就 5 分钟 · 随时可以停',
281288
lastAction: 'start' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
@@ -285,23 +292,25 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
285292

286293
pause: () => {
287294
set((s) => ({
288-
isRunning: false, isPaused: true,
295+
isRunning: false, isPaused: true, endAt: null,
289296
lastAction: 'pause' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
290297
}));
291298
soundPlayer.play('pomodoro_pause');
292299
},
293300

294301
resume: () => {
295302
const { sessionStartTime, wasImmersive } = get();
296-
set({
303+
set((s) => ({
297304
isRunning: true,
298305
isPaused: false,
306+
// 恢复运行重设墙钟截止点(暂停期间时间不计入)
307+
endAt: Date.now() + s.remainingSeconds * 1000,
299308
// 如果 sessionStartTime 为空(重置后),重新记录
300309
sessionStartTime: sessionStartTime ?? Date.now(),
301310
// 若上次是从沉浸模式退出的,自动重新进入沉浸
302311
isImmersive: wasImmersive ? true : get().isImmersive,
303312
wasImmersive: false,
304-
});
313+
}));
305314
},
306315

307316
reset: () => {
@@ -312,6 +321,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
312321
totalSeconds: duration,
313322
isRunning: false,
314323
isPaused: false,
324+
endAt: null,
315325
sessionStartTime: null,
316326
wasImmersive: false,
317327
isMiniDive: false,
@@ -331,6 +341,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
331341
completedCount: newCount,
332342
isRunning: false,
333343
isPaused: false,
344+
endAt: null,
334345
isMiniDive: false,
335346
});
336347
},
@@ -376,7 +387,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
376387
soundPlayer.play('pomodoro_pause');
377388
}
378389
set((s) => ({
379-
isImmersive: false, wasImmersive: true, isRunning: false, isPaused: true,
390+
isImmersive: false, wasImmersive: true, isRunning: false, isPaused: true, endAt: null,
380391
lastAction: 'exit_immersive' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
381392
}));
382393
},
@@ -476,6 +487,8 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
476487
completedCount: newCount,
477488
isRunning: shouldAutoStart,
478489
isPaused: !shouldAutoStart,
490+
// 自动进入下一阶段时重设墙钟截止点;否则清空等待下次 start/resume
491+
endAt: shouldAutoStart ? Date.now() + duration * 1000 : null,
479492
// 迷你潜水仅限一个工作阶段,阶段切换即恢复常规节律
480493
isMiniDive: false,
481494
// 切换到新阶段时清空计时,下一个 start/resume 会重新设置
@@ -490,7 +503,16 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
490503
showCompletionOverlay: phase === 'work' ? true : s.showCompletionOverlay,
491504
}));
492505
} else {
493-
const nextRemaining = remainingSeconds - 1;
506+
let nextRemaining = remainingSeconds - 1;
507+
// 墙钟校准:系统休眠唤醒/setInterval 漂移后,与 endAt 误差超过 1s 时直接吸附,
508+
// 避免递减模型累积误差(误差 ≤1s 时保持 -1 节奏,防止 UI 跳秒)
509+
const { endAt } = get();
510+
if (endAt != null) {
511+
const wallRemaining = Math.max(0, Math.round((endAt - Date.now()) / 1000));
512+
if (Math.abs(wallRemaining - nextRemaining) > 1) {
513+
nextRemaining = wallRemaining;
514+
}
515+
}
494516
// 静默预设跳过预警和滴答音
495517
if (!isSilent) {
496518
// 预警(工作阶段)—— 支持自定义时点

0 commit comments

Comments
 (0)