WeightTracker/js/utils.js

253 lines
8.2 KiB
JavaScript

/**
* Utilities Module
* Contains helper functions used across the application
*/
const Utils = (() => {
/**
* Format a date string (YYYY-MM-DD) to a more readable format
* @param {string} dateString - Date in YYYY-MM-DD format
* @returns {string} - Formatted date (e.g., "May 26, 2025")
*/
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
/**
* Get today's date in YYYY-MM-DD format
* @returns {string} - Today's date
*/
const getTodayDate = () => {
const today = new Date();
return today.toISOString().split('T')[0];
};
/**
* Truncate text to a specific length
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length
* @returns {string} - Truncated text with ellipsis if needed
*/
const truncateText = (text, maxLength = 50) => {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
};
/**
* Show a notification/toast message
* @param {string} message - Message to display
* @param {string} type - Type of message (success, error, info)
*/
const showNotification = (message, type = 'info') => {
// Create notification element
const notification = document.createElement('div');
notification.classList.add('notification', `notification-${type}`);
notification.textContent = message;
// Add to DOM
document.body.appendChild(notification);
// Trigger animation
setTimeout(() => {
notification.classList.add('show');
}, 10);
// Remove after delay
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
notification.remove();
}, 300);
}, 3000);
};
/**
* Validate a weight entry
* @param {Object} entry - Weight entry to validate
* @returns {Object} - Validation result {valid, message}
*/
const validateWeightEntry = (entry) => {
if (!entry.date) {
return { valid: false, message: 'Date is required' };
}
if (!entry.weight) {
return { valid: false, message: 'Weight value is required' };
}
const weightValue = parseFloat(entry.weight);
if (isNaN(weightValue) || weightValue <= 0) {
return { valid: false, message: 'Weight must be a positive number' };
}
return { valid: true, message: 'Valid entry' };
};
/**
* Calculate weight change and trend
* @param {Array} weights - Array of weight entries
* @returns {Object} - Statistics {totalChange, averageChange, trend}
*/
const calculateWeightStats = (weights) => {
if (!weights || weights.length < 2) {
return { totalChange: 0, averageChange: 0, trend: 'neutral' };
}
// Sort by date (oldest first)
const sortedWeights = [...weights].sort((a, b) => new Date(a.date) - new Date(b.date));
const firstWeight = sortedWeights[0].weight;
const lastWeight = sortedWeights[sortedWeights.length - 1].weight;
const totalChange = lastWeight - firstWeight;
// Calculate average change per day
const daysDiff = Math.max(1, (new Date(sortedWeights[sortedWeights.length - 1].date) - new Date(sortedWeights[0].date)) / (1000 * 60 * 60 * 24));
const averageChange = totalChange / daysDiff;
// Determine trend
let trend = 'neutral';
if (totalChange < 0) {
trend = 'down';
} else if (totalChange > 0) {
trend = 'up';
}
return {
totalChange: totalChange.toFixed(2),
averageChange: averageChange.toFixed(2),
trend
};
};
/**
* Copy text to clipboard
* @param {string} text - Text to copy
* @returns {boolean} - Success status
*/
const copyToClipboard = (text) => {
try {
// Use the modern navigator.clipboard API if available
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text);
return true;
}
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (error) {
console.error('Error copying to clipboard:', error);
return false;
}
};
/**
* Sanitize HTML to prevent XSS attacks.
* Replaces special characters with their HTML entities.
* @param {string} text - The text to sanitize.
* @returns {string} - The sanitized text.
*/
const sanitizeHTML = (text) => {
if (typeof text !== 'string') return '';
return text.replace(/[&<>"']/g, function (match) {
return {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[match];
});
};
// Return public API
/**
* Convert a date string from UI format (DD/MM/YYYY) or existing ISO format (YYYY-MM-DD) to ISO format (YYYY-MM-DD).
* @param {string} dateStr - The date string to convert.
* @returns {string} - The date string in YYYY-MM-DD format, or original if conversion fails.
*/
const uiDateToISO = (dateStr) => {
if (!dateStr) return '';
// Check if already YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
// Potentially validate if it's a real date, but for now, assume format implies intent
return dateStr;
}
// Try to parse as DD/MM/YYYY
const parts = dateStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (parts) {
const day = String(parts[1]).padStart(2, '0');
const month = String(parts[2]).padStart(2, '0');
const year = parts[3];
return `${year}-${month}-${day}`;
}
console.warn(`uiDateToISO: Unrecognized date format "${dateStr}". Returning as is.`);
return dateStr; // Fallback: return original if not recognized
};
/**
* Convert a date string from DD/MM/YYYY to YYYY-MM-DD (ISO).
* @param {string} uiDate - Date in DD/MM/YYYY
* @returns {string} - Date in YYYY-MM-DD
*/
const toISODate = (uiDate) => {
if (!uiDate) return '';
const parts = uiDate.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (parts) {
const day = String(parts[1]).padStart(2, '0');
const month = String(parts[2]).padStart(2, '0');
const year = parts[3];
return `${year}-${month}-${day}`;
}
// If already in ISO format, return as is
if (/^\d{4}-\d{2}-\d{2}$/.test(uiDate)) return uiDate;
return uiDate;
};
/**
* Convert a date string from YYYY-MM-DD (ISO) to DD/MM/YYYY for UI.
* @param {string} isoDate - Date in YYYY-MM-DD
* @returns {string} - Date in DD/MM/YYYY
*/
const toUIDate = (isoDate) => {
if (!isoDate) return '';
const parts = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (parts) {
return `${parts[3]}/${parts[2]}/${parts[1]}`;
}
// If already in UI format, return as is
if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(isoDate)) return isoDate;
return isoDate;
};
return {
formatDate,
getTodayDate,
truncateText,
showNotification,
validateWeightEntry,
calculateWeightStats,
copyToClipboard,
sanitizeHTML,
toISODate,
toUIDate
};
})();