Add Meeting system subject and notification system

- Auto-create a locked "Meeting" subject for every user on registration
  and seed it for all existing users on startup
- Meeting subject cannot be deleted or renamed (is_system flag)
- 5-minute corner toast warning on Dashboard and TV with live countdown,
  dismiss button, and 1-minute re-notify if dismissed
- At start time: full-screen TV overlay with 30-second auto-dismiss,
  automatic pause of running block, switch to Meeting block, and
  auto-start of Meeting timer
- Web Audio API chimes: rising on warnings, falling at meeting start
- Update README with Meeting subject and notification system docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:44:21 -08:00
parent c560055b10
commit f645d78c83
10 changed files with 356 additions and 11 deletions

View File

@@ -2,12 +2,15 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy import select, text
from sqlalchemy.exc import OperationalError
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.subject import Subject
from app.models.user import User
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.routers import morning_routine, break_activity, admin
from app.websocket.manager import manager
@@ -35,6 +38,27 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "schedule_blocks", "break_time_minutes", "INT NULL")
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0")
# 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()
for user in all_users:
existing = await db.execute(
select(Subject).where(Subject.user_id == user.id, Subject.is_system == True)
)
if not existing.scalar_one_or_none():
db.add(Subject(
user_id=user.id,
name="Meeting",
icon="📅",
color="#6366f1",
is_system=True,
))
await db.commit()
yield

View File

@@ -12,6 +12,7 @@ class Subject(TimestampMixin, Base):
color: Mapped[str] = mapped_column(String(7), default="#10B981") # hex color
icon: Mapped[str] = mapped_column(String(10), default="📚") # emoji
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="subjects") # noqa: F821
schedule_blocks: Mapped[list["ScheduleBlock"]] = relationship( # noqa: F821

View File

@@ -11,6 +11,7 @@ from app.auth.jwt import (
)
from app.dependencies import get_db
from app.models.user import User
from app.models.subject import Subject
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut
@@ -39,6 +40,16 @@ async def register(body: RegisterRequest, response: Response, db: AsyncSession =
await db.commit()
await db.refresh(user)
meeting = Subject(
user_id=user.id,
name="Meeting",
icon="📅",
color="#6366f1",
is_system=True,
)
db.add(meeting)
await db.commit()
access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)

View File

@@ -101,6 +101,8 @@ async def delete_subject(
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
if subject.is_system:
raise HTTPException(status_code=403, detail="System subjects cannot be deleted")
await db.delete(subject)
await db.commit()

View File

@@ -37,6 +37,7 @@ class SubjectOut(BaseModel):
color: str
icon: str
is_active: bool
is_system: bool = False
options: list[SubjectOptionOut] = []
model_config = {"from_attributes": True}