Initial project scaffold

Full-stack homeschool web app with FastAPI backend, Vue 3 frontend,
MySQL database, and Docker Compose orchestration. Includes JWT auth,
WebSocket real-time TV dashboard, schedule builder, activity logging,
and multi-child support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 22:56:31 -08:00
parent 93e0494864
commit 417b3adfe8
68 changed files with 3919 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Copy this file to .env and fill in values
# Generate SECRET_KEY with: openssl rand -hex 32
MYSQL_ROOT_PASSWORD=change_me_root
MYSQL_DATABASE=homeschool
MYSQL_USER=homeschool
MYSQL_PASSWORD=change_me_db
SECRET_KEY=change_me_generate_with_openssl_rand_hex_32
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=30
# Comma-separated allowed CORS origins (no trailing slash)
CORS_ORIGINS=http://localhost:8054

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Environment — never commit real secrets
.env
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
# Node / frontend
node_modules/
frontend/dist/
frontend/.vite/
# Alembic generated (keep versions dir, ignore cache)
backend/alembic/__pycache__/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev gcc pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

42
backend/alembic.ini Normal file
View File

@@ -0,0 +1,42 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

62
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,62 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import all models so Alembic can detect them
from app.models import Base # noqa: F401
from app.config import get_settings
config = context.config
settings = get_settings()
# Override the sqlalchemy.url from environment
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

0
backend/app/__init__.py Normal file
View File

View File

40
backend/app/auth/jwt.py Normal file
View File

@@ -0,0 +1,40 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload.update({"exp": expire, "type": "access"})
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
payload.update({"exp": expire, "type": "refresh"})
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
raise ValueError("Invalid or expired token")

23
backend/app/config.py Normal file
View File

@@ -0,0 +1,23 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
cors_origins: str = "http://localhost:8054"
@property
def cors_origins_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",")]
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()

18
backend/app/database.py Normal file
View File

@@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=False,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)

View File

@@ -0,0 +1,44 @@
from typing import AsyncGenerator, Optional
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.jwt import decode_token
from app.database import AsyncSessionLocal
from app.models.user import User
from sqlalchemy import select
bearer_scheme = HTTPBearer(auto_error=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
if not credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if payload.get("type") != "access":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong token type")
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
result = await db.execute(select(User).where(User.id == int(user_id), User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user

64
backend/app/main.py Normal file
View File

@@ -0,0 +1,64 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.database import engine
from app.models import Base
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.websocket.manager import manager
settings = get_settings()
@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)
yield
app = FastAPI(
title="Homeschool API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(children.router)
app.include_router(subjects.router)
app.include_router(schedules.router)
app.include_router(sessions.router)
app.include_router(logs.router)
app.include_router(dashboard.router)
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.websocket("/ws/{child_id}")
async def websocket_endpoint(websocket: WebSocket, child_id: int):
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)

View File

@@ -0,0 +1,22 @@
# Import all models here so Alembic can discover them via Base.metadata
from app.models.base import Base, TimestampMixin
from app.models.user import User
from app.models.child import Child
from app.models.subject import Subject
from app.models.schedule import ScheduleTemplate, ScheduleBlock
from app.models.session import DailySession, TimerEvent, TimerEventType
from app.models.activity import ActivityLog
__all__ = [
"Base",
"TimestampMixin",
"User",
"Child",
"Subject",
"ScheduleTemplate",
"ScheduleBlock",
"DailySession",
"TimerEvent",
"TimerEventType",
"ActivityLog",
]

View File

@@ -0,0 +1,24 @@
from datetime import date
from sqlalchemy import Date, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ActivityLog(TimestampMixin, Base):
__tablename__ = "activity_logs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
child_id: Mapped[int] = mapped_column(ForeignKey("children.id", ondelete="CASCADE"), nullable=False)
subject_id: Mapped[int | None] = mapped_column(
ForeignKey("subjects.id", ondelete="SET NULL"), nullable=True
)
session_id: Mapped[int | None] = mapped_column(
ForeignKey("daily_sessions.id", ondelete="SET NULL"), nullable=True
)
log_date: Mapped[date] = mapped_column(Date, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
child: Mapped["Child"] = relationship("Child", back_populates="activity_logs") # noqa: F821
subject: Mapped["Subject | None"] = relationship("Subject", back_populates="activity_logs") # noqa: F821
session: Mapped["DailySession | None"] = relationship("DailySession", back_populates="activity_logs") # noqa: F821

View File

@@ -0,0 +1,14 @@
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
default=func.now(), server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,23 @@
from datetime import date
from sqlalchemy import String, Boolean, ForeignKey, Date
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Child(TimestampMixin, Base):
__tablename__ = "children"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
birth_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
"DailySession", back_populates="child"
)
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="child"
)

View File

@@ -0,0 +1,45 @@
from datetime import time
from sqlalchemy import String, Boolean, ForeignKey, Time, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class ScheduleTemplate(TimestampMixin, Base):
__tablename__ = "schedule_templates"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
child_id: Mapped[int | None] = mapped_column(
ForeignKey("children.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
user: Mapped["User"] = relationship("User", back_populates="schedule_templates") # noqa: F821
child: Mapped["Child | None"] = relationship("Child") # noqa: F821
blocks: Mapped[list["ScheduleBlock"]] = relationship(
"ScheduleBlock", back_populates="template", cascade="all, delete-orphan", order_by="ScheduleBlock.order_index"
)
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
"DailySession", back_populates="template"
)
class ScheduleBlock(Base):
__tablename__ = "schedule_blocks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
template_id: Mapped[int] = mapped_column(
ForeignKey("schedule_templates.id", ondelete="CASCADE"), nullable=False
)
subject_id: Mapped[int | None] = mapped_column(
ForeignKey("subjects.id", ondelete="SET NULL"), nullable=True
)
time_start: Mapped[time] = mapped_column(Time, nullable=False)
time_end: Mapped[time] = mapped_column(Time, nullable=False)
label: Mapped[str | None] = mapped_column(String(100), nullable=True) # override subject name
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0)
template: Mapped["ScheduleTemplate"] = relationship("ScheduleTemplate", back_populates="blocks")
subject: Mapped["Subject | None"] = relationship("Subject", back_populates="schedule_blocks") # noqa: F821

View File

@@ -0,0 +1,55 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
import enum
class TimerEventType(str, enum.Enum):
start = "start"
pause = "pause"
resume = "resume"
complete = "complete"
skip = "skip"
class DailySession(TimestampMixin, Base):
__tablename__ = "daily_sessions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
child_id: Mapped[int] = mapped_column(ForeignKey("children.id", ondelete="CASCADE"), nullable=False)
template_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_templates.id", ondelete="SET NULL"), nullable=True
)
session_date: Mapped[date] = mapped_column(Date, nullable=False)
is_active: Mapped[bool] = mapped_column(default=True)
current_block_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True
)
child: Mapped["Child"] = relationship("Child", back_populates="daily_sessions") # noqa: F821
template: Mapped["ScheduleTemplate | None"] = relationship( # noqa: F821
"ScheduleTemplate", back_populates="daily_sessions"
)
current_block: Mapped["ScheduleBlock | None"] = relationship("ScheduleBlock", foreign_keys=[current_block_id]) # noqa: F821
timer_events: Mapped[list["TimerEvent"]] = relationship(
"TimerEvent", back_populates="session", cascade="all, delete-orphan"
)
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="session"
)
class TimerEvent(Base):
__tablename__ = "timer_events"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("daily_sessions.id", ondelete="CASCADE"), nullable=False)
block_id: Mapped[int | None] = mapped_column(
ForeignKey("schedule_blocks.id", ondelete="SET NULL"), nullable=True
)
event_type: Mapped[str] = mapped_column(String(20), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), server_default=func.now())
session: Mapped["DailySession"] = relationship("DailySession", back_populates="timer_events")
block: Mapped["ScheduleBlock | None"] = relationship("ScheduleBlock") # noqa: F821

View File

@@ -0,0 +1,22 @@
from sqlalchemy import String, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class Subject(TimestampMixin, Base):
__tablename__ = "subjects"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
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)
user: Mapped["User"] = relationship("User", back_populates="subjects") # noqa: F821
schedule_blocks: Mapped[list["ScheduleBlock"]] = relationship( # noqa: F821
"ScheduleBlock", back_populates="subject"
)
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="subject"
)

