Răsfoiți Sursa

TG-50, TG-51: Integrate frontend meal totals with backend API and add stability fixes

FerRo988 1 săptămână în urmă
părinte
comite
929fbd58dc
3 a modificat fișierele cu 208 adăugiri și 2 ștergeri
  1. 24 0
      static/index.html
  2. 101 2
      static/script.js
  3. 83 0
      static/style.css

+ 24 - 0
static/index.html

@@ -59,6 +59,30 @@
                     <div class="empty-meal-msg" id="empty-meal-msg">No foods added yet. Use the search + button to build a meal.</div>
                 </div>
                 <div class="meal-builder-footer" id="meal-builder-footer" style="display: none;">
+                    <div class="meal-totals-banner" id="meal-totals-banner">
+                        <div class="totals-header">
+                            <span class="totals-title">Total Macros</span>
+                            <span class="totals-weight" id="meal-total-weight">0g</span>
+                        </div>
+                        <div class="totals-grid">
+                            <div class="total-card">
+                                <span class="total-val" id="meal-total-cal">0</span>
+                                <span class="total-lbl">kcal</span>
+                            </div>
+                            <div class="total-card protein-highlight">
+                                <span class="total-val" id="meal-total-pro">0</span>
+                                <span class="total-lbl">Protein</span>
+                            </div>
+                            <div class="total-card">
+                                <span class="total-val" id="meal-total-fat">0</span>
+                                <span class="total-lbl">Fat</span>
+                            </div>
+                            <div class="total-card">
+                                <span class="total-val" id="meal-total-carb">0</span>
+                                <span class="total-lbl">Carbs</span>
+                            </div>
+                        </div>
+                    </div>
                     <button id="generate-recipe-btn" class="primary-btn-sm">🍲 Generate Recipe Prompt</button>
                 </div>
             </div>

+ 101 - 2
static/script.js

@@ -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();
         }
     };
 

+ 83 - 0
static/style.css

@@ -1065,3 +1065,86 @@ textarea::placeholder {
 .primary-btn-sm:active {
     transform: translateY(0);
 }
+
+/* --- Meal Totals Banner --- */
+.meal-totals-banner {
+    width: 100%;
+    margin-bottom: 12px;
+    background: rgba(15, 23, 42, 0.4);
+    border: 1px solid rgba(255, 255, 255, 0.1);
+    border-radius: 10px;
+    padding: 12px;
+}
+
+.totals-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 10px;
+    padding-bottom: 6px;
+    border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.totals-title {
+    font-size: 0.85rem;
+    font-weight: 600;
+    color: var(--text-muted);
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+}
+
+.totals-weight {
+    font-size: 0.85rem;
+    font-weight: 500;
+    color: var(--text-muted);
+}
+
+.totals-grid {
+    display: grid;
+    grid-template-columns: repeat(4, 1fr);
+    gap: 8px;
+}
+
+.total-card {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background: rgba(255, 255, 255, 0.03);
+    border-radius: 6px;
+    padding: 8px 4px;
+    transition: background 0.3s;
+}
+
+.total-val {
+    font-size: 1.05rem;
+    font-weight: 700;
+    color: var(--text-light);
+}
+
+.total-lbl {
+    font-size: 0.7rem;
+    color: var(--text-muted);
+    margin-top: 2px;
+}
+
+.protein-highlight .total-val {
+    color: var(--primary);
+}
+
+.protein-highlight {
+    background: rgba(59, 130, 246, 0.05);
+    border: 1px solid rgba(59, 130, 246, 0.1);
+}
+
+.meal-totals-banner.has-error {
+    border-color: rgba(239, 68, 68, 0.5);
+    background: rgba(127, 29, 29, 0.2);
+    animation: errorPulse 2s infinite;
+}
+
+@keyframes errorPulse {
+    0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
+    70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
+    100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
+}