Add subject options and redesign TV dashboard layout

Subject options:
- New subject_options table (auto-created on startup)
- SubjectOut now includes options list; all eager-loading chains updated
- Admin: Options panel per subject with add, inline edit, and delete
- WS broadcast and dashboard API include options in block subject data

TV dashboard:
- Three equal columns: Timer | Activities | Schedule
- Activities column shows current subject's options in large readable text
- Activities area has subject-colored border and tinted background
- Subject name and label displayed correctly using embedded subject data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 11:18:55 -08:00
parent c12f07daa3
commit c9441a9c9a
11 changed files with 375 additions and 51 deletions

View File

@@ -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",

View File

@@ -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")

View File

@@ -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()

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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}

View File

@@ -10,7 +10,8 @@
<div class="block-indicator" :style="{ background: subjectColor }"></div>
<div class="block-body">
<div class="block-title">
{{ block.label || subjectName || 'Block' }}
<span>{{ subjectName || block.label || 'Block' }}</span>
<span v-if="subjectName && block.label" class="block-label-suffix"> - {{ block.label }}</span>
</div>
<div class="block-time">
{{ 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; }

View File

@@ -61,7 +61,9 @@
</form>
<div class="item-list">
<div v-for="subject in subjects" :key="subject.id" class="item-row">
<div v-for="subject in subjects" :key="subject.id" class="subject-block">
<!-- Subject row -->
<div class="item-row">
<template v-if="editingSubject && editingSubject.id === subject.id">
<input v-model="editingSubject.name" class="edit-input" required />
<input v-model="editingSubject.icon" placeholder="Icon" maxlength="4" style="width:60px" class="edit-input" />
@@ -77,10 +79,40 @@
<span class="item-name">{{ subject.name }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditSubject(subject)">Edit</button>
<button
class="btn-sm"
@click="expandedSubject = expandedSubject === subject.id ? null : subject.id"
>{{ expandedSubject === subject.id ? 'Hide Options' : 'Options' }}</button>
<button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
</div>
</template>
</div>
<!-- Options panel -->
<div v-if="expandedSubject === subject.id" class="options-panel">
<div class="option-list">
<template v-for="opt in subject.options" :key="opt.id">
<div v-if="editingOption && editingOption.id === opt.id" class="option-edit-row">
<input v-model="editingOption.text" class="option-input" @keyup.enter="saveOption(subject.id)" />
<button class="btn-sm btn-primary" @click="saveOption(subject.id)">Save</button>
<button class="btn-sm" @click="editingOption = null">Cancel</button>
</div>
<div v-else class="option-row">
<span class="option-text">{{ opt.text }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditOption(opt)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteOption(subject.id, opt.id)"></button>
</div>
</div>
</template>
<div v-if="subject.options.length === 0" class="empty-small">No options yet.</div>
</div>
<form class="option-add-row" @submit.prevent="addOption(subject.id)">
<input v-model="newOptionText" placeholder="Add an option..." class="option-input" required />
<button type="submit" class="btn-primary btn-sm">Add</button>
</form>
</div>
</div>
<div v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
</div>
</section>
@@ -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 {

View File

@@ -25,12 +25,9 @@
<!-- 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 }"
>
<!-- Left: timer -->
<div class="tv-timer-col" v-if="scheduleStore.currentBlock">
<div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
{{ currentSubjectIcon }} {{ currentSubjectName }}
</div>
<TimerDisplay
@@ -45,8 +42,26 @@
</div>
</div>
<!-- Center: subject options -->
<div class="tv-options-col" :style="{ background: currentSubjectColor + '22', borderColor: currentSubjectColor }">
<div class="tv-options-title">Activities</div>
<div
v-if="currentSubjectOptions.length"
class="tv-options-list"
>
<div
v-for="opt in currentSubjectOptions"
:key="opt.id"
class="tv-option-item"
>
{{ opt.text }}
</div>
</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
</div>
<!-- Right: schedule list -->
<div class="tv-sidebar">
<!-- Schedule list -->
<div class="tv-schedule-list">
<ScheduleBlock
v-for="block in scheduleStore.blocks"
@@ -123,6 +138,9 @@ const currentSubjectIcon = computed(() => 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 {