diff --git a/backend/app/main.py b/backend/app/main.py index 6f7cbcb..9a84fa5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text +from sqlalchemy.exc import OperationalError from app.config import get_settings from app.database import engine @@ -12,11 +14,23 @@ from app.websocket.manager import manager settings = get_settings() +async def _add_column_if_missing(conn, table: str, column: str, definition: str): + """Add a column to a table, silently ignoring if it already exists (MySQL 1060).""" + try: + await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")) + except OperationalError as e: + if e.orig.args[0] != 1060: # 1060 = Duplicate column name + raise + + @asynccontextmanager async def lifespan(app: FastAPI): # Create tables on startup (Alembic handles migrations in prod, this is a safety net) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + # Idempotent column additions for schema migrations + await _add_column_if_missing(conn, "schedule_templates", "day_start_time", "TIME NULL") + await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL") yield diff --git a/backend/app/models/schedule.py b/backend/app/models/schedule.py index d5cad38..9061f22 100644 --- a/backend/app/models/schedule.py +++ b/backend/app/models/schedule.py @@ -14,6 +14,8 @@ class ScheduleTemplate(TimestampMixin, Base): ) name: Mapped[str] = mapped_column(String(100), nullable=False) is_default: Mapped[bool] = mapped_column(Boolean, default=False) + day_start_time: Mapped[time | None] = mapped_column(Time, nullable=True) + day_end_time: Mapped[time | None] = mapped_column(Time, nullable=True) user: Mapped["User"] = relationship("User", back_populates="schedule_templates") # noqa: F821 child: Mapped["Child | None"] = relationship("Child") # noqa: F821 diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index d19f4f0..a32ba33 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db from app.models.child import Child -from app.models.schedule import ScheduleBlock +from app.models.schedule import ScheduleBlock, ScheduleTemplate from app.models.session import DailySession, TimerEvent from app.schemas.session import DashboardSnapshot @@ -41,6 +41,8 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): blocks = [] completed_ids = [] block_elapsed_seconds = 0 + day_start_time = None + day_end_time = None if session and session.template_id: blocks_result = await db.execute( @@ -50,6 +52,14 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): ) blocks = blocks_result.scalars().all() + template_result = await db.execute( + select(ScheduleTemplate).where(ScheduleTemplate.id == session.template_id) + ) + template = template_result.scalar_one_or_none() + if template: + day_start_time = template.day_start_time + day_end_time = template.day_end_time + events_result = await db.execute( select(TimerEvent).where( TimerEvent.session_id == session.id, @@ -88,4 +98,6 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): blocks=blocks, completed_block_ids=completed_ids, block_elapsed_seconds=block_elapsed_seconds, + day_start_time=day_start_time, + day_end_time=day_end_time, ) diff --git a/backend/app/routers/schedules.py b/backend/app/routers/schedules.py index 95d38e8..3f6e31b 100644 --- a/backend/app/routers/schedules.py +++ b/backend/app/routers/schedules.py @@ -42,6 +42,8 @@ async def create_template( name=body.name, child_id=body.child_id, is_default=body.is_default, + day_start_time=body.day_start_time, + day_end_time=body.day_end_time, ) db.add(template) await db.flush() # get template.id before adding blocks diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py index cd162b3..6fafef5 100644 --- a/backend/app/schemas/schedule.py +++ b/backend/app/schemas/schedule.py @@ -27,6 +27,8 @@ class ScheduleTemplateCreate(BaseModel): name: str child_id: int | None = None is_default: bool = False + day_start_time: time | None = None + day_end_time: time | None = None blocks: list[ScheduleBlockCreate] = [] @@ -34,6 +36,8 @@ class ScheduleTemplateUpdate(BaseModel): name: str | None = None child_id: int | None = None is_default: bool | None = None + day_start_time: time | None = None + day_end_time: time | None = None class ScheduleTemplateOut(BaseModel): @@ -41,6 +45,8 @@ class ScheduleTemplateOut(BaseModel): name: str child_id: int | None is_default: bool + day_start_time: time | None + day_end_time: time | None blocks: list[ScheduleBlockOut] = [] model_config = {"from_attributes": True} diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 5a3aca9..ce592da 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, time from pydantic import BaseModel from app.schemas.schedule import ScheduleBlockOut from app.schemas.child import ChildOut @@ -43,3 +43,5 @@ class DashboardSnapshot(BaseModel): blocks: list[ScheduleBlockOut] = [] completed_block_ids: list[int] = [] block_elapsed_seconds: int = 0 # seconds already elapsed for the current block + day_start_time: time | None = None + day_end_time: time | None = None diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 8055a8a..6a19bb3 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -10,6 +10,8 @@ export const useScheduleStore = defineStore('schedule', () => { const isPaused = ref(false) const blockStartedAt = ref(null) // Date.now() ms when current counting period started const blockElapsedOffset = ref(0) // seconds already elapsed before blockStartedAt + const dayStartTime = ref(null) // "HH:MM:SS" string or null + const dayEndTime = ref(null) // "HH:MM:SS" string or null const currentBlock = computed(() => session.value?.current_block_id @@ -28,6 +30,8 @@ export const useScheduleStore = defineStore('schedule', () => { completedBlockIds.value = snapshot.completed_block_ids || [] isPaused.value = false if (snapshot.child) child.value = snapshot.child + dayStartTime.value = snapshot.day_start_time || null + dayEndTime.value = snapshot.day_end_time || null // Restore elapsed time from server-computed value const serverElapsed = snapshot.block_elapsed_seconds || 0 if (snapshot.session?.current_block_id && serverElapsed > 0) { @@ -111,6 +115,8 @@ export const useScheduleStore = defineStore('schedule', () => { isPaused, blockStartedAt, blockElapsedOffset, + dayStartTime, + dayEndTime, currentBlock, progressPercent, applySnapshot, diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 64c87bb..8919345 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -135,6 +135,24 @@