diff --git a/js/analytics-dashboard.js b/js/analytics-dashboard.js index d2f0bf5..45565c4 100644 --- a/js/analytics-dashboard.js +++ b/js/analytics-dashboard.js @@ -7,25 +7,25 @@ const ANALYTICS_COLORS = [ '#fbbf24', '#c084fc', '#fb7185', '#34d399', '#60a5fa', '#ef4444', '#c2410c', '#a3e635', '#facc15', '#fb923c', '#38bdf8', '#e879f9', '#a16207', '#f5d0fe', '#fde68a', '#bbf7d0', '#d946ef', '#fff3bf', '#9d4edd', '#0f172a' ]; -const ANALYTICS_CACHE_KEY = 'vitruvian.analyticsWorkouts'; -const ANALYTICS_CACHE_META_KEY = 'vitruvian.analyticsMeta'; -const ANALYTICS_CACHE_LIMIT = 400; -const MONTHLY_BAR_MAX_HEIGHT = 240; -const PROGRAM_MODE_VALUE_MAP = new Map([ - [0, 'OLD_SCHOOL'], - [1, 'PUMP'], - [2, 'TIME_UNDER_TENSION'], - [3, 'TIME_UNDER_TENSION_BEAST'], - [4, 'ECCENTRIC'] -]); -const WORKLOAD_BREAKDOWN_CATEGORIES = [ - { key: 'ECHO', label: 'Echo Mode', color: '#f97316' }, - { key: 'OLD_SCHOOL', label: 'Old School', color: '#10b981' }, - { key: 'TIME_UNDER_TENSION', label: 'Time Under Tension', color: '#eab308' }, - { key: 'TIME_UNDER_TENSION_BEAST', label: 'TUT Beast Mode', color: '#a855f7' }, - { key: 'ECCENTRIC', label: 'Eccentric', color: '#ef4444' } -]; -const WORKLOAD_OTHER_CATEGORY = { key: 'OTHER', label: 'Other', color: '#64748b' }; +const ANALYTICS_CACHE_KEY = 'vitruvian.analyticsWorkouts'; +const ANALYTICS_CACHE_META_KEY = 'vitruvian.analyticsMeta'; +const ANALYTICS_CACHE_LIMIT = 400; +const MONTHLY_BAR_MAX_HEIGHT = 240; +const PROGRAM_MODE_VALUE_MAP = new Map([ + [0, 'OLD_SCHOOL'], + [1, 'PUMP'], + [2, 'TIME_UNDER_TENSION'], + [3, 'TIME_UNDER_TENSION_BEAST'], + [4, 'ECCENTRIC'] +]); +const WORKLOAD_BREAKDOWN_CATEGORIES = [ + { key: 'ECHO', label: 'Echo Mode', color: '#f97316' }, + { key: 'OLD_SCHOOL', label: 'Old School', color: '#10b981' }, + { key: 'TIME_UNDER_TENSION', label: 'Time Under Tension', color: '#eab308' }, + { key: 'TIME_UNDER_TENSION_BEAST', label: 'TUT Beast Mode', color: '#a855f7' }, + { key: 'ECCENTRIC', label: 'Eccentric', color: '#ef4444' } +]; +const WORKLOAD_OTHER_CATEGORY = { key: 'OTHER', label: 'Other', color: '#64748b' }; export class AnalyticsDashboard { constructor(options = {}) { @@ -52,19 +52,19 @@ export class AnalyticsDashboard { this.rangeLabelEl = null; this.pointCountEl = null; this.chartUnitEl = null; - this.exerciseHintEl = null; - this.peakConcentricValueEl = null; - this.peakConcentricDateEl = null; - this.peakEccentricValueEl = null; - this.peakEccentricDateEl = null; - this.baselineConcentricValueEl = null; - this.baselineConcentricDateEl = null; - this.baselineEccentricValueEl = null; - this.baselineEccentricDateEl = null; - this.deltaConcentricValueEl = null; - this.deltaConcentricPctEl = null; - this.deltaEccentricValueEl = null; - this.deltaEccentricPctEl = null; + this.exerciseHintEl = null; + this.peakConcentricValueEl = null; + this.peakConcentricDateEl = null; + this.peakEccentricValueEl = null; + this.peakEccentricDateEl = null; + this.baselineConcentricValueEl = null; + this.baselineConcentricDateEl = null; + this.baselineEccentricValueEl = null; + this.baselineEccentricDateEl = null; + this.deltaConcentricValueEl = null; + this.deltaConcentricPctEl = null; + this.deltaEccentricValueEl = null; + this.deltaEccentricPctEl = null; this.monthlyChartWrapper = null; this.monthlyChartEl = null; this.monthlyLegendEl = null; @@ -92,25 +92,25 @@ export class AnalyticsDashboard { this._autoSyncRequested = false; this.pendingDropboxWrite = false; this.alignPromptState = null; - this.boundMonthlySegmentEnter = this.handleMonthlySegmentEnter.bind(this); - this.boundMonthlySegmentMove = this.handleMonthlySegmentMove.bind(this); - this.boundMonthlySegmentLeave = this.handleMonthlySegmentLeave.bind(this); - this.boundMonthlySegmentFocus = this.handleMonthlySegmentFocus.bind(this); - this.boundMonthlySegmentBlur = this.handleMonthlySegmentBlur.bind(this); - this.workloadMetricEl = null; - this.workloadTriggerEl = null; - this.totalVolumeEl = null; - this.totalRepsEl = null; - this.averageLoadEl = null; - this.workloadTooltipEl = null; - this.workloadTooltipTotalEl = null; - this.workloadPieEl = null; - this.workloadBreakdownEl = null; - this.workloadAvgConcentricEl = null; - this.workloadAvgEccentricEl = null; - this.workloadHideTimeout = null; - this.currentWorkloadStats = null; - } + this.boundMonthlySegmentEnter = this.handleMonthlySegmentEnter.bind(this); + this.boundMonthlySegmentMove = this.handleMonthlySegmentMove.bind(this); + this.boundMonthlySegmentLeave = this.handleMonthlySegmentLeave.bind(this); + this.boundMonthlySegmentFocus = this.handleMonthlySegmentFocus.bind(this); + this.boundMonthlySegmentBlur = this.handleMonthlySegmentBlur.bind(this); + this.workloadMetricEl = null; + this.workloadTriggerEl = null; + this.totalVolumeEl = null; + this.totalRepsEl = null; + this.averageLoadEl = null; + this.workloadTooltipEl = null; + this.workloadTooltipTotalEl = null; + this.workloadPieEl = null; + this.workloadBreakdownEl = null; + this.workloadAvgConcentricEl = null; + this.workloadAvgEccentricEl = null; + this.workloadHideTimeout = null; + this.currentWorkloadStats = null; + } init() { if (typeof document === 'undefined') return; @@ -128,52 +128,52 @@ export class AnalyticsDashboard { this.pointCountEl = document.getElementById('analyticsPointCount'); this.chartUnitEl = document.getElementById('analyticsChartUnit'); this.exerciseHintEl = document.getElementById('analyticsExerciseHint'); - this.peakConcentricValueEl = document.getElementById('analyticsPeakConcentric'); - this.peakConcentricDateEl = document.getElementById('analyticsPeakConcentricDate'); - this.peakEccentricValueEl = document.getElementById('analyticsPeakEccentric'); - this.peakEccentricDateEl = document.getElementById('analyticsPeakEccentricDate'); - this.baselineConcentricValueEl = document.getElementById('analyticsBaselineConcentric'); - this.baselineConcentricDateEl = document.getElementById('analyticsBaselineConcentricDate'); - this.baselineEccentricValueEl = document.getElementById('analyticsBaselineEccentric'); - this.baselineEccentricDateEl = document.getElementById('analyticsBaselineEccentricDate'); - this.deltaConcentricPctEl = document.getElementById('analyticsDeltaConcentricPct'); - this.deltaConcentricValueEl = document.getElementById('analyticsDeltaConcentricValue'); - this.deltaEccentricPctEl = document.getElementById('analyticsDeltaEccentricPct'); - this.deltaEccentricValueEl = document.getElementById('analyticsDeltaEccentricValue'); + this.peakConcentricValueEl = document.getElementById('analyticsPeakConcentric'); + this.peakConcentricDateEl = document.getElementById('analyticsPeakConcentricDate'); + this.peakEccentricValueEl = document.getElementById('analyticsPeakEccentric'); + this.peakEccentricDateEl = document.getElementById('analyticsPeakEccentricDate'); + this.baselineConcentricValueEl = document.getElementById('analyticsBaselineConcentric'); + this.baselineConcentricDateEl = document.getElementById('analyticsBaselineConcentricDate'); + this.baselineEccentricValueEl = document.getElementById('analyticsBaselineEccentric'); + this.baselineEccentricDateEl = document.getElementById('analyticsBaselineEccentricDate'); + this.deltaConcentricPctEl = document.getElementById('analyticsDeltaConcentricPct'); + this.deltaConcentricValueEl = document.getElementById('analyticsDeltaConcentricValue'); + this.deltaEccentricPctEl = document.getElementById('analyticsDeltaEccentricPct'); + this.deltaEccentricValueEl = document.getElementById('analyticsDeltaEccentricValue'); this.monthlyChartWrapper = document.getElementById('analyticsMonthlyChartWrapper'); - this.monthlyChartEl = document.getElementById('analyticsMonthlyChart'); - this.monthlyLegendEl = document.getElementById('analyticsMonthlyLegend'); - this.monthlyEmptyEl = document.getElementById('analyticsMonthlyEmpty'); - this.monthlyAxisEl = document.getElementById('analyticsMonthlyAxis'); - this.monthlyPeakLineEl = document.getElementById('analyticsMonthlyPeakLine'); - this.monthlyPeakLabelEl = document.getElementById('analyticsMonthlyPeakLabel'); - this.monthlyXAxisEl = document.getElementById('analyticsMonthlyXAxis'); - this.workloadMetricEl = document.getElementById('analyticsWorkloadMetric'); - this.workloadTriggerEl = document.getElementById('analyticsWorkloadTrigger'); - this.totalVolumeEl = document.getElementById('analyticsTotalVolume'); - this.totalRepsEl = document.getElementById('analyticsTotalReps'); - this.averageLoadEl = document.getElementById('analyticsAverageLoad'); - this.workloadTooltipEl = document.getElementById('analyticsWorkloadTooltip'); - this.workloadTooltipTotalEl = document.getElementById('analyticsWorkloadTooltipTotal'); - this.workloadPieEl = document.getElementById('analyticsWorkloadPie'); - this.workloadBreakdownEl = document.getElementById('analyticsWorkloadBreakdown'); - this.workloadAvgConcentricEl = document.getElementById('analyticsWorkloadAvgConcentric'); - this.workloadAvgEccentricEl = document.getElementById('analyticsWorkloadAvgEccentric'); + this.monthlyChartEl = document.getElementById('analyticsMonthlyChart'); + this.monthlyLegendEl = document.getElementById('analyticsMonthlyLegend'); + this.monthlyEmptyEl = document.getElementById('analyticsMonthlyEmpty'); + this.monthlyAxisEl = document.getElementById('analyticsMonthlyAxis'); + this.monthlyPeakLineEl = document.getElementById('analyticsMonthlyPeakLine'); + this.monthlyPeakLabelEl = document.getElementById('analyticsMonthlyPeakLabel'); + this.monthlyXAxisEl = document.getElementById('analyticsMonthlyXAxis'); + this.workloadMetricEl = document.getElementById('analyticsWorkloadMetric'); + this.workloadTriggerEl = document.getElementById('analyticsWorkloadTrigger'); + this.totalVolumeEl = document.getElementById('analyticsTotalVolume'); + this.totalRepsEl = document.getElementById('analyticsTotalReps'); + this.averageLoadEl = document.getElementById('analyticsAverageLoad'); + this.workloadTooltipEl = document.getElementById('analyticsWorkloadTooltip'); + this.workloadTooltipTotalEl = document.getElementById('analyticsWorkloadTooltipTotal'); + this.workloadPieEl = document.getElementById('analyticsWorkloadPie'); + this.workloadBreakdownEl = document.getElementById('analyticsWorkloadBreakdown'); + this.workloadAvgConcentricEl = document.getElementById('analyticsWorkloadAvgConcentric'); + this.workloadAvgEccentricEl = document.getElementById('analyticsWorkloadAvgEccentric'); if (this.exerciseSelect) { this.exerciseSelect.addEventListener('change', () => this.handleExerciseChange()); } - this.rangeButtons.forEach((button) => { - button.addEventListener('click', () => this.setRange(button.dataset.analyticsRange)); - }); - - this.bindWorkloadTooltipEvents(); - - if (this.syncButton) { - this.syncButton.addEventListener('click', () => this.handleSyncRequest()); - this.syncButton.disabled = true; - } + this.rangeButtons.forEach((button) => { + button.addEventListener('click', () => this.setRange(button.dataset.analyticsRange)); + }); + + this.bindWorkloadTooltipEvents(); + + if (this.syncButton) { + this.syncButton.addEventListener('click', () => this.handleSyncRequest()); + this.syncButton.disabled = true; + } if (this.alignIdsButton) { this.alignIdsButton.addEventListener('click', () => this.handleAlignIdsRequest()); this.alignIdsButton.disabled = true; @@ -797,53 +797,53 @@ export class AnalyticsDashboard { return this.exerciseOptionMap.get(this.currentExerciseKey) || null; } - updateChart() { - if (!this.chartEl) { - return; - } - - const filteredWorkouts = this.filterWorkoutsByRange(this.filterWorkoutsByExercise()); - const scopedWorkouts = this.currentExerciseKey ? filteredWorkouts : []; - - if (!this.currentExerciseKey) { - this.hideChart(this.dropboxConnected - ? 'Select an exercise to load analytics.' - : null); - this.updateSummary([]); - this.updateMeta([]); - this.updateWorkloadMetrics([]); - this.renderMonthlyChart(filteredWorkouts); - return; - } - - const allEntries = this.buildSeriesForExercise(this.currentExerciseKey); + updateChart() { + if (!this.chartEl) { + return; + } + + const filteredWorkouts = this.filterWorkoutsByRange(this.filterWorkoutsByExercise()); + const scopedWorkouts = this.currentExerciseKey ? filteredWorkouts : []; + + if (!this.currentExerciseKey) { + this.hideChart(this.dropboxConnected + ? 'Select an exercise to load analytics.' + : null); + this.updateSummary([]); + this.updateMeta([]); + this.updateWorkloadMetrics([]); + this.renderMonthlyChart(filteredWorkouts); + return; + } + + const allEntries = this.buildSeriesForExercise(this.currentExerciseKey); const visibleEntries = this.filterSeriesByRange(allEntries); if (visibleEntries.length === 0) { - const message = this.dropboxConnected - ? 'No workouts in the selected range. Try syncing or expanding the range.' - : null; - this.hideChart(message); - this.updateSummary([]); - this.updateMeta([]); - this.updateWorkloadMetrics(scopedWorkouts); - this.renderMonthlyChart(filteredWorkouts); - return; - } - - this.showChart(); + const message = this.dropboxConnected + ? 'No workouts in the selected range. Try syncing or expanding the range.' + : null; + this.hideChart(message); + this.updateSummary([]); + this.updateMeta([]); + this.updateWorkloadMetrics(scopedWorkouts); + this.renderMonthlyChart(filteredWorkouts); + return; + } + + this.showChart(); const data = this.buildChartData(visibleEntries); this.ensureChart(); if (this.chart) { this.chart.setData(data); this.refreshChartSize(); - } - - this.updateSummary(visibleEntries); - this.updateMeta(visibleEntries); - this.updateWorkloadMetrics(scopedWorkouts); - this.renderMonthlyChart(filteredWorkouts); - } + } + + this.updateSummary(visibleEntries); + this.updateMeta(visibleEntries); + this.updateWorkloadMetrics(scopedWorkouts); + this.renderMonthlyChart(filteredWorkouts); + } filterWorkoutsByExercise() { const key = this.currentExerciseKey; @@ -889,13 +889,37 @@ export class AnalyticsDashboard { continue; } + // compute peaks and rep/volume totals for this workout const peaks = this.getWorkoutPhasePeaks(workout); const concKg = Number(peaks.concentricKg) || 0; const eccKg = Number(peaks.eccentricKg) || 0; + // Skip workouts with no measurable peaks if (concKg <= 0 && eccKg <= 0) { continue; } + // Compute per-workout averages (kg) to contribute to per-day means. + // Prefer stored per-workout average fields when present, otherwise + // fall back to rep-derived values. + const repDetails = this.getWorkoutRepDetails(workout); + const reps = Number(repDetails.count) || 0; + + const storedAvgTotal = Number(workout.averageLoad) || Number(workout.averageTotal) || 0; + const storedAvgLeft = Number(workout.averageLoadLeft) || Number(workout.averageLeft) || 0; + const storedAvgRight = Number(workout.averageLoadRight) || Number(workout.averageRight) || 0; + + const perWorkoutAvgKgFromReps = (function () { + const totalConcentricKg = Number(repDetails.totalConcentricKg) || 0; + const totalEccentricKg = Number(repDetails.totalEccentricKg) || 0; + const totalVol = Math.max(totalConcentricKg, totalEccentricKg, 0); + return reps > 0 ? totalVol / reps : 0; + })(); + + // Determine per-workout averages (kg), preferring stored fields + const perWorkoutAvgKg = storedAvgTotal > 0 ? storedAvgTotal : perWorkoutAvgKgFromReps || 0; + const perWorkoutAvgLeftKg = storedAvgLeft > 0 ? storedAvgLeft : 0; + const perWorkoutAvgRightKg = storedAvgRight > 0 ? storedAvgRight : 0; + const dayStart = this.getDayStart(timestamp); const existing = dayMap.get(dayStart); if (!existing) { @@ -904,10 +928,19 @@ export class AnalyticsDashboard { weightKg: concKg, concentricKg: concKg, eccentricKg: eccKg, - timestamp + timestamp, + // accumulate per-day workout-average sums and counts + dayAvgTotalSumKg: perWorkoutAvgKg > 0 ? perWorkoutAvgKg : 0, + dayAvgLeftSumKg: perWorkoutAvgLeftKg > 0 ? perWorkoutAvgLeftKg : 0, + dayAvgRightSumKg: perWorkoutAvgRightKg > 0 ? perWorkoutAvgRightKg : 0, + dayWorkoutCountTotal: perWorkoutAvgKg > 0 ? 1 : 0, + dayWorkoutCountLeft: perWorkoutAvgLeftKg > 0 ? 1 : 0, + dayWorkoutCountRight: perWorkoutAvgRightKg > 0 ? 1 : 0 }); continue; } + + // update peaks if this workout contributes a higher peak if (concKg > existing.concentricKg) { existing.concentricKg = concKg; existing.weightKg = concKg; @@ -916,9 +949,43 @@ export class AnalyticsDashboard { if (eccKg > existing.eccentricKg) { existing.eccentricKg = eccKg; } + + // accumulate per-day workout-average sums and counts + existing.dayAvgTotalSumKg = (existing.dayAvgTotalSumKg || 0) + (perWorkoutAvgKg > 0 ? perWorkoutAvgKg : 0); + existing.dayAvgLeftSumKg = (existing.dayAvgLeftSumKg || 0) + (perWorkoutAvgLeftKg > 0 ? perWorkoutAvgLeftKg : 0); + existing.dayAvgRightSumKg = (existing.dayAvgRightSumKg || 0) + (perWorkoutAvgRightKg > 0 ? perWorkoutAvgRightKg : 0); + existing.dayWorkoutCountTotal = (existing.dayWorkoutCountTotal || 0) + (perWorkoutAvgKg > 0 ? 1 : 0); + existing.dayWorkoutCountLeft = (existing.dayWorkoutCountLeft || 0) + (perWorkoutAvgLeftKg > 0 ? 1 : 0); + existing.dayWorkoutCountRight = (existing.dayWorkoutCountRight || 0) + (perWorkoutAvgRightKg > 0 ? 1 : 0); } - return Array.from(dayMap.values()).sort((a, b) => a.day - b.day); + // Convert accumulated map to array and compute per-day averages + const results = Array.from(dayMap.values()).map((entry) => { + const totalSum = Number(entry.dayAvgTotalSumKg) || 0; + const totalCount = Number(entry.dayWorkoutCountTotal) || 0; + const avgTotalKg = totalCount > 0 ? totalSum / totalCount : 0; + + const leftSum = Number(entry.dayAvgLeftSumKg) || 0; + const leftCount = Number(entry.dayWorkoutCountLeft) || 0; + const avgLeftKg = leftCount > 0 ? leftSum / leftCount : 0; + + const rightSum = Number(entry.dayAvgRightSumKg) || 0; + const rightCount = Number(entry.dayWorkoutCountRight) || 0; + const avgRightKg = rightCount > 0 ? rightSum / rightCount : 0; + + return { + day: entry.day, + weightKg: entry.weightKg, + concentricKg: entry.concentricKg, + eccentricKg: entry.eccentricKg, + timestamp: entry.timestamp, + averageTotalKg: avgTotalKg, + averageLeftKg: avgLeftKg, + averageRightKg: avgRightKg + }; + }); + + return results.sort((a, b) => a.day - b.day); } getDayStart(date) { @@ -941,16 +1008,18 @@ export class AnalyticsDashboard { const timestamps = []; const concentric = []; const eccentric = []; - + const avgSeries = []; entries.forEach((entry) => { const concKg = Number(entry.concentricKg ?? entry.weightKg) || 0; const eccKg = Number(entry.eccentricKg ?? entry.weightKg) || 0; + const entryAvgKg = Number(entry.averageTotalKg) || 0; timestamps.push(entry.day / 1000); concentric.push(this.convertKgToDisplay(concKg, unit)); eccentric.push(this.convertKgToDisplay(eccKg, unit)); + avgSeries.push(this.convertKgToDisplay(entryAvgKg, unit)); }); - return [timestamps, concentric, eccentric]; + return [timestamps, concentric, eccentric, avgSeries]; } ensureChart() { @@ -998,6 +1067,14 @@ export class AnalyticsDashboard { stroke: '#f472b6', width: 2, value: (u, v) => (v == null ? '-' : v.toFixed(this.getDisplayDecimals())) + }, + { + label: `Avg Total Load (${unitLabel})`, + stroke: '#38bdf8', + width: 2, + dash: [6, 6], + value: (u, v) => (v == null ? '-' : v.toFixed(this.getDisplayDecimals())), + points: { show: false }, } ], axes: [ @@ -1014,7 +1091,7 @@ export class AnalyticsDashboard { ] }; - this.chart = new window.uPlot(opts, [[], [], []], this.chartEl); + this.chart = new window.uPlot(opts, [[], [], [], []], this.chartEl); this.setupResizeObserver(); } @@ -1038,73 +1115,73 @@ export class AnalyticsDashboard { } } - refreshChartSize() { - if (!this.chart || !this.chartWrapper) { - return; - } - const width = this.chartWrapper.clientWidth || 600; - this.chart.setSize({ width, height: 360 }); - } - - bindWorkloadTooltipEvents() { - if (this.workloadTriggerEl) { - this.workloadTriggerEl.addEventListener('pointerenter', () => this.showWorkloadTooltip()); - this.workloadTriggerEl.addEventListener('pointerleave', () => this.scheduleHideWorkloadTooltip()); - this.workloadTriggerEl.addEventListener('focus', () => this.showWorkloadTooltip()); - this.workloadTriggerEl.addEventListener('blur', () => this.scheduleHideWorkloadTooltip()); - } - if (this.workloadTooltipEl) { - this.workloadTooltipEl.addEventListener('pointerenter', () => this.showWorkloadTooltip()); - this.workloadTooltipEl.addEventListener('pointerleave', () => this.scheduleHideWorkloadTooltip()); - } - } - - showWorkloadTooltip() { - if (!this.workloadTooltipEl || !this.workloadTriggerEl || this.workloadTriggerEl.disabled) { - return; - } - this.clearWorkloadHideTimeout(); - this.workloadTooltipEl.classList.add('is-visible'); - this.workloadTooltipEl.setAttribute('aria-hidden', 'false'); - this.workloadTriggerEl.setAttribute('aria-expanded', 'true'); - } - - hideWorkloadTooltip(immediate = false) { - if (!this.workloadTooltipEl || !this.workloadTriggerEl) { - return; - } - this.clearWorkloadHideTimeout(); - if (immediate) { - this.workloadTooltipEl.classList.remove('is-visible'); - this.workloadTooltipEl.setAttribute('aria-hidden', 'true'); - this.workloadTriggerEl.setAttribute('aria-expanded', 'false'); - return; - } - this.workloadTooltipEl.classList.remove('is-visible'); - this.workloadTooltipEl.setAttribute('aria-hidden', 'true'); - this.workloadTriggerEl.setAttribute('aria-expanded', 'false'); - } - - scheduleHideWorkloadTooltip() { - if (!this.workloadTooltipEl) { - return; - } - this.clearWorkloadHideTimeout(); - this.workloadHideTimeout = setTimeout(() => { - this.hideWorkloadTooltip(true); - }, 120); - } - - clearWorkloadHideTimeout() { - if (this.workloadHideTimeout) { - clearTimeout(this.workloadHideTimeout); - this.workloadHideTimeout = null; - } - } - - handleUnitChange() { - this.updateChart(); - } + refreshChartSize() { + if (!this.chart || !this.chartWrapper) { + return; + } + const width = this.chartWrapper.clientWidth || 600; + this.chart.setSize({ width, height: 360 }); + } + + bindWorkloadTooltipEvents() { + if (this.workloadTriggerEl) { + this.workloadTriggerEl.addEventListener('pointerenter', () => this.showWorkloadTooltip()); + this.workloadTriggerEl.addEventListener('pointerleave', () => this.scheduleHideWorkloadTooltip()); + this.workloadTriggerEl.addEventListener('focus', () => this.showWorkloadTooltip()); + this.workloadTriggerEl.addEventListener('blur', () => this.scheduleHideWorkloadTooltip()); + } + if (this.workloadTooltipEl) { + this.workloadTooltipEl.addEventListener('pointerenter', () => this.showWorkloadTooltip()); + this.workloadTooltipEl.addEventListener('pointerleave', () => this.scheduleHideWorkloadTooltip()); + } + } + + showWorkloadTooltip() { + if (!this.workloadTooltipEl || !this.workloadTriggerEl || this.workloadTriggerEl.disabled) { + return; + } + this.clearWorkloadHideTimeout(); + this.workloadTooltipEl.classList.add('is-visible'); + this.workloadTooltipEl.setAttribute('aria-hidden', 'false'); + this.workloadTriggerEl.setAttribute('aria-expanded', 'true'); + } + + hideWorkloadTooltip(immediate = false) { + if (!this.workloadTooltipEl || !this.workloadTriggerEl) { + return; + } + this.clearWorkloadHideTimeout(); + if (immediate) { + this.workloadTooltipEl.classList.remove('is-visible'); + this.workloadTooltipEl.setAttribute('aria-hidden', 'true'); + this.workloadTriggerEl.setAttribute('aria-expanded', 'false'); + return; + } + this.workloadTooltipEl.classList.remove('is-visible'); + this.workloadTooltipEl.setAttribute('aria-hidden', 'true'); + this.workloadTriggerEl.setAttribute('aria-expanded', 'false'); + } + + scheduleHideWorkloadTooltip() { + if (!this.workloadTooltipEl) { + return; + } + this.clearWorkloadHideTimeout(); + this.workloadHideTimeout = setTimeout(() => { + this.hideWorkloadTooltip(true); + }, 120); + } + + clearWorkloadHideTimeout() { + if (this.workloadHideTimeout) { + clearTimeout(this.workloadHideTimeout); + this.workloadHideTimeout = null; + } + } + + handleUnitChange() { + this.updateChart(); + } hideChart(message = null) { if (this.chartEl) { @@ -1148,512 +1225,512 @@ export class AnalyticsDashboard { return 'No workouts in the selected range.'; } - updateSummary(entries) { - if (!Array.isArray(entries) || entries.length === 0) { - this.setDualMetricPlaceholders(); - return; - } - - let peakConcentricEntry = null; - let peakEccentricEntry = null; - let baselineEntry = null; - let baselineTime = Infinity; - - entries.forEach((entry) => { - const conc = Number(entry.concentricKg ?? entry.weightKg) || 0; - const ecc = Number(entry.eccentricKg) || 0; - if (!peakConcentricEntry || conc > (Number(peakConcentricEntry.concentricKg ?? peakConcentricEntry.weightKg) || 0)) { - peakConcentricEntry = entry; - } - if (!peakEccentricEntry || ecc > (Number(peakEccentricEntry.eccentricKg) || 0)) { - peakEccentricEntry = entry; - } - const dayValue = Number(entry.day); - const timeValue = Number.isFinite(dayValue) - ? dayValue - : entry.timestamp instanceof Date && !Number.isNaN(entry.timestamp.getTime()) - ? entry.timestamp.getTime() - : Infinity; - if (timeValue < baselineTime) { - baselineEntry = entry; - baselineTime = timeValue; - } - }); - - if (!baselineEntry) { - baselineEntry = entries[0]; - } - - const baselineConcentric = Number(baselineEntry?.concentricKg ?? baselineEntry?.weightKg) || 0; - const baselineEccentric = Number(baselineEntry?.eccentricKg ?? baselineEntry?.weightKg) || 0; - const baselineDate = baselineEntry?.timestamp || baselineEntry?.day || null; - - const peakConcentric = Number(peakConcentricEntry?.concentricKg ?? peakConcentricEntry?.weightKg) || 0; - const peakEccentric = Number(peakEccentricEntry?.eccentricKg ?? peakConcentricEntry?.eccentricKg) || 0; - - this.setDualMetricValue(this.peakConcentricValueEl, peakConcentric); - this.setDualMetricValue(this.peakEccentricValueEl, peakEccentric); - this.setDualMetricDate(this.peakConcentricDateEl, peakConcentricEntry); - this.setDualMetricDate(this.peakEccentricDateEl, peakEccentricEntry); - - this.setDualMetricValue(this.baselineConcentricValueEl, baselineConcentric); - this.setDualMetricValue(this.baselineEccentricValueEl, baselineEccentric); - this.setDualMetricDateValue(this.baselineConcentricDateEl, baselineDate); - this.setDualMetricDateValue(this.baselineEccentricDateEl, baselineDate); - - this.setStrengthGainMetrics( - this.deltaConcentricPctEl, - this.deltaConcentricValueEl, - peakConcentric, - baselineConcentric - ); - this.setStrengthGainMetrics( - this.deltaEccentricPctEl, - this.deltaEccentricValueEl, - peakEccentric, - baselineEccentric - ); - } - - updateWorkloadMetrics(workouts) { - if (!this.totalVolumeEl || !this.totalRepsEl || !this.averageLoadEl) { - return; - } - const stats = this.calculateWorkloadStats(workouts); - this.currentWorkloadStats = stats; - if (!stats.hasWorkouts) { - this.totalVolumeEl.textContent = '—'; - this.totalRepsEl.textContent = '—'; - this.averageLoadEl.textContent = '—'; - if (this.workloadTriggerEl) { - this.workloadTriggerEl.disabled = true; - } - this.updateWorkloadSummary(stats); - this.renderWorkloadBreakdown(stats); - return; - } - const volumeDisplay = stats.totalVolumeKg > 0 - ? this.formatVolumeValue(stats.totalVolumeKg) - : this.formatVolumeValue(0); - this.totalVolumeEl.textContent = volumeDisplay; - this.totalRepsEl.textContent = stats.totalReps > 0 ? this.formatCount(stats.totalReps) : '0'; - this.averageLoadEl.textContent = - stats.totalReps > 0 && stats.averageLoadKg > 0 ? this.formatWeight(stats.averageLoadKg) : '—'; - if (this.workloadTriggerEl) { - const disabled = !(stats.totalVolumeKg > 0); - this.workloadTriggerEl.disabled = disabled; - if (disabled) { - this.hideWorkloadTooltip(true); - } - } - this.updateWorkloadSummary(stats); - this.renderWorkloadBreakdown(stats); - } - - updateWorkloadSummary(stats) { - if (!this.workloadAvgConcentricEl || !this.workloadAvgEccentricEl) { - return; - } - const avgConc = Number(stats?.averageConcentricKg) || 0; - const avgEcc = Number(stats?.averageEccentricKg) || 0; - this.workloadAvgConcentricEl.textContent = avgConc > 0 ? this.formatWeight(avgConc) : '—'; - this.workloadAvgEccentricEl.textContent = avgEcc > 0 ? this.formatWeight(avgEcc) : '—'; - } - - renderWorkloadBreakdown(stats) { - if (!this.workloadBreakdownEl || !this.workloadPieEl) { - return; - } - const totalVolume = Number(stats?.totalVolumeKg) || 0; - if (this.workloadTooltipTotalEl) { - this.workloadTooltipTotalEl.textContent = - totalVolume > 0 ? this.formatVolumeValue(totalVolume) : '—'; - } - if (!stats?.hasWorkouts || totalVolume <= 0) { - this.workloadPieEl.style.background = '#182036'; - this.workloadBreakdownEl.innerHTML = ''; - const empty = document.createElement('p'); - empty.className = 'analytics-workload-breakdown__empty'; - empty.textContent = this.currentExerciseKey - ? 'No working volume recorded for this range.' - : 'Select an exercise to view rep mix.'; - this.workloadBreakdownEl.appendChild(empty); - return; - } - const segments = this.buildWorkloadSegments(stats.breakdown, totalVolume); - this.applyWorkloadPieSegments(segments, totalVolume); - const visibleSegments = segments.filter((segment) => segment.valueKg > 0 || segment.reps > 0); - if (!visibleSegments.length) { - this.workloadBreakdownEl.innerHTML = ''; - const empty = document.createElement('p'); - empty.className = 'analytics-workload-breakdown__empty'; - empty.textContent = 'No rep breakdown available for this range.'; - this.workloadBreakdownEl.appendChild(empty); - return; - } - const fragment = document.createDocumentFragment(); - visibleSegments.forEach((segment) => { - const row = document.createElement('div'); - row.className = 'analytics-workload-breakdown__row'; - const label = document.createElement('div'); - label.className = 'analytics-workload-breakdown__label'; - const swatch = document.createElement('span'); - swatch.className = 'analytics-workload-breakdown__swatch'; - swatch.style.backgroundColor = segment.color; - label.appendChild(swatch); - const text = document.createElement('span'); - text.textContent = segment.label; - label.appendChild(text); - const percent = document.createElement('strong'); - percent.className = 'analytics-workload-breakdown__percent'; - percent.textContent = this.formatPercent(segment.percent); - const details = document.createElement('div'); - details.className = 'analytics-workload-breakdown__details'; - const volumeLine = document.createElement('span'); - volumeLine.textContent = this.formatVolumeValue(segment.valueKg); - const repsLine = document.createElement('span'); - repsLine.textContent = `${this.formatCount(segment.reps)} reps`; - const avgConcLine = document.createElement('span'); - avgConcLine.textContent = `Avg Con ${this.formatAverageLoad(segment.avgConcentricKg)}`; - const avgEccLine = document.createElement('span'); - avgEccLine.textContent = `Avg Ecc ${this.formatAverageLoad(segment.avgEccentricKg)}`; - details.appendChild(volumeLine); - details.appendChild(repsLine); - details.appendChild(avgConcLine); - details.appendChild(avgEccLine); - row.appendChild(label); - row.appendChild(percent); - row.appendChild(details); - fragment.appendChild(row); - }); - this.workloadBreakdownEl.innerHTML = ''; - this.workloadBreakdownEl.appendChild(fragment); - } - - buildWorkloadSegments(breakdownMap, totalVolumeKg) { - const map = breakdownMap instanceof Map ? breakdownMap : new Map(); - const trackedKeys = new Set(WORKLOAD_BREAKDOWN_CATEGORIES.map((category) => category.key)); - const segments = WORKLOAD_BREAKDOWN_CATEGORIES.map((category) => { - const entry = map.get(category.key) || { - volumeKg: 0, - reps: 0, - concentricKg: 0, - eccentricKg: 0 - }; - const reps = Number(entry.reps) || 0; - const conc = Number(entry.concentricKg) || 0; - const ecc = Number(entry.eccentricKg) || 0; - const volumeKg = Number(entry.volumeKg) || 0; - return { - key: category.key, - label: category.label, - color: category.color, - valueKg: volumeKg, - reps, - percent: totalVolumeKg > 0 ? (volumeKg / totalVolumeKg) * 100 : 0, - avgConcentricKg: reps > 0 ? conc / reps : 0, - avgEccentricKg: reps > 0 ? ecc / reps : 0 - }; - }); - const otherEntry = { - volumeKg: 0, - reps: 0, - concentricKg: 0, - eccentricKg: 0 - }; - map.forEach((entry, key) => { - if (trackedKeys.has(key)) { - return; - } - otherEntry.volumeKg += Number(entry?.volumeKg) || 0; - otherEntry.reps += Number(entry?.reps) || 0; - otherEntry.concentricKg += Number(entry?.concentricKg) || 0; - otherEntry.eccentricKg += Number(entry?.eccentricKg) || 0; - }); - const captured = segments.reduce((sum, entry) => sum + entry.valueKg, 0) + otherEntry.volumeKg; - const remainderKg = Math.max(0, totalVolumeKg - captured); - if (remainderKg > 0.01) { - otherEntry.volumeKg += remainderKg; - } - if (otherEntry.volumeKg > 0.01 || otherEntry.reps > 0) { - const reps = Number(otherEntry.reps) || 0; - segments.push({ - key: WORKLOAD_OTHER_CATEGORY.key, - label: WORKLOAD_OTHER_CATEGORY.label, - color: WORKLOAD_OTHER_CATEGORY.color, - valueKg: otherEntry.volumeKg, - reps, - percent: totalVolumeKg > 0 ? (otherEntry.volumeKg / totalVolumeKg) * 100 : 0, - avgConcentricKg: reps > 0 ? (otherEntry.concentricKg || 0) / reps : 0, - avgEccentricKg: reps > 0 ? (otherEntry.eccentricKg || 0) / reps : 0 - }); - } - return segments; - } - - applyWorkloadPieSegments(segments, totalVolumeKg) { - if (!this.workloadPieEl) { - return; - } - const validSegments = segments.filter((segment) => segment.valueKg > 0); - if (!validSegments.length || !Number.isFinite(totalVolumeKg) || totalVolumeKg <= 0) { - this.workloadPieEl.style.background = '#182036'; - return; - } - let offset = 0; - const gradientStops = validSegments.map((segment) => { - const share = segment.valueKg / totalVolumeKg; - const start = offset * 360; - offset += share; - const end = Math.min(360, offset * 360); - return `${segment.color} ${start}deg ${end}deg`; - }); - this.workloadPieEl.style.background = `conic-gradient(${gradientStops.join(', ')})`; - } - - calculateWorkloadStats(workouts = []) { - const hasWorkouts = Array.isArray(workouts) && workouts.length > 0; - const stats = { - totalVolumeKg: 0, - totalReps: 0, - averageLoadKg: 0, - totalConcentricKg: 0, - totalEccentricKg: 0, - averageConcentricKg: 0, - averageEccentricKg: 0, - breakdown: new Map(), - hasWorkouts - }; - if (!hasWorkouts) { - return stats; - } - workouts.forEach((workout) => { - if (!workout) { - return; - } - const repDetails = this.getWorkoutRepDetails(workout); - const reps = repDetails.count; - if (reps > 0) { - stats.totalReps += reps; - stats.totalConcentricKg += repDetails.totalConcentricKg; - stats.totalEccentricKg += repDetails.totalEccentricKg; - } - const category = this.getWorkoutModeCategory(workout); - const bucket = stats.breakdown.get(category) || { - volumeKg: 0, - reps: 0, - concentricKg: 0, - eccentricKg: 0 - }; - const volumeKg = this.getWorkoutVolumeKg(workout); - if (Number.isFinite(volumeKg) && volumeKg > 0) { - stats.totalVolumeKg += volumeKg; - bucket.volumeKg += volumeKg; - } - if (reps > 0) { - bucket.reps += reps; - bucket.concentricKg += repDetails.totalConcentricKg; - bucket.eccentricKg += repDetails.totalEccentricKg; - } - stats.breakdown.set(category, bucket); - }); - stats.averageLoadKg = stats.totalReps > 0 ? stats.totalVolumeKg / stats.totalReps : 0; - stats.averageConcentricKg = stats.totalReps > 0 ? stats.totalConcentricKg / stats.totalReps : 0; - stats.averageEccentricKg = stats.totalReps > 0 ? stats.totalEccentricKg / stats.totalReps : 0; - return stats; - } - - getWorkoutRepDetails(workout) { - if (!workout || typeof workout !== 'object') { - return { count: 0, totalConcentricKg: 0, totalEccentricKg: 0 }; - } - const analysis = this.ensurePhaseAnalysis(workout); - if (this.hasPhaseReps(analysis)) { - const count = analysis.reps.length; - const conc = Number(analysis.totalConcentricKg) || 0; - const ecc = Number(analysis.totalEccentricKg) || 0; - return { - count, - totalConcentricKg: conc, - totalEccentricKg: ecc - }; - } - const stored = Number(workout.reps); - if (Number.isFinite(stored) && stored > 0) { - const totalLoadKg = this.getWorkoutTotalLoadKg(workout); - const volume = Number.isFinite(totalLoadKg) && totalLoadKg > 0 ? totalLoadKg * stored : 0; - return { - count: stored, - totalConcentricKg: volume, - totalEccentricKg: volume - }; - } - const builderReps = Number(workout.builderMeta?.reps); - if (Number.isFinite(builderReps) && builderReps > 0) { - const totalLoadKg = this.getWorkoutTotalLoadKg(workout); - const volume = Number.isFinite(totalLoadKg) && totalLoadKg > 0 ? totalLoadKg * builderReps : 0; - return { - count: builderReps, - totalConcentricKg: volume, - totalEccentricKg: volume - }; - } - return { count: 0, totalConcentricKg: 0, totalEccentricKg: 0 }; - } - - getWorkoutRepCount(workout) { - const details = this.getWorkoutRepDetails(workout); - return details.count; - } - - getWorkoutModeCategory(workout) { - if (!workout) { - return WORKLOAD_OTHER_CATEGORY.key; - } - if (this.isEchoWorkout(workout)) { - return 'ECHO'; - } - const resolved = this.resolveWorkoutModeValue(workout); - if (!resolved) { - return WORKLOAD_OTHER_CATEGORY.key; - } - if (resolved === 'ECHO') return 'ECHO'; - if (resolved === 'TIME_UNDER_TENSION_BEAST' || resolved === 'TUT_BEAST') { - return 'TIME_UNDER_TENSION_BEAST'; - } - if (resolved === 'TIME_UNDER_TENSION' || resolved === 'TUT') { - return 'TIME_UNDER_TENSION'; - } - if (resolved === 'ECCENTRIC' || resolved === 'ECCENTRIC_ONLY') { - return 'ECCENTRIC'; - } - if (resolved === 'OLD_SCHOOL' || resolved === 'JUST_LIFT' || resolved === 'PUMP') { - return 'OLD_SCHOOL'; - } - if (resolved.includes('BEAST')) { - return 'TIME_UNDER_TENSION_BEAST'; - } - if (resolved.includes('TUT')) { - return 'TIME_UNDER_TENSION'; - } - if (resolved.includes('ECCENTRIC')) { - return 'ECCENTRIC'; - } - if (resolved.includes('OLD') || resolved.includes('JUST') || resolved.includes('PUMP')) { - return 'OLD_SCHOOL'; - } - if (resolved.includes('ECHO')) { - return 'ECHO'; - } - return WORKLOAD_OTHER_CATEGORY.key; - } - - resolveWorkoutModeValue(workout) { - const candidates = [ - workout?.mode, - workout?.builderMeta?.mode, - workout?.builderMeta?.modeLabel, - workout?.planMode, - workout?.itemType, - workout?.programMode, - workout?.builderMeta?.programMode - ]; - for (const candidate of candidates) { - const normalized = this.normalizeModeValue(candidate); - if (normalized) { - return normalized; - } - } - return null; - } - - normalizeModeValue(value) { - if (value === null || value === undefined) { - return null; - } - if (typeof value === 'number') { - return PROGRAM_MODE_VALUE_MAP.get(value) || null; - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const upper = trimmed.toUpperCase(); - if (upper.includes('ECHO')) { - return 'ECHO'; - } - if (upper === 'TUT') { - return 'TIME_UNDER_TENSION'; - } - if (upper === 'TUT_BEAST') { - return 'TIME_UNDER_TENSION_BEAST'; - } - if (upper === 'ECCENTRIC_ONLY') { - return 'ECCENTRIC'; - } - if (upper.includes('JUST') && upper.includes('LIFT')) { - return 'OLD_SCHOOL'; - } - return upper.replace(/[^A-Z0-9]+/g, '_'); - } - return null; - } - - formatVolumeValue(kg) { - const unit = this.getUnitLabel(); - const display = this.convertKgToDisplay(kg, unit); - if (!Number.isFinite(display) || display <= 0) { - return `0 ${unit}`; - } - return `${this.formatCompactValue(display)} ${unit}`; - } - - formatCount(value) { - if (!Number.isFinite(value) || value <= 0) { - return '0'; - } - return Math.round(value).toLocaleString(); - } - - formatPercent(value) { - if (!Number.isFinite(value) || value <= 0) { - return '0%'; - } - if (value >= 99.5) { - return '100%'; - } - if (value >= 10) { - return `${Math.round(value)}%`; - } - return `${value.toFixed(1)}%`; - } - - formatGrowthPercent(value) { - if (!Number.isFinite(value) || value === 0) { - return '0%'; - } - const sign = value > 0 ? '+' : ''; - return `${sign}${value.toFixed(1)}%`; - } - - formatDeltaWeight(deltaKg) { - if (!Number.isFinite(deltaKg)) { - return '—'; - } - const unit = this.getUnitLabel(); - const display = Math.abs(this.convertKgToDisplay(deltaKg, unit)); - const decimals = this.getDisplayDecimals(); - const sign = deltaKg > 0 ? '+' : deltaKg < 0 ? '-' : ''; - return `${sign}${display.toFixed(decimals)} ${unit}`; - } - - formatAverageLoad(kg) { - if (!Number.isFinite(kg) || kg <= 0) { - return '—'; - } - return this.formatWeight(kg); - } + updateSummary(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + this.setDualMetricPlaceholders(); + return; + } + + let peakConcentricEntry = null; + let peakEccentricEntry = null; + let baselineEntry = null; + let baselineTime = Infinity; + + entries.forEach((entry) => { + const conc = Number(entry.concentricKg ?? entry.weightKg) || 0; + const ecc = Number(entry.eccentricKg) || 0; + if (!peakConcentricEntry || conc > (Number(peakConcentricEntry.concentricKg ?? peakConcentricEntry.weightKg) || 0)) { + peakConcentricEntry = entry; + } + if (!peakEccentricEntry || ecc > (Number(peakEccentricEntry.eccentricKg) || 0)) { + peakEccentricEntry = entry; + } + const dayValue = Number(entry.day); + const timeValue = Number.isFinite(dayValue) + ? dayValue + : entry.timestamp instanceof Date && !Number.isNaN(entry.timestamp.getTime()) + ? entry.timestamp.getTime() + : Infinity; + if (timeValue < baselineTime) { + baselineEntry = entry; + baselineTime = timeValue; + } + }); + + if (!baselineEntry) { + baselineEntry = entries[0]; + } + + const baselineConcentric = Number(baselineEntry?.concentricKg ?? baselineEntry?.weightKg) || 0; + const baselineEccentric = Number(baselineEntry?.eccentricKg ?? baselineEntry?.weightKg) || 0; + const baselineDate = baselineEntry?.timestamp || baselineEntry?.day || null; + + const peakConcentric = Number(peakConcentricEntry?.concentricKg ?? peakConcentricEntry?.weightKg) || 0; + const peakEccentric = Number(peakEccentricEntry?.eccentricKg ?? peakConcentricEntry?.eccentricKg) || 0; + + this.setDualMetricValue(this.peakConcentricValueEl, peakConcentric); + this.setDualMetricValue(this.peakEccentricValueEl, peakEccentric); + this.setDualMetricDate(this.peakConcentricDateEl, peakConcentricEntry); + this.setDualMetricDate(this.peakEccentricDateEl, peakEccentricEntry); + + this.setDualMetricValue(this.baselineConcentricValueEl, baselineConcentric); + this.setDualMetricValue(this.baselineEccentricValueEl, baselineEccentric); + this.setDualMetricDateValue(this.baselineConcentricDateEl, baselineDate); + this.setDualMetricDateValue(this.baselineEccentricDateEl, baselineDate); + + this.setStrengthGainMetrics( + this.deltaConcentricPctEl, + this.deltaConcentricValueEl, + peakConcentric, + baselineConcentric + ); + this.setStrengthGainMetrics( + this.deltaEccentricPctEl, + this.deltaEccentricValueEl, + peakEccentric, + baselineEccentric + ); + } + + updateWorkloadMetrics(workouts) { + if (!this.totalVolumeEl || !this.totalRepsEl || !this.averageLoadEl) { + return; + } + const stats = this.calculateWorkloadStats(workouts); + this.currentWorkloadStats = stats; + if (!stats.hasWorkouts) { + this.totalVolumeEl.textContent = '—'; + this.totalRepsEl.textContent = '—'; + this.averageLoadEl.textContent = '—'; + if (this.workloadTriggerEl) { + this.workloadTriggerEl.disabled = true; + } + this.updateWorkloadSummary(stats); + this.renderWorkloadBreakdown(stats); + return; + } + const volumeDisplay = stats.totalVolumeKg > 0 + ? this.formatVolumeValue(stats.totalVolumeKg) + : this.formatVolumeValue(0); + this.totalVolumeEl.textContent = volumeDisplay; + this.totalRepsEl.textContent = stats.totalReps > 0 ? this.formatCount(stats.totalReps) : '0'; + this.averageLoadEl.textContent = + stats.totalReps > 0 && stats.averageLoadKg > 0 ? this.formatWeight(stats.averageLoadKg) : '—'; + if (this.workloadTriggerEl) { + const disabled = !(stats.totalVolumeKg > 0); + this.workloadTriggerEl.disabled = disabled; + if (disabled) { + this.hideWorkloadTooltip(true); + } + } + this.updateWorkloadSummary(stats); + this.renderWorkloadBreakdown(stats); + } + + updateWorkloadSummary(stats) { + if (!this.workloadAvgConcentricEl || !this.workloadAvgEccentricEl) { + return; + } + const avgConc = Number(stats?.averageConcentricKg) || 0; + const avgEcc = Number(stats?.averageEccentricKg) || 0; + this.workloadAvgConcentricEl.textContent = avgConc > 0 ? this.formatWeight(avgConc) : '—'; + this.workloadAvgEccentricEl.textContent = avgEcc > 0 ? this.formatWeight(avgEcc) : '—'; + } + + renderWorkloadBreakdown(stats) { + if (!this.workloadBreakdownEl || !this.workloadPieEl) { + return; + } + const totalVolume = Number(stats?.totalVolumeKg) || 0; + if (this.workloadTooltipTotalEl) { + this.workloadTooltipTotalEl.textContent = + totalVolume > 0 ? this.formatVolumeValue(totalVolume) : '—'; + } + if (!stats?.hasWorkouts || totalVolume <= 0) { + this.workloadPieEl.style.background = '#182036'; + this.workloadBreakdownEl.innerHTML = ''; + const empty = document.createElement('p'); + empty.className = 'analytics-workload-breakdown__empty'; + empty.textContent = this.currentExerciseKey + ? 'No working volume recorded for this range.' + : 'Select an exercise to view rep mix.'; + this.workloadBreakdownEl.appendChild(empty); + return; + } + const segments = this.buildWorkloadSegments(stats.breakdown, totalVolume); + this.applyWorkloadPieSegments(segments, totalVolume); + const visibleSegments = segments.filter((segment) => segment.valueKg > 0 || segment.reps > 0); + if (!visibleSegments.length) { + this.workloadBreakdownEl.innerHTML = ''; + const empty = document.createElement('p'); + empty.className = 'analytics-workload-breakdown__empty'; + empty.textContent = 'No rep breakdown available for this range.'; + this.workloadBreakdownEl.appendChild(empty); + return; + } + const fragment = document.createDocumentFragment(); + visibleSegments.forEach((segment) => { + const row = document.createElement('div'); + row.className = 'analytics-workload-breakdown__row'; + const label = document.createElement('div'); + label.className = 'analytics-workload-breakdown__label'; + const swatch = document.createElement('span'); + swatch.className = 'analytics-workload-breakdown__swatch'; + swatch.style.backgroundColor = segment.color; + label.appendChild(swatch); + const text = document.createElement('span'); + text.textContent = segment.label; + label.appendChild(text); + const percent = document.createElement('strong'); + percent.className = 'analytics-workload-breakdown__percent'; + percent.textContent = this.formatPercent(segment.percent); + const details = document.createElement('div'); + details.className = 'analytics-workload-breakdown__details'; + const volumeLine = document.createElement('span'); + volumeLine.textContent = this.formatVolumeValue(segment.valueKg); + const repsLine = document.createElement('span'); + repsLine.textContent = `${this.formatCount(segment.reps)} reps`; + const avgConcLine = document.createElement('span'); + avgConcLine.textContent = `Avg Con ${this.formatAverageLoad(segment.avgConcentricKg)}`; + const avgEccLine = document.createElement('span'); + avgEccLine.textContent = `Avg Ecc ${this.formatAverageLoad(segment.avgEccentricKg)}`; + details.appendChild(volumeLine); + details.appendChild(repsLine); + details.appendChild(avgConcLine); + details.appendChild(avgEccLine); + row.appendChild(label); + row.appendChild(percent); + row.appendChild(details); + fragment.appendChild(row); + }); + this.workloadBreakdownEl.innerHTML = ''; + this.workloadBreakdownEl.appendChild(fragment); + } + + buildWorkloadSegments(breakdownMap, totalVolumeKg) { + const map = breakdownMap instanceof Map ? breakdownMap : new Map(); + const trackedKeys = new Set(WORKLOAD_BREAKDOWN_CATEGORIES.map((category) => category.key)); + const segments = WORKLOAD_BREAKDOWN_CATEGORIES.map((category) => { + const entry = map.get(category.key) || { + volumeKg: 0, + reps: 0, + concentricKg: 0, + eccentricKg: 0 + }; + const reps = Number(entry.reps) || 0; + const conc = Number(entry.concentricKg) || 0; + const ecc = Number(entry.eccentricKg) || 0; + const volumeKg = Number(entry.volumeKg) || 0; + return { + key: category.key, + label: category.label, + color: category.color, + valueKg: volumeKg, + reps, + percent: totalVolumeKg > 0 ? (volumeKg / totalVolumeKg) * 100 : 0, + avgConcentricKg: reps > 0 ? conc / reps : 0, + avgEccentricKg: reps > 0 ? ecc / reps : 0 + }; + }); + const otherEntry = { + volumeKg: 0, + reps: 0, + concentricKg: 0, + eccentricKg: 0 + }; + map.forEach((entry, key) => { + if (trackedKeys.has(key)) { + return; + } + otherEntry.volumeKg += Number(entry?.volumeKg) || 0; + otherEntry.reps += Number(entry?.reps) || 0; + otherEntry.concentricKg += Number(entry?.concentricKg) || 0; + otherEntry.eccentricKg += Number(entry?.eccentricKg) || 0; + }); + const captured = segments.reduce((sum, entry) => sum + entry.valueKg, 0) + otherEntry.volumeKg; + const remainderKg = Math.max(0, totalVolumeKg - captured); + if (remainderKg > 0.01) { + otherEntry.volumeKg += remainderKg; + } + if (otherEntry.volumeKg > 0.01 || otherEntry.reps > 0) { + const reps = Number(otherEntry.reps) || 0; + segments.push({ + key: WORKLOAD_OTHER_CATEGORY.key, + label: WORKLOAD_OTHER_CATEGORY.label, + color: WORKLOAD_OTHER_CATEGORY.color, + valueKg: otherEntry.volumeKg, + reps, + percent: totalVolumeKg > 0 ? (otherEntry.volumeKg / totalVolumeKg) * 100 : 0, + avgConcentricKg: reps > 0 ? (otherEntry.concentricKg || 0) / reps : 0, + avgEccentricKg: reps > 0 ? (otherEntry.eccentricKg || 0) / reps : 0 + }); + } + return segments; + } + + applyWorkloadPieSegments(segments, totalVolumeKg) { + if (!this.workloadPieEl) { + return; + } + const validSegments = segments.filter((segment) => segment.valueKg > 0); + if (!validSegments.length || !Number.isFinite(totalVolumeKg) || totalVolumeKg <= 0) { + this.workloadPieEl.style.background = '#182036'; + return; + } + let offset = 0; + const gradientStops = validSegments.map((segment) => { + const share = segment.valueKg / totalVolumeKg; + const start = offset * 360; + offset += share; + const end = Math.min(360, offset * 360); + return `${segment.color} ${start}deg ${end}deg`; + }); + this.workloadPieEl.style.background = `conic-gradient(${gradientStops.join(', ')})`; + } + + calculateWorkloadStats(workouts = []) { + const hasWorkouts = Array.isArray(workouts) && workouts.length > 0; + const stats = { + totalVolumeKg: 0, + totalReps: 0, + averageLoadKg: 0, + totalConcentricKg: 0, + totalEccentricKg: 0, + averageConcentricKg: 0, + averageEccentricKg: 0, + breakdown: new Map(), + hasWorkouts + }; + if (!hasWorkouts) { + return stats; + } + workouts.forEach((workout) => { + if (!workout) { + return; + } + const repDetails = this.getWorkoutRepDetails(workout); + const reps = repDetails.count; + if (reps > 0) { + stats.totalReps += reps; + stats.totalConcentricKg += repDetails.totalConcentricKg; + stats.totalEccentricKg += repDetails.totalEccentricKg; + } + const category = this.getWorkoutModeCategory(workout); + const bucket = stats.breakdown.get(category) || { + volumeKg: 0, + reps: 0, + concentricKg: 0, + eccentricKg: 0 + }; + const volumeKg = this.getWorkoutVolumeKg(workout); + if (Number.isFinite(volumeKg) && volumeKg > 0) { + stats.totalVolumeKg += volumeKg; + bucket.volumeKg += volumeKg; + } + if (reps > 0) { + bucket.reps += reps; + bucket.concentricKg += repDetails.totalConcentricKg; + bucket.eccentricKg += repDetails.totalEccentricKg; + } + stats.breakdown.set(category, bucket); + }); + stats.averageLoadKg = stats.totalReps > 0 ? stats.totalVolumeKg / stats.totalReps : 0; + stats.averageConcentricKg = stats.totalReps > 0 ? stats.totalConcentricKg / stats.totalReps : 0; + stats.averageEccentricKg = stats.totalReps > 0 ? stats.totalEccentricKg / stats.totalReps : 0; + return stats; + } + + getWorkoutRepDetails(workout) { + if (!workout || typeof workout !== 'object') { + return { count: 0, totalConcentricKg: 0, totalEccentricKg: 0 }; + } + const analysis = this.ensurePhaseAnalysis(workout); + if (this.hasPhaseReps(analysis)) { + const count = analysis.reps.length; + const conc = Number(analysis.totalConcentricKg) || 0; + const ecc = Number(analysis.totalEccentricKg) || 0; + return { + count, + totalConcentricKg: conc, + totalEccentricKg: ecc + }; + } + const stored = Number(workout.reps); + if (Number.isFinite(stored) && stored > 0) { + const totalLoadKg = this.getWorkoutTotalLoadKg(workout); + const volume = Number.isFinite(totalLoadKg) && totalLoadKg > 0 ? totalLoadKg * stored : 0; + return { + count: stored, + totalConcentricKg: volume, + totalEccentricKg: volume + }; + } + const builderReps = Number(workout.builderMeta?.reps); + if (Number.isFinite(builderReps) && builderReps > 0) { + const totalLoadKg = this.getWorkoutTotalLoadKg(workout); + const volume = Number.isFinite(totalLoadKg) && totalLoadKg > 0 ? totalLoadKg * builderReps : 0; + return { + count: builderReps, + totalConcentricKg: volume, + totalEccentricKg: volume + }; + } + return { count: 0, totalConcentricKg: 0, totalEccentricKg: 0 }; + } + + getWorkoutRepCount(workout) { + const details = this.getWorkoutRepDetails(workout); + return details.count; + } + + getWorkoutModeCategory(workout) { + if (!workout) { + return WORKLOAD_OTHER_CATEGORY.key; + } + if (this.isEchoWorkout(workout)) { + return 'ECHO'; + } + const resolved = this.resolveWorkoutModeValue(workout); + if (!resolved) { + return WORKLOAD_OTHER_CATEGORY.key; + } + if (resolved === 'ECHO') return 'ECHO'; + if (resolved === 'TIME_UNDER_TENSION_BEAST' || resolved === 'TUT_BEAST') { + return 'TIME_UNDER_TENSION_BEAST'; + } + if (resolved === 'TIME_UNDER_TENSION' || resolved === 'TUT') { + return 'TIME_UNDER_TENSION'; + } + if (resolved === 'ECCENTRIC' || resolved === 'ECCENTRIC_ONLY') { + return 'ECCENTRIC'; + } + if (resolved === 'OLD_SCHOOL' || resolved === 'JUST_LIFT' || resolved === 'PUMP') { + return 'OLD_SCHOOL'; + } + if (resolved.includes('BEAST')) { + return 'TIME_UNDER_TENSION_BEAST'; + } + if (resolved.includes('TUT')) { + return 'TIME_UNDER_TENSION'; + } + if (resolved.includes('ECCENTRIC')) { + return 'ECCENTRIC'; + } + if (resolved.includes('OLD') || resolved.includes('JUST') || resolved.includes('PUMP')) { + return 'OLD_SCHOOL'; + } + if (resolved.includes('ECHO')) { + return 'ECHO'; + } + return WORKLOAD_OTHER_CATEGORY.key; + } + + resolveWorkoutModeValue(workout) { + const candidates = [ + workout?.mode, + workout?.builderMeta?.mode, + workout?.builderMeta?.modeLabel, + workout?.planMode, + workout?.itemType, + workout?.programMode, + workout?.builderMeta?.programMode + ]; + for (const candidate of candidates) { + const normalized = this.normalizeModeValue(candidate); + if (normalized) { + return normalized; + } + } + return null; + } + + normalizeModeValue(value) { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'number') { + return PROGRAM_MODE_VALUE_MAP.get(value) || null; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const upper = trimmed.toUpperCase(); + if (upper.includes('ECHO')) { + return 'ECHO'; + } + if (upper === 'TUT') { + return 'TIME_UNDER_TENSION'; + } + if (upper === 'TUT_BEAST') { + return 'TIME_UNDER_TENSION_BEAST'; + } + if (upper === 'ECCENTRIC_ONLY') { + return 'ECCENTRIC'; + } + if (upper.includes('JUST') && upper.includes('LIFT')) { + return 'OLD_SCHOOL'; + } + return upper.replace(/[^A-Z0-9]+/g, '_'); + } + return null; + } + + formatVolumeValue(kg) { + const unit = this.getUnitLabel(); + const display = this.convertKgToDisplay(kg, unit); + if (!Number.isFinite(display) || display <= 0) { + return `0 ${unit}`; + } + return `${this.formatCompactValue(display)} ${unit}`; + } + + formatCount(value) { + if (!Number.isFinite(value) || value <= 0) { + return '0'; + } + return Math.round(value).toLocaleString(); + } + + formatPercent(value) { + if (!Number.isFinite(value) || value <= 0) { + return '0%'; + } + if (value >= 99.5) { + return '100%'; + } + if (value >= 10) { + return `${Math.round(value)}%`; + } + return `${value.toFixed(1)}%`; + } + + formatGrowthPercent(value) { + if (!Number.isFinite(value) || value === 0) { + return '0%'; + } + const sign = value > 0 ? '+' : ''; + return `${sign}${value.toFixed(1)}%`; + } + + formatDeltaWeight(deltaKg) { + if (!Number.isFinite(deltaKg)) { + return '—'; + } + const unit = this.getUnitLabel(); + const display = Math.abs(this.convertKgToDisplay(deltaKg, unit)); + const decimals = this.getDisplayDecimals(); + const sign = deltaKg > 0 ? '+' : deltaKg < 0 ? '-' : ''; + return `${sign}${display.toFixed(decimals)} ${unit}`; + } + + formatAverageLoad(kg) { + if (!Number.isFinite(kg) || kg <= 0) { + return '—'; + } + return this.formatWeight(kg); + } updateMeta(entries) { if (this.rangeLabelEl) { @@ -2763,73 +2840,73 @@ export class AnalyticsDashboard { } workout.cablePeakKg = peak; - workout.totalLoadPeakKg = peak; - return peak; - } - - setDualMetricPlaceholders() { - const valueEls = [ - this.peakConcentricValueEl, - this.peakEccentricValueEl, - this.baselineConcentricValueEl, - this.baselineEccentricValueEl, - this.deltaConcentricValueEl, - this.deltaEccentricValueEl - ]; - valueEls.forEach((el) => { - if (el) el.textContent = '—'; - }); - const percentEls = [this.deltaConcentricPctEl, this.deltaEccentricPctEl]; - percentEls.forEach((el) => { - if (el) el.textContent = '0%'; - }); - const dateEls = [ - this.peakConcentricDateEl, - this.peakEccentricDateEl, - this.baselineConcentricDateEl, - this.baselineEccentricDateEl - ]; - dateEls.forEach((el) => { - if (el) el.textContent = ''; - }); - } - - setDualMetricValue(el, kg) { - if (!el) return; - if (Number.isFinite(kg) && kg > 0) { - el.textContent = this.formatWeight(kg); - } else { - el.textContent = '—'; - } - } - - setDualMetricDate(el, entry) { - if (!el) return; - if (!entry) { - el.textContent = ''; - return; - } - const dateValue = entry.timestamp || entry.day || null; - this.setDualMetricDateValue(el, dateValue); - } - - setDualMetricDateValue(el, dateValue) { - if (!el) return; - if (!dateValue) { - el.textContent = ''; - return; - } - el.textContent = this.formatDate(dateValue); - } - - setStrengthGainMetrics(pctEl, valueEl, peakKg, baselineKg) { - if (pctEl) { - const percent = baselineKg > 0 ? ((Math.max(0, peakKg - baselineKg)) / baselineKg) * 100 : 0; - pctEl.textContent = this.formatGrowthPercent(percent); - } - if (valueEl) { - const deltaKg = Math.max(0, peakKg - baselineKg); - valueEl.textContent = this.formatDeltaWeight(deltaKg); - } - } -} + workout.totalLoadPeakKg = peak; + return peak; + } + + setDualMetricPlaceholders() { + const valueEls = [ + this.peakConcentricValueEl, + this.peakEccentricValueEl, + this.baselineConcentricValueEl, + this.baselineEccentricValueEl, + this.deltaConcentricValueEl, + this.deltaEccentricValueEl + ]; + valueEls.forEach((el) => { + if (el) el.textContent = '—'; + }); + const percentEls = [this.deltaConcentricPctEl, this.deltaEccentricPctEl]; + percentEls.forEach((el) => { + if (el) el.textContent = '0%'; + }); + const dateEls = [ + this.peakConcentricDateEl, + this.peakEccentricDateEl, + this.baselineConcentricDateEl, + this.baselineEccentricDateEl + ]; + dateEls.forEach((el) => { + if (el) el.textContent = ''; + }); + } + + setDualMetricValue(el, kg) { + if (!el) return; + if (Number.isFinite(kg) && kg > 0) { + el.textContent = this.formatWeight(kg); + } else { + el.textContent = '—'; + } + } + + setDualMetricDate(el, entry) { + if (!el) return; + if (!entry) { + el.textContent = ''; + return; + } + const dateValue = entry.timestamp || entry.day || null; + this.setDualMetricDateValue(el, dateValue); + } + + setDualMetricDateValue(el, dateValue) { + if (!el) return; + if (!dateValue) { + el.textContent = ''; + return; + } + el.textContent = this.formatDate(dateValue); + } + + setStrengthGainMetrics(pctEl, valueEl, peakKg, baselineKg) { + if (pctEl) { + const percent = baselineKg > 0 ? ((Math.max(0, peakKg - baselineKg)) / baselineKg) * 100 : 0; + pctEl.textContent = this.formatGrowthPercent(percent); + } + if (valueEl) { + const deltaKg = Math.max(0, peakKg - baselineKg); + valueEl.textContent = this.formatDeltaWeight(deltaKg); + } + } +} diff --git a/workout-time/app.js b/workout-time/app.js index 2ca91c2..214b871 100644 --- a/workout-time/app.js +++ b/workout-time/app.js @@ -1,19 +1,19 @@ // app.js - Main application logic and UI management -const sharedWeights = window.WeightUtils || {}; -let sharedEchoTelemetry = typeof window !== "undefined" ? window.EchoTelemetry || null : null; -const resolveSharedEchoTelemetry = () => { - if (typeof window === "undefined") { - sharedEchoTelemetry = null; - return sharedEchoTelemetry; - } - if (window.EchoTelemetry && window.EchoTelemetry !== sharedEchoTelemetry) { - sharedEchoTelemetry = window.EchoTelemetry; - } - return sharedEchoTelemetry; -}; -const LB_PER_KG = sharedWeights.LB_PER_KG || 2.2046226218488; -const KG_PER_LB = sharedWeights.KG_PER_LB || 1 / LB_PER_KG; +const sharedWeights = window.WeightUtils || {}; +let sharedEchoTelemetry = typeof window !== "undefined" ? window.EchoTelemetry || null : null; +const resolveSharedEchoTelemetry = () => { + if (typeof window === "undefined") { + sharedEchoTelemetry = null; + return sharedEchoTelemetry; + } + if (window.EchoTelemetry && window.EchoTelemetry !== sharedEchoTelemetry) { + sharedEchoTelemetry = window.EchoTelemetry; + } + return sharedEchoTelemetry; +}; +const LB_PER_KG = sharedWeights.LB_PER_KG || 2.2046226218488; +const KG_PER_LB = sharedWeights.KG_PER_LB || 1 / LB_PER_KG; const fallbackConvertKgToUnit = (kg, unit = "kg") => { if (kg === null || kg === undefined || isNaN(kg)) { return NaN; @@ -26,38 +26,38 @@ const fallbackConvertUnitToKg = (value, unit = "kg") => { } return unit === "lb" ? value * KG_PER_LB : value; }; -const sharedConvertKgToUnit = - typeof sharedWeights.convertKgToUnit === "function" - ? sharedWeights.convertKgToUnit - : fallbackConvertKgToUnit; -const sharedConvertUnitToKg = - typeof sharedWeights.convertUnitToKg === "function" - ? sharedWeights.convertUnitToKg - : fallbackConvertUnitToKg; -const sharedGetUnitPreference = - typeof sharedWeights.getStoredUnitPreference === "function" - ? sharedWeights.getStoredUnitPreference - : null; -const sharedSetUnitPreference = - typeof sharedWeights.setStoredUnitPreference === "function" - ? sharedWeights.setStoredUnitPreference - : null; -let sharedAnalyzePhases = null; -let sharedIsEchoWorkout = null; -const refreshSharedEchoTelemetryHelpers = () => { - const telemetry = resolveSharedEchoTelemetry(); - sharedAnalyzePhases = - typeof telemetry?.analyzeMovementPhases === "function" - ? telemetry.analyzeMovementPhases - : typeof telemetry?.analyzeEchoWorkout === "function" - ? telemetry.analyzeEchoWorkout - : null; - sharedIsEchoWorkout = - typeof telemetry?.isEchoWorkout === "function" - ? telemetry.isEchoWorkout - : null; -}; -refreshSharedEchoTelemetryHelpers(); +const sharedConvertKgToUnit = + typeof sharedWeights.convertKgToUnit === "function" + ? sharedWeights.convertKgToUnit + : fallbackConvertKgToUnit; +const sharedConvertUnitToKg = + typeof sharedWeights.convertUnitToKg === "function" + ? sharedWeights.convertUnitToKg + : fallbackConvertUnitToKg; +const sharedGetUnitPreference = + typeof sharedWeights.getStoredUnitPreference === "function" + ? sharedWeights.getStoredUnitPreference + : null; +const sharedSetUnitPreference = + typeof sharedWeights.setStoredUnitPreference === "function" + ? sharedWeights.setStoredUnitPreference + : null; +let sharedAnalyzePhases = null; +let sharedIsEchoWorkout = null; +const refreshSharedEchoTelemetryHelpers = () => { + const telemetry = resolveSharedEchoTelemetry(); + sharedAnalyzePhases = + typeof telemetry?.analyzeMovementPhases === "function" + ? telemetry.analyzeMovementPhases + : typeof telemetry?.analyzeEchoWorkout === "function" + ? telemetry.analyzeEchoWorkout + : null; + sharedIsEchoWorkout = + typeof telemetry?.isEchoWorkout === "function" + ? telemetry.isEchoWorkout + : null; +}; +refreshSharedEchoTelemetryHelpers(); const DEFAULT_PER_CABLE_KG = 4; // ≈8.8 lb baseline when nothing is loaded const MIN_ACTIVE_CABLE_RANGE = 35; // minimum delta between red/green markers to treat a cable as engaged for load tracking const AUTO_STOP_RANGE_THRESHOLD = 50; // slightly higher buffer for safety before auto-stop logic activates @@ -69,43 +69,43 @@ const PR_HIGHLIGHT_STYLE = { bgColor: { rgb: "FFC6EFCE" }, }, }; -const HEADER_STYLE = { - fill: { - patternType: "solid", - fgColor: { rgb: "FF1F4E78" }, - bgColor: { rgb: "FF1F4E78" }, +const HEADER_STYLE = { + fill: { + patternType: "solid", + fgColor: { rgb: "FF1F4E78" }, + bgColor: { rgb: "FF1F4E78" }, }, font: { color: { rgb: "FFFFFFFF" }, bold: true, }, }; -const WORKOUT_TAB_COLOR = { rgb: "FF2E75B6" }; -const PR_TAB_COLOR = { rgb: "FFF1C232" }; -const PLAN_SUMMARY_FLAT_AMOUNTS = [0.5, 1, 1.5, 2, 2.5, 5]; -const PLAN_SUMMARY_PERCENT_AMOUNTS = [0.5, 1, 1.5, 2, 2.5, 5]; -const FILTERED_HISTORY_PAGE_SIZE = 20; - -const escapeHtml = (value) => { - const str = value === null || value === undefined ? '' : String(value); - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -}; +const WORKOUT_TAB_COLOR = { rgb: "FF2E75B6" }; +const PR_TAB_COLOR = { rgb: "FFF1C232" }; +const PLAN_SUMMARY_FLAT_AMOUNTS = [0.5, 1, 1.5, 2, 2.5, 5]; +const PLAN_SUMMARY_PERCENT_AMOUNTS = [0.5, 1, 1.5, 2, 2.5, 5]; +const FILTERED_HISTORY_PAGE_SIZE = 20; + +const escapeHtml = (value) => { + const str = value === null || value === undefined ? '' : String(value); + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; class VitruvianApp { - constructor() { - this.device = new VitruvianDevice(); - this.chartManager = new ChartManager("loadGraph"); - this._boundMonitorListener = null; - this._boundRepListener = null; - this._monitorListenerRegistered = false; - this._repListenerRegistered = false; - this._pendingChartResizeFrame = null; - this._delayedChartResizeTimeout = null; + constructor() { + this.device = new VitruvianDevice(); + this.chartManager = new ChartManager("loadGraph"); + this._boundMonitorListener = null; + this._boundRepListener = null; + this._monitorListenerRegistered = false; + this._repListenerRegistered = false; + this._pendingChartResizeFrame = null; + this._delayedChartResizeTimeout = null; this.dropboxManager = new DropboxManager(); // Dropbox cloud storage this.maxPos = 1000; // Shared max for both cables (keeps bars comparable) this.weightUnit = "kg"; // Display unit for weights (default) @@ -131,11 +131,11 @@ class VitruvianApp { this.warmupTarget = 3; // Default warmup target this.targetReps = 0; // Target working reps this.workoutHistory = []; // Track completed workouts - this.personalRecords = this.loadPersonalRecordsCache(); - const personalRecordsSyncState = this.loadPersonalRecordsSyncState(); - this._pendingPersonalRecordCandidate = null; - this._personalRecordsDirty = personalRecordsSyncState.dirty; - this._personalRecordsSyncInFlight = false; + this.personalRecords = this.loadPersonalRecordsCache(); + const personalRecordsSyncState = this.loadPersonalRecordsSyncState(); + this._pendingPersonalRecordCandidate = null; + this._personalRecordsDirty = personalRecordsSyncState.dirty; + this._personalRecordsSyncInFlight = false; this._pendingPersonalRecordsDropboxSync = personalRecordsSyncState.pendingDropboxSync; this._personalRecordsForceSync = false; @@ -160,12 +160,12 @@ class VitruvianApp { this.defaultPerCableKg = DEFAULT_PER_CABLE_KG; this._weightInputKg = DEFAULT_PER_CABLE_KG; this._cancelRest = null; - this.theme = this.loadStoredTheme(); - this.applyAppVersion(); - this.registerAppVersionListener(); - this.setupLogging(); - this.registerDeviceListeners(); - this.setupChart(); + this.theme = this.loadStoredTheme(); + this.applyAppVersion(); + this.registerAppVersionListener(); + this.setupLogging(); + this.registerDeviceListeners(); + this.setupChart(); this.setupUnitControls(); this.planItems = []; // array of {type: 'exercise'|'echo', fields...} this.planActive = false; // true when plan runner is active @@ -208,13 +208,13 @@ class VitruvianApp { this._scrollButtonsUpdate = null; - this.selectedHistoryKey = null; // currently selected history entry key - this.selectedHistoryIndex = null; // cache index for quick lookup - - this.historyPage = 1; - this.historyPageSize = 5; - this.historyFilterKey = "all"; - this._loadedPlanName = null; + this.selectedHistoryKey = null; // currently selected history entry key + this.selectedHistoryIndex = null; // cache index for quick lookup + + this.historyPage = 1; + this.historyPageSize = 5; + this.historyFilterKey = "all"; + this._loadedPlanName = null; this._preferredPlanSelection = null; this._planNameCollator = null; @@ -245,9 +245,9 @@ class VitruvianApp { this._planSummaryPercentOptions = []; this._planSummaryPercentLabelEl = null; this._planSummaryPercentUnitEl = null; - this._planSummaryAdjustmentFeedbackEl = null; - this._planSummaryDisplayUnit = null; - this._planSummarySource = null; + this._planSummaryAdjustmentFeedbackEl = null; + this._planSummaryDisplayUnit = null; + this._planSummarySource = null; this._planSummaryReopenBtn = null; this._wakeLockSentinel = null; @@ -310,50 +310,50 @@ class VitruvianApp { handleViewportChange(); } - setupLogging() { - // Connect device logging to UI - this.device.onLog = (message, type) => { - this.addLogEntry(message, type); - }; - } - - registerDeviceListeners() { - if (!this.device) { - return; - } - - if (!this._boundMonitorListener) { - this._boundMonitorListener = (sample) => { - this.updateLiveStats(sample); - }; - } - - if ( - !this._monitorListenerRegistered && - typeof this.device.addMonitorListener === "function" - ) { - this.device.addMonitorListener(this._boundMonitorListener); - this._monitorListenerRegistered = true; - } - - if (!this._boundRepListener) { - this._boundRepListener = (data) => { - this.handleRepNotification(data); - }; - } - - if ( - !this._repListenerRegistered && - typeof this.device.addRepListener === "function" - ) { - this.device.addRepListener(this._boundRepListener); - this._repListenerRegistered = true; - } - } - - setupChart() { - // Initialize chart and connect logging - this.chartManager.init(); + setupLogging() { + // Connect device logging to UI + this.device.onLog = (message, type) => { + this.addLogEntry(message, type); + }; + } + + registerDeviceListeners() { + if (!this.device) { + return; + } + + if (!this._boundMonitorListener) { + this._boundMonitorListener = (sample) => { + this.updateLiveStats(sample); + }; + } + + if ( + !this._monitorListenerRegistered && + typeof this.device.addMonitorListener === "function" + ) { + this.device.addMonitorListener(this._boundMonitorListener); + this._monitorListenerRegistered = true; + } + + if (!this._boundRepListener) { + this._boundRepListener = (data) => { + this.handleRepNotification(data); + }; + } + + if ( + !this._repListenerRegistered && + typeof this.device.addRepListener === "function" + ) { + this.device.addRepListener(this._boundRepListener); + this._repListenerRegistered = true; + } + } + + setupChart() { + // Initialize chart and connect logging + this.chartManager.init(); this.chartManager.onLog = (message, type) => { this.addLogEntry(message, type); }; @@ -425,16 +425,16 @@ class VitruvianApp { const storedUnit = this.loadStoredWeightUnit(); - if (storedUnit !== this.weightUnit) { - this.setWeightUnit(storedUnit, { - previousUnit: this.weightUnit, - force: true, - }); - } else { - this.onUnitChanged(); - this.saveWeightUnitPreference(); - } - } + if (storedUnit !== this.weightUnit) { + this.setWeightUnit(storedUnit, { + previousUnit: this.weightUnit, + force: true, + }); + } else { + this.onUnitChanged(); + this.saveWeightUnitPreference(); + } + } setupThemeToggle() { const toggleButton = document.getElementById("themeToggle"); @@ -449,78 +449,78 @@ class VitruvianApp { this.setTheme(this.theme, { skipSave: true }); } - loadStoredTheme() { - if (typeof window === "undefined" || !window.localStorage) { - return "light"; - } - try { - const stored = window.localStorage.getItem("vitruvian.theme"); - return stored === "dark" ? "dark" : "light"; - } catch (error) { - return "light"; - } - } - - registerAppVersionListener() { - if ( - typeof document === "undefined" || - typeof document.addEventListener !== "function" - ) { - return; - } - - const updateVersionBadge = () => { - this.applyAppVersion(); - }; - - document.addEventListener( - "workouttime:version-ready", - updateVersionBadge, - { once: true } - ); - } - - applyAppVersion() { - const root = - typeof globalThis !== "undefined" - ? globalThis - : typeof window !== "undefined" - ? window - : null; - - const badge = - typeof document !== "undefined" - ? document.getElementById("appVersionBadge") - : null; - - if (!badge) { - return; - } - - const versionInfo = (root && root.WorkoutTimeAppInfo) || {}; - const appVersion = - typeof versionInfo.version === "string" && versionInfo.version.trim().length > 0 - ? versionInfo.version.trim() - : null; - - if (!appVersion) { - badge.hidden = true; - badge.removeAttribute("title"); - delete badge.dataset.version; - return; - } - - const label = - typeof versionInfo.getVersionLabel === "function" - ? versionInfo.getVersionLabel({ prefix: "v" }) - : `v${appVersion}`; - - badge.textContent = label; - badge.hidden = false; - badge.setAttribute("title", `App version ${appVersion}`); - badge.setAttribute("aria-label", `Application version ${label}`); - badge.dataset.version = appVersion; - } + loadStoredTheme() { + if (typeof window === "undefined" || !window.localStorage) { + return "light"; + } + try { + const stored = window.localStorage.getItem("vitruvian.theme"); + return stored === "dark" ? "dark" : "light"; + } catch (error) { + return "light"; + } + } + + registerAppVersionListener() { + if ( + typeof document === "undefined" || + typeof document.addEventListener !== "function" + ) { + return; + } + + const updateVersionBadge = () => { + this.applyAppVersion(); + }; + + document.addEventListener( + "workouttime:version-ready", + updateVersionBadge, + { once: true } + ); + } + + applyAppVersion() { + const root = + typeof globalThis !== "undefined" + ? globalThis + : typeof window !== "undefined" + ? window + : null; + + const badge = + typeof document !== "undefined" + ? document.getElementById("appVersionBadge") + : null; + + if (!badge) { + return; + } + + const versionInfo = (root && root.WorkoutTimeAppInfo) || {}; + const appVersion = + typeof versionInfo.version === "string" && versionInfo.version.trim().length > 0 + ? versionInfo.version.trim() + : null; + + if (!appVersion) { + badge.hidden = true; + badge.removeAttribute("title"); + delete badge.dataset.version; + return; + } + + const label = + typeof versionInfo.getVersionLabel === "function" + ? versionInfo.getVersionLabel({ prefix: "v" }) + : `v${appVersion}`; + + badge.textContent = label; + badge.hidden = false; + badge.setAttribute("title", `App version ${appVersion}`); + badge.setAttribute("aria-label", `Application version ${label}`); + badge.dataset.version = appVersion; + } setTheme(theme, options = {}) { const normalized = theme === "dark" ? "dark" : "light"; @@ -1317,22 +1317,22 @@ class VitruvianApp { ); this._planSummaryReopenBtn = document.getElementById("planSummaryReopen"); - this._planSummaryFlatSelect = document.getElementById( - "planSummaryFlatSelect", - ); - this._planSummaryPercentSelect = document.getElementById( - "planSummaryPercentSelect", - ); - this._planSummaryFlatOptions = this.populatePlanSummaryOptions( - this._planSummaryFlatSelect, - PLAN_SUMMARY_FLAT_AMOUNTS, - "flat", - ); - this._planSummaryPercentOptions = this.populatePlanSummaryOptions( - this._planSummaryPercentSelect, - PLAN_SUMMARY_PERCENT_AMOUNTS, - "percent", - ); + this._planSummaryFlatSelect = document.getElementById( + "planSummaryFlatSelect", + ); + this._planSummaryPercentSelect = document.getElementById( + "planSummaryPercentSelect", + ); + this._planSummaryFlatOptions = this.populatePlanSummaryOptions( + this._planSummaryFlatSelect, + PLAN_SUMMARY_FLAT_AMOUNTS, + "flat", + ); + this._planSummaryPercentOptions = this.populatePlanSummaryOptions( + this._planSummaryPercentSelect, + PLAN_SUMMARY_PERCENT_AMOUNTS, + "percent", + ); if (this._planSummaryAdjustmentsEl) { this._planSummaryFlatGroup = this._planSummaryAdjustmentsEl.querySelector( @@ -1611,46 +1611,46 @@ class VitruvianApp { this._planSummaryAdjustmentsEl.dataset.activeMode = mode; } - if (disableAdjustments && this._planSummaryAdjustmentsHintEl) { - this._planSummaryAdjustmentsHintEl.textContent = - "Add at least one weighted exercise to adjust your next plan."; - } - } - - populatePlanSummaryOptions(selectElement, values, mode = "flat") { - if (!selectElement || !Array.isArray(values) || values.length === 0) { - return []; - } - - const normalizedMode = mode === "percent" ? "percent" : "flat"; - const dataAttribute = `data-plan-summary-${normalizedMode}`; - selectElement - .querySelectorAll(`option[${dataAttribute}]`) - .forEach((option) => option.remove()); - - const doc = selectElement.ownerDocument || document; - const createdOptions = []; - - values.forEach((value) => { - const numericValue = Number(value); - if (!Number.isFinite(numericValue) || numericValue <= 0) { - return; - } - - const textValue = numericValue.toString(); - const option = doc.createElement("option"); - option.value = textValue; - option.textContent = textValue; - option.setAttribute(dataAttribute, textValue); - selectElement.appendChild(option); - createdOptions.push(option); - }); - - return createdOptions; - } - - setPlanSummaryAdjustmentMode(mode = "flat", options = {}) { - const normalized = mode === "percent" ? "percent" : "flat"; + if (disableAdjustments && this._planSummaryAdjustmentsHintEl) { + this._planSummaryAdjustmentsHintEl.textContent = + "Add at least one weighted exercise to adjust your next plan."; + } + } + + populatePlanSummaryOptions(selectElement, values, mode = "flat") { + if (!selectElement || !Array.isArray(values) || values.length === 0) { + return []; + } + + const normalizedMode = mode === "percent" ? "percent" : "flat"; + const dataAttribute = `data-plan-summary-${normalizedMode}`; + selectElement + .querySelectorAll(`option[${dataAttribute}]`) + .forEach((option) => option.remove()); + + const doc = selectElement.ownerDocument || document; + const createdOptions = []; + + values.forEach((value) => { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return; + } + + const textValue = numericValue.toString(); + const option = doc.createElement("option"); + option.value = textValue; + option.textContent = textValue; + option.setAttribute(dataAttribute, textValue); + selectElement.appendChild(option); + createdOptions.push(option); + }); + + return createdOptions; + } + + setPlanSummaryAdjustmentMode(mode = "flat", options = {}) { + const normalized = mode === "percent" ? "percent" : "flat"; this._planSummaryActiveAdjustmentMode = normalized; if (this._planSummaryAdjustmentsEl) { @@ -1693,87 +1693,87 @@ class VitruvianApp { } else if (normalized === "percent" && this._planSummaryPercentSelect) { this._planSummaryPercentSelect.focus(); } - } - } - - capturePlanSourceInfo() { - const items = Array.isArray(this.planItems) ? this.planItems : []; - const itemCount = items.reduce((total, item) => { - const setsValue = Number(item?.sets); - return total + (Number.isFinite(setsValue) && setsValue > 0 ? setsValue : 1); - }, 0); - return { - loadedName: this._loadedPlanName || null, - itemCount, - signature: this.computePlanItemsSignature(items), - }; - } - - computePlanItemsSignature(items) { - if (!Array.isArray(items)) { - return null; - } - if (items.length === 0) { - return "empty"; - } - try { - const canonical = this.canonicalizePlanValue(items); - const json = JSON.stringify(canonical); - let hash = 0; - for (let i = 0; i < json.length; i += 1) { - hash = (hash * 31 + json.charCodeAt(i)) >>> 0; - } - return hash.toString(16); - } catch (error) { - console.warn("Failed to compute plan signature", error); - return null; - } - } - - canonicalizePlanValue(value) { - if (value === null || value === undefined) { - return null; - } - if (Array.isArray(value)) { - return value.map((entry) => this.canonicalizePlanValue(entry)); - } - if (typeof value === "object") { - const result = {}; - Object.keys(value) - .sort() - .forEach((key) => { - const current = value[key]; - if (typeof current === "function") { - return; - } - result[key] = this.canonicalizePlanValue(current); - }); - return result; - } - if (typeof value === "number" && !Number.isFinite(value)) { - return null; - } - return value; - } - - isPlanSourceMatching(a, b) { - if (!a || !b) { - return false; - } - if (a.signature && b.signature) { - return a.signature === b.signature; - } - if (!a.signature && !b.signature) { - return a.itemCount === b.itemCount && a.loadedName === b.loadedName; - } - return false; - } - - applyPlanSummaryAdjustment(options = {}) { - const mode = options.mode === "percent" ? "percent" : "flat"; - - const summary = this._lastPlanSummary; - if (!summary || !Array.isArray(summary.sets) || summary.sets.length === 0) { + } + } + + capturePlanSourceInfo() { + const items = Array.isArray(this.planItems) ? this.planItems : []; + const itemCount = items.reduce((total, item) => { + const setsValue = Number(item?.sets); + return total + (Number.isFinite(setsValue) && setsValue > 0 ? setsValue : 1); + }, 0); + return { + loadedName: this._loadedPlanName || null, + itemCount, + signature: this.computePlanItemsSignature(items), + }; + } + + computePlanItemsSignature(items) { + if (!Array.isArray(items)) { + return null; + } + if (items.length === 0) { + return "empty"; + } + try { + const canonical = this.canonicalizePlanValue(items); + const json = JSON.stringify(canonical); + let hash = 0; + for (let i = 0; i < json.length; i += 1) { + hash = (hash * 31 + json.charCodeAt(i)) >>> 0; + } + return hash.toString(16); + } catch (error) { + console.warn("Failed to compute plan signature", error); + return null; + } + } + + canonicalizePlanValue(value) { + if (value === null || value === undefined) { + return null; + } + if (Array.isArray(value)) { + return value.map((entry) => this.canonicalizePlanValue(entry)); + } + if (typeof value === "object") { + const result = {}; + Object.keys(value) + .sort() + .forEach((key) => { + const current = value[key]; + if (typeof current === "function") { + return; + } + result[key] = this.canonicalizePlanValue(current); + }); + return result; + } + if (typeof value === "number" && !Number.isFinite(value)) { + return null; + } + return value; + } + + isPlanSourceMatching(a, b) { + if (!a || !b) { + return false; + } + if (a.signature && b.signature) { + return a.signature === b.signature; + } + if (!a.signature && !b.signature) { + return a.itemCount === b.itemCount && a.loadedName === b.loadedName; + } + return false; + } + + applyPlanSummaryAdjustment(options = {}) { + const mode = options.mode === "percent" ? "percent" : "flat"; + + const summary = this._lastPlanSummary; + if (!summary || !Array.isArray(summary.sets) || summary.sets.length === 0) { this.showPlanSummaryAdjustmentFeedback( "Run a workout plan to adjust the next session.", "error", @@ -1789,22 +1789,22 @@ class VitruvianApp { return; } - const sourceInfo = this._planSummarySource; - const currentSource = this.capturePlanSourceInfo(); - if (!this.isPlanSourceMatching(sourceInfo, currentSource)) { - const label = - summary.planName || - sourceInfo?.loadedName || - "this plan"; - this.showPlanSummaryAdjustmentFeedback( - `Load "${label}" so it matches the completed workout before applying adjustments.`, - "error", - ); - return; - } - - const unit = this._planSummaryDisplayUnit || summary.unit || this.weightUnit; - const normalizedUnit = unit === "lb" ? "lb" : "kg"; + const sourceInfo = this._planSummarySource; + const currentSource = this.capturePlanSourceInfo(); + if (!this.isPlanSourceMatching(sourceInfo, currentSource)) { + const label = + summary.planName || + sourceInfo?.loadedName || + "this plan"; + this.showPlanSummaryAdjustmentFeedback( + `Load "${label}" so it matches the completed workout before applying adjustments.`, + "error", + ); + return; + } + + const unit = this._planSummaryDisplayUnit || summary.unit || this.weightUnit; + const normalizedUnit = unit === "lb" ? "lb" : "kg"; if (mode === "flat") { const rawValue = Number.parseFloat(options.amount); @@ -1825,23 +1825,23 @@ class VitruvianApp { return; } - const adjustedCount = this.adjustPlanWeightsByDelta(deltaKg); - if (adjustedCount > 0) { - const decimals = this.getLoadDisplayDecimalsForUnit(normalizedUnit); - const amountText = parseFloat(rawValue.toFixed(decimals)).toString(); - const formattedDelta = `${amountText} ${this.getUnitLabel(normalizedUnit)}`; - this.showPlanSummaryAdjustmentFeedback( - `Added ${formattedDelta} per cable to ${adjustedCount} set${ - adjustedCount === 1 ? "" : "s" - }.`, - "success", - ); - this._planSummarySource = this.capturePlanSourceInfo(); - } else { - this.showPlanSummaryAdjustmentFeedback( - "No weighted sets were available to adjust.", - "error", - ); + const adjustedCount = this.adjustPlanWeightsByDelta(deltaKg); + if (adjustedCount > 0) { + const decimals = this.getLoadDisplayDecimalsForUnit(normalizedUnit); + const amountText = parseFloat(rawValue.toFixed(decimals)).toString(); + const formattedDelta = `${amountText} ${this.getUnitLabel(normalizedUnit)}`; + this.showPlanSummaryAdjustmentFeedback( + `Added ${formattedDelta} per cable to ${adjustedCount} set${ + adjustedCount === 1 ? "" : "s" + }.`, + "success", + ); + this._planSummarySource = this.capturePlanSourceInfo(); + } else { + this.showPlanSummaryAdjustmentFeedback( + "No weighted sets were available to adjust.", + "error", + ); } return; } @@ -1855,21 +1855,21 @@ class VitruvianApp { return; } - const adjustedCount = this.adjustPlanWeightsByPercent(rawPercent); - if (adjustedCount > 0) { - const percentText = parseFloat(rawPercent.toFixed(1)).toString(); - this.showPlanSummaryAdjustmentFeedback( - `Increased ${adjustedCount} set${ - adjustedCount === 1 ? "" : "s" - } by ${percentText} percent per cable.`, - "success", - ); - this._planSummarySource = this.capturePlanSourceInfo(); - } else { - this.showPlanSummaryAdjustmentFeedback( - "No weighted sets were available to adjust.", - "error", - ); + const adjustedCount = this.adjustPlanWeightsByPercent(rawPercent); + if (adjustedCount > 0) { + const percentText = parseFloat(rawPercent.toFixed(1)).toString(); + this.showPlanSummaryAdjustmentFeedback( + `Increased ${adjustedCount} set${ + adjustedCount === 1 ? "" : "s" + } by ${percentText} percent per cable.`, + "success", + ); + this._planSummarySource = this.capturePlanSourceInfo(); + } else { + this.showPlanSummaryAdjustmentFeedback( + "No weighted sets were available to adjust.", + "error", + ); } } @@ -2187,19 +2187,19 @@ class VitruvianApp { } initializePlanSummary() { - this._planSummaryData = { - startedAt: Date.now(), - planName: this.getActivePlanDisplayName(), - sets: [], - unit: this.weightUnit, - }; - this._lastPlanSummary = null; - this._planSummaryDisplayUnit = null; - this._planSummarySource = this.capturePlanSourceInfo(); - this.hidePlanSummary(); - this.resetPlanSummaryAdjustments(); - this.updatePlanSummaryReopenVisibility(); - } + this._planSummaryData = { + startedAt: Date.now(), + planName: this.getActivePlanDisplayName(), + sets: [], + unit: this.weightUnit, + }; + this._lastPlanSummary = null; + this._planSummaryDisplayUnit = null; + this._planSummarySource = this.capturePlanSourceInfo(); + this.hidePlanSummary(); + this.resetPlanSummaryAdjustments(); + this.updatePlanSummaryReopenVisibility(); + } recordPlanSetResult(workout, meta = {}) { if (!this._planSummaryData || !workout) { @@ -3149,6 +3149,121 @@ class VitruvianApp { } } + requestUpdateAveragesOldWorkouts() { + return this.updateAveragesOldWorkouts({ manual: true }); + } + + async updateAveragesOldWorkouts(options = {}) { + if (!this.dropboxManager.isConnected) { + alert("Please connect to Dropbox first"); + return; + } + + const manual = options?.manual === true; + if (!manual) { + this.addLogEntry( + "Blocked non-manual request to update averages", + "warning", + ); + return; + } + + const confirmed = window.confirm( + "This will scan all your workout files from Dropbox and calculate missing average load fields (averageLoad, averageLoadLeft, averageLoadRight). This may take a moment. Continue?", + ); + if (!confirmed) { + this.addLogEntry("Average update cancelled", "info"); + return; + } + + const context = "Update Averages"; + const updateStatus = (message, opts = {}) => { + this.logDropboxConsole(context, message, opts); + this.setDropboxStatus(`${context}: ${message}`, opts); + }; + + try { + updateStatus("Downloading workouts from Dropbox..."); + const cloudWorkouts = await this.dropboxManager.loadWorkouts({ + maxEntries: Infinity, + }); + + if (!Array.isArray(cloudWorkouts) || cloudWorkouts.length === 0) { + updateStatus("No workouts found", { color: "#ff922b" }); + this.addLogEntry("No workouts found in Dropbox", "info"); + return; + } + + let updatedCount = 0; + const updatedWorkouts = []; + + for (const workout of cloudWorkouts) { + if (!workout || typeof workout !== "object") { + continue; + } + + // Check if averages are missing + const hasAverageLoad = workout.hasOwnProperty("averageLoad"); + const hasAverageLoadLeft = workout.hasOwnProperty("averageLoadLeft"); + const hasAverageLoadRight = workout.hasOwnProperty("averageLoadRight"); + + if (hasAverageLoad && hasAverageLoadLeft && hasAverageLoadRight) { + // All fields exist, skip + continue; + } + + // Calculate averages from movement data + const averageLoads = this.calculateAverageLoadForWorkout( + Array.isArray(workout.movementData) ? workout.movementData : [], + workout.warmupEndTime, + workout.endTime, + ); + + // Only update if missing at least one field + if (!hasAverageLoad || !hasAverageLoadLeft || !hasAverageLoadRight) { + workout.averageLoad = averageLoads ? averageLoads.averageTotal : null; + workout.averageLoadLeft = averageLoads ? averageLoads.averageLeft : null; + workout.averageLoadRight = averageLoads ? averageLoads.averageRight : null; + updatedWorkouts.push(workout); + updatedCount++; + } + } + + if (updatedCount === 0) { + updateStatus("All workouts already have average fields", { + color: "#2f9e44", + preserveColor: true, + }); + this.addLogEntry("No updates needed", "info"); + return; + } + + updateStatus(`Uploading ${updatedCount} updated workouts...`); + + // Upload all updated workouts to Dropbox, overwriting the original files + for (const workout of updatedWorkouts) { + try { + await this.dropboxManager.overwriteWorkout(workout); + } catch (error) { + this.addLogEntry( + `Failed to save workout: ${error.message}`, + "error", + ); + } + } + + updateStatus(`Completed! Updated ${updatedCount} workouts.`, { + color: "#2f9e44", + preserveColor: true, + }); + this.addLogEntry(`Updated averages for ${updatedCount} workouts`, "success"); + } catch (error) { + const message = `Failed to update averages: ${error.message}`; + this.addLogEntry(message, "error"); + updateStatus(`Error: ${error.message}`, { color: "#c92a2a" }); + } + } + setWeightUnit(unit, options = {}) { if (unit !== "kg" && unit !== "lb") { return; @@ -3326,49 +3441,49 @@ class VitruvianApp { return `${min.toFixed(this.getWeightInputDecimals())}-${max.toFixed(this.getWeightInputDecimals())} ${this.getUnitLabel()}`; } - getProgressionRangeText() { - const maxDisplay = this.convertKgToDisplay(3); - const decimals = this.getProgressionInputDecimals(); - const formatted = maxDisplay.toFixed(decimals); - return `+${formatted} to -${formatted} ${this.getUnitLabel()}`; - } - - loadStoredWeightUnit() { - const sharedUnit = - typeof sharedGetUnitPreference === "function" - ? sharedGetUnitPreference() - : null; - if (sharedUnit === "lb" || sharedUnit === "kg") { - return sharedUnit; - } - if (typeof window === "undefined" || !window.localStorage) { - return "kg"; - } - try { - const stored = localStorage.getItem("vitruvian.weightUnit"); - if (stored === "lb" || stored === "kg") { - return stored; - } - } catch (error) { - // Ignore storage errors and fall back to default. - } - return "kg"; - } - - saveWeightUnitPreference() { - const normalized = this.weightUnit === "lb" ? "lb" : "kg"; - if (typeof sharedSetUnitPreference === "function") { - sharedSetUnitPreference(normalized); - } - if (typeof window === "undefined" || !window.localStorage) { - return; - } - try { - localStorage.setItem("vitruvian.weightUnit", normalized); - } catch (error) { - // Ignore storage errors (e.g., private browsing). - } - } + getProgressionRangeText() { + const maxDisplay = this.convertKgToDisplay(3); + const decimals = this.getProgressionInputDecimals(); + const formatted = maxDisplay.toFixed(decimals); + return `+${formatted} to -${formatted} ${this.getUnitLabel()}`; + } + + loadStoredWeightUnit() { + const sharedUnit = + typeof sharedGetUnitPreference === "function" + ? sharedGetUnitPreference() + : null; + if (sharedUnit === "lb" || sharedUnit === "kg") { + return sharedUnit; + } + if (typeof window === "undefined" || !window.localStorage) { + return "kg"; + } + try { + const stored = localStorage.getItem("vitruvian.weightUnit"); + if (stored === "lb" || stored === "kg") { + return stored; + } + } catch (error) { + // Ignore storage errors and fall back to default. + } + return "kg"; + } + + saveWeightUnitPreference() { + const normalized = this.weightUnit === "lb" ? "lb" : "kg"; + if (typeof sharedSetUnitPreference === "function") { + sharedSetUnitPreference(normalized); + } + if (typeof window === "undefined" || !window.localStorage) { + return; + } + try { + localStorage.setItem("vitruvian.weightUnit", normalized); + } catch (error) { + // Ignore storage errors (e.g., private browsing). + } + } renderLoadDisplays(sample) { const decimals = this.getLoadDisplayDecimals(); @@ -4195,11 +4310,11 @@ class VitruvianApp { this.updatePositionBarColors(null); } - normalizeWorkout(workout) { - if (!workout || typeof workout !== "object") { - return null; - } - + normalizeWorkout(workout) { + if (!workout || typeof workout !== "object") { + return null; + } + const toDate = (value) => { if (!value) return null; if (value instanceof Date) return value; @@ -4212,21 +4327,21 @@ class VitruvianApp { return Number.isFinite(num) ? num : 0; }; - if (typeof workout.setName === "string") { - workout.setName = workout.setName.trim(); - if (workout.setName.length === 0) { - workout.setName = null; - } - } - - if (Object.prototype.hasOwnProperty.call(workout, "exerciseIdNew")) { - const numeric = this.toNumericExerciseId(workout.exerciseIdNew); - workout.exerciseIdNew = numeric; - } - - if (typeof workout.mode === "string") { - workout.mode = workout.mode.trim(); - } + if (typeof workout.setName === "string") { + workout.setName = workout.setName.trim(); + if (workout.setName.length === 0) { + workout.setName = null; + } + } + + if (Object.prototype.hasOwnProperty.call(workout, "exerciseIdNew")) { + const numeric = this.toNumericExerciseId(workout.exerciseIdNew); + workout.exerciseIdNew = numeric; + } + + if (typeof workout.mode === "string") { + workout.mode = workout.mode.trim(); + } workout.timestamp = toDate(workout.timestamp); workout.startTime = toDate(workout.startTime); @@ -4260,12 +4375,12 @@ class VitruvianApp { }) .filter(Boolean); - this.applyCableActivationToMovementData(workout.movementData); - - this.calculateTotalLoadPeakKg(workout); - this.ensurePhaseAnalysis(workout); - return workout; - } + this.applyCableActivationToMovementData(workout.movementData); + + this.calculateTotalLoadPeakKg(workout); + this.ensurePhaseAnalysis(workout); + return workout; + } applyCableActivationToMovementData(points) { if (!Array.isArray(points) || points.length === 0) { @@ -4320,23 +4435,23 @@ class VitruvianApp { return points; } - calculateTotalLoadPeakKg(workout) { - if (!workout || typeof workout !== "object") { - return 0; - } - - // Personal records track the heaviest load on any single cable during a set. - let peak = Number(workout.cablePeakKg); - const analysis = this.ensurePhaseAnalysis(workout); - if (analysis?.hasReps && Number.isFinite(analysis.maxConcentricKg) && analysis.maxConcentricKg > 0) { - peak = analysis.maxConcentricKg; - } - if (!Number.isFinite(peak) || peak <= 0) { - peak = 0; - - if (Array.isArray(workout.movementData) && workout.movementData.length > 0) { - for (const point of workout.movementData) { - const cablePeak = Math.max( + calculateTotalLoadPeakKg(workout) { + if (!workout || typeof workout !== "object") { + return 0; + } + + // Personal records track the heaviest load on any single cable during a set. + let peak = Number(workout.cablePeakKg); + const analysis = this.ensurePhaseAnalysis(workout); + if (analysis?.hasReps && Number.isFinite(analysis.maxConcentricKg) && analysis.maxConcentricKg > 0) { + peak = analysis.maxConcentricKg; + } + if (!Number.isFinite(peak) || peak <= 0) { + peak = 0; + + if (Array.isArray(workout.movementData) && workout.movementData.length > 0) { + for (const point of workout.movementData) { + const cablePeak = Math.max( Number(point.loadA) || 0, Number(point.loadB) || 0, ); @@ -4359,12 +4474,12 @@ class VitruvianApp { } } } - } - - workout.cablePeakKg = peak; - workout.totalLoadPeakKg = peak; - return peak; - } + } + + workout.cablePeakKg = peak; + workout.totalLoadPeakKg = peak; + return peak; + } getPriorBestTotalLoadKg(identity, options = {}) { if (!identity || typeof identity.key !== "string") { @@ -4432,36 +4547,36 @@ class VitruvianApp { return best; } - initializeCurrentWorkoutPersonalBest() { - if (!this.currentWorkout) { - this.updatePersonalBestDisplay(); - return; - } - - const baseIdentity = this.getWorkoutIdentityInfo(this.currentWorkout); - const identity = this.isEchoWorkout(this.currentWorkout) - ? this.getEchoPhaseIdentity(baseIdentity, "concentric", this.currentWorkout) || baseIdentity - : baseIdentity; - if (identity) { - this.currentWorkout.identityKey = identity.key; - this.currentWorkout.identityLabel = identity.label; - this.currentWorkout.priorBestTotalLoadKg = - this.getPriorBestTotalLoadKg(identity); + initializeCurrentWorkoutPersonalBest() { + if (!this.currentWorkout) { + this.updatePersonalBestDisplay(); + return; + } + + const baseIdentity = this.getWorkoutIdentityInfo(this.currentWorkout); + const identity = this.isEchoWorkout(this.currentWorkout) + ? this.getEchoPhaseIdentity(baseIdentity, "concentric", this.currentWorkout) || baseIdentity + : baseIdentity; + if (identity) { + this.currentWorkout.identityKey = identity.key; + this.currentWorkout.identityLabel = identity.label; + this.currentWorkout.priorBestTotalLoadKg = + this.getPriorBestTotalLoadKg(identity); + } else { + this.currentWorkout.identityKey = null; + this.currentWorkout.identityLabel = null; + this.currentWorkout.priorBestTotalLoadKg = 0; + } + if (this.isEchoWorkout(this.currentWorkout)) { + this.currentWorkout.echoEccentricIdentity = + this.getEchoPhaseIdentity(baseIdentity, "eccentric", this.currentWorkout) || null; } else { - this.currentWorkout.identityKey = null; - this.currentWorkout.identityLabel = null; - this.currentWorkout.priorBestTotalLoadKg = 0; - } - if (this.isEchoWorkout(this.currentWorkout)) { - this.currentWorkout.echoEccentricIdentity = - this.getEchoPhaseIdentity(baseIdentity, "eccentric", this.currentWorkout) || null; - } else { - this.currentWorkout.echoEccentricIdentity = null; - } - - this.currentWorkout.livePeakTotalLoadKg = 0; - this.currentWorkout.currentPersonalBestKg = - this.currentWorkout.priorBestTotalLoadKg || 0; + this.currentWorkout.echoEccentricIdentity = null; + } + + this.currentWorkout.livePeakTotalLoadKg = 0; + this.currentWorkout.currentPersonalBestKg = + this.currentWorkout.priorBestTotalLoadKg || 0; this.currentWorkout.hasNewPersonalBest = false; this.currentWorkout.celebratedPersonalBestKg = this.currentWorkout.currentPersonalBestKg || 0; @@ -4471,120 +4586,120 @@ class VitruvianApp { this.updatePersonalBestDisplay(); } - getWorkoutIdentityInfo(workout) { - if (!workout) return null; - - const numericId = this.toNumericExerciseId( - workout?.exerciseIdNew ?? - workout?.planExerciseIdNew ?? - workout?.builderMeta?.exerciseIdNew ?? - workout?.builderMeta?.exerciseNumericId, - ); - const setName = - typeof workout.setName === "string" && workout.setName.trim().length > 0 - ? workout.setName.trim() - : null; - const addEchoSuffix = (label) => { - if (!this.isEchoWorkout(workout)) { - return label; - } - const workoutId = this.getWorkoutDisplayId(workout); - return `${label} (Echo Mode · ${workoutId})`; - }; - - if (numericId !== null) { - return { - key: `exercise:${numericId}`, - label: addEchoSuffix(setName || `Exercise ${numericId}`), - }; - } - if (setName) { - return { key: `set:${setName.toLowerCase()}`, label: addEchoSuffix(setName) }; - } - - const mode = - typeof workout.mode === "string" && workout.mode.trim().length > 0 - ? workout.mode.trim() - : null; - if (mode) { - return { key: `mode:${mode.toLowerCase()}`, label: addEchoSuffix(mode) }; - } - - return null; - } - - getWorkoutDisplayId(workout) { - if (!workout || typeof workout !== "object") { - return "unknown"; - } - const candidates = [ - workout.workoutId, - workout.id, - workout.builderMeta?.workoutId, - workout.builderMeta?.workout_id, - workout.dropboxId, - ]; - for (const value of candidates) { - if (typeof value === "string" && value.trim().length > 0) { - return value.trim(); - } - } - const timestamp = this.getWorkoutTimestamp(workout); - return timestamp ? timestamp.toISOString() : "unknown"; - } - - isEchoWorkout(workout) { - if (!workout) return false; - if (typeof sharedIsEchoWorkout !== "function") { - refreshSharedEchoTelemetryHelpers(); - } - if (typeof sharedIsEchoWorkout === "function") { - return sharedIsEchoWorkout(workout); - } - const type = String(workout.itemType || "").toLowerCase(); - if (type.includes("echo")) return true; - const mode = String(workout.mode || "").toLowerCase(); - return mode.includes("echo"); - } - - ensurePhaseAnalysis(workout) { - if (!workout) { - return null; - } - if (typeof sharedAnalyzePhases !== "function") { - refreshSharedEchoTelemetryHelpers(); - } - if (typeof sharedAnalyzePhases !== "function") { - return null; - } - if (workout.phaseAnalysis && Array.isArray(workout.phaseAnalysis.reps)) { - return workout.phaseAnalysis; - } - const analysis = sharedAnalyzePhases(workout) || null; - if (analysis) { - workout.phaseAnalysis = analysis; - if (analysis.range) { - workout.phaseRange = analysis.range; - } - if (analysis.isEcho) { - workout.echoAnalysis = analysis; - if (analysis.range) { - workout.echoRange = analysis.range; - } - } - } - return workout.phaseAnalysis || workout.echoAnalysis || null; - } - - getEchoPhaseIdentity(identity, phase, workout) { - if (!identity) return null; - const suffix = phase === "eccentric" ? "Eccentric" : "Concentric"; - return { - key: `${identity.key}|echo-${phase}`, - label: `${identity.label} · ${suffix}`, - workoutId: this.getWorkoutDisplayId(workout), - }; - } + getWorkoutIdentityInfo(workout) { + if (!workout) return null; + + const numericId = this.toNumericExerciseId( + workout?.exerciseIdNew ?? + workout?.planExerciseIdNew ?? + workout?.builderMeta?.exerciseIdNew ?? + workout?.builderMeta?.exerciseNumericId, + ); + const setName = + typeof workout.setName === "string" && workout.setName.trim().length > 0 + ? workout.setName.trim() + : null; + const addEchoSuffix = (label) => { + if (!this.isEchoWorkout(workout)) { + return label; + } + const workoutId = this.getWorkoutDisplayId(workout); + return `${label} (Echo Mode · ${workoutId})`; + }; + + if (numericId !== null) { + return { + key: `exercise:${numericId}`, + label: addEchoSuffix(setName || `Exercise ${numericId}`), + }; + } + if (setName) { + return { key: `set:${setName.toLowerCase()}`, label: addEchoSuffix(setName) }; + } + + const mode = + typeof workout.mode === "string" && workout.mode.trim().length > 0 + ? workout.mode.trim() + : null; + if (mode) { + return { key: `mode:${mode.toLowerCase()}`, label: addEchoSuffix(mode) }; + } + + return null; + } + + getWorkoutDisplayId(workout) { + if (!workout || typeof workout !== "object") { + return "unknown"; + } + const candidates = [ + workout.workoutId, + workout.id, + workout.builderMeta?.workoutId, + workout.builderMeta?.workout_id, + workout.dropboxId, + ]; + for (const value of candidates) { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + const timestamp = this.getWorkoutTimestamp(workout); + return timestamp ? timestamp.toISOString() : "unknown"; + } + + isEchoWorkout(workout) { + if (!workout) return false; + if (typeof sharedIsEchoWorkout !== "function") { + refreshSharedEchoTelemetryHelpers(); + } + if (typeof sharedIsEchoWorkout === "function") { + return sharedIsEchoWorkout(workout); + } + const type = String(workout.itemType || "").toLowerCase(); + if (type.includes("echo")) return true; + const mode = String(workout.mode || "").toLowerCase(); + return mode.includes("echo"); + } + + ensurePhaseAnalysis(workout) { + if (!workout) { + return null; + } + if (typeof sharedAnalyzePhases !== "function") { + refreshSharedEchoTelemetryHelpers(); + } + if (typeof sharedAnalyzePhases !== "function") { + return null; + } + if (workout.phaseAnalysis && Array.isArray(workout.phaseAnalysis.reps)) { + return workout.phaseAnalysis; + } + const analysis = sharedAnalyzePhases(workout) || null; + if (analysis) { + workout.phaseAnalysis = analysis; + if (analysis.range) { + workout.phaseRange = analysis.range; + } + if (analysis.isEcho) { + workout.echoAnalysis = analysis; + if (analysis.range) { + workout.echoRange = analysis.range; + } + } + } + return workout.phaseAnalysis || workout.echoAnalysis || null; + } + + getEchoPhaseIdentity(identity, phase, workout) { + if (!identity) return null; + const suffix = phase === "eccentric" ? "Eccentric" : "Concentric"; + return { + key: `${identity.key}|echo-${phase}`, + label: `${identity.label} · ${suffix}`, + workoutId: this.getWorkoutDisplayId(workout), + }; + } getWorkoutHistoryKey(workout) { if (!workout || typeof workout !== "object") { @@ -4811,6 +4926,9 @@ class VitruvianApp { `Peak Load (${unitLabel})`, "Duration (seconds)", "Movement Data Points", + `Average Load (${unitLabel})`, + `Average Load Left (${unitLabel})`, + `Average Load Right (${unitLabel})`, "Is PR", ]; @@ -4856,6 +4974,21 @@ class VitruvianApp { ? this.formatWeightValue(totalLoadKg) : ""; + // Get average loads from workout object (converted to display unit if needed) + const averageLoadKg = workout.averageLoad; + const averageLoadLeftKg = workout.averageLoadLeft; + const averageLoadRightKg = workout.averageLoadRight; + + const averageLoadDisplay = Number.isFinite(averageLoadKg) + ? this.formatWeightValue(averageLoadKg) + : ""; + const averageLoadLeftDisplay = Number.isFinite(averageLoadLeftKg) + ? this.formatWeightValue(averageLoadLeftKg) + : ""; + const averageLoadRightDisplay = Number.isFinite(averageLoadRightKg) + ? this.formatWeightValue(averageLoadRightKg) + : ""; + rows.push([ timestamp instanceof Date ? new Date(timestamp) : "", planName, @@ -4876,6 +5009,9 @@ class VitruvianApp { peakDisplay, durationSeconds, String(movementPoints), + averageLoadDisplay, + averageLoadLeftDisplay, + averageLoadRightDisplay, workout._exportIdentityLabel ? isPR : "", ]); } @@ -5204,21 +5340,21 @@ class VitruvianApp { }; } - finalizePersonalRecordForWorkout(workout, options = {}) { - if (!workout || options.skipped) { - this.clearPersonalRecordCandidate(); - return false; - } - - const baseIdentity = this.getWorkoutIdentityInfo(workout); - const identity = this.isEchoWorkout(workout) - ? this.getEchoPhaseIdentity(baseIdentity, "concentric", workout) || baseIdentity - : baseIdentity; - if (!identity) { - this.clearPersonalRecordCandidate(); - return false; - } - + finalizePersonalRecordForWorkout(workout, options = {}) { + if (!workout || options.skipped) { + this.clearPersonalRecordCandidate(); + return false; + } + + const baseIdentity = this.getWorkoutIdentityInfo(workout); + const identity = this.isEchoWorkout(workout) + ? this.getEchoPhaseIdentity(baseIdentity, "concentric", workout) || baseIdentity + : baseIdentity; + if (!identity) { + this.clearPersonalRecordCandidate(); + return false; + } + const candidate = this._pendingPersonalRecordCandidate; const matchesCandidate = candidate && candidate.identityKey === identity.key; @@ -5231,34 +5367,34 @@ class VitruvianApp { this.getWorkoutTimestamp(workout) || new Date(); - const updated = this.applyPersonalRecordCandidate( - identity, - targetWeightKg, - timestamp, - { reason: options.reason || "workout-complete" }, - ); - - if (this.isEchoWorkout(workout)) { - const analysis = this.ensurePhaseAnalysis(workout); - const eccIdentity = this.getEchoPhaseIdentity(baseIdentity, "eccentric", workout); - if ( - analysis && - eccIdentity && - Number.isFinite(analysis.maxEccentricKg) && - analysis.maxEccentricKg > 0 - ) { - this.applyPersonalRecordCandidate( - eccIdentity, - analysis.maxEccentricKg, - timestamp, - { reason: "echo-workout-complete" }, - ); - } - } - - this.clearPersonalRecordCandidate(); - return updated; - } + const updated = this.applyPersonalRecordCandidate( + identity, + targetWeightKg, + timestamp, + { reason: options.reason || "workout-complete" }, + ); + + if (this.isEchoWorkout(workout)) { + const analysis = this.ensurePhaseAnalysis(workout); + const eccIdentity = this.getEchoPhaseIdentity(baseIdentity, "eccentric", workout); + if ( + analysis && + eccIdentity && + Number.isFinite(analysis.maxEccentricKg) && + analysis.maxEccentricKg > 0 + ) { + this.applyPersonalRecordCandidate( + eccIdentity, + analysis.maxEccentricKg, + timestamp, + { reason: "echo-workout-complete" }, + ); + } + } + + this.clearPersonalRecordCandidate(); + return updated; + } applyPersonalRecordCandidate(identity, weightKg, timestamp, options = {}) { if ( @@ -5338,67 +5474,67 @@ class VitruvianApp { return true; } - ensurePersonalRecordsFromHistory() { - const workouts = Array.isArray(this.workoutHistory) - ? this.workoutHistory - : []; - - const bestByIdentity = new Map(); - const epsilon = 0.0001; - - const registerBest = (identity, weightKg, timestamp) => { - if (!identity || !Number.isFinite(weightKg) || weightKg <= 0) { - return; - } - const existing = bestByIdentity.get(identity.key); - if (!existing) { - bestByIdentity.set(identity.key, { identity, weightKg, timestamp }); - return; - } - const delta = weightKg - existing.weightKg; - const tsMs = timestamp instanceof Date ? timestamp.getTime() : 0; - const existingMs = - existing.timestamp instanceof Date ? existing.timestamp.getTime() : 0; - - if (delta > epsilon || (Math.abs(delta) <= epsilon && tsMs > existingMs)) { - bestByIdentity.set(identity.key, { identity, weightKg, timestamp }); - } - }; - - for (const workout of workouts) { - const identity = this.getWorkoutIdentityInfo(workout); - if (!identity) { - continue; - } - - const timestamp = this.getWorkoutTimestamp(workout); - if (this.isEchoWorkout(workout)) { - const analysis = this.ensurePhaseAnalysis(workout); - if (analysis) { - const concIdentity = - this.getEchoPhaseIdentity(identity, "concentric", workout) || identity; - if (Number.isFinite(analysis.maxConcentricKg) && analysis.maxConcentricKg > 0) { - registerBest(concIdentity, analysis.maxConcentricKg, timestamp); - } - const eccIdentity = this.getEchoPhaseIdentity(identity, "eccentric", workout); - if (eccIdentity && Number.isFinite(analysis.maxEccentricKg) && analysis.maxEccentricKg > 0) { - registerBest(eccIdentity, analysis.maxEccentricKg, timestamp); - } - } - continue; - } - - const peakKg = this.calculateTotalLoadPeakKg(workout); - if (!Number.isFinite(peakKg) || peakKg <= 0) { - continue; - } - - registerBest(identity, peakKg, timestamp); - } - - let updated = false; - for (const entry of bestByIdentity.values()) { - const applied = this.applyPersonalRecordCandidate( + ensurePersonalRecordsFromHistory() { + const workouts = Array.isArray(this.workoutHistory) + ? this.workoutHistory + : []; + + const bestByIdentity = new Map(); + const epsilon = 0.0001; + + const registerBest = (identity, weightKg, timestamp) => { + if (!identity || !Number.isFinite(weightKg) || weightKg <= 0) { + return; + } + const existing = bestByIdentity.get(identity.key); + if (!existing) { + bestByIdentity.set(identity.key, { identity, weightKg, timestamp }); + return; + } + const delta = weightKg - existing.weightKg; + const tsMs = timestamp instanceof Date ? timestamp.getTime() : 0; + const existingMs = + existing.timestamp instanceof Date ? existing.timestamp.getTime() : 0; + + if (delta > epsilon || (Math.abs(delta) <= epsilon && tsMs > existingMs)) { + bestByIdentity.set(identity.key, { identity, weightKg, timestamp }); + } + }; + + for (const workout of workouts) { + const identity = this.getWorkoutIdentityInfo(workout); + if (!identity) { + continue; + } + + const timestamp = this.getWorkoutTimestamp(workout); + if (this.isEchoWorkout(workout)) { + const analysis = this.ensurePhaseAnalysis(workout); + if (analysis) { + const concIdentity = + this.getEchoPhaseIdentity(identity, "concentric", workout) || identity; + if (Number.isFinite(analysis.maxConcentricKg) && analysis.maxConcentricKg > 0) { + registerBest(concIdentity, analysis.maxConcentricKg, timestamp); + } + const eccIdentity = this.getEchoPhaseIdentity(identity, "eccentric", workout); + if (eccIdentity && Number.isFinite(analysis.maxEccentricKg) && analysis.maxEccentricKg > 0) { + registerBest(eccIdentity, analysis.maxEccentricKg, timestamp); + } + } + continue; + } + + const peakKg = this.calculateTotalLoadPeakKg(workout); + if (!Number.isFinite(peakKg) || peakKg <= 0) { + continue; + } + + registerBest(identity, peakKg, timestamp); + } + + let updated = false; + for (const entry of bestByIdentity.values()) { + const applied = this.applyPersonalRecordCandidate( entry.identity, entry.weightKg, entry.timestamp, @@ -5439,86 +5575,86 @@ class VitruvianApp { return updated; } - buildPersonalRecordsFromWorkouts(workouts = []) { - if (!Array.isArray(workouts) || workouts.length === 0) { - return false; - } - - const epsilon = 0.0001; - const nextRecords = {}; - const updateRecord = (identity, weightKg, isoTimestamp) => { - if ( - !identity || - typeof identity.key !== "string" || - !Number.isFinite(weightKg) || - weightKg <= 0 - ) { - return; - } - - const key = identity.key; - const existing = nextRecords[key]; - if (!existing) { - nextRecords[key] = { - key, - label: identity.label, - weightKg, - timestamp: isoTimestamp, - }; - return; - } - - const delta = weightKg - existing.weightKg; - const timestampMs = new Date(isoTimestamp).getTime(); - const existingMs = existing.timestamp - ? new Date(existing.timestamp).getTime() - : 0; - - if ( - delta > epsilon || - (Math.abs(delta) <= epsilon && timestampMs > existingMs) - ) { - nextRecords[key] = { - key, - label: identity.label, - weightKg, - timestamp: isoTimestamp, - }; - } - }; - - for (const workout of workouts) { - const baseIdentity = this.getWorkoutIdentityInfo(workout); - if (!baseIdentity) { - continue; - } - - const timestamp = this.getWorkoutTimestamp(workout); - const isoTimestamp = timestamp instanceof Date - ? timestamp.toISOString() - : new Date(timestamp || Date.now()).toISOString(); - - const isEcho = this.isEchoWorkout(workout); - const concentricIdentity = isEcho - ? this.getEchoPhaseIdentity(baseIdentity, "concentric", workout) || baseIdentity - : baseIdentity; - - const peakKg = this.calculateTotalLoadPeakKg(workout); - updateRecord(concentricIdentity, peakKg, isoTimestamp); - - if (isEcho) { - const analysis = this.ensurePhaseAnalysis(workout); - const eccIdentity = this.getEchoPhaseIdentity(baseIdentity, "eccentric", workout); - const eccPeakKg = analysis ? Number(analysis.maxEccentricKg) : NaN; - updateRecord(eccIdentity, eccPeakKg, isoTimestamp); - } - } - - this.personalRecords = nextRecords; - this.setPersonalRecordsDirty(true); - this.savePersonalRecordsCache(); - return true; - } + buildPersonalRecordsFromWorkouts(workouts = []) { + if (!Array.isArray(workouts) || workouts.length === 0) { + return false; + } + + const epsilon = 0.0001; + const nextRecords = {}; + const updateRecord = (identity, weightKg, isoTimestamp) => { + if ( + !identity || + typeof identity.key !== "string" || + !Number.isFinite(weightKg) || + weightKg <= 0 + ) { + return; + } + + const key = identity.key; + const existing = nextRecords[key]; + if (!existing) { + nextRecords[key] = { + key, + label: identity.label, + weightKg, + timestamp: isoTimestamp, + }; + return; + } + + const delta = weightKg - existing.weightKg; + const timestampMs = new Date(isoTimestamp).getTime(); + const existingMs = existing.timestamp + ? new Date(existing.timestamp).getTime() + : 0; + + if ( + delta > epsilon || + (Math.abs(delta) <= epsilon && timestampMs > existingMs) + ) { + nextRecords[key] = { + key, + label: identity.label, + weightKg, + timestamp: isoTimestamp, + }; + } + }; + + for (const workout of workouts) { + const baseIdentity = this.getWorkoutIdentityInfo(workout); + if (!baseIdentity) { + continue; + } + + const timestamp = this.getWorkoutTimestamp(workout); + const isoTimestamp = timestamp instanceof Date + ? timestamp.toISOString() + : new Date(timestamp || Date.now()).toISOString(); + + const isEcho = this.isEchoWorkout(workout); + const concentricIdentity = isEcho + ? this.getEchoPhaseIdentity(baseIdentity, "concentric", workout) || baseIdentity + : baseIdentity; + + const peakKg = this.calculateTotalLoadPeakKg(workout); + updateRecord(concentricIdentity, peakKg, isoTimestamp); + + if (isEcho) { + const analysis = this.ensurePhaseAnalysis(workout); + const eccIdentity = this.getEchoPhaseIdentity(baseIdentity, "eccentric", workout); + const eccPeakKg = analysis ? Number(analysis.maxEccentricKg) : NaN; + updateRecord(eccIdentity, eccPeakKg, isoTimestamp); + } + } + + this.personalRecords = nextRecords; + this.setPersonalRecordsDirty(true); + this.savePersonalRecordsCache(); + return true; + } async syncPersonalRecordsFromDropbox(options = {}) { if (!this.dropboxManager?.isConnected) { @@ -5845,17 +5981,17 @@ class VitruvianApp { return; } - const workout = this.workoutHistory[index]; - const previousKey = this.selectedHistoryKey; - const newKey = this.getWorkoutHistoryKey(workout); - - const pageSize = this.getHistoryPageSize(); - const filteredEntries = this.getFilteredHistoryEntries(); - const filteredIndex = filteredEntries.findIndex((entry) => entry.index === index); - const targetPage = filteredIndex >= 0 - ? Math.floor(filteredIndex / pageSize) + 1 - : Math.floor(index / pageSize) + 1; - this.setHistoryPage(targetPage); + const workout = this.workoutHistory[index]; + const previousKey = this.selectedHistoryKey; + const newKey = this.getWorkoutHistoryKey(workout); + + const pageSize = this.getHistoryPageSize(); + const filteredEntries = this.getFilteredHistoryEntries(); + const filteredIndex = filteredEntries.findIndex((entry) => entry.index === index); + const targetPage = filteredIndex >= 0 + ? Math.floor(filteredIndex / pageSize) + 1 + : Math.floor(index / pageSize) + 1; + this.setHistoryPage(targetPage); this.selectedHistoryKey = newKey; this.selectedHistoryIndex = index; @@ -6042,182 +6178,182 @@ class VitruvianApp { "warning", ); } - } - - return true; - } - - isHistoryFilterActive() { - return this.getActiveHistoryFilterKey() !== "all"; - } - - getHistoryPageSize() { - const baseSize = Number(this.historyPageSize) > 0 ? this.historyPageSize : 5; - return this.isHistoryFilterActive() ? FILTERED_HISTORY_PAGE_SIZE : baseSize; - } - - getHistoryFilterOptions(history = this.workoutHistory) { - const list = Array.isArray(history) ? history : []; - const options = new Map(); - - for (const workout of list) { - const identity = this.getWorkoutIdentityInfo(workout); - if (!identity || !identity.key) { - continue; - } - const existing = options.get(identity.key); - if (existing) { - existing.count += 1; - } else { - options.set(identity.key, { - key: identity.key, - label: identity.label || "Unnamed Exercise", - count: 1, - }); - } - } - - return Array.from(options.values()).sort((a, b) => - a.label.localeCompare(b.label), - ); - } - - getActiveHistoryFilterKey(history = this.workoutHistory) { - const normalized = - typeof this.historyFilterKey === "string" && this.historyFilterKey.trim().length > 0 - ? this.historyFilterKey.trim() - : "all"; - - if (normalized === "all") { - return "all"; - } - - const options = this.getHistoryFilterOptions(history); - const hasOption = options.some((option) => option.key === normalized); - if (!hasOption) { - this.historyFilterKey = "all"; - return "all"; - } - - return normalized; - } - - getFilteredHistoryEntries(history = this.workoutHistory) { - const list = Array.isArray(history) ? history : []; - const activeKey = this.getActiveHistoryFilterKey(list); - - if (activeKey === "all") { - return list.map((workout, index) => ({ workout, index })); - } - - return list - .map((workout, index) => ({ workout, index })) - .filter(({ workout }) => { - const identity = this.getWorkoutIdentityInfo(workout); - return identity && identity.key === activeKey; - }); - } - - setHistoryFilter(key) { - const normalized = - typeof key === "string" && key.trim().length > 0 ? key.trim() : "all"; - const options = this.getHistoryFilterOptions(); - const isValid = options.some((option) => option.key === normalized); - const targetKey = normalized === "all" || !isValid ? "all" : normalized; - - if (targetKey === this.historyFilterKey) { - return; - } - - this.historyFilterKey = targetKey; - this.historyPage = 1; - this.selectedHistoryKey = null; - this.selectedHistoryIndex = null; - this.updateHistoryDisplay(); - } - - updateHistoryDisplay() { - const historyList = document.getElementById("historyList"); - if (!historyList) return; - - const history = Array.isArray(this.workoutHistory) ? this.workoutHistory : []; - const filterOptions = this.getHistoryFilterOptions(history); - const filteredEntries = this.getFilteredHistoryEntries(history); - const pageSize = this.getHistoryPageSize(); - const totalItems = filteredEntries.length; - const totalPages = totalItems > 0 ? Math.ceil(totalItems / pageSize) : 0; - - if (totalItems === 0) { - this.historyPage = 1; - historyList.innerHTML = ` -