From 59900f46f2b5dc8a963e660aa016fbf36285b501 Mon Sep 17 00:00:00 2001 From: Billie Ponder Date: Sun, 20 Sep 2026 07:23:00 -0400 Subject: [PATCH] feat(web): add clubs rides management (#9) Add the disabled Founder Portal Club Rides scaffold aligned with the live GroupRide.clubId domain. Keep production discovery and actions disabled until a privacy-preserving read path and real management destinations are implemented. --- .../app/clubs/[clubId]/manage/rides/page.tsx | 248 +++++++++++++ .../founder-portal/club-ride-card.tsx | 42 ++- .../founder-portal/founder-rides.tsx | 344 ++++++++++++++++++ .../founder-portal/founder-sidebar.tsx | 10 +- .../founder-portal/founder-top-bar.tsx | 6 + website/components/founder-portal/types.ts | 24 +- website/lib/club-data.ts | 39 ++ website/lib/club-fixtures.ts | 42 +++ website/tests/founder-portal.test.cjs | 109 +++++- 9 files changed, 824 insertions(+), 40 deletions(-) create mode 100644 website/app/clubs/[clubId]/manage/rides/page.tsx create mode 100644 website/components/founder-portal/founder-rides.tsx diff --git a/website/app/clubs/[clubId]/manage/rides/page.tsx b/website/app/clubs/[clubId]/manage/rides/page.tsx new file mode 100644 index 0000000..d8fa470 --- /dev/null +++ b/website/app/clubs/[clubId]/manage/rides/page.tsx @@ -0,0 +1,248 @@ +'use client'; + +import React, { useEffect, useState, use } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + fetchFounderClubContext, + FounderClubContext, + fetchClubRides, + fetchClubApplications, + sanitizePortalError, +} from '../../../../../lib/club-data'; +import { ClubUpcomingRide } from '../../../../../components/founder-portal/types'; +import { FounderPortalShell } from '../../../../../components/founder-portal/founder-portal-shell'; +import { FounderRides } from '../../../../../components/founder-portal/founder-rides'; +import { OverviewSkeleton } from '../../../../../components/founder-portal/skeleton-state'; +import { SafeErrorState } from '../../../../../components/founder-portal/safe-error-state'; +import { firebaseAuth } from '../../../../../lib/firebase-client'; + +interface PageProps { + params: Promise<{ clubId: string }>; +} + +export default function ClubFounderRidesPage({ params }: PageProps) { + const { clubId } = use(params); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [context, setContext] = useState(null); + const [upcomingRides, setUpcomingRides] = useState([]); + const [pastRides, setPastRides] = useState([]); + const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [unauthenticated, setUnauthenticated] = useState(false); + const [unauthorized, setUnauthorized] = useState(false); + + const isDemoPreview = searchParams?.get('preview') === 'demo'; + + async function loadData() { + setLoading(true); + setError(null); + setUnauthenticated(false); + setUnauthorized(false); + + try { + await firebaseAuth.authStateReady(); + const currentUser = firebaseAuth.currentUser; + + // Unauthenticated Guard: Direct access without demo preview requires authentication + if (!isDemoPreview && !currentUser) { + setUnauthenticated(true); + setLoading(false); + return; + } + + const ctx = await fetchFounderClubContext(clubId, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + + // Authorization Guard: Owner and Admin can access Rides management; Members cannot + if (!isDemoPreview && (!ctx.permissions.canManageSettings && !ctx.permissions.canPlanRides)) { + setUnauthorized(true); + setLoading(false); + return; + } + + setContext(ctx); + + // Fetch pending applications count for sidebar badge + try { + const apps = await fetchClubApplications(ctx.club.id, currentUser?.uid, { + allowDemoPreview: isDemoPreview, + }); + setPendingApplicationsCount(apps.length); + } catch { + setPendingApplicationsCount(0); + } + + // In demo preview mode: load fixture rides for UI/design validation + if (isDemoPreview) { + const ridesResult = await fetchClubRides(ctx.club.id, currentUser?.uid, { + allowDemoPreview: true, + }); + setUpcomingRides(ridesResult.upcomingRides); + setPastRides(ridesResult.pastRides); + } + // In production mode, do not attempt a broad client-side clubId query. + // GroupRide.clubId is now persisted, but Firestore intentionally does not authorize + // Club-wide discovery until mutual-block privacy can be preserved. + } catch (err) { + setError(sanitizePortalError(err, "Club rides couldn't be loaded.")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadData(); + }, [clubId]); + + if (loading) { + return ( +
+
+ +
+
+ ); + } + + // Unauthenticated safe error state + if (unauthenticated) { + return ( +
+ router.push('/login')} + /> +
+ ); + } + + // Unauthorized (Member or non-admin) safe error state + if (unauthorized) { + return ( +
+ router.push('/clubs')} + /> +
+ ); + } + + // Error or missing context + if (error || !context) { + return ( +
+ router.push('/clubs')} + onRetry={loadData} + /> +
+ ); + } + + // Normal production operation: truthful discovery-pending state. + // GroupRide.clubId exists, but no privacy-preserving Club-wide read path is available. + // Do not describe this state as "No rides". + if (!isDemoPreview) { + return ( + +
+ {/* Header Section */} +
+
+
+

