Merge final group into main #3
33
src/app.ts
33
src/app.ts
@@ -2,10 +2,9 @@ import { getUserId } from "./identity";
|
||||
import {
|
||||
addOption,
|
||||
toggleVote,
|
||||
deleteOption,
|
||||
setDeadline,
|
||||
clearDeadline,
|
||||
createViewModel,
|
||||
getDeadline,
|
||||
} from "./state";
|
||||
import { initSync } from "./sync";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
@@ -48,18 +47,13 @@ export function initApp(container: HTMLElement): () => void {
|
||||
|
||||
const actions = {
|
||||
addOption: (label: string) => {
|
||||
const vm = createViewModel(getViewModelParams());
|
||||
if (vm.votingClosed) return;
|
||||
if (isVotingClosed()) return;
|
||||
return addOption(sync.options, label, userId);
|
||||
},
|
||||
toggleVote: (optionId: string) => {
|
||||
const vm = createViewModel(getViewModelParams());
|
||||
if (vm.votingClosed) return;
|
||||
if (isVotingClosed()) return;
|
||||
toggleVote(sync.votes, userId, optionId);
|
||||
},
|
||||
deleteOption: (optionId: string) => {
|
||||
deleteOption(sync.options, sync.votes, optionId);
|
||||
},
|
||||
startDeadline: (durationMs: number) => {
|
||||
setDeadline(sync.deadlineMap, durationMs);
|
||||
},
|
||||
@@ -68,18 +62,10 @@ export function initApp(container: HTMLElement): () => void {
|
||||
},
|
||||
};
|
||||
|
||||
function getViewModelParams() {
|
||||
return {
|
||||
yTitle: sync.yTitle,
|
||||
options: sync.options,
|
||||
votes: sync.votes,
|
||||
deadlineMap: sync.deadlineMap,
|
||||
roomId,
|
||||
shareUrl,
|
||||
connectionStatus: sync.getConnectionStatus(),
|
||||
peerCount: sync.getPeerCount(),
|
||||
userId,
|
||||
};
|
||||
function isVotingClosed() {
|
||||
const deadline = getDeadline(sync.deadlineMap);
|
||||
const votingClosed = deadline !== null && Date.now() >= deadline;
|
||||
return votingClosed;
|
||||
}
|
||||
|
||||
// --- Build UI ---
|
||||
@@ -112,10 +98,7 @@ export function initApp(container: HTMLElement): () => void {
|
||||
if (result && !result.ok) return result.error;
|
||||
return null;
|
||||
});
|
||||
const pollList = PollList(sync.options, sync.votes, userId, () => {
|
||||
const vm = createViewModel(getViewModelParams());
|
||||
return vm.votingClosed;
|
||||
}, actions.toggleVote, actions.deleteOption);
|
||||
const pollList = PollList(sync.options, sync.votes, userId, isVotingClosed, actions.toggleVote);
|
||||
const deadlineTimer = DeadlineTimer(
|
||||
sync.deadlineMap,
|
||||
actions.startDeadline,
|
||||
|
||||
@@ -9,7 +9,6 @@ export function PollList(
|
||||
userId: string,
|
||||
isVotingClosed: () => boolean,
|
||||
onVote: (optionId: string) => void,
|
||||
onDelete: (optionId: string) => void,
|
||||
): HTMLElement {
|
||||
|
||||
var currentOptions : { [x: string]: any; } | undefined = undefined
|
||||
@@ -112,7 +111,6 @@ export function PollList(
|
||||
totalVotes: total,
|
||||
votingClosed,
|
||||
onVote,
|
||||
onDelete,
|
||||
});
|
||||
const currentEl = list.children[i] as HTMLElement | undefined;
|
||||
|
||||
|
||||
@@ -8,11 +8,10 @@ export interface PollOptionProps {
|
||||
totalVotes: number;
|
||||
votingClosed: boolean;
|
||||
onVote: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function PollOption(props: PollOptionProps): HTMLElement {
|
||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote, onDelete } = props;
|
||||
const { id, name, votes, voted, totalVotes, votingClosed, onVote } = props;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = `poll-option${voted ? " poll-option--voted" : ""}`;
|
||||
@@ -30,17 +29,11 @@ export function PollOption(props: PollOptionProps): HTMLElement {
|
||||
<button class="poll-option__vote-btn" aria-pressed="${voted}"${votingClosed ? " disabled" : ""}>
|
||||
${voted ? "Voted" : "Vote"}
|
||||
</button>
|
||||
<button class="poll-option__delete-btn" aria-label="Remove option">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
row.querySelector(".poll-option__vote-btn")!.addEventListener("click", () => onVote(id));
|
||||
row.querySelector(".poll-option__delete-btn")!.addEventListener("click", () => onDelete(id));
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
103
src/state.ts
103
src/state.ts
@@ -11,25 +11,6 @@ export interface OptionRecord {
|
||||
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 {
|
||||
@@ -104,20 +85,6 @@ export function toggleVote(
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -135,73 +102,3 @@ 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