253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
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<string, unknown>): RtdbQuizPollSettings {
|
|
const s = raw.settings;
|
|
if (!s || typeof s !== "object" || Array.isArray(s)) return {};
|
|
const o = s as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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<string, Record<string, unknown>>;
|
|
return Object.entries(data)
|
|
.map(([key, raw]) => {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const v = raw as Record<string, unknown>;
|
|
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<string, unknown>;
|
|
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)));
|
|
},
|
|
);
|
|
}
|