feat: add combined codebase
This commit is contained in:
207
src/state.ts
Normal file
207
src/state.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user