diff --git a/backend/app/main.py b/backend/app/main.py index bbba44f..bea932a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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_end_time", "TIME 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 diff --git a/backend/app/models/child.py b/backend/app/models/child.py index 7883287..1d16d6e 100644 --- a/backend/app/models/child.py +++ b/backend/app/models/child.py @@ -1,5 +1,5 @@ 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 app.models.base import Base, TimestampMixin @@ -13,6 +13,7 @@ class Child(TimestampMixin, Base): 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 + strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False) user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821 daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821 diff --git a/backend/app/routers/children.py b/backend/app/routers/children.py index 9033659..9a9507a 100644 --- a/backend/app/routers/children.py +++ b/backend/app/routers/children.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession 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.user import User from app.schemas.child import ChildCreate, ChildOut, ChildUpdate +from app.websocket.manager import manager router = APIRouter(prefix="/api/children", tags=["children"]) @@ -70,6 +72,30 @@ async def update_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) async def delete_child( child_id: int, diff --git a/backend/app/schemas/child.py b/backend/app/schemas/child.py index 3221356..baed2b9 100644 --- a/backend/app/schemas/child.py +++ b/backend/app/schemas/child.py @@ -1,5 +1,5 @@ from datetime import date -from pydantic import BaseModel +from pydantic import BaseModel, Field class ChildCreate(BaseModel): @@ -13,6 +13,7 @@ class ChildUpdate(BaseModel): birth_date: date | None = None color: str | None = None is_active: bool | None = None + strikes: int | None = Field(None, ge=0, le=3) class ChildOut(BaseModel): @@ -21,5 +22,6 @@ class ChildOut(BaseModel): birth_date: date | None is_active: bool color: str + strikes: int = 0 model_config = {"from_attributes": True} diff --git a/frontend/src/stores/schedule.js b/frontend/src/stores/schedule.js index 2fcef44..802e661 100644 --- a/frontend/src/stores/schedule.js +++ b/frontend/src/stores/schedule.js @@ -55,6 +55,10 @@ export const useScheduleStore = defineStore('schedule', () => { applySnapshot(event) return } + if (event.event === 'strikes_update') { + if (child.value) child.value = { ...child.value, strikes: event.strikes } + return + } // Session ended if (event.is_active === false) { session.value = null diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 3e85ea7..62c31de 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -79,6 +79,28 @@ + +
+
3 Strikes
+
+
+
+ {{ child.name }} +
+ +
+
+
No children added yet.
+
+
+
TV Dashboard
@@ -183,6 +205,13 @@ async function startSession() { 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) { if (!scheduleStore.session) return 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; } +.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-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; } diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index fee4a03..ee0755e 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -2,7 +2,16 @@
-
{{ scheduleStore.child?.name || 'Loading...' }}
+
+
{{ scheduleStore.child?.name || 'Loading...' }}
+
+ +
+
{{ clockDisplay }}
{{ dateDisplay }}
@@ -197,12 +206,30 @@ onMounted(async () => { padding-bottom: 1rem; } +.tv-child-name-wrap { + display: flex; + align-items: center; + gap: 1rem; +} + .tv-child-name { font-size: 2.5rem; font-weight: 700; 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 { font-size: 3rem; font-weight: 300;