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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:19:15 -08:00
parent b5f4188396
commit 5cd537a445
9 changed files with 222 additions and 15 deletions

View File

@@ -9,6 +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.websocket.manager import manager from app.websocket.manager import manager
settings = get_settings() settings = get_settings()
@@ -62,6 +63,7 @@ app.include_router(subjects.router)
app.include_router(schedules.router) 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(dashboard.router) app.include_router(dashboard.router)

View File

@@ -6,6 +6,7 @@ from app.models.subject import Subject, SubjectOption
from app.models.schedule import ScheduleTemplate, ScheduleBlock 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.strike import StrikeEvent from app.models.strike import StrikeEvent
__all__ = [ __all__ = [
@@ -21,5 +22,6 @@ __all__ = [
"TimerEvent", "TimerEvent",
"TimerEventType", "TimerEventType",
"ActivityLog", "ActivityLog",
"MorningRoutineItem",
"StrikeEvent", "StrikeEvent",
] ]

View 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 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

View File

@@ -11,6 +11,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.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
@@ -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) # 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" 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( return DashboardSnapshot(
session=session, session=session,
child=child, child=child,
@@ -106,4 +114,5 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
is_paused=is_paused, is_paused=is_paused,
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,
) )

View 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.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()

View File

@@ -46,3 +46,4 @@ class DashboardSnapshot(BaseModel):
is_paused: bool = False # whether the current block's timer is paused is_paused: bool = False # whether the current block's timer is paused
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

View File

@@ -13,6 +13,7 @@ export const useScheduleStore = defineStore('schedule', () => {
const blockElapsedCache = ref({}) // blockId → total elapsed seconds (survives block switches) const blockElapsedCache = ref({}) // blockId → total elapsed seconds (survives block switches)
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 currentBlock = computed(() => const currentBlock = computed(() =>
session.value?.current_block_id session.value?.current_block_id
@@ -40,6 +41,7 @@ export const useScheduleStore = defineStore('schedule', () => {
if (snapshot.child) child.value = snapshot.child if (snapshot.child) child.value = snapshot.child
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 || []
// 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 && serverElapsed > 0) { if (snapshot.session?.current_block_id && serverElapsed > 0) {
@@ -165,6 +167,7 @@ export const useScheduleStore = defineStore('schedule', () => {
blockElapsedCache, blockElapsedCache,
dayStartTime, dayStartTime,
dayEndTime, dayEndTime,
morningRoutine,
currentBlock, currentBlock,
progressPercent, progressPercent,
applySnapshot, applySnapshot,

View File

@@ -117,6 +117,37 @@
</div> </div>
</section> </section>
<!-- Morning Routine section -->
<section class="section">
<div class="section-header">
<h2>Morning Routine</h2>
</div>
<div class="card">
<p class="routine-hint">These items appear in the Activities panel on the TV during the "Good Morning" countdown before the first block starts.</p>
<div class="option-list">
<template v-for="item in morningRoutine" :key="item.id">
<div v-if="editingRoutineItem && editingRoutineItem.id === item.id" class="option-edit-row">
<input v-model="editingRoutineItem.text" class="option-input" @keyup.enter="saveRoutineItem" />
<button class="btn-sm btn-primary" @click="saveRoutineItem">Save</button>
<button class="btn-sm" @click="editingRoutineItem = 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="startEditRoutineItem(item)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteRoutineItem(item.id)"></button>
</div>
</div>
</template>
<div v-if="morningRoutine.length === 0" class="empty-small">No items yet.</div>
</div>
<form class="option-add-row" style="margin-top: 0.75rem" @submit.prevent="addRoutineItem">
<input v-model="newRoutineText" placeholder="Add a morning routine item..." 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">
@@ -408,6 +439,42 @@ async function deleteOption(subjectId, optionId) {
await loadSubjects() 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 // Schedules
const templates = ref([]) const templates = ref([])
const showCreateForm = ref(false) const showCreateForm = ref(false)
@@ -510,7 +577,7 @@ async function saveDayHours(template, which, value) {
onMounted(async () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
await Promise.all([loadSubjects(), loadTemplates()]) await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine()])
selectedTimezone.value = authStore.timezone selectedTimezone.value = authStore.timezone
}) })
</script> </script>
@@ -703,6 +770,7 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.option-add-row .option-input { background: #1e293b; } .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; } .empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.settings-card { .settings-card {

View File

@@ -59,22 +59,32 @@
</div> </div>
</div> </div>
<!-- Center: subject options --> <!-- Center: subject options or morning routine -->
<div class="tv-options-col" :style="{ background: currentSubjectColor + '22', borderColor: currentSubjectColor }"> <div
class="tv-options-col"
:style="scheduleStore.currentBlock
? { background: currentSubjectColor + '22', borderColor: currentSubjectColor }
: { background: '#1e293b', borderColor: '#334155' }"
>
<div class="tv-options-title">Activities</div> <div class="tv-options-title">Activities</div>
<div <!-- Morning routine during greeting state -->
v-if="currentSubjectOptions.length" <template v-if="!scheduleStore.currentBlock">
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 {{ item }}
v-for="opt in currentSubjectOptions" </div>
:key="opt.id"
class="tv-option-item"
>
{{ opt.text }}
</div> </div>
</div> <div v-else class="tv-options-empty">No morning routine items added yet.</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div> </template>
<!-- Subject options during active block -->
<template v-else>
<div v-if="currentSubjectOptions.length" class="tv-options-list">
<div v-for="opt in currentSubjectOptions" :key="opt.id" class="tv-option-item">
{{ opt.text }}
</div>
</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
</template>
</div> </div>
<!-- Right: schedule list --> <!-- Right: schedule list -->