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 @@ -32,7 +32,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- 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.
- 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, including complete route details in RideControl TCX files and saved-route identity and course position in RideControl FIT files; FIT routes reconnect automatically when the matching built-in or saved route is available. 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.
- Imports individual FIT or TCX activities, or every supported activity inside nested folders in a mixed-format ZIP, directly into local session history. Full exports can be restored without application-imposed file-count, per-activity-size, or total-archive-size ceilings. ZIPs are read incrementally and activities are parsed and saved sequentially rather than inflating the entire archive first; available browser memory and storage still determine practical capacity. Compatible ride data is preserved, including complete route details in RideControl TCX files and saved-route identity and course position in RideControl FIT files; FIT routes reconnect automatically when the matching built-in or saved route is available. Duplicates are detected across formats by identifier or stable activity data, and invalid activity files do not stop the rest of a batch. Import failures and partial successes with errors appear in a dismissible dialog over any Sessions tab, with affected filenames and error details; successfully imported rides remain saved if a later archive entry fails. 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. Linked course sessions expose compact part-number navigation through the continuation path, plus an all-parts view that combines every session 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.
Expand Down
67 changes: 53 additions & 14 deletions src/components/session-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { SelectMenu } from './select-menu';
import { SessionCalendar } from './session-calendar';
import { SessionDetail } from './session-detail';
import { SessionHistoryList } from './session-history-list';
import { SessionImportResultDialog } from './session-import-dialog';
import { SessionStatistics } from './session-statistics';
import { SideTray } from './side-tray';
import { Tabs } from './tabs';
Expand All @@ -56,6 +57,23 @@ const SESSION_DOWNLOAD_FORMAT_OPTIONS = [
{ label: 'TCX', value: ACTIVITY_FILE_FORMAT.TCX },
] as const;

function SessionHistoryStatus({ status, total }: { status: string; total: number }) {
const count = total.toLocaleString();
const sessions = `${count} ${total === 1 ? 'session' : 'sessions'}`;
return (
<p
aria-label={`${sessions}${status ? `, ${status}` : ''}`}
aria-live="polite"
className="max-w-xl truncate text-slate-500 text-xs"
role="status"
title={`${sessions}${status ? ` · ${status}` : ''}`}
>
{count}
{status ? <span className="text-cyan-300"> · {status}</span> : null}
</p>
);
}

