let pollId, isAuthenticated;
let selectedDates = new Set();
let selectedTimes = new Set(); // minutes from midnight (0, 30, 60, 90, ...)
let currentMonth, currentYear;
let lastClickedDate = null, lastClickedTime = null;
let previewDates = new Set(), previewTimes = new Set();
let previewWillRemove = false, timePreviewWillRemove = false;
let pollData = null;
let hasJoined = false;
let isCreator = false;
let isLocked = false;
let chosenSlots = [];
let allowedDates = new Set();
let isDayWise = false;
let hoveredPane = null; // 'calendar' or 'time'
let viewMode = false; // true = show all availability, false = show only yours
let attendeesMode = 'available'; // 'available' or 'unavailable'
let sortColumn = 'available'; // 'date' or 'available'
let sortDirection = 'desc'; // 'asc' or 'desc'
let hasUserSorted = false; // Track if user has clicked to sort
let allAvailability = { dates: new Map(), times: new Map() }; // Maps date/time -> count
let myAvailability = { dates: new Set(), times: new Set() };
let maxDateCount = 0;
let maxTimeCount = 0;
let bestDates = new Set();
let bestTimes = new Set();
let highlightedTime = null; // Time slot highlighted from table click
let guestName = null; // Stored when guest joins
let filteredAttendees = new Set(); // For attendee filtering
let editDates = new Set(); // For edit modal calendar
let editMonth, editYear; // For edit modal calendar navigation
let editLastClickedDate = null;
let editPreviewDates = new Set();
let editPreviewWillRemove = false;
function initPoll(id, auth) {
pollId = id;
isAuthenticated = auth;
const now = new Date();
currentMonth = now.getMonth();
currentYear = now.getFullYear();
document.getElementById('prev-month').onclick = () => { currentMonth--; if (currentMonth < 0) { currentMonth = 11; currentYear--; } renderCalendar(); };
document.getElementById('next-month').onclick = () => { currentMonth++; if (currentMonth > 11) { currentMonth = 0; currentYear++; } renderCalendar(); };
document.getElementById('submit-btn').onclick = submitResponse;
document.getElementById('reset-days-btn').onclick = () => { selectedDates.clear(); lastClickedDate = null; highlightedTime = null; recalculateFilteredHeatMap(); };
document.getElementById('reset-time-btn').onclick = () => { selectedTimes.clear(); lastClickedTime = null; renderTimeGrid(); };
document.getElementById('edit-btn').onclick = openEditModal;
document.getElementById('lock-btn').onclick = toggleLock;
document.getElementById('delete-btn').onclick = deletePoll;
document.getElementById('join-btn').onclick = handleJoinClick;
document.getElementById('view-mode-toggle').checked = false;
// Modal handlers
const modal = document.getElementById('guest-modal');
if (modal) {
const closeModal = () => {
modal.style.display = 'none';
document.getElementById('guest-modal-error').textContent = '';
};
document.getElementById('guest-modal-close').onclick = closeModal;
document.getElementById('guest-modal-join').onclick = submitGuestJoin;
modal.onclick = (e) => { if (e.target === modal) closeModal(); };
}
// Edit modal handlers
const editModal = document.getElementById('edit-modal');
if (editModal) {
const closeEditModal = () => {
editModal.style.display = 'none';
document.getElementById('edit-modal-error').textContent = '';
};
document.getElementById('edit-modal-close').onclick = closeEditModal;
document.getElementById('edit-modal-save').onclick = saveEditedPoll;
editModal.onclick = (e) => { if (e.target === editModal) closeEditModal(); };
document.getElementById('edit-prev-month').onclick = () => { editMonth--; if (editMonth < 0) { editMonth = 11; editYear--; } renderEditCalendar(); };
document.getElementById('edit-next-month').onclick = () => { editMonth++; if (editMonth > 11) { editMonth = 0; editYear++; } renderEditCalendar(); };
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (modal && modal.style.display !== 'none') { modal.style.display = 'none'; document.getElementById('guest-modal-error').textContent = ''; }
if (editModal && editModal.style.display !== 'none') { editModal.style.display = 'none'; document.getElementById('edit-modal-error').textContent = ''; }
}
});
document.getElementById('view-mode-toggle').onclick = (e) => { viewMode = e.target.checked; recalculateFilteredHeatMap(); };
// Attendees mode toggle handlers
document.getElementById('attendees-mode-available').onclick = () => {
if (attendeesMode !== 'available') {
attendeesMode = 'available';
document.getElementById('attendees-mode-available').classList.add('active');
document.getElementById('attendees-mode-unavailable').classList.remove('active');
renderResults(pollData.responses);
}
};
document.getElementById('attendees-mode-unavailable').onclick = () => {
if (attendeesMode !== 'unavailable') {
attendeesMode = 'unavailable';
document.getElementById('attendees-mode-available').classList.remove('active');
document.getElementById('attendees-mode-unavailable').classList.add('active');
renderResults(pollData.responses);
}
};
// Global shift key tracking for immediate preview
document.addEventListener('keydown', (e) => {
if (e.key === 'Shift') updatePreviewsOnShift();
if (e.key === 'c' || e.key === 'C') {
if (e.shiftKey) {
selectedDates.clear(); lastClickedDate = null; highlightedTime = null;
selectedTimes.clear(); lastClickedTime = null;
recalculateFilteredHeatMap();
} else if (hoveredPane === 'calendar') {
selectedDates.clear(); lastClickedDate = null; highlightedTime = null; recalculateFilteredHeatMap();
} else if (hoveredPane === 'time') {
selectedTimes.clear(); lastClickedTime = null; renderTimeGrid();
}
}
});
document.addEventListener('keyup', (e) => {
if (e.key === 'Shift') {
previewDates.clear();
previewTimes.clear();
rangeSelectMode = null;
timeRangeMode = null;
renderCalendar();
renderTimeGrid();
}
});
// Track hovered pane
document.querySelector('.calendar-panel').onmouseenter = () => hoveredPane = 'calendar';
document.querySelector('.calendar-panel').onmouseleave = () => hoveredPane = null;
document.querySelector('.time-panel').onmouseenter = () => hoveredPane = 'time';
document.querySelector('.time-panel').onmouseleave = () => hoveredPane = null;
loadPollData();
}
function updatePreviewsOnShift() {
// Immediately show preview when shift is pressed
const hoveredDay = document.querySelector('.calendar .day:hover:not(.other-month):not(.day-header)');
if (hoveredDay && lastClickedDate) {
showDatePreview(hoveredDay.dataset.date);
}
const hoveredTime = document.querySelector('.time-slot:hover');
if (hoveredTime && lastClickedTime !== null) {
showTimePreview(parseInt(hoveredTime.dataset.time));
}
}
function showStatus(message, type = 'success') {
// Status messages removed from UI
}
function handleJoinClick() {
if (isAuthenticated) {
joinPoll({});
} else {
document.getElementById('guest-modal').style.display = 'flex';
document.getElementById('guest-display-name').focus();
}
}
async function submitGuestJoin() {
const name = document.getElementById('guest-display-name').value.trim();
if (!name) {
document.getElementById('guest-modal-error').textContent = 'Please enter your name';
return;
}
await joinPoll({ name });
}
async function joinPoll(body) {
const res = await fetch(`/api/poll/${pollId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (res.ok) {
if (body.name) guestName = body.name;
document.getElementById('guest-modal').style.display = 'none';
document.getElementById('guest-modal-error').textContent = '';
loadPollData();
} else {
document.getElementById('guest-modal-error').textContent = data.error || 'Failed to join';
}
}
function renderParticipants() {
const list = document.getElementById('participants-list');
if (!pollData.participants || pollData.participants.length === 0) {
list.innerHTML = 'No one has joined yet';
return;
}
list.innerHTML = pollData.participants.map(p =>
`${p.name}`
).join('');
// Add click handlers for filtering
list.querySelectorAll('.participant[data-name]').forEach(el => {
el.onclick = () => toggleAttendeeFilter(el.dataset.name);
});
}
function updateJoinButton() {
const joinSection = document.getElementById('join-section');
const submitBtn = document.getElementById('submit-btn');
const lockedNotice = document.getElementById('locked-notice');
const hint = document.querySelector('.calendar-panel .hint');
const hintText = document.querySelector('.calendar-panel .hint-text');
// For anonymous users, guestName indicates they've joined
const userHasJoined = isCreator || hasJoined || (!isAuthenticated && guestName);
// Show locked notice for non-creators when locked
if (lockedNotice) {
lockedNotice.style.display = (isLocked && !isCreator) ? 'block' : 'none';
}
if (isLocked && !isCreator) {
joinSection.style.display = 'none';
submitBtn.disabled = true;
if (hintText) hintText.textContent = 'Poll is locked';
if (hint) hint.classList.add('hint-centered');
} else if (userHasJoined) {
joinSection.style.display = 'none';
submitBtn.disabled = false;
if (hintText) hintText.textContent = 'Click to toggle, Shift+click for range';
if (hint) hint.classList.remove('hint-centered');
} else {
joinSection.style.display = 'flex';
submitBtn.disabled = true;
if (hintText) hintText.textContent = 'Join the poll first!';
if (hint) hint.classList.add('hint-centered');
}
}
function renderCalendar() {
const cal = document.getElementById('calendar');
const firstDay = new Date(currentYear, currentMonth, 1);
const lastDay = new Date(currentYear, currentMonth + 1, 0);
const startDay = firstDay.getDay();
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
document.getElementById('month-year').textContent = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
let html = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => `
`).join('');
const canSelect = isCreator || hasJoined || (!isAuthenticated && guestName);
const prevLastDay = new Date(currentYear, currentMonth, 0).getDate();
const prevMonth = currentMonth === 0 ? 12 : currentMonth;
const prevYear = currentMonth === 0 ? currentYear - 1 : currentYear;
for (let i = startDay - 1; i >= 0; i--) {
const d = prevLastDay - i;
const dateStr = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const dateAllowed = allowedDates.size === 0 || allowedDates.has(dateStr);
const notAllowed = !dateAllowed ? 'not-allowed' : '';
const disabled = (!canSelect || !dateAllowed) ? 'disabled' : '';
const selected = selectedDates.has(dateStr) ? 'selected' : '';
const preview = previewDates.has(dateStr) ? (previewWillRemove ? 'preview-remove' : 'preview') : '';
let heatClass = '';
if (viewMode && maxDateCount > 0) {
const count = allAvailability.dates.get(dateStr) || 0;
if (count > 0) heatClass = `heat-${Math.ceil((count / maxDateCount) * 4)}`;
} else if (!viewMode && myAvailability.dates.has(dateStr)) {
heatClass = 'heat-4';
}
html += `${d}
`;
}
for (let d = 1; d <= lastDay.getDate(); d++) {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const selected = selectedDates.has(dateStr) ? 'selected' : '';
const isToday = dateStr === todayStr ? 'today' : '';
const preview = previewDates.has(dateStr) ? (previewWillRemove ? 'preview-remove' : 'preview') : '';
// Heat map intensity
let heatClass = '';
if (viewMode && maxDateCount > 0) {
const count = allAvailability.dates.get(dateStr) || 0;
if (count > 0) {
const intensity = Math.ceil((count / maxDateCount) * 4);
heatClass = `heat-${intensity}`;
}
} else if (!viewMode && myAvailability.dates.has(dateStr)) {
heatClass = 'heat-4';
}
// Check if date is allowed by poll settings
const dateAllowed = allowedDates.size === 0 || allowedDates.has(dateStr);
const notAllowed = !dateAllowed ? 'not-allowed' : '';
const disabled = (!canSelect || !dateAllowed) ? 'disabled' : '';
const best = bestDates.has(dateStr) ? 'best-slot' : '';
html += `${d}
`;
}
const remaining = 42 - (startDay + lastDay.getDate());
const nextMonth = currentMonth === 11 ? 1 : currentMonth + 2;
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
for (let i = 1; i <= remaining && remaining < 7; i++) {
const dateStr = `${nextYear}-${String(nextMonth).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
const dateAllowed = allowedDates.size === 0 || allowedDates.has(dateStr);
const notAllowed = !dateAllowed ? 'not-allowed' : '';
const disabled = (!canSelect || !dateAllowed) ? 'disabled' : '';
const selected = selectedDates.has(dateStr) ? 'selected' : '';
const preview = previewDates.has(dateStr) ? (previewWillRemove ? 'preview-remove' : 'preview') : '';
let heatClass = '';
if (viewMode && maxDateCount > 0) {
const count = allAvailability.dates.get(dateStr) || 0;
if (count > 0) heatClass = `heat-${Math.ceil((count / maxDateCount) * 4)}`;
} else if (!viewMode && myAvailability.dates.has(dateStr)) {
heatClass = 'heat-4';
}
html += `${i}
`;
}
cal.innerHTML = html;
cal.querySelectorAll('.day:not(.day-header):not(.disabled)').forEach(day => {
day.onclick = (e) => handleDateClick(day.dataset.date, e.shiftKey, e.ctrlKey || e.metaKey);
day.onmouseenter = (e) => { if (e.shiftKey && lastClickedDate) showDatePreview(day.dataset.date); };
});
cal.onmouseleave = () => { previewDates.clear(); renderCalendar(); };
}
function handleDateClick(date, shiftKey, ctrlKey) {
highlightedTime = null;
if (isDayWise) {
// Day-wise poll: inverted behavior for easier multi-day selection
if (shiftKey && lastClickedDate) {
// Range selection
const range = getDateRange(lastClickedDate, date);
const shouldSelect = !selectedDates.has(date);
range.forEach(d => {
if (isCreator || allowedDates.size === 0 || allowedDates.has(d)) {
shouldSelect ? selectedDates.add(d) : selectedDates.delete(d);
}
});
} else if (ctrlKey) {
// Ctrl+click: select ONLY this day (clear others)
selectedDates.clear();
selectedDates.add(date);
} else {
// Normal click: toggle this day (keep others)
const wasSelected = selectedDates.has(date);
if (wasSelected) selectedDates.delete(date);
else selectedDates.add(date);
}
} else {
// Time-based poll: original behavior
if (shiftKey && lastClickedDate) {
const range = getDateRange(lastClickedDate, date);
const shouldSelect = !selectedDates.has(date);
range.forEach(d => {
if (isCreator || allowedDates.size === 0 || allowedDates.has(d)) {
shouldSelect ? selectedDates.add(d) : selectedDates.delete(d);
}
});
// Reset times only if no days selected
if (selectedDates.size === 0) {
selectedTimes.clear();
lastClickedTime = null;
}
} else if (ctrlKey) {
// Ctrl+click: toggle without clearing
const wasSelected = selectedDates.has(date);
if (wasSelected) selectedDates.delete(date);
else selectedDates.add(date);
// Reset times only if no days selected
if (selectedDates.size === 0) {
selectedTimes.clear();
lastClickedTime = null;
}
} else {
// Normal click: select only this day, reset times
selectedDates.clear();
selectedDates.add(date);
selectedTimes.clear();
lastClickedTime = null;
}
}
lastClickedDate = date;
previewDates.clear();
recalculateFilteredHeatMap();
}
function showDatePreview(endDate) {
previewDates.clear();
// Determine if action will add or remove based on hovered day's state
previewWillRemove = selectedDates.has(endDate);
if (lastClickedDate) {
getDateRange(lastClickedDate, endDate).forEach(d => {
if (isCreator || allowedDates.size === 0 || allowedDates.has(d)) {
previewDates.add(d);
}
});
}
renderCalendar();
}
function getDateRange(start, end) {
const dates = [];
let d1 = new Date(start), d2 = new Date(end);
if (d1 > d2) [d1, d2] = [d2, d1];
while (d1 <= d2) {
dates.push(d1.toISOString().split('T')[0]);
d1.setDate(d1.getDate() + 1);
}
return dates;
}
function formatTime(minutes) {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function renderTimeGrid() {
const grid = document.getElementById('time-grid');
const canSelect = isCreator || hasJoined || (!isAuthenticated && guestName);
let html = '';
// All times are allowed for any selected date
const allTimesAllowed = selectedDates.size > 0;
// 48 slots of 30 minutes each
for (let mins = 0; mins < 1440; mins += 30) {
const label = `${formatTime(mins)} - ${formatTime(mins + 30)}`;
const selected = selectedTimes.has(mins) ? 'selected' : '';
const preview = previewTimes.has(mins) ? (timePreviewWillRemove ? 'preview-remove' : 'preview') : '';
const disabled = (!canSelect || !allTimesAllowed) ? 'disabled' : '';
// Heat map intensity
let heatClass = '';
if (viewMode && maxTimeCount > 0) {
const count = allAvailability.times.get(mins) || 0;
if (count > 0) {
const intensity = Math.ceil((count / maxTimeCount) * 4);
heatClass = `heat-${intensity}`;
}
} else if (!viewMode && myAvailability.times.has(mins)) {
heatClass = 'heat-4';
}
const best = bestTimes.has(mins) ? 'best-slot' : '';
const arrow = highlightedTime === mins ? '' : '';
html += `${arrow}${label}
`;
}
grid.innerHTML = html;
grid.querySelectorAll('.time-slot:not(.disabled)').forEach(slot => {
const t = parseInt(slot.dataset.time);
slot.onclick = (e) => handleTimeClick(t, e.shiftKey);
slot.onmouseenter = (e) => { if (e.shiftKey && lastClickedTime !== null) showTimePreview(t); };
});
grid.onmouseleave = () => { previewTimes.clear(); renderTimeGrid(); };
}
function handleTimeClick(time, shiftKey) {
if (shiftKey && lastClickedTime !== null) {
const range = getTimeRange(lastClickedTime, time);
// Use hovered time's state to determine action
const shouldSelect = !selectedTimes.has(time);
range.forEach(t => shouldSelect ? selectedTimes.add(t) : selectedTimes.delete(t));
} else {
const wasSelected = selectedTimes.has(time);
if (wasSelected) selectedTimes.delete(time);
else selectedTimes.add(time);
}
lastClickedTime = time;
previewTimes.clear();
renderTimeGrid();
}
function showTimePreview(endTime) {
previewTimes.clear();
// Determine if action will add or remove based on hovered time's state
timePreviewWillRemove = selectedTimes.has(endTime);
if (lastClickedTime !== null) {
getTimeRange(lastClickedTime, endTime).forEach(t => previewTimes.add(t));
}
renderTimeGrid();
}
function getTimeRange(start, end) {
const times = [];
let [t1, t2] = start < end ? [start, end] : [end, start];
for (let t = t1; t <= t2; t += 30) times.push(t);
return times;
}
async function loadPollData() {
const res = await fetch(`/api/poll/${pollId}`);
const data = await res.json();
processPollData(data);
const overlay = document.getElementById('loading-overlay');
overlay.classList.add('fade-out');
setTimeout(() => overlay.remove(), 300);
}
function processPollData(data) {
pollData = data;
hasJoined = pollData.has_joined;
isCreator = pollData.is_creator;
isLocked = pollData.is_locked;
chosenSlots = pollData.chosen_slots || [];
if (pollData.guest_name) guestName = pollData.guest_name;
// Update admin section
const adminSection = document.getElementById('admin-section');
const lockBtn = document.getElementById('lock-btn');
if (isCreator) {
adminSection.style.display = 'flex';
lockBtn.textContent = isLocked ? '🔓 Unlock' : '🔒 Lock';
} else {
adminSection.style.display = 'none';
}
// Get allowed dates and day-wise mode from poll settings
allowedDates = new Set(pollData.allowed_dates || []);
isDayWise = pollData.is_day_wise || false;
// Adjust layout for day-wise polls
const pollContent = document.querySelector('.poll-content');
const timePanel = document.querySelector('.time-panel');
if (pollContent) {
pollContent.classList.toggle('day-wise', isDayWise);
}
if (timePanel) {
timePanel.style.display = isDayWise ? 'none' : '';
}
// Update hint text based on poll type
const hintText = document.getElementById('hint-text');
const hintHelp = document.getElementById('hint-help');
if (isDayWise) {
if (hintText) hintText.textContent = 'Click to toggle days';
if (hintHelp) hintHelp.setAttribute('title',
'Click: Toggle this day\nCtrl+click: Select only this day\nShift+click: Select range\nC: Clear hovered panel\nShift+C: Clear all');
} else {
if (hintText) hintText.textContent = 'Ctrl+click to toggle multiple';
if (hintHelp) hintHelp.setAttribute('title',
'Click: Select this day\nCtrl+click: Toggle without clearing\nShift+click: Select range\nC: Clear hovered panel\nShift+C: Clear all');
}
// Update table header and notes pane for day-wise polls
const slotHeader = document.querySelector('#results-table thead th:first-child');
if (slotHeader) {
slotHeader.innerHTML = ``;
slotHeader.style.cursor = 'pointer';
slotHeader.dataset.sort = 'date';
}
const availableHeader = document.querySelector('#results-table thead th:nth-child(2)');
if (availableHeader) {
availableHeader.innerHTML = '';
availableHeader.style.cursor = 'pointer';
availableHeader.dataset.sort = 'available';
}
// Add click handlers to entire th elements for sortable headers
document.querySelectorAll('#results-table thead th[data-sort]').forEach(th => {
th.onclick = () => {
const newSortColumn = th.dataset.sort;
hasUserSorted = true;
if (sortColumn === newSortColumn) {
// Toggle direction
sortDirection = sortDirection === 'desc' ? 'asc' : 'desc';
} else {
// New column - default to desc for available, asc for date
sortColumn = newSortColumn;
sortDirection = newSortColumn === 'available' ? 'desc' : 'asc';
}
updateSortIndicators();
renderResults(pollData.responses);
};
});
const notesPane = document.getElementById('notes-pane');
if (notesPane && notesPane.querySelector('.notes-pane-empty')) {
notesPane.innerHTML = `Click a ${isDayWise ? 'date' : 'time slot'} to view notes`;
}
// Update navbar with guest name
if (guestName && !isAuthenticated) {
const navRight = document.querySelector('.nav-right');
if (navRight && !document.getElementById('guest-name-display')) {
const span = document.createElement('span');
span.id = 'guest-name-display';
span.className = 'user-info';
span.textContent = guestName;
navRight.insertBefore(span, navRight.firstChild);
}
}
renderParticipants();
updateJoinButton();
renderResults(pollData.responses);
recalculateFilteredHeatMap();
}
function updateSortIndicators() {
// Only show sort indicators after user has clicked to sort
if (!hasUserSorted) return;
document.querySelectorAll('.sortable-header').forEach(header => {
const arrow = header.querySelector('.sort-arrow');
if (header.dataset.sort === sortColumn) {
header.classList.add('active');
arrow.textContent = sortDirection === 'desc' ? '▼' : '▲';
} else {
header.classList.remove('active');
arrow.textContent = '';
}
});
}
function renderResults(responses) {
const tbody = document.querySelector('#results-table tbody');
const slotMap = {};
// Get all participants who have joined
const allParticipants = (pollData.participants || []).map(p => ({
name: p.name,
id: null, // Will be set to response ID if they respond
hasPic: false
}));
// Update with response data (to get response ID for picture)
responses.forEach(r => {
const participant = allParticipants.find(p => p.name === r.name);
if (participant) {
participant.id = r.id; // Response ID for picture
participant.hasPic = r.has_picture;
} else {
// Someone responded without joining first (shouldn't happen, but handle it)
allParticipants.push({ name: r.name, id: r.id, hasPic: r.has_picture });
}
});
responses.forEach(r => {
r.slots.forEach(s => {
// For day-wise polls, group by date only
const key = isDayWise ? s.date : `${s.date} ${formatTime(s.start)}-${formatTime(s.end)}`;
if (!slotMap[key]) slotMap[key] = { date: s.date, start: s.start, attendees: [], notes: [] };
// Avoid duplicate attendees for the same slot
if (!slotMap[key].attendees.some(a => a.name === r.name)) {
slotMap[key].attendees.push({ name: r.name, id: r.id, hasPic: r.has_picture });
}
if (s.note && !slotMap[key].notes.some(n => n.name === r.name && n.note === s.note)) {
slotMap[key].notes.push({ name: r.name, note: s.note });
}
});
});
// Sort based on user's selected column and direction
const sorted = Object.entries(slotMap).sort((a, b) => {
if (sortColumn === 'available') {
// Sort by available count
const countDiff = sortDirection === 'desc'
? b[1].attendees.length - a[1].attendees.length
: a[1].attendees.length - b[1].attendees.length;
if (countDiff !== 0) return countDiff;
// Secondary sort by date+time
const dateCmp = a[1].date.localeCompare(b[1].date);
if (dateCmp !== 0) return dateCmp;
return a[1].start - b[1].start;
} else {
// Sort by date+time
const dateCmp = a[1].date.localeCompare(b[1].date);
if (dateCmp !== 0) return sortDirection === 'asc' ? dateCmp : -dateCmp;
const timeDiff = a[1].start - b[1].start;
if (timeDiff !== 0) return sortDirection === 'asc' ? timeDiff : -timeDiff;
// Secondary sort by count (descending)
return b[1].attendees.length - a[1].attendees.length;
}
});
const totalPeople = allParticipants.length;
// Find max count for best-slot marking (always based on available count)
const maxSlotCount = sorted.length > 0 ? Math.max(...sorted.map(([, d]) => d.attendees.length)) : 0;
// Check if any slots are chosen
const hasChosenSlots = isLocked && chosenSlots.length > 0;
tbody.innerHTML = sorted.map(([slot, data]) => {
// Determine which attendees to show based on mode
let displayAttendees;
if (attendeesMode === 'available') {
displayAttendees = data.attendees;
} else {
// Show unavailable attendees (those who joined but are NOT in the available list)
const availableNames = new Set(data.attendees.map(a => a.name));
displayAttendees = allParticipants.filter(p => !availableNames.has(p.name));
}
// "Available" count is always based on available attendees, regardless of mode
const availableCount = data.attendees.length;
const hasNotes = data.notes.length > 0;
const noteIndicator = hasNotes ? '📝' : '';
const attendeeNames = displayAttendees.map(a => a.name);
const attendeeHtml = displayAttendees.map(a =>
`${a.hasPic ? `
` : ''}${a.name}`
).join('');
const bestClass = availableCount === maxSlotCount ? 'best-slot' : '';
// Chosen slot styling
const isChosen = chosenSlots.some(c => c.date === data.date && c.start === data.start);
let chosenClass = '';
if (hasChosenSlots) {
chosenClass = isChosen ? 'slot-chosen' : 'slot-greyed';
}
return `
| ${slot} |
${availableCount}/${totalPeople}${noteIndicator} |
${attendeeHtml} |
`;
}).join('');
if (sorted.length === 0) {
tbody.innerHTML = '| No responses yet |
';
}
// Store slotMap globally for notes pane
window.slotNotesMap = slotMap;
// Make rows clickable (but not attendees)
tbody.querySelectorAll('tr[data-date]').forEach(row => {
row.onclick = (e) => {
if (e.target.closest('.attendee')) return;
const date = row.dataset.date;
const time = parseInt(row.dataset.time);
// If locked and creator, toggle chosen slot (only for time-based polls)
if (isLocked && isCreator && !isDayWise) {
chooseSlot(date, time);
return;
}
// Select only this date
selectedDates.clear();
selectedDates.add(date);
highlightedTime = isDayWise ? null : time;
recalculateFilteredHeatMap();
scrollToSlot(date, isDayWise ? null : time);
showSlotNotes(row.dataset.slotKey);
};
});
// Attendee filter click handlers
tbody.querySelectorAll('.attendee').forEach(el => {
el.onclick = (e) => {
e.stopPropagation();
toggleAttendeeFilter(el.dataset.name);
};
});
applyAttendeeFilter();
updateSortIndicators();
}
function toggleAttendeeFilter(name) {
if (filteredAttendees.has(name)) {
filteredAttendees.delete(name);
} else {
filteredAttendees.add(name);
}
// Update UI - attendees in results table
document.querySelectorAll('.attendee').forEach(el => {
el.classList.toggle('filtered', filteredAttendees.has(el.dataset.name));
});
// Update UI - participants in header
document.querySelectorAll('.participant[data-name]').forEach(el => {
el.classList.toggle('filtered', filteredAttendees.has(el.dataset.name));
});
applyAttendeeFilter();
recalculateFilteredHeatMap();
}
function applyAttendeeFilter() {
const rows = document.querySelectorAll('#results-table tbody tr[data-attendees]');
rows.forEach(row => {
if (filteredAttendees.size === 0) {
row.style.display = '';
return;
}
const rowAttendees = row.dataset.attendees.split(',');
const hasAll = [...filteredAttendees].every(name => rowAttendees.includes(name));
row.style.display = hasAll ? '' : 'none';
});
}
function recalculateFilteredHeatMap() {
// Recalculate availability based only on filtered attendees
allAvailability.dates = new Map();
allAvailability.times = new Map();
myAvailability.dates.clear();
myAvailability.times.clear();
const filterByDates = selectedDates.size > 0;
const hasFilter = filteredAttendees.size > 0;
if (!hasFilter) {
// No filter - count all responses normally
pollData.responses.forEach(r => {
const seenDates = new Set();
const seenTimes = new Set();
r.slots.forEach(s => {
if (!seenDates.has(s.date)) {
seenDates.add(s.date);
allAvailability.dates.set(s.date, (allAvailability.dates.get(s.date) || 0) + 1);
}
if (r.is_owner) {
myAvailability.dates.add(s.date);
}
if (!filterByDates || selectedDates.has(s.date)) {
for (let m = s.start; m < s.end; m += 30) {
const timeKey = filterByDates ? `${s.date}-${m}` : `${m}`;
if (!seenTimes.has(timeKey)) {
seenTimes.add(timeKey);
allAvailability.times.set(m, (allAvailability.times.get(m) || 0) + 1);
}
if (r.is_owner) {
myAvailability.times.add(m);
}
}
}
});
});
} else {
// Filter active - only count slots where ALL filtered attendees are available
// Build what each filtered attendee has
const attendeeDates = new Map(); // name -> Set of dates
const attendeeTimes = new Map(); // name -> Map of time -> Set of dates with that time
filteredAttendees.forEach(name => {
attendeeDates.set(name, new Set());
attendeeTimes.set(name, new Map());
});
pollData.responses.forEach(r => {
if (!filteredAttendees.has(r.name)) return;
r.slots.forEach(s => {
attendeeDates.get(r.name).add(s.date);
if (!filterByDates || selectedDates.has(s.date)) {
const timesMap = attendeeTimes.get(r.name);
for (let m = s.start; m < s.end; m += 30) {
if (!timesMap.has(m)) timesMap.set(m, new Set());
timesMap.get(m).add(s.date);
}
}
});
});
// Find dates where ALL filtered attendees are available
const allDates = new Set();
attendeeDates.forEach(dates => dates.forEach(d => allDates.add(d)));
allDates.forEach(date => {
const allHaveIt = [...filteredAttendees].every(name => attendeeDates.get(name).has(date));
if (allHaveIt) {
allAvailability.dates.set(date, filteredAttendees.size);
}
});
// Find times where ALL filtered attendees are available (on matching dates)
const allTimeSlots = new Set();
attendeeTimes.forEach(timesMap => timesMap.forEach((dates, m) => allTimeSlots.add(m)));
allTimeSlots.forEach(m => {
// Count dates where ALL filtered attendees have this time
const datesWithTime = new Set();
let first = true;
filteredAttendees.forEach(name => {
const timesMap = attendeeTimes.get(name);
const dates = timesMap.get(m) || new Set();
if (first) {
dates.forEach(d => datesWithTime.add(d));
first = false;
} else {
// Intersect
datesWithTime.forEach(d => {
if (!dates.has(d)) datesWithTime.delete(d);
});
}
});
if (datesWithTime.size > 0) {
allAvailability.times.set(m, datesWithTime.size);
}
});
// Handle myAvailability (current user's responses)
const myResponse = pollData.responses.find(r => r.is_owner);
if (myResponse) {
myResponse.slots.forEach(s => {
myAvailability.dates.add(s.date);
if (!filterByDates || selectedDates.has(s.date)) {
for (let m = s.start; m < s.end; m += 30) {
myAvailability.times.add(m);
}
}
});
}
}
// Recalculate max counts
maxDateCount = Math.max(1, ...allAvailability.dates.values(), 0);
maxTimeCount = Math.max(1, ...allAvailability.times.values(), 0);
if (allAvailability.dates.size === 0) maxDateCount = 1;
if (allAvailability.times.size === 0) maxTimeCount = 1;
// Calculate best slots from visible table rows
calculateBestSlots();
renderCalendar();
renderTimeGrid();
}
function calculateBestSlots() {
bestDates.clear();
bestTimes.clear();
// Get visible rows and find max count
const rows = document.querySelectorAll('#results-table tbody tr[data-attendees]');
let maxCount = 0;
const slotData = [];
rows.forEach(row => {
if (row.style.display === 'none') return;
const countText = row.querySelector('.count')?.textContent || '0';
const count = parseInt(countText.split('/')[0]);
const date = row.dataset.date;
const time = parseInt(row.dataset.time);
slotData.push({ date, time, count });
if (count > maxCount) maxCount = count;
});
// Mark dates and times that have max count
const bestDateTimes = new Map(); // date -> Set of times with max count
slotData.forEach(s => {
if (s.count === maxCount) {
bestDates.add(s.date);
if (!bestDateTimes.has(s.date)) bestDateTimes.set(s.date, new Set());
bestDateTimes.get(s.date).add(s.time);
}
});
// A time is "best" only if it's optimal for ALL selected dates
// (or if no dates selected, for all best dates)
const datesToCheck = selectedDates.size > 0 ? selectedDates : bestDates;
if (datesToCheck.size > 0 && bestDateTimes.size > 0) {
// Find times that are best for all dates we're checking
let commonBestTimes = null;
datesToCheck.forEach(date => {
const timesForDate = bestDateTimes.get(date);
if (timesForDate) {
if (commonBestTimes === null) {
commonBestTimes = new Set(timesForDate);
} else {
commonBestTimes = new Set([...commonBestTimes].filter(t => timesForDate.has(t)));
}
}
});
if (commonBestTimes) {
commonBestTimes.forEach(t => bestTimes.add(t));
}
}
}
function showSlotNotes(slotKey) {
const pane = document.getElementById('notes-pane');
const slotData = window.slotNotesMap?.[slotKey];
if (!slotData || slotData.notes.length === 0) {
pane.innerHTML = `No notes for ${slotKey}`;
return;
}
pane.innerHTML = slotData.notes.map(n =>
`${n.name}: ${n.note}
`
).join('');
}
function scrollToSlot(date, time) {
// Navigate to the correct month
const d = new Date(date);
currentMonth = d.getMonth();
currentYear = d.getFullYear();
renderCalendar();
// Highlight the day
setTimeout(() => {
const dayEl = document.querySelector(`.calendar .day[data-date="${date}"]`);
if (dayEl) {
dayEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
// Scroll time grid and highlight (skip for day-wise polls)
if (time !== null) {
const timeEl = document.querySelector(`.time-slot[data-time="${time}"]`);
if (timeEl) {
timeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
timeEl.classList.add('highlight-flash');
setTimeout(() => timeEl.classList.remove('highlight-flash'), 600);
}
}
}
async function submitResponse() {
if (selectedDates.size === 0) {
return;
}
const slots = [];
if (isDayWise) {
// Day-wise: submit full-day slot for each selected date
selectedDates.forEach(date => {
if (allowedDates.size === 0 || allowedDates.has(date)) {
slots.push({ date, start: 0, end: 1440 });
}
});
} else {
// Time-based: submit each 30-min slot individually
const times = Array.from(selectedTimes).sort((a, b) => a - b);
if (times.length > 0) {
selectedDates.forEach(date => {
if (allowedDates.size === 0 || allowedDates.has(date)) {
times.forEach(t => {
slots.push({ date, start: t, end: t + 30 });
});
}
});
}
}
const noteInput = document.getElementById('note-input');
const note = noteInput?.value?.trim() || '';
const body = {
slots,
dates: Array.from(selectedDates),
note
};
if (!isAuthenticated) {
if (!guestName) {
return;
}
body.name = guestName;
}
await fetch(`/api/poll/${pollId}/respond`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// Show checkmark
const btn = document.getElementById('submit-btn');
btn.classList.add('success');
setTimeout(() => btn.classList.remove('success'), 2000);
if (noteInput) noteInput.value = '';
await loadPollData();
const msg = isDayWise || slots.length > 0 ? 'Availability submitted!' : 'Availability cleared for selected days';
showStatus(msg);
}
async function toggleLock() {
const res = await fetch(`/api/poll/${pollId}/lock`, { method: 'POST' });
const data = await res.json();
if (data.success) {
isLocked = data.is_locked;
if (!isLocked) {
chosenSlots = [];
}
document.getElementById('lock-btn').textContent = isLocked ? '🔓 Unlock' : '🔒 Lock';
showStatus(isLocked ? 'Poll locked' : 'Poll unlocked');
updateJoinButton();
renderResults(pollData.responses);
}
}
async function deletePoll() {
if (!confirm('Are you sure you want to delete this poll? This cannot be undone.')) return;
const res = await fetch(`/api/poll/${pollId}/delete`, { method: 'POST' });
const data = await res.json();
if (data.success) {
window.location.href = '/';
}
}
async function chooseSlot(date, start) {
const res = await fetch(`/api/poll/${pollId}/choose`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, start })
});
const data = await res.json();
if (data.success) {
chosenSlots = data.chosen_slots;
renderResults(pollData.responses);
}
}
function openEditModal() {
const now = new Date();
editMonth = now.getMonth();
editYear = now.getFullYear();
editDates = new Set(allowedDates);
editLastClickedDate = null;
editPreviewDates.clear();
document.getElementById('edit-poll-title').value = pollData.title;
document.getElementById('edit-poll-description').value = pollData.description || '';
document.getElementById('edit-allow-anonymous').checked = pollData.allow_anonymous;
renderEditCalendar();
document.getElementById('edit-modal').style.display = 'flex';
}
function renderEditCalendar() {
const cal = document.getElementById('edit-calendar');
const firstDay = new Date(editYear, editMonth, 1);
const lastDay = new Date(editYear, editMonth + 1, 0);
const startDay = firstDay.getDay();
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
document.getElementById('edit-month-year').textContent = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
let html = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => ``).join('');
const prevLastDay = new Date(editYear, editMonth, 0).getDate();
for (let i = startDay - 1; i >= 0; i--) {
html += `${prevLastDay - i}
`;
}
for (let d = 1; d <= lastDay.getDate(); d++) {
const dateStr = `${editYear}-${String(editMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const selected = editDates.has(dateStr) ? 'selected' : '';
const preview = editPreviewDates.has(dateStr) ? (editPreviewWillRemove ? 'preview-remove' : 'preview') : '';
const isToday = dateStr === todayStr ? 'today' : '';
html += `${d}
`;
}
const remaining = 42 - (startDay + lastDay.getDate());
for (let i = 1; i <= remaining && remaining < 7; i++) {
html += `${i}
`;
}
cal.innerHTML = html;
cal.querySelectorAll('.day:not(.other-month):not(.day-header)').forEach(day => {
day.onclick = (e) => handleEditDateClick(day.dataset.date, e.shiftKey);
day.onmouseenter = (e) => { if (e.shiftKey && editLastClickedDate) showEditDatePreview(day.dataset.date); };
});
cal.onmouseleave = () => { editPreviewDates.clear(); renderEditCalendar(); };
}
function handleEditDateClick(date, shiftKey) {
if (shiftKey && editLastClickedDate) {
const range = getDateRange(editLastClickedDate, date);
const shouldSelect = !editDates.has(date);
range.forEach(d => shouldSelect ? editDates.add(d) : editDates.delete(d));
} else {
if (editDates.has(date)) editDates.delete(date);
else editDates.add(date);
}
editLastClickedDate = date;
editPreviewDates.clear();
renderEditCalendar();
}
function showEditDatePreview(endDate) {
editPreviewDates.clear();
editPreviewWillRemove = editDates.has(endDate);
if (editLastClickedDate) {
getDateRange(editLastClickedDate, endDate).forEach(d => editPreviewDates.add(d));
}
renderEditCalendar();
}
async function saveEditedPoll() {
const title = document.getElementById('edit-poll-title').value.trim();
if (!title) {
document.getElementById('edit-modal-error').textContent = 'Title is required';
return;
}
if (editDates.size === 0) {
document.getElementById('edit-modal-error').textContent = 'Select at least one date';
return;
}
const res = await fetch(`/api/poll/${pollId}/edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description: document.getElementById('edit-poll-description').value,
allow_anonymous: document.getElementById('edit-allow-anonymous').checked,
allowed_dates: Array.from(editDates).sort()
})
});
const data = await res.json();
if (data.success) {
document.getElementById('edit-modal').style.display = 'none';
location.reload();
} else {
document.getElementById('edit-modal-error').textContent = data.error || 'Failed to save';
}
}