Skip to content

Commit ed9e5d7

Browse files
committed
fix(pomodoro): 修复周期计数无重置路径与副作用双重执行
无重置路径的周期计数 + 跨模式状态残留 + store/hook 副作用双重执行 导致番茄钟计数统计异常。
1 parent b0f90e1 commit ed9e5d7

3 files changed

Lines changed: 114 additions & 133 deletions

File tree

Lines changed: 10 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
/**
22
* 番茄钟副作用 hook
3-
* @ai-context 监听 usePomodoroStore 的 lastAction 信号,触发音效、通知、成就检测、会话记录
3+
* @ai-context 监听 usePomodoroStore 的 lastAction 信号,触发视觉反馈(水墨涟漪)与通知权限请求
44
*
55
* 与 usePomodoroStore 配合使用:
6-
* - Store 仅负责纯状态变更(计时器 tick、阶段切换、设置更新)
7-
* - 本 hook 负责所有副作用(音效播放、浏览器通知、成就检测、会话持久化)
6+
* - Store 负责状态变更及音效播放、会话持久化、浏览器通知(tick/skip/start 内联执行)
7+
* - 本 hook 仅负责 store 无法执行的 DOM 级副作用(涟漪动画、权限请求)
8+
*
9+
* 注意:不要在此重复播放音效或记录会话——store 已执行过,
10+
* 重复执行会导致会话统计翻倍(BUG:番茄会话重复记录)。
811
*
912
* 使用方式:在番茄钟页面顶层组件中调用一次即可
1013
* <code>usePomodoroEffects()</code>
1114
*/
1215

1316
import { useEffect, useRef } from 'react';
1417
import { usePomodoroActionSignal } from '../store/usePomodoroStore';
15-
import { recordSession, playCompletionSound, sendNotification } from '../store/usePomodoroPersistence';
16-
import { soundPlayer } from '@/lib/audio/SoundPlayer';
1718
import { triggerInkRipple } from '@/lib/animation/InkRipple';
18-
import type { PomodoroAction } from '../store/usePomodoroStore';
1919

