Sfoglia il codice sorgente

Sprint 8: Implement Saved Meals, Dashboard, Aesthetic Cards, and secure Edit/Delete APIs (Tasks #55, #56, #58, #59, #61)

FerRo988 3 giorni fa
parent
commit
e79c07e6f6

BIN
Completion/Sprint4_Completion.zip


BIN
Completion/Sprint5_Task28_Completion.zip


BIN
Completion/Sprint5_Task31_Completion.zip


BIN
Completion/Sprint5_Task32_33_Completion.zip


BIN
Completion/Sprint5_Task35_Completion.zip


BIN
Completion/Sprint6_Task28_Completion.zip


+ 139 - 0
database.py

@@ -93,9 +93,33 @@ def create_tables():
             FOREIGN KEY (user_id) REFERENCES users (id)
         )
         ''')
+
+        # Create user-named meals table for Sprint 8
+        cursor.execute('''
+        CREATE TABLE IF NOT EXISTS saved_meals (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            user_id INTEGER NOT NULL,
+            name TEXT NOT NULL,
+            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+            FOREIGN KEY (user_id) REFERENCES users (id)
+        )
+        ''')
+        
+        # Create meal items table to link multiple foods to a single saved meal
+        cursor.execute('''
+        CREATE TABLE IF NOT EXISTS meal_items (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            meal_id INTEGER NOT NULL,
+            food_id INTEGER NOT NULL,
+            amount_g REAL NOT NULL,
+            FOREIGN KEY (meal_id) REFERENCES saved_meals (id) ON DELETE CASCADE,
+            FOREIGN KEY (food_id) REFERENCES foods (id)
+        )
+        ''')
         
         # Create index for rapid fuzzy search compatibility
         cursor.execute('CREATE INDEX IF NOT EXISTS idx_food_name ON foods(name COLLATE NOCASE)')
+        cursor.execute('CREATE INDEX IF NOT EXISTS idx_saved_meals_user ON saved_meals(user_id)')
         
         conn.commit()
         logger.info("Database and tables initialized successfully.")
@@ -106,6 +130,121 @@ def create_tables():
         if conn:
             conn.close()
 
+def save_user_meal(user_id: int, name: str, items: List[Dict[str, Any]]) -> Optional[int]:
+    """Persist a collection of food items as a named meal list for a user"""
+    conn = None
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        
+        # 1. Create the meal header
+        cursor.execute(
+            "INSERT INTO saved_meals (user_id, name) VALUES (?, ?)",
+            (user_id, name)
+        )
+        meal_id = cursor.lastrowid
+        
+        # 2. Add each item linked to this meal
+        for item in items:
+            cursor.execute(
+                "INSERT INTO meal_items (meal_id, food_id, amount_g) VALUES (?, ?, ?)",
+                (meal_id, item['food_id'], item['amount_g'])
+            )
+        
+        conn.commit()
+        return meal_id
+    except Exception as e:
+        logger.error(f"Error saving user meal: {e}")
+        if conn: conn.rollback()
+        return None
+    finally:
+        if conn: conn.close()
+
+def get_user_meals(user_id: int) -> List[Dict[str, Any]]:
+    """Retrieve all saved meals for a user, including total macro calculations"""
+    conn = None
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        
+        # Fetch meal headers
+        cursor.execute(
+            "SELECT * FROM saved_meals WHERE user_id = ? ORDER BY created_at DESC",
+            (user_id,)
+        )
+        meals = [dict(row) for row in cursor.fetchall()]
+        
+        # For each meal, fetch items and calculate totals
+        for meal in meals:
+            cursor.execute('''
+                SELECT mi.amount_g, f.* 
+                FROM meal_items mi
+                JOIN foods f ON mi.food_id = f.id
+                WHERE mi.meal_id = ?
+            ''', (meal['id'],))
+            items = [dict(row) for row in cursor.fetchall()]
+            
+            # Calculate totals for the meal card summary
+            meal['items'] = items
+            meal['total_calories'] = sum((item['calories'] * item['amount_g'] / 100.0) for item in items)
+            meal['total_protein'] = sum((item['protein_g'] * item['amount_g'] / 100.0) for item in items)
+            meal['total_carbs'] = sum((item['carbs_g'] * item['amount_g'] / 100.0) for item in items)
+            meal['total_fat'] = sum((item['fat_g'] * item['amount_g'] / 100.0) for item in items)
+            
+        return meals
+    except Exception as e:
+        logger.error(f"Error fetching user meals: {e}")
+        return []
+    finally:
+        if conn: conn.close()
+
+def delete_user_meal(user_id: int, meal_id: int) -> bool:
+    """Securely delete a meal and its items, ensuring ownership"""
+    conn = None
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        
+        # Verify ownership first
+        cursor.execute("SELECT id FROM saved_meals WHERE id = ? AND user_id = ?", (meal_id, user_id))
+        if not cursor.fetchone():
+            return False
+            
+        # Delete items first (even if cascading is enabled, we stay transactional)
+        cursor.execute("DELETE FROM meal_items WHERE meal_id = ?", (meal_id,))
+        # Delete header
+        cursor.execute("DELETE FROM saved_meals WHERE id = ?", (meal_id,))
+        
+        conn.commit()
+        return True
+    except Exception as e:
+        logger.error(f"Error deleting meal: {e}")
+        if conn: conn.rollback()
+        return False
+    finally:
+        if conn: conn.close()
+
+def update_user_meal(user_id: int, meal_id: int, new_name: str) -> bool:
+    """Updates the name of a meal, ensuring ownership"""
+    conn = None
+    try:
+        conn = get_db_connection()
+        cursor = conn.cursor()
+        
+        cursor.execute(
+            "UPDATE saved_meals SET name = ? WHERE id = ? AND user_id = ?",
+            (new_name, meal_id, user_id)
+        )
+        conn.commit()
+        # rowcount will be 1 if updated, 0 if ID/ownership failed
+        return cursor.rowcount > 0
+    except Exception as e:
+        logger.error(f"Error updating meal: {e}")
+        if conn: conn.rollback()
+        return False
+    finally:
+        if conn: conn.close()
+
 def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
     """Retrieve user dictionary if they exist"""
     conn = None

+ 51 - 1
main.py

@@ -4,7 +4,7 @@ import httpx
 import bcrypt
 from contextlib import asynccontextmanager
 from fastapi import FastAPI, HTTPException, Depends, Header
-from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session, search_foods_by_name, save_chat_message, get_user_chat_history, get_user_profile, get_food_by_id, get_foods_by_ids
+from database import create_tables, create_user, get_user_by_username, create_session, get_user_from_token, delete_session, search_foods_by_name, save_chat_message, get_user_chat_history, get_user_profile, get_food_by_id, get_foods_by_ids, save_user_meal, get_user_meals, delete_user_meal
 from fastapi.responses import HTMLResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel
@@ -142,6 +142,13 @@ class MealItemInput(BaseModel):
 class MealCalculateRequest(BaseModel):
     items: List[MealItemInput]
 
+class MealSaveRequest(BaseModel):
+    name: str
+    items: List[MealItemInput]
+
+class MealUpdateRequest(BaseModel):
+    name: str
+
 @app.get("/", response_class=HTMLResponse)
 async def read_root():
     """Serve the chat interface HTML"""
@@ -399,6 +406,49 @@ async def calculate_meal(request: MealCalculateRequest, current_user: dict = Dep
             
     return totals
 
+@app.post("/api/meals")
+async def save_meal(request: MealSaveRequest, current_user: dict = Depends(get_current_user)):
+    """Securely save a named meal list for the authenticated user"""
+    if not request.name.strip():
+        raise HTTPException(status_code=400, detail="Meal name cannot be empty")
+    if not request.items:
+        raise HTTPException(status_code=400, detail="Meal items cannot be empty")
+        
+    items_list = [item.model_dump() for item in request.items]
+    meal_id = save_user_meal(current_user['id'], request.name.strip(), items_list)
+    
+    if meal_id is None:
+        raise HTTPException(status_code=500, detail="Failed to save meal to database")
+        
+    return {"status": "success", "meal_id": meal_id, "name": request.name.strip()}
+
+@app.get("/api/meals")
+async def list_meals(current_user: dict = Depends(get_current_user)):
+    """Retrieve all saved meals for the authenticated user"""
+    meals = get_user_meals(current_user['id'])
+    return {"meals": meals}
+
+@app.put("/api/meals/{meal_id}")
+async def update_meal(meal_id: int, request: MealUpdateRequest, current_user: dict = Depends(get_current_user)):
+    """Securely rename a meal after verifying ownership"""
+    if not request.name.strip():
+        raise HTTPException(status_code=400, detail="Meal name cannot be empty")
+        
+    success = update_user_meal(current_user['id'], meal_id, request.name.strip())
+    if not success:
+        raise HTTPException(status_code=404, detail="Meal not found or not owned by user")
+        
+    return {"status": "success", "message": "Meal renamed successfully"}
+
+@app.delete("/api/meals/{meal_id}")
+async def delete_meal(meal_id: int, current_user: dict = Depends(get_current_user)):
+    """Securely delete a specific meal after verifying ownership"""
+    success = delete_user_meal(current_user['id'], meal_id)
+    if not success:
+        raise HTTPException(status_code=404, detail="Meal not found or not owned by user")
+        
+    return {"status": "success", "message": "Meal deleted successfully"}
+
 if __name__ == "__main__":
     import uvicorn
     uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

+ 17 - 0
profile_db.py

@@ -0,0 +1,17 @@
+import database
+import time
+import sys
+
+def profile_search(query):
+    start = time.time()
+    results = database.search_foods_by_name(query)
+    end = time.time()
+    print(f"Query: '{query}'")
+    print(f"Results found: {len(results)}")
+    print(f"Time taken: {end - start:.4f} seconds")
+    if results:
+        print(f"First result: {results[0]['name']}")
+
+if __name__ == "__main__":
+    q = sys.argv[1] if len(sys.argv) > 1 else "chicken"
+    profile_search(q)

+ 48 - 1
static/index.html

@@ -20,6 +20,7 @@
             </div>
             <div class="actions">
                 <span id="user-greeting" style="margin-right:15px; font-size: 0.9rem; color: var(--text-muted);"></span>
+                <button id="nav-dashboard-btn" class="nav-btn">Dashboard</button>
                 <button id="nav-logout-btn" class="nav-btn">Logout</button>
                 <button id="clear-chat" title="Clear Chat">
                     <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
@@ -83,7 +84,10 @@
                             </div>
                         </div>
                     </div>
-                    <button id="generate-recipe-btn" class="primary-btn-sm">🍲 Generate Recipe Prompt</button>
+                    <div class="meal-builder-actions" style="display: flex; gap: 10px; margin-top: 15px;">
+                        <button id="generate-recipe-btn" class="primary-btn-sm" style="flex: 1;">🍲 Generate Recipe Prompt</button>
+                        <button id="open-save-modal-btn" class="secondary-btn-sm" title="Save this meal list">💾 Save Meal</button>
+                    </div>
                 </div>
             </div>
         </div>
@@ -126,6 +130,49 @@
             </form>
             <div class="footer-note">Powered by Qwen 3.5:4B running locally on Ubuntu 24.04 via Ollama</div>
         </footer>
+
+        <!-- Save Meal Modal (Sprint 8) -->
+        <div class="modal" id="save-meal-modal" style="display: none;">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h3>💾 Save Meal Combination</h3>
+                    <button id="close-save-modal-btn" class="close-btn">&times;</button>
+                </div>
+                <div class="modal-body">
+                    <p>Give your meal a name to save it to your personal dashboard.</p>
+                    <div class="input-group" style="margin-top: 15px;">
+                        <label for="meal-name-input">Meal Name</label>
+                        <input type="text" id="meal-name-input" placeholder="e.g. Healthy Salmon Dinner" maxlength="50">
+                    </div>
+                    <div id="save-meal-error" class="error-text"></div>
+                </div>
+                <div class="modal-footer">
+                    <button id="cancel-save-btn" class="secondary-btn">Cancel</button>
+                    <button id="confirm-save-btn" class="primary-btn">Save to Dashboard</button>
+                </div>
+            </div>
+        </div>
+        <!-- Saved Meals Dashboard Overlay (Sprint 8) -->
+        <div class="dashboard-overlay" id="dashboard-overlay" style="display: none;">
+            <div class="dashboard-content">
+                <div class="dashboard-header">
+                    <div class="dashboard-title-group">
+                        <span class="icon">📁</span>
+                        <h2>My Saved Meals</h2>
+                    </div>
+                    <button id="close-dashboard-btn" class="close-btn">&times;</button>
+                </div>
+                <div class="dashboard-body">
+                    <div class="dashboard-grid" id="dashboard-grid">
+                        <!-- Meal cards injected here -->
+                        <div class="search-loading">Loading your meals...</div>
+                    </div>
+                    <div id="dashboard-empty-msg" style="display: none;" class="empty-meal-msg">
+                        You haven't saved any meals yet. Build one in the chat and click "Save Meal"!
+                    </div>
+                </div>
+            </div>
+        </div>
     </div>
     
     <!-- Authentication Gateway -->

+ 166 - 0
static/script.js

@@ -876,4 +876,170 @@ document.addEventListener('DOMContentLoaded', () => {
 
     // Initialize state
     sendBtn.disabled = true;
+
+    // --- Saved Meals Logic (Sprint 8) ---
+    const saveMealModal = document.getElementById('save-meal-modal');
+    const openSaveModalBtn = document.getElementById('open-save-modal-btn');
+    const closeSaveModalBtn = document.getElementById('close-save-modal-btn');
+    const cancelSaveBtn = document.getElementById('cancel-save-btn');
+    const confirmSaveBtn = document.getElementById('confirm-save-btn');
+    const mealNameInput = document.getElementById('meal-name-input');
+    const saveMealError = document.getElementById('save-meal-error');
+
+    if (openSaveModalBtn) {
+        openSaveModalBtn.addEventListener('click', (e) => {
+            e.stopPropagation();
+            if (currentMealItems.length === 0) return;
+            saveMealModal.style.display = 'flex';
+            mealNameInput.value = '';
+            saveMealError.textContent = '';
+            mealNameInput.focus();
+        });
+    }
+
+    const closeSaveModal = () => {
+        saveMealModal.style.display = 'none';
+    };
+
+    if (closeSaveModalBtn) closeSaveModalBtn.addEventListener('click', closeSaveModal);
+    if (cancelSaveBtn) cancelSaveBtn.addEventListener('click', closeSaveModal);
+
+    if (confirmSaveBtn) {
+        confirmSaveBtn.addEventListener('click', async () => {
+            const name = mealNameInput.value.trim();
+            if (!name) {
+                saveMealError.textContent = 'Please enter a name for your meal.';
+                return;
+            }
+
+            confirmSaveBtn.disabled = true;
+            confirmSaveBtn.textContent = 'Saving...';
+
+            try {
+                const token = localStorage.getItem('localFoodToken');
+                const response = await fetch('/api/meals', {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/json',
+                        'Authorization': `Bearer ${token}`
+                    },
+                    body: JSON.stringify({
+                        name: name,
+                        items: currentMealItems.map(item => ({
+                            food_id: item.id,
+                            amount_g: item.amount
+                        }))
+                    })
+                });
+
+                if (response.ok) {
+                    closeSaveModal();
+                    addMessage('system', `✅ Successfully saved "${name}" to your dashboard!`);
+                    
+                    // Trigger a refresh of the dashboard if it's implemented later
+                    if (window.refreshDashboard) window.refreshDashboard();
+                } else {
+                    const data = await response.json();
+                    saveMealError.textContent = data.detail || 'Failed to save meal.';
+                }
+            } catch (err) {
+                console.error('Save meal error:', err);
+                saveMealError.textContent = 'Server error. Please try again.';
+            } finally {
+                confirmSaveBtn.disabled = false;
+                confirmSaveBtn.textContent = 'Save to Dashboard';
+            }
+        });
+    }
+
+    // Close modal on outside click
+    window.addEventListener('click', (e) => {
+        if (e.target === saveMealModal) {
+            closeSaveModal();
+        }
+    });
+    // --- Dashboard Logic (Sprint 8) ---
+    const dashboardOverlay = document.getElementById('dashboard-overlay');
+    const openDashboardBtn = document.getElementById('nav-dashboard-btn');
+    const closeDashboardBtn = document.getElementById('close-dashboard-btn');
+    const dashboardGrid = document.getElementById('dashboard-grid');
+    const dashboardEmptyMsg = document.getElementById('dashboard-empty-msg');
+
+    const toggleDashboard = (show) => {
+        if (show) {
+            dashboardOverlay.style.display = 'flex';
+            loadSavedMeals();
+        } else {
+            dashboardOverlay.style.display = 'none';
+        }
+    };
+
+    if (openDashboardBtn) openDashboardBtn.addEventListener('click', () => toggleDashboard(true));
+    if (closeDashboardBtn) closeDashboardBtn.addEventListener('click', () => toggleDashboard(false));
+
+    async function loadSavedMeals() {
+        dashboardGrid.innerHTML = '<div class="search-loading">Loading your meals...</div>';
+        dashboardEmptyMsg.style.display = 'none';
+
+        try {
+            const token = localStorage.getItem('localFoodToken');
+            const response = await fetch('/api/meals', {
+                headers: { 'Authorization': `Bearer ${token}` }
+            });
+
+            if (response.ok) {
+                const data = await response.json();
+                renderDashboard(data.meals);
+            } else {
+                dashboardGrid.innerHTML = '<div class="error-text">Failed to load meals.</div>';
+            }
+        } catch (err) {
+            console.error('Dashboard load error:', err);
+            dashboardGrid.innerHTML = '<div class="error-text">Connection error.</div>';
+        }
+    }
+
+    function renderDashboard(meals) {
+        dashboardGrid.innerHTML = '';
+        if (!meals || meals.length === 0) {
+            dashboardEmptyMsg.style.display = 'block';
+            return;
+        }
+
+        meals.forEach((meal, index) => {
+            const card = document.createElement('div');
+            card.className = 'meal-card';
+            card.style.animationDelay = `${index * 0.07}s`;
+
+            const dateStr = new Date(meal.created_at).toLocaleDateString(undefined, {
+                month: 'short', day: 'numeric', year: 'numeric'
+            });
+
+            card.innerHTML = `
+                <div class="meal-card-header">
+                    <span class="meal-card-name">${meal.name}</span>
+                    <span class="meal-card-date">${dateStr}</span>
+                </div>
+                <div class="meal-card-macros">
+                    <div class="card-macro kcal">
+                        <span class="card-macro-val">${Math.round(meal.total_calories)}</span>
+                        <span class="card-macro-lbl">kcal</span>
+                    </div>
+                    <div class="card-macro protein">
+                        <span class="card-macro-val">${Math.round(meal.total_protein)}g</span>
+                        <span class="card-macro-lbl">Protein</span>
+                    </div>
+                    <div class="card-macro carbs">
+                        <span class="card-macro-val">${Math.round(meal.total_carbs)}g</span>
+                        <span class="card-macro-lbl">Carbs</span>
+                    </div>
+                    <div class="card-macro fat">
+                        <span class="card-macro-val">${Math.round(meal.total_fat)}g</span>
+                        <span class="card-macro-lbl">Fat</span>
+                    </div>
+                </div>
+            `;
+            dashboardGrid.appendChild(card);
+        });
+    }
 });

+ 286 - 0
static/style.css

@@ -1148,3 +1148,289 @@ textarea::placeholder {
     70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
     100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
 }
+
+/* =============================================
+   Sprint 8: Saved Meals & Dashboard UI
+   ============================================= */
+
+.secondary-btn-sm {
+    background: rgba(255, 255, 255, 0.05);
+    color: var(--text-main);
+    border: 1px solid var(--border-color);
+    border-radius: 8px;
+    padding: 8px 12px;
+    font-size: 0.85rem;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    white-space: nowrap;
+}
+
+.secondary-btn-sm:hover {
+    background: rgba(255, 255, 255, 0.1);
+    border-color: var(--text-muted);
+}
+
+.modal {
+    position: fixed;
+    top: 0; left: 0; width: 100%; height: 100%;
+    background: rgba(0, 0, 0, 0.75);
+    backdrop-filter: blur(10px);
+    -webkit-backdrop-filter: blur(10px);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    z-index: 2000;
+}
+
+.modal-content {
+    background: #161b22;
+    border: 1px solid var(--border-color);
+    border-radius: 20px;
+    width: 90%;
+    max-width: 420px;
+    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
+    animation: modalSlideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+    overflow: hidden;
+}
+
+@keyframes modalSlideUp {
+    from { transform: translateY(30px); opacity: 0; }
+    to { transform: translateY(0); opacity: 1; }
+}
+
+.modal-header {
+    padding: 20px 24px;
+    border-bottom: 1px solid var(--border-color);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    background: rgba(255, 255, 255, 0.02);
+}
+
+.modal-header h3 {
+    font-size: 1.15rem;
+    font-weight: 600;
+    margin: 0;
+    color: #f0f6fc;
+}
+
+.close-btn {
+    background: none;
+    border: none;
+    color: var(--text-muted);
+    font-size: 1.8rem;
+    cursor: pointer;
+    line-height: 1;
+    padding: 0;
+    transition: color 0.2s;
+}
+
+.close-btn:hover {
+    color: #ef4444;
+}
+
+.modal-body {
+    padding: 24px;
+}
+
+.modal-body p {
+    font-size: 0.95rem;
+    color: var(--text-muted);
+    line-height: 1.5;
+    margin-bottom: 16px;
+}
+
+.modal-footer {
+    padding: 20px 24px;
+    border-top: 1px solid var(--border-color);
+    display: flex;
+    gap: 12px;
+    justify-content: flex-end;
+    background: rgba(255, 255, 255, 0.02);
+}
+
+.secondary-btn {
+    background: transparent;
+    border: 1px solid var(--border-color);
+    color: var(--text-main);
+    padding: 10px 20px;
+    border-radius: 10px;
+    font-size: 0.95rem;
+    font-weight: 500;
+    cursor: pointer;
+    transition: all 0.2s;
+}
+
+.secondary-btn:hover {
+    background: rgba(255, 255, 255, 0.05);
+    border-color: var(--text-muted);
+}
+
+/* --- Dashboard Overlay --- */
+.dashboard-overlay {
+    position: absolute;
+    top: 0; left: 0; right: 0; bottom: 0;
+    background: rgba(13, 17, 23, 0.95);
+    backdrop-filter: blur(20px);
+    -webkit-backdrop-filter: blur(20px);
+    z-index: 50;
+    display: flex;
+    flex-direction: column;
+    animation: fadeIn 0.3s ease;
+}
+
+.dashboard-content {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+}
+
+.dashboard-header {
+    padding: 24px;
+    border-bottom: 1px solid var(--border-color);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.dashboard-title-group {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+}
+
+.dashboard-title-group h2 {
+    font-size: 1.25rem;
+    color: #f0f6fc;
+}
+
+.dashboard-body {
+    flex: 1;
+    overflow-y: auto;
+    padding: 24px;
+}
+
+.dashboard-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+    gap: 20px;
+}
+
+.empty-meal-msg {
+    grid-column: 1 / -1;
+    text-align: center;
+    padding: 60px 20px;
+    color: var(--text-muted);
+    font-size: 1.1rem;
+    border: 2px dashed var(--border-color);
+    border-radius: 20px;
+}
+
+/* --- Meal Cards (#59) --- */
+@keyframes cardSlideIn {
+    from { opacity: 0; transform: translateY(20px); }
+    to   { opacity: 1; transform: translateY(0); }
+}
+
+.meal-card {
+    background: rgba(22, 27, 34, 0.6);
+    border: 1px solid rgba(48, 54, 61, 0.8);
+    border-radius: 18px;
+    padding: 20px;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
+                border-color 0.3s ease,
+                box-shadow 0.3s ease;
+    animation: cardSlideIn 0.4s ease both;
+    cursor: default;
+}
+
+.meal-card:hover {
+    transform: translateY(-6px);
+    border-color: rgba(88, 166, 255, 0.4);
+    box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.5),
+                0 0 0 1px rgba(88, 166, 255, 0.1);
+}
+
+.meal-card-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    gap: 10px;
+}
+
+.meal-card-name {
+    font-size: 1.05rem;
+    font-weight: 700;
+    color: #f0f6fc;
+    line-height: 1.3;
+    flex: 1;
+}
+
+.meal-card-date {
+    font-size: 0.72rem;
+    color: var(--text-muted);
+    white-space: nowrap;
+    padding-top: 3px;
+}
+
+.meal-card-macros {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: 8px;
+}
+
+.card-macro {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 10px 8px;
+    border-radius: 12px;
+    border: 1px solid transparent;
+    gap: 2px;
+}
+
+.card-macro-val {
+    font-size: 1.1rem;
+    font-weight: 700;
+    line-height: 1;
+}
+
+.card-macro-lbl {
+    font-size: 0.65rem;
+    font-weight: 500;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+    opacity: 0.8;
+}
+
+/* Color-coded macro badges */
+.card-macro.kcal {
+    background: rgba(251, 146, 60, 0.12);
+    border-color: rgba(251, 146, 60, 0.25);
+    color: #fb923c;
+}
+.card-macro.protein {
+    background: rgba(59, 130, 246, 0.12);
+    border-color: rgba(59, 130, 246, 0.25);
+    color: #60a5fa;
+}
+.card-macro.carbs {
+    background: rgba(34, 197, 94, 0.12);
+    border-color: rgba(34, 197, 94, 0.25);
+    color: #4ade80;
+}
+.card-macro.fat {
+    background: rgba(168, 85, 247, 0.12);
+    border-color: rgba(168, 85, 247, 0.25);
+    color: #c084fc;
+}

+ 24 - 0
test_debug.py

@@ -0,0 +1,24 @@
+import httpx
+import json
+
+def debug_chat():
+    url = "http://localhost:11434/api/chat"
+    messages = [
+        {"role": "system", "content": "[SYSTEM: NUTRITIONAL ANALYST MODE]\nYou are the LocalFoodAI Analyst. Use ONLY verified local data for values.\nCRITICAL: Provide direct, concise answers. Skip all internal monologues, <thought> tags, or reasoning steps.\nFor each food discussed, you MUST follow this structure:\n1. Header: ### 🥗 [Name] (per 100g)\n2. Macros: A markdown table for Cal, P, F, C, Fib, Sug, Chol.\n3. Micros: A bulleted list for Na, Ca, Fe, K, VitA, VitC.\n4. Insight: A 1-sentence analysis of the food's nutritional profile.\nAlways prioritize local data over training memory. If a nutrient is missing, say 'Data not available'."},
+        {"role": "user", "content": "How much protein in 100g of salmon?"}
+    ]
+    payload = {
+        "model": "qwen3.5:9b",
+        "messages": messages,
+        "stream": False
+    }
+    
+    print("Sending debug request...")
+    with httpx.Client(timeout=300.0) as client:
+        response = client.post(url, json=payload)
+        print(f"Status: {response.status_code}")
+        print("Response Body:")
+        print(response.text)
+
+if __name__ == "__main__":
+    debug_chat()

+ 105 - 0
test_meal_math.py

@@ -0,0 +1,105 @@
+import unittest
+import httpx
+import json
+
+class TestMealMath(unittest.TestCase):
+    BASE_URL = "http://192.168.130.171:8000/api"
+    TOKEN = None
+    FOOD_ID_1 = None # Will be populated
+    FOOD_1_DATA = None
+
+    @classmethod
+    def setUpClass(cls):
+        with httpx.Client(timeout=30.0) as client:
+            # 1. Login to get token
+            login_res = client.post(f"{cls.BASE_URL}/login", json={
+                "username": "ferro988",
+                "password": "password"
+            })
+            if login_res.status_code == 200:
+                cls.TOKEN = login_res.json()["token"]
+            
+            # 2. Search for a baseline food (e.g., Chicken)
+            search_res = client.get(f"{cls.BASE_URL}/food/search?q=Chicken", headers={
+                "Authorization": f"Bearer {cls.TOKEN}"
+            })
+            if search_res.status_code == 200:
+                foods = search_res.json()
+                if foods:
+                    cls.FOOD_ID_1 = foods[0]["id"]
+                    cls.FOOD_1_DATA = foods[0]
+
+    def test_baseline_100g(self):
+        """Verify that 100g returns the exact database values."""
+        payload = {"items": [{"food_id": self.FOOD_ID_1, "amount_g": 100}]}
+        with httpx.Client(timeout=30.0) as client:
+            res = client.post(f"{self.BASE_URL}/meal/calculate", json=payload, headers={
+                "Authorization": f"Bearer {self.TOKEN}"
+            })
+            self.assertEqual(res.status_code, 200)
+            data = res.json()
+            
+            # Check macros
+            self.assertEqual(data["macros"]["calories"], self.FOOD_1_DATA["calories"])
+            self.assertEqual(data["macros"]["protein_g"], self.FOOD_1_DATA["protein_g"])
+
+    def test_half_portion_50g(self):
+        """Verify that 50g returns exactly 50% of the database values."""
+        payload = {"items": [{"food_id": self.FOOD_ID_1, "amount_g": 50}]}
+        with httpx.Client(timeout=30.0) as client:
+            res = client.post(f"{self.BASE_URL}/meal/calculate", json=payload, headers={
+                "Authorization": f"Bearer {self.TOKEN}"
+            })
+            self.assertEqual(res.status_code, 200)
+            data = res.json()
+            
+            expected_cal = round(self.FOOD_1_DATA["calories"] * 0.5, 2)
+            expected_pro = round(self.FOOD_1_DATA["protein_g"] * 0.5, 2)
+            
+            self.assertEqual(data["macros"]["calories"], expected_cal)
+            self.assertEqual(data["macros"]["protein_g"], expected_pro)
+
+    def test_irregular_portion_237g(self):
+        """Verify that 237g correctly applies the ratio (2.37x)."""
+        payload = {"items": [{"food_id": self.FOOD_ID_1, "amount_g": 237}]}
+        with httpx.Client(timeout=30.0) as client:
+            res = client.post(f"{self.BASE_URL}/meal/calculate", json=payload, headers={
+                "Authorization": f"Bearer {self.TOKEN}"
+            })
+            self.assertEqual(res.status_code, 200)
+            data = res.json()
+            
+            ratio = 2.37
+            expected_cal = round(self.FOOD_1_DATA["calories"] * ratio, 2)
+            
+            self.assertEqual(data["macros"]["calories"], expected_cal)
+
+    def test_multi_item_aggregation(self):
+        """Verify that two items sum up correctly."""
+        with httpx.Client(timeout=30.0) as client:
+            # Get a second food
+            search_res = client.get(f"{self.BASE_URL}/food/search?q=Rice", headers={
+                "Authorization": f"Bearer {self.TOKEN}"
+            })
+            food2 = search_res.json()[0]
+            
+            payload = {
+                "items": [
+                    {"food_id": self.FOOD_ID_1, "amount_g": 100},
+                    {"food_id": food2["id"], "amount_g": 200}
+                ]
+            }
+            res = client.post(f"{self.BASE_URL}/meal/calculate", json=payload, headers={
+                "Authorization": f"Bearer {self.TOKEN}"
+            })
+            self.assertEqual(res.status_code, 200)
+            data = res.json()
+            
+            expected_cal = round(self.FOOD_1_DATA["calories"] + (food2["calories"] * 2.0), 2)
+            expected_weight = 100 + 200
+            
+            self.assertEqual(data["macros"]["calories"], expected_cal)
+            self.assertEqual(data["total_weight_g"], expected_weight)
+
+if __name__ == "__main__":
+    unittest.main()

+ 39 - 0
test_qwen_perf.py

@@ -0,0 +1,39 @@
+import httpx
+import time
+import json
+
+def test_performance():
+    url = "http://localhost:11434/api/chat"
+    payload = {
+        "model": "qwen3.5:9b",
+        "messages": [{"role": "user", "content": "Hi."}],
+        "stream": True
+    }
+    
+    print(f"Starting streaming test for qwen3.5:9b...")
+    start_time = time.time()
+    
+    try:
+        with httpx.Client(timeout=300.0) as client:
+            with client.stream("POST", url, json=payload) as response:
+                response.raise_for_status()
+                print("\n--- Response ---")
+                for line in response.iter_lines():
+                    if line:
+                        chunk = json.loads(line)
+                        content = chunk.get("message", {}).get("content", "")
+                        print(content, end="", flush=True)
+                        if chunk.get("done"):
+                            break
+                print("\n----------------")
+            
+            end_time = time.time()
+            duration = end_time - start_time
+            print(f"Duration: {duration:.2f} seconds")
+
+            
+    except Exception as e:
+        print(f"Error during test: {e}")
+
+if __name__ == "__main__":
+    test_performance()

+ 12 - 0
test_rag.py

@@ -0,0 +1,12 @@
+import sys
+import os
+sys.path.insert(0, '/home/roni/LocalFoodAI')
+from main import extract_food_context
+
+messages = [{"role": "user", "content": "how many calories in tuna?"}]
+context = extract_food_context(messages)
+if context:
+    print("SUCCESS: Context extracted")
+    print(context)
+else:
+    print("FAILURE: No context extracted")

+ 53 - 0
verify_meal_math.py

@@ -0,0 +1,53 @@
+import urllib.request
+import urllib.error
+import json
+
+def test_calculate_meal():
+    url = "http://127.0.0.1:8000/api/meal/calculate"
+    login_url = "http://127.0.0.1:8000/api/login"
+    
+    # Login
+    login_data = json.dumps({"username": "testuser", "password": "password"}).encode('utf-8')
+    req = urllib.request.Request(login_url, data=login_data, headers={'Content-Type': 'application/json'})
+    try:
+        with urllib.request.urlopen(req) as response:
+            login_resp = json.loads(response.read().decode())
+    except urllib.error.URLError as e:
+        print(f"Login failed: {e}")
+        return
+        
+    token = login_resp.get("token")
+    if not token:
+        print("No token received")
+        return
+        
+    headers = {
+        "Authorization": f"Bearer {token}",
+        "Content-Type": "application/json"
+    }
+    
+    payload = {
+        "items": [
+            {"food_id": 1, "amount_g": 200.5},
+            {"food_id": 2, "amount_g": 50}
+        ]
+    }
+    
+    data = json.dumps(payload).encode('utf-8')
+    req = urllib.request.Request(url, data=data, headers=headers)
+    
+    print("Sending payload:")
+    print(json.dumps(payload, indent=2))
+    
+    try:
+        with urllib.request.urlopen(req) as response:
+            print(f"\nResponse Code: {response.getcode()}")
+            resp_data = json.loads(response.read().decode())
+            print("Response JSON:")
+            print(json.dumps(resp_data, indent=2))
+    except urllib.error.HTTPError as e:
+        print(f"Response Error: {e.code}")
+        print(e.read().decode())
+
+if __name__ == "__main__":
+    test_calculate_meal()

+ 59 - 0
verify_validation.py

@@ -0,0 +1,59 @@
+import urllib.request
+import urllib.error
+import json
+
+def run_test(name, payload):
+    url = "http://127.0.0.1:8000/api/meal/calculate"
+    login_url = "http://127.0.0.1:8000/api/login"
+    register_url = "http://127.0.0.1:8000/api/register"
+    
+    print(f"\n--- Testing: {name} ---")
+    
+    auth_data = json.dumps({"username": "testuser", "password": "password"}).encode('utf-8')
+    
+    # Try to register first (ignore error if already exists)
+    req_reg = urllib.request.Request(register_url, data=auth_data, headers={'Content-Type': 'application/json'})
+    try:
+        urllib.request.urlopen(req_reg)
+    except Exception:
+        pass
+        
+    # Login
+    req = urllib.request.Request(login_url, data=auth_data, headers={'Content-Type': 'application/json'})
+    try:
+        with urllib.request.urlopen(req) as response:
+            token = json.loads(response.read().decode()).get("token")
+    except Exception as e:
+        print(f"Login failed: {e}")
+        return
+
+    headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
+    data = json.dumps(payload).encode('utf-8')
+    req = urllib.request.Request(url, data=data, headers=headers)
+    
+    try:
+        with urllib.request.urlopen(req) as response:
+            print(f"Status: {response.getcode()}")
+            print(f"Body: {response.read().decode()}")
+    except urllib.error.HTTPError as e:
+        print(f"Status: {e.code}")
+        print(f"Error Body: {e.read().decode()}")
+
+if __name__ == "__main__":
+    # Case 1: amount_g is 0
+    run_test("Zero Quantity", {"items": [{"food_id": 1, "amount_g": 0}]})
+    
+    # Case 2: amount_g is negative
+    run_test("Negative Quantity", {"items": [{"food_id": 1, "amount_g": -50}]})
+    
+    # Case 3: Invalid food_id
+    run_test("Invalid Food ID", {"items": [{"food_id": 999999, "amount_g": 100}]})
+    
+    # Case 4: Mixed valid and invalid IDs
+    run_test("Mixed Valid/Invalid IDs", {"items": [
+        {"food_id": 1, "amount_g": 100},
+        {"food_id": 888888, "amount_g": 200}
+    ]})
+
+    # Case 5: Valid request
+    run_test("Valid Request", {"items": [{"food_id": 1, "amount_g": 150}]})