fix(website): truthful account metrics error states and persistent demo chat labeling
- Replace false zero metrics (0 miles / 0 rides / 100% Cloud Synced) on loading, unauthenticated, permission-denied, or network errors with truthful states - Update AppCloudData to query motorcycles and rides via Promise.allSettled and report granular CloudDataStatus - Update AccountPage KPI cards to display Loading…, —, or Unavailable with explicit contextual status and error recovery - Fix founder demo chat to permanently preserve Example thread and example badges during preview=demo simulation instead of flipping to Live Connected - Add 8 unit/component tests in account-metrics-truthfulness.test.tsx and 6 tests in founder-routes-chat.test.tsx
This commit is contained in:
parent
9c43a4f92e
commit
5e1ce969cc
|
|
@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { onAuthStateChanged, multiFactor, User } from 'firebase/auth';
|
import { onAuthStateChanged, multiFactor, User } from 'firebase/auth';
|
||||||
import { firebaseAuth } from '../../lib/firebase-client';
|
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 { FirebaseLink } from '../../components/firebase-link';
|
||||||
import { SignOutButton } from '../../components/sign-out-button';
|
import { SignOutButton } from '../../components/sign-out-button';
|
||||||
|
|
||||||
|
|
@ -22,10 +22,12 @@ export default function AccountPage() {
|
||||||
const [syncTrigger, setSyncTrigger] = useState(0);
|
const [syncTrigger, setSyncTrigger] = useState(0);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [lastSynced, setLastSynced] = useState<string | null>(null);
|
const [lastSynced, setLastSynced] = useState<string | null>(null);
|
||||||
// Initialise to zero; AppCloudData replaces these with real totals from the rider's
|
const [syncError, setSyncError] = useState<string | null>(null);
|
||||||
// Firestore rides (or leaves them at zero when signed out / no rides). Never seed
|
// Truthful metrics & sync states:
|
||||||
// fabricated stats.
|
// Distinguish 'idle', 'loading', 'unauthenticated', 'ready', 'permission-denied', 'unavailable', and 'error'.
|
||||||
const [metrics, setMetrics] = useState({ totalMiles: 0, rideCount: 0, peakLean: 0 });
|
// 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
|
// Active Device Sessions State
|
||||||
const [sessions, setSessions] = useState<DeviceSession[]>([]);
|
const [sessions, setSessions] = useState<DeviceSession[]>([]);
|
||||||
|
|
@ -41,6 +43,11 @@ export default function AccountPage() {
|
||||||
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
|
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
setAuthChecked(true);
|
setAuthChecked(true);
|
||||||
|
if (!currentUser) {
|
||||||
|
setMetricsStatus('unauthenticated');
|
||||||
|
setMetrics(null);
|
||||||
|
setSyncError(null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -161,7 +168,10 @@ export default function AccountPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuickSync() {
|
function handleQuickSync() {
|
||||||
|
if (!user) return;
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
|
setSyncError(null);
|
||||||
|
setMetricsStatus('loading');
|
||||||
setSyncTrigger((prev) => prev + 1);
|
setSyncTrigger((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,7 +249,15 @@ export default function AccountPage() {
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>{isSyncing ? 'Syncing...' : lastSynced ? `Synced (${lastSynced})` : 'Quick Sync'}</span>
|
<span>
|
||||||
|
{isSyncing
|
||||||
|
? 'Syncing...'
|
||||||
|
: syncError
|
||||||
|
? 'Sync Failed (Retry)'
|
||||||
|
: lastSynced
|
||||||
|
? `Synced (${lastSynced})`
|
||||||
|
: 'Quick Sync'}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="px-3 py-1.5 rounded-lg bg-brand-cardAlt border border-brand-border text-xs font-mono text-rose-400">
|
<div className="px-3 py-1.5 rounded-lg bg-brand-cardAlt border border-brand-border text-xs font-mono text-rose-400">
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
|
|
@ -262,46 +280,128 @@ export default function AccountPage() {
|
||||||
{/* BEGIN: TelemetryKpiGrid */}
|
{/* BEGIN: TelemetryKpiGrid */}
|
||||||
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-purpose="telemetry-overview-stats">
|
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4" data-purpose="telemetry-overview-stats">
|
||||||
{/* Metric 1: Distance */}
|
{/* 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">
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||||
<span>Total Distance Logged</span>
|
<span>Total Distance Logged</span>
|
||||||
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Cloud Aggregate</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>
|
||||||
<div className="mt-3 flex items-baseline space-x-2">
|
<div className="mt-3 flex items-baseline space-x-2">
|
||||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.totalMiles.toLocaleString()}</span>
|
{metricsStatus === 'ready' && metrics !== null ? (
|
||||||
<span className="text-sm font-mono text-slate-400">miles</span>
|
<>
|
||||||
|
<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>
|
||||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
|
{metricsStatus === 'ready' ? (
|
||||||
<span>All-time telemetry aggregate</span>
|
<>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/* Metric 2: Sessions */}
|
{/* 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">
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||||
<span>Recorded Sessions</span>
|
<span>Recorded Sessions</span>
|
||||||
<span className="text-brand-emerald bg-brand-emerald/10 px-1.5 py-0.5 rounded text-[10px]">100% Cloud Synced</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>
|
||||||
<div className="mt-3 flex items-baseline space-x-2">
|
<div className="mt-3 flex items-baseline space-x-2">
|
||||||
<span className="text-3xl font-extrabold text-white font-mono">{metrics.rideCount}</span>
|
{metricsStatus === 'ready' && metrics !== null ? (
|
||||||
<span className="text-sm font-mono text-slate-400">rides</span>
|
<>
|
||||||
|
<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>
|
||||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||||
<span>Canyons, Circuits & Commutes</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{/* Metric 3: Lean Angle Peak */}
|
{/* 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">
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
|
||||||
<span>Peak Lean Angle</span>
|
<span>Peak Lean Angle</span>
|
||||||
<span className="text-brand-cyan font-mono text-[10px]">Lateral Roll</span>
|
<span className="text-brand-cyan font-mono text-[10px]">
|
||||||
|
{metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error'
|
||||||
|
? 'Unavailable'
|
||||||
|
: 'Lateral Roll'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-baseline space-x-2">
|
<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>
|
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
||||||
|
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
||||||
|
</span>
|
||||||
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||||
<span className="text-amber-400">6-Axis IMU Verified</span>
|
{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>No active session data</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500">No IMU roll data</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Metric 4: Hardware Link */}
|
{/* Metric 4: Hardware Link */}
|
||||||
|
|
@ -325,11 +425,23 @@ export default function AccountPage() {
|
||||||
syncTrigger={syncTrigger}
|
syncTrigger={syncTrigger}
|
||||||
onSyncStateChange={(syncing) => {
|
onSyncStateChange={(syncing) => {
|
||||||
setIsSyncing(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' }));
|
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||||
|
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
|
||||||
|
setSyncError('Sync failed');
|
||||||
|
} else if (status === 'unauthenticated') {
|
||||||
|
setMetrics(null);
|
||||||
|
setSyncError(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMetricsLoaded={(m) => setMetrics(m)}
|
onMetricsLoaded={(m) => {
|
||||||
|
setMetrics(m);
|
||||||
|
setMetricsStatus('ready');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* BEGIN: PlanAndClusterSection */}
|
{/* BEGIN: PlanAndClusterSection */}
|
||||||
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="subscription-and-sync-status">
|
<section className="grid grid-cols-1 lg:grid-cols-12 gap-6" data-purpose="subscription-and-sync-status">
|
||||||
|
|
|
||||||
|
|
@ -139,15 +139,40 @@ function StockMotorcycleGraphic({ className = 'w-full h-40' }: { className?: str
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppCloudDataProps {
|
export interface AccountCloudMetrics {
|
||||||
syncTrigger?: number;
|
totalMiles: number;
|
||||||
onSyncStateChange?: (syncing: boolean) => void;
|
rideCount: number;
|
||||||
onMetricsLoaded?: (metrics: { totalMiles: number; rideCount: number; peakLean: number }) => void;
|
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 [motorcycles, setMotorcycles] = useState<Motorcycle[]>([]);
|
||||||
const [rides, setRides] = useState<Ride[]>([]);
|
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 [state, setState] = useState('Loading telemetry cloud data...');
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
|
@ -331,109 +356,180 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
setMotorcycles([]);
|
setMotorcycles([]);
|
||||||
setRides([]);
|
setRides([]);
|
||||||
|
setGarageStatus('unauthenticated');
|
||||||
|
setRidesStatus('unauthenticated');
|
||||||
|
setGarageError(null);
|
||||||
|
setRidesError(null);
|
||||||
setState('Sign in or connect your Google account to view your live telemetry.');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setGarageStatus('loading');
|
||||||
|
setRidesStatus('loading');
|
||||||
|
setGarageError(null);
|
||||||
|
setRidesError(null);
|
||||||
|
if (onStatusChange) onStatusChange('loading');
|
||||||
if (onSyncStateChange) onSyncStateChange(true);
|
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 {
|
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, 'motorcycles')),
|
||||||
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
|
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const loadedMotorcycles: Motorcycle[] = motorcycleSnapshot.docs.map((snapshot) => {
|
// Process Motorcycles
|
||||||
const data = snapshot.data();
|
if (motorcycleRes.status === 'fulfilled') {
|
||||||
const yearVal = number(data.year) || 0;
|
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {
|
||||||
const makeVal = text(data.makeName) || text(data.make);
|
const data = snapshot.data();
|
||||||
const modelVal = text(data.modelName) || text(data.model);
|
const yearVal = number(data.year) || 0;
|
||||||
const variantVal = text(data.variantName) || text(data.variant);
|
const makeVal = text(data.makeName) || text(data.make);
|
||||||
const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' ');
|
const modelVal = text(data.modelName) || text(data.model);
|
||||||
return {
|
const variantVal = text(data.variantName) || text(data.variant);
|
||||||
id: snapshot.id,
|
const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' ');
|
||||||
nickname: text(data.nickname) || description || 'Motorcycle',
|
return {
|
||||||
makeName: makeVal,
|
id: snapshot.id,
|
||||||
modelName: modelVal,
|
nickname: text(data.nickname) || description || 'Motorcycle',
|
||||||
variantName: variantVal,
|
makeName: makeVal,
|
||||||
year: yearVal,
|
modelName: modelVal,
|
||||||
description,
|
variantName: variantVal,
|
||||||
isDefault: data.isDefault === true,
|
year: yearVal,
|
||||||
imageUrl: text(data.imageUrl) || undefined,
|
description,
|
||||||
vin: text(data.vin) || text(data.vinNumber) || undefined,
|
isDefault: data.isDefault === true,
|
||||||
};
|
imageUrl: text(data.imageUrl) || undefined,
|
||||||
}).sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
vin: text(data.vin) || text(data.vinNumber) || undefined,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
||||||
|
|
||||||
let totalMiles = 0;
|
setMotorcycles(loadedMotorcycles);
|
||||||
let peakLean = 0;
|
setGarageStatus('ready');
|
||||||
|
setGarageError(null);
|
||||||
|
} else {
|
||||||
|
const classified = classifyError(motorcycleRes.reason);
|
||||||
|
setGarageStatus(classified.status);
|
||||||
|
setGarageError(classified.message);
|
||||||
|
console.error('Error loading motorcycles:', motorcycleRes.reason);
|
||||||
|
}
|
||||||
|
|
||||||
const loadedRides: Ride[] = rideSnapshot.docs.map((snapshot) => {
|
// Process Rides & Metrics
|
||||||
const data = snapshot.data();
|
if (rideRes.status === 'fulfilled') {
|
||||||
const rawStart = text(data.startTime);
|
let totalMiles = 0;
|
||||||
const rawEnd = text(data.endTime);
|
let peakLean = 0;
|
||||||
const startedAt = rawStart ? new Date(rawStart) : null;
|
|
||||||
const endedAt = rawEnd ? new Date(rawEnd) : null;
|
|
||||||
const dist = number(data.distanceMiles);
|
|
||||||
const leanL = number(data.maxLeanLeft);
|
|
||||||
const leanR = number(data.maxLeanRight);
|
|
||||||
|
|
||||||
totalMiles += dist;
|
const loadedRides: Ride[] = rideRes.value.docs.map((snapshot) => {
|
||||||
if (leanL > peakLean) peakLean = leanL;
|
const data = snapshot.data();
|
||||||
if (leanR > peakLean) peakLean = leanR;
|
const rawStart = text(data.startTime);
|
||||||
|
const rawEnd = text(data.endTime);
|
||||||
|
const startedAt = rawStart ? new Date(rawStart) : null;
|
||||||
|
const endedAt = rawEnd ? new Date(rawEnd) : null;
|
||||||
|
const dist = number(data.distanceMiles);
|
||||||
|
const leanL = number(data.maxLeanLeft);
|
||||||
|
const leanR = number(data.maxLeanRight);
|
||||||
|
|
||||||
const startLat = number(data.startLat) || number(data.startLatitude) || undefined;
|
totalMiles += dist;
|
||||||
const startLon = number(data.startLon) || number(data.startLongitude) || undefined;
|
if (leanL > peakLean) peakLean = leanL;
|
||||||
const endLat = number(data.endLat) || number(data.endLatitude) || undefined;
|
if (leanR > peakLean) peakLean = leanR;
|
||||||
const endLon = number(data.endLon) || number(data.endLongitude) || undefined;
|
|
||||||
const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined;
|
|
||||||
|
|
||||||
let ridePoints: TelemetryPoint[] | undefined;
|
const startLat = number(data.startLat) || number(data.startLatitude) || undefined;
|
||||||
if (Array.isArray(data.points) && data.points.length > 0) {
|
const startLon = number(data.startLon) || number(data.startLongitude) || undefined;
|
||||||
ridePoints = data.points;
|
const endLat = number(data.endLat) || number(data.endLatitude) || undefined;
|
||||||
} else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) {
|
const endLon = number(data.endLon) || number(data.endLongitude) || undefined;
|
||||||
ridePoints = data.telemetryPoints;
|
const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined;
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
let ridePoints: TelemetryPoint[] | undefined;
|
||||||
id: snapshot.id,
|
if (Array.isArray(data.points) && data.points.length > 0) {
|
||||||
startedAt: startedAt && !Number.isNaN(startedAt.getTime()) ? startedAt : null,
|
ridePoints = data.points;
|
||||||
endedAt: endedAt && !Number.isNaN(endedAt.getTime()) ? endedAt : null,
|
} else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) {
|
||||||
distanceMiles: dist,
|
ridePoints = data.telemetryPoints;
|
||||||
topSpeedMph: number(data.topSpeedMph),
|
}
|
||||||
averageSpeed: number(data.averageSpeed),
|
|
||||||
maxLeanLeft: leanL,
|
|
||||||
maxLeanRight: leanR,
|
|
||||||
maxAccelG: number(data.maxAccelG),
|
|
||||||
maxBrakingG: number(data.maxBrakingG),
|
|
||||||
movingTime: number(data.movingTime),
|
|
||||||
elevationGain: number(data.elevationGain),
|
|
||||||
qualityScore: number(data.qualityScore) || 100,
|
|
||||||
motorcycle: [number(data.motorcycleYear) || '', text(data.motorcycleMake), text(data.motorcycleModel)].filter(Boolean).join(' '),
|
|
||||||
startLat,
|
|
||||||
startLon,
|
|
||||||
endLat,
|
|
||||||
endLon,
|
|
||||||
points: ridePoints,
|
|
||||||
gpxContent: text(data.gpxContent) || undefined,
|
|
||||||
csvContent: text(data.csvContent) || undefined,
|
|
||||||
telemetryBundlePath: bundlePath,
|
|
||||||
};
|
|
||||||
}).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0));
|
|
||||||
|
|
||||||
setMotorcycles(loadedMotorcycles);
|
return {
|
||||||
setRides(loadedRides);
|
id: snapshot.id,
|
||||||
setState('');
|
startedAt: startedAt && !Number.isNaN(startedAt.getTime()) ? startedAt : null,
|
||||||
|
endedAt: endedAt && !Number.isNaN(endedAt.getTime()) ? endedAt : null,
|
||||||
|
distanceMiles: dist,
|
||||||
|
topSpeedMph: number(data.topSpeedMph),
|
||||||
|
averageSpeed: number(data.averageSpeed),
|
||||||
|
maxLeanLeft: leanL,
|
||||||
|
maxLeanRight: leanR,
|
||||||
|
maxAccelG: number(data.maxAccelG),
|
||||||
|
maxBrakingG: number(data.maxBrakingG),
|
||||||
|
movingTime: number(data.movingTime),
|
||||||
|
elevationGain: number(data.elevationGain),
|
||||||
|
qualityScore: number(data.qualityScore) || 100,
|
||||||
|
motorcycle: [number(data.motorcycleYear) || '', text(data.motorcycleMake), text(data.motorcycleModel)].filter(Boolean).join(' '),
|
||||||
|
startLat,
|
||||||
|
startLon,
|
||||||
|
endLat,
|
||||||
|
endLon,
|
||||||
|
points: ridePoints,
|
||||||
|
gpxContent: text(data.gpxContent) || undefined,
|
||||||
|
csvContent: text(data.csvContent) || undefined,
|
||||||
|
telemetryBundlePath: bundlePath,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0));
|
||||||
|
|
||||||
if (onMetricsLoaded) {
|
setRides(loadedRides);
|
||||||
onMetricsLoaded({
|
setRidesStatus('ready');
|
||||||
|
setRidesError(null);
|
||||||
|
|
||||||
|
const computedMetrics = {
|
||||||
totalMiles: Math.round(totalMiles),
|
totalMiles: Math.round(totalMiles),
|
||||||
rideCount: loadedRides.length,
|
rideCount: loadedRides.length,
|
||||||
peakLean: Number(peakLean.toFixed(1)),
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading MotorSignal cloud data:', error);
|
console.error('Error loading MotorSignal cloud data:', error);
|
||||||
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
|
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
|
||||||
|
if (onStatusChange) {
|
||||||
|
onStatusChange('error', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (onSyncStateChange) onSyncStateChange(false);
|
if (onSyncStateChange) onSyncStateChange(false);
|
||||||
}
|
}
|
||||||
|
|
@ -855,6 +951,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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">
|
<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" />
|
<StockMotorcycleGraphic className="max-w-md mx-auto h-36 opacity-60" />
|
||||||
|
|
@ -890,9 +1014,19 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
{ridesStatus === 'loading' ? (
|
||||||
{rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded
|
<span className="text-xs font-mono text-slate-400 bg-slate-800 border border-slate-700 px-2.5 py-1 rounded-full">
|
||||||
</span>
|
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>
|
</div>
|
||||||
|
|
||||||
{rides.length > 0 ? (
|
{rides.length > 0 ? (
|
||||||
|
|
@ -1098,6 +1232,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="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">
|
<div className="w-12 h-12 mx-auto rounded-full bg-surface-container flex items-center justify-center text-slate-500">
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,15 @@ const CHANNELS: ChannelInfo[] = [
|
||||||
|
|
||||||
export function FounderChat({ context }: FounderChatProps) {
|
export function FounderChat({ context }: FounderChatProps) {
|
||||||
const { club, permissions, currentUser, isDemoFixture } = context;
|
const { club, permissions, currentUser, isDemoFixture } = context;
|
||||||
|
const isDemo = Boolean(isDemoFixture);
|
||||||
const [activeChannelId, setActiveChannelId] = useState<ChannelId>('general');
|
const [activeChannelId, setActiveChannelId] = useState<ChannelId>('general');
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState('');
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [connectionState, setConnectionState] = useState<'connecting' | 'connected' | 'error'>(
|
||||||
|
isDemo ? 'connected' : 'connecting'
|
||||||
|
);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
|
const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
|
||||||
|
|
@ -61,22 +65,27 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
|
const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
|
||||||
const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
|
const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
|
||||||
const hasExampleMessages = messages.length > 0 && messages.every((m) => m.isExample);
|
const hasExampleMessages = messages.length > 0 && messages.every((m) => m.isExample);
|
||||||
|
const isExampleThread = isDemo || hasExampleMessages;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
let unsubscribe: () => void = () => {};
|
let unsubscribe: () => void = () => {};
|
||||||
|
|
||||||
if (isDemoFixture) {
|
if (isDemo) {
|
||||||
|
setConnectionState('connected');
|
||||||
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
|
const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
|
||||||
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true })));
|
setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId, isExample: true })));
|
||||||
} else {
|
} else {
|
||||||
|
setConnectionState('connecting');
|
||||||
unsubscribe = subscribeClubChat(
|
unsubscribe = subscribeClubChat(
|
||||||
club.id,
|
club.id,
|
||||||
activeChannelId,
|
activeChannelId,
|
||||||
(newMsgs) => {
|
(newMsgs) => {
|
||||||
setMessages(newMsgs);
|
setMessages(newMsgs);
|
||||||
|
setConnectionState('connected');
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
|
setConnectionState('error');
|
||||||
setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
|
setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -85,7 +94,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [club.id, activeChannelId, isDemoFixture]);
|
}, [club.id, activeChannelId, isDemo]);
|
||||||
|
|
||||||
// Auto-scroll on new message
|
// Auto-scroll on new message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -103,7 +112,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isDemoFixture) {
|
if (isDemo) {
|
||||||
const optimisticMsg: ChatMessage = {
|
const optimisticMsg: ChatMessage = {
|
||||||
id: `demo-msg-${Date.now()}`,
|
id: `demo-msg-${Date.now()}`,
|
||||||
clubId: club.id,
|
clubId: club.id,
|
||||||
|
|
@ -115,6 +124,7 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
authorRole: currentUser.role,
|
authorRole: currentUser.role,
|
||||||
content: clean,
|
content: clean,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
isExample: true,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, optimisticMsg]);
|
setMessages((prev) => [...prev, optimisticMsg]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -144,11 +154,21 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||||
Squad Live Chat
|
Squad Live Chat
|
||||||
</h1>
|
</h1>
|
||||||
{hasExampleMessages ? (
|
{isExampleThread ? (
|
||||||
<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="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>
|
<span className="material-symbols-outlined text-[14px]">visibility</span>
|
||||||
Example thread
|
Example thread
|
||||||
</span>
|
</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>
|
||||||
|
) : 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>
|
||||||
) : (
|
) : (
|
||||||
<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="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" />
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
|
@ -187,12 +207,21 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasExampleMessages && (
|
{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">
|
<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 className="material-symbols-outlined text-[16px] text-amber-400 shrink-0">info</span>
|
||||||
<span>
|
<span>
|
||||||
These are <strong className="font-semibold text-amber-100">example messages</strong> — this
|
{isDemo ? (
|
||||||
channel has no posts yet. Send a message to start the real conversation.
|
<>
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -298,6 +327,11 @@ export function FounderChat({ context }: FounderChatProps) {
|
||||||
{msg.authorDisplayRank}
|
{msg.authorDisplayRank}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{msg.isExample && (
|
||||||
|
<span className="font-mono px-1.5 py-0.2 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">
|
<span className="text-slate-500 font-mono ml-auto">
|
||||||
{msg.createdAt
|
{msg.createdAt
|
||||||
? new Date(msg.createdAt).toLocaleTimeString([], {
|
? new Date(msg.createdAt).toLocaleTimeString([], {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
authListeners.forEach((listener) => listener(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
authListeners.clear();
|
||||||
|
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 be '--'
|
||||||
|
const leanCard = screen.getByTestId('metric-card-lean');
|
||||||
|
expect(leanCard.textContent).toContain('--');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -355,6 +355,105 @@ describe('Founder Chat Management', () => {
|
||||||
await screen.findByText('Officers Lounge Restricted');
|
await screen.findByText('Officers Lounge Restricted');
|
||||||
expect(screen.getByText(/strictly reserved for club Founders and Admins/i)).toBeDefined();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Founder Community Page & Sidebar Integration', () => {
|
describe('Founder Community Page & Sidebar Integration', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue