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.
This commit is contained in:
Billie Ponder 2026-09-20 07:23:00 -04:00 committed by GitHub
parent 68d4539f90
commit 59900f46f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 824 additions and 40 deletions

View File

@ -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<FounderClubContext | null>(null);
const [upcomingRides, setUpcomingRides] = useState<ClubUpcomingRide[]>([]);
const [pastRides, setPastRides] = useState<ClubUpcomingRide[]>([]);
const [pendingApplicationsCount, setPendingApplicationsCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
<div className="max-w-5xl mx-auto">
<OverviewSkeleton />
</div>
</div>
);
}
// Unauthenticated safe error state
if (unauthenticated) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Authentication Required"
message="Sign in with your rider account to access club rides."
actionLabel="Sign In"
onAction={() => router.push('/login')}
/>
</div>
);
}
// Unauthorized (Member or non-admin) safe error state
if (unauthorized) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Management Access Restricted"
message="You must be an Owner or Admin of this Club to manage Club Rides."
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
/>
</div>
);
}
// Error or missing context
if (error || !context) {
return (
<div className="min-h-screen bg-surface flex items-center justify-center p-6">
<SafeErrorState
title="Rides Unavailable"
message={error ?? "Club rides couldn't be loaded."}
actionLabel="Return to Clubs"
onAction={() => router.push('/clubs')}
onRetry={loadData}
/>
</div>
);
}
// 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 (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="rides"
pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={0}
>
<div className="space-y-6" aria-label="Club Rides Management">
{/* Header Section */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="font-headline text-xl sm:text-2xl font-bold text-white tracking-tight">
Club Rides
</h1>
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-400/10 text-amber-400 border border-amber-400/30 font-semibold">
DISCOVERY PENDING
</span>
</div>
<p className="font-body text-xs text-slate-400 mt-1">
Schedule, coordinate, and review group rides and road captain assignments.
</p>
</div>
<div
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-400 font-body text-xs cursor-not-allowed select-none"
title="Founder Portal ride planning is not wired yet"
aria-disabled="true"
>
<span className="material-symbols-outlined text-[17px] text-slate-500">add_road</span>
<span className="text-slate-400">Plan Club Ride</span>
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-400 border border-brand-border/60">
SOON
</span>
</div>
</div>
{/* Pending Backend Integration Notice */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-6 sm:p-8 text-center space-y-4 max-w-2xl mx-auto my-8">
<div className="w-12 h-12 rounded-2xl bg-amber-400/10 border border-amber-400/30 flex items-center justify-center text-amber-400 mx-auto">
<span className="material-symbols-outlined text-[28px]">route</span>
</div>
<div className="space-y-2">
<h2 className="font-headline text-lg sm:text-xl font-bold text-white">
Club Rides Discovery Pending
</h2>
<p className="font-body text-xs sm:text-sm text-slate-400 max-w-lg mx-auto leading-relaxed">
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.
</p>
</div>
<div className="pt-2 border-t border-brand-border/40 text-left text-xs text-slate-400 space-y-2 bg-surface-container-low/60 rounded-xl p-4">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] text-brand-cyan shrink-0 mt-0.5">info</span>
<span>
<strong>Architecture Note:</strong> Group rides now link to clubs through <code className="font-mono text-[11px] text-brand-cyan">GroupRide.clubId == club.id</code>. Club-wide discovery is a separate deferred capability.
</span>
</div>
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-[16px] text-brand-cyan shrink-0 mt-0.5">shield</span>
<span>
<strong>Privacy Invariant:</strong> Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
</span>
</div>
</div>
</div>
</div>
</FounderPortalShell>
);
}
// Explicit Development Demo Preview: Renders full interactive UI with fixtures
return (
<FounderPortalShell
club={context.club}
permissions={context.permissions}
activeNav="rides"
pendingApplicationsCount={pendingApplicationsCount}
upcomingRidesCount={upcomingRides.length}
>
<FounderRides
club={context.club}
permissions={context.permissions}
upcomingRides={upcomingRides}
pastRides={pastRides}
isLoading={false}
error={null}
/>
</FounderPortalShell>
);
}