2020
/**
2121
* 番茄钟副作用 hook
@@ -44,125 +44,10 @@ export function usePomodoroEffects(): void {
4444
if (signal.lastActionCounter === prevCounterRef.current) return;
4545
prevCounterRef.current = signal.lastActionCounter;
4646

47-
const action: PomodoroAction | null = signal.lastAction;
48-
if (!action) return;
49-
50-
switch (action) {
51-
case 'start':
52-
soundPlayer.play('pomodoro_start');
53-
break;
54-
55-
case 'pause':
56-
soundPlayer.play('pomodoro_pause');
57-
break;
58-
59-
case 'exit_immersive':
60-
soundPlayer.play('pomodoro_pause');
61-
break;
62-
63-
case 'tick_5min_warning':
64-
// store 已确保 mode !== 'class' 时才发出此信号
65-
soundPlayer.play('pomodoro_5min_warning');
66-
break;
67-
68-
case 'tick_final':
69-
// store 已确保 mode !== 'class' 时才发出此信号
70-
soundPlayer.play('pomodoro_tick_final');
71-
break;
72-
73-
case 'phase_complete':
74-
handlePhaseComplete(signal);
75-
break;
47+
// 音效/会话记录/浏览器通知均由 store 内联执行,此处仅处理视觉反馈
48+
if (signal.lastAction === 'phase_complete') {
49+
// 水墨涟漪反馈:每次阶段完成时触发
50+
triggerInkRipple(window.innerWidth / 2, window.innerHeight / 2);
7651
}
7752
}, [signal.lastActionCounter, signal.lastAction, signal]);
7853
}
79-
80-
// ─────────────────────────────────────────────────────────────
81-
// 内部:phase_complete 副作用处理
82-
// ─────────────────────────────────────────────────────────────
83-
84-
interface PhaseCompletePayload {
85-
lastCompletedPhase: 'work' | 'short_break' | 'long_break' | null;
86-
isCycleComplete: boolean;
87-
lastSessionActualDuration: number | null;
88-
mode: 'class' | 'self_study';
89-
settings: {
90-
soundEnabled: boolean;
91-
notificationEnabled: boolean;
92-
workDuration: number;
93-
classDuration: number;
94-
};
95-
currentGoal: string | null;
96-
}
97-
98-
function handlePhaseComplete(payload: PhaseCompletePayload): void {
99-
const {
100-
lastCompletedPhase,
101-
isCycleComplete,
102-
lastSessionActualDuration,
103-
mode,
104-
settings,
105-
currentGoal,
106-
} = payload;
107-
108-
if (!lastCompletedPhase) return;
109-
110-
// ── 水墨涟漪反馈:每次阶段完成时触发 ────────────────
111-
triggerInkRipple(window.innerWidth / 2, window.innerHeight / 2);
112-
113-
// ── 记录会话(仅 work 阶段) ─────────────────────────────
114-
if (lastCompletedPhase === 'work') {
115-
const workMinutes = mode === 'class' ? settings.classDuration : settings.workDuration;
116-
const actualDuration = lastSessionActualDuration ?? workMinutes * 60;
117-
118-
recordSession({
119-
mode,
120-
duration: workMinutes * 60,
121-
actualDuration,
122-
completedAt: new Date(),
123-
interrupted: false,
124-
goal: currentGoal ?? undefined,
125-
})
126-
.then(() => {
127-
// 触发成就检查(动态 import 避免循环依赖)
128-
import('@/lib/achievements/evaluator')
129-
.then(({ checkAchievements }) => {
130-
checkAchievements({ type: 'pomodoro_completed' })
131-
.then((unlocked) => {
132-
unlocked.forEach((a) => {
133-
window.dispatchEvent(
134-
new CustomEvent('achievement-unlocked', { detail: a }),
135-
);
136-
});
137-
})
138-
.catch(() => {});
139-
})
140-
.catch(() => {});
141-
})
142-
.catch(() => {});
143-
}
144-
145-
// ── 播放音效(上课模式静默) ──────────────────────────────
146-
if (mode !== 'class') {
147-
if (settings.soundEnabled) {
148-
playCompletionSound();
149-
}
150-
if (lastCompletedPhase === 'work') {
151-
soundPlayer.play('pomodoro_work_complete');
152-
} else {
153-
soundPlayer.play('pomodoro_break_end');
154-
if (isCycleComplete) {
155-
soundPlayer.play('pomodoro_complete');
156-
}
157-
}
158-
}
159-
160-
// ── 发送浏览器通知 ────────────────────────────────────────
161-
if (settings.notificationEnabled) {
162-
if (lastCompletedPhase === 'work') {
163-
sendNotification('又添了一段暖意', '继续深潜吧 ☕').catch(() => {});
164-
} else {
165-
sendNotification('休息结束!', '开始下一个番茄 🍅').catch(() => {});
166-
}
167-
}
168-
}

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,5 +465,54 @@ describe('Pomodoro Store', () => {
465465
usePomodoroStore.getState().setMode('class');
466466
expect(usePomodoroStore.getState().mode).toBe('class');
467467
});
468+
469+
it('should reset completedCount when switching mode', () => {
470+
// 上课模式累计了 7 个番茄后切到自习模式,计数应归零
471+
// (回归:旧计数带入新模式导致首轮长休要等到 8 个番茄)
472+
usePomodoroStore.setState({ mode: 'class', completedCount: 7 });
473+
usePomodoroStore.getState().setMode('self_study');
474+
expect(usePomodoroStore.getState().completedCount).toBe(0);
475+
});
476+
477+
it('should NOT reset completedCount when setting the same mode', () => {
478+
usePomodoroStore.setState({ mode: 'self_study', completedCount: 2 });
479+
usePomodoroStore.getState().setMode('self_study');
480+
expect(usePomodoroStore.getState().completedCount).toBe(2);
481+
});
482+
});
483+
484+
// ── class mode completedCount ────────────────────────────
485+
486+
describe('class mode completedCount', () => {
487+
const completeWorkPhase = () => {
488+
usePomodoroStore.setState({ phase: 'work', remainingSeconds: 1, isRunning: true });
489+
usePomodoroStore.getState().tick(); // work → short_break
490+
usePomodoroStore.setState({ remainingSeconds: 1, isRunning: true });
491+
usePomodoroStore.getState().tick(); // short_break → work
492+
};
493+
494+
it('should never enter long_break in class mode', () => {
495+
usePomodoroStore.setState({
496+
mode: 'class', phase: 'work', completedCount: 3,
497+
isRunning: true, remainingSeconds: 1,
498+
});
499+
usePomodoroStore.getState().tick();
500+
expect(usePomodoroStore.getState().phase).toBe('short_break');
501+
});
502+
503+
it('should wrap completedCount within longBreakInterval in class mode (no unbounded growth)', () => {
504+
// 回归:上课模式无长休导致计数永不归零、一直累加(实测 9/4)
505+
usePomodoroStore.setState({ mode: 'class', completedCount: 0 });
506+
for (let i = 0; i < 9; i++) completeWorkPhase();
507+
const count = usePomodoroStore.getState().completedCount;
508+
expect(count).toBeGreaterThanOrEqual(1);
509+
expect(count).toBeLessThanOrEqual(DEFAULT_SETTINGS.longBreakInterval);
510+
});
511+
512+
it('should wrap count via skip in class mode as well', () => {
513+
usePomodoroStore.setState({ mode: 'class', phase: 'work', completedCount: 4 });
514+
usePomodoroStore.getState().skip();
515+
expect(usePomodoroStore.getState().completedCount).toBe(1);
516+
});
468517
});
469518
});

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

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ interface PomodoroState {
4444
sessionStartTime: number | null;
4545
/** 当前番茄目标文字 */
4646
currentGoal: string | null;
47+
/** 首潜迷你会话标记:3 分钟体验潜水,会话时长按实际记录而非 settings 时长 */
48+
isMiniDive: boolean;
4749
/** 是否处于沉浸专注模式 */
4850
isImmersive: boolean;
4951
/** 退出沉浸后标记,用于 resume 时自动重入 */
@@ -64,6 +66,8 @@ interface PomodoroState {
6466
lastSessionActualDuration: number | null;
6567

6668
start: () => void;
69+
/** 开始首潜 3 分钟迷你体验(新手引导专用,不改动用户设置) */
70+
startMiniDive: () => void;
6771
pause: () => void;
6872
resume: () => void;
6973
reset: () => void;
@@ -90,6 +94,9 @@ const getPhaseDuration = (phase: Phase, settings: PomodoroSettings, mode?: Mode)
9094
}
9195
};
9296

