Files
homeschool/frontend/src/views/TVView.vue
derekc 823260cdd8 Add timezone selector to Admin settings with full-stack support
- Add `timezone` column to User model (VARCHAR 64, default UTC) with
  idempotent startup migration
- Expose and persist timezone via PATCH /api/users/me
- Fix TimerEvent.occurred_at serialization to include UTC offset marker
  (+00:00) so JavaScript correctly parses timestamps as UTC
- Add frontend utility (src/utils/time.js) with timezone-aware
  formatTime, getHHMM, getDateInTZ, tzDateTimeToUTC helpers and a
  curated IANA timezone list
- Add Settings section to Admin page with timezone dropdown; saves to
  both the API and localStorage for the unauthenticated TV view
- Update Activity Log to display and edit times in the user's timezone
- Update TV dashboard clock to respect the saved timezone
- Update README: features, setup steps, usage table, WebSocket events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:16:37 -08:00

438 lines
11 KiB
Vue

<template>
<div class="tv-root">
<!-- Header bar -->
<header class="tv-header">
<div class="tv-child-name-wrap">
<div class="tv-child-name">{{ scheduleStore.child?.name || 'Loading...' }}</div>
<div class="tv-strikes" v-if="scheduleStore.child?.strikes > 0">
<span
v-for="i in scheduleStore.child.strikes"
:key="i"
class="tv-strike-x"
></span>
</div>
</div>
<div class="tv-clock">{{ clockDisplay }}</div>
<div class="tv-date">{{ dateDisplay }}</div>
</header>
<!-- Day progress bar full width, always visible when day hours are set -->
<div class="tv-day-progress" v-if="scheduleStore.dayStartTime && scheduleStore.dayEndTime">
<div class="tv-day-progress-meta">
<span class="tv-day-start">{{ formatDayTime(scheduleStore.dayStartTime) }}</span>
<span class="tv-day-pct">{{ dayProgressPercent }}% through the day</span>
<span class="tv-day-end">{{ formatDayTime(scheduleStore.dayEndTime) }}</span>
</div>
<ProgressBar :percent="dayProgressPercent" />
</div>
<!-- No session state -->
<div v-if="!scheduleStore.session" class="tv-idle">
<div class="tv-idle-icon">🌟</div>
<div class="tv-idle-text">No active school session today</div>
</div>
<!-- Active session -->
<div v-else class="tv-main">
<!-- Left: timer -->
<div class="tv-timer-col tv-greeting-col" v-if="!scheduleStore.currentBlock">
<div class="tv-greeting">Good Morning!</div>
<div class="tv-greeting-sub">Ready to start school?</div>
<div v-if="firstBlockCountdown !== null" class="tv-countdown-wrap">
<div class="tv-countdown-label">First block starts in</div>
<div class="tv-countdown">{{ firstBlockCountdown }}</div>
</div>
</div>
<div class="tv-timer-col" v-else>
<div class="tv-subject-badge" :style="{ background: currentSubjectColor }">
{{ currentSubjectIcon }} {{ currentSubjectName }}
</div>
<TimerDisplay
:block="scheduleStore.currentBlock"
:session="scheduleStore.session"
:is-paused="scheduleStore.isPaused"
:block-started-at="scheduleStore.blockStartedAt"
:block-elapsed-offset="scheduleStore.blockElapsedOffset"
/>
<div class="tv-block-notes" v-if="scheduleStore.currentBlock.notes">
{{ scheduleStore.currentBlock.notes }}
</div>
</div>
<!-- Center: subject options -->
<div class="tv-options-col" :style="{ background: currentSubjectColor + '22', borderColor: currentSubjectColor }">
<div class="tv-options-title">Activities</div>
<div
v-if="currentSubjectOptions.length"
class="tv-options-list"
>
<div
v-for="opt in currentSubjectOptions"
:key="opt.id"
class="tv-option-item"
>
{{ opt.text }}
</div>
</div>
<div v-else class="tv-options-empty">No activities listed for this subject.</div>
</div>
<!-- Right: schedule list -->
<div class="tv-sidebar">
<div class="tv-schedule-list">
<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)"
compact
/>
</div>
</div>
</div>
<!-- WS connection indicator -->
<div class="tv-ws-status" :class="{ connected: wsConnected }">
{{ wsConnected ? '● Live' : '○ Reconnecting...' }}
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useScheduleStore } from '@/stores/schedule'
import { useWebSocket } from '@/composables/useWebSocket'
import TimerDisplay from '@/components/TimerDisplay.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import ScheduleBlock from '@/components/ScheduleBlock.vue'
const route = useRoute()
const scheduleStore = useScheduleStore()
const childId = parseInt(route.params.childId)
// Read timezone from localStorage (set by the parent in Admin → Settings)
const tz = localStorage.getItem('tz') || undefined
// Clock
const now = ref(new Date())
setInterval(() => { now.value = new Date() }, 1000)
const clockDisplay = computed(() =>
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', timeZone: tz })
)
const dateDisplay = computed(() =>
now.value.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', timeZone: tz })
)
// Day progress helpers
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 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)))
})
// Countdown to first block
const firstBlockCountdown = computed(() => {
const first = scheduleStore.blocks[0]
if (!first?.time_start) return null
const [h, m, s] = first.time_start.split(':').map(Number)
const target = new Date(now.value)
target.setHours(h, m, s || 0, 0)
const diffMs = target - now.value
if (diffMs <= 0) return null
const totalSec = Math.floor(diffMs / 1000)
const hours = Math.floor(totalSec / 3600)
const mins = Math.floor((totalSec % 3600) / 60)
const secs = totalSec % 60
if (hours > 0) return `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
})
// Subject display helpers
const currentSubjectColor = computed(() => {
const block = scheduleStore.currentBlock
return block?.subject?.color || '#4F46E5'
})
const currentSubjectIcon = computed(() => scheduleStore.currentBlock?.subject?.icon || '📚')
const currentSubjectName = computed(() =>
scheduleStore.currentBlock?.label || scheduleStore.currentBlock?.subject?.name || 'Current Block'
)
const currentSubjectOptions = computed(() =>
scheduleStore.currentBlock?.subject?.options || []
)
// WebSocket
const { connected: wsConnected } = useWebSocket(childId, (msg) => {
scheduleStore.applyWsEvent(msg)
})
// Initial data load
onMounted(async () => {
await scheduleStore.fetchDashboard(childId)
})
</script>
<style scoped>
.tv-root {
min-height: 100vh;
background: #0f172a;
color: #f1f5f9;
display: flex;
flex-direction: column;
padding: 2rem;
gap: 2rem;
}
.tv-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid #1e293b;
padding-bottom: 1rem;
}
.tv-child-name-wrap {
display: flex;
align-items: center;
gap: 1rem;
}
.tv-child-name {
font-size: 2.5rem;
font-weight: 700;
color: #818cf8;
}
.tv-strikes {
display: flex;
gap: 0.4rem;
}
.tv-strike-x {
font-size: 2rem;
font-weight: 700;
color: #ef4444;
line-height: 1;
}
.tv-clock {
font-size: 3rem;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: #f8fafc;
}
.tv-date {
font-size: 1.25rem;
color: #94a3b8;
text-align: right;
}
.tv-idle {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.tv-idle-icon { font-size: 5rem; }
.tv-idle-text { font-size: 2rem; color: #64748b; }
.tv-main {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
min-height: 0;
}
.tv-timer-col {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
justify-content: center;
}
.tv-subject-badge {
font-size: 1.4rem;
font-weight: 600;
padding: 0.6rem 1.5rem;
border-radius: 999px;
color: #fff;
text-align: center;
}
.tv-greeting-col {
justify-content: center;
gap: 1rem;
}
.tv-greeting {
font-size: 3rem;
font-weight: 700;
color: #818cf8;
text-align: center;
}
.tv-greeting-sub {
font-size: 1.6rem;
color: #64748b;
text-align: center;
}
.tv-countdown-wrap {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.tv-countdown-label {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
}
.tv-countdown {
font-size: 4rem;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: #c7d2fe;
letter-spacing: 0.05em;
}
.tv-block-notes {
font-size: 1rem;
color: #94a3b8;
text-align: center;
}
.tv-options-col {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
border: 2px solid;
border-radius: 1rem;
padding: 1.5rem 2rem;
}
.tv-options-title {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #475569;
}
.tv-options-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tv-option-item {
font-size: 2rem;
font-weight: 500;
color: #e2e8f0;
padding: 0.6rem 0;
border-bottom: 1px solid #1e293b;
line-height: 1.3;
}
.tv-options-empty {
font-size: 1.1rem;
color: #334155;
font-style: italic;
}
.tv-day-progress {
background: #1e293b;
border-radius: 1rem;
padding: 1rem 1.5rem 1.25rem;
}
.tv-day-progress-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.6rem;
}
.tv-day-start,
.tv-day-end {
font-size: 1rem;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.tv-day-pct {
font-size: 1.1rem;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.02em;
}
.tv-sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.tv-schedule-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 60vh;
}
/* Expand timer to fill the column */
.tv-timer-col :deep(.timer-wrap) {
width: min(100%, 420px);
height: min(100%, 420px);
}
.tv-timer-col :deep(.timer-time) { font-size: 5.5rem; }
.tv-timer-col :deep(.timer-label) { font-size: 1.4rem; }
/* Make schedule block text larger for TV viewing */
.tv-schedule-list :deep(.block-title) { font-size: 1.2rem; }
.tv-schedule-list :deep(.block-time) { font-size: 1rem; }
.tv-schedule-list :deep(.block-card) { padding: 0.85rem 1rem; }
.tv-ws-status {
position: fixed;
bottom: 1rem;
right: 1rem;
font-size: 0.75rem;
color: #ef4444;
opacity: 0.7;
}
.tv-ws-status.connected {
color: #22c55e;
}
</style>