diff --git a/website/components/group-rides/group-rides-management.tsx b/website/components/group-rides/group-rides-management.tsx index 9d7a2f8..8b92257 100644 --- a/website/components/group-rides/group-rides-management.tsx +++ b/website/components/group-rides/group-rides-management.tsx @@ -23,6 +23,7 @@ import { connectFirestoreEmulator, } from 'firebase/firestore'; import { firestore } from '../../lib/firebase-client'; +import { buildWebGroupRidePayload } from '../../lib/group-ride-payload'; function getActiveFirestore() { if ( @@ -1000,12 +1001,12 @@ export function GroupRidesManagement({ }, [currentUser?.uid]); // Handlers for Add, Alter, Remove, Join, Leave - const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string }) => { + const handleCreateRide = async (newRide: GroupRideSummary & { visibility?: string; clubId?: string; clubName?: string }) => { const uid = currentUser?.uid; const now = new Date(); const rideId = uid ? `ride_${now.getTime()}_${uid.substring(0, Math.min(6, uid.length))}` - : `MS-${Math.floor(1000 + Math.random() * 9000)}`; + : `local_${now.getTime()}`; const rideWithOrganizer: GroupRideSummary = { ...newRide, @@ -1018,80 +1019,49 @@ export function GroupRidesManagement({ leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(), }; - // Optimistic local update + // Optimistic local insert — rolled back below if a backend write is attempted and fails. setRides((prev) => [rideWithOrganizer, ...prev]); - showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success'); - if (uid && firestore) { - try { - const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer'; - const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider'; + // Without an authenticated session and Firestore we cannot persist the ride. Keep the + // local entry for responsiveness, but never claim it was saved to the cloud. + if (!uid || !firestore) { + return; + } - let scheduledStartAt = new Date(now.getTime() + 3600000).toISOString(); - let departureAt = new Date(now.getTime() + 3600000 + 1800000).toISOString(); + try { + const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer'; + const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider'; - if (newRide.scheduledDate) { - const parsed = new Date(`${newRide.scheduledDate} ${newRide.scheduledTime}`); - if (!isNaN(parsed.getTime()) && parsed.getTime() > now.getTime() - 86400000) { - scheduledStartAt = parsed.toISOString(); - departureAt = new Date(parsed.getTime() + 30 * 60000).toISOString(); - } - } + const firestoreRideData = buildWebGroupRidePayload(newRide, { + rideId, + uid, + leadHandle, + leadDisplayName, + now, + }); - const firestoreRideData: any = { - id: rideId, - organizerUid: uid, - organizerHandle: leadHandle, - organizerDisplayName: leadDisplayName, - title: newRide.title.trim(), - description: newRide.description?.trim() || '', - visibility: newRide.visibility || 'public', - joinPolicy: newRide.joinPolicy || 'requestToJoin', - status: 'scheduled', - scheduledStartAt, - departureAt, - participantLimit: Number(newRide.participantLimit) || 12, - participantCount: 1, - accessUids: [uid], - meetingPoint: { - latitude: 34.5378, - longitude: -83.9849, - displayName: newRide.meetingPoint || newRide.region || 'Designated Rally Point', - address: newRide.region || '', - locationType: 'meetup', - notes: '', - }, - stops: [], - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - paceClassification: newRide.paceClassification || 'Brisk Pace', - discipline: newRide.discipline || 'Sport / Canyon', - formationProfile: newRide.formationProfile || 'Staggered Dual Column [B]', - routeTitle: newRide.routeSnippet || '', - }; + const organizerParticipant = { + userId: uid, + role: 'organizer', + status: 'accepted', + riderHandle: leadHandle, + riderDisplayName: leadDisplayName, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; - if (newRide.clubId) { - firestoreRideData.clubId = newRide.clubId; - if (newRide.clubName) firestoreRideData.clubName = newRide.clubName; - } + const batch = writeBatch(firestore); + batch.set(doc(firestore, 'group_rides', rideId), firestoreRideData); + batch.set(doc(firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant); + await batch.commit(); - const organizerParticipant = { - userId: uid, - role: 'organizer', - status: 'accepted', - riderHandle: leadHandle, - riderDisplayName: leadDisplayName, - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - }; - - const batch = writeBatch(firestore); - batch.set(doc(firestore, 'group_rides', rideId), firestoreRideData); - batch.set(doc(firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant); - await batch.commit(); - } catch (err: any) { - console.error('Failed to persist group ride to Firestore:', err); - } + // Report success only after the write actually commits. + showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success'); + } catch (err: any) { + console.error('Failed to persist group ride to Firestore:', err); + // Roll back the optimistic insert and report the failure truthfully. + setRides((prev) => prev.filter((r) => r.id !== rideId)); + showAlert('Could not create the ride. Please check your connection and try again.', 'error'); } }; diff --git a/website/lib/group-ride-payload.ts b/website/lib/group-ride-payload.ts new file mode 100644 index 0000000..60c2d62 --- /dev/null +++ b/website/lib/group-ride-payload.ts @@ -0,0 +1,81 @@ +import type { GroupRideSummary } from '../components/group-rides/types'; + +export interface GroupRidePayloadContext { + rideId: string; + uid: string; + leadHandle: string; + leadDisplayName: string; + now: Date; +} + +export type WebGroupRideInput = GroupRideSummary & { + visibility?: string; + clubId?: string; + clubName?: string; +}; + +/** + * Builds the Firestore `group_rides/{id}` document for a ride created from the web hub. + * + * The web create form captures a free-text rally point only (there is no map picker or + * geocoder), so the meeting point stores `displayName`/`address` text and deliberately omits + * `latitude`/`longitude`. We never fabricate coordinates: a missing coordinate means + * "location not pinned yet", which `mapFirestoreRideToSummary` already handles (it derives + * `meetingPointCoords` only when both lat and lon are present). + * + * Pure and dependency-free so it can be unit-tested without Firebase. + */ +export function buildWebGroupRidePayload( + newRide: WebGroupRideInput, + ctx: GroupRidePayloadContext +): Record { + const { rideId, uid, leadHandle, leadDisplayName, now } = ctx; + + let scheduledStartAt = new Date(now.getTime() + 3600000).toISOString(); + let departureAt = new Date(now.getTime() + 3600000 + 1800000).toISOString(); + + if (newRide.scheduledDate) { + const parsed = new Date(`${newRide.scheduledDate} ${newRide.scheduledTime}`); + if (!isNaN(parsed.getTime()) && parsed.getTime() > now.getTime() - 86400000) { + scheduledStartAt = parsed.toISOString(); + departureAt = new Date(parsed.getTime() + 30 * 60000).toISOString(); + } + } + + const payload: Record = { + id: rideId, + organizerUid: uid, + organizerHandle: leadHandle, + organizerDisplayName: leadDisplayName, + title: newRide.title.trim(), + description: newRide.description?.trim() || '', + visibility: newRide.visibility || 'public', + joinPolicy: newRide.joinPolicy || 'requestToJoin', + status: 'scheduled', + scheduledStartAt, + departureAt, + participantLimit: Number(newRide.participantLimit) || 12, + participantCount: 1, + accessUids: [uid], + meetingPoint: { + displayName: newRide.meetingPoint || newRide.region || 'Designated Rally Point', + address: newRide.region || '', + locationType: 'meetup', + notes: '', + }, + stops: [], + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + paceClassification: newRide.paceClassification || 'Brisk Pace', + discipline: newRide.discipline || 'Sport / Canyon', + formationProfile: newRide.formationProfile || 'Staggered Dual Column [B]', + routeTitle: newRide.routeSnippet || '', + }; + + if (newRide.clubId) { + payload.clubId = newRide.clubId; + if (newRide.clubName) payload.clubName = newRide.clubName; + } + + return payload; +} diff --git a/website/tests/group-ride-payload.test.ts b/website/tests/group-ride-payload.test.ts new file mode 100644 index 0000000..8c9fa6e --- /dev/null +++ b/website/tests/group-ride-payload.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { buildWebGroupRidePayload, type WebGroupRideInput } from '../lib/group-ride-payload'; + +const baseRide: WebGroupRideInput = { + id: 'ride-temp', + title: ' Canyon Dawn Patrol ', + description: ' Fast sweepers ', + region: 'Santa Cruz Mountains', + scheduledDate: '', + scheduledTime: '', + paceClassification: 'Brisk Pace', + discipline: 'Sport / Canyon', + formationProfile: 'Staggered Dual Column [B]', + joinPolicy: 'requestToJoin', + leadHandle: 'You (Organizer)', + leadAvatarText: 'YOU', + participantCount: 1, + participantLimit: 12, + distance: '50.0 Miles', + meetingPoint: 'Alice’s Restaurant, Skyline Blvd', + routeSnippet: 'Skyline > Hwy 9', + userStatus: 'joined', + status: 'scheduled', +}; + +const ctx = { + rideId: 'ride_123_abc', + uid: 'rider-uid-1', + leadHandle: 'rider_one', + leadDisplayName: 'Rider One', + now: new Date('2026-10-14T12:00:00.000Z'), +}; + +describe('buildWebGroupRidePayload (WEB Phase 3 ride creation)', () => { + it('never fabricates meeting-point coordinates', () => { + const p = buildWebGroupRidePayload(baseRide, ctx) as any; + expect(p.meetingPoint.latitude).toBeUndefined(); + expect(p.meetingPoint.longitude).toBeUndefined(); + expect('latitude' in p.meetingPoint).toBe(false); + expect('longitude' in p.meetingPoint).toBe(false); + // The rider's typed rally point is preserved as text. + expect(p.meetingPoint.displayName).toBe('Alice’s Restaurant, Skyline Blvd'); + expect(p.meetingPoint.address).toBe('Santa Cruz Mountains'); + }); + + it('falls back to the region for the rally point when none is typed', () => { + const p = buildWebGroupRidePayload({ ...baseRide, meetingPoint: '' }, ctx) as any; + expect(p.meetingPoint.displayName).toBe('Santa Cruz Mountains'); + }); + + it('sets authoritative organizer and access fields and trims text', () => { + const p = buildWebGroupRidePayload(baseRide, ctx) as any; + expect(p.id).toBe('ride_123_abc'); + expect(p.organizerUid).toBe('rider-uid-1'); + expect(p.accessUids).toEqual(['rider-uid-1']); + expect(p.participantCount).toBe(1); + expect(p.title).toBe('Canyon Dawn Patrol'); + expect(p.description).toBe('Fast sweepers'); + expect(typeof p.scheduledStartAt).toBe('string'); + expect(Number.isNaN(Date.parse(p.scheduledStartAt as string))).toBe(false); + }); + + it('honors a valid future scheduled date/time', () => { + const p = buildWebGroupRidePayload( + { ...baseRide, scheduledDate: '2026-12-25', scheduledTime: '09:30' }, + ctx + ) as any; + expect(p.scheduledStartAt).toBe(new Date('2026-12-25 09:30').toISOString()); + }); + + it('includes club identity only when provided', () => { + const withClub = buildWebGroupRidePayload({ ...baseRide, clubId: 'club-1', clubName: 'Apex MC' }, ctx) as any; + expect(withClub.clubId).toBe('club-1'); + expect(withClub.clubName).toBe('Apex MC'); + + const withoutClub = buildWebGroupRidePayload(baseRide, ctx) as any; + expect('clubId' in withoutClub).toBe(false); + }); +});