From 7d9ff15aa9112e2a5711a109ea059f28f1fbf7a5 Mon Sep 17 00:00:00 2001 From: Ride Control Date: Sun, 6 Sep 2026 09:36:03 -0700 Subject: [PATCH] feat: compare bulk download formats in a modal --- README.md | 2 +- src/components/session-download-dialog.tsx | 242 +++++++++++++++++++++ src/components/session-history.tsx | 128 +++++++---- src/hooks/use-session-history.ts | 2 - src/lib/activity-file.ts | 4 - src/lib/session-history-preferences.ts | 29 --- tests/components.test.tsx | 11 - tests/session-history-preferences.test.ts | 23 -- 8 files changed, 329 insertions(+), 112 deletions(-) create mode 100644 src/components/session-download-dialog.tsx diff --git a/README.md b/README.md index de8cc93..f30581c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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. 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. +- Downloads every locally saved ride at once as a compressed ZIP with collision-safe filenames when sessions share the same start time. **Download all** opens a modal comparing the data restored from RideControl's TCX and FIT exports, explaining why TCX is recommended for a richer RideControl round trip and FIT is useful for external fitness services. Explicit **Download TCX**, **Download FIT**, and **Cancel** buttons replace the bulk format dropdown and remembered choice. Archive preparation errors remain in the modal for retry, while successful downloads close it and return focus to **Download all**. Single-ride download controls remain unchanged. - 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. - Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions. diff --git a/src/components/session-download-dialog.tsx b/src/components/session-download-dialog.tsx new file mode 100644 index 0000000..ec6350e --- /dev/null +++ b/src/components/session-download-dialog.tsx @@ -0,0 +1,242 @@ +import { useEffect, useRef } from 'react'; +import { useCloseOnEscape, useDialogInitialFocus } from '../hooks/use-dialog-behavior'; +import { ACTIVITY_FILE_FORMAT, type ActivityFileFormat } from '../lib/activity-file'; + +const FORMAT_COMPARISON = [ + { + data: 'Core ride metrics (time, distance, power, heart rate, cadence and speed)', + fit: 'Yes', + tcx: 'Yes', + }, + { + data: 'Recorded resistance', + fit: 'Yes', + tcx: 'Yes', + }, + { + data: 'Virtual gear history / control mode', + fit: 'No', + tcx: 'Yes', + }, + { + data: 'Ride notes / feeling', + fit: 'No', + tcx: 'Yes', + }, + { + data: 'Rider/bike physics snapshot (weights, drivetrain and bike identity)', + fit: 'No', + tcx: 'Yes', + }, + { + data: 'Full workout route / progress', + fit: 'Partial — the matching route must already exist in RideControl.', + tcx: 'Embedded', + }, + { + data: 'Original session ID / continuation links', + fit: 'No — an imported ID is generated.', + tcx: 'Yes', + }, +]; + +export function SessionDownloadDialog({ + downloading, + error, + onClose, + onDownload, +}: { + downloading: boolean; + error?: string; + onClose: () => void; + onDownload: (format: ActivityFileFormat) => void; +}) { + const dialogRef = useRef(null); + const detailsRef = useRef(null); + const comparisonRef = useRef(null); + const closeButtonRef = useRef(null); + const fitButtonRef = useRef(null); + const cancelButtonRef = useRef(null); + const tcxButtonRef = useDialogInitialFocus(); + useCloseOnEscape(!downloading, onClose); + + useEffect(() => { + const dialog = dialogRef.current; + const containFocus = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + const targets = downloading + ? [detailsRef.current, comparisonRef.current] + : [ + closeButtonRef.current, + detailsRef.current, + comparisonRef.current, + tcxButtonRef.current, + fitButtonRef.current, + cancelButtonRef.current, + ]; + const currentIndex = targets.indexOf( + event.target instanceof HTMLElement ? event.target : null + ); + const direction = event.shiftKey ? -1 : 1; + const nextIndex = (currentIndex + direction + targets.length) % targets.length; + event.preventDefault(); + targets[nextIndex]?.focus(); + }; + dialog?.addEventListener('keydown', containFocus); + return () => dialog?.removeEventListener('keydown', containFocus); + }, [downloading, tcxButtonRef]); + + useEffect(() => { + if (downloading) { + detailsRef.current?.focus(); + } + }, [downloading]); + + return ( +
+ + +
+

+ Choose a format for all sessions. TCX is recommended for importing back into + RideControl because it preserves RideControl-specific data. FIT is useful + for uploads to external fitness services. +

+

+ This comparison covers data restored from RideControl's own downloads + when reimported into RideControl, not the full capabilities of each format. +

+
+ + + + + + + + + + + {FORMAT_COMPARISON.map((row) => ( + + + + + + ))} + +
+ Data preserved when reimported into RideControl +
+ Session data + + TCX + + Recommended + + + FIT +
+ {row.data} + {row.tcx}{row.fit}
+
+

+ TCX retains the extra RideControl data through RideControl-specific + extensions. Another service may strip these extensions when re-exporting, so + keep the original TCX files for a complete RideControl round trip. +

+ {error ? ( +

+ {error} +

+ ) : null} +
+
+

+ {downloading ? 'Preparing your download…' : ''} +

