From fdd85d3df5808ccbc15a80b18aca9ab4398c64e5 Mon Sep 17 00:00:00 2001 From: derekc Date: Thu, 19 Mar 2026 07:58:30 -0700 Subject: [PATCH] Add per-block agenda overrides for daily sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 10 +- backend/app/models/__init__.py | 2 + backend/app/models/session_block_agenda.py | 18 +++ backend/app/routers/dashboard.py | 11 ++ backend/app/routers/sessions.py | 56 +++++++++ backend/app/schemas/session.py | 1 + frontend/src/stores/schedule.js | 15 +++ frontend/src/views/DashboardView.vue | 126 +++++++++++++++++++-- frontend/src/views/TVView.vue | 24 +++- 9 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 backend/app/models/session_block_agenda.py diff --git a/README.md b/README.md index de73639..a225714 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning - **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break. - **Rules & Expectations** — Define a list of household rules or expectations in Admin → Rules & Expectations. Items can be reordered by dragging with the handle on the left. From the Dashboard, press the **Rules/Expectations** button in the Overlays card to show them as a full-screen numbered overlay on the TV. The button turns highlighted with a pulsing **LIVE** badge while the overlay is active. Press it again to dismiss. Tapping anywhere on the TV overlay also dismisses it locally. - **Dashboard Overlays** — A dedicated card on the parent Dashboard groups controls that push full-screen content to the TV. Currently contains the Rules/Expectations overlay button; designed to accommodate additional overlay types in the future. +- **Block Agenda Override** — Each block in the Today's Schedule card has a 📝 button. Clicking it opens a dialog where you can type a custom activity or note for that block for the current day (e.g. "Chapter 5 reading", "Worksheet page 12"). While an agenda is set, the TV center panel shows that text instead of the block's normal subject options. Agendas are saved per session and automatically forgotten when the day ends — they never carry over to future sessions. The TV updates live via WebSocket as soon as an agenda is saved or cleared. - **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **Start** and **Finish**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks. - **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page. - **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, **Done** to mark it as fully complete, and **Reset** to clear the elapsed time back to zero (timer stays paused). Elapsed time per block is remembered across switches, so returning to a block picks up where it left off. @@ -79,6 +80,7 @@ homeschool/ │ │ ├── morning_routine.py# MorningRoutineItem │ │ ├── break_activity.py # BreakActivityItem │ │ ├── rule.py # RuleItem (rules & expectations) +│ │ ├── session_block_agenda.py # SessionBlockAgenda (per-session block overrides) │ │ ├── strike.py # StrikeEvent (strike history) │ │ └── user.py # User (incl. timezone, last_active_at) │ ├── schemas/ # Pydantic request/response schemas @@ -87,7 +89,7 @@ homeschool/ │ │ ├── children.py # Children CRUD + strikes + midnight reset │ │ ├── subjects.py │ │ ├── schedules.py -│ │ ├── sessions.py # Timer actions + break timer events +│ │ ├── sessions.py # Timer actions + break timer events + block agenda upsert │ │ ├── logs.py # Timeline + strike events │ │ ├── morning_routine.py │ │ ├── break_activity.py # Break activities CRUD @@ -197,7 +199,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou | URL | Description | |-----|-------------| -| `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes, trigger TV overlays | +| `/dashboard` | Overview, start/stop sessions, select and time blocks, set per-block agendas, issue behavior strikes, trigger TV overlays | | `/logs` | Browse timer and strike event history and manual notes; filter by child and date | | `/admin` | Manage children, subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. | @@ -274,7 +276,7 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events: | Event | Triggered by | Key payload fields | |-------|-------------|---------| -| `session_update` | Session start | Full session snapshot including blocks, morning routine, break activities, and day times | +| `session_update` | Session start | Full session snapshot including blocks, morning routine, break activities, block agendas, and day times | | `start` | Block timer started | `block_id`, `current_block_id`, `block_elapsed_seconds`, `prev_block_id`, `prev_block_elapsed_seconds` | | `pause` | Block timer paused | `block_id`, `current_block_id` | | `resume` | Block timer resumed | `block_id`, `current_block_id` | @@ -287,6 +289,7 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events: | `strikes_update` | Strike issued/cleared/midnight reset | `strikes` | | `show_rules` | Rules/Expectations overlay triggered from Dashboard | `rules` (array of rule text strings) | | `hide_rules` | Rules/Expectations overlay dismissed from Dashboard | — | +| `agenda_update` | Block agenda saved or cleared from Dashboard | `block_id`, `text` (empty string = cleared) | **Notes:** @@ -296,6 +299,7 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events: - `select` events are broadcast via WebSocket but are **not** persisted to the database or shown in the activity log. - Implicit `pause` events (written when switching blocks or starting a break) are only recorded if the block's timer was actually running — no duplicate pauses are written if the block was already paused or never started. - Break timer events (`break_*`) do not affect block selection or elapsed time for the main block timer. +- `agenda_update` events are scoped to the current session — agendas are cleared automatically when the session ends and are never carried forward to future days. --- diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a5a8fe1..193995f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/session_block_agenda.py b/backend/app/models/session_block_agenda.py new file mode 100644 index 0000000..98f781c --- /dev/null +++ b/backend/app/models/session_block_agenda.py @@ -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) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 16a6a5f..f3fd44e 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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, ) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index 1742e2d..b5fe401 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -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, + }) diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index ee2a50b..5007af4 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -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 diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 83f254e..4e62abd 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -20,6 +20,7 @@ export const useScheduleStore = defineStore('schedule', () => { const breakElapsedCache = ref({}) // blockId → total break elapsed seconds const showRulesOverlay = ref(false) // whether the rules overlay is visible on TV const rulesOverlayItems = ref([]) // list of rule text strings to display + const blockAgendas = ref({}) // blockId (string) → agenda text override const currentBlock = computed(() => session.value?.current_block_id @@ -47,6 +48,7 @@ export const useScheduleStore = defineStore('schedule', () => { if (snapshot.child) child.value = snapshot.child morningRoutine.value = snapshot.morning_routine || [] breakActivities.value = snapshot.break_activities || [] + blockAgendas.value = snapshot.block_agendas || {} // Restore elapsed time from server-computed value and seed the per-block cache const serverElapsed = snapshot.block_elapsed_seconds || 0 if (snapshot.session?.current_block_id) { @@ -82,6 +84,17 @@ export const useScheduleStore = defineStore('schedule', () => { } function applyWsEvent(event) { + if (event.event === 'agenda_update') { + const key = String(event.block_id) + if (event.text) { + blockAgendas.value = { ...blockAgendas.value, [key]: event.text } + } else { + const updated = { ...blockAgendas.value } + delete updated[key] + blockAgendas.value = updated + } + return + } if (event.event === 'show_rules') { rulesOverlayItems.value = event.rules || [] showRulesOverlay.value = true @@ -112,6 +125,7 @@ export const useScheduleStore = defineStore('schedule', () => { breakStartedAt.value = null breakElapsedOffset.value = 0 breakElapsedCache.value = {} + blockAgendas.value = {} return } // Break timer events @@ -407,6 +421,7 @@ export const useScheduleStore = defineStore('schedule', () => { breakElapsedCache, showRulesOverlay, rulesOverlayItems, + blockAgendas, currentBlock, progressPercent, applySnapshot, diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 0adc3da..6045622 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -183,21 +183,50 @@ No blocks loaded.
- +
+ + +
+ +
+
+

