Merge pull request #38 from bponders12-cell/fix/website-production-hardening-20261014
fix(website): production hardening — truthful states, labelled examples, support center
This commit is contained in:
commit
d8670be7ea
|
|
@ -1,7 +1,7 @@
|
|||
# MotorSignal Website Environment Configuration
|
||||
#
|
||||
# Copy this file to .env.local for local development, or configure these
|
||||
# variables in your deployment environment (Vercel, Docker, VPS, etc.).
|
||||
# variables in your deployment environment (Vultr VPS, Caddy, Docker, etc.).
|
||||
#
|
||||
# Production Firebase project: motoapp-d22e0
|
||||
# Staging Firebase project: motorsignal-staging
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { onAuthStateChanged, User } from 'firebase/auth';
|
||||
import { onAuthStateChanged, multiFactor, User } from 'firebase/auth';
|
||||
import { firebaseAuth } from '../../lib/firebase-client';
|
||||
import { AppCloudData } from '../../components/app-cloud-data';
|
||||
import { AppCloudData, type CloudDataStatus } from '../../components/app-cloud-data';
|
||||
import { FirebaseLink } from '../../components/firebase-link';
|
||||
import { SignOutButton } from '../../components/sign-out-button';
|
||||
|
||||
|
|
@ -22,7 +22,12 @@ export default function AccountPage() {
|
|||
const [syncTrigger, setSyncTrigger] = useState(0);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [lastSynced, setLastSynced] = useState<string | null>(null);
|
||||
const [metrics, setMetrics] = useState({ totalMiles: 12480, rideCount: 184, peakLean: 56.4 });
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
// Truthful metrics & sync states:
|
||||
// Distinguish 'idle', 'loading', 'unauthenticated', 'ready', 'permission-denied', 'unavailable', and 'error'.
|
||||
// Never initialize unavailable information in a way that displays as verified zero metrics or 100% synced.
|
||||
const [metricsStatus, setMetricsStatus] = useState<CloudDataStatus>('loading');
|
||||
const [metrics, setMetrics] = useState<{ totalMiles: number; rideCount: number; peakLean: number } | null>(null);
|
||||
|
||||
// Active Device Sessions State
|
||||
const [sessions, setSessions] = useState<DeviceSession[]>([]);
|
||||
|
|
@ -38,10 +43,30 @@ export default function AccountPage() {
|
|||
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
|
||||
setUser(currentUser);
|
||||
setAuthChecked(true);
|
||||
if (!currentUser) {
|
||||
setMetricsStatus('unauthenticated');
|
||||
setMetrics(null);
|
||||
setSyncError(null);
|
||||
} else {
|
||||
setMetricsStatus('loading');
|
||||
setMetrics(null);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Truthful 2FA state: reflect the user's actual Firebase multi-factor enrollment.
|
||||
// The web app has no MFA enrollment flow, so this is read-only; enrollment is done in
|
||||
// the mobile app. Never claim 2FA is enabled unless a factor is actually enrolled.
|
||||
const twoFactorEnrolled = (() => {
|
||||
if (!user) return false;
|
||||
try {
|
||||
return multiFactor(user).enrolledFactors.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
async function loadSessions() {
|
||||
setIsLoadingSessions(true);
|
||||
try {
|
||||
|
|
@ -146,7 +171,10 @@ export default function AccountPage() {
|
|||
}
|
||||
|
||||
function handleQuickSync() {
|
||||
if (!user) return;
|
||||
setIsSyncing(true);
|
||||
setSyncError(null);
|
||||
setMetricsStatus('loading');
|
||||
setSyncTrigger((prev) => prev + 1);
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +252,15 @@ export default function AccountPage() {
|
|||
strokeWidth="2"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{isSyncing ? 'Syncing...' : lastSynced ? `Synced (${lastSynced})` : 'Quick Sync'}</span>
|
||||
<span>
|
||||
{isSyncing
|
||||
? 'Syncing...'
|
||||
: syncError
|
||||
? 'Sync Failed (Retry)'
|
||||
: lastSynced
|
||||
? `Synced (${lastSynced})`
|
||||
: 'Quick Sync'}
|
||||
</span>
|
||||
</button>
|
||||
<div className="px-3 py-1.5 rounded-lg bg-brand-cardAlt border border-brand-border text-xs font-mono text-rose-400">
|
||||
<SignOutButton />
|
||||
|
|
@ -247,46 +283,148 @@ export default function AccountPage() {
|
|||
{/* BEGIN: TelemetryKpiGrid */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-purpose="telemetry-overview-stats">
|
||||
{/* Metric 1: Distance */}
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-distance">
|
||||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||
<span>Total Distance Logged</span>
|
||||
{metricsStatus === 'loading' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||||
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync unavailable</span>
|
||||
) : (
|
||||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Aggregate</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-2">
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
<>
|
||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.totalMiles.toLocaleString()}</span>
|
||||
<span className="text-sm font-mono text-slate-400">miles</span>
|
||||
</>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
{metricsStatus === 'ready' ? (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
|
||||
<span>All-time telemetry aggregate</span>
|
||||
</>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span>Retrieving telemetry aggregate…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span>Sign in to view telemetry aggregate</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400">Access to cloud telemetry restricted</span>
|
||||
) : (
|
||||
<span className="text-amber-400">Telemetry aggregate unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 2: Sessions */}
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-sessions">
|
||||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||
<span>Recorded Sessions</span>
|
||||
{metricsStatus === 'loading' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Checking sync…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign-in required</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||||
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync status unavailable</span>
|
||||
) : metrics !== null && metrics.rideCount > 0 ? (
|
||||
<span className="text-brand-emerald bg-brand-emerald/10 px-1.5 py-0.5 rounded text-[10px]">100% Cloud Synced</span>
|
||||
) : (
|
||||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Synced</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-2">
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
<>
|
||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.rideCount}</span>
|
||||
<span className="text-sm font-mono text-slate-400">rides</span>
|
||||
</>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
metrics.rideCount > 0 ? (
|
||||
<span>Canyons, Circuits & Commutes</span>
|
||||
) : (
|
||||
<span>No rides recorded yet</span>
|
||||
)
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span>Checking cloud telemetry sessions…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span>Sign in to load synced rides</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400">Check Firestore permissions</span>
|
||||
) : (
|
||||
<span className="text-amber-400">Could not retrieve sessions from cloud</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 3: Lean Angle Peak */}
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200">
|
||||
<div className="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-lean">
|
||||
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||
<span>Peak Lean Angle</span>
|
||||
<span className="text-brand-cyan font-mono text-[10px]">Lateral Roll</span>
|
||||
{metricsStatus === 'loading' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
|
||||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||||
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync unavailable</span>
|
||||
) : (
|
||||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Lateral Roll</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}</span>
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
<>
|
||||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
||||
{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
||||
</>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
|
||||
<span className="text-amber-400">6-Axis IMU Verified</span>
|
||||
) : metricsStatus === 'loading' ? (
|
||||
<span>Retrieving lean telemetry…</span>
|
||||
) : metricsStatus === 'unauthenticated' ? (
|
||||
<span>Sign in to view lean telemetry</span>
|
||||
) : metricsStatus === 'permission-denied' ? (
|
||||
<span className="text-rose-400">Lean telemetry restricted</span>
|
||||
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
|
||||
<span className="text-amber-400">Lean telemetry unavailable</span>
|
||||
) : (
|
||||
<span className="text-slate-500">No IMU roll data</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Metric 4: Hardware Link */}
|
||||
|
|
@ -310,11 +448,26 @@ export default function AccountPage() {
|
|||
syncTrigger={syncTrigger}
|
||||
onSyncStateChange={(syncing) => {
|
||||
setIsSyncing(syncing);
|
||||
if (!syncing) {
|
||||
}}
|
||||
onStatusChange={(status, _err) => {
|
||||
setMetricsStatus(status);
|
||||
if (status === 'ready') {
|
||||
setSyncError(null);
|
||||
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
|
||||
setMetrics(null);
|
||||
setSyncError('Sync failed');
|
||||
} else if (status === 'unauthenticated') {
|
||||
setMetrics(null);
|
||||
setSyncError(null);
|
||||
} else if (status === 'loading') {
|
||||
setMetrics(null);
|
||||
}
|
||||
}}
|
||||
onMetricsLoaded={(m) => setMetrics(m)}
|
||||
onMetricsLoaded={(m) => {
|
||||
setMetrics(m);
|
||||
setMetricsStatus('ready');
|
||||
}}
|
||||
/>
|
||||
{/* BEGIN: PlanAndClusterSection */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="subscription-and-sync-status">
|
||||
|
|
@ -438,14 +591,14 @@ export default function AccountPage() {
|
|||
<div>
|
||||
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-4">
|
||||
<span className="text-xs font-mono tracking-normal text-brand-cyan">App Connections & Sync</span>
|
||||
<span className="text-xs font-mono text-brand-emerald flex items-center space-x-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
|
||||
<span>All Synced</span>
|
||||
<span className={`text-xs font-mono flex items-center space-x-1.5 ${user ? 'text-brand-emerald' : 'text-slate-400'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${user ? 'bg-brand-emerald' : 'bg-slate-500'}`}></span>
|
||||
<span>{user ? 'Account linked' : 'Not signed in'}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Sync List */}
|
||||
<div className="space-y-3 font-mono text-xs">
|
||||
{/* Google App Account */}
|
||||
{/* Google App Account (real: reflects signed-in Firebase user) */}
|
||||
<div className="p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-800 flex items-center justify-center font-bold text-slate-300">
|
||||
|
|
@ -460,37 +613,41 @@ export default function AccountPage() {
|
|||
{user ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
{/* MotorSignal Mobile App */}
|
||||
{/* Authenticated devices (real: from /api/account/sessions) */}
|
||||
<div className="p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-cyan-500/15 text-brand-cyan flex items-center justify-center">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white block font-sans font-medium">iOS Mobile App</span>
|
||||
<span className="text-slate-400 text-[11px]">iPhone 16 Pro • v4.1.0</span>
|
||||
<span className="text-white block font-sans font-medium">Authenticated Devices</span>
|
||||
<span className="text-slate-400 text-[11px]">
|
||||
{!user ? 'Sign in to view linked devices' : isLoadingSessions ? 'Checking sessions…' : 'Manage in Active Device Sessions below'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-slate-400 text-[11px]">14m ago</span>
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded border ${user && sessions.length > 0 ? 'text-brand-emerald bg-emerald-500/10 border-emerald-500/20' : 'text-slate-400 bg-slate-800 border-slate-700'}`}>
|
||||
{!user ? '—' : isLoadingSessions ? '…' : `${sessions.length} active`}
|
||||
</span>
|
||||
</div>
|
||||
{/* Comms / Cardo */}
|
||||
{/* Helmet comms / accessories — Bluetooth pairing is not observable from a browser */}
|
||||
<div className="p-3 bg-brand-cardAlt/70 rounded-lg border border-brand-border flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-800 text-slate-300 flex items-center justify-center">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white block font-sans font-medium">Helmet Comms Link</span>
|
||||
<span className="text-slate-400 text-[11px]">Cardo Packtalk Edge</span>
|
||||
<span className="text-white block font-sans font-medium">Helmet Comms & Accessories</span>
|
||||
<span className="text-slate-400 text-[11px]">Bluetooth pairing is managed in the mobile app</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-brand-emerald text-[11px] bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Paired</span>
|
||||
<span className="text-slate-400 text-[11px] bg-slate-800 px-2 py-0.5 rounded border border-slate-700">Unavailable on web</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 flex items-center justify-between text-xs font-mono">
|
||||
<span className="text-slate-400">Apple Watch Companion: <span className="text-slate-400 font-semibold">Not Connected (Unavailable on Web)</span></span>
|
||||
<button className="text-brand-cyan hover:underline font-sans font-medium">Manage Devices</button>
|
||||
<a href="#active-device-sessions" className="text-brand-cyan hover:underline font-sans font-medium">Manage Devices</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -498,7 +655,7 @@ export default function AccountPage() {
|
|||
{/* BEGIN: SecurityAndSessionsGrid */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="security-and-session-management">
|
||||
{/* Sessions (7 cols) */}
|
||||
<div className="lg:col-span-7 glass-card rounded-xl border border-brand-border p-6" data-purpose="active-browser-sessions">
|
||||
<div id="active-device-sessions" className="lg:col-span-7 glass-card rounded-xl border border-brand-border p-6 scroll-mt-24" data-purpose="active-browser-sessions">
|
||||
<div className="flex items-center justify-between border-b border-brand-border pb-4 mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Active Device Sessions</h3>
|
||||
|
|
@ -600,27 +757,31 @@ export default function AccountPage() {
|
|||
</div>
|
||||
<FirebaseLink linked={!!user} />
|
||||
</div>
|
||||
{/* 2FA State */}
|
||||
{/* 2FA State — reflects real Firebase multi-factor enrollment; enrollment is managed in the mobile app */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-brand-cardAlt border border-brand-border">
|
||||
<div>
|
||||
<span className="text-white font-bold block">Two-Factor Authentication (2FA)</span>
|
||||
<span className="text-slate-400 text-[11px]">Hardware TOTP Authenticator</span>
|
||||
<span className="text-slate-400 text-[11px]">{twoFactorEnrolled ? 'Authenticator enrolled' : 'Enroll in the mobile app'}</span>
|
||||
</div>
|
||||
{twoFactorEnrolled ? (
|
||||
<span className="text-brand-emerald bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20 font-bold">ENABLED</span>
|
||||
) : (
|
||||
<span className="text-slate-400 bg-slate-800 px-2 py-0.5 rounded border border-slate-700 font-bold">NOT ENABLED</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Leaderboard Privacy */}
|
||||
{/* Leaderboard Privacy — managed in the mobile app; the web app has no privacy toggle backend */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-brand-cardAlt border border-brand-border">
|
||||
<div>
|
||||
<span className="text-white font-bold block">Telemetry Privacy Mode</span>
|
||||
<span className="text-slate-400 text-[11px]">Public Leaderboard (Club Trophies)</span>
|
||||
<span className="text-slate-400 text-[11px]">Leaderboard visibility is managed in the mobile app</span>
|
||||
</div>
|
||||
<button className="text-brand-cyan hover:underline">Change</button>
|
||||
<span className="text-slate-400 text-[11px] bg-slate-800 px-2 py-0.5 rounded border border-slate-700">In mobile app</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-brand-border mt-4 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400 font-mono">Telemetry Data Retention: 100% Retained</span>
|
||||
<a className="text-brand-cyan hover:underline font-mono" href="/account">Security Logs</a>
|
||||
<a className="text-brand-cyan hover:underline font-mono" href="#active-device-sessions">Review active sessions</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) {
|
|||
const [members, setMembers] = useState<ClubMember[]>([]);
|
||||
const [permissions, setPermissions] = useState(DEFAULT_RANK_PERMISSIONS);
|
||||
const [selectedRankId, setSelectedRankId] = useState<string | null>('president');
|
||||
const [isSavingPermissions, setIsSavingPermissions] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unauthenticated, setUnauthenticated] = useState(false);
|
||||
|
|
@ -104,16 +103,6 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) {
|
|||
}));
|
||||
}
|
||||
|
||||
function handleResetDefaults() {
|
||||
setPermissions(DEFAULT_RANK_PERMISSIONS);
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
setIsSavingPermissions(true);
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
setIsSavingPermissions(false);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-bg telemetry-grid-bg p-6">
|
||||
|
|
@ -220,16 +209,23 @@ export default function ClubFounderRanksRolesPage({ params }: PageProps) {
|
|||
/>
|
||||
|
||||
{/* Interactive Permission Toggle Switches Matrix */}
|
||||
{/*
|
||||
Per-rank capability permissions have no server-side persistence contract yet.
|
||||
The enforced authorization model is role-based (Owner / Admin) via
|
||||
getPortalPermissions in lib/club-data.ts, not this rank matrix. Rendering the
|
||||
matrix read-only (persistenceAvailable={false}) keeps it truthful: it shows the
|
||||
standard governance conventions without claiming a save that does not occur.
|
||||
See website/docs/backend-contracts/club-rank-permissions.md for the proposed
|
||||
contract that would flip persistenceAvailable to true.
|
||||
*/}
|
||||
<ClubPermissionMatrix
|
||||
ranks={DEFAULT_CLUB_RANKS}
|
||||
capabilities={DEFAULT_CLUB_CAPABILITIES}
|
||||
permissions={permissions}
|
||||
selectedRankId={selectedRankId}
|
||||
onTogglePermission={handleTogglePermission}
|
||||
onResetDefaults={handleResetDefaults}
|
||||
onSavePermissions={handleSavePermissions}
|
||||
isSaving={isSavingPermissions}
|
||||
canEdit={context.permissions.canManageSettings}
|
||||
persistenceAvailable={false}
|
||||
/>
|
||||
</div>
|
||||
</FounderPortalShell>
|
||||
|
|
|
|||
|
|
@ -1 +1,72 @@
|
|||
export default function SupportPage() { return <section className="hero"><span className="eyebrow">Support</span><h1>Help for the road ahead.</h1><p>Account and product support will be available here as the MotorSignal web platform comes online.</p><a className="button" href="mailto:support@motorsignal.app">Contact support</a></section>; }
|
||||
import { SupportContactForm } from '../../components/support-contact-form';
|
||||
|
||||
const SUPPORT_EMAIL = 'support@motorsignal.app';
|
||||
|
||||
// Grounded in features that actually ship today. Keep answers truthful: anything managed
|
||||
// only in the mobile app, or not yet available on the web, is stated plainly.
|
||||
const FAQ: { q: string; a: string }[] = [
|
||||
{
|
||||
q: 'Can I use MotorSignal without an internet connection?',
|
||||
a: 'Yes. Ride recording and telemetry run on-device, so you can record offline and while signed out. Your rides sync to the cloud once you are signed in and back online — nothing is lost in the meantime.',
|
||||
},
|
||||
{
|
||||
q: 'How do I manage my subscription or find my invoices?',
|
||||
a: 'Subscriptions, payment methods, and invoices are managed in the MotorSignal mobile app. The web billing portal is not available yet, so the Account page will point you to the app for billing changes.',
|
||||
},
|
||||
{
|
||||
q: 'How do I turn on two-factor authentication (2FA)?',
|
||||
a: 'Two-factor authentication is enrolled in the mobile app. The web Account page reflects your real 2FA status — it will show “Enabled” only once a factor is actually enrolled.',
|
||||
},
|
||||
{
|
||||
q: 'Can I export my ride data?',
|
||||
a: 'Yes. On the web Account page, each ride can be exported as a GPX track or a CSV of its telemetry. Your historical exports are not altered after the fact.',
|
||||
},
|
||||
{
|
||||
q: 'How do I sign out other devices or browsers?',
|
||||
a: 'Open Account → Active Device Sessions. You can revoke an individual session, or revoke all sessions at once. The session you are currently using is marked “this device”.',
|
||||
},
|
||||
{
|
||||
q: 'Why don’t I see live riders on the web radar?',
|
||||
a: 'The Formation Radar shows live rider positions only for a group ride you are an accepted participant in. Outside of that it displays a clearly labelled demonstration, not real riders.',
|
||||
},
|
||||
{
|
||||
q: 'What’s the difference between the public Group Rides page and my hub?',
|
||||
a: 'Signed out, the Group Rides page is a public showcase with sample formations. Signed in, it becomes your hub where you create, manage, join, and leave real rides.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<section className="content-page">
|
||||
<span className="eyebrow">Support</span>
|
||||
<h1>Help for the road ahead.</h1>
|
||||
<p>
|
||||
Search the answers below for the quickest fix. If you still need a hand, send us an email and
|
||||
we’ll get back to you.
|
||||
</p>
|
||||
|
||||
<div className="panel" style={{ marginTop: 28 }}>
|
||||
<h2>Contact support</h2>
|
||||
<p>
|
||||
Reach the team at{' '}
|
||||
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>. Prefer a form? Fill this in and it will
|
||||
open a prefilled email for you to send.
|
||||
</p>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<SupportContactForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="faq-section">
|
||||
<span className="eyebrow">Common questions</span>
|
||||
<h2>Frequently asked</h2>
|
||||
{FAQ.map((item) => (
|
||||
<details key={item.q}>
|
||||
<summary>{item.q}</summary>
|
||||
<p>{item.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,15 +139,40 @@ function StockMotorcycleGraphic({ className = 'w-full h-40' }: { className?: str
|
|||
);
|
||||
}
|
||||
|
||||
interface AppCloudDataProps {
|
||||
syncTrigger?: number;
|
||||
onSyncStateChange?: (syncing: boolean) => void;
|
||||
onMetricsLoaded?: (metrics: { totalMiles: number; rideCount: number; peakLean: number }) => void;
|
||||
export interface AccountCloudMetrics {
|
||||
totalMiles: number;
|
||||
rideCount: number;
|
||||
peakLean: number;
|
||||
}
|
||||
|
||||
export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoaded }: AppCloudDataProps) {
|
||||
export type CloudDataStatus =
|
||||
| 'idle'
|
||||
| 'loading'
|
||||
| 'unauthenticated'
|
||||
| 'ready'
|
||||
| 'permission-denied'
|
||||
| 'unavailable'
|
||||
| 'error';
|
||||
|
||||
export interface AppCloudDataProps {
|
||||
syncTrigger?: number;
|
||||
onSyncStateChange?: (syncing: boolean) => void;
|
||||
onMetricsLoaded?: (metrics: AccountCloudMetrics) => void;
|
||||
onStatusChange?: (status: CloudDataStatus, error?: unknown) => void;
|
||||
}
|
||||
|
||||
export function AppCloudData({
|
||||
syncTrigger = 0,
|
||||
onSyncStateChange,
|
||||
onMetricsLoaded,
|
||||
onStatusChange,
|
||||
}: AppCloudDataProps) {
|
||||
const [motorcycles, setMotorcycles] = useState<Motorcycle[]>([]);
|
||||
const [rides, setRides] = useState<Ride[]>([]);
|
||||
const [garageStatus, setGarageStatus] = useState<CloudDataStatus>('loading');
|
||||
const [ridesStatus, setRidesStatus] = useState<CloudDataStatus>('loading');
|
||||
const [garageError, setGarageError] = useState<string | null>(null);
|
||||
const [ridesError, setRidesError] = useState<string | null>(null);
|
||||
const [state, setState] = useState('Loading telemetry cloud data...');
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
|
|
@ -331,19 +356,58 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
if (!currentUser) {
|
||||
setMotorcycles([]);
|
||||
setRides([]);
|
||||
setGarageStatus('unauthenticated');
|
||||
setRidesStatus('unauthenticated');
|
||||
setGarageError(null);
|
||||
setRidesError(null);
|
||||
setState('Sign in or connect your Google account to view your live telemetry.');
|
||||
if (onMetricsLoaded) onMetricsLoaded({ totalMiles: 0, rideCount: 0, peakLean: 0 });
|
||||
if (onStatusChange) onStatusChange('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
setGarageStatus('loading');
|
||||
setRidesStatus('loading');
|
||||
setGarageError(null);
|
||||
setRidesError(null);
|
||||
if (onStatusChange) onStatusChange('loading');
|
||||
if (onSyncStateChange) onSyncStateChange(true);
|
||||
|
||||
function classifyError(err: unknown): { status: CloudDataStatus; message: string } {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = (err as { code?: string })?.code;
|
||||
if (
|
||||
code === 'permission-denied' ||
|
||||
msg.includes('permission-denied') ||
|
||||
msg.includes('Missing or insufficient permissions')
|
||||
) {
|
||||
return { status: 'permission-denied', message: 'Permission denied: unable to access cloud telemetry.' };
|
||||
}
|
||||
if (
|
||||
code === 'unavailable' ||
|
||||
msg.includes('unavailable') ||
|
||||
msg.includes('offline') ||
|
||||
msg.includes('network') ||
|
||||
msg.includes('Failed to fetch')
|
||||
) {
|
||||
return { status: 'unavailable', message: 'Network offline: unable to reach MotorSignal cloud.' };
|
||||
}
|
||||
return { status: 'error', message: msg || 'An unexpected error occurred while loading cloud telemetry.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const [motorcycleSnapshot, rideSnapshot] = await Promise.all([
|
||||
const [motorcycleRes, rideRes] = await Promise.allSettled([
|
||||
getDocs(collection(firestore, 'users', currentUser.uid, 'motorcycles')),
|
||||
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
|
||||
]);
|
||||
|
||||
const loadedMotorcycles: Motorcycle[] = motorcycleSnapshot.docs.map((snapshot) => {
|
||||
// Guard against race conditions if user logged out or changed while async queries were in-flight
|
||||
if (firebaseAuth.currentUser?.uid !== currentUser.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process Motorcycles
|
||||
if (motorcycleRes.status === 'fulfilled') {
|
||||
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {
|
||||
const data = snapshot.data();
|
||||
const yearVal = number(data.year) || 0;
|
||||
const makeVal = text(data.makeName) || text(data.make);
|
||||
|
|
@ -364,10 +428,22 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
};
|
||||
}).sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
||||
|
||||
setMotorcycles(loadedMotorcycles);
|
||||
setGarageStatus('ready');
|
||||
setGarageError(null);
|
||||
} else {
|
||||
const classified = classifyError(motorcycleRes.reason);
|
||||
setGarageStatus(classified.status);
|
||||
setGarageError(classified.message);
|
||||
console.error('Error loading motorcycles:', motorcycleRes.reason);
|
||||
}
|
||||
|
||||
// Process Rides & Metrics
|
||||
if (rideRes.status === 'fulfilled') {
|
||||
let totalMiles = 0;
|
||||
let peakLean = 0;
|
||||
|
||||
const loadedRides: Ride[] = rideSnapshot.docs.map((snapshot) => {
|
||||
const loadedRides: Ride[] = rideRes.value.docs.map((snapshot) => {
|
||||
const data = snapshot.data();
|
||||
const rawStart = text(data.startTime);
|
||||
const rawEnd = text(data.endTime);
|
||||
|
|
@ -420,20 +496,45 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
};
|
||||
}).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0));
|
||||
|
||||
setMotorcycles(loadedMotorcycles);
|
||||
setRides(loadedRides);
|
||||
setState('');
|
||||
setRidesStatus('ready');
|
||||
setRidesError(null);
|
||||
|
||||
if (onMetricsLoaded) {
|
||||
onMetricsLoaded({
|
||||
const computedMetrics = {
|
||||
totalMiles: Math.round(totalMiles),
|
||||
rideCount: loadedRides.length,
|
||||
peakLean: Number(peakLean.toFixed(1)),
|
||||
});
|
||||
};
|
||||
|
||||
if (onMetricsLoaded) {
|
||||
onMetricsLoaded(computedMetrics);
|
||||
}
|
||||
if (onStatusChange) {
|
||||
onStatusChange('ready');
|
||||
}
|
||||
} else {
|
||||
const classified = classifyError(rideRes.reason);
|
||||
setRidesStatus(classified.status);
|
||||
setRidesError(classified.message);
|
||||
console.error('Error loading telemetry rides:', rideRes.reason);
|
||||
if (onStatusChange) {
|
||||
onStatusChange(classified.status, rideRes.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (motorcycleRes.status === 'rejected' && rideRes.status === 'rejected') {
|
||||
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
|
||||
} else if (motorcycleRes.status === 'rejected' || rideRes.status === 'rejected') {
|
||||
setState('Some cloud telemetry could not be loaded.');
|
||||
} else {
|
||||
setState('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading MotorSignal cloud data:', error);
|
||||
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
|
||||
if (onStatusChange) {
|
||||
onStatusChange('error', error);
|
||||
}
|
||||
} finally {
|
||||
if (onSyncStateChange) onSyncStateChange(false);
|
||||
}
|
||||
|
|
@ -855,6 +956,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : garageStatus === 'loading' ? (
|
||||
<div className="glass-card rounded-2xl border border-slate-800 p-8 text-center space-y-3" data-testid="garage-loading">
|
||||
<div className="w-8 h-8 mx-auto rounded-full border-2 border-brand-cyan/40 border-t-brand-cyan animate-spin"></div>
|
||||
<p className="text-xs text-slate-400 font-mono">Loading motorcycle garage…</p>
|
||||
</div>
|
||||
) : garageStatus === 'permission-denied' || garageStatus === 'unavailable' || garageStatus === 'error' ? (
|
||||
<div className="glass-card rounded-2xl border border-rose-500/30 bg-rose-500/5 p-8 text-center space-y-3" data-testid="cloud-garage-error">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-white">
|
||||
{garageStatus === 'permission-denied'
|
||||
? 'Garage Telemetry Restricted — Permission Denied'
|
||||
: 'Garage Telemetry Unavailable'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 font-mono max-w-md mx-auto">
|
||||
{garageError || (garageStatus === 'permission-denied'
|
||||
? 'Your account does not have permission to read motorcycles from Firestore.'
|
||||
: 'Unable to load motorcycle garage from cloud. Please verify connection and retry.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadData(user)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono border border-slate-600 transition cursor-pointer"
|
||||
>
|
||||
Retry Sync
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl border border-dashed border-slate-700 p-8 text-center space-y-4">
|
||||
<StockMotorcycleGraphic className="max-w-md mx-auto h-36 opacity-60" />
|
||||
|
|
@ -890,9 +1019,19 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{ridesStatus === 'loading' ? (
|
||||
<span className="text-xs font-mono text-slate-400 bg-slate-800 border border-slate-700 px-2.5 py-1 rounded-full">
|
||||
Checking sessions…
|
||||
</span>
|
||||
) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? (
|
||||
<span className="text-xs font-mono text-rose-400 bg-rose-500/10 border border-rose-500/30 px-2.5 py-1 rounded-full">
|
||||
Feed Unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-brand-emerald bg-brand-emerald/10 border border-brand-emerald/30 px-2.5 py-1 rounded-full">
|
||||
{rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rides.length > 0 ? (
|
||||
|
|
@ -1098,6 +1237,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : ridesStatus === 'loading' ? (
|
||||
<div className="glass-card rounded-2xl border border-slate-800 p-8 text-center space-y-3" data-testid="rides-loading">
|
||||
<div className="w-8 h-8 mx-auto rounded-full border-2 border-brand-emerald/40 border-t-brand-emerald animate-spin"></div>
|
||||
<p className="text-xs text-slate-400 font-mono">Loading telemetry sessions…</p>
|
||||
</div>
|
||||
) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? (
|
||||
<div className="glass-card rounded-2xl border border-rose-500/30 bg-rose-500/5 p-8 text-center space-y-3" data-testid="cloud-rides-error">
|
||||
<div className="w-10 h-10 mx-auto rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center font-bold">
|
||||
!
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
{ridesStatus === 'permission-denied'
|
||||
? 'Recent Rides Restricted — Permission Denied'
|
||||
: 'Recent Rides Cloud Feed Unavailable'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 font-mono max-w-sm mx-auto">
|
||||
{ridesError || (ridesStatus === 'permission-denied'
|
||||
? 'Firestore rejected read access to telemetry sessions for this rider.'
|
||||
: 'Unable to retrieve telemetry sessions from MotorSignal cloud. Please check network and retry.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadData(user)}
|
||||
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono border border-slate-600 transition cursor-pointer"
|
||||
>
|
||||
Retry Cloud Sync
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-card rounded-2xl border border-dashed border-slate-700 p-8 text-center space-y-3">
|
||||
<div className="w-12 h-12 mx-auto rounded-full bg-surface-container flex items-center justify-center text-slate-500">
|
||||
|
|
|
|||
|
|
@ -9,10 +9,22 @@ interface ClubPermissionMatrixProps {
|
|||
permissions: Record<string, Record<ClubCapability, boolean>>;
|
||||
selectedRankId: string | null;
|
||||
onTogglePermission: (rankId: string, capability: ClubCapability) => void;
|
||||
onResetDefaults: () => void;
|
||||
onSavePermissions: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
onResetDefaults?: () => void;
|
||||
onSavePermissions?: () => Promise<void>;
|
||||
isSaving?: boolean;
|
||||
canEdit: boolean;
|
||||
/**
|
||||
* Whether a server-side persistence contract for per-rank capability
|
||||
* permissions actually exists. Defaults to false.
|
||||
*
|
||||
* The enforced club authorization model is role-based (Owner / Admin), not
|
||||
* rank-based (see getPortalPermissions in lib/club-data.ts). No Firestore
|
||||
* document or API persists this capability matrix today, so by default the
|
||||
* matrix renders as a read-only reference of the standard governance model.
|
||||
* The editable + save path is retained for when a backend contract ships
|
||||
* (see website/docs/backend-contracts/club-rank-permissions.md).
|
||||
*/
|
||||
persistenceAvailable?: boolean;
|
||||
}
|
||||
|
||||
export function ClubPermissionMatrix({
|
||||
|
|
@ -23,12 +35,19 @@ export function ClubPermissionMatrix({
|
|||
onTogglePermission,
|
||||
onResetDefaults,
|
||||
onSavePermissions,
|
||||
isSaving,
|
||||
isSaving = false,
|
||||
canEdit,
|
||||
persistenceAvailable = false,
|
||||
}: ClubPermissionMatrixProps) {
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Edit + save controls are only live when the caller both grants edit rights
|
||||
// AND a real persistence backend is wired. Without persistence the matrix is
|
||||
// a truthful read-only reference: it never mutates and never reports a save.
|
||||
const editable = canEdit && persistenceAvailable && typeof onSavePermissions === 'function';
|
||||
|
||||
async function handleSave() {
|
||||
if (!editable || !onSavePermissions) return;
|
||||
await onSavePermissions();
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2500);
|
||||
|
|
@ -48,12 +67,13 @@ export function ClubPermissionMatrix({
|
|||
</h2>
|
||||
</div>
|
||||
<p className="font-body text-xs sm:text-sm text-slate-400 mt-1">
|
||||
Configure delegated operational privileges per squad rank. Administrative roles enforce backend limits.
|
||||
Standard delegated operational privileges per squad rank. Administrative roles enforce backend limits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
{editable ? (
|
||||
<div className="flex items-center gap-2.5 self-start sm:self-center">
|
||||
{onResetDefaults && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetDefaults}
|
||||
|
|
@ -61,6 +81,7 @@ export function ClubPermissionMatrix({
|
|||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
|
|
@ -85,9 +106,33 @@ export function ClubPermissionMatrix({
|
|||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 self-start sm:self-center px-3 py-1.5 rounded-xl text-[11px] font-mono text-slate-400 bg-surface-container border border-brand-border/60"
|
||||
title="Per-rank permission customization is not yet available. Software access is enforced by Owner and Admin roles."
|
||||
>
|
||||
<span className="material-symbols-outlined text-[15px]">visibility</span>
|
||||
View-only reference
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Truthful availability notice: shown whenever persistence is not wired. */}
|
||||
{!editable && (
|
||||
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-xs font-body text-amber-200/90">
|
||||
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0 mt-0.5">
|
||||
info
|
||||
</span>
|
||||
<span>
|
||||
This matrix shows MotorSignal’s standard rank conventions for reference. Custom per-rank
|
||||
permissions are not saved yet. Who can actually manage this club is enforced by the{' '}
|
||||
<strong className="font-semibold text-amber-100">Owner</strong> and{' '}
|
||||
<strong className="font-semibold text-amber-100">Admin</strong> roles in the member roster — see
|
||||
Ranks & Roles above. Per-rank customization is planned.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop / Tablet Matrix Table */}
|
||||
<div className="overflow-x-auto rounded-xl border border-brand-border/60 bg-surface-container/60">
|
||||
<table className="w-full text-left border-collapse min-w-[720px]">
|
||||
|
|
@ -142,17 +187,22 @@ export function ClubPermissionMatrix({
|
|||
{capabilities.map((cap) => {
|
||||
const isGranted = rankPerms[cap.id] ?? false;
|
||||
const isImmutableOwner = rank.id === 'president';
|
||||
const toggleDisabled = !editable || isImmutableOwner;
|
||||
|
||||
return (
|
||||
<td key={cap.id} className="py-3.5 px-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit || isImmutableOwner}
|
||||
onClick={() => onTogglePermission(rank.id, cap.id)}
|
||||
aria-label={`Toggle ${cap.label} for ${rank.title}`}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-75 disabled:cursor-not-allowed ${
|
||||
isGranted ? 'bg-brand-cyan' : 'bg-slate-700'
|
||||
}`}
|
||||
disabled={toggleDisabled}
|
||||
aria-readonly={!editable}
|
||||
onClick={() => {
|
||||
if (toggleDisabled) return;
|
||||
onTogglePermission(rank.id, cap.id);
|
||||
}}
|
||||
aria-label={`${editable ? 'Toggle' : 'Standard setting:'} ${cap.label} for ${rank.title}`}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-75 disabled:cursor-not-allowed ${
|
||||
editable ? 'cursor-pointer' : 'cursor-default'
|
||||
} ${isGranted ? 'bg-brand-cyan' : 'bg-slate-700'}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-black shadow ring-0 transition duration-200 ease-in-out ${
|
||||
|
|
|
|||
|
|
@ -49,33 +49,44 @@ const CHANNELS: ChannelInfo[] = [
|
|||
|
||||
export function FounderChat({ context }: FounderChatProps) {
|
||||
const { club, permissions, currentUser, isDemoFixture } = context;
|
||||
const isDemo = Boolean(isDemoFixture);
|
||||
const [activeChannelId, setActiveChannelId] = useState<ChannelId>('general');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionState, setConnectionState] = useState<'connecting' | 'connected' | 'error'>(
|
||||
isDemo ? 'connected' : 'connecting'
|
||||
);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
|
||||
|
||||
const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
|
||||
const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
|
||||
const hasExampleMessages = messages.length > 0 && messages.every((m) => m.isExample);
|
||||
const isExampleThread = isDemo || hasExampleMessages;
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
setMessages([]);
|
||||
let unsubscribe: () => void = () => {};
|
||||
|
||||
if (isDemoFixture) {
|
||||
if (isDemo) {
|
||||
setConnectionState('connected');
|
||||
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
|
||||
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId })));
|
||||
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true })));
|
||||
} else {
|
||||
setConnectionState('connecting');
|
||||
unsubscribe = subscribeClubChat(
|
||||
club.id,
|
||||
activeChannelId,
|
||||
(newMsgs) => {
|
||||
setMessages(newMsgs);
|
||||
setConnectionState('connected');
|
||||
},
|
||||
(err) => {
|
||||
setConnectionState('error');
|
||||
setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
|
||||
}
|
||||
);
|
||||
|
|
@ -84,7 +95,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [club.id, activeChannelId, isDemoFixture]);
|
||||
}, [club.id, activeChannelId, isDemo]);
|
||||
|
||||
// Auto-scroll on new message
|
||||
useEffect(() => {
|
||||
|
|
@ -102,7 +113,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
if (isDemoFixture) {
|
||||
if (isDemo) {
|
||||
const optimisticMsg: ChatMessage = {
|
||||
id: `demo-msg-${Date.now()}`,
|
||||
clubId: club.id,
|
||||
|
|
@ -114,6 +125,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
authorRole: currentUser.role,
|
||||
content: clean,
|
||||
createdAt: new Date().toISOString(),
|
||||
isExample: true,
|
||||
};
|
||||
setMessages((prev) => [...prev, optimisticMsg]);
|
||||
} else {
|
||||
|
|
@ -143,10 +155,32 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||
Squad Live Chat
|
||||
</h1>
|
||||
{isDemo ? (
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[14px]">visibility</span>
|
||||
Example thread
|
||||
</span>
|
||||
) : connectionState === 'error' || error ? (
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-rose-500/15 text-rose-400 border border-rose-500/30 font-semibold flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-rose-400" />
|
||||
Disconnected
|
||||
</span>
|
||||
) : connectionState === 'connecting' ? (
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-slate-500/15 text-slate-300 border border-slate-500/30 font-semibold flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
|
||||
Connecting…
|
||||
</span>
|
||||
) : hasExampleMessages ? (
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-[14px]">visibility</span>
|
||||
Example thread
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Live Connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
|
||||
Real-time squad channels for ride organization, road condition warnings, and administrative officer discussions.
|
||||
|
|
@ -179,6 +213,25 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isExampleThread && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-200/90 text-xs flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">info</span>
|
||||
<span>
|
||||
{isDemo ? (
|
||||
<>
|
||||
<strong className="font-semibold text-amber-100">Founder Demo Preview</strong> — this is a
|
||||
simulated conversation thread. Messages are not persisted or sent to club members.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
These are <strong className="font-semibold text-amber-100">example messages</strong> — this
|
||||
channel has no posts yet. Send a message to start the real conversation.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
|
|
@ -276,10 +329,15 @@ export function FounderChat({ context }: FounderChatProps) {
|
|||
</span>
|
||||
)}
|
||||
{msg.authorDisplayRank && (
|
||||
<span className="font-mono px-1.5 py-0.2 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
|
||||
<span className="font-mono px-1.5 py-0.5 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
|
||||
{msg.authorDisplayRank}
|
||||
</span>
|
||||
)}
|
||||
{msg.isExample && (
|
||||
<span className="font-mono px-1.5 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[9px]">
|
||||
Example
|
||||
</span>
|
||||
)}
|
||||
<span className="text-slate-500 font-mono ml-auto">
|
||||
{msg.createdAt
|
||||
? new Date(msg.createdAt).toLocaleTimeString([], {
|
||||
|
|
|
|||
|
|
@ -178,6 +178,8 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
|
||||
const canCurate = permissions.canPlanRides || permissions.canManageSettings;
|
||||
|
||||
const hasExampleRoutes = routes.length > 0 && routes.every((r) => r.isExample);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
{/* Top Header Card */}
|
||||
|
|
@ -187,8 +189,14 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||
Curated Club Routes
|
||||
</h1>
|
||||
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-brand-cyan/15 text-brand-cyan border border-brand-cyan/30 font-semibold">
|
||||
{routes.length} Available
|
||||
<span
|
||||
className={`font-mono text-xs px-2.5 py-0.5 rounded-full border font-semibold ${
|
||||
hasExampleRoutes
|
||||
? 'bg-amber-500/15 text-amber-300 border-amber-500/30'
|
||||
: 'bg-brand-cyan/15 text-brand-cyan border-brand-cyan/30'
|
||||
}`}
|
||||
>
|
||||
{hasExampleRoutes ? `${routes.length} Examples` : `${routes.length} Available`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-body text-xs text-slate-400 mt-1 max-w-xl">
|
||||
|
|
@ -207,6 +215,16 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{hasExampleRoutes && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-200/90 text-xs flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">info</span>
|
||||
<span>
|
||||
These are <strong className="font-semibold text-amber-100">example routes</strong> — your club
|
||||
hasn’t curated any yet. Curate a route to replace them with your own.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-brand-crimson/10 border border-brand-crimson/30 text-brand-crimson text-xs flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
|
|
@ -314,9 +332,16 @@ export function FounderRoutes({ context }: FounderRoutesProps) {
|
|||
<div className="space-y-3">
|
||||
{/* Card Header & Badge */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-heading text-base font-bold text-white leading-snug">
|
||||
{route.title}
|
||||
</h3>
|
||||
{route.isExample && (
|
||||
<span className="inline-flex items-center gap-1 mt-1 font-mono text-[9px] px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold uppercase tracking-wider">
|
||||
Example
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 font-mono text-[10px] px-2 py-0.5 rounded font-semibold border ${
|
||||
isHighCurvature
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { GroupRideSummary } from './types';
|
|||
interface CreateRideModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateRide: (ride: GroupRideSummary) => void;
|
||||
onCreateRide: (ride: GroupRideSummary) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
|
||||
|
|
@ -24,6 +24,7 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
const [participantLimit, setParticipantLimit] = useState(12);
|
||||
const [description, setDescription] = useState('');
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
|
|
@ -42,6 +43,7 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
setParticipantLimit(12);
|
||||
setDescription('');
|
||||
setErrorMsg(null);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
|
|
@ -58,9 +60,12 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Prevent duplicate submissions while a create is in flight.
|
||||
if (isSubmitting) return;
|
||||
|
||||
if (!title.trim()) {
|
||||
setErrorMsg('Please enter a ride title.');
|
||||
return;
|
||||
|
|
@ -92,8 +97,13 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
status: 'scheduled',
|
||||
};
|
||||
|
||||
onCreateRide(newRide);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onCreateRide(newRide);
|
||||
onClose();
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -349,9 +359,10 @@ export function CreateRideModal({ isOpen, onClose, onCreateRide }: CreateRideMod
|
|||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-space-xl py-2.5 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all"
|
||||
disabled={isSubmitting}
|
||||
className="px-space-xl py-2.5 rounded-xl bg-primary-container text-on-primary-container font-headline-sm text-headline-sm hover:shadow-lg transition-all disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Publish Group Ride
|
||||
{isSubmitting ? 'Publishing…' : 'Publish Group Ride'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getApps, initializeApp } from 'firebase/app';
|
||||
import { getAuth, connectAuthEmulator } from 'firebase/auth';
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
connectFirestoreEmulator,
|
||||
} from 'firebase/firestore';
|
||||
import { firestore } from '../../lib/firebase-client';
|
||||
import { commitGroupRide } from '../../lib/create-group-ride';
|
||||
|
||||
function getActiveFirestore() {
|
||||
if (
|
||||
|
|
@ -182,7 +183,7 @@ type MyRidesFilter = 'all' | 'organized' | 'joined';
|
|||
interface CreateRideModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateRide: (ride: GroupRideSummary) => void;
|
||||
onCreateRide: (ride: GroupRideSummary) => void | boolean | Promise<void | boolean>;
|
||||
}
|
||||
|
||||
function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModalProps) {
|
||||
|
|
@ -221,6 +222,7 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal
|
|||
const [participantLimit, setParticipantLimit] = useState(12);
|
||||
const [description, setDescription] = useState('');
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
|
|
@ -244,6 +246,7 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal
|
|||
setParticipantLimit(12);
|
||||
setDescription('');
|
||||
setErrorMsg(null);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [isOpen, defaultDateValues]);
|
||||
|
||||
|
|
@ -287,8 +290,9 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal
|
|||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
if (!validate()) return;
|
||||
|
||||
const newRide: GroupRideSummary & {
|
||||
|
|
@ -320,8 +324,15 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal
|
|||
...(rideType === 'club' && clubName.trim() ? { clubName: clubName.trim(), clubId: `club-${clubName.toLowerCase().replace(/\s+/g, '-')}` } : {}),
|
||||
};
|
||||
|
||||
onCreateRide(newRide);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const outcome = await onCreateRide(newRide);
|
||||
if (outcome !== false) {
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -707,9 +718,12 @@ function CreateGroupRideModal({ isOpen, onClose, onCreateRide }: CreateRideModal
|
|||
<button
|
||||
type="submit"
|
||||
id="btn-publish-ride"
|
||||
className="px-space-xl py-2.5 rounded-xl bg-primary-container hover:bg-primary-fixed text-on-primary-container font-headline-sm text-xs sm:text-sm font-bold shadow-lg hover:shadow-[0_0_20px_rgba(0,242,254,0.4)] transition-all cursor-pointer"
|
||||
disabled={isSubmitting}
|
||||
className={`px-space-xl py-2.5 rounded-xl bg-primary-container hover:bg-primary-fixed text-on-primary-container font-headline-sm text-xs sm:text-sm font-bold shadow-lg hover:shadow-[0_0_20px_rgba(0,242,254,0.4)] transition-all ${
|
||||
isSubmitting ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
Publish Group Ride
|
||||
{isSubmitting ? 'Publishing…' : 'Publish Group Ride'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -724,6 +738,8 @@ export function GroupRidesManagement({
|
|||
onViewShowcase,
|
||||
}: GroupRidesManagementProps) {
|
||||
const [rides, setRides] = useState<GroupRideSummary[]>(INITIAL_GROUP_RIDES);
|
||||
// Guards against duplicate create submissions while a write is in flight.
|
||||
const creatingRideRef = useRef(false);
|
||||
const [communityRides, setCommunityRides] = useState<GroupRideSummary[]>([]);
|
||||
const [isCommunityLoading, setIsCommunityLoading] = useState(false);
|
||||
const [communityError, setCommunityError] = useState<string | null>(null);
|
||||
|
|
@ -1000,98 +1016,59 @@ 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 }): Promise<boolean> => {
|
||||
// Prevent duplicate submissions while a create is already in flight.
|
||||
if (creatingRideRef.current) return false;
|
||||
|
||||
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)}`;
|
||||
// Check authentication and persistence availability BEFORE touching UI state, so an
|
||||
// unauthenticated user (or an unavailable Firestore) never sees a phantom ride that was
|
||||
// never saved. They get an actionable error instead.
|
||||
if (!uid || !firestore) {
|
||||
showAlert('Sign in to create a group ride — it could not be saved.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const rideWithOrganizer: GroupRideSummary = {
|
||||
...newRide,
|
||||
id: rideId,
|
||||
isOrganizer: true,
|
||||
status: 'scheduled',
|
||||
userStatus: 'joined',
|
||||
rideCode: rideId,
|
||||
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
|
||||
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
|
||||
};
|
||||
|
||||
// Optimistic local update
|
||||
setRides((prev) => [rideWithOrganizer, ...prev]);
|
||||
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${rideId}`, 'success');
|
||||
|
||||
if (uid && firestore) {
|
||||
creatingRideRef.current = true;
|
||||
try {
|
||||
const leadDisplayName = currentUser.displayName || currentUser.email?.split('@')[0] || 'Organizer';
|
||||
const leadHandle = currentUser.displayName?.toLowerCase().replace(/\s+/g, '_') || currentUser.email?.split('@')[0] || 'rider';
|
||||
|
||||
let scheduledStartAt = new Date(now.getTime() + 3600000).toISOString();
|
||||
let departureAt = new Date(now.getTime() + 3600000 + 1800000).toISOString();
|
||||
const result = await commitGroupRide(newRide, {
|
||||
uid,
|
||||
firestore,
|
||||
leadDisplayName,
|
||||
leadHandle,
|
||||
writeBatch: (fs) => writeBatch(fs),
|
||||
docRef: (fs, ...segments) => doc(fs, segments[0], ...segments.slice(1)),
|
||||
});
|
||||
|
||||
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: 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',
|
||||
if (result.ok) {
|
||||
// Insert into UI state ONLY after Firestore confirms the write — no phantom rides.
|
||||
const rideWithOrganizer: GroupRideSummary = {
|
||||
...newRide,
|
||||
id: result.rideId,
|
||||
isOrganizer: true,
|
||||
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 || '',
|
||||
userStatus: 'joined',
|
||||
rideCode: result.rideId,
|
||||
leadHandle: currentUser?.displayName || currentUser?.email?.split('@')[0] || 'MyHandle',
|
||||
leadAvatarText: (currentUser?.displayName?.[0] || currentUser?.email?.[0] || 'M').toUpperCase(),
|
||||
};
|
||||
|
||||
if (newRide.clubId) {
|
||||
firestoreRideData.clubId = newRide.clubId;
|
||||
if (newRide.clubName) firestoreRideData.clubName = newRide.clubName;
|
||||
}
|
||||
|
||||
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);
|
||||
setRides((prev) => (prev.some((r) => r.id === result.rideId) ? prev : [rideWithOrganizer, ...prev]));
|
||||
showAlert(`Created new group ride "${newRide.title}"! Ride Code: ${result.rideId}`, 'success');
|
||||
return true;
|
||||
} else if (result.reason === 'commit-failed') {
|
||||
console.error('Failed to persist group ride to Firestore:', result.error);
|
||||
showAlert('Could not create the ride. Please check your connection and try again.', 'error');
|
||||
return false;
|
||||
} else {
|
||||
// unauthenticated / unavailable discovered inside the helper (defensive — guarded above).
|
||||
showAlert('Could not create the ride right now. Please try again.', 'error');
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
creatingRideRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const SUPPORT_EMAIL = 'support@motorsignal.app';
|
||||
|
||||
const CATEGORIES = [
|
||||
'Account & sign-in',
|
||||
'Subscription & billing',
|
||||
'Group rides',
|
||||
'Clubs',
|
||||
'Telemetry & ride data',
|
||||
'Bug report',
|
||||
'Something else',
|
||||
] as const;
|
||||
|
||||
type Errors = { name?: string; email?: string; message?: string };
|
||||
|
||||
// Public support contact. There is no public ticket backend, so this never claims a
|
||||
// ticket was filed: it validates input and opens the visitor's own email client with a
|
||||
// prefilled message. Email remains the real, working support channel.
|
||||
export function SupportContactForm() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [category, setCategory] = useState<(typeof CATEGORIES)[number]>(CATEGORIES[0]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
function validate(): Errors {
|
||||
const next: Errors = {};
|
||||
if (!name.trim()) next.name = 'Please tell us your name.';
|
||||
// Pragmatic email check: a single @ with a dot in the domain.
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||||
next.email = 'Enter a valid email address so we can reply.';
|
||||
}
|
||||
if (message.trim().length < 10) {
|
||||
next.message = 'Add a few more details (at least 10 characters).';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const found = validate();
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) {
|
||||
setOpened(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = `[${category}] Support request from ${name.trim()}`;
|
||||
const body =
|
||||
`Name: ${name.trim()}\n` +
|
||||
`Email: ${email.trim()}\n` +
|
||||
`Category: ${category}\n\n` +
|
||||
`${message.trim()}\n`;
|
||||
const mailto = `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
|
||||
// Open the visitor's email client. We do not and cannot confirm delivery here.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = mailto;
|
||||
}
|
||||
setOpened(true);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3.5 py-2.5 rounded-xl bg-surface-container-low border border-outline-variant/40 focus:border-primary-container focus:ring-1 focus:ring-primary-container text-white text-sm outline-none transition placeholder:text-slate-500';
|
||||
|
||||
return (
|
||||
<form className="space-y-4 text-left" onSubmit={handleSubmit} noValidate>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="support-name" className="block text-xs font-mono tracking-wide text-on-surface-variant">
|
||||
Your name
|
||||
</label>
|
||||
<input
|
||||
id="support-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="Alex Rider"
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
{errors.name && <p className="form-error" role="alert">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="support-email" className="block text-xs font-mono tracking-wide text-on-surface-variant">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="support-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={inputClass}
|
||||
placeholder="you@example.com"
|
||||
aria-invalid={Boolean(errors.email)}
|
||||
/>
|
||||
{errors.email && <p className="form-error" role="alert">{errors.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="support-category" className="block text-xs font-mono tracking-wide text-on-surface-variant">
|
||||
Topic
|
||||
</label>
|
||||
<select
|
||||
id="support-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as (typeof CATEGORIES)[number])}
|
||||
className={inputClass}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="support-message" className="block text-xs font-mono tracking-wide text-on-surface-variant">
|
||||
How can we help?
|
||||
</label>
|
||||
<textarea
|
||||
id="support-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
className={inputClass}
|
||||
placeholder="Describe what you ran into, including your device and app version if relevant."
|
||||
aria-invalid={Boolean(errors.message)}
|
||||
/>
|
||||
{errors.message && <p className="form-error" role="alert">{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="button">Compose support email</button>
|
||||
|
||||
{opened && (
|
||||
<p className="form-message" role="status">
|
||||
Your email app should have opened with the details filled in. If nothing happened, email{' '}
|
||||
<a className="underline" href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> directly.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="form-divider">
|
||||
This opens your own email app — we don’t submit a ticket on your behalf, so your message
|
||||
isn’t sent until you send it.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
analyzeCorners,
|
||||
} from '../lib/telemetry-export';
|
||||
import { firebaseAuth } from '../lib/firebase-client';
|
||||
import { areTelemetryPointsConnected, normalizeTelemetryPoints } from '../lib/telemetry-track';
|
||||
import { areTelemetryPointsConnected, normalizeTelemetryPoints, advancePlaybackIndex } from '../lib/telemetry-track';
|
||||
import { getAwsSatelliteTileUrl, AWS_MAPS_ATTRIBUTION } from '../lib/aws-maps';
|
||||
|
||||
interface WebTelemetryReplayModalProps {
|
||||
|
|
@ -119,10 +119,38 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const lastTimeRef = useRef<number | null>(null);
|
||||
const indexRef = useRef(0);
|
||||
const playbackCarryRef = useRef(0);
|
||||
|
||||
const totalPoints = points.length;
|
||||
const currentPoint = points[currentIndex] || points[0];
|
||||
|
||||
// Keep a ref mirror of the cursor so the rAF loop resumes from the latest position
|
||||
// (including manual scrubs) without re-subscribing every index change.
|
||||
useEffect(() => {
|
||||
indexRef.current = currentIndex;
|
||||
}, [currentIndex]);
|
||||
|
||||
// True when the recording has disconnected segments / GPS-loss gaps. Playback compresses
|
||||
// those gaps (advancePlaybackIndex), so the elapsed clock — which shows real recorded time —
|
||||
// will jump forward at a gap even though wall-clock playback does not pause. We surface this
|
||||
// explicitly rather than let the jump look like a glitch.
|
||||
const hasCompressedGaps = useMemo(
|
||||
() => points.some((p, i) => i > 0 && !areTelemetryPointsConnected(points[i - 1], p)),
|
||||
[points]
|
||||
);
|
||||
|
||||
// Manual seek (scrub, restart, corner jump, arrow keys): reconcile playback timing so the
|
||||
// next animation frame does not apply stale budget accumulated before the seek.
|
||||
const seekTo = (index: number) => {
|
||||
const maxIndex = Math.max(0, totalPoints - 1);
|
||||
const clamped = Math.max(0, Math.min(maxIndex, index));
|
||||
playbackCarryRef.current = 0;
|
||||
lastTimeRef.current = null;
|
||||
indexRef.current = clamped;
|
||||
setCurrentIndex(clamped);
|
||||
};
|
||||
|
||||
// Dynamically load Leaflet library and CSS with multi-CDN fallback
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
|
@ -435,7 +463,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
{ direction: 'top' }
|
||||
);
|
||||
cornerMarker.on('click', () => {
|
||||
setCurrentIndex(corner.apexIndex);
|
||||
seekTo(corner.apexIndex);
|
||||
});
|
||||
routeGroup.addLayer(cornerMarker);
|
||||
}
|
||||
|
|
@ -611,30 +639,35 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
}
|
||||
};
|
||||
|
||||
// Handle Playback Loop
|
||||
// Handle Playback Loop — timestamp-driven so the rider marker stays synchronized with the
|
||||
// elapsed-time readout. Advances by real recorded time scaled by playbackSpeed; disconnected
|
||||
// gaps are compressed by advancePlaybackIndex so playback never stalls across dead time.
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||
lastTimeRef.current = null;
|
||||
playbackCarryRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!lastTimeRef.current) lastTimeRef.current = timestamp;
|
||||
const delta = timestamp - lastTimeRef.current;
|
||||
|
||||
// 20 Hz step interval is 50 ms. With playback multiplier:
|
||||
const stepInterval = 50 / playbackSpeed;
|
||||
|
||||
if (delta >= stepInterval) {
|
||||
if (lastTimeRef.current == null) lastTimeRef.current = timestamp;
|
||||
const rawDelta = timestamp - lastTimeRef.current;
|
||||
const frameDelta = Math.max(0, Math.min(rawDelta, 200));
|
||||
lastTimeRef.current = timestamp;
|
||||
setCurrentIndex((prev) => {
|
||||
if (prev >= totalPoints - 1) {
|
||||
setIsPlaying(false);
|
||||
return prev;
|
||||
|
||||
// Virtual recorded-time budget to consume this frame, at the selected speed.
|
||||
const budget = frameDelta * playbackSpeed + playbackCarryRef.current;
|
||||
const { index, remainingMs } = advancePlaybackIndex(points, indexRef.current, budget);
|
||||
playbackCarryRef.current = index >= totalPoints - 1 ? 0 : remainingMs;
|
||||
|
||||
if (index !== indexRef.current) {
|
||||
indexRef.current = index;
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
if (index >= totalPoints - 1) {
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
|
@ -644,8 +677,9 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
|
||||
return () => {
|
||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||
lastTimeRef.current = null;
|
||||
};
|
||||
}, [isPlaying, playbackSpeed, totalPoints]);
|
||||
}, [isPlaying, playbackSpeed, totalPoints, points]);
|
||||
|
||||
// Keyboard navigation (Space to toggle play)
|
||||
useEffect(() => {
|
||||
|
|
@ -654,9 +688,9 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
e.preventDefault();
|
||||
setIsPlaying((prev) => !prev);
|
||||
} else if (e.code === 'ArrowRight' && totalPoints > 0) {
|
||||
setCurrentIndex((prev) => Math.min(totalPoints - 1, prev + 20));
|
||||
seekTo(indexRef.current + 20);
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
setCurrentIndex((prev) => Math.max(0, prev - 20));
|
||||
seekTo(indexRef.current - 20);
|
||||
} else if (e.code === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
|
|
@ -1052,7 +1086,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
key={corner.id}
|
||||
className="hover:bg-brand-cardAlt/50 transition cursor-pointer"
|
||||
onClick={() => {
|
||||
setCurrentIndex(corner.apexIndex);
|
||||
seekTo(corner.apexIndex);
|
||||
setActiveTab('map');
|
||||
}}
|
||||
>
|
||||
|
|
@ -1093,7 +1127,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex(corner.apexIndex);
|
||||
seekTo(corner.apexIndex);
|
||||
setActiveTab('map');
|
||||
}}
|
||||
className="px-2 py-1 rounded bg-surface-container-high hover:bg-brand-cyan hover:text-black text-[10px] transition cursor-pointer"
|
||||
|
|
@ -1128,7 +1162,7 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
min={0}
|
||||
max={Math.max(1, totalPoints - 1)}
|
||||
value={currentIndex}
|
||||
onChange={(e) => setCurrentIndex(Number(e.target.value))}
|
||||
onChange={(e) => seekTo(Number(e.target.value))}
|
||||
className="flex-1 accent-brand-cyan cursor-pointer h-2 bg-slate-800 rounded-lg"
|
||||
data-testid="replay-timeline-slider"
|
||||
disabled={!points.length}
|
||||
|
|
@ -1136,11 +1170,22 @@ export function WebTelemetryReplayModal({ ride, onClose }: WebTelemetryReplayMod
|
|||
<span className="text-xs font-mono text-slate-400 w-12">{formatTime(totalSeconds)}</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline is recorded time. Make the gap-compression behaviour explicit. */}
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-slate-500">
|
||||
<span>Recorded elapsed time</span>
|
||||
{hasCompressedGaps && (
|
||||
<span className="text-amber-400/80 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">timer_off</span>
|
||||
Recording has gaps — dead time is skipped during playback
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentIndex(0)}
|
||||
onClick={() => seekTo(0)}
|
||||
title="Restart from beginning"
|
||||
className="p-2 rounded-lg bg-surface-container hover:bg-slate-700 text-slate-300 hover:text-white transition cursor-pointer"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
# MotorSignal Website — Feature Audit Matrix
|
||||
|
||||
Date: 2026-10-14
|
||||
Branch: `fix/website-production-hardening-20261014`
|
||||
Baseline: PR #36 integration HEAD `4931920` (`codex/master-consolidation-20261012`)
|
||||
Scope: `website/` only (Next.js 16.3.4 / React 19). Flutter, Drift, Firebase rules,
|
||||
Cloudflare Worker, and backend production code are out of scope.
|
||||
|
||||
Method: classifications below are grounded in the current source (file:line evidence),
|
||||
not in filenames or prior docs. "Verified truthful" means the code was read and does a
|
||||
real backend operation (or honestly reports unavailability); it does **not** claim any
|
||||
live device / motorcycle validation.
|
||||
|
||||
Legend — status: ✅ implemented & truthful · 🟠 partial · 🔴 broken / misleading ·
|
||||
🏷️ UI-only placeholder · 🔌 backend-dependent · 🗓️ deferred/roadmap.
|
||||
Severity: P0 security / data-integrity / misleading success · P1 broken core workflow ·
|
||||
P2 incomplete / usability · P3 roadmap.
|
||||
|
||||
## Summary
|
||||
|
||||
The PR #36 baseline is substantially real. Club governance writes, group-ride
|
||||
create/edit/delete/join, the garage/ride-history/export surface, device sessions, the
|
||||
billing-portal fallback, and the Formation Radar are all implemented against real
|
||||
Firestore/API paths or honestly report unavailability. The genuine defects are a focused
|
||||
set of **fake success / fabricated-as-real** spots plus the static Support page.
|
||||
|
||||
| # | Defect | File | Severity | Status |
|
||||
|---|--------|------|----------|--------|
|
||||
| W1 | Rank "permission matrix" faked a save (setTimeout, no write) | `app/clubs/[clubId]/manage/ranks-roles/page.tsx`, `components/founder-portal/club-permission-matrix.tsx` | P0 | ✅ FIXED (commit c014c2f) |
|
||||
| W-2FA | Account shows "Two-Factor Authentication ENABLED" hardcoded for every signed-in user | `app/account/page.tsx:603-610` | P0 | 🔴 confirmed |
|
||||
| W-devices | Account shows hardcoded "iOS Mobile App iPhone 16 Pro v4.1.0 · 14m ago" and "Cardo Packtalk Edge · Paired" as real connected devices | `app/account/page.tsx:463-488` | P1 | 🔴 confirmed |
|
||||
| W-routes | Club routes: empty Firestore collection falls back to `FIXTURE_CLUB_ROUTES` (fake "Coastal Marshes" route + `/fixtures/routes/*.gpx`) in production | `lib/club-community.ts:265-268` | P1 | 🔴 confirmed |
|
||||
| W-chat | Club chat: empty channel falls back to `FIXTURE_CHAT_MESSAGES` in production | `lib/club-community.ts:443-447` | P1 | 🔴 confirmed |
|
||||
| W-metrics | Account dashboard `metrics` initialises to fabricated `12480 mi / 184 rides / 56.4°` before real data loads (persists if load fails) | `app/account/page.tsx:25` | P2 | 🟠 confirmed |
|
||||
| W2 | Support Center is a static hero + `mailto:` only — no FAQ, no validation, no ticketing | `app/support/page.tsx` | P2 | 🔴 confirmed |
|
||||
| W-deadctl | Dead/no-op controls: "Change" (telemetry privacy), "Manage Devices", "Security Logs"→`/account`, static "All Synced" | `app/account/page.tsx` | P2/P3 | 🟠 confirmed |
|
||||
|
||||
## Feature matrix (by area)
|
||||
|
||||
### Public site & navigation
|
||||
- Homepage `app/page.tsx` + `components/landing/*` — ✅ marketing content.
|
||||
- 🟠 `components/landing/TelemetryStrip.tsx:10-11` and `app/features/page.tsx:15-20` use
|
||||
`Math.random` for animated counters. Decorative marketing, not presented as a user's
|
||||
real telemetry. P3 — acceptable but worth a "demo" label.
|
||||
- Navigation / responsive mobile nav `app/layout.tsx` — ⏳ not yet width-tested (Phase 8).
|
||||
|
||||
### Authentication & account
|
||||
- Login / register / forgot-password / verify-email — ✅ real (`components/auth-form.tsx`,
|
||||
`api/auth/[action]`); covered by `tests/auth-form.test.tsx`, `auth-recovery-verification`.
|
||||
- Account settings `app/account/page.tsx` — ✅ mostly real, with the W-2FA / W-devices /
|
||||
W-metrics / W-deadctl defects above.
|
||||
- Device sessions `app/account/page.tsx` + `api/account/sessions` — ✅ real list, revoke &
|
||||
revoke-all, pagination, truthful empty/loading states.
|
||||
- Subscription & billing — ✅ truthful: `/api/billing/portal` is unconfigured and the page
|
||||
says so ("managed through the mobile app"); no fake success (`app/account/page.tsx:107-141`).
|
||||
|
||||
### Garage & motorcycle management
|
||||
- `components/app-cloud-data.tsx` — ✅ real Firestore: add / edit / delete / make-primary /
|
||||
photo upload (`users/{uid}/motorcycles`), image via `/api/motorcycle-image`.
|
||||
|
||||
### Ride history, telemetry sessions & export
|
||||
- `components/app-cloud-data.tsx` — ✅ real rides from `users/{uid}/rides`, GPX & CSV export
|
||||
handlers, delete, "Show more" pagination. CSV/GPX byte-integrity not yet re-verified (Phase 4/9).
|
||||
- Telemetry Forensic Replay `components/web-telemetry-replay-modal.tsx` — ⏳ not yet fully
|
||||
audited (map tiles, progress, playback smoothness — Phase 4). Tests exist
|
||||
(`tests/telemetry-replay-export.test.tsx`, `replay-recorded-data.test.tsx`).
|
||||
|
||||
### Clubs / Founder Portal
|
||||
- Profile / access-settings / ownership-transfer / archive — ✅ real writes with owner/admin
|
||||
authorization (`lib/club-data.ts`; demo branch uses labelled simulated latency only).
|
||||
- Applications approve/decline — ✅ real transactional writes.
|
||||
- Member rank assignment — ✅ real (`updateMemberRank`).
|
||||
- Rank permission matrix — ✅ FIXED (W1): now a truthful read-only reference.
|
||||
- Club routes — 🔴 W-routes fixture fallback.
|
||||
- Club chat / wall — 🔴 W-chat fixture fallback (wall send path not yet fully audited).
|
||||
|
||||
### Group Rides
|
||||
- Public page `app/group-rides/page.tsx` — ✅ splits by auth: signed-out →
|
||||
`GroupRidesShowcase` (clearly labelled "Sample Pack Formations" / "Radar Simulation"),
|
||||
signed-in → `GroupRidesManagement`. Public path exposes no real private rides.
|
||||
- Authenticated Hub `components/group-rides/group-rides-management.tsx` — ✅ real:
|
||||
`/api/group-rides` discovery + Firestore `onSnapshot`; create/edit/delete/join/leave write
|
||||
to `group_rides`. 🟠 join/ride identifier generated client-side with `Math.random`
|
||||
(`:1008`) — weak/non-authoritative; 🟠 `INITIAL_GROUP_RIDES` seeds initial state (replaced
|
||||
on first snapshot; can linger if no Firestore). P2.
|
||||
- Address autocomplete / route distance / scheduling / join codes / QR — ⏳ not yet fully
|
||||
audited (Phase 3).
|
||||
|
||||
### Live Formation Radar
|
||||
- `components/group-rides/live-pack-radar.tsx` — ✅ truthful. Real rides subscribe to
|
||||
Firestore `live_presence` with authorization + loading + error states and **no** fixture
|
||||
fallback; `INITIAL_RADAR_RIDERS` is used only for the showcase/demo and is explicitly
|
||||
labelled "Public Demonstration (Simulated Pack)" with "[Simulation Demo]" ping copy. The
|
||||
historical dummy-data concern is already remediated in this baseline.
|
||||
|
||||
### Device connections / hardware
|
||||
- 🔴 W-devices: account "App Connections" block hardcodes a connected iOS app and a paired
|
||||
Cardo unit. Apple Watch row is truthful ("Not Connected (Unavailable on Web)").
|
||||
- Helmet Comms / GoPro / Apple Watch as first-class features — 🗓️ roadmap; must be labelled.
|
||||
|
||||
### Support Center
|
||||
- 🔴 W2: `app/support/page.tsx` is one static hero + `mailto:`. No public ticket backend
|
||||
exists (the `api/ops/support/tickets/*` routes are the internal operations console, not a
|
||||
public endpoint). Phase 6 remediation: keep email fallback, add grounded FAQ + validated
|
||||
contact UX; do not simulate ticket submission.
|
||||
|
||||
### Operations console (`app/api/ops/*`, `components/ops/*`, `app/admin`)
|
||||
- Out of the brief's feature list; appears to be a separate internal console. Not modified.
|
||||
|
||||
## Remaining P0–P3 work (website)
|
||||
|
||||
- **P0** — W-2FA (false security state). Fix: show real MFA state / "managed in mobile app",
|
||||
never a hardcoded "ENABLED".
|
||||
- **P1** — W-devices, W-routes, W-chat (fabricated-as-real). Fix: real state or truthful
|
||||
empty/unavailable; never fixtures in production paths.
|
||||
- **P2** — W2 Support (FAQ + validation, email fallback), W-metrics (init to loading/0),
|
||||
W-deadctl (wire or remove dead controls), group-ride client-side id generation, mobile
|
||||
responsiveness pass (Phase 8), telemetry replay deep audit (Phase 4).
|
||||
- **P3** — marketing `Math.random` counters (label as demo), hardware roadmap labelling.
|
||||
|
||||
## Open product questions (do not block P0/P1 truthfulness fixes)
|
||||
1. Support: confirm no public ticket backend is planned for this release (default: email + FAQ).
|
||||
2. Device-connections block: remove, or convert to a truthful "manage in mobile app" state?
|
||||
3. Club routes/chat empty states: plain empty state, or keep clearly-labelled "example" content?
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
# Backend Contract Proposal — Club Rank Permission Matrix (WEB-W1)
|
||||
|
||||
Status: **Proposed / not implemented.** This documents the backend work required
|
||||
before the "Operational Permission Matrix" on
|
||||
`app/clubs/[clubId]/manage/ranks-roles/page.tsx` can be made editable. Until this
|
||||
ships, the matrix is rendered read-only (`persistenceAvailable={false}`).
|
||||
|
||||
## Problem
|
||||
|
||||
`handleSavePermissions` previously waited ~400 ms and showed a "Saved" state
|
||||
without writing anything (confirmed in source prior to this change). There is no
|
||||
Firestore document, API route, or security rule that stores or enforces a
|
||||
per-rank capability matrix. The enforced club authorization model is **role
|
||||
based**, derived solely from the administrative role on the member document:
|
||||
|
||||
- `lib/club-data.ts` → `getPortalPermissions(role)` maps `owner`/`admin` →
|
||||
`canManageSettings`, `canInviteMembers`, `canApproveApplications`,
|
||||
`canPlanRides`; `member` → all false.
|
||||
- Display rank (`President`, `Road Captain`, …) is cosmetic and explicitly does
|
||||
**not** grant software authority.
|
||||
|
||||
Persisting the matrix without enforcement would mislead club owners into
|
||||
believing a toggle restricts members when it does nothing. That is prohibited by
|
||||
the project rules (no unenforced permission models, no false success).
|
||||
|
||||
## Why this was not implemented in the website task
|
||||
|
||||
- Enforcement requires **Firestore security rule** changes (out of scope / frozen).
|
||||
- A new persistence model must not conflict with the existing Flutter club role
|
||||
model (`owner`/`admin`/`member`). Rank → capability is a different axis and
|
||||
needs a product decision on precedence.
|
||||
|
||||
## Proposed contract (for a future, authorized backend change)
|
||||
|
||||
### 1. Storage
|
||||
|
||||
Document: `clubs/{clubId}/settings/rankPermissions`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "<serverTimestamp>",
|
||||
"updatedByUid": "<uid>",
|
||||
"ranks": {
|
||||
"road_captain": {
|
||||
"planRides": true,
|
||||
"manageApplications": false,
|
||||
"postAnnouncements": true,
|
||||
"moderateWall": true,
|
||||
"manageRoster": false,
|
||||
"editProfile": false
|
||||
}
|
||||
// ... one entry per rank id in DEFAULT_CLUB_RANKS except `president`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Invariants:
|
||||
- `president` is immutable (always full authority); never written or editable.
|
||||
- Unknown rank ids and unknown capability keys are rejected.
|
||||
- Capability keys are constrained to `DEFAULT_CLUB_CAPABILITIES` ids.
|
||||
|
||||
### 2. Security rules (requires authorized change to the frozen rules)
|
||||
|
||||
- Read: any active club member.
|
||||
- Write: only `clubs/{clubId}.ownerUid == request.auth.uid`, or an active member
|
||||
whose `role == 'admin'`. Validate the written shape (version, allowed rank ids,
|
||||
boolean capability values only). Reject writes that would alter `president`.
|
||||
|
||||
### 3. Enforcement precedence (product decision required)
|
||||
|
||||
Rank capabilities may only **narrow**, never widen, what the administrative role
|
||||
already denies. Administrative `role` remains the hard security boundary enforced
|
||||
in rules; rank capabilities are a soft, owner-configurable delegation layer that
|
||||
the Flutter and web clients honor for UI affordances. The Flutter club model must
|
||||
adopt the same precedence so web and mobile agree.
|
||||
|
||||
### 4. Website wiring once the above exists
|
||||
|
||||
1. Add `fetchRankPermissions(clubId)` and `saveRankPermissions(clubId, callerUid, matrix)`
|
||||
to `lib/club-data.ts`, mirroring the authorization pattern of
|
||||
`updateClubAccessSettings` (owner/admin check + `updateDoc`).
|
||||
2. In `ranks-roles/page.tsx`: load persisted permissions in `loadData`, and
|
||||
implement `handleSavePermissions` to call `saveRankPermissions`, then re-read
|
||||
to confirm the write survived before showing success.
|
||||
3. Flip `<ClubPermissionMatrix persistenceAvailable={true} />` and pass
|
||||
`onSavePermissions` / `onResetDefaults` / `isSaving`.
|
||||
4. Add tests: persistence round-trip (save → reload → values survive), owner/admin
|
||||
authorization success, member/unauthenticated rejection, `president`
|
||||
immutability, and rejection of unknown rank/capability keys.
|
||||
|
||||
## Current truthful behavior (shipped in this branch)
|
||||
|
||||
- Matrix renders as a read-only reference of the standard governance model.
|
||||
- No Save/Reset controls; a "View-only reference" indicator and an availability
|
||||
notice explain that access is enforced by Owner/Admin roles and that per-rank
|
||||
customization is planned.
|
||||
- No save is attempted and no success is reported.
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Backend Contract Proposal — Device Connections feed (W-devices)
|
||||
|
||||
Status: **Partially implemented.** The website now shows the device/connection data it can
|
||||
truthfully obtain today. A richer "connected devices" view (platform, app version, last-seen,
|
||||
accessory pairing) needs a backend feed that does not exist yet.
|
||||
|
||||
## What shipped on the website (no backend change)
|
||||
|
||||
The account page "App Connections & Sync" block previously hardcoded a connected iOS app
|
||||
("iPhone 16 Pro", "v4.1.0", "14m ago") and a paired "Cardo Packtalk Edge". These were
|
||||
fabricated. They are now replaced with truthful, real-data states:
|
||||
|
||||
- **Google account** — reflects the signed-in Firebase user (real).
|
||||
- **Authenticated devices** — count comes from the real `/api/account/sessions` feed
|
||||
(proxied to `${MOTORSIGNAL_API_BASE_URL}/api/v1/auth/sessions`), already rendered in
|
||||
detail in the Active Device Sessions panel.
|
||||
- **Helmet comms & accessories** — "Unavailable on web": a browser cannot observe Bluetooth
|
||||
pairing. Managed in the mobile app.
|
||||
- **Apple Watch** — "Unavailable on web" (unchanged, already truthful).
|
||||
|
||||
## Why the full feature needs a backend
|
||||
|
||||
A browser cannot see a rider's paired Bluetooth hardware, installed mobile app build, or
|
||||
device platform. Those facts live on the device and in MotorSignal's backend. The auth
|
||||
sessions feed is close but does not classify platform / app version / accessory pairing.
|
||||
|
||||
Delivering a true "connected devices" panel therefore requires a backend endpoint (FastAPI;
|
||||
out of scope for the website task and must be authorized separately).
|
||||
|
||||
## Proposed contract
|
||||
|
||||
Endpoint: `GET ${MOTORSIGNAL_API_BASE_URL}/api/v1/account/connections`
|
||||
Auth: Bearer access token (same as the sessions endpoint).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"id": "string",
|
||||
"kind": "mobile_app | web_session | accessory",
|
||||
"platform": "ios | android | web | null",
|
||||
"label": "iPhone 16 Pro", // human-friendly, backend-provided
|
||||
"appVersion": "4.1.0 | null",
|
||||
"lastSeenAt": "ISO-8601 | null",
|
||||
"pairingStatus": "paired | disconnected | null", // accessories only
|
||||
"current": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- The backend is the only source of platform / app version / accessory pairing. The web
|
||||
client must render exactly what it returns and must never infer or fabricate these.
|
||||
- Absent/empty feed → truthful empty state ("No linked devices"), never sample data.
|
||||
- Accessory (Cardo/Sena/GoPro/Apple Watch) rows appear only if the backend reports them;
|
||||
otherwise the web continues to show "Unavailable on web".
|
||||
|
||||
## Website wiring once the endpoint exists
|
||||
|
||||
1. Add `fetchConnections()` to a lib module, proxied like `/api/account/sessions`.
|
||||
2. Replace the current heuristic "Authenticated devices" row with the real feed, grouping by
|
||||
`kind`, showing `label`, `appVersion`, and relative `lastSeenAt`.
|
||||
3. Add tests for: real feed rendering, empty feed → empty state, and no fabrication when the
|
||||
endpoint is unavailable.
|
||||
|
|
@ -198,6 +198,8 @@ export interface ClubRoute {
|
|||
curatorUid: string;
|
||||
curatorName: string;
|
||||
createdAt: string;
|
||||
/** True when this is MotorSignal sample/example content, not a real club-curated route. */
|
||||
isExample?: boolean;
|
||||
}
|
||||
|
||||
export const FIXTURE_CLUB_ROUTES: ClubRoute[] = [
|
||||
|
|
@ -256,15 +258,16 @@ export const FIXTURE_CLUB_ROUTES: ClubRoute[] = [
|
|||
|
||||
export async function fetchClubRoutes(clubId: string, options?: { allowDemoPreview?: boolean }): Promise<ClubRoute[]> {
|
||||
if (options?.allowDemoPreview) {
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId, isExample: true }));
|
||||
}
|
||||
callerUid();
|
||||
const routesRef = collection(firestore, 'clubs', clubId, 'routes');
|
||||
const q = query(routesRef, orderBy('createdAt', 'desc'), limit(50));
|
||||
const snap = await getDocs(q);
|
||||
if (snap.empty) {
|
||||
// Return sample starter routes if no custom routes have been uploaded
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
|
||||
// No custom routes uploaded yet: surface sample starter routes, explicitly flagged as
|
||||
// examples so the UI can label them and they are never mistaken for real club routes.
|
||||
return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId, isExample: true }));
|
||||
}
|
||||
return snap.docs.map((d) => {
|
||||
const data = d.data();
|
||||
|
|
@ -358,6 +361,8 @@ export interface ChatMessage {
|
|||
authorRole: ClubRole;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
/** True when this is MotorSignal sample/example content, not a real posted message. */
|
||||
isExample?: boolean;
|
||||
}
|
||||
|
||||
export const FIXTURE_CHAT_MESSAGES: Record<string, ChatMessage[]> = {
|
||||
|
|
@ -441,9 +446,10 @@ export function subscribeClubChat(
|
|||
q,
|
||||
(snap) => {
|
||||
if (snap.empty) {
|
||||
// Fallback to fixture messages if channel has no messages yet
|
||||
// No messages in this channel yet: surface sample messages, explicitly flagged as
|
||||
// examples so the UI can label them and they are never mistaken for real posts.
|
||||
const initial = FIXTURE_CHAT_MESSAGES[channelId] || [];
|
||||
onMessages(initial.map((m) => ({ ...m, clubId, channelId })));
|
||||
onMessages(initial.map((m) => ({ ...m, clubId, channelId, isExample: true })));
|
||||
return;
|
||||
}
|
||||
const msgs: ChatMessage[] = snap.docs.map((d) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { buildWebGroupRidePayload, type WebGroupRideInput } from './group-ride-payload';
|
||||
|
||||
export type CreateRideResult =
|
||||
| { ok: true; rideId: string; payload: Record<string, unknown> }
|
||||
| { ok: false; reason: 'unauthenticated' }
|
||||
| { ok: false; reason: 'unavailable' }
|
||||
| { ok: false; reason: 'commit-failed'; error: unknown };
|
||||
|
||||
export interface FirestoreBatchLike {
|
||||
// Loose signatures so the real firebase WriteBatch is assignable here (its set() is
|
||||
// generic over DocumentReference and returns WriteBatch). Tests pass a simple fake.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
set: (ref: any, data: any) => unknown;
|
||||
commit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface CommitGroupRideDeps {
|
||||
uid?: string | null;
|
||||
firestore: unknown | null;
|
||||
leadDisplayName: string;
|
||||
leadHandle: string;
|
||||
/** Injected Firestore primitives so this is unit-testable without the firebase SDK. */
|
||||
writeBatch: (firestore: any) => FirestoreBatchLike;
|
||||
docRef: (firestore: any, ...segments: string[]) => unknown;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a web-created group ride and reports the outcome. It performs the backend write
|
||||
* and resolves ONLY after the batch commit succeeds, so callers can insert into UI state
|
||||
* strictly after confirmation (no phantom rides). It never fabricates coordinates (see
|
||||
* buildWebGroupRidePayload) and the returned rideId is the real Firestore document id used
|
||||
* as the join code.
|
||||
*
|
||||
* Returns a discriminated result instead of throwing, so every failure path is explicit:
|
||||
* - unauthenticated: no uid (an unauthenticated user cannot create a persisted ride)
|
||||
* - unavailable: no Firestore handle
|
||||
* - commit-failed: the write was attempted but rejected (nothing persisted)
|
||||
*/
|
||||
let lastRideTimestamp = 0;
|
||||
|
||||
export function resetRideIdCounter(): void {
|
||||
lastRideTimestamp = 0;
|
||||
}
|
||||
|
||||
export async function commitGroupRide(
|
||||
newRide: WebGroupRideInput,
|
||||
deps: CommitGroupRideDeps
|
||||
): Promise<CreateRideResult> {
|
||||
const uid = deps.uid;
|
||||
if (!uid) return { ok: false, reason: 'unauthenticated' };
|
||||
if (!deps.firestore) return { ok: false, reason: 'unavailable' };
|
||||
|
||||
const now = deps.now ?? new Date();
|
||||
const rawMs = now.getTime();
|
||||
const timestamp = Math.max(rawMs, lastRideTimestamp + 1);
|
||||
lastRideTimestamp = timestamp;
|
||||
const rideId = `ride_${timestamp}_${uid.substring(0, Math.min(6, uid.length))}`;
|
||||
|
||||
const payload = buildWebGroupRidePayload(newRide, {
|
||||
rideId,
|
||||
uid,
|
||||
leadHandle: deps.leadHandle,
|
||||
leadDisplayName: deps.leadDisplayName,
|
||||
now,
|
||||
});
|
||||
|
||||
const organizerParticipant = {
|
||||
userId: uid,
|
||||
role: 'organizer',
|
||||
status: 'accepted',
|
||||
riderHandle: deps.leadHandle,
|
||||
riderDisplayName: deps.leadDisplayName,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const batch = deps.writeBatch(deps.firestore);
|
||||
batch.set(deps.docRef(deps.firestore, 'group_rides', rideId), payload);
|
||||
batch.set(deps.docRef(deps.firestore, 'group_rides', rideId, 'participants', uid), organizerParticipant);
|
||||
await batch.commit();
|
||||
return { ok: true, rideId, payload };
|
||||
} catch (error) {
|
||||
return { ok: false, reason: 'commit-failed', error };
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
|
|
@ -44,6 +44,46 @@ export function areTelemetryPointsConnected(a: TelemetryPoint, b: TelemetryPoint
|
|||
return a.segmentId === b.segmentId && delta >= 0 && delta <= 5000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances a replay cursor by a budget of virtual milliseconds, honoring the real time
|
||||
* between recorded samples so the rider marker stays synchronized with the elapsed-time
|
||||
* readout (which is derived from timestamps). Playback is therefore wall-clock accurate at
|
||||
* 1x and correctly scaled at other speeds, even when samples are non-uniform.
|
||||
*
|
||||
* Disconnected transitions (segment breaks or GPS loss, per areTelemetryPointsConnected)
|
||||
* are compressed to a single nominal 20 Hz step (50 ms) so playback never stalls across
|
||||
* dead time instead of waiting out a multi-second gap. No coordinates are altered.
|
||||
*
|
||||
* Returns the new index and the leftover budget to carry into the next frame.
|
||||
*/
|
||||
export function advancePlaybackIndex(
|
||||
points: TelemetryPoint[],
|
||||
fromIndex: number,
|
||||
budgetMs: number
|
||||
): { index: number; remainingMs: number } {
|
||||
if (points.length === 0) return { index: 0, remainingMs: 0 };
|
||||
const i = Math.max(0, Math.min(fromIndex, points.length - 1));
|
||||
if (!Number.isFinite(budgetMs) || budgetMs <= 0) {
|
||||
return { index: i, remainingMs: 0 };
|
||||
}
|
||||
let budget = budgetMs;
|
||||
let curr = i;
|
||||
while (curr < points.length - 1) {
|
||||
const connected = areTelemetryPointsConnected(points[curr], points[curr + 1]);
|
||||
const rawDt = connected
|
||||
? Date.parse(points[curr + 1].timestamp) - Date.parse(points[curr].timestamp)
|
||||
: 50;
|
||||
const dt = Number.isFinite(rawDt) ? Math.max(0, rawDt) : 50;
|
||||
if (budget >= dt) {
|
||||
budget -= dt;
|
||||
curr++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { index: curr, remainingMs: curr < points.length - 1 ? budget : 0 };
|
||||
}
|
||||
|
||||
/** Leaflet's geographic API is [latitude, longitude], never normalized screen pixels. */
|
||||
export function geographicSegments(points: TelemetryPoint[]): [number, number][][] {
|
||||
const segments: [number,number][][] = [];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:components": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,481 @@
|
|||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import AccountPage from '../app/account/page';
|
||||
import { AppCloudData } from '../components/app-cloud-data';
|
||||
import { firebaseAuth } from '../lib/firebase-client';
|
||||
|
||||
const authListeners = new Set<(user: unknown) => void>();
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDocs: vi.fn(),
|
||||
deleteDoc: vi.fn(),
|
||||
updateDoc: vi.fn(),
|
||||
addDoc: vi.fn(),
|
||||
writeBatch: vi.fn(() => ({
|
||||
update: vi.fn(),
|
||||
commit: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/account',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/firebase-client', () => ({
|
||||
firebaseAuth: {},
|
||||
firestore: {},
|
||||
}));
|
||||
|
||||
vi.mock('firebase/auth', () => ({
|
||||
onAuthStateChanged: (_auth: unknown, deliver: (user: unknown) => void) => {
|
||||
authListeners.add(deliver);
|
||||
return () => {
|
||||
authListeners.delete(deliver);
|
||||
};
|
||||
},
|
||||
multiFactor: () => ({ enrolledFactors: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('firebase/firestore', () => ({
|
||||
collection: (...args: unknown[]) => ({ path: args.slice(1).join('/') }),
|
||||
doc: (...args: unknown[]) => ({ path: args.slice(1).join('/'), id: args[args.length - 1] }),
|
||||
getDocs: (...args: unknown[]) => mocks.getDocs(...args),
|
||||
deleteDoc: (...args: unknown[]) => mocks.deleteDoc(...args),
|
||||
updateDoc: (...args: unknown[]) => mocks.updateDoc(...args),
|
||||
addDoc: (...args: unknown[]) => mocks.addDoc(...args),
|
||||
writeBatch: () => mocks.writeBatch(),
|
||||
}));
|
||||
|
||||
function deliverAuth(user: unknown) {
|
||||
(firebaseAuth as { currentUser?: unknown }).currentUser = user;
|
||||
authListeners.forEach((listener) => listener(user));
|
||||
}
|
||||
|
||||
describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
authListeners.clear();
|
||||
(firebaseAuth as { currentUser?: unknown }).currentUser = undefined;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const authUser = {
|
||||
uid: 'rider-123',
|
||||
email: 'trackday@example.com',
|
||||
displayName: 'Test Rider',
|
||||
};
|
||||
|
||||
it('distinguishes initial loading: shows Loading… and Checking sync…, never 0 miles, 0 rides, or 100% Cloud Synced', async () => {
|
||||
// Keep getDocs pending so it stays in loading state
|
||||
let resolveMotorcycles: (val: unknown) => void;
|
||||
let resolveRides: (val: unknown) => void;
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (colRef.path.includes('motorcycles')) {
|
||||
return new Promise((res) => { resolveMotorcycles = res; });
|
||||
}
|
||||
return new Promise((res) => { resolveRides = res; });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
// Metric 1: Distance must show Loading…, not 0 miles
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Loading…');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
|
||||
// Metric 2: Sessions must show Checking sync… or Loading…, not 0 rides or 100% Cloud Synced
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Checking sync…');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
// Metric 3: Lean Angle Peak must show Loading…, not 0 or false verified roll
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('Loading…');
|
||||
expect(leanCard.textContent).toContain('Retrieving lean telemetry…');
|
||||
});
|
||||
|
||||
it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => {
|
||||
render(<AccountPage />);
|
||||
deliverAuth(null);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Sign in to view');
|
||||
expect(distanceCard.textContent).toContain('—');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Sign-in required');
|
||||
expect(sessionsCard.textContent).toContain('—');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('Sign in to view');
|
||||
expect(leanCard.textContent).toContain('—');
|
||||
});
|
||||
|
||||
it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => {
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('0');
|
||||
expect(distanceCard.textContent).toContain('miles');
|
||||
expect(distanceCard.textContent).toContain('Cloud Aggregate');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('0');
|
||||
expect(sessionsCard.textContent).toContain('rides');
|
||||
// Must NOT claim "100% Cloud Synced" on an empty account with no rides
|
||||
expect(sessionsCard.textContent).toContain('Cloud Synced');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
expect(sessionsCard.textContent).toContain('No rides recorded yet');
|
||||
});
|
||||
|
||||
it('displays real calculated totals for populated accounts and claims 100% Cloud Synced only when rides exist', async () => {
|
||||
const mockRides = [
|
||||
{
|
||||
id: 'ride-1',
|
||||
data: () => ({
|
||||
startTime: '2026-07-01T10:00:00Z',
|
||||
endTime: '2026-07-01T11:00:00Z',
|
||||
distanceMiles: 45.2,
|
||||
topSpeedMph: 72.4,
|
||||
averageSpeed: 45.2,
|
||||
maxLeanLeft: 42.1,
|
||||
maxLeanRight: 45.6,
|
||||
maxAccelG: 0.65,
|
||||
maxBrakingG: 0.82,
|
||||
movingTime: 3600,
|
||||
elevationGain: 850,
|
||||
qualityScore: 98,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Yamaha',
|
||||
motorcycleModel: 'YZF-R7',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'ride-2',
|
||||
data: () => ({
|
||||
startTime: '2026-07-02T14:00:00Z',
|
||||
endTime: '2026-07-02T15:30:00Z',
|
||||
distanceMiles: 54.8,
|
||||
topSpeedMph: 85.0,
|
||||
averageSpeed: 52.0,
|
||||
maxLeanLeft: 48.5,
|
||||
maxLeanRight: 46.0,
|
||||
maxAccelG: 0.75,
|
||||
maxBrakingG: 0.90,
|
||||
movingTime: 5400,
|
||||
elevationGain: 1200,
|
||||
qualityScore: 95,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Yamaha',
|
||||
motorcycleModel: 'YZF-R7',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (colRef.path.includes('rides')) {
|
||||
return Promise.resolve({ docs: mockRides, empty: false });
|
||||
}
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
// 45.2 + 54.8 = 100 miles
|
||||
expect(distanceCard.textContent).toContain('100');
|
||||
expect(distanceCard.textContent).toContain('miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('2');
|
||||
expect(sessionsCard.textContent).toContain('rides');
|
||||
// With real rides loaded, it is confirmed 100% Cloud Synced
|
||||
expect(sessionsCard.textContent).toContain('100% Cloud Synced');
|
||||
expect(sessionsCard.textContent).toContain('Canyons, Circuits & Commutes');
|
||||
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('48.5°');
|
||||
expect(leanCard.textContent).toContain('6-Axis IMU Verified');
|
||||
});
|
||||
|
||||
it('truthfully handles Firestore permission denied: shows Unavailable and Permission denied, never verified zero totals', async () => {
|
||||
mocks.getDocs.mockImplementation(() => {
|
||||
const err = new Error('Missing or insufficient permissions.');
|
||||
(err as unknown as { code: string }).code = 'permission-denied';
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Unavailable');
|
||||
expect(distanceCard.textContent).toContain('Permission denied');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).toContain('Permission denied');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('Unavailable');
|
||||
expect(leanCard.textContent).toContain('Permission denied');
|
||||
expect(leanCard.textContent).toContain('Lean telemetry restricted');
|
||||
expect(leanCard.textContent).not.toContain('No IMU roll data');
|
||||
|
||||
// AppCloudData error banners
|
||||
expect(screen.getByTestId('cloud-garage-error')).toBeDefined();
|
||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||
expect(screen.getByText(/Recent Rides Restricted — Permission Denied/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('truthfully handles network / temporary backend failure: shows Unavailable and Sync status unavailable', async () => {
|
||||
mocks.getDocs.mockImplementation(() => {
|
||||
const err = new Error('Failed to fetch: client is offline');
|
||||
(err as unknown as { code: string }).code = 'unavailable';
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Unavailable');
|
||||
expect(distanceCard.textContent).toContain('Sync unavailable');
|
||||
expect(distanceCard.textContent).not.toContain('0 miles');
|
||||
});
|
||||
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).toContain('Sync status unavailable');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('Unavailable');
|
||||
expect(leanCard.textContent).toContain('Sync unavailable');
|
||||
expect(leanCard.textContent).toContain('Lean telemetry unavailable');
|
||||
expect(leanCard.textContent).not.toContain('No IMU roll data');
|
||||
|
||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||
expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles partial data availability: shows garage when motorcycles load, but keeps rides metrics Unavailable when rides query fails', async () => {
|
||||
const mockBike = {
|
||||
id: 'bike-1',
|
||||
data: () => ({
|
||||
year: 2024,
|
||||
makeName: 'Ducati',
|
||||
modelName: 'Panigale V4',
|
||||
variantName: 'SP2',
|
||||
nickname: 'Red Bullet',
|
||||
isDefault: true,
|
||||
}),
|
||||
};
|
||||
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (colRef.path.includes('motorcycles')) {
|
||||
return Promise.resolve({ docs: [mockBike], empty: false });
|
||||
}
|
||||
const err = new Error('Rides query unavailable');
|
||||
(err as unknown as { code: string }).code = 'unavailable';
|
||||
return Promise.reject(err);
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
await waitFor(() => {
|
||||
// Bike rendered in garage
|
||||
expect(screen.getByText('Red Bullet')).toBeDefined();
|
||||
});
|
||||
|
||||
// But ride metrics MUST show Unavailable, not zero!
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).not.toContain('0 rides');
|
||||
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced');
|
||||
|
||||
// Rides feed in AppCloudData shows error
|
||||
expect(screen.getByTestId('cloud-rides-error')).toBeDefined();
|
||||
});
|
||||
|
||||
it('recovers after retry: Quick Sync displays error on failure and updates to Synced on successful retry', async () => {
|
||||
let shouldFail = true;
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (shouldFail) {
|
||||
const err = new Error('Network error');
|
||||
(err as unknown as { code: string }).code = 'unavailable';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
if (colRef.path.includes('rides')) {
|
||||
return Promise.resolve({
|
||||
docs: [
|
||||
{
|
||||
id: 'ride-1',
|
||||
data: () => ({
|
||||
startTime: '2026-07-01T10:00:00Z',
|
||||
endTime: '2026-07-01T11:00:00Z',
|
||||
distanceMiles: 15.0,
|
||||
topSpeedMph: 60,
|
||||
averageSpeed: 35,
|
||||
maxLeanLeft: 30,
|
||||
maxLeanRight: 32,
|
||||
maxAccelG: 0.5,
|
||||
maxBrakingG: 0.6,
|
||||
movingTime: 1200,
|
||||
elevationGain: 200,
|
||||
qualityScore: 90,
|
||||
motorcycleYear: 2024,
|
||||
motorcycleMake: 'Yamaha',
|
||||
motorcycleModel: 'MT-07',
|
||||
}),
|
||||
},
|
||||
],
|
||||
empty: false,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
// Initial failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Sync Failed \(Retry\)/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// Make next fetch succeed
|
||||
shouldFail = false;
|
||||
|
||||
// Click retry via Quick Sync button
|
||||
const quickSyncBtn = screen.getByRole('button', { name: /Sync Failed \(Retry\)/i });
|
||||
fireEvent.click(quickSyncBtn);
|
||||
|
||||
// Recovered state
|
||||
await waitFor(() => {
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('1');
|
||||
expect(sessionsCard.textContent).toContain('rides');
|
||||
expect(sessionsCard.textContent).toContain('100% Cloud Synced');
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Synced \(/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('guards against race conditions when user logs out or switches accounts during in-flight fetch', async () => {
|
||||
let resolveMotorcycles: (val: unknown) => void;
|
||||
let resolveRides: (val: unknown) => void;
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (colRef.path.includes('motorcycles')) {
|
||||
return new Promise((res) => { resolveMotorcycles = res; });
|
||||
}
|
||||
return new Promise((res) => { resolveRides = res; });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
// Initial loading
|
||||
expect(screen.getByTestId('metric-card-distance').textContent).toContain('Loading…');
|
||||
|
||||
// User logs out while fetch is still in-flight
|
||||
deliverAuth(null);
|
||||
|
||||
// Now the in-flight promise resolves with User A's data
|
||||
resolveMotorcycles!({
|
||||
docs: [{
|
||||
id: 'bike-user-a',
|
||||
data: () => ({ year: 2024, makeName: 'Yamaha', modelName: 'R7', nickname: 'Ghost Bike' }),
|
||||
}],
|
||||
empty: false,
|
||||
});
|
||||
resolveRides!({
|
||||
docs: [{
|
||||
id: 'ride-user-a',
|
||||
data: () => ({ distanceMiles: 50, maxLeanLeft: 35, maxLeanRight: 35, startTime: '2026-07-01T10:00:00Z' }),
|
||||
}],
|
||||
empty: false,
|
||||
});
|
||||
|
||||
// Wait to confirm User A's ghost data does NOT leak into logged-out state
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Sign in to view');
|
||||
expect(distanceCard.textContent).not.toContain('50');
|
||||
expect(screen.queryByText('Ghost Bike')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears metrics state when an error occurs so stale metrics from a prior successful sync are not retained', async () => {
|
||||
let shouldFail = false;
|
||||
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
|
||||
if (shouldFail) {
|
||||
const err = new Error('Permission denied on re-sync');
|
||||
(err as unknown as { code: string }).code = 'permission-denied';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
if (colRef.path.includes('rides')) {
|
||||
return Promise.resolve({
|
||||
docs: [{
|
||||
id: 'ride-1',
|
||||
data: () => ({ distanceMiles: 25.0, maxLeanLeft: 30, maxLeanRight: 32, startTime: '2026-07-01T10:00:00Z' }),
|
||||
}],
|
||||
empty: false,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ docs: [], empty: true });
|
||||
});
|
||||
|
||||
render(<AccountPage />);
|
||||
deliverAuth(authUser);
|
||||
|
||||
// Verify initial successful sync
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('metric-card-distance').textContent).toContain('25');
|
||||
});
|
||||
|
||||
// Now trigger Quick Sync and make it fail
|
||||
shouldFail = true;
|
||||
const quickSyncBtn = screen.getByRole('button', { name: /Synced/i });
|
||||
fireEvent.click(quickSyncBtn);
|
||||
|
||||
// Verify that metrics are immediately cleared and show Unavailable, not the stale 25 miles
|
||||
await waitFor(() => {
|
||||
const distanceCard = screen.getByTestId('metric-card-distance');
|
||||
expect(distanceCard.textContent).toContain('Unavailable');
|
||||
expect(distanceCard.textContent).not.toContain('25');
|
||||
const sessionsCard = screen.getByTestId('metric-card-sessions');
|
||||
expect(sessionsCard.textContent).toContain('Unavailable');
|
||||
expect(sessionsCard.textContent).not.toContain('1 rides');
|
||||
const leanCard = screen.getByTestId('metric-card-lean');
|
||||
expect(leanCard.textContent).toContain('Unavailable');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import AccountPage from '../app/account/page';
|
||||
|
||||
// Account page only needs next/navigation stubbed; firebase-client initialises under the
|
||||
// staging test env and reports a signed-out user (onAuthStateChanged -> null).
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/account',
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe('WEB-W1b/W-2FA/W-devices: account page truthful states', () => {
|
||||
it('does not fabricate a connected mobile device, paired comms, or sync status', async () => {
|
||||
render(<AccountPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('App Connections & Sync')).toBeDefined();
|
||||
});
|
||||
|
||||
// Fabricated device/pairing claims must be gone.
|
||||
expect(screen.queryByText(/iPhone 16 Pro/)).toBeNull();
|
||||
expect(screen.queryByText(/Cardo Packtalk Edge/)).toBeNull();
|
||||
expect(screen.queryByText(/^Paired$/)).toBeNull();
|
||||
expect(screen.queryByText(/^14m ago$/)).toBeNull();
|
||||
expect(screen.queryByText(/^All Synced$/)).toBeNull();
|
||||
|
||||
// Truthful states are present instead.
|
||||
expect(screen.getByText('Authenticated Devices')).toBeDefined();
|
||||
expect(screen.getByText('Helmet Comms & Accessories')).toBeDefined();
|
||||
expect(screen.getAllByText(/Unavailable on web/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('never shows 2FA as ENABLED unless a factor is actually enrolled (signed-out = NOT ENABLED)', async () => {
|
||||
render(<AccountPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-Factor Authentication (2FA)')).toBeDefined();
|
||||
});
|
||||
|
||||
// Exact-match: the badge node reads "NOT ENABLED" for a user with no enrolled factor,
|
||||
// so an exact "ENABLED" node must not exist.
|
||||
expect(screen.getByText('NOT ENABLED')).toBeDefined();
|
||||
expect(screen.queryByText('ENABLED')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -119,10 +119,8 @@ describe('WEB-13: ClubHierarchyBuilder & ClubPermissionMatrix', () => {
|
|||
expect(handleUpdateMemberRank).toHaveBeenCalledWith('rider-03', expect.any(String));
|
||||
});
|
||||
|
||||
it('renders permission matrix table with capabilities and allows toggling', () => {
|
||||
it('renders permission matrix capabilities for reference', () => {
|
||||
const handleToggle = vi.fn();
|
||||
const handleReset = vi.fn();
|
||||
const handleSave = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<ClubPermissionMatrix
|
||||
|
|
@ -131,9 +129,6 @@ describe('WEB-13: ClubHierarchyBuilder & ClubPermissionMatrix', () => {
|
|||
permissions={DEFAULT_RANK_PERMISSIONS}
|
||||
selectedRankId="road_captain"
|
||||
onTogglePermission={handleToggle}
|
||||
onResetDefaults={handleReset}
|
||||
onSavePermissions={handleSave}
|
||||
isSaving={false}
|
||||
canEdit={true}
|
||||
/>
|
||||
);
|
||||
|
|
@ -143,15 +138,50 @@ describe('WEB-13: ClubHierarchyBuilder & ClubPermissionMatrix', () => {
|
|||
expect(screen.getByText('Review Applications')).toBeDefined();
|
||||
expect(screen.getByText('Post Announcements')).toBeDefined();
|
||||
expect(screen.getByText('Moderate Club Wall')).toBeDefined();
|
||||
|
||||
// Find toggle button for road captain and click it
|
||||
const toggleButton = screen.getByRole('button', { name: 'Toggle Plan Club Rides for Road Captain' });
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
expect(handleToggle).toHaveBeenCalledWith('road_captain', 'planRides');
|
||||
});
|
||||
|
||||
it('triggers save matrix and reset defaults', async () => {
|
||||
// WEB-W1 regression: without a persistence backend the matrix must be an honest
|
||||
// read-only reference. It must not offer a Save control, must not report a save,
|
||||
// and must not mutate permissions — even for an Owner/Admin (canEdit=true).
|
||||
it('is read-only and never reports a false save when persistence is unavailable', () => {
|
||||
const handleToggle = vi.fn();
|
||||
const handleSave = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<ClubPermissionMatrix
|
||||
ranks={DEFAULT_CLUB_RANKS}
|
||||
capabilities={DEFAULT_CLUB_CAPABILITIES}
|
||||
permissions={DEFAULT_RANK_PERMISSIONS}
|
||||
selectedRankId="road_captain"
|
||||
onTogglePermission={handleToggle}
|
||||
onSavePermissions={handleSave}
|
||||
canEdit={true}
|
||||
// persistenceAvailable defaults to false
|
||||
/>
|
||||
);
|
||||
|
||||
// No save/reset affordances are rendered.
|
||||
expect(screen.queryByRole('button', { name: /Save Matrix/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /Reset Defaults/i })).toBeNull();
|
||||
expect(screen.queryByText(/Saved/)).toBeNull();
|
||||
|
||||
// A truthful view-only indicator and availability notice are present.
|
||||
expect(screen.getByText(/View-only reference/i)).toBeDefined();
|
||||
expect(screen.getByText(/not saved yet/i)).toBeDefined();
|
||||
|
||||
// Toggles are disabled and do not mutate permissions.
|
||||
const toggleButton = screen.getByRole('button', {
|
||||
name: /Standard setting:\s*Plan Club Rides for Road Captain/i,
|
||||
});
|
||||
expect((toggleButton as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(toggleButton);
|
||||
expect(handleToggle).not.toHaveBeenCalled();
|
||||
expect(handleSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Forward-compatible editable path: when a persistence backend is wired AND the
|
||||
// caller may edit, the matrix exposes real Save/Reset controls and toggles.
|
||||
it('allows editing and saving only when persistence is available and canEdit', async () => {
|
||||
const handleToggle = vi.fn();
|
||||
const handleReset = vi.fn();
|
||||
const handleSave = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -167,9 +197,14 @@ describe('WEB-13: ClubHierarchyBuilder & ClubPermissionMatrix', () => {
|
|||
onSavePermissions={handleSave}
|
||||
isSaving={false}
|
||||
canEdit={true}
|
||||
persistenceAvailable={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleButton = screen.getByRole('button', { name: 'Toggle Plan Club Rides for Road Captain' });
|
||||
fireEvent.click(toggleButton);
|
||||
expect(handleToggle).toHaveBeenCalledWith('road_captain', 'planRides');
|
||||
|
||||
const resetBtn = screen.getByRole('button', { name: /Reset Defaults/i });
|
||||
fireEvent.click(resetBtn);
|
||||
expect(handleReset).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { commitGroupRide, resetRideIdCounter } from '../lib/create-group-ride';
|
||||
import 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',
|
||||
routeSnippet: 'Skyline > Hwy 9',
|
||||
userStatus: 'joined',
|
||||
status: 'scheduled',
|
||||
};
|
||||
|
||||
function makeDeps(overrides: Partial<Parameters<typeof commitGroupRide>[1]> = {}) {
|
||||
const setSpy = vi.fn();
|
||||
const commitSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const deps = {
|
||||
uid: 'rider-uid-1' as string | null | undefined,
|
||||
firestore: {} as unknown,
|
||||
leadDisplayName: 'Rider One',
|
||||
leadHandle: 'rider_one',
|
||||
now: new Date('2026-10-14T12:00:00.000Z'),
|
||||
writeBatch: () => ({ set: setSpy, commit: commitSpy }),
|
||||
docRef: (_fs: any, ...segments: string[]) => ({ path: segments.join('/') }),
|
||||
...overrides,
|
||||
};
|
||||
return { deps, setSpy, commitSpy };
|
||||
}
|
||||
|
||||
describe('commitGroupRide (WEB Phase 1 — persisted ride creation)', () => {
|
||||
beforeEach(() => {
|
||||
resetRideIdCounter();
|
||||
});
|
||||
|
||||
it('refuses to create for an unauthenticated user (no uid) and never writes', async () => {
|
||||
const { deps, commitSpy } = makeDeps({ uid: undefined });
|
||||
const res = await commitGroupRide(baseRide, deps);
|
||||
expect(res).toEqual({ ok: false, reason: 'unauthenticated' });
|
||||
expect(commitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unavailable when Firestore is missing and never writes', async () => {
|
||||
const { deps, commitSpy } = makeDeps({ firestore: null });
|
||||
const res = await commitGroupRide(baseRide, deps);
|
||||
expect(res).toEqual({ ok: false, reason: 'unavailable' });
|
||||
expect(commitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists the ride and participant, then returns the real rideId as the join code', async () => {
|
||||
const { deps, setSpy, commitSpy } = makeDeps();
|
||||
const res = await commitGroupRide(baseRide, deps);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
expect(res.rideId).toMatch(/^ride_\d+_rider-/);
|
||||
// Two writes: the ride document and the organizer participant.
|
||||
expect(setSpy).toHaveBeenCalledTimes(2);
|
||||
expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||
// The persisted payload never fabricates coordinates.
|
||||
const mp = (res.payload as any).meetingPoint;
|
||||
expect('latitude' in mp).toBe(false);
|
||||
expect('longitude' in mp).toBe(false);
|
||||
expect(mp.displayName).toBe('Alice’s Restaurant');
|
||||
expect((res.payload as any).organizerUid).toBe('rider-uid-1');
|
||||
});
|
||||
|
||||
it('returns commit-failed (and does not throw) when the Firestore write rejects', async () => {
|
||||
const boom = new Error('network down');
|
||||
const { deps } = makeDeps({
|
||||
writeBatch: () => ({ set: vi.fn(), commit: vi.fn().mockRejectedValue(boom) }),
|
||||
});
|
||||
const res = await commitGroupRide(baseRide, deps);
|
||||
expect(res).toEqual({ ok: false, reason: 'commit-failed', error: boom });
|
||||
});
|
||||
|
||||
it('guarantees unique ride IDs across concurrent submissions at the same millisecond', async () => {
|
||||
const sharedNow = new Date('2026-10-14T12:00:00.000Z');
|
||||
const { deps: deps1 } = makeDeps({ now: sharedNow });
|
||||
const { deps: deps2 } = makeDeps({ now: sharedNow });
|
||||
|
||||
const [res1, res2] = await Promise.all([
|
||||
commitGroupRide(baseRide, deps1),
|
||||
commitGroupRide(baseRide, deps2),
|
||||
]);
|
||||
|
||||
expect(res1.ok).toBe(true);
|
||||
expect(res2.ok).toBe(true);
|
||||
if (res1.ok && res2.ok) {
|
||||
expect(res1.rideId).not.toBe(res2.rideId);
|
||||
expect(res1.rideId).toMatch(/^ride_\d+_rider-/);
|
||||
expect(res2.rideId).toMatch(/^ride_\d+_rider-/);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves clean timestamps without string suffix corruption on successive rides', async () => {
|
||||
const t1 = new Date('2026-10-14T12:00:00.000Z');
|
||||
const t2 = new Date('2026-10-14T12:05:00.000Z');
|
||||
const { deps: deps1 } = makeDeps({ now: t1 });
|
||||
const { deps: deps2 } = makeDeps({ now: t2 });
|
||||
|
||||
const res1 = await commitGroupRide(baseRide, deps1);
|
||||
const res2 = await commitGroupRide(baseRide, deps2);
|
||||
|
||||
expect(res1.ok).toBe(true);
|
||||
expect(res2.ok).toBe(true);
|
||||
if (res1.ok && res2.ok) {
|
||||
// Must equal exact millisecond timestamp, NOT corrupted with appended counter digits
|
||||
expect(res1.rideId).toBe(`ride_${t1.getTime()}_rider-`);
|
||||
expect(res2.rideId).toBe(`ride_${t2.getTime()}_rider-`);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles permission-denied or rejected commit cleanly without throwing', async () => {
|
||||
const permDenied = new Error('permission-denied: caller lacks organizer permission');
|
||||
const { deps } = makeDeps({
|
||||
writeBatch: () => ({ set: vi.fn(), commit: vi.fn().mockRejectedValue(permDenied) }),
|
||||
});
|
||||
const res = await commitGroupRide(baseRide, deps);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.reason).toBe('commit-failed');
|
||||
expect((res as any).error).toBe(permDenied);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -250,6 +250,18 @@ describe('Founder Routes Management', () => {
|
|||
expect(mocks.deleteRoute).toHaveBeenCalledWith('ClubID', 'r1');
|
||||
});
|
||||
});
|
||||
|
||||
// WEB-W-routes regression: fixture fallback routes must be labelled as examples so a
|
||||
// rider can never mistake sample content for the club's real curated routes.
|
||||
it('labels example routes when the club has none of its own', async () => {
|
||||
mocks.fetchRoutes.mockResolvedValue(sampleRoutes.map((r) => ({ ...r, isExample: true })));
|
||||
render(<FounderRoutes context={mockContext} />);
|
||||
await screen.findByText('Blood Mountain Gap Loop');
|
||||
|
||||
expect(screen.getByText(/example routes/i)).toBeDefined();
|
||||
expect(screen.getByText(/Examples$/)).toBeDefined();
|
||||
expect(screen.getAllByText('Example').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Founder Chat Management', () => {
|
||||
|
|
@ -296,6 +308,36 @@ describe('Founder Chat Management', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// WEB-W-chat regression: fixture fallback messages must be labelled as examples and the
|
||||
// header must not claim a live connection when only sample content is shown.
|
||||
it('labels example messages when the channel has no real posts', async () => {
|
||||
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => {
|
||||
onMessages([
|
||||
{
|
||||
id: 'example-msg-1',
|
||||
clubId: 'ClubID',
|
||||
channelId: 'general',
|
||||
authorUid: 'owner',
|
||||
authorDisplayName: 'Morgan Vance',
|
||||
authorHandle: '@morgan_v',
|
||||
authorDisplayRank: 'President',
|
||||
authorRole: 'owner',
|
||||
content: 'Welcome to squad chat!',
|
||||
createdAt: '2026-06-01T12:00:00Z',
|
||||
isExample: true,
|
||||
},
|
||||
]);
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText('Welcome to squad chat!');
|
||||
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.getByText(/example messages/i)).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
});
|
||||
|
||||
it('restricts Officers Lounge channel for non-officers', async () => {
|
||||
const memberContext: FounderClubContext = {
|
||||
...mockContext,
|
||||
|
|
@ -313,6 +355,151 @@ describe('Founder Chat Management', () => {
|
|||
await screen.findByText('Officers Lounge Restricted');
|
||||
expect(screen.getByText(/strictly reserved for club Founders and Admins/i)).toBeDefined();
|
||||
});
|
||||
|
||||
describe('Founder Demo Chat Truthfulness (PR #38 Thread 2)', () => {
|
||||
const demoContext: FounderClubContext = {
|
||||
...mockContext,
|
||||
isDemoFixture: true,
|
||||
};
|
||||
|
||||
it('displays Example thread and Demo Preview banner on initial demo fixtures', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.getByText(/Founder Demo Preview/i)).toBeDefined();
|
||||
expect(screen.getByText(/simulated conversation thread/i)).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
// subscribeChat must NOT be called in demo mode
|
||||
expect(mocks.subscribeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Example thread and does not flip to Live Connected after sending a simulated message', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
const input = screen.getByPlaceholderText(/Message #general-squad/i);
|
||||
fireEvent.change(input, { target: { value: 'Testing demo simulated message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
|
||||
|
||||
await screen.findByText('Testing demo simulated message');
|
||||
|
||||
// Crucial: Must NEVER switch to Live Connected
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.getByText(/Founder Demo Preview/i)).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
|
||||
// Backend send must NOT be called in demo mode
|
||||
expect(mocks.sendChat).not.toHaveBeenCalled();
|
||||
|
||||
// Simulated message has Example badge
|
||||
expect(screen.getAllByText('Example').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('keeps Example thread after sending multiple simulated messages', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
const input = screen.getByPlaceholderText(/Message #general-squad/i);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'First simulated message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
|
||||
await screen.findByText('First simulated message');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Second simulated message' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send/i }));
|
||||
await screen.findByText('Second simulated message');
|
||||
|
||||
// Still Example thread
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
expect(mocks.sendChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('displays Example thread even when demo channel has zero messages', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
// Switch to road-intel channel which has empty fixture in our mock
|
||||
fireEvent.click(screen.getByText('#road-intel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No messages yet in #road-intel/i)).toBeDefined();
|
||||
});
|
||||
|
||||
// In demo mode, zero messages must still show Example thread, never Live Connected!
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Live Connected only when real backend subscription delivers messages', async () => {
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText('Welcome to squad chat!');
|
||||
|
||||
expect(screen.getByText('Live Connected')).toBeDefined();
|
||||
expect(screen.queryByText('Example thread')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Disconnected and error banner when backend subscription fails in real mode', async () => {
|
||||
mocks.subscribeChat.mockImplementation((_clubId, _channelId, _onMsgs, onError) => {
|
||||
onError(new Error('Backend connection failed'));
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText(/Failed to connect to chat channel/i);
|
||||
|
||||
expect(screen.getByText('Disconnected')).toBeDefined();
|
||||
expect(screen.queryByText('Live Connected')).toBeNull();
|
||||
expect(screen.queryByText('Example thread')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Disconnected rather than Example thread when real connection fails even if channel had sample messages', async () => {
|
||||
let triggerError: (err: Error) => void;
|
||||
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMsgs, onError) => {
|
||||
onMsgs([
|
||||
{
|
||||
id: 'sample-1',
|
||||
clubId: 'club-1',
|
||||
channelId: 'general',
|
||||
authorUid: 'admin',
|
||||
authorDisplayName: 'Admin',
|
||||
authorRole: 'admin',
|
||||
content: 'Sample post',
|
||||
createdAt: '2026-06-01T12:00:00Z',
|
||||
isExample: true,
|
||||
},
|
||||
]);
|
||||
triggerError = onError;
|
||||
return vi.fn();
|
||||
});
|
||||
|
||||
render(<FounderChat context={mockContext} />);
|
||||
await screen.findByText('Sample post');
|
||||
expect(screen.getByText('Example thread')).toBeDefined();
|
||||
|
||||
// Now connection drops in real mode
|
||||
triggerError!(new Error('Connection interrupted'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disconnected')).toBeDefined();
|
||||
expect(screen.queryByText('Example thread')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears message list when switching channels to prevent message bleed', async () => {
|
||||
render(<FounderChat context={demoContext} />);
|
||||
await screen.findByText('Squad Live Chat');
|
||||
|
||||
expect(screen.getByText(/Welcome to squad chat!/i)).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByText('#road-intel'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Welcome to squad chat!/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Founder Community Page & Sidebar Integration', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,12 +19,56 @@ vi.mock('next/navigation', () => ({
|
|||
useRouter: () => ({ push: mockPush, replace: mockReplace, back: vi.fn() }),
|
||||
}));
|
||||
|
||||
// Deterministic Firebase so the authenticated GroupRidesManagement hub renders and persists
|
||||
// without real network. `fb.commitImpl` lets individual tests force commit success/failure.
|
||||
const fb = vi.hoisted(() => ({ commitImpl: vi.fn(() => Promise.resolve()) }));
|
||||
|
||||
vi.mock('firebase/firestore', () => ({
|
||||
collection: vi.fn(() => ({})),
|
||||
query: vi.fn(() => ({})),
|
||||
where: vi.fn(() => ({})),
|
||||
onSnapshot: vi.fn(() => () => {}),
|
||||
doc: vi.fn((_fs: unknown, ...segs: string[]) => ({ path: segs.join('/') })),
|
||||
writeBatch: vi.fn(() => ({ set: vi.fn(), update: vi.fn(), commit: () => fb.commitImpl() })),
|
||||
updateDoc: vi.fn(() => Promise.resolve()),
|
||||
deleteDoc: vi.fn(() => Promise.resolve()),
|
||||
setDoc: vi.fn(() => Promise.resolve()),
|
||||
arrayUnion: vi.fn((x: unknown) => x),
|
||||
arrayRemove: vi.fn((x: unknown) => x),
|
||||
increment: vi.fn((n: unknown) => n),
|
||||
serverTimestamp: vi.fn(() => 'ts'),
|
||||
getDoc: vi.fn(() => Promise.resolve({ exists: () => false, data: () => ({}) })),
|
||||
getFirestore: vi.fn(() => ({})),
|
||||
connectFirestoreEmulator: vi.fn(),
|
||||
}));
|
||||
vi.mock('firebase/app', () => ({
|
||||
getApps: vi.fn(() => []),
|
||||
initializeApp: vi.fn(() => ({ name: 'test' })),
|
||||
}));
|
||||
vi.mock('firebase/auth', () => ({
|
||||
getAuth: vi.fn(() => ({ currentUser: null })),
|
||||
connectAuthEmulator: vi.fn(),
|
||||
signInWithEmailAndPassword: vi.fn(),
|
||||
signOut: vi.fn(() => Promise.resolve()),
|
||||
onAuthStateChanged: vi.fn((_auth: unknown, cb: (u: unknown) => void) => {
|
||||
cb(null);
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
vi.mock('../lib/firebase-client', () => ({ firestore: {}, firebaseAuth: { currentUser: null } }));
|
||||
|
||||
// An authenticated organizer: a real Firebase user always has a uid.
|
||||
const authedUser = { uid: 'organizer-uid-1', email: 'lead.rider@motorsignal.app', displayName: 'LeadRider' };
|
||||
|
||||
import GroupRidesManageRoute from '../app/group-rides/manage/page';
|
||||
import GroupRidesShowcaseRoute from '../app/group-rides/showcase/page';
|
||||
|
||||
describe('Group Rides Interactive Page & Components', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Default: Firestore commit succeeds. Individual tests override for failure paths.
|
||||
fb.commitImpl.mockReset();
|
||||
fb.commitImpl.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -178,9 +222,9 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
expect(onCreateRide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits valid ride and invokes onCreateRide callback', () => {
|
||||
it('submits valid ride and invokes onCreateRide callback', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onCreateRide = vi.fn();
|
||||
const onCreateRide = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
|
||||
|
||||
|
|
@ -200,7 +244,31 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
userStatus: 'joined',
|
||||
})
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
// The modal closes only after onCreateRide resolves (persistence confirmed).
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('disables the submit button while a create is in flight (no duplicate submissions)', async () => {
|
||||
const onClose = vi.fn();
|
||||
let resolveCreate: () => void = () => {};
|
||||
const onCreateRide = vi.fn(() => new Promise<void>((r) => { resolveCreate = r; }));
|
||||
|
||||
render(<CreateRideModal isOpen={true} onClose={onClose} onCreateRide={onCreateRide} />);
|
||||
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'Pending Ride' } });
|
||||
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Nowhere' } });
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: /publish group ride/i });
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
// While the create promise is pending, the button is disabled and relabeled.
|
||||
const pendingBtn = await screen.findByRole('button', { name: /publishing/i });
|
||||
expect((pendingBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
// A second click must not trigger another create.
|
||||
fireEvent.click(pendingBtn);
|
||||
expect(onCreateRide).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveCreate();
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -308,8 +376,7 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
|
||||
describe('GroupRidesManagement (For Logged In Users - View, Add, Remove, Alter)', () => {
|
||||
it('allows viewing, adding, altering, canceling, and deleting group rides', async () => {
|
||||
const mockUser = { email: 'lead.rider@motorsignal.app', displayName: 'LeadRider' };
|
||||
render(<GroupRidesManagement currentUser={mockUser} />);
|
||||
render(<GroupRidesManagement currentUser={authedUser} />);
|
||||
|
||||
// Header & Console title
|
||||
expect(screen.getByText(/Group Rides Management/i)).toBeDefined();
|
||||
|
|
@ -348,8 +415,8 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
||||
|
||||
// Verify newly created ride appears in the list
|
||||
expect(screen.getByRole('heading', { name: /High Sierra Alpine Sprint/i })).toBeDefined();
|
||||
// The ride is inserted only after Firestore confirms the write, so wait for it.
|
||||
await screen.findByRole('heading', { name: /High Sierra Alpine Sprint/i });
|
||||
|
||||
// 3. ALTER: Edit existing organized ride parameters
|
||||
const alterButtons = screen.getAllByRole('button', { name: /alter \/ edit/i });
|
||||
|
|
@ -394,6 +461,74 @@ describe('Group Rides Interactive Page & Components', () => {
|
|||
expect(screen.getByText(/Live Squad Telemetry Stream: Connected/i)).toBeDefined();
|
||||
expect(screen.getByText(/Formation session #CA-84-SW/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('blocks ride creation for an unauthenticated user and never shows a phantom ride', async () => {
|
||||
const anonUser = { email: 'anon@motorsignal.app' } as any; // no uid
|
||||
render(<GroupRidesManagement currentUser={anonUser} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create group ride/i }));
|
||||
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'Phantom Ride' } });
|
||||
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Nowhere' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
||||
|
||||
// Actionable error, and the ride must never appear in the list.
|
||||
await screen.findByText(/could not be saved/i);
|
||||
expect(screen.queryByRole('heading', { name: /Phantom Ride/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not leave a phantom ride when the Firestore commit fails', async () => {
|
||||
fb.commitImpl.mockReset();
|
||||
fb.commitImpl.mockRejectedValue(new Error('offline'));
|
||||
render(<GroupRidesManagement currentUser={authedUser} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create group ride/i }));
|
||||
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'Doomed Ride' } });
|
||||
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Nowhere' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /publish group ride/i }));
|
||||
|
||||
await screen.findByText(/Could not create the ride/i);
|
||||
expect(screen.queryByRole('heading', { name: /Doomed Ride/i })).toBeNull();
|
||||
// Form inputs remain preserved in the modal so the user does not lose their typed data
|
||||
expect(screen.getByLabelText<HTMLInputElement>(/ride title/i).value).toBe('Doomed Ride');
|
||||
expect(screen.getByLabelText<HTMLInputElement>(/region \/ city/i).value).toBe('Nowhere');
|
||||
});
|
||||
|
||||
it('enforces commit-before-UI: never inserts into UI state while commit is in-flight', async () => {
|
||||
let resolveCommit!: () => void;
|
||||
const pendingCommit = new Promise<void>((res) => {
|
||||
resolveCommit = res;
|
||||
});
|
||||
fb.commitImpl.mockReset();
|
||||
fb.commitImpl.mockImplementation(() => pendingCommit);
|
||||
|
||||
render(<GroupRidesManagement currentUser={authedUser} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /create group ride/i }));
|
||||
fireEvent.change(screen.getByLabelText(/ride title/i), { target: { value: 'In Flight Ride' } });
|
||||
fireEvent.change(screen.getByLabelText(/region \/ city/i), { target: { value: 'Highway 1' } });
|
||||
|
||||
const publishBtn = screen.getByRole('button', { name: /publish group ride/i });
|
||||
fireEvent.click(publishBtn);
|
||||
|
||||
// 1. While in-flight, button disables and shows publishing indicator
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Publishing…/i)).toBeDefined();
|
||||
});
|
||||
// 2. The ride MUST NOT exist in UI state before commit confirms
|
||||
expect(screen.queryByRole('heading', { name: /In Flight Ride/i })).toBeNull();
|
||||
|
||||
// 3. Duplicate clicks while in flight are ignored
|
||||
const inFlightBtn = screen.getByText(/Publishing…/i);
|
||||
fireEvent.click(inFlightBtn);
|
||||
expect(fb.commitImpl).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 4. Resolve the commit
|
||||
resolveCommit();
|
||||
|
||||
// 5. Ride now appears in UI state strictly after commit confirmation
|
||||
await screen.findByRole('heading', { name: /In Flight Ride/i });
|
||||
expect(fb.commitImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full GroupRidesPage Smart Routing', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import SupportPage from '../app/support/page';
|
||||
import { SupportContactForm } from '../components/support-contact-form';
|
||||
|
||||
describe('WEB-W2: public Support Center', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('renders a grounded FAQ and a real email contact channel', () => {
|
||||
render(<SupportPage />);
|
||||
expect(screen.getByText('Frequently asked')).toBeDefined();
|
||||
expect(screen.getByText(/Can I export my ride data\?/i)).toBeDefined();
|
||||
expect(screen.getByText(/How do I turn on two-factor authentication/i)).toBeDefined();
|
||||
// Email fallback is present as a real mailto link.
|
||||
const mailLinks = screen.getAllByRole('link', { name: /support@motorsignal\.app/i });
|
||||
expect(mailLinks.length).toBeGreaterThan(0);
|
||||
expect(mailLinks[0].getAttribute('href')).toBe('mailto:support@motorsignal.app');
|
||||
});
|
||||
|
||||
it('validates input and does not claim any submission on invalid data', () => {
|
||||
render(<SupportContactForm />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Compose support email/i }));
|
||||
|
||||
expect(screen.getByText(/tell us your name/i)).toBeDefined();
|
||||
expect(screen.getByText(/valid email address/i)).toBeDefined();
|
||||
expect(screen.getByText(/at least 10 characters/i)).toBeDefined();
|
||||
// No success/opened confirmation on invalid submit.
|
||||
expect(screen.queryByText(/email app should have opened/i)).toBeNull();
|
||||
// Truthful disclaimer is always present.
|
||||
expect(screen.getByText(/don.?t submit a ticket on your behalf/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('opens a prefilled email (no simulated ticket) when the form is valid', () => {
|
||||
const originalLocation = window.location;
|
||||
const hrefSpy = vi.fn();
|
||||
// Replace window.location with a mock that records href assignments.
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
set href(value: string) {
|
||||
hrefSpy(value);
|
||||
},
|
||||
get href() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
render(<SupportContactForm />);
|
||||
fireEvent.change(screen.getByLabelText('Your name'), { target: { value: 'Alex Rider' } });
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'alex@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Topic'), { target: { value: 'Bug report' } });
|
||||
fireEvent.change(screen.getByLabelText(/How can we help/i), {
|
||||
target: { value: 'The replay map tiles are not loading on my ride.' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Compose support email/i }));
|
||||
|
||||
expect(hrefSpy).toHaveBeenCalledTimes(1);
|
||||
const url = hrefSpy.mock.calls[0][0] as string;
|
||||
expect(url.startsWith('mailto:support@motorsignal.app?')).toBe(true);
|
||||
expect(decodeURIComponent(url)).toContain('[Bug report]');
|
||||
expect(decodeURIComponent(url)).toContain('alex@example.com');
|
||||
expect(screen.getByText(/email app should have opened/i)).toBeDefined();
|
||||
} finally {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { advancePlaybackIndex } from '../lib/telemetry-track';
|
||||
import type { TelemetryPoint } from '../lib/telemetry-export';
|
||||
|
||||
const BASE = Date.parse('2026-09-28T14:30:00.000Z');
|
||||
|
||||
// Minimal TelemetryPoint builder — advancePlaybackIndex only reads timestamp + segmentId.
|
||||
function mk(offsetMs: number, segmentId = 0): TelemetryPoint {
|
||||
return {
|
||||
timestamp: new Date(BASE + offsetMs).toISOString(),
|
||||
latitude: 40,
|
||||
longitude: -74,
|
||||
altitude: 0,
|
||||
speed: 0,
|
||||
heading: 0,
|
||||
gpsAccuracy: 0,
|
||||
leanAngle: 0,
|
||||
pitchAngle: 0,
|
||||
accelX: 0,
|
||||
accelY: 0,
|
||||
accelZ: 0,
|
||||
longitudinalG: 0,
|
||||
lateralG: 0,
|
||||
segmentId,
|
||||
};
|
||||
}
|
||||
|
||||
describe('advancePlaybackIndex (replay playback synchronization)', () => {
|
||||
const uniform = Array.from({ length: 10 }, (_, i) => mk(i * 50)); // 20 Hz, 50 ms apart
|
||||
|
||||
it('advances in step with real recorded time (1x)', () => {
|
||||
// 200 ms of budget over 50 ms samples = 4 steps.
|
||||
const r = advancePlaybackIndex(uniform, 0, 200);
|
||||
expect(r.index).toBe(4);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('carries leftover budget when it does not cover a full step', () => {
|
||||
// 120 ms consumes two 50 ms steps (100 ms), leaving 20 ms.
|
||||
const r = advancePlaybackIndex(uniform, 0, 120);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(20);
|
||||
});
|
||||
|
||||
it('does not advance when the budget is below one step', () => {
|
||||
const r = advancePlaybackIndex(uniform, 3, 10);
|
||||
expect(r.index).toBe(3);
|
||||
expect(r.remainingMs).toBe(10);
|
||||
});
|
||||
|
||||
it('compresses a multi-second gap to a single 50 ms step so playback never stalls', () => {
|
||||
// Samples 0..2 uniform; sample 3 is 10 s later (disconnected). A 60 ms budget from
|
||||
// index 2 must still cross the gap (treated as a 50 ms step), not wait 10 s.
|
||||
const gapped = [mk(0), mk(50), mk(100), mk(10_100)];
|
||||
const r = advancePlaybackIndex(gapped, 2, 60);
|
||||
expect(r.index).toBe(3);
|
||||
expect(r.remainingMs).toBe(0); // reached the end
|
||||
});
|
||||
|
||||
it('clamps at the final index with no remaining budget', () => {
|
||||
const r = advancePlaybackIndex(uniform, 8, 100_000);
|
||||
expect(r.index).toBe(9);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op on empty input', () => {
|
||||
expect(advancePlaybackIndex([], 0, 1000)).toEqual({ index: 0, remainingMs: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay regression tests: irregular timestamps', () => {
|
||||
it('correctly tracks jittered / non-uniform sample intervals', () => {
|
||||
// Non-uniform offsets: 0, 30, 80, 110, 160 ms
|
||||
const jittered = [mk(0), mk(30), mk(80), mk(110), mk(160)];
|
||||
// 25 ms: not enough for step 0->1 (needs 30)
|
||||
let r = advancePlaybackIndex(jittered, 0, 25);
|
||||
expect(r.index).toBe(0);
|
||||
expect(r.remainingMs).toBe(25);
|
||||
|
||||
// Add another 15 ms (total 40 ms): crosses step 0->1 (30 ms), leaves 10 ms (step 1->2 needs 50 ms)
|
||||
r = advancePlaybackIndex(jittered, r.index, 15 + r.remainingMs);
|
||||
expect(r.index).toBe(1);
|
||||
expect(r.remainingMs).toBe(10);
|
||||
|
||||
// Add 60 ms (total 70 ms): crosses step 1->2 (50 ms, leaves 20 ms). Step 2->3 needs 30 ms (20 < 30).
|
||||
r = advancePlaybackIndex(jittered, r.index, 60 + r.remainingMs);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(20);
|
||||
});
|
||||
|
||||
it('steps through identical timestamps (dt === 0) without consuming extraneous budget', () => {
|
||||
// Samples 1 and 2 have the exact same timestamp
|
||||
const dups = [mk(0), mk(50), mk(50), mk(100)];
|
||||
// Budget 50 ms from index 0: covers 0->1 (50 ms) and 1->2 (0 ms)
|
||||
const r = advancePlaybackIndex(dups, 0, 50);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('does not advance on zero or negative budget even if adjacent samples have identical timestamp (dt === 0)', () => {
|
||||
const dups = [mk(0), mk(0), mk(50)];
|
||||
const rZero = advancePlaybackIndex(dups, 0, 0);
|
||||
expect(rZero.index).toBe(0);
|
||||
expect(rZero.remainingMs).toBe(0);
|
||||
|
||||
const rNeg = advancePlaybackIndex(dups, 0, -10);
|
||||
expect(rNeg.index).toBe(0);
|
||||
expect(rNeg.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('treats inverted / backwards timestamps as disconnected (50 ms step) instead of looping or stalling', () => {
|
||||
// Sample 2 is out of order (timestamp goes backwards)
|
||||
const backwards = [mk(0), mk(50), mk(30), mk(100)];
|
||||
// From index 1 to 2, delta is -20 ms (disconnected). Should step across with 50 ms step.
|
||||
const r = advancePlaybackIndex(backwards, 1, 50);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('handles unparseable or invalid timestamps gracefully without throwing', () => {
|
||||
const invalidPts: TelemetryPoint[] = [
|
||||
mk(0),
|
||||
{ ...mk(50), timestamp: 'invalid-timestamp-string' },
|
||||
mk(100),
|
||||
];
|
||||
const r = advancePlaybackIndex(invalidPts, 0, 100);
|
||||
expect(r.index).toBe(2);
|
||||
});
|
||||
|
||||
it('safely handles non-finite or NaN budget without freezing', () => {
|
||||
const uniform = [mk(0), mk(50), mk(100)];
|
||||
const rNaN = advancePlaybackIndex(uniform, 0, NaN);
|
||||
expect(rNaN.index).toBe(0);
|
||||
expect(rNaN.remainingMs).toBe(0);
|
||||
|
||||
const rNeg = advancePlaybackIndex(uniform, 0, -500);
|
||||
expect(rNeg.index).toBe(0);
|
||||
expect(rNeg.remainingMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay regression tests: large gaps & segment breaks', () => {
|
||||
it('compresses a multi-minute gap (>5s) to a single 50 ms step', () => {
|
||||
// 15-minute gap
|
||||
const gapped = [mk(0), mk(50), mk(15 * 60 * 1000)];
|
||||
const r = advancePlaybackIndex(gapped, 1, 50);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('compresses explicit segment boundary transitions to 50 ms', () => {
|
||||
const segments = [mk(0, 0), mk(50, 0), mk(60, 1), mk(110, 1)];
|
||||
// From index 1 to 2, segmentId changes from 0 to 1
|
||||
const r = advancePlaybackIndex(segments, 1, 50);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('handles multiple consecutive disconnected gaps smoothly', () => {
|
||||
// 3 successive gaps of 10 seconds each
|
||||
const multiGapped = [mk(0), mk(10_000), mk(20_000), mk(30_000)];
|
||||
// Each gap is compressed to 50 ms. Total 150 ms crosses all 3 gaps.
|
||||
const r = advancePlaybackIndex(multiGapped, 0, 150);
|
||||
expect(r.index).toBe(3);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('compresses a 24-hour pause in ride without stalling', () => {
|
||||
const dayGap = [mk(0), mk(50), mk(24 * 60 * 60 * 1000)];
|
||||
const r = advancePlaybackIndex(dayGap, 1, 55);
|
||||
expect(r.index).toBe(2);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay regression tests: pause and resume playback simulation', () => {
|
||||
const points = Array.from({ length: 20 }, (_, i) => mk(i * 50));
|
||||
|
||||
it('simulates play -> pause -> resume without a timing jump', () => {
|
||||
let currentIndex = 0;
|
||||
let carry = 0;
|
||||
let lastTime: number | null = null;
|
||||
|
||||
// Helper simulating an animation frame at 60 fps (16.67 ms frame delta)
|
||||
const tick = (now: number, isPlaying: boolean, speed = 1.0) => {
|
||||
if (!isPlaying) {
|
||||
lastTime = null;
|
||||
carry = 0;
|
||||
return;
|
||||
}
|
||||
if (lastTime == null) lastTime = now;
|
||||
const rawDelta = now - lastTime;
|
||||
const frameDelta = Math.max(0, Math.min(rawDelta, 200));
|
||||
lastTime = now;
|
||||
const budget = frameDelta * speed + carry;
|
||||
const res = advancePlaybackIndex(points, currentIndex, budget);
|
||||
currentIndex = res.index;
|
||||
carry = currentIndex >= points.length - 1 ? 0 : res.remainingMs;
|
||||
};
|
||||
|
||||
// 1. Play for 3 frames (16.67 ms each, ~50 ms total)
|
||||
tick(1000, true); // frame 1: delta 0, index 0, carry 0
|
||||
expect(currentIndex).toBe(0);
|
||||
tick(1016.67, true); // frame 2: delta 16.67, index 0, carry 16.67
|
||||
expect(currentIndex).toBe(0);
|
||||
expect(carry).toBeCloseTo(16.67, 1);
|
||||
tick(1050, true); // frame 3: delta 33.33 + carry 16.67 = 50 ms -> index 1!
|
||||
expect(currentIndex).toBe(1);
|
||||
expect(carry).toBe(0);
|
||||
|
||||
// 2. Pause for 5 seconds (5000 ms wall clock)
|
||||
tick(1060, false); // pause triggered
|
||||
expect(lastTime).toBeNull();
|
||||
expect(carry).toBe(0);
|
||||
|
||||
// 3. Resume 5 seconds later at t=6050
|
||||
tick(6050, true); // first resume frame: lastTime initialized to 6050, delta = 0!
|
||||
expect(currentIndex).toBe(1); // DID NOT JUMP
|
||||
expect(carry).toBe(0);
|
||||
|
||||
// 4. Continue playing after resume
|
||||
tick(6100, true); // 50 ms delta -> index 2!
|
||||
expect(currentIndex).toBe(2);
|
||||
expect(carry).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay regression tests: manual seek', () => {
|
||||
const points = Array.from({ length: 50 }, (_, i) => mk(i * 50));
|
||||
|
||||
it('seeks forward and clears carryover', () => {
|
||||
let index = 5;
|
||||
let carry = 25;
|
||||
// Manual seek to 20
|
||||
index = 20;
|
||||
carry = 0;
|
||||
const r = advancePlaybackIndex(points, index, 50 + carry);
|
||||
expect(r.index).toBe(21);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
});
|
||||
|
||||
it('seeks backward and clears carryover', () => {
|
||||
let index = 30;
|
||||
let carry = 40;
|
||||
// Manual seek back to 10
|
||||
index = 10;
|
||||
carry = 0;
|
||||
const r = advancePlaybackIndex(points, index, 100 + carry);
|
||||
expect(r.index).toBe(12);
|
||||
});
|
||||
|
||||
it('clamps out-of-bounds seek indices safely', () => {
|
||||
const rNegative = advancePlaybackIndex(points, -100, 100);
|
||||
expect(rNegative.index).toBe(2); // starts from 0, advances 2 steps
|
||||
|
||||
const rBeyond = advancePlaybackIndex(points, 9999, 100);
|
||||
expect(rBeyond.index).toBe(49); // clamped to length - 1
|
||||
expect(rBeyond.remainingMs).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay regression tests: speed changes', () => {
|
||||
const points = Array.from({ length: 100 }, (_, i) => mk(i * 50));
|
||||
|
||||
it('scales budget consumption proportionally to speed', () => {
|
||||
// At 0.5x speed: 100 ms real time = 50 ms virtual budget = 1 step
|
||||
const rHalf = advancePlaybackIndex(points, 0, 100 * 0.5);
|
||||
expect(rHalf.index).toBe(1);
|
||||
|
||||
// At 2x speed: 50 ms real time = 100 ms virtual budget = 2 steps
|
||||
const rDouble = advancePlaybackIndex(points, 0, 50 * 2.0);
|
||||
expect(rDouble.index).toBe(2);
|
||||
|
||||
// At 5x speed: 50 ms real time = 250 ms virtual budget = 5 steps
|
||||
const rFive = advancePlaybackIndex(points, 0, 50 * 5.0);
|
||||
expect(rFive.index).toBe(5);
|
||||
|
||||
// At 10x speed: 50 ms real time = 500 ms virtual budget = 10 steps
|
||||
const rTen = advancePlaybackIndex(points, 0, 50 * 10.0);
|
||||
expect(rTen.index).toBe(10);
|
||||
});
|
||||
|
||||
it('handles speed changes mid-playback without time discontinuity', () => {
|
||||
let index = 10;
|
||||
let carry = 0;
|
||||
let speed = 1.0;
|
||||
|
||||
// 1 frame at 1x (50 ms): advances 1 step
|
||||
let r = advancePlaybackIndex(points, index, 50 * speed + carry);
|
||||
index = r.index;
|
||||
carry = r.remainingMs;
|
||||
expect(index).toBe(11);
|
||||
|
||||
// User switches to 4x speed: reset lastTime (delta = 0 on transition), next frame is 50 ms * 4 = 200 ms
|
||||
speed = 4.0;
|
||||
r = advancePlaybackIndex(points, index, 50 * speed + carry);
|
||||
index = r.index;
|
||||
carry = r.remainingMs;
|
||||
// 200 ms budget over 50 ms steps = 4 steps: 11 + 4 = 15
|
||||
expect(index).toBe(15);
|
||||
expect(carry).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replay regression tests: large datasets', () => {
|
||||
it('advances efficiently across 10,000 points', () => {
|
||||
const large = Array.from({ length: 10_000 }, (_, i) => mk(i * 50));
|
||||
const start = performance.now();
|
||||
// Advance 500 steps (25,000 ms budget)
|
||||
const r = advancePlaybackIndex(large, 1000, 25_000);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(r.index).toBe(1500);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('handles 144,000 points (full 2-hour 20 Hz recording) without stalling', () => {
|
||||
const huge = Array.from({ length: 144_000 }, (_, i) => mk(i * 50));
|
||||
const start = performance.now();
|
||||
|
||||
// Advance 2,000 steps at high speed
|
||||
const r = advancePlaybackIndex(huge, 100_000, 100_000);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(r.index).toBe(102_000);
|
||||
expect(r.remainingMs).toBe(0);
|
||||
expect(duration).toBeLessThan(100);
|
||||
|
||||
// End of recording clamp
|
||||
const rEnd = advancePlaybackIndex(huge, 143_990, 1_000_000);
|
||||
expect(rEnd.index).toBe(143_999);
|
||||
expect(rEnd.remainingMs).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue