Files
homeschool/backend/app/routers/sessions.py
derekc cc599603cf Add manual block start and fix timer display labels
Blocks are now selected without auto-starting the timer. Clicking a block
makes it current (highlighted) but leaves it in a ready state. A "Start"
button (indigo) triggers timing for a fresh block; "Resume" appears for
previously-worked blocks; "Pause" remains while running.

Also fixes the sidebar duration label to show "Done!" when elapsed ≥ total
and "< 1 min" for sub-minute remaining time instead of "0 min".

Backend adds a "select" event type that records an implicit pause for the
previous block, updates current_block_id, and broadcasts is_paused=true
with prev_block_elapsed_seconds so the TV sidebar stays accurate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:09:27 -08:00

281 lines
11 KiB
Python

from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.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.websocket.manager import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
"""Build a snapshot dict and broadcast it to all connected TVs for this child."""
blocks = []
day_start_time = None
day_end_time = None
if session.template_id:
blocks_result = await db.execute(
select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id)
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.order_by(ScheduleBlock.time_start)
)
blocks = [
{
"id": b.id,
"subject_id": b.subject_id,
"subject": {
"id": b.subject.id,
"name": b.subject.name,
"color": b.subject.color,
"icon": b.subject.icon,
"options": [{"id": o.id, "text": o.text, "order_index": o.order_index}
for o in b.subject.options],
} if b.subject else None,
"time_start": str(b.time_start),
"time_end": str(b.time_end),
"duration_minutes": b.duration_minutes,
"label": b.label,
"order_index": b.order_index,
}
for b in blocks_result.scalars().all()
]
template_result = await db.execute(
select(ScheduleTemplate).where(ScheduleTemplate.id == session.template_id)
)
template = template_result.scalar_one_or_none()
if template:
day_start_time = str(template.day_start_time) if template.day_start_time else None
day_end_time = str(template.day_end_time) if template.day_end_time else None
# Gather completed block IDs from timer events
events_result = await db.execute(
select(TimerEvent).where(
TimerEvent.session_id == session.id,
TimerEvent.event_type == "complete",
)
)
completed_ids = [e.block_id for e in events_result.scalars().all() if e.block_id]
# Fetch morning routine items via child → user_id
child_result = await db.execute(select(Child).where(Child.id == session.child_id))
child = child_result.scalar_one_or_none()
morning_routine: list[str] = []
if child:
routine_result = await db.execute(
select(MorningRoutineItem)
.where(MorningRoutineItem.user_id == child.user_id)
.order_by(MorningRoutineItem.order_index, MorningRoutineItem.id)
)
morning_routine = [item.text for item in routine_result.scalars().all()]
payload = {
"event": "session_update",
"session": {
"id": session.id,
"child_id": session.child_id,
"session_date": str(session.session_date),
"is_active": session.is_active,
"current_block_id": session.current_block_id,
},
"blocks": blocks,
"completed_block_ids": completed_ids,
"day_start_time": day_start_time,
"day_end_time": day_end_time,
"morning_routine": morning_routine,
}
await manager.broadcast(session.child_id, payload)
@router.post("", response_model=DailySessionOut, status_code=status.HTTP_201_CREATED)
async def start_session(
body: SessionStart,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Verify child belongs to user
child_result = await db.execute(
select(Child).where(Child.id == body.child_id, Child.user_id == current_user.id)
)
child = child_result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
# Reset strikes at the start of each new day
if child.strikes != 0:
child.strikes = 0
await manager.broadcast(body.child_id, {"event": "strikes_update", "strikes": 0})
session_date = body.session_date or date.today()
# Deactivate any existing active session for this child today
existing = await db.execute(
select(DailySession).where(
DailySession.child_id == body.child_id,
DailySession.session_date == session_date,
DailySession.is_active == True,
)
)
for old in existing.scalars().all():
old.is_active = False
session = DailySession(
child_id=body.child_id,
template_id=body.template_id,
session_date=session_date,
is_active=True,
)
db.add(session)
await db.commit()
await db.refresh(session)
# Record session start as a timer event so it appears in the activity log
db.add(TimerEvent(session_id=session.id, block_id=None, event_type="session_start"))
await db.commit()
await _broadcast_session(db, session)
return session
@router.get("/{session_id}", response_model=DailySessionOut)
async def get_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject))
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.post("/{session_id}/timer", response_model=DailySessionOut)
async def timer_action(
session_id: int,
body: TimerAction,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block).selectinload(ScheduleBlock.subject))
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# When starting or selecting a different block, implicitly pause the previous
# one so the activity log stays accurate and elapsed time is preserved.
prev_block_id = None
prev_block_elapsed_seconds = 0
if body.event_type in ("start", "select") and body.block_id is not None:
prev_block_id = session.current_block_id
if prev_block_id and prev_block_id != body.block_id:
db.add(TimerEvent(
session_id=session.id,
block_id=prev_block_id,
event_type="pause",
))
# Compute prev block's accumulated elapsed so all clients (especially TV)
# can update their local cache without relying on a separate pause broadcast.
prev_tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == prev_block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]),
)
.order_by(TimerEvent.occurred_at)
)
prev_tick_events = prev_tick_result.scalars().all()
_last_start = None
for e in prev_tick_events:
if e.event_type in ("start", "resume"):
_last_start = e.occurred_at
elif e.event_type == "pause" and _last_start:
prev_block_elapsed_seconds += int((e.occurred_at - _last_start).total_seconds())
_last_start = None
# Add the current open interval (up to the implicit pause just recorded)
if _last_start:
prev_block_elapsed_seconds += int((datetime.utcnow() - _last_start).total_seconds())
# Update current block if provided
if body.block_id is not None:
session.current_block_id = body.block_id
# Record the timer event
event = TimerEvent(
session_id=session.id,
block_id=body.block_id or session.current_block_id,
event_type=body.event_type,
)
db.add(event)
# Mark session complete if event is session-level complete
if body.event_type == "complete" and body.block_id is None:
session.is_active = False
await db.commit()
await db.refresh(session)
# For 'start' and 'select' events, compute accumulated elapsed for the new
# block from previous intervals so every client can restore the correct offset.
block_elapsed_seconds = 0
if body.event_type in ("start", "select") and event.block_id:
tick_result = await db.execute(
select(TimerEvent)
.where(
TimerEvent.session_id == session.id,
TimerEvent.block_id == event.block_id,
TimerEvent.event_type.in_(["start", "resume", "pause"]),
)
.order_by(TimerEvent.occurred_at)
)
tick_events = tick_result.scalars().all()
last_start_time = None
for e in tick_events:
if e.event_type in ("start", "resume"):
last_start_time = e.occurred_at
elif e.event_type == "pause" and last_start_time:
block_elapsed_seconds += int((e.occurred_at - last_start_time).total_seconds())
last_start_time = None
# For 'start' also count the current open interval
if body.event_type == "start" and last_start_time:
block_elapsed_seconds += int((datetime.utcnow() - last_start_time).total_seconds())
# Broadcast the timer event to all TV clients
ws_payload = {
"event": body.event_type,
"session_id": session.id,
"block_id": event.block_id,
"current_block_id": session.current_block_id,
"is_active": session.is_active,
"is_paused": body.event_type == "select",
"block_elapsed_seconds": block_elapsed_seconds,
"prev_block_id": prev_block_id,
"prev_block_elapsed_seconds": prev_block_elapsed_seconds,
}
await manager.broadcast(session.child_id, ws_payload)
return session