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:
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
15
backend/app/models/morning_routine.py
Normal file
15
backend/app/models/morning_routine.py
Normal 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
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
97
backend/app/routers/morning_routine.py
Normal file
97
backend/app/routers/morning_routine.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
</div>
|
||||||
class="tv-option-item"
|
<div v-else class="tv-options-empty">No morning routine items added yet.</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 }}
|
{{ opt.text }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
|
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: schedule list -->
|
<!-- Right: schedule list -->
|
||||||
|
|||||||
Reference in New Issue
Block a user