diff --git a/README.md b/README.md index 33b8357..53a3f2d 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. diff --git a/bun.lock b/bun.lock index 8c28ff6..15613d9 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,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", @@ -472,6 +473,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], diff --git a/package.json b/package.json index a73ab0d..90fca3f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.tsx b/src/app.tsx index 954efc0..667b465 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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 { @@ -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()) { @@ -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, @@ -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: @@ -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) { @@ -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); @@ -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) { @@ -637,10 +700,14 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored /> setActiveOverlay(undefined)} + onSelectCalendarMonth={selectHistoryCalendarMonth} onSelectSessionId={selectHistorySession} + onSelectView={selectHistoryView} onStartNew={continueFromHistory} open={activeOverlay === APP_OVERLAY.HISTORY} requestedSessionId={requestedSessionId} + requestedSessionMonth={requestedSessionCalendarMonth} + requestedView={requestedSessionHistoryView} speedUnit={speedUnit} /> void; + selected: boolean; + session: SavedSessionSummary; + speedUnit: SpeedUnit; +}) { + return ( + + ); +} + +function CalendarDaySessions({ + onSelect, + selectedId, + sessions, + speedUnit, +}: { + onSelect: (id: string) => void; + selectedId?: string; + sessions: SavedSessionSummary[]; + speedUnit: SpeedUnit; +}) { + return ( +
+ {sessions.map((session) => ( + + ))} +
+ ); +} + +export function SessionCalendar({ + error, + loading, + month, + onChangeMonth, + onSelect, + selectedId, + speedUnit, + summaries, +}: { + error: string; + loading: boolean; + month: Date; + onChangeMonth: (month: Date) => void; + onSelect: (id: string) => void; + selectedId?: string; + speedUnit: SpeedUnit; + summaries: SavedSessionSummary[]; +}) { + const normalizedMonth = sessionCalendarMonth(month); + const days = useMemo( + () => sessionCalendarDays(normalizedMonth, summaries), + [normalizedMonth, summaries] + ); + const selectedSession = summaries.find((session) => session.id === selectedId); + const selectedSessionDayKey = selectedSession + ? localSessionDateKey(selectedSession.startedAt) + : undefined; + const selectedSessionIsVisible = days.some( + (day) => day.inCurrentMonth && day.key === selectedSessionDayKey + ); + const initialDayKey = + selectedSessionIsVisible && selectedSessionDayKey + ? selectedSessionDayKey + : (days.find((day) => day.inCurrentMonth && day.sessions.length > 0)?.key ?? + localSessionDateKey(normalizedMonth.getTime())); + const [selectedDayKey, setSelectedDayKey] = useState(initialDayKey); + + useEffect(() => { + if (selectedSessionIsVisible && selectedSessionDayKey) { + setSelectedDayKey(selectedSessionDayKey); + return; + } + if (!days.some((day) => day.key === selectedDayKey && day.inCurrentMonth)) { + setSelectedDayKey( + days.find((day) => day.inCurrentMonth && day.sessions.length > 0)?.key ?? + localSessionDateKey(normalizedMonth.getTime()) + ); + } + }, [days, normalizedMonth, selectedDayKey, selectedSessionDayKey, selectedSessionIsVisible]); + + const selectedDay = days.find((day) => day.key === selectedDayKey); + const monthTotals = summaries.reduce( + (totals, session) => ({ + distance: totals.distance + session.distance, + elapsedSeconds: totals.elapsedSeconds + session.elapsedSeconds, + }), + { distance: 0, elapsedSeconds: 0 } + ); + + return ( + + ); +} diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index da0158f..e0bbfa2 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import type { KeyboardEvent as ReactKeyboardEvent, 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, @@ -17,11 +18,29 @@ import { historyShortcutForKey, } from '../lib/keyboard'; import { adjacentSession } from '../lib/saved-sessions'; +import { + sessionCalendarMonth, + sessionCalendarMonthFromKey, + sessionCalendarMonthKey, +} from '../lib/session-calendar'; +import { + loadSessionDownloadFormat, + saveSessionDownloadFormat, +} from '../lib/session-history-preferences'; +import { + loadSessionHistoryView, + SESSION_HISTORY_VIEW, + SESSION_HISTORY_VIEW_OPTIONS, + type SessionHistoryView, + saveSessionHistoryView, +} from '../lib/session-history-view'; import { preferencesStore } from '../stores/preferences-store'; import type { ChartMode, SavedSession, SpeedUnit } from '../types'; import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'; +import { SessionCalendar } from './session-calendar'; import { SessionDetail } from './session-detail'; import { SessionHistoryList } from './session-history-list'; +import { SessionStatistics } from './session-statistics'; import { SideTray } from './side-tray'; function shouldIgnoreHistoryAction(event: KeyboardEvent) { @@ -34,17 +53,25 @@ function shouldIgnoreHistoryAction(event: KeyboardEvent) { export function SessionHistory({ onClose, + onSelectCalendarMonth, onSelectSessionId, + onSelectView, onStartNew, open, requestedSessionId, + requestedSessionMonth, + requestedView, speedUnit, }: { onClose: () => void; + onSelectCalendarMonth: (month: string) => void; onSelectSessionId?: (sessionId: string) => void; + onSelectView: (view: SessionHistoryView) => void; onStartNew: (session: SavedSession) => void; open: boolean; requestedSessionId?: string; + requestedSessionMonth?: string; + requestedView?: SessionHistoryView; speedUnit: SpeedUnit; }) { const { @@ -59,22 +86,39 @@ export function SessionHistory({ importing, loading, loadMore, + revision, selected, selectedId, selectSession: selectHistorySession, summaries, total, } = useSessionHistory(open, requestedSessionId); + const [storedHistoryView, setStoredHistoryView] = + useState(loadSessionHistoryView); + const historyView = requestedView ?? storedHistoryView; + const calendarMonth = useMemo( + () => + sessionCalendarMonthFromKey(requestedSessionMonth) ?? + sessionCalendarMonth(selected ? new Date(selected.startedAt) : new Date()), + [requestedSessionMonth, selected] + ); + const { + analytics, + calendarSummaries, + error: insightsError, + loading: insightsLoading, + } = useSessionInsights(open, calendarMonth, revision); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [historyHelpOpen, setHistoryHelpOpen] = useState(false); const [selectedChartMode, setSelectedChartMode] = useState( () => preferencesStore.get().chartMode ); - const [downloadFormat, setDownloadFormat] = useState( - ACTIVITY_FILE_FORMAT.FIT - ); + const [downloadFormat, setDownloadFormat] = + useState(loadSessionDownloadFormat); const importInput = useRef(null); const transferring = exporting || importing; + const navigationSummaries = + historyView === SESSION_HISTORY_VIEW.CALENDAR ? calendarSummaries : summaries; useEffect(() => { if (open && selected) { @@ -97,6 +141,10 @@ export function SessionHistory({ }, [selectHistorySession] ); + const selectCalendarMonth = useCallback( + (month: Date) => onSelectCalendarMonth(sessionCalendarMonthKey(month)), + [onSelectCalendarMonth] + ); const deleteSelectedSession = useCallback(async () => { if (await deleteHistorySession()) { @@ -109,11 +157,15 @@ export function SessionHistory({ return; } const selectAdjacent = (event: KeyboardEvent, direction: 'next' | 'previous') => { - if (deleteConfirmationOpen || historyHelpOpen) { + if ( + deleteConfirmationOpen || + historyHelpOpen || + historyView === SESSION_HISTORY_VIEW.STATISTICS + ) { return; } event.preventDefault(); - const next = adjacentSession(summaries, selectedId, direction); + const next = adjacentSession(navigationSummaries, selectedId, direction); if (next) { selectSession(next.id); } @@ -130,14 +182,23 @@ export function SessionHistory({ } }, confirmDelete: (event) => { - if (!deleteConfirmationOpen || eventTargetsInteractiveControl(event)) { + if ( + historyView === SESSION_HISTORY_VIEW.STATISTICS || + !deleteConfirmationOpen || + eventTargetsInteractiveControl(event) + ) { return; } event.preventDefault(); deleteSelectedSession(); }, deleteSession: (event) => { - if (deleteConfirmationOpen || historyHelpOpen || !selected) { + if ( + deleteConfirmationOpen || + historyHelpOpen || + historyView === SESSION_HISTORY_VIEW.STATISTICS || + !selected + ) { return; } event.preventDefault(); @@ -166,14 +227,66 @@ export function SessionHistory({ deleteConfirmationOpen, deleteSelectedSession, historyHelpOpen, + historyView, onClose, open, selectSession, selected, selectedId, - summaries, + navigationSummaries, ]); + const selectHistoryView = useCallback( + (view: SessionHistoryView) => { + setDeleteConfirmationOpen(false); + setHistoryHelpOpen(false); + setStoredHistoryView(view); + saveSessionHistoryView(view); + onSelectView(view); + }, + [onSelectView] + ); + const selectHistoryViewFromKeyboard = useCallback( + (event: ReactKeyboardEvent, view: SessionHistoryView) => { + const currentIndex = SESSION_HISTORY_VIEW_OPTIONS.findIndex( + (option) => option.value === view + ); + let nextIndex: number | undefined; + if (event.key === 'ArrowLeft') { + nextIndex = + (currentIndex - 1 + SESSION_HISTORY_VIEW_OPTIONS.length) % + SESSION_HISTORY_VIEW_OPTIONS.length; + } else if (event.key === 'ArrowRight') { + nextIndex = (currentIndex + 1) % SESSION_HISTORY_VIEW_OPTIONS.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = SESSION_HISTORY_VIEW_OPTIONS.length - 1; + } + const nextView = + nextIndex === undefined + ? undefined + : SESSION_HISTORY_VIEW_OPTIONS[nextIndex]?.value; + if (!nextView) { + return; + } + event.preventDefault(); + selectHistoryView(nextView); + event.currentTarget.ownerDocument + .getElementById(`session-history-tab-${nextView}`) + ?.focus(); + }, + [selectHistoryView] + ); + + const selectStatisticsSession = useCallback( + (id: string) => { + selectHistorySession(id); + selectHistoryView(SESSION_HISTORY_VIEW.LIST); + }, + [selectHistorySession, selectHistoryView] + ); + let detail: ReactNode = null; if (loading) { detail = ( @@ -204,6 +317,18 @@ export function SessionHistory({ Select a session ); + } else { + detail = ( +
+
+

No saved sessions yet

+

+ End a session or import a FIT or TCX file to fill your calendar and build + ride statistics. +

+
+
+ ); } return ( @@ -276,6 +401,7 @@ export function SessionHistory({ const format = event.currentTarget.value; if (isActivityFileFormat(format)) { setDownloadFormat(format); + saveSessionDownloadFormat(format); } }} value={downloadFormat} @@ -305,19 +431,77 @@ export function SessionHistory({ -
- - {detail} +
+ {SESSION_HISTORY_VIEW_OPTIONS.map((option) => ( + + ))} +
+
+ {historyView === SESSION_HISTORY_VIEW.STATISTICS ? ( + + ) : ( + <> + {historyView === SESSION_HISTORY_VIEW.CALENDAR ? ( + + ) : ( + + )} + {detail} + + )}
string; + period: SessionAnalyticsPeriod; + title: string; + unit: string; + value: (rollup: SessionAnalyticsRollup) => number; +} + +interface TrendMetricDefinition { + color: string; + formatValue: (value: number) => string; + key: SessionTrendMetric; + label: string; + unit: string; + value: (rollup: SessionAnalyticsRollup) => number; +} + +function analyticsDuration(seconds: number): string { + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) { + return `${days}d ${hours}h`; + } + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +function StatisticsCard({ label, unit, value }: { label: string; unit?: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} + {unit ? ( + + {unit} + + ) : null} +

+
+ ); +} + +function activePeriodLabel(count: number, period: SessionAnalyticsPeriod): string { + const noun = count === 1 ? period : `${period}s`; + return `${INTEGER_FORMATTER.format(count)} active ${noun}`; +} + +function AnalyticsTrendChart({ + color, + data, + formatValue, + period, + title, + unit, + value, +}: AnalyticsChartProps) { + const values = data.map((item) => value(item.rollup)); + const maximum = Math.max(...values, 0); + const labelEvery = Math.max(1, Math.ceil(data.length / 6)); + const activePeriods = values.filter((itemValue) => itemValue > 0).length; + const latest = data.at(-1); + const latestValue = values.at(-1) ?? 0; + return ( +
+
+
+

{title}

+

+ {formatValue(latestValue)} + {unit ? ( + + {unit} + + ) : null} +

+

{latest?.label}

+
+
+

Best period

+

+ {formatValue(maximum)} + {unit ? ` ${unit}` : ''} +

+
+
+
+
+
+ ); +} + +function AnalyticsTrendOverview({ + data, + metrics, +}: { + data: ChartDatum[]; + metrics: TrendMetricDefinition[]; +}) { + return ( +
+
+

All trends

+ {metrics.length} metrics +
+
+ {metrics.map((metric) => { + const values = data.map((item) => metric.value(item.rollup)); + const maximum = Math.max(...values, 0); + const latestValue = values.at(-1) ?? 0; + return ( +
+
+

+

+

+ {metric.formatValue(latestValue)} + {metric.unit ? ( + + {metric.unit} + + ) : null} +

+
+
+ {data.map((item, index) => { + const itemValue = values[index] ?? 0; + const height = + maximum > 0 && itemValue > 0 + ? Math.max(8, (itemValue / maximum) * 100) + : 0; + return ( + 0 ? 'value' : 'empty' + } + key={item.key} + style={{ + backgroundColor: metric.color, + height: `${height}%`, + }} + title={`${item.label}: ${metric.formatValue(itemValue)}${ + metric.unit ? ` ${metric.unit}` : '' + }`} + /> + ); + })} +
+
+ ); + })} +
+
+ ); +} + +function trendMetricDefinitions(speedUnit: SpeedUnit): TrendMetricDefinition[] { + return [ + { + color: '#22d3ee', + formatValue: (value) => NUMBER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.DISTANCE, + label: 'Distance', + unit: distanceUnitLabel(speedUnit), + value: (rollup) => convertDistance(rollup.distance, speedUnit), + }, + { + color: '#a78bfa', + formatValue: (value) => NUMBER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.RIDE_TIME, + label: 'Ride time', + unit: 'hours', + value: (rollup) => rollup.elapsedSeconds / 3600, + }, + { + color: '#86efac', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.CLIMBING, + label: 'Climbing', + unit: elevationUnitLabel(speedUnit), + value: (rollup) => convertElevation(rollup.ascent, speedUnit), + }, + { + color: '#60a5fa', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.DOWNHILL, + label: 'Downhill', + unit: elevationUnitLabel(speedUnit), + value: (rollup) => convertElevation(rollup.descent, speedUnit), + }, + { + color: '#fb923c', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.CALORIES, + label: 'Calories', + unit: 'kcal', + value: (rollup) => rollup.calories, + }, + { + color: '#f8fafc', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.RIDES, + label: 'Rides', + unit: '', + value: (rollup) => rollup.sessionCount, + }, + { + color: '#38bdf8', + formatValue: (value) => NUMBER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.AVERAGE_SPEED, + label: 'Average speed', + unit: speedUnitLabel(speedUnit), + value: (rollup) => convertSpeed(sessionAnalyticsAverageSpeed(rollup), speedUnit), + }, + { + color: '#facc15', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.AVERAGE_POWER, + label: 'Average power', + unit: 'W', + value: (rollup) => sessionAnalyticsMetricAverage(rollup.power), + }, + { + color: '#a78bfa', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.AVERAGE_CADENCE, + label: 'Average cadence', + unit: 'rpm', + value: (rollup) => sessionAnalyticsMetricAverage(rollup.cadence), + }, + { + color: '#fb7185', + formatValue: (value) => INTEGER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.AVERAGE_HEART_RATE, + label: 'Average heart rate', + unit: 'bpm', + value: (rollup) => sessionAnalyticsMetricAverage(rollup.heartRate), + }, + { + color: '#86efac', + formatValue: (value) => NUMBER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.AVERAGE_GEAR, + label: 'Average gear', + unit: '', + value: (rollup) => sessionAnalyticsMetricAverage(rollup.gear), + }, + { + color: '#2dd4bf', + formatValue: (value) => NUMBER_FORMATTER.format(value), + key: SESSION_TREND_METRIC.AVERAGE_RESISTANCE, + label: 'Average resistance', + unit: '%', + value: (rollup) => sessionAnalyticsMetricAverage(rollup.resistance), + }, + ]; +} + +function selectedTrendMetric( + definitions: TrendMetricDefinition[], + key: SessionTrendMetric +): TrendMetricDefinition { + const definition = definitions.find((candidate) => candidate.key === key); + if (!definition) { + throw new Error('Unsupported session trend metric.'); + } + return definition; +} + +function PeakCard({ + label, + onSelect, + value, +}: { + label: string; + onSelect?: () => void; + value: string; +}) { + const className = + 'min-w-0 rounded-lg border border-line bg-slate-900/35 p-4 text-left transition'; + if (!onSelect) { + return ( +
+

{label}

+

{value}

+
+ ); + } + return ( + + ); +} + +export function SessionStatistics({ + analytics, + error, + initialTrendMetric = ALL_TREND_METRICS, + initialTrendRange = SESSION_ANALYTICS_PERIOD.MONTH, + loading, + onSelectSession, + speedUnit, + trendEndTimestamp, +}: { + analytics: SessionAnalyticsCache; + error: string; + initialTrendMetric?: TrendMetricSelection; + initialTrendRange?: SessionTrendRange; + loading: boolean; + onSelectSession: (id: string) => void; + speedUnit: SpeedUnit; + trendEndTimestamp?: number; +}) { + const [trendRange, setTrendRange] = useState(initialTrendRange); + const [trendMetricKey, setTrendMetricKey] = useState(initialTrendMetric); + const [trendEnd] = useState(() => trendEndTimestamp ?? Date.now()); + const trendMetrics = useMemo(() => trendMetricDefinitions(speedUnit), [speedUnit]); + const activeTrendMetric = + trendMetricKey === ALL_TREND_METRICS + ? undefined + : selectedTrendMetric(trendMetrics, trendMetricKey); + const chartPeriod = trendRange === ALL_TREND_RANGE ? SESSION_ANALYTICS_PERIOD.YEAR : trendRange; + const chartData = useMemo( + () => + (trendRange === ALL_TREND_RANGE + ? sessionAnalyticsCompleteTrendRollups(analytics) + : sessionAnalyticsTrendRollups(analytics, trendRange, trendEnd) + ).map(([key, rollup]) => ({ + key, + label: sessionAnalyticsPeriodLabel(key, chartPeriod), + rollup, + })), + [analytics, chartPeriod, trendEnd, trendRange] + ); + const { totals } = analytics; + const averagePower = sessionAnalyticsMetricAverage(totals.power); + const averageCadence = sessionAnalyticsMetricAverage(totals.cadence); + const averageGear = sessionAnalyticsMetricAverage(totals.gear); + const averageHeartRate = sessionAnalyticsMetricAverage(totals.heartRate); + const averageResistance = sessionAnalyticsMetricAverage(totals.resistance); + const averageRideDistance = totals.sessionCount > 0 ? totals.distance / totals.sessionCount : 0; + const averageRideDuration = + totals.sessionCount > 0 ? totals.elapsedSeconds / totals.sessionCount : 0; + const peakDefinitions = [ + { + key: SESSION_ANALYTICS_PEAK.DISTANCE, + label: 'Longest distance', + value: (value: number) => + `${NUMBER_FORMATTER.format(convertDistance(value, speedUnit))} ${distanceUnitLabel(speedUnit)}`, + }, + { + key: SESSION_ANALYTICS_PEAK.DURATION, + label: 'Longest time', + value: analyticsDuration, + }, + { + key: SESSION_ANALYTICS_PEAK.CLIMB, + label: 'Most climbing', + value: (value: number) => + `${INTEGER_FORMATTER.format(convertElevation(value, speedUnit))} ${elevationUnitLabel(speedUnit)}`, + }, + { + key: SESSION_ANALYTICS_PEAK.DESCENT, + label: 'Most downhill', + value: (value: number) => + `${INTEGER_FORMATTER.format(convertElevation(value, speedUnit))} ${elevationUnitLabel(speedUnit)}`, + }, + { + key: SESSION_ANALYTICS_PEAK.CALORIES, + label: 'Most calories', + value: (value: number) => `${INTEGER_FORMATTER.format(value)} kcal`, + }, + { + key: SESSION_ANALYTICS_PEAK.SPEED, + label: 'Top speed', + value: (value: number) => + `${NUMBER_FORMATTER.format(convertSpeed(value, speedUnit))} ${speedUnitLabel(speedUnit)}`, + }, + { + key: SESSION_ANALYTICS_PEAK.POWER, + label: 'Peak power', + value: (value: number) => `${INTEGER_FORMATTER.format(value)} W`, + }, + { + key: SESSION_ANALYTICS_PEAK.CADENCE, + label: 'Peak cadence', + value: (value: number) => `${INTEGER_FORMATTER.format(value)} rpm`, + }, + { + key: SESSION_ANALYTICS_PEAK.HEART_RATE, + label: 'Peak heart rate', + value: (value: number) => `${INTEGER_FORMATTER.format(value)} bpm`, + }, + ]; + + if (totals.sessionCount === 0 && !loading) { + return ( +
+
+

No ride statistics yet

+

+ Complete or import a session to start building your history. +

+
+
+ ); + } + + return ( +
+ {error ? ( +

{error}

+ ) : null} +
+
+

All-time totals

+

+ Updated locally whenever a session is saved, imported, changed, or deleted. +

+
+ {loading ? Updating… : null} +
+
+ + + + + + +
+
+ + + + + 0 ? NUMBER_FORMATTER.format(averageGear) : '—'} + /> + 0 + ? NUMBER_FORMATTER.format(averageResistance) + : '—' + } + /> + + + + + + +
+
+

