Commit all changes

This commit is contained in:
2025-04-27 17:43:41 +02:00
parent 66884527f1
commit 1c5b38fade
5 changed files with 1071 additions and 85 deletions
+361 -49
View File
@@ -88,6 +88,25 @@ class ChartsManager {
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
* @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);
}