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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- 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. 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.
- 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. Saving an ended session immediately opens the Sessions drawer with that new ride selected, while save-and-start flows continue directly into the next ride. 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, yearly, and complete-history graphs show distance, time, elevation, calories, ride count, average speed, power, cadence, and heart rate. Trends remembers both the selected chart metric and timeframe. 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.
- Creates an on-demand 1200×630 workout card for sharing on X from a stable, stateless RideControl.xyz link containing selected summary stats, a compact route map and elevation preview, and accurate personal-best callouts. Public GPX workouts link back to their exact RideControl route. Cloudflare regenerates an evicted image from the link and serves it with immutable cache headers; no share data is stored in KV or R2. Sharing is explicit and does not publish raw ride samples, comments, or profile details.
Expand Down
23 changes: 21 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ import { activeRiderPhysicsProfile, type RiderPhysicsProfile } from './lib/profi
import type { ProfileTab } from './lib/profile-tab';
import { sessionHasRecordedData, sessionNeedsUnloadWarning } from './lib/session';
import { createSessionDeviceReconnectController } from './lib/session-device-reconnect';
import { loadSessionHistoryView, type SessionHistoryView } from './lib/session-history-view';
import {
loadSessionHistoryView,
SESSION_HISTORY_VIEW,
type SessionHistoryView,
} from './lib/session-history-view';
import { requestUnloadConfirmation } from './lib/unload';
import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome';
import {
Expand Down Expand Up @@ -559,7 +563,22 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
onRestoreResistance: trainer.restoreManualResistance,
resistance: workoutResistance,
});
const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide);
const openEndedSession = useCallback(
(sessionId: string) => {
navigateToAppRoute({
historyView: SESSION_HISTORY_VIEW.LIST,
kind: APP_ROUTE_KIND.SESSION,
sessionId,
});
},
[navigateToAppRoute]
);
const workflow = useSessionWorkflow(
session,
trainer.setNotice,
trainer.settleAfterRide,
openEndedSession
);
const workoutLocked = workoutSelectionLocked(session);
const clickConnectionActive = clickConnectionActiveForSession(session);
useEffect(() => {
Expand Down
9 changes: 8 additions & 1 deletion src/hooks/use-session-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ import {
SESSION_WORKFLOW_PHASE,
type SessionWorkflowController,
type SessionWorkflowIntent,
sessionHistorySelectionAfterSave,
} from '../lib/session-workflow';
import { createSessionWorkflowStore } from '../stores/session-workflow-store';
import type { SavedSession, SessionMetadata, SessionSnapshot } from '../types';

export function useSessionWorkflow(
session: SessionWorkflowController,
setNotice: (notice: string) => void,
settleTrainerResistance: () => void
settleTrainerResistance: () => void,
onEndedSessionSaved: (sessionId: string) => void
) {
const sessionIsResolved = Boolean(session.savedSessionId) || session.discarded;
const storeRef = useRef<ReturnType<typeof createSessionWorkflowStore> | undefined>(undefined);
Expand Down Expand Up @@ -77,6 +79,7 @@ export function useSessionWorkflow(

const completeIntent = useCallback(
(intent: SessionWorkflowIntent, savedSession?: SavedSession) => {
const historySelection = sessionHistorySelectionAfterSave(intent, savedSession);
switch (intent.kind) {
case SESSION_WORKFLOW_INTENT.EXTEND:
session.extendFrom(intent.session, intent.session.id);
Expand Down Expand Up @@ -111,8 +114,12 @@ export function useSessionWorkflow(
unreachable(intent);
}
store.actions.close();
if (historySelection) {
onEndedSessionSaved(historySelection);
}
},
[
onEndedSessionSaved,
session.extendFrom,
session.markDiscarded,
session.savedSessionId,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/session-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export type SessionWorkflowIntent =
| { kind: typeof SESSION_WORKFLOW_INTENT.NEW }
| { kind: typeof SESSION_WORKFLOW_INTENT.EXTEND; session: SavedSession };

export function sessionHistorySelectionAfterSave(
intent: SessionWorkflowIntent,
savedSession?: SavedSession
): string | undefined {
return intent.kind === SESSION_WORKFLOW_INTENT.END ? savedSession?.id : undefined;
}

export type SessionWorkflowState =
| { phase: typeof SESSION_WORKFLOW_PHASE.CLOSED }
| {
Expand Down
17 changes: 17 additions & 0 deletions tests/session-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
finishRideSession,
SESSION_WORKFLOW_INTENT,
SESSION_WORKFLOW_PHASE,
sessionHistorySelectionAfterSave,
} from '../src/lib/session-workflow';
import {
createSessionWorkflowStore,
Expand Down Expand Up @@ -30,6 +31,22 @@ describe('session workflow store', () => {
});
});

test('selects a saved ended session without interrupting new or extended ride flows', () => {
const savedSession = { id: 'saved-session' } as SavedSession;
expect(
sessionHistorySelectionAfterSave({ kind: SESSION_WORKFLOW_INTENT.END }, savedSession)
).toBe(savedSession.id);
expect(
sessionHistorySelectionAfterSave({ kind: SESSION_WORKFLOW_INTENT.NEW }, savedSession)
).toBeUndefined();
expect(
sessionHistorySelectionAfterSave(
{ kind: SESSION_WORKFLOW_INTENT.EXTEND, session: savedSession },
savedSession
)
).toBeUndefined();
});

test('preserves the requested next session while saving', () => {
const session = { id: 'saved-session' } as SavedSession;
const store = createSessionWorkflowStore(false);
Expand Down