Developer reference for the TimerState shape, state transitions, and session intent lifecycle.
Defined in src/shared/types.ts:
| Field | Type | Description |
|---|---|---|
mode | "idle" | "working" | "breaking" | Current timer mode |
workStartTime | number | null | Epoch ms when the current work block began; null when not working |
breakStartTime | number | null | Epoch ms when the current break block began; null when not breaking |
savedBreakMinutes | number | Accumulated unused break time (minutes). Grows when work ends, shrinks when break time is consumed |
breakFraction | number | Ratio of break-to-work (default 1/3). Persists across resets |
totalWorkTime | number | Cumulative work minutes finalized at mode transitions |
totalBreakTime | number | Cumulative break minutes finalized at mode transitions |
sessionStartedAt | number | null | Epoch ms when the session first started (set on the first startWork from idle); used as the upsert key for session history |
All transition functions live in src/frontend/utils/timer-actions.ts. Each returns a TimerActionResult containing the new state and an optional notification.
| Action | Valid from | Mode after | Key field changes |
|---|---|---|---|
startWork | idle, breaking | working | workStartTime = now, breakStartTime = null, sessionStartedAt = now (only if was idle) |
takeBreak | working | breaking | Earned break = workDuration * breakFraction; savedBreakMinutes += earned; totalWorkTime += workDuration; breakStartTime = now; workStartTime = null |
resumeWork | breaking | working | Consumed break = min(breakDuration, savedBreakMinutes); totalBreakTime += consumed; savedBreakMinutes -= breakDuration (floored at 0); workStartTime = now; breakStartTime = null |
handleInterruption | working | breaking | Same accumulation as takeBreak (earned break added to savedBreakMinutes, totalWorkTime updated). Represents an unplanned break |
takeBigBreak | working, breaking | breaking | If was working: totalWorkTime += workDuration. savedBreakMinutes = 0 (all saved break consumed); breakStartTime = now; workStartTime = null |
resetSession | any | idle | All timing fields zeroed: workStartTime = null, breakStartTime = null, savedBreakMinutes = 0, totalWorkTime = 0, totalBreakTime = 0, sessionStartedAt = null. breakFraction is preserved |
totalWorkTime and totalBreakTime only grow at mode transitions (when an action function is called). They never grow continuously.
While the timer is running, the in-progress block (time since workStartTime or breakStartTime) is not reflected in totalWorkTime/totalBreakTime within the state object. UI components and session-history writers compute this on the fly:
currentWorkTime = totalWorkTime + (now - workStartTime) / 60000 // if mode === "working"
currentBreakTime = totalBreakTime + min((now - breakStartTime) / 60000, savedBreakMinutes) // if mode === "breaking"
The break cap (min(..., savedBreakMinutes)) ensures break time only counts up to the amount that was earned.
This pattern appears in three places:
TimerContainerstats calculation (src/frontend/components/TimerContainer.tsx,statsuseMemo)- Backend live upsert (
src/backend/index.ts,POST /api/state) - Frontend reset/rollover (
src/frontend/index.tsx,resetSessionand day-rolloveruseEffect)
A session intent is a short user-entered string describing what they plan to work on. It is stored in localStorage, not in TimerState.
- Key:
timer_session_intentin localStorage - Counter:
timer_breaks_since_intenttracks how many breaks have occurred since the intent was set - Functions:
getSessionIntent,setSessionIntent,getBreaksSinceIntent,setBreaksSinceIntent,incrementBreaksSinceIntentinsrc/frontend/utils/passphrase.ts
| User action | Effect on intent | breaksSinceIntent |
|---|---|---|
startWork | Set to input value (or removed if blank) | Reset to 0 |
takeBreak | Unchanged | Incremented by 1 |
resumeWork | Unchanged | Unchanged |
handleInterruption | Cleared (null) | Reset to 0 |
takeBigBreak | Cleared (null) | Reset to 0 |
| Clear intent button | Cleared (null) | Reset to 0 |
| Submit intent while working | Set to input value | Reset to 0 |
resetSession | Not explicitly cleared | Not explicitly reset |
After handleInterruption and takeBigBreak, the intent input auto-focuses so the user can set a new intent before resuming work.
The intent is not explicitly cleared on resetSession or day rollover. It persists in localStorage until the next startWork overwrites it or the user clears it manually. In practice, every new work session begins with startWork, which always writes the intent.
When session history is written (via POST /api/state or POST /api/history), the current sessionIntent from localStorage is included. This means the history record reflects whatever intent was active at write time, not necessarily the intent that was set at session start.
Timer events (work_started, intent_set, break_started, etc.) are persisted independently from timer state. They flow through a dedicated pipeline that is decoupled from state saves.
logEvent() → in-memory eventQueue[]
↓
auto-flush (3 s timer) OR beforeunload / visibilitychange
↓ ↓
fetch POST navigator.sendBeacon
↓ ↓
POST /api/events (anonymous) or POST /api/me/events (authenticated)
↓
saveEvents() → thirdtime_event_log table
| Trigger | Mechanism | Notes |
|---|---|---|
| Timer (3 s) | setTimeout scheduled on first logEvent() after the queue was empty | Resets after each flush |
| Tab hidden | visibilitychange → sendBeacon | Catches mobile tab switches and desktop alt-tabs |
| Page unload | beforeunload → sendBeacon | Last-chance flush before the page is destroyed |
| Flush failure | Re-queues events and schedules a new 3 s timer | Retries automatically |
initEventFlushing()is called once at app startup (before React renders). It registersbeforeunloadandvisibilitychangelisteners.- Each
logEvent()call pushes an event onto the in-memory queue and schedules a flush timer if one isn't already running. - When the timer fires (or a visibility/unload event occurs), all queued events are drained and sent to the backend in a single POST.
- If the POST fails (network error or non-200 response), events are re-queued and a new flush is scheduled.
sendBeaconis used for unload/visibility flushes because it survives page teardown. IfsendBeaconreturnsfalse(browser rejected), events are re-queued (though the page may be closing).
| Endpoint | Auth | Body fields |
|---|---|---|
POST /api/events | Anonymous | userId, events, passphrase |
POST /api/me/events | Authenticated | events, passphrase |
Both delegate to saveEvents() in src/backend/database/queries.ts, which inserts each event into thirdtime_event_log.
Previously, events piggybacked on state saves — they were bundled into the POST /api/state body and flushed whenever debouncedSaveState fired. This coupling caused several problems:
- Lost events on tab close: The 500 ms debounce meant events could be lost if the user closed the tab before the save fired.
- Delayed intent events: Actions that didn't change
TimerState(e.g., clearing an intent with no active session) never triggered a state save, so their events sat in the queue indefinitely. - Stale context: The
sessionIntentsent alongside events reflected the value at flush time, not at event time.
The decoupled model gives events their own persistence path with shorter flush intervals, unload safety via sendBeacon, and automatic retries.
-
src/frontend/utils/event-log.ts— event queue, auto-flush timer,sendBeaconhandlers,initEventFlushing() -
src/shared/types.ts—TimerState,SessionStats,GroupMemberState,TimerEventtype definitions -
src/frontend/utils/timer-actions.ts— all state transition functions -
src/frontend/components/TimerContainer.tsx— wires actions to UI, computes live stats -
src/frontend/utils/passphrase.ts— intent andbreaksSinceIntentlocalStorage helpers -
src/frontend/utils/state-persistence.ts—loadState,saveState,debouncedSaveState(state only, no events) -
src/backend/database/queries.ts—saveEvents,getUserEvents,getGroupEvents