Commit all changes
This commit is contained in:
+28
-3
@@ -170,8 +170,19 @@ p {
|
|||||||
margin-bottom: 20px;
|
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 {
|
.option-group {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +388,7 @@ textarea {
|
|||||||
.chart-container {
|
.chart-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 40px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -404,6 +415,11 @@ textarea {
|
|||||||
display: none;
|
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 Styles */
|
||||||
.pdf-export .chart-container {
|
.pdf-export .chart-container {
|
||||||
max-width: 500px; /* Optimal width for A4 page with margins */
|
max-width: 500px; /* Optimal width for A4 page with margins */
|
||||||
@@ -428,7 +444,16 @@ textarea {
|
|||||||
.options-container {
|
.options-container {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.options-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.advanced-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
flex-direction: row;
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
.report-header {
|
.report-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
+42
-33
@@ -48,40 +48,49 @@
|
|||||||
<h2>Customize Your Analysis</h2>
|
<h2>Customize Your Analysis</h2>
|
||||||
|
|
||||||
<div class="options-container">
|
<div class="options-container">
|
||||||
<div class="option-group">
|
<div class="options-row">
|
||||||
<h3>Analysis Focus</h3>
|
<div class="option-group">
|
||||||
<select id="analysis-focus" aria-label="Analysis Focus">
|
<h3>Analysis Focus</h3>
|
||||||
<option value="comprehensive">Comprehensive Analysis</option>
|
<select id="analysis-focus" aria-label="Analysis Focus">
|
||||||
<option value="sleep">Sleep Quality</option>
|
<option value="comprehensive">Comprehensive Analysis</option>
|
||||||
<option value="stress">Stress Management</option>
|
<option value="sleep">Sleep Quality</option>
|
||||||
<option value="workout">Workout Performance</option>
|
<option value="stress">Stress Management</option>
|
||||||
<option value="recovery">Recovery Patterns</option>
|
<option value="workout">Workout Performance</option>
|
||||||
</select>
|
<option value="recovery">Recovery Patterns</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<h3>Recommendation Style</h3>
|
||||||
|
<select id="recommendation-style" aria-label="Recommendation Style">
|
||||||
|
<option value="moderate">Moderate</option>
|
||||||
|
<option value="conservative">Conservative</option>
|
||||||
|
<option value="aggressive">Aggressive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="option-group">
|
||||||
|
<h3>Report Detail</h3>
|
||||||
|
<select id="report-detail" aria-label="Report Detail Level">
|
||||||
|
<option value="detailed">Detailed</option>
|
||||||
|
<option value="summary">Summary</option>
|
||||||
|
<option value="comprehensive">Comprehensive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="options-row advanced-row">
|
||||||
<div class="option-group">
|
<div class="option-group">
|
||||||
<h3>Recommendation Style</h3>
|
<h3>Advanced Options</h3>
|
||||||
<select id="recommendation-style" aria-label="Recommendation Style">
|
<div class="checkbox-option">
|
||||||
<option value="moderate">Moderate</option>
|
<input type="checkbox" id="show-gpt-input" aria-label="Show GPT-4.1 Input Data">
|
||||||
<option value="conservative">Conservative</option>
|
<label for="show-gpt-input">Show data sent to GPT-4.1</label>
|
||||||
<option value="aggressive">Aggressive</option>
|
</div>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
<div class="option-group">
|
||||||
|
<h3>Sleep Goal</h3>
|
||||||
<div class="option-group">
|
<label for="sleep-goal-input">Sleep Goal (hours/night):</label>
|
||||||
<h3>Report Detail</h3>
|
<input type="number" id="sleep-goal-input" min="0" step="0.1" placeholder="e.g. 8">
|
||||||
<select id="report-detail" aria-label="Report Detail Level">
|
<span class="tooltip">Set your target sleep duration per night</span>
|
||||||
<option value="detailed">Detailed</option>
|
|
||||||
<option value="summary">Summary</option>
|
|
||||||
<option value="comprehensive">Comprehensive</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option-group">
|
|
||||||
<h3>Advanced Options</h3>
|
|
||||||
<div class="checkbox-option">
|
|
||||||
<input type="checkbox" id="show-gpt-input" aria-label="Show GPT-4.1 Input Data">
|
|
||||||
<label for="show-gpt-input">Show data sent to GPT-4.1</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 = '<i class="fas fa-arrow-left"></i> 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);
|
||||||
|
};
|
||||||
@@ -28,6 +28,7 @@ class HealthAnalyticsApp {
|
|||||||
this.reportDetailSelect = document.getElementById('report-detail');
|
this.reportDetailSelect = document.getElementById('report-detail');
|
||||||
this.customPromptTextarea = document.getElementById('custom-prompt');
|
this.customPromptTextarea = document.getElementById('custom-prompt');
|
||||||
this.showGptInputCheckbox = document.getElementById('show-gpt-input');
|
this.showGptInputCheckbox = document.getElementById('show-gpt-input');
|
||||||
|
this.sleepGoalInput = document.getElementById('sleep-goal-input');
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
this.useSampleDataButton = document.getElementById('use-sample-data');
|
this.useSampleDataButton = document.getElementById('use-sample-data');
|
||||||
@@ -37,6 +38,12 @@ class HealthAnalyticsApp {
|
|||||||
|
|
||||||
// Report section
|
// Report section
|
||||||
this.reportSection = document.querySelector('.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.metricsFileInput.addEventListener('change', (e) => this.handleFileInputChange(e, this.metricsFilename));
|
||||||
this.workoutsFileInput.addEventListener('change', (e) => this.handleFileInputChange(e, this.workoutsFilename));
|
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
|
// Button click events
|
||||||
this.useSampleDataButton.addEventListener('click', () => this.handleUseSampleData());
|
this.useSampleDataButton.addEventListener('click', () => this.handleUseSampleData());
|
||||||
this.generateReportButton.addEventListener('click', () => this.handleGenerateReport());
|
this.generateReportButton.addEventListener('click', () => this.handleGenerateReport());
|
||||||
@@ -123,6 +140,14 @@ class HealthAnalyticsApp {
|
|||||||
|
|
||||||
// Get analysis options
|
// Get analysis options
|
||||||
const options = this.getAnalysisOptions();
|
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
|
// Prepare data for analysis
|
||||||
const data = dataProcessor.prepareDataForAnalysis();
|
const data = dataProcessor.prepareDataForAnalysis();
|
||||||
|
|||||||
+361
-49
@@ -88,6 +88,25 @@ class ChartsManager {
|
|||||||
return chartContainer;
|
return chartContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Calculate linear trend (slope, intercept, trend line) for a dataset
|
||||||
|
* @param {Array<number>} 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
|
* Create sleep patterns chart
|
||||||
* @param {Array} metricsData - Processed metrics data
|
* @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 deepSleep = sleepData.map(day => day.metrics['Time In Deep Sleep'] || 0);
|
||||||
const lightSleep = sleepData.map(day => day.metrics['Time In Light 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);
|
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
|
// Create container for stacked bar chart
|
||||||
const stackedContainer = this.createChartContainer(wrapper);
|
const stackedContainer = this.createChartContainer(wrapper);
|
||||||
@@ -187,18 +217,51 @@ class ChartsManager {
|
|||||||
const trendCanvas = document.createElement('canvas');
|
const trendCanvas = document.createElement('canvas');
|
||||||
trendContainer.appendChild(trendCanvas);
|
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, {
|
const trendChart = new Chart(trendCanvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: dates,
|
labels: dates,
|
||||||
datasets: [{
|
datasets: trendDatasets
|
||||||
label: 'Sleep Duration',
|
|
||||||
data: totalSleep,
|
|
||||||
borderColor: this.colors.sleep.total,
|
|
||||||
backgroundColor: 'rgba(74, 111, 165, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -207,6 +270,9 @@ class ChartsManager {
|
|||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Sleep Duration Trend'
|
text: 'Sleep Duration Trend'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
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
|
// Store chart reference
|
||||||
this.charts.push(trendChart);
|
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, {
|
const timelineChart = new Chart(timelineCanvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: dates,
|
labels: dates,
|
||||||
datasets: [{
|
datasets: timelineDatasets
|
||||||
label: 'Stress Level',
|
|
||||||
data: stressValues,
|
|
||||||
borderColor: this.colors.accent,
|
|
||||||
backgroundColor: 'rgba(255, 126, 103, 0.1)',
|
|
||||||
fill: true,
|
|
||||||
stepped: true
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -339,7 +460,6 @@ class ChartsManager {
|
|||||||
label: (context) => {
|
label: (context) => {
|
||||||
const value = context.raw;
|
const value = context.raw;
|
||||||
let label = '';
|
let label = '';
|
||||||
|
|
||||||
switch(value) {
|
switch(value) {
|
||||||
case 1: label = 'Calm'; break;
|
case 1: label = 'Calm'; break;
|
||||||
case 2: label = 'Balanced'; break;
|
case 2: label = 'Balanced'; break;
|
||||||
@@ -347,16 +467,22 @@ class ChartsManager {
|
|||||||
case 4: label = 'Very Stressful'; break;
|
case 4: label = 'Very Stressful'; break;
|
||||||
default: label = 'Unknown';
|
default: label = 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
min: 0.5,
|
min: 0.5,
|
||||||
max: 4.5,
|
max: 4.5,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Stress Level'
|
||||||
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: (value) => {
|
callback: (value) => {
|
||||||
switch(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
|
// Store chart reference
|
||||||
this.charts.push(timelineChart);
|
this.charts.push(timelineChart);
|
||||||
}
|
}
|
||||||
@@ -550,34 +699,93 @@ class ChartsManager {
|
|||||||
const hrvValues = hrvData.map(day => day.metrics['HRV']);
|
const hrvValues = hrvData.map(day => day.metrics['HRV']);
|
||||||
const pulseValues = hrvData.map(day => day.metrics['Pulse']);
|
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
|
// Create container for HRV/Pulse chart
|
||||||
const hrvContainer = this.createChartContainer(wrapper);
|
const hrvContainer = this.createChartContainer(wrapper);
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
hrvContainer.appendChild(canvas);
|
hrvContainer.appendChild(canvas);
|
||||||
|
|
||||||
// Create chart
|
// Create chart
|
||||||
const chart = new Chart(canvas, {
|
const chart = new Chart(canvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: dates,
|
labels: dates,
|
||||||
datasets: [
|
datasets: hrvPulseDatasets
|
||||||
{
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -586,6 +794,9 @@ class ChartsManager {
|
|||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'HRV and Resting Pulse'
|
text: 'HRV and Resting Pulse'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
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
|
// Store chart reference
|
||||||
this.charts.push(chart);
|
this.charts.push(chart);
|
||||||
|
|
||||||
// Create body battery chart if data is available
|
// Create body battery chart if data is available
|
||||||
const bodyBatteryData = metricsData.filter(day =>
|
const bodyBatteryData = metricsData.filter(day =>
|
||||||
day.metrics['Body Battery'] !== undefined
|
day.metrics['Body Battery'] !== undefined
|
||||||
@@ -629,23 +885,54 @@ class ChartsManager {
|
|||||||
return avgMatch ? parseInt(avgMatch[1]) : null;
|
return avgMatch ? parseInt(avgMatch[1]) : null;
|
||||||
}).filter(val => val !== 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
|
// Create container for body battery chart
|
||||||
const bbContainer = this.createChartContainer(wrapper);
|
const bbContainer = this.createChartContainer(wrapper);
|
||||||
const bbCanvas = document.createElement('canvas');
|
const bbCanvas = document.createElement('canvas');
|
||||||
bbContainer.appendChild(bbCanvas);
|
bbContainer.appendChild(bbCanvas);
|
||||||
|
|
||||||
// Create chart
|
// Create chart
|
||||||
const bbChart = new Chart(bbCanvas, {
|
const bbChart = new Chart(bbCanvas, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: batteryDates,
|
labels: batteryDates,
|
||||||
datasets: [{
|
datasets: bbDatasets
|
||||||
label: 'Body Battery (Average)',
|
|
||||||
data: batteryValues,
|
|
||||||
borderColor: this.colors.secondary,
|
|
||||||
backgroundColor: 'rgba(111, 185, 143, 0.1)',
|
|
||||||
fill: true
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -654,6 +941,9 @@ class ChartsManager {
|
|||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: 'Body Battery Trend'
|
text: 'Body Battery Trend'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
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
|
// Store chart reference
|
||||||
this.charts.push(bbChart);
|
this.charts.push(bbChart);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user