Skip to main content
← Blog

When the Focus Bar Started Counting 25 Minutes

VauDium ·

Decisions made while putting a proper Pomodoro on an existing strip — why I kept the canonical cycle, why I didn't use setInterval, and why the timer has to dissolve when focus does.

When the Focus Bar Started Counting 25 Minutes

Fecit already had a focus bar. When the user picks “I’m focusing on this,” a small PRIMARY-colored strip appears above the task list, and the chosen task sits in its own slot below. There was no time dimension to it — it just marked which task was picked.

Today I added an hourglass icon to that strip. Tap it, and a proper Pomodoro starts running.

Keeping the Canonical Cycle

The first decision was which Pomodoro to build.

Three options. (1) A single 25-minute session — tap once, 25 minutes pass, done. (2) One round of 25/5. (3) 25/5 × 4 + a 15-minute long break, Cirillo’s original cycle. Simpler is easier to build, but it also chips away at what 30 years of “Pomodoro” has loaded onto the word. If the user taps something called Pomodoro, are they expecting “this runs for 25 minutes” or “this runs a full cycle”? I read the latter as more natural.

Went with the third. work 25 / shortBreak 5 / on the fourth, longBreak 15. Auto-advance. The user only taps Start.

It’s also a question of where to put the default. A lot of apps show a slider for custom minute lengths, but that means more to decide. The canonical cycle is a 30-year-tested default. There’s no reason to make the user touch it.

Don’t Subtract One Per Second

The common Pomodoro implementation is setInterval(1000) ticking down by one every second. It works, but it has two weak spots — it stalls (or drifts) when the app goes to the background, and it gets fuzzy about what to do with the time the user was away.

Instead I store one number, startedAt, and compute remaining time from the current clock on every render. The 1-second nowSecondAtom already runs at the app root, so I just piggyback. No new setInterval.

const elapsed = paused
    ? elapsedBeforePauseMs
    : elapsedBeforePauseMs + (Date.now() - startedAt);
const remaining = phaseDuration - elapsed;

Phase transition is checked in a separate effect each tick — whether remaining <= 0.

Jump All the Way When You Come Back

Going timestamp-based pulled along a natural follow-up. What happens if the user is away for 30 minutes and comes back?

In cycle terms, that should have been work 25 → shortBreak 5 → 5 more minutes of the next work. The simplest implementation is to advance one phase per tick — which means on return, the timer catches up one phase per second, awkwardly.

I tightened it into a small loop. advanceAll. Inside one tick, if remaining < 0, transition to the next phase, and set its start to the moment the previous phase ended. Overflow time flows naturally into the next phase’s elapsed. With a safety bound of 16 jumps (one set is 8 phases plus margin), one effect run can take you all the way.

On return, only the final transition fires its toast and haptic. The user isn’t bombed with seven missed phase notifications.

When Focus Dissolves, So Should the Timer

There’s one more reason putting the Pomodoro UI inside the focus bar turned out right — it surfaced a coupling I wouldn’t have seen otherwise.

The focus bar disappears when the user removes focus (the focus slot itself stops rendering). But the Pomodoro state lives in a separate atom, so it’s possible for the timer to keep running while the focus is gone — a ghost state. The user can’t see the countdown, the entry point (the hourglass icon) is gone, and yet 25 minutes later a beep and a toast fire from somewhere they can’t trace.

I mounted a small hook at the root, usePomodoroFocusGuard. If achiever.focusedTaskRecordId is empty, set pomodoroAtom to null, close the modal. Simple. Without it, the app would occasionally beep at the user for no obvious reason.

It composes nicely with the auto-unfocus option, too. When the focused task is completed (autoUnfocusOnComplete = true by default), focus drops, and the Pomodoro stops with it. One intent — this task is done — gets reflected on two surfaces at once.

Haptics on Mobile, Beep on Desktop

I had to pick how to announce phase transitions and set completion.

Desktop was simple. A Web Audio sine wave at 880Hz, three beeps. No external audio file needed.

Mobile is more layered. The honest choice would have been to schedule local notifications via expo-notifications — those fire even when the app is backgrounded or the device is locked. But that needs permission, and rejected permissions need a fallback, and “asking for notifications just to do one Pomodoro?” carries friction.

I went small. Foreground only, Haptics.NotificationFeedbackType.Success + a toast. Doesn’t fire when the app is backgrounded. That actually matches how Pomodoro is used — you’re sitting with the work, looking at the screen.

The countdown stays visible in mm:ss on the focus bar even with the modal closed. The user doesn’t lose track of where they are, even if the announcement signal is quiet.

The Star Should Dance Differently

This isn’t directly about Pomodoro, but it came up alongside.

When a new task is created, its star bounces (playHop — pops up 4px with a spring). Once you settle on a motion, you want to reuse it elsewhere — and memos started getting the new-item indicator around the same time. But if the memo star bounces identically, you can’t tell tasks and memos apart at a glance.

I added one more function — swirl. playSwirl: a 360° rotation + 1.18 scale pop, 500ms. Bounce reads as “leaping up,” swirl reads as “spinning into place.” Memos are a quieter signal than tasks (a task is a unit of action, a memo is a unit of thought), so rotation suits them better.

Tasks = bounce, memos = swirl, templates = dignified (a more solemn rotation). Three different expressions in the same library.

The Hourglass Disappears, but the Progress Stays

The hourglass at the right end of the focus bar is only visible when the Pomodoro isn’t running. When the user starts it, that same spot transitions into the mm:ss countdown and the phase icon (hammer or coffee). It doesn’t vanish; it changes form. Same spot, so the user never asks “where did it go?”

The phase icons had their own decision path. I tried a pencil for work, switched to a hammer, then tried hand-rolling the hammer SVG and failed at small sizes, then brought in the Lucide hammer (MIT, 24×24, stroke 2px). At 14px, stroke-only icons almost always lose to a vetted library shape — the author’s intent matters less than the silhouette being immediately readable.

A centered modal feels right on desktop, but most modals on mobile follow the bottom-sheet pattern. PomodoroModal first launched as a centered RN <Modal>, then got swapped to BottomSheetModal for tonal consistency with other modals.

Along the way I caught a small iOS issue — a dim layer mounted via Portal would get hidden behind a transparent <Modal>. The iOS RN Modal lives in its own window, so portal content sits behind it. Fix: integrate the dim into the outer View inside the Modal. Drops the portal dependency and gets simpler.


The focus bar used to be a signal that said “I’ve picked this task.” Adding 25 minutes turns it into “I’ve promised this task 25 minutes.” The signal got one layer thicker — a small change, but one that makes the promise to oneself more visible.

Even a five-minute task, if it can’t settle in within 25 minutes, lets you know you broke a promise to yourself. That, I think, is the real effect of a 25-minute cycle.