View File

@@ -0,0 +1,20 @@
from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
class User(TimestampMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821
schedule_templates: Mapped[list["ScheduleTemplate"]] = relationship( # noqa: F821
"ScheduleTemplate", back_populates="user"
)

View File

View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.auth.jwt import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
from app.dependencies import get_db
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
REFRESH_COOKIE = "refresh_token"
COOKIE_OPTS = {
"httponly": True,
"samesite": "lax",
"secure": False, # set True in production with HTTPS
}
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, response: Response, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.email == body.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
email=body.email,
hashed_password=hash_password(body.password),
full_name=body.full_name,
)
db.add(user)
await db.commit()
await db.refresh(user)
access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
return TokenResponse(access_token=access)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email, User.is_active == True))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)
return TokenResponse(access_token=access)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
token = request.cookies.get(REFRESH_COOKIE)
if not token:
raise HTTPException(status_code=401, detail="No refresh token")
try:
payload = decode_token(token)
except ValueError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Wrong token type")
user_id = payload.get("sub")
result = await db.execute(select(User).where(User.id == int(user_id), User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found")
access = create_access_token({"sub": str(user.id)})
new_refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, new_refresh, **COOKIE_OPTS)
return TokenResponse(access_token=access)
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(REFRESH_COOKIE)
return {"detail": "Logged out"}

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.user import User
from app.schemas.child import ChildCreate, ChildOut, ChildUpdate
router = APIRouter(prefix="/api/children", tags=["children"])
@router.get("", response_model=list[ChildOut])
async def list_children(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.user_id == current_user.id).order_by(Child.name)
)
return result.scalars().all()
@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)
db.add(child)
await db.commit()
await db.refresh(child)
return child
@router.get("/{child_id}", response_model=ChildOut)
async def get_child(
child_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
return child
@router.patch("/{child_id}", response_model=ChildOut)
async def update_child(
child_id: int,
body: ChildUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(child, field, value)
await db.commit()
await db.refresh(child)
return child
@router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child(
child_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
await db.delete(child)
await db.commit()

View File

@@ -0,0 +1,65 @@
"""
Public dashboard endpoint — no authentication required.
Used by the TV view to get the initial session snapshot before WebSocket connects.
"""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.session import DailySession, TimerEvent
from app.schemas.session import DashboardSnapshot
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))
child = child_result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
# Get today's active session
session_result = await db.execute(
select(DailySession)
.where(
DailySession.child_id == child_id,
DailySession.session_date == date.today(),
DailySession.is_active == True,
)
.options(selectinload(DailySession.current_block))
.limit(1)
)
session = session_result.scalar_one_or_none()
blocks = []
completed_ids = []
if session and session.template_id:
blocks_result = await db.execute(
select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id)
.order_by(ScheduleBlock.order_index)
)
blocks = blocks_result.scalars().all()
events_result = await db.execute(
select(TimerEvent).where(
TimerEvent.session_id == session.id,
TimerEvent.event_type == "complete",
)
)
completed_ids = [e.block_id for e in events_result.scalars().all() if e.block_id]
return DashboardSnapshot(
session=session,
child=child,
blocks=blocks,
completed_block_ids=completed_ids,
)

View File

@@ -0,0 +1,95 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.activity import ActivityLog
from app.models.child import Child
from app.models.user import User
from app.schemas.activity import ActivityLogCreate, ActivityLogOut, ActivityLogUpdate
router = APIRouter(prefix="/api/logs", tags=["logs"])
@router.get("", response_model=list[ActivityLogOut])
async def list_logs(
child_id: int | None = None,
log_date: date | None = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = (
select(ActivityLog)
.join(Child)
.where(Child.user_id == current_user.id)
.order_by(ActivityLog.log_date.desc(), ActivityLog.created_at.desc())
)
if child_id:
query = query.where(ActivityLog.child_id == child_id)
if log_date:
query = query.where(ActivityLog.log_date == log_date)
result = await db.execute(query)
return result.scalars().all()
@router.post("", response_model=ActivityLogOut, status_code=status.HTTP_201_CREATED)
async def create_log(
body: ActivityLogCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
child_result = await db.execute(
select(Child).where(Child.id == body.child_id, Child.user_id == current_user.id)
)
if not child_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Child not found")
log = ActivityLog(**body.model_dump())
db.add(log)
await db.commit()
await db.refresh(log)
return log
@router.patch("/{log_id}", response_model=ActivityLogOut)
async def update_log(
log_id: int,
body: ActivityLogUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ActivityLog)
.join(Child)
.where(ActivityLog.id == log_id, Child.user_id == current_user.id)
)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="Log not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(log, field, value)
await db.commit()
await db.refresh(log)
return log
@router.delete("/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_log(
log_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ActivityLog)
.join(Child)
.where(ActivityLog.id == log_id, Child.user_id == current_user.id)
)
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="Log not found")
await db.delete(log)
await db.commit()

View File

@@ -0,0 +1,165 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
from app.models.schedule import ScheduleTemplate, ScheduleBlock
from app.models.user import User
from app.schemas.schedule import (
ScheduleTemplateCreate,
ScheduleTemplateOut,
ScheduleTemplateUpdate,
ScheduleBlockCreate,
ScheduleBlockOut,
)
router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@router.get("", response_model=list[ScheduleTemplateOut])
async def list_templates(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.user_id == current_user.id)
.options(selectinload(ScheduleTemplate.blocks))
.order_by(ScheduleTemplate.name)
)
return result.scalars().all()
@router.post("", response_model=ScheduleTemplateOut, status_code=status.HTTP_201_CREATED)
async def create_template(
body: ScheduleTemplateCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
template = ScheduleTemplate(
user_id=current_user.id,
name=body.name,
child_id=body.child_id,
is_default=body.is_default,
)
db.add(template)
await db.flush() # get template.id before adding blocks
for block_data in body.blocks:
block = ScheduleBlock(template_id=template.id, **block_data.model_dump())
db.add(block)
await db.commit()
await db.refresh(template)
# Re-fetch with blocks loaded
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template.id)
.options(selectinload(ScheduleTemplate.blocks))
)
return result.scalar_one()
@router.get("/{template_id}", response_model=ScheduleTemplateOut)
async def get_template(
template_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
.options(selectinload(ScheduleTemplate.blocks))
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return template
@router.patch("/{template_id}", response_model=ScheduleTemplateOut)
async def update_template(
template_id: int,
body: ScheduleTemplateUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
.options(selectinload(ScheduleTemplate.blocks))
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(template, field, value)
await db.commit()
await db.refresh(template)
return template
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
)
template = result.scalar_one_or_none()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
await db.delete(template)
await db.commit()
# --- Schedule Block sub-routes ---
@router.post("/{template_id}/blocks", response_model=ScheduleBlockOut, status_code=status.HTTP_201_CREATED)
async def add_block(
template_id: int,
body: ScheduleBlockCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Template not found")
block = ScheduleBlock(template_id=template_id, **body.model_dump())
db.add(block)
await db.commit()
await db.refresh(block)
return block
@router.delete("/{template_id}/blocks/{block_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_block(
template_id: int,
block_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ScheduleBlock)
.join(ScheduleTemplate)
.where(
ScheduleBlock.id == block_id,
ScheduleBlock.template_id == template_id,
ScheduleTemplate.user_id == current_user.id,
)
)
block = result.scalar_one_or_none()
if not block:
raise HTTPException(status_code=404, detail="Block not found")
await db.delete(block)
await db.commit()

View File

@@ -0,0 +1,159 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.schedule import ScheduleBlock, ScheduleTemplate
from app.models.session import DailySession, TimerEvent
from app.models.user import User
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
from app.websocket.manager import manager
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
async def _broadcast_session(db: AsyncSession, session: DailySession) -> None:
"""Build a snapshot dict and broadcast it to all connected TVs for this child."""
# Load template blocks if available
blocks = []
if session.template_id:
result = await db.execute(
select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id)
.order_by(ScheduleBlock.order_index)
)
blocks = [{"id": b.id, "subject_id": b.subject_id, "time_start": str(b.time_start),
"time_end": str(b.time_end), "label": b.label, "order_index": b.order_index}
for b in result.scalars().all()]
# Gather completed block IDs from timer events
events_result = await db.execute(
select(TimerEvent).where(
TimerEvent.session_id == session.id,
TimerEvent.event_type == "complete",
)
)
completed_ids = [e.block_id for e in events_result.scalars().all() if e.block_id]
payload = {
"event": "session_update",
"session": {
"id": session.id,
"child_id": session.child_id,
"session_date": str(session.session_date),
"is_active": session.is_active,
"current_block_id": session.current_block_id,
},
"blocks": blocks,
"completed_block_ids": completed_ids,
}
await manager.broadcast(session.child_id, payload)
@router.post("", response_model=DailySessionOut, status_code=status.HTTP_201_CREATED)
async def start_session(
body: SessionStart,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# Verify child belongs to user
child_result = await db.execute(
select(Child).where(Child.id == body.child_id, Child.user_id == current_user.id)
)
if not child_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Child not found")
session_date = body.session_date or date.today()
# Deactivate any existing active session for this child today
existing = await db.execute(
select(DailySession).where(
DailySession.child_id == body.child_id,
DailySession.session_date == session_date,
DailySession.is_active == True,
)
)
for old in existing.scalars().all():
old.is_active = False
session = DailySession(
child_id=body.child_id,
template_id=body.template_id,
session_date=session_date,
is_active=True,
)
db.add(session)
await db.commit()
await db.refresh(session)
await _broadcast_session(db, session)
return session
@router.get("/{session_id}", response_model=DailySessionOut)
async def get_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block))
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session
@router.post("/{session_id}/timer", response_model=DailySessionOut)
async def timer_action(
session_id: int,
body: TimerAction,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DailySession)
.join(Child)
.where(DailySession.id == session_id, Child.user_id == current_user.id)
.options(selectinload(DailySession.current_block))
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Update current block if provided
if body.block_id is not None:
session.current_block_id = body.block_id
# Record the timer event
event = TimerEvent(
session_id=session.id,
block_id=body.block_id or session.current_block_id,
event_type=body.event_type,
)
db.add(event)
# Mark session complete if event is session-level complete
if body.event_type == "complete" and body.block_id is None:
session.is_active = False
await db.commit()
await db.refresh(session)
# Broadcast the timer event to all TV clients
ws_payload = {
"event": body.event_type,
"session_id": session.id,
"block_id": event.block_id,
"current_block_id": session.current_block_id,
}
await manager.broadcast(session.child_id, ws_payload)
return session

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.subject import Subject
from app.models.user import User
from app.schemas.subject import SubjectCreate, SubjectOut, SubjectUpdate
router = APIRouter(prefix="/api/subjects", tags=["subjects"])
@router.get("", response_model=list[SubjectOut])
async def list_subjects(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.user_id == current_user.id).order_by(Subject.name)
)
return result.scalars().all()
@router.post("", response_model=SubjectOut, status_code=status.HTTP_201_CREATED)
async def create_subject(
body: SubjectCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
subject = Subject(**body.model_dump(), user_id=current_user.id)
db.add(subject)
await db.commit()
await db.refresh(subject)
return subject
@router.get("/{subject_id}", response_model=SubjectOut)
async def get_subject(
subject_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
)
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
return subject
@router.patch("/{subject_id}", response_model=SubjectOut)
async def update_subject(
subject_id: int,
body: SubjectUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
)
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(subject, field, value)
await db.commit()
await db.refresh(subject)
return subject
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_subject(
subject_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id)
)
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
await db.delete(subject)
await db.commit()

