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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
42 changes: 36 additions & 6 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<AppShortcut, (event: KeyboardEvent) => void> = {
Expand Down Expand Up @@ -160,7 +176,7 @@ export function App() {
},
};
const handleShortcut = (event: KeyboardEvent) => {
if (shouldIgnoreShortcut(event)) {
if (welcomeOpen || shouldIgnoreShortcut(event)) {
return;
}
if (historyOpen) {
Expand All @@ -181,6 +197,7 @@ export function App() {
session.ended,
session.togglePause,
shortcutsOpen,
welcomeOpen,
]);

function selectSpeedUnit(unit: SpeedUnit) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -405,7 +428,7 @@ export function App() {
/>
</section>

<section className="mt-6 grid gap-6 xl:grid-cols-[1.35fr_.65fr]">
<section className="mt-6 grid gap-6 xl:grid-cols-[1.45fr_.55fr]">
<div className="rounded-2xl border border-line bg-panel p-5 sm:p-6">
<div className="grid grid-cols-3 divide-x divide-line rounded-xl border border-slate-500 bg-[#12171d]">
<SmallMetric
Expand All @@ -423,7 +446,7 @@ export function App() {
</div>
<SessionChart
history={session.history}
keyboardEnabled={!(historyOpen || shortcutsOpen)}
keyboardEnabled={!(historyOpen || shortcutsOpen || welcomeOpen)}
route={EMPTY_ROUTE}
speedUnit={speedUnit}
/>
Expand All @@ -448,7 +471,13 @@ export function App() {
</section>
</div>
<footer className="fixed bottom-3 left-4 z-20 flex items-center gap-1.5 text-[11px] text-slate-600">
<span className="font-semibold tracking-wide">Ride Control</span>
<button
className="font-semibold tracking-wide transition hover:text-slate-400"
onClick={() => setWelcomeOpen(true)}
type="button"
>
Ride Control
</button>
<span aria-hidden="true">·</span>
<a
className="transition hover:text-slate-400"
Expand Down Expand Up @@ -490,6 +519,7 @@ export function App() {
speedUnit={speedUnit}
/>
<KeyboardShortcutsDialog onClose={closeShortcuts} open={shortcutsOpen} />
<WelcomeDialog onClose={closeWelcome} open={welcomeOpen} />
</main>
);
}
1 change: 0 additions & 1 deletion src/components/connection-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export function ConnectionControl({
onClick={onConnect}
type="button"
>
<span className="h-2 w-2 shrink-0 rounded-full bg-ink/50" />
<Icon className="h-4 w-4" name="bluetooth" />
Connect trainer
</button>
Expand Down
59 changes: 24 additions & 35 deletions src/components/keyboard-shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
import { useEffect } from 'react';

export interface KeyboardShortcutDescription {
keys: string[];
label: string;
}

export const dashboardKeyboardShortcuts: KeyboardShortcutDescription[] = [
{ keys: ['Space'], label: 'Pause or resume the session' },
{ keys: ['Q'], label: 'End the current session' },
{ keys: ['↑', '↓'], label: 'Increase or decrease resistance' },
{ keys: ['←', '→'], label: 'Change the chart view' },
{ keys: ['N'], label: 'Start a new session after ending' },
{ keys: ['H'], label: 'Open session history' },
{ keys: ['?'], label: 'Show keyboard shortcuts' },
{ keys: ['Esc'], label: 'Close an open dialog' },
];
import { Fragment, useEffect } from 'react';
import { dashboardKeyboardShortcuts, type KeyboardShortcutDescription } from '../lib/keyboard';

export function KeyboardShortcutsDialog({
handleEscape = true,
Expand Down Expand Up @@ -68,24 +53,28 @@ export function KeyboardShortcutsDialog({
×
</button>
</div>
<div className="mt-5 divide-y divide-line overflow-hidden rounded-xl border border-line bg-[#12171d]">
{shortcuts.map((shortcut) => (
<div
className="flex min-h-12 items-center justify-between gap-5 px-3.5 py-2.5"
key={shortcut.label}
>
<span className="text-slate-300 text-sm">{shortcut.label}</span>
<span className="flex shrink-0 gap-1.5">
{shortcut.keys.map((key) => (
<kbd
className="min-w-8 rounded-md border border-slate-600 bg-slate-800 px-2 py-1 text-center font-mono font-semibold text-slate-200 text-xs shadow-sm"
key={key}
>
{key}
</kbd>
))}
</span>
</div>
<div className="mt-5 overflow-hidden rounded-xl border border-line bg-[#12171d]">
{shortcuts.map((shortcut, index) => (
<Fragment key={shortcut.label}>
{shortcut.group && shortcut.group !== shortcuts[index - 1]?.group ? (
<div className="border-line border-t bg-slate-800/40 px-3.5 py-2 font-bold text-[10px] text-slate-500 tracking-[.14em] first:border-t-0">
{shortcut.group.toUpperCase()}
</div>
) : null}
<div className="flex min-h-12 items-center justify-between gap-5 border-line border-t px-3.5 py-2.5 first:border-t-0">
<span className="text-slate-300 text-sm">{shortcut.label}</span>
<span className="flex shrink-0 gap-1.5">
{shortcut.keys.map((key) => (
<kbd
className="min-w-8 rounded-md border border-slate-600 bg-slate-800 px-2 py-1 text-center font-mono font-semibold text-slate-200 text-xs shadow-sm"
key={key}
>
{key}
</kbd>
))}
</span>
</div>
</Fragment>
))}
</div>
</section>
Expand Down
81 changes: 49 additions & 32 deletions src/components/session-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,37 +27,51 @@ export function ChartPlot({
values,
}: PlotProps) {
const labels = [maximum, (maximum + minimum) / 2, minimum];
const labelPositions = [14, 52, 90];
return (
<div className={`relative w-full ${heightClass}`}>
<div className="pointer-events-none absolute top-[11%] bottom-[8%] left-1 z-10 flex flex-col justify-between font-medium text-[10px] text-slate-400">
{labels.map((label) => (
<span className="w-fit rounded bg-[#12171d]/85 px-1" key={label}>
<div className={`flex w-full ${heightClass}`}>
<div className="pointer-events-none relative h-full w-15 shrink-0 font-medium text-[10px] text-slate-400">
{labels.map((label, index) => (
<span
className="absolute right-2 -translate-y-1/2 whitespace-nowrap"
key={label}
style={{ top: `${labelPositions[index]}%` }}
>
{label.toFixed(decimals)} {unit}
</span>
))}
</div>
<svg
className="h-full w-full overflow-visible"
preserveAspectRatio="none"
viewBox="0 0 100 100"
>
<title>{title}</title>
<path
d="M0 90H100 M0 52H100 M0 14H100 M25 14V90 M50 14V90 M75 14V90"
fill="none"
stroke="#3a4654"
strokeDasharray="2.5 2.5"
strokeWidth=".75"
vectorEffect="non-scaling-stroke"
/>
<path
d={chartPath(values, minimum, maximum, positions)}
fill="none"
stroke={color}
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</svg>
<div className="h-full min-w-0 flex-1 overflow-hidden">
<svg
className="block h-full w-full"
preserveAspectRatio="none"
viewBox="0 0 100 100"
>
<title>{title}</title>
<path
d="M0 14H100 M0 90H100"
fill="none"
stroke="#3a4654"
strokeWidth=".75"
vectorEffect="non-scaling-stroke"
/>
<path
d="M0 52H100 M25 14V90 M50 14V90 M75 14V90"
fill="none"
stroke="#3a4654"
strokeDasharray="2.5 2.5"
strokeWidth=".75"
vectorEffect="non-scaling-stroke"
/>
<path
d={chartPath(values, minimum, maximum, positions)}
fill="none"
stroke={color}
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
</div>
);
}
Expand Down Expand Up @@ -263,12 +277,15 @@ export function SessionChart({
))
)}
</div>
<div className="mt-1 flex justify-between text-[10px] text-slate-500">
{[0, 0.25, 0.5, 0.75, 1].map((position) => (
<span key={position}>
{formatChartSeconds(historyStart + historySeconds * position)}
</span>
))}
<div className="mt-1 grid grid-cols-[3.75rem_minmax(0,1fr)] text-[10px] text-slate-500">
<span aria-hidden="true" />
<div className="flex justify-between">
{[0, 0.25, 0.5, 0.75, 1].map((position) => (
<span key={position}>
{formatChartSeconds(historyStart + historySeconds * position)}
</span>
))}
</div>
</div>
</div>
</div>
Expand Down
Loading