|
|
@@ -485,19 +485,118 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
if (mealContent.classList.contains('collapsed')) {
|
|
|
toggleMealBuilder();
|
|
|
}
|
|
|
+
|
|
|
+ // Trigger calculation
|
|
|
+ calculateMealTotals();
|
|
|
};
|
|
|
|
|
|
const removeItemFromMeal = (id) => {
|
|
|
currentMealItems = currentMealItems.filter(item => item.id !== id);
|
|
|
renderMealItems();
|
|
|
+ calculateMealTotals();
|
|
|
+ };
|
|
|
+
|
|
|
+ let calculateAbortController = null;
|
|
|
+
|
|
|
+ const calculateMealTotals = async () => {
|
|
|
+ const activeItems = currentMealItems.filter(item => item.amount > 0);
|
|
|
+
|
|
|
+ // DOM Elements
|
|
|
+ const totalsBanner = document.getElementById('meal-totals-banner');
|
|
|
+ const totalWeightEl = document.getElementById('meal-total-weight');
|
|
|
+ const totalCalEl = document.getElementById('meal-total-cal');
|
|
|
+ const totalProEl = document.getElementById('meal-total-pro');
|
|
|
+ const totalFatEl = document.getElementById('meal-total-fat');
|
|
|
+ const totalCarbEl = document.getElementById('meal-total-carb');
|
|
|
+
|
|
|
+ if (activeItems.length === 0) {
|
|
|
+ if (totalsBanner) totalsBanner.classList.remove('has-error');
|
|
|
+ if (totalWeightEl) totalWeightEl.textContent = '0g';
|
|
|
+ if (totalCalEl) totalCalEl.textContent = '0';
|
|
|
+ if (totalProEl) totalProEl.textContent = '0';
|
|
|
+ if (totalFatEl) totalFatEl.textContent = '0';
|
|
|
+ if (totalCarbEl) totalCarbEl.textContent = '0';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Race Condition Protection: Abort previous request
|
|
|
+ if (calculateAbortController) {
|
|
|
+ calculateAbortController.abort();
|
|
|
+ }
|
|
|
+ calculateAbortController = new AbortController();
|
|
|
+
|
|
|
+ try {
|
|
|
+ const token = localStorage.getItem('localFoodToken');
|
|
|
+ const payload = {
|
|
|
+ items: activeItems.map(item => ({
|
|
|
+ food_id: item.id,
|
|
|
+ amount_g: item.amount
|
|
|
+ }))
|
|
|
+ };
|
|
|
+
|
|
|
+ const response = await fetch('/api/meal/calculate', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ 'Authorization': `Bearer ${token}`
|
|
|
+ },
|
|
|
+ body: JSON.stringify(payload),
|
|
|
+ signal: calculateAbortController.signal
|
|
|
+ });
|
|
|
+
|
|
|
+ if (response.ok) {
|
|
|
+ const data = await response.json();
|
|
|
+ if (totalsBanner) totalsBanner.classList.remove('has-error');
|
|
|
+
|
|
|
+ // Update UI with animation for "alive" feel
|
|
|
+ if (totalWeightEl) totalWeightEl.textContent = `${data.total_weight_g}g`;
|
|
|
+ if (totalCalEl) animateValue(totalCalEl, data.macros.calories);
|
|
|
+ if (totalProEl) animateValue(totalProEl, data.macros.protein_g);
|
|
|
+ if (totalFatEl) animateValue(totalFatEl, data.macros.fat_g);
|
|
|
+ if (totalCarbEl) animateValue(totalCarbEl, data.macros.carbs_g);
|
|
|
+ } else {
|
|
|
+ if (totalsBanner) totalsBanner.classList.add('has-error');
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ if (err.name === 'AbortError') return; // Ignore expected aborts
|
|
|
+ console.error('Failed to calculate meal totals:', err);
|
|
|
+ if (totalsBanner) totalsBanner.classList.add('has-error');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // Helper for smooth number transitions
|
|
|
+ const animateValue = (el, targetValue) => {
|
|
|
+ const startValue = parseFloat(el.textContent) || 0;
|
|
|
+ const duration = 400;
|
|
|
+ const startTime = performance.now();
|
|
|
+
|
|
|
+ const update = (currentTime) => {
|
|
|
+ const elapsed = currentTime - startTime;
|
|
|
+ const progress = Math.min(elapsed / duration, 1);
|
|
|
+
|
|
|
+ // Ease out quad
|
|
|
+ const ease = progress * (2 - progress);
|
|
|
+ const currentValue = startValue + (targetValue - startValue) * ease;
|
|
|
+
|
|
|
+ el.textContent = targetValue % 1 === 0 ? Math.round(currentValue) : currentValue.toFixed(1);
|
|
|
+
|
|
|
+ if (progress < 1) {
|
|
|
+ requestAnimationFrame(update);
|
|
|
+ } else {
|
|
|
+ el.textContent = targetValue;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ requestAnimationFrame(update);
|
|
|
};
|
|
|
|
|
|
+ const debouncedCalculate = debounce(calculateMealTotals, 300);
|
|
|
+
|
|
|
const updateItemAmount = (id, newAmount) => {
|
|
|
const item = currentMealItems.find(item => item.id === id);
|
|
|
if (item) {
|
|
|
item.amount = parseInt(newAmount) || 0;
|
|
|
- // No need to re-render everything, just keep state in sync
|
|
|
- // Task #50 will handle dynamic totals later
|
|
|
+ debouncedCalculate();
|
|
|
}
|
|
|
};
|
|
|
|