Show timer remaining per block and fix single-click block switching

- Block list on both dashboards now shows time remaining on each block's
  timer (allocated duration minus elapsed) instead of total duration;
  the active block counts down live every second
- Fix block switching requiring 2 clicks: replace separate pause+start
  requests with a single start request; backend implicitly records a
  pause event for the previous block atomically
- Export blockElapsedCache from store so views can compute per-block
  elapsed for both running and paused blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:00:23 -08:00
parent a02876c20d
commit f730e9edf9
5 changed files with 76 additions and 18 deletions

View File

@@ -170,6 +170,17 @@ async def timer_action(
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
# the activity log stays accurate and elapsed time is preserved correctly.
if body.event_type == "start" 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(
session_id=session.id,
block_id=prev_block_id,
event_type="pause",
))
# Update current block if provided # Update current block if provided
if body.block_id is not None: if body.block_id is not None:
session.current_block_id = body.block_id session.current_block_id = body.block_id

View File

@@ -33,6 +33,7 @@ const props = defineProps({
isCurrent: { type: Boolean, default: false }, isCurrent: { type: Boolean, default: false },
isCompleted: { type: Boolean, default: false }, isCompleted: { type: Boolean, default: false },
compact: { type: Boolean, default: false }, compact: { type: Boolean, default: false },
elapsedSeconds: { type: Number, default: 0 },
}) })
function formatTime(str) { function formatTime(str) {
@@ -46,17 +47,30 @@ function formatTime(str) {
const subjectColor = computed(() => props.block.subject?.color || '#475569') const subjectColor = computed(() => props.block.subject?.color || '#475569')
const subjectName = computed(() => props.block.subject?.name || null) const subjectName = computed(() => props.block.subject?.name || null)
const durationLabel = computed(() => { function blockTotalSeconds() {
if (props.block.duration_minutes != null) return `${props.block.duration_minutes} min` if (props.block.duration_minutes != null) return props.block.duration_minutes * 60
const start = props.block.time_start const start = props.block.time_start
const end = props.block.time_end const end = props.block.time_end
if (start && end) { if (start && end) {
const [sh, sm] = start.split(':').map(Number) const [sh, sm] = start.split(':').map(Number)
const [eh, em] = end.split(':').map(Number) const [eh, em] = end.split(':').map(Number)
const mins = (eh * 60 + em) - (sh * 60 + sm) return ((eh * 60 + em) - (sh * 60 + sm)) * 60
if (mins > 0) return `${mins} min`
} }
return '' return 0
}
const durationLabel = computed(() => {
const totalSec = blockTotalSeconds()
if (totalSec <= 0) return ''
const remSec = Math.max(0, totalSec - props.elapsedSeconds)
const remMin = Math.floor(remSec / 60)
if (remMin <= 0) return '0 min'
if (remMin >= 60) {
const h = Math.floor(remMin / 60)
const m = remMin % 60
return m === 0 ? `${h}h` : `${h}h ${m}m`
}
return `${remMin} min`
}) })
</script> </script>

View File

@@ -132,6 +132,28 @@ export const useScheduleStore = defineStore('schedule', () => {
}) })
} }
// Switch to a different block in one atomic operation (no separate pause request).
// The backend records an implicit pause for the previous block.
function switchBlock(sessionId, newBlockId) {
if (!session.value) return
const prevBlockId = session.value.current_block_id
// Save elapsed seconds for the block we're leaving
if (prevBlockId && blockStartedAt.value) {
blockElapsedCache.value[prevBlockId] =
blockElapsedOffset.value + Math.floor((Date.now() - blockStartedAt.value) / 1000)
}
// Optimistic update — UI responds immediately
session.value.current_block_id = newBlockId
isPaused.value = false
blockElapsedOffset.value = blockElapsedCache.value[newBlockId] ?? 0
blockStartedAt.value = Date.now()
// Single HTTP call; WS start event will confirm with authoritative elapsed
sendTimerAction(sessionId, 'start', newBlockId)
}
return { return {
session, session,
blocks, blocks,
@@ -140,6 +162,7 @@ export const useScheduleStore = defineStore('schedule', () => {
isPaused, isPaused,
blockStartedAt, blockStartedAt,
blockElapsedOffset, blockElapsedOffset,
blockElapsedCache,
dayStartTime, dayStartTime,
dayEndTime, dayEndTime,
currentBlock, currentBlock,
@@ -149,5 +172,6 @@ export const useScheduleStore = defineStore('schedule', () => {
fetchDashboard, fetchDashboard,
startSession, startSession,
sendTimerAction, sendTimerAction,
switchBlock,
} }
}) })

