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