Compare commits

..

No commits in common. "0dd56c0c7580e93aada6cf64721ed4d9ded81639" and "9e5b3193f76b5df10c227cd05bc46066acac4e63" have entirely different histories.

5 changed files with 39 additions and 509 deletions

View File

@ -1,10 +0,0 @@
# 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.

View File

@ -15,10 +15,7 @@ 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
- <20> Volunteer shift management system
- ✋ User shift signup and cancellation
- 👥 Admin shift creation and management
- <20>🐳 Docker containerization for easy deployment
- 🐳 Docker containerization for easy deployment
- 🆓 100% open source (no proprietary dependencies)
## Quick Start
@ -43,15 +40,13 @@ 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://db.cmlite.org/api/v1
NOCODB_API_URL=https://your-nocodb-instance.com/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
@ -62,13 +57,6 @@ 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**
@ -79,30 +67,26 @@ A containerized web application that visualizes geographic data from NocoDB on a
./build-nocodb.sh
```
This creates five tables:
This creates three 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 at https://db.cmlite.org
2. Navigate to your project ("Map Viewer Project - TIMESTAMP")
1. Login to your NocoDB instance
2. Navigate to your project ("Map Viewer Project")
3. Copy the view URLs for each table from your browser address bar
4. URLs should look like: `https://db.cmlite.org/dashboard/#/nc/project-id/table-id`
4. URLs should look like: `https://your-nocodb.com/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://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
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
```
6. **Build and Deploy**
@ -121,8 +105,6 @@ 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
@ -165,18 +147,6 @@ 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
@ -189,20 +159,6 @@ 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
@ -216,35 +172,6 @@ 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.
@ -289,20 +216,13 @@ 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 for locations table | Required |
| `NOCODB_VIEW_URL` | Full NocoDB view URL | Required |
| `NOCODB_LOGIN_SHEET` | Login table URL for authentication | Required |
| `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 |
| `NOCODB_SETTINGS_SHEET` | Settings table URL for admin config | Optional |
| `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

View File

