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 @@ -31,7 +31,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- 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.
- 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 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. Active rides are checkpointed locally and restored after a page reload; the restored dashboard explains that ride data remains safe while Bluetooth devices may need time to reconnect before riding continues, then automatically removes that notice once the trainer and every other paired ride device are connected again.
- Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. Linked course sessions expose Previous and Next controls for moving through the continuation path, plus a full-journey view that combines every part on that path without mixing in alternate branches. Active rides are checkpointed locally and restored after a page reload; the restored dashboard explains that ride data remains safe while Bluetooth devices may need time to reconnect before riding continues, then automatically removes that notice once the trainer and every other paired ride device are connected again.
- 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.
- Displays connection and application notices with a visible 15-second countdown and automatic dismissal.
Expand Down
36 changes: 33 additions & 3 deletions src/components/session-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,17 +139,44 @@ export function DeleteSessionDialog({
function JourneyMetricScope({
journey,
onChange,
onSelectSession,
showCombined,
}: {
journey: CombinedSessionJourney;
onChange: (showCombined: boolean) => void;
onSelectSession?: (sessionId: string) => void;
showCombined: boolean;
}) {
const selectSession = (sessionId: string | undefined) => {
if (sessionId) {
onSelectSession?.(sessionId);
}
};
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-line border-y py-2">
<p className="text-slate-400 text-xs">
Course journey · Part {journey.partNumber} of {journey.partCount}
</p>
<div className="flex flex-wrap items-center gap-2">
<p className="text-slate-400 text-xs">
Course journey · Part {journey.partNumber} of {journey.partCount}
</p>
<div className="inline-flex border border-line">
<button
className="px-2.5 py-1 font-semibold text-slate-400 text-xs hover:text-white disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:text-slate-400"
disabled={!(journey.previousSessionId && onSelectSession)}
onClick={() => selectSession(journey.previousSessionId)}
type="button"
>
Previous
</button>
<button
className="border-line border-l px-2.5 py-1 font-semibold text-slate-400 text-xs hover:text-white disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:text-slate-400"
disabled={!(journey.nextSessionId && onSelectSession)}
onClick={() => selectSession(journey.nextSessionId)}
type="button"
>
Next
</button>
</div>
</div>
<fieldset className="flex items-center border border-line">
<legend className="sr-only">Session metric scope</legend>
<button
Expand Down Expand Up @@ -186,6 +213,7 @@ export function SessionDetail({
onConfirmDelete,
onDelete,
onSelectChartMode,
onSelectLinkedSession,
onStartNew,
selectedChartMode,
session,
Expand All @@ -199,6 +227,7 @@ export function SessionDetail({
onConfirmDelete?: () => void;
onDelete?: () => void;
onSelectChartMode?: (mode: ChartMode) => void;
onSelectLinkedSession?: (sessionId: string) => void;
onStartNew?: () => void;
selectedChartMode?: ChartMode;
session: SavedSession;
Expand Down Expand Up @@ -407,6 +436,7 @@ export function SessionDetail({
<JourneyMetricScope
journey={combinedJourney}
onChange={setShowCombinedJourney}
onSelectSession={onSelectLinkedSession}
showCombined={showCombinedJourney}
/>
) : null}
Expand Down
1 change: 1 addition & 0 deletions src/components/session-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export function SessionHistory({
onConfirmDelete={() => deleteSelectedSession()}
onDelete={() => setDeleteConfirmationOpen(true)}
onSelectChartMode={setSelectedChartMode}
onSelectLinkedSession={selectSession}
onStartNew={() => onStartNew(selected)}
selectedChartMode={selectedChartMode}
session={selected}
Expand Down
81 changes: 60 additions & 21 deletions src/lib/session-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import { nonNegativeNumber } from './numbers';
import { isFiniteNumber, isRecord, isString } from './type-guards';

export interface CombinedSessionJourney {
nextSessionId?: string;
partCount: number;
partNumber: number;
previousSessionId?: string;
session: SavedSession;
}

Expand Down Expand Up @@ -95,6 +97,38 @@ function ancestryForSession(
return ancestry;
}

function journeyPathForSession(
sessions: readonly SavedSession[],
selected: SavedSession
): SavedSession[] {
const path = ancestryForSession(sessions, selected);
const visited = new Set(path.map((session) => session.id));
let journeyId = selected.id;
if (selected.continuation) {
({ journeyId } = selected.continuation);
}
let current = selected;
const nextSession = () => {
const [next] = sessions
.filter(
(session) =>
!visited.has(session.id) &&
session.continuation?.journeyId === journeyId &&
session.continuation.previousSessionId === current.id
)
.sort((left, right) => left.startedAt - right.startedAt);
return next;
};
let next = nextSession();
while (next) {
path.push(next);
visited.add(next.id);
current = next;
next = nextSession();
}
return path;
}

export function combineSessionJourney(
sessions: readonly SavedSession[],
selectedId: string
Expand All @@ -103,44 +137,47 @@ export function combineSessionJourney(
if (!selected) {
return;
}
const ancestry = ancestryForSession(sessions, selected).sort(
(left, right) => left.startedAt - right.startedAt
);
if (ancestry.length < 2) {
const journeyPath = journeyPathForSession(sessions, selected);
if (journeyPath.length < 2) {
return;
}
let elapsedOffset = 0;
const history = ancestry.flatMap((session) => {
const history = journeyPath.flatMap((session) => {
const shifted = session.history.map((sample) => ({
...sample,
elapsedSeconds: elapsedOffset + sample.elapsedSeconds,
}));
elapsedOffset += session.elapsedSeconds;
return shifted;
});
const first = ancestry[0] ?? selected;
const last = ancestry.at(-1) ?? selected;
const first = journeyPath[0] ?? selected;
const last = journeyPath.at(-1) ?? selected;
const selectedIndex = journeyPath.findIndex((session) => session.id === selected.id);
const maximumKeys = ['cadence', 'heartRate', 'power', 'speed'] as const;
const combined: SavedSession = {
...selected,
aggregates: {
cadence: combinedAggregate(ancestry.map((session) => session.aggregates.cadence)),
gear: combinedAggregate(ancestry.map((session) => session.aggregates.gear)),
heartRate: combinedAggregate(ancestry.map((session) => session.aggregates.heartRate)),
power: combinedAggregate(ancestry.map((session) => session.aggregates.power)),
resistance: combinedAggregate(ancestry.map((session) => session.aggregates.resistance)),
cadence: combinedAggregate(journeyPath.map((session) => session.aggregates.cadence)),
gear: combinedAggregate(journeyPath.map((session) => session.aggregates.gear)),
heartRate: combinedAggregate(
journeyPath.map((session) => session.aggregates.heartRate)
),
power: combinedAggregate(journeyPath.map((session) => session.aggregates.power)),
resistance: combinedAggregate(
journeyPath.map((session) => session.aggregates.resistance)
),
},
calories: ancestry.reduce((sum, session) => sum + session.calories, 0),
calories: journeyPath.reduce((sum, session) => sum + session.calories, 0),
comments: '',
continuation: undefined,
controlMode: ancestry.some((session) => session.controlMode === CONTROL_MODE.GEAR)
controlMode: journeyPath.some((session) => session.controlMode === CONTROL_MODE.GEAR)
? CONTROL_MODE.GEAR
: CONTROL_MODE.RESISTANCE,
distance: ancestry.reduce((sum, session) => sum + session.distance, 0),
elapsedSeconds: ancestry.reduce((sum, session) => sum + session.elapsedSeconds, 0),
distance: journeyPath.reduce((sum, session) => sum + session.distance, 0),
elapsedSeconds: journeyPath.reduce((sum, session) => sum + session.elapsedSeconds, 0),
elevationTotals: {
ascent: ancestry.reduce((sum, session) => sum + session.elevationTotals.ascent, 0),
descent: ancestry.reduce((sum, session) => sum + session.elevationTotals.descent, 0),
ascent: journeyPath.reduce((sum, session) => sum + session.elevationTotals.ascent, 0),
descent: journeyPath.reduce((sum, session) => sum + session.elevationTotals.descent, 0),
},
endedAt: last.endedAt,
feeling: undefined,
Expand All @@ -152,7 +189,7 @@ export function combineSessionJourney(
...Object.fromEntries(
maximumKeys.map((key) => [
key,
Math.max(...ancestry.map((session) => session.maximums[key])),
Math.max(...journeyPath.map((session) => session.maximums[key])),
])
),
},
Expand All @@ -161,8 +198,10 @@ export function combineSessionJourney(
workout: last.workout ?? first.workout,
};
return {
partCount: ancestry.length,
partNumber: ancestry.findIndex((session) => session.id === selected.id) + 1,
nextSessionId: journeyPath[selectedIndex + 1]?.id,
partCount: journeyPath.length,
partNumber: selectedIndex + 1,
previousSessionId: journeyPath[selectedIndex - 1]?.id,
session: combined,
};
}
5 changes: 5 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,11 @@ html[data-theme="light"] .session-chart[data-variant="session"] {
gap: 0.75rem;
}

.workout-distance-laps-stats > :first-child > :last-child {
font-size: clamp(1.5rem, 8cqi, 2.25rem);
letter-spacing: -0.025em;
}

.workout-state-stats {
gap: 0.5rem;
}
Expand Down
5 changes: 5 additions & 0 deletions tests/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2327,8 +2327,10 @@ describe('view components', () => {
const html = render(
<SessionDetail
combinedJourney={{
nextSessionId: undefined,
partCount: 2,
partNumber: 2,
previousSessionId: savedSessionFixture.id,
session: {
...savedSessionFixture,
calories: 500,
Expand All @@ -2337,6 +2339,7 @@ describe('view components', () => {
id: 'journey:saved-session',
},
}}
onSelectLinkedSession={() => undefined}
session={{
...savedSessionFixture,
continuation: {
Expand All @@ -2354,6 +2357,8 @@ describe('view components', () => {
expect(html).toContain('aria-pressed="false"');
expect(html).toContain('This session');
expect(html).toContain('Full journey');
expect(html).toContain('>Previous</button>');
expect(html).toContain('disabled="" type="button">Next</button>');
});

test('shows the rider weight captured with the session in the selected units', () => {
Expand Down
33 changes: 31 additions & 2 deletions tests/session-continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe('linked session continuations', () => {
).toBe(15.5);
});

test('combines only the selected ancestry into one complete journey view', () => {
test('combines and navigates the complete continuation path without crossing branches', () => {
const second = linkedSession({
distance: 2,
elapsedSeconds: 3,
Expand Down Expand Up @@ -109,7 +109,11 @@ describe('linked session continuations', () => {
if (!journey) {
throw new Error('Expected a combined journey');
}
expect(journey).toMatchObject({ partCount: 3, partNumber: 3 });
expect(journey).toMatchObject({
partCount: 3,
partNumber: 3,
previousSessionId: second.id,
});
expect(journey.session).toMatchObject({
calories: savedSessionFixture.calories + 200 + 300,
distance: 6.5,
Expand All @@ -125,5 +129,30 @@ describe('linked session continuations', () => {
expect(journey.session.history.map((sample) => sample.elapsedSeconds)).toEqual([
1, 2, 5, 9,
]);

const middleJourney = combineSessionJourney(
[branch, third, savedSessionFixture, second],
second.id
);
expect(middleJourney).toMatchObject({
nextSessionId: third.id,
partCount: 3,
partNumber: 2,
previousSessionId: savedSessionFixture.id,
session: { distance: 6.5, elapsedSeconds: 9 },
});

const branchedJourney = combineSessionJourney(
[branch, third, savedSessionFixture, second],
branch.id
);
expect(branchedJourney).toMatchObject({
partCount: 2,
partNumber: 2,
previousSessionId: savedSessionFixture.id,
session: {
distance: savedSessionFixture.distance + branch.distance,
},
});
});
});