diff --git a/README.md b/README.md index 12d60dc..3cf78e4 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou 5. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here. 6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes. 7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template -8. **TV** → Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID) +8. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV. --- @@ -236,9 +236,9 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check | URL | Description | |-----|-------------| -| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay | +| `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay | -Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. +Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. ### API Documentation diff --git a/backend/app/main.py b/backend/app/main.py index 293f1dc..868614c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import random from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect @@ -9,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.database import engine from app.models import Base +from app.models.child import Child from app.models.subject import Subject from app.models.user import User from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard @@ -41,9 +43,27 @@ async def lifespan(app: FastAPI): await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0") await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL") await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL") + await _add_column_if_missing(conn, "children", "tv_token", "INT NULL") + + # Backfill tv_token for existing children that don't have one + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + result = await db.execute(select(Child).where(Child.tv_token == None)) # noqa: E711 + children_without_token = result.scalars().all() + used_tokens = set() + for child in children_without_token: + while True: + token = random.randint(1000, 9999) + if token not in used_tokens: + existing = await db.execute(select(Child).where(Child.tv_token == token)) + if not existing.scalar_one_or_none(): + break + child.tv_token = token + used_tokens.add(token) + if children_without_token: + await db.commit() # Seed Meeting system subject for any existing users who don't have one - from app.database import AsyncSessionLocal async with AsyncSessionLocal() as db: users_result = await db.execute(select(User).where(User.is_active == True)) all_users = users_result.scalars().all() @@ -100,12 +120,19 @@ async def health(): return {"status": "ok"} -@app.websocket("/ws/{child_id}") -async def websocket_endpoint(websocket: WebSocket, child_id: int): +@app.websocket("/ws/{tv_token}") +async def websocket_endpoint(websocket: WebSocket, tv_token: int): + from app.database import AsyncSessionLocal + async with AsyncSessionLocal() as db: + result = await db.execute(select(Child).where(Child.tv_token == tv_token)) + child = result.scalar_one_or_none() + if not child: + await websocket.close(code=4004) + return + child_id = child.id await manager.connect(websocket, child_id) try: while True: - # Keep connection alive; TV clients are receive-only await websocket.receive_text() except WebSocketDisconnect: manager.disconnect(websocket, child_id) diff --git a/backend/app/models/child.py b/backend/app/models/child.py index 3567126..688d621 100644 --- a/backend/app/models/child.py +++ b/backend/app/models/child.py @@ -16,6 +16,7 @@ class Child(TimestampMixin, Base): color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False) strikes_last_reset: Mapped[Optional[date]] = mapped_column(Date, nullable=True, default=None) + tv_token: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, unique=True) user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821 daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821 diff --git a/backend/app/routers/children.py b/backend/app/routers/children.py index e3cb41b..515799e 100644 --- a/backend/app/routers/children.py +++ b/backend/app/routers/children.py @@ -1,3 +1,4 @@ +import random from datetime import datetime, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -49,13 +50,22 @@ async def list_children( return children +async def _generate_tv_token(db: AsyncSession) -> int: + while True: + token = random.randint(1000, 9999) + result = await db.execute(select(Child).where(Child.tv_token == token)) + if not result.scalar_one_or_none(): + return token + + @router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED) async def create_child( body: ChildCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - child = Child(**body.model_dump(), user_id=current_user.id) + tv_token = await _generate_tv_token(db) + child = Child(**body.model_dump(), user_id=current_user.id, tv_token=tv_token) db.add(child) await db.commit() await db.refresh(child) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 29b3cb6..16a6a5f 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -22,12 +22,13 @@ from app.utils.timer import compute_block_elapsed, compute_break_elapsed router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) -@router.get("/{child_id}", response_model=DashboardSnapshot) -async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): - child_result = await db.execute(select(Child).where(Child.id == child_id, Child.is_active == True)) +@router.get("/{tv_token}", response_model=DashboardSnapshot) +async def get_dashboard(tv_token: int, db: AsyncSession = Depends(get_db)): + child_result = await db.execute(select(Child).where(Child.tv_token == tv_token, Child.is_active == True)) child = child_result.scalar_one_or_none() if not child: raise HTTPException(status_code=404, detail="Child not found") + child_id = child.id # Get today's active session session_result = await db.execute( diff --git a/backend/app/schemas/child.py b/backend/app/schemas/child.py index baed2b9..4070029 100644 --- a/backend/app/schemas/child.py +++ b/backend/app/schemas/child.py @@ -23,5 +23,6 @@ class ChildOut(BaseModel): is_active: bool color: str strikes: int = 0 + tv_token: int | None = None model_config = {"from_attributes": True} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 0e8dd4e..9f54128 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -38,7 +38,7 @@
TV Dashboard

Open this on the living room TV for the full-screen view.

- + Open TV View →