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 => `
${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 = `${isDayWise ? 'Date' : 'Time Slot'} `; slotHeader.style.cursor = 'pointer'; slotHeader.dataset.sort = 'date'; } const availableHeader = document.querySelector('#results-table thead th:nth-child(2)'); if (availableHeader) { availableHeader.innerHTML = 'Available '; 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 => `
${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'; } }