Move 3 Strikes from Admin to Dashboard, add strikes feature

- Adds strikes (0-3) to Child model with migration
- New PATCH /api/children/{id}/strikes endpoint with WebSocket broadcast
- TV dashboard shows red ✕ marks next to child name when strikes > 0
- 3 Strikes card on Dashboard page (removed from Admin)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 17:20:10 -08:00
parent 4b49605ed1
commit 44e8f7de7b
7 changed files with 132 additions and 3 deletions

View File

@@ -32,6 +32,7 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "schedule_templates", "day_start_time", "TIME NULL") await _add_column_if_missing(conn, "schedule_templates", "day_start_time", "TIME NULL")
await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL") await _add_column_if_missing(conn, "schedule_templates", "day_end_time", "TIME NULL")
await _add_column_if_missing(conn, "schedule_blocks", "duration_minutes", "INT NULL") await _add_column_if_missing(conn, "schedule_blocks", "duration_minutes", "INT NULL")
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
yield yield

View File

@@ -1,5 +1,5 @@
from datetime import date from datetime import date
from sqlalchemy import String, Boolean, ForeignKey, Date from sqlalchemy import String, Boolean, ForeignKey, Date, 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
@@ -13,6 +13,7 @@ class Child(TimestampMixin, Base):
birth_date: Mapped[date | None] = mapped_column(Date, nullable=True) birth_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821 user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821 daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -6,6 +7,7 @@ from app.dependencies import get_db, get_current_user
from app.models.child import Child from app.models.child import Child
from app.models.user import User from app.models.user import User
from app.schemas.child import ChildCreate, ChildOut, ChildUpdate from app.schemas.child import ChildCreate, ChildOut, ChildUpdate
from app.websocket.manager import manager
router = APIRouter(prefix="/api/children", tags=["children"]) router = APIRouter(prefix="/api/children", tags=["children"])
@@ -70,6 +72,30 @@ async def update_child(
return child return child
class StrikesBody(BaseModel):
strikes: int = Field(..., ge=0, le=3)
@router.patch("/{child_id}/strikes", response_model=ChildOut)
async def update_strikes(
child_id: int,
body: StrikesBody,
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")
child.strikes = body.strikes
await db.commit()
await db.refresh(child)
await manager.broadcast(child_id, {"event": "strikes_update", "strikes": child.strikes})
return child
@router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child( async def delete_child(
child_id: int, child_id: int,

View File

@@ -1,5 +1,5 @@
from datetime import date from datetime import date
from pydantic import BaseModel from pydantic import BaseModel, Field
class ChildCreate(BaseModel): class ChildCreate(BaseModel):
@@ -13,6 +13,7 @@ class ChildUpdate(BaseModel):
birth_date: date | None = None birth_date: date | None = None
color: str | None = None color: str | None = None
is_active: bool | None = None is_active: bool | None = None
strikes: int | None = Field(None, ge=0, le=3)
class ChildOut(BaseModel): class ChildOut(BaseModel):
@@ -21,5 +22,6 @@ class ChildOut(BaseModel):
birth_date: date | None birth_date: date | None
is_active: bool is_active: bool
color: str color: str
strikes: int = 0
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -55,6 +55,10 @@ export const useScheduleStore = defineStore('schedule', () => {
applySnapshot(event) applySnapshot(event)
return return
} }
if (event.event === 'strikes_update') {
if (child.value) child.value = { ...child.value, strikes: event.strikes }
return
}
// Session ended // Session ended
if (event.is_active === false) { if (event.is_active === false) {
session.value = null session.value = null

View File

@@ -79,6 +79,28 @@
</div> </div>
</div> </div>
<!-- 3 Strikes -->
<div class="card strikes-card">
<div class="card-title">3 Strikes</div>
<div class="strikes-list">
<div v-for="child in childrenStore.children" :key="child.id" class="strikes-row">
<div class="strikes-child-color" :style="{ background: child.color }"></div>
<span class="strikes-child-name">{{ child.name }}</span>
<div class="strikes-buttons">
<button
v-for="i in 3"
:key="i"
class="strike-btn"
:class="{ lit: i <= child.strikes }"
@click="toggleStrike(child, i)"
:title="`Strike ${i}`"
>✕</button>
</div>
</div>
<div v-if="childrenStore.children.length === 0" class="empty-small">No children added yet.</div>
</div>
</div>
<!-- TV Link --> <!-- TV Link -->
<div class="card tv-card"> <div class="card tv-card">
<div class="card-title">TV Dashboard</div> <div class="card-title">TV Dashboard</div>
@@ -183,6 +205,13 @@ async function startSession() {
await loadDashboard() await loadDashboard()
} }
async function toggleStrike(child, index) {
const newStrikes = index <= child.strikes ? index - 1 : index
const res = await api.patch(`/api/children/${child.id}/strikes`, { strikes: newStrikes })
const idx = childrenStore.children.findIndex((c) => c.id === child.id)
if (idx !== -1) childrenStore.children[idx] = res.data
}
async function sendAction(type) { async function sendAction(type) {
if (!scheduleStore.session) return if (!scheduleStore.session) return
await scheduleStore.sendTimerAction(scheduleStore.session.id, type) await scheduleStore.sendTimerAction(scheduleStore.session.id, type)
@@ -288,6 +317,45 @@ h1 { font-size: 1.75rem; font-weight: 700; }
.block-list { display: flex; flex-direction: column; gap: 0.5rem; } .block-list { display: flex; flex-direction: column; gap: 0.5rem; }
.strikes-card { grid-column: span 1; }
.strikes-list { display: flex; flex-direction: column; gap: 0.6rem; }
.strikes-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.strikes-child-color { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.strikes-child-name { flex: 1; font-size: 0.95rem; }
.strikes-buttons { display: flex; gap: 0.5rem; }
.strike-btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
border: 2px solid #334155;
background: transparent;
color: #334155;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.strike-btn:hover { border-color: #ef4444; color: #ef4444; }
.strike-btn.lit {
background: #7f1d1d;
border-color: #ef4444;
color: #fca5a5;
}
.tv-card { grid-column: span 1; } .tv-card { grid-column: span 1; }
.tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; } .tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; }

View File

@@ -2,7 +2,16 @@
<div class="tv-root"> <div class="tv-root">
<!-- Header bar --> <!-- Header bar -->
<header class="tv-header"> <header class="tv-header">
<div class="tv-child-name">{{ scheduleStore.child?.name || 'Loading...' }}</div> <div class="tv-child-name-wrap">
<div class="tv-child-name">{{ scheduleStore.child?.name || 'Loading...' }}</div>
<div class="tv-strikes" v-if="scheduleStore.child?.strikes > 0">
<span
v-for="i in scheduleStore.child.strikes"
:key="i"
class="tv-strike-x"
></span>
</div>
</div>
<div class="tv-clock">{{ clockDisplay }}</div> <div class="tv-clock">{{ clockDisplay }}</div>
<div class="tv-date">{{ dateDisplay }}</div> <div class="tv-date">{{ dateDisplay }}</div>
</header> </header>
@@ -197,12 +206,30 @@ onMounted(async () => {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.tv-child-name-wrap {
display: flex;
align-items: center;
gap: 1rem;
}
.tv-child-name { .tv-child-name {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
color: #818cf8; color: #818cf8;
} }
.tv-strikes {
display: flex;
gap: 0.4rem;
}
.tv-strike-x {
font-size: 2rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
}
.tv-clock { .tv-clock {
font-size: 3rem; font-size: 3rem;
font-weight: 300; font-weight: 300;