View File

@ -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 */}
<div className="grid grid-cols-3 gap-2 py-2.5 bg-surface-container-low/80 rounded-xl px-3.5 my-3 border border-brand-border/60">
<div>
<div className="font-mono text-[10px] text-slate-400">Distance</div>
<div className="font-mono text-[10px] text-slate-400">Straight-line distance</div>
<div className="font-headline text-sm font-semibold text-slate-100 mt-0.5">
{ride.distanceMiles ? `${ride.distanceMiles} mi` : '--'}
</div>
@ -131,20 +131,24 @@ export function ClubRideCard({
</div>
</div>
{/* Actions */}
{/* Actions: Disabled until backend club-ride management ships */}
<div className="mt-4 pt-3 border-t border-brand-border/60 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{permissions.canPlanRides ? (
<button
onClick={() => onManageRide?.(ride.id)}
className="h-9 px-3.5 rounded-xl bg-primary-container text-on-primary-container font-body text-xs font-bold hover:brightness-110 shadow-sm transition-all cursor-pointer"
type="button"
disabled
title="Ride management is planned for a future release"
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
>
Manage Ride
</button>
) : (
<button
onClick={() => onViewRoster?.(ride.id)}
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-200 font-body text-xs font-medium hover:bg-surface-container-high transition-colors cursor-pointer"
type="button"
disabled
title="Ride details are planned for a future release"
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs font-medium cursor-not-allowed flex items-center justify-center"
>
View Ride
</button>
@ -161,10 +165,12 @@ export function ClubRideCard({
</div>
<button
onClick={() => onViewRoute?.(ride.routeId ?? undefined)}
className="h-9 px-3.5 rounded-xl bg-surface-container border border-brand-border/60 text-slate-300 hover:text-white font-body text-xs flex items-center gap-1.5 transition-colors cursor-pointer"
type="button"
disabled
title="Route navigation is planned for a future release"
className="h-9 px-3.5 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-500 font-body text-xs flex items-center gap-1.5 cursor-not-allowed select-none"
>
<span className="material-symbols-outlined text-[15px] text-brand-cyan">map</span>
<span className="material-symbols-outlined text-[15px] text-slate-600">map</span>
<span>View Route</span>
</button>
</div>
@ -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({
</div>
{rides.length > 0 && (
<span
title="Full ride schedule is planned for a future release"
className="font-mono text-xs text-slate-500 flex items-center gap-1 cursor-not-allowed"
<Link
href={`/clubs/${targetClubId}/manage/rides${queryParam}`}
className="font-mono text-xs text-brand-cyan hover:text-white flex items-center gap-1 transition-colors"
>
<span>Full Ride Schedule</span>
<span className="material-symbols-outlined text-[13px] text-slate-600">schedule</span>
</span>
<span className="material-symbols-outlined text-[13px]">arrow_forward</span>
</Link>
)}
</div>
@ -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"
/>
) : (

View File

@ -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<TabType>('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<string>();
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 (
<div className="space-y-6 animate-pulse" aria-label="Loading club rides">
<div className="h-8 bg-surface-container-high rounded-xl w-64" />
<div className="h-20 bg-surface-container-high rounded-2xl" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-28 bg-surface-container-high rounded-2xl" />
))}
</div>
<div className="h-64 bg-surface-container-high rounded-2xl" />
</div>
);
}
if (error) {
return (
<div className="rounded-2xl bg-error-container/20 border border-error/30 p-6 text-center space-y-3">
<span className="material-symbols-outlined text-error text-[36px]">error</span>
<h3 className="font-headline text-base font-bold text-white">Unable to Load Club Rides</h3>
<p className="font-body text-xs text-slate-400 max-w-md mx-auto">{error}</p>
</div>
);
}
return (
<div className="space-y-6" aria-label="Club Rides Management">
{/* Header Section */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2.5">
<h1 className="font-headline text-xl sm:text-2xl font-bold text-white tracking-tight">
Club Rides
</h1>
<span className="font-mono text-xs px-2 py-0.5 rounded-full bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/30 font-semibold">
{upcomingCount} Scheduled
</span>
</div>
<p className="font-body text-xs text-slate-400 mt-1">
Schedule, coordinate, and review group rides and road captain assignments.
</p>
</div>
<div
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-surface-container/50 border border-brand-border/40 text-slate-400 font-body text-xs cursor-not-allowed select-none"
title="Founder Portal ride planning is not wired yet"
aria-disabled="true"
>
<span className="material-symbols-outlined text-[17px] text-slate-500">add_road</span>
<span className="text-slate-400">Plan Club Ride</span>
<span className="font-mono text-[9px] px-1.5 py-0.5 rounded bg-surface-container text-slate-400 border border-brand-border/60">
SOON
</span>
</div>
</div>
{/* Architecture & Privacy Notice Banner */}
<div className="rounded-2xl bg-surface-container-low border border-brand-border/60 p-4 space-y-2">
<div className="flex items-start gap-2.5">
<span className="material-symbols-outlined text-[18px] text-brand-cyan shrink-0 mt-0.5">
shield
</span>
<div className="space-y-1 text-xs">
<p className="font-body text-slate-300">
Live location is shared only with eligible ride participants while sharing is enabled. Club membership alone never grants access to rider location.
</p>
<p className="font-mono text-[11px] text-slate-400">
Discovery Pending: GroupRide.clubId is live, but broad Founder Portal discovery remains disabled until mutual-block privacy can be preserved.
</p>
</div>
</div>
</div>
{/* Metrics Summary Strip (4 Cards) */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
{/* Card 1: Upcoming Rides */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="font-mono text-xs tracking-wider">UPCOMING RIDES</span>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">route</span>
</div>
<div className="mt-3">
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
{upcomingCount}
</div>
<div className="font-body text-[11px] text-slate-400 mt-0.5">
Active &amp; scheduled
</div>
</div>
</div>
{/* Card 2: Confirmed Riders */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="font-mono text-xs tracking-wider">CONFIRMED RIDERS</span>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">group</span>
</div>
<div className="mt-3">
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
{confirmedRidersTotal}
</div>
<div className="font-body text-[11px] text-slate-400 mt-0.5">
Across upcoming rides
</div>
</div>
</div>
{/* Card 3: Completed Rides */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="font-mono text-xs tracking-wider">COMPLETED RIDES</span>
<span className="material-symbols-outlined text-[18px] text-brand-cyan">task_alt</span>
</div>
<div className="mt-3">
<div className="font-headline text-2xl sm:text-3xl font-bold text-white">
{completedCount}
</div>
<div className="font-body text-[11px] text-slate-400 mt-0.5">
Recorded club events
</div>
</div>
</div>
{/* Card 4: Club Miles */}
<div className="rounded-2xl glass-card border border-brand-border/60 p-4 sm:p-5 flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400">
<span className="font-mono text-xs tracking-wider">CLUB MILES</span>
<span className="material-symbols-outlined text-[18px] text-slate-500">lock</span>
</div>
<div className="mt-3">
<div className="font-headline text-2xl sm:text-3xl font-bold text-slate-400">
--
</div>
<div className="font-mono text-[10px] text-slate-500 mt-0.5">
Completed Club Rides only
</div>
</div>
<div className="mt-2 text-[10px] text-slate-500 font-body border-t border-brand-border/40 pt-1.5">
Calculated from eligible completed Club Rides. Private rider mileage is not automatically included.
</div>
</div>
</div>
{/* Tabs & Search Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-1 border-b border-brand-border/60">
{/* Navigation Tabs */}
<div className="flex items-center gap-1 bg-surface-container-low p-1 rounded-xl border border-brand-border/60">
<button
onClick={() => setActiveTab('upcoming')}
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
activeTab === 'upcoming'
? 'bg-primary-container text-on-primary-container'
: 'text-slate-400 hover:text-white'
}`}
>
Upcoming ({upcomingCount})
</button>
<button
onClick={() => setActiveTab('completed')}
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
activeTab === 'completed'
? 'bg-primary-container text-on-primary-container'
: 'text-slate-400 hover:text-white'
}`}
>
Completed ({completedCount})
</button>
<button
onClick={() => setActiveTab('all')}
className={`px-3 py-1.5 rounded-lg font-mono text-xs font-semibold transition-colors cursor-pointer ${
activeTab === 'all'
? 'bg-primary-container text-on-primary-container'
: 'text-slate-400 hover:text-white'
}`}
>
All ({allRides.length})
</button>
</div>
{/* Search & Pace Filter */}
<div className="flex items-center gap-2">
{/* Search Input */}
<div className="relative min-w-[180px] sm:min-w-[220px]">
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500 text-[16px]">
search
</span>
<input
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white text-xs"
aria-label="Clear search"
>
✕
</button>
)}
</div>
{/* Pace Filter Dropdown */}
{paceOptions.length > 0 && (
<select
value={paceFilter}
onChange={(e) => setPaceFilter(e.target.value)}
className="h-8 px-2.5 rounded-lg bg-surface-container-low border border-brand-border/60 text-slate-300 font-body text-xs focus:outline-none focus:border-brand-cyan cursor-pointer"
aria-label="Filter by pace classification"
>
<option value="all">All Paces</option>
{paceOptions.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
)}
</div>
</div>
{/* Ride List */}
{filteredRides.length === 0 ? (
<EmptyState
icon="route"
title={
searchQuery || paceFilter !== 'all'
? 'No rides match the selected filters.'
: activeTab === 'completed'
? 'No completed club rides recorded.'
: 'No upcoming club rides scheduled.'
}
description={
searchQuery || paceFilter !== 'all'
? 'Try adjusting your search query or pace filter.'
: activeTab === 'completed'
? 'Completed group rides with telemetry bundles will appear here.'
: 'Scheduled club rides will appear here once club ride coordination is active.'
}
className="py-12"
/>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{filteredRides.map((ride) => (
<ClubRideCard
key={ride.id}
ride={ride}
permissions={permissions}
clubId={club.slug || club.id}
onManageRide={onManageRide}
onViewRoster={onViewRoster}
onViewRoute={onViewRoute}
/>
))}
</div>
)}
</div>
);
}

View File

@ -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 (

View File

@ -92,6 +92,12 @@ export function FounderTopBar({
<span className="text-brand-cyan font-semibold truncate">Ranks &amp; Roles</span>
</>
)}
{activeNav === 'rides' && (
<>
<span className="text-outline-variant shrink-0">/</span>
<span data-breadcrumb="RIDES" className="text-brand-cyan font-semibold truncate">Rides</span>
</>
)}
{activeNav === 'settings' && (
<>
<span className="text-outline-variant shrink-0">/</span>

View File

@ -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 {

View File

@ -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<ClubRidesResult> {
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.'
);
}

View File

@ -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;

View File

@ -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('<FounderPortalShell'), 'Rides page must reuse FounderPortalShell');
assert(ridesPageSrc.includes('activeNav="rides"'), 'Rides page must pass activeNav="rides"');
// B. Owner can access
assert.equal(ownerPerms.canPlanRides, true, 'Owner has canPlanRides authority');
assert.equal(ownerPerms.canManageSettings, true, 'Owner has canManageSettings authority');
// C. Admin can access
assert.equal(adminPerms.canPlanRides, true, 'Admin has canPlanRides authority');
assert.equal(adminPerms.canManageSettings, true, 'Admin has canManageSettings authority');
// D. Member denied management access
assert.equal(memberPerms.canPlanRides, false, 'Member must not have canPlanRides authority');
assert.equal(memberPerms.canManageSettings, false, 'Member must not have canManageSettings authority');
assert(ridesPageSrc.includes('Management Access Restricted'), 'Rides page must guard against unauthorized members');
// E. Unauthenticated denied
assert.equal(unauthPerms.isAuthenticated, false, 'Unauthenticated user has isAuthenticated = false');
assert.equal(unauthPerms.canPlanRides, false, 'Unauthenticated user has canPlanRides = false');
assert(ridesPageSrc.includes('Authentication Required'), 'Rides page must guard against unauthenticated users');
// F. Production rides page renders an explicit discovery-pending state, never a false empty schedule
assert(ridesPageSrc.includes('Club Rides Discovery Pending'), 'Production rides page must display Club Rides Discovery Pending notice');
assert(ridesPageSrc.includes('DISCOVERY PENDING'), 'Production rides page must display DISCOVERY PENDING badge');
assert(ridesPageSrc.includes('Club-affiliated Group Rides can now be created in the mobile application'), 'Must truthfully describe current mobile capability');
// G. fetchClubRides must not issue an unsupported broad production query
const fetchRidesBody = clubDataSrc.substring(clubDataSrc.lastIndexOf('export async function fetchClubRides'));
assert(!fetchRidesBody.includes("where('clubId'"), 'Scaffold fetchClubRides must not issue a broad client-side clubId query');
assert(fetchRidesBody.includes('Club ride discovery is not available until a privacy-preserving production read path is implemented.'), 'Production discovery must fail explicitly');
// H. Action hardening: All unsupported actions are disabled with explanatory tooltips
assert(founderRidesSrc.includes('title="Founder Portal ride planning is not wired yet"'), 'Plan Club Ride must be disabled with tooltip');
assert(founderRidesSrc.includes('SOON'), 'Plan Club Ride must have SOON badge');
assert(clubRideCardSrc.includes('title="Ride management is planned for a future release"'), 'Manage Ride must be disabled');
assert(clubRideCardSrc.includes('title="Ride details are planned for a future release"'), 'View Ride must be disabled');
assert(clubRideCardSrc.includes('title="Route navigation is planned for a future release"'), 'View Route must be disabled');
assert(clubRideCardSrc.includes('title="Member roster view is planned for a future release"'), 'View Roster must be disabled');
// I. Model corrections: completedAt removed, completion uses status == completed, routeDistanceMeters is straight-line
assert(!typesSrc.includes('completedAt'), 'types.ts must not define completedAt');
assert(!clubFixturesSrc.includes('completedAt'), 'club-fixtures.ts must not contain completedAt');
assert(typesSrc.includes("Completion is indicated strictly by status == 'completed'"), 'types.ts must document completion via status');
assert(typesSrc.includes('routeDistanceMeters is a STRAIGHT-LINE snapshot'), 'types.ts must document straight-line snapshot');
assert(clubRideCardSrc.includes('Straight-line distance'), 'club-ride-card.tsx must label distance as Straight-line distance');
// J. Location privacy invariant (ClubMember != GroupRideParticipant)
assert(founderRidesSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in FounderRides');
assert(ridesPageSrc.includes(verbatimNotice), 'Missing verbatim live location privacy notice in rides page');
// K. Demo preview development gating
assert(fetchRidesBody.includes("options?.allowDemoPreview && process.env.NODE_ENV === 'development'"), 'fetchClubRides must gate demo preview to development');
assert(ridesPageSrc.includes("searchParams?.get('preview') === 'demo'"), 'rides page must require ?preview=demo for demo mode');
// L. Production fixture isolation (never falls back to demo data in production)
let ridesProdThrew = false;
try {
process.env.NODE_ENV = 'production';
await fetchClubRides('', '');
} catch {
ridesProdThrew = true;
} finally {
process.env.NODE_ENV = 'test';
}
assert.equal(ridesProdThrew, true, 'fetchClubRides must reject empty clubId in production without fallback');
// M. Sidebar routing & TopBar breadcrumb
assert(sidebarSrc.includes("if (id === 'rides') return `/clubs/${clubParam}/manage/rides${queryParam}`;"), 'FounderSidebar must route to rides');
assert(sidebarSrc.includes("isRides = item.id === 'rides';"), 'FounderSidebar must treat rides as subroute');
assert(topBarSrc.includes("activeNav === 'rides'"), 'FounderTopBar must handle rides activeNav');
assert(topBarSrc.includes('RIDES'), 'FounderTopBar must include RIDES breadcrumb title');
// N. Fixture past rides present and valid
assert(Array.isArray(FIXTURE_PAST_RIDES), 'FIXTURE_PAST_RIDES must be an array');
assert(FIXTURE_PAST_RIDES.length > 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) => {