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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- 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.
- 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.
- 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; 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, comments, and the original session identifier.
- Imports individual FIT or TCX activities, or every supported activity inside nested folders in a mixed-format ZIP, directly into local session history. Compatible ride data is preserved, duplicates are detected across formats by identifier or stable activity data, and invalid files do not stop the rest of a batch; imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes.
- Downloads every locally saved ride at once as a compressed ZIP of individual FIT or TCX files, with FIT selected by default and collision-safe filenames when sessions share the same start time.
- Downloads every locally saved ride at once as a compressed ZIP of individual FIT or TCX files, with TCX selected by default, the rider's format choice remembered locally, and collision-safe filenames when sessions share the same start time.
- 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.
- Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions.
Expand Down Expand Up @@ -82,6 +82,12 @@ and elevation appear alongside the other session graphs for the full ride, and t
repeats on every loop or out-and-back trip while point-to-point routes stop at their finish; route
progress, selected gear, and applied resistance stay portable in saved sessions and TCX files;
standard ride metrics also stay portable through FIT files.
Saved-session writes also maintain a versioned IndexedDB analytics cache containing compact
per-session contributions plus daily, weekly, monthly, yearly, and all-time rollups. New sessions
update those totals incrementally; replacement and deletion subtract the prior contribution, and
only a removed personal-best holder requires a scan of the compact contribution store to select
the next peak. Existing saved rides are indexed and backfilled once when the analytics stores are
created, so opening the calendar or statistics view never walks complete telemetry histories.
Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation,
and repeated dialog and keyboard behavior so those rules stay consistent across views and exports.

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@types/react-dom": "^19.2.3",
"@types/web-bluetooth": "^0.0.21",
"@xmldom/xmldom": "^0.9.10",
"fake-indexeddb": "^6.2.5",
"typescript": "^7.0.2",
"ultracite": "^7.9.4",
"vite": "^8.1.5",
Expand Down
81 changes: 74 additions & 7 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { maximumGear, resistanceForVirtualGear } from './lib/gears';
import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard';
import { profileTotalMassKg, type RiderProfile } from './lib/profile';
import { sessionNeedsUnloadWarning } from './lib/session';
import { loadSessionHistoryView, type SessionHistoryView } from './lib/session-history-view';
import { requestUnloadConfirmation } from './lib/unload';
import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome';
import {
Expand Down Expand Up @@ -122,7 +123,10 @@ function restoredRoute(overlay: AppOverlay | undefined): AppRoute {
return { kind: APP_ROUTE_KIND.DEVICES };
}
if (overlay === APP_OVERLAY.HISTORY) {
return { kind: APP_ROUTE_KIND.SESSION };
return {
historyView: loadSessionHistoryView(),
kind: APP_ROUTE_KIND.SESSION,
};
}
if (overlay === APP_OVERLAY.WORKOUTS) {
if (loadBikeGpxBrowserOpen()) {
Expand Down Expand Up @@ -151,6 +155,24 @@ function initialNavigation(linkedRoute: AppRoute, pathname: string): InitialNavi
};
}

function sessionRouteSearch(
calendarMonth?: string,
historyView?: SessionHistoryView
): { date?: string; view?: SessionHistoryView } {
return {
...(calendarMonth ? { date: calendarMonth } : {}),
...(historyView ? { view: historyView } : {}),
};
}

function sessionRouteRequest(route: AppRoute): {
calendarMonth?: string;
historyView?: SessionHistoryView;
sessionId?: string;
} {
return route.kind === APP_ROUTE_KIND.SESSION ? route : {};
}

export function App({ initialSession = emptySession }: { initialSession?: StoredSession }) {
const pathname = useRouterState({
select: (state) => state.location.pathname,
Expand Down Expand Up @@ -201,10 +223,15 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
navigate({
params: { sessionId: route.sessionId },
replace,
search: sessionRouteSearch(route.calendarMonth, route.historyView),
to: APP_ROUTE_PATH.SESSION,
}).catch(() => undefined);
} else {
navigate({ replace, to: APP_ROUTE_PATH.SESSIONS }).catch(() => undefined);
navigate({
replace,
search: sessionRouteSearch(route.calendarMonth, route.historyView),
to: APP_ROUTE_PATH.SESSIONS,
}).catch(() => undefined);
}
return;
case APP_ROUTE_KIND.WORKOUT:
Expand All @@ -231,7 +258,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
return;
}
if (overlay === APP_OVERLAY.HISTORY) {
navigateToAppRoute({ kind: APP_ROUTE_KIND.SESSION });
navigateToAppRoute({
historyView: loadSessionHistoryView(),
kind: APP_ROUTE_KIND.SESSION,
});
return;
}
if (overlay === APP_OVERLAY.WORKOUTS) {
Expand Down Expand Up @@ -478,8 +508,11 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
const bikeGpxRouteId = bikeGpxBrowserOpen ? appRoute.routeId : undefined;
const focusedWorkoutId =
appRoute.kind === APP_ROUTE_KIND.WORKOUT ? appRoute.workoutId : undefined;
const requestedSessionId =
appRoute.kind === APP_ROUTE_KIND.SESSION ? appRoute.sessionId : undefined;
const {
calendarMonth: requestedSessionCalendarMonth,
historyView: requestedSessionHistoryView,
sessionId: requestedSessionId,
} = sessionRouteRequest(appRoute);
const focusWorkout = useCallback(
(courseId: string | undefined) => {
navigateToAppRoute({ kind: APP_ROUTE_KIND.WORKOUT, workoutId: courseId }, true);
Expand All @@ -500,9 +533,39 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
);
const selectHistorySession = useCallback(
(sessionId: string) => {
navigateToAppRoute({ kind: APP_ROUTE_KIND.SESSION, sessionId }, true);
navigateToAppRoute(
{
calendarMonth: requestedSessionCalendarMonth,
historyView: requestedSessionHistoryView,
kind: APP_ROUTE_KIND.SESSION,
sessionId,
},
true
);
},
[navigateToAppRoute]
[navigateToAppRoute, requestedSessionCalendarMonth, requestedSessionHistoryView]
);
const selectHistoryCalendarMonth = useCallback(
(calendarMonth: string) => {
navigateToAppRoute({
calendarMonth,
historyView: requestedSessionHistoryView,
kind: APP_ROUTE_KIND.SESSION,
sessionId: requestedSessionId,
});
},
[navigateToAppRoute, requestedSessionHistoryView, requestedSessionId]
);
const selectHistoryView = useCallback(
(historyView: SessionHistoryView) => {
navigateToAppRoute({
calendarMonth: requestedSessionCalendarMonth,
historyView,
kind: APP_ROUTE_KIND.SESSION,
sessionId: requestedSessionId,
});
},
[navigateToAppRoute, requestedSessionCalendarMonth, requestedSessionId]
);
useEffect(() => {
if (!selectedWorkoutCourse) {
Expand Down Expand Up @@ -637,10 +700,14 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
/>
<SessionHistory
onClose={() => setActiveOverlay(undefined)}
onSelectCalendarMonth={selectHistoryCalendarMonth}
onSelectSessionId={selectHistorySession}
onSelectView={selectHistoryView}
onStartNew={continueFromHistory}
open={activeOverlay === APP_OVERLAY.HISTORY}
requestedSessionId={requestedSessionId}
requestedSessionMonth={requestedSessionCalendarMonth}
requestedView={requestedSessionHistoryView}
speedUnit={speedUnit}
/>
<WorkoutPanel
Expand Down
Loading