97+
/** 首潜迷你体验时长(3 分钟),见新手引导系统 */
98+
export const MINI_DIVE_SECONDS = 180;
99+
93100
const getNextPhase = (
94101
currentPhase: Phase,
95102
completedCount: number,
@@ -106,6 +113,24 @@ const getNextPhase = (
106113
return 'work';
107114
};
108115

116+
/**
117+
* 计算阶段结束后的完成计数:
118+
* - 长休结束 → 归零(一轮完成)
119+
* - 工作结束 → +1;上课模式无长休,计数达到周期上限后回绕,避免无限累加
120+
* - 其他阶段 → 不变
121+
*/
122+
const getNextCount = (
123+
phase: Phase,
124+
completedCount: number,
125+
longBreakInterval: number,
126+
mode?: Mode,
127+
): number => {
128+
if (phase === 'long_break') return 0;
129+
if (phase !== 'work') return completedCount;
130+
if (mode === 'class') return (completedCount % longBreakInterval) + 1;
131+
return completedCount + 1;
132+
};
133+
109134
export const usePomodoroStore = create<PomodoroState>((set, get) => {
110135
const defaultSettings: PomodoroSettings = {
111136
workDuration: 25,
@@ -130,6 +155,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
130155
settings: defaultSettings,
131156
sessionStartTime: null,
132157
currentGoal: null,
158+
isMiniDive: false,
133159
isImmersive: false,
134160
wasImmersive: false,
135161
aiRecommendedDuration: undefined,
@@ -168,6 +194,18 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
168194
soundPlayer.play('pomodoro_start');
169195
},
170196

197+
startMiniDive: () => {
198+
// 3 分钟真实专注:走完整 tick 链路(记会话/触发成就),duration 按 180s 如实记录
199+
set((s) => ({
200+
mode: 'self_study', phase: 'work',
201+
remainingSeconds: MINI_DIVE_SECONDS, totalSeconds: MINI_DIVE_SECONDS,
202+
isMiniDive: true, isRunning: true, isPaused: false,
203+
sessionStartTime: Date.now(), currentGoal: '首潜 · 3 分钟体验',
204+
lastAction: 'start' as PomodoroAction, lastActionCounter: s.lastActionCounter + 1,
205+
}));
206+
soundPlayer.play('pomodoro_start');
207+
},
208+
171209
pause: () => {
172210
set((s) => ({
173211
isRunning: false, isPaused: true,
@@ -199,12 +237,13 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
199237
isPaused: false,
200238
sessionStartTime: null,
201239
wasImmersive: false,
240+
isMiniDive: false,
202241
});
203242
},
204243

205244
skip: () => {
206245
const { phase, completedCount, settings, mode } = get();
207-
const newCount = phase === 'long_break' ? 0 : (phase === 'work' ? completedCount + 1 : completedCount);
246+
const newCount = getNextCount(phase, completedCount, settings.longBreakInterval, mode);
208247
const nextPhase = getNextPhase(phase, completedCount, settings.longBreakInterval, mode);
209248
const duration = getPhaseDuration(nextPhase, settings, mode);
210249
set({
@@ -214,12 +253,15 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
214253
completedCount: newCount,
215254
isRunning: false,
216255
isPaused: false,
256+
isMiniDive: false,
217257
});
218258
},
219259

220260
setMode: (mode) => {
221-
const { settings, phase, isRunning, isPaused } = get();
222-
set({ mode });
261+
const { settings, phase, isRunning, isPaused, mode: prevMode } = get();
262+
if (mode === prevMode) return;
263+
// 切换模式 = 开启新周期:计数归零,避免跨模式累计(自习首轮跳到 8 的根因)
264+
set({ mode, completedCount: 0 });
223265
// 切换模式后,若计时器未运行,重置当前阶段时长
224266
if (!isRunning && !isPaused) {
225267
const duration = getPhaseDuration(phase, settings, mode);
@@ -252,7 +294,7 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
252294
if (remainingSeconds <= 1) {
253295
// Phase completed
254296
const wasRunning = isRunning;
255-
const newCount = phase === 'long_break' ? 0 : (phase === 'work' ? completedCount + 1 : completedCount);
297+
const newCount = getNextCount(phase, completedCount, settings.longBreakInterval, mode);
256298
const nextPhase = getNextPhase(phase, completedCount, settings.longBreakInterval, mode);
257299
const duration = getPhaseDuration(nextPhase, settings, mode);
258300
const isCycleComplete = phase === 'long_break';
@@ -272,14 +314,17 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
272314
// 记录完成的番茄会话
273315
let actualDuration: number | null = null;
274316
if (phase === 'work') {
275-
const { sessionStartTime: sst } = get();
276-
const workMinutes = mode === 'class' ? settings.classDuration : settings.workDuration;
317+
const { sessionStartTime: sst, isMiniDive } = get();
318+
// 迷你潜水按实际 180s 记录,避免污染效率统计(首潜决策:计入成就)
319+
const plannedSeconds = isMiniDive
320+
? MINI_DIVE_SECONDS
321+
: (mode === 'class' ? settings.classDuration : settings.workDuration) * 60;
277322
actualDuration = sst
278323
? Math.round((Date.now() - sst) / 1000)
279-
: workMinutes * 60;
324+
: plannedSeconds;
280325
recordSession({
281326
mode: get().mode,
282-
duration: workMinutes * 60,
327+
duration: plannedSeconds,
283328
actualDuration,
284329
completedAt: new Date(),
285330
interrupted: false,
@@ -328,6 +373,8 @@ export const usePomodoroStore = create<PomodoroState>((set, get) => {
328373
completedCount: newCount,
329374
isRunning: shouldAutoStart,
330375
isPaused: !shouldAutoStart,
376+
// 迷你潜水仅限一个工作阶段,阶段切换即恢复常规节律
377+
isMiniDive: false,
331378
// 切换到新阶段时清空计时,下一个 start/resume 会重新设置
332379
sessionStartTime: null,
333380
// 发出 phase_complete 动作信号

0 commit comments

Comments
 (0)