From 04e56a985de1be7ba564ddbe78dd49b04f836222 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 14 Oct 2026 16:05:37 -0400 Subject: [PATCH] fix(website): harden account peak-lean states, stale metrics hygiene, and demo-chat error precedence - Handle loading, unauthenticated, permission-denied, and unavailable states truthfully in metric-card-lean (Peak Lean Angle KPI) rather than falling back to '--' and 'No IMU roll data' - Reset metrics state to null on auth change and in onStatusChange loading/error branches to prevent stale metrics retention - Add race condition guard in AppCloudData.loadData to discard in-flight results if firebaseAuth.currentUser changed during asynchronous fetch - Prioritize Disconnected and Connecting states over sample example messages in real club mode in founder-chat.tsx - Clear chat messages on channel switch in founder-chat.tsx to eliminate cross-channel message bleed, and fix invalid py-0.2 Tailwind classes to py-0.5 - Add regression tests in account-metrics-truthfulness.test.tsx and founder-routes-chat.test.tsx --- website/app/account/page.tsx | 46 +++++-- website/components/app-cloud-data.tsx | 5 + .../founder-portal/founder-chat.tsx | 20 +-- .../account-metrics-truthfulness.test.tsx | 114 +++++++++++++++++- website/tests/founder-routes-chat.test.tsx | 46 +++++++ 5 files changed, 212 insertions(+), 19 deletions(-) diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index 87982fc..cf16e95 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -47,6 +47,9 @@ export default function AccountPage() { setMetricsStatus('unauthenticated'); setMetrics(null); setSyncError(null); + } else { + setMetricsStatus('loading'); + setMetrics(null); } }); return () => unsubscribe(); @@ -380,17 +383,33 @@ export default function AccountPage() {
Peak Lean Angle - - {metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error' - ? 'Unavailable' - : 'Lateral Roll'} - +{metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + Sign in to view +) : metricsStatus === 'permission-denied' ? ( + Permission denied +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Sync unavailable +) : ( + Lateral Roll +)}
- - {metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} - -lateral roll +{metricsStatus === 'ready' && metrics !== null ? ( + <> + + {metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} + + lateral roll + +) : metricsStatus === 'loading' ? ( + Loading… +) : metricsStatus === 'unauthenticated' ? ( + — +) : ( + Unavailable +)}
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? ( @@ -398,7 +417,11 @@ export default function AccountPage() { ) : metricsStatus === 'loading' ? ( Retrieving lean telemetry… ) : metricsStatus === 'unauthenticated' ? ( - No active session data + Sign in to view lean telemetry +) : metricsStatus === 'permission-denied' ? ( + Lean telemetry restricted +) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? ( + Lean telemetry unavailable ) : ( No IMU roll data )} @@ -432,10 +455,13 @@ export default function AccountPage() { setSyncError(null); setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); } else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') { + setMetrics(null); setSyncError('Sync failed'); } else if (status === 'unauthenticated') { setMetrics(null); setSyncError(null); + } else if (status === 'loading') { + setMetrics(null); } }} onMetricsLoaded={(m) => { diff --git a/website/components/app-cloud-data.tsx b/website/components/app-cloud-data.tsx index c267736..5151ae7 100644 --- a/website/components/app-cloud-data.tsx +++ b/website/components/app-cloud-data.tsx @@ -400,6 +400,11 @@ export function AppCloudData({ getDocs(collection(firestore, 'users', currentUser.uid, 'rides')), ]); + // Guard against race conditions if user logged out or changed while async queries were in-flight + if (firebaseAuth.currentUser?.uid !== currentUser.uid) { + return; + } + // Process Motorcycles if (motorcycleRes.status === 'fulfilled') { const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => { diff --git a/website/components/founder-portal/founder-chat.tsx b/website/components/founder-portal/founder-chat.tsx index 58cdd64..d21a46e 100644 --- a/website/components/founder-portal/founder-chat.tsx +++ b/website/components/founder-portal/founder-chat.tsx @@ -69,6 +69,7 @@ export function FounderChat({ context }: FounderChatProps) { useEffect(() => { setError(null); + setMessages([]); let unsubscribe: () => void = () => {}; if (isDemo) { @@ -154,20 +155,25 @@ export function FounderChat({ context }: FounderChatProps) {

Squad Live Chat

- {isExampleThread ? ( + {isDemo ? ( visibility Example thread + ) : connectionState === 'error' || error ? ( + + + Disconnected + ) : connectionState === 'connecting' ? ( Connecting… - ) : connectionState === 'error' || error ? ( - - - Disconnected + ) : hasExampleMessages ? ( + + visibility + Example thread ) : ( @@ -323,12 +329,12 @@ export function FounderChat({ context }: FounderChatProps) { )} {msg.authorDisplayRank && ( - + {msg.authorDisplayRank} )} {msg.isExample && ( - + Example )} diff --git a/website/tests/account-metrics-truthfulness.test.tsx b/website/tests/account-metrics-truthfulness.test.tsx index 5255215..da97082 100644 --- a/website/tests/account-metrics-truthfulness.test.tsx +++ b/website/tests/account-metrics-truthfulness.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import AccountPage from '../app/account/page'; import { AppCloudData } from '../components/app-cloud-data'; +import { firebaseAuth } from '../lib/firebase-client'; const authListeners = new Set<(user: unknown) => void>(); @@ -48,6 +49,7 @@ vi.mock('firebase/firestore', () => ({ })); function deliverAuth(user: unknown) { + (firebaseAuth as { currentUser?: unknown }).currentUser = user; authListeners.forEach((listener) => listener(user)); } @@ -55,6 +57,7 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { afterEach(() => { cleanup(); authListeners.clear(); + (firebaseAuth as { currentUser?: unknown }).currentUser = undefined; vi.clearAllMocks(); }); @@ -89,9 +92,10 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); - // Metric 3: Lean Angle Peak must be '--' + // Metric 3: Lean Angle Peak must show Loading…, not 0 or false verified roll const leanCard = screen.getByTestId('metric-card-lean'); - expect(leanCard.textContent).toContain('--'); + expect(leanCard.textContent).toContain('Loading…'); + expect(leanCard.textContent).toContain('Retrieving lean telemetry…'); }); it('truthfully handles signed-out state: shows sign-in required, never 0 miles or 100% Cloud Synced', async () => { @@ -110,6 +114,10 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { expect(sessionsCard.textContent).toContain('—'); expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); + + const leanCard = screen.getByTestId('metric-card-lean'); + expect(leanCard.textContent).toContain('Sign in to view'); + expect(leanCard.textContent).toContain('—'); }); it('distinguishes real zero-ride account: shows verified 0 miles, 0 rides, Cloud Synced (NOT 100% Cloud Synced)', async () => { @@ -232,6 +240,12 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); + const leanCard = screen.getByTestId('metric-card-lean'); + expect(leanCard.textContent).toContain('Unavailable'); + expect(leanCard.textContent).toContain('Permission denied'); + expect(leanCard.textContent).toContain('Lean telemetry restricted'); + expect(leanCard.textContent).not.toContain('No IMU roll data'); + // AppCloudData error banners expect(screen.getByTestId('cloud-garage-error')).toBeDefined(); expect(screen.getByTestId('cloud-rides-error')).toBeDefined(); @@ -261,6 +275,12 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); + const leanCard = screen.getByTestId('metric-card-lean'); + expect(leanCard.textContent).toContain('Unavailable'); + expect(leanCard.textContent).toContain('Sync unavailable'); + expect(leanCard.textContent).toContain('Lean telemetry unavailable'); + expect(leanCard.textContent).not.toContain('No IMU roll data'); + expect(screen.getByTestId('cloud-rides-error')).toBeDefined(); expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).toBeDefined(); }); @@ -368,4 +388,94 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => { expect(screen.getByText(/Synced \(/i)).toBeDefined(); }); + + it('guards against race conditions when user logs out or switches accounts during in-flight fetch', async () => { + let resolveMotorcycles: (val: unknown) => void; + let resolveRides: (val: unknown) => void; + mocks.getDocs.mockImplementation((colRef: { path: string }) => { + if (colRef.path.includes('motorcycles')) { + return new Promise((res) => { resolveMotorcycles = res; }); + } + return new Promise((res) => { resolveRides = res; }); + }); + + render(); + deliverAuth(authUser); + + // Initial loading + expect(screen.getByTestId('metric-card-distance').textContent).toContain('Loading…'); + + // User logs out while fetch is still in-flight + deliverAuth(null); + + // Now the in-flight promise resolves with User A's data + resolveMotorcycles!({ + docs: [{ + id: 'bike-user-a', + data: () => ({ year: 2024, makeName: 'Yamaha', modelName: 'R7', nickname: 'Ghost Bike' }), + }], + empty: false, + }); + resolveRides!({ + docs: [{ + id: 'ride-user-a', + data: () => ({ distanceMiles: 50, maxLeanLeft: 35, maxLeanRight: 35, startTime: '2026-07-01T10:00:00Z' }), + }], + empty: false, + }); + + // Wait to confirm User A's ghost data does NOT leak into logged-out state + await waitFor(() => { + const distanceCard = screen.getByTestId('metric-card-distance'); + expect(distanceCard.textContent).toContain('Sign in to view'); + expect(distanceCard.textContent).not.toContain('50'); + expect(screen.queryByText('Ghost Bike')).toBeNull(); + }); + }); + + it('clears metrics state when an error occurs so stale metrics from a prior successful sync are not retained', async () => { + let shouldFail = false; + mocks.getDocs.mockImplementation((colRef: { path: string }) => { + if (shouldFail) { + const err = new Error('Permission denied on re-sync'); + (err as unknown as { code: string }).code = 'permission-denied'; + return Promise.reject(err); + } + if (colRef.path.includes('rides')) { + return Promise.resolve({ + docs: [{ + id: 'ride-1', + data: () => ({ distanceMiles: 25.0, maxLeanLeft: 30, maxLeanRight: 32, startTime: '2026-07-01T10:00:00Z' }), + }], + empty: false, + }); + } + return Promise.resolve({ docs: [], empty: true }); + }); + + render(); + deliverAuth(authUser); + + // Verify initial successful sync + await waitFor(() => { + expect(screen.getByTestId('metric-card-distance').textContent).toContain('25'); + }); + + // Now trigger Quick Sync and make it fail + shouldFail = true; + const quickSyncBtn = screen.getByRole('button', { name: /Synced/i }); + fireEvent.click(quickSyncBtn); + + // Verify that metrics are immediately cleared and show Unavailable, not the stale 25 miles + await waitFor(() => { + const distanceCard = screen.getByTestId('metric-card-distance'); + expect(distanceCard.textContent).toContain('Unavailable'); + expect(distanceCard.textContent).not.toContain('25'); + const sessionsCard = screen.getByTestId('metric-card-sessions'); + expect(sessionsCard.textContent).toContain('Unavailable'); + expect(sessionsCard.textContent).not.toContain('1 rides'); + const leanCard = screen.getByTestId('metric-card-lean'); + expect(leanCard.textContent).toContain('Unavailable'); + }); + }); }); diff --git a/website/tests/founder-routes-chat.test.tsx b/website/tests/founder-routes-chat.test.tsx index 8334d64..a848c8d 100644 --- a/website/tests/founder-routes-chat.test.tsx +++ b/website/tests/founder-routes-chat.test.tsx @@ -453,6 +453,52 @@ describe('Founder Chat Management', () => { expect(screen.queryByText('Live Connected')).toBeNull(); expect(screen.queryByText('Example thread')).toBeNull(); }); + + it('shows Disconnected rather than Example thread when real connection fails even if channel had sample messages', async () => { + let triggerError: (err: Error) => void; + mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMsgs, onError) => { + onMsgs([ + { + id: 'sample-1', + clubId: 'club-1', + channelId: 'general', + authorUid: 'admin', + authorDisplayName: 'Admin', + authorRole: 'admin', + content: 'Sample post', + createdAt: '2026-06-01T12:00:00Z', + isExample: true, + }, + ]); + triggerError = onError; + return vi.fn(); + }); + + render(); + await screen.findByText('Sample post'); + expect(screen.getByText('Example thread')).toBeDefined(); + + // Now connection drops in real mode + triggerError!(new Error('Connection interrupted')); + + await waitFor(() => { + expect(screen.getByText('Disconnected')).toBeDefined(); + expect(screen.queryByText('Example thread')).toBeNull(); + }); + }); + + it('clears message list when switching channels to prevent message bleed', async () => { + render(); + await screen.findByText('Squad Live Chat'); + + expect(screen.getByText(/Welcome to squad chat!/i)).toBeDefined(); + + fireEvent.click(screen.getByText('#road-intel')); + + await waitFor(() => { + expect(screen.queryByText(/Welcome to squad chat!/i)).toBeNull(); + }); + }); }); });