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:
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
15
backend/app/models/break_activity.py
Normal file
15
backend/app/models/break_activity.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
97
backend/app/routers/break_activity.py
Normal file
97
backend/app/routers/break_activity.py
Normal 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()
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user