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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 14:24:50 -08:00
parent c565c94a23
commit a8e1b322f1
4 changed files with 73 additions and 16 deletions

View File

@@ -191,18 +191,30 @@ async def timer_action(
BREAK_EVENTS = {"break_start", "break_pause", "break_resume", "break_reset"} BREAK_EVENTS = {"break_start", "break_pause", "break_resume", "break_reset"}
if body.event_type in BREAK_EVENTS: if body.event_type in BREAK_EVENTS:
block_id = body.block_id or session.current_block_id 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, session_id=session.id,
block_id=block_id, block_id=block_id,
event_type=body.event_type, event_type=body.event_type,
) ))
db.add(event)
await db.commit() await db.commit()
await db.refresh(session) await db.refresh(session)
break_elapsed_seconds = 0 break_elapsed_seconds = 0
block_elapsed_seconds = 0
if body.event_type in ("break_start", "break_reset") and block_id: if body.event_type in ("break_start", "break_reset") and block_id:
break_elapsed_seconds, _ = await compute_break_elapsed(db, session.id, 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 = { ws_payload = {
"event": body.event_type, "event": body.event_type,
@@ -211,6 +223,7 @@ async def timer_action(
"current_block_id": session.current_block_id, "current_block_id": session.current_block_id,
"is_active": session.is_active, "is_active": session.is_active,
"break_elapsed_seconds": break_elapsed_seconds, "break_elapsed_seconds": break_elapsed_seconds,
"block_elapsed_seconds": block_elapsed_seconds,
} }
await manager.broadcast(session.child_id, ws_payload) await manager.broadcast(session.child_id, ws_payload)
return session return session

View File

@@ -58,10 +58,12 @@ const elapsed = computed(() => {
const remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value)) const remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value))
const display = computed(() => { const display = computed(() => {
const s = remaining.value const s = blockDuration.value - elapsed.value
const m = Math.floor(s / 60) if (s < 0) {
const sec = s % 60 const abs = Math.abs(s)
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` 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(() => { const label = computed(() => {
@@ -72,7 +74,7 @@ const label = computed(() => {
const CIRCUMFERENCE = 2 * Math.PI * 88 const CIRCUMFERENCE = 2 * Math.PI * 88
const dashOffset = computed(() => { const dashOffset = computed(() => {
if (!blockDuration.value) return CIRCUMFERENCE if (!blockDuration.value) return CIRCUMFERENCE
const pct = remaining.value / blockDuration.value const pct = Math.max(0, remaining.value / blockDuration.value)
return CIRCUMFERENCE * (1 - pct) return CIRCUMFERENCE * (1 - pct)
}) })

View File

@@ -95,6 +95,12 @@ export const useScheduleStore = defineStore('schedule', () => {
if (event.block_id) breakElapsedCache.value[event.block_id] = elapsed if (event.block_id) breakElapsedCache.value[event.block_id] = elapsed
breakStartedAt.value = Date.now() breakStartedAt.value = Date.now()
isBreakMode.value = true 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 (event.event === 'break_pause') {
if (breakStartedAt.value) { if (breakStartedAt.value) {
@@ -140,13 +146,10 @@ export const useScheduleStore = defineStore('schedule', () => {
} }
blockStartedAt.value = Date.now() blockStartedAt.value = Date.now()
isPaused.value = false isPaused.value = false
// Switching to a new block clears break mode
if (event.block_id !== event.current_block_id || !isBreakMode.value) {
isBreakMode.value = false isBreakMode.value = false
breakStartedAt.value = null breakStartedAt.value = null
breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0 breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0
} }
}
// Reset — clear elapsed to 0 and start counting immediately // Reset — clear elapsed to 0 and start counting immediately
if (event.event === 'reset') { if (event.event === 'reset') {
if (event.block_id) blockElapsedCache.value[event.block_id] = 0 if (event.block_id) blockElapsedCache.value[event.block_id] = 0
@@ -175,6 +178,8 @@ export const useScheduleStore = defineStore('schedule', () => {
if (event.event === 'resume') { if (event.event === 'resume') {
blockStartedAt.value = Date.now() blockStartedAt.value = Date.now()
isPaused.value = false isPaused.value = false
isBreakMode.value = false
breakStartedAt.value = null
} }
// Timer events update session state // Timer events update session state
if (event.current_block_id !== undefined && session.value) { 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). // Start the timer for the currently selected block (optimistic).
function startCurrentBlock(sessionId) { function startCurrentBlock(sessionId) {
if (!session.value?.current_block_id) return 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 isPaused.value = false
blockStartedAt.value = Date.now() 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 // Break timer actions
function startBreak(sessionId) { function startBreak(sessionId) {
if (!session.value?.current_block_id) return if (!session.value?.current_block_id) return
const blockId = session.value.current_block_id 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 isBreakMode.value = true
breakElapsedOffset.value = breakElapsedCache.value[blockId] ?? 0 breakElapsedOffset.value = breakElapsedCache.value[blockId] ?? 0
breakStartedAt.value = Date.now() breakStartedAt.value = Date.now()
@@ -332,6 +373,7 @@ export const useScheduleStore = defineStore('schedule', () => {
switchBlock, switchBlock,
selectBlock, selectBlock,
startCurrentBlock, startCurrentBlock,
resumeCurrentBlock,
resetCurrentBlock, resetCurrentBlock,
startBreak, startBreak,
pauseBreak, pauseBreak,

View File

@@ -88,7 +88,7 @@
<button <button
class="btn-sm" class="btn-sm"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0" v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
@click="sendAction('resume')" @click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
>Resume</button> >Resume</button>
<button <button
class="btn-sm" class="btn-sm"