diff --git a/map/Instuctions.md b/map/Instuctions.md new file mode 100644 index 0000000..44fb4bb --- /dev/null +++ b/map/Instuctions.md @@ -0,0 +1,10 @@ +# Instructions + +The following are instructions for developing this project. The project is called Map and is a canvasing application for political campaigns. + +It uses nocodb as a backend database. + +## Rules + +- Do not use inline event handlers. Use properly attached event listeners instead. +- Always update the README.md, or instruct the user to update the README, when developing new features. diff --git a/map/README.md b/map/README.md index 18a7891..b26aaa8 100644 --- a/map/README.md +++ b/map/README.md @@ -15,7 +15,10 @@ 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 -- 🐳 Docker containerization for easy deployment +- � Volunteer shift management system +- ✋ User shift signup and cancellation +- 👥 Admin shift creation and management +- �🐳 Docker containerization for easy deployment - 🆓 100% open source (no proprietary dependencies) ## Quick Start @@ -40,13 +43,15 @@ A containerized web application that visualizes geographic data from NocoDB on a Edit the `.env` file with your NocoDB API and API Url: ```env # NocoDB API Configuration - NOCODB_API_URL=https://your-nocodb-instance.com/api/v1 + NOCODB_API_URL=https://db.cmlite.org/api/v1 NOCODB_API_TOKEN=your-api-token-here # These will be populated after running build-nocodb.sh NOCODB_VIEW_URL= NOCODB_LOGIN_SHEET= NOCODB_SETTINGS_SHEET= + NOCODB_SHIFTS_SHEET= + NOCODB_SHIFT_SIGNUPS_SHEET= # Server Configuration PORT=3000 @@ -57,6 +62,13 @@ A containerized web application that visualizes geographic data from NocoDB on a DEFAULT_LAT=53.5461 DEFAULT_LNG=-113.4938 DEFAULT_ZOOM=11 + + # Cloudflare Settings + TRUST_PROXY=true + COOKIE_DOMAIN=.cmlite.org + + # Allowed Origins + ALLOWED_ORIGINS=https://map.cmlite.org,http://localhost:3000 ``` 3. **Auto-Create Database Structure** @@ -67,26 +79,30 @@ A containerized web application that visualizes geographic data from NocoDB on a ./build-nocodb.sh ``` - This creates three tables: + This creates five tables: - **Locations** - Main map data with geo-location, contact info, support levels - **Login** - User authentication (email, name, admin flag) - **Settings** - Admin configuration and QR codes + - **Shifts** - Shift scheduling and management + - **Shift Signups** - User shift registrations 4. **Get Table URLs** After the script completes: - 1. Login to your NocoDB instance - 2. Navigate to your project ("Map Viewer Project") + 1. Login to your NocoDB instance at https://db.cmlite.org + 2. Navigate to your project ("Map Viewer Project - TIMESTAMP") 3. Copy the view URLs for each table from your browser address bar - 4. URLs should look like: `https://your-nocodb.com/dashboard/#/nc/project-id/table-id` + 4. URLs should look like: `https://db.cmlite.org/dashboard/#/nc/project-id/table-id` 5. **Update Environment with URLs** Edit your `.env` file and add the table URLs: ```env - NOCODB_VIEW_URL=https://your-nocodb.com/dashboard/#/nc/project-id/locations-table-id - NOCODB_LOGIN_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/login-table-id - NOCODB_SETTINGS_SHEET=https://your-nocodb.com/dashboard/#/nc/project-id/settings-table-id + NOCODB_VIEW_URL=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/m6g7bkzv7s1w2ur + NOCODB_LOGIN_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mizyc64e4r7ppzh + NOCODB_SETTINGS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mix06f2mlep7gqb + NOCODB_SHIFTS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mkx0tex0iquus1u + NOCODB_SHIFT_SIGNUPS_SHEET=https://db.cmlite.org/dashboard/#/nc/pnsalzrup2zqvz8/mi8jg1tn26mu8fj ``` 6. **Build and Deploy** @@ -105,6 +121,8 @@ A containerized web application that visualizes geographic data from NocoDB on a - Check container status: `docker-compose ps` - View logs: `docker-compose logs -f map-viewer` - Access the application at: http://localhost:3000 + - Access shift management at: http://localhost:3000/shifts.html + - Access admin panel at: http://localhost:3000/admin.html (admin users only) ## Database Schema @@ -147,6 +165,18 @@ The build script automatically creates the following table structure: - `qr_code_2_image` (Attachment): QR code 2 image - `qr_code_3_image` (Attachment): QR code 3 image +### Shifts Table +- Standard NocoDB fields for shift scheduling and management +- Contains shift dates, times, locations, capacity limits +- Status tracking (Active, Cancelled, Full) +- Created automatically by build script with basic structure + +### Shift Signups Table +- Links users to shifts they've signed up for +- Tracks signup timestamps and user information +- Handles cancellations and waitlist management +- Created automatically by build script with basic structure + ## API Endpoints ### Public Endpoints @@ -159,6 +189,20 @@ The build script automatically creates the following table structure: - `GET /api/config/start-location` - Get map start location - `GET /health` - Health check +### Shifts Endpoints (requires authentication) + +- `GET /api/shifts` - Get all available shifts +- `GET /api/shifts/my-signups` - Get current user's shift signups +- `POST /api/shifts/:shiftId/signup` - Sign up for a shift +- `POST /api/shifts/:shiftId/cancel` - Cancel shift signup + +### Shifts Admin Endpoints (requires admin privileges) + +- `GET /api/shifts/admin` - Get all shifts including cancelled ones +- `POST /api/shifts/admin` - Create new shift +- `PUT /api/shifts/admin/:id` - Update existing shift +- `DELETE /api/shifts/admin/:id` - Delete shift + ### Authentication Endpoints - `POST /api/auth/login` - User login @@ -172,6 +216,35 @@ The build script automatically creates the following table structure: - `GET /api/admin/walk-sheet-config` - Get walk sheet configuration - `POST /api/admin/walk-sheet-config` - Save walk sheet configuration +## Shifts Management + +The application includes a comprehensive volunteer shift management system accessible at `/shifts.html`. + +### User Features + +- **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 +- **Cancel Signups**: Cancel your shift signups when needed +- **Date Filtering**: Filter shifts by specific dates +- **Real-time Updates**: Shift availability updates dynamically + +### Admin Features + +Administrators have additional capabilities for managing shifts: + +- **Create New Shifts**: Add new volunteer shifts with date, time, location, and capacity +- **Edit Existing Shifts**: Modify shift details, times, or capacity +- **Cancel Shifts**: Mark shifts as cancelled (they remain in system but hidden from users) +- **View All Signups**: See who has signed up for each shift +- **Manage Capacity**: Set maximum number of volunteers per shift + +### Shift Status System + +- **Active**: Available for signups +- **Full**: Capacity reached, no more signups accepted +- **Cancelled**: Hidden from public view but retained in database + ## Admin Panel Users with admin privileges can access the admin panel at `/admin.html` to configure system settings. @@ -216,13 +289,20 @@ All configuration is done via environment variables: |----------|-------------|---------| | `NOCODB_API_URL` | NocoDB API base URL | Required | | `NOCODB_API_TOKEN` | API authentication token | Required | -| `NOCODB_VIEW_URL` | Full NocoDB view URL | Required | +| `NOCODB_VIEW_URL` | Full NocoDB view URL for locations table | Required | | `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required | -| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Optional | +| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Required | +| `NOCODB_SHIFTS_SHEET` | Shifts table URL for shift management | Required | +| `NOCODB_SHIFT_SIGNUPS_SHEET` | Shift signups table URL for user registrations | Required | | `PORT` | Server port | 3000 | +| `NODE_ENV` | Environment mode | production | +| `SESSION_SECRET` | Session encryption secret | Required | | `DEFAULT_LAT` | Default map latitude | 53.5461 | | `DEFAULT_LNG` | Default map longitude | -113.4938 | | `DEFAULT_ZOOM` | Default map zoom level | 11 | +| `TRUST_PROXY` | Trust proxy headers (for Cloudflare) | true | +| `COOKIE_DOMAIN` | Cookie domain setting | .cmlite.org | +| `ALLOWED_ORIGINS` | CORS allowed origins | Multiple URLs | ## Maintenance Commands diff --git a/map/app/controllers/shiftsController.js b/map/app/controllers/shiftsController.js index 51d37f2..fe6f364 100644 --- a/map/app/controllers/shiftsController.js +++ b/map/app/controllers/shiftsController.js @@ -16,18 +16,40 @@ class ShiftsController { logger.info('Loading public shifts from:', config.nocodb.shiftsSheetId); - // Load all shifts without filter - we'll filter in JavaScript const response = await nocodbService.getAll(config.nocodb.shiftsSheetId, { sort: 'Date,Start Time' }); - logger.info('Loaded shifts:', response); - - // Filter out cancelled shifts manually - const shifts = (response.list || []).filter(shift => + let shifts = (response.list || []).filter(shift => shift.Status !== 'Cancelled' ); + // If signups sheet is configured, calculate current volunteer counts + if (config.nocodb.shiftSignupsSheetId) { + try { + const signupsResponse = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId); + const allSignups = signupsResponse.list || []; + + // Update each shift with calculated volunteer count + shifts = shifts.map(shift => { + const confirmedSignups = allSignups.filter(signup => + signup['Shift ID'] === shift.ID && signup.Status === 'Confirmed' + ); + + const currentVolunteers = confirmedSignups.length; + const maxVolunteers = shift['Max Volunteers'] || 0; + + return { + ...shift, + 'Current Volunteers': currentVolunteers, + 'Status': currentVolunteers >= maxVolunteers ? 'Full' : 'Open' + }; + }); + } catch (signupError) { + logger.warn('Could not load signups for volunteer count calculation:', signupError); + } + } + res.json({ success: true, shifts: shifts diff --git a/map/app/public/css/shifts.css b/map/app/public/css/shifts.css index 8e8dd88..1247f33 100644 --- a/map/app/public/css/shifts.css +++ b/map/app/public/css/shifts.css @@ -109,6 +109,10 @@ .shift-actions { margin-top: 15px; + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; } .no-shifts { @@ -118,14 +122,67 @@ grid-column: 1 / -1; /* Span all columns */ } -/* Tablet: 2 columns */ -@media (max-width: 1024px) and (min-width: 769px) { - .shifts-grid { - grid-template-columns: repeat(2, 1fr); - } +/* Calendar dropdown styles */ +.calendar-dropdown { + position: relative; + display: inline-block; } -/* Mobile: 1 column */ +.calendar-toggle { + cursor: pointer; + position: relative; +} + +.calendar-options { + position: absolute; + top: 100%; + left: 0; + min-width: 180px; + background: white; + border: 1px solid #e0e0e0; + border-radius: var(--border-radius); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 1000; + margin-top: 4px; +} + +.calendar-option { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 15px; + text-decoration: none; + color: var(--dark-color); + transition: var(--transition); + border-bottom: 1px solid #f0f0f0; +} + +.calendar-option:last-child { + border-bottom: none; +} + +.calendar-option:hover { + background-color: #f5f5f5; +} + +.calendar-option img { + width: 16px; + height: 16px; +} + +/* Ensure dropdowns appear above other elements */ +.shift-card { + position: relative; +} + +/* Update signup actions for My Shifts section */ +.signup-actions { + display: flex; + gap: 10px; + align-items: center; +} + +/* Mobile adjustments */ @media (max-width: 768px) { .shifts-container { padding: 15px; @@ -142,8 +199,30 @@ gap: 10px; } - .shift-card { - padding: 15px; + .signup-actions { + width: 100%; + justify-content: flex-start; + } + + .shift-actions { + flex-direction: column; + width: 100%; + } + + .shift-actions .btn, + .calendar-dropdown { + width: 100%; + } + + .calendar-toggle { + width: 100%; + justify-content: center; + } + + .calendar-options { + left: 0; + right: 0; + width: 100%; } .my-signups { @@ -158,4 +237,13 @@ .filter-group { flex-wrap: wrap; } +} + +/* Prevent dropdown from being cut off */ +.shifts-grid { + overflow: visible; +} + +.shift-card { + overflow: visible; } \ No newline at end of file diff --git a/map/app/public/js/shifts.js b/map/app/public/js/shifts.js index 8d802e1..08f3fd0 100644 --- a/map/app/public/js/shifts.js +++ b/map/app/public/js/shifts.js @@ -105,7 +105,8 @@ function displayShifts(shifts) { ${shift.Description ? `
${escapeHtml(shift.Description)}
` : ''}
${isSignedUp - ? `` + ? ` + ${generateCalendarDropdown(shift)}` : isFull ? '' : `` @@ -141,7 +142,10 @@ function displayMySignups() {

${escapeHtml(signup.shift.Title)}

📅 ${shiftDate.toLocaleDateString()} ⏰ ${signup.shift['Start Time']} - ${signup.shift['End Time']}

- +
+ ${generateCalendarDropdown(signup.shift)} + +
`; }).join(''); @@ -159,15 +163,40 @@ function setupShiftCardListeners() { const newGrid = grid.cloneNode(true); grid.parentNode.replaceChild(newGrid, grid); - // Add click listener for signup buttons + // Add click listener for all buttons newGrid.addEventListener('click', async (e) => { + // Handle signup buttons if (e.target.classList.contains('signup-btn')) { const shiftId = e.target.getAttribute('data-shift-id'); await signupForShift(shiftId); - } else if (e.target.classList.contains('cancel-signup-btn')) { + } + // Handle cancel buttons + else if (e.target.classList.contains('cancel-signup-btn')) { const shiftId = e.target.getAttribute('data-shift-id'); await cancelSignup(shiftId); } + // Handle calendar toggle buttons + else if (e.target.classList.contains('calendar-toggle')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + const isOpen = options.style.display !== 'none'; + + // Close all other dropdowns + document.querySelectorAll('.calendar-options').forEach(opt => { + opt.style.display = 'none'; + }); + + // Toggle this dropdown + options.style.display = isOpen ? 'none' : 'block'; + } + // Handle calendar option clicks + else if (e.target.closest('.calendar-option')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + options.style.display = 'none'; + } }); } @@ -180,12 +209,35 @@ function setupMySignupsListeners() { const newList = list.cloneNode(true); list.parentNode.replaceChild(newList, list); - // Add click listener for cancel buttons + // Add click listener for all interactions newList.addEventListener('click', async (e) => { + // Handle cancel buttons if (e.target.classList.contains('cancel-signup-btn')) { const shiftId = e.target.getAttribute('data-shift-id'); await cancelSignup(shiftId); } + // Handle calendar toggle buttons + else if (e.target.classList.contains('calendar-toggle')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + const isOpen = options.style.display !== 'none'; + + // Close all other dropdowns + document.querySelectorAll('.calendar-options').forEach(opt => { + opt.style.display = 'none'; + }); + + // Toggle this dropdown + options.style.display = isOpen ? 'none' : 'block'; + } + // Handle calendar option clicks + else if (e.target.closest('.calendar-option')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + options.style.display = 'none'; + } }); } @@ -290,4 +342,209 @@ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; -} \ No newline at end of file +} + +// Add these calendar URL generation functions after the existing functions +function generateCalendarUrls(shift) { + const shiftDate = new Date(shift.Date); + + // Parse start and end times + const [startHour, startMinute] = shift['Start Time'].split(':').map(n => parseInt(n)); + const [endHour, endMinute] = shift['End Time'].split(':').map(n => parseInt(n)); + + // Create start and end datetime objects + const startDate = new Date(shiftDate); + startDate.setHours(startHour, startMinute, 0, 0); + + const endDate = new Date(shiftDate); + endDate.setHours(endHour, endMinute, 0, 0); + + // Format dates for different calendar formats + const formatGoogleDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}${month}${day}T${hours}${minutes}00`; + }; + + const formatISODate = (date) => { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + }; + + // Event details + const title = shift.Title; + const description = shift.Description || 'Volunteer shift'; + const location = shift.Location || ''; + + // Google Calendar URL + const googleStartStr = formatGoogleDate(startDate); + const googleEndStr = formatGoogleDate(endDate); + const googleParams = new URLSearchParams({ + action: 'TEMPLATE', + text: title, + dates: `${googleStartStr}/${googleEndStr}`, + details: description, + location: location + }); + const googleUrl = `https://calendar.google.com/calendar/render?${googleParams.toString()}`; + + // Outlook Web Calendar URL + const outlookStartStr = startDate.toISOString(); + const outlookEndStr = endDate.toISOString(); + const outlookParams = new URLSearchParams({ + path: '/calendar/action/compose', + rru: 'addevent', + subject: title, + startdt: outlookStartStr, + enddt: outlookEndStr, + body: description, + location: location + }); + const outlookUrl = `https://outlook.live.com/calendar/0/deeplink/compose?${outlookParams.toString()}`; + + // Apple Calendar (.ics file) - we'll generate this dynamically + const icsContent = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BNKops//Volunteer Shifts//EN', + 'BEGIN:VEVENT', + `UID:${shift.ID}-${Date.now()}@bnkops.com`, + `DTSTART:${formatISODate(startDate)}`, + `DTEND:${formatISODate(endDate)}`, + `SUMMARY:${title}`, + `DESCRIPTION:${description.replace(/\n/g, '\\n')}`, + `LOCATION:${location}`, + 'STATUS:CONFIRMED', + 'END:VEVENT', + 'END:VCALENDAR' + ].join('\r\n'); + + // Create a data URL for the .ics file + const icsDataUrl = 'data:text/calendar;charset=utf-8,' + encodeURIComponent(icsContent); + + return { + google: googleUrl, + outlook: outlookUrl, + apple: icsDataUrl, + icsFilename: `${title.replace(/[^a-z0-9]/gi, '_')}_${shift.ID}.ics` + }; +} + +// Update calendar dropdown HTML generator (remove onclick handlers) +function generateCalendarDropdown(shift) { + const urls = generateCalendarUrls(shift); + return ` +
+ + +
+ `; +} + +// Update setupShiftCardListeners to handle calendar dropdowns +function setupShiftCardListeners() { + const grid = document.getElementById('shifts-grid'); + if (!grid) return; + + // Remove any existing listeners by cloning + const newGrid = grid.cloneNode(true); + grid.parentNode.replaceChild(newGrid, grid); + + // Add click listener for all buttons + newGrid.addEventListener('click', async (e) => { + // Handle signup buttons + if (e.target.classList.contains('signup-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + await signupForShift(shiftId); + } + // Handle cancel buttons + else if (e.target.classList.contains('cancel-signup-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + await cancelSignup(shiftId); + } + // Handle calendar toggle buttons + else if (e.target.classList.contains('calendar-toggle')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + const isOpen = options.style.display !== 'none'; + + // Close all other dropdowns + document.querySelectorAll('.calendar-options').forEach(opt => { + opt.style.display = 'none'; + }); + + // Toggle this dropdown + options.style.display = isOpen ? 'none' : 'block'; + } + // Handle calendar option clicks + else if (e.target.closest('.calendar-option')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + options.style.display = 'none'; + } + }); +} + +// Update setupMySignupsListeners similarly +function setupMySignupsListeners() { + const list = document.getElementById('my-signups-list'); + if (!list) return; + + // Remove any existing listeners by cloning + const newList = list.cloneNode(true); + list.parentNode.replaceChild(newList, list); + + // Add click listener for all interactions + newList.addEventListener('click', async (e) => { + // Handle cancel buttons + if (e.target.classList.contains('cancel-signup-btn')) { + const shiftId = e.target.getAttribute('data-shift-id'); + await cancelSignup(shiftId); + } + // Handle calendar toggle buttons + else if (e.target.classList.contains('calendar-toggle')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + const isOpen = options.style.display !== 'none'; + + // Close all other dropdowns + document.querySelectorAll('.calendar-options').forEach(opt => { + opt.style.display = 'none'; + }); + + // Toggle this dropdown + options.style.display = isOpen ? 'none' : 'block'; + } + // Handle calendar option clicks + else if (e.target.closest('.calendar-option')) { + e.stopPropagation(); + const dropdown = e.target.closest('.calendar-dropdown'); + const options = dropdown.querySelector('.calendar-options'); + options.style.display = 'none'; + } + }); +} + +// Keep the document click handler to close dropdowns when clicking outside +document.addEventListener('click', function() { + document.querySelectorAll('.calendar-options').forEach(opt => { + opt.style.display = 'none'; + }); +}); \ No newline at end of file