From a8e1b322f173d57dba27cc24e67b48cbfedb2554 Mon Sep 17 00:00:00 2001 From: derekc Date: Tue, 3 Mar 2026 14:24:50 -0800 Subject: [PATCH] Tie break timer and main block timer together - Starting break now pauses the main block timer (frontend optimistic + backend implicit pause event recorded before break_start) - Resuming/starting the main block while break is active pauses the break timer and exits break mode on all clients including TV - Timer display counts negative past zero so overtime is visible while label stays "Done!" - Fixed WS start handler incorrectly skipping break-mode clear when restarting the same block; resume handler now also clears break mode Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routers/sessions.py | 19 ++++++-- frontend/src/components/TimerDisplay.vue | 12 ++--- frontend/src/stores/schedule.js | 56 +++++++++++++++++++++--- frontend/src/views/DashboardView.vue | 2 +- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index 4e9dd0c..454389b 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -191,18 +191,30 @@ async def timer_action( BREAK_EVENTS = {"break_start", "break_pause", "break_resume", "break_reset"} if body.event_type in BREAK_EVENTS: block_id = body.block_id or session.current_block_id - event = TimerEvent( + + # When break starts, implicitly pause the main block timer so elapsed + # time is captured accurately in the activity log and on page reload. + if body.event_type == "break_start" and block_id: + db.add(TimerEvent( + session_id=session.id, + block_id=block_id, + event_type="pause", + )) + + db.add(TimerEvent( session_id=session.id, block_id=block_id, event_type=body.event_type, - ) - db.add(event) + )) await db.commit() await db.refresh(session) break_elapsed_seconds = 0 + block_elapsed_seconds = 0 if body.event_type in ("break_start", "break_reset") and block_id: break_elapsed_seconds, _ = await compute_break_elapsed(db, session.id, block_id) + if body.event_type == "break_start" and block_id: + block_elapsed_seconds, _ = await compute_block_elapsed(db, session.id, block_id) ws_payload = { "event": body.event_type, @@ -211,6 +223,7 @@ async def timer_action( "current_block_id": session.current_block_id, "is_active": session.is_active, "break_elapsed_seconds": break_elapsed_seconds, + "block_elapsed_seconds": block_elapsed_seconds, } await manager.broadcast(session.child_id, ws_payload) return session diff --git a/frontend/src/components/TimerDisplay.vue b/frontend/src/components/TimerDisplay.vue index f2498a3..5c19e9e 100644 --- a/frontend/src/components/TimerDisplay.vue +++ b/frontend/src/components/TimerDisplay.vue @@ -58,10 +58,12 @@ const elapsed = computed(() => { const remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value)) const display = computed(() => { - const s = remaining.value - const m = Math.floor(s / 60) - const sec = s % 60 - return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` + const s = blockDuration.value - elapsed.value + if (s < 0) { + const abs = Math.abs(s) + return `-${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}` + } + return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` }) const label = computed(() => { @@ -72,7 +74,7 @@ const label = computed(() => { const CIRCUMFERENCE = 2 * Math.PI * 88 const dashOffset = computed(() => { if (!blockDuration.value) return CIRCUMFERENCE - const pct = remaining.value / blockDuration.value + const pct = Math.max(0, remaining.value / blockDuration.value) return CIRCUMFERENCE * (1 - pct) }) diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 0c87024..6843695 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -95,6 +95,12 @@ export const useScheduleStore = defineStore('schedule', () => { if (event.block_id) breakElapsedCache.value[event.block_id] = elapsed breakStartedAt.value = Date.now() isBreakMode.value = true + // Sync main block timer to server-authoritative elapsed and mark paused + const blockElapsed = event.block_elapsed_seconds ?? blockElapsedCache.value[event.block_id] ?? blockElapsedOffset.value + blockElapsedOffset.value = blockElapsed + if (event.block_id) blockElapsedCache.value[event.block_id] = blockElapsed + blockStartedAt.value = null + isPaused.value = true } if (event.event === 'break_pause') { if (breakStartedAt.value) { @@ -140,12 +146,9 @@ export const useScheduleStore = defineStore('schedule', () => { } blockStartedAt.value = Date.now() isPaused.value = false - // Switching to a new block clears break mode - if (event.block_id !== event.current_block_id || !isBreakMode.value) { - isBreakMode.value = false - breakStartedAt.value = null - breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0 - } + isBreakMode.value = false + breakStartedAt.value = null + breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0 } // Reset — clear elapsed to 0 and start counting immediately if (event.event === 'reset') { @@ -175,6 +178,8 @@ export const useScheduleStore = defineStore('schedule', () => { if (event.event === 'resume') { blockStartedAt.value = Date.now() isPaused.value = false + isBreakMode.value = false + breakStartedAt.value = null } // Timer events update session state if (event.current_block_id !== undefined && session.value) { @@ -256,15 +261,51 @@ export const useScheduleStore = defineStore('schedule', () => { // Start the timer for the currently selected block (optimistic). function startCurrentBlock(sessionId) { if (!session.value?.current_block_id) return + const blockId = session.value.current_block_id + if (isBreakMode.value) { + if (breakStartedAt.value) { + breakElapsedOffset.value += Math.floor((Date.now() - breakStartedAt.value) / 1000) + } + breakElapsedCache.value[blockId] = breakElapsedOffset.value + breakStartedAt.value = null + isBreakMode.value = false + sendTimerAction(sessionId, 'break_pause', blockId) + } isPaused.value = false blockStartedAt.value = Date.now() - sendTimerAction(sessionId, 'start', session.value.current_block_id) + sendTimerAction(sessionId, 'start', blockId) + } + + // Resume the timer for the currently selected block (optimistic). + function resumeCurrentBlock(sessionId) { + if (!session.value?.current_block_id) return + const blockId = session.value.current_block_id + if (isBreakMode.value) { + if (breakStartedAt.value) { + breakElapsedOffset.value += Math.floor((Date.now() - breakStartedAt.value) / 1000) + } + breakElapsedCache.value[blockId] = breakElapsedOffset.value + breakStartedAt.value = null + isBreakMode.value = false + sendTimerAction(sessionId, 'break_pause', blockId) + } + blockStartedAt.value = Date.now() + isPaused.value = false + sendTimerAction(sessionId, 'resume') } // Break timer actions function startBreak(sessionId) { if (!session.value?.current_block_id) return const blockId = session.value.current_block_id + // Pause the main block timer + if (blockStartedAt.value) { + blockElapsedOffset.value += Math.floor((Date.now() - blockStartedAt.value) / 1000) + } + blockElapsedCache.value[blockId] = blockElapsedOffset.value + blockStartedAt.value = null + isPaused.value = true + // Start break timer isBreakMode.value = true breakElapsedOffset.value = breakElapsedCache.value[blockId] ?? 0 breakStartedAt.value = Date.now() @@ -332,6 +373,7 @@ export const useScheduleStore = defineStore('schedule', () => { switchBlock, selectBlock, startCurrentBlock, + resumeCurrentBlock, resetCurrentBlock, startBreak, pauseBreak, diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 1ea8e37..fec0a03 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -88,7 +88,7 @@