commit fc15f362cf488359059afe78da71dbce396ad236 Author: Heiko Joerg Schick Date: Sun Apr 27 14:31:03 2025 +0200 Initial commit diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..eb482ac --- /dev/null +++ b/css/styles.css @@ -0,0 +1,446 @@ +/* Health Analytics Dashboard Styles */ + +:root { + --primary-color: #4a6fa5; + --primary-dark: #3a5a8c; + --secondary-color: #6fb98f; + --accent-color: #ff7e67; + --light-bg: #f7f9fc; + --dark-text: #2c3e50; + --light-text: #ecf0f1; + --border-color: #dce1e8; + --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + --card-bg: #ffffff; + --error-color: #e74c3c; + --success-color: #2ecc71; + --warning-color: #f39c12; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: var(--dark-text); + background-color: var(--light-bg); +} + +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + margin-bottom: 30px; + padding: 20px 0; + border-bottom: 1px solid var(--border-color); +} + +header h1 { + color: var(--primary-color); + font-size: 2.5rem; +} + +header h1 i { + margin-right: 10px; + color: var(--accent-color); +} + +main { + display: grid; + gap: 30px; +} + +section { + background-color: var(--card-bg); + border-radius: 8px; + padding: 25px; + box-shadow: var(--shadow); +} + +h2 { + color: var(--primary-color); + margin-bottom: 15px; + font-size: 1.8rem; +} + +h3 { + color: var(--dark-text); + margin-bottom: 10px; + font-size: 1.2rem; +} + +p { + margin-bottom: 15px; +} + +/* File Upload Styles */ +.file-upload-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; +} + +.file-upload { + flex: 1; + min-width: 250px; +} + +.file-upload label { + display: block; + padding: 15px; + background-color: var(--primary-color); + color: var(--light-text); + text-align: center; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.3s; +} + +.file-upload label:hover { + background-color: var(--primary-dark); +} + +.file-upload input[type="file"] { + display: none; +} + +.file-upload span { + display: block; + margin-top: 8px; + font-size: 0.9rem; + color: var(--dark-text); + text-align: center; +} + +.use-sample-data { + display: flex; + align-items: center; + position: relative; +} + +.use-sample-data button { + background-color: var(--secondary-color); + color: var(--light-text); + border: none; + padding: 10px 15px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.3s; +} + +.use-sample-data button:hover { + background-color: #5da97c; +} + +.tooltip { + visibility: hidden; + background-color: var(--dark-text); + color: var(--light-text); + text-align: center; + border-radius: 6px; + padding: 5px 10px; + position: absolute; + z-index: 1; + left: 100%; + margin-left: 10px; + opacity: 0; + transition: opacity 0.3s; + font-size: 0.8rem; + width: 200px; +} + +.use-sample-data:hover .tooltip { + visibility: visible; + opacity: 1; +} + +/* Analysis Options Styles */ +.options-container { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; +} + +.option-group { + flex: 1; + min-width: 200px; +} + +select { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background-color: var(--light-bg); + color: var(--dark-text); + font-size: 1rem; +} + +.custom-prompt { + margin-bottom: 20px; +} + +textarea { + width: 100%; + height: 100px; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background-color: var(--light-bg); + color: var(--dark-text); + font-size: 1rem; + resize: vertical; +} + +.primary-button { + background-color: var(--primary-color); + color: var(--light-text); + border: none; + padding: 12px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 1.1rem; + transition: background-color 0.3s; + display: block; + width: 100%; +} + +.primary-button:hover { + background-color: var(--primary-dark); +} + +/* Checkbox Option Styles */ +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.checkbox-option input[type="checkbox"] { + margin-right: 10px; +} + +.checkbox-option label { + cursor: pointer; +} + +/* Raw Data Display Styles */ +.raw-data-section { + margin-top: 30px; + border-top: 1px solid var(--border-color); + padding-top: 20px; +} + +.raw-data-container { + margin-top: 10px; + padding: 15px; + background-color: var(--light-bg); + border-radius: 6px; + border: 1px solid var(--border-color); + overflow: auto; + max-height: 300px; +} + +.raw-data-container pre { + font-family: monospace; + font-size: 0.9rem; + white-space: pre-wrap; + margin: 0; +} + +.raw-data-toggle { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + cursor: pointer; + -webkit-user-select: none; + user-select: none; +} + +.raw-data-toggle h3 { + margin-bottom: 0; +} + +.toggle-icon { + font-size: 1.2rem; +} + +/* Report Section Styles */ +.report-section { + min-height: 400px; +} + +.report-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border-color); +} + +.report-actions { + display: flex; + gap: 10px; +} + +.report-actions button { + background-color: var(--light-bg); + color: var(--dark-text); + border: 1px solid var(--border-color); + padding: 8px 15px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.3s; + min-width: 140px; +} + +.report-actions button:hover { + background-color: var(--border-color); +} + +.report-actions button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.report-actions button .fa-spinner { + animation: spin 1s linear infinite; +} + +.loading-indicator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; +} + +.spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + width: 36px; + height: 36px; + border-radius: 50%; + border-left-color: var(--primary-color); + animation: spin 1s linear infinite; + margin-bottom: 15px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +#report-container { + line-height: 1.8; +} + +#report-container h3 { + margin-top: 20px; + margin-bottom: 10px; + color: var(--primary-color); +} + +#report-container ul { + margin-left: 20px; + margin-bottom: 15px; +} + +#report-container p { + margin-bottom: 15px; +} + +/* Chart Styles */ +.chart-section { + margin-top: 30px; + margin-bottom: 40px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 20px; +} + +.charts-wrapper { + display: flex; + flex-direction: column; + gap: 30px; +} + +.chart-container { + position: relative; + height: 300px; + margin-bottom: 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + background-color: var(--light-bg); +} + +.chart-container canvas { + width: 100%; + height: 100%; +} + +.no-data-message { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + background-color: var(--light-bg); + border-radius: 6px; + color: var(--dark-text); + font-style: italic; +} + +.hidden { + display: none; +} + +/* PDF Export Styles */ +.pdf-export .chart-container { + max-width: 500px; /* Optimal width for A4 page with margins */ + margin-left: auto; + margin-right: auto; +} + +.pdf-export .charts-wrapper { + width: 100%; + max-width: 500px; + margin-left: auto; + margin-right: auto; +} + +.pdf-export canvas { + max-width: 100%; +} + +/* Responsive Adjustments */ +@media (max-width: 768px) { + .file-upload-container, + .options-container { + flex-direction: column; + } + + .report-header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } +} + +footer { + text-align: center; + margin-top: 30px; + padding: 20px 0; + color: var(--dark-text); + font-size: 0.9rem; + border-top: 1px solid var(--border-color); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..3b4d3f8 --- /dev/null +++ b/index.html @@ -0,0 +1,129 @@ + + + + + + Health Analytics Dashboard + + + + + + +
+
+

Health Analytics Dashboard

+
+ +
+
+

Upload Your Health Data

+

Upload your metrics and workouts CSV files to get personalized health recommendations.

+ +
+
+ + No file selected +
+ +
+ + No file selected +
+
+ +
+ + Use pre-loaded sample data for demonstration +
+
+ +
+

Customize Your Analysis

+ +
+
+

Analysis Focus

+ +
+ +
+

Recommendation Style

+ +
+ +
+

Report Detail

+ +
+ +
+

Advanced Options

+
+ + +
+
+
+ +
+

Custom Instructions (Optional)

+ +
+ + +
+ + +
+ +
+

Health Analytics Dashboard | Powered by OpenAI GPT-4.1

+
+
+ + + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..1738917 --- /dev/null +++ b/js/app.js @@ -0,0 +1,322 @@ +/** + * Main Application Module + * Handles UI interactions and coordinates between modules + */ + +class HealthAnalyticsApp { + constructor() { + // Initialize UI elements + this.initializeUI(); + + // Add event listeners + this.addEventListeners(); + } + + /** + * Initialize UI elements + */ + initializeUI() { + // File upload elements + this.metricsFileInput = document.getElementById('metrics-file'); + this.workoutsFileInput = document.getElementById('workouts-file'); + this.metricsFilename = document.getElementById('metrics-filename'); + this.workoutsFilename = document.getElementById('workouts-filename'); + + // Analysis options elements + this.analysisFocusSelect = document.getElementById('analysis-focus'); + this.recommendationStyleSelect = document.getElementById('recommendation-style'); + this.reportDetailSelect = document.getElementById('report-detail'); + this.customPromptTextarea = document.getElementById('custom-prompt'); + this.showGptInputCheckbox = document.getElementById('show-gpt-input'); + + // Buttons + this.useSampleDataButton = document.getElementById('use-sample-data'); + this.generateReportButton = document.getElementById('generate-report'); + this.downloadReportButton = document.getElementById('download-report'); + this.newAnalysisButton = document.getElementById('new-analysis'); + + // Report section + this.reportSection = document.querySelector('.report-section'); + } + + /** + * Add event listeners to UI elements + */ + addEventListeners() { + // File input change events + this.metricsFileInput.addEventListener('change', (e) => this.handleFileInputChange(e, this.metricsFilename)); + this.workoutsFileInput.addEventListener('change', (e) => this.handleFileInputChange(e, this.workoutsFilename)); + + // Button click events + this.useSampleDataButton.addEventListener('click', () => this.handleUseSampleData()); + this.generateReportButton.addEventListener('click', () => this.handleGenerateReport()); + this.downloadReportButton.addEventListener('click', () => this.handleDownloadReport()); + this.newAnalysisButton.addEventListener('click', () => this.handleNewAnalysis()); + } + + /** + * Handle file input change + * @param {Event} event - Change event + * @param {HTMLElement} filenameElement - Element to display filename + */ + handleFileInputChange(event, filenameElement) { + const file = event.target.files[0]; + if (file) { + filenameElement.textContent = file.name; + } else { + filenameElement.textContent = 'No file selected'; + } + } + + /** + * Handle use sample data button click + */ + async handleUseSampleData() { + try { + // Disable the button during loading + this.useSampleDataButton.disabled = true; + this.useSampleDataButton.textContent = 'Loading...'; + + // Load sample data + await dataProcessor.loadSampleData(); + + // Update UI to show sample data is loaded + this.metricsFilename.textContent = 'Sample metrics.csv loaded'; + this.workoutsFilename.textContent = 'Sample workouts.csv loaded'; + + // Show success message + this.showNotification('Sample data loaded successfully!', 'success'); + } catch (error) { + console.error('Error loading sample data:', error); + this.showNotification('Error loading sample data: ' + error.message, 'error'); + } finally { + // Re-enable the button + this.useSampleDataButton.disabled = false; + this.useSampleDataButton.textContent = 'Use Sample Data'; + } + } + + /** + * Handle generate report button click + */ + async handleGenerateReport() { + try { + // Check if files are selected but not processed + if ((this.metricsFileInput.files.length > 0 || this.workoutsFileInput.files.length > 0) && + (!dataProcessor.metricsData || !dataProcessor.workoutsData)) { + // Process the uploaded files + await this.processUploadedFiles(); + } + // Check if data is loaded + else if (!this.isDataLoaded()) { + // If no files are selected, try to use sample data + if (this.metricsFilename.textContent === 'No file selected' && + this.workoutsFilename.textContent === 'No file selected') { + await this.handleUseSampleData(); + } else { + throw new Error('Please upload both metrics and workouts files or use sample data.'); + } + } + + // Disable the button during generation + this.generateReportButton.disabled = true; + + // Get analysis options + const options = this.getAnalysisOptions(); + + // Prepare data for analysis + const data = dataProcessor.prepareDataForAnalysis(); + + // Generate the report + await reportGenerator.generateReport(data, options); + + } catch (error) { + console.error('Error generating report:', error); + this.showNotification('Error generating report: ' + error.message, 'error'); + } finally { + // Re-enable the button + this.generateReportButton.disabled = false; + } + } + + /** + * Handle download report button click + */ + async handleDownloadReport() { + try { + await reportGenerator.generatePDF(); + } catch (error) { + console.error('Error downloading report:', error); + this.showNotification('Error downloading report: ' + error.message, 'error'); + } + } + + /** + * Handle new analysis button click + */ + handleNewAnalysis() { + // Reset the report view + reportGenerator.resetReport(); + + // Scroll to the top of the page + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + /** + * Check if data is loaded + * @returns {boolean} - Whether data is loaded + */ + isDataLoaded() { + return dataProcessor.metricsData !== null && dataProcessor.workoutsData !== null; + } + + /** + * Get analysis options from UI + * @returns {Object} - Analysis options + */ + getAnalysisOptions() { + // Get API key from local storage or prompt + let apiKey = localStorage.getItem('openai_api_key'); + + // If no API key is stored, prompt the user + if (!apiKey) { + apiKey = this.promptForAPIKey(); + } + + return { + analysisFocus: this.analysisFocusSelect.value, + recommendationStyle: this.recommendationStyleSelect.value, + reportDetail: this.reportDetailSelect.value, + customPrompt: this.customPromptTextarea.value, + showGptInput: this.showGptInputCheckbox.checked, + apiKey: apiKey + }; + } + + /** + * Prompt user for OpenAI API key + * @returns {string} - API key + */ + promptForAPIKey() { + // In a real application, you would use a modal dialog + // For this demo, we'll use a simple prompt + const apiKey = prompt( + 'Enter your OpenAI API key to generate personalized recommendations.\n' + + 'If you don\'t have an API key, leave this blank to use demo mode.', + '' + ); + + // Ask if the user wants to save the API key + if (apiKey && apiKey.trim()) { + const saveKey = confirm('Would you like to save this API key for future use?'); + if (saveKey) { + localStorage.setItem('openai_api_key', apiKey); + } + } + + return apiKey || ''; + } + + /** + * Process uploaded files + * @returns {Promise} - Promise resolving when files are processed + */ + async processUploadedFiles() { + const metricsFile = this.metricsFileInput.files[0]; + const workoutsFile = this.workoutsFileInput.files[0]; + + if (!metricsFile || !workoutsFile) { + throw new Error('Please upload both metrics and workouts files.'); + } + + return await dataProcessor.processFiles(metricsFile, workoutsFile); + } + + /** + * Show notification message + * @param {string} message - Message to display + * @param {string} type - Notification type (success, error, warning) + */ + showNotification(message, type = 'info') { + // Create notification element + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.textContent = message; + + // Add close button + const closeButton = document.createElement('button'); + closeButton.className = 'notification-close'; + closeButton.innerHTML = '×'; + closeButton.addEventListener('click', () => { + document.body.removeChild(notification); + }); + + notification.appendChild(closeButton); + + // Add to document + document.body.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (document.body.contains(notification)) { + document.body.removeChild(notification); + } + }, 5000); + } +} + +// Initialize the application when the DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + // Create notification styles dynamically + const notificationStyles = document.createElement('style'); + notificationStyles.textContent = ` + .notification { + position: fixed; + top: 20px; + right: 20px; + padding: 15px 20px; + border-radius: 6px; + color: white; + max-width: 300px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + z-index: 1000; + animation: slideIn 0.3s ease-out; + } + + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + .notification.success { + background-color: #2ecc71; + } + + .notification.error { + background-color: #e74c3c; + } + + .notification.warning { + background-color: #f39c12; + } + + .notification.info { + background-color: #3498db; + } + + .notification-close { + background: none; + border: none; + color: white; + font-size: 20px; + cursor: pointer; + position: absolute; + top: 5px; + right: 10px; + } + `; + document.head.appendChild(notificationStyles); + + // Initialize the app + window.app = new HealthAnalyticsApp(); +}); diff --git a/js/charts.js b/js/charts.js new file mode 100644 index 0000000..279fa4d --- /dev/null +++ b/js/charts.js @@ -0,0 +1,700 @@ +/** + * Charts Module + * Handles the creation and rendering of data visualizations for health reports + */ + +class ChartsManager { + constructor() { + // Store references to created charts for cleanup + this.charts = []; + + // Define chart colors + this.colors = { + primary: '#4a6fa5', + secondary: '#6fb98f', + accent: '#ff7e67', + dark: '#2c3e50', + light: '#ecf0f1', + sleep: { + total: '#4a6fa5', + deep: '#2c3e50', + light: '#95a5a6', + rem: '#3498db', + awake: '#e74c3c' + }, + stress: { + calm: '#2ecc71', + balanced: '#3498db', + stressful: '#f39c12', + veryStressful: '#e74c3c' + } + }; + } + + /** + * Create all charts for the report + * @param {Object} data - Processed health and workout data + * @param {HTMLElement} container - Container element for the charts + */ + createCharts(data, container) { + // Clear any existing charts + this.clearCharts(); + + // Create chart sections + const sleepSection = this.createChartSection('Sleep Patterns', container); + const stressSection = this.createChartSection('Stress Levels', container); + const workoutSection = this.createChartSection('Workout Analysis', container); + const hrvSection = this.createChartSection('Recovery Metrics', container); + + // Create charts + this.createSleepChart(data.metrics, sleepSection); + this.createStressChart(data.metrics, stressSection); + this.createWorkoutChart(data.workouts, workoutSection); + this.createHRVChart(data.metrics, hrvSection); + } + + /** + * Create a section for a chart + * @param {string} title - Section title + * @param {HTMLElement} container - Parent container + * @returns {HTMLElement} - The charts wrapper element + */ + createChartSection(title, container) { + const section = document.createElement('div'); + section.className = 'chart-section'; + + const heading = document.createElement('h3'); + heading.textContent = title; + section.appendChild(heading); + + // Create a wrapper for all charts in this section + const chartsWrapper = document.createElement('div'); + chartsWrapper.className = 'charts-wrapper'; + section.appendChild(chartsWrapper); + + container.appendChild(section); + return chartsWrapper; + } + + /** + * Create an individual chart container + * @param {HTMLElement} wrapper - Parent wrapper element + * @returns {HTMLElement} - The chart container element + */ + createChartContainer(wrapper) { + const chartContainer = document.createElement('div'); + chartContainer.className = 'chart-container'; + wrapper.appendChild(chartContainer); + return chartContainer; + } + + /** + * Create sleep patterns chart + * @param {Array} metricsData - Processed metrics data + * @param {HTMLElement} wrapper - Wrapper for the chart containers + */ + createSleepChart(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'); + return; + } + + // Prepare data for chart + const dates = sleepData.map(day => day.date); + const totalSleep = sleepData.map(day => day.metrics['Sleep Hours']); + 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); + + // Create container for stacked bar chart + const stackedContainer = this.createChartContainer(wrapper); + const canvas = document.createElement('canvas'); + stackedContainer.appendChild(canvas); + + // Create chart + const chart = new Chart(canvas, { + type: 'bar', + data: { + labels: dates, + datasets: [ + { + label: 'Deep Sleep', + data: deepSleep, + backgroundColor: this.colors.sleep.deep, + stack: 'sleep' + }, + { + label: 'Light Sleep', + data: lightSleep, + backgroundColor: this.colors.sleep.light, + stack: 'sleep' + }, + { + label: 'REM Sleep', + data: remSleep, + backgroundColor: this.colors.sleep.rem, + stack: 'sleep' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Sleep Composition by Night' + }, + tooltip: { + mode: 'index', + callbacks: { + afterTitle: (items) => { + const index = items[0].dataIndex; + return `Total: ${totalSleep[index]} hours`; + } + } + } + }, + scales: { + x: { + stacked: true, + title: { + display: true, + text: 'Date' + } + }, + y: { + stacked: true, + title: { + display: true, + text: 'Hours' + } + } + } + } + }); + + // Store chart reference + this.charts.push(chart); + + // Create container for trend line chart + const trendContainer = this.createChartContainer(wrapper); + const trendCanvas = document.createElement('canvas'); + trendContainer.appendChild(trendCanvas); + + 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 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Sleep Duration Trend' + } + }, + scales: { + y: { + title: { + display: true, + text: 'Hours' + }, + min: Math.max(0, Math.min(...totalSleep) - 1), + max: Math.max(...totalSleep) + 1 + } + } + } + }); + + // Store chart reference + this.charts.push(trendChart); + } + + /** + * Create stress levels chart + * @param {Array} metricsData - Processed metrics data + * @param {HTMLElement} wrapper - Wrapper for the chart containers + */ + createStressChart(metricsData, wrapper) { + // Filter out days without stress data + const stressData = metricsData.filter(day => + day.metrics['Stress Qualifier'] !== undefined + ); + + if (stressData.length === 0) { + this.showNoDataMessage(wrapper, 'No stress data available'); + return; + } + + // Prepare data for chart + const dates = stressData.map(day => day.date); + const stressQualifiers = stressData.map(day => day.metrics['Stress Qualifier']); + + // Count occurrences of each stress level + const stressLevels = ['calm', 'balanced', 'stressful', 'very_stressful']; + const stressCounts = {}; + + stressLevels.forEach(level => { + stressCounts[level] = stressQualifiers.filter(q => q === level).length; + }); + + // Create container for pie chart + const pieContainer = this.createChartContainer(wrapper); + const pieCanvas = document.createElement('canvas'); + pieContainer.appendChild(pieCanvas); + + // Create pie chart + const pieChart = new Chart(pieCanvas, { + type: 'doughnut', + data: { + labels: ['Calm', 'Balanced', 'Stressful', 'Very Stressful'], + datasets: [{ + data: [ + stressCounts.calm || 0, + stressCounts.balanced || 0, + stressCounts.stressful || 0, + stressCounts.very_stressful || 0 + ], + backgroundColor: [ + this.colors.stress.calm, + this.colors.stress.balanced, + this.colors.stress.stressful, + this.colors.stress.veryStressful + ] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Stress Level Distribution' + }, + legend: { + position: 'right' + } + } + } + }); + + // Store chart reference + this.charts.push(pieChart); + + // Create container for timeline chart + const timelineContainer = this.createChartContainer(wrapper); + const timelineCanvas = document.createElement('canvas'); + timelineContainer.appendChild(timelineCanvas); + + // Map stress qualifiers to numeric values for the chart + const stressValues = stressQualifiers.map(qualifier => { + switch(qualifier) { + case 'calm': return 1; + case 'balanced': return 2; + case 'stressful': return 3; + case 'very_stressful': return 4; + default: return 0; + } + }); + + 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 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Stress Level Timeline' + }, + tooltip: { + callbacks: { + label: (context) => { + const value = context.raw; + let label = ''; + + switch(value) { + case 1: label = 'Calm'; break; + case 2: label = 'Balanced'; break; + case 3: label = 'Stressful'; break; + case 4: label = 'Very Stressful'; break; + default: label = 'Unknown'; + } + + return label; + } + } + } + }, + scales: { + y: { + min: 0.5, + max: 4.5, + ticks: { + callback: (value) => { + switch(value) { + case 1: return 'Calm'; + case 2: return 'Balanced'; + case 3: return 'Stressful'; + case 4: return 'Very Stressful'; + default: return ''; + } + } + } + } + } + } + }); + + // Store chart reference + this.charts.push(timelineChart); + } + + /** + * Create workout analysis chart + * @param {Array} workoutsData - Processed workouts data + * @param {HTMLElement} wrapper - Wrapper for the chart containers + */ + createWorkoutChart(workoutsData, wrapper) { + if (workoutsData.length === 0) { + this.showNoDataMessage(wrapper, 'No workout data available'); + return; + } + + // Prepare data for chart + const dates = workoutsData.map(workout => workout.date); + const heartRates = workoutsData.map(workout => workout.heartRate.average); + const durations = workoutsData.map(workout => workout.duration * 60); // Convert to minutes + + // Create container for bar/line chart + const barContainer = this.createChartContainer(wrapper); + const canvas = document.createElement('canvas'); + barContainer.appendChild(canvas); + + // Create chart + const chart = new Chart(canvas, { + type: 'bar', + data: { + labels: dates, + datasets: [ + { + label: 'Duration (minutes)', + data: durations, + backgroundColor: this.colors.secondary, + yAxisID: 'y', + order: 1 // Draw bars first (underneath) + }, + { + label: 'Avg Heart Rate (bpm)', + data: heartRates, + borderColor: this.colors.accent, + backgroundColor: 'transparent', // No fill + borderWidth: 3, // Thicker line + pointRadius: 4, // Visible points + pointBackgroundColor: this.colors.accent, + pointBorderColor: '#fff', + pointHoverRadius: 6, + pointHoverBackgroundColor: this.colors.accent, + tension: 0.3, // Slightly curved line + type: 'line', + yAxisID: 'y1', + order: 0 // Draw line last (on top) + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + title: { + display: true, + text: 'Workout Duration and Intensity' + }, + tooltip: { + callbacks: { + title: (tooltipItems) => { + return `Date: ${tooltipItems[0].label}`; + } + } + } + }, + scales: { + y: { + type: 'linear', + position: 'left', + title: { + display: true, + text: 'Duration (minutes)' + }, + min: 0 + }, + y1: { + type: 'linear', + position: 'right', + title: { + display: true, + text: 'Heart Rate (bpm)' + }, + min: Math.max(0, Math.min(...heartRates) - 10), + max: Math.max(...heartRates) + 10, + grid: { + drawOnChartArea: false + } + } + } + } + }); + + // Store chart reference + this.charts.push(chart); + + // Create workout type distribution chart if we have type data + if (workoutsData.some(workout => workout.type)) { + // Count workout types + const workoutTypes = {}; + workoutsData.forEach(workout => { + const type = workout.type || 'Unknown'; + workoutTypes[type] = (workoutTypes[type] || 0) + 1; + }); + + // Create container for pie chart + const pieContainer = this.createChartContainer(wrapper); + const pieCanvas = document.createElement('canvas'); + pieContainer.appendChild(pieCanvas); + + // Create color array based on number of workout types + const typeColors = Object.keys(workoutTypes).map((_, index) => { + const hue = (index * 137) % 360; // Golden angle approximation for good distribution + return `hsl(${hue}, 70%, 60%)`; + }); + + // Create pie chart + const pieChart = new Chart(pieCanvas, { + type: 'pie', + data: { + labels: Object.keys(workoutTypes), + datasets: [{ + data: Object.values(workoutTypes), + backgroundColor: typeColors + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Workout Type Distribution' + }, + legend: { + position: 'right' + } + } + } + }); + + // Store chart reference + this.charts.push(pieChart); + } + } + + /** + * Create HRV and recovery metrics chart + * @param {Array} metricsData - Processed metrics data + * @param {HTMLElement} wrapper - Wrapper for the chart containers + */ + createHRVChart(metricsData, wrapper) { + // Filter out days without HRV data + const hrvData = metricsData.filter(day => + day.metrics['HRV'] !== undefined + ); + + if (hrvData.length === 0) { + this.showNoDataMessage(wrapper, 'No HRV data available'); + return; + } + + // Prepare data for chart + const dates = hrvData.map(day => day.date); + const hrvValues = hrvData.map(day => day.metrics['HRV']); + const pulseValues = hrvData.map(day => day.metrics['Pulse']); + + // 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' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'HRV and Resting Pulse' + } + }, + scales: { + y: { + type: 'linear', + position: 'left', + title: { + display: true, + text: 'HRV (ms)' + } + }, + y1: { + type: 'linear', + position: 'right', + title: { + display: true, + text: 'Pulse (bpm)' + }, + grid: { + drawOnChartArea: false + } + } + } + } + }); + + // 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 + ); + + if (bodyBatteryData.length > 0) { + // Parse body battery values (format: "Min : X / Max : Y / Avg : Z") + const batteryDates = bodyBatteryData.map(day => day.date); + const batteryValues = bodyBatteryData.map(day => { + const batteryString = day.metrics['Body Battery']; + const avgMatch = batteryString.match(/Avg\s*:\s*(\d+)/); + return avgMatch ? parseInt(avgMatch[1]) : null; + }).filter(val => val !== null); + + // 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 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Body Battery Trend' + } + }, + scales: { + y: { + min: 0, + max: 100, + title: { + display: true, + text: 'Energy Level' + } + } + } + } + }); + + // Store chart reference + this.charts.push(bbChart); + } + } + + /** + * Show a message when no data is available for a chart + * @param {HTMLElement} container - Container element + * @param {string} message - Message to display + */ + showNoDataMessage(container, message) { + const messageElement = document.createElement('div'); + messageElement.className = 'no-data-message'; + messageElement.textContent = message; + container.appendChild(messageElement); + } + + /** + * Clear all charts + */ + clearCharts() { + // Destroy all chart instances + this.charts.forEach(chart => chart.destroy()); + this.charts = []; + } +} + +// Create a global instance +const chartsManager = new ChartsManager(); diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..d2f2af1 --- /dev/null +++ b/js/config.js @@ -0,0 +1,128 @@ +/** + * Health Analytics Dashboard Configuration + * Contains settings for OpenAI API, system prompts, and application configuration + */ + +const CONFIG = { + // OpenAI API Configuration + openai: { + model: "gpt-4.1", // Using GPT-4.1 model + temperature: 0.7, + max_tokens: 4000, + // Note: API key should be provided by the user or stored securely + // This is a placeholder for demonstration purposes + apiKeyPlaceholder: "YOUR_OPENAI_API_KEY" + }, + + // System Prompts for different analysis types + systemPrompts: { + // Base system prompt that defines the AI's role and capabilities + base: `You are an expert health and fitness analyst with deep knowledge in exercise physiology, +sleep science, stress management, and recovery optimization. Your task is to analyze health metrics +and workout data to provide personalized recommendations. + +You should maintain a professional, encouraging tone while providing evidence-based insights. +Focus on actionable recommendations that are realistic and tailored to the data provided. +Identify patterns, correlations, and potential areas of improvement. +Highlight both strengths and areas that need attention. + +Your analysis should be structured, clear, and easy to understand for someone without medical expertise.`, + + // Comprehensive analysis prompt + comprehensive: `Conduct a comprehensive analysis of all provided health and fitness data. +Examine sleep patterns, stress levels, workout performance, and recovery indicators. +Identify correlations between different metrics (e.g., how sleep quality affects workout performance). +Provide a holistic set of recommendations addressing all aspects of health and fitness. +Include short-term actions and long-term strategies for improvement.`, + + // Sleep-focused analysis prompt + sleep: `Focus your analysis on sleep patterns and quality. +Evaluate total sleep duration, sleep cycle distribution (deep, light, REM), and consistency. +Identify potential sleep disruptors based on other metrics (stress, exercise timing, etc.). +Provide specific recommendations to improve sleep quality and optimize sleep cycles. +Suggest optimal sleep schedules and pre-sleep routines based on the data patterns.`, + + // Stress management analysis prompt + stress: `Concentrate your analysis on stress levels and management. +Evaluate stress patterns, peak stress periods, and potential triggers. +Analyze the relationship between stress and other health metrics (sleep, recovery, etc.). +Provide targeted recommendations for stress reduction and resilience building. +Suggest specific techniques for managing high-stress periods identified in the data.`, + + // Workout performance analysis prompt + workout: `Focus your analysis on workout performance and training optimization. +Evaluate workout intensity, frequency, and patterns across the data period. +Analyze performance metrics in relation to recovery indicators and sleep quality. +Provide recommendations for optimizing training schedule, intensity, and recovery. +Suggest potential adjustments to improve performance and prevent overtraining.`, + + // Recovery patterns analysis prompt + recovery: `Concentrate your analysis on recovery patterns and optimization. +Evaluate body battery trends, HRV patterns, and recovery indicators. +Analyze the relationship between workouts, sleep quality, and recovery metrics. +Provide specific recommendations for enhancing recovery between workouts. +Suggest recovery strategies tailored to the individual's patterns and needs.` + }, + + // Recommendation style configurations + recommendationStyles: { + conservative: { + description: "Provides cautious, gradual recommendations with minimal changes to current patterns", + prompt: `Provide conservative recommendations that involve minimal changes to current routines. +Focus on small, incremental adjustments that are easy to implement. +Prioritize consistency and sustainability over dramatic changes. +Suggest gradual progression paths rather than significant overhauls.` + }, + + moderate: { + description: "Balanced approach with practical, evidence-based recommendations", + prompt: `Provide balanced recommendations that are practical and evidence-based. +Suggest moderate adjustments to current patterns where beneficial. +Balance ambitious goals with realistic implementation considerations. +Focus on sustainable changes that can be maintained long-term.` + }, + + aggressive: { + description: "Bold, ambitious recommendations for maximum results", + prompt: `Provide ambitious recommendations aimed at achieving maximum results. +Suggest significant adjustments where the data indicates potential for improvement. +Focus on optimal practices rather than minimal effective doses. +Present challenging but achievable targets based on the data patterns.` + } + }, + + // Report detail level configurations + reportDetailLevels: { + summary: { + description: "Brief overview with key insights and core recommendations", + prompt: `Provide a concise summary of the most important insights and recommendations. +Limit your analysis to 3-5 key observations and their implications. +Focus only on the highest-priority recommendations. +Use bullet points and brief explanations rather than detailed analysis.` + }, + + detailed: { + description: "Comprehensive analysis with detailed recommendations", + prompt: `Provide a detailed analysis covering all relevant aspects of the data. +Include specific observations with supporting evidence from the data. +Offer comprehensive recommendations with implementation guidance. +Explain the reasoning behind your recommendations.` + }, + + comprehensive: { + description: "In-depth analysis with extensive recommendations and implementation plans", + prompt: `Provide an exhaustive analysis of all data points and their interrelationships. +Include detailed observations with specific references to the data. +Offer extensive recommendations with step-by-step implementation guidance. +Explain the scientific rationale behind each recommendation. +Include potential challenges and how to overcome them. +Provide both short-term actions and long-term strategies.` + } + }, + + // Sample data for demonstration purposes + sampleData: { + metricsPath: "metrics.csv", + workoutsPath: "workouts.csv" + } +}; diff --git a/js/data-processor.js b/js/data-processor.js new file mode 100644 index 0000000..d7726d8 --- /dev/null +++ b/js/data-processor.js @@ -0,0 +1,462 @@ +/** + * Data Processor Module + * Handles parsing and processing of CSV data for health metrics and workouts + */ + +class DataProcessor { + constructor() { + this.metricsData = null; + this.workoutsData = null; + } + + /** + * Parse CSV string into array of objects + * @param {string} csvString - Raw CSV data as string + * @returns {Array} - Array of objects with headers as keys + */ + parseCSV(csvString) { + // Split the CSV string into lines + const lines = csvString.split('\n'); + + // Extract headers (first line) and remove quotes and any extra characters + const headers = lines[0].split(',').map(header => { + // Clean up header - remove quotes, carriage returns, etc. + return header.replace(/^"(.*)"$/, '$1').replace(/\r$/, ''); + }); + + // Process each data line + const results = []; + for (let i = 1; i < lines.length; i++) { + // Skip empty lines + if (!lines[i].trim()) continue; + + // Split the line into values, handling quoted values with commas + const values = this.splitCSVLine(lines[i]); + + // Create an object with headers as keys and values + const entry = {}; + headers.forEach((header, index) => { + // Remove quotes if present and convert to appropriate type + let value = values[index] ? values[index].replace(/^"(.*)"$/, '$1').replace(/\r$/, '') : ''; + + // Try to convert to number if it looks like one + if (!isNaN(value) && value.trim() !== '') { + value = parseFloat(value); + } + + entry[header] = value; + }); + + results.push(entry); + } + + return results; + } + + /** + * Split CSV line handling quoted values with commas + * @param {string} line - CSV line to split + * @returns {Array} - Array of values + */ + splitCSVLine(line) { + const values = []; + let inQuotes = false; + let currentValue = ''; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + values.push(currentValue); + currentValue = ''; + } else { + currentValue += char; + } + } + + // Add the last value + values.push(currentValue); + + return values; + } + + /** + * Process metrics data from CSV + * @param {string} csvString - Raw CSV data as string + * @returns {Object} - Processed metrics data + */ + processMetricsData(csvString) { + const rawData = this.parseCSV(csvString); + + // Log the first few entries to verify structure + console.log('First few entries from metrics CSV:', rawData.slice(0, 5)); + + // Group metrics by date + const metricsByDate = {}; + + rawData.forEach(entry => { + // Extract date from timestamp (format: "2025-04-14 00:00:00") + const date = entry.Timestamp.split(' ')[0]; + + if (!metricsByDate[date]) { + metricsByDate[date] = {}; + } + + // Add metric to the date group + // Ensure Type is trimmed to handle any extra spaces + const metricType = entry.Type.trim(); + + // Get the value from the correct column (might be '"Value"' instead of 'Value') + let metricValue = null; + if (entry.Value !== undefined) { + metricValue = entry.Value; + } else if (entry['"Value"'] !== undefined) { + metricValue = entry['"Value"']; + } + + // Convert Value to number if it's numeric + if (metricValue !== null && !isNaN(metricValue) && metricValue.toString().trim() !== '') { + metricValue = parseFloat(metricValue); + } + + metricsByDate[date][metricType] = metricValue; + }); + + // Log the metrics by date to verify structure + console.log('Sample of metrics by date:', Object.keys(metricsByDate).slice(0, 3).map(date => ({ + date, + metrics: metricsByDate[date] + }))); + + // Convert to array and sort by date + const processedData = Object.keys(metricsByDate).map(date => { + return { + date, + metrics: metricsByDate[date] + }; + }).sort((a, b) => new Date(a.date) - new Date(b.date)); + + this.metricsData = processedData; + return processedData; + } + + /** + * Process workouts data from CSV + * @param {string} csvString - Raw CSV data as string + * @returns {Array} - Processed workouts data + */ + processWorkoutsData(csvString) { + const rawData = this.parseCSV(csvString); + + // Process each workout entry + const processedData = rawData.map(workout => { + // Convert workout day to date object for easier comparison + const workoutDate = new Date(workout.WorkoutDay); + + return { + date: workout.WorkoutDay, + type: workout.WorkoutType, + title: workout.Title, + distance: workout.DistanceInMeters, + duration: workout.TimeTotalInHours, + heartRate: { + average: workout.HeartRateAverage, + max: workout.HeartRateMax + }, + power: { + average: workout.PowerAverage, + max: workout.PowerMax + }, + energy: workout.Energy, + zones: { + heartRate: this.extractHeartRateZones(workout), + power: this.extractPowerZones(workout) + }, + rpe: workout.Rpe, + feeling: workout.Feeling, + cadence: { + average: workout.CadenceAverage, + max: workout.CadenceMax + }, + velocity: { + average: workout.VelocityAverage, + max: workout.VelocityMax + }, + comments: { + athlete: workout.AthleteComments, + coach: workout.CoachComments + } + }; + }).sort((a, b) => new Date(a.date) - new Date(b.date)); + + this.workoutsData = processedData; + return processedData; + } + + /** + * Extract heart rate zone data from workout entry + * @param {Object} workout - Workout data entry + * @returns {Array} - Heart rate zone minutes + */ + extractHeartRateZones(workout) { + const zones = []; + for (let i = 1; i <= 10; i++) { + const zoneKey = `HRZone${i}Minutes`; + if (workout[zoneKey] !== undefined && workout[zoneKey] !== '') { + zones.push({ + zone: i, + minutes: parseFloat(workout[zoneKey]) + }); + } + } + return zones; + } + + /** + * Extract power zone data from workout entry + * @param {Object} workout - Workout data entry + * @returns {Array} - Power zone minutes + */ + extractPowerZones(workout) { + const zones = []; + for (let i = 1; i <= 10; i++) { + const zoneKey = `PWRZone${i}Minutes`; + if (workout[zoneKey] !== undefined && workout[zoneKey] !== '') { + zones.push({ + zone: i, + minutes: parseFloat(workout[zoneKey]) + }); + } + } + return zones; + } + + /** + * Load sample data from files + * @returns {Promise} - Promise resolving when data is loaded + */ + async loadSampleData() { + try { + // Load metrics data using XMLHttpRequest to avoid CORS issues with file:// URLs + const metricsCSV = await this.loadFileWithXHR(CONFIG.sampleData.metricsPath); + this.processMetricsData(metricsCSV); + + // Load workouts data + const workoutsCSV = await this.loadFileWithXHR(CONFIG.sampleData.workoutsPath); + this.processWorkoutsData(workoutsCSV); + + return { + metrics: this.metricsData, + workouts: this.workoutsData + }; + } catch (error) { + console.error('Error loading sample data:', error); + throw error; + } + } + + /** + * Load a file using XMLHttpRequest (works with file:// URLs) + * @param {string} filePath - Path to the file + * @returns {Promise} - Promise resolving with file contents + */ + loadFileWithXHR(filePath) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', filePath, true); + xhr.responseType = 'text'; + + xhr.onload = function() { + if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) { + resolve(xhr.responseText); + } else { + reject(new Error(`Failed to load file: ${filePath}`)); + } + }; + + xhr.onerror = function() { + reject(new Error(`Network error loading file: ${filePath}`)); + }; + + xhr.send(); + }); + } + + /** + * Process file data from file inputs + * @param {File} metricsFile - Metrics CSV file + * @param {File} workoutsFile - Workouts CSV file + * @returns {Promise} - Promise resolving with processed data + */ + async processFiles(metricsFile, workoutsFile) { + try { + // Process metrics file + const metricsData = await this.readFile(metricsFile); + this.processMetricsData(metricsData); + + // Process workouts file + const workoutsData = await this.readFile(workoutsFile); + this.processWorkoutsData(workoutsData); + + return { + metrics: this.metricsData, + workouts: this.workoutsData + }; + } catch (error) { + console.error('Error processing files:', error); + throw error; + } + } + + /** + * Read file as text + * @param {File} file - File to read + * @returns {Promise} - Promise resolving with file contents + */ + readFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + resolve(event.target.result); + }; + + reader.onerror = (error) => { + reject(error); + }; + + reader.readAsText(file); + }); + } + + /** + * Prepare data for OpenAI analysis + * @returns {Object} - Data formatted for analysis + */ + prepareDataForAnalysis() { + if (!this.metricsData || !this.workoutsData) { + throw new Error('Data not loaded. Please load data first.'); + } + + return { + metrics: this.metricsData, + workouts: this.workoutsData, + summary: this.generateDataSummary() + }; + } + + /** + * Generate summary statistics from the data + * @returns {Object} - Summary statistics + */ + generateDataSummary() { + if (!this.metricsData || !this.workoutsData) { + return null; + } + + // Log all available metric types from the first few days to help diagnose issues + console.log('Available metric types in first few days:'); + this.metricsData.slice(0, 3).forEach(day => { + console.log(`Day ${day.date} metrics:`, Object.keys(day.metrics)); + }); + + // Find sleep hours data with case-insensitive matching + const sleepData = []; + const sleepHoursKey = 'Sleep Hours'; // The expected key + + this.metricsData.forEach(day => { + // Try to find the sleep hours key (case-insensitive) + let foundSleepHours = false; + + // First try the exact key + if (day.metrics[sleepHoursKey] !== undefined) { + sleepData.push(parseFloat(day.metrics[sleepHoursKey])); + foundSleepHours = true; + } else { + // If not found, try case-insensitive search + for (const key in day.metrics) { + if (key.toLowerCase() === sleepHoursKey.toLowerCase()) { + sleepData.push(parseFloat(day.metrics[key])); + foundSleepHours = true; + console.log(`Found sleep hours with different case: "${key}"`); + break; + } + } + } + + if (!foundSleepHours) { + console.log(`No sleep hours found for day ${day.date}`); + } + }); + + console.log(`Found ${sleepData.length} days with sleep data out of ${this.metricsData.length} total days`); + + const sleepStats = sleepData.length > 0 ? { + average: this.calculateAverage(sleepData), + min: Math.min(...sleepData), + max: Math.max(...sleepData) + } : null; + + console.log('Sleep stats:', sleepStats); + + // Calculate stress statistics + const stressQualifiers = this.metricsData + .filter(day => day.metrics['Stress Qualifier'] !== undefined) + .map(day => day.metrics['Stress Qualifier']); + + const stressQualifierCounts = {}; + stressQualifiers.forEach(qualifier => { + stressQualifierCounts[qualifier] = (stressQualifierCounts[qualifier] || 0) + 1; + }); + + // Calculate workout statistics + const workoutDistances = this.workoutsData + .map(workout => parseFloat(workout.distance)); + + const workoutStats = workoutDistances.length > 0 ? { + count: this.workoutsData.length, + totalDistance: this.calculateSum(workoutDistances), + averageDistance: this.calculateAverage(workoutDistances), + averageHeartRate: this.calculateAverage( + this.workoutsData.map(w => parseFloat(w.heartRate.average)) + ) + } : null; + + return { + dateRange: { + start: this.metricsData[0]?.date, + end: this.metricsData[this.metricsData.length - 1]?.date + }, + sleep: sleepStats, + stress: { + qualifiers: stressQualifierCounts + }, + workouts: workoutStats + }; + } + + /** + * Calculate average of array values + * @param {Array} values - Array of numbers + * @returns {number} - Average value + */ + calculateAverage(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 + */ + calculateSum(values) { + if (!values || values.length === 0) return 0; + return values.reduce((sum, val) => sum + val, 0); + } +} + +// Create a global instance +const dataProcessor = new DataProcessor(); diff --git a/js/openai-service.js b/js/openai-service.js new file mode 100644 index 0000000..c5d100e --- /dev/null +++ b/js/openai-service.js @@ -0,0 +1,232 @@ +/** + * OpenAI Service Module + * Handles interactions with the OpenAI API for generating health recommendations + */ + +class OpenAIService { + constructor() { + this.apiKey = null; + this.model = CONFIG.openai.model; + this.temperature = CONFIG.openai.temperature; + this.maxTokens = CONFIG.openai.max_tokens; + } + + /** + * Set the API key for OpenAI requests + * @param {string} apiKey - OpenAI API key + */ + setApiKey(apiKey) { + this.apiKey = apiKey; + } + + /** + * Check if API key is set + * @returns {boolean} - Whether API key is set + */ + hasApiKey() { + return !!this.apiKey; + } + + /** + * Generate a system prompt based on user configuration + * @param {Object} options - Analysis options + * @returns {string} - Complete system prompt + */ + generateSystemPrompt(options) { + // Start with the base prompt + let systemPrompt = CONFIG.systemPrompts.base; + + // Add analysis focus prompt + if (options.analysisFocus && CONFIG.systemPrompts[options.analysisFocus]) { + systemPrompt += '\n\n' + CONFIG.systemPrompts[options.analysisFocus]; + } + + // Add recommendation style prompt + if (options.recommendationStyle && + CONFIG.recommendationStyles[options.recommendationStyle]) { + systemPrompt += '\n\n' + CONFIG.recommendationStyles[options.recommendationStyle].prompt; + } + + // Add report detail level prompt + if (options.reportDetail && + CONFIG.reportDetailLevels[options.reportDetail]) { + systemPrompt += '\n\n' + CONFIG.reportDetailLevels[options.reportDetail].prompt; + } + + return systemPrompt; + } + + /** + * Generate a user prompt with data and custom instructions + * @param {Object} data - Processed health and workout data + * @param {string} customPrompt - User's custom instructions + * @returns {string} - Complete user prompt + */ + generateUserPrompt(data, customPrompt) { + // Create a structured representation of the data + const dataString = JSON.stringify(data, null, 2); + + let userPrompt = `Please analyze the following health metrics and workout data and provide personalized recommendations: + +DATA: +${dataString}`; + + // Add custom instructions if provided + if (customPrompt && customPrompt.trim()) { + userPrompt += `\n\nADDITIONAL INSTRUCTIONS: +${customPrompt}`; + } + + userPrompt += `\n\nPlease format your response in HTML for direct display in a web application. Use h2, h3, p, ul, li, and other appropriate HTML tags to structure your response. You may use , , and other formatting tags as needed. Do not include , , , or tags. + +Note: Data visualizations will be automatically generated for sleep patterns, stress levels, workout performance, and recovery metrics, so you don't need to describe these visually in your response. Focus on providing insights and recommendations based on the data.`; + + return userPrompt; + } + + /** + * Generate health recommendations using OpenAI API + * @param {Object} data - Processed health and workout data + * @param {Object} options - Analysis options + * @returns {Promise} - Promise resolving with the generated recommendations and raw input data + */ + async generateRecommendations(data, options) { + if (!this.hasApiKey()) { + throw new Error('OpenAI API key not set. Please set an API key before generating recommendations.'); + } + + try { + const systemPrompt = this.generateSystemPrompt(options); + const userPrompt = this.generateUserPrompt(data, options.customPrompt); + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify({ + model: this.model, + messages: [ + { + role: 'system', + content: systemPrompt + }, + { + role: 'user', + content: userPrompt + } + ], + temperature: this.temperature, + max_tokens: this.maxTokens + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`OpenAI API Error: ${errorData.error?.message || 'Unknown error'}`); + } + + const result = await response.json(); + + return { + content: result.choices[0].message.content, + rawInput: { + systemPrompt, + userPrompt + } + }; + + } catch (error) { + console.error('Error generating recommendations:', error); + throw error; + } + } + + /** + * Generate a demo response without using the API + * @param {Object} data - Processed health and workout data + * @param {Object} options - Analysis options + * @returns {Promise} - Promise resolving with the generated demo recommendations and raw input data + */ + async generateDemoRecommendations(data, options) { + // This function provides a demo response when no API key is available + // It returns a pre-written HTML response based on the data and options + + // Generate the prompts that would be sent to the API + const systemPrompt = this.generateSystemPrompt(options); + const userPrompt = this.generateUserPrompt(data, options.customPrompt); + + // Extract some basic stats for the demo + const sleepAvg = data.summary.sleep?.average.toFixed(1) || 'N/A'; + const workoutCount = data.summary.workouts?.count || 0; + const stressLevels = Object.keys(data.summary.stress?.qualifiers || {}).join(', '); + const dateRange = `${data.summary.dateRange?.start || 'N/A'} to ${data.summary.dateRange?.end || 'N/A'}`; + + // Create a demo HTML response + const demoHTML = ` +
+

Health & Fitness Analysis

+

Analysis Period: ${dateRange}

+ +
+

Summary

+

This analysis is based on ${data.metrics.length} days of health metrics and ${workoutCount} workouts. The data shows an average sleep duration of ${sleepAvg} hours per night with stress levels categorized as: ${stressLevels}.

+

Note: This is a demo analysis. For a complete analysis, please provide an OpenAI API key.

+
+ +
+

Sleep Analysis

+

Your average sleep duration of ${sleepAvg} hours is ${sleepAvg >= 7 ? 'within the recommended range' : 'below the recommended range'} for optimal health. Sleep quality indicators suggest ${sleepAvg >= 7 ? 'adequate' : 'potential issues with'} recovery between activities.

+
    +
  • Recommendation: ${sleepAvg >= 7 ? 'Maintain your current sleep schedule' : 'Aim to increase sleep duration by 30-60 minutes'} to optimize recovery and performance.
  • +
  • Recommendation: Consider implementing a consistent pre-sleep routine to improve sleep quality.
  • +
+
+ +
+

Stress Management

+

Your stress levels show ${stressLevels.includes('balanced') ? 'a good balance overall' : 'potential areas for improvement'}, with some periods of elevated stress detected.

+
    +
  • Recommendation: Incorporate daily mindfulness practices (5-10 minutes) to manage stress levels.
  • +
  • Recommendation: Consider scheduling regular recovery days, especially following high-stress periods.
  • +
+
+ +
+

Workout Analysis

+

Your training data shows ${workoutCount} workouts during the analysis period, with consistent effort levels.

+
    +
  • Recommendation: ${workoutCount >= 3 ? 'Maintain your current workout frequency' : 'Consider increasing workout frequency to 3-4 sessions per week'} for optimal fitness development.
  • +
  • Recommendation: Incorporate varied intensity levels in your training to stimulate different energy systems.
  • +
+
+ +
+

Next Steps

+

Based on this analysis, focus on the following areas:

+
    +
  1. ${sleepAvg >= 7 ? 'Maintain your sleep consistency' : 'Improve sleep duration and quality'}
  2. +
  3. Implement regular stress management practices
  4. +
  5. ${workoutCount >= 3 ? 'Continue your consistent training schedule' : 'Increase workout frequency gradually'}
  6. +
  7. Monitor your progress and adjust based on recovery indicators
  8. +
+
+
+ `; + + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 1500)); + + return { + content: demoHTML, + rawInput: { + systemPrompt, + userPrompt + } + }; + } +} + +// Create a global instance +const openaiService = new OpenAIService(); diff --git a/js/report-generator.js b/js/report-generator.js new file mode 100644 index 0000000..bd7f1e5 --- /dev/null +++ b/js/report-generator.js @@ -0,0 +1,236 @@ +/** + * Report Generator Module + * Handles the generation and display of health reports + */ + +class ReportGenerator { + constructor() { + this.reportContainer = document.getElementById('report-container'); + this.loadingIndicator = document.querySelector('.loading-indicator'); + this.reportSection = document.querySelector('.report-section'); + } + + /** + * Generate a health report based on data and options + * @param {Object} data - Processed health and workout data + * @param {Object} options - Analysis options + * @returns {Promise} - Promise resolving when report is generated + */ + async generateReport(data, options) { + try { + // Show loading indicator + this.showLoading(); + + let result; + + // Check if we have an API key + if (options.apiKey && options.apiKey.trim()) { + // Set the API key + openaiService.setApiKey(options.apiKey); + + // Generate recommendations using the OpenAI API + result = await openaiService.generateRecommendations(data, options); + } else { + // Generate demo recommendations without API + result = await openaiService.generateDemoRecommendations(data, options); + } + + // Display the report + this.displayReport(result.content, options.showGptInput ? result.rawInput : null); + + return result.content; + } catch (error) { + this.displayError(error.message); + throw error; + } finally { + // Hide loading indicator + this.hideLoading(); + } + } + + /** + * Display the generated report + * @param {string} reportContent - HTML content of the report + * @param {Object|null} rawInputData - Raw input data sent to GPT-4.1 + */ + displayReport(reportContent, rawInputData = null) { + // Set the report content + this.reportContainer.innerHTML = reportContent; + + // Create charts section + const chartsSection = document.createElement('div'); + chartsSection.className = 'charts-section'; + + const chartsSectionTitle = document.createElement('h2'); + chartsSectionTitle.textContent = 'Data Visualizations'; + chartsSection.appendChild(chartsSectionTitle); + + const chartsSectionDescription = document.createElement('p'); + chartsSectionDescription.textContent = 'Interactive visualizations of your health and fitness data.'; + chartsSection.appendChild(chartsSectionDescription); + + // Add charts section to the report + this.reportContainer.appendChild(chartsSection); + + // Generate charts based on the data + if (dataProcessor.metricsData && dataProcessor.workoutsData) { + const data = { + metrics: dataProcessor.metricsData, + workouts: dataProcessor.workoutsData + }; + + // Create charts + chartsManager.createCharts(data, chartsSection); + } + + // Add raw input data section if available + if (rawInputData) { + const rawDataSection = document.createElement('div'); + rawDataSection.className = 'raw-data-section'; + + const rawDataToggle = document.createElement('div'); + rawDataToggle.className = 'raw-data-toggle'; + rawDataToggle.innerHTML = ` +

GPT-4.1 Input Data

+ + `; + + const rawDataContainer = document.createElement('div'); + rawDataContainer.className = 'raw-data-container'; + + const systemPromptPre = document.createElement('pre'); + systemPromptPre.textContent = `/* System Prompt */\n${rawInputData.systemPrompt}`; + + const userPromptPre = document.createElement('pre'); + userPromptPre.textContent = `\n\n/* User Prompt */\n${rawInputData.userPrompt}`; + + rawDataContainer.appendChild(systemPromptPre); + rawDataContainer.appendChild(userPromptPre); + + rawDataSection.appendChild(rawDataToggle); + rawDataSection.appendChild(rawDataContainer); + + this.reportContainer.appendChild(rawDataSection); + + // Add toggle functionality + rawDataToggle.addEventListener('click', () => { + rawDataContainer.classList.toggle('hidden'); + rawDataToggle.querySelector('.toggle-icon').textContent = + rawDataContainer.classList.contains('hidden') ? '▶' : '▼'; + }); + } + + // Show the report section + this.reportSection.classList.remove('hidden'); + + // Scroll to the report section + this.reportSection.scrollIntoView({ behavior: 'smooth' }); + } + + /** + * Display an error message + * @param {string} errorMessage - Error message to display + */ + displayError(errorMessage) { + const errorHTML = ` +
+

Error Generating Report

+

${errorMessage}

+

Please try again or check your settings.

+
+ `; + + this.reportContainer.innerHTML = errorHTML; + this.reportSection.classList.remove('hidden'); + } + + /** + * Show the loading indicator + */ + showLoading() { + this.loadingIndicator.classList.remove('hidden'); + this.reportContainer.innerHTML = ''; + this.reportSection.classList.remove('hidden'); + } + + /** + * Hide the loading indicator + */ + hideLoading() { + this.loadingIndicator.classList.add('hidden'); + } + + /** + * Generate a PDF from the report + * @returns {Promise} - Promise resolving when PDF is generated + */ + async generatePDF() { + try { + // Show a loading indicator or message + const downloadButton = document.getElementById('download-report'); + const originalText = downloadButton.innerHTML; + downloadButton.innerHTML = ' Generating PDF...'; + downloadButton.disabled = true; + + // Add PDF-specific class to constrain chart widths + this.reportContainer.classList.add('pdf-export'); + + // Wait a moment to ensure all charts are fully rendered with new styles + await new Promise(resolve => setTimeout(resolve, 800)); + + // Configure PDF options + const element = this.reportContainer; + const opt = { + margin: [10, 10], + filename: 'health-analytics-report.pdf', + image: { type: 'jpeg', quality: 0.98 }, + html2canvas: { + scale: 2, + useCORS: true, + logging: false + }, + jsPDF: { + unit: 'mm', + format: 'a4', + orientation: 'portrait' + } + }; + + // Generate and save the PDF + await html2pdf().set(opt).from(element).save(); + + // Remove PDF-specific class + this.reportContainer.classList.remove('pdf-export'); + + // Restore button state + downloadButton.innerHTML = originalText; + downloadButton.disabled = false; + + return true; + } catch (error) { + console.error('Error generating PDF:', error); + alert('Error generating PDF: ' + error.message); + + // Remove PDF-specific class in case of error + this.reportContainer.classList.remove('pdf-export'); + + // Restore button state + const downloadButton = document.getElementById('download-report'); + downloadButton.innerHTML = ' Download PDF'; + downloadButton.disabled = false; + + return false; + } + } + + /** + * Reset the report view + */ + resetReport() { + this.reportContainer.innerHTML = ''; + this.reportSection.classList.add('hidden'); + } +} + +// Create a global instance +const reportGenerator = new ReportGenerator(); diff --git a/metrics.csv b/metrics.csv new file mode 100644 index 0000000..b9ac206 --- /dev/null +++ b/metrics.csv @@ -0,0 +1,131 @@ +"Timestamp","Type","Value" +"2025-04-14 00:00:00","Sleep Hours","7.90" +"2025-04-14 00:00:00","Time In Deep Sleep","1.26666665077209" +"2025-04-14 00:00:00","Time In Light Sleep","4.46666669845581" +"2025-04-14 00:00:00","Time In REM Sleep","2.16666674613953" +"2025-04-14 00:00:00","Time Awake","0.16666667163372" +"2025-04-14 00:00:00","HRV","40" +"2025-04-14 00:00:00","Pulse","57" +"2025-04-14 00:00:00","Stress Level","Max : 92 / Avg : 34" +"2025-04-14 00:00:00","Stress Qualifier","balanced" +"2025-04-14 00:00:00","Body Battery","Min : 25 / Max : 90 / Avg : 51" +"2025-04-15 00:00:00","Sleep Hours","6.82" +"2025-04-15 00:00:00","Time In Deep Sleep","1.08333337306976" +"2025-04-15 00:00:00","Time In Light Sleep","4.28333330154419" +"2025-04-15 00:00:00","Time In REM Sleep","1.45000004768372" +"2025-04-15 00:00:00","Time Awake","1.14999997615814" +"2025-04-15 00:00:00","HRV","44" +"2025-04-15 00:00:00","Pulse","56" +"2025-04-15 00:00:00","Stress Level","Max : 99 / Avg : 53" +"2025-04-15 00:00:00","Stress Qualifier","stressful" +"2025-04-15 00:00:00","Body Battery","Min : 5 / Max : 86 / Avg : 38" +"2025-04-16 00:00:00","Sleep Hours","7.60" +"2025-04-16 00:00:00","Time In Deep Sleep","1.35000002384186" +"2025-04-16 00:00:00","Time In Light Sleep","5.09999990463257" +"2025-04-16 00:00:00","Time In REM Sleep","1.14999997615814" +"2025-04-16 00:00:00","Time Awake","0.899999976158142" +"2025-04-16 00:00:00","HRV","27" +"2025-04-16 00:00:00","Pulse","63" +"2025-04-16 00:00:00","Stress Level","Max : 93 / Avg : 43" +"2025-04-16 00:00:00","Stress Qualifier","very_stressful" +"2025-04-16 00:00:00","Body Battery","Min : 5 / Max : 29 / Avg : 14" +"2025-04-17 00:00:00","Sleep Hours","6.89" +"2025-04-17 00:00:00","Time In Deep Sleep","0.933333337306976" +"2025-04-17 00:00:00","Time In Light Sleep","4.60833311080933" +"2025-04-17 00:00:00","Time In REM Sleep","1.35000002384186" +"2025-04-17 00:00:00","Time Awake","0.200000002980232" +"2025-04-17 00:00:00","HRV","41" +"2025-04-17 00:00:00","Pulse","60" +"2025-04-17 00:00:00","Stress Level","Max : 89 / Avg : 36" +"2025-04-17 00:00:00","Stress Qualifier","stressful" +"2025-04-17 00:00:00","Body Battery","Min : 9 / Max : 73 / Avg : 34" +"2025-04-18 00:00:00","Sleep Hours","9.08" +"2025-04-18 00:00:00","Time In Deep Sleep","1.21666669845581" +"2025-04-18 00:00:00","Time In Light Sleep","6.56666660308838" +"2025-04-18 00:00:00","Time In REM Sleep","1.29999995231628" +"2025-04-18 00:00:00","Time Awake","0.216666668653488" +"2025-04-18 00:00:00","HRV","45" +"2025-04-18 00:00:00","Pulse","56" +"2025-04-18 00:00:00","Stress Level","Max : 87 / Avg : 20" +"2025-04-18 00:00:00","Stress Qualifier","calm" +"2025-04-18 00:00:00","Body Battery","Min : 13 / Max : 97 / Avg : 62" +"2025-04-19 00:00:00","Pulse","56" +"2025-04-19 00:00:00","Stress Level","Max : 97 / Avg : 37" +"2025-04-19 00:00:00","Stress Qualifier","balanced" +"2025-04-19 00:00:00","Body Battery","Min : 10 / Max : 87 / Avg : 48" +"2025-04-19 00:00:00","Sleep Hours","6.85" +"2025-04-19 00:00:00","Time In Deep Sleep","1.04999995231628" +"2025-04-19 00:00:00","Time In Light Sleep","4.75" +"2025-04-19 00:00:00","Time In REM Sleep","1.04999995231628" +"2025-04-19 00:00:00","Time Awake","0.233333334326744" +"2025-04-19 00:00:00","HRV","39" +"2025-04-20 00:00:00","Sleep Hours","8.83" +"2025-04-20 00:00:00","Time In Deep Sleep","1.26666665077209" +"2025-04-20 00:00:00","Time In Light Sleep","5.18333339691162" +"2025-04-20 00:00:00","Time In REM Sleep","2.38333344459534" +"2025-04-20 00:00:00","Time Awake","1" +"2025-04-20 00:00:00","HRV","37" +"2025-04-20 00:00:00","Pulse","55" +"2025-04-20 00:00:00","Stress Level","Max : 99 / Avg : 35" +"2025-04-20 00:00:00","Stress Qualifier","stressful" +"2025-04-20 00:00:00","Body Battery","Min : 16 / Max : 69 / Avg : 43" +"2025-04-21 00:00:00","Pulse","57" +"2025-04-21 00:00:00","Stress Level","Max : 91 / Avg : 25" +"2025-04-21 00:00:00","Stress Qualifier","balanced" +"2025-04-21 00:00:00","Body Battery","Min : 24 / Max : 79 / Avg : 50" +"2025-04-21 00:00:00","Sleep Hours","4.86" +"2025-04-21 00:00:00","Time In Deep Sleep","0.816666662693024" +"2025-04-21 00:00:00","Time In Light Sleep","3.66361117362976" +"2025-04-21 00:00:00","Time In REM Sleep","0.383333325386047" +"2025-04-21 00:00:00","Time Awake","0.216666668653488" +"2025-04-21 00:00:00","HRV","41" +"2025-04-22 00:00:00","Sleep Hours","6.98" +"2025-04-22 00:00:00","Time In Deep Sleep","1.36666667461395" +"2025-04-22 00:00:00","Time In Light Sleep","4.28333330154419" +"2025-04-22 00:00:00","Time In REM Sleep","1.33333337306976" +"2025-04-22 00:00:00","Time Awake","1" +"2025-04-22 00:00:00","HRV","40" +"2025-04-22 00:00:00","Pulse","57" +"2025-04-22 00:00:00","Stress Level","Max : 93 / Avg : 43" +"2025-04-22 00:00:00","Stress Qualifier","stressful" +"2025-04-22 00:00:00","Body Battery","Min : 9 / Max : 81 / Avg : 42" +"2025-04-23 00:00:00","Sleep Hours","8.20" +"2025-04-23 00:00:00","Time In Deep Sleep","1.04999995231628" +"2025-04-23 00:00:00","Time In Light Sleep","5.30000019073486" +"2025-04-23 00:00:00","Time In REM Sleep","1.85000002384186" +"2025-04-23 00:00:00","Time Awake","0.266666680574417" +"2025-04-23 00:00:00","HRV","36" +"2025-04-23 00:00:00","Pulse","55" +"2025-04-23 00:00:00","Stress Level","Max : 98 / Avg : 37" +"2025-04-23 00:00:00","Stress Qualifier","balanced" +"2025-04-23 00:00:00","Body Battery","Min : 11 / Max : 75 / Avg : 42" +"2025-04-24 00:00:00","Pulse","60" +"2025-04-24 00:00:00","Stress Level","Max : 89 / Avg : 33" +"2025-04-24 00:00:00","Stress Qualifier","stressful" +"2025-04-24 00:00:00","Body Battery","Min : 7 / Max : 37 / Avg : 20" +"2025-04-24 00:00:00","Sleep Hours","4.32" +"2025-04-24 00:00:00","Time In Deep Sleep","0.616666674613953" +"2025-04-24 00:00:00","Time In Light Sleep","3.09999990463257" +"2025-04-24 00:00:00","Time In REM Sleep","0.600000023841858" +"2025-04-24 00:00:00","Time Awake","0" +"2025-04-24 00:00:00","HRV","32" +"2025-04-25 00:00:00","Pulse","54" +"2025-04-25 00:00:00","Stress Level","Max : 91 / Avg : 30" +"2025-04-25 00:00:00","Stress Qualifier","balanced" +"2025-04-25 00:00:00","Body Battery","Min : 17 / Max : 97 / Avg : 51" +"2025-04-25 00:00:00","Sleep Hours","7.33" +"2025-04-25 00:00:00","Time In Deep Sleep","1.06666672229767" +"2025-04-25 00:00:00","Time In Light Sleep","3.76666665077209" +"2025-04-25 00:00:00","Time In REM Sleep","2.5" +"2025-04-25 00:00:00","Time Awake","0.133333340287209" +"2025-04-25 00:00:00","HRV","41" +"2025-04-26 00:00:00","Pulse","54" +"2025-04-26 00:00:00","Stress Level","Max : 87 / Avg : 23" +"2025-04-26 00:00:00","Stress Qualifier","balanced" +"2025-04-26 00:00:00","Body Battery","Min : 24 / Max : 91 / Avg : 61" +"2025-04-26 00:00:00","Sleep Hours","7.28" +"2025-04-26 00:00:00","Time In Deep Sleep","1.06666672229767" +"2025-04-26 00:00:00","Time In Light Sleep","4.01666688919067" +"2025-04-26 00:00:00","Time In REM Sleep","2.20000004768372" +"2025-04-26 00:00:00","Time Awake","0" +"2025-04-26 00:00:00","HRV","38" diff --git a/test-sleep-data.js b/test-sleep-data.js new file mode 100644 index 0000000..1f4d47d --- /dev/null +++ b/test-sleep-data.js @@ -0,0 +1,229 @@ +/** + * Test script to verify sleep data processing + * This script loads the metrics.csv file directly and processes it + * to check if sleep data is being correctly identified and processed. + */ + +// First, load the required modules +const fs = require('fs'); +const path = require('path'); + +// Create a simplified version of the DataProcessor class for testing +class TestDataProcessor { + constructor() { + this.metricsData = null; + } + + parseCSV(csvString) { + // Split the CSV string into lines + const lines = csvString.split('\n'); + + // Extract headers (first line) and remove quotes and any extra characters + const headers = lines[0].split(',').map(header => { + // Clean up header - remove quotes, carriage returns, etc. + return header.replace(/^"(.*)"$/, '$1').replace(/\r$/, ''); + }); + + console.log('CSV Headers:', headers); + + // Process each data line + const results = []; + for (let i = 1; i < lines.length; i++) { + // Skip empty lines + if (!lines[i].trim()) continue; + + // Split the line into values, handling quoted values with commas + const values = this.splitCSVLine(lines[i]); + + // Create an object with headers as keys and values + const entry = {}; + headers.forEach((header, index) => { + // Remove quotes if present and convert to appropriate type + let value = values[index] ? values[index].replace(/^"(.*)"$/, '$1').replace(/\r$/, '') : ''; + + // Try to convert to number if it looks like one + if (!isNaN(value) && value.trim() !== '') { + value = parseFloat(value); + } + + entry[header] = value; + }); + + results.push(entry); + } + + return results; + } + + splitCSVLine(line) { + const values = []; + let inQuotes = false; + let currentValue = ''; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + values.push(currentValue); + currentValue = ''; + } else { + currentValue += char; + } + } + + // Add the last value + values.push(currentValue); + + return values; + } + + processMetricsData(csvString) { + const rawData = this.parseCSV(csvString); + + console.log('First few entries from metrics CSV:', rawData.slice(0, 5)); + + // Group metrics by date + const metricsByDate = {}; + + rawData.forEach(entry => { + // Extract date from timestamp (format: "2025-04-14 00:00:00") + const date = entry.Timestamp.split(' ')[0]; + + if (!metricsByDate[date]) { + metricsByDate[date] = {}; + } + + // Add metric to the date group + // Ensure Type is trimmed to handle any extra spaces + const metricType = entry.Type.trim(); + + // Get the value from the correct column (might be '"Value"' instead of 'Value') + let metricValue = null; + if (entry.Value !== undefined) { + metricValue = entry.Value; + } else if (entry['"Value"'] !== undefined) { + metricValue = entry['"Value"']; + } + + // Convert Value to number if it's numeric + if (metricValue !== null && !isNaN(metricValue) && metricValue.toString().trim() !== '') { + metricValue = parseFloat(metricValue); + } + + metricsByDate[date][metricType] = metricValue; + }); + + console.log('Sample of metrics by date:', Object.keys(metricsByDate).slice(0, 3).map(date => ({ + date, + metrics: metricsByDate[date] + }))); + + // Convert to array and sort by date + const processedData = Object.keys(metricsByDate).map(date => { + return { + date, + metrics: metricsByDate[date] + }; + }).sort((a, b) => new Date(a.date) - new Date(b.date)); + + this.metricsData = processedData; + return processedData; + } + + generateDataSummary() { + if (!this.metricsData) { + return null; + } + + // Log all available metric types from the first few days to help diagnose issues + console.log('Available metric types in first few days:'); + this.metricsData.slice(0, 3).forEach(day => { + console.log(`Day ${day.date} metrics:`, Object.keys(day.metrics)); + }); + + // Find sleep hours data with case-insensitive matching + const sleepData = []; + const sleepHoursKey = 'Sleep Hours'; // The expected key + + this.metricsData.forEach(day => { + // Try to find the sleep hours key (case-insensitive) + let foundSleepHours = false; + + // First try the exact key + if (day.metrics[sleepHoursKey] !== undefined) { + sleepData.push(parseFloat(day.metrics[sleepHoursKey])); + foundSleepHours = true; + } else { + // If not found, try case-insensitive search + for (const key in day.metrics) { + if (key.toLowerCase() === sleepHoursKey.toLowerCase()) { + sleepData.push(parseFloat(day.metrics[key])); + foundSleepHours = true; + console.log(`Found sleep hours with different case: "${key}"`); + break; + } + } + } + + if (!foundSleepHours) { + console.log(`No sleep hours found for day ${day.date}`); + } + }); + + console.log(`Found ${sleepData.length} days with sleep data out of ${this.metricsData.length} total days`); + + const sleepStats = sleepData.length > 0 ? { + average: this.calculateAverage(sleepData), + min: Math.min(...sleepData), + max: Math.max(...sleepData) + } : null; + + console.log('Sleep stats:', sleepStats); + + return { + sleep: sleepStats + }; + } + + calculateAverage(values) { + if (!values || values.length === 0) return 0; + return values.reduce((sum, val) => sum + val, 0) / values.length; + } +} + +// Main test function +async function testSleepDataProcessing() { + try { + // Read the metrics.csv file + const metricsFilePath = path.join(__dirname, 'metrics.csv'); + const metricsCSV = fs.readFileSync(metricsFilePath, 'utf8'); + + console.log('Loaded metrics.csv file'); + + // Create a test processor and process the data + const processor = new TestDataProcessor(); + processor.processMetricsData(metricsCSV); + + // Generate summary to check sleep data + const summary = processor.generateDataSummary(); + + console.log('Summary:', summary); + + // Check if sleep data was found + if (summary.sleep) { + console.log('SUCCESS: Sleep data was found and processed correctly'); + console.log(`Average sleep: ${summary.sleep.average.toFixed(2)} hours`); + console.log(`Min sleep: ${summary.sleep.min.toFixed(2)} hours`); + console.log(`Max sleep: ${summary.sleep.max.toFixed(2)} hours`); + } else { + console.log('ERROR: No sleep data was found'); + } + } catch (error) { + console.error('Error testing sleep data processing:', error); + } +} + +// Run the test +testSleepDataProcessing(); diff --git a/workouts.csv b/workouts.csv new file mode 100644 index 0000000..8958a97 --- /dev/null +++ b/workouts.csv @@ -0,0 +1,4 @@ +"Title","WorkoutType","WorkoutDescription","PlannedDuration","PlannedDistanceInMeters","WorkoutDay","CoachComments","DistanceInMeters","PowerAverage","PowerMax","Energy","AthleteComments","TimeTotalInHours","VelocityAverage","VelocityMax","CadenceAverage","CadenceMax","HeartRateAverage","HeartRateMax","TorqueAverage","TorqueMax","IF","TSS","HRZone1Minutes","HRZone2Minutes","HRZone3Minutes","HRZone4Minutes","HRZone5Minutes","HRZone6Minutes","HRZone7Minutes","HRZone8Minutes","HRZone9Minutes","HRZone10Minutes","PWRZone1Minutes","PWRZone2Minutes","PWRZone3Minutes","PWRZone4Minutes","PWRZone5Minutes","PWRZone6Minutes","PWRZone7Minutes","PWRZone8Minutes","PWRZone9Minutes","PWRZone10Minutes","Rpe","Feeling" +"Running","Run","","","","2025-04-15","","3228.94995117188","113","166","186.335","","0.458420544862747","1.95700001716614","","147","","137","156","","","0.721194092255499","25.9","2","6","15","6","0","","","","","","","","","","","","","","","","4","5" +"Running","Run","","","","2025-04-17","","3611.32006835938","126","174","209.082","","0.458588898181915","2.18700003623962","","162","","152","168","","","0.78983598119143","31.1","0","1","7","19","1","","","","","","","","","","","","","","","","4","5" +"Running","Run","","","","2025-04-19","","3678.7900390625","138","188","221.048","","0.443656384944916","2.30299997329712","","160","","152","175","","","0.844946656374587","34.36","1","2","6","16","4","","","","","","","","","","","","","","","","3","5"