diff --git a/README.md b/README.md index eaccbfb..1890612 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/app/main.py b/backend/app/main.py index e414d8e..25bdc4f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index d8fb914..f9aed18 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -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 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 510bbf1..5a6f773 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/routers/subjects.py b/backend/app/routers/subjects.py index 264f8b6..b3bdbe8 100644 --- a/backend/app/routers/subjects.py +++ b/backend/app/routers/subjects.py @@ -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() diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index d7ef68b..bb2e730 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -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} diff --git a/frontend/src/composables/useMeetingAlerts.js b/frontend/src/composables/useMeetingAlerts.js new file mode 100644 index 0000000..ea9647e --- /dev/null +++ b/frontend/src/composables/useMeetingAlerts.js @@ -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, + } +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 82c373e..d27e4fa 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -65,7 +65,7 @@
@@ -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 { diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index fec0a03..0f02db1 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -189,6 +189,23 @@ + + + +
+ +
+
📅
+
+
Upcoming Meeting
+
{{ alert.label }}
+
Starts in {{ meetingAlerts.alertCountdown(alert) }}
+
+ +
+
+
+
@@ -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%); } diff --git a/frontend/src/views/TVView.vue b/frontend/src/views/TVView.vue index f93e038..40ec065 100644 --- a/frontend/src/views/TVView.vue +++ b/frontend/src/views/TVView.vue @@ -136,6 +136,33 @@
{{ wsConnected ? '● Live' : '○ Reconnecting...' }}
+ + +
+ +
+
📅
+
+
Upcoming Meeting
+
{{ alert.label }}
+
Starts in {{ meetingAlerts.alertCountdown(alert) }}
+
+ +
+
+
+ + + +
+
+
📅
+
Meeting Starting Now
+
{{ meetingAlerts.tvAlert.value.label }}
+
Tap anywhere to dismiss • Auto-closing in {{ meetingAlerts.tvCountdown.value }}s
+
+
+
@@ -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); }