View File

@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.user import UserOut, UserUpdate
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.patch("/me", response_model=UserOut)
async def update_me(
body: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if body.full_name is not None:
current_user.full_name = body.full_name
if body.email is not None:
current_user.email = body.email
await db.commit()
await db.refresh(current_user)
return current_user

View File

View File

@@ -0,0 +1,29 @@
from datetime import date
from pydantic import BaseModel
class ActivityLogCreate(BaseModel):
child_id: int
subject_id: int | None = None
session_id: int | None = None
log_date: date
notes: str | None = None
duration_minutes: int | None = None
class ActivityLogUpdate(BaseModel):
notes: str | None = None
duration_minutes: int | None = None
subject_id: int | None = None
class ActivityLogOut(BaseModel):
id: int
child_id: int
subject_id: int | None
session_id: int | None
log_date: date
notes: str | None
duration_minutes: int | None
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel, EmailStr
class RegisterRequest(BaseModel):
email: EmailStr
password: str
full_name: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str

View File

@@ -0,0 +1,25 @@
from datetime import date
from pydantic import BaseModel
class ChildCreate(BaseModel):
name: str
birth_date: date | None = None
color: str = "#4F46E5"
class ChildUpdate(BaseModel):
name: str | None = None
birth_date: date | None = None
color: str | None = None
is_active: bool | None = None
class ChildOut(BaseModel):
id: int
name: str
birth_date: date | None
is_active: bool
color: str
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,46 @@
from datetime import time
from pydantic import BaseModel
class ScheduleBlockCreate(BaseModel):
subject_id: int | None = None
time_start: time
time_end: time
label: str | None = None
notes: str | None = None
order_index: int = 0
class ScheduleBlockOut(BaseModel):
id: int
subject_id: int | None
time_start: time
time_end: time
label: str | None
notes: str | None
order_index: int
model_config = {"from_attributes": True}
class ScheduleTemplateCreate(BaseModel):
name: str
child_id: int | None = None
is_default: bool = False
blocks: list[ScheduleBlockCreate] = []
class ScheduleTemplateUpdate(BaseModel):
name: str | None = None
child_id: int | None = None
is_default: bool | None = None
class ScheduleTemplateOut(BaseModel):
id: int
name: str
child_id: int | None
is_default: bool
blocks: list[ScheduleBlockOut] = []
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,44 @@
from datetime import date, datetime
from pydantic import BaseModel
from app.schemas.schedule import ScheduleBlockOut
from app.schemas.child import ChildOut
class SessionStart(BaseModel):
child_id: int
template_id: int | None = None
session_date: date | None = None # defaults to today
class TimerAction(BaseModel):
event_type: str # start | pause | resume | complete | skip
block_id: int | None = None
class TimerEventOut(BaseModel):
id: int
block_id: int | None
event_type: str
occurred_at: datetime
model_config = {"from_attributes": True}
class DailySessionOut(BaseModel):
id: int
child_id: int
template_id: int | None
session_date: date
is_active: bool
current_block_id: int | None
current_block: ScheduleBlockOut | None = None
model_config = {"from_attributes": True}
class DashboardSnapshot(BaseModel):
"""Public TV dashboard payload — no auth required."""
session: DailySessionOut | None
child: ChildOut
blocks: list[ScheduleBlockOut] = []
completed_block_ids: list[int] = []

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel
class SubjectCreate(BaseModel):
name: str
color: str = "#10B981"
icon: str = "📚"
class SubjectUpdate(BaseModel):
name: str | None = None
color: str | None = None
icon: str | None = None
is_active: bool | None = None
class SubjectOut(BaseModel):
id: int
name: str
color: str
icon: str
is_active: bool
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel, EmailStr
class UserOut(BaseModel):
id: int
email: EmailStr
full_name: str
is_active: bool
is_admin: bool
model_config = {"from_attributes": True}
class UserUpdate(BaseModel):
full_name: str | None = None
email: EmailStr | None = None

View File

View File

@@ -0,0 +1,42 @@
import json
import logging
from collections import defaultdict
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
# child_id → list of active WebSocket connections
self.active: dict[int, list[WebSocket]] = defaultdict(list)
async def connect(self, websocket: WebSocket, child_id: int) -> None:
await websocket.accept()
self.active[child_id].append(websocket)
logger.info("WS connected for child %d%d total", child_id, len(self.active[child_id]))
def disconnect(self, websocket: WebSocket, child_id: int) -> None:
self.active[child_id].discard(websocket) if hasattr(self.active[child_id], "discard") else None
try:
self.active[child_id].remove(websocket)
except ValueError:
pass
logger.info("WS disconnected for child %d%d remaining", child_id, len(self.active[child_id]))
async def broadcast(self, child_id: int, message: dict) -> None:
"""Send a JSON message to all TVs watching a given child."""
dead = []
for ws in list(self.active.get(child_id, [])):
try:
await ws.send_text(json.dumps(message))
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, child_id)
# Singleton — imported by routers and the WS endpoint
manager = ConnectionManager()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
aiomysql==0.2.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
pydantic-settings==2.5.2
alembic==1.13.3
python-multipart==0.0.12

View File

@@ -0,0 +1,17 @@
# Development overrides — hot reload for backend, Vite dev server for frontend
services:
backend:
volumes:
- ./backend/app:/app/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
target: builder
ports:
- "8054:5173"
volumes:
- ./frontend/src:/app/src
- ./frontend/index.html:/app/index.html
command: npm run dev -- --host 0.0.0.0 --port 5173

54
docker-compose.yml Normal file
View File

@@ -0,0 +1,54 @@
services:
db:
image: mysql:8.0
container_name: homeschool_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
networks:
- homeschool_net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
container_name: homeschool_backend
restart: unless-stopped
environment:
DATABASE_URL: mysql+aiomysql://${MYSQL_USER}:${MYSQL_PASSWORD}@db/${MYSQL_DATABASE}
SECRET_KEY: ${SECRET_KEY}
ALGORITHM: ${ALGORITHM:-HS256}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8054}
depends_on:
db:
condition: service_healthy
networks:
- homeschool_net
frontend:
build: ./frontend
container_name: homeschool_frontend
restart: unless-stopped
ports:
- "8054:80"
depends_on:
- backend
networks:
- homeschool_net
networks:
homeschool_net:
driver: bridge
volumes:
mysql_data:

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Stage 1: Build Vue.js app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homeschool Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

28
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# API proxy → FastAPI backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# WebSocket proxy → FastAPI backend
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
# Vue Router — all other paths serve index.html (SPA fallback)
location / {
try_files $uri $uri/ /index.html;
}
}

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "homeschool-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"pinia": "^2.2.4",
"vue": "^3.5.12",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.10"
}
}

27
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,27 @@
<template>
<RouterView />
</template>
<script setup>
import { RouterView } from 'vue-router'
</script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<div class="child-selector" v-if="childrenStore.children.length > 1">
<button
v-for="child in childrenStore.children.filter(c => c.is_active)"
:key="child.id"
class="child-btn"
:class="{ active: childrenStore.activeChild?.id === child.id }"
:style="{ '--color': child.color }"
@click="childrenStore.setActiveChild(child)"
>
{{ child.name }}
</button>
</div>
</template>
<script setup>
import { useChildrenStore } from '@/stores/children'
const childrenStore = useChildrenStore()
</script>
<style scoped>
.child-selector {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.child-btn {
padding: 0.4rem 1rem;
border: 2px solid var(--color, #4f46e5);
background: transparent;
color: #94a3b8;
border-radius: 999px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.child-btn:hover,
.child-btn.active {
background: var(--color, #4f46e5);
color: #fff;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<nav class="navbar">
<RouterLink class="nav-brand" to="/dashboard">🏠 Homeschool</RouterLink>
<div class="nav-links">
<RouterLink to="/dashboard" active-class="active">Dashboard</RouterLink>
<RouterLink to="/schedules" active-class="active">Schedules</RouterLink>
<RouterLink to="/logs" active-class="active">Logs</RouterLink>
<RouterLink to="/admin" active-class="active">Admin</RouterLink>
</div>
<div class="nav-user" v-if="auth.user">
<span class="nav-name">{{ auth.user.full_name }}</span>
<button class="btn-logout" @click="logout">Sign out</button>
</div>
</nav>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
async function logout() {
await auth.logout()
router.push('/login')
}
</script>
<style scoped>
.navbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.875rem 2rem;
background: #1e293b;
border-bottom: 1px solid #334155;
}
.nav-brand {
font-size: 1.1rem;
font-weight: 700;
color: #818cf8;
text-decoration: none;
white-space: nowrap;
}
.nav-links {
display: flex;
gap: 0.25rem;
flex: 1;
}
.nav-links a {
padding: 0.4rem 0.9rem;
border-radius: 0.5rem;
color: #94a3b8;
font-size: 0.9rem;
transition: all 0.2s;
text-decoration: none;
}
.nav-links a:hover,
.nav-links a.active {
background: #334155;
color: #f1f5f9;
}
.nav-user {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.nav-name { font-size: 0.875rem; color: #64748b; }
.btn-logout {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-logout:hover { background: #334155; color: #f1f5f9; }
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: `${Math.min(100, Math.max(0, percent))}%` }"
></div>
</div>
</template>
<script setup>
defineProps({ percent: { type: Number, default: 0 } })
</script>
<style scoped>
.progress-track {
width: 100%;
height: 8px;
background: #0f172a;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #818cf8);
border-radius: 999px;
transition: width 0.5s ease;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div
class="block-card"
:class="{
'is-current': isCurrent,
'is-completed': isCompleted,
compact,
}"
>
<div class="block-indicator" :style="{ background: subjectColor }"></div>
<div class="block-body">
<div class="block-title">
{{ block.label || subjectName || 'Block' }}
</div>
<div class="block-time">{{ block.time_start }} {{ block.time_end }}</div>
</div>
<div class="block-status" v-if="isCompleted"></div>
<div class="block-status active" v-else-if="isCurrent"></div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
block: { type: Object, required: true },
isCurrent: { type: Boolean, default: false },
isCompleted: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
})
const subjectColor = computed(() => props.block.subject?.color || '#475569')
const subjectName = computed(() => props.block.subject?.name || null)
</script>
<style scoped>
.block-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: #1e293b;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid transparent;
transition: all 0.2s;
cursor: default;
}
.block-card.is-current {
border-color: #4f46e5;
background: #1e1b4b;
}
.block-card.is-completed {
opacity: 0.5;
}
.block-card.compact {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
}
.block-indicator {
width: 4px;
height: 36px;
border-radius: 2px;
flex-shrink: 0;
}
.block-card.compact .block-indicator {
height: 24px;
}
.block-body { flex: 1; min-width: 0; }
.block-title {
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.block-card.compact .block-title { font-size: 0.85rem; }
.block-time {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.15rem;
font-variant-numeric: tabular-nums;
}
.block-status {
font-size: 0.85rem;
color: #64748b;
flex-shrink: 0;
}
.block-status.active { color: #818cf8; }
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="timer-wrap">
<!-- SVG circular ring -->
<svg class="timer-ring" viewBox="0 0 200 200">
<circle cx="100" cy="100" r="88" class="ring-bg" />
<circle
cx="100" cy="100" r="88"
class="ring-fill"
:style="{ strokeDashoffset: dashOffset, stroke: ringColor }"
/>
</svg>
<div class="timer-inner">
<div class="timer-time">{{ display }}</div>
<div class="timer-label">{{ label }}</div>
</div>
</div>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
block: { type: Object, default: null },
session: { type: Object, default: null },
})
const elapsed = ref(0)
let interval = null
function parseTime(str) {
if (!str) return 0
const [h, m, s = 0] = str.split(':').map(Number)
return h * 3600 + m * 60 + s
}
const blockDuration = computed(() => {
if (!props.block) return 0
return parseTime(props.block.time_end) - parseTime(props.block.time_start)
})
const remaining = computed(() => Math.max(0, blockDuration.value - elapsed.value))
const display = computed(() => {
const s = remaining.value
const m = Math.floor(s / 60)
const sec = s % 60
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
})
const label = computed(() => {
if (remaining.value === 0) return 'Done!'
return 'remaining'
})
const CIRCUMFERENCE = 2 * Math.PI * 88
const dashOffset = computed(() => {
if (!blockDuration.value) return CIRCUMFERENCE
const pct = remaining.value / blockDuration.value
return CIRCUMFERENCE * (1 - pct)
})
const ringColor = computed(() => props.block?.subject?.color || '#4f46e5')
// Tick timer
watch(() => props.block?.id, () => { elapsed.value = 0 })
function startTick() {
if (interval) clearInterval(interval)
interval = setInterval(() => { elapsed.value++ }, 1000)
}
startTick()
onUnmounted(() => clearInterval(interval))
</script>
<style scoped>
.timer-wrap {
position: relative;
width: 280px;
height: 280px;
}
.timer-ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ring-bg {
fill: none;
stroke: #1e293b;
stroke-width: 12;
}
.ring-fill {
fill: none;
stroke-width: 12;
stroke-linecap: round;
stroke-dasharray: 552.92; /* 2π×88 */
transition: stroke-dashoffset 1s linear, stroke 0.5s;
}
.timer-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.timer-time {
font-size: 4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.02em;
}
.timer-label {
font-size: 0.9rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.1em;
}
</style>

View File

@@ -0,0 +1,55 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/',
withCredentials: true, // send cookies (refresh token)
})
// Attach access token to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Auto-refresh on 401
let refreshing = false
let waitQueue = []
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
if (refreshing) {
return new Promise((resolve, reject) => {
waitQueue.push({ resolve, reject })
}).then(() => api(original))
}
original._retry = true
refreshing = true
try {
const res = await axios.post('/api/auth/refresh', {}, { withCredentials: true })
const token = res.data.access_token
localStorage.setItem('access_token', token)
waitQueue.forEach(({ resolve }) => resolve())
waitQueue = []
original.headers.Authorization = `Bearer ${token}`
return api(original)
} catch (_) {
waitQueue.forEach(({ reject }) => reject())
waitQueue = []
localStorage.removeItem('access_token')
window.location.href = '/login'
} finally {
refreshing = false
}
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,50 @@
import { ref, onUnmounted } from 'vue'
export function useWebSocket(childId, onMessage) {
const connected = ref(false)
let ws = null
let reconnectTimer = null
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const url = `${protocol}://${window.location.host}/ws/${childId}`
ws = new WebSocket(url)
ws.onopen = () => {
connected.value = true
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage(data)
} catch (e) {
console.warn('WS parse error', e)
}
}
ws.onclose = () => {
connected.value = false
// Reconnect after 3 seconds
reconnectTimer = setTimeout(connect, 3000)
}
ws.onerror = () => {
ws.close()
}
}
function disconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
if (ws) ws.close()
}
connect()
onUnmounted(disconnect)
return { connected, disconnect }
}

