Add break time feature to schedule blocks

- Admin: per-block "Break Time" checkbox + duration (min) setting; new
  Break Activities section (global list, same pattern as Morning Routine)
- Dashboard: break timer section appears on blocks with break enabled;
  Start/Pause/Resume/Reset controls work independently of the main timer
- TV: left column switches to amber break badge + countdown during break;
  center column shows configurable Break Activities list
- Backend: break_time_enabled/break_time_minutes columns on schedule_blocks
  (auto-migrated on startup); break_activity_items table + CRUD router;
  break timer events (break_start/pause/resume/reset) stored as TimerEvents
  and broadcast via WebSocket; break_activities included in dashboard
  snapshot and session_update broadcast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 08:40:49 -08:00
parent 13f3e08744
commit 87315b8902
14 changed files with 578 additions and 24 deletions

View File

@@ -14,6 +14,12 @@ export const useScheduleStore = defineStore('schedule', () => {
const dayStartTime = ref(null) // "HH:MM:SS" string or null
const dayEndTime = ref(null) // "HH:MM:SS" string or null
const morningRoutine = ref([]) // list of text strings shown during greeting state
const breakActivities = ref([]) // list of text strings shown during break time
// Break timer state (per-block break time at end of block)
const isBreakMode = ref(false) // currently in break time
const breakStartedAt = ref(null) // Date.now() ms when break counting started
const breakElapsedOffset = ref(0) // break seconds already elapsed
const breakElapsedCache = ref({}) // blockId → total break elapsed seconds
const currentBlock = computed(() =>
session.value?.current_block_id
@@ -42,6 +48,7 @@ export const useScheduleStore = defineStore('schedule', () => {
dayStartTime.value = snapshot.day_start_time || null
dayEndTime.value = snapshot.day_end_time || null
morningRoutine.value = snapshot.morning_routine || []
breakActivities.value = snapshot.break_activities || []
// Restore elapsed time from server-computed value and seed the per-block cache
const serverElapsed = snapshot.block_elapsed_seconds || 0
if (snapshot.session?.current_block_id) {
@@ -54,6 +61,11 @@ export const useScheduleStore = defineStore('schedule', () => {
blockElapsedOffset.value = 0
blockStartedAt.value = null
}
// Reset break state on snapshot (not persisted across page loads)
isBreakMode.value = false
breakStartedAt.value = null
breakElapsedOffset.value = 0
breakElapsedCache.value = {}
}
function applyWsEvent(event) {
@@ -76,6 +88,39 @@ export const useScheduleStore = defineStore('schedule', () => {
blockElapsedCache.value = {}
dayStartTime.value = null
dayEndTime.value = null
isBreakMode.value = false
breakStartedAt.value = null
breakElapsedOffset.value = 0
breakElapsedCache.value = {}
return
}
// Break timer events
if (event.event === 'break_start') {
const elapsed = event.break_elapsed_seconds ?? breakElapsedCache.value[event.block_id] ?? 0
breakElapsedOffset.value = elapsed
if (event.block_id) breakElapsedCache.value[event.block_id] = elapsed
breakStartedAt.value = Date.now()
isBreakMode.value = true
}
if (event.event === 'break_pause') {
if (breakStartedAt.value) {
breakElapsedOffset.value += Math.floor((Date.now() - breakStartedAt.value) / 1000)
}
if (event.block_id) breakElapsedCache.value[event.block_id] = breakElapsedOffset.value
breakStartedAt.value = null
isBreakMode.value = true
}
if (event.event === 'break_resume') {
breakStartedAt.value = Date.now()
isBreakMode.value = true
}
if (event.event === 'break_reset') {
if (event.block_id) breakElapsedCache.value[event.block_id] = 0
breakElapsedOffset.value = 0
breakStartedAt.value = Date.now()
isBreakMode.value = true
}
if (['break_start', 'break_pause', 'break_resume', 'break_reset'].includes(event.event)) {
return
}
// Pause — accumulate elapsed, save to cache, stop counting
@@ -101,6 +146,12 @@ export const useScheduleStore = defineStore('schedule', () => {
}
blockStartedAt.value = Date.now()
isPaused.value = false
// Switching to a new block clears break mode
if (event.block_id !== event.current_block_id || !isBreakMode.value) {
isBreakMode.value = false
breakStartedAt.value = null
breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0
}
}
// Reset — clear elapsed to 0 and start counting immediately
if (event.event === 'reset') {
@@ -121,6 +172,10 @@ export const useScheduleStore = defineStore('schedule', () => {
if (event.block_id) blockElapsedCache.value[event.block_id] = elapsed
blockStartedAt.value = null
isPaused.value = true
// Switching blocks clears break mode
isBreakMode.value = false
breakStartedAt.value = null
breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0
}
// Resume — continue from where we left off
if (event.event === 'resume') {
@@ -212,6 +267,41 @@ export const useScheduleStore = defineStore('schedule', () => {
sendTimerAction(sessionId, 'start', session.value.current_block_id)
}
// Break timer actions
function startBreak(sessionId) {
if (!session.value?.current_block_id) return
const blockId = session.value.current_block_id
isBreakMode.value = true
breakElapsedOffset.value = breakElapsedCache.value[blockId] ?? 0
breakStartedAt.value = Date.now()
sendTimerAction(sessionId, 'break_start', blockId)
}
function pauseBreak(sessionId) {
if (!session.value?.current_block_id) return
if (breakStartedAt.value) {
breakElapsedOffset.value += Math.floor((Date.now() - breakStartedAt.value) / 1000)
}
breakStartedAt.value = null
sendTimerAction(sessionId, 'break_pause', session.value.current_block_id)
}
function resumeBreak(sessionId) {
if (!session.value?.current_block_id) return
breakStartedAt.value = Date.now()
sendTimerAction(sessionId, 'break_resume', session.value.current_block_id)
}
function resetBreak(sessionId) {
if (!session.value?.current_block_id) return
const blockId = session.value.current_block_id
breakElapsedCache.value[blockId] = 0
breakElapsedOffset.value = 0
breakStartedAt.value = Date.now()
isBreakMode.value = true
sendTimerAction(sessionId, 'break_reset', blockId)
}
// Reset the current block's timer to 0 and start counting immediately.
function resetCurrentBlock(sessionId) {
if (!session.value?.current_block_id) return
@@ -235,6 +325,11 @@ export const useScheduleStore = defineStore('schedule', () => {
dayStartTime,
dayEndTime,
morningRoutine,
breakActivities,
isBreakMode,
breakStartedAt,
breakElapsedOffset,
breakElapsedCache,
currentBlock,
progressPercent,
applySnapshot,
@@ -246,5 +341,9 @@ export const useScheduleStore = defineStore('schedule', () => {
selectBlock,
startCurrentBlock,
resetCurrentBlock,
startBreak,
pauseBreak,
resumeBreak,
resetBreak,
}
})

View File

@@ -148,6 +148,37 @@
</div>
</section>
<!-- Break Activities section -->
<section class="section">
<div class="section-header">
<h2>Break Activities</h2>
</div>
<div class="card">
<p class="routine-hint">These items appear in the Activities panel on the TV during break time.</p>
<div class="option-list">
<template v-for="item in breakActivities" :key="item.id">
<div v-if="editingBreakItem && editingBreakItem.id === item.id" class="option-edit-row">
<input v-model="editingBreakItem.text" class="option-input" @keyup.enter="saveBreakItem" />
<button class="btn-sm btn-primary" @click="saveBreakItem">Save</button>
<button class="btn-sm" @click="editingBreakItem = null">Cancel</button>
</div>
<div v-else class="option-row">
<span class="option-text">{{ item.text }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditBreakItem(item)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteBreakItem(item.id)"></button>
</div>
</div>
</template>
<div v-if="breakActivities.length === 0" class="empty-small">No items yet.</div>
</div>
<form class="option-add-row" style="margin-top: 0.75rem" @submit.prevent="addBreakItem">
<input v-model="newBreakText" placeholder="Add a break activity..." class="option-input" required />
<button type="submit" class="btn-primary btn-sm">Add</button>
</form>
</div>
</section>
<!-- Schedules section -->
<section class="section">
<div class="section-header">
@@ -238,6 +269,17 @@
style="width:130px"
/>
<input v-model="editingBlock.label" placeholder="Label (optional)" />
<label class="break-check-label">
<input type="checkbox" v-model="editingBlock.break_time_enabled" />
Break
</label>
<input
v-if="editingBlock.break_time_enabled"
v-model.number="editingBlock.break_time_minutes"
type="number" min="1" max="120"
placeholder="Break (min)"
style="width:100px"
/>
<button type="submit" class="btn-sm btn-primary">Save</button>
<button type="button" class="btn-sm" @click="editingBlock = null">Cancel</button>
</form>
@@ -248,6 +290,9 @@
<span class="block-duration" :class="{ 'block-duration-custom': block.duration_minutes != null }">
{{ blockDurationLabel(block) }}
</span>
<span v-if="block.break_time_enabled" class="break-badge">
{{ block.break_time_minutes ? `${block.break_time_minutes}min` : '' }} break
</span>
<button class="btn-sm" @click="startEditBlock(block)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)"></button>
</div>
@@ -271,6 +316,17 @@
style="width:130px"
/>
<input v-model="newBlock.label" placeholder="Label (optional)" />
<label class="break-check-label">
<input type="checkbox" v-model="newBlock.break_time_enabled" />
Break
</label>
<input
v-if="newBlock.break_time_enabled"
v-model.number="newBlock.break_time_minutes"
type="number" min="1" max="120"
placeholder="Break (min)"
style="width:100px"
/>
<button type="submit" class="btn-primary btn-sm">Add Block</button>
</form>
</div>
@@ -475,12 +531,48 @@ async function deleteRoutineItem(id) {
await loadMorningRoutine()
}
// Break Activities
const breakActivities = ref([])
const newBreakText = ref('')
const editingBreakItem = ref(null)
async function loadBreakActivities() {
const res = await api.get('/api/break-activities')
breakActivities.value = res.data
}
async function addBreakItem() {
await api.post('/api/break-activities', {
text: newBreakText.value,
order_index: breakActivities.value.length,
})
newBreakText.value = ''
await loadBreakActivities()
}
function startEditBreakItem(item) {
editingBreakItem.value = { ...item }
}
async function saveBreakItem() {
await api.patch(`/api/break-activities/${editingBreakItem.value.id}`, {
text: editingBreakItem.value.text,
})
editingBreakItem.value = null
await loadBreakActivities()
}
async function deleteBreakItem(id) {
await api.delete(`/api/break-activities/${id}`)
await loadBreakActivities()
}
// Schedules
const templates = ref([])
const showCreateForm = ref(false)
const editingTemplate = ref(null)
const newTemplate = ref({ name: '', child_id: null, is_default: false })
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', duration_minutes: null, label: '', order_index: 0 })
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', duration_minutes: null, label: '', order_index: 0, break_time_enabled: false, break_time_minutes: null })
const editingBlock = ref(null)
function childName(id) {
@@ -540,7 +632,7 @@ async function addBlock(templateId) {
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
}
await api.post(`/api/schedules/${templateId}/blocks`, payload)
newBlock.value = { subject_id: null, time_start: '', time_end: '', duration_minutes: null, label: '', order_index: 0 }
newBlock.value = { subject_id: null, time_start: '', time_end: '', duration_minutes: null, label: '', order_index: 0, break_time_enabled: false, break_time_minutes: null }
await loadTemplates()
}
@@ -552,6 +644,8 @@ function startEditBlock(block) {
time_end: block.time_end ? block.time_end.slice(0, 5) : '',
duration_minutes: block.duration_minutes ?? null,
label: block.label || '',
break_time_enabled: block.break_time_enabled || false,
break_time_minutes: block.break_time_minutes ?? null,
}
}
@@ -577,7 +671,7 @@ async function saveDayHours(template, which, value) {
onMounted(async () => {
await childrenStore.fetchChildren()
await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine()])
await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities()])
selectedTimezone.value = authStore.timezone
})
</script>
@@ -824,4 +918,25 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.break-badge {
font-size: 0.72rem;
background: #451a03;
color: #fdba74;
border: 1px solid #92400e;
padding: 0.15rem 0.5rem;
border-radius: 999px;
white-space: nowrap;
}
.break-check-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: #94a3b8;
cursor: pointer;
white-space: nowrap;
}
.break-check-label input[type="checkbox"] { cursor: pointer; }
</style>

View File

@@ -72,6 +72,52 @@
:block-elapsed-offset="scheduleStore.blockElapsedOffset"
/>
</div>
<!-- Break Time section -->
<div
v-if="scheduleStore.currentBlock?.break_time_enabled"
class="break-section"
:class="{ 'break-active': scheduleStore.isBreakMode }"
>
<div class="break-header">
<span class="break-icon"></span>
<span class="break-title">Break Time</span>
<span class="break-duration-badge">{{ scheduleStore.currentBlock.break_time_minutes }} min</span>
</div>
<div v-if="scheduleStore.isBreakMode" class="break-timer-display">
<TimerDisplay
compact
:block="breakBlock"
:session="scheduleStore.session"
:is-paused="!scheduleStore.breakStartedAt"
:block-started-at="scheduleStore.breakStartedAt"
:block-elapsed-offset="scheduleStore.breakElapsedOffset"
/>
</div>
<div class="break-actions">
<button
class="btn-sm btn-break"
v-if="!scheduleStore.isBreakMode"
@click="scheduleStore.startBreak(scheduleStore.session.id)"
>Start Break</button>
<button
class="btn-sm btn-break"
v-if="scheduleStore.isBreakMode && scheduleStore.breakStartedAt"
@click="scheduleStore.pauseBreak(scheduleStore.session.id)"
>Pause</button>
<button
class="btn-sm btn-break"
v-if="scheduleStore.isBreakMode && !scheduleStore.breakStartedAt && scheduleStore.breakElapsedOffset > 0"
@click="scheduleStore.resumeBreak(scheduleStore.session.id)"
>Resume</button>
<button
class="btn-sm"
v-if="scheduleStore.isBreakMode"
@click="scheduleStore.resetBreak(scheduleStore.session.id)"
>Reset</button>
</div>
</div>
<div class="session-actions">
<div class="session-actions-left">
<button
@@ -161,6 +207,13 @@ import TimerDisplay from '@/components/TimerDisplay.vue'
const childrenStore = useChildrenStore()
const scheduleStore = useScheduleStore()
const activeChild = computed(() => childrenStore.activeChild)
// Virtual block for break timer (same block but with break duration)
const breakBlock = computed(() => {
const block = scheduleStore.currentBlock
if (!block?.break_time_enabled) return null
return { ...block, duration_minutes: block.break_time_minutes }
})
const showStartDialog = ref(false)
const selectedTemplate = ref(null)
const templates = ref([])
@@ -333,6 +386,34 @@ h1 { font-size: 1.75rem; font-weight: 700; }
.btn-sm.btn-start { border-color: #4f46e5; color: #818cf8; }
.btn-sm.btn-start:hover { background: #4f46e5; color: #fff; }
.break-section {
margin: 0.75rem 0;
background: #1c1207;
border: 1px solid #78350f;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
}
.break-section.break-active { border-color: #f59e0b; background: #1c1a07; }
.break-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.break-icon { font-size: 1rem; }
.break-title { font-size: 0.8rem; font-weight: 600; color: #fbbf24; text-transform: uppercase; letter-spacing: 0.06em; flex: 1; }
.break-duration-badge {
font-size: 0.75rem;
background: #78350f;
color: #fde68a;
padding: 0.1rem 0.45rem;
border-radius: 999px;
}
.break-timer-display { display: flex; justify-content: flex-start; margin-bottom: 0.5rem; }
.break-actions { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.btn-break { border-color: #92400e !important; color: #fbbf24 !important; }
.btn-break:hover { background: #78350f !important; }
.no-session { text-align: center; padding: 1.5rem 0; color: #64748b; }
.no-session p { margin-bottom: 1rem; }

View File

@@ -44,31 +44,59 @@
</div>
</div>
<div class="tv-timer-col" v-else>
<div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
{{ currentSubjectIcon }} {{ currentSubjectName }}
</div>
<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 }}
</div>
<!-- Break mode badge -->
<template v-if="scheduleStore.isBreakMode">
<div class="tv-subject-badge tv-break-badge">
Break Time
</div>
<TimerDisplay
:block="tvBreakBlock"
:session="scheduleStore.session"
:is-paused="!scheduleStore.breakStartedAt"
:block-started-at="scheduleStore.breakStartedAt"
:block-elapsed-offset="scheduleStore.breakElapsedOffset"
/>
</template>
<!-- Normal subject timer -->
<template v-else>
<div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
{{ currentSubjectIcon }} {{ currentSubjectName }}
</div>
<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 }}
</div>
</template>
</div>
<!-- Center: subject options or morning routine -->
<!-- Center: subject options / break message / morning routine -->
<div
class="tv-options-col"
:style="scheduleStore.currentBlock
? { background: currentSubjectColor + '22', borderColor: currentSubjectColor }
: { background: '#1e293b', borderColor: '#334155' }"
:style="scheduleStore.isBreakMode
? { background: '#451a0322', borderColor: '#f59e0b' }
: scheduleStore.currentBlock
? { background: currentSubjectColor + '22', borderColor: currentSubjectColor }
: { background: '#1e293b', borderColor: '#334155' }"
>
<div class="tv-options-title">Activities</div>
<!-- Break time panel -->
<template v-if="scheduleStore.isBreakMode">
<div class="tv-options-title" style="color: #f59e0b;">Break Activities</div>
<div v-if="scheduleStore.breakActivities.length" class="tv-options-list">
<div v-for="(item, i) in scheduleStore.breakActivities" :key="i" class="tv-option-item">
{{ item }}
</div>
</div>
<div v-else class="tv-options-empty">No break activities added yet.</div>
</template>
<!-- Morning routine during greeting state -->
<template v-if="!scheduleStore.currentBlock">
<template v-else-if="!scheduleStore.currentBlock">
<div class="tv-options-title">Activities</div>
<div v-if="scheduleStore.morningRoutine.length" class="tv-options-list">
<div v-for="(item, i) in scheduleStore.morningRoutine" :key="i" class="tv-option-item">
{{ item }}
@@ -78,6 +106,7 @@
</template>
<!-- Subject options during active block -->
<template v-else>
<div class="tv-options-title">Activities</div>
<div v-if="currentSubjectOptions.length" class="tv-options-list">
<div v-for="opt in currentSubjectOptions" :key="opt.id" class="tv-option-item">
{{ opt.text }}
@@ -177,6 +206,20 @@ const firstBlockCountdown = computed(() => {
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
})
// Virtual block for break timer display on TV.
// We don't re-check break_time_enabled here — if isBreakMode is true we always
// want to show a timer. Fall back to the block's own duration when
// break_time_minutes is not set.
const tvBreakBlock = computed(() => {
const block = scheduleStore.currentBlock
if (!block) return null
return {
...block,
duration_minutes: block.break_time_minutes ?? null,
subject: { color: '#f59e0b' },
}
})
// Subject display helpers
const currentSubjectColor = computed(() => {
const block = scheduleStore.currentBlock
@@ -302,6 +345,12 @@ onMounted(async () => {
text-align: center;
}
.tv-break-badge {
background: #92400e;
color: #fde68a;
}
.tv-greeting-col {
justify-content: center;
gap: 1rem;