diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index c8dfb46..1a3ded8 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import date, datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -159,7 +159,7 @@ async def get_session( select(DailySession) .join(Child) .where(DailySession.id == session_id, Child.user_id == current_user.id) - .options(selectinload(DailySession.current_block)) + .options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject)) ) session = result.scalar_one_or_none() if not session: @@ -178,15 +178,17 @@ async def timer_action( select(DailySession) .join(Child) .where(DailySession.id == session_id, Child.user_id == current_user.id) - .options(selectinload(DailySession.current_block)) + .options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject)) ) session = result.scalar_one_or_none() if not session: raise HTTPException(status_code=404, detail="Session not found") - # When starting a different block, implicitly pause the previous one so - # the activity log stays accurate and elapsed time is preserved correctly. - if body.event_type == "start" and body.block_id is not None: + # When starting or selecting a different block, implicitly pause the previous + # one so the activity log stays accurate and elapsed time is preserved. + prev_block_id = None + prev_block_elapsed_seconds = 0 + if body.event_type in ("start", "select") and body.block_id is not None: prev_block_id = session.current_block_id if prev_block_id and prev_block_id != body.block_id: db.add(TimerEvent( @@ -194,6 +196,28 @@ async def timer_action( block_id=prev_block_id, event_type="pause", )) + # Compute prev block's accumulated elapsed so all clients (especially TV) + # can update their local cache without relying on a separate pause broadcast. + prev_tick_result = await db.execute( + select(TimerEvent) + .where( + TimerEvent.session_id == session.id, + TimerEvent.block_id == prev_block_id, + TimerEvent.event_type.in_(["start", "resume", "pause"]), + ) + .order_by(TimerEvent.occurred_at) + ) + prev_tick_events = prev_tick_result.scalars().all() + _last_start = None + for e in prev_tick_events: + if e.event_type in ("start", "resume"): + _last_start = e.occurred_at + elif e.event_type == "pause" and _last_start: + prev_block_elapsed_seconds += int((e.occurred_at - _last_start).total_seconds()) + _last_start = None + # Add the current open interval (up to the implicit pause just recorded) + if _last_start: + prev_block_elapsed_seconds += int((datetime.utcnow() - _last_start).total_seconds()) # Update current block if provided if body.block_id is not None: @@ -214,17 +238,16 @@ async def timer_action( await db.commit() await db.refresh(session) - # For 'start' events, compute elapsed from previous intervals so every - # client (including TV) can restore the correct offset without a cache. + # For 'start' and 'select' events, compute accumulated elapsed for the new + # block from previous intervals so every client can restore the correct offset. block_elapsed_seconds = 0 - if body.event_type == "start" and event.block_id: + if body.event_type in ("start", "select") and event.block_id: tick_result = await db.execute( select(TimerEvent) .where( TimerEvent.session_id == session.id, TimerEvent.block_id == event.block_id, TimerEvent.event_type.in_(["start", "resume", "pause"]), - TimerEvent.id != event.id, ) .order_by(TimerEvent.occurred_at) ) @@ -236,6 +259,9 @@ async def timer_action( elif e.event_type == "pause" and last_start_time: block_elapsed_seconds += int((e.occurred_at - last_start_time).total_seconds()) last_start_time = None + # For 'start' also count the current open interval + if body.event_type == "start" and last_start_time: + block_elapsed_seconds += int((datetime.utcnow() - last_start_time).total_seconds()) # Broadcast the timer event to all TV clients ws_payload = { @@ -244,7 +270,10 @@ async def timer_action( "block_id": event.block_id, "current_block_id": session.current_block_id, "is_active": session.is_active, + "is_paused": body.event_type == "select", "block_elapsed_seconds": block_elapsed_seconds, + "prev_block_id": prev_block_id, + "prev_block_elapsed_seconds": prev_block_elapsed_seconds, } await manager.broadcast(session.child_id, ws_payload) diff --git a/frontend/src/components/ScheduleBlock.vue b/frontend/src/components/ScheduleBlock.vue index 063ee49..ce35ae9 100644 --- a/frontend/src/components/ScheduleBlock.vue +++ b/frontend/src/components/ScheduleBlock.vue @@ -63,8 +63,9 @@ const durationLabel = computed(() => { const totalSec = blockTotalSeconds() if (totalSec <= 0) return '' const remSec = Math.max(0, totalSec - props.elapsedSeconds) + if (remSec <= 0) return 'Done!' const remMin = Math.floor(remSec / 60) - if (remMin <= 0) return '0 min' + if (remMin <= 0) return '< 1 min' if (remMin >= 60) { const h = Math.floor(remMin / 60) const m = remMin % 60 diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 8a700b6..faca99b 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -94,9 +94,26 @@ export const useScheduleStore = defineStore('schedule', () => { const elapsed = event.block_elapsed_seconds ?? blockElapsedCache.value[event.block_id] ?? 0 blockElapsedOffset.value = elapsed if (event.block_id) blockElapsedCache.value[event.block_id] = elapsed + // Sync the previous block's cache so TV sidebar doesn't reset to full duration + if (event.prev_block_id != null) { + blockElapsedCache.value[event.prev_block_id] = event.prev_block_elapsed_seconds ?? blockElapsedCache.value[event.prev_block_id] ?? 0 + } blockStartedAt.value = Date.now() isPaused.value = false } + // Select — switch current block but keep timer stopped (manual start required) + if (event.event === 'select') { + // Sync the previous block's cache + if (event.prev_block_id != null) { + blockElapsedCache.value[event.prev_block_id] = event.prev_block_elapsed_seconds ?? blockElapsedCache.value[event.prev_block_id] ?? 0 + } + // Restore new block's accumulated elapsed but don't start counting + const elapsed = event.block_elapsed_seconds ?? blockElapsedCache.value[event.block_id] ?? 0 + blockElapsedOffset.value = elapsed + if (event.block_id) blockElapsedCache.value[event.block_id] = elapsed + blockStartedAt.value = null + isPaused.value = true + } // Resume — continue from where we left off if (event.event === 'resume') { blockStartedAt.value = Date.now() @@ -156,6 +173,37 @@ export const useScheduleStore = defineStore('schedule', () => { sendTimerAction(sessionId, 'start', newBlockId) } + // Select a block without starting its timer (requires explicit Start/Resume). + // The backend records an implicit pause for the previous block if it was running. + function selectBlock(sessionId, newBlockId) { + if (!session.value) return + const prevBlockId = session.value.current_block_id + if (prevBlockId === newBlockId) return + + // Save elapsed seconds for the block we're leaving (if timer was running) + if (prevBlockId && blockStartedAt.value) { + blockElapsedCache.value[prevBlockId] = + blockElapsedOffset.value + Math.floor((Date.now() - blockStartedAt.value) / 1000) + } + + // Optimistic update — switch block, stay paused, stop counting + session.value.current_block_id = newBlockId + isPaused.value = true + blockElapsedOffset.value = blockElapsedCache.value[newBlockId] ?? 0 + blockStartedAt.value = null + + // Single HTTP call; WS select event will confirm with authoritative elapsed + sendTimerAction(sessionId, 'select', newBlockId) + } + + // Start the timer for the currently selected block (optimistic). + function startCurrentBlock(sessionId) { + if (!session.value?.current_block_id) return + isPaused.value = false + blockStartedAt.value = Date.now() + sendTimerAction(sessionId, 'start', session.value.current_block_id) + } + return { session, blocks, @@ -176,5 +224,7 @@ export const useScheduleStore = defineStore('schedule', () => { startSession, sendTimerAction, switchBlock, + selectBlock, + startCurrentBlock, } }) diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 2233444..316d34d 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -47,9 +47,14 @@ v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused" @click="sendAction('pause')" >Pause + @@ -227,20 +232,9 @@ function blockElapsed(block) { function selectBlock(block) { if (!scheduleStore.session) return - - const currentId = scheduleStore.session.current_block_id - - // Clicking the current block while paused → resume it - if (block.id === currentId && scheduleStore.isPaused) { - scheduleStore.sendTimerAction(scheduleStore.session.id, 'resume') - return - } - - // Clicking the current block while running → do nothing - if (block.id === currentId) return - - // Switch to the new block in one atomic step (no separate pause request) - scheduleStore.switchBlock(scheduleStore.session.id, block.id) + // Clicking the current block does nothing — use Start/Pause/Resume buttons + if (block.id === scheduleStore.session.current_block_id) return + scheduleStore.selectBlock(scheduleStore.session.id, block.id) } onMounted(async () => { @@ -327,6 +321,8 @@ h1 { font-size: 1.75rem; font-weight: 700; } .btn-sm:hover { background: #334155; } .btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; } .btn-sm.btn-danger:hover { background: #7f1d1d; } +.btn-sm.btn-start { border-color: #4f46e5; color: #818cf8; } +.btn-sm.btn-start:hover { background: #4f46e5; color: #fff; } .no-session { text-align: center; padding: 1.5rem 0; color: #64748b; } .no-session p { margin-bottom: 1rem; }