export function SessionHistory({
onClose,
onSelectCalendarMonth,
Expand All @@ -82,6 +100,7 @@ export function SessionHistory({
weightHistory?: readonly RiderWeightEntry[];
}) {
const {
clearImportResult,
combinedJourney,
deleteSelectedSession: deleteHistorySession,
deleting,
Expand All @@ -92,6 +111,7 @@ export function SessionHistory({
highlightedSessionIds,
importActivityFile,
importing,
importResult,
loading,
loadMore,
revision,
Expand Down Expand Up @@ -124,6 +144,8 @@ export function SessionHistory({
const [downloadFormat, setDownloadFormat] =
useState<ActivityFileFormat>(loadSessionDownloadFormat);
const importInput = useRef<HTMLInputElement>(null);
const importButton = useRef<HTMLButtonElement>(null);
const restoreImportFocus = useRef(false);
const transferring = exporting || importing;
const navigationSummaries =
historyView === SESSION_HISTORY_VIEW.CALENDAR ? calendarSummaries : summaries;
Expand All @@ -135,6 +157,22 @@ export function SessionHistory({
}
}, [open]);

const closeImportResult = useCallback(() => {
restoreImportFocus.current = true;
clearImportResult();
}, [clearImportResult]);

useEffect(() => {
if (importResult || !restoreImportFocus.current) {
return;
}
restoreImportFocus.current = false;
// The disabled upload button loses focus before the dialog can remember it.
if (open) {
importButton.current?.focus();
}
}, [importResult, open]);

const selectSession = useCallback(
(id: string) => {
setDeleteConfirmationOpen(false);
Expand All @@ -155,7 +193,7 @@ export function SessionHistory({
}, [deleteHistorySession]);

useEffect(() => {
if (!open) {
if (!open || importResult) {
return;
}
const selectAdjacent = (event: KeyboardEvent, direction: 'next' | 'previous') => {
Expand Down Expand Up @@ -230,6 +268,7 @@ export function SessionHistory({
deleteSelectedSession,
historyHelpOpen,
historyView,
importResult,
onClose,
open,
selectSession,
Expand Down Expand Up @@ -266,7 +305,9 @@ export function SessionHistory({
} else if (selected) {
detail = (
<SessionDetail
chartKeyboardEnabled={open && !(deleteConfirmationOpen || historyHelpOpen)}
chartKeyboardEnabled={
open && !(deleteConfirmationOpen || historyHelpOpen || importResult)
}
combinedJourney={combinedJourney}
deleteConfirmationOpen={deleteConfirmationOpen}
deleting={deleting}
Expand Down Expand Up @@ -318,18 +359,7 @@ export function SessionHistory({
<h2 className="font-bold text-xl" id="session-history-title">
Sessions
</h2>
<p
aria-label={`${total.toLocaleString()} ${total === 1 ? 'session' : 'sessions'}${historyStatus ? `, ${historyStatus}` : ''}`}
aria-live="polite"
className="max-w-xl truncate text-slate-500 text-xs"
role="status"
title={`${total.toLocaleString()} ${total === 1 ? 'session' : 'sessions'}${historyStatus ? ` · ${historyStatus}` : ''}`}
>
{total.toLocaleString()}
{historyStatus ? (
<span className="text-cyan-300"> · {historyStatus}</span>
) : null}
</p>
<SessionHistoryStatus status={historyStatus} total={total} />
</div>
<div className="flex flex-wrap items-center gap-1">
<input
Expand All @@ -349,6 +379,7 @@ export function SessionHistory({
className="h-9 rounded-lg border border-line px-3 font-semibold text-slate-300 text-xs hover:border-cyan-400/60 hover:text-white disabled:cursor-wait disabled:opacity-60"
disabled={transferring}
onClick={() => importInput.current?.click()}
ref={importButton}
type="button"
>
{importing ? 'Importing…' : 'Import FIT/TCX'}
Expand Down Expand Up @@ -463,6 +494,14 @@ export function SessionHistory({
shortcuts={historyKeyboardShortcuts}
title="History keyboard controls"
/>
{open && importResult ? (
<SessionImportResultDialog
error={importResult.error}
fileName={importResult.fileName}
onClose={closeImportResult}
result={importResult.result}
/>
) : null}
</>
);
}
132 changes: 132 additions & 0 deletions src/components/session-import-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
import { useCloseOnEscape, useDialogInitialFocus } from '../hooks/use-dialog-behavior';
import { type ActivityImportResult, activityImportResultMessage } from '../lib/activity-import';

export function SessionImportResultDialog({
error,
fileName,
onClose,
result,
}: {
error?: string;
fileName: string;
onClose: () => void;
result?: ActivityImportResult;
}) {
const dialogRef = useRef<HTMLElement>(null);
const detailsRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const doneButtonRef = useDialogInitialFocus<HTMLButtonElement>();
useCloseOnEscape(true, onClose);

useEffect(() => {
const dialog = dialogRef.current;
const containFocus = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return;
}
let target: HTMLElement | null = detailsRef.current;
if (event.target === detailsRef.current) {
target = event.shiftKey ? closeButtonRef.current : doneButtonRef.current;
} else if (event.shiftKey && event.target === closeButtonRef.current) {
target = doneButtonRef.current;
} else if (!event.shiftKey && event.target === doneButtonRef.current) {
target = closeButtonRef.current;
}
event.preventDefault();
target?.focus();
};
dialog?.addEventListener('keydown', containFocus);
return () => dialog?.removeEventListener('keydown', containFocus);
}, [doneButtonRef]);

return (
<div className="fixed inset-0 z-60 grid place-items-center bg-black/70 p-3 backdrop-blur-sm sm:p-4">
<button
aria-label="Dismiss session import summary"
className="absolute inset-0 h-full w-full cursor-default"
onClick={onClose}
tabIndex={-1}
type="button"
/>
<section
aria-describedby="session-import-result-description"
aria-labelledby="session-import-result-title"
aria-modal="true"
className="relative z-10 flex max-h-[calc(100dvh-1.5rem)] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-slate-600 bg-panel shadow-2xl shadow-black/60 sm:max-h-[calc(100dvh-2rem)]"
ref={dialogRef}
role="dialog"
>
<header className="flex shrink-0 items-start justify-between gap-4 border-line border-b px-5 py-4 sm:px-6">
<div className="min-w-0">
<h2 className="font-bold text-2xl" id="session-import-result-title">
{result && result.importedSessions.length > 0
? 'Sessions imported with errors'
: 'Session import failed'}
</h2>
<p className="mt-1 break-all text-slate-400 text-sm">{fileName}</p>
</div>
<button
aria-label="Close session import summary"
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-400 hover:bg-slate-700 hover:text-white"
onClick={onClose}
ref={closeButtonRef}
type="button"
>
×
</button>
</header>
<div
aria-label="Session import details"
className="space-y-4 overflow-y-auto px-5 py-5 text-sm sm:px-6"
ref={detailsRef}
role="document"
tabIndex={-1}
>
<p className="text-slate-300" id="session-import-result-description">
{result
? activityImportResultMessage(result)
: 'The selected file could not be imported.'}
</p>
{error ? (
<p className="wrap-break-word whitespace-pre-wrap rounded-xl border border-rose-400/25 bg-rose-950/25 p-4 text-rose-200 leading-relaxed">
{error}
</p>
) : null}
{result ? (
<ul aria-label="Files that could not be imported" className="space-y-3">
{result.failures.map((failure) => (
<li
className="rounded-xl border border-rose-400/25 bg-rose-950/25 p-4"
key={failure.fileName}
>
<p className="break-all font-semibold text-slate-100">
{failure.fileName}
</p>
<p className="wrap-break-word mt-1 whitespace-pre-wrap text-rose-200 leading-relaxed">
{failure.message}
</p>
</li>
))}
</ul>
) : null}
<p className="text-slate-400 leading-relaxed">
Review the errors, fix or export the affected files again, then retry the
import. Any successfully imported sessions are saved; existing sessions will
be skipped as duplicates when you retry.
</p>
</div>
<footer className="flex shrink-0 justify-end border-line border-t px-5 py-4 sm:px-6">
<button
className="rounded-lg bg-lime px-5 py-2.5 font-bold text-ink text-sm hover:bg-[#e4ff9c]"
onClick={onClose}
ref={doneButtonRef}
type="button"
>
Done
</button>
</footer>
</section>
</div>
);
}
Loading