Add Meeting system subject and notification system
- Auto-create a locked "Meeting" subject for every user on registration and seed it for all existing users on startup - Meeting subject cannot be deleted or renamed (is_system flag) - 5-minute corner toast warning on Dashboard and TV with live countdown, dismiss button, and 1-minute re-notify if dismissed - At start time: full-screen TV overlay with 30-second auto-dismiss, automatic pause of running block, switch to Meeting block, and auto-start of Meeting timer - Web Audio API chimes: rising on warnings, falling at meeting start - Update README with Meeting subject and notification system docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
132
frontend/src/composables/useMeetingAlerts.js
Normal file
132
frontend/src/composables/useMeetingAlerts.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { useScheduleStore } from '@/stores/schedule'
|
||||
|
||||
function playChime(ascending = true) {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||
const freqs = ascending ? [523, 659, 784] : [784, 659, 523]
|
||||
freqs.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.frequency.value = freq
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime + i * 0.18)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.18 + 0.45)
|
||||
osc.start(ctx.currentTime + i * 0.18)
|
||||
osc.stop(ctx.currentTime + i * 0.18 + 0.5)
|
||||
})
|
||||
} catch (_) { /* audio unavailable */ }
|
||||
}
|
||||
|
||||
export function useMeetingAlerts(onMeetingStart = null) {
|
||||
const scheduleStore = useScheduleStore()
|
||||
const now = ref(new Date())
|
||||
const triggered = new Set()
|
||||
|
||||
// Active corner toasts for dashboard: [{ id, blockId, label }]
|
||||
const dashboardAlerts = ref([])
|
||||
|
||||
// Full-screen TV alert: null | { blockId, label, autoDismissAt }
|
||||
const tvAlert = ref(null)
|
||||
|
||||
const meetingBlocks = computed(() =>
|
||||
(scheduleStore.blocks || []).filter(b => b.subject?.is_system)
|
||||
)
|
||||
|
||||
function secsUntil(block) {
|
||||
if (!block.time_start) return null
|
||||
const [h, m, s] = block.time_start.split(':').map(Number)
|
||||
const target = new Date(now.value)
|
||||
target.setHours(h, m, s || 0, 0)
|
||||
return Math.round((target - now.value) / 1000)
|
||||
}
|
||||
|
||||
function blockLabel(block) {
|
||||
return block.label || block.subject?.name || 'Meeting'
|
||||
}
|
||||
|
||||
// Live countdown string for a dashboard alert
|
||||
function alertCountdown(alert) {
|
||||
const block = meetingBlocks.value.find(b => b.id === alert.blockId)
|
||||
if (!block) return ''
|
||||
const secs = secsUntil(block)
|
||||
if (secs === null || secs <= 0) return 'Starting now!'
|
||||
const m = Math.floor(secs / 60)
|
||||
const s = secs % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Live TV auto-dismiss countdown
|
||||
const tvCountdown = computed(() => {
|
||||
if (!tvAlert.value) return 0
|
||||
return Math.max(0, Math.round((tvAlert.value.autoDismissAt - now.value) / 1000))
|
||||
})
|
||||
|
||||
function dismissDashboardAlert(id) {
|
||||
dashboardAlerts.value = dashboardAlerts.value.filter(a => a.id !== id)
|
||||
}
|
||||
|
||||
function dismissTvAlert() {
|
||||
tvAlert.value = null
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
now.value = new Date()
|
||||
|
||||
// Auto-dismiss TV alert when countdown expires
|
||||
if (tvAlert.value && now.value >= tvAlert.value.autoDismissAt) {
|
||||
tvAlert.value = null
|
||||
}
|
||||
|
||||
meetingBlocks.value.forEach(block => {
|
||||
const secs = secsUntil(block)
|
||||
if (secs === null) return
|
||||
|
||||
const key5 = `${block.id}-5min`
|
||||
const key1 = `${block.id}-1min`
|
||||
const keyStart = `${block.id}-start`
|
||||
|
||||
// 5-minute warning
|
||||
if (secs <= 300 && secs > 0 && !triggered.has(key5)) {
|
||||
triggered.add(key5)
|
||||
playChime(true)
|
||||
dashboardAlerts.value.push({ id: key5, blockId: block.id, label: blockLabel(block) })
|
||||
}
|
||||
|
||||
// 1-minute re-notify (only if user already dismissed the 5-min toast)
|
||||
if (secs <= 60 && secs > 0 && !triggered.has(key1)) {
|
||||
triggered.add(key1)
|
||||
const stillActive = dashboardAlerts.value.some(a => a.blockId === block.id)
|
||||
if (!stillActive) {
|
||||
playChime(true)
|
||||
dashboardAlerts.value.push({ id: key1, blockId: block.id, label: blockLabel(block) })
|
||||
}
|
||||
}
|
||||
|
||||
// At start time: full-screen TV alert, clear dashboard toasts
|
||||
if (secs <= 0 && secs >= -15 && !triggered.has(keyStart)) {
|
||||
triggered.add(keyStart)
|
||||
dashboardAlerts.value = dashboardAlerts.value.filter(a => a.blockId !== block.id)
|
||||
playChime(false)
|
||||
if (onMeetingStart) onMeetingStart(block.id)
|
||||
tvAlert.value = {
|
||||
blockId: block.id,
|
||||
label: blockLabel(block),
|
||||
autoDismissAt: new Date(Date.now() + 30000),
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
|
||||
return {
|
||||
dashboardAlerts,
|
||||
tvAlert,
|
||||
tvCountdown,
|
||||
alertCountdown,
|
||||
dismissDashboardAlert,
|
||||
dismissTvAlert,
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
<!-- Subject row -->
|
||||
<div class="item-row">
|
||||
<template v-if="editingSubject && editingSubject.id === subject.id">
|
||||
<input v-model="editingSubject.name" class="edit-input" required />
|
||||
<input v-model="editingSubject.name" class="edit-input" :disabled="editingSubject.is_system" :title="editingSubject.is_system ? 'System subject name cannot be changed' : ''" required />
|
||||
<input v-model="editingSubject.icon" placeholder="Icon" maxlength="4" style="width:60px" class="edit-input" />
|
||||
<input v-model="editingSubject.color" type="color" title="Color" />
|
||||
<div class="item-actions">
|
||||
@@ -83,7 +83,8 @@
|
||||
class="btn-sm"
|
||||
@click="expandedSubject = expandedSubject === subject.id ? null : subject.id"
|
||||
>{{ expandedSubject === subject.id ? 'Hide Options' : 'Options' }}</button>
|
||||
<button class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
|
||||
<button v-if="!subject.is_system" class="btn-sm btn-danger" @click="deleteSubject(subject.id)">Delete</button>
|
||||
<span v-else class="system-badge">🔒 System</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -427,11 +428,9 @@ function startEditSubject(subject) {
|
||||
}
|
||||
|
||||
async function saveSubject() {
|
||||
const res = await api.patch(`/api/subjects/${editingSubject.value.id}`, {
|
||||
name: editingSubject.value.name,
|
||||
icon: editingSubject.value.icon,
|
||||
color: editingSubject.value.color,
|
||||
})
|
||||
const payload = { icon: editingSubject.value.icon, color: editingSubject.value.color }
|
||||
if (!editingSubject.value.is_system) payload.name = editingSubject.value.name
|
||||
const res = await api.patch(`/api/subjects/${editingSubject.value.id}`, payload)
|
||||
const idx = subjects.value.findIndex((s) => s.id === editingSubject.value.id)
|
||||
if (idx !== -1) subjects.value[idx] = res.data
|
||||
editingSubject.value = null
|
||||
@@ -873,6 +872,7 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
||||
.btn-sm:hover { background: #334155; }
|
||||
.btn-sm.btn-danger { border-color: #7f1d1d; color: #fca5a5; }
|
||||
.btn-sm.btn-danger:hover { background: #7f1d1d; }
|
||||
.system-badge { font-size: 0.75rem; color: #94a3b8; padding: 0.25rem 0.5rem; }
|
||||
.btn-primary.btn-sm { padding: 0.4rem 0.8rem; font-size: 0.85rem; }
|
||||
|
||||
.break-badge {
|
||||
|
||||
@@ -189,6 +189,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Meeting alert corner toasts -->
|
||||
<teleport to="body">
|
||||
<div class="meeting-toasts">
|
||||
<transition-group name="toast">
|
||||
<div v-for="alert in meetingAlerts.dashboardAlerts.value" :key="alert.id" class="meeting-toast">
|
||||
<div class="toast-icon">📅</div>
|
||||
<div class="toast-body">
|
||||
<div class="toast-title">Upcoming Meeting</div>
|
||||
<div class="toast-label">{{ alert.label }}</div>
|
||||
<div class="toast-countdown">Starts in {{ meetingAlerts.alertCountdown(alert) }}</div>
|
||||
</div>
|
||||
<button class="toast-dismiss" @click="meetingAlerts.dismissDashboardAlert(alert.id)">✕</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -197,6 +214,7 @@ import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useChildrenStore } from '@/stores/children'
|
||||
import { useScheduleStore } from '@/stores/schedule'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useMeetingAlerts } from '@/composables/useMeetingAlerts'
|
||||
import api from '@/composables/useApi'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import ChildSelector from '@/components/ChildSelector.vue'
|
||||
@@ -207,6 +225,10 @@ import TimerDisplay from '@/components/TimerDisplay.vue'
|
||||
const childrenStore = useChildrenStore()
|
||||
const scheduleStore = useScheduleStore()
|
||||
const activeChild = computed(() => childrenStore.activeChild)
|
||||
const meetingAlerts = useMeetingAlerts((blockId) => {
|
||||
if (!scheduleStore.session) return
|
||||
scheduleStore.switchBlock(scheduleStore.session.id, blockId)
|
||||
})
|
||||
|
||||
// Virtual block for break timer (same block but with break duration)
|
||||
const breakBlock = computed(() => {
|
||||
@@ -539,4 +561,39 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Meeting alert toasts */
|
||||
.meeting-toasts {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.meeting-toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
background: #1c1a07;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
width: 280px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.6);
|
||||
}
|
||||
.toast-icon { font-size: 1.5rem; flex-shrink: 0; }
|
||||
.toast-body { flex: 1; min-width: 0; }
|
||||
.toast-title { font-size: 0.7rem; font-weight: 700; color: #fbbf24; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.15rem; }
|
||||
.toast-label { font-size: 0.95rem; font-weight: 600; color: #f1f5f9; margin-bottom: 0.25rem; }
|
||||
.toast-countdown { font-size: 1.1rem; font-weight: 700; color: #fbbf24; font-variant-numeric: tabular-nums; }
|
||||
.toast-dismiss { background: none; border: none; color: #64748b; cursor: pointer; font-size: 1rem; padding: 0; flex-shrink: 0; line-height: 1; }
|
||||
.toast-dismiss:hover { color: #f1f5f9; }
|
||||
|
||||
.toast-enter-active { transition: all 0.3s ease; }
|
||||
.toast-leave-active { transition: all 0.3s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(100%); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(100%); }
|
||||
</style>
|
||||
|
||||
@@ -136,6 +136,33 @@
|
||||
<div class="tv-ws-status" :class="{ connected: wsConnected }">
|
||||
{{ wsConnected ? '● Live' : '○ Reconnecting...' }}
|
||||
</div>
|
||||
|
||||
<!-- Meeting corner toasts (5-min / 1-min warnings) -->
|
||||
<div class="tv-meeting-toasts">
|
||||
<transition-group name="toast">
|
||||
<div v-for="alert in meetingAlerts.dashboardAlerts.value" :key="alert.id" class="tv-meeting-toast">
|
||||
<div class="tv-toast-icon">📅</div>
|
||||
<div class="tv-toast-body">
|
||||
<div class="tv-toast-title">Upcoming Meeting</div>
|
||||
<div class="tv-toast-label">{{ alert.label }}</div>
|
||||
<div class="tv-toast-countdown">Starts in {{ meetingAlerts.alertCountdown(alert) }}</div>
|
||||
</div>
|
||||
<button class="tv-toast-dismiss" @click="meetingAlerts.dismissDashboardAlert(alert.id)">✕</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- Meeting full-screen alert overlay -->
|
||||
<transition name="tv-alert">
|
||||
<div v-if="meetingAlerts.tvAlert.value" class="tv-meeting-overlay" @click="meetingAlerts.dismissTvAlert()">
|
||||
<div class="tv-meeting-box">
|
||||
<div class="tv-meeting-icon">📅</div>
|
||||
<div class="tv-meeting-title">Meeting Starting Now</div>
|
||||
<div class="tv-meeting-label">{{ meetingAlerts.tvAlert.value.label }}</div>
|
||||
<div class="tv-meeting-dismiss-hint">Tap anywhere to dismiss • Auto-closing in {{ meetingAlerts.tvCountdown.value }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -144,6 +171,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useScheduleStore } from '@/stores/schedule'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { useMeetingAlerts } from '@/composables/useMeetingAlerts'
|
||||
import TimerDisplay from '@/components/TimerDisplay.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
import ScheduleBlock from '@/components/ScheduleBlock.vue'
|
||||
@@ -151,6 +179,7 @@ import ScheduleBlock from '@/components/ScheduleBlock.vue'
|
||||
const route = useRoute()
|
||||
const scheduleStore = useScheduleStore()
|
||||
const childId = parseInt(route.params.childId)
|
||||
const meetingAlerts = useMeetingAlerts()
|
||||
|
||||
// Read timezone from localStorage (set by the parent in Admin → Settings)
|
||||
const tz = localStorage.getItem('tz') || undefined
|
||||
@@ -502,4 +531,85 @@ onMounted(async () => {
|
||||
.tv-ws-status.connected {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Meeting corner toasts */
|
||||
.tv-meeting-toasts {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.tv-meeting-toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
background: #1c1a07;
|
||||
border: 2px solid #f59e0b;
|
||||
border-radius: 1rem;
|
||||
padding: 1.2rem 1.5rem;
|
||||
width: 380px;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,0.7);
|
||||
}
|
||||
.tv-toast-icon { font-size: 2.2rem; flex-shrink: 0; }
|
||||
.tv-toast-body { flex: 1; min-width: 0; }
|
||||
.tv-toast-title { font-size: 0.85rem; font-weight: 700; color: #fbbf24; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.2rem; }
|
||||
.tv-toast-label { font-size: 1.3rem; font-weight: 700; color: #f1f5f9; margin-bottom: 0.3rem; }
|
||||
.tv-toast-countdown { font-size: 1.6rem; font-weight: 800; color: #fbbf24; font-variant-numeric: tabular-nums; }
|
||||
.tv-toast-dismiss { background: none; border: none; color: #64748b; cursor: pointer; font-size: 1.4rem; padding: 0; flex-shrink: 0; line-height: 1; }
|
||||
.tv-toast-dismiss:hover { color: #f1f5f9; }
|
||||
|
||||
.toast-enter-active { transition: all 0.3s ease; }
|
||||
.toast-leave-active { transition: all 0.3s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(100%); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(100%); }
|
||||
|
||||
/* Meeting full-screen alert */
|
||||
.tv-meeting-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tv-meeting-box {
|
||||
background: #1e1b4b;
|
||||
border: 3px solid #6366f1;
|
||||
border-radius: 2rem;
|
||||
padding: 4rem 5rem;
|
||||
text-align: center;
|
||||
max-width: 700px;
|
||||
width: 90%;
|
||||
box-shadow: 0 0 80px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
.tv-meeting-icon { font-size: 5rem; margin-bottom: 1rem; }
|
||||
.tv-meeting-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: #a5b4fc;
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.tv-meeting-label {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 900;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.tv-meeting-dismiss-hint {
|
||||
font-size: 1.1rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tv-alert-enter-active { transition: all 0.4s ease; }
|
||||
.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); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user