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

@@ -12,37 +12,60 @@
</div>
<div v-else class="dashboard-grid">
<!-- 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>
<!-- 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>
<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>
<!-- 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 v-if="childrenStore.children.length === 0" class="empty-small">No children added yet.</div>
</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>
</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;