+
+ + + +
+
+ +
+ ); +} diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index 5c3aee8..05a1ad2 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -2,13 +2,14 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSessionHistory } from '../hooks/use-session-history'; import { useSessionInsights } from '../hooks/use-session-insights'; -import { ACTIVITY_FILE_FORMAT, type ActivityFileFormat } from '../lib/activity-file'; +import type { ActivityFileFormat } from '../lib/activity-file'; import { APP_OVERLAY } from '../lib/app-overlay'; import { eventTargetsEditableControl, eventTargetsInteractiveControl, keyboardEventHasModifiers, } from '../lib/dom'; +import { errorMessage } from '../lib/errors'; import { type HistoryShortcut, historyKeyboardShortcuts, @@ -21,10 +22,6 @@ import { sessionCalendarMonthFromKey, sessionCalendarMonthKey, } from '../lib/session-calendar'; -import { - loadSessionDownloadFormat, - saveSessionDownloadFormat, -} from '../lib/session-history-preferences'; import { loadSessionHistoryView, SESSION_HISTORY_VIEW, @@ -35,9 +32,9 @@ import { import { preferencesStore } from '../stores/preferences-store'; import type { ChartMode, SavedSession, SpeedUnit } from '../types'; import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'; -import { SelectMenu } from './select-menu'; import { SessionCalendar } from './session-calendar'; import { SessionDetail } from './session-detail'; +import { SessionDownloadDialog } from './session-download-dialog'; import { SessionHistoryList } from './session-history-list'; import { SessionImportResultDialog } from './session-import-dialog'; import { SessionStatistics } from './session-statistics'; @@ -52,11 +49,6 @@ function shouldIgnoreHistoryAction(event: KeyboardEvent) { ); } -const SESSION_DOWNLOAD_FORMAT_OPTIONS = [ - { label: 'FIT', value: ACTIVITY_FILE_FORMAT.FIT }, - { 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'}`; @@ -141,8 +133,12 @@ export function SessionHistory({ const [selectedChartMode, setSelectedChartMode] = useState( () => preferencesStore.get().chartMode ); - const [downloadFormat, setDownloadFormat] = - useState(loadSessionDownloadFormat); + const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); + const [downloadError, setDownloadError] = useState(''); + const downloadButton = useRef(null); + const downloadInProgress = useRef(false); + const downloadGeneration = useRef(0); + const restoreDownloadFocus = useRef(false); const importInput = useRef(null); const importButton = useRef(null); const restoreImportFocus = useRef(false); @@ -154,6 +150,10 @@ export function SessionHistory({ if (!open) { setDeleteConfirmationOpen(false); setHistoryHelpOpen(false); + setDownloadDialogOpen(false); + setDownloadError(''); + downloadGeneration.current += 1; + restoreDownloadFocus.current = false; } }, [open]); @@ -173,6 +173,49 @@ export function SessionHistory({ } }, [importResult, open]); + const closeDownloadDialog = useCallback(() => { + if (downloadInProgress.current) { + return; + } + restoreDownloadFocus.current = true; + setDownloadDialogOpen(false); + }, []); + + const downloadAllSessions = useCallback( + async (format: ActivityFileFormat) => { + if (downloadInProgress.current) { + return; + } + downloadInProgress.current = true; + const generation = downloadGeneration.current; + setDownloadError(''); + try { + await downloadAllActivityFiles(format); + if (generation === downloadGeneration.current) { + restoreDownloadFocus.current = true; + setDownloadDialogOpen(false); + } + } catch (preparationError) { + if (generation === downloadGeneration.current) { + setDownloadError(errorMessage(preparationError)); + } + } finally { + downloadInProgress.current = false; + } + }, + [downloadAllActivityFiles] + ); + + useEffect(() => { + if (downloadDialogOpen || !restoreDownloadFocus.current) { + return; + } + restoreDownloadFocus.current = false; + if (open) { + downloadButton.current?.focus(); + } + }, [downloadDialogOpen, open]); + const selectSession = useCallback( (id: string) => { setDeleteConfirmationOpen(false); @@ -193,7 +236,7 @@ export function SessionHistory({ }, [deleteHistorySession]); useEffect(() => { - if (!open || importResult) { + if (!open || importResult || downloadDialogOpen) { return; } const selectAdjacent = (event: KeyboardEvent, direction: 'next' | 'previous') => { @@ -266,6 +309,7 @@ export function SessionHistory({ }, [ deleteConfirmationOpen, deleteSelectedSession, + downloadDialogOpen, historyHelpOpen, historyView, importResult, @@ -306,7 +350,13 @@ export function SessionHistory({ detail = ( {importing ? 'Importing…' : 'Import FIT/TCX'} -
{ + setDownloadError(''); + setDeleteConfirmationOpen(false); + setHistoryHelpOpen(false); + setDownloadDialogOpen(true); + }} + ref={downloadButton} + type="button" > - - { - setDownloadFormat(format); - saveSessionDownloadFormat(format); - }} - options={SESSION_DOWNLOAD_FORMAT_OPTIONS} - size="compact" - triggerClassName="border-l-0" - value={downloadFormat} - width="compact" - /> -
+ {exporting ? 'Preparing…' : 'Download all'} +