From 721c641a94d3942a9965ff2bbe65a04c85193de6 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sat, 18 Jul 2026 20:08:49 -0700 Subject: [PATCH 1/3] feat: clarify overnight session history --- README.md | 2 +- src/components/session-history.tsx | 9 +++-- src/lib/saved-sessions.ts | 50 ++++++++++++++++++++++----- tests/components.test.tsx | 29 ++++++++++++++++ tests/saved-sessions.test.ts | 54 ++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c566ab5..4822fb1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,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. - Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and resistance, with focused and combined chart views. - Saves completed sessions to browser-managed IndexedDB storage, including optional comments and how the ride felt, and requests persistent browser storage when supported. -- 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. +- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, 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 recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session. diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index 27b1dde..4b7076e 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -11,7 +11,8 @@ import { countSavedSessions, deleteSavedSession, feelingLabel, - formatSessionTime, + formatSessionDateRange, + formatSessionListTime, formatSessionTimeRange, getSavedSession, groupSessionsByDate, @@ -127,9 +128,7 @@ export function SessionDetail({

- {new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format( - session.startedAt - )} + {formatSessionDateRange(session)}

{formatSessionTimeRange(session)}

@@ -562,7 +561,7 @@ export function SessionHistory({ >
- {formatSessionTime(session.startedAt)} + {formatSessionListTime(session)} {formatDuration(session.elapsedSeconds)} diff --git a/src/lib/saved-sessions.ts b/src/lib/saved-sessions.ts index 2a7b481..23c5b8d 100644 --- a/src/lib/saved-sessions.ts +++ b/src/lib/saved-sessions.ts @@ -13,6 +13,9 @@ const SESSION_STORE = 'sessions'; const SUMMARY_STORE = 'session-summaries'; const ENDED_AT_INDEX = 'endedAt'; const MERIDIEM_SUFFIX = /\s*(AM|PM)$/i; +const SESSION_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }); + +type SessionTiming = Pick; let databasePromise: Promise | undefined; @@ -194,18 +197,45 @@ export interface SessionGroup { sessions: SavedSessionSummary[]; } +function localDateKey(timestamp: number): string { + const date = new Date(timestamp); + return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; +} + +function hasRecordedSessionEnd(session: SessionTiming): boolean { + return session.elapsedSeconds > 0 && session.endedAt > session.startedAt; +} + +function sessionSpansDates(session: SessionTiming): boolean { + return ( + hasRecordedSessionEnd(session) && + localDateKey(session.startedAt) !== localDateKey(session.endedAt) + ); +} + +export function formatSessionDateRange(session: SessionTiming): string { + const started = new Date(session.startedAt); + if (!sessionSpansDates(session)) { + return SESSION_DATE_FORMATTER.format(started); + } + return SESSION_DATE_FORMATTER.formatRange(started, new Date(session.endedAt)); +} + +function sessionDateGroupKey(session: SessionTiming): string { + const started = localDateKey(session.startedAt); + return sessionSpansDates(session) ? `${started}/${localDateKey(session.endedAt)}` : started; +} + export function groupSessionsByDate(sessions: SavedSessionSummary[]): SessionGroup[] { const groups = new Map(); - const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }); for (const session of sessions) { - const date = new Date(session.startedAt); - const key = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + const key = sessionDateGroupKey(session); const existing = groups.get(key); if (existing) { existing.sessions.push(session); } else { groups.set(key, { - date: dateFormatter.format(date), + date: formatSessionDateRange(session), key, sessions: [session], }); @@ -251,16 +281,20 @@ export function formatSessionTime(timestamp: number): string { .replace(MERIDIEM_SUFFIX, (suffix) => suffix.trim().toLowerCase()); } -export function formatSessionTimeRange( - session: Pick -): string { +export function formatSessionTimeRange(session: SessionTiming): string { const started = formatSessionTime(session.startedAt); - if (!(session.elapsedSeconds > 0 && session.endedAt > session.startedAt)) { + if (!hasRecordedSessionEnd(session)) { return started; } return `${started} – ${formatSessionTime(session.endedAt)}`; } +export function formatSessionListTime(session: SessionTiming): string { + return sessionSpansDates(session) + ? formatSessionTimeRange(session) + : formatSessionTime(session.startedAt); +} + export async function requestPersistentSessionStorage(): Promise { if (!navigator.storage?.persist) { return false; diff --git a/tests/components.test.tsx b/tests/components.test.tsx index e0757e5..a2ca993 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -393,6 +393,35 @@ describe('view components', () => { ).toBe(''); }); + test('renders an overnight session with date and time ranges', () => { + const startedAt = new Date(2026, 6, 18, 23).getTime(); + const endedAt = new Date(2026, 6, 19, 1).getTime(); + const html = render( + + ); + expect(html).toContain( + new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).formatRange( + new Date(startedAt), + new Date(endedAt) + ) + ); + expect(html).toContain('11:00pm – 1:00am'); + }); + test('styles an unrecorded feeling like the comments value', () => { const html = render( { expect(groups[1]?.sessions[0]?.id).toBe('three'); }); + test('groups overnight sessions by their complete local date range', () => { + const overnightStart = new Date(2026, 6, 18, 23).getTime(); + const overnightEnd = new Date(2026, 6, 19, 1).getTime(); + const daytimeStart = new Date(2026, 6, 18, 9).getTime(); + const summaries = [ + { + calories: 0, + distance: 0, + elapsedSeconds: 7200, + endedAt: overnightEnd, + id: 'overnight', + startedAt: overnightStart, + }, + { + calories: 0, + distance: 0, + elapsedSeconds: 3600, + endedAt: daytimeStart + 3_600_000, + id: 'daytime', + startedAt: daytimeStart, + }, + ] satisfies SavedSessionSummary[]; + const groups = groupSessionsByDate(summaries); + expect(groups).toHaveLength(2); + expect(groups[0]).toMatchObject({ + date: new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).formatRange( + new Date(overnightStart), + new Date(overnightEnd) + ), + key: '2026-7-18/2026-7-19', + }); + expect(groups[0]?.sessions[0]?.id).toBe('overnight'); + expect(groups[1]?.sessions[0]?.id).toBe('daytime'); + }); + test('selects the nearest remaining session after deletion', () => { const sessions = ['one', 'two', 'three'].map( (id, index) => @@ -204,6 +241,23 @@ describe('saved session utilities', () => { expect(formatSessionTimeRange({ ...snapshot, endedAt: snapshot.startedAt })).toBe('8:30am'); }); + test('shows date and time ranges for overnight sessions', () => { + const overnight = { + elapsedSeconds: 7200, + endedAt: new Date(2026, 6, 19, 1).getTime(), + startedAt: new Date(2026, 6, 18, 23).getTime(), + }; + const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }); + expect(formatSessionDateRange(overnight)).toBe( + dateFormatter.formatRange(new Date(overnight.startedAt), new Date(overnight.endedAt)) + ); + expect(formatSessionListTime(overnight)).toBe('11:00pm – 1:00am'); + expect(formatSessionListTime(snapshot)).toBe('8:30am'); + expect(formatSessionDateRange({ ...overnight, elapsedSeconds: 0 })).toBe( + dateFormatter.format(overnight.startedAt) + ); + }); + test('requests persistent storage only when needed and supported', async () => { const original = Object.getOwnPropertyDescriptor(navigator, 'storage'); try { From ff91197b7e6a8b0976155cf4aecff9a33de94d71 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sat, 18 Jul 2026 20:44:52 -0700 Subject: [PATCH 2/3] feat: improve live ride control feedback --- README.md | 2 +- src/app.tsx | 12 ++- src/components/metrics.tsx | 24 +++--- src/components/resistance-control.tsx | 75 +++++++++++++++++- src/hooks/use-trainer.ts | 110 +++++++++++++++++++++++--- src/lib/resistance.ts | 32 ++++++++ src/style.css | 63 +++++++++++++++ src/types.ts | 11 +++ tests/components.test.tsx | 47 +++++++++++ tests/resistance.test.ts | 36 +++++++++ 10 files changed, 384 insertions(+), 28 deletions(-) create mode 100644 src/lib/resistance.ts create mode 100644 tests/resistance.test.ts diff --git a/README.md b/README.md index 4822fb1..ab3f698 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo Kickr Core 2 - 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. +- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics. - 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. - Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and resistance, with focused and combined chart views. - Saves completed sessions to browser-managed IndexedDB storage, including optional comments and how the ride felt, and requests persistent browser storage when supported. diff --git a/src/app.tsx b/src/app.tsx index 4e8973a..40c5c44 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -454,16 +454,20 @@ export function App() {

Resistance control

- - {trainer.resistance} - % - +
+ + {trainer.resistance} + % + +
diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index 3ad117c..cd1491b 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -59,15 +59,21 @@ export function Metric({ {value} {unit}
-
- - AVG - {average} {unit} - - - MAX - {maximum} {unit} - +
+
+

AVG

+

+ {average} + {unit} +

+
+
+

MAX

+

+ {maximum} + {unit} +

+
diff --git a/src/components/resistance-control.tsx b/src/components/resistance-control.tsx index 83b0f01..4b26899 100644 --- a/src/components/resistance-control.tsx +++ b/src/components/resistance-control.tsx @@ -1,3 +1,6 @@ +import { type CSSProperties, useEffect, useRef, useState } from 'react'; +import { resistanceAdjustmentDirection } from '../lib/resistance'; +import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; import { Icon } from './icon'; export function ResistanceControl({ @@ -7,6 +10,8 @@ export function ResistanceControl({ step, onChange, disabled, + keyboardFlash, + ramp, }: { value: number; min: number; @@ -14,13 +19,64 @@ export function ResistanceControl({ step: number; onChange: (value: number) => void; disabled: boolean; + keyboardFlash?: ResistanceAdjustmentDirection; + ramp: ResistanceRamp; }) { + const [sliderFlash, setSliderFlash] = useState(); + const sliderDragging = useRef(false); + const sliderFlashTimer = useRef(undefined); + const sliderValue = useRef(value); + const rampProgress = ramp.phase === 'settled' ? 1 : Math.max(0, Math.min(1, ramp.progress)); + const rampProgressPercent = Math.round(rampProgress * 100); + const sliderPosition = + max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) * 100 : 0; + const sliderStyle = { + '--ramp-progress': `${rampProgress * 360}deg`, + '--resistance-position': `${sliderPosition}%`, + } as CSSProperties; + const buttonClass = + 'grid h-9 w-9 place-items-center rounded-lg border border-line text-slate-300 transition duration-150 hover:border-mint disabled:opacity-40'; + const keyboardFlashClass = + 'scale-105 border-mint bg-mint/15 text-mint shadow-[0_0_14px_rgba(190,242,100,.4)]'; + const activeFlash = keyboardFlash ?? sliderFlash; + + useEffect(() => { + sliderValue.current = value; + }, [value]); + + useEffect( + () => () => { + window.clearTimeout(sliderFlashTimer.current); + }, + [] + ); + + const clearSliderFlash = () => { + sliderDragging.current = false; + window.clearTimeout(sliderFlashTimer.current); + sliderFlashTimer.current = window.setTimeout(() => setSliderFlash(undefined), 180); + }; + + const handleSliderChange = (next: number) => { + const direction = resistanceAdjustmentDirection(sliderValue.current, next); + sliderValue.current = next; + if (direction) { + window.clearTimeout(sliderFlashTimer.current); + setSliderFlash(direction); + if (!sliderDragging.current) { + sliderFlashTimer.current = window.setTimeout(() => setSliderFlash(undefined), 180); + } + } + onChange(next); + }; + return (
onChange(Number(event.target.value))} + onBlur={clearSliderFlash} + onChange={(event) => handleSliderChange(Number(event.target.value))} + onPointerCancel={clearSliderFlash} + onPointerDown={() => { + sliderDragging.current = true; + }} + onPointerUp={clearSliderFlash} step={step} + style={sliderStyle} type="range" value={value} /> - setDevicesOpen(true)} + pairedCount={pairedDeviceCount} />
@@ -406,7 +469,7 @@ export function App() { label="POWER" maximum={String(Math.round(session.maximums.power))} unit="watts" - value={String(trainer.metrics.power)} + value={String(liveMetrics.power)} /> @@ -445,32 +508,46 @@ export function App() { />
-

Resistance control

+

+ {click.paired ? 'Virtual shifting' : 'Resistance control'} +

- {trainer.resistance} - % + {click.paired ? gearControl.gear : trainer.resistance} + + {click.paired ? 'of 24' : '%'} +
- + {click.paired ? ( + + ) : ( + + )}
@@ -522,7 +599,41 @@ export function App() { open={historyOpen} speedUnit={speedUnit} /> - + setDevicesOpen(false)} + open={devicesOpen} + trainer={{ + busy: trainer.connectionBusy, + connected: trainer.connected, + name: trainer.pairedDeviceName, + onDisconnect: trainer.disconnect, + onForget: trainer.forget, + onPair: trainer.connect, + onReconnect: trainer.reconnect, + paired: trainer.paired, + status: trainer.status, + }} + /> + ); diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx new file mode 100644 index 0000000..228ecdb --- /dev/null +++ b/src/components/device-pairing.tsx @@ -0,0 +1,354 @@ +import { useEffect } from 'react'; +import { Icon } from './icon'; + +interface DeviceSlot { + allowRetryWhileBusy?: boolean; + battery?: number; + busy: boolean; + connected: boolean; + name?: string; + onDisconnect: () => void; + onForget: () => void | Promise; + onPair: () => void | Promise; + onReconnect: () => void | Promise; + paired: boolean; + status: string; +} + +interface ClickController { + active: boolean; + connected: boolean; + connecting: boolean; + id: string; + label: string; +} + +interface ClickSlot extends DeviceSlot { + connectedCount: number; + controllers: ClickController[]; + onForgetController: (deviceId: string) => void | Promise; + pairedCount: number; + pairing: boolean; +} + +function clickControllerOrder(controller: ClickController) { + if (controller.label.startsWith('+')) { + return 0; + } + if (controller.label.startsWith('−')) { + return 1; + } + return 2; +} + +function StatusDot({ connected, busy }: { connected: boolean; busy: boolean }) { + let statusClass = 'bg-slate-600'; + if (busy) { + statusClass = 'animate-pulse bg-yellow-300'; + } else if (connected) { + statusClass = 'bg-mint shadow-[0_0_10px_rgba(173,245,189,.55)]'; + } + return ( +