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:
parent
5e1ce969cc
commit
04e56a985d
|
|
@ -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() {
|
|||
<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]">
|
||||
{metricsStatus === 'permission-denied' || metricsStatus === 'unavailable' || metricsStatus === 'error'
|
||||
? 'Unavailable'
|
||||
: 'Lateral Roll'}
|
||||
</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]">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">
|
||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? `${metrics.peakLean}°` : '--'}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-slate-400">lateral roll</span>
|
||||
{metricsStatus === 'ready' && metrics !== null ? (
|
||||
<>
|
||||
<span className="text-3xl font-extrabold text-brand-cyan font-mono">
|
||||
{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 className="mt-2 text-xs text-slate-400 flex items-center space-x-1.5">
|
||||
{metricsStatus === 'ready' && metrics !== null && metrics.peakLean > 0 ? (
|
||||
|
|
@ -398,7 +417,11 @@ export default function AccountPage() {
|
|||
) : metricsStatus === 'loading' ? (
|
||||
<span>Retrieving lean telemetry…</span>
|
||||
) : 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>
|
||||
)}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<h1 className="font-heading text-2xl font-bold tracking-wide text-white">
|
||||
Squad Live Chat
|
||||
</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="material-symbols-outlined text-[14px]">visibility</span>
|
||||
Example thread
|
||||
</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' ? (
|
||||
<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
|
||||
) : hasExampleMessages ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{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}
|
||||
</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]">
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue