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:
2026-03-04 23:44:21 -08:00
parent c560055b10
commit f645d78c83
10 changed files with 356 additions and 11 deletions

View File

@@ -20,6 +20,12 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
- **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history.
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required).
- **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Lists all registered parent accounts and allows impersonating any user — switching into their session to view and manage their data. An impersonation banner is shown at the top of the screen with a one-click "Exit to Admin Panel" button.
- **Meeting Subject** — A system subject called "Meeting" (📅) is automatically created for every user and cannot be deleted or renamed. Add it to any schedule block like a normal subject and assign activity options (e.g. agenda items) that will display on the TV during the meeting.
- **Meeting Notifications** — When a schedule block assigned to the Meeting subject is approaching, the app automatically alerts everyone:
- **5-minute warning** — An amber corner toast appears on both the parent Dashboard and the TV with the meeting name and a live countdown. Tap ✕ to dismiss.
- **1-minute re-notify** — If the 5-minute toast was dismissed, it reappears at the 1-minute mark.
- **At start time** — A full-screen overlay fires on the TV with the meeting name and a 30-second auto-dismiss countdown (tap anywhere to dismiss early). Simultaneously, the currently running block timer is paused, the schedule switches to the Meeting block, and its timer starts automatically. The TV center panel switches to show the meeting's activity options.
- **Chime sounds** — A rising three-note chime plays on warnings; a falling chime plays at meeting start. Generated via the Web Audio API — no audio files required.
---
@@ -86,7 +92,8 @@ homeschool/
└── src/
├── composables/
│ ├── useApi.js # Axios with auto token-refresh
── useWebSocket.js # Auto-reconnecting WebSocket
── useWebSocket.js # Auto-reconnecting WebSocket
│ └── useMeetingAlerts.js # Meeting countdown, corner toasts, TV overlay, chimes
├── stores/ # Pinia: auth, children, schedule, superAdmin
├── views/ # LoginView, TVView, DashboardView, LogView, AdminView,
│ # SuperAdminLoginView, SuperAdminView
@@ -153,7 +160,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
### 5. Set up your data (in order)
1. **Admin** (`/admin`) → Add each child, pick a color
2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block.
2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block. The **Meeting** subject is created automatically and cannot be deleted or renamed, but you can add activity options (agenda items) to it.
3. **Admin** → Add **Morning Routine** items — these show on the TV during the greeting before the first block starts.
4. **Admin** → Add **Break Activities** items — these show on the TV center panel whenever a break is active.
5. **Admin** → Scroll to **Settings** and select your local timezone
@@ -209,7 +216,7 @@ While a session is active, clicking a block in the schedule list **selects** it
| URL | Description |
|-----|-------------|
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar (🟢 Start → Finish 🏁), schedule sidebar |
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar (🟢 Start → Finish 🏁), schedule sidebar, meeting warning toasts, meeting start overlay |
Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.

View File

@@ -2,12 +2,15 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy import select, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import engine
from app.models import Base
from app.models.subject import Subject
from app.models.user import User
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.routers import morning_routine, break_activity, admin
from app.websocket.manager import manager
@@ -35,6 +38,27 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "schedule_blocks", "break_time_minutes", "INT NULL")
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0")
# Seed Meeting system subject for any existing users who don't have one
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
users_result = await db.execute(select(User).where(User.is_active == True))
all_users = users_result.scalars().all()
for user in all_users:
existing = await db.execute(
select(Subject).where(Subject.user_id == user.id, Subject.is_system == True)
)
if not existing.scalar_one_or_none():
db.add(Subject(
user_id=user.id,
name="Meeting",
icon="📅",
color="#6366f1",
is_system=True,
))
await db.commit()
yield

View File

@@ -12,6 +12,7 @@ class Subject(TimestampMixin, Base):
color: Mapped[str] = mapped_column(String(7), default="#10B981") # hex color
icon: Mapped[str] = mapped_column(String(10), default="📚") # emoji
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
user: Mapped["User"] = relationship("User", back_populates="subjects") # noqa: F821
schedule_blocks: Mapped[list["ScheduleBlock"]] = relationship( # noqa: F821

View File

@@ -11,6 +11,7 @@ from app.auth.jwt import (
)
from app.dependencies import get_db
from app.models.user import User
from app.models.subject import Subject
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
from app.schemas.user import UserOut
@@ -39,6 +40,16 @@ async def register(body: RegisterRequest, response: Response, db: AsyncSession =
await db.commit()
await db.refresh(user)
meeting = Subject(
user_id=user.id,
name="Meeting",
icon="📅",
color="#6366f1",
is_system=True,
)
db.add(meeting)
await db.commit()
access = create_access_token({"sub": str(user.id)})
refresh = create_refresh_token({"sub": str(user.id)})
response.set_cookie(REFRESH_COOKIE, refresh, **COOKIE_OPTS)

View File

@@ -101,6 +101,8 @@ async def delete_subject(
subject = result.scalar_one_or_none()
if not subject:
raise HTTPException(status_code=404, detail="Subject not found")
if subject.is_system:
raise HTTPException(status_code=403, detail="System subjects cannot be deleted")
await db.delete(subject)
await db.commit()

View File

@@ -37,6 +37,7 @@ class SubjectOut(BaseModel):
color: str
icon: str
is_active: bool
is_system: bool = False
options: list[SubjectOptionOut] = []
model_config = {"from_attributes": True}

View 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,
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 &bull; 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>