View File

@@ -74,6 +74,7 @@
:block="block" :block="block"
:is-current="block.id === scheduleStore.session?.current_block_id" :is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)" :is-completed="scheduleStore.completedBlockIds.includes(block.id)"
:elapsed-seconds="blockElapsed(block)"
@click="selectBlock(block)" @click="selectBlock(block)"
/> />
</div> </div>
@@ -151,9 +152,8 @@ const showStartDialog = ref(false)
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templates = ref([]) const templates = ref([])
// Day progress clock (minute precision is enough)
const now = ref(new Date()) const now = ref(new Date())
setInterval(() => { now.value = new Date() }, 60000) setInterval(() => { now.value = new Date() }, 1000)
function timeStrToMinutes(str) { function timeStrToMinutes(str) {
if (!str) return null if (!str) return null
@@ -217,7 +217,15 @@ async function sendAction(type) {
await scheduleStore.sendTimerAction(scheduleStore.session.id, type) await scheduleStore.sendTimerAction(scheduleStore.session.id, type)
} }
async function selectBlock(block) { function blockElapsed(block) {
const currentId = scheduleStore.session?.current_block_id
if (block.id === currentId && scheduleStore.blockStartedAt && !scheduleStore.isPaused) {
return scheduleStore.blockElapsedOffset + Math.floor((now.value - scheduleStore.blockStartedAt) / 1000)
}
return scheduleStore.blockElapsedCache[block.id] || 0
}
function selectBlock(block) {
if (!scheduleStore.session) return if (!scheduleStore.session) return
const currentId = scheduleStore.session.current_block_id const currentId = scheduleStore.session.current_block_id
@@ -231,16 +239,8 @@ async function selectBlock(block) {
// Clicking the current block while running → do nothing // Clicking the current block while running → do nothing
if (block.id === currentId) return if (block.id === currentId) return
// Switching to a different block — pause the current one first if it's running // Switch to the new block in one atomic step (no separate pause request)
if (currentId && !scheduleStore.isPaused) { scheduleStore.switchBlock(scheduleStore.session.id, block.id)
await scheduleStore.sendTimerAction(scheduleStore.session.id, 'pause', currentId)
}
// Optimistically update so the UI switches immediately without waiting for the WS start event
scheduleStore.session.current_block_id = block.id
scheduleStore.isPaused = false
scheduleStore.sendTimerAction(scheduleStore.session.id, 'start', block.id)
} }
onMounted(async () => { onMounted(async () => {

View File

@@ -86,6 +86,7 @@
:block="block" :block="block"
:is-current="block.id === scheduleStore.session?.current_block_id" :is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)" :is-completed="scheduleStore.completedBlockIds.includes(block.id)"
:elapsed-seconds="blockElapsed(block)"
compact compact
/> />
</div> </div>
@@ -179,6 +180,14 @@ const currentSubjectOptions = computed(() =>
scheduleStore.currentBlock?.subject?.options || [] scheduleStore.currentBlock?.subject?.options || []
) )
function blockElapsed(block) {
const currentId = scheduleStore.session?.current_block_id
if (block.id === currentId && scheduleStore.blockStartedAt && !scheduleStore.isPaused) {
return scheduleStore.blockElapsedOffset + Math.floor((now.value - scheduleStore.blockStartedAt) / 1000)
}
return scheduleStore.blockElapsedCache[block.id] || 0
}
// WebSocket // WebSocket
const { connected: wsConnected } = useWebSocket(childId, (msg) => { const { connected: wsConnected } = useWebSocket(childId, (msg) => {
scheduleStore.applyWsEvent(msg) scheduleStore.applyWsEvent(msg)