+ Club Rides +

+ + DISCOVERY PENDING + +
+

+ Schedule, coordinate, and review group rides and road captain assignments. +

+
+ +
+ add_road + Plan Club Ride + + SOON + +
+
+ + {/* Pending Backend Integration Notice */} +
+
+ route +
+
+

+ Club Rides Discovery Pending +

+

+ Club-affiliated Group Rides can now be created in the mobile application. Club-wide Founder Portal discovery remains disabled until a read path can preserve mutual-block privacy. +

+
+
+
+ info + + Architecture Note: Group rides now link to clubs through GroupRide.clubId == club.id. Club-wide discovery is a separate deferred capability. + +
+
+ shield + + Privacy Invariant: Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location. + +
+
+
+
+
+ ); + } + + // Explicit Development Demo Preview: Renders full interactive UI with fixtures + return ( + + + + ); +} diff --git a/website/components/founder-portal/club-ride-card.tsx b/website/components/founder-portal/club-ride-card.tsx index 4cea8c8..f752f10 100644 --- a/website/components/founder-portal/club-ride-card.tsx +++ b/website/components/founder-portal/club-ride-card.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useSearchParams } from 'next/navigation'; import { ClubUpcomingRide, UserPortalPermissions } from './types'; import { EmptyState } from './empty-state'; @@ -60,7 +60,7 @@ export function ClubRideCard({ {/* Attributes Row */}
-
Distance
+
Straight-line distance
{ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'}
@@ -131,20 +131,24 @@ export function ClubRideCard({
- {/* Actions */} + {/* Actions: Disabled until backend club-ride management ships */}
{permissions.canPlanRides ? ( ) : ( @@ -161,10 +165,12 @@ export function ClubRideCard({
@@ -182,6 +188,9 @@ export function UpcomingRidesSection({ onViewRoute, }: UpcomingRidesSectionProps) { const params = useParams(); + const searchParams = useSearchParams(); + const isDemo = searchParams?.get('preview') === 'demo'; + const queryParam = isDemo ? '?preview=demo' : ''; const targetClubId = clubId || (typeof params?.clubId === 'string' ? params.clubId : ''); return ( @@ -197,13 +206,13 @@ export function UpcomingRidesSection({ {rides.length > 0 && ( - Full Ride Schedule - schedule - + arrow_forward + )} @@ -212,8 +221,7 @@ export function UpcomingRidesSection({ icon="route" title="No Club Rides are currently scheduled." description="Plan a group route, designate pacelines, and invite club riders." - actionLabel={permissions.canPlanRides ? 'Plan Club Ride' : undefined} - onAction={permissions.canPlanRides ? onPlanRide : undefined} + actionLabel={undefined} className="py-10" /> ) : ( diff --git a/website/components/founder-portal/founder-rides.tsx b/website/components/founder-portal/founder-rides.tsx new file mode 100644 index 0000000..011d72e --- /dev/null +++ b/website/components/founder-portal/founder-rides.tsx @@ -0,0 +1,344 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import Link from 'next/link'; +import { Club, ClubUpcomingRide, UserPortalPermissions } from './types'; +import { EmptyState } from './empty-state'; +import { ClubRideCard } from './club-ride-card'; + +interface FounderRidesProps { + club: Club; + permissions: UserPortalPermissions; + upcomingRides: ClubUpcomingRide[]; + pastRides: ClubUpcomingRide[]; + isLoading?: boolean; + error?: string | null; + onPlanRide?: () => void; + onManageRide?: (rideId: string) => void; + onViewRoster?: (rideId: string) => void; + onViewRoute?: (routeId?: string) => void; +} + +type TabType = 'upcoming' | 'completed' | 'all'; + +export function FounderRides({ + club, + permissions, + upcomingRides, + pastRides, + isLoading = false, + error = null, + onPlanRide, + onManageRide, + onViewRoster, + onViewRoute, +}: FounderRidesProps) { + const [activeTab, setActiveTab] = useState('upcoming'); + const [searchQuery, setSearchQuery] = useState(''); + const [paceFilter, setPaceFilter] = useState('all'); + + const allRides = useMemo(() => { + return [...upcomingRides, ...pastRides]; + }, [upcomingRides, pastRides]); + + // Available pace options from actual data + const paceOptions = useMemo(() => { + const paces = new Set(); + for (const r of allRides) { + if (r.paceClassification) paces.add(r.paceClassification); + } + return Array.from(paces); + }, [allRides]); + + // Tab-filtered rides + const tabRides = useMemo(() => { + if (activeTab === 'upcoming') return upcomingRides; + if (activeTab === 'completed') return pastRides; + return allRides; + }, [activeTab, upcomingRides, pastRides, allRides]); + + // Search & pace filtered rides + const filteredRides = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return tabRides.filter((ride) => { + const matchesSearch = + !q || + ride.title.toLowerCase().includes(q) || + ride.rideIdentifier.toLowerCase().includes(q) || + ride.roadCaptainName.toLowerCase().includes(q) || + (ride.roadCaptainHandle && ride.roadCaptainHandle.toLowerCase().includes(q)) || + (ride.description && ride.description.toLowerCase().includes(q)); + + const matchesPace = paceFilter === 'all' || ride.paceClassification === paceFilter; + + return matchesSearch && matchesPace; + }); + }, [tabRides, searchQuery, paceFilter]); + + // Metrics summary + const upcomingCount = upcomingRides.length; + const confirmedRidersTotal = upcomingRides.reduce((sum, r) => sum + r.confirmedCount, 0); + const completedCount = pastRides.length; + + if (isLoading) { + return ( +
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+
+ ); + } + + if (error) { + return ( +
+ error +

Unable to Load Club Rides

+

{error}

+
+ ); + } + + return ( +
+ {/* Header Section */} +
+
+
+

+ Club Rides +

+ + {upcomingCount} Scheduled + +
+

+ Schedule, coordinate, and review group rides and road captain assignments. +

+
+ +
+ add_road + Plan Club Ride + + SOON + +
+
+ + {/* Architecture & Privacy Notice Banner */} +
+
+ + shield + +
+

+ Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location. +

+

+ Discovery Pending: GroupRide.clubId is live, but broad Founder Portal discovery remains disabled until mutual-block privacy can be preserved. +

+
+
+
+ + {/* Metrics Summary Strip (4 Cards) */} +
+ {/* Card 1: Upcoming Rides */} +
+
+ UPCOMING RIDES + route +
+
+
+ {upcomingCount} +
+
+ Active & scheduled +
+
+
+ + {/* Card 2: Confirmed Riders */} +
+
+ CONFIRMED RIDERS + group +
+
+
+ {confirmedRidersTotal} +
+
+ Across upcoming rides +
+
+
+ + {/* Card 3: Completed Rides */} +
+
+ COMPLETED RIDES + task_alt +
+
+
+ {completedCount} +
+
+ Recorded club events +
+
+
+ + {/* Card 4: Club Miles */} +
+
+ CLUB MILES + lock +
+
+
+ -- +
+
+ Completed Club Rides only +
+
+
+ Calculated from eligible completed Club Rides. Private rider mileage is not automatically included. +
+
+
+ + {/* Tabs & Search Toolbar */} +
+ {/* Navigation Tabs */} +
+ + + +
+ + {/* Search & Pace Filter */} +
+ {/* Search Input */} +
+ + search + + setSearchQuery(e.target.value)} + placeholder="Search rides or captains..." + className="w-full h-8 pl-8 pr-3 rounded-lg bg-surface-container-low border border-brand-border/60 text-white placeholder:text-slate-500 font-body text-xs focus:outline-none focus:border-brand-cyan transition-colors" + aria-label="Search rides or road captains" + /> + {searchQuery && ( + + )} +
+ + {/* Pace Filter Dropdown */} + {paceOptions.length > 0 && ( + + )} +
+
+ + {/* Ride List */} + {filteredRides.length === 0 ? ( + + ) : ( +
+ {filteredRides.map((ride) => ( + + ))} +
+ )} +
+ ); +} diff --git a/website/components/founder-portal/founder-sidebar.tsx b/website/components/founder-portal/founder-sidebar.tsx index ca2c616..5bd75ab 100644 --- a/website/components/founder-portal/founder-sidebar.tsx +++ b/website/components/founder-portal/founder-sidebar.tsx @@ -58,9 +58,9 @@ export function FounderSidebar({ id: 'rides', label: 'Rides', icon: 'route', - badge: 'SOON', - badgeType: 'disabled', - disabled: true, + badge: upcomingRidesCount > 0 ? `${upcomingRidesCount}` : undefined, + badgeType: 'secondary', + disabled: false, }, { id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true }, { id: 'wall', label: 'Wall', icon: 'forum', badge: 'SOON', badgeType: 'disabled', disabled: true }, @@ -78,6 +78,7 @@ export function FounderSidebar({ if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`; if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`; if (id === 'ranks_roles') return `/clubs/${clubParam}/manage/ranks-roles${queryParam}`; + if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`; if (id === 'settings') return `/clubs/${clubParam}/manage/settings${queryParam}`; return undefined; } @@ -100,7 +101,8 @@ export function FounderSidebar({ const isProfile = item.id === 'profile'; const isSettings = item.id === 'settings'; const isApplications = item.id === 'applications'; - const isSubRoute = isProfile || isSettings || isApplications || item.id === 'ranks_roles'; + const isRides = item.id === 'rides'; + const isSubRoute = isProfile || isSettings || isApplications || isRides || item.id === 'ranks_roles'; if (item.disabled) { return ( diff --git a/website/components/founder-portal/founder-top-bar.tsx b/website/components/founder-portal/founder-top-bar.tsx index a724d34..78add09 100644 --- a/website/components/founder-portal/founder-top-bar.tsx +++ b/website/components/founder-portal/founder-top-bar.tsx @@ -92,6 +92,12 @@ export function FounderTopBar({ Ranks & Roles )} + {activeNav === 'rides' && ( + <> + / + Rides + + )} {activeNav === 'settings' && ( <> / diff --git a/website/components/founder-portal/types.ts b/website/components/founder-portal/types.ts index b75480e..86f1a15 100644 --- a/website/components/founder-portal/types.ts +++ b/website/components/founder-portal/types.ts @@ -63,21 +63,22 @@ export interface ClubMember { } /** - * Club-affiliated upcoming ride model. + * Club-affiliated ride model (Founder Portal presentation view). * - * CANONICAL AFFILIATION CONTRACT: - * - Authoritative link: GroupRide.clubId == club.id - * - Affiliation is NEVER inferred from accepted rosters, participant lists, - * organizer membership in a Club, accessUids, or live presence. + * ARCHITECTURAL STATUS: + * - Production GroupRide documents support optional immutable clubId affiliation. + * - The canonical link is GroupRide.clubId == club.id. + * - Broad client-side Club discovery remains deferred for mutual-block privacy. * - Hierarchy: Club (/clubs/{clubId}) -> GroupRide (/group_rides/{groupRideId}) -> Route (/routes/{routeId}) * - PRIVACY INVARIANT: ClubMember != GroupRideParticipant. * Club membership alone never grants access to rider location or ride presence. + * - LIFECYCLE: Completion is indicated strictly by status == 'completed' (no completion timestamp). * * ROUTE METRICS CONTRACT: - * - Derived from MotorSignalRoute (/routes/{routeId}). - * - straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted). - * - estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates. - * - When unpopulated or optional, UI displays '--' without fabricating geometry or duration. + * - straightLineDistanceMeters: Geodesic Haversine distance between ordered stops (persisted) + * - estimatedDistanceMeters & estimatedDurationSeconds: Optional Valhalla road estimates + * - routeDistanceMeters is a STRAIGHT-LINE snapshot, NOT a guaranteed road distance. + * - UI displays '--' when distance/duration is unpopulated without fabricating values. */ export interface ClubUpcomingRide { id: string; @@ -85,7 +86,7 @@ export interface ClubUpcomingRide { title: string; scheduledStartAt: string; // formatted or ISO departureAt?: string; - distanceMiles?: number | null; + distanceMiles?: number | null; // Straight-line preview in miles durationString?: string | null; paceClassification: string; // e.g. "Brisk Paceline", "Scenic Pace" roadCaptainName: string; @@ -98,6 +99,9 @@ export interface ClubUpcomingRide { capacity?: number | null; briefingTime?: string | null; routeId?: string | null; + status?: string | null; + organizerUid?: string | null; + description?: string | null; } export interface ClubMetrics { diff --git a/website/lib/club-data.ts b/website/lib/club-data.ts index 6d01997..76642a8 100644 --- a/website/lib/club-data.ts +++ b/website/lib/club-data.ts @@ -15,6 +15,7 @@ import { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS, FIXTURE_UPCOMING_RIDES, + FIXTURE_PAST_RIDES, FIXTURE_PENDING_APPLICATIONS, } from './club-fixtures'; @@ -1172,4 +1173,42 @@ export async function declineClubApplication( }); } +export interface ClubRidesResult { + upcomingRides: ClubUpcomingRide[]; + pastRides: ClubUpcomingRide[]; +} + +/** + * Supplies the Founder Portal Club Rides scaffold. + * + * CURRENT PRODUCTION CONTRACT: + * - GroupRide.clubId is persisted by the Flutter application. + * - Firestore intentionally does not authorize a broad client-side clubId list query, + * because discovery must preserve mutual-block privacy. + * - Production callers must not infer Club membership as GroupRide participation or + * live-location access. + * - The explicit development preview remains fixture-backed. + */ +export async function fetchClubRides( + clubId: string, + _callerUid?: string, + options?: { allowDemoPreview?: boolean } +): Promise { + const normalized = (clubId || '').trim(); + + if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { + return { + upcomingRides: FIXTURE_UPCOMING_RIDES, + pastRides: FIXTURE_PAST_RIDES, + }; + } + + if (!normalized) { + throw new Error('not-found'); + } + + throw new Error( + 'Club ride discovery is not available until a privacy-preserving production read path is implemented.' + ); +} diff --git a/website/lib/club-fixtures.ts b/website/lib/club-fixtures.ts index 4fd1e5f..3f478ee 100644 --- a/website/lib/club-fixtures.ts +++ b/website/lib/club-fixtures.ts @@ -121,8 +121,50 @@ export const FIXTURE_PENDING_APPLICATIONS: ClubMember[] = [ }, ]; +export const FIXTURE_PAST_RIDES: ClubUpcomingRide[] = [ + { + id: 'ride-ms-atl-880', + rideIdentifier: 'MS-ATL-880', + title: 'Russell-Brasstown Scenic Byway Loop', + scheduledStartAt: 'Oct 11 · 8:00 AM', + distanceMiles: 118, + durationString: '3h 10m', + paceClassification: 'Brisk Paceline', + roadCaptainName: 'Dave K.', + roadCaptainMotorcycle: 'Yamaha MT-09 SP', + sweepName: 'Marcus Vance', + sweepStatus: 'assigned', + confirmedCount: 22, + pendingCount: 0, + capacity: 25, + routeId: 'rt-russell-brasstown-01', + status: 'completed', + description: 'Crisp autumn run across the highest gap in Georgia with sweep radar coordination.', + }, + { + id: 'ride-ms-atl-879', + rideIdentifier: 'MS-ATL-879', + title: 'Suches & Wolf Pen Shakedown', + scheduledStartAt: 'Sep 27 · 8:30 AM', + distanceMiles: 95, + durationString: '2h 30m', + paceClassification: 'Sport Pace', + roadCaptainName: 'Marcus Vance', + roadCaptainMotorcycle: 'Ducati Panigale V4', + sweepName: 'Elena Rostova', + sweepStatus: 'assigned', + confirmedCount: 18, + pendingCount: 0, + capacity: 20, + routeId: 'rt-suches-wolf-pen-02', + status: 'completed', + description: 'Technical mountain shakedown ride testing sensor fusion lean angle and paceline spacing.', + }, +]; + export const CANONICAL_FOUNDER_CLUB = FIXTURE_FOUNDER_CLUB; export const CANONICAL_METRICS = FIXTURE_METRICS; export const CANONICAL_UPCOMING_RIDES = FIXTURE_UPCOMING_RIDES; +export const CANONICAL_PAST_RIDES = FIXTURE_PAST_RIDES; export const CANONICAL_PENDING_APPLICATIONS = FIXTURE_PENDING_APPLICATIONS; diff --git a/website/tests/founder-portal.test.cjs b/website/tests/founder-portal.test.cjs index ca5ddaf..0e72a39 100644 --- a/website/tests/founder-portal.test.cjs +++ b/website/tests/founder-portal.test.cjs @@ -70,8 +70,9 @@ const { approveClubApplication, declineClubApplication, validateClubImageUrl, + fetchClubRides, } = clubData; -const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS } = clubFixtures; +const { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS, FIXTURE_UPCOMING_RIDES, FIXTURE_PAST_RIDES } = clubFixtures; // ========================================================================= // TEST 1: Role-Based Portal Access (Owner vs Admin vs Member vs Unauthenticated) @@ -293,17 +294,16 @@ async function runAllTests() { console.log(' PASS: Founder identity contract verified (ownerUid is identity only; profile read via /public_profiles/{ownerUid}; conservative fallback).'); // ========================================================================= - // TEST 12: FP-1.3 Club Ride Association Contract (GroupRide.clubId == club.id) + // TEST 12: FP-1.3 Club Ride Association Architecture Status // ========================================================================= - console.log('Testing: FP-1.3 Canonical Club Ride Association (GroupRide.clubId == club.id)...'); + console.log('Testing: FP-1.3 Club Ride Association Architecture Status...'); - // Verify canonical query in club-data.ts - assert(clubDataSrc.includes("where('clubId', '==', resolvedClubId)"), 'Upcoming rides must query strictly where clubId == resolvedClubId'); - assert(typesSrc.includes('Authoritative link: GroupRide.clubId == club.id'), 'Upcoming rides type must document canonical link'); - assert(typesSrc.includes('Affiliation is NEVER inferred from accepted rosters'), 'Must document non-inference from rosters'); + assert(typesSrc.includes('Production GroupRide documents support optional immutable clubId affiliation'), 'Types must document live optional GroupRide.clubId support'); + assert(typesSrc.includes('GroupRide.clubId == club.id'), 'Types must document the canonical Club affiliation link'); + assert(typesSrc.includes('Broad client-side Club discovery remains deferred for mutual-block privacy'), 'Types must document the deferred discovery boundary'); assert(typesSrc.includes('ClubMember != GroupRideParticipant'), 'Must document ClubMember != GroupRideParticipant invariant'); - console.log(' PASS: Club Ride Association strictly bound to GroupRide.clubId == club.id (never inferred from rosters/accessUids).'); + console.log(' PASS: Club affiliation is documented as live while broad privacy-preserving discovery remains deferred.'); // ========================================================================= // TEST 13: FP-1.3 Route Distance & Duration Storage Contract @@ -947,7 +947,98 @@ async function runAllTests() { console.log(' PASS: Ranks-Roles canManageSettings guard verified (owner/admin allowed, member/unauth denied, demo-preview preserved); HTTPS image URL invariant enforced in app layer and Firestore rules (https accepted, blank clears, http/data/blob/javascript/malformed rejected).'); - console.log('--- ALL FOUNDER PORTAL TESTS PASSED (24/24) ---'); + // ========================================================================= + // TEST 25: FP-16 Clubs Rides Management Suite (Truthfulness Hardening) + // ========================================================================= + console.log('Testing: FP-16 Clubs Rides Management Suite (Truthfulness Hardening)...'); + + const ridesPageSrc = fs.readFileSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/rides/page.tsx'), 'utf8'); + const founderRidesSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/founder-rides.tsx'), 'utf8'); + const clubRideCardSrc = fs.readFileSync(path.resolve(__dirname, '../components/founder-portal/club-ride-card.tsx'), 'utf8'); + const clubFixturesSrc = fs.readFileSync(path.resolve(__dirname, '../lib/club-fixtures.ts'), 'utf8'); + + // A. Route exists and reuses FounderPortalShell with activeNav="rides" + assert(fs.existsSync(path.resolve(__dirname, '../app/clubs/[clubId]/manage/rides/page.tsx')), 'Rides page route must exist'); + assert(ridesPageSrc.includes(' 0, 'FIXTURE_PAST_RIDES must not be empty'); + assert.equal(FIXTURE_PAST_RIDES[0].status, 'completed', 'Past rides must have completed status'); + + console.log(' PASS: FP-16 Tests A through N verified (Route exists, Owner/Admin access, Member denial, Unauth denial, discovery-pending production state, unsupported broad query absent, actions disabled, model corrections, privacy notice, dev-gated preview, production fixture isolation, Sidebar/TopBar, fixtures valid).'); + + console.log('--- ALL FOUNDER PORTAL TESTS PASSED (25/25) ---'); } runAllTests().catch((err) => {