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. Public dashboard endpoint — no authentication required.
Used by the TV view to get the initial session snapshot before WebSocket connects. 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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -40,6 +40,7 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
blocks = [] blocks = []
completed_ids = [] completed_ids = []
block_elapsed_seconds = 0
if session and session.template_id: if session and session.template_id:
blocks_result = await db.execute( 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] 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( return DashboardSnapshot(
session=session, session=session,
child=child, child=child,
blocks=blocks, blocks=blocks,
completed_block_ids=completed_ids, completed_block_ids=completed_ids,
block_elapsed_seconds=block_elapsed_seconds,
) )

View File

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

View File

@@ -1,5 +1,9 @@
<template> <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 circular ring -->
<svg class="timer-ring" viewBox="0 0 200 200"> <svg class="timer-ring" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="88" class="ring-bg" /> <circle cx="100" cy="100" r="88" class="ring-bg" />
@@ -17,15 +21,21 @@
</template> </template>
<script setup> <script setup>
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref } from 'vue'
const props = defineProps({ const props = defineProps({
block: { type: Object, default: null }, block: { type: Object, default: null },
session: { 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) // Tick so computed elapsed re-evaluates each second
let interval = null const now = ref(Date.now())
const ticker = setInterval(() => { now.value = Date.now() }, 1000)
onUnmounted(() => clearInterval(ticker))
function parseTime(str) { function parseTime(str) {
if (!str) return 0 if (!str) return 0
@@ -38,6 +48,12 @@ const blockDuration = computed(() => {
return parseTime(props.block.time_end) - parseTime(props.block.time_start) 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 remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value))
const display = computed(() => { const display = computed(() => {
@@ -60,17 +76,6 @@ const dashOffset = computed(() => {
}) })
const ringColor = computed(() => props.block?.subject?.color || '#4f46e5') 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> </script>
<style scoped> <style scoped>
@@ -124,4 +129,24 @@ onUnmounted(() => clearInterval(interval))
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; 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> </style>

View File

@@ -7,6 +7,9 @@ export const useScheduleStore = defineStore('schedule', () => {
const blocks = ref([]) const blocks = ref([])
const completedBlockIds = ref([]) const completedBlockIds = ref([])
const child = ref(null) 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(() => const currentBlock = computed(() =>
session.value?.current_block_id session.value?.current_block_id
@@ -23,7 +26,17 @@ export const useScheduleStore = defineStore('schedule', () => {
session.value = snapshot.session session.value = snapshot.session
blocks.value = snapshot.blocks || [] blocks.value = snapshot.blocks || []
completedBlockIds.value = snapshot.completed_block_ids || [] completedBlockIds.value = snapshot.completed_block_ids || []
isPaused.value = false
if (snapshot.child) child.value = snapshot.child 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) { function applyWsEvent(event) {
@@ -34,8 +47,30 @@ export const useScheduleStore = defineStore('schedule', () => {
// Session ended // Session ended
if (event.is_active === false) { if (event.is_active === false) {
session.value = null session.value = null
isPaused.value = false
blockStartedAt.value = null
blockElapsedOffset.value = 0
return 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 // Timer events update session state
if (event.current_block_id !== undefined && session.value) { if (event.current_block_id !== undefined && session.value) {
session.value.current_block_id = event.current_block_id session.value.current_block_id = event.current_block_id
@@ -73,6 +108,9 @@ export const useScheduleStore = defineStore('schedule', () => {
blocks, blocks,
completedBlockIds, completedBlockIds,
child, child,
isPaused,
blockStartedAt,
blockElapsedOffset,
currentBlock, currentBlock,
progressPercent, progressPercent,
applySnapshot, applySnapshot,

View File

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

View File

@@ -26,6 +26,9 @@
<TimerDisplay <TimerDisplay
:block="scheduleStore.currentBlock" :block="scheduleStore.currentBlock"
:session="scheduleStore.session" :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"> <div class="tv-block-notes" v-if="scheduleStore.currentBlock.notes">
{{ scheduleStore.currentBlock.notes }} {{ scheduleStore.currentBlock.notes }}