208 lines
4.7 KiB
TypeScript
208 lines
4.7 KiB
TypeScript
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<OptionRecord>,
|
|
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<string>,
|
|
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<OptionRecord>,
|
|
votes: Y.Map<string>,
|
|
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<unknown>,
|
|
durationMs: number,
|
|
): void {
|
|
deadlineMap.set("deadline", Date.now() + durationMs);
|
|
}
|
|
|
|
export function clearDeadline(deadlineMap: Y.Map<unknown>): void {
|
|
deadlineMap.delete("deadline");
|
|
}
|
|
|
|
export function getDeadline(deadlineMap: Y.Map<unknown>): number | null {
|
|
const val = deadlineMap.get("deadline");
|
|
return typeof val === "number" ? val : null;
|
|
}
|
|
|
|
// --- ViewModel ---
|
|
|
|
export function createViewModel(params: {
|
|
yTitle: Y.Text;
|
|
options: Y.Map<OptionRecord>;
|
|
votes: Y.Map<string>;
|
|
deadlineMap: Y.Map<unknown>;
|
|
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<string, number>();
|
|
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,
|
|
})),
|
|
};
|
|
}
|