From ff3e1e868b63fce174fade7bec786edd4abed3ad Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 16 Jul 2025 18:05:49 -0600 Subject: [PATCH] Added calendar view to the shifts --- map/README.md | 17 +- map/app/public/css/shifts.css | 257 ++++++++++++++++++++++++++++++- map/app/public/js/shifts.js | 281 +++++++++++++++++++++++++++++++++- map/app/public/shifts.html | 33 +++- map/files-explainer.md | 6 +- 5 files changed, 575 insertions(+), 19 deletions(-) diff --git a/map/README.md b/map/README.md index b26aaa8..522e86e 100644 --- a/map/README.md +++ b/map/README.md @@ -15,8 +15,8 @@ A containerized web application that visualizes geographic data from NocoDB on a - 🎯 Configurable map start location - 📋 Walk Sheet generator for door-to-door canvassing - 🔗 QR code integration for digital resources -- � Volunteer shift management system -- ✋ User shift signup and cancellation +- � Volunteer shift management system with calendar and grid views +- ✋ User shift signup and cancellation with color-coded calendar - 👥 Admin shift creation and management - �🐳 Docker containerization for easy deployment - 🆓 100% open source (no proprietary dependencies) @@ -222,12 +222,19 @@ The application includes a comprehensive volunteer shift management system acces ### User Features +- **Dual View Options**: Toggle between grid view and calendar view for shift display +- **Calendar View**: Interactive monthly calendar showing shifts with color-coded indicators: + - Green: Shifts you've signed up for + - Blue: Available shifts you can join + - Gray: Full shifts (no spots available) - **View Available Shifts**: See all upcoming shifts with date, time, and capacity information -- **Sign Up for Shifts**: One-click signup for available shifts -- **My Shifts Dashboard**: View all your current shift signups +- **Sign Up for Shifts**: One-click signup for available shifts (works in both views) +- **My Shifts Dashboard**: View all your current shift signups at the top of the page - **Cancel Signups**: Cancel your shift signups when needed -- **Date Filtering**: Filter shifts by specific dates +- **Date Filtering**: Filter shifts by specific dates (applies to both views) - **Real-time Updates**: Shift availability updates dynamically +- **Interactive Calendar**: Click on calendar shifts for detailed popup with signup options +- **Calendar Navigation**: Navigate between months to view future shifts ### Admin Features diff --git a/map/app/public/css/shifts.css b/map/app/public/css/shifts.css index 1247f33..834a64e 100644 --- a/map/app/public/css/shifts.css +++ b/map/app/public/css/shifts.css @@ -182,6 +182,196 @@ align-items: center; } +/* View toggle and calendar styles */ +.shifts-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.view-toggle { + display: flex; + gap: 5px; +} + +.view-toggle .btn { + padding: 8px 15px; +} + +.view-toggle .btn.active { + background-color: var(--primary-color); + color: white; +} + +/* Calendar View Styles */ +.calendar-view { + margin-bottom: 40px; +} + +.calendar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 0 10px; +} + +.calendar-header h3 { + margin: 0; + color: var(--dark-color); +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; + background-color: #e0e0e0; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + overflow: hidden; +} + +.calendar-day-header { + background-color: var(--primary-color); + color: white; + padding: 12px 8px; + text-align: center; + font-weight: bold; + font-size: 0.9em; +} + +.calendar-day { + background-color: white; + min-height: 120px; + padding: 8px; + position: relative; + border: none; + display: flex; + flex-direction: column; +} + +.calendar-day.other-month { + background-color: #f9f9f9; + color: #ccc; +} + +.calendar-day.today { + background-color: #fff3cd; +} + +.calendar-day-number { + font-size: 0.9em; + font-weight: bold; + margin-bottom: 5px; + color: var(--dark-color); +} + +.calendar-day.other-month .calendar-day-number { + color: #ccc; +} + +.calendar-shifts { + display: flex; + flex-direction: column; + gap: 2px; + flex-grow: 1; +} + +.calendar-shift { + background-color: var(--primary-color); + color: white; + padding: 2px 4px; + border-radius: 3px; + font-size: 0.75em; + cursor: pointer; + transition: var(--transition); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.calendar-shift:hover { + opacity: 0.8; + transform: scale(1.02); +} + +.calendar-shift.my-shift { + background-color: var(--success-color); +} + +.calendar-shift.full-shift { + background-color: var(--secondary-color); +} + +.calendar-shift.available-shift { + background-color: var(--primary-color); +} + +/* Calendar legend */ +.calendar-legend { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 20px; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9em; +} + +.legend-color { + width: 16px; + height: 16px; + border-radius: 3px; +} + +.legend-color.my-shift { + background-color: var(--success-color); +} + +.legend-color.available-shift { + background-color: var(--primary-color); +} + +.legend-color.full-shift { + background-color: var(--secondary-color); +} + +/* Calendar shift popup/tooltip */ +.shift-popup { + position: absolute; + background: white; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + padding: 15px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + min-width: 250px; + max-width: 300px; +} + +.shift-popup h4 { + margin: 0 0 10px 0; + color: var(--dark-color); +} + +.shift-popup p { + margin: 5px 0; + color: var(--secondary-color); +} + +.shift-popup .shift-actions { + margin-top: 10px; + display: flex; + gap: 10px; +} + /* Mobile adjustments */ @media (max-width: 768px) { .shifts-container { @@ -237,13 +427,66 @@ .filter-group { flex-wrap: wrap; } + + .shifts-header { + flex-direction: column; + align-items: stretch; + gap: 15px; + } + + .view-toggle { + justify-content: center; + } + + .calendar-day { + min-height: 80px; + padding: 4px; + } + + .calendar-day-number { + font-size: 0.8em; + } + + .calendar-shift { + font-size: 0.7em; + padding: 1px 2px; + } + + .calendar-header { + padding: 0; + } + + .calendar-header h3 { + font-size: 1.1em; + } + + .calendar-legend { + gap: 15px; + } + + .legend-item { + font-size: 0.8em; + } + + .shift-popup { + max-width: 280px; + padding: 12px; + } } -/* Prevent dropdown from being cut off */ -.shifts-grid { - overflow: visible; -} - -.shift-card { - overflow: visible; +/* Extra small screens */ +@media (max-width: 480px) { + .calendar-day { + min-height: 60px; + padding: 2px; + } + + .calendar-shift { + font-size: 0.65em; + padding: 1px; + } + + .calendar-day-number { + font-size: 0.75em; + } } \ No newline at end of file diff --git a/map/app/public/js/shifts.js b/map/app/public/js/shifts.js index 353b05b..94a14f2 100644 --- a/map/app/public/js/shifts.js +++ b/map/app/public/js/shifts.js @@ -1,6 +1,8 @@ let currentUser = null; let allShifts = []; let mySignups = []; +let currentView = 'grid'; // 'grid' or 'calendar' +let currentCalendarDate = new Date(); // For calendar navigation // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', async () => { @@ -8,6 +10,7 @@ document.addEventListener('DOMContentLoaded', async () => { await loadShifts(); await loadMySignups(); setupEventListeners(); + initializeViewToggle(); // Add clear filters button handler const clearBtn = document.getElementById('clear-filters-btn'); @@ -118,6 +121,11 @@ function displayShifts(shifts) { // Add event listeners after rendering setupShiftCardListeners(); + + // Update calendar view if it's currently active + if (currentView === 'calendar') { + renderCalendar(); + } } function displayMySignups() { @@ -157,6 +165,11 @@ function displayMySignups() { // Add event listeners after rendering setupMySignupsListeners(); + + // Update calendar view if it's currently active + if (currentView === 'calendar') { + renderCalendar(); + } } // New function to setup listeners for shift cards @@ -447,13 +460,13 @@ function generateCalendarDropdown(shift) { @@ -547,6 +560,268 @@ function setupMySignupsListeners() { }); } +// Calendar View Functions +function initializeViewToggle() { + const gridBtn = document.getElementById('grid-view-btn'); + const calendarBtn = document.getElementById('calendar-view-btn'); + const prevBtn = document.getElementById('prev-month'); + const nextBtn = document.getElementById('next-month'); + + if (gridBtn && calendarBtn) { + gridBtn.addEventListener('click', () => switchView('grid')); + calendarBtn.addEventListener('click', () => switchView('calendar')); + + // Set initial active state + gridBtn.classList.add('active'); + } + + if (prevBtn && nextBtn) { + prevBtn.addEventListener('click', () => navigateCalendar(-1)); + nextBtn.addEventListener('click', () => navigateCalendar(1)); + } +} + +function switchView(view) { + const gridView = document.getElementById('shifts-grid'); + const calendarView = document.getElementById('calendar-view'); + const gridBtn = document.getElementById('grid-view-btn'); + const calendarBtn = document.getElementById('calendar-view-btn'); + + currentView = view; + + if (view === 'calendar') { + gridView.style.display = 'none'; + calendarView.style.display = 'block'; + gridBtn.classList.remove('active'); + calendarBtn.classList.add('active'); + renderCalendar(); + } else { + gridView.style.display = 'grid'; + calendarView.style.display = 'none'; + gridBtn.classList.add('active'); + calendarBtn.classList.remove('active'); + } +} + +function navigateCalendar(direction) { + currentCalendarDate.setMonth(currentCalendarDate.getMonth() + direction); + renderCalendar(); +} + +function renderCalendar() { + const year = currentCalendarDate.getFullYear(); + const month = currentCalendarDate.getMonth(); + + // Update header + const monthYearHeader = document.getElementById('calendar-month-year'); + if (monthYearHeader) { + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + monthYearHeader.textContent = `${monthNames[month]} ${year}`; + } + + // Get calendar grid + const calendarGrid = document.getElementById('calendar-grid'); + if (!calendarGrid) return; + + calendarGrid.innerHTML = ''; + + // Add day headers + const dayHeaders = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + dayHeaders.forEach(day => { + const dayHeader = document.createElement('div'); + dayHeader.className = 'calendar-day-header'; + dayHeader.textContent = day; + calendarGrid.appendChild(dayHeader); + }); + + // Get first day of month and number of days + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + // Get previous month's last days + const prevMonth = new Date(year, month, 0); + const daysInPrevMonth = prevMonth.getDate(); + + // Add previous month's trailing days + for (let i = startingDayOfWeek - 1; i >= 0; i--) { + const dayNumber = daysInPrevMonth - i; + const dayElement = createCalendarDay(dayNumber, true, new Date(year, month - 1, dayNumber)); + calendarGrid.appendChild(dayElement); + } + + // Add current month's days + for (let day = 1; day <= daysInMonth; day++) { + const currentDate = new Date(year, month, day); + const dayElement = createCalendarDay(day, false, currentDate); + calendarGrid.appendChild(dayElement); + } + + // Add next month's leading days to fill the grid + const totalCells = calendarGrid.children.length; + const remainingCells = 42 - totalCells; // 6 rows × 7 days + for (let day = 1; day <= remainingCells; day++) { + const dayElement = createCalendarDay(day, true, new Date(year, month + 1, day)); + calendarGrid.appendChild(dayElement); + } +} + +function createCalendarDay(dayNumber, isOtherMonth, date) { + const dayElement = document.createElement('div'); + dayElement.className = 'calendar-day'; + + if (isOtherMonth) { + dayElement.classList.add('other-month'); + } + + // Check if it's today + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + dayElement.classList.add('today'); + } + + // Add day number + const dayNumberElement = document.createElement('div'); + dayNumberElement.className = 'calendar-day-number'; + dayNumberElement.textContent = dayNumber; + dayElement.appendChild(dayNumberElement); + + // Add shifts for this day + const shiftsContainer = document.createElement('div'); + shiftsContainer.className = 'calendar-shifts'; + + const dateString = date.toISOString().split('T')[0]; + const dayShifts = allShifts.filter(shift => { + const shiftDate = new Date(shift.Date); + return shiftDate.toISOString().split('T')[0] === dateString; + }); + + dayShifts.forEach(shift => { + const shiftElement = createCalendarShift(shift); + shiftsContainer.appendChild(shiftElement); + }); + + dayElement.appendChild(shiftsContainer); + return dayElement; +} + +function createCalendarShift(shift) { + const shiftElement = document.createElement('div'); + shiftElement.className = 'calendar-shift'; + + // Determine shift type and color + const isSignedUp = mySignups.some(signup => signup.shift_id === shift.ID); + const isFull = shift['Current Volunteers'] >= shift['Max Volunteers']; + + if (isSignedUp) { + shiftElement.classList.add('my-shift'); + } else if (isFull) { + shiftElement.classList.add('full-shift'); + } else { + shiftElement.classList.add('available-shift'); + } + + // Set shift text (time and title) + const timeText = `${shift['Start Time']} ${shift.Title}`; + shiftElement.textContent = timeText; + shiftElement.title = `${shift.Title}\n${shift['Start Time']} - ${shift['End Time']}\n${shift.Location || 'TBD'}`; + + // Add click handler + shiftElement.addEventListener('click', (e) => { + e.stopPropagation(); + showShiftPopup(shift, e.target); + }); + + return shiftElement; +} + +function showShiftPopup(shift, targetElement) { + // Remove any existing popup + const existingPopup = document.querySelector('.shift-popup'); + if (existingPopup) { + existingPopup.remove(); + } + + const popup = document.createElement('div'); + popup.className = 'shift-popup'; + + const isSignedUp = mySignups.some(signup => signup.shift_id === shift.ID); + const isFull = shift['Current Volunteers'] >= shift['Max Volunteers']; + const shiftDate = new Date(shift.Date); + + popup.innerHTML = ` +

