Developer reference for how session history is written and calculated.
Session history records completed (or in-progress) work sessions so users can review past activity grouped by intent or time period.
Defined in src/backend/database/migrations.ts:
CREATE TABLE session_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_token TEXT NOT NULL,
intent TEXT, -- nullable; the user's session intent string
work_time REAL NOT NULL, -- total work minutes
break_time REAL NOT NULL, -- total break minutes
started_at TEXT NOT NULL, -- ISO-8601 timestamp
ended_at TEXT NOT NULL, -- ISO-8601 timestamp
break_fraction REAL NOT NULL -- the break fraction setting at write time
);
UNIQUE INDEX idx_session_history_session ON session_history(owner_token, started_at)
(owner_token, started_at) uniquely identifies a session. The started_at value comes from TimerState.sessionStartedAt (set once on the first startWork from idle). Because sessionStartedAt is stable for the lifetime of a session, repeated writes update the same row via ON CONFLICT ... DO UPDATE.
idx_session_history_owner(owner_token, ended_at DESC) — supports queries that list a user's history in reverse chronological order.Session history is written from three different code paths. All three now include in-progress time (the current work or break block that hasn't been finalized into totalWorkTime/totalBreakTime yet).
Trigger: Every POST /api/state/:userId call — i.e., every debounced state save while the timer is running.
Location: src/backend/index.ts, inside the POST /api/state handler.
How times are calculated:
workTime = state.totalWorkTime + (now - state.workStartTime) / 60000 // if working
breakTime = state.totalBreakTime + min((now - state.breakStartTime) / 60000, state.savedBreakMinutes) // if breaking
Conditions:
ownerToken is present and state.sessionStartedAt != nullworkTime > 0Data flow:
Frontend state change
→ debouncedSaveState() [state-persistence.ts]
→ POST /api/state/:userId [state-persistence.ts → backend]
→ saveSessionHistory() [backend/index.ts → queries.ts]
→ UPSERT session_history [queries.ts]
Trigger: User clicks "Reset Session".
Location: src/frontend/index.tsx, resetSession callback.
How times are calculated:
finalWorkTime = state.totalWorkTime + (now - state.workStartTime) / 60000 // if working
finalBreakTime = state.totalBreakTime + min((now - state.breakStartTime) / 60000, state.savedBreakMinutes) // if breaking
The intent is captured from getSessionIntent() before the state is reset.
Conditions:
finalWorkTime > 0Data flow:
User clicks "Reset Session"
→ resetSession() [index.tsx]
→ archiveSession() [history-api.ts]
→ POST /api/history [history-api.ts → backend]
→ saveSessionHistory() [backend/index.ts → queries.ts]
→ UPSERT session_history [queries.ts]
→ TimerActions.resetSession() [timer-actions.ts → state zeroed]
Trigger: App initializes and detects that the stored timer_user_id is from a previous day.
Location: src/frontend/index.tsx, initialization useEffect.
How times are calculated:
The stale state is fetched from the backend using the old user_id. Then:
workTime = staleState.totalWorkTime + (now - staleState.workStartTime) / 60000 // if was working
breakTime = staleState.totalBreakTime + min((now - staleState.breakStartTime) / 60000, staleState.savedBreakMinutes) // if was breaking
The intent is read from localStorage (getSessionIntent()), which still holds the value from the previous day.
Conditions:
isUserIdValidForToday()staleState.totalWorkTime > 0 or staleState.mode === "working"Data flow:
App mounts
→ useEffect checks timer_user_id age [index.tsx]
→ GET /api/state/:oldUserId [fetch → backend]
→ archiveSession() [history-api.ts]
→ POST /api/history [history-api.ts → backend]
→ saveSessionHistory() [backend/index.ts → queries.ts]
→ UPSERT session_history [queries.ts]
→ localStorage.removeItem("timer_user_id") [index.tsx — forces new ID]
→ loadState() with fresh user_id [state-persistence.ts]
All three write paths use the same pattern to include the current in-progress block:
let workTime = state.totalWorkTime;
let breakTime = state.totalBreakTime;
const now = Date.now();
if (state.mode === "working" && state.workStartTime) {
workTime += (now - state.workStartTime) / 1000 / 60;
}
if (state.mode === "breaking" && state.breakStartTime) {
breakTime += Math.min(
(now - state.breakStartTime) / 1000 / 60,
state.savedBreakMinutes,
);
}
The break cap (min(elapsed, savedBreakMinutes)) ensures that break time beyond the earned allowance is not counted.
This pattern exists because totalWorkTime and totalBreakTime in TimerState only update at mode transitions. Without this addition, closing the browser mid-work or mid-break would lose the current block.
┌──────────────────────┐
│ Frontend (browser) │
└───────┬──────┬────────┘
│ │
┌─────────────────────┘ └──────────────────────┐
│ │
State changes Reset / Day rollover
(every ~500ms) │
│ │
▼ ▼
POST /api/state/:userId POST /api/history
{state, ownerToken, {ownerToken, intent,
sessionIntent, ...} workTime, breakTime,
│ startedAt, endedAt,
│ breakFraction}
▼ │
┌────────────────────┐ │
│ Backend (Hono) │◄───────────────────────────────────────┘
│ │
│ Computes in- │ (live upsert only; reset/rollover
│ progress time │ compute on the frontend)
│ │
└────────┬───────────┘
│
▼
saveSessionHistory()
│
▼
┌────────────────────┐
│ SQLite │
│ session_history │
│ UPSERT on │
│ (owner_token, │
│ started_at) │
└────────────────────┘
src/backend/database/migrations.ts — session_history table and index creationsrc/backend/database/queries.ts — saveSessionHistory, getHistoryByIntent, getHistorySummarysrc/backend/index.ts — POST /api/state (live upsert), POST /api/history (explicit archive)src/frontend/index.tsx — resetSession callback, day-rollover useEffectsrc/frontend/utils/history-api.ts — archiveSession (frontend → POST /api/history)src/frontend/utils/state-persistence.ts — debouncedSaveState (frontend → POST /api/state)