Skip to Content
FeaturesTimersFeature guide

Timers Feature Guide

Guide to the Timers module — a server-authoritative, event-based timer system with timer lists, standalone timers, pomodoro tracking, and WebSocket-driven state sync.

Overview

The Timers module lets users group timers into timer lists (optionally looping and pomodoro-grouped) or run standalone timers that are not attached to a list. The server is the single source of truth: it stores planned duration plus the timestamps (startTime, pauseTime, accumulatedPausedMs) needed to derive elapsed/remaining time, rather than persisting per-second ticks. Clients control timers over HTTP and receive authoritative state changes over a WebSocket. A background worker completes timers even when no client is connected. The local UI runs a 1-second tick loop purely for smooth display and re-syncs whenever a server event arrives.

Architecture

Time is never stored as a running count — TimerTimeCalculator derives elapsed/remaining from server timestamps for both live requests and background completion checks.

Data Models

The shared client Timer model (duration in ms, remainingSeconds derived) is what flows over the API. The server entity Timers additionally persists the clock fields used for calculation: startTime, pauseTime, accumulatedPausedMs, updatedAt, plus optional listId, studySessionId, and workSessionId (the latter two back temporary study/work session timers). A TimerList may have a null listId on its timers, which makes those timers standalone.

TimerState Enum (state)

ValueMeaning
IDLECreated but never started
RUNNINGCounting down (server time advancing)
PAUSEDFrozen; pauseTime set, accumulated on resume
STOPPEDHalted/reset (server entity default)
COMPLETEDRemaining reached 0

Connection State (TimerConnectionState)

ValueMeaning
ConnectedWebSocket live; triggers re-sync of running timers
DisconnectedNo WebSocket connection
ReconnectingAttempting to re-establish
Error(message)Connection failed

Local Playback Status (TimerPlaybackStatus)

Used only for the client tick loop in TimerPlaybackManager: Running, Paused, Completed, Stopped.

Time Calculation

TimerTimeCalculator derives time from timestamps (all stored as UTC):

  • RUNNING: elapsed = (nowServer − startTime) − accumulatedPausedMs
  • PAUSED: elapsed = (pauseTime − startTime) − accumulatedPausedMs
  • remaining = max(duration − elapsed, 0)
  • startTime is set on start and never reset on resume; resume adds the pause gap to accumulatedPausedMs.

API Endpoints

All endpoints are mounted under /api/v1/timers and require authentication.

MethodPathKey ParamsDescription
POST/timersbody CreateTimerRequest (timerListId optional)Create a timer in a list, or standalone if timerListId is null
PATCH/timers/{id}body UpdateTimerRequestUpdate a list timer
DELETE/timers/{id}Delete a timer (204 No Content)
GET/timers/standaloneList the user’s standalone timers
POST/timers/standalonebody CreateTimerRequestCreate a standalone timer (201 Created)
PATCH/timers/standalone/{id}body UpdateTimerRequestUpdate a standalone timer
POST/timers/standalone/{id}/startStart; 409 Conflict if another standalone timer is running
POST/timers/standalone/{id}/pauseclientTimestamp?Pause a standalone timer
POST/timers/standalone/{id}/resumeclientTimestamp?Resume a standalone timer
POST/timers/standalone/{id}/stopclientTimestamp?Stop a standalone timer
POST/timers/control/{listId}/starttimerId?Start a timer in a list (first enabled if omitted) → List<Timer>
POST/timers/control/{listId}/pausetimerId?, clientTimestamp?Pause running timer(s) → List<Timer>
POST/timers/control/{listId}/resumeclientTimestamp?Resume the paused timer → List<Timer>
POST/timers/control/{listId}/stoptimerId?, clientTimestamp?Stop active timer(s) → List<Timer>
POST/timers/control/{listId}/restarttimerId?Restart from the beginning → List<Timer>
GET/timers/listsList the user’s timer lists
POST/timers/listsbody CreateTimerListRequestCreate a timer list
GET/timers/lists/{id}Get a timer list with its timers
PATCH/timers/lists/{id}body CreateTimerListRequestUpdate a timer list
DELETE/timers/lists/{id}Delete a timer list (204 No Content)
GET/timers/settingsGet the user’s timer settings
PUT/timers/settingsbody UpdateUserSettingsRequestUpdate defaultTimerListId, dailyPomodoroGoal, notificationsEnabled
WS/timers/notificationstoken? (query) or Authorization: BearerWebSocket for state changes + ping/pong

duration is sent and stored in milliseconds. clientTimestamp is an optional epoch hint for pause/resume/stop accuracy. The WebSocket accepts the JWT either via the Authorization header or a token query parameter (for browser clients that cannot set WS headers).

State Machine

State transitions are authoritative on the server. startTime is preserved across pause/resume cycles; only accumulatedPausedMs grows.

Client Behavior

On Connected, the ViewModel calls syncRunningTimersFromServer() so reconnecting devices immediately reconcile to authoritative state. The tick loop never issues network calls — it only decrements local remainingMillis.

