Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion playwright/tests/route-import-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ test('imports sample GeoJSON route and renders it in 3D workout view without err

await page.goto('./');

await page.getByRole('button', { name: /import route/i }).click();
// Use DOM click here to avoid route-info-overlay backdrop-filter compositing layer
// intercepting pointer events on macOS headless Chromium.
await page.evaluate(() => {
(document.querySelector('button.btn-import-route') as HTMLButtonElement)?.click();
});
await page.getByLabel('Route name').fill('Rownative Fixture Course');
await page.locator('.route-import input[type="file"]').setInputFiles(sampleGeoJsonPath);

Expand Down
2 changes: 2 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,7 @@ body {
display: flex;
flex-wrap: wrap;
gap: 6px;
pointer-events: none;
}

.route-info-overlay .route-tags .tag {
Expand All @@ -629,6 +630,7 @@ body {
font-size: 11px;
color: var(--color-text-secondary);
font-weight: 500;
pointer-events: none;
}

.route-info-overlay .selected-workout-info {
Expand Down
103 changes: 20 additions & 83 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ function extractRouteStatus(tags: string[] | undefined): string | undefined {

function App() {
const { isAuthenticated } = useAuth();
// In Playwright e2e tests, window.__PLAYWRIGHT_TESTING is set to true by mock-bluetooth.js.
// Guard all unauthenticated-guest behaviours on this flag so tests can exercise the full UI.
const isGuestSession = !isAuthenticated && !window.__PLAYWRIGHT_TESTING;
const showAuthFeatures = isAuthenticated || !!window.__PLAYWRIGHT_TESTING;
const [currentView, setCurrentView] = useState<'routes' | 'workout' | 'history'>('routes');
const [routes, setRoutes] = useState<WaterRoute[]>([]);
const [selectedRoute, setSelectedRoute] = useState<WaterRoute | null>(null);
Expand Down Expand Up @@ -64,9 +68,7 @@ function App() {
const [debugMode, setDebugMode] = useState(false);
// Session state for the overlay UI
const [sessionState, setSessionState] = useState<SessionState>('idle');
// Guest mode — activated by ?guest=true URL param or Quick Start button
const [isGuestMode, setIsGuestMode] = useState(false);
// Holds a completed guest session until the summary modal is dismissed
// Holds a completed unauthenticated session until the summary modal is dismissed
const [guestCompletedSession, setGuestCompletedSession] = useState<WorkoutSession | null>(null);
// Route description panel state (collapsed/expanded)
const [isRouteDescriptionExpanded, setIsRouteDescriptionExpanded] = useState(true);
Expand All @@ -78,35 +80,26 @@ function App() {
const [isImportOpen, setIsImportOpen] = useState(false);
const [importRouteName, setImportRouteName] = useState('');

// Activate guest mode if the URL contains ?guest=true
// Pre-select Willowbrook River for unauthenticated users
useEffect(() => {
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
if (params.get('guest') === 'true') {
setIsGuestMode(true);
}
}
}, []);

// When guest mode is activated (URL param or button) ensure Willowbrook is selected
useEffect(() => {
if (isGuestMode && routes.length > 0) {
if (!isAuthenticated && routes.length > 0) {
const wb = routes.find(r => r.id === '1');
if (wb) setSelectedRoute(wb);
}
}, [isGuestMode, routes]);
}, [isAuthenticated, routes]);

// Auto-start/stop the HR simulator when guest mode toggles
// Auto-start/stop the HR simulator for unauthenticated users
// Skip in Playwright test mode so tests can control HR connection state explicitly.
useEffect(() => {
if (isGuestMode) {
if (isGuestSession) {
heartRateSimulator.start(130);
} else {
heartRateSimulator.stop();
}
return () => {
heartRateSimulator.stop();
};
}, [isGuestMode]);
}, [isGuestSession]);

// Start/stop activity timer when workout state changes
useEffect(() => {
Expand Down Expand Up @@ -233,21 +226,9 @@ function App() {
setSelectedRoute(route);
}, []);

// The Willowbrook River route (id '1') is the default guest route
const willowbrookRoute = useMemo(() => routes.find(r => r.id === '1') ?? null, [routes]);
const selectedRouteEnrichment = selectedRoute ? routeEnrichments[selectedRoute.id] ?? null : null;
const selectedRouteEnrichmentLoading = selectedRoute ? !!routeEnrichmentLoading[selectedRoute.id] : false;

const handleQuickStart = useCallback(() => {
setIsGuestMode(true);
if (willowbrookRoute) setSelectedRoute(willowbrookRoute);
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.set('guest', 'true');
window.history.replaceState({}, '', url.toString());
}
}, [willowbrookRoute]);

const handleStartWorkout = () => {
// Guard against double-start (rapid clicks, re-entrant calls, or already-active session)
if (isStartingSessionRef.current || isWorkoutActive || workoutService.getCurrentSession()) return;
Expand All @@ -264,7 +245,7 @@ function App() {
undefined,
activeRowerType,
hrConnected,
isGuestMode,
isGuestSession,
);
setCurrentSession(session);
setIsWorkoutActive(true);
Expand All @@ -281,13 +262,13 @@ function App() {
setCurrentSession(null);
setSessionState('idle');

if (isGuestMode && completed) {
// Show summary modal; do NOT push to workoutHistory (guest sessions are excluded)
if (isGuestSession && completed) {
// Show summary modal; do NOT push to workoutHistory (unauthenticated sessions are excluded)
setGuestCompletedSession(completed);
} else {
setCurrentView('routes');
}
}, [isGuestMode]);
}, [isGuestSession]);

const handleGuestRowAgain = useCallback(() => {
setGuestCompletedSession(null);
Expand All @@ -296,14 +277,7 @@ function App() {

const handleGuestExit = useCallback(() => {
setGuestCompletedSession(null);
setIsGuestMode(false);
setCurrentView('routes');
// Remove ?guest param from URL without reload
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.delete('guest');
window.history.replaceState({}, '', url.toString());
}
}, []);

const handlePauseWorkout = useCallback(() => {
Expand Down Expand Up @@ -543,24 +517,13 @@ function App() {
<AuthButton />
</div>
</div>
{/* Show guest banner only when in guest mode and not authenticated */}
{isGuestMode && !isAuthenticated && (
<div className="guest-mode-banner">
<span className="guest-badge-header">Guest Mode</span>
<span className="guest-banner-text">Session data will not be saved.</span>
<button className="btn-exit-guest" onClick={handleGuestExit} type="button">
Exit Guest Mode
</button>
</div>
)}
{/* Sign-out from the auth dropdown also exits guest mode if active */}
{/* Session data will not be saved when unauthenticated */}
</header>

<div className={`app-layout app-layout--${currentView}`}>
<aside
className={[
'app-sidebar',
isGuestMode ? 'app-sidebar--guest' : '',
isWorkoutActive && currentView === 'workout' && !window.__PLAYWRIGHT_TESTING
? 'app-sidebar--hidden'
: '',
Expand All @@ -573,7 +536,7 @@ function App() {
>
<span className="tab-icon">🗺️</span> Routes
</button>
{!isGuestMode && (
{showAuthFeatures && (
<button
className={`nav-tab ${currentView === 'history' ? 'active' : ''}`}
onClick={() => setCurrentView('history')}
Expand Down Expand Up @@ -635,36 +598,12 @@ function App() {
</aside>

<main className="app-main">
{/* Quick Start CTA — shown only when NOT in guest mode, on the routes view */}
{currentView === 'routes' && !isGuestMode && (
<div className="quick-start-banner">
<div className="quick-start-content">
<span className="quick-start-label">No account needed</span>
<h3>Just want to row?</h3>
<p>Start instantly on Willowbrook River — no sign-up, no data saved.</p>
</div>
<button className="btn btn-quick-start" onClick={handleQuickStart} type="button">
⚡ Quick Start
</button>
</div>
)}

{currentView === 'routes' && selectedRoute && (
<div className="view-container view-container--routes">
<div className="map-container">
<RouteMap route={selectedRoute} />
</div>
<div className="route-details-panel">
<button
className="btn-toggle-description btn-toggle-description--route-details"
onClick={() => setIsRouteDescriptionExpanded(!isRouteDescriptionExpanded)}
type="button"
aria-label={isRouteDescriptionExpanded ? "Collapse description" : "Expand description"}
aria-expanded={isRouteDescriptionExpanded}
>
{isRouteDescriptionExpanded ? '▼' : '▶'} Description
</button>

{/* Route Info Overlay */}
<div className="route-info-overlay">
<div className="route-info-header">
Expand All @@ -673,9 +612,7 @@ function App() {
</div>

<div className="route-description-container">
{isRouteDescriptionExpanded && (
<p className="route-description">{selectedRoute.description}</p>
)}
<p className="route-description">{selectedRoute.description}</p>
</div>

<div className="route-meta-compact">
Expand Down Expand Up @@ -715,7 +652,7 @@ function App() {
</button>
</div>

{!isGuestMode && (
{showAuthFeatures && (
<div className="routes-list">
<div className="routes-list-header">
<h3>Routes</h3>
Expand Down
66 changes: 51 additions & 15 deletions src/__tests__/app.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from '../App';
import { formatPace } from '../utils/formatters';
import * as AuthContext from '../context/AuthContext';
import type { AuthContextValue } from '../context/AuthContext';

const originalGetContext = HTMLCanvasElement.prototype.getContext;

Expand Down Expand Up @@ -67,27 +69,61 @@ describe('App component', () => {
expect(formatPace(359)).toBe('5:59/500m');
});

it('shows Quick Start button in normal mode', () => {
it('does not show Quick Start button', () => {
render(<App />);
expect(screen.getByRole('button', { name: /Quick Start/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Quick Start/i })).not.toBeInTheDocument();
});

it('shows route-only navigation in guest mode (?guest=true)', () => {
// Simulate URL param
const url = new URL(window.location.href);
url.searchParams.set('guest', 'true');
window.history.replaceState({}, '', url.toString());

it('shows route-only navigation for unauthenticated users (no History tab)', () => {
render(<App />);

expect(screen.getByRole('button', { name: /Routes/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /History/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Workouts/i })).not.toBeInTheDocument();
expect(screen.getAllByText(/Guest Mode/i).length).toBeGreaterThan(0);
});

it('shows Rower Device and Heart Rate panels for unauthenticated users without guest sidebar class', () => {
const { container } = render(<App />);

expect(screen.getByText(/Rower Device/i)).toBeInTheDocument();
expect(screen.getByText(/Heart Rate/i)).toBeInTheDocument();
// GUEST-2: sidebar must not carry app-sidebar--guest (which previously hid device panels)
const sidebar = container.querySelector('.app-sidebar');
expect(sidebar?.classList.contains('app-sidebar--guest')).toBe(false);
});

it('does not show Import Route button for unauthenticated users', () => {
render(<App />);
expect(screen.queryByRole('button', { name: /Import Route/i })).not.toBeInTheDocument();
});

it('does not show Open rownative.icu button for unauthenticated users', () => {
render(<App />);
expect(screen.queryByRole('button', { name: /rownative\.icu/i })).not.toBeInTheDocument();
});

describe('authenticated user', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('shows Import Route and Open rownative.icu buttons when logged in', () => {
const authedValue: AuthContextValue = {
user: { id: 'i12345', name: 'Test User', email: 'test@example.com' },
isAuthenticated: true,
isLoading: false,
authError: null,
login: vi.fn(),
logout: vi.fn(),
clearAuthError: vi.fn(),
pendingAction: null,
setPendingAction: vi.fn(),
};
vi.spyOn(AuthContext, 'useAuth').mockReturnValue(authedValue);

render(<App />);

// Clean up URL
const cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete('guest');
window.history.replaceState({}, '', cleanUrl.toString());
expect(screen.getByRole('button', { name: /Import Route/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /rownative\.icu/i })).toBeInTheDocument();
});
});
});
2 changes: 1 addition & 1 deletion src/components/GuestSessionSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function GuestSessionSummary({ session, onRowAgain, onExit }: GuestSessio
▶ Row Again
</button>
<button className="btn btn-guest-exit" onClick={onExit} type="button">
Exit Guest Mode
Done
</button>
</div>
</div>
Expand Down
Loading