From 5cd537a44587406c3f5d9f03ce7d051a069d4854 Mon Sep 17 00:00:00 2001 From: derekc Date: Sun, 1 Mar 2026 22:19:15 -0800 Subject: [PATCH] Add Morning Routine to Admin and TV greeting state Adds a per-user Morning Routine item list that appears in the TV dashboard Activities panel during the "Good Morning" countdown (before the first block starts). - morning_routine_items table (auto-created on startup) - CRUD API at /api/morning-routine (auth-required) - Items included in the public DashboardSnapshot so TV gets them without auth - Morning Routine section in Admin page (same add/edit/delete UX as subject options) - TV Activities column shows routine items when no block is active, switches to subject options once a block starts Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 2 + backend/app/models/__init__.py | 2 + backend/app/models/morning_routine.py | 15 ++++ backend/app/routers/dashboard.py | 9 +++ backend/app/routers/morning_routine.py | 97 ++++++++++++++++++++++++++ backend/app/schemas/session.py | 1 + frontend/src/stores/schedule.js | 3 + frontend/src/views/AdminView.vue | 70 ++++++++++++++++++- frontend/src/views/TVView.vue | 38 ++++++---- 9 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 backend/app/models/morning_routine.py create mode 100644 backend/app/routers/morning_routine.py diff --git a/backend/app/main.py b/backend/app/main.py index f23d745..1a26cbe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from app.config import get_settings from app.database import engine from app.models import Base from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard +from app.routers import morning_routine from app.websocket.manager import manager settings = get_settings() @@ -62,6 +63,7 @@ app.include_router(subjects.router) app.include_router(schedules.router) app.include_router(sessions.router) app.include_router(logs.router) +app.include_router(morning_routine.router) app.include_router(dashboard.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0df98ae..6c05717 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.subject import Subject, SubjectOption from app.models.schedule import ScheduleTemplate, ScheduleBlock from app.models.session import DailySession, TimerEvent, TimerEventType from app.models.activity import ActivityLog +from app.models.morning_routine import MorningRoutineItem from app.models.strike import StrikeEvent __all__ = [ @@ -21,5 +22,6 @@ __all__ = [ "TimerEvent", "TimerEventType", "ActivityLog", + "MorningRoutineItem", "StrikeEvent", ] diff --git a/backend/app/models/morning_routine.py b/backend/app/models/morning_routine.py new file mode 100644 index 0000000..eeed8b6 --- /dev/null +++ b/backend/app/models/morning_routine.py @@ -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 MorningRoutineItem(Base): + __tablename__ = "morning_routine_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 diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 121ed8d..db27e64 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db 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 @@ -97,6 +98,13 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): # Paused if the last tick event was a pause (last_start is None but events exist) is_paused = bool(tick_events) and tick_events[-1].event_type == "pause" + 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()] + return DashboardSnapshot( session=session, child=child, @@ -106,4 +114,5 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): is_paused=is_paused, day_start_time=day_start_time, day_end_time=day_end_time, + morning_routine=morning_routine, ) diff --git a/backend/app/routers/morning_routine.py b/backend/app/routers/morning_routine.py new file mode 100644 index 0000000..c29f8e0 --- /dev/null +++ b/backend/app/routers/morning_routine.py @@ -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.morning_routine import MorningRoutineItem +from app.models.user import User + +router = APIRouter(prefix="/api/morning-routine", tags=["morning-routine"]) + + +class MorningRoutineItemOut(BaseModel): + id: int + text: str + order_index: int + model_config = {"from_attributes": True} + + +class MorningRoutineItemCreate(BaseModel): + text: str + order_index: int = 0 + + +class MorningRoutineItemUpdate(BaseModel): + text: str | None = None + order_index: int | None = None + + +@router.get("", response_model=list[MorningRoutineItemOut]) +async def list_items( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MorningRoutineItem) + .where(MorningRoutineItem.user_id == current_user.id) + .order_by(MorningRoutineItem.order_index, MorningRoutineItem.id) + ) + return result.scalars().all() + + +@router.post("", response_model=MorningRoutineItemOut, status_code=status.HTTP_201_CREATED) +async def create_item( + body: MorningRoutineItemCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + item = MorningRoutineItem(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=MorningRoutineItemOut) +async def update_item( + item_id: int, + body: MorningRoutineItemUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(MorningRoutineItem).where( + MorningRoutineItem.id == item_id, + MorningRoutineItem.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(MorningRoutineItem).where( + MorningRoutineItem.id == item_id, + MorningRoutineItem.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() diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 87028c2..54c7353 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -46,3 +46,4 @@ class DashboardSnapshot(BaseModel): is_paused: bool = False # whether the current block's timer is paused day_start_time: time | None = None day_end_time: time | None = None + morning_routine: list[str] = [] # text items shown on TV during greeting state diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index e0a2fac..8a700b6 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -13,6 +13,7 @@ export const useScheduleStore = defineStore('schedule', () => { const blockElapsedCache = ref({}) // blockId → total elapsed seconds (survives block switches) const dayStartTime = 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 currentBlock = computed(() => session.value?.current_block_id @@ -40,6 +41,7 @@ export const useScheduleStore = defineStore('schedule', () => { if (snapshot.child) child.value = snapshot.child dayStartTime.value = snapshot.day_start_time || null dayEndTime.value = snapshot.day_end_time || null + morningRoutine.value = snapshot.morning_routine || [] // 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 && serverElapsed > 0) { @@ -165,6 +167,7 @@ export const useScheduleStore = defineStore('schedule', () => { blockElapsedCache, dayStartTime, dayEndTime, + morningRoutine, currentBlock, progressPercent, applySnapshot, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 91cfdad..ad05500 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -117,6 +117,37 @@ + +
+
+

Morning Routine

+
+
+

These items appear in the Activities panel on the TV during the "Good Morning" countdown before the first block starts.

+
+ +
No items yet.
+
+
+ + +
+
+
+
@@ -408,6 +439,42 @@ async function deleteOption(subjectId, optionId) { await loadSubjects() } +// Morning Routine +const morningRoutine = ref([]) +const newRoutineText = ref('') +const editingRoutineItem = ref(null) + +async function loadMorningRoutine() { + const res = await api.get('/api/morning-routine') + morningRoutine.value = res.data +} + +async function addRoutineItem() { + await api.post('/api/morning-routine', { + text: newRoutineText.value, + order_index: morningRoutine.value.length, + }) + newRoutineText.value = '' + await loadMorningRoutine() +} + +function startEditRoutineItem(item) { + editingRoutineItem.value = { ...item } +} + +async function saveRoutineItem() { + await api.patch(`/api/morning-routine/${editingRoutineItem.value.id}`, { + text: editingRoutineItem.value.text, + }) + editingRoutineItem.value = null + await loadMorningRoutine() +} + +async function deleteRoutineItem(id) { + await api.delete(`/api/morning-routine/${id}`) + await loadMorningRoutine() +} + // Schedules const templates = ref([]) const showCreateForm = ref(false) @@ -510,7 +577,7 @@ async function saveDayHours(template, which, value) { onMounted(async () => { await childrenStore.fetchChildren() - await Promise.all([loadSubjects(), loadTemplates()]) + await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine()]) selectedTimezone.value = authStore.timezone }) @@ -703,6 +770,7 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin .option-add-row .option-input { background: #1e293b; } +.routine-hint { font-size: 0.82rem; color: #64748b; margin-bottom: 1rem; } .empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; } .settings-card { diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index 03ac27b..ed60108 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -59,22 +59,32 @@
- -
+ +
Activities
-
-
- {{ opt.text }} + + + +