420 lines
15 KiB
JavaScript
420 lines
15 KiB
JavaScript
/**
|
|
* Data Manager Module
|
|
* Handles all data operations including loading, saving, importing, and exporting data
|
|
* Supports both localStorage (for development) and server-side storage (for Docker deployment)
|
|
*/
|
|
const DataManager = (() => {
|
|
// Default empty data structure
|
|
const defaultData = {
|
|
weights: [],
|
|
meals: [],
|
|
version: '1.0.0'
|
|
};
|
|
|
|
// Current application data
|
|
let appData = {...defaultData};
|
|
|
|
/**
|
|
* Initialize data - load from localStorage or use defaults.
|
|
* Actual file loading will be a separate user-initiated action.
|
|
*/
|
|
const init = async () => {
|
|
try {
|
|
console.log('Initializing data manager by fetching from API...');
|
|
const response = await fetch('/app-data/'); // Nginx proxies this to Node API
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data && typeof data === 'object') {
|
|
appData = data;
|
|
console.log('Successfully loaded data from API.');
|
|
// Optionally, update localStorage for offline or quick display, but API is truth
|
|
// saveDataToLocalStorage();
|
|
} else {
|
|
console.warn('Data from API was not valid JSON or empty. Using default data.');
|
|
appData = { ...defaultData };
|
|
}
|
|
} else {
|
|
console.error(`Failed to fetch data from API: ${response.status} ${response.statusText}. Using default data.`);
|
|
appData = { ...defaultData };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initializing data via API:', error);
|
|
appData = { ...defaultData }; // Fallback to default data on critical error
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Load data from localStorage (used in development or as fallback)
|
|
*/
|
|
const loadFromLocalStorage = () => {
|
|
const savedData = localStorage.getItem('weightTrackerData');
|
|
if (savedData) {
|
|
appData = JSON.parse(savedData);
|
|
console.log('Data loaded from localStorage');
|
|
} else {
|
|
console.log('No existing data found in localStorage. Starting with empty data.');
|
|
appData = {...defaultData};
|
|
saveDataToLocalStorage(); // Save default data structure
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Save data to API.
|
|
*/
|
|
const saveData = async () => {
|
|
console.log('Attempting to save data via API...');
|
|
try {
|
|
const response = await fetch('/app-data/', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(appData),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('Data saved successfully to API:', result.message);
|
|
// Optionally, update localStorage after successful API save
|
|
// saveDataToLocalStorage();
|
|
return true;
|
|
} else {
|
|
const errorResult = await response.json();
|
|
console.error(`Failed to save data to API: ${response.status} ${response.statusText}`, errorResult.message);
|
|
// Consider how to inform the user: alert, UI message, etc.
|
|
alert(`Failed to save data: ${errorResult.message || response.statusText}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving data via API:', error);
|
|
alert(`Error saving data: ${error.message}`);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Save data to localStorage (development environment)
|
|
*/
|
|
const saveDataToLocalStorage = () => {
|
|
try {
|
|
localStorage.setItem('weightTrackerData', JSON.stringify(appData));
|
|
console.log('Data saved to localStorage');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error saving data to localStorage:', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Add a weight entry
|
|
* @param {Object} entry - Weight entry {date, weight, notes}
|
|
*/
|
|
const addWeight = (entry) => {
|
|
// Create an entry with a unique ID
|
|
const newEntry = {
|
|
id: generateId(),
|
|
date: entry.date,
|
|
weight: parseFloat(entry.weight),
|
|
notes: entry.notes || '',
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
appData.weights.push(newEntry);
|
|
appData.weights.sort((a, b) => new Date(b.date) - new Date(a.date)); // Sort by date, newest first
|
|
return saveData();
|
|
};
|
|
|
|
/**
|
|
* Delete a weight entry
|
|
* @param {string} id - Entry ID
|
|
*/
|
|
const deleteWeight = (id) => {
|
|
appData.weights = appData.weights.filter(entry => entry.id !== id);
|
|
return saveData();
|
|
};
|
|
|
|
/**
|
|
* Update a weight entry
|
|
* @param {Object} entry - Updated entry data
|
|
*/
|
|
const updateWeight = (entry) => {
|
|
const index = appData.weights.findIndex(item => item.id === entry.id);
|
|
if (index !== -1) {
|
|
appData.weights[index] = {
|
|
...appData.weights[index],
|
|
date: entry.date,
|
|
weight: parseFloat(entry.weight),
|
|
notes: entry.notes || '',
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
appData.weights.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
return saveData();
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* Get all weight entries
|
|
* @param {Object} filters - Optional filters {startDate, endDate}
|
|
* @returns {Array} - Filtered weight entries
|
|
*/
|
|
const getWeights = (filters = {}) => {
|
|
let results = [...appData.weights];
|
|
|
|
if (filters.startDate) {
|
|
results = results.filter(entry => new Date(entry.date) >= new Date(filters.startDate));
|
|
}
|
|
|
|
if (filters.endDate) {
|
|
results = results.filter(entry => new Date(entry.date) <= new Date(filters.endDate));
|
|
}
|
|
|
|
return results;
|
|
};
|
|
|
|
/**
|
|
* Add a meal entry
|
|
* @param {Object} entry - Meal entry {date, breakfast, lunch, dinner, otherMeals}
|
|
*/
|
|
const addMeal = (entry) => {
|
|
// Check if a meal entry for this date already exists
|
|
const existingIndex = appData.meals.findIndex(meal => meal.date === entry.date);
|
|
|
|
if (existingIndex !== -1) {
|
|
// Update existing entry
|
|
appData.meals[existingIndex] = {
|
|
...appData.meals[existingIndex],
|
|
breakfast: entry.breakfast || '',
|
|
lunch: entry.lunch || '',
|
|
dinner: entry.dinner || '',
|
|
otherMeals: entry.otherMeals || '',
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
} else {
|
|
// Create new entry
|
|
const newEntry = {
|
|
id: generateId(),
|
|
date: entry.date,
|
|
breakfast: entry.breakfast || '',
|
|
lunch: entry.lunch || '',
|
|
dinner: entry.dinner || '',
|
|
otherMeals: entry.otherMeals || '',
|
|
createdAt: new Date().toISOString()
|
|
};
|
|
|
|
appData.meals.push(newEntry);
|
|
}
|
|
|
|
appData.meals.sort((a, b) => new Date(b.date) - new Date(a.date)); // Sort by date, newest first
|
|
return saveData();
|
|
};
|
|
|
|
/**
|
|
* Delete a meal entry
|
|
* @param {string} id - Entry ID
|
|
*/
|
|
const deleteMeal = (id) => {
|
|
appData.meals = appData.meals.filter(entry => entry.id !== id);
|
|
return saveData();
|
|
};
|
|
|
|
/**
|
|
* Get all meal entries
|
|
* @param {Object} filters - Optional filters {startDate, endDate}
|
|
* @returns {Array} - Filtered meal entries
|
|
*/
|
|
const getMeals = (filters = {}) => {
|
|
let results = [...appData.meals];
|
|
|
|
if (filters.startDate) {
|
|
results = results.filter(entry => new Date(entry.date) >= new Date(filters.startDate));
|
|
}
|
|
|
|
if (filters.endDate) {
|
|
results = results.filter(entry => new Date(entry.date) <= new Date(filters.endDate));
|
|
}
|
|
|
|
return results;
|
|
};
|
|
|
|
/**
|
|
* Export all data as a JSON file
|
|
*/
|
|
const exportData = () => {
|
|
try {
|
|
const jsonData = JSON.stringify(appData, null, 2); // Pretty print JSON
|
|
const blob = new Blob([jsonData], { type: 'application/json;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', `weight-tracker-data-${formatDateForFilename(new Date())}.json`);
|
|
link.style.visibility = 'hidden';
|
|
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
console.log('Data exported as JSON file.');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error exporting data as JSON:', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Import data from a JSON file
|
|
* @param {Object} importedData - Data to import
|
|
* @param {string} mode - 'merge' or 'overwrite'
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
const importData = (importedData, mode = 'merge') => {
|
|
try {
|
|
// Validate imported data has the required structure
|
|
if (!importedData || !importedData.weights || !Array.isArray(importedData.weights) ||
|
|
!importedData.meals || !Array.isArray(importedData.meals)) {
|
|
console.error('Invalid data structure in imported file');
|
|
return false;
|
|
}
|
|
|
|
if (mode === 'overwrite') {
|
|
// Overwrite all data
|
|
appData = {
|
|
...importedData,
|
|
version: importedData.version || '1.0.0' // Ensure version exists
|
|
};
|
|
} else {
|
|
// Merge data - combine arrays and remove duplicates by ID
|
|
const mergedWeights = [...appData.weights];
|
|
const mergedMeals = [...appData.meals];
|
|
|
|
// Process imported weights
|
|
importedData.weights.forEach(importedWeight => {
|
|
const existingIndex = mergedWeights.findIndex(w => w.id === importedWeight.id);
|
|
if (existingIndex !== -1) {
|
|
mergedWeights[existingIndex] = importedWeight; // Replace with imported
|
|
} else {
|
|
mergedWeights.push(importedWeight); // Add new entry
|
|
}
|
|
});
|
|
|
|
// Process imported meals
|
|
importedData.meals.forEach(importedMeal => {
|
|
const existingIndex = mergedMeals.findIndex(m => m.id === importedMeal.id);
|
|
if (existingIndex !== -1) {
|
|
mergedMeals[existingIndex] = importedMeal; // Replace with imported
|
|
} else {
|
|
mergedMeals.push(importedMeal); // Add new entry
|
|
}
|
|
});
|
|
|
|
// Sort arrays by date
|
|
mergedWeights.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
mergedMeals.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
// Update app data
|
|
appData = {
|
|
weights: mergedWeights,
|
|
meals: mergedMeals,
|
|
version: appData.version // Keep current version
|
|
};
|
|
}
|
|
|
|
return saveData();
|
|
} catch (error) {
|
|
console.error('Error importing data:', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Export data as CSV
|
|
* @param {string} type - 'weights' or 'meals'
|
|
*/
|
|
const exportCSV = (type) => {
|
|
try {
|
|
let csvContent = '';
|
|
let filename = '';
|
|
|
|
if (type === 'weights') {
|
|
// CSV Header
|
|
csvContent = 'Date,Weight (kg),Notes\n';
|
|
|
|
// Add data rows
|
|
appData.weights.forEach(entry => {
|
|
const notes = entry.notes ? `"${entry.notes.replace(/"/g, '""')}"` : '';
|
|
csvContent += `${entry.date},${entry.weight},${notes}\n`;
|
|
});
|
|
|
|
filename = `weight-data-${formatDateForFilename(new Date())}.csv`;
|
|
}
|
|
else if (type === 'meals') {
|
|
// CSV Header
|
|
csvContent = 'Date,Breakfast,Lunch,Dinner,Other\n';
|
|
|
|
// Add data rows
|
|
appData.meals.forEach(entry => {
|
|
const breakfast = entry.breakfast ? `"${entry.breakfast.replace(/"/g, '""')}"` : '';
|
|
const lunch = entry.lunch ? `"${entry.lunch.replace(/"/g, '""')}"` : '';
|
|
const dinner = entry.dinner ? `"${entry.dinner.replace(/"/g, '""')}"` : '';
|
|
const other = entry.otherMeals ? `"${entry.otherMeals.replace(/"/g, '""')}"` : '';
|
|
|
|
csvContent += `${entry.date},${breakfast},${lunch},${dinner},${other}\n`;
|
|
});
|
|
|
|
filename = `meal-data-${formatDateForFilename(new Date())}.csv`;
|
|
}
|
|
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', filename);
|
|
link.style.visibility = 'hidden';
|
|
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} catch (error) {
|
|
console.error('Error exporting CSV:', error);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Generate a unique ID for entries
|
|
* @returns {string} - Unique ID
|
|
*/
|
|
const generateId = () => {
|
|
return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
|
};
|
|
|
|
/**
|
|
* Format date for filename
|
|
* @param {Date} date - Date to format
|
|
* @returns {string} - Formatted date string (YYYY-MM-DD)
|
|
*/
|
|
const formatDateForFilename = (date) => {
|
|
return date.toISOString().split('T')[0];
|
|
};
|
|
|
|
// Return public API
|
|
return {
|
|
init,
|
|
addWeight,
|
|
deleteWeight,
|
|
updateWeight,
|
|
getWeights,
|
|
addMeal,
|
|
deleteMeal,
|
|
getMeals,
|
|
exportData,
|
|
importData,
|
|
exportCSV,
|
|
getData: () => appData
|
|
};
|
|
})();
|