UNPKG

@profullstack/fasting

Version:

A comprehensive CLI and Node.js module for 16:8 intermittent fasting with meal tracking, weight monitoring, and fast history with visual charts

478 lines (396 loc) 17.8 kB
import { getTimezone } from './config.js'; /** * Formats a date to a consistent time string with leading zeros * @param {Date} date - The date to format * @param {string} timezone - The timezone to use * @returns {string} Formatted time string (e.g., "06:11 PM") */ function formatTimeString(date, timezone) { // Ensure we have a proper Date object const dateObj = date instanceof Date ? date : new Date(date); // Create a new date in the specified timezone const formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', hour12: true }); const parts = formatter.formatToParts(dateObj); // Extract hour, minute, and dayPeriod from parts let hour = ''; let minute = ''; let dayPeriod = ''; parts.forEach(part => { if (part.type === 'hour') { hour = part.value; } else if (part.type === 'minute') { minute = part.value; } else if (part.type === 'dayPeriod') { dayPeriod = part.value; } }); // Ensure hour has leading zero (pad to 2 digits) hour = hour.padStart(2, '0'); // Ensure minute has leading zero (should already be 2 digits from formatToParts) minute = minute.padStart(2, '0'); return `${hour}:${minute} ${dayPeriod}`; } /** * Creates a simple ASCII line chart for weight data * @param {Array} weightData - Array of weight entries with weight and timestamp * @param {number} width - Chart width in characters (default: 60) * @param {number} height - Chart height in characters (default: 15) * @returns {string} ASCII chart */ export function createWeightChart(weightData, width = 60, height = 15) { if (!weightData || weightData.length === 0) { return 'No weight data available'; } // Sort by timestamp const sortedData = [...weightData].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Get min/max values for scaling const weights = sortedData.map(d => d.weight); const minWeight = Math.min(...weights); const maxWeight = Math.max(...weights); const weightRange = maxWeight - minWeight || 1; // Avoid division by zero // Create chart grid const grid = Array(height).fill().map(() => Array(width).fill(' ')); // Plot the line for (let i = 0; i < sortedData.length - 1; i++) { const currentWeight = sortedData[i].weight; const nextWeight = sortedData[i + 1].weight; // Calculate positions const currentCol = Math.round((i / (sortedData.length - 1)) * (width - 1)); const nextCol = Math.round(((i + 1) / (sortedData.length - 1)) * (width - 1)); const currentRow = Math.round(((currentWeight - minWeight) / weightRange) * (height - 1)); const nextRow = Math.round(((nextWeight - minWeight) / weightRange) * (height - 1)); // Draw line between points const colDiff = nextCol - currentCol; const rowDiff = nextRow - currentRow; const steps = Math.max(Math.abs(colDiff), Math.abs(rowDiff), 1); for (let step = 0; step <= steps; step++) { const col = Math.round(currentCol + (colDiff * step / steps)); const row = Math.round(currentRow + (rowDiff * step / steps)); if (col >= 0 && col < width && row >= 0 && row < height) { grid[height - 1 - row][col] = step === 0 || step === steps ? '●' : '─'; } } } // Handle single point if (sortedData.length === 1) { const weight = sortedData[0].weight; const col = Math.round(width / 2); const row = Math.round(((weight - minWeight) / weightRange) * (height - 1)); if (row >= 0 && row < height) { grid[height - 1 - row][col] = '●'; } } // Create chart lines const lines = []; // Add title lines.push('Weight History (lbs)'); lines.push(''); // Add chart with y-axis labels for (let row = 0; row < height; row++) { const currentWeight = minWeight + (weightRange * (height - 1 - row) / (height - 1)); const line = currentWeight.toFixed(1).padStart(6) + ' │' + grid[row].join(''); lines.push(line); } // Add x-axis lines.push(' └' + '─'.repeat(width) + ''); // Add date labels if (sortedData.length > 0) { const firstDate = new Date(sortedData[0].timestamp).toLocaleDateString(); const lastDate = new Date(sortedData[sortedData.length - 1].timestamp).toLocaleDateString(); lines.push(` ${firstDate}${' '.repeat(Math.max(0, width - firstDate.length - lastDate.length))}${lastDate}`); } return lines.join('\n'); } /** * Creates a simple ASCII bar chart for fast duration data * @param {Array} fastData - Array of completed fast entries with durationHours * @param {number} width - Chart width in characters (default: 60) * @param {number} maxBars - Maximum number of bars to show (default: 10) * @returns {string} ASCII chart */ export function createFastChart(fastData, width = 60, maxBars = 10) { if (!fastData || fastData.length === 0) { return 'No completed fasts available'; } // Filter out invalid fasts (negative durations) and sort by start time const validFasts = fastData.filter(fast => fast.durationHours > 0); if (validFasts.length === 0) { return 'No valid completed fasts available'; } const sortedData = [...validFasts] .sort((a, b) => new Date(b.startTime) - new Date(a.startTime)) .slice(0, maxBars) .reverse(); // Show oldest to newest const maxDuration = Math.max(...sortedData.map(f => f.durationHours)); const barWidth = width - 20; // Leave space for labels const lines = []; lines.push('Recent Fast Durations (hours)'); lines.push(''); sortedData.forEach((fast, index) => { const date = new Date(fast.startTime).toLocaleDateString(); const duration = fast.durationHours; const barLength = Math.round((duration / maxDuration) * barWidth); const bar = '█'.repeat(barLength); const label = `${date.padEnd(10)} │${bar} ${duration}h`; lines.push(label); }); lines.push(''); lines.push(`Target: 16h ${'█'.repeat(Math.round((16 / maxDuration) * barWidth))} (16:8 intermittent fasting)`); return lines.join('\n'); } /** * Creates a simple ASCII line chart for daily calorie data * @param {Array} calorieData - Array of calorie entries with date, calories, and timestamp * @param {number} width - Chart width in characters (default: 60) * @param {number} height - Chart height in characters (default: 15) * @returns {string} ASCII chart */ export function createCalorieChart(calorieData, width = 60, height = 15) { if (!calorieData || calorieData.length === 0) { return 'No calorie data available'; } // Sort by timestamp const sortedData = [...calorieData].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Get min/max values for scaling const calories = sortedData.map(d => d.calories); const minCalories = Math.min(...calories); const maxCalories = Math.max(...calories); const calorieRange = maxCalories - minCalories || 1; // Avoid division by zero // Create chart grid const grid = Array(height).fill().map(() => Array(width).fill(' ')); // Plot the line for (let i = 0; i < sortedData.length - 1; i++) { const currentCalories = sortedData[i].calories; const nextCalories = sortedData[i + 1].calories; // Calculate positions const currentCol = Math.round((i / (sortedData.length - 1)) * (width - 1)); const nextCol = Math.round(((i + 1) / (sortedData.length - 1)) * (width - 1)); const currentRow = Math.round(((currentCalories - minCalories) / calorieRange) * (height - 1)); const nextRow = Math.round(((nextCalories - minCalories) / calorieRange) * (height - 1)); // Draw line between points const colDiff = nextCol - currentCol; const rowDiff = nextRow - currentRow; const steps = Math.max(Math.abs(colDiff), Math.abs(rowDiff), 1); for (let step = 0; step <= steps; step++) { const col = Math.round(currentCol + (colDiff * step / steps)); const row = Math.round(currentRow + (rowDiff * step / steps)); if (col >= 0 && col < width && row >= 0 && row < height) { grid[height - 1 - row][col] = step === 0 || step === steps ? '●' : '─'; } } } // Handle single point if (sortedData.length === 1) { const calories = sortedData[0].calories; const col = Math.round(width / 2); const row = Math.round(((calories - minCalories) / calorieRange) * (height - 1)); if (row >= 0 && row < height) { grid[height - 1 - row][col] = '●'; } } // Create chart lines const lines = []; // Add title lines.push('Daily Calorie Intake'); lines.push(''); // Add chart with y-axis labels for (let row = 0; row < height; row++) { const currentCalories = Math.round(minCalories + (calorieRange * (height - 1 - row) / (height - 1))); const line = currentCalories.toString().padStart(6) + ' │' + grid[row].join(''); lines.push(line); } // Add x-axis lines.push(' └' + '─'.repeat(width) + ''); // Add date labels if (sortedData.length > 0) { const firstDate = new Date(sortedData[0].timestamp).toLocaleDateString(); const lastDate = new Date(sortedData[sortedData.length - 1].timestamp).toLocaleDateString(); lines.push(` ${firstDate}${' '.repeat(Math.max(0, width - firstDate.length - lastDate.length))}${lastDate}`); } // Add average line const avgCalories = Math.round(calories.reduce((sum, cal) => sum + cal, 0) / calories.length); lines.push(''); lines.push(`Average: ${avgCalories} calories/day`); return lines.join('\n'); } /** * Creates a simple ASCII line chart for daily exercise calorie burn data * @param {Array} exerciseData - Array of exercise entries with date, caloriesBurned, and timestamp * @param {number} width - Chart width in characters (default: 60) * @param {number} height - Chart height in characters (default: 15) * @returns {string} ASCII chart */ export function createExerciseChart(exerciseData, width = 60, height = 15) { if (!exerciseData || exerciseData.length === 0) { return 'No exercise data available'; } // Sort by timestamp const sortedData = [...exerciseData].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); // Get min/max values for scaling const calories = sortedData.map(d => d.caloriesBurned); const minCalories = Math.min(...calories); const maxCalories = Math.max(...calories); const calorieRange = maxCalories - minCalories || 1; // Avoid division by zero // Create chart grid const grid = Array(height).fill().map(() => Array(width).fill(' ')); // Plot the line for (let i = 0; i < sortedData.length - 1; i++) { const currentCalories = sortedData[i].caloriesBurned; const nextCalories = sortedData[i + 1].caloriesBurned; // Calculate positions const currentCol = Math.round((i / (sortedData.length - 1)) * (width - 1)); const nextCol = Math.round(((i + 1) / (sortedData.length - 1)) * (width - 1)); const currentRow = Math.round(((currentCalories - minCalories) / calorieRange) * (height - 1)); const nextRow = Math.round(((nextCalories - minCalories) / calorieRange) * (height - 1)); // Draw line between points const colDiff = nextCol - currentCol; const rowDiff = nextRow - currentRow; const steps = Math.max(Math.abs(colDiff), Math.abs(rowDiff), 1); for (let step = 0; step <= steps; step++) { const col = Math.round(currentCol + (colDiff * step / steps)); const row = Math.round(currentRow + (rowDiff * step / steps)); if (col >= 0 && col < width && row >= 0 && row < height) { grid[height - 1 - row][col] = step === 0 || step === steps ? '●' : '─'; } } } // Handle single point if (sortedData.length === 1) { const calories = sortedData[0].caloriesBurned; const col = Math.round(width / 2); const row = Math.round(((calories - minCalories) / calorieRange) * (height - 1)); if (row >= 0 && row < height) { grid[height - 1 - row][col] = '●'; } } // Create chart lines const lines = []; // Add title lines.push('Daily Calories Burned (Exercise)'); lines.push(''); // Add chart with y-axis labels for (let row = 0; row < height; row++) { const currentCalories = Math.round(minCalories + (calorieRange * (height - 1 - row) / (height - 1))); const line = currentCalories.toString().padStart(6) + ' │' + grid[row].join(''); lines.push(line); } // Add x-axis lines.push(' └' + '─'.repeat(width) + ''); // Add date labels if (sortedData.length > 0) { const firstDate = new Date(sortedData[0].timestamp).toLocaleDateString(); const lastDate = new Date(sortedData[sortedData.length - 1].timestamp).toLocaleDateString(); lines.push(` ${firstDate}${' '.repeat(Math.max(0, width - firstDate.length - lastDate.length))}${lastDate}`); } // Add average line const avgCalories = Math.round(calories.reduce((sum, cal) => sum + cal, 0) / calories.length); lines.push(''); lines.push(`Average: ${avgCalories} calories burned/day`); return lines.join('\n'); } /** * Creates a summary table with recent data * @param {Object} data - Object containing meals, weights, fasts, and current fast * @returns {string} Formatted summary table */ export function createSummaryTable(data) { const { todaysEntries, recentWeights, fastStats, currentFast, recentFasts, todaysExercises } = data; const lines = []; lines.push('═══════════════════════════════════════════════════════════════'); lines.push(' FASTING APP SUMMARY '); lines.push('═══════════════════════════════════════════════════════════════'); lines.push(''); // Current Fast Status lines.push('🕐 CURRENT FAST STATUS'); lines.push('─'.repeat(40)); if (currentFast) { const startTime = new Date(currentFast.startTime); const now = new Date(); const hoursElapsed = Math.round((now - startTime) / (1000 * 60 * 60) * 10) / 10; lines.push(`Status: FASTING (${hoursElapsed}h elapsed)`); const timezone = getTimezone(); lines.push(`Started: ${startTime.toLocaleString([], { timeZone: timezone })}`); if (hoursElapsed >= 16) { lines.push('✅ 16-hour target reached!'); } else { const remaining = 16 - hoursElapsed; lines.push(`⏰ ${remaining.toFixed(1)}h remaining to reach 16h target`); } } else { lines.push('Status: NOT FASTING'); lines.push('💡 Use "fasting fast start" to begin a new fast'); } lines.push(''); // Today's Food Log lines.push('🍽️ TODAY\'S FOOD LOG'); lines.push('─'.repeat(40)); if (todaysEntries && todaysEntries.length > 0) { let totalCalories = 0; todaysEntries.forEach(entry => { const timezone = getTimezone(); const time = formatTimeString(new Date(entry.timestamp), timezone); const type = entry.type === 'meal' ? '🍽️' : '🥤'; lines.push(`${time} ${type} ${entry.description} (${entry.calories} cal)`); totalCalories += entry.calories; }); lines.push(''); lines.push(`Total calories today: ${totalCalories}`); } else { lines.push('No meals or drinks logged today'); } lines.push(''); // Today's Exercise Log lines.push('🏃 TODAY\'S EXERCISE LOG'); lines.push('─'.repeat(40)); if (todaysExercises && todaysExercises.length > 0) { let totalCaloriesBurned = 0; todaysExercises.forEach(exercise => { const timezone = getTimezone(); const time = formatTimeString(new Date(exercise.timestamp), timezone); lines.push(`${time} 🏃 ${exercise.description} (${exercise.duration}min, ${exercise.caloriesBurned} cal burned)`); totalCaloriesBurned += exercise.caloriesBurned || 0; }); lines.push(''); lines.push(`Total calories burned today: ${totalCaloriesBurned}`); } else { lines.push('No exercises logged today'); } lines.push(''); // Fast Statistics lines.push('📊 FAST STATISTICS'); lines.push('─'.repeat(40)); if (fastStats.totalFasts > 0) { lines.push(`Total completed fasts: ${fastStats.totalFasts}`); lines.push(`Average duration: ${fastStats.averageDuration}h`); lines.push(`Longest fast: ${fastStats.longestFast}h`); lines.push(`Shortest fast: ${fastStats.shortestFast}h`); } else { lines.push('No completed fasts yet'); } lines.push(''); // Recent Weight lines.push('⚖️ WEIGHT TRACKING'); lines.push('─'.repeat(40)); if (recentWeights && recentWeights.length > 0) { const latest = recentWeights[recentWeights.length - 1]; const latestDate = new Date(latest.timestamp).toLocaleDateString(); lines.push(`Latest: ${latest.weight} lbs (${latestDate})`); if (recentWeights.length > 1) { const previous = recentWeights[recentWeights.length - 2]; const change = latest.weight - previous.weight; const changeStr = change > 0 ? `+${change.toFixed(1)}` : change.toFixed(1); const arrow = change > 0 ? '📈' : change < 0 ? '📉' : '➡️'; lines.push(`Change: ${changeStr} lbs ${arrow}`); } } else { lines.push('No weight data recorded'); } lines.push(''); lines.push('═══════════════════════════════════════════════════════════════'); return lines.join('\n'); }