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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever the `+` Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, and keyboard down/up arrows remain usable. The physical `+` button shifts up and its blue `Y` button shifts down. These controls step through the 24 physical combinations of 39/53-tooth chainrings and a 12-speed 12–24-tooth cassette, sorted by actual drivetrain ratio rather than equal percentage intervals. The prepared route grade produces one stable terrain target, then a calibrated load curve spreads the physical drivetrain ratios across a useful trainer range: gear 12 preserves the terrain target, gear 1 substantially unloads it for climbing, and gear 24 provides the full 53/12 top end. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer resistance.
- 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. 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.
- 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 optional comments and ride feeling, and persistent browser storage is requested when supported.
- Opens saved rides from the dashboard's Sessions button and organizes them by local date and time in a slide-out Sessions tray with a compact inline session count, clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. The tray restores the selected session, the session-list scroll position, and each session's independent detail-pane scroll position after a page reload, falling back to the newest available session when a remembered ride no longer exists.
- 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, comments, and the original session identifier.
Expand Down
2 changes: 1 addition & 1 deletion src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
].some(Boolean);

return (
<main className="min-h-screen bg-ink selection:bg-mint/30">
<main className="flex min-h-dvh min-w-0 flex-col overflow-x-clip bg-ink selection:bg-mint/30">
<Dashboard>
<DashboardToolbar>
<SessionControls
Expand Down
2 changes: 1 addition & 1 deletion src/components/app-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BUILD_PR_URL, BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from '../lib/

export function AppFooter({ onOpenWelcome }: { onOpenWelcome: () => void }) {
return (
<footer className="fixed right-4 bottom-3 left-4 z-20 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-slate-600">
<footer className="mx-auto flex w-full max-w-7xl flex-wrap items-center gap-x-1.5 gap-y-0.5 px-3 pt-2 pb-[max(0.75rem,env(safe-area-inset-bottom))] text-[11px] text-slate-600 sm:px-8">
<button
className="font-semibold tracking-wide transition hover:text-slate-400"
onClick={onOpenWelcome}
Expand Down
8 changes: 6 additions & 2 deletions src/components/dashboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Children, type ReactNode } from 'react';

export function Dashboard({ children }: { children: ReactNode }) {
return <div className="mx-auto max-w-7xl px-5 py-5 sm:px-8">{children}</div>;
return (
<div className="mx-auto w-full min-w-0 max-w-7xl flex-1 px-3 py-3 sm:px-8 sm:py-5">
{children}
</div>
);
}

export function DashboardToolbar({ children }: { children: ReactNode }) {
Expand All @@ -10,5 +14,5 @@ export function DashboardToolbar({ children }: { children: ReactNode }) {

export function DashboardWorkspace({ children }: { children: ReactNode }) {
const columns = Children.toArray(children).length > 1 ? 'xl:grid-cols-[1.45fr_.55fr]' : '';
return <section className={`mt-4 grid gap-4 ${columns}`}>{children}</section>;
return <section className={`mt-4 grid min-w-0 gap-4 *:min-w-0 ${columns}`}>{children}</section>;
}
8 changes: 4 additions & 4 deletions src/components/metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ export function SmallMetric({
value: string;
}) {
return (
<div className={large ? 'px-4 py-3 sm:px-5' : 'p-4 sm:p-5'}>
<div className={large ? 'min-w-0 px-3 py-3 sm:px-5' : 'min-w-0 p-4 sm:p-5'}>
<p className="font-bold text-[11px] text-slate-500 tracking-[.12em]">{label}</p>
<p
className={`mt-1 flex items-baseline gap-2 font-semibold tracking-tight ${large ? 'text-3xl sm:text-5xl' : 'text-lg sm:text-2xl'}`}
className={`mt-1 flex min-w-0 items-baseline gap-1.5 font-semibold tracking-tight sm:gap-2 ${large ? 'text-3xl sm:text-5xl min-[420px]:text-2xl' : 'text-lg sm:text-2xl'}`}
>
<span>{value}</span>
<span className="min-w-0">{value}</span>
{unit ? (
<span
className={`font-medium text-slate-400 tracking-normal ${large ? 'text-base sm:text-xl' : 'text-xs sm:text-sm'}`}
className={`shrink-0 font-medium text-slate-400 tracking-normal ${large ? 'text-sm sm:text-xl' : 'text-xs sm:text-sm'}`}
>
{unit}
</span>
Expand Down
20 changes: 12 additions & 8 deletions src/components/session-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ export function ChartPlot({
const labelPositions = [14, 52, 90];
return (
<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">
<div className="pointer-events-none relative h-full w-12 shrink-0 font-medium text-[9px] text-slate-400 sm:w-15 sm:text-[10px]">
{labels.map((label, index) => (
<span
className="absolute right-2 -translate-y-1/2 whitespace-nowrap"
className="absolute right-1 -translate-y-1/2 whitespace-nowrap sm:right-2"
key={label}
style={{ top: `${labelPositions[index]}%` }}
>
Expand Down Expand Up @@ -301,11 +301,11 @@ export function SessionChart({
}, [availableModes, effectiveMode, keyboardEnabled, selectMode]);

return (
<div className="mt-6 overflow-hidden rounded-xl border border-line bg-[#12171d] p-4">
<div className="flex w-full gap-1 overflow-x-auto rounded-lg bg-[#0d1217] p-1">
<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">
{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-[13px] transition ${effectiveMode === mode.value ? 'bg-slate-700 text-white' : 'text-slate-500 hover:text-slate-200'}`}
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'}`}
key={mode.value}
onClick={() => selectMode(mode.value)}
type="button"
Expand Down Expand Up @@ -336,7 +336,11 @@ export function SessionChart({
<ChartPlot
color={item.color}
decimals={item.decimals}
heightClass={effectiveMode === CHART_MODE.ALL ? 'h-24' : 'h-52'}
heightClass={
effectiveMode === CHART_MODE.ALL
? 'h-20 sm:h-24'
: 'h-40 sm:h-52'
}
maximum={item.chartMaximum}
minimum={item.minimum}
positions={historyPositions}
Expand All @@ -348,14 +352,14 @@ export function SessionChart({
index < visibleSeries.length - 1 ? (
<div
aria-hidden="true"
className="pointer-events-none relative -my-3 ml-15 h-6 bg-white/1.5"
className="pointer-events-none relative -my-3 ml-12 h-6 bg-white/1.5 sm:ml-15"
data-chart-separator="true"
/>
) : null}
</Fragment>
))}
</div>
<div className="mt-1 grid grid-cols-[3.75rem_minmax(0,1fr)] text-[10px] text-slate-500">
<div className="mt-1 grid grid-cols-[3rem_minmax(0,1fr)] text-[9px] text-slate-500 sm:grid-cols-[3.75rem_minmax(0,1fr)] sm:text-[10px]">
<span aria-hidden="true" />
<div className="flex justify-between">
{[0, 0.25, 0.5, 0.75, 1].map((position) => (
Expand Down
2 changes: 1 addition & 1 deletion src/components/session-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function SessionDetail({

return (
<div
className="min-w-0 flex-1 overflow-y-auto p-5 sm:p-6"
className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden p-3 sm:p-6"
data-testid="session-detail"
onScroll={detailScroll.onScroll}
ref={detailScroll.ref}
Expand Down
2 changes: 1 addition & 1 deletion src/components/session-history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function SessionHistoryList({

return (
<aside
className="max-h-64 shrink-0 overflow-y-auto border-line border-b bg-[#12171d] p-3 md:max-h-none md:w-80 md:border-r md:border-b-0"
className="max-h-64 min-w-0 shrink-0 overflow-y-auto overflow-x-hidden border-line border-b bg-[#12171d] p-3 md:max-h-none md:w-80 md:border-r md:border-b-0"
data-testid="session-list"
onScroll={sessionListScroll.onScroll}
ref={sessionListScroll.ref}
Expand Down
10 changes: 5 additions & 5 deletions src/components/session-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export function SessionHistory({
open={open}
panelClassName="flex max-w-6xl flex-col overflow-hidden sm:w-[min(72rem,calc(100vw-2rem))]"
>
<header className="flex flex-wrap items-center gap-x-4 gap-y-2 border-line border-b px-5 py-3">
<header className="relative flex flex-wrap items-center gap-x-4 gap-y-2 border-line border-b py-3 pr-24 pl-5 sm:px-5">
<div className="mr-auto flex min-w-0 items-center gap-2">
<h2 className="font-bold text-xl" id="session-history-title">
Sessions
Expand All @@ -232,7 +232,7 @@ export function SessionHistory({
) : null}
</p>
</div>
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center gap-1">
<input
accept=".fit,.tcx,.zip,application/vnd.ant.fit,application/vnd.garmin.tcx+xml,application/zip"
className="hidden"
Expand Down Expand Up @@ -286,7 +286,7 @@ export function SessionHistory({
</fieldset>
<button
aria-label="Show history keyboard controls"
className="grid h-9 w-9 place-items-center rounded-lg font-bold text-slate-400 text-sm hover:bg-slate-700 hover:text-white"
className="absolute top-3 right-14 grid h-9 w-9 place-items-center rounded-lg font-bold text-slate-400 text-sm hover:bg-slate-700 hover:text-white sm:static"
onClick={() => {
setDeleteConfirmationOpen(false);
setHistoryHelpOpen(true);
Expand All @@ -297,15 +297,15 @@ export function SessionHistory({
</button>
<button
aria-label="Close session history"
className="grid h-9 w-9 place-items-center rounded-lg text-slate-400 hover:bg-slate-700 hover:text-white"
className="absolute top-3 right-3 grid h-9 w-9 place-items-center rounded-lg text-slate-400 hover:bg-slate-700 hover:text-white sm:static"
onClick={onClose}
type="button"
>
×
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden md:flex-row">
<SessionHistoryList
error={error}
highlightedSessionIds={highlightedSessionIds}
Expand Down
4 changes: 2 additions & 2 deletions src/components/session-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export function SessionOverview({
workout?: SessionWorkout;
}) {
return (
<div className="rounded-2xl border border-line bg-panel p-4">
<div className="grid grid-cols-3 divide-x divide-line rounded-xl bg-[#12171d] ring-1 ring-slate-500 ring-inset">
<div className="min-w-0 rounded-2xl border border-line bg-panel p-3 sm:p-4">
<div className="grid min-w-0 grid-cols-1 divide-y divide-line rounded-xl bg-[#12171d] ring-1 ring-slate-500 ring-inset min-[420px]:grid-cols-3 min-[420px]:divide-x min-[420px]:divide-y-0">
<SessionSummary
calories={rideCalories}
distance={rideDistance}
Expand Down
2 changes: 1 addition & 1 deletion src/components/training-control-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function TrainingControlPanel({
value: number;
}) {
return (
<div className="self-start rounded-2xl border border-line bg-panel p-4 sm:p-5">
<div className="w-full min-w-0 self-start rounded-2xl border border-line bg-panel p-4 sm:p-5">
<div className="flex items-center justify-between gap-4">
<h2 className="font-bold text-lg">{title}</h2>
<div className="text-right">
Expand Down
6 changes: 6 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
}

@layer components {
.scrollbar-hidden {
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
.resistance-slider {
height: 0.375rem;
appearance: none;
Expand Down
Loading