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.database import engine
from app.models import Base from app.models import Base
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard 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 from app.websocket.manager import manager
settings = get_settings() 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_start_time", "TIME NULL")
await _add_column_if_missing(conn, "schedule_templates", "day_end_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", "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, "children", "strikes", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'") await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
yield yield
@@ -64,6 +66,7 @@ app.include_router(schedules.router)
app.include_router(sessions.router) app.include_router(sessions.router)
app.include_router(logs.router) app.include_router(logs.router)
app.include_router(morning_routine.router) app.include_router(morning_routine.router)
app.include_router(break_activity.router)
app.include_router(dashboard.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.session import DailySession, TimerEvent, TimerEventType
from app.models.activity import ActivityLog from app.models.activity import ActivityLog
from app.models.morning_routine import MorningRoutineItem from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.strike import StrikeEvent from app.models.strike import StrikeEvent
__all__ = [ __all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"TimerEventType", "TimerEventType",
"ActivityLog", "ActivityLog",
"MorningRoutineItem", "MorningRoutineItem",
"BreakActivityItem",
"StrikeEvent", "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 label: Mapped[str | None] = mapped_column(String(100), nullable=True) # override subject name
notes: Mapped[str | None] = mapped_column(Text, nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0) 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") template: Mapped["ScheduleTemplate"] = relationship("ScheduleTemplate", back_populates="blocks")
subject: Mapped["Subject | None"] = relationship("Subject", back_populates="schedule_blocks") # noqa: F821 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.dependencies import get_db
from app.models.child import Child from app.models.child import Child
from app.models.morning_routine import MorningRoutineItem from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.schedule import ScheduleBlock, ScheduleTemplate from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
from app.models.session import DailySession, TimerEvent 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()] 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( return DashboardSnapshot(
session=session, session=session,
child=child, 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_start_time=day_start_time,
day_end_time=day_end_time, day_end_time=day_end_time,
morning_routine=morning_routine, 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.dependencies import get_db, get_current_user
from app.models.child import Child from app.models.child import Child
from app.models.morning_routine import MorningRoutineItem from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem
from app.models.schedule import ScheduleBlock, ScheduleTemplate from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
from app.models.session import DailySession, TimerEvent from app.models.session import DailySession, TimerEvent
from app.models.user import User from app.models.user import User
from app.schemas.session import DailySessionOut, SessionStart, TimerAction 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 from app.websocket.manager import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"]) 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, "duration_minutes": b.duration_minutes,
"label": b.label, "label": b.label,
"order_index": b.order_index, "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() 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()] 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 = { payload = {
"event": "session_update", "event": "session_update",
"session": { "session": {
@@ -96,6 +108,7 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
"day_start_time": day_start_time, "day_start_time": day_start_time,
"day_end_time": day_end_time, "day_end_time": day_end_time,
"morning_routine": morning_routine, "morning_routine": morning_routine,
"break_activities": break_activities,
} }
await manager.broadcast(session.child_id, payload) await manager.broadcast(session.child_id, payload)
@@ -185,6 +198,35 @@ 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")
# 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 # When switching to a different block (start / select / reset), implicitly
# pause the previous block so the activity log stays accurate. # pause the previous block so the activity log stays accurate.
prev_block_id = None prev_block_id = None

View File

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

View File

@@ -47,3 +47,4 @@ class DashboardSnapshot(BaseModel):
day_start_time: time | None = None day_start_time: time | None = None
day_end_time: time | None = None day_end_time: time | None = None
morning_routine: list[str] = [] # text items shown on TV during greeting state 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" is_paused = bool(tick_events) and tick_events[-1].event_type == "pause"
return int(elapsed), is_paused 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 dayStartTime = ref(null) // "HH:MM:SS" string or null
const dayEndTime = 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 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(() => const currentBlock = computed(() =>
session.value?.current_block_id session.value?.current_block_id
@@ -42,6 +48,7 @@ export const useScheduleStore = defineStore('schedule', () => {
dayStartTime.value = snapshot.day_start_time || null dayStartTime.value = snapshot.day_start_time || null
dayEndTime.value = snapshot.day_end_time || null dayEndTime.value = snapshot.day_end_time || null
morningRoutine.value = snapshot.morning_routine || [] morningRoutine.value = snapshot.morning_routine || []
breakActivities.value = snapshot.break_activities || []
// Restore elapsed time from server-computed value and seed the per-block cache // Restore elapsed time from server-computed value and seed the per-block cache
const serverElapsed = snapshot.block_elapsed_seconds || 0 const serverElapsed = snapshot.block_elapsed_seconds || 0
if (snapshot.session?.current_block_id) { if (snapshot.session?.current_block_id) {
@@ -54,6 +61,11 @@ export const useScheduleStore = defineStore('schedule', () => {
blockElapsedOffset.value = 0 blockElapsedOffset.value = 0
blockStartedAt.value = null 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) { function applyWsEvent(event) {
@@ -76,6 +88,39 @@ export const useScheduleStore = defineStore('schedule', () => {
blockElapsedCache.value = {} blockElapsedCache.value = {}
dayStartTime.value = null dayStartTime.value = null
dayEndTime.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 return
} }
// Pause — accumulate elapsed, save to cache, stop counting // Pause — accumulate elapsed, save to cache, stop counting
@@ -101,6 +146,12 @@ export const useScheduleStore = defineStore('schedule', () => {
} }
blockStartedAt.value = Date.now() blockStartedAt.value = Date.now()
isPaused.value = false 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 // Reset — clear elapsed to 0 and start counting immediately
if (event.event === 'reset') { if (event.event === 'reset') {
@@ -121,6 +172,10 @@ export const useScheduleStore = defineStore('schedule', () => {
if (event.block_id) blockElapsedCache.value[event.block_id] = elapsed if (event.block_id) blockElapsedCache.value[event.block_id] = elapsed
blockStartedAt.value = null blockStartedAt.value = null
isPaused.value = true 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 // Resume — continue from where we left off
if (event.event === 'resume') { if (event.event === 'resume') {
@@ -212,6 +267,41 @@ export const useScheduleStore = defineStore('schedule', () => {
sendTimerAction(sessionId, 'start', session.value.current_block_id) 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. // Reset the current block's timer to 0 and start counting immediately.
function resetCurrentBlock(sessionId) { function resetCurrentBlock(sessionId) {
if (!session.value?.current_block_id) return if (!session.value?.current_block_id) return
@@ -235,6 +325,11 @@ export const useScheduleStore = defineStore('schedule', () => {
dayStartTime, dayStartTime,
dayEndTime, dayEndTime,
morningRoutine, morningRoutine,
breakActivities,
isBreakMode,
breakStartedAt,
breakElapsedOffset,
breakElapsedCache,
currentBlock, currentBlock,
progressPercent, progressPercent,
applySnapshot, applySnapshot,
@@ -246,5 +341,9 @@ export const useScheduleStore = defineStore('schedule', () => {
selectBlock, selectBlock,
startCurrentBlock, startCurrentBlock,
resetCurrentBlock, resetCurrentBlock,
startBreak,
pauseBreak,
resumeBreak,
resetBreak,
} }
}) })

View File

@@ -148,6 +148,37 @@
</div> </div>
</section> </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 --> <!-- Schedules section -->
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
@@ -238,6 +269,17 @@
style="width:130px" style="width:130px"
/> />
<input v-model="editingBlock.label" placeholder="Label (optional)" /> <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="submit" class="btn-sm btn-primary">Save</button>
<button type="button" class="btn-sm" @click="editingBlock = null">Cancel</button> <button type="button" class="btn-sm" @click="editingBlock = null">Cancel</button>
</form> </form>
@@ -248,6 +290,9 @@
<span class="block-duration" :class="{ 'block-duration-custom': block.duration_minutes != null }"> <span class="block-duration" :class="{ 'block-duration-custom': block.duration_minutes != null }">
{{ blockDurationLabel(block) }} {{ blockDurationLabel(block) }}
</span> </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" @click="startEditBlock(block)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)"></button> <button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)"></button>
</div> </div>
@@ -271,6 +316,17 @@
style="width:130px" style="width:130px"
/> />
<input v-model="newBlock.label" placeholder="Label (optional)" /> <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> <button type="submit" class="btn-primary btn-sm">Add Block</button>
</form> </form>
</div> </div>
@@ -475,12 +531,48 @@ async function deleteRoutineItem(id) {
await loadMorningRoutine() 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 // Schedules
const templates = ref([]) const templates = ref([])
const showCreateForm = ref(false) const showCreateForm = ref(false)
const editingTemplate = ref(null) const editingTemplate = ref(null)
const newTemplate = ref({ name: '', child_id: null, is_default: false }) 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) const editingBlock = ref(null)
function childName(id) { function childName(id) {
@@ -540,7 +632,7 @@ async function addBlock(templateId) {
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0, order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
} }
await api.post(`/api/schedules/${templateId}/blocks`, payload) 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() await loadTemplates()
} }
@@ -552,6 +644,8 @@ function startEditBlock(block) {
time_end: block.time_end ? block.time_end.slice(0, 5) : '', time_end: block.time_end ? block.time_end.slice(0, 5) : '',
duration_minutes: block.duration_minutes ?? null, duration_minutes: block.duration_minutes ?? null,
label: block.label || '', 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 () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine()]) await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities()])
selectedTimezone.value = authStore.timezone selectedTimezone.value = authStore.timezone
}) })
</script> </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 { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; } .btn-sm.btn-danger:hover { background: #7f1d1d; }
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; } .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> </style>

View File

@@ -72,6 +72,52 @@
:block-elapsed-offset="scheduleStore.blockElapsedOffset" :block-elapsed-offset="scheduleStore.blockElapsedOffset"
/> />
</div> </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">
<div class="session-actions-left"> <div class="session-actions-left">
<button <button
@@ -161,6 +207,13 @@ import TimerDisplay from '@/components/TimerDisplay.vue'
const childrenStore = useChildrenStore() const childrenStore = useChildrenStore()
const scheduleStore = useScheduleStore() const scheduleStore = useScheduleStore()
const activeChild = computed(() => childrenStore.activeChild) 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 showStartDialog = ref(false)
const selectedTemplate = ref(null) const selectedTemplate = ref(null)
const templates = ref([]) 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 { border-color: #4f46e5; color: #818cf8; }
.btn-sm.btn-start:hover { background: #4f46e5; color: #fff; } .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 { text-align: center; padding: 1.5rem 0; color: #64748b; }
.no-session p { margin-bottom: 1rem; } .no-session p { margin-bottom: 1rem; }

View File

@@ -44,6 +44,21 @@
</div> </div>
</div> </div>
<div class="tv-timer-col" v-else> <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 }"> <div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
{{ currentSubjectIcon }} {{ currentSubjectName }} {{ currentSubjectIcon }} {{ currentSubjectName }}
</div> </div>
@@ -57,18 +72,31 @@
<div class="tv-block-notes" v-if="scheduleStore.currentBlock.notes"> <div class="tv-block-notes" v-if="scheduleStore.currentBlock.notes">
{{ scheduleStore.currentBlock.notes }} {{ scheduleStore.currentBlock.notes }}
</div> </div>
</template>
</div> </div>
<!-- Center: subject options or morning routine --> <!-- Center: subject options / break message / morning routine -->
<div <div
class="tv-options-col" class="tv-options-col"
:style="scheduleStore.currentBlock :style="scheduleStore.isBreakMode
? { background: '#451a0322', borderColor: '#f59e0b' }
: scheduleStore.currentBlock
? { background: currentSubjectColor + '22', borderColor: currentSubjectColor } ? { background: currentSubjectColor + '22', borderColor: currentSubjectColor }
: { background: '#1e293b', borderColor: '#334155' }" : { 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 --> <!-- 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-if="scheduleStore.morningRoutine.length" class="tv-options-list">
<div v-for="(item, i) in scheduleStore.morningRoutine" :key="i" class="tv-option-item"> <div v-for="(item, i) in scheduleStore.morningRoutine" :key="i" class="tv-option-item">
{{ item }} {{ item }}
@@ -78,6 +106,7 @@
</template> </template>
<!-- Subject options during active block --> <!-- Subject options during active block -->
<template v-else> <template v-else>
<div class="tv-options-title">Activities</div>
<div v-if="currentSubjectOptions.length" class="tv-options-list"> <div v-if="currentSubjectOptions.length" class="tv-options-list">
<div v-for="opt in currentSubjectOptions" :key="opt.id" class="tv-option-item"> <div v-for="opt in currentSubjectOptions" :key="opt.id" class="tv-option-item">
{{ opt.text }} {{ opt.text }}
@@ -177,6 +206,20 @@ const firstBlockCountdown = computed(() => {
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` 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 // Subject display helpers
const currentSubjectColor = computed(() => { const currentSubjectColor = computed(() => {
const block = scheduleStore.currentBlock const block = scheduleStore.currentBlock
@@ -302,6 +345,12 @@ onMounted(async () => {
text-align: center; text-align: center;
} }
.tv-break-badge {
background: #92400e;
color: #fde68a;
}
.tv-greeting-col { .tv-greeting-col {
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;