diff --git a/AGENTS.md b/AGENTS.md index 9dab345..e603145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,5 +5,8 @@ - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. - Do not reference the AI name used (codex, openai, etc...) in any commits, pr's, issue titles or anywhere else. - When the user says "add, commit", group all existing changes into logical sets, stage and commit each group, and repeat until every change is committed and the working tree is clean. +- At the start of every new work request, check the current Git branch before changing files. If the current branch is `main`, create and switch to a descriptively named task branch first. +- Never push work directly to `main`. If `main` already contains uncommitted changes or commits that have not been pushed, create the task branch from its current state so all of that work moves forward on the new branch, then continue the normal workflow there. +- When the user says "add, commit, push, pr, merge" or otherwise confirms that the work is ready, complete the delivery workflow in order: group and commit all changes until the tree is clean, push the task branch, open a pull request targeting `main`, wait for required checks to pass, and merge the pull request. If checks fail or the pull request cannot merge, fix the problem on the same task branch and repeat the relevant steps. - Keep React component modules compatible with Vite Fast Refresh: export only React components from component files, and move non-component runtime exports such as constants, helpers, and metadata into separate modules to avoid incompatible-export invalidations. - Record every new user-facing feature in the README as part of implementing it. diff --git a/README.md b/README.md index 1e195cd..c566ab5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo Kickr Core 2 ## Features +- Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; the welcome screen remains available from the Ride Control footer link. - Connects to compatible bike trainers through Web Bluetooth, remembers authorized devices, and automatically reconnects when possible. - Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes. - Provides direct resistance control with buttons, a slider, and keyboard shortcuts while recording resistance changes alongside the other ride metrics. @@ -15,7 +16,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo Kickr Core 2 - Organizes saved sessions by local date and time in a slide-out history tray with paginated loading, detailed metrics and charts, keyboard navigation, and permanent deletion. - Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance, calories, ride feeling, and comments for upload to Strava and other cycling services. - Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. -- Protects active or unsaved ride data by presenting the save workflow before starting or continuing another session. +- Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session. - Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions. - Displays connection and application notices with a visible 15-second countdown and automatic dismissal. - Keeps all ride data local to the current browser profile; no account or remote service is required. diff --git a/src/app.tsx b/src/app.tsx index 5a9f79d..4e8973a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -8,6 +8,7 @@ import { ResistanceControl } from './components/resistance-control'; import { SessionChart } from './components/session-chart'; import { SessionHistory } from './components/session-history'; import { SessionSaveDialog } from './components/session-save-dialog'; +import { WelcomeDialog } from './components/welcome-dialog'; import { useSession } from './hooks/use-session'; import { useTrainer } from './hooks/use-trainer'; import { formatAggregateAverage, formatDuration } from './lib/format'; @@ -17,6 +18,8 @@ import { requestPersistentSessionStorage, saveSession, } from './lib/saved-sessions'; +import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; +import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; import type { RoutePoint, SavedSession, SessionMetadata, SpeedUnit } from './types'; const EMPTY_ROUTE: RoutePoint[] = []; @@ -48,6 +51,7 @@ export function App() { ); const [historyOpen, setHistoryOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); + const [welcomeOpen, setWelcomeOpen] = useState(shouldShowWelcome); const [saveDialogOpen, setSaveDialogOpen] = useState(() => session.ended && !sessionIsSaved); const [saving, setSaving] = useState(false); const [startAfterSave, setStartAfterSave] = useState(false); @@ -117,9 +121,21 @@ export function App() { requestPersistentSessionStorage().catch(() => false); }, []); + const warnBeforeUnload = sessionNeedsUnloadWarning(session.ended, session.elapsedSeconds); useEffect(() => { - trainer.setKeyboardControlsEnabled(!(historyOpen || shortcutsOpen)); - }, [historyOpen, shortcutsOpen, trainer.setKeyboardControlsEnabled]); + if (!warnBeforeUnload) { + return; + } + const confirmActiveSessionExit = (event: BeforeUnloadEvent) => { + requestUnloadConfirmation(event); + }; + window.addEventListener('beforeunload', confirmActiveSessionExit); + return () => window.removeEventListener('beforeunload', confirmActiveSessionExit); + }, [warnBeforeUnload]); + + useEffect(() => { + trainer.setKeyboardControlsEnabled(!(historyOpen || shortcutsOpen || welcomeOpen)); + }, [historyOpen, shortcutsOpen, trainer.setKeyboardControlsEnabled, welcomeOpen]); useEffect(() => { const shortcutHandlers: Record void> = { @@ -160,7 +176,7 @@ export function App() { }, }; const handleShortcut = (event: KeyboardEvent) => { - if (shouldIgnoreShortcut(event)) { + if (welcomeOpen || shouldIgnoreShortcut(event)) { return; } if (historyOpen) { @@ -181,6 +197,7 @@ export function App() { session.ended, session.togglePause, shortcutsOpen, + welcomeOpen, ]); function selectSpeedUnit(unit: SpeedUnit) { @@ -223,6 +240,12 @@ export function App() { const closeHistory = useCallback(() => setHistoryOpen(false), []); const closeShortcuts = useCallback(() => setShortcutsOpen(false), []); + const closeWelcome = useCallback((dontShowAgain: boolean) => { + if (dontShowAgain) { + rememberWelcomeDismissal(); + } + setWelcomeOpen(false); + }, []); const startNewSessionFromHistory = useCallback( (savedSession: SavedSession) => { setHistoryOpen(false); @@ -405,7 +428,7 @@ export function App() { /> -
+
@@ -448,7 +471,13 @@ export function App() {
diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index d5c728a..874c473 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -27,37 +27,51 @@ export function ChartPlot({ values, }: PlotProps) { const labels = [maximum, (maximum + minimum) / 2, minimum]; + const labelPositions = [14, 52, 90]; return ( -
-
- {labels.map((label) => ( - +
+
+ {labels.map((label, index) => ( + {label.toFixed(decimals)} {unit} ))}
- - {title} - - - +
+ + {title} + + + + +
); } @@ -263,12 +277,15 @@ export function SessionChart({ )) )}
-
- {[0, 0.25, 0.5, 0.75, 1].map((position) => ( - - {formatChartSeconds(historyStart + historySeconds * position)} - - ))} +
+
diff --git a/src/components/welcome-dialog.tsx b/src/components/welcome-dialog.tsx new file mode 100644 index 0000000..bab03f5 --- /dev/null +++ b/src/components/welcome-dialog.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; + +export function WelcomeDialog({ + onClose, + open, +}: { + onClose: (dontShowAgain: boolean) => void; + open: boolean; +}) { + const [dontShowAgain, setDontShowAgain] = useState(false); + const dontShowAgainRef = useRef(false); + + useEffect(() => { + if (!open) { + return; + } + dontShowAgainRef.current = false; + setDontShowAgain(false); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(dontShowAgainRef.current); + } + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [onClose, open]); + + function updateDontShowAgain(checked: boolean) { + dontShowAgainRef.current = checked; + setDontShowAgain(checked); + } + + if (!open) { + return null; + } + + return ( +
+
+
+
+

+ WELCOME TO +

+

+ RideControl.xyz +

+
+ +
+ +

+ Connect a compatible trainer over Bluetooth, adjust resistance, and keep + detailed records of every ride—all from your browser. +

+

+ Ride Control is a freely available, open-source GPLv3 application. View the{' '} + + source code on GitHub + + . +

+

+ Everything runs locally, and all ride data stays in your browser. We don't + upload it anywhere, although we may add an opt-in feature in the future that + would only upload data with your permission. +

+

+ From the history, you can download your rides as TCX files and upload them to + your preferred cycling service whenever you choose. +

+

+ Press ? anytime to + see the available keyboard controls. +

+ +
+ + +
+
+
+ ); +} diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index 588a9b1..ffa8218 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -1,4 +1,9 @@ export type AppShortcut = 'endSession' | 'history' | 'newSession' | 'pause' | 'shortcuts'; +export interface KeyboardShortcutDescription { + group?: string; + keys: string[]; + label: string; +} export type HistoryShortcut = | 'close' | 'confirmDelete' @@ -17,10 +22,21 @@ const historyShortcuts: Record = { Escape: 'close', }; -export const historyKeyboardShortcuts = [ +export const dashboardKeyboardShortcuts: KeyboardShortcutDescription[] = [ + { group: 'Session', keys: ['Space'], label: 'Pause or resume the session' }, + { group: 'Session', keys: ['q'], label: 'End the current session' }, + { group: 'Session', keys: ['n'], label: 'Start a new session after ending' }, + { group: 'Session', keys: ['h'], label: 'Open session history' }, + { group: 'Ride controls', keys: ['↑', '↓'], label: 'Increase or decrease resistance' }, + { group: 'Ride controls', keys: ['←', '→'], label: 'Change the chart view' }, + { group: 'General', keys: ['?'], label: 'Show keyboard shortcuts' }, + { group: 'General', keys: ['Esc'], label: 'Close an open dialog' }, +]; + +export const historyKeyboardShortcuts: KeyboardShortcutDescription[] = [ { keys: ['↑', '↓'], label: 'Select the previous or next session' }, { keys: ['←', '→'], label: 'Change the session chart view' }, - { keys: ['D'], label: 'Delete the selected session' }, + { keys: ['d'], label: 'Delete the selected session' }, { keys: ['Enter'], label: 'Confirm session deletion' }, { keys: ['?'], label: 'Show history keyboard controls' }, { keys: ['Esc'], label: 'Close help or session history' }, diff --git a/src/lib/session.ts b/src/lib/session.ts index 6b22f8d..36c1c3d 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -28,6 +28,17 @@ export function sessionContinuation(snapshot: SessionSnapshot): StoredSession { }; } +export function sessionNeedsUnloadWarning(ended: boolean, elapsedSeconds: number): boolean { + return !ended && elapsedSeconds > 0; +} + +export function requestUnloadConfirmation( + event: Pick +): void { + event.preventDefault(); + event.returnValue = true; +} + export function addAggregate( aggregate: MetricAggregate, value: number, diff --git a/src/lib/welcome.ts b/src/lib/welcome.ts new file mode 100644 index 0000000..16b6224 --- /dev/null +++ b/src/lib/welcome.ts @@ -0,0 +1,20 @@ +export const WELCOME_DISMISSED_STORAGE_KEY = 'ride-control-welcome-dismissed'; + +export function shouldShowWelcome(storage: Pick = localStorage): boolean { + try { + return storage.getItem(WELCOME_DISMISSED_STORAGE_KEY) !== 'true'; + } catch { + return true; + } +} + +export function rememberWelcomeDismissal( + storage: Pick = localStorage +): boolean { + try { + storage.setItem(WELCOME_DISMISSED_STORAGE_KEY, 'true'); + return true; + } catch { + return false; + } +} diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 072a15f..e0757e5 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -20,11 +20,16 @@ import { SessionHistory, } from '../src/components/session-history'; import { SessionSaveDialog } from '../src/components/session-save-dialog'; +import { WelcomeDialog } from '../src/components/welcome-dialog'; import { CHROME_BLUETOOTH_PERMISSION_MESSAGE, emptyMetrics, emptySession } from '../src/constants'; import { historyKeyboardShortcuts } from '../src/lib/keyboard'; const render = (element: React.ReactNode) => renderToStaticMarkup(element); const enabledEndSessionButton = /]*disabled)[^>]*>End session<\/button>/; +const solidChartBoundaries = + /d="M0 14H100 M0 90H100"[^>]*stroke="#3a4654"(?![^>]*stroke-dasharray)/; +const dashedChartGuides = + /d="M0 52H100 M25 14V90 M50 14V90 M75 14V90"[^>]*stroke-dasharray="2.5 2.5"/; describe('view components', () => { test('renders known and fallback icons', () => { @@ -106,18 +111,18 @@ describe('view components', () => { }); test('renders connection, busy, and connected states', () => { - expect( - render( - undefined} - onConnect={() => undefined} - onDisconnect={() => undefined} - status="Ready" - /> - ) - ).toContain('Connect trainer'); + const ready = render( + undefined} + onConnect={() => undefined} + onDisconnect={() => undefined} + status="Ready" + /> + ); + expect(ready).toContain('Connect trainer'); + expect(ready).not.toContain('bg-ink/50'); const busy = render( { expect(html).toContain('href="https://github.com/lookfirst"'); expect(html).toContain('href="https://github.com/sponsors/lookfirst"'); expect(html).toContain('Sponsor'); + expect(html).toContain('WELCOME TO'); + expect(html).toContain('show again'); + expect(html).toContain('tracking-wide transition hover:text-slate-400'); + expect(html).toContain('type="button">Ride Control'); + expect(html).toContain('xl:grid-cols-[1.45fr_.55fr]'); expect(html.indexOf('KM/H')).toBeLessThan(html.indexOf('Show keyboard controls')); expect(html).toMatch(enabledEndSessionButton); }); + test('renders the first-time welcome message', () => { + expect(render( undefined} open={false} />)).toBe(''); + const html = render( undefined} open />); + expect(html).toContain('aria-modal="true"'); + expect(html).toContain('WELCOME TO'); + expect(html).toContain('RideControl.xyz'); + expect(html).toContain('show again'); + expect(html).toContain('Get started'); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('open-source GPLv3 application'); + expect(html).toContain('source code on GitHub'); + expect(html).toContain('href="https://github.com/lookfirst/RideControl"'); + expect(html).toContain('all ride data stays in your browser'); + expect(html).toContain('We don't upload it anywhere'); + expect(html).toContain('would only upload data with your permission'); + expect(html).toContain('From the history, you can download your rides as TCX files'); + }); + test('renders the keyboard controls reference', () => { expect(render( undefined} open={false} />)).toBe(''); const html = render( undefined} open />); @@ -216,6 +244,9 @@ describe('view components', () => { expect(html).toContain('Start a new session after ending'); expect(html).toContain('Increase or decrease resistance'); expect(html).toContain('Change the chart view'); + expect(html).toContain('SESSION'); + expect(html).toContain('RIDE CONTROLS'); + expect(html).toContain('GENERAL'); const historyHtml = render( undefined} @@ -260,6 +291,14 @@ describe('view components', () => { ); expect(html).toContain('Resistance over time'); expect(html).toContain('Resistance'); + expect(html).toContain('grid-cols-[3.75rem_minmax(0,1fr)]'); + expect(html).toContain('absolute right-2 -translate-y-1/2 whitespace-nowrap'); + expect(html).toContain('pointer-events-none relative h-full w-15 shrink-0'); + expect(html).toContain('h-full min-w-0 flex-1 overflow-hidden'); + expect(html).toContain('class="block h-full w-full"'); + expect(html).toMatch(solidChartBoundaries); + expect(html).toMatch(dashedChartGuides); + expect(html).not.toContain('absolute top-[11%] bottom-[8%] left-1'); }); test('renders the session save workflow', () => { diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts index 37f6460..4dae216 100644 --- a/tests/keyboard.test.ts +++ b/tests/keyboard.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { appShortcutForKey, historyShortcutForKey } from '../src/lib/keyboard'; +import { + appShortcutForKey, + dashboardKeyboardShortcuts, + historyKeyboardShortcuts, + historyShortcutForKey, +} from '../src/lib/keyboard'; describe('keyboard shortcuts', () => { test('maps history, help, session, and pause keys', () => { @@ -17,12 +22,33 @@ describe('keyboard shortcuts', () => { expect(appShortcutForKey({ code: 'KeyR', key: 'r' })).toBeUndefined(); }); + test('groups main-screen session controls together', () => { + expect(dashboardKeyboardShortcuts.slice(0, 4).map((shortcut) => shortcut.label)).toEqual([ + 'Pause or resume the session', + 'End the current session', + 'Start a new session after ending', + 'Open session history', + ]); + expect(dashboardKeyboardShortcuts.slice(0, 4).map((shortcut) => shortcut.group)).toEqual([ + 'Session', + 'Session', + 'Session', + 'Session', + ]); + expect(dashboardKeyboardShortcuts.slice(1, 4).map((shortcut) => shortcut.keys[0])).toEqual([ + 'q', + 'n', + 'h', + ]); + }); + test('maps history navigation keys', () => { expect(historyShortcutForKey('ArrowUp')).toBe('previousSession'); expect(historyShortcutForKey('ArrowDown')).toBe('nextSession'); expect(historyShortcutForKey('d')).toBe('deleteSession'); expect(historyShortcutForKey('D')).toBe('deleteSession'); expect(historyShortcutForKey('Enter')).toBe('confirmDelete'); + expect(historyKeyboardShortcuts[2]?.keys).toEqual(['d']); expect(historyShortcutForKey('?')).toBe('help'); expect(historyShortcutForKey('Escape')).toBe('close'); expect(historyShortcutForKey('ArrowLeft')).toBeUndefined(); diff --git a/tests/session.test.ts b/tests/session.test.ts index 14b8109..a00ac5b 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -6,8 +6,10 @@ import { aggregateResistance, loadStoredSession, nonNegativeNumber, + requestUnloadConfirmation, restoreAggregate, sessionContinuation, + sessionNeedsUnloadWarning, storedResistance, } from '../src/lib/session'; @@ -48,6 +50,23 @@ describe('session utilities', () => { expect(continued.savedSessionId).toBeUndefined(); }); + test('protects recorded active sessions from accidental page exit', () => { + expect(sessionNeedsUnloadWarning(false, 1)).toBe(true); + expect(sessionNeedsUnloadWarning(false, 0)).toBe(false); + expect(sessionNeedsUnloadWarning(true, 1)).toBe(false); + + let prevented = false; + const event = { + preventDefault: () => { + prevented = true; + }, + returnValue: false, + }; + requestUnloadConfirmation(event); + expect(prevented).toBe(true); + expect(event.returnValue).toBe(true); + }); + test('adds aggregate samples according to zero policy', () => { const initial = { count: 2, sum: 10 }; expect(addAggregate(initial, 5, false)).toEqual({ count: 3, sum: 15 }); diff --git a/tests/welcome.test.ts b/tests/welcome.test.ts new file mode 100644 index 0000000..26af56b --- /dev/null +++ b/tests/welcome.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { + rememberWelcomeDismissal, + shouldShowWelcome, + WELCOME_DISMISSED_STORAGE_KEY, +} from '../src/lib/welcome'; + +describe('welcome preferences', () => { + test('shows the welcome message until its dismissal is remembered', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(shouldShowWelcome(storage)).toBe(true); + expect(rememberWelcomeDismissal(storage)).toBe(true); + expect(values.get(WELCOME_DISMISSED_STORAGE_KEY)).toBe('true'); + expect(shouldShowWelcome(storage)).toBe(false); + }); + + test('keeps showing the welcome message when storage is unavailable', () => { + const unavailableStorage = { + getItem: () => { + throw new Error('Unavailable'); + }, + setItem: () => { + throw new Error('Unavailable'); + }, + }; + + expect(shouldShowWelcome(unavailableStorage)).toBe(true); + expect(rememberWelcomeDismissal(unavailableStorage)).toBe(false); + }); +});