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:
Developer 2026-10-14 15:49:12 -04:00
parent 9c43a4f92e
commit 5e1ce969cc
5 changed files with 894 additions and 116 deletions

View File

@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import Link from 'next/link';
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,10 +22,12 @@ export default function AccountPage() {
const [syncTrigger, setSyncTrigger] = useState(0);
const [isSyncing, setIsSyncing] = useState(false);
const [lastSynced, setLastSynced] = useState<string | null>(null);
// Initialise to zero; AppCloudData replaces these with real totals from the rider's
// Firestore rides (or leaves them at zero when signed out / no rides). Never seed
// fabricated stats.
const [metrics, setMetrics] = useState({ totalMiles: 0, rideCount: 0, peakLean: 0 });
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[]>([]);
@ -41,6 +43,11 @@ export default function AccountPage() {
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
setUser(currentUser);
setAuthChecked(true);
if (!currentUser) {
setMetricsStatus('unauthenticated');
setMetrics(null);
setSyncError(null);
}
});
return () => unsubscribe();
}, []);
@ -161,7 +168,10 @@ export default function AccountPage() {
}
function handleQuickSync() {
if (!user) return;
setIsSyncing(true);
setSyncError(null);
setMetricsStatus('loading');
setSyncTrigger((prev) => prev + 1);
}
@ -239,7 +249,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 />
@ -262,46 +280,128 @@ 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>
<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 className="mt-3 flex items-baseline space-x-2">
<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 === '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">
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
<span>All-time telemetry aggregate</span>
{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>
<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 className="mt-3 flex items-baseline space-x-2">
<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 === '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">
<span>Canyons, Circuits &amp; Commutes</span>
{metricsStatus === 'ready' && metrics !== null ? (
metrics.rideCount > 0 ? (
<span>Canyons, Circuits &amp; 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>
<span className="text-brand-cyan font-mono text-[10px]">
{metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error'
? 'Unavailable'
: '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>
<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>
</div>
<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>
{/* Metric 4: Hardware Link */}
@ -325,11 +425,23 @@ 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') {
setSyncError('Sync failed');
} else if (status === 'unauthenticated') {
setMetrics(null);
setSyncError(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">

View File

@ -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,53 @@ 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) => {
// 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 +423,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 +491,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 +951,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 +1014,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 +1232,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">

View File

@ -49,11 +49,15 @@ 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';
@ -61,22 +65,27 @@ export function FounderChat({ context }: FounderChatProps) {
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);
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, 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.'));
}
);
@ -85,7 +94,7 @@ export function FounderChat({ context }: FounderChatProps) {
return () => {
unsubscribe();
};
}, [club.id, activeChannelId, isDemoFixture]);
}, [club.id, activeChannelId, isDemo]);
// Auto-scroll on new message
useEffect(() => {
@ -103,7 +112,7 @@ export function FounderChat({ context }: FounderChatProps) {
setError(null);
try {
if (isDemoFixture) {
if (isDemo) {
const optimisticMsg: ChatMessage = {
id: `demo-msg-${Date.now()}`,
clubId: club.id,
@ -115,6 +124,7 @@ export function FounderChat({ context }: FounderChatProps) {
authorRole: currentUser.role,
content: clean,
createdAt: new Date().toISOString(),
isExample: true,
};
setMessages((prev) => [...prev, optimisticMsg]);
} else {
@ -144,11 +154,21 @@ export function FounderChat({ context }: FounderChatProps) {
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
Squad Live Chat
</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="material-symbols-outlined text-[14px]">visibility</span>
Example thread
</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="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
@ -187,12 +207,21 @@ export function FounderChat({ context }: FounderChatProps) {
</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">
<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>
)}
@ -298,6 +327,11 @@ export function FounderChat({ context }: FounderChatProps) {
{msg.authorDisplayRank}
</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">
{msg.createdAt
? new Date(msg.createdAt).toLocaleTimeString([], {

View File

@ -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();
});
});

View File

@ -355,6 +355,105 @@ 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();
});
});
});
describe('Founder Community Page & Sidebar Integration', () => {