9
frontend/src/main.js Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,65 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
redirect: '/dashboard',
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true },
},
{
path: '/tv/:childId',
name: 'tv',
component: () => import('@/views/TVView.vue'),
meta: { public: true },
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: '/schedules',
name: 'schedules',
component: () => import('@/views/ScheduleView.vue'),
meta: { requiresAuth: true },
},
{
path: '/logs',
name: 'logs',
component: () => import('@/views/LogView.vue'),
meta: { requiresAuth: true },
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to) => {
if (to.meta.requiresAuth) {
const auth = useAuthStore()
if (!auth.isAuthenticated) {
// Try to refresh before redirecting to login
await auth.tryRefresh()
if (!auth.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } }
}
}
}
})
export default router

View File

@@ -0,0 +1,76 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/composables/useApi'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref(localStorage.getItem('access_token') || null)
const user = ref(null)
const isAuthenticated = computed(() => !!accessToken.value)
function setToken(token) {
accessToken.value = token
localStorage.setItem('access_token', token)
}
function clearToken() {
accessToken.value = null
user.value = null
localStorage.removeItem('access_token')
}
async function login(email, password) {
const res = await api.post('/api/auth/login', { email, password })
setToken(res.data.access_token)
await fetchMe()
}
async function register(email, password, fullName) {
const res = await api.post('/api/auth/register', {
email,
password,
full_name: fullName,
})
setToken(res.data.access_token)
await fetchMe()
}
async function logout() {
try {
await api.post('/api/auth/logout')
} catch (_) {
// ignore errors on logout
}
clearToken()
}
async function tryRefresh() {
try {
const res = await api.post('/api/auth/refresh')
setToken(res.data.access_token)
await fetchMe()
} catch (_) {
clearToken()
}
}
async function fetchMe() {
try {
const res = await api.get('/api/users/me')
user.value = res.data
} catch (_) {
clearToken()
}
}
return {
accessToken,
user,
isAuthenticated,
login,
register,
logout,
tryRefresh,
fetchMe,
}
})

View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/composables/useApi'
export const useChildrenStore = defineStore('children', () => {
const children = ref([])
const activeChild = ref(null)
async function fetchChildren() {
const res = await api.get('/api/children')
children.value = res.data
if (!activeChild.value && children.value.length > 0) {
activeChild.value = children.value[0]
}
}
async function createChild(data) {
const res = await api.post('/api/children', data)
children.value.push(res.data)
return res.data
}
async function updateChild(id, data) {
const res = await api.patch(`/api/children/${id}`, data)
const idx = children.value.findIndex((c) => c.id === id)
if (idx !== -1) children.value[idx] = res.data
return res.data
}
async function deleteChild(id) {
await api.delete(`/api/children/${id}`)
children.value = children.value.filter((c) => c.id !== id)
}
function setActiveChild(child) {
activeChild.value = child
}
return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, setActiveChild }
})

View File

@@ -0,0 +1,79 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/composables/useApi'
export const useScheduleStore = defineStore('schedule', () => {
const session = ref(null)
const blocks = ref([])
const completedBlockIds = ref([])
const child = ref(null)
const currentBlock = computed(() =>
session.value?.current_block_id
? blocks.value.find((b) => b.id === session.value.current_block_id) || null
: null
)
const progressPercent = computed(() => {
if (!blocks.value.length) return 0
return Math.round((completedBlockIds.value.length / blocks.value.length) * 100)
})
function applySnapshot(snapshot) {
session.value = snapshot.session
blocks.value = snapshot.blocks || []
completedBlockIds.value = snapshot.completed_block_ids || []
if (snapshot.child) child.value = snapshot.child
}
function applyWsEvent(event) {
if (event.event === 'session_update') {
applySnapshot(event)
return
}
// Timer events update session state
if (event.current_block_id !== undefined && session.value) {
session.value.current_block_id = event.current_block_id
}
if (event.event === 'complete' && event.block_id) {
if (!completedBlockIds.value.includes(event.block_id)) {
completedBlockIds.value.push(event.block_id)
}
}
}
async function fetchDashboard(childId) {
const res = await api.get(`/api/dashboard/${childId}`)
applySnapshot(res.data)
}
async function startSession(childId, templateId) {
const res = await api.post('/api/sessions', {
child_id: childId,
template_id: templateId,
})
session.value = res.data
completedBlockIds.value = []
}
async function sendTimerAction(sessionId, eventType, blockId = null) {
await api.post(`/api/sessions/${sessionId}/timer`, {
event_type: eventType,
block_id: blockId,
})
}
return {
session,
blocks,
completedBlockIds,
child,
currentBlock,
progressPercent,
applySnapshot,
applyWsEvent,
fetchDashboard,
startSession,
sendTimerAction,
}
})

View File

