-
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);
}