- 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>
438 lines
11 KiB
Vue
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>
|