Skip to content

Commit 7fa8d2d

Browse files
committed
try fix pomodoro timer
1 parent 1cdad12 commit 7fa8d2d

2 files changed

Lines changed: 182 additions & 65 deletions

File tree

src/components/PomodoroTimer.tsx

Lines changed: 67 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
3737
const {
3838
isRunning,
3939
isPaused,
40-
remainingTime,
40+
remainingTime: storeRemainingTime, // Rename to distinguish from local smooth state
4141
mode,
4242
start,
4343
pause,
@@ -50,7 +50,8 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
5050
breakDuration,
5151
setDurations,
5252
setStartTime,
53-
startTime,
53+
startTime: storeStartTime,
54+
endTime, // Use endTime for smooth calcs
5455
} = usePomodoroStore();
5556
const { pomodoro, updatePomodoro, theme } = useSettings();
5657
const { addSession } = usePomodoroHistory();
@@ -73,6 +74,10 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
7374
});
7475
const offsetRef = useRef({ x: 0, y: 0 });
7576

77+
// Local state for smooth updates
78+
const [smoothRemaining, setSmoothRemaining] = useState(storeRemainingTime);
79+
const [smoothProgress, setSmoothProgress] = useState(0);
80+
7681
useEffect(() => {
7782
try {
7883
localStorage.setItem("pomodoroFloatPos", JSON.stringify(position));
@@ -109,6 +114,46 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
109114
window.addEventListener("pointerup", stopDrag);
110115
};
111116

117+
// High frequency update loop for smooth visuals
118+
useEffect(() => {
119+
if (!isRunning || isPaused || !endTime) {
120+
// Fallback to store values when not running smoothly
121+
setSmoothRemaining(storeRemainingTime);
122+
const duration = mode === "work" ? workDuration : breakDuration;
123+
setSmoothProgress(storeRemainingTime / duration);
124+
return;
125+
}
126+
127+
let animationFrameId: number;
128+
129+
const animate = () => {
130+
const now = Date.now();
131+
const msRemaining = Math.max(0, endTime - now);
132+
const secondsRemaining = Math.ceil(msRemaining / 1000);
133+
134+
const duration = mode === "work" ? workDuration : breakDuration;
135+
// Calculate precise progress (0.0 to 1.0)
136+
// We use ms for the ring to be buttery smooth
137+
// Total duration in ms
138+
const durationMs = duration * 1000;
139+
// Progress acts inverted in the original code (remaining / total)
140+
const exactProgress = Math.min(1, Math.max(0, msRemaining / durationMs));
141+
142+
setSmoothRemaining(secondsRemaining);
143+
setSmoothProgress(exactProgress);
144+
145+
if (msRemaining > 0) {
146+
animationFrameId = requestAnimationFrame(animate);
147+
}
148+
};
149+
150+
animate();
151+
152+
return () => cancelAnimationFrame(animationFrameId);
153+
}, [isRunning, isPaused, endTime, mode, workDuration, breakDuration, storeRemainingTime]);
154+
155+
156+
// Keep 'now' updated for pause duration display (low freq is fine for this)
112157
useEffect(() => {
113158
if (!isPaused) return;
114159
const i = setInterval(() => setNow(Date.now()), 1000);
@@ -189,16 +234,16 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
189234
// We rely on store.startTime for both work and break now.
190235

191236
const handlePause = () => {
192-
if (startTime) {
193-
addSession(startTime, Date.now(), mode);
237+
if (storeStartTime) {
238+
addSession(storeStartTime, Date.now(), mode);
194239
setStartTime(undefined);
195240
}
196241
pause();
197242
};
198243

199244
const handleReset = () => {
200-
if (startTime) {
201-
addSession(startTime, Date.now(), mode);
245+
if (storeStartTime) {
246+
addSession(storeStartTime, Date.now(), mode);
202247
}
203248
setStartTime(undefined);
204249
reset();
@@ -209,13 +254,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
209254
addSession(pauseStart, Date.now(), "break");
210255
}
211256
resume();
212-
setStartTime(Date.now());
257+
// Resume function in store now handles startTime/endTime logic
213258
};
214259

215260
const handleStartBreak = () => {
216261
// If we are currently working, save the work done so far
217-
if (mode === "work" && startTime) {
218-
addSession(startTime, Date.now(), "work");
262+
if (mode === "work" && storeStartTime) {
263+
addSession(storeStartTime, Date.now(), "work");
219264
} else if (pauseStart) {
220265
// If we were paused, the gap was a break
221266
addSession(pauseStart, Date.now(), "break");
@@ -226,8 +271,8 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
226271

227272
const handleSkipBreak = () => {
228273
// If we are currently in a break, save the break time taken so far
229-
if (mode === "break" && startTime) {
230-
addSession(startTime, Date.now(), "break");
274+
if (mode === "break" && storeStartTime) {
275+
addSession(storeStartTime, Date.now(), "break");
231276
} else if (pauseStart) {
232277
addSession(pauseStart, Date.now(), "break");
233278
}
@@ -296,14 +341,19 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
296341

297342
if (compact && !isRunning) return null;
298343

299-
const duration = mode === "work" ? workDuration : breakDuration;
300-
const progress = remainingTime / duration;
301-
302344
const radius = size;
303345
const stroke = 8;
304346
const normalizedRadius = radius - stroke / 2;
305347
const circumference = normalizedRadius * 2 * Math.PI;
306-
const strokeDashoffset = circumference - progress * circumference;
348+
349+
// Use smoothProgress for ring, smoothRemaining for text
350+
// smoothProgress is 0..1 (remaining/total), but we want inverse for strokeDashoffset calc if we want it to shrink
351+
// The original code was: progress = remainingTime / duration
352+
// strokeDashoffset = circumference - progress * circumference
353+
354+
// So if progress is 1 (full), offset is 0 (full ring).
355+
// If progress is 0 (empty), offset is circumference (empty ring).
356+
const strokeDashoffset = circumference - smoothProgress * circumference;
307357

308358
return (
309359
<div
@@ -344,7 +394,7 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
344394
strokeDasharray={`${circumference} ${circumference}`}
345395
style={{
346396
strokeDashoffset,
347-
transition: "stroke-dashoffset 1s linear",
397+
transition: "stroke-dashoffset 0s linear", // Disable CSS transition for smooth JS animation
348398
}}
349399
r={normalizedRadius}
350400
cx={radius}
@@ -357,7 +407,7 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({
357407
>
358408
{isPaused
359409
? `${t("pomodoroTimer.pauseLabel")} ${formatTime(pauseDuration)}`
360-
: formatTime(remainingTime)}
410+
: formatTime(smoothRemaining)}
361411
</div>
362412
</div>
363413
</div>

src/stores/pomodoro.ts

Lines changed: 115 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface PomodoroState {
1111
workDuration: number;
1212
breakDuration: number;
1313
startTime?: number;
14+
endTime?: number; // Target timestamp when timer ends
1415
lastTick?: number;
1516
pauseStart?: number;
1617
// Transient state to notify UI to record a session
@@ -45,20 +46,49 @@ export const usePomodoroStore = create<PomodoroState>()(
4546
startTime: undefined,
4647
lastTick: undefined,
4748
finishedSession: undefined,
49+
endTime: undefined, // New: track expected end time
50+
4851
start: (taskId?: string) =>
49-
set((state) => ({
50-
isRunning: true,
51-
isPaused: false,
52-
pauseStart: undefined,
53-
remainingTime: state.workDuration,
54-
mode: "work",
55-
currentTaskId: taskId,
56-
startTime: Date.now(),
57-
lastTick: Date.now(),
58-
})),
59-
pause: () => set({ isPaused: true, pauseStart: Date.now() }),
52+
set((state) => {
53+
const now = Date.now();
54+
const duration = state.workDuration;
55+
return {
56+
isRunning: true,
57+
isPaused: false,
58+
pauseStart: undefined,
59+
remainingTime: duration,
60+
mode: "work",
61+
currentTaskId: taskId,
62+
startTime: now,
63+
endTime: now + duration * 1000,
64+
lastTick: now,
65+
};
66+
}),
67+
68+
pause: () =>
69+
set((state) => {
70+
// When pausing, we clear endTime because "real time" flow stops for the timer.
71+
// remainingTime is preserved.
72+
return {
73+
isPaused: true,
74+
pauseStart: Date.now(),
75+
endTime: undefined
76+
};
77+
}),
78+
6079
resume: () =>
61-
set({ isPaused: false, lastTick: Date.now(), pauseStart: undefined }),
80+
set((state) => {
81+
const now = Date.now();
82+
// Calculate new endTime based on preserved remainingTime
83+
const newEndTime = now + state.remainingTime * 1000;
84+
return {
85+
isPaused: false,
86+
pauseStart: undefined,
87+
lastTick: now,
88+
endTime: newEndTime,
89+
};
90+
}),
91+
6292
reset: () =>
6393
set((state) => ({
6494
isRunning: false,
@@ -70,73 +100,110 @@ export const usePomodoroStore = create<PomodoroState>()(
70100
lastTick: undefined,
71101
pauseStart: undefined,
72102
finishedSession: undefined,
103+
endTime: undefined,
73104
})),
105+
74106
startBreak: () =>
75-
set((state) => ({
76-
isRunning: true,
77-
isPaused: false,
78-
pauseStart: undefined,
79-
mode: "break",
80-
remainingTime: state.breakDuration,
81-
lastTick: Date.now(),
82-
startTime: Date.now(), // Explicitly track break start
83-
})),
107+
set((state) => {
108+
const now = Date.now();
109+
const duration = state.breakDuration;
110+
return {
111+
isRunning: true,
112+
isPaused: false,
113+
pauseStart: undefined,
114+
mode: "break",
115+
remainingTime: duration,
116+
lastTick: now,
117+
startTime: now,
118+
endTime: now + duration * 1000,
119+
};
120+
}),
121+
84122
skipBreak: () =>
85-
set((state) => ({
86-
isRunning: true,
87-
isPaused: false,
88-
pauseStart: undefined,
89-
mode: "work",
90-
remainingTime: state.workDuration,
91-
lastTick: Date.now(),
92-
startTime: Date.now(),
93-
})),
123+
set((state) => {
124+
const now = Date.now();
125+
const duration = state.workDuration;
126+
return {
127+
isRunning: true,
128+
isPaused: false,
129+
pauseStart: undefined,
130+
mode: "work",
131+
remainingTime: duration,
132+
lastTick: now,
133+
startTime: now,
134+
endTime: now + duration * 1000,
135+
};
136+
}),
137+
94138
tick: () =>
95139
set((state) => {
96140
if (!state.isRunning || state.isPaused) return state;
141+
97142
const now = Date.now();
98-
const lastTick = state.lastTick || now;
99-
const elapsed = Math.max(0, Math.floor((now - lastTick) / 1000));
100-
101-
if (elapsed === 0) return state;
143+
// If we somehow don't have an endTime (legacy state or bug), set it now
144+
if (!state.endTime) {
145+
return {
146+
...state,
147+
endTime: now + state.remainingTime * 1000,
148+
lastTick: now,
149+
};
150+
}
102151

103-
if (state.remainingTime > elapsed) {
104-
return {
105-
remainingTime: state.remainingTime - elapsed,
106-
lastTick: now,
107-
};
152+
const msRemaining = state.endTime - now;
153+
// Ceiling to keep 0.9s as "1s" remaining on UI until it truly hits 0
154+
const remainingSeconds = Math.max(0, Math.ceil(msRemaining / 1000));
155+
156+
if (remainingSeconds > 0) {
157+
// Only update if changed to avoid unnecessary re-renders if called rapidly
158+
if (remainingSeconds !== state.remainingTime) {
159+
return {
160+
remainingTime: remainingSeconds,
161+
lastTick: now,
162+
};
163+
}
164+
return state;
108165
}
109166

110167
// Timer finished
111168
const finishedMode = state.mode;
112-
// Calculate when it roughly finished (startTime + duration) or just now
113-
// If huge drift, we prefer 'expected' end time to avoid 2-hour sessions
169+
// Accurate start/end for history
114170
const durationSec =
115171
finishedMode === "work" ? state.workDuration : state.breakDuration;
116-
const expectedEnd = (state.startTime || now) + durationSec * 1000;
172+
173+
// Use stored endTime for exact record, or now if significantly off
174+
const exactEnd = state.endTime;
175+
const calculatedStart = exactEnd - durationSec * 1000;
117176

118177
const finishedSession = {
119-
start: state.startTime || now - durationSec * 1000,
120-
end: expectedEnd,
178+
start: calculatedStart,
179+
end: exactEnd,
121180
type: finishedMode,
122181
};
123182

124183
const nextMode = state.mode === "work" ? "break" : "work";
184+
const nextDuration = nextMode === "work" ? state.workDuration : state.breakDuration;
185+
186+
// Start next session immediately from 'now'.
187+
// (Optionally could be 'exactEnd' if we want zero gap, but 'now' is safer for user perception)
188+
125189
return {
126190
mode: nextMode,
127-
remainingTime:
128-
nextMode === "work" ? state.workDuration : state.breakDuration,
191+
remainingTime: nextDuration,
129192
lastTick: now,
130-
startTime: now, // Start the next session NOW (resetting the gap)
193+
startTime: now,
194+
endTime: now + nextDuration * 1000,
131195
finishedSession,
132-
} as PomodoroState;
196+
};
133197
}),
198+
134199
setStartTime: (time) => set({ startTime: time }),
135200
setLastTick: (time) => set({ lastTick: time }),
136201
setDurations: (work, brk) =>
137202
set((state) => ({
138203
workDuration: work,
139204
breakDuration: brk,
205+
// If not running, update display immediately.
206+
// If running, don't change current session but next one will use new durations.
140207
remainingTime: !state.isRunning ? work : state.remainingTime,
141208
})),
142209
consumeFinishedSession: () => set({ finishedSession: undefined }),

0 commit comments

Comments
 (0)