Add Rules & Expectations feature with TV overlay and dashboard layout updates

- Add Rules & Expectations admin section with drag-to-reorder, add/edit/delete
- Add Overlays card to Dashboard with Rules/Expectations toggle button (LIVE badge when active)
- Add full-screen rules overlay on TV view (green theme, numbered list, tap to dismiss)
- Backend: new RuleItem model, /api/rules CRUD + bulk reorder, /api/overlays WS broadcast endpoints
- Schedule store handles show_rules / hide_rules WebSocket events
- Rearrange Dashboard top row: TV Dashboard | 3 Strikes | Overlays (3-col, mobile stacks)
- Put Today's Session and Today's Schedule side-by-side at 50/50 width (mobile stacks)
- Update README with all new features, setup steps, WS events, and project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 07:21:50 -07:00
parent 956df11f49
commit d724262e27
10 changed files with 569 additions and 42 deletions

View File

@@ -10,6 +10,8 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
- **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins. - **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins.
- **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options. Break timer state is fully restored when navigating away and returning to the dashboard. - **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options. Break timer state is fully restored when navigating away and returning to the dashboard.
- **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break. - **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break.
- **Rules & Expectations** — Define a list of household rules or expectations in Admin → Rules & Expectations. Items can be reordered by dragging with the handle on the left. From the Dashboard, press the **Rules/Expectations** button in the Overlays card to show them as a full-screen numbered overlay on the TV. The button turns highlighted with a pulsing **LIVE** badge while the overlay is active. Press it again to dismiss. Tapping anywhere on the TV overlay also dismisses it locally.
- **Dashboard Overlays** — A dedicated card on the parent Dashboard groups controls that push full-screen content to the TV. Currently contains the Rules/Expectations overlay button; designed to accommodate additional overlay types in the future.
- **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **Start** and **Finish**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks. - **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **Start** and **Finish**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks.
- **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page. - **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page.
- **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, **Done** to mark it as fully complete, and **Reset** to clear the elapsed time back to zero (timer stays paused). Elapsed time per block is remembered across switches, so returning to a block picks up where it left off. - **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, **Done** to mark it as fully complete, and **Reset** to clear the elapsed time back to zero (timer stays paused). Elapsed time per block is remembered across switches, so returning to a block picks up where it left off.
@@ -76,6 +78,7 @@ homeschool/
│ │ ├── activity.py # ActivityLog (manual notes) │ │ ├── activity.py # ActivityLog (manual notes)
│ │ ├── morning_routine.py# MorningRoutineItem │ │ ├── morning_routine.py# MorningRoutineItem
│ │ ├── break_activity.py # BreakActivityItem │ │ ├── break_activity.py # BreakActivityItem
│ │ ├── rule.py # RuleItem (rules & expectations)
│ │ ├── strike.py # StrikeEvent (strike history) │ │ ├── strike.py # StrikeEvent (strike history)
│ │ └── user.py # User (incl. timezone, last_active_at) │ │ └── user.py # User (incl. timezone, last_active_at)
│ ├── schemas/ # Pydantic request/response schemas │ ├── schemas/ # Pydantic request/response schemas
@@ -88,6 +91,8 @@ homeschool/
│ │ ├── logs.py # Timeline + strike events │ │ ├── logs.py # Timeline + strike events
│ │ ├── morning_routine.py │ │ ├── morning_routine.py
│ │ ├── break_activity.py # Break activities CRUD │ │ ├── break_activity.py # Break activities CRUD
│ │ ├── rules.py # Rules & Expectations CRUD + bulk reorder
│ │ ├── overlays.py # WS broadcast: show/hide rules overlay
│ │ ├── dashboard.py # Public snapshot endpoint (TV + dashboard reload) │ │ ├── dashboard.py # Public snapshot endpoint (TV + dashboard reload)
│ │ ├── admin.py # Super admin: login, user list, impersonate, reset-password, │ │ ├── admin.py # Super admin: login, user list, impersonate, reset-password,
│ │ │ # toggle-active, delete-user │ │ │ # toggle-active, delete-user
@@ -178,10 +183,11 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block. The **Meeting** subject is created automatically and cannot be deleted or renamed, but you can add activity options (agenda items) to it. 2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block. The **Meeting** subject is created automatically and cannot be deleted or renamed, but you can add activity options (agenda items) to it.
3. **Admin** → Add **Morning Routine** items — these show on the TV during the greeting before the first block starts. 3. **Admin** → Add **Morning Routine** items — these show on the TV during the greeting before the first block starts.
4. **Admin** → Add **Break Activities** items — these show on the TV center panel whenever a break is active. 4. **Admin** → Add **Break Activities** items — these show on the TV center panel whenever a break is active.
5. **Admin**Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here. 5. **Admin**Add **Rules & Expectations** items — drag to reorder. Trigger from the Dashboard Overlays card to display on the TV.
6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes. 6. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here.
7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template 7. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes.
8. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV. 8. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
9. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV.
--- ---
@@ -191,9 +197,9 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes | | `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes, trigger TV overlays |
| `/logs` | Browse timer and strike event history and manual notes; filter by child and date | | `/logs` | Browse timer and strike event history and manual notes; filter by child and date |
| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. | | `/admin` | Manage children, subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. |
### Super Admin Views ### Super Admin Views
@@ -236,7 +242,7 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay | | `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay, rules/expectations overlay |
Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard. Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
@@ -279,6 +285,8 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events:
| `break_resume` | Break timer resumed | `block_id`, `current_block_id` | | `break_resume` | Break timer resumed | `block_id`, `current_block_id` |
| `break_reset` | Break timer reset to zero | `block_id`, `current_block_id`, `break_elapsed_seconds` (always 0) | | `break_reset` | Break timer reset to zero | `block_id`, `current_block_id`, `break_elapsed_seconds` (always 0) |
| `strikes_update` | Strike issued/cleared/midnight reset | `strikes` | | `strikes_update` | Strike issued/cleared/midnight reset | `strikes` |
| `show_rules` | Rules/Expectations overlay triggered from Dashboard | `rules` (array of rule text strings) |
| `hide_rules` | Rules/Expectations overlay dismissed from Dashboard | — |
**Notes:** **Notes:**

