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 <[email protected]>
This commit is contained in:
1 parent
c560055b10
commit
f645d78c83
10 files changed
+356
-11
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user