Key Files

FilePurpose
shared/.../models/Timer.ktClient Timer model (duration ms, derived remainingSeconds)
shared/.../models/TimerList.ktTimerList model + indexInEnabledTimers()
shared/.../models/timers/TimerState.ktTimerState enum (IDLE/RUNNING/PAUSED/STOPPED/COMPLETED)
shared/.../timer/TimerPlaybackManager.ktLocal 1s tick loop + TimerPlaybackState/TimerPlaybackStatus
shared/.../timer/TimerConnectionState.ktWebSocket connection state
shared/.../timer/TimerWebSocketServerMessage.ktServer→client WS messages (TimerUpdate, Pong, AgendaRefresh, …)
shared/.../services/timers/TimerService.ktKtor HTTP client for timer endpoints
shared/.../websocket/TimerWebSocketClient.ktWebSocket connection + message handling
timers/timers_domain/.../use_case/TimerUseCases.ktAggregated use cases (control + CRUD + standalone)
timers/timers_domain/.../repository/TimersRepository.ktRepository interface
timers/timers_data/.../repository/TimersRepositoryImpl.ktRepository implementation (token + service calls)
timers/timers_presentation/.../viewmodel/TimerViewModel.ktMVI ViewModel coordinating HTTP + WS + playback
timers/timers_presentation/.../viewmodel/state/TimerState.ktTimerState UI state
timers/timers_presentation/.../ui/intent/TimerIntent.ktTimerIntent / TimerEffect
timers/timers_presentation/.../service/TimerForegroundService.ktAndroid foreground service for running timers
server/.../routing/TimerRouting.ktAll REST + WebSocket endpoints
server/.../service/TimerService.ktServer-side timer business logic
server/.../service/TimerTimeCalculator.ktTimestamp-based elapsed/remaining/shouldComplete
server/.../service/TimerNotifier.ktWebSocket session registry + broadcasting
server/.../service/TimerCheckerService.ktBackground completion + reminder worker
server/.../database/entities/Timers.ktTimers / TimerLists Exposed tables + entities

Testing

  • Unit (server): TimerTimeCalculatorTest — running elapsed growth, paused freeze, multiple pause/resume cycles accumulating correctly, shouldComplete at/after zero, coercion to non-negative values.
  • Timezone: TimerCheckerTimezoneTest — timestamps treated as UTC; no drift across zones.
  • Background completion: TimerCheckerServiceEventDrivenTest — running timers complete and broadcast even with no client connected.
  • List ordering: TimerListIndexInEnabledTimersTestindexInEnabledTimers() skips disabled timers and returns -1 when absent/disabled.
  • WebSocket: ping/pong time sync, one client’s control action propagating to another client, reconnect re-sync via syncRunningTimersFromServer().

Troubleshooting

Timer not completing

  • Confirm TimerCheckerService is running on the server.
  • Verify startTime is set and state == RUNNING.
  • Check TimerTimeCalculator.shouldComplete() — completion is server-authoritative and does not require a connected client.

Pause/resume produces wrong remaining time

  • Verify accumulatedPausedMs grows on each resume (gap = now − pauseTime).
  • Ensure startTime is not being reset on resume — it must persist for the lifetime of the run.

Time drifts between devices

  • Ensure ping/pong is flowing; the client derives serverTimeOffset from Pong.serverTime.
  • The offset is display-only — authoritative time always comes from the server.

Standalone timer returns 409 Conflict on start

  • Only one standalone timer may run at a time; stop or pause the currently running one first (StandaloneTimerConflictException).

Timer state out of sync in the UI

  • Check connectionState; on reconnect the ViewModel re-syncs running timers.
  • Confirm TimerUpdate WS events are received and TimerPlaybackManager.overridePlaybackState() is applied.

WebSocket connection rejected (401)

  • The connection needs a valid JWT either in the Authorization: Bearer header or as a token query parameter; browser clients must use the query parameter.

API Endpoints

All endpoints require JWT authentication and are prefixed with /api/v1. See server/README.md for full reference.

MethodPathDescription
GET/timers/listsList timer lists
POST/timers/listsCreate a timer list
GET/timers/lists/{id}Get timer list by ID
PATCH/timers/lists/{id}Update timer list
DELETE/timers/lists/{id}Delete timer list
POST/timers/control/{listId}/startStart a timer in a list
POST/timers/control/{listId}/pausePause a running timer
POST/timers/control/{listId}/resumeResume a paused timer
POST/timers/control/{listId}/stopStop a running timer
POST/timers/control/{listId}/restartRestart a timer
GET/timers/standaloneList standalone timers
POST/timers/standaloneCreate standalone timer
WebSocket/timers/notificationsReal-time timer notifications
  • Timer system — server-authoritative timer system deep dive (WebSocket message schemas, migration notes)
  • Domain & models — shared domain model reference
  • UI & UX — timer screen layouts and controls
  • Testing & QA — testing strategy