Add per-block agenda overrides for daily sessions
- Add 📝 Agenda button to each block in Today's Schedule on the Dashboard - Dialog allows setting a free-text activity/note for that block for the current day - Agenda replaces subject options in the TV center panel while set; clears on session end - Backend: new SessionBlockAgenda model, PUT /api/sessions/{id}/blocks/{block_id}/agenda - Agendas included in dashboard snapshot and session_update WS broadcast - New agenda_update WS event keeps TV in sync live when agenda is saved or cleared - Update README with feature description, project structure, and WS event table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from app.models.morning_routine import MorningRoutineItem
|
||||
from app.models.break_activity import BreakActivityItem
|
||||
from app.models.strike import StrikeEvent
|
||||
from app.models.rule import RuleItem
|
||||
from app.models.session_block_agenda import SessionBlockAgenda
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -28,4 +29,5 @@ __all__ = [
|
||||
"BreakActivityItem",
|
||||
"StrikeEvent",
|
||||
"RuleItem",
|
||||
"SessionBlockAgenda",
|
||||
]
|
||||
|
||||
18
backend/app/models/session_block_agenda.py
Normal file
18
backend/app/models/session_block_agenda.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class SessionBlockAgenda(Base):
|
||||
__tablename__ = "session_block_agendas"
|
||||
__table_args__ = (UniqueConstraint("session_id", "block_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("daily_sessions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
block_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("schedule_blocks.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
@@ -14,6 +14,7 @@ from app.models.child import Child
|
||||
from app.models.morning_routine import MorningRoutineItem
|
||||
from app.models.break_activity import BreakActivityItem
|
||||
from app.models.schedule import ScheduleBlock
|
||||
from app.models.session_block_agenda import SessionBlockAgenda
|
||||
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
|
||||
from app.models.session import DailySession, TimerEvent
|
||||
from app.schemas.session import DashboardSnapshot
|
||||
@@ -109,6 +110,15 @@ async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
break_activities = [item.text for item in break_result.scalars().all()]
|
||||
|
||||
block_agendas: dict[str, str] = {}
|
||||
if session:
|
||||
agendas_result = await db.execute(
|
||||
select(SessionBlockAgenda).where(SessionBlockAgenda.session_id == session.id)
|
||||
)
|
||||
block_agendas = {
|
||||
str(item.block_id): item.text for item in agendas_result.scalars().all()
|
||||
}
|
||||
|
||||
return DashboardSnapshot(
|
||||
session=session,
|
||||
child=child,
|
||||
@@ -121,4 +131,5 @@ async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)):
|
||||
is_break_active=is_break_active,
|
||||
break_elapsed_seconds=break_elapsed_seconds,
|
||||
is_break_paused=is_break_paused,
|
||||
block_agendas=block_agendas,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -12,6 +13,7 @@ from app.models.break_activity import BreakActivityItem
|
||||
from app.models.schedule import ScheduleBlock
|
||||
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
|
||||
from app.models.session import DailySession, TimerEvent
|
||||
from app.models.session_block_agenda import SessionBlockAgenda
|
||||
from app.models.user import User
|
||||
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
|
||||
from sqlalchemy import delete as sql_delete
|
||||
@@ -85,6 +87,13 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
|
||||
)
|
||||
break_activities = [item.text for item in break_result.scalars().all()]
|
||||
|
||||
agendas_result = await db.execute(
|
||||
select(SessionBlockAgenda).where(SessionBlockAgenda.session_id == session.id)
|
||||
)
|
||||
block_agendas = {
|
||||
str(item.block_id): item.text for item in agendas_result.scalars().all()
|
||||
}
|
||||
|
||||
payload = {
|
||||
"event": "session_update",
|
||||
"session": {
|
||||
@@ -98,6 +107,7 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
|
||||
"completed_block_ids": completed_ids,
|
||||
"morning_routine": morning_routine,
|
||||
"break_activities": break_activities,
|
||||
"block_agendas": block_agendas,
|
||||
}
|
||||
await manager.broadcast(session.child_id, payload)
|
||||
|
||||
@@ -308,3 +318,49 @@ async def timer_action(
|
||||
await manager.broadcast(session.child_id, ws_payload)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
class AgendaUpdate(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.put("/{session_id}/blocks/{block_id}/agenda", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def set_block_agenda(
|
||||
session_id: int,
|
||||
block_id: int,
|
||||
body: AgendaUpdate,
|
||||
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)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(SessionBlockAgenda).where(
|
||||
SessionBlockAgenda.session_id == session_id,
|
||||
SessionBlockAgenda.block_id == block_id,
|
||||
)
|
||||
)
|
||||
agenda = existing.scalar_one_or_none()
|
||||
|
||||
clean_text = body.text.strip()
|
||||
if clean_text:
|
||||
if agenda:
|
||||
agenda.text = clean_text
|
||||
else:
|
||||
db.add(SessionBlockAgenda(session_id=session_id, block_id=block_id, text=clean_text))
|
||||
elif agenda:
|
||||
await db.delete(agenda)
|
||||
|
||||
await db.commit()
|
||||
await manager.broadcast(session.child_id, {
|
||||
"event": "agenda_update",
|
||||
"block_id": block_id,
|
||||
"text": clean_text,
|
||||
})
|
||||
|
||||
@@ -49,3 +49,4 @@ class DashboardSnapshot(BaseModel):
|
||||
is_break_active: bool = False # whether break mode is currently active
|
||||
break_elapsed_seconds: int = 0 # seconds already elapsed in the break timer
|
||||
is_break_paused: bool = False # whether the break timer is paused
|
||||
block_agendas: dict[str, str] = {} # block_id → agenda text override for today
|
||||
|
||||
Reference in New Issue
Block a user