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
This commit is contained in:
Developer 2026-10-14 16:05:37 -04:00
parent 5e1ce969cc
commit 04e56a985d
5 changed files with 212 additions and 19 deletions

View File

@ -47,6 +47,9 @@ export default function AccountPage() {
setMetricsStatus('unauthenticated'); setMetricsStatus('unauthenticated');
setMetrics(null); setMetrics(null);
setSyncError(null); setSyncError(null);
} else {
setMetricsStatus('loading');
setMetrics(null);
} }
}); });
return () => unsubscribe(); return () => unsubscribe();
@ -380,17 +383,33 @@ export default function AccountPage() {
<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="glass-card p-5 rounded-xl border border-brand-border glow-border-cyan transition duration-200" data-testid="metric-card-lean">
<div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal"> <div className="flex items-center justify-between text-xs font-mono text-slate-400 tracking-normal">
<span>Peak Lean Angle</span> <span>Peak Lean Angle</span>
<span className="text-brand-cyan font-mono text-[10px]"> {metricsStatus === 'loading' ? (
{metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error' <span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Loading…</span>
? 'Unavailable' ) : metricsStatus === 'unauthenticated' ? (
: 'Lateral Roll'} <span className="text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded text-[10px]">Sign in to view</span>
</span> ) : metricsStatus === 'permission-denied' ? (
<span className="text-rose-400 bg-rose-500/10 px-1.5 py-0.5 rounded text-[10px]">Permission denied</span>
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
<span className="text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded text-[10px]">Sync unavailable</span>
) : (
<span className="text-brand-cyan bg-brand-cyan/10 px-1.5 py-0.5 rounded text-[10px]">Lateral Roll</span>
)}
</div> </div>
<div className="mt-3 flex items-baseline space-x-2"> <div className="mt-3 flex items-baseline space-x-2">
<span className="text-3xl font-extrabold text-brand-cyan font-mono"> {metricsStatus === 'ready' && metrics !== null ? (
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'} <>
</span> <span className="text-3xl font-extrabold text-brand-cyan font-mono">
<span className="text-sm font-mono text-slate-400">lateral roll</span> {metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
</span>
<span className="text-sm font-mono text-slate-400">lateral roll</span>
</>
) : metricsStatus === 'loading' ? (
<span className="text-2xl font-bold text-slate-400 font-mono animate-pulse">Loading…</span>
) : metricsStatus === 'unauthenticated' ? (
<span className="text-3xl font-bold text-slate-500 font-mono">—</span>
) : (
<span className="text-2xl font-bold text-rose-400 font-mono">Unavailable</span>
)}
</div> </div>
<div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5"> <div className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? ( {metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
@ -398,7 +417,11 @@ export default function AccountPage() {
) : metricsStatus === 'loading' ? ( ) : metricsStatus === 'loading' ? (
<span>Retrieving lean telemetry…</span> <span>Retrieving lean telemetry…</span>
) : metricsStatus === 'unauthenticated' ? ( ) : metricsStatus === 'unauthenticated' ? (
<span>No active session data</span> <span>Sign in to view lean telemetry</span>
) : metricsStatus === 'permission-denied' ? (
<span className="text-rose-400">Lean telemetry restricted</span>
) : metricsStatus === 'unavailable' || metricsStatus === 'error' ? (
<span className="text-amber-400">Lean telemetry unavailable</span>
) : ( ) : (
<span className="text-slate-500">No IMU roll data</span> <span className="text-slate-500">No IMU roll data</span>
)} )}
@ -432,10 +455,13 @@ export default function AccountPage() {
setSyncError(null); setSyncError(null);
setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); setLastSynced(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
} else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') { } else if (status === 'permission-denied' || status === 'unavailable' || status === 'error') {
setMetrics(null);
setSyncError('Sync failed'); setSyncError('Sync failed');
} else if (status === 'unauthenticated') { } else if (status === 'unauthenticated') {
setMetrics(null); setMetrics(null);
setSyncError(null); setSyncError(null);
} else if (status === 'loading') {
setMetrics(null);
} }
}} }}
onMetricsLoaded={(m) => { onMetricsLoaded={(m) => {

View File

@ -400,6 +400,11 @@ export function AppCloudData({
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')), 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 // Process Motorcycles
if (motorcycleRes.status === 'fulfilled') { if (motorcycleRes.status === 'fulfilled') {
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => { const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {

View File

@ -69,6 +69,7 @@ export function FounderChat({ context }: FounderChatProps) {
useEffect(() => { useEffect(() => {
setError(null); setError(null);
setMessages([]);
let unsubscribe: () => void = () => {}; let unsubscribe: () => void = () => {};
if (isDemo) { if (isDemo) {
@ -154,20 +155,25 @@ export function FounderChat({ context }: FounderChatProps) {
<h1 className="font-heading text-2xl font-bold tracking-wide text-white"> <h1 className="font-heading text-2xl font-bold tracking-wide text-white">
Squad Live Chat Squad Live Chat
</h1> </h1>
{isExampleThread ? ( {isDemo ? (
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5"> <span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 font-semibold flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px]">visibility</span> <span className="material-symbols-outlined text-[14px]">visibility</span>
Example thread Example thread
</span> </span>
) : connectionState === 'error' || error ? (
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-rose-500/15 text-rose-400 border border-rose-500/30 font-semibold flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-rose-400" />
Disconnected
</span>
) : connectionState === 'connecting' ? ( ) : 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="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" /> <span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" />
Connecting… Connecting…
</span> </span>
) : connectionState === 'error' || error ? ( ) : hasExampleMessages ? (
<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="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="w-2 h-2 rounded-full bg-rose-400" /> <span className="material-symbols-outlined text-[14px]">visibility</span>
Disconnected Example thread
</span> </span>
) : ( ) : (
<span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5"> <span className="font-mono text-xs px-2.5 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold flex items-center gap-1.5">
@ -323,12 +329,12 @@ export function FounderChat({ context }: FounderChatProps) {
</span> </span>
)} )}
{msg.authorDisplayRank && ( {msg.authorDisplayRank && (
<span className="font-mono px-1.5 py-0.2 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]"> <span className="font-mono px-1.5 py-0.5 rounded bg-surface-container border border-brand-border/40 text-brand-cyan text-[9px]">
{msg.authorDisplayRank} {msg.authorDisplayRank}
</span> </span>
)} )}
{msg.isExample && ( {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]"> <span className="font-mono px-1.5 py-0.5 rounded bg-amber-500/15 border border-amber-500/30 text-amber-300 text-[9px]">
Example Example
</span> </span>
)} )}

View File

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import AccountPage from '../app/account/page'; import AccountPage from '../app/account/page';
import { AppCloudData } from '../components/app-cloud-data'; import { AppCloudData } from '../components/app-cloud-data';
import { firebaseAuth } from '../lib/firebase-client';
const authListeners = new Set<(user: unknown) => void>(); const authListeners = new Set<(user: unknown) => void>();
@ -48,6 +49,7 @@ vi.mock('firebase/firestore', () => ({
})); }));
function deliverAuth(user: unknown) { function deliverAuth(user: unknown) {
(firebaseAuth as { currentUser?: unknown }).currentUser = user;
authListeners.forEach((listener) => listener(user)); authListeners.forEach((listener) => listener(user));
} }
@ -55,6 +57,7 @@ describe('Account Cloud Metrics Truthfulness (PR #38 Thread 1)', () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
authListeners.clear(); authListeners.clear();
(firebaseAuth as { currentUser?: unknown }).currentUser = undefined;
vi.clearAllMocks(); 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('0 rides');
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); 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'); 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 () => { 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).toContain('—');
expect(sessionsCard.textContent).not.toContain('0 rides'); expect(sessionsCard.textContent).not.toContain('0 rides');
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); 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 () => { 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('0 rides');
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); 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 // AppCloudData error banners
expect(screen.getByTestId('cloud-garage-error')).toBeDefined(); expect(screen.getByTestId('cloud-garage-error')).toBeDefined();
expect(screen.getByTestId('cloud-rides-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('0 rides');
expect(sessionsCard.textContent).not.toContain('100% Cloud Synced'); 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.getByTestId('cloud-rides-error')).toBeDefined();
expect(screen.getByText(/Recent Rides Cloud Feed Unavailable/i)).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(); expect(screen.getByText(/Synced \(/i)).toBeDefined();
}); });
it('guards against race conditions when user logs out or switches accounts during in-flight fetch', async () => {
let resolveMotorcycles: (val: unknown) => void;
let resolveRides: (val: unknown) => void;
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
if (colRef.path.includes('motorcycles')) {
return new Promise((res) => { resolveMotorcycles = res; });
}
return new Promise((res) => { resolveRides = res; });
});
render(<AccountPage />);
deliverAuth(authUser);
// Initial loading
expect(screen.getByTestId('metric-card-distance').textContent).toContain('Loading…');
// User logs out while fetch is still in-flight
deliverAuth(null);
// Now the in-flight promise resolves with User A's data
resolveMotorcycles!({
docs: [{
id: 'bike-user-a',
data: () => ({ year: 2024, makeName: 'Yamaha', modelName: 'R7', nickname: 'Ghost Bike' }),
}],
empty: false,
});
resolveRides!({
docs: [{
id: 'ride-user-a',
data: () => ({ distanceMiles: 50, maxLeanLeft: 35, maxLeanRight: 35, startTime: '2026-07-01T10:00:00Z' }),
}],
empty: false,
});
// Wait to confirm User A's ghost data does NOT leak into logged-out state
await waitFor(() => {
const distanceCard = screen.getByTestId('metric-card-distance');
expect(distanceCard.textContent).toContain('Sign in to view');
expect(distanceCard.textContent).not.toContain('50');
expect(screen.queryByText('Ghost Bike')).toBeNull();
});
});
it('clears metrics state when an error occurs so stale metrics from a prior successful sync are not retained', async () => {
let shouldFail = false;
mocks.getDocs.mockImplementation((colRef: { path: string }) => {
if (shouldFail) {
const err = new Error('Permission denied on re-sync');
(err as unknown as { code: string }).code = 'permission-denied';
return Promise.reject(err);
}
if (colRef.path.includes('rides')) {
return Promise.resolve({
docs: [{
id: 'ride-1',
data: () => ({ distanceMiles: 25.0, maxLeanLeft: 30, maxLeanRight: 32, startTime: '2026-07-01T10:00:00Z' }),
}],
empty: false,
});
}
return Promise.resolve({ docs: [], empty: true });
});
render(<AccountPage />);
deliverAuth(authUser);
// Verify initial successful sync
await waitFor(() => {
expect(screen.getByTestId('metric-card-distance').textContent).toContain('25');
});
// Now trigger Quick Sync and make it fail
shouldFail = true;
const quickSyncBtn = screen.getByRole('button', { name: /Synced/i });
fireEvent.click(quickSyncBtn);
// Verify that metrics are immediately cleared and show Unavailable, not the stale 25 miles
await waitFor(() => {
const distanceCard = screen.getByTestId('metric-card-distance');
expect(distanceCard.textContent).toContain('Unavailable');
expect(distanceCard.textContent).not.toContain('25');
const sessionsCard = screen.getByTestId('metric-card-sessions');
expect(sessionsCard.textContent).toContain('Unavailable');
expect(sessionsCard.textContent).not.toContain('1 rides');
const leanCard = screen.getByTestId('metric-card-lean');
expect(leanCard.textContent).toContain('Unavailable');
});
});
}); });

View File

@ -453,6 +453,52 @@ describe('Founder Chat Management', () => {
expect(screen.queryByText('Live Connected')).toBeNull(); expect(screen.queryByText('Live Connected')).toBeNull();
expect(screen.queryByText('Example thread')).toBeNull(); expect(screen.queryByText('Example thread')).toBeNull();
}); });
it('shows Disconnected rather than Example thread when real connection fails even if channel had sample messages', async () => {
let triggerError: (err: Error) => void;
mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMsgs, onError) => {
onMsgs([
{
id: 'sample-1',
clubId: 'club-1',
channelId: 'general',
authorUid: 'admin',
authorDisplayName: 'Admin',
authorRole: 'admin',
content: 'Sample post',
createdAt: '2026-06-01T12:00:00Z',
isExample: true,
},
]);
triggerError = onError;
return vi.fn();
});
render(<FounderChat context={mockContext} />);
await screen.findByText('Sample post');
expect(screen.getByText('Example thread')).toBeDefined();
// Now connection drops in real mode
triggerError!(new Error('Connection interrupted'));
await waitFor(() => {
expect(screen.getByText('Disconnected')).toBeDefined();
expect(screen.queryByText('Example thread')).toBeNull();
});
});
it('clears message list when switching channels to prevent message bleed', async () => {
render(<FounderChat context={demoContext} />);
await screen.findByText('Squad Live Chat');
expect(screen.getByText(/Welcome to squad chat!/i)).toBeDefined();
fireEvent.click(screen.getByText('#road-intel'));
await waitFor(() => {
expect(screen.queryByText(/Welcome to squad chat!/i)).toBeNull();
});
});
}); });
}); });