From 5e1ce969cc9ba96cf8c9f7e0404e7e2be5124878 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 15:49:12 -0400 Subject: [PATCH] fix(website): truthful account metrics error states and persistent demo chat labeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- website/app/account/page.tsx | 158 ++++++-- website/components/app-cloud-data.tsx | 334 ++++++++++++---- .../founder-portal/founder-chat.tsx | 48 ++- .../account-metrics-truthfulness.test.tsx | 371 ++++++++++++++++++ website/tests/founder-routes-chat.test.tsx | 99 +++++ 5 files changed, 894 insertions(+), 116 deletions(-) create mode 100644 website/tests/account-metrics-truthfulness.test.tsx diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index bd29499..87982fc 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -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(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(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('loading'); + const [metrics, setMetrics] = useState<{ totalMiles: number; rideCount: number; peakLean: number } | null>(null); // Active Device Sessions State const [sessions, setSessions] = useState([]); @@ -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" > - {isSyncing ? 'Syncing...' : lastSynced ? `Synced (${lastSynced})` : 'Quick Sync'} + + {isSyncing + ? 'Syncing...' + : syncError + ? 'Sync Failed (Retry)' + : lastSynced + ? `Synced (${lastSynced})` + : 'Quick Sync'} +
@@ -262,46 +280,128 @@ export default function AccountPage() { {/* BEGIN: TelemetryKpiGrid */}
{/* Metric 1: Distance */} -
+
Total Distance Logged -Cloud Aggregate +{metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view +) : metricsStatus === 'permission-denied' ? ( + Permission denied +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Sync unavailable +) : ( + Cloud Aggregate +)}
-{metrics.totalMiles.toLocaleString()} -miles +{metricsStatus === 'ready' && metrics !== null ? ( + <> + {metrics.totalMiles.toLocaleString()} + miles + +) : metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + — +) : ( + Unavailable +)}
- -All-time telemetry aggregate +{metricsStatus === 'ready' ? ( + <> + + All-time telemetry aggregate + +) : metricsStatus === 'loading' ? ( + Retrieving telemetry aggregate… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view telemetry aggregate +) : metricsStatus === 'permission-denied' ? ( + Access to cloud telemetry restricted +) : ( + Telemetry aggregate unavailable +)}
{/* Metric 2: Sessions */} -
+
Recorded Sessions -100% Cloud Synced +{metricsStatus === 'loading' ? ( + Checking sync… +) : metricsStatus === 'unauthenticated' ? ( + Sign-in required +) : metricsStatus === 'permission-denied' ? ( + Permission denied +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Sync status unavailable +) : metrics !== null && metrics.rideCount > 0 ? ( + 100% Cloud Synced +) : ( + Cloud Synced +)}
-{metrics.rideCount} -rides +{metricsStatus === 'ready' && metrics !== null ? ( + <> + {metrics.rideCount} + rides + +) : metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + — +) : ( + Unavailable +)}
-Canyons, Circuits & Commutes +{metricsStatus === 'ready' && metrics !== null ? ( + metrics.rideCount > 0 ? ( + Canyons, Circuits & Commutes + ) : ( + No rides recorded yet + ) +) : metricsStatus === 'loading' ? ( + Checking cloud telemetry sessions… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to load synced rides +) : metricsStatus === 'permission-denied' ? ( + Check Firestore permissions +) : ( + Could not retrieve sessions from cloud +)}
{/* Metric 3: Lean Angle Peak */} -
+
Peak Lean Angle -Lateral Roll + + {metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error' + ? 'Unavailable' + : 'Lateral Roll'} +
-{metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} + + {metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} + lateral roll
-6-Axis IMU Verified +{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? ( + 6-Axis IMU Verified +) : metricsStatus === 'loading' ? ( + Retrieving lean telemetry… +) : metricsStatus === 'unauthenticated' ? ( + No active session data +) : ( + No IMU roll data +)}
{/* 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 */}
diff --git a/website/components/app-cloud-data.tsx b/website/components/app-cloud-data.tsx index cbb32fe..c267736 100644 --- a/website/components/app-cloud-data.tsx +++ b/website/components/app-cloud-data.tsx @@ -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([]); const [rides, setRides] = useState([]); + const [garageStatus, setGarageStatus] = useState('loading'); + const [ridesStatus, setRidesStatus] = useState('loading'); + const [garageError, setGarageError] = useState(null); + const [ridesError, setRidesError] = useState(null); const [state, setState] = useState('Loading telemetry cloud data...'); const [user, setUser] = useState(null); @@ -331,109 +356,180 @@ 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) => { - const data = snapshot.data(); - const yearVal = number(data.year) || 0; - const makeVal = text(data.makeName) || text(data.make); - const modelVal = text(data.modelName) || text(data.model); - const variantVal = text(data.variantName) || text(data.variant); - const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' '); - return { - id: snapshot.id, - nickname: text(data.nickname) || description || 'Motorcycle', - makeName: makeVal, - modelName: modelVal, - variantName: variantVal, - year: yearVal, - description, - isDefault: data.isDefault === true, - imageUrl: text(data.imageUrl) || undefined, - vin: text(data.vin) || text(data.vinNumber) || undefined, - }; - }).sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); + // 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); + const modelVal = text(data.modelName) || text(data.model); + const variantVal = text(data.variantName) || text(data.variant); + const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' '); + return { + id: snapshot.id, + nickname: text(data.nickname) || description || 'Motorcycle', + makeName: makeVal, + modelName: modelVal, + variantName: variantVal, + year: yearVal, + description, + isDefault: data.isDefault === true, + imageUrl: text(data.imageUrl) || undefined, + vin: text(data.vin) || text(data.vinNumber) || undefined, + }; + }).sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); - let totalMiles = 0; - let peakLean = 0; + 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); + } - const loadedRides: Ride[] = rideSnapshot.docs.map((snapshot) => { - const data = snapshot.data(); - 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); + // Process Rides & Metrics + if (rideRes.status === 'fulfilled') { + let totalMiles = 0; + let peakLean = 0; - totalMiles += dist; - if (leanL > peakLean) peakLean = leanL; - if (leanR > peakLean) peakLean = leanR; + const loadedRides: Ride[] = rideRes.value.docs.map((snapshot) => { + const data = snapshot.data(); + 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; - const startLon = number(data.startLon) || number(data.startLongitude) || undefined; - const endLat = number(data.endLat) || number(data.endLatitude) || undefined; - const endLon = number(data.endLon) || number(data.endLongitude) || undefined; - const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined; + totalMiles += dist; + if (leanL > peakLean) peakLean = leanL; + if (leanR > peakLean) peakLean = leanR; - let ridePoints: TelemetryPoint[] | undefined; - if (Array.isArray(data.points) && data.points.length > 0) { - ridePoints = data.points; - } else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) { - ridePoints = data.telemetryPoints; - } + const startLat = number(data.startLat) || number(data.startLatitude) || undefined; + const startLon = number(data.startLon) || number(data.startLongitude) || undefined; + const endLat = number(data.endLat) || number(data.endLatitude) || undefined; + const endLon = number(data.endLon) || number(data.endLongitude) || undefined; + const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined; - return { - id: snapshot.id, - 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)); + let ridePoints: TelemetryPoint[] | undefined; + if (Array.isArray(data.points) && data.points.length > 0) { + ridePoints = data.points; + } else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) { + ridePoints = data.telemetryPoints; + } - setMotorcycles(loadedMotorcycles); - setRides(loadedRides); - setState(''); + return { + id: snapshot.id, + 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) { - onMetricsLoaded({ + setRides(loadedRides); + setRidesStatus('ready'); + setRidesError(null); + + 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
))}
+ ) : garageStatus === 'loading' ? ( +
+
+

Loading motorcycle garage…

+
+ ) : garageStatus === 'permission-denied' || garageStatus === 'unavailable' || garageStatus === 'error' ? ( +
+
+ ! +
+

+ {garageStatus === 'permission-denied' + ? 'Garage Telemetry Restricted — Permission Denied' + : 'Garage Telemetry Unavailable'} +

+

+ {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.')} +

+ +
) : (
@@ -890,9 +1014,19 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad

- - {rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded - + {ridesStatus === 'loading' ? ( + + Checking sessions… + + ) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? ( + + Feed Unavailable + + ) : ( + + {rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded + + )}
{rides.length > 0 ? ( @@ -1098,6 +1232,34 @@ export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoad )} + ) : ridesStatus === 'loading' ? ( +
+
+

Loading telemetry sessions…

+
+ ) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? ( +
+
+ ! +
+

+ {ridesStatus === 'permission-denied' + ? 'Recent Rides Restricted — Permission Denied' + : 'Recent Rides Cloud Feed Unavailable'} +

+

+ {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.')} +

+ +
) : (
diff --git a/website/components/founder-portal/founder-chat.tsx b/website/components/founder-portal/founder-chat.tsx index 1707e08..58cdd64 100644 --- a/website/components/founder-portal/founder-chat.tsx +++ b/website/components/founder-portal/founder-chat.tsx @@ -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('general'); const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); + const [connectionState, setConnectionState] = useState<'connecting' | 'connected' | 'error'>( + isDemo ? 'connected' : 'connecting' + ); const messagesEndRef = useRef(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) {

Squad Live Chat

- {hasExampleMessages ? ( + {isExampleThread ? ( visibility Example thread + ) : connectionState === 'connecting' ? ( + + + Connecting… + + ) : connectionState === 'error' || error ? ( + + + Disconnected + ) : ( @@ -187,12 +207,21 @@ export function FounderChat({ context }: FounderChatProps) {
- {hasExampleMessages && ( + {isExampleThread && (
info - These are example messages — this - channel has no posts yet. Send a message to start the real conversation. + {isDemo ? ( + <> + Founder Demo Preview — this is a + simulated conversation thread. Messages are not persisted or sent to club members. + + ) : ( + <> + These are example messages — this + channel has no posts yet. Send a message to start the real conversation. + + )}
)} @@ -298,6 +327,11 @@ export function FounderChat({ context }: FounderChatProps) { {msg.authorDisplayRank} )} + {msg.isExample && ( + + Example + + )} {msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString([], { diff --git a/website/tests/account-metrics-truthfulness.test.tsx b/website/tests/account-metrics-truthfulness.test.tsx new file mode 100644 index 0000000..5255215 --- /dev/null +++ b/website/tests/account-metrics-truthfulness.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/website/tests/founder-routes-chat.test.tsx b/website/tests/founder-routes-chat.test.tsx index fbb481b..8334d64 100644 --- a/website/tests/founder-routes-chat.test.tsx +++ b/website/tests/founder-routes-chat.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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', () => {