• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
ozanatgreenpt

ozanatgreenpt

thirdTimer

This version stores some data, so you can restart the app!
Public
Like
thirdTimer
Home
Code
13
.claude
1
.cursor
1
docs
3
src
3
starter-template
6
.cursorrules
.vtignore
AGENTS.md
README.md
article.md
biome.json
deno.json
knowledge.md
Environment variables
1
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
docs
/
timer-state-and-intents.md
Code
/
docs
/
timer-state-and-intents.md
Search
3/1/2026
Viewing readonly version of main branch: v745
View latest version
timer-state-and-intents.md

Timer State & Intents

Developer reference for the TimerState shape, state transitions, and session intent lifecycle.

TimerState shape

Defined in src/shared/types.ts:

FieldTypeDescription
mode"idle" | "working" | "breaking"Current timer mode
workStartTimenumber | nullEpoch ms when the current work block began; null when not working
breakStartTimenumber | nullEpoch ms when the current break block began; null when not breaking
savedBreakMinutesnumberAccumulated unused break time (minutes). Grows when work ends, shrinks when break time is consumed
breakFractionnumberRatio of break-to-work (default 1/3). Persists across resets
totalWorkTimenumberCumulative work minutes finalized at mode transitions
totalBreakTimenumberCumulative break minutes finalized at mode transitions
sessionStartedAtnumber | nullEpoch ms when the session first started (set on the first startWork from idle); used as the upsert key for session history

State transitions

All transition functions live in src/frontend/utils/timer-actions.ts. Each returns a TimerActionResult containing the new state and an optional notification.

ActionValid fromMode afterKey field changes
startWorkidle, breakingworkingworkStartTime = now, breakStartTime = null, sessionStartedAt = now (only if was idle)
takeBreakworkingbreakingEarned break = workDuration * breakFraction; savedBreakMinutes += earned; totalWorkTime += workDuration; breakStartTime = now; workStartTime = null
resumeWorkbreakingworkingConsumed break = min(breakDuration, savedBreakMinutes); totalBreakTime += consumed; savedBreakMinutes -= breakDuration (floored at 0); workStartTime = now; breakStartTime = null
handleInterruptionworkingbreakingSame accumulation as takeBreak (earned break added to savedBreakMinutes, totalWorkTime updated). Represents an unplanned break
takeBigBreakworking, breakingbreakingIf was working: totalWorkTime += workDuration. savedBreakMinutes = 0 (all saved break consumed); breakStartTime = now; workStartTime = null
resetSessionanyidleAll timing fields zeroed: workStartTime = null, breakStartTime = null, savedBreakMinutes = 0, totalWorkTime = 0, totalBreakTime = 0, sessionStartedAt = null. breakFraction is preserved

Time accumulation rule

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:

  • TimerContainer stats calculation (src/frontend/components/TimerContainer.tsx, stats useMemo)
  • Backend live upsert (src/backend/index.ts, POST /api/state)
  • Frontend reset/rollover (src/frontend/index.tsx, resetSession and day-rollover useEffect)

Intent lifecycle

A session intent is a short user-entered string describing what they plan to work on. It is stored in localStorage, not in TimerState.

Storage

  • Key: timer_session_intent in localStorage
  • Counter: timer_breaks_since_intent tracks how many breaks have occurred since the intent was set
  • Functions: getSessionIntent, setSessionIntent, getBreaksSinceIntent, setBreaksSinceIntent, incrementBreaksSinceIntent in src/frontend/utils/passphrase.ts

Intent changes per user action

User actionEffect on intentbreaksSinceIntent
startWorkSet to input value (or removed if blank)Reset to 0
takeBreakUnchangedIncremented by 1
resumeWorkUnchangedUnchanged
handleInterruptionCleared (null)Reset to 0
takeBigBreakCleared (null)Reset to 0
Clear intent buttonCleared (null)Reset to 0
Submit intent while workingSet to input valueReset to 0
resetSessionNot explicitly clearedNot 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.

Intent in session history

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.

Event persistence

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.

Architecture

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

Flush triggers

TriggerMechanismNotes
Timer (3 s)setTimeout scheduled on first logEvent() after the queue was emptyResets after each flush
Tab hiddenvisibilitychange → sendBeaconCatches mobile tab switches and desktop alt-tabs
Page unloadbeforeunload → sendBeaconLast-chance flush before the page is destroyed
Flush failureRe-queues events and schedules a new 3 s timerRetries automatically

Lifecycle

  1. initEventFlushing() is called once at app startup (before React renders). It registers beforeunload and visibilitychange listeners.
  2. Each logEvent() call pushes an event onto the in-memory queue and schedules a flush timer if one isn't already running.
  3. When the timer fires (or a visibility/unload event occurs), all queued events are drained and sent to the backend in a single POST.
  4. If the POST fails (network error or non-200 response), events are re-queued and a new flush is scheduled.
  5. sendBeacon is used for unload/visibility flushes because it survives page teardown. If sendBeacon returns false (browser rejected), events are re-queued (though the page may be closing).

Backend endpoints

EndpointAuthBody fields
POST /api/eventsAnonymoususerId, events, passphrase
POST /api/me/eventsAuthenticatedevents, passphrase

Both delegate to saveEvents() in src/backend/database/queries.ts, which inserts each event into thirdtime_event_log.

Design rationale

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 sessionIntent sent 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.

Key source files

  • src/frontend/utils/event-log.ts — event queue, auto-flush timer, sendBeacon handlers, initEventFlushing()

  • src/shared/types.ts — TimerState, SessionStats, GroupMemberState, TimerEvent type 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 and breaksSinceIntent localStorage helpers

  • src/frontend/utils/state-persistence.ts — loadState, saveState, debouncedSaveState (state only, no events)

  • src/backend/database/queries.ts — saveEvents, getUserEvents, getGroupEvents

FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
AboutAlternativesPricingBlogNewsletterCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.