Timer System Documentation
Guide to the server-authoritative timer system — state machine, time calculation model, WebSocket events, and client sync behavior.
Overview
The Timer System in Oter uses a server-authoritative, event-based model where time is derived from timestamps rather than stored ticks. This ensures consistency across devices and handles network issues gracefully.
Architecture
Server-Authoritative Model
The server is the single source of truth for timer state. All timer actions (start, pause, resume, stop) are processed on the server using server time, and clients receive updates via WebSocket events.
Data Model
Timer {
id: UUID
name: String
duration: Long // Total planned duration in seconds
state: TimerState // IDLE, RUNNING, PAUSED, STOPPED, COMPLETED
startTime: DateTime? // Server time when last started
pauseTime: DateTime? // Server time when last paused (if any)
accumulatedPausedMs: Long // Total time paused so far (milliseconds)
updatedAt: DateTime // For optimistic concurrency / conflict detection
// ... other fields (listId, order, enabled, etc.)
}Timer State Machine
Timer lifecycle managed server-side; all state transitions are authoritative on the server and broadcast to clients via WebSocket.
Time Calculation Logic
Time is calculated from timestamps, not stored ticks:
For RUNNING timers:
elapsed = (nowServer - startedAt) - accumulatedPausedMs
remaining = max(durationMs - elapsed, 0)For PAUSED timers:
elapsed = (pausedAt - startedAt) - accumulatedPausedMs
remaining = max(durationMs - elapsed, 0)Key Points:
startTimeis set when timer starts and never reset (even on resume)accumulatedPausedMstracks total pause time across all pause/resume cycles- When pausing:
pauseTimeis set, but accumulated pause is calculated on resume - When resuming: pause duration
(now - pauseTime)is added toaccumulatedPausedMs
API Endpoints
Timer Control
All timer control endpoints require authentication and return the updated timer(s).
POST /api/v1/timers/control/{listId}/start
Start a timer in the specified list.
Query Parameters:
timerId(UUID, optional): Specific timer to start. If omitted, starts the first enabled timer.
Response: 200 OK with List<Timer>
POST /api/v1/timers/control/{listId}/pause
Pause the currently running timer(s) in the list.
Query Parameters:
timerId(UUID, optional): Specific timer to pause. If omitted, pauses all running timers.
Response: 200 OK with List<Timer>
POST /api/v1/timers/control/{listId}/resume
Resume the paused timer in the list.
Response: 200 OK with List<Timer>
POST /api/v1/timers/control/{listId}/stop
Stop the running/paused timer(s) in the list.
Query Parameters:
timerId(UUID, optional): Specific timer to stop. If omitted, stops all active timers.
Response: 200 OK with List<Timer>
POST /api/v1/timers/control/{listId}/restart
Restart timer(s) from the beginning.
Query Parameters:
timerId(UUID, optional): Specific timer to restart. If omitted, restarts all timers in the list.
Response: 200 OK with List<Timer>
WebSocket Communication
WebSocket Event Flow
Client controls timers via HTTP; real-time state changes arrive via WebSocket broadcasts from the server.
Connection
Endpoint: ws://host:port/api/v1/timers/notifications
Authentication: Bearer token in headers (same as HTTP endpoints)
Message Types
Client → Server
Ping (for time synchronization):
{
"type": "Ping",
"clientTime": 1234567890123
}SubscribeTimers (optional, implicit for user’s own timers):
{
"type": "SubscribeTimers",
"scope": "user",
"userId": "123"
}TimerUpdate (legacy, deprecated):
{
"type": "TimerUpdate",
"listId": "uuid",
"timer": { ... },
"remainingSeconds": 60
}Server → Client
TimerUpdate (state change notification):
{
"type": "TimerUpdate",
"listId": "uuid",
"timer": {
"id": "uuid",
"name": "Timer Name",
"duration": 300,
"state": "RUNNING",
"remainingSeconds": 245,
...
},
"remainingTime": 245
}Pong (time synchronization response):
{
"type": "Pong",
"serverTime": 1234567890123,
"clientTime": 1234567890122
}AgendaRefresh (dashboard next task/habit / card status may have changed — refetch GET /api/v1/dashboard):
{
"type": "AgendaRefresh",
"reason": "TASK_COMPLETED"
}reason is a server-defined hint, e.g. TASK_CREATED, TASK_UPDATED, TASK_DELETED, TASK_COMPLETED, TASK_UNCOMPLETED, HABIT_CREATED, HABIT_UPDATED, HABIT_DELETED, HABIT_COMPLETED, HABIT_UNCOMPLETED.
Time Synchronization
Clients maintain a serverTimeOffset calculated from ping/pong messages:
- Client sends
PingwithclientTimeevery 15 seconds - Server responds with
PongcontainingserverTimeand echoedclientTime - Client calculates:
serverTimeOffset = serverTime - clientTime - Client uses
(Date.now() + serverTimeOffset)as approximate server time for UI calculations
Note: This offset is used for UI display only. All authoritative calculations happen on the server.
Client Behavior
On Load / Screen Open
- Fetch timer lists via
GET /api/v1/timers/lists - Connect to WebSocket
- For each timer, compute remaining time from server state:
- Use
timer.remainingSecondsfrom server response - Or calculate locally:
remaining = duration - elapsedusing server timestamps
- Use
Local UI Update Loop
- Update displayed remaining time every 1 second (not per-tick)
- Do NOT send network requests on each tick
- Use server-provided
remainingSecondsas the base
Reacting to WebSocket Events
When TimerUpdate is received:
- Update local timer state
- Recompute remaining time from server state
- Sync
TimerPlaybackManagerwith server state - Re-render UI
Offline / Flaky Network
If connection drops:
- Continue running local UI timer based on last known state
- Optionally mark timer as “syncing…” in UI
- When network returns, server state will be authoritative
For actions done offline:
- Queue user actions with local timestamps (future enhancement)
- When network returns, send queued actions to server
- Server reconciles and sends back authoritative timer state
Server-Side Timer Completion
A background worker (TimerCheckerService) runs every 30 seconds to:
- Find all timers with
state = RUNNING - Use
TimerTimeCalculator.shouldComplete()to check if remaining <= 0 - Set timer to
COMPLETED - Emit
TimerUpdateWebSocket event - Send push notification (if enabled)
- Optionally start next timer in sequence (if list has loop enabled)
Note: Timer completion is server-authoritative, so timers will complete even if no client is connected.
Migration from Old Model
Database Changes
The following fields were added to the timers table:
accumulated_paused_ms(BIGINT, default 0)updated_at(TIMESTAMP, default now)
Migration:
- Existing timers get
accumulated_paused_ms = 0 updated_atis set to current timestamp for all existing timers
Backward Compatibility
- Existing APIs remain functional
- Old WebSocket messages are still accepted (but deprecated)
- Client can gradually migrate to new HTTP endpoints
Testing
Time Calculation Tests
Test cases cover:
- Running timer: elapsed time increases correctly
- Paused timer: elapsed time is frozen
- Multiple pause/resume cycles: accumulated pause time is correct
- Timer completion: remaining time reaches 0
- Edge cases: negative values, long pauses, timezone handling
Concurrency Tests
Test cases cover:
- Two clients trying to pause/resume the same timer
- Server completing timer while client is paused
- Network delays and out-of-order messages
WebSocket Tests
Test cases cover:
- Client starting timer, another client receiving update
- Ping/pong time synchronization
- Connection drops and reconnection
Implementation Details
Server Components
- TimerService: Business logic for timer operations
- TimerTimeCalculator: Helper for time calculations
- TimerNotifier: WebSocket broadcasting
- TimerCheckerService: Background worker for completion
Client Components
- TimerService (shared): HTTP client for API calls
- TimerWebSocketClient: WebSocket connection and message handling
- TimerPlaybackManager: Local UI state management
- TimersViewModel: State management and coordination
Key Files
Server:
server/src/main/kotlin/com/esteban/ruano/database/entities/Timers.ktserver/src/main/kotlin/com/esteban/ruano/service/TimerService.ktserver/src/main/kotlin/com/esteban/ruano/service/TimerTimeCalculator.ktserver/src/main/kotlin/com/esteban/ruano/service/TimerNotifier.ktserver/src/main/kotlin/com/esteban/ruano/routing/TimerRouting.kt
Shared:
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/Timer.ktshared/src/commonMain/kotlin/com/esteban/ruano/oter/timer/TimerWebSocketServerMessage.ktshared/src/commonMain/kotlin/com/esteban/ruano/oter/timer/TimerWebSocketClientMessage.kt
Client:
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/viewmodels/TimersViewModel.ktshared/src/commonMain/kotlin/com/esteban/ruano/oter/timer/TimerPlaybackManager.kt
Best Practices
- Always use server time for authoritative calculations
- Update UI optimistically but sync with server on WebSocket events
- Handle network failures gracefully - continue UI updates, mark as syncing
- Use WebSocket for state changes, not for per-second updates
- Calculate remaining time locally from server state for smooth UI
- Respect
updatedAtfor conflict detection (future enhancement)
Troubleshooting
Timer not completing
- Check
TimerCheckerServiceis running - Verify
startTimeis set correctly - Check time calculation logic in
TimerTimeCalculator
Time drift between devices
- Ensure ping/pong is working (check WebSocket logs)
- Verify
serverTimeOffsetis being updated - Check server time is accurate
Timer state out of sync
- Check WebSocket connection status
- Verify
TimerUpdateevents are being received - Check server logs for broadcast errors
Pause/resume not working correctly
- Verify
accumulatedPausedMsis being updated on resume - Check
startTimeis not being reset - Verify pause duration calculation