@ -16,40 +16,18 @@ 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'
});
let shifts = (response.list || []).filter(shift =>
logger.info('Loaded shifts:', response);
// Filter out cancelled shifts manually
const 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
@ -157,24 +135,15 @@ class ShiftsController {
});
}
// Calculate current volunteers dynamically
let currentVolunteers = 0;
if (config.nocodb.shiftSignupsSheetId) {
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const confirmedSignups = (allSignups.list || []).filter(signup =>
signup['Shift ID'] === parseInt(shiftId) && signup.Status === 'Confirmed'
);
currentVolunteers = confirmedSignups.length;
}
if (currentVolunteers >= shift['Max Volunteers']) {
if (shift['Current Volunteers'] >= shift['Max Volunteers']) {
return res.status(400).json({
success: false,
error: 'Shift is full'
});
}
// Check if already signed up - we already have allSignups from above
// Check if already signed up - get all signups and filter
const allSignups = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const existingSignup = (allSignups.list || []).find(signup => {
return signup['Shift ID'] === parseInt(shiftId) &&
signup['User Email'] === userEmail &&
@ -199,10 +168,10 @@ class ShiftsController {
logger.info('Created signup:', signup);
// Update shift volunteer count with calculated value
// Update shift volunteer count
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
'Current Volunteers': currentVolunteers + 1,
'Status': currentVolunteers + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open'
'Current Volunteers': (shift['Current Volunteers'] || 0) + 1,
'Status': shift['Current Volunteers'] + 1 >= shift['Max Volunteers'] ? 'Full' : 'Open'
});
res.json({
@ -254,14 +223,10 @@ class ShiftsController {
'Status': 'Cancelled'
});
// Calculate current volunteers dynamically after cancellation
const allSignupsAfter = await nocodbService.getAll(config.nocodb.shiftSignupsSheetId);
const confirmedSignupsAfter = (allSignupsAfter.list || []).filter(s =>
s['Shift ID'] === parseInt(shiftId) && s.Status === 'Confirmed'
);
const newCount = confirmedSignupsAfter.length;
// Update shift volunteer count
const shift = await nocodbService.getById(config.nocodb.shiftsSheetId, shiftId);
const newCount = Math.max(0, (shift['Current Volunteers'] || 0) - 1);
await nocodbService.update(config.nocodb.shiftsSheetId, shiftId, {
'Current Volunteers': newCount,
'Status': newCount >= shift['Max Volunteers'] ? 'Full' : 'Open'

View File

@ -109,10 +109,6 @@
.shift-actions {
margin-top: 15px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.no-shifts {
@ -122,67 +118,14 @@
grid-column: 1 / -1; /* Span all columns */
}
/* Calendar dropdown styles */
.calendar-dropdown {
position: relative;
display: inline-block;
/* Tablet: 2 columns */
@media (max-width: 1024px) and (min-width: 769px) {
.shifts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.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 */
/* Mobile: 1 column */
@media (max-width: 768px) {
.shifts-container {
padding: 15px;
@ -199,30 +142,8 @@
gap: 10px;
}
.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%;
.shift-card {
padding: 15px;
}
.my-signups {
@ -238,12 +159,3 @@
flex-wrap: wrap;
}
}
/* Prevent dropdown from being cut off */
.shifts-grid {
overflow: visible;
}
.shift-card {
overflow: visible;
}

View File

@ -105,8 +105,7 @@ function displayShifts(shifts) {
${shift.Description ? `<div class="shift-description">${escapeHtml(shift.Description)}</div>` : ''}
<div class="shift-actions">
${isSignedUp
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>
${generateCalendarDropdown(shift)}`
? `<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${shift.ID}">Cancel Signup</button>`
: isFull
? '<button class="btn btn-secondary btn-sm" disabled>Shift Full</button>'
: `<button class="btn btn-primary btn-sm signup-btn" data-shift-id="${shift.ID}">Sign Up</button>`
@ -142,10 +141,7 @@ function displayMySignups() {
<h4>${escapeHtml(signup.shift.Title)}</h4>
<p>📅 ${shiftDate.toLocaleDateString()} ${signup.shift['Start Time']} - ${signup.shift['End Time']}</p>
</div>
<div class="signup-actions">
${generateCalendarDropdown(signup.shift)}
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
</div>
<button class="btn btn-danger btn-sm cancel-signup-btn" data-shift-id="${signup.shift.ID}">Cancel</button>
</div>
`;
}).join('');
@ -163,40 +159,15 @@ function setupShiftCardListeners() {
const newGrid = grid.cloneNode(true);
grid.parentNode.replaceChild(newGrid, grid);
// Add click listener for all buttons
// Add click listener for signup 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')) {
} 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';
}
});
}
@ -209,35 +180,12 @@ function setupMySignupsListeners() {
const newList = list.cloneNode(true);
list.parentNode.replaceChild(newList, list);
// Add click listener for all interactions
// Add click listener for cancel buttons
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';
}
});
}
@ -343,208 +291,3 @@ function escapeHtml(text) {
div.textContent = String(text);
return div.innerHTML;
}
// 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 `
<div class="calendar-dropdown">
<button class="btn btn-secondary btn-sm calendar-toggle" data-shift-id="${shift.ID}">
📅 Add to Calendar
</button>
<div class="calendar-options" style="display: none;">
<a href="${urls.google}" target="_blank" class="calendar-option" data-calendar-type="google">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyLjUgMkgxMy41QzE0LjMyODQgMiAxNSAyLjY3MTU3IDE1IDMuNVYxMi41QzE1IDEzLjMyODQgMTQuMzI4NCAxNCAxMy41IDE0SDIuNUMxLjY3MTU3IDE0IDEgMTMuMzI4NCAxIDEyLjVWMy41QzEgMi42NzE1NyAxLjY3MTU3IDIgMi41IDJIMy41IiBzdHJva2U9IiM0Mjg1RjQiIHN0cm9rZS13aWR0aD0iMS41Ii8+CjxwYXRoIGQ9Ik00IDFWM00xMiAxVjMiIHN0cm9rZT0iIzQyODVGNCIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8cGF0aCBkPSJNMSA1SDE1IiBzdHJva2U9IiM0Mjg1RjQiIHN0cm9rZS13aWR0aD0iMS41Ii8+Cjwvc3ZnPg==" alt="Google"> Google Calendar
</a>
<a href="${urls.outlook}" target="_blank" class="calendar-option" data-calendar-type="outlook">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDJIMlYxNEgxNFYyWiIgZmlsbD0iIzAwNzhENCIvPgo8cGF0aCBkPSJNOCA4VjE0SDE0VjhIOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik04IDJWOEgxNFYySDhaIiBmaWxsPSIjMDA3OEQ0Ii8+Cjwvc3ZnPg==" alt="Outlook"> Outlook
</a>
<a href="${urls.apple}" download="${urls.icsFilename}" class="calendar-option" data-calendar-type="apple">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1zbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEzIDJIMy41QzIuNjcxNTcgMiAyIDIuNjcxNTcgMiAzLjVWMTIuNUMyIDEzLjMyODQgMi42NzE1NyAxNCAzLjUgMTRIMTIuNUMxMy4zMjg0IDE0IDE0IDEzLjMyODQgMTQgMTIuNVYzQzE0IDIuNDQ3NzIgMTMuNTUyMyAyIDEzIDJaIiBmaWxsPSIjRkY1NzMzIi8+CjxwYXRoIGQ9Ik00IDFWM00xMiAxVjMiIHN0cm9rZT0iI0ZGNTczMyIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4KPHBhdGggZD0iTTIgNUgxNCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIxLjUiLz4KPHRleHQgeD0iOCIgeT0iMTEiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSI2IiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSI+MzE8L3RleHQ+Cjwvc3ZnPg==" alt="Apple"> Apple Calendar
</a>
</div>
</div>
`;
}
// 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';
});
});