Spaces:
Sleeping
Sleeping
| import { Chart, ChartConfiguration, registerables } from 'chart.js'; | |
| import type { MetricACWRData, Activity } from '@/types'; | |
| // Register all Chart.js components | |
| Chart.register(...registerables); | |
| let distanceChart: Chart | null = null; | |
| let durationChart: Chart | null = null; | |
| let tssChart: Chart | null = null; | |
| let caloriesChart: Chart | null = null; | |
| // ACWR color zones | |
| function getACWRColor(value: number | null): string { | |
| if (value === null || value === undefined) return 'rgba(148, 163, 184, 0.3)'; | |
| if (value < 0.8) return 'rgba(59, 130, 246, 0.9)'; // Blue - Detraining risk | |
| if (value <= 1.3) return 'rgba(16, 185, 129, 0.9)'; // Green - Optimal | |
| if (value <= 1.5) return 'rgba(249, 115, 22, 0.9)'; // Orange - Warning | |
| return 'rgba(239, 68, 68, 0.9)'; // Red - Injury risk | |
| } | |
| // Plugin to draw gradient-colored ACWR line | |
| const acwrGradientPlugin = { | |
| id: 'acwrGradient', | |
| afterDatasetsDraw(chart: Chart) { | |
| const meta = chart.getDatasetMeta(3); // ACWR is the 4th dataset (index 3) | |
| if (!meta || meta.hidden) return; | |
| const ctx = chart.ctx; | |
| const data = chart.data.datasets[3].data as (number | null)[]; | |
| const chartArea = chart.chartArea; | |
| ctx.save(); | |
| // First, fill the area under the curve with gradient colors | |
| for (let i = 0; i < meta.data.length - 1; i++) { | |
| const point1 = meta.data[i]; | |
| const point2 = meta.data[i + 1]; | |
| if (!point1 || !point2) continue; | |
| const value1 = data[i]; | |
| const value2 = data[i + 1]; | |
| // Skip if both values are null | |
| if (value1 === null && value2 === null) continue; | |
| // Use the second point value (most recent/rightward) | |
| const segmentValue = value2 ?? value1; | |
| const color = getACWRColor(segmentValue); | |
| // Make the fill more transparent | |
| const fillColor = color.replace(/[\d.]+\)$/, '0.15)'); | |
| ctx.fillStyle = fillColor; | |
| ctx.beginPath(); | |
| ctx.moveTo(point1.x, point1.y); | |
| ctx.lineTo(point2.x, point2.y); | |
| ctx.lineTo(point2.x, chartArea.bottom); | |
| ctx.lineTo(point1.x, chartArea.bottom); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| // Then draw the line on top | |
| ctx.lineWidth = 4; | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| // Draw line segments with colors based on values | |
| for (let i = 0; i < meta.data.length - 1; i++) { | |
| const point1 = meta.data[i]; | |
| const point2 = meta.data[i + 1]; | |
| if (!point1 || !point2) continue; | |
| const value1 = data[i]; | |
| const value2 = data[i + 1]; | |
| // Skip if both values are null | |
| if (value1 === null && value2 === null) continue; | |
| // Use the second point value (most recent/rightward) | |
| const segmentValue = value2 ?? value1; | |
| ctx.strokeStyle = getACWRColor(segmentValue); | |
| ctx.beginPath(); | |
| ctx.moveTo(point1.x, point1.y); | |
| ctx.lineTo(point2.x, point2.y); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| }, | |
| }; | |
| // Register the custom plugin | |
| Chart.register(acwrGradientPlugin); | |
| // Function to get activity emoji based on type | |
| function getActivityEmoji(activityType: string): string { | |
| const type = activityType.toLowerCase(); | |
| if (type.includes('run')) return 'π'; | |
| if (type.includes('cycling') || type.includes('bike') || type.includes('ride')) return 'π΄'; | |
| if (type.includes('swim')) return 'π'; | |
| if (type.includes('walk') || type.includes('hiking')) return 'πΆ'; | |
| if (type.includes('strength') || type.includes('gym')) return 'πͺ'; | |
| if (type.includes('yoga')) return 'π§'; | |
| if (type.includes('row')) return 'π£'; | |
| if (type.includes('ski')) return 'β·οΈ'; | |
| return 'β‘'; // Default for other activities | |
| } | |
| // Function to show activity details popover | |
| function showActivityDetails(dateStr: string, activities: Activity[]): void { | |
| const popover = document.getElementById('activity-popover'); | |
| const dateTitle = document.getElementById('activity-date-title'); | |
| const activityList = document.getElementById('activity-list'); | |
| if (!popover || !dateTitle || !activityList) return; | |
| // Format date | |
| const date = new Date(dateStr); | |
| const formattedDate = date.toLocaleDateString('en-US', { | |
| weekday: 'long', | |
| year: 'numeric', | |
| month: 'long', | |
| day: 'numeric' | |
| }); | |
| dateTitle.textContent = formattedDate; | |
| // Clear previous activities | |
| activityList.innerHTML = ''; | |
| // Add each activity | |
| activities.forEach(activity => { | |
| const activityItem = document.createElement('div'); | |
| activityItem.className = 'activity-item'; | |
| const displayName = activity.title || activity.activityType || 'Activity'; | |
| const emoji = getActivityEmoji(activity.activityType || 'Activity'); | |
| const header = document.createElement('div'); | |
| header.className = 'activity-item-header'; | |
| header.innerHTML = `<span>${emoji}</span><span>${displayName}</span>`; | |
| const details = document.createElement('div'); | |
| details.className = 'activity-item-details'; | |
| // Distance | |
| if (activity.distance !== undefined && activity.distance > 0) { | |
| const distanceDetail = document.createElement('div'); | |
| distanceDetail.className = 'activity-detail'; | |
| distanceDetail.innerHTML = ` | |
| <span class="activity-detail-label">πΊοΈ Distance</span> | |
| <span class="activity-detail-value">${activity.distance.toFixed(2)} km</span> | |
| `; | |
| details.appendChild(distanceDetail); | |
| } | |
| // Duration | |
| if (activity.duration !== undefined && activity.duration > 0) { | |
| const durationDetail = document.createElement('div'); | |
| durationDetail.className = 'activity-detail'; | |
| const durationMin = Math.round(activity.duration); | |
| durationDetail.innerHTML = ` | |
| <span class="activity-detail-label">β±οΈ Duration</span> | |
| <span class="activity-detail-value">${durationMin} min</span> | |
| `; | |
| details.appendChild(durationDetail); | |
| } | |
| // TSS | |
| if (activity.trainingStressScore !== undefined && activity.trainingStressScore > 0) { | |
| const tssDetail = document.createElement('div'); | |
| tssDetail.className = 'activity-detail'; | |
| tssDetail.innerHTML = ` | |
| <span class="activity-detail-label">π₯΅ TSS</span> | |
| <span class="activity-detail-value">${activity.trainingStressScore.toFixed(0)}</span> | |
| `; | |
| details.appendChild(tssDetail); | |
| } | |
| // Calories | |
| if (activity.calories !== undefined && activity.calories > 0) { | |
| const caloriesDetail = document.createElement('div'); | |
| caloriesDetail.className = 'activity-detail'; | |
| caloriesDetail.innerHTML = ` | |
| <span class="activity-detail-label">π Calories</span> | |
| <span class="activity-detail-value">${activity.calories.toFixed(0)} kcal</span> | |
| `; | |
| details.appendChild(caloriesDetail); | |
| } | |
| // Average HR | |
| if (activity.averageHR !== undefined && activity.averageHR > 0) { | |
| const avgHRDetail = document.createElement('div'); | |
| avgHRDetail.className = 'activity-detail'; | |
| avgHRDetail.innerHTML = ` | |
| <span class="activity-detail-label">π Avg HR</span> | |
| <span class="activity-detail-value">${activity.averageHR.toFixed(0)} bpm</span> | |
| `; | |
| details.appendChild(avgHRDetail); | |
| } | |
| // Max HR | |
| if (activity.maxHR !== undefined && activity.maxHR > 0) { | |
| const maxHRDetail = document.createElement('div'); | |
| maxHRDetail.className = 'activity-detail'; | |
| maxHRDetail.innerHTML = ` | |
| <span class="activity-detail-label">β€οΈ Max HR</span> | |
| <span class="activity-detail-value">${activity.maxHR.toFixed(0)} bpm</span> | |
| `; | |
| details.appendChild(maxHRDetail); | |
| } | |
| activityItem.appendChild(header); | |
| activityItem.appendChild(details); | |
| activityList.appendChild(activityItem); | |
| }); | |
| // Show popover | |
| popover.classList.remove('hidden'); | |
| } | |
| // Setup activity popover close handlers | |
| function setupActivityPopoverHandlers(): void { | |
| const popover = document.getElementById('activity-popover'); | |
| const closeButton = document.getElementById('activity-close'); | |
| if (!popover || !closeButton) return; | |
| // Close button click | |
| closeButton.addEventListener('click', () => { | |
| popover.classList.add('hidden'); | |
| }); | |
| // Click outside to close | |
| popover.addEventListener('click', (e) => { | |
| if (e.target === popover) { | |
| popover.classList.add('hidden'); | |
| } | |
| }); | |
| // Escape key to close | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Escape' && !popover.classList.contains('hidden')) { | |
| popover.classList.add('hidden'); | |
| } | |
| }); | |
| } | |
| // Initialize handlers on load | |
| if (typeof window !== 'undefined') { | |
| setupActivityPopoverHandlers(); | |
| } | |
| // Common chart styling inspired by Garmin | |
| const commonOptions = { | |
| responsive: true, | |
| maintainAspectRatio: true, | |
| plugins: { | |
| legend: { | |
| display: true, | |
| position: 'top' as const, | |
| labels: { | |
| usePointStyle: true, | |
| padding: 15, | |
| font: { | |
| size: 12, | |
| }, | |
| }, | |
| }, | |
| tooltip: { | |
| mode: 'index' as const, | |
| intersect: false, | |
| backgroundColor: 'rgba(255, 255, 255, 0.95)', | |
| titleColor: '#1e293b', | |
| bodyColor: '#475569', | |
| borderColor: '#e2e8f0', | |
| borderWidth: 1, | |
| padding: 12, | |
| displayColors: true, | |
| }, | |
| }, | |
| scales: { | |
| x: { | |
| grid: { | |
| display: true, | |
| color: 'rgba(148, 163, 184, 0.15)', | |
| lineWidth: 1, | |
| drawBorder: false, | |
| }, | |
| ticks: { | |
| maxRotation: 45, | |
| minRotation: 45, | |
| padding: 8, | |
| color: '#64748b', | |
| font: { | |
| size: 10, | |
| }, | |
| autoSkip: true, | |
| maxTicksLimit: 20, | |
| }, | |
| }, | |
| }, | |
| }; | |
| function createDualAxisChart( | |
| canvasId: string, | |
| data: MetricACWRData, | |
| metricLabel: string, | |
| metricUnit: string, | |
| dotColor: string | |
| ): Chart { | |
| const canvas = document.getElementById(canvasId) as HTMLCanvasElement; | |
| if (!canvas) throw new Error(`Canvas ${canvasId} not found`); | |
| const config: ChartConfiguration = { | |
| type: 'line', | |
| data: { | |
| labels: data.dates, | |
| datasets: [ | |
| { | |
| type: 'scatter', | |
| label: `Daily ${metricLabel}`, | |
| data: data.values, | |
| backgroundColor: dotColor, | |
| borderColor: dotColor.replace('0.8)', '1)'), | |
| borderWidth: 1, | |
| pointRadius: 5, | |
| pointHoverRadius: 7, | |
| yAxisID: 'y', | |
| }, | |
| { | |
| type: 'line', | |
| label: '7-Day Average', | |
| data: data.average7d, | |
| borderColor: 'rgba(168, 85, 247, 0.5)', | |
| backgroundColor: 'rgba(168, 85, 247, 0.05)', | |
| borderWidth: 1.5, | |
| pointRadius: 0, | |
| pointHoverRadius: 4, | |
| tension: 0.4, | |
| fill: false, | |
| yAxisID: 'y', | |
| }, | |
| { | |
| type: 'line', | |
| label: '28-Day Average', | |
| data: data.average28d, | |
| borderColor: 'rgba(236, 72, 153, 0.5)', | |
| backgroundColor: 'rgba(236, 72, 153, 0.05)', | |
| borderWidth: 1.5, | |
| pointRadius: 0, | |
| pointHoverRadius: 4, | |
| tension: 0.4, | |
| fill: false, | |
| yAxisID: 'y', | |
| }, | |
| { | |
| type: 'line', | |
| label: 'ACWR', | |
| data: data.acwr, | |
| borderColor: 'rgba(139, 92, 246, 0)', // Transparent - we draw it in the plugin | |
| backgroundColor: 'rgba(139, 92, 246, 0)', | |
| borderWidth: 4, | |
| pointRadius: 0, | |
| pointHoverRadius: 5, | |
| pointHoverBackgroundColor: (context: any) => { | |
| const value = context.raw; | |
| return getACWRColor(value); | |
| }, | |
| tension: 0.4, | |
| fill: false, | |
| yAxisID: 'y1', | |
| }, | |
| ], | |
| }, | |
| options: { | |
| ...commonOptions, | |
| onClick: (_event: any, elements: any[]) => { | |
| if (elements.length > 0) { | |
| const element = elements[0]; | |
| const datasetIndex = element.datasetIndex; | |
| // Only handle clicks on the scatter (daily values) dataset | |
| if (datasetIndex === 0) { | |
| const index = element.index; | |
| const dateStr = data.dates[index]; | |
| // Get activities for this date | |
| if (data.activitiesByDate) { | |
| const activities = data.activitiesByDate.get(dateStr); | |
| if (activities && activities.length > 0) { | |
| showActivityDetails(dateStr, activities); | |
| } | |
| } | |
| } | |
| } | |
| }, | |
| scales: { | |
| ...commonOptions.scales, | |
| y: { | |
| type: 'linear', | |
| position: 'left', | |
| beginAtZero: true, | |
| border: { | |
| display: false, | |
| }, | |
| grid: { | |
| color: 'rgba(148, 163, 184, 0.1)', | |
| }, | |
| ticks: { | |
| padding: 8, | |
| color: '#64748b', | |
| font: { | |
| size: 11, | |
| }, | |
| }, | |
| title: { | |
| display: true, | |
| text: `${metricLabel} ${metricUnit}`, | |
| color: '#64748b', | |
| font: { | |
| size: 12, | |
| weight: 500, | |
| }, | |
| }, | |
| }, | |
| y1: { | |
| type: 'linear', | |
| position: 'right', | |
| beginAtZero: true, | |
| suggestedMin: 0, | |
| suggestedMax: 2, | |
| grid: { | |
| drawOnChartArea: false, | |
| }, | |
| ticks: { | |
| padding: 8, | |
| color: '#8b5cf6', | |
| font: { | |
| size: 11, | |
| }, | |
| }, | |
| title: { | |
| display: true, | |
| text: 'ACWR', | |
| color: '#8b5cf6', | |
| font: { | |
| size: 12, | |
| weight: 500, | |
| }, | |
| }, | |
| }, | |
| }, | |
| interaction: { | |
| mode: 'index' as const, | |
| intersect: false, | |
| }, | |
| }, | |
| }; | |
| return new Chart(canvas, config); | |
| } | |
| export function createDistanceChart(data: MetricACWRData): void { | |
| if (distanceChart) { | |
| distanceChart.destroy(); | |
| } | |
| distanceChart = createDualAxisChart( | |
| 'distance-chart', | |
| data, | |
| 'Distance', | |
| '(km)', | |
| 'rgba(234, 179, 8, 0.8)' | |
| ); | |
| updateTargetInfo('distance-target', data.targetTomorrowValue, 'km', data.targetACWR, data.restTomorrowACWR); | |
| } | |
| export function createDurationChart(data: MetricACWRData): void { | |
| if (durationChart) { | |
| durationChart.destroy(); | |
| } | |
| durationChart = createDualAxisChart( | |
| 'duration-chart', | |
| data, | |
| 'Duration', | |
| '(min)', | |
| 'rgba(234, 179, 8, 0.8)' | |
| ); | |
| updateTargetInfo('duration-target', data.targetTomorrowValue, 'minutes', data.targetACWR, data.restTomorrowACWR); | |
| } | |
| export function createTSSChart(data: MetricACWRData): void { | |
| if (tssChart) { | |
| tssChart.destroy(); | |
| } | |
| tssChart = createDualAxisChart( | |
| 'tss-chart', | |
| data, | |
| 'TSS', | |
| '', | |
| 'rgba(234, 179, 8, 0.8)' | |
| ); | |
| updateTargetInfo('tss-target', data.targetTomorrowValue, 'TSS', data.targetACWR, data.restTomorrowACWR); | |
| } | |
| export function createCaloriesChart(data: MetricACWRData): void { | |
| if (caloriesChart) { | |
| caloriesChart.destroy(); | |
| } | |
| caloriesChart = createDualAxisChart( | |
| 'calories-chart', | |
| data, | |
| 'Calories', | |
| '(kcal)', | |
| 'rgba(234, 179, 8, 0.8)' | |
| ); | |
| updateTargetInfo('calories-target', data.targetTomorrowValue, 'kcal', data.targetACWR, data.restTomorrowACWR); | |
| } | |
| function updateTargetInfo(elementId: string, targetValue: number | null | undefined, unit: string, targetACWR: number | undefined, restTomorrowACWR: number | null | undefined): void { | |
| const element = document.getElementById(elementId); | |
| if (!element) return; | |
| if (targetACWR !== undefined) { | |
| let html: string; | |
| if (targetValue === null || targetValue === undefined) { | |
| html = `<strong>π‘ Target for tomorrow:</strong> <span style="color: var(--secondary-color);">Target ACWR of ${targetACWR} cannot be reached in one day</span>`; | |
| } else if (targetValue === 0) { | |
| html = `<strong>π‘ Target for tomorrow:</strong> <span style="color: var(--secondary-color);">Rest day recommended to reach ACWR of ${targetACWR}</span>`; | |
| } else { | |
| const formattedValue = targetValue.toFixed(1); | |
| html = `<strong>π‘ Target for tomorrow:</strong> <span class="target-value">${formattedValue} ${unit}</span> <span style="color: var(--secondary-color);">to reach ACWR of ${targetACWR}</span>`; | |
| } | |
| // Add rest tomorrow information | |
| if (restTomorrowACWR !== null && restTomorrowACWR !== undefined) { | |
| const color = getACWRColor(restTomorrowACWR); | |
| html += `<br><strong>π΄ Rest tomorrow:</strong> <span style="color: ${color};">ACWR ${restTomorrowACWR.toFixed(2)}</span>`; | |
| } | |
| element.innerHTML = html; | |
| element.classList.add('visible'); | |
| } else { | |
| element.innerHTML = `<strong>βΉοΈ Target for tomorrow:</strong> <span style="color: var(--secondary-color);">Need at least 28 days of data to calculate</span>`; | |
| element.classList.add('visible'); | |
| } | |
| } | |
| export function destroyAllCharts(): void { | |
| if (distanceChart) { | |
| distanceChart.destroy(); | |
| distanceChart = null; | |
| } | |
| if (durationChart) { | |
| durationChart.destroy(); | |
| durationChart = null; | |
| } | |
| if (tssChart) { | |
| tssChart.destroy(); | |
| tssChart = null; | |
| } | |
| if (caloriesChart) { | |
| caloriesChart.destroy(); | |
| caloriesChart = null; | |
| } | |
| } | |