From 1c5b38fadeaa11721012cb81ed55d15dd3bd4e73 Mon Sep 17 00:00:00 2001 From: Heiko Joerg Schick Date: Sun, 27 Apr 2025 17:43:41 +0200 Subject: [PATCH] Commit all changes --- css/styles.css | 31 ++- index.html | 75 +++--- js/advanced-charts.js | 615 ++++++++++++++++++++++++++++++++++++++++++ js/app.js | 25 ++ js/charts.js | 410 ++++++++++++++++++++++++---- 5 files changed, 1071 insertions(+), 85 deletions(-) create mode 100644 js/advanced-charts.js diff --git a/css/styles.css b/css/styles.css index 109ca94..6ac2ded 100644 --- a/css/styles.css +++ b/css/styles.css @@ -170,8 +170,19 @@ p { margin-bottom: 20px; } +/* New: Option rows for analysis options */ +.options-row { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} +.advanced-row { + margin-top: 16px; +} + .option-group { - flex: 1; + width: 100%; min-width: 200px; } @@ -377,7 +388,7 @@ textarea { .chart-container { position: relative; height: 300px; - margin-bottom: 10px; + margin-bottom: 40px; border: 1px solid var(--border-color); border-radius: 6px; padding: 10px; @@ -404,6 +415,11 @@ textarea { display: none; } +/* Add extra space after the last goal label in each chart */ +.goal-label:last-child { + margin-bottom: 24px; +} + /* PDF Export Styles */ .pdf-export .chart-container { max-width: 500px; /* Optimal width for A4 page with margins */ @@ -428,7 +444,16 @@ textarea { .options-container { flex-direction: column; } - + .options-row { + flex-direction: column; + gap: 16px; + } +.advanced-row { + margin-top: 16px; + flex-direction: row; + display: flex; + gap: 24px; +} .report-header { flex-direction: column; align-items: flex-start; diff --git a/index.html b/index.html index 48b375f..1a7baad 100644 --- a/index.html +++ b/index.html @@ -48,40 +48,49 @@

Customize Your Analysis

-
-

Analysis Focus

- +
+
+

Analysis Focus

+ +
+ +
+

Recommendation Style

+ +
+ +
+

Report Detail

+ +
- -
-

Recommendation Style

- -
- -
-

Report Detail

- -
- -
-

Advanced Options

-
- - +
+
+

Advanced Options

+
+ + +
+
+
+

Sleep Goal

+ + + Set your target sleep duration per night
diff --git a/js/advanced-charts.js b/js/advanced-charts.js new file mode 100644 index 0000000..553ed01 --- /dev/null +++ b/js/advanced-charts.js @@ -0,0 +1,615 @@ +/** + * Advanced chart methods for the Sport Science Coach + * Contains implementations for heatmaps, radar charts, and drill-down functionality + */ + +// Add these methods to the ChartsManager class + +/** + * Create sleep heatmap chart + * @param {Array} metricsData - Processed metrics data + * @param {HTMLElement} wrapper - Wrapper for the chart containers + */ +ChartsManager.prototype.createSleepHeatmap = function(metricsData, wrapper) { + // Filter out days without sleep data + const sleepData = metricsData.filter(day => + day.metrics['Sleep Hours'] !== undefined + ); + + if (sleepData.length === 0) { + this.showNoDataMessage(wrapper, 'No sleep data available for heatmap'); + return; + } + + // Create container for heatmap + const heatmapContainer = this.createChartContainer(wrapper); + heatmapContainer.classList.add('interactive'); + const canvas = document.createElement('canvas'); + heatmapContainer.appendChild(canvas); + + // Process data for heatmap + // Group by day of week and week number + const heatmapData = this.processDataForSleepHeatmap(sleepData); + + // Create heatmap chart + const chart = new Chart(canvas, { + type: 'matrix', + data: { + datasets: [{ + label: 'Sleep Hours', + data: heatmapData, + backgroundColor(context) { + const value = context.dataset.data[context.dataIndex].v; + // Color scale based on sleep hours + if (value === null) return 'rgba(0, 0, 0, 0.05)'; + return value >= 8 ? 'rgba(46, 204, 113, 0.8)' : + value >= 7 ? 'rgba(52, 152, 219, 0.8)' : + value >= 6 ? 'rgba(241, 196, 15, 0.8)' : + 'rgba(231, 76, 60, 0.8)'; + }, + borderColor: 'rgba(255, 255, 255, 0.2)', + borderWidth: 1, + width: 25, + height: 25 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + tooltip: { + callbacks: { + title() { + return ''; + }, + label(context) { + const v = context.dataset.data[context.dataIndex]; + if (v.v === null) return 'No data'; + return [ + `Date: ${v.date}`, + `Sleep: ${v.v.toFixed(1)} hours` + ]; + } + } + }, + title: { + display: true, + text: 'Sleep Pattern Heatmap' + }, + subtitle: { + display: true, + text: 'Click for detailed view', + padding: { + bottom: 10 + } + }, + legend: { + display: false + } + }, + scales: { + y: { + type: 'category', + labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + offset: true, + ticks: { + display: true + }, + grid: { + display: false + }, + title: { + display: true, + text: 'Day of Week' + } + }, + x: { + type: 'category', + labels: this.getWeekLabels(sleepData), + offset: true, + ticks: { + display: true + }, + grid: { + display: false + }, + title: { + display: true, + text: 'Week' + } + } + } + } + }); + + // Store chart reference + this.charts.push(chart); + + // Add heatmap legend + const legend = document.createElement('div'); + legend.className = 'heatmap-legend'; + + const legendItems = [ + { label: '< 6 hours', color: 'rgba(231, 76, 60, 0.8)' }, + { label: '6-7 hours', color: 'rgba(241, 196, 15, 0.8)' }, + { label: '7-8 hours', color: 'rgba(52, 152, 219, 0.8)' }, + { label: '8+ hours', color: 'rgba(46, 204, 113, 0.8)' } + ]; + + legendItems.forEach(item => { + const legendItem = document.createElement('div'); + legendItem.className = 'heatmap-legend-item'; + + const colorBox = document.createElement('div'); + colorBox.className = 'heatmap-legend-color'; + colorBox.style.backgroundColor = item.color; + + const label = document.createElement('span'); + label.textContent = item.label; + + legendItem.appendChild(colorBox); + legendItem.appendChild(label); + legend.appendChild(legendItem); + }); + + heatmapContainer.appendChild(legend); + + // Add click event for drill-down + canvas.addEventListener('click', (evt) => { + const points = chart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, false); + + if (points.length) { + const firstPoint = points[0]; + const data = chart.data.datasets[firstPoint.datasetIndex].data[firstPoint.index]; + + if (data.v !== null) { + this.createSleepDetailDrillDown(data.date, heatmapContainer); + } + } + }); +}; + +/** + * Process data for sleep heatmap + * @param {Array} sleepData - Sleep metrics data + * @returns {Array} - Processed data for heatmap + */ +ChartsManager.prototype.processDataForSleepHeatmap = function(sleepData) { + const heatmapData = []; + + // Get the first date in the dataset + const firstDate = new Date(sleepData[0].date); + const firstWeek = this.getWeekNumber(firstDate); + + // Create a map of date to sleep hours + const dateToSleepHours = {}; + sleepData.forEach(day => { + dateToSleepHours[day.date] = day.metrics['Sleep Hours']; + }); + + // Generate all days in the range + const startDate = new Date(sleepData[0].date); + const endDate = new Date(sleepData[sleepData.length - 1].date); + + // Add one day to endDate to include it in the range + endDate.setDate(endDate.getDate() + 1); + + for (let d = new Date(startDate); d < endDate; d.setDate(d.getDate() + 1)) { + const date = d.toISOString().split('T')[0]; + const dayOfWeek = d.getDay(); // 0 = Sunday, 6 = Saturday + const weekNumber = this.getWeekNumber(d); + const weekIndex = weekNumber - firstWeek; + + heatmapData.push({ + x: weekIndex, + y: dayOfWeek, + v: dateToSleepHours[date] !== undefined ? dateToSleepHours[date] : null, + date: date + }); + } + + return heatmapData; +}; + +/** + * Get week number for a date + * @param {Date} date - Date to get week number for + * @returns {number} - Week number + */ +ChartsManager.prototype.getWeekNumber = function(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); +}; + +/** + * Get week labels for heatmap + * @param {Array} sleepData - Sleep metrics data + * @returns {Array} - Week labels + */ +ChartsManager.prototype.getWeekLabels = function(sleepData) { + const firstDate = new Date(sleepData[0].date); + const lastDate = new Date(sleepData[sleepData.length - 1].date); + + const firstWeek = this.getWeekNumber(firstDate); + const lastWeek = this.getWeekNumber(lastDate); + + const labels = []; + for (let i = firstWeek; i <= lastWeek; i++) { + labels.push(`Week ${i}`); + } + + return labels; +}; + +/** + * Create fitness radar chart + * @param {Object} data - Processed health and workout data + * @param {HTMLElement} wrapper - Wrapper for the chart containers + */ +ChartsManager.prototype.createFitnessRadarChart = function(data, wrapper) { + // Create container for radar chart + const radarContainer = this.createChartContainer(wrapper); + radarContainer.classList.add('radar-chart-container'); + radarContainer.classList.add('interactive'); + const canvas = document.createElement('canvas'); + radarContainer.appendChild(canvas); + + // Process data for radar chart + const radarData = this.processDataForRadarChart(data); + + if (!radarData) { + this.showNoDataMessage(radarContainer, 'Insufficient data for fitness radar chart'); + return; + } + + // Create radar chart + const chart = new Chart(canvas, { + type: 'radar', + data: { + labels: ['Sleep Quality', 'Stress Management', 'Workout Intensity', 'Recovery', 'Consistency'], + datasets: [ + { + label: 'Current Profile', + data: radarData.current, + backgroundColor: 'rgba(111, 185, 143, 0.2)', + borderColor: this.colors.secondary, + pointBackgroundColor: this.colors.secondary, + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: this.colors.secondary + }, + { + label: 'Optimal Profile', + data: radarData.optimal, + backgroundColor: 'rgba(74, 111, 165, 0.2)', + borderColor: this.colors.primary, + pointBackgroundColor: this.colors.primary, + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: this.colors.primary + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Fitness Balance Radar' + }, + subtitle: { + display: true, + text: 'Click for detailed view', + padding: { + bottom: 10 + } + }, + tooltip: { + callbacks: { + label: (context) => { + const value = context.raw; + const percentage = (value * 10).toFixed(0); + return `${context.dataset.label}: ${percentage}%`; + } + } + }, + datalabels: { + formatter: (value) => { + return (value * 10).toFixed(0); + }, + color: '#fff', + backgroundColor: function(context) { + return context.dataset.borderColor; + }, + borderRadius: 3, + font: { + weight: 'bold', + size: 10 + }, + padding: 4 + } + }, + scales: { + r: { + angleLines: { + display: true, + color: 'rgba(0, 0, 0, 0.1)' + }, + suggestedMin: 0, + suggestedMax: 10, + ticks: { + stepSize: 2, + callback: function(value) { + return (value * 10) + '%'; + } + } + } + } + } + }); + + // Store chart reference + this.charts.push(chart); + + // Add click event for drill-down + canvas.addEventListener('click', () => { + this.createFitnessRadarDrillDown(data, radarData, radarContainer); + }); +}; + +/** + * Process data for radar chart + * @param {Object} data - Processed health and workout data + * @returns {Object|null} - Processed data for radar chart or null if insufficient data + */ +ChartsManager.prototype.processDataForRadarChart = function(data) { + // Check if we have enough data + if (!data.metrics || data.metrics.length === 0 || !data.workouts || data.workouts.length === 0) { + return null; + } + + // Calculate sleep quality score (0-10) + const sleepHours = data.metrics + .filter(day => day.metrics['Sleep Hours'] !== undefined) + .map(day => day.metrics['Sleep Hours']); + + if (sleepHours.length === 0) return null; + + const avgSleepHours = this.calculateAverage(sleepHours); + const sleepQualityScore = Math.min(10, Math.max(0, (avgSleepHours - 4) * 1.67)) / 10; + + // Calculate stress management score (0-10) + const stressQualifiers = data.metrics + .filter(day => day.metrics['Stress Qualifier'] !== undefined) + .map(day => day.metrics['Stress Qualifier']); + + if (stressQualifiers.length === 0) return null; + + const stressScores = { + 'calm': 10, + 'balanced': 7.5, + 'stressful': 4, + 'very_stressful': 1 + }; + + const avgStressScore = this.calculateAverage(stressQualifiers.map(qualifier => stressScores[qualifier] || 5)) / 10; + + // Calculate workout intensity score (0-10) + const workoutIntensities = data.workouts.map(workout => { + const heartRateScore = workout.heartRate.average / 180 * 10; // Normalize to 0-10 + return Math.min(10, Math.max(0, heartRateScore)); + }); + + const avgWorkoutIntensity = this.calculateAverage(workoutIntensities) / 10; + + // Calculate recovery score (0-10) + const hrvValues = data.metrics + .filter(day => day.metrics['HRV'] !== undefined) + .map(day => day.metrics['HRV']); + + if (hrvValues.length === 0) return null; + + const avgHRV = this.calculateAverage(hrvValues); + const recoveryScore = Math.min(10, Math.max(0, avgHRV / 7)) / 10; + + // Calculate consistency score (0-10) + const totalDays = (new Date(data.metrics[data.metrics.length - 1].date) - + new Date(data.metrics[0].date)) / (1000 * 60 * 60 * 24); + + const workoutFrequency = data.workouts.length / (totalDays / 7); // Workouts per week + const consistencyScore = Math.min(10, Math.max(0, workoutFrequency * 2.5)) / 10; + + return { + current: [ + sleepQualityScore, + avgStressScore, + avgWorkoutIntensity, + recoveryScore, + consistencyScore + ], + optimal: [0.8, 0.8, 0.7, 0.8, 0.7] // Optimal values for comparison + }; +}; + +/** + * Create fitness radar drill-down + * @param {Object} data - Processed health and workout data + * @param {Object} radarData - Processed radar chart data + * @param {HTMLElement} container - Container element + */ +ChartsManager.prototype.createFitnessRadarDrillDown = function(data, radarData, container) { + // Check if drill-down container already exists + let drillDownContainer = container.querySelector('.drill-down-container'); + + if (!drillDownContainer) { + drillDownContainer = document.createElement('div'); + drillDownContainer.className = 'drill-down-container'; + container.appendChild(drillDownContainer); + } else { + drillDownContainer.innerHTML = ''; + } + + // Create header + const header = document.createElement('div'); + header.className = 'drill-down-header'; + + const title = document.createElement('h4'); + title.className = 'drill-down-title'; + title.textContent = 'Fitness Balance Details'; + + const backButton = document.createElement('button'); + backButton.className = 'drill-down-back-button'; + backButton.innerHTML = ' Back'; + backButton.addEventListener('click', () => { + drillDownContainer.classList.add('hidden'); + }); + + header.appendChild(title); + header.appendChild(backButton); + drillDownContainer.appendChild(header); + + // Create metrics table + const metricsTable = document.createElement('table'); + metricsTable.style.width = '100%'; + metricsTable.style.borderCollapse = 'collapse'; + metricsTable.style.marginTop = '20px'; + + // Add table header + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + + ['Metric', 'Score', 'Details', 'Recommendation'].forEach(text => { + const th = document.createElement('th'); + th.textContent = text; + th.style.padding = '8px'; + th.style.textAlign = 'left'; + th.style.borderBottom = '1px solid #ddd'; + headerRow.appendChild(th); + }); + + thead.appendChild(headerRow); + metricsTable.appendChild(thead); + + // Add table body + const tbody = document.createElement('tbody'); + + // Define metrics details + const metricsDetails = [ + { + name: 'Sleep Quality', + score: (radarData.current[0] * 10).toFixed(0) + '%', + details: `Average sleep: ${this.calculateAverage(data.metrics.filter(day => day.metrics['Sleep Hours'] !== undefined).map(day => day.metrics['Sleep Hours'])).toFixed(1)} hours`, + recommendation: radarData.current[0] < 0.7 ? 'Aim for 7-9 hours of sleep per night' : 'Maintain current sleep schedule' + }, + { + name: 'Stress Management', + score: (radarData.current[1] * 10).toFixed(0) + '%', + details: this.getStressDistribution(data.metrics), + recommendation: radarData.current[1] < 0.7 ? 'Incorporate daily mindfulness or meditation practice' : 'Continue effective stress management techniques' + }, + { + name: 'Workout Intensity', + score: (radarData.current[2] * 10).toFixed(0) + '%', + details: `Average heart rate: ${this.calculateAverage(data.workouts.map(w => w.heartRate.average)).toFixed(0)} bpm`, + recommendation: radarData.current[2] < 0.6 ? 'Consider adding high-intensity interval training' : (radarData.current[2] > 0.8 ? 'Balance high-intensity workouts with recovery sessions' : 'Current intensity level is well-balanced') + }, + { + name: 'Recovery', + score: (radarData.current[3] * 10).toFixed(0) + '%', + details: `Average HRV: ${this.calculateAverage(data.metrics.filter(day => day.metrics['HRV'] !== undefined).map(day => day.metrics['HRV'])).toFixed(0)} ms`, + recommendation: radarData.current[3] < 0.7 ? 'Focus on recovery techniques like stretching and proper nutrition' : 'Maintain current recovery practices' + }, + { + name: 'Consistency', + score: (radarData.current[4] * 10).toFixed(0) + '%', + details: `${data.workouts.length} workouts over ${Math.ceil((new Date(data.metrics[data.metrics.length - 1].date) - new Date(data.metrics[0].date)) / (1000 * 60 * 60 * 24))} days`, + recommendation: radarData.current[4] < 0.6 ? 'Establish a regular workout schedule (3-4 times per week)' : 'Maintain current consistency' + } + ]; + + // Add metrics rows + metricsDetails.forEach(metric => { + const row = document.createElement('tr'); + + ['name', 'score', 'details', 'recommendation'].forEach(prop => { + const cell = document.createElement('td'); + cell.textContent = metric[prop]; + cell.style.padding = '8px'; + cell.style.borderBottom = '1px solid #eee'; + row.appendChild(cell); + }); + + tbody.appendChild(row); + }); + + metricsTable.appendChild(tbody); + drillDownContainer.appendChild(metricsTable); + + // Add explanation + const explanation = document.createElement('div'); + explanation.style.marginTop = '20px'; + explanation.style.padding = '15px'; + explanation.style.backgroundColor = 'rgba(74, 111, 165, 0.1)'; + explanation.style.borderRadius = '6px'; + + const explanationTitle = document.createElement('h4'); + explanationTitle.textContent = 'About This Chart'; + explanationTitle.style.marginTop = '0'; + + const explanationText = document.createElement('p'); + explanationText.textContent = 'The Fitness Balance Radar provides a holistic view of your health and fitness profile across five key dimensions. Each dimension is scored from 0-100% based on your data, with higher scores indicating better performance. The "Optimal Profile" represents balanced targets across all dimensions.'; + + explanation.appendChild(explanationTitle); + explanation.appendChild(explanationText); + drillDownContainer.appendChild(explanation); +}; + +/** + * Get stress distribution text + * @param {Array} metricsData - Metrics data + * @returns {string} - Stress distribution text + */ +ChartsManager.prototype.getStressDistribution = function(metricsData) { + const stressQualifiers = metricsData + .filter(day => day.metrics['Stress Qualifier'] !== undefined) + .map(day => day.metrics['Stress Qualifier']); + + const counts = { + 'calm': 0, + 'balanced': 0, + 'stressful': 0, + 'very_stressful': 0 + }; + + stressQualifiers.forEach(qualifier => { + counts[qualifier] = (counts[qualifier] || 0) + 1; + }); + + const total = stressQualifiers.length; + + return `${Math.round(counts.calm / total * 100)}% Calm, ${Math.round(counts.balanced / total * 100)}% Balanced, ${Math.round(counts.stressful / total * 100)}% Stressful, ${Math.round(counts.very_stressful / total * 100)}% Very Stressful`; +}; + +/** + * Calculate average of array values + * @param {Array} values - Array of numbers + * @returns {number} - Average value + */ +ChartsManager.prototype.calculateAverage = function(values) { + if (!values || values.length === 0) return 0; + return values.reduce((sum, val) => sum + val, 0) / values.length; +}; + +/** + * Calculate sum of array values + * @param {Array} values - Array of numbers + * @returns {number} - Sum of values + */ +ChartsManager.prototype.calculateSum = function(values) { + if (!values || values.length === 0) return 0; + return values.reduce((sum, val) => sum + val, 0); +}; diff --git a/js/app.js b/js/app.js index 1738917..376126d 100644 --- a/js/app.js +++ b/js/app.js @@ -28,6 +28,7 @@ class HealthAnalyticsApp { this.reportDetailSelect = document.getElementById('report-detail'); this.customPromptTextarea = document.getElementById('custom-prompt'); this.showGptInputCheckbox = document.getElementById('show-gpt-input'); + this.sleepGoalInput = document.getElementById('sleep-goal-input'); // Buttons this.useSampleDataButton = document.getElementById('use-sample-data'); @@ -37,6 +38,12 @@ class HealthAnalyticsApp { // Report section this.reportSection = document.querySelector('.report-section'); + + // Initialize sleep goal input from localStorage + const storedGoal = localStorage.getItem('sleep_goal'); + if (storedGoal !== null) { + this.sleepGoalInput.value = storedGoal; + } } /** @@ -47,6 +54,16 @@ class HealthAnalyticsApp { this.metricsFileInput.addEventListener('change', (e) => this.handleFileInputChange(e, this.metricsFilename)); this.workoutsFileInput.addEventListener('change', (e) => this.handleFileInputChange(e, this.workoutsFilename)); + // Sleep goal input event + this.sleepGoalInput.addEventListener('input', (e) => { + const value = e.target.value; + if (value && !isNaN(value)) { + localStorage.setItem('sleep_goal', value); + } else { + localStorage.removeItem('sleep_goal'); + } + }); + // Button click events this.useSampleDataButton.addEventListener('click', () => this.handleUseSampleData()); this.generateReportButton.addEventListener('click', () => this.handleGenerateReport()); @@ -123,6 +140,14 @@ class HealthAnalyticsApp { // Get analysis options const options = this.getAnalysisOptions(); + + // Pass sleep goal to chartsManager + const sleepGoalValue = parseFloat(this.sleepGoalInput.value); + if (!isNaN(sleepGoalValue) && sleepGoalValue > 0) { + chartsManager.sleepGoal = sleepGoalValue; + } else { + chartsManager.sleepGoal = null; + } // Prepare data for analysis const data = dataProcessor.prepareDataForAnalysis(); diff --git a/js/charts.js b/js/charts.js index 279fa4d..2b6b8ed 100644 --- a/js/charts.js +++ b/js/charts.js @@ -88,6 +88,25 @@ class ChartsManager { return chartContainer; } + /** + * Helper: Calculate linear trend (slope, intercept, trend line) for a dataset + * @param {Array} y - Array of y values (e.g., totalSleep) + * @returns {Object} { slope, intercept, trendLine } + */ + getLinearTrend(y) { + const n = y.length; + if (n < 2) return { slope: 0, intercept: y[0] || 0, trendLine: y.map(() => y[0] || 0) }; + const x = Array.from({ length: n }, (_, i) => i); + const sumX = x.reduce((a, b) => a + b, 0); + const sumY = y.reduce((a, b) => a + b, 0); + const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0); + const sumXX = x.reduce((acc, xi) => acc + xi * xi, 0); + const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + const trendLine = x.map(xi => slope * xi + intercept); + return { slope, intercept, trendLine }; + } + /** * Create sleep patterns chart * @param {Array} metricsData - Processed metrics data @@ -110,6 +129,17 @@ class ChartsManager { const deepSleep = sleepData.map(day => day.metrics['Time In Deep Sleep'] || 0); const lightSleep = sleepData.map(day => day.metrics['Time In Light Sleep'] || 0); const remSleep = sleepData.map(day => day.metrics['Time In REM Sleep'] || 0); + + // --- Trend Detection for Sleep Duration --- + const { slope, trendLine } = this.getLinearTrend(totalSleep); + let trendLabel = ''; + if (slope > 0.1) { + trendLabel = 'Improving Sleep Duration'; + } else if (slope < -0.1) { + trendLabel = 'Declining Sleep Duration'; + } else { + trendLabel = 'Stable Sleep Duration'; + } // Create container for stacked bar chart const stackedContainer = this.createChartContainer(wrapper); @@ -187,18 +217,51 @@ class ChartsManager { const trendCanvas = document.createElement('canvas'); trendContainer.appendChild(trendCanvas); + // --- Goal Line and Progress --- + let goalDataset = null; + let goalLabelDiv = null; + if (typeof this.sleepGoal === "number" && !isNaN(this.sleepGoal) && this.sleepGoal > 0) { + goalDataset = { + label: `Goal (${this.sleepGoal}h)`, + data: totalSleep.map(() => this.sleepGoal), + borderColor: "#2ecc71", + backgroundColor: "rgba(46, 204, 113, 0.08)", + fill: false, + borderDash: [2, 6], + pointRadius: 0, + tension: 0, + order: 0 + }; + } + + // Compose datasets for trend chart + const trendDatasets = [ + { + label: 'Sleep Duration', + data: totalSleep, + borderColor: this.colors.sleep.total, + backgroundColor: 'rgba(74, 111, 165, 0.1)', + fill: true, + tension: 0.3 + }, + { + label: 'Trend', + data: trendLine, + borderColor: '#ff7e67', + backgroundColor: 'rgba(255, 126, 103, 0.08)', + fill: false, + borderDash: [8, 4], + pointRadius: 0, + tension: 0 + } + ]; + if (goalDataset) trendDatasets.push(goalDataset); + const trendChart = new Chart(trendCanvas, { type: 'line', data: { labels: dates, - datasets: [{ - label: 'Sleep Duration', - data: totalSleep, - borderColor: this.colors.sleep.total, - backgroundColor: 'rgba(74, 111, 165, 0.1)', - fill: true, - tension: 0.3 - }] + datasets: trendDatasets }, options: { responsive: true, @@ -207,6 +270,9 @@ class ChartsManager { title: { display: true, text: 'Sleep Duration Trend' + }, + legend: { + display: true } }, scales: { @@ -221,6 +287,28 @@ class ChartsManager { } } }); + + // Add trend label below the chart + const trendLabelDiv = document.createElement('div'); + trendLabelDiv.className = 'trend-label'; + trendLabelDiv.style.margin = '8px 0 0 0'; + trendLabelDiv.style.fontWeight = 'bold'; + trendLabelDiv.style.color = slope > 0.1 ? '#2ecc71' : (slope < -0.1 ? '#e74c3c' : '#3498db'); + trendLabelDiv.textContent = trendLabel; + trendContainer.appendChild(trendLabelDiv); + + // Add goal progress below the trend label if goal is set + if (goalDataset) { + const nightsMet = totalSleep.filter(h => h >= this.sleepGoal).length; + const percentMet = Math.round((nightsMet / totalSleep.length) * 100); + const goalLabelDiv = document.createElement('div'); + goalLabelDiv.className = 'goal-label'; + goalLabelDiv.style.margin = '4px 0 16px 0'; + goalLabelDiv.style.fontWeight = 'bold'; + goalLabelDiv.style.color = '#2ecc71'; + goalLabelDiv.textContent = `Goal met on ${nightsMet} of ${totalSleep.length} nights (${percentMet}%)`; + trendContainer.appendChild(goalLabelDiv); + } // Store chart reference this.charts.push(trendChart); @@ -313,18 +401,51 @@ class ChartsManager { } }); + // --- Trendline and Goal for Stress Level Timeline --- + const { slope: stressSlope, trendLine: stressTrendLine } = this.getLinearTrend(stressValues); + + // Default goal: 2 ("balanced") + const stressGoal = 2; + const goalDataset = { + label: 'Goal (Balanced)', + data: stressValues.map(() => stressGoal), + borderColor: "#2ecc71", + backgroundColor: "rgba(46, 204, 113, 0.08)", + fill: false, + borderDash: [2, 6], + pointRadius: 0, + tension: 0, + order: 0 + }; + + // Compose datasets for timeline chart + const timelineDatasets = [ + { + label: 'Stress Level', + data: stressValues, + borderColor: this.colors.accent, + backgroundColor: 'rgba(255, 126, 103, 0.1)', + fill: true, + stepped: true + }, + { + label: 'Trend', + data: stressTrendLine, + borderColor: '#ff7e67', + backgroundColor: 'rgba(255, 126, 103, 0.08)', + fill: false, + borderDash: [8, 4], + pointRadius: 0, + tension: 0 + }, + goalDataset + ]; + const timelineChart = new Chart(timelineCanvas, { type: 'line', data: { labels: dates, - datasets: [{ - label: 'Stress Level', - data: stressValues, - borderColor: this.colors.accent, - backgroundColor: 'rgba(255, 126, 103, 0.1)', - fill: true, - stepped: true - }] + datasets: timelineDatasets }, options: { responsive: true, @@ -339,7 +460,6 @@ class ChartsManager { label: (context) => { const value = context.raw; let label = ''; - switch(value) { case 1: label = 'Calm'; break; case 2: label = 'Balanced'; break; @@ -347,16 +467,22 @@ class ChartsManager { case 4: label = 'Very Stressful'; break; default: label = 'Unknown'; } - return label; } } + }, + legend: { + display: true } }, scales: { y: { min: 0.5, max: 4.5, + title: { + display: true, + text: 'Stress Level' + }, ticks: { callback: (value) => { switch(value) { @@ -372,7 +498,30 @@ class ChartsManager { } } }); - + + // Add trend label below the chart + const trendLabelDiv = document.createElement('div'); + trendLabelDiv.className = 'trend-label'; + trendLabelDiv.style.margin = '8px 0 0 0'; + trendLabelDiv.style.fontWeight = 'bold'; + trendLabelDiv.style.color = stressSlope < -0.1 ? '#2ecc71' : (stressSlope > 0.1 ? '#e74c3c' : '#3498db'); + trendLabelDiv.textContent = + stressSlope < -0.1 + ? 'Improving Stress Levels' + : (stressSlope > 0.1 ? 'Declining Stress Levels' : 'Stable Stress Levels'); + timelineContainer.appendChild(trendLabelDiv); + + // Add goal progress below the trend label + const daysMet = stressValues.filter(v => v <= stressGoal).length; + const percentMet = Math.round((daysMet / stressValues.length) * 100); + const goalLabelDiv = document.createElement('div'); + goalLabelDiv.className = 'goal-label'; + goalLabelDiv.style.margin = '4px 0 16px 0'; + goalLabelDiv.style.fontWeight = 'bold'; + goalLabelDiv.style.color = '#2ecc71'; + goalLabelDiv.textContent = `Goal met on ${daysMet} of ${stressValues.length} days (${percentMet}%)`; + timelineContainer.appendChild(goalLabelDiv); + // Store chart reference this.charts.push(timelineChart); } @@ -550,34 +699,93 @@ class ChartsManager { const hrvValues = hrvData.map(day => day.metrics['HRV']); const pulseValues = hrvData.map(day => day.metrics['Pulse']); + // --- Trendline and Goal for HRV and Resting Pulse --- + const { slope: hrvSlope, trendLine: hrvTrendLine } = this.getLinearTrend(hrvValues); + const { slope: pulseSlope, trendLine: pulseTrendLine } = this.getLinearTrend(pulseValues); + + // Default goals + const hrvGoal = 50; + const pulseGoal = 60; + const hrvGoalDataset = { + label: 'Goal (HRV 50ms)', + data: hrvValues.map(() => hrvGoal), + borderColor: "#2ecc71", + backgroundColor: "rgba(46, 204, 113, 0.08)", + fill: false, + borderDash: [2, 6], + pointRadius: 0, + tension: 0, + yAxisID: 'y', + order: 0 + }; + const pulseGoalDataset = { + label: 'Goal (Pulse 60bpm)', + data: pulseValues.map(() => pulseGoal), + borderColor: "#3498db", + backgroundColor: "rgba(52, 152, 219, 0.08)", + fill: false, + borderDash: [2, 6], + pointRadius: 0, + tension: 0, + yAxisID: 'y1', + order: 0 + }; + + // Compose datasets for HRV/Pulse chart + const hrvPulseDatasets = [ + { + label: 'HRV', + data: hrvValues, + borderColor: this.colors.primary, + backgroundColor: 'rgba(74, 111, 165, 0.1)', + fill: true, + yAxisID: 'y' + }, + { + label: 'HRV Trend', + data: hrvTrendLine, + borderColor: '#ff7e67', + backgroundColor: 'rgba(255, 126, 103, 0.08)', + fill: false, + borderDash: [8, 4], + pointRadius: 0, + tension: 0, + yAxisID: 'y' + }, + hrvGoalDataset, + { + label: 'Resting Pulse', + data: pulseValues, + borderColor: this.colors.accent, + backgroundColor: 'rgba(255, 126, 103, 0.1)', + fill: true, + yAxisID: 'y1' + }, + { + label: 'Pulse Trend', + data: pulseTrendLine, + borderColor: '#8e44ad', + backgroundColor: 'rgba(142, 68, 173, 0.08)', + fill: false, + borderDash: [8, 4], + pointRadius: 0, + tension: 0, + yAxisID: 'y1' + }, + pulseGoalDataset + ]; + // Create container for HRV/Pulse chart const hrvContainer = this.createChartContainer(wrapper); const canvas = document.createElement('canvas'); hrvContainer.appendChild(canvas); - + // Create chart const chart = new Chart(canvas, { type: 'line', data: { labels: dates, - datasets: [ - { - label: 'HRV', - data: hrvValues, - borderColor: this.colors.primary, - backgroundColor: 'rgba(74, 111, 165, 0.1)', - fill: true, - yAxisID: 'y' - }, - { - label: 'Resting Pulse', - data: pulseValues, - borderColor: this.colors.accent, - backgroundColor: 'rgba(255, 126, 103, 0.1)', - fill: true, - yAxisID: 'y1' - } - ] + datasets: hrvPulseDatasets }, options: { responsive: true, @@ -586,6 +794,9 @@ class ChartsManager { title: { display: true, text: 'HRV and Resting Pulse' + }, + legend: { + display: true } }, scales: { @@ -611,10 +822,55 @@ class ChartsManager { } } }); - + + // Add trend and goal progress labels below the chart + // HRV + const hrvTrendLabelDiv = document.createElement('div'); + hrvTrendLabelDiv.className = 'trend-label'; + hrvTrendLabelDiv.style.margin = '8px 0 0 0'; + hrvTrendLabelDiv.style.fontWeight = 'bold'; + hrvTrendLabelDiv.style.color = hrvSlope > 0.1 ? '#2ecc71' : (hrvSlope < -0.1 ? '#e74c3c' : '#3498db'); + hrvTrendLabelDiv.textContent = + hrvSlope > 0.1 + ? 'Improving HRV' + : (hrvSlope < -0.1 ? 'Declining HRV' : 'Stable HRV'); + hrvContainer.appendChild(hrvTrendLabelDiv); + + const hrvDaysMet = hrvValues.filter(v => v >= hrvGoal).length; + const hrvPercentMet = Math.round((hrvDaysMet / hrvValues.length) * 100); + const hrvGoalLabelDiv = document.createElement('div'); + hrvGoalLabelDiv.className = 'goal-label'; + hrvGoalLabelDiv.style.margin = '4px 0 0 0'; + hrvGoalLabelDiv.style.fontWeight = 'bold'; + hrvGoalLabelDiv.style.color = '#2ecc71'; + hrvGoalLabelDiv.textContent = `HRV Goal met on ${hrvDaysMet} of ${hrvValues.length} days (${hrvPercentMet}%)`; + hrvContainer.appendChild(hrvGoalLabelDiv); + + // Pulse + const pulseTrendLabelDiv = document.createElement('div'); + pulseTrendLabelDiv.className = 'trend-label'; + pulseTrendLabelDiv.style.margin = '8px 0 0 0'; + pulseTrendLabelDiv.style.fontWeight = 'bold'; + pulseTrendLabelDiv.style.color = pulseSlope < -0.1 ? '#2ecc71' : (pulseSlope > 0.1 ? '#e74c3c' : '#3498db'); + pulseTrendLabelDiv.textContent = + pulseSlope < -0.1 + ? 'Improving Resting Pulse' + : (pulseSlope > 0.1 ? 'Declining Resting Pulse' : 'Stable Resting Pulse'); + hrvContainer.appendChild(pulseTrendLabelDiv); + + const pulseDaysMet = pulseValues.filter(v => v <= pulseGoal).length; + const pulsePercentMet = Math.round((pulseDaysMet / pulseValues.length) * 100); + const pulseGoalLabelDiv = document.createElement('div'); + pulseGoalLabelDiv.className = 'goal-label'; + pulseGoalLabelDiv.style.margin = '4px 0 16px 0'; + pulseGoalLabelDiv.style.fontWeight = 'bold'; + pulseGoalLabelDiv.style.color = '#2ecc71'; + pulseGoalLabelDiv.textContent = `Pulse Goal met on ${pulseDaysMet} of ${pulseValues.length} days (${pulsePercentMet}%)`; + hrvContainer.appendChild(pulseGoalLabelDiv); + // Store chart reference this.charts.push(chart); - + // Create body battery chart if data is available const bodyBatteryData = metricsData.filter(day => day.metrics['Body Battery'] !== undefined @@ -629,23 +885,54 @@ class ChartsManager { return avgMatch ? parseInt(avgMatch[1]) : null; }).filter(val => val !== null); + // --- Trendline and Goal for Body Battery --- + const { slope: bbSlope, trendLine: bbTrendLine } = this.getLinearTrend(batteryValues); + const bbGoal = 80; + const bbGoalDataset = { + label: 'Goal (80)', + data: batteryValues.map(() => bbGoal), + borderColor: "#2ecc71", + backgroundColor: "rgba(46, 204, 113, 0.08)", + fill: false, + borderDash: [2, 6], + pointRadius: 0, + tension: 0, + order: 0 + }; + + // Compose datasets for Body Battery chart + const bbDatasets = [ + { + label: 'Body Battery (Average)', + data: batteryValues, + borderColor: this.colors.secondary, + backgroundColor: 'rgba(111, 185, 143, 0.1)', + fill: true + }, + { + label: 'Trend', + data: bbTrendLine, + borderColor: '#ff7e67', + backgroundColor: 'rgba(255, 126, 103, 0.08)', + fill: false, + borderDash: [8, 4], + pointRadius: 0, + tension: 0 + }, + bbGoalDataset + ]; + // Create container for body battery chart const bbContainer = this.createChartContainer(wrapper); const bbCanvas = document.createElement('canvas'); bbContainer.appendChild(bbCanvas); - + // Create chart const bbChart = new Chart(bbCanvas, { type: 'line', data: { labels: batteryDates, - datasets: [{ - label: 'Body Battery (Average)', - data: batteryValues, - borderColor: this.colors.secondary, - backgroundColor: 'rgba(111, 185, 143, 0.1)', - fill: true - }] + datasets: bbDatasets }, options: { responsive: true, @@ -654,6 +941,9 @@ class ChartsManager { title: { display: true, text: 'Body Battery Trend' + }, + legend: { + display: true } }, scales: { @@ -668,7 +958,29 @@ class ChartsManager { } } }); - + + // Add trend and goal progress labels below the chart + const bbTrendLabelDiv = document.createElement('div'); + bbTrendLabelDiv.className = 'trend-label'; + bbTrendLabelDiv.style.margin = '8px 0 0 0'; + bbTrendLabelDiv.style.fontWeight = 'bold'; + bbTrendLabelDiv.style.color = bbSlope > 0.1 ? '#2ecc71' : (bbSlope < -0.1 ? '#e74c3c' : '#3498db'); + bbTrendLabelDiv.textContent = + bbSlope > 0.1 + ? 'Improving Body Battery' + : (bbSlope < -0.1 ? 'Declining Body Battery' : 'Stable Body Battery'); + bbContainer.appendChild(bbTrendLabelDiv); + + const bbDaysMet = batteryValues.filter(v => v >= bbGoal).length; + const bbPercentMet = Math.round((bbDaysMet / batteryValues.length) * 100); + const bbGoalLabelDiv = document.createElement('div'); + bbGoalLabelDiv.className = 'goal-label'; + bbGoalLabelDiv.style.margin = '4px 0 16px 0'; + bbGoalLabelDiv.style.fontWeight = 'bold'; + bbGoalLabelDiv.style.color = '#2ecc71'; + bbGoalLabelDiv.textContent = `Goal met on ${bbDaysMet} of ${batteryValues.length} days (${bbPercentMet}%)`; + bbContainer.appendChild(bbGoalLabelDiv); + // Store chart reference this.charts.push(bbChart); }