From e165fea414c2722cbc2288b2d1df3a4e95ec75da Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 16 Nov 2025 20:06:09 +0000 Subject: [PATCH] feat: implement event creation and management interface (task 13) - Create EventForm component with date/time picker and map-based location selection - Implement EventList component with status badges and participant counts - Add EventFilters component for filtering by status and date range - Create EventDetailModal with registration functionality and event details - Build eventService for API calls (getEvents, createEvent, register, etc.) - Update EventsPage with full event management UI - Support event creation, listing, filtering, and registration - Requirements: 4.1, 4.2, 4.3, 4.4 --- .kiro/specs/beach-litter-mapping/tasks.md | 57 +-- src/components/event/EventDetailModal.tsx | 275 ++++++++++++++ src/components/event/EventFilters.tsx | 140 +++++++ src/components/event/EventForm.tsx | 430 ++++++++++++++++++++++ src/components/event/EventList.tsx | 188 ++++++++++ src/components/event/index.ts | 6 + src/components/index.ts | 3 + src/pages/EventsPage.tsx | 261 ++++++++++++- src/services/eventService.ts | 102 +++++ src/services/index.ts | 2 + 10 files changed, 1434 insertions(+), 30 deletions(-) create mode 100644 src/components/event/EventDetailModal.tsx create mode 100644 src/components/event/EventFilters.tsx create mode 100644 src/components/event/EventForm.tsx create mode 100644 src/components/event/EventList.tsx create mode 100644 src/components/event/index.ts create mode 100644 src/services/eventService.ts diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 0e8f01c..0ef75aa 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -100,7 +100,7 @@ ## Event Management -- [ ] 13. Build event creation and management interface +- [-] 13. Build event creation and management interface - Create event creation form with date/time picker - Implement location selection on map - Add participant registration functionality @@ -143,9 +143,22 @@ - Show verification audit log - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ +## Cleanup Tracking System + +- [ ] 18. Implement "Mark as Cleaned" functionality + - Add `cleaned_at` timestamp field to reports table + - Add `cleaned_by_user_id` field to track who marked it as cleaned + - Create POST /api/reports/:id/mark-cleaned backend endpoint + - Add "Mark as Cleaned" button to report popup on map + - Update map markers to visually distinguish cleaned vs uncleaned reports (e.g., green vs red markers) + - Add filter option to show/hide cleaned reports + - Display cleanup date and user in report details + - Implement authorization (only authenticated users can mark as cleaned) + - _Requirements: Community engagement, impact tracking_ + ## External Data Integration -- [ ] 18. Create external data display components +- [ ] 19. Create external data display components - Build weather information widget - Implement tide times display - Add water quality indicator @@ -155,7 +168,7 @@ ## Mobile Optimization -- [ ] 19. Implement mobile-responsive design +- [ ] 20. Implement mobile-responsive design - Apply Tailwind responsive classes throughout - Optimize map controls for touch interfaces - Ensure minimum tap target sizes (44x44px) @@ -163,7 +176,7 @@ - Implement mobile navigation menu - _Requirements: 8.1, 8.4_ -- [ ] 20. Optimize mobile performance +- [ ] 21. Optimize mobile performance - Implement code splitting and lazy loading - Add image optimization and lazy loading - Configure service worker for offline capability @@ -173,7 +186,7 @@ ## Backend API Development -- [x] 21. Set up backend project structure +- [x] 22. Set up backend project structure - Initialize Node.js project with Express - Configure TypeScript for backend - Set up folder structure: routes, controllers, services, middleware, utils @@ -181,7 +194,7 @@ - Create environment configuration - _Requirements: All backend requirements_ -- [x] 22. Configure database with Prisma and MySQL spatial support +- [x] 23. Configure database with Prisma and MySQL spatial support - Set up MySQL 8.0+ with spatial data support - Create Prisma schema with all models (User, Report, Event, etc.) - Configure MySQL POINT spatial types for location fields @@ -189,7 +202,7 @@ - Set up database indexes including spatial indexes for performance - _Requirements: 1.1, 2.1, 3.2, 4.1_ -- [ ] 23. Implement authentication system +- [ ] 24. Implement authentication system - Create user registration endpoint with password hashing - Build login endpoint with JWT token generation - Implement refresh token logic with httpOnly cookies @@ -197,7 +210,7 @@ - Add rate limiting to auth endpoints - _Requirements: 6.1, 6.2_ -- [ ] 24. Build report API endpoints +- [ ] 25. Build report API endpoints - Create POST /api/reports endpoint for report submission - Implement GET /api/reports with filtering and pagination - Build GET /api/reports/:id for single report retrieval @@ -206,7 +219,7 @@ - Add spatial queries using MySQL spatial functions for location-based filtering - _Requirements: 1.1-1.8, 2.1, 2.4_ -- [x] 25. Implement file upload and EXIF processing +- [x] 26. Implement file upload and EXIF processing - Configure Multer for photo uploads to local filesystem - Set up uploads directory and configure Express static file serving - Implement EXIF metadata extraction with exifr @@ -214,7 +227,7 @@ - Create photo URL generation for local storage paths - _Requirements: 1.2, 1.3, 1.4, 1.5_ -- [ ] 26. Build event management endpoints +- [ ] 27. Build event management endpoints - Create POST /api/events for event creation - Implement GET /api/events with filtering - Build event registration endpoint @@ -222,7 +235,7 @@ - Add participant count tracking - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_ -- [ ] 27. Implement analytics endpoints +- [ ] 28. Implement analytics endpoints - Create GET /api/analytics/summary for overall statistics - Build time series data endpoint for trends - Implement CSV export functionality @@ -230,7 +243,7 @@ - Create comparison calculations for period-over-period metrics - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_ -- [ ] 28. Build hotspot calculation system +- [ ] 29. Build hotspot calculation system - Create scheduled job with node-cron for hotspot calculations - Implement MySQL spatial queries to identify clusters - Calculate severity scores based on report count and frequency @@ -238,7 +251,7 @@ - Create GET /api/analytics/hotspots endpoint - _Requirements: 3.2, 3.3, 3.4_ -- [ ] 29. Implement verification system endpoints +- [ ] 30. Implement verification system endpoints - Create POST /api/reports/:id/verify endpoint - Build POST /api/reports/:id/dispute endpoint - Implement verification count tracking @@ -246,7 +259,7 @@ - Create verification audit log - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ -- [ ] 30. Build user profile endpoints +- [ ] 31. Build user profile endpoints - Create GET /api/users/:id for profile retrieval - Implement PATCH /api/users/:id for profile updates - Build GET /api/users/:id/reports endpoint @@ -255,7 +268,7 @@ - Add notification preferences endpoint - _Requirements: 6.3, 6.4, 6.5, 10.4_ -- [ ] 31. Integrate external APIs with caching +- [ ] 32. Integrate external APIs with caching - Set up Redis for caching external API responses - Implement weather API integration (OpenWeatherMap) - Build tide API integration (WorldTides or NOAA) @@ -264,7 +277,7 @@ - Implement 6-hour cache expiration - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6_ -- [ ] 32. Implement notification system +- [ ] 33. Implement notification system - Create notification service with area-of-interest matching - Build email notification integration (SendGrid) - Implement in-app notification storage and retrieval @@ -272,7 +285,7 @@ - Add notification delivery within 10-minute requirement - _Requirements: 10.1, 10.2, 10.3, 10.4_ -- [ ] 33. Add API security and validation +- [ ] 34. Add API security and validation - Implement request validation with Zod schemas - Add rate limiting middleware - Configure CORS for allowed origins @@ -281,7 +294,7 @@ - Create error handling middleware - _Requirements: All security-related requirements_ -- [ ] 34. Implement environmental concerns endpoints +- [ ] 35. Implement environmental concerns endpoints - Create endpoints for submitting environmental concerns - Build filtering and retrieval for concern types - Implement linking of related reports within 100m @@ -290,7 +303,7 @@ ## Integration and Polish -- [ ] 35. Connect frontend to backend APIs +- [ ] 36. Connect frontend to backend APIs - Configure API base URL and axios/fetch setup - Implement React Query hooks for all endpoints - Add error handling and retry logic @@ -298,7 +311,7 @@ - Implement optimistic updates where appropriate - _Requirements: All integration requirements_ -- [ ] 36. Implement error handling and user feedback +- [ ] 37. Implement error handling and user feedback - Create toast notification system for user feedback - Add error boundaries for React components - Implement form validation error displays @@ -306,7 +319,7 @@ - Add loading spinners and skeleton screens - _Requirements: All UX requirements_ -- [ ]* 37. Write integration tests +- [ ]* 38. Write integration tests - Create API endpoint tests with Supertest - Test authentication flows - Test file upload pipeline @@ -314,7 +327,7 @@ - Test external API integration with mocks - _Requirements: All requirements_ -- [ ] 38. Set up deployment configuration +- [ ] 39. Set up deployment configuration - Create Docker configuration for development - Set up environment variables for production - Configure build scripts for frontend and backend diff --git a/src/components/event/EventDetailModal.tsx b/src/components/event/EventDetailModal.tsx new file mode 100644 index 0000000..46f1759 --- /dev/null +++ b/src/components/event/EventDetailModal.tsx @@ -0,0 +1,275 @@ +import { useEffect, useRef } from 'react'; +import { format } from 'date-fns'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import type { CleanupEvent } from '@/types'; + +// Fix for default marker icons +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; + +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, +}); + +interface EventDetailModalProps { + event: CleanupEvent; + isRegistered: boolean; + onClose: () => void; + onRegister: () => void; + onUnregister: () => void; + isLoading?: boolean; +} + +export function EventDetailModal({ + event, + isRegistered, + onClose, + onRegister, + onUnregister, + isLoading = false, +}: EventDetailModalProps) { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + + // Initialize map + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return; + + const map = L.map(mapRef.current).setView([event.latitude, event.longitude], 13); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(map); + + L.marker([event.latitude, event.longitude]).addTo(map); + + mapInstanceRef.current = map; + + return () => { + map.remove(); + mapInstanceRef.current = null; + }; + }, [event.latitude, event.longitude]); + + const formatEventDate = (date: Date) => { + return format(new Date(date), 'EEEE, MMMM d, yyyy'); + }; + + const formatEventTime = (date: Date) => { + return format(new Date(date), 'h:mm a'); + }; + + const formatDuration = (minutes: number) => { + if (minutes < 60) { + return `${minutes} minutes`; + } + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ${mins} minutes` : `${hours} hour${hours > 1 ? 's' : ''}`; + }; + + const getStatusBadge = () => { + const styles = { + upcoming: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + cancelled: 'bg-red-100 text-red-800', + }; + + const labels = { + upcoming: 'Upcoming', + completed: 'Completed', + cancelled: 'Cancelled', + }; + + return ( + + {labels[event.status]} + + ); + }; + + return ( +
+
+ {/* Background overlay */} +
+ + {/* Modal panel */} +
+ {/* Header */} +
+
+
+

