Add manual block start and fix timer display labels

Blocks are now selected without auto-starting the timer. Clicking a block
makes it current (highlighted) but leaves it in a ready state. A "Start"
button (indigo) triggers timing for a fresh block; "Resume" appears for
previously-worked blocks; "Pause" remains while running.

Also fixes the sidebar duration label to show "Done!" when elapsed ≥ total
and "< 1 min" for sub-minute remaining time instead of "0 min".

Backend adds a "select" event type that records an implicit pause for the
previous block, updates current_block_id, and broadcasts is_paused=true
with prev_block_elapsed_seconds so the TV sidebar stays accurate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 00:09:27 -08:00
parent ca858c2050
commit cc599603cf
4 changed files with 102 additions and 26 deletions

View File

@@ -1,4 +1,4 @@
from datetime import date from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -159,7 +159,7 @@ async def get_session(
select(DailySession) select(DailySession)
.join(Child) .join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id) .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() session = result.scalar_one_or_none()
if not session: if not session:
@@ -178,15 +178,17 @@ async def timer_action(
select(DailySession) select(DailySession)
.join(Child) .join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id) .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() session = result.scalar_one_or_none()
if not session: if not session:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
# When starting a different block, implicitly pause the previous one so # When starting or selecting a different block, implicitly pause the previous
# the activity log stays accurate and elapsed time is preserved correctly. # one so the activity log stays accurate and elapsed time is preserved.
if body.event_type == "start" and body.block_id is not None: 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 prev_block_id = session.current_block_id
if prev_block_id and prev_block_id != body.block_id: if prev_block_id and prev_block_id != body.block_id:
db.add(TimerEvent( db.add(TimerEvent(
@@ -194,6 +196,28 @@ async def timer_action(
block_id=prev_block_id, block_id=prev_block_id,
event_type="pause", 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 # Update current block if provided
if body.block_id is not None: if body.block_id is not None:
@@ -214,17 +238,16 @@ async def timer_action(
await db.commit() await db.commit()
await db.refresh(session) await db.refresh(session)
# For 'start' events, compute elapsed from previous intervals so every # For 'start' and 'select' events, compute accumulated elapsed for the new
# client (including TV) can restore the correct offset without a cache. # block from previous intervals so every client can restore the correct offset.
block_elapsed_seconds = 0 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( tick_result = await db.execute(
select(TimerEvent) select(TimerEvent)
.where( .where(
TimerEvent.session_id == session.id, TimerEvent.session_id == session.id,
TimerEvent.block_id == event.block_id, TimerEvent.block_id == event.block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]), TimerEvent.event_type.in_(["start", "resume", "pause"]),
TimerEvent.id != event.id,
) )
.order_by(TimerEvent.occurred_at) .order_by(TimerEvent.occurred_at)
) )
@@ -236,6 +259,9 @@ async def timer_action(
elif e.event_type == "pause" and last_start_time: elif e.event_type == "pause" and last_start_time:
block_elapsed_seconds += int((e.occurred_at - last_start_time).total_seconds()) block_elapsed_seconds += int((e.occurred_at - last_start_time).total_seconds())
last_start_time = None 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 # Broadcast the timer event to all TV clients
ws_payload = { ws_payload = {
@@ -244,7 +270,10 @@ async def timer_action(
"block_id": event.block_id, "block_id": event.block_id,
"current_block_id": session.current_block_id, "current_block_id": session.current_block_id,
"is_active": session.is_active, "is_active": session.is_active,
"is_paused": body.event_type == "select",
"block_elapsed_seconds": block_elapsed_seconds, "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) await manager.broadcast(session.child_id, ws_payload)

View File

@@ -63,8 +63,9 @@ const durationLabel = computed(() => {
const totalSec = blockTotalSeconds() const totalSec = blockTotalSeconds()
if (totalSec <= 0) return '' if (totalSec <= 0) return ''
const remSec = Math.max(0, totalSec - props.elapsedSeconds) const remSec = Math.max(0, totalSec - props.elapsedSeconds)
if (remSec <= 0) return 'Done!'
const remMin = Math.floor(remSec / 60) const remMin = Math.floor(remSec / 60)
if (remMin <= 0) return '0 min' if (remMin <= 0) return '< 1 min'
if (remMin >= 60) { if (remMin >= 60) {
const h = Math.floor(remMin / 60) const h = Math.floor(remMin / 60)
const m = remMin % 60 const m = remMin % 60

View File

@@ -94,9 +94,26 @@ export const useScheduleStore = defineStore('schedule', () => {
const elapsed = event.block_elapsed_seconds ?? blockElapsedCache.value[event.block_id] ?? 0 const elapsed = event.block_elapsed_seconds ?? blockElapsedCache.value[event.block_id] ?? 0
blockElapsedOffset.value = elapsed blockElapsedOffset.value = elapsed
if (event.block_id) blockElapsedCache.value[event.block_id] = 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() blockStartedAt.value = Date.now()
isPaused.value = false 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 // Resume — continue from where we left off
if (event.event === 'resume') { if (event.event === 'resume') {
blockStartedAt.value = Date.now() blockStartedAt.value = Date.now()
@@ -156,6 +173,37 @@ export const useScheduleStore = defineStore('schedule', () => {
sendTimerAction(sessionId, 'start', newBlockId) 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 { return {
session, session,
blocks, blocks,
@@ -176,5 +224,7 @@ export const useScheduleStore = defineStore('schedule', () => {
startSession, startSession,
sendTimerAction, sendTimerAction,
switchBlock, switchBlock,
selectBlock,
startCurrentBlock,
} }
}) })

View File

@@ -47,9 +47,14 @@
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused" v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
@click="sendAction('pause')" @click="sendAction('pause')"
>Pause</button> >Pause</button>
<button
class="btn-sm btn-start"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
>Start</button>
<button <button
class="btn-sm" class="btn-sm"
v-if="scheduleStore.isPaused" v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
@click="sendAction('resume')" @click="sendAction('resume')"
>Resume</button> >Resume</button>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button> <button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
@@ -227,20 +232,9 @@ function blockElapsed(block) {
function selectBlock(block) { function selectBlock(block) {
if (!scheduleStore.session) return if (!scheduleStore.session) return
// Clicking the current block does nothing — use Start/Pause/Resume buttons
const currentId = scheduleStore.session.current_block_id if (block.id === scheduleStore.session.current_block_id) return
scheduleStore.selectBlock(scheduleStore.session.id, 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)
} }
onMounted(async () => { onMounted(async () => {
@@ -327,6 +321,8 @@ h1 { font-size: 1.75rem; font-weight: 700; }
.btn-sm:hover { background: #334155; } .btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; } .btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; } .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 { text-align: center; padding: 1.5rem 0; color: #64748b; }
.no-session p { margin-bottom: 1rem; } .no-session p { margin-bottom: 1rem; }