Files
427e7578-d7bf-49c8-aee9-2dd…/js/poll-manager.js
akinwumisehinde fea88b277c feat: complete P2P polling app with vote management and UI improvements
- Add peer-to-peer polling with real-time synchronization via PeerJS
- Implement vote changing, unvoting, and proper vote validation
- Add modal interface for adding poll options
- Fix vote inheritance issues for new users joining rooms
- Create sidebar with available polls and connected peers
- Add vote count synchronization across all connected peers
- Implement proper vote state validation per user
- Add localStorage persistence for polls and vote states
- Create responsive design with modern UI components
- Replace emojis with proper text-based icons
- Add comprehensive error handling and user feedback
- Support multiple polls with switching capabilities
- Implement conflict resolution for concurrent voting
2026-03-09 13:37:31 +01:00

429 lines
12 KiB
JavaScript

class PollManager {
constructor(peerManager) {
this.peerManager = peerManager;
this.currentPoll = null;
this.availablePolls = new Map(); // pollId -> poll
this.myVotes = new Set();
this.onPollUpdated = null;
this.onPollCreated = null;
this.onPollSelected = null;
this.onPollsListUpdated = null;
}
createPoll(question, options) {
const poll = {
id: this.generatePollId(),
question: question.trim(),
options: options.map((text, index) => ({
id: `opt-${index}`,
text: text.trim(),
votes: 0,
voters: []
})),
createdBy: this.peerManager.getPeerId(),
createdAt: Date.now()
};
// Clear any existing votes when creating a new poll
this.myVotes.clear();
this.currentPoll = poll;
this.availablePolls.set(poll.id, poll);
this.saveToLocalStorage();
if (this.onPollCreated) {
this.onPollCreated(poll);
}
if (this.onPollsListUpdated) {
this.onPollsListUpdated(Array.from(this.availablePolls.values()));
}
// Broadcast new poll to all peers
this.broadcastPollUpdate();
return poll;
}
addOption(text) {
if (!this.currentPoll) {
throw new Error('No active poll');
}
const newOption = {
id: `opt-${Date.now()}`,
text: text.trim(),
votes: 0,
voters: []
};
this.currentPoll.options.push(newOption);
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastPollUpdate();
return newOption;
}
vote(optionId) {
if (!this.currentPoll) {
throw new Error('No active poll');
}
const myPeerId = this.peerManager.getPeerId();
// Check if already voted for this option
if (this.myVotes.has(optionId)) {
return false;
}
this.removePreviousVote(myPeerId);
// Add new vote
const option = this.currentPoll.options.find(opt => opt.id === optionId);
if (!option) {
throw new Error('Option not found');
}
option.votes++;
option.voters.push(myPeerId);
this.myVotes.add(optionId);
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastVote(optionId, myPeerId);
return true;
}
removePreviousVote(peerId) {
if (!this.currentPoll) return;
this.currentPoll.options.forEach(option => {
const voterIndex = option.voters.indexOf(peerId);
if (voterIndex !== -1) {
option.votes--;
option.voters.splice(voterIndex, 1);
// Remove from my votes if it's my vote
if (peerId === this.peerManager.getPeerId()) {
this.myVotes.delete(option.id);
}
}
});
}
resetPoll() {
if (!this.currentPoll) {
throw new Error('No active poll');
}
this.currentPoll.options.forEach(option => {
option.votes = 0;
option.voters = [];
});
this.myVotes.clear();
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastPollReset();
}
syncPoll(pollData) {
if (!this.availablePolls.has(pollData.id)) {
this.availablePolls.set(pollData.id, pollData);
this.saveToLocalStorage();
if (this.onPollsListUpdated) {
this.onPollsListUpdated(Array.from(this.availablePolls.values()));
}
}
if (!this.currentPoll || this.currentPoll.id === pollData.id) {
this.currentPoll = pollData;
this.availablePolls.set(pollData.id, pollData);
this.validateMyVotes(pollData);
this.saveToLocalStorage();
if (this.onPollCreated) {
this.onPollCreated(pollData);
}
} else if (this.currentPoll.id === pollData.id) {
this.mergePollData(pollData);
this.availablePolls.set(pollData.id, this.currentPoll);
this.validateMyVotes(this.currentPoll);
this.saveToLocalStorage();
this.notifyPollUpdated();
}
}
mergePollData(remotePoll) {
const localOptions = new Map(this.currentPoll.options.map(opt => [opt.id, opt]));
const remoteOptions = new Map(remotePoll.options.map(opt => [opt.id, opt]));
remoteOptions.forEach((remoteOpt, id) => {
if (!localOptions.has(id)) {
this.currentPoll.options.push(remoteOpt);
localOptions.set(id, remoteOpt);
}
});
this.currentPoll.options.forEach(localOpt => {
const remoteOpt = remoteOptions.get(localOpt.id);
if (remoteOpt) {
const allVoters = new Set([...localOpt.voters, ...remoteOpt.voters]);
localOpt.voters = Array.from(allVoters);
localOpt.votes = localOpt.voters.length;
}
});
}
handleVoteMessage(optionId, voterId) {
if (!this.currentPoll) return;
this.removePreviousVote(voterId);
const option = this.currentPoll.options.find(opt => opt.id === optionId);
if (option && !option.voters.includes(voterId)) {
option.votes++;
option.voters.push(voterId);
// Update my votes if this is my vote
if (voterId === this.peerManager.getPeerId()) {
this.myVotes.add(optionId);
}
this.saveToLocalStorage();
this.notifyPollUpdated();
}
}
handleUnvoteMessage(voterId) {
if (!this.currentPoll) return;
// Remove vote from this voter
this.removePreviousVote(voterId);
// Update my votes if this is my vote
if (voterId === this.peerManager.getPeerId()) {
this.myVotes.clear();
}
this.saveToLocalStorage();
this.notifyPollUpdated();
}
handlePollReset() {
if (!this.currentPoll) return;
this.currentPoll.options.forEach(option => {
option.votes = 0;
option.voters = [];
});
this.myVotes.clear();
this.saveToLocalStorage();
this.notifyPollUpdated();
}
broadcastPollUpdate() {
if (!this.currentPoll) return;
this.peerManager.broadcastMessage({
type: 'poll_update',
poll: this.currentPoll,
senderId: this.peerManager.getPeerId()
});
}
broadcastVote(optionId, voterId) {
this.peerManager.broadcastMessage({
type: 'vote',
optionId: optionId,
voterId: voterId,
senderId: this.peerManager.getPeerId()
});
}
broadcastPollReset() {
this.peerManager.broadcastMessage({
type: 'poll_reset',
senderId: this.peerManager.getPeerId()
});
}
selectPoll(pollId) {
const poll = this.availablePolls.get(pollId);
if (poll) {
this.currentPoll = poll;
// Clear votes when switching to a different poll
// Only clear if this is a different poll than what we had voted in
const currentVotedOption = this.getMyVotedOption();
if (currentVotedOption && !poll.options.some(opt => opt.id === currentVotedOption)) {
this.myVotes.clear();
}
this.saveToLocalStorage();
if (this.onPollSelected) {
this.onPollSelected(poll);
}
if (this.onPollUpdated) {
this.onPollUpdated(poll);
}
return true;
}
return false;
}
createNewPoll() {
this.currentPoll = null;
// Clear current poll inputs but keep available polls
if (this.onPollSelected) {
this.onPollSelected(null);
}
}
getAvailablePolls() {
return Array.from(this.availablePolls.values());
}
unvote() {
if (!this.currentPoll) {
throw new Error('No active poll');
}
const myPeerId = this.peerManager.getPeerId();
const myVotedOption = this.getMyVotedOption();
if (!myVotedOption) {
return false; // No vote to remove
}
// Remove vote from current option
this.removePreviousVote(myPeerId);
this.saveToLocalStorage();
this.notifyPollUpdated();
this.broadcastUnvote(myPeerId);
return true;
}
validateMyVotes(poll) {
const myPeerId = this.peerManager.getPeerId();
const validVotes = new Set();
console.log('Validating votes for peer:', myPeerId);
console.log('Current myVotes:', Array.from(this.myVotes));
console.log('Poll voters:', poll.options.map(opt => ({ id: opt.id, voters: opt.voters })));
// Check each vote in myVotes to see if it's actually mine
this.myVotes.forEach(optionId => {
const option = poll.options.find(opt => opt.id === optionId);
if (option && option.voters.includes(myPeerId)) {
// This vote is actually mine
validVotes.add(optionId);
console.log(`Vote ${optionId} is valid for peer ${myPeerId}`);
} else {
console.log(`Vote ${optionId} is NOT valid for peer ${myPeerId}`);
}
});
// Replace myVotes with only the valid votes
this.myVotes = validVotes;
console.log('Final valid votes:', Array.from(this.myVotes));
}
getCurrentPoll() {
return this.currentPoll;
}
broadcastUnvote(peerId) {
this.peerManager.broadcastMessage({
type: 'unvote',
voterId: peerId,
senderId: this.peerManager.getPeerId()
});
}
hasVoted(optionId) {
return this.myVotes.has(optionId);
}
getMyVotedOption() {
return Array.from(this.myVotes)[0] || null;
}
generatePollId() {
return `poll-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
saveToLocalStorage() {
// Save all available polls
const pollsData = Array.from(this.availablePolls.values());
localStorage.setItem('p2p-polls-list', JSON.stringify(pollsData));
// Save current poll separately
if (this.currentPoll) {
localStorage.setItem('p2p-poll-current', JSON.stringify(this.currentPoll));
} else {
localStorage.removeItem('p2p-poll-current');
}
localStorage.setItem('p2p-poll-my-votes', JSON.stringify(Array.from(this.myVotes)));
}
loadFromLocalStorage() {
try {
const savedPollsList = localStorage.getItem('p2p-polls-list');
const savedVotes = localStorage.getItem('p2p-poll-my-votes');
if (savedPollsList) {
const polls = JSON.parse(savedPollsList);
this.availablePolls.clear();
polls.forEach(poll => {
this.availablePolls.set(poll.id, poll);
});
if (this.onPollsListUpdated) {
this.onPollsListUpdated(polls);
}
}
if (savedVotes) {
this.myVotes = new Set(JSON.parse(savedVotes));
// Validate votes against current poll
const currentPoll = this.currentPoll || this.availablePolls.values().next().value;
if (currentPoll) {
this.validateMyVotes(currentPoll);
}
}
} catch (error) {
console.error('Failed to load from localStorage:', error);
}
}
clearData() {
this.currentPoll = null;
this.availablePolls.clear();
this.myVotes.clear();
localStorage.removeItem('p2p-poll-current');
localStorage.removeItem('p2p-polls-list');
localStorage.removeItem('p2p-poll-my-votes');
}
notifyPollUpdated() {
if (this.onPollUpdated) {
this.onPollUpdated(this.currentPoll);
}
}
}