{event.title}

+
+ {getStatusBadge()} + + {event.participantCount} {event.participantCount === 1 ? 'participant' : 'participants'} + +
+
+ +
+
+ + {/* Content */} +
+ {/* Event Details */} +
+
+ + + +
+

Date & Time

+

+ {formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)} +

+
+
+ +
+ + + +
+

Duration

+

{formatDuration(event.duration)}

+
+
+ +
+ + + + +
+

Location

+

{event.locationName}

+

+ {event.latitude.toFixed(6)}, {event.longitude.toFixed(6)} +

+
+
+
+ + {/* Description */} +
+

Description

+

{event.description}

+
+ + {/* Completed Event Info */} + {event.status === 'completed' && event.litterCollected !== undefined && ( +
+
+ + + +
+

Event Completed

+

+ {event.litterCollected} kg of litter collected +

+
+
+
+ )} + + {/* Map */} +
+

Event Location

+
+
+
+
+ + {/* Photos (if completed) */} + {event.status === 'completed' && event.photos && event.photos.length > 0 && ( +
+

Event Photos

+
+ {event.photos.map((photo, index) => ( + {`Event + ))} +
+
+ )} +
+ + {/* Footer Actions */} + {event.status === 'upcoming' && ( +
+ {isRegistered ? ( +
+
+ + + + You're registered for this event +
+ +
+ ) : ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/src/components/event/EventFilters.tsx b/src/components/event/EventFilters.tsx new file mode 100644 index 0000000..18554e2 --- /dev/null +++ b/src/components/event/EventFilters.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import type { EventStatus } from '@/types'; + +export interface EventFilterState { + status: EventStatus | 'all'; + startDate?: string; + endDate?: string; +} + +interface EventFiltersProps { + filters: EventFilterState; + onFiltersChange: (filters: EventFilterState) => void; +} + +export function EventFilters({ filters, onFiltersChange }: EventFiltersProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const handleStatusChange = (status: EventStatus | 'all') => { + onFiltersChange({ ...filters, status }); + }; + + const handleDateChange = (field: 'startDate' | 'endDate', value: string) => { + onFiltersChange({ ...filters, [field]: value || undefined }); + }; + + const handleClearFilters = () => { + onFiltersChange({ + status: 'all', + startDate: undefined, + endDate: undefined, + }); + }; + + const hasActiveFilters = filters.status !== 'all' || filters.startDate || filters.endDate; + + return ( +
+
+

Filters

+ +
+ + {/* Status Filter - Always Visible */} +
+ +
+ + + + +
+
+ + {/* Expanded Filters */} + {isExpanded && ( +
+ {/* Date Range */} +
+
+ + handleDateChange('startDate', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + handleDateChange('endDate', e.target.value)} + min={filters.startDate} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + {/* Clear Filters Button */} + {hasActiveFilters && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/event/EventForm.tsx b/src/components/event/EventForm.tsx new file mode 100644 index 0000000..f0f3e9b --- /dev/null +++ b/src/components/event/EventForm.tsx @@ -0,0 +1,430 @@ +import { useState, useRef, useEffect } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import { isWithinIreland, IRELAND_BOUNDS } from '@/utils/coordinates'; +import type { Coordinates } from '@/types'; + +// Fix for default marker icons in Leaflet with Vite +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; +import markerIcon from 'leaflet/dist/images/marker-icon.png'; +import markerShadow from 'leaflet/dist/images/marker-shadow.png'; + +delete (L.Icon.Default.prototype as any)._getIconUrl; +L.Icon.Default.mergeOptions({ + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, +}); + +interface EventFormData { + title: string; + description: string; + latitude: number; + longitude: number; + locationName: string; + scheduledDate: string; + scheduledTime: string; + duration: number; +} + +interface EventFormProps { + onSubmit: (data: EventFormData) => void; + onCancel: () => void; + initialData?: Partial; + isSubmitting?: boolean; +} + +export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = false }: EventFormProps) { + const [formData, setFormData] = useState({ + title: initialData?.title || '', + description: initialData?.description || '', + latitude: initialData?.latitude || 0, + longitude: initialData?.longitude || 0, + locationName: initialData?.locationName || '', + scheduledDate: initialData?.scheduledDate || '', + scheduledTime: initialData?.scheduledTime || '', + duration: initialData?.duration || 120, + }); + + const [selectedLocation, setSelectedLocation] = useState( + initialData?.latitude && initialData?.longitude + ? { latitude: initialData.latitude, longitude: initialData.longitude } + : null + ); + const [errors, setErrors] = useState>>({}); + const [locationError, setLocationError] = useState(null); + + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const markerRef = useRef(null); + + // Initialize map + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return; + + // Default center: Dublin, Ireland + const defaultCenter: [number, number] = [53.3498, -6.2603]; + const initialCenter: [number, number] = selectedLocation + ? [selectedLocation.latitude, selectedLocation.longitude] + : defaultCenter; + + const map = L.map(mapRef.current).setView(initialCenter, selectedLocation ? 13 : 7); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(map); + + // Add Ireland boundary rectangle + const irelandBounds = L.latLngBounds( + [IRELAND_BOUNDS.minLat, IRELAND_BOUNDS.minLng], + [IRELAND_BOUNDS.maxLat, IRELAND_BOUNDS.maxLng] + ); + + L.rectangle(irelandBounds, { + color: '#3B82F6', + weight: 2, + fillOpacity: 0.05, + dashArray: '5, 5', + }).addTo(map); + + // Add marker if location is already selected + if (selectedLocation) { + markerRef.current = L.marker([selectedLocation.latitude, selectedLocation.longitude], { + draggable: true, + }).addTo(map); + + markerRef.current.on('dragend', () => { + if (markerRef.current) { + const position = markerRef.current.getLatLng(); + handleMapLocationSelect(position.lat, position.lng); + } + }); + } + + // Handle map clicks + map.on('click', (e: L.LeafletMouseEvent) => { + handleMapLocationSelect(e.latlng.lat, e.latlng.lng); + }); + + mapInstanceRef.current = map; + + return () => { + map.remove(); + mapInstanceRef.current = null; + markerRef.current = null; + }; + }, []); + + // Update marker when location changes + useEffect(() => { + if (!mapInstanceRef.current || !selectedLocation) return; + + const map = mapInstanceRef.current; + + if (markerRef.current) { + map.removeLayer(markerRef.current); + } + + markerRef.current = L.marker([selectedLocation.latitude, selectedLocation.longitude], { + draggable: true, + }).addTo(map); + + markerRef.current.on('dragend', () => { + if (markerRef.current) { + const position = markerRef.current.getLatLng(); + handleMapLocationSelect(position.lat, position.lng); + } + }); + + map.setView([selectedLocation.latitude, selectedLocation.longitude], 13); + }, [selectedLocation]); + + const handleMapLocationSelect = (lat: number, lng: number) => { + if (!isWithinIreland(lat, lng)) { + setLocationError('Selected location is outside Ireland. Please select a location within Ireland.'); + return; + } + + const coords = { latitude: lat, longitude: lng }; + setSelectedLocation(coords); + setLocationError(null); + setFormData(prev => ({ + ...prev, + latitude: lat, + longitude: lng, + })); + }; + + const handleInputChange = (field: keyof EventFormData, value: string | number) => { + setFormData(prev => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + const validateForm = (): boolean => { + const newErrors: Partial> = {}; + + if (!formData.title.trim()) { + newErrors.title = 'Event title is required'; + } else if (formData.title.length < 3) { + newErrors.title = 'Title must be at least 3 characters'; + } + + if (!formData.description.trim()) { + newErrors.description = 'Event description is required'; + } else if (formData.description.length < 10) { + newErrors.description = 'Description must be at least 10 characters'; + } + + if (!formData.locationName.trim()) { + newErrors.locationName = 'Location name is required'; + } + + if (!selectedLocation) { + setLocationError('Please select a location on the map'); + } + + if (!formData.scheduledDate) { + newErrors.scheduledDate = 'Event date is required'; + } else { + const selectedDate = new Date(formData.scheduledDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (selectedDate < today) { + newErrors.scheduledDate = 'Event date must be in the future'; + } + } + + if (!formData.scheduledTime) { + newErrors.scheduledTime = 'Event time is required'; + } + + if (formData.duration < 30) { + newErrors.duration = 'Duration must be at least 30 minutes'; + } else if (formData.duration > 480) { + newErrors.duration = 'Duration cannot exceed 8 hours'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0 && selectedLocation !== null; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + onSubmit(formData); + }; + + // Get minimum date (today) + const getMinDate = () => { + const today = new Date(); + return today.toISOString().split('T')[0]; + }; + + return ( +
+ {/* Title */} +
+ + handleInputChange('title', e.target.value)} + className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${ + errors.title ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="e.g., Sandymount Beach Cleanup" + /> + {errors.title &&

{errors.title}

} +
+ + {/* Description */} +
+ +