Personal bests

+
+ {peakDefinitions.map((definition) => { + const peak = analytics.peaks[definition.key]; + return ( + onSelectSession(peak.sessionId) : undefined} + value={definition.value(peak?.value ?? 0)} + /> + ); + })} +
+
+
+
+

Trends

+

+ {trendRange === ALL_TREND_RANGE + ? 'Complete ride history' + : `The latest ${ + trendRange === SESSION_ANALYTICS_PERIOD.YEAR + ? '10 years' + : `12 ${trendRange}s` + }`} +

+
+
+ +
+ {TREND_RANGE_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ {activeTrendMetric ? ( + + ) : ( + + )} +
+

+ Exact ride time: {formatDuration(totals.elapsedSeconds)} +

+
+ ); +} diff --git a/src/components/workout-panel.tsx b/src/components/workout-panel.tsx index d215dd5..f461500 100644 --- a/src/components/workout-panel.tsx +++ b/src/components/workout-panel.tsx @@ -124,8 +124,7 @@ function WorkoutCourseCard({ className={`relative overflow-hidden rounded-2xl border bg-[#12171d] transition-colors ${emphasis} ${dragged ? 'cursor-grabbing opacity-95 shadow-[0_20px_50px_rgba(0,0,0,.5)]' : ''}`} data-focused={focused ? 'true' : undefined} id={`workout-${encodeURIComponent(course.id)}`} - onFocusCapture={onFocus} - onPointerDownCapture={onFocus} + onClickCapture={onFocus} ref={setNodeRef} style={style} > diff --git a/src/hooks/use-session-history.ts b/src/hooks/use-session-history.ts index 903dd42..8e2d4ea 100644 --- a/src/hooks/use-session-history.ts +++ b/src/hooks/use-session-history.ts @@ -32,6 +32,7 @@ export function useSessionHistory(open: boolean, preferredSessionId?: string) { const [historyStatus, setHistoryStatus] = useState(''); const [highlightedSessionIds, setHighlightedSessionIds] = useState([]); const [error, setError] = useState(''); + const [revision, setRevision] = useState(0); const deleteInProgress = useRef(false); const historyLoadGeneration = useRef(0); const historyInitialized = useRef(false); @@ -71,6 +72,7 @@ export function useSessionHistory(open: boolean, preferredSessionId?: string) { } setSummaries(sessions); setTotal(count); + setRevision((current) => current + 1); setError(''); const nextSessionId = sessions.some((session) => session.id === requestedSessionId) ? requestedSessionId @@ -168,6 +170,7 @@ export function useSessionHistory(open: boolean, preferredSessionId?: string) { const updated = sessionListAfterDelete(summaries, selected.id); setSummaries(updated.sessions); setTotal((current) => Math.max(0, current - 1)); + setRevision((current) => current + 1); setError(''); if (updated.next) { await selectSession(updated.next.id); @@ -211,6 +214,7 @@ export function useSessionHistory(open: boolean, preferredSessionId?: string) { importing, loading, loadMore, + revision, selected, selectedId, selectSession, diff --git a/src/hooks/use-session-insights.ts b/src/hooks/use-session-insights.ts new file mode 100644 index 0000000..9bd63f0 --- /dev/null +++ b/src/hooks/use-session-insights.ts @@ -0,0 +1,57 @@ +import { useEffect, useRef, useState } from 'react'; +import { errorMessage } from '../lib/errors'; +import { getSessionAnalytics, listSavedSessionsForMonth } from '../lib/saved-sessions'; +import { emptySessionAnalyticsCache } from '../lib/session-analytics'; +import type { SavedSessionSummary } from '../types'; + +export function useSessionInsights(open: boolean, month: Date, revision: number) { + const [analytics, setAnalytics] = useState(emptySessionAnalyticsCache); + const [calendarSummaries, setCalendarSummaries] = useState([]); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const generation = useRef(0); + const loadedRevision = useRef(-1); + const year = month.getFullYear(); + const monthIndex = month.getMonth(); + + useEffect(() => { + if (!open) { + generation.current += 1; + return; + } + const currentGeneration = generation.current + 1; + generation.current = currentGeneration; + loadedRevision.current = revision; + setLoading(true); + Promise.all([listSavedSessionsForMonth(year, monthIndex), getSessionAnalytics()]) + .then(([summaries, storedAnalytics]) => { + if ( + generation.current !== currentGeneration || + loadedRevision.current !== revision + ) { + return; + } + setCalendarSummaries(summaries); + setAnalytics(storedAnalytics); + setError(''); + }) + .catch((loadError: unknown) => { + if ( + generation.current === currentGeneration && + loadedRevision.current === revision + ) { + setError(errorMessage(loadError)); + } + }) + .finally(() => { + if ( + generation.current === currentGeneration && + loadedRevision.current === revision + ) { + setLoading(false); + } + }); + }, [monthIndex, open, revision, year]); + + return { analytics, calendarSummaries, error, loading }; +} diff --git a/src/lib/app-route.ts b/src/lib/app-route.ts index bbf9efd..fe7b680 100644 --- a/src/lib/app-route.ts +++ b/src/lib/app-route.ts @@ -1,5 +1,7 @@ import { APP_OVERLAY, type SideTrayOverlay } from './app-overlay'; import { unreachable } from './errors'; +import { sessionCalendarMonthKeySchema } from './session-calendar'; +import { type SessionHistoryView, sessionHistoryViewSchema } from './session-history-view'; export const APP_ROUTE_PATH = { BIKEGPX: '/bikegpx', @@ -24,16 +26,38 @@ export type AppRoute = | { kind: typeof APP_ROUTE_KIND.BIKEGPX; routeId?: string } | { kind: typeof APP_ROUTE_KIND.DEVICES } | { kind: typeof APP_ROUTE_KIND.HOME } - | { kind: typeof APP_ROUTE_KIND.SESSION; sessionId?: string } + | { + calendarMonth?: string; + historyView?: SessionHistoryView; + kind: typeof APP_ROUTE_KIND.SESSION; + sessionId?: string; + } | { kind: typeof APP_ROUTE_KIND.WORKOUT; workoutId?: string }; export const HOME_APP_ROUTE: AppRoute = { kind: APP_ROUTE_KIND.HOME }; +function matchedSessionRoute( + sessionId: string | undefined, + search: Readonly> | undefined +): AppRoute { + const parsedMonth = sessionCalendarMonthKeySchema.safeParse(search?.date); + const calendarMonth = parsedMonth.success ? parsedMonth.data : undefined; + const parsedView = sessionHistoryViewSchema.safeParse(search?.view); + const historyView = parsedView.success ? parsedView.data : undefined; + return { + ...(calendarMonth ? { calendarMonth } : {}), + ...(historyView ? { historyView } : {}), + kind: APP_ROUTE_KIND.SESSION, + ...(sessionId ? { sessionId } : {}), + }; +} + export function appRouteFromRouterMatch( match: | { params: Readonly>; routeId: string; + search?: Readonly>; } | undefined ): AppRoute { @@ -47,11 +71,9 @@ export function appRouteFromRouterMatch( case APP_ROUTE_PATH.DEVICES: return { kind: APP_ROUTE_KIND.DEVICES }; case APP_ROUTE_PATH.SESSION: - return match.params.sessionId - ? { kind: APP_ROUTE_KIND.SESSION, sessionId: match.params.sessionId } - : { kind: APP_ROUTE_KIND.SESSION }; + return matchedSessionRoute(match.params.sessionId, match.search); case APP_ROUTE_PATH.SESSIONS: - return { kind: APP_ROUTE_KIND.SESSION }; + return matchedSessionRoute(undefined, match.search); case APP_ROUTE_PATH.WORKOUT: return match.params.workoutId ? { kind: APP_ROUTE_KIND.WORKOUT, workoutId: match.params.workoutId } diff --git a/src/lib/gears.ts b/src/lib/gears.ts index c6549d0..2ae71b3 100644 --- a/src/lib/gears.ts +++ b/src/lib/gears.ts @@ -53,6 +53,7 @@ export const MAXIMUM_VIRTUAL_DRIVE_RATIO = Math.max( const RESISTANCE_PRECISION = 10; const VIRTUAL_GEAR_LOAD_EXPONENT = 2; +const HARD_GEAR_RESISTANCE_FLOOR_SCALE = 18.5; const DEFAULT_TOTAL_MASS_KG = DEFAULT_RIDER_WEIGHT_KG + DEFAULT_BIKE_WEIGHT_KG; export function maximumGear(drivetrain: VirtualDrivetrain = DEFAULT_VIRTUAL_DRIVETRAIN): number { @@ -122,6 +123,19 @@ function gearLoadMultiplierFromCombinations( return relativeRatio ** VIRTUAL_GEAR_LOAD_EXPONENT; } +function hardGearResistanceFloor( + gear: number, + combinations: readonly VirtualGearCombination[] +): number { + // Descending terrain can approach zero load, but a large virtual ratio still + // represents substantial wheel speed and aerodynamic load at normal cadence. + return Math.max( + 0, + (gearLoadMultiplierFromCombinations(gear, combinations) - 1) * + HARD_GEAR_RESISTANCE_FLOOR_SCALE + ); +} + export function systemMassLoadMultiplier(totalMassKg = DEFAULT_TOTAL_MASS_KG): number { return clamp(totalMassKg / DEFAULT_TOTAL_MASS_KG, 0.25, 5); } @@ -132,10 +146,14 @@ export function resistanceForVirtualGear( drivetrain: VirtualDrivetrain = DEFAULT_VIRTUAL_DRIVETRAIN, totalMassKg = DEFAULT_TOTAL_MASS_KG ): number { + const combinations = virtualGearCombinations(drivetrain); return roundedResistance( - clampResistance(terrainResistance) * - virtualGearLoadMultiplier(gear, drivetrain) * - systemMassLoadMultiplier(totalMassKg) + Math.max( + clampResistance(terrainResistance) * + gearLoadMultiplierFromCombinations(gear, combinations) * + systemMassLoadMultiplier(totalMassKg), + hardGearResistanceFloor(gear, combinations) + ) ); } @@ -147,8 +165,11 @@ export function resistanceAfterGearShift( ): number { const combinations = virtualGearCombinations(drivetrain); return roundedResistance( - resistance * - (gearLoadMultiplierFromCombinations(toGear, combinations) / - gearLoadMultiplierFromCombinations(fromGear, combinations)) + Math.max( + resistance * + (gearLoadMultiplierFromCombinations(toGear, combinations) / + gearLoadMultiplierFromCombinations(fromGear, combinations)), + hardGearResistanceFloor(toGear, combinations) + ) ); } diff --git a/src/lib/saved-sessions.ts b/src/lib/saved-sessions.ts index 1bc18f6..1a2a61b 100644 --- a/src/lib/saved-sessions.ts +++ b/src/lib/saved-sessions.ts @@ -18,6 +18,17 @@ import { restoreAggregate, restoreStoredSession, } from './session'; +import { + buildSessionAnalyticsCache, + rebuildSessionAnalyticsPeaks, + restoreSessionAnalyticsCache, + restoreSessionAnalyticsContribution, + SESSION_ANALYTICS_CACHE_ID, + type SessionAnalyticsCache, + type SessionAnalyticsContribution, + sessionAnalyticsContribution, + updateSessionAnalyticsCache, +} from './session-analytics'; import { createSessionWorkoutSnapshot, restoreSessionWorkoutSnapshot, @@ -29,15 +40,18 @@ import { isFiniteNumber, isRecord, isString } from './type-guards'; import { restoreSessionWorkout } from './workouts'; const DATABASE_NAME = 'ridecontrol-sessions'; -const DATABASE_VERSION = 3; +const DATABASE_VERSION = 4; const ACTIVE_SESSION_ID = 'current'; const ACTIVE_SESSION_STORE = 'active-session'; const ACTIVE_SESSION_SAMPLE_STORE = 'active-session-samples'; const ACTIVE_SESSION_SAMPLE_CHUNK_SIZE = 100; +const ANALYTICS_CACHE_STORE = 'session-analytics-cache'; +const ANALYTICS_STORE = 'session-analytics'; const SESSION_STORE = 'sessions'; const SUMMARY_STORE = 'session-summaries'; const WORKOUT_STORE = 'session-workouts'; const ENDED_AT_INDEX = 'endedAt'; +const STARTED_AT_INDEX = 'startedAt'; const WORKOUT_SNAPSHOT_INDEX = 'workoutSnapshotId'; const MERIDIEM_SUFFIX = /\s*(AM|PM)$/i; const SESSION_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }); @@ -127,21 +141,37 @@ function workoutStoreForUpgrade(request: IDBOpenDBRequest): IDBObjectStore | und : request.result.createObjectStore(WORKOUT_STORE, { keyPath: 'id' }); } -function createMissingDatabaseStores(database: IDBDatabase): void { - if (!database.objectStoreNames.contains(SUMMARY_STORE)) { - const summaries = database.createObjectStore(SUMMARY_STORE, { keyPath: 'id' }); +function summaryStoreForUpgrade(request: IDBOpenDBRequest): IDBObjectStore | undefined { + const summaries = request.result.objectStoreNames.contains(SUMMARY_STORE) + ? request.transaction?.objectStore(SUMMARY_STORE) + : request.result.createObjectStore(SUMMARY_STORE, { keyPath: 'id' }); + if (summaries && !summaries.indexNames.contains(ENDED_AT_INDEX)) { summaries.createIndex(ENDED_AT_INDEX, ENDED_AT_INDEX); } + if (summaries && !summaries.indexNames.contains(STARTED_AT_INDEX)) { + summaries.createIndex(STARTED_AT_INDEX, STARTED_AT_INDEX); + } + return summaries; +} + +function createMissingDatabaseStores(database: IDBDatabase): void { if (!database.objectStoreNames.contains(ACTIVE_SESSION_STORE)) { database.createObjectStore(ACTIVE_SESSION_STORE, { keyPath: 'id' }); } if (!database.objectStoreNames.contains(ACTIVE_SESSION_SAMPLE_STORE)) { database.createObjectStore(ACTIVE_SESSION_SAMPLE_STORE, { keyPath: 'id' }); } + if (!database.objectStoreNames.contains(ANALYTICS_STORE)) { + database.createObjectStore(ANALYTICS_STORE, { keyPath: 'id' }); + } + if (!database.objectStoreNames.contains(ANALYTICS_CACHE_STORE)) { + database.createObjectStore(ANALYTICS_CACHE_STORE, { keyPath: 'id' }); + } } function upgradeDatabase(request: IDBOpenDBRequest, oldVersion: number): void { const sessions = sessionStoreForUpgrade(request); + summaryStoreForUpgrade(request); createMissingDatabaseStores(request.result); const workouts = workoutStoreForUpgrade(request); if (oldVersion < 2 && sessions && workouts) { @@ -149,6 +179,40 @@ function upgradeDatabase(request: IDBOpenDBRequest, oldVersion: number): void { } } +async function initializeSessionAnalytics(database: IDBDatabase): Promise { + const transaction = database.transaction( + [SESSION_STORE, ANALYTICS_STORE, ANALYTICS_CACHE_STORE], + 'readwrite' + ); + const completed = indexedDbTransactionComplete(transaction); + const sessionStore = transaction.objectStore(SESSION_STORE); + const analyticsStore = transaction.objectStore(ANALYTICS_STORE); + const cacheStore = transaction.objectStore(ANALYTICS_CACHE_STORE); + const [cacheValue, sessionRecords, contributionValues] = await Promise.all([ + indexedDbRequestResult(cacheStore.get(SESSION_ANALYTICS_CACHE_ID)), + indexedDbRequestResult(sessionStore.getAll() as IDBRequest), + indexedDbRequestResult(analyticsStore.getAll()), + ]); + const cache = restoreSessionAnalyticsCache(cacheValue); + const contributions = (contributionValues as unknown[]).flatMap((value) => { + const contribution = restoreSessionAnalyticsContribution(value); + return contribution ? [contribution] : []; + }); + if (cache && contributions.length === sessionRecords.length) { + await completed; + return; + } + const rebuiltContributions = sessionRecords.map((record) => + sessionAnalyticsContribution(normalizeSavedSessionRecord(record)) + ); + analyticsStore.clear(); + for (const contribution of rebuiltContributions) { + analyticsStore.put(contribution); + } + cacheStore.put(buildSessionAnalyticsCache(rebuiltContributions)); + await completed; +} + function openDatabase(): Promise { if (databasePromise) { return databasePromise; @@ -162,7 +226,15 @@ function openDatabase(): Promise { }, { once: true } ); - request.addEventListener('success', () => resolve(request.result), { once: true }); + request.addEventListener( + 'success', + () => { + initializeSessionAnalytics(request.result) + .then(() => resolve(request.result)) + .catch(reject); + }, + { once: true } + ); request.addEventListener('error', () => reject(request.error), { once: true }); }); return databasePromise; @@ -391,18 +463,61 @@ export function deleteSessionRecords( getStore(SUMMARY_STORE).delete(id); } +function storedContribution( + value: unknown, + record: SavedSessionRecord | undefined +): SessionAnalyticsContribution | undefined { + return ( + restoreSessionAnalyticsContribution(value) ?? + (record ? sessionAnalyticsContribution(normalizeSavedSessionRecord(record)) : undefined) + ); +} + +async function updatedAnalyticsCache( + analyticsStore: IDBObjectStore, + current: SessionAnalyticsCache | undefined, + previous: SessionAnalyticsContribution | undefined, + next: SessionAnalyticsContribution | undefined +): Promise { + const update = updateSessionAnalyticsCache(current, previous, next); + if (!update.peaksNeedRebuild) { + return update.cache; + } + const values: unknown[] = await indexedDbRequestResult(analyticsStore.getAll()); + const contributions = values.flatMap((value) => { + const contribution = restoreSessionAnalyticsContribution(value); + return contribution ? [contribution] : []; + }); + return rebuildSessionAnalyticsPeaks(update.cache, contributions); +} + export async function saveSession(session: SavedSession): Promise { const database = await openDatabase(); const transaction = database.transaction( - [SESSION_STORE, SUMMARY_STORE, WORKOUT_STORE], + [SESSION_STORE, SUMMARY_STORE, WORKOUT_STORE, ANALYTICS_STORE, ANALYTICS_CACHE_STORE], 'readwrite' ); const completed = indexedDbTransactionComplete(transaction); const sessions = transaction.objectStore(SESSION_STORE); - const previous = await indexedDbRequestResult( - sessions.get(session.id) as IDBRequest - ); + const analytics = transaction.objectStore(ANALYTICS_STORE); + const analyticsCache = transaction.objectStore(ANALYTICS_CACHE_STORE); + const [previous, previousContributionValue, cacheValue] = await Promise.all([ + indexedDbRequestResult( + sessions.get(session.id) as IDBRequest + ), + indexedDbRequestResult(analytics.get(session.id)), + indexedDbRequestResult(analyticsCache.get(SESSION_ANALYTICS_CACHE_ID)), + ]); const { snapshotId } = saveSessionRecords((name) => transaction.objectStore(name), session); + const contribution = sessionAnalyticsContribution(session); + analytics.put(contribution); + const cache = await updatedAnalyticsCache( + analytics, + restoreSessionAnalyticsCache(cacheValue), + storedContribution(previousContributionValue, previous), + contribution + ); + analyticsCache.put(cache); if (previous?.workoutSnapshotId && previous.workoutSnapshotId !== snapshotId) { const remaining = await indexedDbRequestResult( sessions.index(WORKOUT_SNAPSHOT_INDEX).count(previous.workoutSnapshotId) @@ -417,15 +532,30 @@ export async function saveSession(session: SavedSession): Promise { export async function deleteSavedSession(id: string): Promise { const database = await openDatabase(); const transaction = database.transaction( - [SESSION_STORE, SUMMARY_STORE, WORKOUT_STORE], + [SESSION_STORE, SUMMARY_STORE, WORKOUT_STORE, ANALYTICS_STORE, ANALYTICS_CACHE_STORE], 'readwrite' ); const completed = indexedDbTransactionComplete(transaction); const sessions = transaction.objectStore(SESSION_STORE); - const record = await indexedDbRequestResult( - sessions.get(id) as IDBRequest - ); + const analytics = transaction.objectStore(ANALYTICS_STORE); + const analyticsCache = transaction.objectStore(ANALYTICS_CACHE_STORE); + const [record, contributionValue, cacheValue] = await Promise.all([ + indexedDbRequestResult(sessions.get(id) as IDBRequest), + indexedDbRequestResult(analytics.get(id)), + indexedDbRequestResult(analyticsCache.get(SESSION_ANALYTICS_CACHE_ID)), + ]); deleteSessionRecords((name) => transaction.objectStore(name), id); + analytics.delete(id); + const contribution = storedContribution(contributionValue, record); + if (contribution) { + const cache = await updatedAnalyticsCache( + analytics, + restoreSessionAnalyticsCache(cacheValue), + contribution, + undefined + ); + analyticsCache.put(cache); + } if (record?.workoutSnapshotId) { const remaining = await indexedDbRequestResult( sessions.index(WORKOUT_SNAPSHOT_INDEX).count(record.workoutSnapshotId) @@ -513,6 +643,54 @@ export async function listSavedSessions( return summaries; } +export async function listSavedSessionsForMonth( + year: number, + month: number +): Promise { + const database = await openDatabase(); + const transaction = database.transaction(SUMMARY_STORE, 'readonly'); + const completed = indexedDbTransactionComplete(transaction); + const summaries = transaction.objectStore(SUMMARY_STORE); + const start = new Date(year, month, 1).getTime(); + const end = new Date(year, month + 1, 1).getTime(); + const [startedInMonth, endedInMonth] = await Promise.all([ + indexedDbRequestResult( + summaries + .index(STARTED_AT_INDEX) + .getAll(IDBKeyRange.bound(start, end, false, true)) as IDBRequest< + SavedSessionSummary[] + > + ), + indexedDbRequestResult( + summaries + .index(ENDED_AT_INDEX) + .getAll(IDBKeyRange.bound(start, end, false, true)) as IDBRequest< + SavedSessionSummary[] + > + ), + ]); + await completed; + return [ + ...new Map( + [...startedInMonth, ...endedInMonth].map((summary) => [summary.id, summary]) + ).values(), + ] + .filter((summary) => summary.startedAt < end && summary.endedAt >= start) + .map(normalizeSavedSessionSummary) + .sort((left, right) => right.startedAt - left.startedAt); +} + +export async function getSessionAnalytics(): Promise { + const database = await openDatabase(); + const transaction = database.transaction(ANALYTICS_CACHE_STORE, 'readonly'); + const completed = indexedDbTransactionComplete(transaction); + const value: unknown = await indexedDbRequestResult( + transaction.objectStore(ANALYTICS_CACHE_STORE).get(SESSION_ANALYTICS_CACHE_ID) + ); + await completed; + return restoreSessionAnalyticsCache(value) ?? buildSessionAnalyticsCache([]); +} + export async function listAllSavedSessions(): Promise { const database = await openDatabase(); const transaction = database.transaction(SESSION_STORE, 'readonly'); diff --git a/src/lib/session-analytics.ts b/src/lib/session-analytics.ts new file mode 100644 index 0000000..58ca5ad --- /dev/null +++ b/src/lib/session-analytics.ts @@ -0,0 +1,598 @@ +import { z } from 'zod'; +import type { MetricAggregate, SavedSession } from '../types'; +import { nonNegativeNumber } from './numbers'; +import { averageSpeed } from './units'; + +export const SESSION_ANALYTICS_CACHE_ID = 'all-sessions'; +export const SESSION_ANALYTICS_SCHEMA_VERSION = 1; + +export const SESSION_ANALYTICS_PERIOD = { + MONTH: 'month', + WEEK: 'week', + YEAR: 'year', +} as const; + +export type SessionAnalyticsPeriod = + (typeof SESSION_ANALYTICS_PERIOD)[keyof typeof SESSION_ANALYTICS_PERIOD]; + +export const SESSION_TREND_METRIC = { + AVERAGE_CADENCE: 'averageCadence', + AVERAGE_GEAR: 'averageGear', + AVERAGE_HEART_RATE: 'averageHeartRate', + AVERAGE_POWER: 'averagePower', + AVERAGE_RESISTANCE: 'averageResistance', + AVERAGE_SPEED: 'averageSpeed', + CALORIES: 'calories', + CLIMBING: 'climbing', + DISTANCE: 'distance', + DOWNHILL: 'downhill', + RIDE_TIME: 'rideTime', + RIDES: 'rides', +} as const; + +export type SessionTrendMetric = (typeof SESSION_TREND_METRIC)[keyof typeof SESSION_TREND_METRIC]; + +export const SESSION_ANALYTICS_PEAK = { + CADENCE: 'cadence', + CALORIES: 'calories', + CLIMB: 'climb', + DESCENT: 'descent', + DISTANCE: 'distance', + DURATION: 'duration', + HEART_RATE: 'heartRate', + POWER: 'power', + SPEED: 'speed', +} as const; + +export type SessionAnalyticsPeakKey = + (typeof SESSION_ANALYTICS_PEAK)[keyof typeof SESSION_ANALYTICS_PEAK]; + +export interface SessionAnalyticsMetricTotal { + count: number; + sum: number; +} + +export interface SessionAnalyticsRollup { + ascent: number; + cadence: SessionAnalyticsMetricTotal; + calories: number; + descent: number; + distance: number; + elapsedSeconds: number; + gear: SessionAnalyticsMetricTotal; + heartRate: SessionAnalyticsMetricTotal; + power: SessionAnalyticsMetricTotal; + resistance: SessionAnalyticsMetricTotal; + sessionCount: number; +} + +export interface SessionAnalyticsPeak { + sessionId: string; + value: number; +} + +export type SessionAnalyticsPeaks = Record< + SessionAnalyticsPeakKey, + SessionAnalyticsPeak | undefined +>; + +export interface SessionAnalyticsContribution { + dayKey: string; + id: string; + monthKey: string; + peaks: Record; + rollup: SessionAnalyticsRollup; + weekKey: string; + yearKey: string; +} + +export interface SessionAnalyticsCache { + id: typeof SESSION_ANALYTICS_CACHE_ID; + peaks: SessionAnalyticsPeaks; + periods: { + days: Record; + months: Record; + weeks: Record; + years: Record; + }; + schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION; + totals: SessionAnalyticsRollup; + updatedAt: number; +} + +const metricTotalSchema = z.object({ + count: z.number().finite().nonnegative(), + sum: z.number().finite().nonnegative(), +}); + +const rollupSchema = z.object({ + ascent: z.number().finite().nonnegative(), + cadence: metricTotalSchema, + calories: z.number().finite().nonnegative(), + descent: z.number().finite().nonnegative(), + distance: z.number().finite().nonnegative(), + elapsedSeconds: z.number().finite().nonnegative(), + gear: metricTotalSchema, + heartRate: metricTotalSchema, + power: metricTotalSchema, + resistance: metricTotalSchema, + sessionCount: z.number().finite().nonnegative(), +}); + +const peakSchema = z + .object({ + sessionId: z.string().min(1), + value: z.number().finite().nonnegative(), + }) + .optional(); + +const peaksSchema = z.object({ + cadence: peakSchema, + calories: peakSchema, + climb: peakSchema, + descent: peakSchema, + distance: peakSchema, + duration: peakSchema, + heartRate: peakSchema, + power: peakSchema, + speed: peakSchema, +}); + +const contributionSchema = z.object({ + dayKey: z.string().min(1), + id: z.string().min(1), + monthKey: z.string().min(1), + peaks: z.object({ + cadence: z.number().finite().nonnegative(), + calories: z.number().finite().nonnegative(), + climb: z.number().finite().nonnegative(), + descent: z.number().finite().nonnegative(), + distance: z.number().finite().nonnegative(), + duration: z.number().finite().nonnegative(), + heartRate: z.number().finite().nonnegative(), + power: z.number().finite().nonnegative(), + speed: z.number().finite().nonnegative(), + }), + rollup: rollupSchema, + weekKey: z.string().min(1), + yearKey: z.string().min(1), +}); + +const cacheSchema = z.object({ + id: z.literal(SESSION_ANALYTICS_CACHE_ID), + peaks: peaksSchema, + periods: z.object({ + days: z.record(z.string(), rollupSchema), + months: z.record(z.string(), rollupSchema), + weeks: z.record(z.string(), rollupSchema), + years: z.record(z.string(), rollupSchema), + }), + schemaVersion: z.literal(SESSION_ANALYTICS_SCHEMA_VERSION), + totals: rollupSchema, + updatedAt: z.number().finite().nonnegative(), +}); + +const PEAK_KEYS = Object.values(SESSION_ANALYTICS_PEAK); +const YEAR_KEY_PATTERN = /^\d{4}$/; + +function emptyMetricTotal(): SessionAnalyticsMetricTotal { + return { count: 0, sum: 0 }; +} + +export function emptySessionAnalyticsRollup(): SessionAnalyticsRollup { + return { + ascent: 0, + cadence: emptyMetricTotal(), + calories: 0, + descent: 0, + distance: 0, + elapsedSeconds: 0, + gear: emptyMetricTotal(), + heartRate: emptyMetricTotal(), + power: emptyMetricTotal(), + resistance: emptyMetricTotal(), + sessionCount: 0, + }; +} + +function emptySessionAnalyticsPeaks(): SessionAnalyticsPeaks { + return { + cadence: undefined, + calories: undefined, + climb: undefined, + descent: undefined, + distance: undefined, + duration: undefined, + heartRate: undefined, + power: undefined, + speed: undefined, + }; +} + +export function emptySessionAnalyticsCache(updatedAt = Date.now()): SessionAnalyticsCache { + return { + id: SESSION_ANALYTICS_CACHE_ID, + peaks: emptySessionAnalyticsPeaks(), + periods: { + days: {}, + months: {}, + weeks: {}, + years: {}, + }, + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + totals: emptySessionAnalyticsRollup(), + updatedAt, + }; +} + +function metricTotal(aggregate: MetricAggregate): SessionAnalyticsMetricTotal { + return { + count: Math.max(0, Math.round(nonNegativeNumber(aggregate.count))), + sum: nonNegativeNumber(aggregate.sum), + }; +} + +function metricMaximum(aggregate: MetricAggregate): number { + return nonNegativeNumber(aggregate.maximum); +} + +function paddedDatePart(value: number): string { + return value.toString().padStart(2, '0'); +} + +export function localSessionDateKey(timestamp: number): string { + const date = new Date(timestamp); + return `${date.getFullYear()}-${paddedDatePart(date.getMonth() + 1)}-${paddedDatePart(date.getDate())}`; +} + +function localSessionWeekKey(timestamp: number): string { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + const daysSinceMonday = (date.getDay() + 6) % 7; + date.setDate(date.getDate() - daysSinceMonday); + return localSessionDateKey(date.getTime()); +} + +export function sessionAnalyticsContribution( + session: Pick< + SavedSession, + | 'aggregates' + | 'calories' + | 'distance' + | 'elapsedSeconds' + | 'elevationTotals' + | 'id' + | 'maximums' + | 'startedAt' + > +): SessionAnalyticsContribution { + const date = new Date(session.startedAt); + const ascent = nonNegativeNumber(session.elevationTotals.ascent); + const cadence = metricTotal(session.aggregates.cadence); + const calories = nonNegativeNumber(session.calories); + const descent = nonNegativeNumber(session.elevationTotals.descent); + const gear = metricTotal(session.aggregates.gear); + const heartRate = metricTotal(session.aggregates.heartRate); + const power = metricTotal(session.aggregates.power); + const resistance = metricTotal(session.aggregates.resistance); + const distance = nonNegativeNumber(session.distance); + const elapsedSeconds = nonNegativeNumber(session.elapsedSeconds); + return { + dayKey: localSessionDateKey(session.startedAt), + id: session.id, + monthKey: `${date.getFullYear()}-${paddedDatePart(date.getMonth() + 1)}`, + peaks: { + cadence: metricMaximum(session.aggregates.cadence), + calories, + climb: ascent, + descent, + distance, + duration: elapsedSeconds, + heartRate: metricMaximum(session.aggregates.heartRate), + power: metricMaximum(session.aggregates.power), + speed: nonNegativeNumber(session.maximums.speed), + }, + rollup: { + ascent, + cadence, + calories, + descent, + distance, + elapsedSeconds, + gear, + heartRate, + power, + resistance, + sessionCount: 1, + }, + weekKey: localSessionWeekKey(session.startedAt), + yearKey: date.getFullYear().toString(), + }; +} + +function cloneMetricTotal(total: SessionAnalyticsMetricTotal): SessionAnalyticsMetricTotal { + return { ...total }; +} + +function cloneRollup(rollup: SessionAnalyticsRollup): SessionAnalyticsRollup { + return { + ...rollup, + cadence: cloneMetricTotal(rollup.cadence), + gear: cloneMetricTotal(rollup.gear), + heartRate: cloneMetricTotal(rollup.heartRate), + power: cloneMetricTotal(rollup.power), + resistance: cloneMetricTotal(rollup.resistance), + }; +} + +function clonePeriodRollups( + rollups: Record +): Record { + return Object.fromEntries( + Object.entries(rollups).map(([key, rollup]) => [key, cloneRollup(rollup)]) + ); +} + +function cloneCache(cache: SessionAnalyticsCache): SessionAnalyticsCache { + return { + ...cache, + peaks: Object.fromEntries( + PEAK_KEYS.map((key) => [key, cache.peaks[key] ? { ...cache.peaks[key] } : undefined]) + ) as SessionAnalyticsPeaks, + periods: { + days: clonePeriodRollups(cache.periods.days), + months: clonePeriodRollups(cache.periods.months), + weeks: clonePeriodRollups(cache.periods.weeks), + years: clonePeriodRollups(cache.periods.years), + }, + totals: cloneRollup(cache.totals), + }; +} + +function adjustedValue(current: number, change: number): number { + const adjusted = current + change; + return adjusted > 0 ? adjusted : 0; +} + +function adjustMetricTotal( + current: SessionAnalyticsMetricTotal, + contribution: SessionAnalyticsMetricTotal, + direction: 1 | -1 +): void { + current.count = adjustedValue(current.count, direction * contribution.count); + current.sum = adjustedValue(current.sum, direction * contribution.sum); +} + +function adjustRollup( + current: SessionAnalyticsRollup, + contribution: SessionAnalyticsRollup, + direction: 1 | -1 +): void { + current.ascent = adjustedValue(current.ascent, direction * contribution.ascent); + current.calories = adjustedValue(current.calories, direction * contribution.calories); + current.descent = adjustedValue(current.descent, direction * contribution.descent); + current.distance = adjustedValue(current.distance, direction * contribution.distance); + current.elapsedSeconds = adjustedValue( + current.elapsedSeconds, + direction * contribution.elapsedSeconds + ); + current.sessionCount = adjustedValue( + current.sessionCount, + direction * contribution.sessionCount + ); + adjustMetricTotal(current.cadence, contribution.cadence, direction); + adjustMetricTotal(current.gear, contribution.gear, direction); + adjustMetricTotal(current.heartRate, contribution.heartRate, direction); + adjustMetricTotal(current.power, contribution.power, direction); + adjustMetricTotal(current.resistance, contribution.resistance, direction); +} + +function periodBuckets( + cache: SessionAnalyticsCache, + contribution: SessionAnalyticsContribution +): [Record, string][] { + return [ + [cache.periods.days, contribution.dayKey], + [cache.periods.weeks, contribution.weekKey], + [cache.periods.months, contribution.monthKey], + [cache.periods.years, contribution.yearKey], + ]; +} + +function adjustContribution( + cache: SessionAnalyticsCache, + contribution: SessionAnalyticsContribution, + direction: 1 | -1 +): void { + adjustRollup(cache.totals, contribution.rollup, direction); + for (const [bucket, key] of periodBuckets(cache, contribution)) { + const rollup = bucket[key] ?? emptySessionAnalyticsRollup(); + adjustRollup(rollup, contribution.rollup, direction); + if (rollup.sessionCount === 0) { + delete bucket[key]; + } else { + bucket[key] = rollup; + } + } +} + +function addContributionPeaks( + peaks: SessionAnalyticsPeaks, + contribution: SessionAnalyticsContribution +): void { + for (const key of PEAK_KEYS) { + const current = peaks[key]; + const value = contribution.peaks[key]; + if (!current || value > current.value) { + peaks[key] = { sessionId: contribution.id, value }; + } + } +} + +export function updateSessionAnalyticsCache( + current: SessionAnalyticsCache | undefined, + previous: SessionAnalyticsContribution | undefined, + next: SessionAnalyticsContribution | undefined, + updatedAt = Date.now() +): { cache: SessionAnalyticsCache; peaksNeedRebuild: boolean } { + const cache = cloneCache(current ?? emptySessionAnalyticsCache(updatedAt)); + let peaksNeedRebuild = false; + if (previous) { + adjustContribution(cache, previous, -1); + peaksNeedRebuild = PEAK_KEYS.some((key) => cache.peaks[key]?.sessionId === previous.id); + } + if (next) { + adjustContribution(cache, next, 1); + addContributionPeaks(cache.peaks, next); + } + cache.updatedAt = updatedAt; + return { cache, peaksNeedRebuild }; +} + +export function rebuildSessionAnalyticsPeaks( + cache: SessionAnalyticsCache, + contributions: SessionAnalyticsContribution[] +): SessionAnalyticsCache { + const updated = cloneCache(cache); + updated.peaks = emptySessionAnalyticsPeaks(); + for (const contribution of contributions) { + addContributionPeaks(updated.peaks, contribution); + } + return updated; +} + +export function buildSessionAnalyticsCache( + contributions: SessionAnalyticsContribution[], + updatedAt = Date.now() +): SessionAnalyticsCache { + let cache = emptySessionAnalyticsCache(updatedAt); + for (const contribution of contributions) { + ({ cache } = updateSessionAnalyticsCache(cache, undefined, contribution, updatedAt)); + } + return cache; +} + +export function restoreSessionAnalyticsCache(value: unknown): SessionAnalyticsCache | undefined { + const parsed = cacheSchema.safeParse(value); + if (!parsed.success) { + return; + } + return { + ...parsed.data, + peaks: { + cadence: parsed.data.peaks.cadence, + calories: parsed.data.peaks.calories, + climb: parsed.data.peaks.climb, + descent: parsed.data.peaks.descent, + distance: parsed.data.peaks.distance, + duration: parsed.data.peaks.duration, + heartRate: parsed.data.peaks.heartRate, + power: parsed.data.peaks.power, + speed: parsed.data.peaks.speed, + }, + }; +} + +export function restoreSessionAnalyticsContribution( + value: unknown +): SessionAnalyticsContribution | undefined { + const parsed = contributionSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; +} + +export function sessionAnalyticsMetricAverage(total: SessionAnalyticsMetricTotal): number { + return total.count > 0 ? total.sum / total.count : 0; +} + +export function sessionAnalyticsAverageSpeed(rollup: SessionAnalyticsRollup): number { + return averageSpeed(rollup.distance, rollup.elapsedSeconds); +} + +export function sessionAnalyticsPeriodRollups( + cache: SessionAnalyticsCache, + period: SessionAnalyticsPeriod +): [string, SessionAnalyticsRollup][] { + return Object.entries(sessionAnalyticsRollupsForPeriod(cache, period)).sort(([left], [right]) => + left.localeCompare(right) + ); +} + +function sessionAnalyticsRollupsForPeriod( + cache: SessionAnalyticsCache, + period: SessionAnalyticsPeriod +): Record { + switch (period) { + case SESSION_ANALYTICS_PERIOD.WEEK: + return cache.periods.weeks; + case SESSION_ANALYTICS_PERIOD.MONTH: + return cache.periods.months; + case SESSION_ANALYTICS_PERIOD.YEAR: + return cache.periods.years; + default: + throw new Error('Unsupported session analytics period.'); + } +} + +function trendPeriodKey( + period: SessionAnalyticsPeriod, + endTimestamp: number, + offset: number +): string { + const date = new Date(endTimestamp); + date.setHours(12, 0, 0, 0); + switch (period) { + case SESSION_ANALYTICS_PERIOD.WEEK: + date.setDate(date.getDate() + offset * 7); + return localSessionWeekKey(date.getTime()); + case SESSION_ANALYTICS_PERIOD.MONTH: + date.setDate(1); + date.setMonth(date.getMonth() + offset); + return `${date.getFullYear()}-${paddedDatePart(date.getMonth() + 1)}`; + case SESSION_ANALYTICS_PERIOD.YEAR: + return (date.getFullYear() + offset).toString(); + default: + throw new Error('Unsupported session analytics period.'); + } +} + +export function sessionAnalyticsTrendRollups( + cache: SessionAnalyticsCache, + period: SessionAnalyticsPeriod, + endTimestamp = Date.now() +): [string, SessionAnalyticsRollup][] { + const periodCount = period === SESSION_ANALYTICS_PERIOD.YEAR ? 10 : 12; + const rollups = sessionAnalyticsRollupsForPeriod(cache, period); + return Array.from({ length: periodCount }, (_, index) => { + const key = trendPeriodKey(period, endTimestamp, index - periodCount + 1); + return [key, rollups[key] ?? emptySessionAnalyticsRollup()]; + }); +} + +export function sessionAnalyticsCompleteTrendRollups( + cache: SessionAnalyticsCache +): [string, SessionAnalyticsRollup][] { + const yearKeys = Object.keys(cache.periods.years) + .filter((key) => YEAR_KEY_PATTERN.test(key)) + .sort(); + const firstYear = Number(yearKeys[0]); + const lastYear = Number(yearKeys.at(-1)); + if (!(Number.isFinite(firstYear) && Number.isFinite(lastYear))) { + return []; + } + return Array.from({ length: lastYear - firstYear + 1 }, (_, index) => { + const key = (firstYear + index).toString(); + return [key, cache.periods.years[key] ?? emptySessionAnalyticsRollup()]; + }); +} + +export function sessionAnalyticsPeriodLabel(key: string, period: SessionAnalyticsPeriod): string { + if (period === SESSION_ANALYTICS_PERIOD.YEAR) { + return key; + } + const [year = 0, month = 1, day = 1] = key.split('-').map(Number); + const date = new Date(year, month - 1, day); + if (period === SESSION_ANALYTICS_PERIOD.MONTH) { + return new Intl.DateTimeFormat(undefined, { month: 'short', year: '2-digit' }).format(date); + } + return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short' }).format(date); +} diff --git a/src/lib/session-calendar.ts b/src/lib/session-calendar.ts new file mode 100644 index 0000000..4af6978 --- /dev/null +++ b/src/lib/session-calendar.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import type { SavedSessionSummary } from '../types'; +import { localSessionDateKey } from './session-analytics'; + +export const sessionCalendarMonthKeySchema = z.string().regex(/^[1-9]\d{3}-(?:0[1-9]|1[0-2])$/); + +export interface SessionCalendarDay { + date: Date; + inCurrentMonth: boolean; + key: string; + sessions: SavedSessionSummary[]; +} + +export function sessionCalendarMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +export function sessionCalendarMonthFromKey(value: unknown): Date | undefined { + const parsed = sessionCalendarMonthKeySchema.safeParse(value); + if (!parsed.success) { + return; + } + const [year, month] = parsed.data.split('-').map(Number); + return new Date(year ?? 0, (month ?? 1) - 1, 1); +} + +export function sessionCalendarMonthKey(date: Date): string { + const month = sessionCalendarMonth(date); + return `${month.getFullYear()}-${(month.getMonth() + 1).toString().padStart(2, '0')}`; +} + +export function moveSessionCalendarMonth(date: Date, months: number): Date { + return new Date(date.getFullYear(), date.getMonth() + months, 1); +} + +export function sessionsByLocalDate( + summaries: SavedSessionSummary[] +): Map { + const grouped = new Map(); + for (const session of summaries) { + const date = new Date(session.startedAt); + date.setHours(0, 0, 0, 0); + const lastDate = new Date( + session.elapsedSeconds > 0 && session.endedAt > session.startedAt + ? session.endedAt + : session.startedAt + ); + lastDate.setHours(0, 0, 0, 0); + while (date <= lastDate) { + const key = localSessionDateKey(date.getTime()); + const sessions = grouped.get(key); + if (sessions) { + sessions.push(session); + } else { + grouped.set(key, [session]); + } + date.setDate(date.getDate() + 1); + } + } + for (const sessions of grouped.values()) { + sessions.sort((left, right) => left.startedAt - right.startedAt); + } + return grouped; +} + +export function sessionCalendarDays( + month: Date, + summaries: SavedSessionSummary[] +): SessionCalendarDay[] { + const first = sessionCalendarMonth(month); + const leadingDays = (first.getDay() + 6) % 7; + const start = new Date(first.getFullYear(), first.getMonth(), 1 - leadingDays); + const last = new Date(first.getFullYear(), first.getMonth() + 1, 0); + const occupiedCells = leadingDays + last.getDate(); + const cellCount = Math.ceil(occupiedCells / 7) * 7; + const grouped = sessionsByLocalDate(summaries); + return Array.from({ length: cellCount }, (_, index) => { + const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + index); + const key = localSessionDateKey(date.getTime()); + return { + date, + inCurrentMonth: date.getMonth() === first.getMonth(), + key, + sessions: grouped.get(key) ?? [], + }; + }); +} diff --git a/src/lib/session-history-preferences.ts b/src/lib/session-history-preferences.ts index 09b3755..3435091 100644 --- a/src/lib/session-history-preferences.ts +++ b/src/lib/session-history-preferences.ts @@ -1,6 +1,13 @@ +import { + ACTIVITY_FILE_FORMAT, + type ActivityFileFormat, + isActivityFileFormat, +} from './activity-file'; + export const SESSION_HISTORY_SCROLL_POSITION_STORAGE_KEY = 'ride-control-session-history-scroll-position'; export const SESSION_HISTORY_SELECTION_STORAGE_KEY = 'ride-control-selected-session'; +export const SESSION_HISTORY_DOWNLOAD_FORMAT_STORAGE_KEY = 'ride-control-session-download-format'; const SESSION_DETAIL_SCROLL_POSITION_STORAGE_KEY_PREFIX = 'ride-control-session-detail-scroll-position'; @@ -33,3 +40,26 @@ export function saveSelectedSessionId( return false; } } + +export function loadSessionDownloadFormat( + storage: Pick = localStorage +): ActivityFileFormat { + try { + const format = storage.getItem(SESSION_HISTORY_DOWNLOAD_FORMAT_STORAGE_KEY); + return format && isActivityFileFormat(format) ? format : ACTIVITY_FILE_FORMAT.TCX; + } catch { + return ACTIVITY_FILE_FORMAT.TCX; + } +} + +export function saveSessionDownloadFormat( + format: ActivityFileFormat, + storage: Pick = localStorage +): boolean { + try { + storage.setItem(SESSION_HISTORY_DOWNLOAD_FORMAT_STORAGE_KEY, format); + return true; + } catch { + return false; + } +} diff --git a/src/lib/session-history-view.ts b/src/lib/session-history-view.ts new file mode 100644 index 0000000..36d9efc --- /dev/null +++ b/src/lib/session-history-view.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const SESSION_HISTORY_VIEW = { + CALENDAR: 'calendar', + LIST: 'list', + STATISTICS: 'statistics', +} as const; + +export type SessionHistoryView = (typeof SESSION_HISTORY_VIEW)[keyof typeof SESSION_HISTORY_VIEW]; + +export const sessionHistoryViewSchema = z.enum([ + SESSION_HISTORY_VIEW.CALENDAR, + SESSION_HISTORY_VIEW.LIST, + SESSION_HISTORY_VIEW.STATISTICS, +]); + +export const SESSION_HISTORY_VIEW_OPTIONS: { label: string; value: SessionHistoryView }[] = [ + { label: 'Calendar', value: SESSION_HISTORY_VIEW.CALENDAR }, + { label: 'List', value: SESSION_HISTORY_VIEW.LIST }, + { label: 'Statistics', value: SESSION_HISTORY_VIEW.STATISTICS }, +]; + +const SESSION_HISTORY_VIEW_STORAGE_KEY = 'ride-control-session-history-view'; + +export function isSessionHistoryView(value: string | null): value is SessionHistoryView { + return sessionHistoryViewSchema.safeParse(value).success; +} + +export function loadSessionHistoryView( + storage: Pick = localStorage +): SessionHistoryView { + try { + const value = storage.getItem(SESSION_HISTORY_VIEW_STORAGE_KEY); + return isSessionHistoryView(value) ? value : SESSION_HISTORY_VIEW.CALENDAR; + } catch { + return SESSION_HISTORY_VIEW.CALENDAR; + } +} + +export function saveSessionHistoryView( + view: SessionHistoryView, + storage: Pick = localStorage +): boolean { + try { + storage.setItem(SESSION_HISTORY_VIEW_STORAGE_KEY, view); + return true; + } catch { + return false; + } +} diff --git a/src/router.ts b/src/router.ts index c4809bc..6bb3d45 100644 --- a/src/router.ts +++ b/src/router.ts @@ -6,11 +6,27 @@ import { redirect, } from '@tanstack/react-router'; import { createElement } from 'react'; +import { z } from 'zod'; import { App } from './app'; import { emptySession } from './constants'; import { APP_ROUTE_PATH } from './lib/app-route'; +import { sessionCalendarMonthKeySchema } from './lib/session-calendar'; +import { sessionHistoryViewSchema } from './lib/session-history-view'; import type { StoredSession } from './types'; +const sessionSearchSchema = z.object({ + date: sessionCalendarMonthKeySchema.optional(), + view: sessionHistoryViewSchema.optional(), +}); + +function validateSessionSearch(search: unknown): { + date?: string; + view?: z.infer; +} { + const parsed = sessionSearchSchema.safeParse(search); + return parsed.success ? parsed.data : {}; +} + export interface AppRouterOptions { history?: RouterHistory; initialSession?: StoredSession; @@ -27,8 +43,16 @@ export function createAppRouter({ history, initialSession = emptySession }: AppR createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.WORKOUT }), createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.BIKEGPX }), createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.BIKEGPX_ROUTE }), - createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.SESSIONS }), - createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.SESSION }), + createRoute({ + getParentRoute: () => rootRoute, + path: APP_ROUTE_PATH.SESSIONS, + validateSearch: validateSessionSearch, + }), + createRoute({ + getParentRoute: () => rootRoute, + path: APP_ROUTE_PATH.SESSION, + validateSearch: validateSessionSearch, + }), createRoute({ beforeLoad: () => { throw redirect({ replace: true, to: APP_ROUTE_PATH.HOME }); diff --git a/tests/app-route.test.ts b/tests/app-route.test.ts index 5ee4a03..9626a93 100644 --- a/tests/app-route.test.ts +++ b/tests/app-route.test.ts @@ -38,10 +38,13 @@ describe('application deep links', () => { workoutId: 'prairie roll', }); - const session = await loadedRoute('/sessions/ride%2Fmorning'); + const session = await loadedRoute('/sessions/ride%2Fmorning?date=2026-07&view=statistics'); expect(session.match?.routeId).toBe(APP_ROUTE_PATH.SESSION); expect(session.match?.params).toEqual({ sessionId: 'ride/morning' }); + expect(session.match?.search).toEqual({ date: '2026-07', view: 'statistics' }); expect(appRouteFromRouterMatch(session.match)).toEqual({ + calendarMonth: '2026-07', + historyView: 'statistics', kind: APP_ROUTE_KIND.SESSION, sessionId: 'ride/morning', }); @@ -57,6 +60,23 @@ describe('application deep links', () => { expect((await loadedRoute('/bikegpx')).match?.routeId).toBe(APP_ROUTE_PATH.BIKEGPX); expect((await loadedRoute('/workouts')).match?.routeId).toBe(APP_ROUTE_PATH.WORKOUTS); expect((await loadedRoute('/sessions')).match?.routeId).toBe(APP_ROUTE_PATH.SESSIONS); + const calendar = await loadedRoute('/sessions?date=2025-12&view=calendar'); + expect(appRouteFromRouterMatch(calendar.match)).toEqual({ + calendarMonth: '2025-12', + historyView: 'calendar', + kind: APP_ROUTE_KIND.SESSION, + }); + expect( + appRouteFromRouterMatch((await loadedRoute('/sessions?date=2025-13')).match) + ).toEqual({ kind: APP_ROUTE_KIND.SESSION }); + expect( + appRouteFromRouterMatch( + (await loadedRoute('/sessions?date=2025-12&view=unknown')).match + ) + ).toEqual({ + calendarMonth: '2025-12', + kind: APP_ROUTE_KIND.SESSION, + }); expect((await loadedRoute('/unknown/path')).redirectHref).toBe(APP_ROUTE_PATH.HOME); expect((await loadedRoute('/devices/trainer')).redirectHref).toBe(APP_ROUTE_PATH.HOME); }); @@ -81,9 +101,16 @@ describe('application deep links', () => { expect( router.buildLocation({ params: { sessionId: 'ride#1' }, + search: { date: '2026-07', view: 'list' }, to: APP_ROUTE_PATH.SESSION, }).href - ).toBe('/sessions/ride%231'); + ).toBe('/sessions/ride%231?date=2026-07&view=list'); + expect( + router.buildLocation({ + search: { date: '2026-07', view: 'calendar' }, + to: APP_ROUTE_PATH.SESSIONS, + }).href + ).toBe('/sessions?date=2026-07&view=calendar'); expect(appRouteSideTray({ kind: APP_ROUTE_KIND.BIKEGPX })).toBe(APP_OVERLAY.WORKOUTS); expect(appRouteSideTray({ kind: APP_ROUTE_KIND.WORKOUT })).toBe(APP_OVERLAY.WORKOUTS); @@ -106,4 +133,50 @@ describe('application deep links', () => { await router.load(); expect(router.state.location.pathname).toBe(APP_ROUTE_PATH.DEVICES); }); + + test('moves calendar months through linkable browser history', async () => { + const history = createMemoryHistory({ + initialEntries: ['/sessions?date=2025-12'], + }); + const router = createAppRouter({ history }); + await router.load(); + await router.navigate({ + search: { date: '2026-01' }, + to: APP_ROUTE_PATH.SESSIONS, + }); + expect(router.state.location.href).toBe('/sessions?date=2026-01'); + + router.history.back(); + await router.load(); + expect(router.state.location.href).toBe('/sessions?date=2025-12'); + + router.history.forward(); + await router.load(); + expect(router.state.location.href).toBe('/sessions?date=2026-01'); + }); + + test('moves session views through linkable browser history', async () => { + const history = createMemoryHistory({ + initialEntries: ['/sessions?date=2025-12&view=calendar'], + }); + const router = createAppRouter({ history }); + await router.load(); + await router.navigate({ + search: { date: '2025-12', view: 'list' }, + to: APP_ROUTE_PATH.SESSIONS, + }); + await router.navigate({ + search: { date: '2025-12', view: 'statistics' }, + to: APP_ROUTE_PATH.SESSIONS, + }); + expect(router.state.location.href).toBe('/sessions?date=2025-12&view=statistics'); + + router.history.back(); + await router.load(); + expect(router.state.location.href).toBe('/sessions?date=2025-12&view=list'); + + router.history.back(); + await router.load(); + expect(router.state.location.href).toBe('/sessions?date=2025-12&view=calendar'); + }); }); diff --git a/tests/components.test.tsx b/tests/components.test.tsx index cd2aea4..47f9986 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -13,12 +13,14 @@ import { Notification } from '../src/components/notification'; import { ProfileDialog } from '../src/components/profile-dialog'; import { RenameWorkoutDialog } from '../src/components/rename-workout-dialog'; import { ResistanceControl } from '../src/components/resistance-control'; +import { SessionCalendar } from '../src/components/session-calendar'; import { SessionChart } from '../src/components/session-chart'; import { SessionControls } from '../src/components/session-controls'; import { DeleteSessionDialog, SessionDetail } from '../src/components/session-detail'; import { SessionHistory } from '../src/components/session-history'; import { SessionHistoryList } from '../src/components/session-history-list'; import { SessionSaveDialog } from '../src/components/session-save-dialog'; +import { SessionStatistics } from '../src/components/session-statistics'; import { TrainingControl } from '../src/components/training-control'; import { WelcomeDialog } from '../src/components/welcome-dialog'; import { WorkoutPanel } from '../src/components/workout-panel'; @@ -33,6 +35,11 @@ import { formatGrade } from '../src/lib/format'; import { historyKeyboardShortcuts } from '../src/lib/keyboard'; import { metricAccentClass, metricIconClass } from '../src/lib/metric-presentation'; import { formatSessionImportTime, sessionSummary } from '../src/lib/saved-sessions'; +import { + buildSessionAnalyticsCache, + SESSION_TREND_METRIC, + sessionAnalyticsContribution, +} from '../src/lib/session-analytics'; import { SESSION_WORKFLOW_INTENT } from '../src/lib/session-workflow'; import { WORKOUT_DESCRIPTION_ATTRIBUTION } from '../src/lib/workout-description'; import { WORKOUT_ROUTE_TYPE } from '../src/lib/workout-schema'; @@ -1322,6 +1329,8 @@ describe('view components', () => { const html = render( undefined} + onSelectCalendarMonth={() => undefined} + onSelectView={() => undefined} onStartNew={() => undefined} open speedUnit="kmh" @@ -1331,17 +1340,24 @@ describe('view components', () => { expect(html).toContain('0 sessions'); expect(html).not.toContain('Saved on this device'); expect(html).toContain('data-side-tray="true"'); - expect(html).toContain('No saved sessions yet'); - expect(html).toContain('data-testid="session-list"'); + expect(html).toContain('data-testid="session-calendar"'); + expect(html).toContain('No rides on this day'); + expect(html).toContain('Calendar'); + expect(html).toContain('List'); + expect(html).toContain('Statistics'); + expect(html).toContain('role="tablist"'); + expect(html.match(/role="tab"/g)).toHaveLength(3); + expect(html).toContain('aria-selected="true"'); + expect(html).toContain('role="tabpanel"'); + expect(html).toContain('border-cyan-400 text-white'); expect(html).toContain('min-h-0 min-w-0 flex-1 flex-col overflow-hidden'); expect(html).toContain('overflow-y-auto overflow-x-hidden'); expect(html).toContain('Import FIT/TCX'); expect(html).toContain('data-testid="download-all-sessions"'); - expect(html).toContain('aria-label="Download all sessions as FIT"'); + expect(html).toContain('aria-label="Download all sessions as TCX"'); expect(html).toContain('aria-label="Download all format"'); expect(html).toContain('Download all'); expect(html).toContain('.tcx,.zip'); - expect(html).toContain('End a session or import a FIT or TCX file to add it here.'); expect(html).toContain('ml-auto'); expect(html).toContain('translate-x-0'); expect(html).toContain('Show history keyboard controls'); @@ -1350,6 +1366,111 @@ describe('view components', () => { expect(html.match(/hover:text-white sm:static/g)).toHaveLength(2); }); + test('renders a monthly ride calendar with selectable events', () => { + const month = new Date(2026, 6, 1); + const morning = { + ...sessionSummary({ + ...savedSessionFixture, + id: 'morning-ride', + startedAt: new Date(2026, 6, 18, 8).getTime(), + }), + workoutName: 'Prairie Roll', + }; + const evening = { + ...sessionSummary({ + ...savedSessionFixture, + id: 'evening-ride', + startedAt: new Date(2026, 6, 18, 18).getTime(), + }), + workoutName: 'Harbor Ring', + }; + const html = render( + undefined} + onSelect={() => undefined} + selectedId={morning.id} + speedUnit="mph" + summaries={[evening, morning]} + /> + ); + + expect(html).toContain('July 2026'); + expect(html).toContain('2 rides'); + expect(html).toContain('Prairie Roll'); + expect(html).toContain('Harbor Ring'); + expect(html).toContain('aria-label="Previous month"'); + expect(html).toContain('aria-label="Next month"'); + expect(html).toContain('aria-pressed="true"'); + }); + + test('renders cached all-time statistics and per-period metric charts', () => { + const analytics = buildSessionAnalyticsCache([ + sessionAnalyticsContribution({ + ...savedSessionFixture, + distance: 32, + elapsedSeconds: 3600, + elevationTotals: { ascent: 500, descent: 480 }, + }), + ]); + const html = render( + undefined} + speedUnit="mph" + trendEndTimestamp={new Date(2026, 6, 23, 12).getTime()} + /> + ); + + expect(html).toContain('All-time totals'); + expect(html).toContain('Personal bests'); + expect(html).toContain('Trends'); + expect(html).toContain('Distance'); + expect(html).toContain('Ride time'); + expect(html).toContain('Climbing'); + expect(html).toContain('Downhill'); + expect(html).toContain('Calories'); + expect(html).toContain('Average speed'); + expect(html).toContain('Average power'); + expect(html).toContain('Average cadence'); + expect(html).toContain('Average heart rate'); + expect(html).toContain('Open this session'); + expect(html).toContain('data-testid="session-statistics"'); + expect(html).toContain('aria-label="Trend metric"'); + expect(html).toContain('value="all">All'); + expect(html.match(/>All undefined} + speedUnit="mph" + trendEndTimestamp={new Date(2026, 6, 23, 12).getTime()} + /> + ); + expect(overview).toContain('data-testid="trend-overview"'); + expect(overview).toContain('All trends'); + expect(overview).toContain('12 metrics'); + expect(overview).toContain('Complete ride history'); + expect(overview).toContain('aria-label="Distance trend"'); + expect(overview.match(/data-analytics-overview-bar=/g)).toHaveLength(12); + }); + test('virtualizes a large session history list', () => { const summaries = Array.from({ length: 100 }, (_, index) => ({ ...sessionSummary({ diff --git a/tests/gears.test.ts b/tests/gears.test.ts index 027d9d3..9cb813a 100644 --- a/tests/gears.test.ts +++ b/tests/gears.test.ts @@ -87,6 +87,16 @@ describe('virtual gears', () => { expect(virtualGearLoadMultiplier(DEFAULT_GEAR)).toBe(1); }); + test('keeps the hardest gears loaded through a descent', () => { + const lightRiderAndBikeKg = 68; + expect(resistanceForVirtualGear(4, MIN_GEAR, undefined, lightRiderAndBikeKg)).toBe(1.3); + expect(resistanceForVirtualGear(4, DEFAULT_GEAR, undefined, lightRiderAndBikeKg)).toBe(3.2); + expect(resistanceForVirtualGear(4, 22, undefined, lightRiderAndBikeKg)).toBe(20.7); + expect(resistanceForVirtualGear(4, 23, undefined, lightRiderAndBikeKg)).toBe(27); + expect(resistanceForVirtualGear(4, MAX_GEAR, undefined, lightRiderAndBikeKg)).toBe(34.9); + expect(resistanceAfterGearShift(4, DEFAULT_GEAR, MAX_GEAR)).toBe(34.9); + }); + test('keeps a modest Prairie Roll climb easy in gear one', () => { const prairieRoll = WORKOUT_COURSES.find((course) => course.id === 'prairie-roll'); if (!prairieRoll) { diff --git a/tests/session-analytics-indexeddb.test.ts b/tests/session-analytics-indexeddb.test.ts new file mode 100644 index 0000000..b05b798 --- /dev/null +++ b/tests/session-analytics-indexeddb.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from 'bun:test'; +import 'fake-indexeddb/auto'; +import { + deleteSavedSession, + getSessionAnalytics, + listSavedSessionsForMonth, + saveSession, + sessionSummary, +} from '../src/lib/saved-sessions'; +import { savedSessionFixture } from './fixtures/saved-session'; + +const DATABASE_NAME = 'ridecontrol-sessions'; +const LEGACY_DATABASE_VERSION = 3; + +function requestCompleted(request: IDBRequest | IDBTransaction): Promise { + return new Promise((resolve, reject) => { + request.addEventListener('success', () => resolve(), { once: true }); + request.addEventListener('complete', () => resolve(), { once: true }); + request.addEventListener('error', () => reject(request.error), { once: true }); + request.addEventListener('abort', () => reject((request as IDBTransaction).error), { + once: true, + }); + }); +} + +async function createLegacySessionDatabase(): Promise { + await requestCompleted(indexedDB.deleteDatabase(DATABASE_NAME)); + const openRequest = indexedDB.open(DATABASE_NAME, LEGACY_DATABASE_VERSION); + openRequest.addEventListener( + 'upgradeneeded', + () => { + const sessions = openRequest.result.createObjectStore('sessions', { keyPath: 'id' }); + sessions.createIndex('workoutSnapshotId', 'workoutSnapshotId'); + const summaries = openRequest.result.createObjectStore('session-summaries', { + keyPath: 'id', + }); + summaries.createIndex('endedAt', 'endedAt'); + openRequest.result.createObjectStore('session-workouts', { keyPath: 'id' }); + openRequest.result.createObjectStore('active-session', { keyPath: 'id' }); + openRequest.result.createObjectStore('active-session-samples', { keyPath: 'id' }); + }, + { once: true } + ); + await requestCompleted(openRequest); + const database = openRequest.result; + const transaction = database.transaction(['sessions', 'session-summaries'], 'readwrite'); + transaction.objectStore('sessions').put(savedSessionFixture); + transaction.objectStore('session-summaries').put(sessionSummary(savedSessionFixture)); + await requestCompleted(transaction); + database.close(); +} + +describe('session analytics IndexedDB cache', () => { + test('backfills legacy rides and incrementally handles replacement and deletion', async () => { + await createLegacySessionDatabase(); + + const migrated = await getSessionAnalytics(); + expect(migrated.totals.sessionCount).toBe(1); + expect(migrated.totals.distance).toBe(savedSessionFixture.distance); + expect( + await listSavedSessionsForMonth( + new Date(savedSessionFixture.startedAt).getFullYear(), + new Date(savedSessionFixture.startedAt).getMonth() + ) + ).toHaveLength(1); + + const replacement = { + ...savedSessionFixture, + distance: 10, + elevationTotals: { ascent: 500, descent: 450 }, + }; + await saveSession(replacement); + const afterReplacement = await getSessionAnalytics(); + expect(afterReplacement.totals.sessionCount).toBe(1); + expect(afterReplacement.totals.distance).toBe(10); + expect(afterReplacement.totals.ascent).toBe(500); + + const second = { + ...savedSessionFixture, + distance: 20, + elapsedSeconds: 7200, + endedAt: new Date(2026, 7, 1, 1).getTime(), + id: 'second-session', + startedAt: new Date(2026, 6, 31, 23).getTime(), + }; + await saveSession(second); + const afterAddition = await getSessionAnalytics(); + expect(afterAddition.totals.sessionCount).toBe(2); + expect(afterAddition.totals.distance).toBe(30); + expect(await listSavedSessionsForMonth(2026, 7)).toHaveLength(1); + + await deleteSavedSession(replacement.id); + const afterDelete = await getSessionAnalytics(); + expect(afterDelete.totals.sessionCount).toBe(1); + expect(afterDelete.totals.distance).toBe(20); + expect(afterDelete.peaks.distance).toEqual({ + sessionId: second.id, + value: second.distance, + }); + }); +}); diff --git a/tests/session-analytics.test.ts b/tests/session-analytics.test.ts new file mode 100644 index 0000000..e1decb4 --- /dev/null +++ b/tests/session-analytics.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test'; +import { sessionSummary } from '../src/lib/saved-sessions'; +import { + buildSessionAnalyticsCache, + emptySessionAnalyticsCache, + rebuildSessionAnalyticsPeaks, + restoreSessionAnalyticsCache, + SESSION_ANALYTICS_PEAK, + SESSION_ANALYTICS_PERIOD, + sessionAnalyticsAverageSpeed, + sessionAnalyticsCompleteTrendRollups, + sessionAnalyticsContribution, + sessionAnalyticsMetricAverage, + sessionAnalyticsPeriodRollups, + sessionAnalyticsTrendRollups, + updateSessionAnalyticsCache, +} from '../src/lib/session-analytics'; +import { + sessionCalendarDays, + sessionCalendarMonthFromKey, + sessionCalendarMonthKey, + sessionsByLocalDate, +} from '../src/lib/session-calendar'; +import { savedSessionFixture } from './fixtures/saved-session'; + +function analyticsSession( + id: string, + startedAt: number, + overrides: Partial = {} +) { + return { + ...savedSessionFixture, + ...overrides, + endedAt: + startedAt + (overrides.elapsedSeconds ?? savedSessionFixture.elapsedSeconds) * 1000, + id, + startedAt, + }; +} + +describe('session analytics', () => { + test('builds cached all-time and calendar-period totals from compact contributions', () => { + const first = sessionAnalyticsContribution( + analyticsSession('january', new Date(2026, 0, 5, 8).getTime(), { + distance: 20, + elapsedSeconds: 3600, + elevationTotals: { ascent: 300, descent: 280 }, + }) + ); + const second = sessionAnalyticsContribution( + analyticsSession('february', new Date(2026, 1, 8, 9).getTime(), { + calories: 500, + distance: 40, + elapsedSeconds: 7200, + elevationTotals: { ascent: 600, descent: 590 }, + }) + ); + const cache = buildSessionAnalyticsCache([first, second], 1234); + + expect(cache.updatedAt).toBe(1234); + expect(cache.totals).toMatchObject({ + ascent: 900, + calories: 720, + descent: 870, + distance: 60, + elapsedSeconds: 10_800, + sessionCount: 2, + }); + expect(sessionAnalyticsAverageSpeed(cache.totals)).toBe(20); + expect(sessionAnalyticsMetricAverage(cache.totals.power)).toBe(205); + expect(Object.keys(cache.periods.days)).toHaveLength(2); + expect(Object.keys(cache.periods.weeks)).toHaveLength(2); + expect(Object.keys(cache.periods.months)).toEqual(['2026-01', '2026-02']); + expect(Object.keys(cache.periods.years)).toEqual(['2026']); + expect( + sessionAnalyticsPeriodRollups(cache, SESSION_ANALYTICS_PERIOD.MONTH).map(([key]) => key) + ).toEqual(['2026-01', '2026-02']); + expect(cache.peaks[SESSION_ANALYTICS_PEAK.DISTANCE]).toEqual({ + sessionId: 'february', + value: 40, + }); + expect(cache.peaks[SESSION_ANALYTICS_PEAK.CLIMB]?.value).toBe(600); + }); + + test('pads trend charts with chronological empty periods', () => { + const july = sessionAnalyticsContribution( + analyticsSession('july', new Date(2026, 6, 12, 8).getTime(), { + distance: 42, + }) + ); + const cache = buildSessionAnalyticsCache([july]); + const endTimestamp = new Date(2026, 6, 23, 12).getTime(); + const months = sessionAnalyticsTrendRollups( + cache, + SESSION_ANALYTICS_PERIOD.MONTH, + endTimestamp + ); + const weeks = sessionAnalyticsTrendRollups( + cache, + SESSION_ANALYTICS_PERIOD.WEEK, + endTimestamp + ); + const years = sessionAnalyticsTrendRollups( + cache, + SESSION_ANALYTICS_PERIOD.YEAR, + endTimestamp + ); + + expect(months).toHaveLength(12); + expect(months.at(0)?.[0]).toBe('2025-08'); + expect(months.at(-1)?.[0]).toBe('2026-07'); + expect(months.filter(([, rollup]) => rollup.sessionCount > 0)).toHaveLength(1); + expect(months.at(-1)?.[1].distance).toBe(42); + expect(weeks).toHaveLength(12); + expect(weeks.at(0)?.[0]).toBe('2026-05-04'); + expect(weeks.at(-1)?.[0]).toBe('2026-07-20'); + expect(years.map(([key]) => key)).toEqual([ + '2017', + '2018', + '2019', + '2020', + '2021', + '2022', + '2023', + '2024', + '2025', + '2026', + ]); + }); + + test('shows complete history in chronological yearly buckets', () => { + const first = sessionAnalyticsContribution( + analyticsSession('first', new Date(2023, 2, 12, 8).getTime(), { + distance: 20, + }) + ); + const latest = sessionAnalyticsContribution( + analyticsSession('latest', new Date(2025, 8, 4, 8).getTime(), { + distance: 45, + }) + ); + const complete = sessionAnalyticsCompleteTrendRollups( + buildSessionAnalyticsCache([latest, first]) + ); + + expect(complete.map(([key]) => key)).toEqual(['2023', '2024', '2025']); + expect(complete[0]?.[1].distance).toBe(20); + expect(complete[1]?.[1].sessionCount).toBe(0); + expect(complete[2]?.[1].distance).toBe(45); + expect(sessionAnalyticsCompleteTrendRollups(emptySessionAnalyticsCache())).toEqual([]); + }); + + test('updates replacements and deletions without rescanning telemetry histories', () => { + const original = sessionAnalyticsContribution( + analyticsSession('ride', new Date(2026, 3, 2, 8).getTime(), { + distance: 25, + elevationTotals: { ascent: 500, descent: 450 }, + }) + ); + const other = sessionAnalyticsContribution( + analyticsSession('other', new Date(2026, 3, 9, 8).getTime(), { + distance: 10, + elevationTotals: { ascent: 100, descent: 90 }, + }) + ); + const replacement = sessionAnalyticsContribution( + analyticsSession('ride', new Date(2026, 4, 2, 8).getTime(), { + distance: 30, + elevationTotals: { ascent: 700, descent: 650 }, + }) + ); + const initial = buildSessionAnalyticsCache([original, other], 1); + const replaced = updateSessionAnalyticsCache(initial, original, replacement, 2); + + expect(replaced.cache.totals.distance).toBe(40); + expect(replaced.cache.totals.ascent).toBe(800); + expect(replaced.cache.periods.months['2026-04'].sessionCount).toBe(1); + expect(replaced.cache.periods.months['2026-05'].sessionCount).toBe(1); + expect(replaced.peaksNeedRebuild).toBe(true); + + const rebuilt = rebuildSessionAnalyticsPeaks(replaced.cache, [other, replacement]); + expect(rebuilt.peaks.distance).toEqual({ sessionId: 'ride', value: 30 }); + const deleted = updateSessionAnalyticsCache(rebuilt, replacement, undefined, 3); + const afterDelete = rebuildSessionAnalyticsPeaks(deleted.cache, [other]); + expect(afterDelete.totals.distance).toBe(10); + expect(afterDelete.periods.months['2026-05']).toBeUndefined(); + expect(afterDelete.peaks.distance).toEqual({ sessionId: 'other', value: 10 }); + }); + + test('validates analytics records at the IndexedDB boundary', () => { + const cache = emptySessionAnalyticsCache(42); + expect(restoreSessionAnalyticsCache(cache)).toEqual(cache); + expect(restoreSessionAnalyticsCache({ ...cache, schemaVersion: 999 })).toBeUndefined(); + expect( + restoreSessionAnalyticsCache({ ...cache, totals: { distance: -1 } }) + ).toBeUndefined(); + }); +}); + +describe('session calendar data', () => { + test('round trips linkable calendar months and rejects invalid values', () => { + const month = sessionCalendarMonthFromKey('2026-07'); + + expect(month?.getFullYear()).toBe(2026); + expect(month?.getMonth()).toBe(6); + expect(month ? sessionCalendarMonthKey(month) : undefined).toBe('2026-07'); + expect(sessionCalendarMonthFromKey('2026-13')).toBeUndefined(); + expect(sessionCalendarMonthFromKey('July 2026')).toBeUndefined(); + }); + + test('builds Monday-first calendar weeks and groups every ride on its local day', () => { + const first = analyticsSession('morning', new Date(2026, 6, 1, 8).getTime()); + const second = analyticsSession('evening', new Date(2026, 6, 1, 18).getTime()); + const summaries = [sessionSummary(second), sessionSummary(first)]; + const grouped = sessionsByLocalDate(summaries); + const days = sessionCalendarDays(new Date(2026, 6, 1), summaries); + const rideDay = days.find((day) => day.date.getDate() === 1 && day.inCurrentMonth); + + expect(days.length % 7).toBe(0); + expect(days[0].date.getDay()).toBe(1); + expect(grouped.size).toBe(1); + expect(rideDay?.sessions.map((session) => session.id)).toEqual(['morning', 'evening']); + }); + + test('shows an overnight ride on every local calendar day it spans', () => { + const startedAt = new Date(2026, 6, 31, 23).getTime(); + const overnight = sessionSummary( + analyticsSession('overnight', startedAt, { + elapsedSeconds: 7200, + }) + ); + const grouped = sessionsByLocalDate([overnight]); + + expect(grouped.size).toBe(2); + expect( + [...grouped.values()].every((sessions) => + sessions.some((session) => session.id === overnight.id) + ) + ).toBe(true); + }); +}); diff --git a/tests/session-history-preferences.test.ts b/tests/session-history-preferences.test.ts index 5b3007f..1de4fc6 100644 --- a/tests/session-history-preferences.test.ts +++ b/tests/session-history-preferences.test.ts @@ -1,10 +1,19 @@ import { describe, expect, test } from 'bun:test'; +import { ACTIVITY_FILE_FORMAT } from '../src/lib/activity-file'; import { loadSelectedSessionId, + loadSessionDownloadFormat, + SESSION_HISTORY_DOWNLOAD_FORMAT_STORAGE_KEY, SESSION_HISTORY_SELECTION_STORAGE_KEY, saveSelectedSessionId, + saveSessionDownloadFormat, sessionDetailScrollPositionStorageKey, } from '../src/lib/session-history-preferences'; +import { + loadSessionHistoryView, + SESSION_HISTORY_VIEW, + saveSessionHistoryView, +} from '../src/lib/session-history-view'; describe('session history preferences', () => { test('gives each session detail pane an independent scroll position key', () => { @@ -47,5 +56,38 @@ describe('session history preferences', () => { expect(loadSelectedSessionId(storage)).toBeUndefined(); expect(saveSelectedSessionId('session-42', storage)).toBe(false); + expect(loadSessionDownloadFormat(storage)).toBe(ACTIVITY_FILE_FORMAT.TCX); + expect(saveSessionDownloadFormat(ACTIVITY_FILE_FORMAT.FIT, storage)).toBe(false); + }); + + test('defaults to the calendar and remembers the selected history view', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(loadSessionHistoryView(storage)).toBe(SESSION_HISTORY_VIEW.CALENDAR); + expect(saveSessionHistoryView(SESSION_HISTORY_VIEW.STATISTICS, storage)).toBe(true); + expect(loadSessionHistoryView(storage)).toBe(SESSION_HISTORY_VIEW.STATISTICS); + values.set('ride-control-session-history-view', 'unknown'); + expect(loadSessionHistoryView(storage)).toBe(SESSION_HISTORY_VIEW.CALENDAR); + }); + + test('defaults downloads to TCX and remembers the selected format', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + + expect(loadSessionDownloadFormat(storage)).toBe(ACTIVITY_FILE_FORMAT.TCX); + expect(saveSessionDownloadFormat(ACTIVITY_FILE_FORMAT.FIT, storage)).toBe(true); + expect(values.get(SESSION_HISTORY_DOWNLOAD_FORMAT_STORAGE_KEY)).toBe( + ACTIVITY_FILE_FORMAT.FIT + ); + expect(loadSessionDownloadFormat(storage)).toBe(ACTIVITY_FILE_FORMAT.FIT); + values.set(SESSION_HISTORY_DOWNLOAD_FORMAT_STORAGE_KEY, 'pdf'); + expect(loadSessionDownloadFormat(storage)).toBe(ACTIVITY_FILE_FORMAT.TCX); }); });