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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +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; visitors outside the tested Google Chrome environment also see a prominent compatibility warning and official Chrome download link, and the welcome screen remains available from the Ride Control footer link.
- 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; visitors outside the tested Google Chrome environment first see a prominent top-of-welcome compatibility warning and official browser download link, and the welcome screen remains available from the Ride Control footer link.
- Restores the paired-devices, terrain-workouts, or session-history side tray when the page reloads while that tray is open, and clears the remembered tray as soon as it closes. Pulsing indicators that communicate live connection or route state remain active even when Chromium reports a reduced-motion preference.
- Uses TanStack Router to keep shareable deep links synchronized with the visible selection: `/bikegpx/:routeId` opens the terrain-workout tray, BikeGPX browser, map, and requested prepared route; `/workouts/:workoutId` opens and centers the requested workout; `/sessions/:sessionId` opens the complete saved-session detail; `/devices` opens the paired-devices tray; and `/profile` opens the rider-and-bike profile tray. Collection links open the corresponding tray, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface.
- Manages the smart trainer, heart rate monitor, and the physical `+` Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, direct **Cancel pairing** and **Stop connecting** actions during stalled attempts, immediate local removal when **Forget** is chosen even if the Bluetooth link is failing, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Cancelling invalidates the pending attempt so a late browser selection or GATT completion cannot restore it. Ride Control currently exposes only the reliable `+` controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical `+` button shifts up, and the blue `Y` button shifts down; the controller row briefly identifies those inputs as `+` and `−` while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than `1.2.0` with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices panel is open stay in setup and do not shift the ride.
Expand All @@ -23,7 +23,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives. Reloading while riding or explicitly paused retains the browser safety confirmation, while an inactivity-triggered auto-pause can reload immediately because its complete checkpoint is already stored locally. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects.
- Tracks every time-series sample plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with no duration-based truncation during recording or FIT/TCX import. Large, high-visibility numbers appear in space-efficient live metric and ride-summary cards, with oversized ride totals and subdued unit labels. Focused or combined charts use a responsive display-only sample of long histories without changing the complete data retained for summaries and exports. The resistance chart starts at a useful 50% scale and expands in ten-point steps as samples approach its ceiling. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. Workout elevation is recorded across the entire ride, so the course profile repeats for every completed loop. Saved sessions reference immutable, content-addressed workout snapshots in a separate IndexedDB store: identical course definitions share one snapshot, edited definitions retain their historical versions, and deleting a workout from the selectable library cannot break an older session's maps or terrain details.
- Keeps the complete dashboard usable at phone widths: ride totals reflow when necessary, chart controls and plots shrink within the viewport, virtual shifting uses the available width, and the footer remains below the controls with device safe-area spacing.
- Provides clear footer access to email contact, the privacy policy, terms of service, and the current deployment version in responsive in-app dialogs. Production version details include links and merge dates for the ten most recent frontend pull requests.
- Provides clear footer access to email contact, the privacy policy, terms of service, and the current deployment version in responsive in-app dialogs. The legal dialogs explain today's local-only storage and briefly disclose the planned optional paid premium cloud-storage features that will receive expanded terms and privacy details before launch. Production version details include links and merge dates for the ten most recent frontend pull requests.
- Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions. Saved and in-progress sessions use browser-managed IndexedDB storage, and active rides are checkpointed in small sample chunks so recovery does not repeatedly rewrite the complete history. Existing localStorage recovery data is migrated once and removed only after IndexedDB has accepted it. Saved sessions support an optional 500-character description with a live character count plus ride feeling, and persistent browser storage is requested when supported.
- Opens saved rides from the dashboard's Sessions button in a slide-out tray with Calendar, List, and Statistics views. The month calendar marks every day with rides and makes each event directly selectable, while the virtualized chronological list retains paginated loading for very large histories. Statistics are updated transactionally whenever a session is saved, replaced, imported, or deleted, then read from compact IndexedDB rollups instead of rescanning telemetry. All-time totals cover rides, distance, time, climbing, downhill, calories, speed, power, cadence, and heart rate; their responsive cards use at most three columns and always show complete numeric values instead of truncating them. The statistics view also graphs the same canonical profile-weight history shown in Profile, with values converted into the selected display unit. Personal-best cards open their source sessions, and dedicated weekly, monthly, and yearly graphs show distance, time, elevation, calories, ride count, average speed, power, cadence, and heart rate. Detailed session metrics and charts, clear date ranges for rides that span midnight, keyboard navigation with grouped shortcut help, and permanent deletion remain available. The tray remembers its active view, selected session, list scroll position, and each session's independent detail-pane scroll position after a page reload.
- Downloads saved rides as standards-compliant FIT activities for direct upload to Strava and other fitness services, including indoor-cycling and creator metadata, UTC and local timestamps, distance, speed, power, cadence, estimated crank revolutions and work, heart rate, resistance, elevation, calories, and ride totals. Each FIT filename includes a stable session token for reliable upload identity. TCX export remains available for the richer Ride Control round trip, including virtual gear, terrain workout metadata, ride feeling, session description, and the original session identifier.
Expand Down
21 changes: 21 additions & 0 deletions src/components/legal-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ export function PrivacyPolicyDialog({ onClose, open }: { onClose: () => void; op
</p>
</section>

<section className={sectionClass}>
<h3 className={headingClass}>Future premium cloud storage</h3>
<p>
Ride Control plans to offer optional paid premium features for storing and
synchronizing profile and ride data in the cloud. These features are not
available today. We will expand this policy before they launch to explain what
data is uploaded, how it is protected, how long it is retained, and the choices
available to you.
</p>
</section>

<section className={sectionClass}>
<h3 className={headingClass}>Optional features and external services</h3>
<ul className="list-disc space-y-1 pl-5">
Expand Down Expand Up @@ -225,6 +236,16 @@ export function TermsOfServiceDialog({ onClose, open }: { onClose: () => void; o
</p>
</section>

<section className={sectionClass}>
<h3 className={headingClass}>Future premium services</h3>
<p>
Ride Control plans to offer optional paid premium features for storing and
synchronizing your data in the cloud. These services are not available today. We
will expand these terms before they launch with the applicable storage, pricing,
billing, cancellation, and data provisions.
</p>
</section>

<section className={sectionClass}>
<h3 className={headingClass}>Third-party services and content</h3>
<p>
Expand Down
4 changes: 2 additions & 2 deletions src/components/metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ export function SessionMetric({
<span className="text-slate-400 text-xs">{unit}</span>
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-[11px]">
<span className="font-bold text-slate-600 tracking-[.08em]">AVERAGE</span>
<span className="font-bold text-slate-600 tracking-[.08em]">AVG</span>
{maximum === undefined ? null : (
<span className="text-right text-slate-400">
<span className="pr-1 text-right text-slate-400">
<strong className="mr-1 font-bold text-slate-600 tracking-[.08em]">
MAX
</strong>
Expand Down
22 changes: 20 additions & 2 deletions src/components/session-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export function SessionChart({
route,
selectedChartMode,
speedUnit,
variant = 'dashboard',
}: {
controlMode?: ControlMode;
history: MetricSample[];
Expand All @@ -121,6 +122,7 @@ export function SessionChart({
route: readonly RoutePoint[];
selectedChartMode?: ChartMode;
speedUnit: SpeedUnit;
variant?: 'dashboard' | 'session';
}) {
const preferredChartMode = useSelector(
preferencesStore,
Expand Down Expand Up @@ -265,6 +267,13 @@ export function SessionChart({
const historyStart = chartHistory.at(0)?.elapsedSeconds ?? 0;
const historySeconds =
chartHistory.length > 1 ? (chartHistory.at(-1)?.elapsedSeconds ?? 0) - historyStart : 0;
const sessionControls = variant === 'session';
const controlsClassName = sessionControls
? 'grid w-full gap-1 rounded-lg bg-[#0d1217] p-1'
: 'scrollbar-hidden flex w-full gap-1 overflow-x-auto rounded-lg bg-[#0d1217] p-1';
const controlClassName = sessionControls
? 'inline-flex min-w-0 w-full items-center justify-center gap-1 whitespace-nowrap rounded-md px-1 py-2 font-semibold text-[9px] transition sm:text-[11px] xl:text-[13px]'
: 'inline-flex min-w-max flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-1.5 py-2 font-semibold text-[11px] transition sm:text-[13px]';

const selectMode = useCallback(
(mode: ChartMode) => (onSelectChartMode ?? preferencesStore.actions.selectChartMode)(mode),
Expand Down Expand Up @@ -302,10 +311,19 @@ export function SessionChart({

return (
<div className="mt-4 min-w-0 overflow-hidden rounded-xl border border-line bg-[#12171d] p-2 sm:mt-6 sm:p-4">
<div className="scrollbar-hidden flex w-full gap-1 overflow-x-auto rounded-lg bg-[#0d1217] p-1">
<div
className={controlsClassName}
style={
sessionControls
? {
gridTemplateColumns: `repeat(${availableModes.length}, minmax(0, 1fr))`,
}
: undefined
}
>
{availableModes.map((mode) => (
<button
className={`inline-flex min-w-max flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-1.5 py-2 font-semibold text-[11px] transition sm:text-[13px] ${effectiveMode === mode.value ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-200'}`}
className={`${controlClassName} ${effectiveMode === mode.value ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-200'}`}
key={mode.value}
onClick={() => selectMode(mode.value)}
type="button"
Expand Down
Loading