import * as Y from "yjs"; // --- Types --- export type ConnectionStatus = "connecting" | "connected" | "offline"; export interface OptionRecord { id: string; label: string; createdAt: number; createdBy: string; } export interface PollOptionViewModel extends OptionRecord { voteCount: number; isVotedByMe: boolean; percentage: number; } export interface PollViewModel { title: string; roomId: string; shareUrl: string; connectionStatus: ConnectionStatus; peerCount: number; options: PollOptionViewModel[]; totalVotes: number; myVoteOptionId: string | null; deadline: number | null; votingClosed: boolean; } // --- Helpers --- export function createOptionId(): string { if (typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `option-${Math.random().toString(36).slice(2, 10)}`; } function normalizeLabel(label: string): string { return label.trim().replace(/\s+/g, " "); } export function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } // --- Title --- export function getPollTitle(yTitle: Y.Text): string { const title = yTitle.toString(); return title || "Untitled Poll"; } // --- Options --- export function addOption( options: Y.Map, rawLabel: string, userId: string, ): { ok: true; optionId: string } | { ok: false; error: string } { const label = normalizeLabel(rawLabel); if (!label) { return { ok: false, error: "Option cannot be empty." }; } const normalizedTarget = label.toLocaleLowerCase(); const duplicate = Array.from(options.values()).some( (option) => option.label.trim().toLocaleLowerCase() === normalizedTarget, ); if (duplicate) { return { ok: false, error: "That option already exists." }; } const option: OptionRecord = { id: createOptionId(), label, createdAt: Date.now(), createdBy: userId, }; options.set(option.id, option); return { ok: true, optionId: option.id }; } export function toggleVote( votes: Y.Map, userId: string, optionId: string, ): void { const current = votes.get(userId); if (current === optionId) { votes.delete(userId); } else { votes.set(userId, optionId); } } export function deleteOption( options: Y.Map, votes: Y.Map, optionId: string, ): void { options.delete(optionId); // Clean up votes pointing to this option for (const [userId, votedOptionId] of votes.entries()) { if (votedOptionId === optionId) { votes.delete(userId); } } } // --- Deadline --- export function setDeadline( deadlineMap: Y.Map, durationMs: number, ): void { deadlineMap.set("deadline", Date.now() + durationMs); } export function clearDeadline(deadlineMap: Y.Map): void { deadlineMap.delete("deadline"); } export function getDeadline(deadlineMap: Y.Map): number | null { const val = deadlineMap.get("deadline"); return typeof val === "number" ? val : null; } // --- ViewModel --- export function createViewModel(params: { yTitle: Y.Text; options: Y.Map; votes: Y.Map; deadlineMap: Y.Map; roomId: string; shareUrl: string; connectionStatus: ConnectionStatus; peerCount: number; userId: string; }): PollViewModel { const { yTitle, options, votes, deadlineMap, roomId, shareUrl, connectionStatus, peerCount, userId, } = params; // Tally votes per option const tally = new Map(); for (const optionId of votes.values()) { tally.set(optionId, (tally.get(optionId) ?? 0) + 1); } let totalVotes = 0; for (const count of tally.values()) { totalVotes += count; } const myVoteOptionId = votes.get(userId) ?? null; const deadline = getDeadline(deadlineMap); const votingClosed = deadline !== null && Date.now() >= deadline; // Sort by votes desc, then alphabetically const sortedOptions = Array.from(options.values()).sort((a, b) => { const aVotes = tally.get(a.id) ?? 0; const bVotes = tally.get(b.id) ?? 0; if (bVotes !== aVotes) return bVotes - aVotes; return a.label.localeCompare(b.label); }); return { title: getPollTitle(yTitle), roomId, shareUrl, connectionStatus, peerCount, myVoteOptionId, totalVotes, deadline, votingClosed, options: sortedOptions.map((option) => ({ ...option, voteCount: tally.get(option.id) ?? 0, isVotedByMe: myVoteOptionId === option.id, percentage: totalVotes > 0 ? Math.round(((tally.get(option.id) ?? 0) / totalVotes) * 100) : 0, })), }; }