${escapeHtml(shift.Title)}

+

📅 ${shiftDate.toLocaleDateString()}

+

⏰ ${shift['Start Time']} - ${shift['End Time']}

+

📍 ${escapeHtml(shift.Location || 'TBD')}

+

👥 ${shift['Current Volunteers']}/${shift['Max Volunteers']} volunteers

+ ${shift.Description ? `

${escapeHtml(shift.Description)}

` : ''} +
+ ${isSignedUp + ? `` + : isFull + ? '' + : `` + } +
+ `; + + // Position popup + document.body.appendChild(popup); + + const rect = targetElement.getBoundingClientRect(); + const popupRect = popup.getBoundingClientRect(); + + let left = rect.left + (rect.width / 2) - (popupRect.width / 2); + let top = rect.bottom + 10; + + // Adjust if popup goes off screen + if (left < 10) left = 10; + if (left + popupRect.width > window.innerWidth - 10) { + left = window.innerWidth - popupRect.width - 10; + } + if (top + popupRect.height > window.innerHeight - 10) { + top = rect.top - popupRect.height - 10; + } + + popup.style.left = `${left}px`; + popup.style.top = `${top}px`; + + // Add event listeners for buttons in popup + const signupBtn = popup.querySelector('.signup-btn'); + const cancelBtn = popup.querySelector('.cancel-signup-btn'); + + if (signupBtn) { + signupBtn.addEventListener('click', async () => { + await signupForShift(shift.ID); + popup.remove(); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', async () => { + await cancelSignup(shift.ID); + popup.remove(); + }); + } + + // Close popup when clicking outside + const closePopup = (e) => { + if (!popup.contains(e.target)) { + popup.remove(); + document.removeEventListener('click', closePopup); + } + }; + + setTimeout(() => { + document.addEventListener('click', closePopup); + }, 100); +} + // Keep the document click handler to close dropdowns when clicking outside document.addEventListener('click', function() { document.querySelectorAll('.calendar-options').forEach(opt => { diff --git a/map/app/public/shifts.html b/map/app/public/shifts.html index f6e43b0..2cc9e93 100644 --- a/map/app/public/shifts.html +++ b/map/app/public/shifts.html @@ -31,7 +31,13 @@
-

Available Shifts

+
+

Available Shifts

+
+ + +
+
@@ -42,6 +48,31 @@
+ +
diff --git a/map/files-explainer.md b/map/files-explainer.md index e99875c..759fd1d 100644 --- a/map/files-explainer.md +++ b/map/files-explainer.md @@ -100,7 +100,7 @@ CSS styles specific to the admin panel UI. # app/public/css/shifts.css -CSS styles for the volunteer shifts page. +CSS styles for the volunteer shifts page, including grid view, calendar view, and view toggle functionality. # app/public/css/style.css @@ -120,7 +120,7 @@ Login page HTML for user authentication. # app/public/shifts.html -Volunteer shifts management and signup page HTML. +Volunteer shifts management and signup page HTML with both grid and calendar view options. # app/public/js/admin.js @@ -152,7 +152,7 @@ Backup or legacy version of the main map JavaScript logic. # app/public/js/shifts.js -JavaScript for volunteer shift signup, management, and UI logic. Updated to use shift titles directly from signup records. +JavaScript for volunteer shift signup, management, and UI logic with both grid and calendar view functionality. Features include view toggling, calendar navigation, shift color-coding, and interactive shift popups. Updated to use shift titles directly from signup records. # app/public/js/ui-controls.js