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

@@ -9,7 +9,7 @@ from app.config import get_settings
from app.database import engine
from app.models import Base
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.routers import morning_routine
from app.routers import morning_routine, break_activity
from app.websocket.manager import manager
settings = get_settings()
@@ -33,6 +33,8 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "schedule_templates", "day_start_time", "TIME NULL")
await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL")
await _add_column_if_missing(conn, "schedule_blocks", "duration_minutes", "INT NULL")
await _add_column_if_missing(conn, "schedule_blocks", "break_time_enabled", "TINYINT(1) NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "schedule_blocks", "break_time_minutes", "INT NULL")
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
yield
@@ -64,6 +66,7 @@ app.include_router(schedules.router)
app.include_router(sessions.router)
app.include_router(logs.router)
app.include_router(morning_routine.router)
app.include_router(break_activity.router)
app.include_router(dashboard.router)

View File

@@ -7,6 +7,7 @@ from app.models.schedule import ScheduleTemplate, ScheduleBlock
from app.models.session import DailySession, TimerEvent, TimerEventType
from app.models.activity import ActivityLog
from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.strike import StrikeEvent
__all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"TimerEventType",
"ActivityLog",
"MorningRoutineItem",
"BreakActivityItem",
"StrikeEvent",
]

View File

@@ -0,0 +1,15 @@
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class BreakActivityItem(Base):
__tablename__ = "break_activity_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0)
user: Mapped["User"] = relationship("User") # noqa: F821

View File

@@ -43,6 +43,8 @@ class ScheduleBlock(Base):
label: Mapped[str | None] = mapped_column(String(100), nullable=True) # override subject name
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0)
break_time_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
break_time_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
template: Mapped["ScheduleTemplate"] = relationship("ScheduleTemplate", back_populates="blocks")
subject: Mapped["Subject | None"] = relationship("Subject", back_populates="schedule_blocks") # noqa: F821

View File

