diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 91b7e6c..0d01900 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,7 +2,7 @@ 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.subject import Subject, SubjectOption from app.models.schedule import ScheduleTemplate, ScheduleBlock from app.models.session import DailySession, TimerEvent, TimerEventType from app.models.activity import ActivityLog @@ -13,6 +13,7 @@ __all__ = [ "User", "Child", "Subject", + "SubjectOption", "ScheduleTemplate", "ScheduleBlock", "DailySession", diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index 3db2441..d8fb914 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Boolean, ForeignKey +from sqlalchemy import String, Boolean, ForeignKey, Text, Integer from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin @@ -20,3 +20,20 @@ class Subject(TimestampMixin, Base): activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821 "ActivityLog", back_populates="subject" ) + options: Mapped[list["SubjectOption"]] = relationship( + "SubjectOption", back_populates="subject", cascade="all, delete-orphan", + order_by="SubjectOption.order_index" + ) + + +class SubjectOption(Base): + __tablename__ = "subject_options" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + subject_id: Mapped[int] = mapped_column( + ForeignKey("subjects.id", ondelete="CASCADE"), nullable=False + ) + text: Mapped[str] = mapped_column(Text, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0) + + subject: Mapped["Subject"] = relationship("Subject", back_populates="options") diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index a32ba33..9201ca8 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db from app.models.child import Child from app.models.schedule import ScheduleBlock, ScheduleTemplate +from app.models.subject import Subject # noqa: F401 — needed for selectinload chain from app.models.session import DailySession, TimerEvent from app.schemas.session import DashboardSnapshot @@ -48,6 +49,7 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)): blocks_result = await db.execute( select(ScheduleBlock) .where(ScheduleBlock.template_id == session.template_id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) .order_by(ScheduleBlock.order_index) ) blocks = blocks_result.scalars().all() diff --git a/backend/app/routers/schedules.py b/backend/app/routers/schedules.py index e1f0cfc..e3f609b 100644 --- a/backend/app/routers/schedules.py +++ b/backend/app/routers/schedules.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import selectinload from app.dependencies import get_db, get_current_user from app.models.schedule import ScheduleTemplate, ScheduleBlock +from app.models.subject import Subject # noqa: F401 — for selectinload chain from app.models.user import User from app.schemas.schedule import ( ScheduleTemplateCreate, @@ -26,7 +27,7 @@ async def list_templates( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.user_id == current_user.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) .order_by(ScheduleTemplate.name) ) return result.scalars().all() @@ -60,7 +61,7 @@ async def create_template( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.id == template.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) ) return result.scalar_one() @@ -74,7 +75,7 @@ async def get_template( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) ) template = result.scalar_one_or_none() if not template: @@ -92,7 +93,7 @@ async def update_template( result = await db.execute( select(ScheduleTemplate) .where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id) - .options(selectinload(ScheduleTemplate.blocks)) + .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options)) ) template = result.scalar_one_or_none() if not template: @@ -141,8 +142,12 @@ async def add_block( block = ScheduleBlock(template_id=template_id, **body.model_dump()) db.add(block) await db.commit() - await db.refresh(block) - return block + result = await db.execute( + select(ScheduleBlock) + .where(ScheduleBlock.id == block.id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) + ) + return result.scalar_one() @router.patch("/{template_id}/blocks/{block_id}", response_model=ScheduleBlockOut) @@ -169,8 +174,12 @@ async def update_block( for field, value in body.model_dump(exclude_unset=True).items(): setattr(block, field, value) await db.commit() - await db.refresh(block) - return block + result = await db.execute( + select(ScheduleBlock) + .where(ScheduleBlock.id == block.id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) + ) + return result.scalar_one() @router.delete("/{template_id}/blocks/{block_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py index 03c7d68..90d212e 100644 --- a/backend/app/routers/sessions.py +++ b/backend/app/routers/sessions.py @@ -8,6 +8,7 @@ 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.subject import Subject # noqa: F401 — needed for selectinload chain from app.models.session import DailySession, TimerEvent from app.models.user import User from app.schemas.session import DailySessionOut, SessionStart, TimerAction @@ -26,12 +27,21 @@ async def _broadcast_session(db: AsyncSession, session: DailySession) -> None: blocks_result = await db.execute( select(ScheduleBlock) .where(ScheduleBlock.template_id == session.template_id) + .options(selectinload(ScheduleBlock.subject).selectinload(Subject.options)) .order_by(ScheduleBlock.order_index) ) blocks = [ { "id": b.id, "subject_id": b.subject_id, + "subject": { + "id": b.subject.id, + "name": b.subject.name, + "color": b.subject.color, + "icon": b.subject.icon, + "options": [{"id": o.id, "text": o.text, "order_index": o.order_index} + for o in b.subject.options], + } if b.subject else None, "time_start": str(b.time_start), "time_end": str(b.time_end), "duration_minutes": b.duration_minutes, diff --git a/backend/app/routers/subjects.py b/backend/app/routers/subjects.py index 69411f6..264f8b6 100644 --- a/backend/app/routers/subjects.py +++ b/backend/app/routers/subjects.py @@ -1,22 +1,33 @@ 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.subject import Subject +from app.models.subject import Subject, SubjectOption from app.models.user import User -from app.schemas.subject import SubjectCreate, SubjectOut, SubjectUpdate +from app.schemas.subject import ( + SubjectCreate, SubjectOut, SubjectUpdate, + SubjectOptionCreate, SubjectOptionUpdate, SubjectOptionOut, +) router = APIRouter(prefix="/api/subjects", tags=["subjects"]) +def _opts(): + return selectinload(Subject.options) + + @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) + select(Subject) + .where(Subject.user_id == current_user.id) + .options(_opts()) + .order_by(Subject.name) ) return result.scalars().all() @@ -30,8 +41,10 @@ async def create_subject( subject = Subject(**body.model_dump(), user_id=current_user.id) db.add(subject) await db.commit() - await db.refresh(subject) - return subject + result = await db.execute( + select(Subject).where(Subject.id == subject.id).options(_opts()) + ) + return result.scalar_one() @router.get("/{subject_id}", response_model=SubjectOut) @@ -41,7 +54,9 @@ async def get_subject( db: AsyncSession = Depends(get_db), ): result = await db.execute( - select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id) + select(Subject) + .where(Subject.id == subject_id, Subject.user_id == current_user.id) + .options(_opts()) ) subject = result.scalar_one_or_none() if not subject: @@ -57,7 +72,9 @@ async def update_subject( db: AsyncSession = Depends(get_db), ): result = await db.execute( - select(Subject).where(Subject.id == subject_id, Subject.user_id == current_user.id) + select(Subject) + .where(Subject.id == subject_id, Subject.user_id == current_user.id) + .options(_opts()) ) subject = result.scalar_one_or_none() if not subject: @@ -66,8 +83,10 @@ async def update_subject( for field, value in body.model_dump(exclude_none=True).items(): setattr(subject, field, value) await db.commit() - await db.refresh(subject) - return subject + result = await db.execute( + select(Subject).where(Subject.id == subject.id).options(_opts()) + ) + return result.scalar_one() @router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -84,3 +103,76 @@ async def delete_subject( raise HTTPException(status_code=404, detail="Subject not found") await db.delete(subject) await db.commit() + + +# --- Subject Option sub-routes --- + +@router.post("/{subject_id}/options", response_model=SubjectOptionOut, status_code=status.HTTP_201_CREATED) +async def add_option( + subject_id: int, + body: SubjectOptionCreate, + 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) + ) + if not result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Subject not found") + + option = SubjectOption(subject_id=subject_id, **body.model_dump()) + db.add(option) + await db.commit() + await db.refresh(option) + return option + + +@router.patch("/{subject_id}/options/{option_id}", response_model=SubjectOptionOut) +async def update_option( + subject_id: int, + option_id: int, + body: SubjectOptionUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(SubjectOption) + .join(Subject) + .where( + SubjectOption.id == option_id, + SubjectOption.subject_id == subject_id, + Subject.user_id == current_user.id, + ) + ) + option = result.scalar_one_or_none() + if not option: + raise HTTPException(status_code=404, detail="Option not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(option, field, value) + await db.commit() + await db.refresh(option) + return option + + +@router.delete("/{subject_id}/options/{option_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_option( + subject_id: int, + option_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(SubjectOption) + .join(Subject) + .where( + SubjectOption.id == option_id, + SubjectOption.subject_id == subject_id, + Subject.user_id == current_user.id, + ) + ) + option = result.scalar_one_or_none() + if not option: + raise HTTPException(status_code=404, detail="Option not found") + await db.delete(option) + await db.commit() diff --git a/backend/app/schemas/schedule.py b/backend/app/schemas/schedule.py index 3be64db..448bdfb 100644 --- a/backend/app/schemas/schedule.py +++ b/backend/app/schemas/schedule.py @@ -1,5 +1,6 @@ from datetime import time from pydantic import BaseModel +from app.schemas.subject import SubjectOut class ScheduleBlockCreate(BaseModel): @@ -25,6 +26,7 @@ class ScheduleBlockUpdate(BaseModel): class ScheduleBlockOut(BaseModel): id: int subject_id: int | None + subject: SubjectOut | None = None time_start: time time_end: time duration_minutes: int | None diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index 4c03faf..d7ef68b 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -1,6 +1,23 @@ from pydantic import BaseModel +class SubjectOptionCreate(BaseModel): + text: str + order_index: int = 0 + + +class SubjectOptionUpdate(BaseModel): + text: str | None = None + + +class SubjectOptionOut(BaseModel): + id: int + text: str + order_index: int + + model_config = {"from_attributes": True} + + class SubjectCreate(BaseModel): name: str color: str = "#10B981" @@ -20,5 +37,6 @@ class SubjectOut(BaseModel): color: str icon: str is_active: bool + options: list[SubjectOptionOut] = [] model_config = {"from_attributes": True} diff --git a/frontend/src/components/ScheduleBlock.vue b/frontend/src/components/ScheduleBlock.vue index da5006f..3f9b369 100644 --- a/frontend/src/components/ScheduleBlock.vue +++ b/frontend/src/components/ScheduleBlock.vue @@ -10,7 +10,8 @@
- {{ block.label || subjectName || 'Block' }} + {{ subjectName || block.label || 'Block' }} + - {{ block.label }}
{{ block.time_start }} – {{ block.time_end }} @@ -108,6 +109,7 @@ const durationLabel = computed(() => { font-variant-numeric: tabular-nums; } +.block-label-suffix { font-weight: 400; color: #94a3b8; } .block-duration { color: #475569; } .block-duration-custom { color: #818cf8; } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index b0ab7d0..b69ec69 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -61,25 +61,57 @@
-
- - +
+ + +
+
No subjects added yet.
@@ -269,6 +301,11 @@ async function deleteChild(id) { const showSubjectForm = ref(false) const newSubject = ref({ name: '', icon: '📚', color: '#10B981' }) const editingSubject = ref(null) +const expandedSubject = ref(null) + +// Subject options +const newOptionText = ref('') +const editingOption = ref(null) async function loadSubjects() { const res = await api.get('/api/subjects') @@ -305,6 +342,32 @@ async function deleteSubject(id) { } } +async function addOption(subjectId) { + await api.post(`/api/subjects/${subjectId}/options`, { + text: newOptionText.value, + order_index: subjects.value.find(s => s.id === subjectId)?.options.length || 0, + }) + newOptionText.value = '' + await loadSubjects() +} + +function startEditOption(opt) { + editingOption.value = { id: opt.id, text: opt.text } +} + +async function saveOption(subjectId) { + await api.patch(`/api/subjects/${subjectId}/options/${editingOption.value.id}`, { + text: editingOption.value.text, + }) + editingOption.value = null + await loadSubjects() +} + +async function deleteOption(subjectId, optionId) { + await api.delete(`/api/subjects/${subjectId}/options/${optionId}`) + await loadSubjects() +} + // Schedules const templates = ref([]) const showCreateForm = ref(false) @@ -535,6 +598,57 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin .edit-block-form span { color: #64748b; } .edit-block-form { border: 1px solid #4f46e5; } +.subject-block { display: flex; flex-direction: column; gap: 0; } + +.options-panel { + background: #0f172a; + border-radius: 0 0 0.75rem 0.75rem; + padding: 0.75rem 1rem; + margin-top: -0.5rem; + border: 1px solid #1e293b; + border-top: none; +} + +.option-list { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.75rem; } + +.option-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 0.6rem; + background: #1e293b; + border-radius: 0.4rem; +} + +.option-text { flex: 1; font-size: 0.9rem; } + +.option-edit-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + background: #1e293b; + border: 1px solid #4f46e5; + border-radius: 0.4rem; +} + +.option-input { + flex: 1; + padding: 0.4rem 0.6rem; + background: #0f172a; + border: 1px solid #334155; + border-radius: 0.4rem; + color: #f1f5f9; + font-size: 0.9rem; +} + +.option-add-row { + display: flex; + gap: 0.5rem; +} + +.option-add-row .option-input { background: #1e293b; } + .empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; } .btn-primary { diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index 3e01636..fd9d42b 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -25,12 +25,9 @@
- -
-
+ +
+
{{ currentSubjectIcon }} {{ currentSubjectName }}
+ +
+
Activities
+
+
+ {{ opt.text }} +
+
+
No activities listed for this subject.
+
+ +
-
scheduleStore.currentBlock?.subject?.i const currentSubjectName = computed(() => scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block' ) +const currentSubjectOptions = computed(() => + scheduleStore.currentBlock?.subject?.options || [] +) // WebSocket const { connected: wsConnected } = useWebSocket(childId, (msg) => { @@ -188,11 +206,12 @@ onMounted(async () => { .tv-main { flex: 1; display: grid; - grid-template-columns: 1fr 380px; + grid-template-columns: repeat(3, 1fr); gap: 2rem; + min-height: 0; } -.tv-current { +.tv-timer-col { display: flex; flex-direction: column; gap: 1.5rem; @@ -201,18 +220,56 @@ onMounted(async () => { } .tv-subject-badge { - font-size: 1.75rem; + font-size: 1.4rem; font-weight: 600; - padding: 0.75rem 2rem; + padding: 0.6rem 1.5rem; border-radius: 999px; color: #fff; + text-align: center; } .tv-block-notes { - font-size: 1.25rem; + font-size: 1rem; color: #94a3b8; text-align: center; - max-width: 600px; +} + +.tv-options-col { + display: flex; + flex-direction: column; + gap: 1rem; + justify-content: center; + border: 2px solid; + border-radius: 1rem; + padding: 1.5rem 2rem; +} + +.tv-options-title { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #475569; +} + +.tv-options-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tv-option-item { + font-size: 1.6rem; + font-weight: 500; + color: #e2e8f0; + padding: 0.6rem 0; + border-bottom: 1px solid #1e293b; + line-height: 1.3; +} + +.tv-options-empty { + font-size: 1.1rem; + color: #334155; + font-style: italic; } .tv-day-progress {