Block Agenda

+

{{ agendaDialog.blockLabel }}

+
+ + +
+
+ + + +
+
+
+
@@ -370,6 +399,38 @@ const estimatedFinishTime = computed(() => { return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}` }) +// Agenda dialog +const agendaDialog = ref({ open: false, blockId: null, blockLabel: '', text: '', existing: false }) + +function openAgendaDialog(block) { + if (!scheduleStore.session) return + const label = block.label || block.subject?.name || 'Block' + const existing = scheduleStore.blockAgendas[String(block.id)] || '' + agendaDialog.value = { open: true, blockId: block.id, blockLabel: label, text: existing, existing: !!existing } +} + +function closeAgendaDialog() { + agendaDialog.value = { open: false, blockId: null, blockLabel: '', text: '', existing: false } +} + +async function saveAgenda() { + if (!scheduleStore.session || agendaDialog.value.blockId === null) return + await api.put( + `/api/sessions/${scheduleStore.session.id}/blocks/${agendaDialog.value.blockId}/agenda`, + { text: agendaDialog.value.text } + ) + closeAgendaDialog() +} + +async function clearAgenda() { + if (!scheduleStore.session || agendaDialog.value.blockId === null) return + await api.put( + `/api/sessions/${scheduleStore.session.id}/blocks/${agendaDialog.value.blockId}/agenda`, + { text: '' } + ) + closeAgendaDialog() +} + async function toggleRulesOverlay() { if (!activeChild.value) return const endpoint = scheduleStore.showRulesOverlay @@ -529,6 +590,53 @@ h1 { font-size: 1.75rem; font-weight: 700; } .block-list { display: flex; flex-direction: column; gap: 0.5rem; } +.block-row-wrap { + display: flex; + align-items: center; + gap: 0.5rem; +} +.block-row-wrap > :first-child { flex: 1; min-width: 0; } + +.agenda-btn { + flex-shrink: 0; + width: 2rem; + height: 2rem; + border: 1px solid #334155; + background: transparent; + border-radius: 0.4rem; + cursor: pointer; + font-size: 0.95rem; + opacity: 0.4; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} +.agenda-btn:hover { opacity: 1; background: #334155; } +.agenda-btn-set { opacity: 1; border-color: #6366f1; background: #1e1b4b; } + +.agenda-textarea { + width: 100%; + padding: 0.65rem 0.9rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.5rem; + color: #f1f5f9; + font-size: 0.9rem; + resize: vertical; + font-family: inherit; + line-height: 1.5; + box-sizing: border-box; +} +.agenda-textarea:focus { outline: none; border-color: #818cf8; } + +.dialog-hint { + font-size: 0.82rem; + color: #64748b; + margin: -0.5rem 0 0.25rem; +} + .strikes-list { display: flex; flex-direction: column; gap: 0.6rem; } .strikes-row { diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index 346c05a..5f084b3 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -107,12 +107,20 @@
@@ -483,6 +491,12 @@ onMounted(async () => { font-style: italic; } +.tv-agenda-text { + white-space: pre-wrap; + line-height: 1.5; + border-bottom: none !important; +} + .tv-day-progress { background: #1e293b; border-radius: 1rem;