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.base import Base, TimestampMixin
from app.models.user import User from app.models.user import User
from app.models.child import Child 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.schedule import ScheduleTemplate, ScheduleBlock
from app.models.session import DailySession, TimerEvent, TimerEventType from app.models.session import DailySession, TimerEvent, TimerEventType
from app.models.activity import ActivityLog from app.models.activity import ActivityLog
@@ -13,6 +13,7 @@ __all__ = [
"User", "User",
"Child", "Child",
"Subject", "Subject",
"SubjectOption",
"ScheduleTemplate", "ScheduleTemplate",
"ScheduleBlock", "ScheduleBlock",
"DailySession", "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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -20,3 +20,20 @@ class Subject(TimestampMixin, Base):
activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821 activity_logs: Mapped[list["ActivityLog"]] = relationship( # noqa: F821
"ActivityLog", back_populates="subject" "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.dependencies import get_db
from app.models.child import Child from app.models.child import Child
from app.models.schedule import ScheduleBlock, ScheduleTemplate 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.session import DailySession, TimerEvent
from app.schemas.session import DashboardSnapshot 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( blocks_result = await db.execute(
select(ScheduleBlock) select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id) .where(ScheduleBlock.template_id == session.template_id)
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.order_by(ScheduleBlock.order_index) .order_by(ScheduleBlock.order_index)
) )
blocks = blocks_result.scalars().all() 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.dependencies import get_db, get_current_user
from app.models.schedule import ScheduleTemplate, ScheduleBlock 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.models.user import User
from app.schemas.schedule import ( from app.schemas.schedule import (
ScheduleTemplateCreate, ScheduleTemplateCreate,
@@ -26,7 +27,7 @@ async def list_templates(
result = await db.execute( result = await db.execute(
select(ScheduleTemplate) select(ScheduleTemplate)
.where(ScheduleTemplate.user_id == current_user.id) .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) .order_by(ScheduleTemplate.name)
) )
return result.scalars().all() return result.scalars().all()
@@ -60,7 +61,7 @@ async def create_template(
result = await db.execute( result = await db.execute(
select(ScheduleTemplate) select(ScheduleTemplate)
.where(ScheduleTemplate.id == template.id) .where(ScheduleTemplate.id == template.id)
.options(selectinload(ScheduleTemplate.blocks)) .options(selectinload(ScheduleTemplate.blocks).selectinload(ScheduleBlock.subject).selectinload(Subject.options))
) )
return result.scalar_one() return result.scalar_one()
@@ -74,7 +75,7 @@ async def get_template(
result = await db.execute( result = await db.execute(
select(ScheduleTemplate) select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id) .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() template = result.scalar_one_or_none()
if not template: if not template:
@@ -92,7 +93,7 @@ async def update_template(
result = await db.execute( result = await db.execute(
select(ScheduleTemplate) select(ScheduleTemplate)
.where(ScheduleTemplate.id == template_id, ScheduleTemplate.user_id == current_user.id) .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() template = result.scalar_one_or_none()
if not template: if not template:
@@ -141,8 +142,12 @@ async def add_block(
block = ScheduleBlock(template_id=template_id, **body.model_dump()) block = ScheduleBlock(template_id=template_id, **body.model_dump())
db.add(block) db.add(block)
await db.commit() await db.commit()
await db.refresh(block) result = await db.execute(
return block 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) @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(): for field, value in body.model_dump(exclude_unset=True).items():
setattr(block, field, value) setattr(block, field, value)
await db.commit() await db.commit()
await db.refresh(block) result = await db.execute(
return block 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) @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.dependencies import get_db, get_current_user
from app.models.child import Child from app.models.child import Child
from app.models.schedule import ScheduleBlock, ScheduleTemplate 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.session import DailySession, TimerEvent
from app.models.user import User from app.models.user import User
from app.schemas.session import DailySessionOut, SessionStart, TimerAction 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( blocks_result = await db.execute(
select(ScheduleBlock) select(ScheduleBlock)
.where(ScheduleBlock.template_id == session.template_id) .where(ScheduleBlock.template_id == session.template_id)
.options(selectinload(ScheduleBlock.subject).selectinload(Subject.options))
.order_by(ScheduleBlock.order_index) .order_by(ScheduleBlock.order_index)
) )
blocks = [ blocks = [
{ {
"id": b.id, "id": b.id,
"subject_id": b.subject_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_start": str(b.time_start),
"time_end": str(b.time_end), "time_end": str(b.time_end),
"duration_minutes": b.duration_minutes, "duration_minutes": b.duration_minutes,

View File

@@ -1,22 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.dependencies import get_db, get_current_user 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.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"]) router = APIRouter(prefix="/api/subjects", tags=["subjects"])
def _opts():
return selectinload(Subject.options)
@router.get("", response_model=list[SubjectOut]) @router.get("", response_model=list[SubjectOut])
async def list_subjects( async def list_subjects(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( 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() return result.scalars().all()
@@ -30,8 +41,10 @@ async def create_subject(
subject = Subject(**body.model_dump(), user_id=current_user.id) subject = Subject(**body.model_dump(), user_id=current_user.id)
db.add(subject) db.add(subject)
await db.commit() await db.commit()
await db.refresh(subject) result = await db.execute(
return subject select(Subject).where(Subject.id == subject.id).options(_opts())
)
return result.scalar_one()
@router.get("/{subject_id}", response_model=SubjectOut) @router.get("/{subject_id}", response_model=SubjectOut)
@@ -41,7 +54,9 @@ async def get_subject(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( 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() subject = result.scalar_one_or_none()
if not subject: if not subject:
@@ -57,7 +72,9 @@ async def update_subject(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( 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() subject = result.scalar_one_or_none()
if not subject: if not subject:
@@ -66,8 +83,10 @@ async def update_subject(
for field, value in body.model_dump(exclude_none=True).items(): for field, value in body.model_dump(exclude_none=True).items():
setattr(subject, field, value) setattr(subject, field, value)
await db.commit() await db.commit()
await db.refresh(subject) result = await db.execute(
return subject select(Subject).where(Subject.id == subject.id).options(_opts())
)
return result.scalar_one()
@router.delete("/{subject_id}", status_code=status.HTTP_204_NO_CONTENT) @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") raise HTTPException(status_code=404, detail="Subject not found")
await db.delete(subject) await db.delete(subject)
await db.commit() 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 datetime import time
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.subject import SubjectOut
class ScheduleBlockCreate(BaseModel): class ScheduleBlockCreate(BaseModel):
@@ -25,6 +26,7 @@ class ScheduleBlockUpdate(BaseModel):
class ScheduleBlockOut(BaseModel): class ScheduleBlockOut(BaseModel):
id: int id: int
subject_id: int | None subject_id: int | None
subject: SubjectOut | None = None
time_start: time time_start: time
time_end: time time_end: time
duration_minutes: int | None duration_minutes: int | None

View File

@@ -1,6 +1,23 @@
from pydantic import BaseModel 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): class SubjectCreate(BaseModel):
name: str name: str
color: str = "#10B981" color: str = "#10B981"
@@ -20,5 +37,6 @@ class SubjectOut(BaseModel):
color: str color: str
icon: str icon: str
is_active: bool is_active: bool
options: list[SubjectOptionOut] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -10,7 +10,8 @@
<div class="block-indicator" :style="{ background: subjectColor }"></div> <div class="block-indicator" :style="{ background: subjectColor }"></div>
<div class="block-body"> <div class="block-body">
<div class="block-title"> <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>
<div class="block-time"> <div class="block-time">
{{ block.time_start }} {{ block.time_end }} {{ block.time_start }} {{ block.time_end }}
@@ -108,6 +109,7 @@ const durationLabel = computed(() => {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.block-label-suffix { font-weight: 400; color: #94a3b8; }
.block-duration { color: #475569; } .block-duration { color: #475569; }
.block-duration-custom { color: #818cf8; } .block-duration-custom { color: #818cf8; }

View File

@@ -61,7 +61,9 @@
</form> </form>
<div class="item-list"> <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"> <template v-if="editingSubject && editingSubject.id === subject.id">
<input v-model="editingSubject.name" class="edit-input" required /> <input v-model="editingSubject.name" class="edit-input" required />
<input v-model="editingSubject.icon" placeholder="Icon" maxlength="4" style="width:60px" class="edit-input" /> <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> <span class="item-name">{{ subject.name }}</span>
<div class="item-actions"> <div class="item-actions">
<button class="btn-sm" @click="startEditSubject(subject)">Edit</button> <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> <button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
</div> </div>
</template> </template>
</div> </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 v-if="subjects.length === 0" class="empty-small">No subjects added yet.</div>
</div> </div>
</section> </section>
@@ -269,6 +301,11 @@ async function deleteChild(id) {
const showSubjectForm = ref(false) const showSubjectForm = ref(false)
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' }) const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })
const editingSubject = ref(null) const editingSubject = ref(null)
const expandedSubject = ref(null)
// Subject options
const newOptionText = ref('')
const editingOption = ref(null)
async function loadSubjects() { async function loadSubjects() {
const res = await api.get('/api/subjects') 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 // Schedules
const templates = ref([]) const templates = ref([])
const showCreateForm = ref(false) 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 span { color: #64748b; }
.edit-block-form { border: 1px solid #4f46e5; } .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; } .empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.btn-primary { .btn-primary {

View File

@@ -25,12 +25,9 @@
<!-- Active session --> <!-- Active session -->
<div v-else class="tv-main"> <div v-else class="tv-main">
<!-- Current block (big display) --> <!-- Left: timer -->
<div class="tv-current" v-if="scheduleStore.currentBlock"> <div class="tv-timer-col" v-if="scheduleStore.currentBlock">
<div <div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
class="tv-subject-badge"
:style="{ background: currentSubjectColor }"
>
{{ currentSubjectIcon }} {{ currentSubjectName }} {{ currentSubjectIcon }} {{ currentSubjectName }}
</div> </div>
<TimerDisplay <TimerDisplay
@@ -45,8 +42,26 @@
</div> </div>
</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"> <div class="tv-sidebar">
<!-- Schedule list -->
<div class="tv-schedule-list"> <div class="tv-schedule-list">
<ScheduleBlock <ScheduleBlock
v-for="block in scheduleStore.blocks" v-for="block in scheduleStore.blocks"
@@ -123,6 +138,9 @@ const currentSubjectIcon = computed(() => scheduleStore.currentBlock?.subject?.i
const currentSubjectName = computed(() => const currentSubjectName = computed(() =>
scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block' scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block'
) )
const currentSubjectOptions = computed(() =>
scheduleStore.currentBlock?.subject?.options || []
)
// WebSocket // WebSocket
const { connected: wsConnected } = useWebSocket(childId, (msg) => { const { connected: wsConnected } = useWebSocket(childId, (msg) => {
@@ -188,11 +206,12 @@ onMounted(async () => {
.tv-main { .tv-main {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 1fr 380px; grid-template-columns: repeat(3, 1fr);
gap: 2rem; gap: 2rem;
min-height: 0;
} }
.tv-current { .tv-timer-col {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
@@ -201,18 +220,56 @@ onMounted(async () => {
} }
.tv-subject-badge { .tv-subject-badge {
font-size: 1.75rem; font-size: 1.4rem;
font-weight: 600; font-weight: 600;
padding: 0.75rem 2rem; padding: 0.6rem 1.5rem;
border-radius: 999px; border-radius: 999px;
color: #fff; color: #fff;
text-align: center;
} }
.tv-block-notes { .tv-block-notes {
font-size: 1.25rem; font-size: 1rem;
color: #94a3b8; color: #94a3b8;
text-align: center; 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 { .tv-day-progress {