import { ref, onValue, type Unsubscribe } from "firebase/database"; import { getRtdb } from "./firebaseClient"; export type RtdbQuizPollSettings = { isLive?: boolean; startTime?: string; endTime?: string; /** always_live | always_sched | manual | quiz_master */ displayMode?: string; /** draft | active */ status?: string; timeRestricted?: boolean; /** Seconds for timed mode */ timeLimit?: number; showResults?: boolean; }; /** Normalize status from RTDB; legacy nodes without status are treated as active. */ function settingsStatusActive(settings: RtdbQuizPollSettings): boolean { const raw = settings.status?.trim().toLowerCase(); if (raw == null || raw === "") return true; return raw === "active"; } export function settingsDisplayMode(settings: RtdbQuizPollSettings): string { const m = settings.displayMode?.trim().toLowerCase(); if (!m) return "manual"; return m; } /** * First visible quiz/poll that is audience-controlled via RTDB (`quiz_master` / `manual` while live). * `visibleQuizPolls` should be the output of {@link filterVisibleQuizPolls}. */ export function firstQuizMasterOrManualVisibleId( visibleQuizPolls: RtdbQuizPollListItem[], ): string | null { for (const item of visibleQuizPolls) { const mode = settingsDisplayMode(item.settings); if (mode === "quiz_master" || mode === "manual") { return item._id; } } return null; } /** * Session list & take-page visibility (aligned with dashboard display modes). * * - always_sched: status active, now in [startTime, endTime] (inclusive) * - always_live: status active * - quiz_master: status active and isLive * - manual: status active and isLive */ export function isQuizPollVisibleForAudience(settings: RtdbQuizPollSettings, now: Date = new Date()): boolean { if (!settingsStatusActive(settings)) return false; const mode = settingsDisplayMode(settings); const isLive = Boolean(settings.isLive); if (mode === "always_live") return true; if (mode === "always_sched") { const start = settings.startTime != null ? Date.parse(settings.startTime) : NaN; const end = settings.endTime != null ? Date.parse(settings.endTime) : NaN; const t = now.getTime(); if (!Number.isFinite(start) || !Number.isFinite(end)) return false; return t >= start && t <= end; } if (mode === "quiz_master" || mode === "manual") { return isLive; } return false; } export type RtdbQuizPollOption = { _id: string; text: string; isCorrect: boolean; }; export type RtdbQuizPollQuestion = { _id: string; question: string; type: string; options: RtdbQuizPollOption[]; points?: number; }; export type RtdbQuizPollListItem = { _id: string; title: string; type: string; isLive: boolean; settings: RtdbQuizPollSettings; }; export type RtdbQuizPollDetail = RtdbQuizPollListItem & { questions: RtdbQuizPollQuestion[]; }; function readSettings(raw: Record): RtdbQuizPollSettings { const s = raw.settings; if (!s || typeof s !== "object" || Array.isArray(s)) return {}; const o = s as Record; const isLive = typeof o.isLive === "boolean" ? o.isLive : typeof o.isActive === "boolean" ? o.isActive : false; const startTime = o.startTime != null ? String(o.startTime) : undefined; const endTime = o.endTime != null ? String(o.endTime) : undefined; const displayMode = o.displayMode != null ? String(o.displayMode).trim() : undefined; const status = o.status != null ? String(o.status).trim() : undefined; const timeRestricted = o.timeRestricted === true; const timeLimitRaw = o.timeLimit; const timeLimit = typeof timeLimitRaw === "number" && Number.isFinite(timeLimitRaw) ? Math.max(0, Math.floor(timeLimitRaw)) : 0; const showResults = o.showResults !== false; return { isLive, startTime, endTime, displayMode, status, timeRestricted, timeLimit, showResults }; } function mapOptions(raw: unknown): RtdbQuizPollOption[] { if (!Array.isArray(raw)) return []; return raw.map((opt, j) => { if (!opt || typeof opt !== "object") { return { _id: `opt-${j}`, text: "", isCorrect: false }; } const o = opt as Record; const id = o._id != null && String(o._id).trim() !== "" ? String(o._id).trim() : `opt-${j}`; return { _id: id, text: o.text != null ? String(o.text) : "", isCorrect: Boolean(o.isCorrect), }; }); } function mapQuestions(raw: unknown): RtdbQuizPollQuestion[] { if (!Array.isArray(raw)) return []; return raw.map((q, i) => { if (!q || typeof q !== "object") { return { _id: `q-${i}`, question: "", type: "single", options: [] }; } const o = q as Record; const id = o._id != null && String(o._id).trim() !== "" ? String(o._id).trim() : `q-${i}`; const qt = o.type != null ? String(o.type) : "single"; return { _id: id, question: o.question != null ? String(o.question) : "", type: qt, options: mapOptions(o.options), points: typeof o.points === "number" ? o.points : undefined, }; }); } export function rtdbSnapshotToQuizPolls(val: unknown): RtdbQuizPollListItem[] { if (!val || typeof val !== "object" || Array.isArray(val)) return []; const data = val as Record>; return Object.entries(data) .map(([key, raw]) => { if (!raw || typeof raw !== "object") return null; const v = raw as Record; const settings = readSettings(v); const fromField = v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : ""; const id = fromField || key; return { _id: id, title: v.title != null ? String(v.title) : "", type: v.type != null ? String(v.type) : "poll", isLive: Boolean(settings.isLive), settings, } satisfies RtdbQuizPollListItem; }) .filter((x): x is RtdbQuizPollListItem => x != null) .sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" })); } export function filterVisibleQuizPolls( items: RtdbQuizPollListItem[], now: Date = new Date(), ): RtdbQuizPollListItem[] { return items.filter((x) => isQuizPollVisibleForAudience(x.settings, now)); } export function rtdbValToQuizPollDetail(val: unknown, fallbackId: string): RtdbQuizPollDetail | null { if (!val || typeof val !== "object" || Array.isArray(val)) return null; const v = val as Record; const settings = readSettings(v); const fromField = v._id != null && String(v._id).trim() !== "" ? String(v._id).trim() : ""; const id = fromField || fallbackId; return { _id: id, title: v.title != null ? String(v.title) : "", type: v.type != null ? String(v.type) : "poll", isLive: Boolean(settings.isLive), settings, questions: mapQuestions(v.questions), }; } export function subscribeSessionQuizPolls( sessionId: string, onData: (items: RtdbQuizPollListItem[]) => void, onError?: (e: Error) => void, ): Unsubscribe { const db = getRtdb(); const r = ref(db, `sessions/${sessionId}/quizPolls`); return onValue( r, (snap) => { try { onData(rtdbSnapshotToQuizPolls(snap.val())); } catch (e) { onError?.(e instanceof Error ? e : new Error(String(e))); } }, (err) => { onError?.(err instanceof Error ? err : new Error(String(err))); }, ); } export function subscribeSessionQuizPollDetail( sessionId: string, quizPollId: string, onData: (detail: RtdbQuizPollDetail | null) => void, onError?: (e: Error) => void, ): Unsubscribe { const db = getRtdb(); const r = ref(db, `sessions/${sessionId}/quizPolls/${quizPollId}`); return onValue( r, (snap) => { try { const d = rtdbValToQuizPollDetail(snap.val(), quizPollId); onData(d); } catch (e) { onError?.(e instanceof Error ? e : new Error(String(e))); } }, (err) => { onError?.(err instanceof Error ? err : new Error(String(err))); }, ); }