diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index d4bc51b..c5d3a8a 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -170,6 +170,17 @@ async def timer_action( 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: + 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 if body.block_id is not None: session.current_block_id = body.block_id diff --git a/frontend/src/components/ScheduleBlock.vue b/frontend/src/components/ScheduleBlock.vue index b02a316..063ee49 100644 --- a/frontend/src/components/ScheduleBlock.vue +++ b/frontend/src/components/ScheduleBlock.vue @@ -33,6 +33,7 @@ const props = defineProps({ isCurrent: { type: Boolean, default: false }, isCompleted: { type: Boolean, default: false }, compact: { type: Boolean, default: false }, + elapsedSeconds: { type: Number, default: 0 }, }) function formatTime(str) { @@ -46,17 +47,30 @@ function formatTime(str) { const subjectColor = computed(() => props.block.subject?.color || '#475569') const subjectName = computed(() => props.block.subject?.name || null) -const durationLabel = computed(() => { - if (props.block.duration_minutes != null) return `${props.block.duration_minutes} min` +function blockTotalSeconds() { + if (props.block.duration_minutes != null) return props.block.duration_minutes * 60 const start = props.block.time_start const end = props.block.time_end if (start && end) { const [sh, sm] = start.split(':').map(Number) const [eh, em] = end.split(':').map(Number) - const mins = (eh * 60 + em) - (sh * 60 + sm) - if (mins > 0) return `${mins} min` + return ((eh * 60 + em) - (sh * 60 + sm)) * 60 } - 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` }) diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index b54ae3f..e0a2fac 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -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 { session, blocks, @@ -140,6 +162,7 @@ export const useScheduleStore = defineStore('schedule', () => { isPaused, blockStartedAt, blockElapsedOffset, + blockElapsedCache, dayStartTime, dayEndTime, currentBlock, @@ -149,5 +172,6 @@ export const useScheduleStore = defineStore('schedule', () => { fetchDashboard, startSession, sendTimerAction, + switchBlock, } }) diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index ce474b6..2233444 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -74,6 +74,7 @@ :block="block" :is-current="block.id === scheduleStore.session?.current_block_id" :is-completed="scheduleStore.completedBlockIds.includes(block.id)" + :elapsed-seconds="blockElapsed(block)" @click="selectBlock(block)" /> @@ -151,9 +152,8 @@ const showStartDialog = ref(false) const selectedTemplate = ref(null) const templates = ref([]) -// Day progress clock (minute precision is enough) const now = ref(new Date()) -setInterval(() => { now.value = new Date() }, 60000) +setInterval(() => { now.value = new Date() }, 1000) function timeStrToMinutes(str) { if (!str) return null @@ -217,7 +217,15 @@ async function sendAction(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 const currentId = scheduleStore.session.current_block_id @@ -231,16 +239,8 @@ async function selectBlock(block) { // Clicking the current block while running → do nothing if (block.id === currentId) return - // Switching to a different block — pause the current one first if it's running - if (currentId && !scheduleStore.isPaused) { - 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) + // Switch to the new block in one atomic step (no separate pause request) + scheduleStore.switchBlock(scheduleStore.session.id, block.id) } onMounted(async () => { diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index f817441..03ac27b 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -86,6 +86,7 @@ :block="block" :is-current="block.id === scheduleStore.session?.current_block_id" :is-completed="scheduleStore.completedBlockIds.includes(block.id)" + :elapsed-seconds="blockElapsed(block)" compact /> @@ -179,6 +180,14 @@ const currentSubjectOptions = computed(() => 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 const { connected: wsConnected } = useWebSocket(childId, (msg) => { scheduleStore.applyWsEvent(msg)