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:
22
README.md
22
README.md
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -76,6 +78,7 @@ homeschool/
|
||||
│ │ ├── activity.py # ActivityLog (manual notes)
|
||||
│ │ ├── morning_routine.py# MorningRoutineItem
|
||||
│ │ ├── break_activity.py # BreakActivityItem
|
||||
│ │ ├── rule.py # RuleItem (rules & expectations)
|
||||
│ │ ├── strike.py # StrikeEvent (strike history)
|
||||
│ │ └── user.py # User (incl. timezone, last_active_at)
|
||||
│ ├── schemas/ # Pydantic request/response schemas
|
||||
@@ -88,6 +91,8 @@ homeschool/
|
||||
│ │ ├── logs.py # Timeline + strike events
|
||||
│ │ ├── morning_routine.py
|
||||
│ │ ├── 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)
|
||||
│ │ ├── admin.py # Super admin: login, user list, impersonate, reset-password,
|
||||
│ │ │ # 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.
|
||||
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.
|
||||
5. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here.
|
||||
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.
|
||||
7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
|
||||
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.
|
||||
5. **Admin** → Add **Rules & Expectations** items — drag to reorder. Trigger from the Dashboard Overlays card to display on the TV.
|
||||
6. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here.
|
||||
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. **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 |
|
||||
|-----|-------------|
|
||||
| `/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 |
|
||||
| `/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
|
||||
|
||||
@@ -236,7 +242,7 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check
|
||||
|
||||
| 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.
|
||||
|
||||
@@ -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_reset` | Break timer reset to zero | `block_id`, `current_block_id`, `break_elapsed_seconds` (always 0) |
|
||||
| `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:**
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.models.child import Child
|
||||
from app.models.subject import Subject
|
||||
from app.models.user import User
|
||||
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
|
||||
|
||||
settings = get_settings()
|
||||
@@ -113,6 +113,8 @@ app.include_router(morning_routine.router)
|
||||
app.include_router(break_activity.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(rules.router)
|
||||
app.include_router(overlays.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models.activity import ActivityLog
|
||||
from app.models.morning_routine import MorningRoutineItem
|
||||
from app.models.break_activity import BreakActivityItem
|
||||
from app.models.strike import StrikeEvent
|
||||
from app.models.rule import RuleItem
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -26,4 +27,5 @@ __all__ = [
|
||||
"MorningRoutineItem",
|
||||
"BreakActivityItem",
|
||||
"StrikeEvent",
|
||||
"RuleItem",
|
||||
]
|
||||
|
||||
15
backend/app/models/rule.py
Normal file
15
backend/app/models/rule.py
Normal 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
|
||||
56
backend/app/routers/overlays.py
Normal file
56
backend/app/routers/overlays.py
Normal 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}
|
||||
122
backend/app/routers/rules.py
Normal file
122
backend/app/routers/rules.py
Normal 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()
|
||||
@@ -18,6 +18,8 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
const breakStartedAt = ref(null) // Date.now() ms when break counting started
|
||||
const breakElapsedOffset = ref(0) // break seconds already elapsed
|
||||
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(() =>
|
||||
session.value?.current_block_id
|
||||
@@ -80,6 +82,15 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
}
|
||||
|
||||
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') {
|
||||
applySnapshot(event)
|
||||
return
|
||||
@@ -394,6 +405,8 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
breakStartedAt,
|
||||
breakElapsedOffset,
|
||||
breakElapsedCache,
|
||||
showRulesOverlay,
|
||||
rulesOverlayItems,
|
||||
currentBlock,
|
||||
progressPercent,
|
||||
applySnapshot,
|
||||
|
||||
@@ -196,6 +196,47 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rules & Expectations section -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
<h2>Rules & 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 -->
|
||||
<section class="section">
|
||||
<div class="section-header">
|
||||
@@ -636,6 +677,70 @@ async function deleteBreakItem(id) {
|
||||
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
|
||||
const templates = ref([])
|
||||
const showCreateForm = ref(false)
|
||||
@@ -733,7 +838,7 @@ async function deleteBlock(templateId, blockId) {
|
||||
|
||||
onMounted(async () => {
|
||||
await childrenStore.fetchChildren()
|
||||
await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities()])
|
||||
await Promise.all([loadSubjects(), loadTemplates(), loadMorningRoutine(), loadBreakActivities(), loadRules()])
|
||||
selectedTimezone.value = authStore.timezone
|
||||
})
|
||||
</script>
|
||||
@@ -908,6 +1013,20 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
.option-add-row .option-input { background: #1e293b; }
|
||||
|
||||
.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; }
|
||||
|
||||
.support-card {
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<div class="card strikes-card">
|
||||
<div class="card-title">3 Strikes</div>
|
||||
@@ -34,15 +46,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Overlays -->
|
||||
<div class="card overlays-card">
|
||||
<div class="card-title">Overlays</div>
|
||||
<div class="overlays-grid">
|
||||
<button
|
||||
class="overlay-btn"
|
||||
: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>
|
||||
|
||||
<!-- Session + Schedule row -->
|
||||
<div class="bottom-row">
|
||||
<!-- Today's session card -->
|
||||
<div class="card session-card">
|
||||
<div class="card-title">Today's Session</div>
|
||||
@@ -171,6 +194,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end bottom-row -->
|
||||
|
||||
</div>
|
||||
|
||||
@@ -276,7 +300,7 @@ let wsDisconnect = null
|
||||
|
||||
async function loadDashboard() {
|
||||
if (!activeChild.value) return
|
||||
await scheduleStore.fetchDashboard(activeChild.value.id)
|
||||
await scheduleStore.fetchDashboard(activeChild.value.tv_token)
|
||||
|
||||
// Load templates for start dialog
|
||||
const res = await api.get('/api/schedules')
|
||||
@@ -284,7 +308,7 @@ async function loadDashboard() {
|
||||
|
||||
// WS subscription
|
||||
if (wsDisconnect) wsDisconnect()
|
||||
const { disconnect } = useWebSocket(activeChild.value.id, (msg) => {
|
||||
const { disconnect } = useWebSocket(activeChild.value.tv_token, (msg) => {
|
||||
scheduleStore.applyWsEvent(msg)
|
||||
})
|
||||
wsDisconnect = disconnect
|
||||
@@ -346,6 +370,14 @@ const estimatedFinishTime = computed(() => {
|
||||
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) {
|
||||
if (!scheduleStore.session) return
|
||||
// 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; }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
background: #1e293b;
|
||||
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; }
|
||||
|
||||
.strikes-card { grid-column: span 1; }
|
||||
|
||||
.strikes-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
|
||||
.strikes-row {
|
||||
@@ -516,8 +566,46 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.tv-card { grid-column: span 1; }
|
||||
.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 {
|
||||
display: inline-block;
|
||||
|
||||
@@ -163,6 +163,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</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 & 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>
|
||||
</template>
|
||||
|
||||
@@ -612,4 +630,88 @@ onMounted(async () => {
|
||||
.tv-alert-leave-active { transition: all 0.3s ease; }
|
||||
.tv-alert-enter-from { opacity: 0; transform: scale(0.9); }
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user