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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user