@@ -0,0 +1,97 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.break_activity import BreakActivityItem
from app.models.user import User
router = APIRouter(prefix="/api/break-activities", tags=["break-activities"])
class BreakActivityItemOut(BaseModel):
id: int
text: str
order_index: int
model_config = {"from_attributes": True}
class BreakActivityItemCreate(BaseModel):
text: str
order_index: int = 0
class BreakActivityItemUpdate(BaseModel):
text: str | None = None
order_index: int | None = None
@router.get("", response_model=list[BreakActivityItemOut])
async def list_items(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BreakActivityItem)
.where(BreakActivityItem.user_id == current_user.id)
.order_by(BreakActivityItem.order_index, BreakActivityItem.id)
)
return result.scalars().all()
@router.post("", response_model=BreakActivityItemOut, status_code=status.HTTP_201_CREATED)
async def create_item(
body: BreakActivityItemCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
item = BreakActivityItem(user_id=current_user.id, text=body.text, order_index=body.order_index)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.patch("/{item_id}", response_model=BreakActivityItemOut)
async def update_item(
item_id: int,
body: BreakActivityItemUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BreakActivityItem).where(
BreakActivityItem.id == item_id,
BreakActivityItem.user_id == current_user.id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if body.text is not None:
item.text = body.text
if body.order_index is not None:
item.order_index = body.order_index
await db.commit()
await db.refresh(item)
return item
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(
item_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(BreakActivityItem).where(
BreakActivityItem.id == item_id,
BreakActivityItem.user_id == current_user.id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Item not found")
await db.delete(item)
await db.commit()

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload
from app.dependencies import get_db
from app.models.child import Child
from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
from app.models.session import DailySession, TimerEvent
@@ -86,6 +87,13 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
)
morning_routine = [item.text for item in routine_result.scalars().all()]
break_result = await db.execute(
select(BreakActivityItem)
.where(BreakActivityItem.user_id == child.user_id)
.order_by(BreakActivityItem.order_index, BreakActivityItem.id)
)
break_activities = [item.text for item in break_result.scalars().all()]
return DashboardSnapshot(
session=session,
child=child,
@@ -96,4 +104,5 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
day_start_time=day_start_time,
day_end_time=day_end_time,
morning_routine=morning_routine,
break_activities=break_activities,
)

View File

@@ -8,12 +8,13 @@ from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
from app.models.session import DailySession, TimerEvent
from app.models.user import User
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
from app.utils.timer import compute_block_elapsed
from app.utils.timer import compute_block_elapsed, compute_break_elapsed
from app.websocket.manager import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -49,6 +50,8 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
"duration_minutes": b.duration_minutes,
"label": b.label,
"order_index": b.order_index,
"break_time_enabled": b.break_time_enabled,
"break_time_minutes": b.break_time_minutes,
}
for b in blocks_result.scalars().all()
]
@@ -82,6 +85,15 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
)
morning_routine = [item.text for item in routine_result.scalars().all()]
break_activities: list[str] = []
if child:
break_result = await db.execute(
select(BreakActivityItem)
.where(BreakActivityItem.user_id == child.user_id)
.order_by(BreakActivityItem.order_index, BreakActivityItem.id)
)
break_activities = [item.text for item in break_result.scalars().all()]
payload = {
"event": "session_update",
"session": {
@@ -96,6 +108,7 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
"day_start_time": day_start_time,
"day_end_time": day_end_time,
"morning_routine": morning_routine,
"break_activities": break_activities,
}
await manager.broadcast(session.child_id, payload)
@@ -185,6 +198,35 @@ async def timer_action(
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Break-time events are handled separately — they don't switch blocks or
# trigger implicit pauses. Just record the event and broadcast.
BREAK_EVENTS = {"break_start", "break_pause", "break_resume", "break_reset"}
if body.event_type in BREAK_EVENTS:
block_id = body.block_id or session.current_block_id
event = TimerEvent(
session_id=session.id,
block_id=block_id,
event_type=body.event_type,
)
db.add(event)
await db.commit()
await db.refresh(session)
break_elapsed_seconds = 0
if body.event_type in ("break_start", "break_reset") and block_id:
break_elapsed_seconds, _ = await compute_break_elapsed(db, session.id, block_id)
ws_payload = {
"event": body.event_type,
"session_id": session.id,
"block_id": block_id,
"current_block_id": session.current_block_id,
"is_active": session.is_active,
"break_elapsed_seconds": break_elapsed_seconds,
}
await manager.broadcast(session.child_id, ws_payload)
return session
# When switching to a different block (start / select / reset), implicitly
# pause the previous block so the activity log stays accurate.
prev_block_id = None

View File

@@ -11,6 +11,8 @@ class ScheduleBlockCreate(BaseModel):
label: str | None = None
notes: str | None = None
order_index: int = 0
break_time_enabled: bool = False
break_time_minutes: int | None = None
class ScheduleBlockUpdate(BaseModel):
@@ -21,6 +23,8 @@ class ScheduleBlockUpdate(BaseModel):
label: str | None = None
notes: str | None = None
order_index: int | None = None
break_time_enabled: bool | None = None
break_time_minutes: int | None = None
class ScheduleBlockOut(BaseModel):
@@ -33,6 +37,8 @@ class ScheduleBlockOut(BaseModel):
label: str | None
notes: str | None
order_index: int
break_time_enabled: bool
break_time_minutes: int | None
model_config = {"from_attributes": True}

View File

@@ -47,3 +47,4 @@ class DashboardSnapshot(BaseModel):
day_start_time: time | None = None
day_end_time: time | None = None
morning_routine: list[str] = [] # text items shown on TV during greeting state
break_activities: list[str] = [] # text items shown on TV during break time

View File

@@ -42,3 +42,36 @@ async def compute_block_elapsed(
is_paused = bool(tick_events) and tick_events[-1].event_type == "pause"
return int(elapsed), is_paused
async def compute_break_elapsed(
db: AsyncSession, session_id: int, block_id: int
) -> tuple[int, bool]:
"""Return (break_elapsed_seconds, is_break_paused) for a block's break timer."""
tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session_id,
TimerEvent.block_id == block_id,
TimerEvent.event_type.in_(["break_start", "break_resume", "break_pause", "break_reset"]),
)
.order_by(TimerEvent.occurred_at)
)
tick_events = tick_result.scalars().all()
elapsed = 0.0
last_start = None
for e in tick_events:
if e.event_type == "break_reset":
elapsed = 0.0
last_start = e.occurred_at
elif e.event_type in ("break_start", "break_resume"):
last_start = e.occurred_at
elif e.event_type == "break_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()
is_paused = bool(tick_events) and tick_events[-1].event_type == "break_pause"
return int(elapsed), is_paused

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,6 +44,21 @@
</div>
</div>
<div class="tv-timer-col" v-else>
<!-- 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>
@@ -57,18 +72,31 @@
<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
: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;