@@ -0,0 +1,199 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<h1>Admin</h1>
<!-- Children section -->
<section class="section">
<div class="section-header">
<h2>Children</h2>
<button class="btn-primary btn-sm" @click="showChildForm = !showChildForm">+ Add</button>
</div>
<form v-if="showChildForm" @submit.prevent="createChild" class="inline-form">
<input v-model="newChild.name" placeholder="Name" required />
<input v-model="newChild.color" type="color" title="Color" />
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" @click="showChildForm = false">Cancel</button>
</form>
<div class="item-list">
<div v-for="child in childrenStore.children" :key="child.id" class="item-row">
<div class="item-color" :style="{ background: child.color }"></div>
<span class="item-name">{{ child.name }}</span>
<span class="item-meta">{{ child.is_active ? 'Active' : 'Inactive' }}</span>
<div class="item-actions">
<button class="btn-sm" @click="toggleChild(child)">
{{ child.is_active ? 'Deactivate' : 'Activate' }}
</button>
<button class="btn-sm btn-danger" @click="deleteChild(child.id)">Delete</button>
</div>
</div>
<div v-if="childrenStore.children.length === 0" class="empty-small">No children added yet.</div>
</div>
</section>
<!-- Subjects section -->
<section class="section">
<div class="section-header">
<h2>Subjects</h2>
<button class="btn-primary btn-sm" @click="showSubjectForm = !showSubjectForm">+ Add</button>
</div>
<form v-if="showSubjectForm" @submit.prevent="createSubject" class="inline-form">
<input v-model="newSubject.name" placeholder="Subject name" required />
<input v-model="newSubject.icon" placeholder="Icon (emoji)" maxlength="4" style="width:60px" />
<input v-model="newSubject.color" type="color" title="Color" />
<button type="submit" class="btn-primary btn-sm">Save</button>
<button type="button" @click="showSubjectForm = false">Cancel</button>
</form>
<div class="item-list">
<div v-for="subject in subjects" :key="subject.id" class="item-row">
<div class="item-color" :style="{ background: subject.color }"></div>
<span class="item-icon">{{ subject.icon }}</span>
<span class="item-name">{{ subject.name }}</span>
<div class="item-actions">
<button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
</div>
</div>
<div v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
</div>
</section>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
const childrenStore = useChildrenStore()
const subjects = ref([])
const showChildForm = ref(false)
const showSubjectForm = ref(false)
const newChild = ref({ name: '', color: '#4F46E5' })
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
async function createChild() {
await childrenStore.createChild(newChild.value)
newChild.value = { name: '', color: '#4F46E5' }
showChildForm.value = false
}
async function toggleChild(child) {
await childrenStore.updateChild(child.id, { is_active: !child.is_active })
}
async function deleteChild(id) {
if (confirm('Delete this child? All associated data will be removed.')) {
await childrenStore.deleteChild(id)
}
}
async function loadSubjects() {
const res = await api.get('/api/subjects')
subjects.value = res.data
}
async function createSubject() {
const res = await api.post('/api/subjects', newSubject.value)
subjects.value.push(res.data)
newSubject.value = { name: '', icon: '📚', color: '#10B981' }
showSubjectForm.value = false
}
async function deleteSubject(id) {
if (confirm('Delete this subject?')) {
await api.delete(`/api/subjects/${id}`)
subjects.value = subjects.value.filter((s) => s.id !== id)
}
}
onMounted(async () => {
await childrenStore.fetchChildren()
await loadSubjects()
})
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 2rem; }
.section { margin-bottom: 3rem; }
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
.inline-form {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1e293b;
padding: 1rem;
border-radius: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.inline-form input[type="text"],
.inline-form input:not([type="color"]) {
padding: 0.5rem 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
flex: 1;
min-width: 120px;
}
.item-list { display: flex; flex-direction: column; gap: 0.5rem; }
.item-row {
display: flex;
align-items: center;
gap: 0.75rem;
background: #1e293b;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
}
.item-color { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.item-icon { font-size: 1.2rem; }
.item-name { flex: 1; font-weight: 500; }
.item-meta { font-size: 0.8rem; color: #64748b; }
.item-actions { display: flex; gap: 0.4rem; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.btn-primary {
padding: 0.5rem 1rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.btn-sm {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
</style>

View File

@@ -0,0 +1,264 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Dashboard</h1>
<ChildSelector />
</div>
<div v-if="!activeChild" class="empty-state">
<p>Add a child in <RouterLink to="/admin">Admin</RouterLink> to get started.</p>
</div>
<div v-else class="dashboard-grid">
<!-- Today's session card -->
<div class="card session-card">
<div class="card-title">Today's Session</div>
<div v-if="scheduleStore.session">
<div class="session-info">
<span class="badge-active">Active</span>
<span>{{ scheduleStore.progressPercent }}% complete</span>
</div>
<ProgressBar :percent="scheduleStore.progressPercent" />
<div class="session-actions">
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="sendAction('pause')"
>Pause</button>
<button class="btn-sm" @click="sendAction('start')">Resume</button>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
</div>
</div>
<div v-else class="no-session">
<p>No active session.</p>
<button class="btn-primary" @click="showStartDialog = true">Start Day</button>
</div>
</div>
<!-- Schedule blocks -->
<div class="card">
<div class="card-title">Today's Schedule</div>
<div v-if="scheduleStore.blocks.length === 0" class="empty-small">
No blocks loaded.
</div>
<div class="block-list" v-else>
<ScheduleBlock
v-for="block in scheduleStore.blocks"
:key="block.id"
:block="block"
:is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)"
@click="selectBlock(block)"
/>
</div>
</div>
<!-- TV Link -->
<div class="card tv-card">
<div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.id}`" target="_blank" class="btn-primary">
Open TV View →
</a>
</div>
</div>
<!-- Start session dialog -->
<div class="dialog-overlay" v-if="showStartDialog" @click.self="showStartDialog = false">
<div class="dialog">
<h2>Start School Day</h2>
<div class="field">
<label>Schedule Template</label>
<select v-model="selectedTemplate">
<option :value="null">No template (freestyle)</option>
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div class="dialog-actions">
<button @click="showStartDialog = false">Cancel</button>
<button class="btn-primary" @click="startSession">Start</button>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted, watch, computed } from 'vue'
import { useChildrenStore } from '@/stores/children'
import { useScheduleStore } from '@/stores/schedule'
import { useWebSocket } from '@/composables/useWebSocket'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import ScheduleBlock from '@/components/ScheduleBlock.vue'
const childrenStore = useChildrenStore()
const scheduleStore = useScheduleStore()
const activeChild = computed(() => childrenStore.activeChild)
const showStartDialog = ref(false)
const selectedTemplate = ref(null)
const templates = ref([])
let wsDisconnect = null
async function loadDashboard() {
if (!activeChild.value) return
await scheduleStore.fetchDashboard(activeChild.value.id)
// Load templates for start dialog
const res = await api.get('/api/schedules')
templates.value = res.data
// WS subscription
if (wsDisconnect) wsDisconnect()
const { disconnect } = useWebSocket(activeChild.value.id, (msg) => {
scheduleStore.applyWsEvent(msg)
})
wsDisconnect = disconnect
}
async function startSession() {
await scheduleStore.startSession(activeChild.value.id, selectedTemplate.value)
showStartDialog.value = false
await loadDashboard()
}
async function sendAction(type) {
if (!scheduleStore.session) return
await scheduleStore.sendTimerAction(scheduleStore.session.id, type)
}
function selectBlock(block) {
if (!scheduleStore.session) return
scheduleStore.sendTimerAction(scheduleStore.session.id, 'start', block.id)
}
onMounted(async () => {
await childrenStore.fetchChildren()
await loadDashboard()
})
watch(activeChild, loadDashboard)
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.5rem;
}
.card {
background: #1e293b;
border-radius: 1rem;
padding: 1.5rem;
}
.card-title {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 1rem;
}
.session-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
color: #94a3b8;
}
.badge-active {
background: #14532d;
color: #4ade80;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.session-actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
.btn-sm {
padding: 0.4rem 0.9rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
.no-session { text-align: center; padding: 1.5rem 0; color: #64748b; }
.no-session p { margin-bottom: 1rem; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.block-list { display: flex; flex-direction: column; gap: 0.5rem; }
.tv-card { grid-column: span 1; }
.tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; }
.btn-primary {
display: inline-block;
padding: 0.7rem 1.5rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.75rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: #1e293b;
border-radius: 1rem;
padding: 2rem;
width: 380px;
max-width: 90vw;
}
.dialog h2 { margin-bottom: 1.5rem; }
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field select {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Activity Log</h1>
<button class="btn-primary" @click="showForm = !showForm">+ Log Activity</button>
</div>
<ChildSelector style="margin-bottom: 1.5rem" />
<!-- Add form -->
<div class="card" v-if="showForm">
<h3>Log an Activity</h3>
<form @submit.prevent="createLog">
<div class="field-row">
<div class="field">
<label>Child</label>
<select v-model="newLog.child_id" required>
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field">
<label>Date</label>
<input v-model="newLog.log_date" type="date" required />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Subject (optional)</label>
<select v-model="newLog.subject_id">
<option :value="null">None</option>
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
</select>
</div>
<div class="field">
<label>Duration (minutes)</label>
<input v-model.number="newLog.duration_minutes" type="number" min="0" placeholder="e.g. 30" />
</div>
</div>
<div class="field">
<label>Notes</label>
<textarea v-model="newLog.notes" placeholder="What did they do?" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="button" @click="showForm = false">Cancel</button>
<button type="submit" class="btn-primary">Save Log</button>
</div>
</form>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<input v-model="filterDate" type="date" placeholder="Filter by date" />
<button v-if="filterDate" class="btn-sm" @click="filterDate = ''">Clear</button>
</div>
<!-- Logs -->
<div class="log-list">
<div v-for="log in filteredLogs" :key="log.id" class="log-row">
<div class="log-date">{{ log.log_date }}</div>
<div class="log-content">
<div class="log-subject" v-if="log.subject_id">
{{ subjectDisplay(log.subject_id) }}
</div>
<div class="log-notes" v-if="log.notes">{{ log.notes }}</div>
<div class="log-meta" v-if="log.duration_minutes">
{{ log.duration_minutes }} min
</div>
</div>
<button class="btn-sm btn-danger" @click="deleteLog(log.id)"></button>
</div>
<div v-if="filteredLogs.length === 0" class="empty-state">
No activity logs yet.
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
import ChildSelector from '@/components/ChildSelector.vue'
const childrenStore = useChildrenStore()
const logs = ref([])
const subjects = ref([])
const showForm = ref(false)
const filterDate = ref('')
const today = new Date().toISOString().split('T')[0]
const newLog = ref({ child_id: null, subject_id: null, log_date: today, notes: '', duration_minutes: null })
const filteredLogs = computed(() => {
if (!filterDate.value) return logs.value
return logs.value.filter((l) => l.log_date === filterDate.value)
})
function subjectDisplay(id) {
const s = subjects.value.find((s) => s.id === id)
return s ? `${s.icon} ${s.name}` : ''
}
async function loadLogs() {
const res = await api.get('/api/logs')
logs.value = res.data
}
async function createLog() {
await api.post('/api/logs', newLog.value)
newLog.value = { child_id: newLog.value.child_id, subject_id: null, log_date: today, notes: '', duration_minutes: null }
showForm.value = false
await loadLogs()
}
async function deleteLog(id) {
if (confirm('Delete this log entry?')) {
await api.delete(`/api/logs/${id}`)
logs.value = logs.value.filter((l) => l.id !== id)
}
}
onMounted(async () => {
await childrenStore.fetchChildren()
if (childrenStore.activeChild) newLog.value.child_id = childrenStore.activeChild.id
const [sRes] = await Promise.all([api.get('/api/subjects'), loadLogs()])
subjects.value = sRes.data
})
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; }
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
.card h3 { margin-bottom: 1.25rem; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field input, .field select, .field textarea {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
resize: vertical;
}
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.filter-bar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; }
.filter-bar input {
padding: 0.5rem 0.75rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.875rem;
}
.log-list { display: flex; flex-direction: column; gap: 0.5rem; }
.log-row {
display: flex;
align-items: flex-start;
gap: 1rem;
background: #1e293b;
border-radius: 0.75rem;
padding: 1rem 1.25rem;
}
.log-date { font-size: 0.8rem; color: #64748b; width: 90px; flex-shrink: 0; padding-top: 0.1rem; }
.log-content { flex: 1; }
.log-subject { font-size: 0.85rem; color: #818cf8; margin-bottom: 0.2rem; }
.log-notes { font-size: 0.9rem; }
.log-meta { font-size: 0.8rem; color: #64748b; margin-top: 0.25rem; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
.btn-primary {
padding: 0.65rem 1.25rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.btn-sm {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
</style>

View File

@@ -0,0 +1,174 @@
<template>
<div class="login-root">
<div class="login-card">
<div class="login-logo">🏠</div>
<h1>Homeschool</h1>
<p class="login-sub">Sign in to manage your homeschool</p>
<div class="login-tabs">
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">Sign In</button>
<button :class="{ active: mode === 'register' }" @click="mode = 'register'">Register</button>
</div>
<form @submit.prevent="submit" class="login-form">
<div class="field" v-if="mode === 'register'">
<label>Full Name</label>
<input v-model="form.fullName" type="text" placeholder="Jane Smith" required />
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" placeholder="you@example.com" required />
</div>
<div class="field">
<label>Password</label>
<input v-model="form.password" type="password" placeholder="••••••••" required />
</div>
<div class="login-error" v-if="error">{{ error }}</div>
<button type="submit" class="btn-primary" :disabled="loading">
{{ loading ? 'Please wait...' : mode === 'login' ? 'Sign In' : 'Create Account' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const mode = ref('login')
const loading = ref(false)
const error = ref('')
const form = ref({ email: '', password: '', fullName: '' })
async function submit() {
error.value = ''
loading.value = true
try {
if (mode.value === 'login') {
await auth.login(form.value.email, form.value.password)
} else {
await auth.register(form.value.email, form.value.password, form.value.fullName)
}
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} catch (e) {
error.value = e.response?.data?.detail || 'Something went wrong'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-root {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f172a;
padding: 1rem;
}
.login-card {
background: #1e293b;
border-radius: 1.25rem;
padding: 2.5rem;
width: 100%;
max-width: 400px;
text-align: center;
box-shadow: 0 25px 50px rgba(0,0,0,0.5);
}
.login-logo { font-size: 3rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.75rem; font-weight: 700; color: #f1f5f9; }
.login-sub { color: #64748b; margin-top: 0.25rem; margin-bottom: 1.5rem; }
.login-tabs {
display: flex;
background: #0f172a;
border-radius: 0.75rem;
padding: 0.25rem;
margin-bottom: 1.5rem;
}
.login-tabs button {
flex: 1;
padding: 0.6rem;
border: none;
background: transparent;
color: #64748b;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.login-tabs button.active {
background: #4f46e5;
color: #fff;
font-weight: 600;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: left;
}
.field label {
display: block;
font-size: 0.85rem;
color: #94a3b8;
margin-bottom: 0.4rem;
}
.field input {
width: 100%;
padding: 0.75rem 1rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.75rem;
color: #f1f5f9;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.field input:focus {
border-color: #4f46e5;
}
.login-error {
background: #450a0a;
color: #fca5a5;
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
}
.btn-primary {
width: 100%;
padding: 0.85rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
margin-top: 0.5rem;
}
.btn-primary:hover:not(:disabled) { background: #4338ca; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="page">
<NavBar />
<main class="container">
<div class="page-header">
<h1>Schedules</h1>
<button class="btn-primary" @click="showCreateForm = !showCreateForm">+ New Template</button>
</div>
<!-- Create form -->
<div class="card" v-if="showCreateForm">
<h3>New Schedule Template</h3>
<form @submit.prevent="createTemplate">
<div class="field">
<label>Template Name</label>
<input v-model="newTemplate.name" placeholder="e.g. Monday Schedule" required />
</div>
<div class="field">
<label>Child (optional leave blank for all children)</label>
<select v-model="newTemplate.child_id">
<option :value="null">All children</option>
<option v-for="c in childrenStore.children" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="form-actions">
<button type="button" @click="showCreateForm = false">Cancel</button>
<button type="submit" class="btn-primary">Create</button>
</div>
</form>
</div>
<!-- Template list -->
<div class="template-list">
<div v-for="template in templates" :key="template.id" class="template-card">
<div class="template-header">
<div>
<div class="template-name">{{ template.name }}</div>
<div class="template-child">
{{ template.child_id ? childName(template.child_id) : 'All children' }}
· {{ template.blocks.length }} blocks
</div>
</div>
<div class="template-actions">
<button class="btn-sm" @click="editingTemplate = editingTemplate === template.id ? null : template.id">
{{ editingTemplate === template.id ? 'Close' : 'Edit Blocks' }}
</button>
<button class="btn-sm btn-danger" @click="deleteTemplate(template.id)">Delete</button>
</div>
</div>
<!-- Block editor -->
<div v-if="editingTemplate === template.id" class="block-editor">
<div class="block-list">
<div v-for="block in template.blocks" :key="block.id" class="block-row">
<span class="block-time">{{ block.time_start }} {{ block.time_end }}</span>
<span class="block-label">{{ block.label || subjectName(block.subject_id) || 'Unnamed' }}</span>
<button class="btn-sm btn-danger" @click="deleteBlock(template.id, block.id)"></button>
</div>
<div v-if="template.blocks.length === 0" class="empty-small">No blocks yet.</div>
</div>
<!-- Add block form -->
<form @submit.prevent="addBlock(template.id)" class="add-block-form">
<select v-model="newBlock.subject_id">
<option :value="null">No subject</option>
<option v-for="s in subjects" :key="s.id" :value="s.id">{{ s.icon }} {{ s.name }}</option>
</select>
<input v-model="newBlock.time_start" type="time" required />
<span>to</span>
<input v-model="newBlock.time_end" type="time" required />
<input v-model="newBlock.label" placeholder="Label (optional)" />
<button type="submit" class="btn-primary btn-sm">Add Block</button>
</form>
</div>
</div>
<div v-if="templates.length === 0 && !showCreateForm" class="empty-state">
No schedule templates yet. Create one to get started.
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useChildrenStore } from '@/stores/children'
import api from '@/composables/useApi'
import NavBar from '@/components/NavBar.vue'
const childrenStore = useChildrenStore()
const templates = ref([])
const subjects = ref([])
const showCreateForm = ref(false)
const editingTemplate = ref(null)
const newTemplate = ref({ name: '', child_id: null, is_default: false })
const newBlock = ref({ subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 })
function childName(id) {
return childrenStore.children.find((c) => c.id === id)?.name || 'Unknown'
}
function subjectName(id) {
return subjects.value.find((s) => s.id === id)?.name || null
}
async function loadTemplates() {
const res = await api.get('/api/schedules')
templates.value = res.data
}
async function createTemplate() {
await api.post('/api/schedules', newTemplate.value)
newTemplate.value = { name: '', child_id: null, is_default: false }
showCreateForm.value = false
await loadTemplates()
}
async function deleteTemplate(id) {
if (confirm('Delete this template and all its blocks?')) {
await api.delete(`/api/schedules/${id}`)
await loadTemplates()
}
}
async function addBlock(templateId) {
const payload = {
...newBlock.value,
order_index: templates.value.find((t) => t.id === templateId)?.blocks.length || 0,
}
await api.post(`/api/schedules/${templateId}/blocks`, payload)
newBlock.value = { subject_id: null, time_start: '', time_end: '', label: '', order_index: 0 }
await loadTemplates()
}
async function deleteBlock(templateId, blockId) {
await api.delete(`/api/schedules/${templateId}/blocks/${blockId}`)
await loadTemplates()
}
onMounted(async () => {
await childrenStore.fetchChildren()
const [sRes] = await Promise.all([api.get('/api/subjects'), loadTemplates()])
subjects.value = sRes.data
})
</script>
<style scoped>
.page { min-height: 100vh; background: #0f172a; }
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
h1 { font-size: 1.75rem; font-weight: 700; }
.card { background: #1e293b; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; }
.card h3 { margin-bottom: 1.25rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
.field input, .field select {
width: 100%;
padding: 0.65rem 0.9rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
}
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.template-list { display: flex; flex-direction: column; gap: 1rem; }
.template-card { background: #1e293b; border-radius: 1rem; padding: 1.25rem; }
.template-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.template-name { font-size: 1.05rem; font-weight: 600; }
.template-child { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
.template-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.block-editor { margin-top: 1.25rem; border-top: 1px solid #334155; padding-top: 1.25rem; }
.block-list { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
.block-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: #0f172a; border-radius: 0.5rem; }
.block-time { font-size: 0.8rem; color: #64748b; font-variant-numeric: tabular-nums; }
.block-label { flex: 1; font-size: 0.9rem; }
.add-block-form {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
background: #0f172a;
padding: 0.75rem;
border-radius: 0.75rem;
}
.add-block-form select,
.add-block-form input {
padding: 0.4rem 0.6rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.4rem;
color: #f1f5f9;
font-size: 0.85rem;
}
.add-block-form span { color: #64748b; }
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
.empty-small { color: #64748b; font-size: 0.85rem; padding: 0.5rem 0; }
.btn-primary {
padding: 0.65rem 1.25rem;
background: #4f46e5;
color: #fff;
border: none;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
transition: background 0.2s;
}
.btn-primary:hover { background: #4338ca; }
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
.btn-sm {
padding: 0.35rem 0.75rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.btn-sm:hover { background: #334155; }
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
.btn-sm.btn-danger:hover { background: #7f1d1d; }
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="tv-root">
<!-- Header bar -->
<header class="tv-header">
<div class="tv-child-name">{{ scheduleStore.child?.name || 'Loading...' }}</div>
<div class="tv-clock">{{ clockDisplay }}</div>
<div class="tv-date">{{ dateDisplay }}</div>
</header>
<!-- No session state -->
<div v-if="!scheduleStore.session" class="tv-idle">
<div class="tv-idle-icon">🌟</div>
<div class="tv-idle-text">No active school session today</div>
</div>
<!-- Active session -->
<div v-else class="tv-main">
<!-- Current block (big display) -->
<div class="tv-current" v-if="scheduleStore.currentBlock">
<div
class="tv-subject-badge"
:style="{ background: currentSubjectColor }"
>
{{ currentSubjectIcon }} {{ currentSubjectName }}
</div>
<TimerDisplay
:block="scheduleStore.currentBlock"
:session="scheduleStore.session"
/>
<div class="tv-block-notes" v-if="scheduleStore.currentBlock.notes">
{{ scheduleStore.currentBlock.notes }}
</div>
</div>
<div class="tv-sidebar">
<!-- Progress -->
<div class="tv-progress-section">
<div class="tv-progress-label">
Day Progress {{ scheduleStore.progressPercent }}%
</div>
<ProgressBar :percent="scheduleStore.progressPercent" />
<div class="tv-block-count">
{{ scheduleStore.completedBlockIds.length }} of {{ scheduleStore.blocks.length }} blocks
</div>
</div>
<!-- Schedule list -->
<div class="tv-schedule-list">
<ScheduleBlock
v-for="block in scheduleStore.blocks"
:key="block.id"
:block="block"
:is-current="block.id === scheduleStore.session?.current_block_id"
:is-completed="scheduleStore.completedBlockIds.includes(block.id)"
compact
/>
</div>
</div>
</div>
<!-- WS connection indicator -->
<div class="tv-ws-status" :class="{ connected: wsConnected }">
{{ wsConnected ? '● Live' : '○ Reconnecting...' }}
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useScheduleStore } from '@/stores/schedule'
import { useWebSocket } from '@/composables/useWebSocket'
import TimerDisplay from '@/components/TimerDisplay.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import ScheduleBlock from '@/components/ScheduleBlock.vue'
const route = useRoute()
const scheduleStore = useScheduleStore()
const childId = parseInt(route.params.childId)
// Clock
const now = ref(new Date())
setInterval(() => { now.value = new Date() }, 1000)
const clockDisplay = computed(() =>
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
)
const dateDisplay = computed(() =>
now.value.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
)
// Subject display helpers
const currentSubjectColor = computed(() => {
const block = scheduleStore.currentBlock
return block?.subject?.color || '#4F46E5'
})
const currentSubjectIcon = computed(() => scheduleStore.currentBlock?.subject?.icon || '📚')
const currentSubjectName = computed(() =>
scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block'
)
// WebSocket
const wsConnected = ref(false)
const { connected } = useWebSocket(childId, (msg) => {
scheduleStore.applyWsEvent(msg)
})
wsConnected.value = connected.value
// Initial data load
onMounted(async () => {
await scheduleStore.fetchDashboard(childId)
})
</script>
<style scoped>
.tv-root {
min-height: 100vh;
background: #0f172a;
color: #f1f5f9;
display: flex;
flex-direction: column;
padding: 2rem;
gap: 2rem;
}
.tv-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid #1e293b;
padding-bottom: 1rem;
}
.tv-child-name {
font-size: 2.5rem;
font-weight: 700;
color: #818cf8;
}
.tv-clock {
font-size: 3rem;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: #f8fafc;
}
.tv-date {
font-size: 1.25rem;
color: #94a3b8;
text-align: right;
}
.tv-idle {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.tv-idle-icon { font-size: 5rem; }
.tv-idle-text { font-size: 2rem; color: #64748b; }
.tv-main {
flex: 1;
display: grid;
grid-template-columns: 1fr 380px;
gap: 2rem;
}
.tv-current {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
justify-content: center;
}
.tv-subject-badge {
font-size: 1.75rem;
font-weight: 600;
padding: 0.75rem 2rem;
border-radius: 999px;
color: #fff;
}
.tv-block-notes {
font-size: 1.25rem;
color: #94a3b8;
text-align: center;
max-width: 600px;
}
.tv-sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.tv-progress-section {
background: #1e293b;
border-radius: 1rem;
padding: 1.25rem;
}
.tv-progress-label {
font-size: 0.9rem;
color: #64748b;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tv-block-count {
font-size: 0.85rem;
color: #475569;
margin-top: 0.5rem;
text-align: right;
}
.tv-schedule-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
}
.tv-ws-status {
position: fixed;
bottom: 1rem;
right: 1rem;
font-size: 0.75rem;
color: #ef4444;
opacity: 0.7;
}
.tv-ws-status.connected {
color: #22c55e;
}
</style>

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://backend:8000',
ws: true,
changeOrigin: true,
},
},
},
})