View File

@@ -14,7 +14,7 @@ from app.models.child import Child
from app.models.subject import Subject from app.models.subject import Subject
from app.models.user import User from app.models.user import User
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.routers import morning_routine, break_activity, admin from app.routers import morning_routine, break_activity, admin, rules, overlays
from app.websocket.manager import manager from app.websocket.manager import manager
settings = get_settings() settings = get_settings()
@@ -113,6 +113,8 @@ app.include_router(morning_routine.router)
app.include_router(break_activity.router) app.include_router(break_activity.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(rules.router)
app.include_router(overlays.router)
@app.get("/api/health") @app.get("/api/health")

View File

@@ -9,6 +9,7 @@ from app.models.activity import ActivityLog
from app.models.morning_routine import MorningRoutineItem from app.models.morning_routine import MorningRoutineItem
from app.models.break_activity import BreakActivityItem from app.models.break_activity import BreakActivityItem
from app.models.strike import StrikeEvent from app.models.strike import StrikeEvent
from app.models.rule import RuleItem
__all__ = [ __all__ = [
"Base", "Base",
@@ -26,4 +27,5 @@ __all__ = [
"MorningRoutineItem", "MorningRoutineItem",
"BreakActivityItem", "BreakActivityItem",
"StrikeEvent", "StrikeEvent",
"RuleItem",
] ]

View File

@@ -0,0 +1,15 @@
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class RuleItem(Base):
__tablename__ = "rule_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
text: Mapped[str] = mapped_column(Text, nullable=False)
order_index: Mapped[int] = mapped_column(Integer, default=0)
user: Mapped["User"] = relationship("User") # noqa: F821

View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.child import Child
from app.models.rule import RuleItem
from app.models.user import User
from app.websocket.manager import manager
router = APIRouter(prefix="/api/overlays", tags=["overlays"])
class ChildTarget(BaseModel):
child_id: int
async def _verify_child(child_id: int, user: User, db: AsyncSession) -> Child:
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
return child
@router.post("/rules/show")
async def show_rules_overlay(
body: ChildTarget,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
child = await _verify_child(body.child_id, current_user, db)
rules_result = await db.execute(
select(RuleItem)
.where(RuleItem.user_id == current_user.id)
.order_by(RuleItem.order_index, RuleItem.id)
)
rules = [item.text for item in rules_result.scalars().all()]
await manager.broadcast(child.id, {"event": "show_rules", "rules": rules})
return {"ok": True}
@router.post("/rules/hide")
async def hide_rules_overlay(
body: ChildTarget,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
child = await _verify_child(body.child_id, current_user, db)
await manager.broadcast(child.id, {"event": "hide_rules"})
return {"ok": True}

View File

@@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.rule import RuleItem
from app.models.user import User
router = APIRouter(prefix="/api/rules", tags=["rules"])
class RuleItemOut(BaseModel):
id: int
text: str
order_index: int
model_config = {"from_attributes": True}
class RuleItemCreate(BaseModel):
text: str
order_index: int = 0
class RuleItemUpdate(BaseModel):
text: str | None = None
order_index: int | None = None
class ReorderEntry(BaseModel):
id: int
order_index: int
@router.get("", response_model=list[RuleItemOut])
async def list_rules(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RuleItem)
.where(RuleItem.user_id == current_user.id)
.order_by(RuleItem.order_index, RuleItem.id)
)
return result.scalars().all()
@router.post("", response_model=RuleItemOut, status_code=status.HTTP_201_CREATED)
async def create_rule(
body: RuleItemCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
item = RuleItem(user_id=current_user.id, text=body.text, order_index=body.order_index)
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.patch("/{item_id}", response_model=RuleItemOut)
async def update_rule(
item_id: int,
body: RuleItemUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RuleItem).where(
RuleItem.id == item_id,
RuleItem.user_id == current_user.id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Rule not found")
if body.text is not None:
item.text = body.text
if body.order_index is not None:
item.order_index = body.order_index
await db.commit()
await db.refresh(item)
return item
@router.put("/reorder", status_code=status.HTTP_204_NO_CONTENT)
async def reorder_rules(
body: list[ReorderEntry],
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
ids = [e.id for e in body]
result = await db.execute(
select(RuleItem).where(
RuleItem.id.in_(ids),
RuleItem.user_id == current_user.id,
)
)
items = {item.id: item for item in result.scalars().all()}
for entry in body:
if entry.id in items:
items[entry.id].order_index = entry.order_index
await db.commit()
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_rule(
item_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RuleItem).where(
RuleItem.id == item_id,
RuleItem.user_id == current_user.id,
)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Rule not found")
await db.delete(item)
await db.commit()

View File

@@ -18,6 +18,8 @@ export const useScheduleStore = defineStore('schedule', () => {
const breakStartedAt = ref(null) // Date.now() ms when break counting started const breakStartedAt = ref(null) // Date.now() ms when break counting started
const breakElapsedOffset = ref(0) // break seconds already elapsed const breakElapsedOffset = ref(0) // break seconds already elapsed
const breakElapsedCache = ref({}) // blockId → total break elapsed seconds const breakElapsedCache = ref({}) // blockId → total break elapsed seconds
const showRulesOverlay = ref(false) // whether the rules overlay is visible on TV
const rulesOverlayItems = ref([]) // list of rule text strings to display
const currentBlock = computed(() => const currentBlock = computed(() =>
session.value?.current_block_id session.value?.current_block_id
@@ -80,6 +82,15 @@ export const useScheduleStore = defineStore('schedule', () => {
} }
function applyWsEvent(event) { function applyWsEvent(event) {
if (event.event === 'show_rules') {
rulesOverlayItems.value = event.rules || []
showRulesOverlay.value = true
return
}
if (event.event === 'hide_rules') {
showRulesOverlay.value = false
return
}
if (event.event === 'session_update') { if (event.event === 'session_update') {
applySnapshot(event) applySnapshot(event)
return return
@@ -394,6 +405,8 @@ export const useScheduleStore = defineStore('schedule', () => {
breakStartedAt, breakStartedAt,
breakElapsedOffset, breakElapsedOffset,
breakElapsedCache, breakElapsedCache,
showRulesOverlay,
rulesOverlayItems,
currentBlock, currentBlock,
progressPercent, progressPercent,
applySnapshot, applySnapshot,

View File

@@ -196,6 +196,47 @@
</div> </div>
</section> </section>
<!-- Rules & Expectations section -->
<section class="section">
<div class="section-header">
<h2>Rules &amp; Expectations</h2>
</div>
<div class="card">
<p class="routine-hint">These rules are shown as a full-screen overlay on the TV when you trigger it from the Dashboard.</p>
<div class="option-list">
<template v-for="(item, index) in rules" :key="item.id">
<div v-if="editingRuleItem && editingRuleItem.id === item.id" class="option-edit-row">
<input v-model="editingRuleItem.text" class="option-input" @keyup.enter="saveRuleItem" />
<button class="btn-sm btn-primary" @click="saveRuleItem">Save</button>
<button class="btn-sm" @click="editingRuleItem = null">Cancel</button>
</div>
<div
v-else
class="option-row rule-drag-row"
draggable="true"
@dragstart="ruleDragStart(index)"
@dragover.prevent="ruleDragOver(index)"
@drop="ruleDrop"
@dragend="ruleDragEnd"
:class="{ 'rule-drag-over': ruleDragOverIndex === index && ruleDragStartIndex !== index }"
>
<span class="drag-handle" title="Drag to reorder">⠿</span>
<span class="option-text">{{ item.text }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditRuleItem(item)">Edit</button>
<button class="btn-sm btn-danger" @click="deleteRuleItem(item.id)">✕</button>
</div>
</div>
</template>
<div v-if="rules.length === 0" class="empty-small">No rules added yet.</div>
</div>
<form class="option-add-row" style="margin-top: 0.75rem" @submit.prevent="addRuleItem">
<input v-model="newRuleText" placeholder="Add a rule or expectation..." class="option-input" required />
<button type="submit" class="btn-primary btn-sm">Add</button>
</form>
</div>
</section>
<!-- Schedules section --> <!-- Schedules section -->
<section class="section"> <section class="section">
<div class="section-header"> <div class="section-header">
@@ -636,6 +677,70 @@ async function deleteBreakItem(id) {
await loadBreakActivities() await loadBreakActivities()
} }
// Rules & Expectations
const rules = ref([])
const newRuleText = ref('')
const editingRuleItem = ref(null)
const ruleDragStartIndex = ref(null)
const ruleDragOverIndex = ref(null)
async function loadRules() {
const res = await api.get('/api/rules')
rules.value = res.data
}
async function addRuleItem() {
await api.post('/api/rules', {
text: newRuleText.value,
order_index: rules.value.length,
})
newRuleText.value = ''
await loadRules()
}
function startEditRuleItem(item) {
editingRuleItem.value = { ...item }
}
async function saveRuleItem() {
await api.patch(`/api/rules/${editingRuleItem.value.id}`, {
text: editingRuleItem.value.text,
})
editingRuleItem.value = null
await loadRules()
}
async function deleteRuleItem(id) {
await api.delete(`/api/rules/${id}`)
await loadRules()
}
function ruleDragStart(index) {
ruleDragStartIndex.value = index
}
function ruleDragOver(index) {
ruleDragOverIndex.value = index
}
function ruleDrop() {
const from = ruleDragStartIndex.value
const to = ruleDragOverIndex.value
if (from === null || to === null || from === to) return
const arr = [...rules.value]
const [removed] = arr.splice(from, 1)
arr.splice(to, 0, removed)
rules.value = arr
}
async function ruleDragEnd() {
ruleDragStartIndex.value = null
ruleDragOverIndex.value = null
// Persist new order
const payload = rules.value.map((item, index) => ({ id: item.id, order_index: index }))
await api.put('/api/rules/reorder', payload)
}
// Schedules // Schedules
const templates = ref([]) const templates = ref([])
const showCreateForm = ref(false) const showCreateForm = ref(false)
@@ -733,7 +838,7 @@ async function deleteBlock(templateId, blockId) {
onMounted(async () => { onMounted(async () => {
await childrenStore.fetchChildren() await childrenStore.fetchChildren()
await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities()]) await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities(), loadRules()])
selectedTimezone.value = authStore.timezone selectedTimezone.value = authStore.timezone
}) })
</script> </script>
@@ -908,6 +1013,20 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.option-add-row .option-input { background: #1e293b; } .option-add-row .option-input { background: #1e293b; }
.routine-hint { font-size: 0.82rem; color: #64748b; margin-bottom: 1rem; } .routine-hint { font-size: 0.82rem; color: #64748b; margin-bottom: 1rem; }
.drag-handle {
color: #475569;
cursor: grab;
font-size: 1.1rem;
flex-shrink: 0;
padding: 0 0.25rem;
user-select: none;
}
.drag-handle:active { cursor: grabbing; }
.rule-drag-row { cursor: default; }
.rule-drag-row[draggable="true"]:hover { background: #243448; }
.rule-drag-over { border: 1px dashed #4f46e5 !important; background: #1a1a3a !important; }
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; } .empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
.support-card { .support-card {

View File

@@ -12,6 +12,18 @@
</div> </div>
<div v-else class="dashboard-grid"> <div v-else class="dashboard-grid">
<!-- Top row: TV Dashboard · 3 Strikes · Overlays -->
<div class="top-row">
<!-- TV Link -->
<div class="card tv-card">
<div class="card-title">TV Dashboard</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p>
<a :href="`/tv/${activeChild.tv_token}`" target="_blank" class="btn-primary">
Open TV View
</a>
</div>
<!-- 3 Strikes --> <!-- 3 Strikes -->
<div class="card strikes-card"> <div class="card strikes-card">
<div class="card-title">3 Strikes</div> <div class="card-title">3 Strikes</div>
@@ -34,15 +46,26 @@
</div> </div>
</div> </div>
<!-- TV Link --> <!-- Overlays -->
<div class="card tv-card"> <div class="card overlays-card">
<div class="card-title">TV Dashboard</div> <div class="card-title">Overlays</div>
<p class="tv-desc">Open this on the living room TV for the full-screen view.</p> <div class="overlays-grid">
<a :href="`/tv/${activeChild.tv_token}`" target="_blank" class="btn-primary"> <button
Open TV View class="overlay-btn"
</a> :class="{ 'overlay-btn-active': scheduleStore.showRulesOverlay }"
@click="toggleRulesOverlay"
:disabled="!activeChild"
>
<span class="overlay-btn-icon">📋</span>
<span>Rules/Expectations</span>
<span v-if="scheduleStore.showRulesOverlay" class="overlay-live-badge">LIVE</span>
</button>
</div>
</div>
</div> </div>
<!-- Session + Schedule row -->
<div class="bottom-row">
<!-- Today's session card --> <!-- Today's session card -->
<div class="card session-card"> <div class="card session-card">
<div class="card-title">Today's Session</div> <div class="card-title">Today's Session</div>
@@ -171,6 +194,7 @@
/> />
</div> </div>
</div> </div>
</div><!-- end bottom-row -->
</div> </div>
@@ -276,7 +300,7 @@ let wsDisconnect = null
async function loadDashboard() { async function loadDashboard() {
if (!activeChild.value) return if (!activeChild.value) return
await scheduleStore.fetchDashboard(activeChild.value.id) await scheduleStore.fetchDashboard(activeChild.value.tv_token)
// Load templates for start dialog // Load templates for start dialog
const res = await api.get('/api/schedules') const res = await api.get('/api/schedules')
@@ -284,7 +308,7 @@ async function loadDashboard() {
// WS subscription // WS subscription
if (wsDisconnect) wsDisconnect() if (wsDisconnect) wsDisconnect()
const { disconnect } = useWebSocket(activeChild.value.id, (msg) => { const { disconnect } = useWebSocket(activeChild.value.tv_token, (msg) => {
scheduleStore.applyWsEvent(msg) scheduleStore.applyWsEvent(msg)
}) })
wsDisconnect = disconnect wsDisconnect = disconnect
@@ -346,6 +370,14 @@ const estimatedFinishTime = computed(() => {
return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}` return `${hour}:${String(finish.getMinutes()).padStart(2, '0')} ${period}`
}) })
async function toggleRulesOverlay() {
if (!activeChild.value) return
const endpoint = scheduleStore.showRulesOverlay
? '/api/overlays/rules/hide'
: '/api/overlays/rules/show'
await api.post(endpoint, { child_id: activeChild.value.id })
}
function selectBlock(block) { function selectBlock(block) {
if (!scheduleStore.session) return if (!scheduleStore.session) return
// Clicking the current block does nothing — use Start/Pause/Resume buttons // Clicking the current block does nothing — use Start/Pause/Resume buttons
@@ -368,11 +400,31 @@ watch(activeChild, loadDashboard)
h1 { font-size: 1.75rem; font-weight: 700; } h1 { font-size: 1.75rem; font-weight: 700; }
.dashboard-grid { .dashboard-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
} }
.top-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 700px) {
.top-row,
.bottom-row {
grid-template-columns: 1fr;
}
}
.card { .card {
background: #1e293b; background: #1e293b;
border-radius: 1rem; border-radius: 1rem;
@@ -477,8 +529,6 @@ 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-list { display: flex; flex-direction: column; gap: 0.6rem; }
.strikes-row { .strikes-row {
@@ -516,8 +566,46 @@ h1 { font-size: 1.75rem; font-weight: 700; }
color: #fca5a5; color: #fca5a5;
} }
.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; }
.overlays-grid { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.overlay-btn {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1.25rem;
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.75rem;
color: #94a3b8;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.overlay-btn:hover:not(:disabled) { background: #334155; border-color: #475569; color: #f1f5f9; }
.overlay-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.overlay-btn-active {
background: #1e1b4b;
border-color: #6366f1;
color: #a5b4fc;
}
.overlay-btn-active:hover:not(:disabled) { background: #312e81; }
.overlay-btn-icon { font-size: 1.2rem; }
.overlay-live-badge {
font-size: 0.65rem;
font-weight: 700;
background: #6366f1;
color: #fff;
padding: 0.1rem 0.4rem;
border-radius: 999px;
letter-spacing: 0.06em;
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.btn-primary { .btn-primary {
display: inline-block; display: inline-block;

View File

@@ -163,6 +163,24 @@
</div> </div>
</div> </div>
</transition> </transition>
<!-- Rules/Expectations full-screen overlay -->
<transition name="tv-alert">
<div v-if="scheduleStore.showRulesOverlay" class="tv-rules-overlay" @click="scheduleStore.showRulesOverlay = false">
<div class="tv-rules-box">
<div class="tv-rules-header">
<span class="tv-rules-icon">📋</span>
<span class="tv-rules-title">Rules &amp; Expectations</span>
</div>
<ol class="tv-rules-list">
<li v-for="(rule, i) in scheduleStore.rulesOverlayItems" :key="i" class="tv-rules-item">
{{ rule }}
</li>
</ol>
<div class="tv-rules-dismiss-hint">Tap anywhere to dismiss</div>
</div>
</div>
</transition>
</div> </div>
</template> </template>
@@ -612,4 +630,88 @@ onMounted(async () => {
.tv-alert-leave-active { transition: all 0.3s ease; } .tv-alert-leave-active { transition: all 0.3s ease; }
.tv-alert-enter-from { opacity: 0; transform: scale(0.9); } .tv-alert-enter-from { opacity: 0; transform: scale(0.9); }
.tv-alert-leave-to { opacity: 0; transform: scale(1.05); } .tv-alert-leave-to { opacity: 0; transform: scale(1.05); }
/* Rules/Expectations full-screen overlay */
.tv-rules-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: pointer;
}
.tv-rules-box {
background: #0f172a;
border: 3px solid #10b981;
border-radius: 2rem;
padding: 3.5rem 4.5rem;
max-width: 800px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 0 80px rgba(16, 185, 129, 0.3);
display: flex;
flex-direction: column;
gap: 2rem;
}
.tv-rules-header {
display: flex;
align-items: center;
gap: 1rem;
justify-content: center;
}
.tv-rules-icon { font-size: 3rem; }
.tv-rules-title {
font-size: 2.5rem;
font-weight: 800;
color: #34d399;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tv-rules-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
counter-reset: rules-counter;
}
.tv-rules-item {
counter-increment: rules-counter;
display: flex;
align-items: flex-start;
gap: 1.25rem;
font-size: 2rem;
font-weight: 500;
color: #f1f5f9;
line-height: 1.3;
padding-bottom: 1.25rem;
border-bottom: 1px solid #1e293b;
}
.tv-rules-item:last-child { border-bottom: none; padding-bottom: 0; }
.tv-rules-item::before {
content: counter(rules-counter);
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
background: #064e3b;
border: 2px solid #10b981;
border-radius: 50%;
font-size: 1.2rem;
font-weight: 800;
color: #34d399;
flex-shrink: 0;
margin-top: 0.1rem;
}
.tv-rules-dismiss-hint {
font-size: 1rem;
color: #475569;
text-align: center;
margin-top: 0.5rem;
}
</style> </style>