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

@@ -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,

View File

@@ -196,6 +196,47 @@
</div>
</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 -->
<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 {

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;

View File

@@ -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 &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>
</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>