Reset clears the current block's elapsed time to zero and immediately starts the timer. A shared compute_block_elapsed() utility (utils/timer.py) handles the elapsed calculation in both the sessions and dashboard routers, and correctly treats "reset" events as zero-elapsed restart markers so page reloads after a reset show accurate times. Layout: Start/Pause/Resume/Reset are grouped on the left; End Day sits on the right via justify-content: space-between. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
431 lines
14 KiB
Vue
431 lines
14 KiB
Vue
<template>
|
|
<div class="page">
|
|
<NavBar />
|
|
<main class="container">
|
|
<div class="page-header">
|
|
<h1>Dashboard</h1>
|
|
<ChildSelector />
|
|
</div>
|
|
|
|
<div v-if="!activeChild" class="empty-state">
|
|
<p>Add a child in <RouterLink to="/admin">Admin</RouterLink> to get started.</p>
|
|
</div>
|
|
|
|
<div v-else class="dashboard-grid">
|
|
<!-- Today's session card -->
|
|
<div class="card session-card">
|
|
<div class="card-title">Today's Session</div>
|
|
<div v-if="scheduleStore.session">
|
|
<div v-if="scheduleStore.dayStartTime && scheduleStore.dayEndTime" class="day-progress-section">
|
|
<div class="day-progress-header">
|
|
<span class="badge-active">Active</span>
|
|
<span class="day-progress-pct">{{ dayProgressPercent }}%</span>
|
|
</div>
|
|
<ProgressBar :percent="dayProgressPercent" />
|
|
<div class="day-progress-times">
|
|
<span>{{ formatDayTime(scheduleStore.dayStartTime) }}</span>
|
|
<span>{{ currentTimeDisplay }}</span>
|
|
<span>{{ formatDayTime(scheduleStore.dayEndTime) }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="session-info">
|
|
<span class="badge-active">Active</span>
|
|
</div>
|
|
<div v-if="scheduleStore.currentBlock" class="current-block-timer">
|
|
<TimerDisplay
|
|
compact
|
|
:block="scheduleStore.currentBlock"
|
|
:session="scheduleStore.session"
|
|
:is-paused="scheduleStore.isPaused"
|
|
:block-started-at="scheduleStore.blockStartedAt"
|
|
:block-elapsed-offset="scheduleStore.blockElapsedOffset"
|
|
/>
|
|
</div>
|
|
<div class="session-actions">
|
|
<div class="session-actions-left">
|
|
<button
|
|
class="btn-sm"
|
|
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
|
|
@click="sendAction('pause')"
|
|
>Pause</button>
|
|
<button
|
|
class="btn-sm btn-start"
|
|
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
|
|
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
|
|
>Start</button>
|
|
<button
|
|
class="btn-sm"
|
|
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
|
|
@click="sendAction('resume')"
|
|
>Resume</button>
|
|
<button
|
|
class="btn-sm"
|
|
v-if="scheduleStore.session.current_block_id"
|
|
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
|
|
>Reset</button>
|
|
</div>
|
|
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="no-session">
|
|
<p>No active session.</p>
|
|
<button class="btn-primary" @click="showStartDialog = true">Start Day</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Schedule blocks -->
|
|
<div class="card">
|
|
<div class="card-title">Today's Schedule</div>
|
|
<div v-if="scheduleStore.blocks.length === 0" class="empty-small">
|
|
No blocks loaded.
|
|
</div>
|
|
<div class="block-list" v-else>
|
|
<ScheduleBlock
|
|
v-for="block in scheduleStore.blocks"
|
|
:key="block.id"
|
|
:block="block"
|
|
:is-current="block.id === scheduleStore.session?.current_block_id"
|
|
:is-completed="scheduleStore.completedBlockIds.includes(block.id)"
|
|
:elapsed-seconds="blockElapsed(block)"
|
|
@click="selectBlock(block)"
|
|
/>
|
|
</div>
|
|
</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>
|
|
|
|
<!-- 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.id}`" target="_blank" class="btn-primary">
|
|
Open TV View →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Start session dialog -->
|
|
<div class="dialog-overlay" v-if="showStartDialog" @click.self="showStartDialog = false">
|
|
<div class="dialog">
|
|
<h2>Start School Day</h2>
|
|
<div class="field">
|
|
<label>Schedule Template</label>
|
|
<select v-model="selectedTemplate">
|
|
<option :value="null">No template (freestyle)</option>
|
|
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="dialog-actions">
|
|
<button class="btn-sm" @click="showStartDialog = false">Cancel</button>
|
|
<button class="btn-primary" @click="startSession">Start</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, watch, computed } from 'vue'
|
|
import { useChildrenStore } from '@/stores/children'
|
|
import { useScheduleStore } from '@/stores/schedule'
|
|
import { useWebSocket } from '@/composables/useWebSocket'
|
|
import api from '@/composables/useApi'
|
|
import NavBar from '@/components/NavBar.vue'
|
|
import ChildSelector from '@/components/ChildSelector.vue'
|
|
import ProgressBar from '@/components/ProgressBar.vue'
|
|
import ScheduleBlock from '@/components/ScheduleBlock.vue'
|
|
import TimerDisplay from '@/components/TimerDisplay.vue'
|
|
|
|
const childrenStore = useChildrenStore()
|
|
const scheduleStore = useScheduleStore()
|
|
const activeChild = computed(() => childrenStore.activeChild)
|
|
const showStartDialog = ref(false)
|
|
const selectedTemplate = ref(null)
|
|
const templates = ref([])
|
|
|
|
const now = ref(new Date())
|
|
setInterval(() => { now.value = new Date() }, 1000)
|
|
|
|
function timeStrToMinutes(str) {
|
|
if (!str) return null
|
|
const [h, m] = str.split(':').map(Number)
|
|
return h * 60 + m
|
|
}
|
|
|
|
function formatDayTime(str) {
|
|
if (!str) return ''
|
|
const [h, m] = str.split(':').map(Number)
|
|
const period = h >= 12 ? 'PM' : 'AM'
|
|
const hour = h % 12 || 12
|
|
return `${hour}:${String(m).padStart(2, '0')} ${period}`
|
|
}
|
|
|
|
const currentTimeDisplay = computed(() =>
|
|
now.value.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
)
|
|
|
|
const dayProgressPercent = computed(() => {
|
|
const start = timeStrToMinutes(scheduleStore.dayStartTime)
|
|
const end = timeStrToMinutes(scheduleStore.dayEndTime)
|
|
if (start === null || end === null || end <= start) return 0
|
|
const nowMin = now.value.getHours() * 60 + now.value.getMinutes()
|
|
return Math.max(0, Math.min(100, Math.round((nowMin - start) / (end - start) * 100)))
|
|
})
|
|
|
|
let wsDisconnect = null
|
|
|
|
async function loadDashboard() {
|
|
if (!activeChild.value) return
|
|
await scheduleStore.fetchDashboard(activeChild.value.id)
|
|
|
|
// Load templates for start dialog
|
|
const res = await api.get('/api/schedules')
|
|
templates.value = res.data
|
|
|
|
// WS subscription
|
|
if (wsDisconnect) wsDisconnect()
|
|
const { disconnect } = useWebSocket(activeChild.value.id, (msg) => {
|
|
scheduleStore.applyWsEvent(msg)
|
|
})
|
|
wsDisconnect = disconnect
|
|
}
|
|
|
|
async function startSession() {
|
|
await scheduleStore.startSession(activeChild.value.id, selectedTemplate.value)
|
|
showStartDialog.value = false
|
|
await loadDashboard()
|
|
}
|
|
|
|
async function toggleStrike(child, index) {
|
|
const newStrikes = index <= child.strikes ? index - 1 : index
|
|
const res = await api.patch(`/api/children/${child.id}/strikes`, { strikes: newStrikes })
|
|
const idx = childrenStore.children.findIndex((c) => c.id === child.id)
|
|
if (idx !== -1) childrenStore.children[idx] = res.data
|
|
}
|
|
|
|
async function sendAction(type) {
|
|
if (!scheduleStore.session) return
|
|
await scheduleStore.sendTimerAction(scheduleStore.session.id, type)
|
|
}
|
|
|
|
function blockElapsed(block) {
|
|
const currentId = scheduleStore.session?.current_block_id
|
|
if (block.id === currentId && scheduleStore.blockStartedAt && !scheduleStore.isPaused) {
|
|
return scheduleStore.blockElapsedOffset + Math.floor((now.value - scheduleStore.blockStartedAt) / 1000)
|
|
}
|
|
return scheduleStore.blockElapsedCache[block.id] || 0
|
|
}
|
|
|
|
function selectBlock(block) {
|
|
if (!scheduleStore.session) return
|
|
// Clicking the current block does nothing — use Start/Pause/Resume buttons
|
|
if (block.id === scheduleStore.session.current_block_id) return
|
|
scheduleStore.selectBlock(scheduleStore.session.id, block.id)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await childrenStore.fetchChildren()
|
|
await loadDashboard()
|
|
})
|
|
|
|
watch(activeChild, loadDashboard)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page { min-height: 100vh; background: #0f172a; }
|
|
.container { max-width: 1100px; margin: 0 auto; padding: 2rem; }
|
|
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; }
|
|
h1 { font-size: 1.75rem; font-weight: 700; }
|
|
|
|
.dashboard-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
.card {
|
|
background: #1e293b;
|
|
border-radius: 1rem;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: #64748b;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.session-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.75rem;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.day-progress-section { margin-bottom: 0.75rem; }
|
|
.day-progress-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.day-progress-pct { font-size: 0.9rem; color: #94a3b8; }
|
|
.day-progress-times {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 0.75rem;
|
|
color: #475569;
|
|
margin-top: 0.35rem;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.badge-active {
|
|
background: #14532d;
|
|
color: #4ade80;
|
|
padding: 0.2rem 0.6rem;
|
|
border-radius: 999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.current-block-timer { display: flex; justify-content: center; margin: 1rem 0; }
|
|
.session-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; gap: 0.5rem; }
|
|
.session-actions-left { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
|
|
.btn-sm {
|
|
padding: 0.4rem 0.9rem;
|
|
border: 1px solid #334155;
|
|
background: transparent;
|
|
color: #94a3b8;
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-sm:hover { background: #334155; }
|
|
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
|
|
.btn-sm.btn-danger:hover { background: #7f1d1d; }
|
|
.btn-sm.btn-start { border-color: #4f46e5; color: #818cf8; }
|
|
.btn-sm.btn-start:hover { background: #4f46e5; color: #fff; }
|
|
|
|
.no-session { text-align: center; padding: 1.5rem 0; color: #64748b; }
|
|
.no-session p { margin-bottom: 1rem; }
|
|
|
|
.empty-state { text-align: center; padding: 4rem; color: #64748b; }
|
|
.empty-small { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
|
|
|
.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 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.strikes-child-color { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
.strikes-child-name { flex: 1; font-size: 0.95rem; }
|
|
|
|
.strikes-buttons { display: flex; gap: 0.5rem; }
|
|
|
|
.strike-btn {
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border-radius: 50%;
|
|
border: 2px solid #334155;
|
|
background: transparent;
|
|
color: #334155;
|
|
font-size: 1rem;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.strike-btn:hover { border-color: #ef4444; color: #ef4444; }
|
|
|
|
.strike-btn.lit {
|
|
background: #7f1d1d;
|
|
border-color: #ef4444;
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.tv-card { grid-column: span 1; }
|
|
.tv-desc { color: #64748b; margin-bottom: 1rem; font-size: 0.9rem; }
|
|
|
|
.btn-primary {
|
|
display: inline-block;
|
|
padding: 0.7rem 1.5rem;
|
|
background: #4f46e5;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 0.75rem;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
text-align: center;
|
|
transition: background 0.2s;
|
|
}
|
|
.btn-primary:hover { background: #4338ca; }
|
|
|
|
.dialog-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
}
|
|
.dialog {
|
|
background: #1e293b;
|
|
border-radius: 1rem;
|
|
padding: 2rem;
|
|
width: 380px;
|
|
max-width: 90vw;
|
|
}
|
|
.dialog h2 { margin-bottom: 1.5rem; }
|
|
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
|
|
|
.field { margin-bottom: 1rem; }
|
|
.field label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.4rem; }
|
|
.field select {
|
|
width: 100%;
|
|
padding: 0.65rem 0.9rem;
|
|
background: #0f172a;
|
|
border: 1px solid #334155;
|
|
border-radius: 0.5rem;
|
|
color: #f1f5f9;
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|