Fix timer reset on refresh and sync between dashboard and TV view

- Backend computes block_elapsed_seconds server-side from timer_events
- Store tracks blockStartedAt (ms) + blockElapsedOffset (seconds) instead
  of a client-side counter; updated correctly on start/pause/resume/end
- TimerDisplay derives elapsed from store props so both views always agree
- Add compact timer display to dashboard session card
- Add isPaused/pause-resume logic to dashboard Pause/Resume buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 00:16:29 -08:00
parent 3f9d599998
commit d43791f965
6 changed files with 129 additions and 18 deletions

View File

@@ -2,7 +2,7 @@
Public dashboard endpoint — no authentication required.
Used by the TV view to get the initial session snapshot before WebSocket connects.
"""
from datetime import date
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,6 +40,7 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
blocks = []
completed_ids = []
block_elapsed_seconds = 0
if session and session.template_id:
blocks_result = await db.execute(
@@ -57,9 +58,34 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
)
completed_ids = [e.block_id for e in events_result.scalars().all() if e.block_id]
# Compute elapsed seconds for the current block from timer_events
if session and session.current_block_id:
tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == session.current_block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]),
)
.order_by(TimerEvent.occurred_at)
)
tick_events = tick_result.scalars().all()
last_start = None
elapsed = 0.0
for e in tick_events:
if e.event_type in ("start", "resume"):
last_start = e.occurred_at
elif e.event_type == "pause" and last_start:
elapsed += (e.occurred_at - last_start).total_seconds()
last_start = None
if last_start:
elapsed += (datetime.utcnow() - last_start).total_seconds()
block_elapsed_seconds = int(elapsed)
return DashboardSnapshot(
session=session,
child=child,
blocks=blocks,
completed_block_ids=completed_ids,
block_elapsed_seconds=block_elapsed_seconds,
)

View File

@@ -42,3 +42,4 @@ class DashboardSnapshot(BaseModel):
child: ChildOut
blocks: list[ScheduleBlockOut] = []
completed_block_ids: list[int] = []
block_elapsed_seconds: int = 0 # seconds already elapsed for the current block

View File

@@ -1,5 +1,9 @@
<template>
<div class="timer-wrap">
<div v-if="compact" class="timer-compact">
<span class="timer-time-compact">{{ display }}</span>
<span class="timer-label-compact">{{ label }}</span>
</div>
<div v-else class="timer-wrap">
<!-- SVG circular ring -->
<svg class="timer-ring" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="88" class="ring-bg" />
@@ -17,15 +21,21 @@
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref } from 'vue'
const props = defineProps({
block: { type: Object, default: null },
session: { type: Object, default: null },
isPaused: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
blockStartedAt: { type: Number, default: null }, // ms timestamp, null when paused
blockElapsedOffset: { type: Number, default: 0 }, // seconds already elapsed
})
const elapsed = ref(0)
let interval = null
// Tick so computed elapsed re-evaluates each second
const now = ref(Date.now())
const ticker = setInterval(() => { now.value = Date.now() }, 1000)
onUnmounted(() => clearInterval(ticker))
function parseTime(str) {
if (!str) return 0
@@ -38,6 +48,12 @@ const blockDuration = computed(() => {
return parseTime(props.block.time_end) - parseTime(props.block.time_start)
})
const elapsed = computed(() => {
const base = props.blockElapsedOffset
if (!props.blockStartedAt) return base
return base + Math.floor((now.value - props.blockStartedAt) / 1000)
})
const remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value))
const display = computed(() => {
@@ -60,17 +76,6 @@ const dashOffset = computed(() => {
})
const ringColor = computed(() => props.block?.subject?.color || '#4f46e5')
// Tick timer
watch(() => props.block?.id, () => { elapsed.value = 0 })
function startTick() {
if (interval) clearInterval(interval)
interval = setInterval(() => { elapsed.value++ }, 1000)
}
startTick()
onUnmounted(() => clearInterval(interval))
</script>
<style scoped>
@@ -124,4 +129,24 @@ onUnmounted(() => clearInterval(interval))
text-transform: uppercase;
letter-spacing: 0.1em;
}
.timer-compact {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.timer-time-compact {
font-size: 2rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.timer-label-compact {
font-size: 0.8rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.08em;
}
</style>

View File

@@ -7,6 +7,9 @@ export const useScheduleStore = defineStore('schedule', () => {
const blocks = ref([])
const completedBlockIds = ref([])
const child = ref(null)
const isPaused = ref(false)
const blockStartedAt = ref(null) // Date.now() ms when current counting period started
const blockElapsedOffset = ref(0) // seconds already elapsed before blockStartedAt
const currentBlock = computed(() =>
session.value?.current_block_id
@@ -23,7 +26,17 @@ export const useScheduleStore = defineStore('schedule', () => {
session.value = snapshot.session
blocks.value = snapshot.blocks || []
completedBlockIds.value = snapshot.completed_block_ids || []
isPaused.value = false
if (snapshot.child) child.value = snapshot.child
// Restore elapsed time from server-computed value
const serverElapsed = snapshot.block_elapsed_seconds || 0
if (snapshot.session?.current_block_id && serverElapsed > 0) {
blockElapsedOffset.value = serverElapsed
blockStartedAt.value = Date.now()
} else {
blockElapsedOffset.value = 0
blockStartedAt.value = null
}
}
function applyWsEvent(event) {
@@ -34,8 +47,30 @@ export const useScheduleStore = defineStore('schedule', () => {
// Session ended
if (event.is_active === false) {
session.value = null
isPaused.value = false
blockStartedAt.value = null
blockElapsedOffset.value = 0
return
}
// Pause — accumulate elapsed, stop counting
if (event.event === 'pause') {
if (blockStartedAt.value) {
blockElapsedOffset.value += Math.floor((Date.now() - blockStartedAt.value) / 1000)
}
blockStartedAt.value = null
isPaused.value = true
}
// Start (new block) — reset elapsed, begin counting
if (event.event === 'start') {
blockElapsedOffset.value = 0
blockStartedAt.value = Date.now()
isPaused.value = false
}
// Resume — continue from where we left off
if (event.event === 'resume') {
blockStartedAt.value = Date.now()
isPaused.value = false
}
// Timer events update session state
if (event.current_block_id !== undefined && session.value) {
session.value.current_block_id = event.current_block_id
@@ -73,6 +108,9 @@ export const useScheduleStore = defineStore('schedule', () => {
blocks,
completedBlockIds,
child,
isPaused,
blockStartedAt,
blockElapsedOffset,
currentBlock,
progressPercent,
applySnapshot,

View File

@@ -21,13 +21,27 @@
<span>{{ scheduleStore.progressPercent }}% complete</span>
</div>
<ProgressBar :percent="scheduleStore.progressPercent" />
<div v-if="scheduleStore.currentBlock" class="current-block-timer">
<TimerDisplay
compact
:block="scheduleStore.currentBlock"
:session="scheduleStore.session"
:is-paused="scheduleStore.isPaused"
:block-started-at="scheduleStore.blockStartedAt"
:block-elapsed-offset="scheduleStore.blockElapsedOffset"
/>
</div>
<div class="session-actions">
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
@click="sendAction('pause')"
>Pause</button>
<button class="btn-sm" @click="sendAction('start')">Resume</button>
<button
class="btn-sm"
v-if="scheduleStore.isPaused"
@click="sendAction('resume')"
>Resume</button>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
</div>
</div>
@@ -96,6 +110,7 @@ import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import ScheduleBlock from '@/components/ScheduleBlock.vue'
import TimerDisplay from '@/components/TimerDisplay.vue'
const childrenStore = useChildrenStore()
const scheduleStore = useScheduleStore()
@@ -135,6 +150,8 @@ async function sendAction(type) {
function selectBlock(block) {
if (!scheduleStore.session) return
scheduleStore.session.current_block_id = block.id
scheduleStore.isPaused = false
scheduleStore.sendTimerAction(scheduleStore.session.id, 'start', block.id)
}
@@ -189,6 +206,7 @@ h1 { font-size: 1.75rem; font-weight: 700; }
font-weight: 600;
}
.current-block-timer { display: flex; justify-content: center; margin: 1rem 0; }
.session-actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
.btn-sm {

View File

@@ -26,6 +26,9 @@
<TimerDisplay
:block="scheduleStore.currentBlock"
:session="scheduleStore.session"
:is-paused="scheduleStore.isPaused"
:block-started-at="scheduleStore.blockStartedAt"
:block-elapsed-offset="scheduleStore.blockElapsedOffset"
/>
<div class="tv-block-notes" v-if="scheduleStore.currentBlock.notes">
{{ scheduleStore.currentBlock.notes }}