Or: How I Learned That Timer State Management is Harder Than It Looks
Part 3 of an ongoing series documenting my journey building a production-ready task management app. Read Part 1 and Part 2 to catch up on the foundation and daily planning features.
If you’ve ever thought “adding a timer should be easy,” let me tell you a story. Spoiler: it wasn’t.
The Goal
I wanted something simple: click a tomato icon on any scheduled task, get a 25-minute Pomodoro timer, track actual time spent. Compare estimates to reality. Standard productivity app stuff.
Turns out “simple” is doing a lot of heavy lifting in that sentence.
Session 1: The Foundation (aka “This Should Be Easy”)
I started optimistic. Add an actualTime field to my Prisma schema, create a PomodoroTimer component, slap a tomato button on scheduled tasks. How hard could it be?
// Added to schema
actualTime Int? @default(0)
Built a basic timer component:
- 25-minute countdown (1500 seconds)
- Play/pause buttons
- Shows time in MM:SS format
- Opens in a modal
Hit save. Tested it. It worked!
…for about 30 seconds before I found the first bug.
The Problems Start Rolling In
Problem 1: The Disappearing Timer
Close the modal, reopen it. Timer reset to 25:00.
Oh.
The timer state was living entirely inside the modal component. Close modal = destroy component = lose state. Classic React gotcha.
Problem 2: The Ghost Timer
Start a timer, switch to a different task, click its timer button. Now I have two timers running simultaneously, both ticking away, both thinking they’re the only timer in existence.
Multiple intervals firing. Time being logged to the wrong tasks. Chaos.
Problem 3: The Vanishing Progress
Pause a timer, close the modal without stopping. That progress? Gone. Lost to the void. No time logged.
Not great when you’re trying to track actual work.
Problem 4: The Button Wars
Added the tomato button to scheduled tasks in the timeline. Forgot I already had drag handles on those tasks.
Tried to drag a task = accidentally started a timer.
Tried to start a timer = accidentally dragged the task.
The drag-and-drop library and the click handler were fighting each other. My task cards had become a UX nightmare.
The Refactor: Centralized State Management
After a day of whack-a-mole with bugs, I realized the core issue: timer state was everywhere and nowhere at the same time.
State in the modal component. State in the parent. Intervals scattered across multiple files. No single source of truth.
Time to fix this properly.
Enter Zustand
I was already using Zustand for settings, so I created a dedicated timer store:
// timerStore.ts
interface TimerState {
activeTask: Task | null;
timeLeft: number;
isRunning: boolean;
startTimer: (task: Task) => void;
pauseTimer: () => void;
resumeTimer: () => void;
stopTimer: () => void;
tick: () => void;
}
One store. One source of truth. One interval to rule them all.
The Logic Flow
- Click tomato โ Check if a timer exists for this task
- If yes, just open the modal (don’t reset!)
- If no, create new timer with fresh 25:00
- If different task, warn user (or auto-stop previous timer)
const handleStartTimer = (task: Task) => {
// Don't reset timer if already exists for this task
if (activeTimer && activeTimer.task.id === task.id) {
setShowTimerModal(true);
return;
}
// Starting timer for a different task
setActiveTimer({
task,
timeLeft: 1500,
isRunning: false,
totalTimeSpent: 0,
});
setShowTimerModal(true);
};
The Background Timer Challenge
Next problem: the timer needs to keep running when the modal is closed. Users shouldn’t have to keep the modal open for 25 minutes.
The Floating Timer Button
Created a small indicator that appears when a timer is active:
{activeTimer && (
<button
onClick={() => setShowTimerModal(true)}
className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-full"
>
๐
{formatTime(activeTimer.timeLeft)}
</button>
)}
Now you can see the countdown even with the modal closed. Click it to reopen and pause/resume.
The Interval Management
This is where it got tricky. The interval needs to:
- Run in the background even when modal is closed
- Stop properly when timer completes
- Clean up when component unmounts
- Update the floating button in real-time
useEffect(() => {
if (activeTimer && activeTimer.isRunning && activeTimer.timeLeft > 0) {
const intervalId = setInterval(() => {
setActiveTimer(prev => {
if (!prev) return null;
const newTimeLeft = prev.timeLeft - 1;
// Timer finished!
if (newTimeLeft <= 0) {
const minutesSpent = Math.ceil((1500 + prev.totalTimeSpent) / 60);
handleTimerComplete(prev.task.id, minutesSpent);
return null;
}
return { ...prev, timeLeft: newTimeLeft };
});
}, 1000);
return () => clearInterval(intervalId);
}
}, [activeTimer?.isRunning, activeTimer?.timeLeft]);
The dependencies on this useEffect are critical. If you miss activeTimer?.isRunning, the interval won’t restart properly after a pause. If you miss activeTimer?.timeLeft, you get stale closures.
Ask me how I know.
The Backend Bug
Got everything working in the UI. Time to test saving actualTime to the database.
Hit “Stop & Save Progress.” Check the database. actualTime is still 0.
What.
Debug time. Console logs everywhere. Request is being sent. Response looks good. But database isn’t updating.
Checked the network tab. Payload includes actualTime: 15. Server responds 200 OK.
…wait, let me check the backend route.
// server/src/routes/tasks.ts
router.patch('/:id', async (req, res) => {
const { title, timeEstimate, date, plannedTime } = req.body;
// ^^^
// WHERE'S actualTime?!
});
THERE IT IS.
The PATCH endpoint was accepting actualTime in the request, storing it in memory, responding with it… but never actually writing it to the database because it wasn’t in the destructured body.
One line fix:
const { title, timeEstimate, date, plannedTime, actualTime } = req.body;
And suddenly, everything worked.
The “Why Isn’t It Pausing?” Bug
Thought I was done. Then I tested the full flow:
- Start timer โ
- Let it run for a bit โ
- Pause โ
- Close modal โ
- Reopen modal… timer is running again? โ
The pause state wasn’t persisting when I closed the modal.
The Subtle Bug
My pauseTimer function was doing too much:
pauseTimer: () => {
const timeElapsed = 1500 - timeLeft;
set({
isRunning: false,
totalTimeSpent: totalTimeSpent + timeElapsed,
timeLeft: 1500, // โ RESET TO 25:00?!
});
}
I was resetting timeLeft to 1500 on every pause. The idea was to track “time spent across sessions,” but it broke the basic pause/resume flow.
User pressed pause at 15:23? Should stay at 15:23. Not reset to 25:00.
Fixed version:
pauseTimer: () => {
set({ isRunning: false });
// That's it. Just pause. Don't touch timeLeft.
}
Sometimes the best fix is deleting code.
The Drag Handle Solution
Remember the button wars? Timer button fighting with drag handles?
Solution: move the drag handle to a more specific target.
// Instead of the entire task card being draggable:
<div {...listeners} {...attributes}>
{/* Entire task card */}
</div>
// Make only a specific handle draggable:
<div className="task-card">
<div {...listeners} {...attributes} className="drag-handle">
โฎโฎ
</div>
<button onClick={() => startTimer(task)}>๐
</button>
</div>
Now you can drag the task by grabbing the handle, and start the timer by clicking the tomato. No more conflicts.
The Daily Summary Update
With actualTime tracking working, I updated the daily summary to show reality vs. estimates:
const totalMinutes = tasks.reduce((sum, task) =>
sum + (task.timeEstimate || 0), 0
);
const actualMinutes = tasks.reduce((sum, task) =>
sum + (task.actualTime || 0), 0
);
Now the summary shows:
- Total Time (estimated): 4h 30m
- Actual Time (from Pomodoros): 3h 45m
That purple “Actual Time” number hits different when you realize you estimated 4.5 hours but only worked 3.75.
What I Learned
1. State Management Is Non-Trivial
“Just add a timer” quickly becomes “completely refactor your state architecture.”
Centralizing state early would have saved me two days of debugging.
2. Background Timers Are Tricky
Intervals, cleanup functions, stale closures, re-renders affecting timing… there’s a reason timer libraries exist.
Getting this right requires thinking through every edge case:
- What if the user closes the tab?
- What if they pause and immediately resume?
- What if they start a timer, then schedule the task for a different time?
3. Pausing Should Just Pause
When I wrote pauseTimer to do time calculations and resets, I was overthinking it.
Pause means “stop running.” Nothing more. The simpler the function, the fewer bugs.
4. Backend Validation Matters
I was so focused on the frontend timer logic, I forgot to double-check the backend was actually saving everything.
Always verify the full request/response cycle. Console.log is your friend.
5. Test The Unhappy Paths
I tested “start timer, let it complete” a hundred times.
Know what I didn’t test enough? Pausing midway through, closing the modal, switching tasks, and reopening.
The bugs live in the paths you don’t test.
The Final Implementation
After all the refactoring, the flow is clean:
- Click tomato on a task โ Opens modal with 25:00 countdown
- Click Start โ Timer begins, modal can be closed
- Floating button shows โ Live countdown visible anywhere
- Pause anytime โ Timer stops but remembers position
- Close modal โ Timer keeps running in background
- Timer completes โ Logs actual time to database
- Daily summary updates โ Shows estimated vs actual time
It works. Finally.
Next Up
The Pomodoro timer works. Could I add break reminders, multiple durations, sound notifications? Sure. But that’s polish for later.
Right now, I can only plan one day at a time. I need to see the whole week. Plan Monday while looking at what’s coming Tuesday. Move tasks around without switching between day views.
Week view is next. Time to build the 7-day planning interface that makes this actually useful.
The timer features (breaks, notifications, history) are going in the backlog. They’ll be great additions eventually, but they’re not what’s blocking me from using this app daily.
Ironically, I didn’t use the Pomodoro timer while building the Pomodoro timer. Too meta.
Part 3 Complete. Part 4 will cover building the week view – drag tasks across days, see patterns, plan ahead.
Stay tuned for more adventures in overengineering task management! ๐



Leave a comment