From d43544a232729a256912e34a79175c43c8130a0c Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Mon, 1 Dec 2025 17:03:56 -0300 Subject: [PATCH] feat: add meal planner - wip --- .github/workflows/deploy.yml | 2 + appwrite.config.json | 4 + index.html | 178 +++++++- mealPlanner.css | 283 +++++++++++++ src/appwrite.ts | 215 +++++++++- src/index.ts | 18 +- src/mealPlanner.ts | 770 +++++++++++++++++++++++++++++++++++ src/state.ts | 16 +- src/types.ts | 24 ++ 9 files changed, 1502 insertions(+), 8 deletions(-) create mode 100644 appwrite.config.json create mode 100644 mealPlanner.css create mode 100644 src/mealPlanner.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4327de1..2a2daca 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,8 @@ jobs: VITE_APPWRITE_USERSETTINGSID: ${{ secrets.VITE_APPWRITE_USERSETTINGSID }} VITE_APPWRITE_MONTLYCALORIESID: ${{ secrets.VITE_APPWRITE_MONTLYCALORIESID }} VITE_APPWRITE_SHAREDDAYSID: ${{ secrets.VITE_APPWRITE_SHAREDDAYSID }} + VITE_APPWRITE_MEALPLANTEMPLATESID: ${{ secrets.VITE_APPWRITE_MEALPLANTEMPLATESID }} + VITE_APPWRITE_PLANNEDFOODITEMSID: ${{ secrets.VITE_APPWRITE_PLANNEDFOODITEMSID }} steps: - name: Checkout diff --git a/appwrite.config.json b/appwrite.config.json new file mode 100644 index 0000000..2a32101 --- /dev/null +++ b/appwrite.config.json @@ -0,0 +1,4 @@ +{ + "projectId": "684ac3bf000ee8950f5a", + "endpoint": "https://fra.cloud.appwrite.io/v1" +} \ No newline at end of file diff --git a/index.html b/index.html index a84c21c..af34495 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + @@ -82,6 +83,7 @@
+
@@ -92,6 +94,7 @@ @@ -166,6 +169,14 @@ + + +

Add Food Entry

@@ -248,9 +259,9 @@ + + + diff --git a/mealPlanner.css b/mealPlanner.css new file mode 100644 index 0000000..9546069 --- /dev/null +++ b/mealPlanner.css @@ -0,0 +1,283 @@ +/* Meal Planner Page Styles */ + +/* Template Management */ +.template-management { + margin: 20px 0; + padding: 20px; + background: var(--card-bg, #1e1e1e); + border-radius: 10px; +} + +.template-selector-container { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.template-selector-container label { + font-weight: 500; + color: var(--text-color, #ffffff); +} + +.template-selector { + flex: 1; + min-width: 200px; + padding: 10px; + background: var(--input-bg, #2a2a2a); + color: var(--text-color, #ffffff); + border: 1px solid var(--border-color, #3a3a3a); + border-radius: 5px; + font-size: 16px; +} + +.template-btn { + padding: 10px 20px; + background: var(--primary-color, #4CAF50); + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + transition: background 0.3s; +} + +.template-btn:hover { + background: var(--primary-hover, #45a049); +} + +.template-btn.delete-btn { + background: #f44336; +} + +.template-btn.delete-btn:hover { + background: #da190b; +} + +/* Plan Progress Container */ +.plan-progress-container { + margin: 20px 0; +} + +/* Nutrition Progress Bars */ +.nutrition-progress-bar { + width: 100%; + height: 8px; + background: var(--progress-bg, #2a2a2a); + border-radius: 4px; + overflow: hidden; + margin: 5px 0; +} + +.nutrition-progress-fill { + height: 100%; + background: #4CAF50; + transition: width 0.3s ease, background-color 0.3s ease; + border-radius: 4px; +} + +/* Meals Container */ +.meals-container { + margin: 30px 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; +} + +/* Meal Section */ +.meal-section { + background: var(--card-bg, #1e1e1e); + border-radius: 10px; + padding: 20px; + border: 1px solid var(--border-color, #3a3a3a); +} + +.meal-section-title { + margin: 0 0 15px 0; + color: var(--text-color, #ffffff); + font-size: 20px; +} + +.add-meal-item-btn { + width: 100%; + padding: 10px; + background: var(--primary-color, #4CAF50); + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + margin-bottom: 15px; + transition: background 0.3s; +} + +.add-meal-item-btn:hover { + background: var(--primary-hover, #45a049); +} + +.meal-items-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.no-items-message { + text-align: center; + color: var(--text-secondary, #888); + padding: 20px; + font-style: italic; +} + +/* Planned Item Cards */ +.planned-item-card { + background: var(--input-bg, #2a2a2a); + border-radius: 8px; + padding: 12px; + border: 1px solid var(--border-color, #3a3a3a); + transition: transform 0.2s, box-shadow 0.2s; +} + +.planned-item-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.planned-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.planned-item-name { + font-weight: 500; + color: var(--text-color, #ffffff); + font-size: 16px; +} + +.planned-item-grams { + color: var(--text-secondary, #888); + font-size: 14px; +} + +.planned-item-nutrition { + display: flex; + gap: 15px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-secondary, #888); + margin-bottom: 8px; +} + +.delete-planned-item-btn { + width: 100%; + padding: 6px; + background: #f44336; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: background 0.3s; +} + +.delete-planned-item-btn:hover { + background: #da190b; +} + +/* Add Food to Plan Section */ +.add-food-to-plan-section { + margin: 30px 0; + padding: 20px; + background: var(--card-bg, #1e1e1e); + border-radius: 10px; + border: 2px solid var(--primary-color, #4CAF50); +} + +/* Quick Add Section in Food Log */ +.quick-add-section { + margin: 20px 0; + padding: 20px; + background: var(--card-bg, #1e1e1e); + border-radius: 10px; + border: 1px solid var(--border-color, #3a3a3a); +} + +.quick-add-items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px; + margin-top: 15px; +} + +.quick-add-meal-header { + grid-column: 1 / -1; + margin: 10px 0 5px 0; + color: var(--text-color, #ffffff); + font-size: 16px; +} + +.quick-add-btn { + padding: 12px; + background: var(--input-bg, #2a2a2a); + border: 1px solid var(--border-color, #3a3a3a); + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 5px; +} + +.quick-add-btn:hover { + background: var(--primary-color, #4CAF50); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(76, 175, 80, 0.3); +} + +.quick-add-name { + font-weight: 500; + color: var(--text-color, #ffffff); + font-size: 14px; +} + +.quick-add-details { + font-size: 12px; + color: var(--text-secondary, #888); +} + +.quick-add-btn:hover .quick-add-name, +.quick-add-btn:hover .quick-add-details { + color: white; +} + +/* Mobile Responsiveness */ +@media (max-width: 768px) { + .template-selector-container { + flex-direction: column; + align-items: stretch; + } + + .template-selector { + min-width: auto; + } + + .meals-container { + grid-template-columns: 1fr; + } + + .quick-add-items { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .planned-item-nutrition { + font-size: 12px; + } + + .quick-add-btn { + padding: 10px; + } +} diff --git a/src/appwrite.ts b/src/appwrite.ts index a7fa75c..110894e 100644 --- a/src/appwrite.ts +++ b/src/appwrite.ts @@ -1,5 +1,5 @@ import { Client, Account, Databases, Query, ID } from 'appwrite'; -import { DailyTotalCalories, FoodStorage, UserSettings, SharedDay } from './types'; +import { DailyTotalCalories, FoodStorage, UserSettings, SharedDay, MealPlanTemplate, PlannedFoodItem, MealType } from './types'; // Initialize Appwrite client const client = new Client(); @@ -18,6 +18,8 @@ export const FOOD_ENTRIES_COLLECTION_ID = import.meta.env.VITE_APPWRITE_FOODENTR export const USER_SETTINGS_COLLECTION_ID = import.meta.env.VITE_APPWRITE_USERSETTINGSID export const MONTHLY_CALORIES_COLLECTION_ID = import.meta.env.VITE_APPWRITE_MONTLYCALORIESID export const SHARED_DAYS_COLLECTION_ID = import.meta.env.VITE_APPWRITE_SHAREDDAYSID +export const MEAL_PLAN_TEMPLATES_COLLECTION_ID = import.meta.env.VITE_APPWRITE_MEALPLANTEMPLATESID +export const PLANNED_FOOD_ITEMS_COLLECTION_ID = import.meta.env.VITE_APPWRITE_PLANNEDFOODITEMSID // Auth helper functions export class AppwriteAuth { @@ -330,4 +332,215 @@ export class AppwriteDB { throw error; } } + + // Meal Plan Template methods + + // Create meal plan template + static async createMealPlanTemplate(name: string) { + try { + const user = await account.get(); + const response = await databases.createDocument( + DATABASE_ID, + MEAL_PLAN_TEMPLATES_COLLECTION_ID, + 'unique()', + { + name: name, + userId: user.$id, + isActive: false, + createdAt: new Date().toISOString() + } + ); + console.debug('Meal plan template created:', response); + return response; + } catch (error) { + console.error('Create meal plan template error:', error); + throw error; + } + } + + // Get all meal plan templates for user + static async getMealPlanTemplates() { + try { + const user = await account.get(); + const response = await databases.listDocuments( + DATABASE_ID, + MEAL_PLAN_TEMPLATES_COLLECTION_ID, + [ + Query.equal('userId', user.$id), + Query.limit(50) + ] + ); + console.debug('Meal plan templates retrieved:', response); + return response.documents; + } catch (error) { + console.error('Get meal plan templates error:', error); + throw error; + } + } + + // Get active meal plan template + static async getActiveMealPlanTemplate() { + try { + const user = await account.get(); + const response = await databases.listDocuments( + DATABASE_ID, + MEAL_PLAN_TEMPLATES_COLLECTION_ID, + [ + Query.equal('userId', user.$id), + Query.equal('isActive', true), + Query.limit(1) + ] + ); + console.debug('Active meal plan template retrieved:', response); + return response.documents.length > 0 ? response.documents[0] : null; + } catch (error) { + console.error('Get active meal plan template error:', error); + throw error; + } + } + + // Set active meal plan template + static async setActiveMealPlanTemplate(templateId: string) { + try { + const user = await account.get(); + + // First, deactivate all templates + const allTemplates = await this.getMealPlanTemplates(); + const deactivatePromises = allTemplates.map(template => + databases.updateDocument( + DATABASE_ID, + MEAL_PLAN_TEMPLATES_COLLECTION_ID, + template.$id, + { isActive: false } + ) + ); + await Promise.all(deactivatePromises); + + // Then activate the selected template + const response = await databases.updateDocument( + DATABASE_ID, + MEAL_PLAN_TEMPLATES_COLLECTION_ID, + templateId, + { isActive: true } + ); + console.debug('Meal plan template set as active:', response); + return response; + } catch (error) { + console.error('Set active meal plan template error:', error); + throw error; + } + } + + // Delete meal plan template + static async deleteMealPlanTemplate(templateId: string) { + try { + // First delete all planned items for this template + const plannedItems = await this.getPlannedItemsByTemplate(templateId); + const deletePromises = plannedItems.map(item => + this.deletePlannedFoodItem(item.$id) + ); + await Promise.all(deletePromises); + + // Then delete the template + await databases.deleteDocument( + DATABASE_ID, + MEAL_PLAN_TEMPLATES_COLLECTION_ID, + templateId + ); + console.debug('Meal plan template deleted:', templateId); + } catch (error) { + console.error('Delete meal plan template error:', error); + throw error; + } + } + + // Planned Food Item methods + + // Create planned food item + static async createPlannedFoodItem(item: Omit) { + try { + const response = await databases.createDocument( + DATABASE_ID, + PLANNED_FOOD_ITEMS_COLLECTION_ID, + 'unique()', + item + ); + console.debug('Planned food item created:', response); + return response; + } catch (error) { + console.error('Create planned food item error:', error); + throw error; + } + } + + // Get all planned items for a template + static async getPlannedItemsByTemplate(templateId: string) { + try { + const response = await databases.listDocuments( + DATABASE_ID, + PLANNED_FOOD_ITEMS_COLLECTION_ID, + [ + Query.equal('templateId', templateId), + Query.limit(100) + ] + ); + console.debug('Planned items retrieved for template:', response); + return response.documents; + } catch (error) { + console.error('Get planned items by template error:', error); + throw error; + } + } + + // Get planned items by template and meal type + static async getPlannedItemsByMealType(templateId: string, mealType: MealType) { + try { + const response = await databases.listDocuments( + DATABASE_ID, + PLANNED_FOOD_ITEMS_COLLECTION_ID, + [ + Query.equal('templateId', templateId), + Query.equal('mealType', mealType), + Query.limit(50) + ] + ); + console.debug('Planned items retrieved for meal type:', response); + return response.documents; + } catch (error) { + console.error('Get planned items by meal type error:', error); + throw error; + } + } + + // Update planned food item + static async updatePlannedFoodItem(itemId: string, updates: Partial) { + try { + const response = await databases.updateDocument( + DATABASE_ID, + PLANNED_FOOD_ITEMS_COLLECTION_ID, + itemId, + updates + ); + console.debug('Planned food item updated:', response); + return response; + } catch (error) { + console.error('Update planned food item error:', error); + throw error; + } + } + + // Delete planned food item + static async deletePlannedFoodItem(itemId: string) { + try { + await databases.deleteDocument( + DATABASE_ID, + PLANNED_FOOD_ITEMS_COLLECTION_ID, + itemId + ); + console.debug('Planned food item deleted:', itemId); + } catch (error) { + console.error('Delete planned food item error:', error); + throw error; + } + } } diff --git a/src/index.ts b/src/index.ts index a9426fb..dd70c7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import swal from 'sweetalert'; import { closeMobileMenu, delay, getCleanName, getIcon, handleMobileCalendarClick, hideLoading, hideSearchResults, navigateResultsKeyboard, QUICK_DELAY, scrollToCalendarView, showLoading, toggleCardHandler, toggleMobileMenu } from './Utils.ts'; import { showAuthForms, toggleAuthForms } from './auth.ts'; import { appState } from "./state"; +import { setupMealPlannerEventListeners, displayQuickAddItems } from './mealPlanner.js'; // App state let selectedDate = new Date(); @@ -485,13 +486,21 @@ const setupEventListeners = () => { const target = e.target as HTMLElement; const mobileMenu = document.getElementById('mobileMenu'); const mobileMenuToggle = document.getElementById('mobileMenuToggle'); - - if (mobileMenu && mobileMenuToggle && - !mobileMenu.contains(target) && + + if (mobileMenu && mobileMenuToggle && + !mobileMenu.contains(target) && !mobileMenuToggle.contains(target)) { closeMobileMenu(); } }); + + // Setup meal planner event listeners + setupMealPlannerEventListeners(); + + // Listen for food added event from quick add + window.addEventListener('foodAdded', () => { + loadFoodEntries(selectedDate); + }); } function performSearch(query: string) { @@ -1028,6 +1037,9 @@ async function loadFoodEntries(date: Date) { getDivById('carboGoalText').classList.add('hidden'); getDivById('fiberGoalText').classList.add('hidden'); } + + // Load quick add items from meal plan + await displayQuickAddItems(); } catch (error) { console.error('Load food entries error:', error); if (error instanceof Error) { diff --git a/src/mealPlanner.ts b/src/mealPlanner.ts new file mode 100644 index 0000000..ecc9ec6 --- /dev/null +++ b/src/mealPlanner.ts @@ -0,0 +1,770 @@ +import { foodDatabase } from './foodDatabase.js'; +import { getButtonById, getDivById, getInputById, showFoodPreview } from './DomUtils.ts'; +import { FoodItem, MealType, PlannedFoodItem } from './types.js'; +import { AppwriteDB } from './appwrite.js'; +import swal from 'sweetalert'; +import { hideLoading, showLoading, getCleanName, getIcon } from './Utils.ts'; +import { appState } from "./state"; + +let selectedPlanFood: FoodItem | null = null; +let currentMealType: MealType | null = null; + +// Navigation functions +export async function toggleMealPlannerView() { + const showMealPlanner = getButtonById('mealPlannerBtn').textContent?.includes('Meal Planner'); + + if (showMealPlanner) { + showLoading(); + + try { + // Load templates + await loadMealPlanTemplates(); + + // Load active template if exists + const activeTemplate = await AppwriteDB.getActiveMealPlanTemplate(); + if (activeTemplate) { + appState.currentTemplate = activeTemplate as any; + await loadPlannedItemsForTemplate(activeTemplate.$id); + } + + // Show meal planner page + getDivById('app-content').classList.add('hidden'); + getDivById('settings-content').classList.add('hidden'); + getDivById('meal-planner-content').classList.remove('hidden'); + getButtonById('mealPlannerBtn').textContent = '← Back'; + getButtonById('mealPlannerBtnMobile').textContent = '← Back'; + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Show meal planner error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to load meal planner: ' + error.message, 'error'); + } + } + } else { + // Go back to main app + getDivById('app-content').classList.remove('hidden'); + getDivById('meal-planner-content').classList.add('hidden'); + getButtonById('mealPlannerBtn').textContent = '📋 Meal Planner'; + getButtonById('mealPlannerBtnMobile').textContent = '📋 Meal Planner'; + } +} + +export function handleMobileMealPlannerClick() { + toggleMealPlannerView(); + // Close mobile menu + const mobileMenu = document.getElementById('mobileMenu'); + if (mobileMenu) { + mobileMenu.classList.add('hidden'); + } +} + +// Template management functions +export async function loadMealPlanTemplates() { + try { + const templates = await AppwriteDB.getMealPlanTemplates(); + const templateSelector = document.getElementById('templateSelector') as HTMLSelectElement; + + if (!templateSelector) return; + + // Clear existing options except the first one + templateSelector.innerHTML = ''; + + // Add templates + templates.forEach(template => { + const option = document.createElement('option'); + option.value = template.$id; + option.textContent = template.name; + if (template.isActive) { + option.selected = true; + } + templateSelector.appendChild(option); + }); + } catch (error) { + console.error('Load templates error:', error); + throw error; + } +} + +export async function createNewTemplate() { + const templateName = await swal({ + title: 'New Meal Plan', + text: 'Enter a name for your meal plan:', + content: { + element: 'input', + attributes: { + placeholder: 'e.g., Workout Day, Rest Day', + type: 'text', + }, + }, + buttons: { + cancel: true, + confirm: true, + }, + }); + + if (!templateName || templateName.trim() === '') { + return; + } + + showLoading(); + + try { + const newTemplate = await AppwriteDB.createMealPlanTemplate(templateName.trim()); + await loadMealPlanTemplates(); + + // Set as active and load it + await AppwriteDB.setActiveMealPlanTemplate(newTemplate.$id); + appState.currentTemplate = newTemplate as any; + appState.plannedItems = []; + + // Update selector + const templateSelector = document.getElementById('templateSelector') as HTMLSelectElement; + if (templateSelector) { + templateSelector.value = newTemplate.$id; + } + + // Refresh display + displayPlannedItems(); + updatePlanTotals(); + + hideLoading(); + swal('Success!', 'Meal plan created successfully!', 'success'); + } catch (error) { + hideLoading(); + console.error('Create template error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to create meal plan: ' + error.message, 'error'); + } + } +} + +export async function deleteCurrentTemplate() { + if (!appState.currentTemplate) { + swal('Oh no!', 'No meal plan selected', 'error'); + return; + } + + const confirm = await swal({ + title: 'Delete Meal Plan', + text: `Are you sure you want to delete "${appState.currentTemplate.name}"? This will also delete all planned items.`, + icon: 'warning', + buttons: { + cancel: true, + confirm: { + text: 'Delete', + value: true, + }, + }, + dangerMode: true, + }); + + if (!confirm) { + return; + } + + showLoading(); + + try { + await AppwriteDB.deleteMealPlanTemplate(appState.currentTemplate.id!); + appState.currentTemplate = null; + appState.plannedItems = []; + + await loadMealPlanTemplates(); + displayPlannedItems(); + updatePlanTotals(); + + hideLoading(); + swal('Success!', 'Meal plan deleted successfully!', 'success'); + } catch (error) { + hideLoading(); + console.error('Delete template error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to delete meal plan: ' + error.message, 'error'); + } + } +} + +export async function handleTemplateChange(templateId: string) { + if (!templateId) { + appState.currentTemplate = null; + appState.plannedItems = []; + displayPlannedItems(); + updatePlanTotals(); + return; + } + + showLoading(); + + try { + // Set as active template + await AppwriteDB.setActiveMealPlanTemplate(templateId); + + // Load template details + const templates = await AppwriteDB.getMealPlanTemplates(); + const template = templates.find(t => t.$id === templateId); + + if (template) { + appState.currentTemplate = template as any; + await loadPlannedItemsForTemplate(templateId); + } + + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Handle template change error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to load meal plan: ' + error.message, 'error'); + } + } +} + +async function loadPlannedItemsForTemplate(templateId: string) { + try { + const items = await AppwriteDB.getPlannedItemsByTemplate(templateId); + appState.plannedItems = items as any[]; + displayPlannedItems(); + updatePlanTotals(); + } catch (error) { + console.error('Load planned items error:', error); + throw error; + } +} + +// Food search and add functions +export function setupPlanFoodSearch() { + const searchInput = document.getElementById('planFoodSearchInput') as HTMLInputElement; + const gramAmountInput = document.getElementById('planGramAmount') as HTMLInputElement; + + if (!searchInput || !gramAmountInput) return; + + searchInput.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + const searchTerm = target.value.trim().toLowerCase(); + + if (searchTerm === '') { + hidePlanSearchResults(); + selectedPlanFood = null; + return; + } + + // Debounce search + if (appState.planSearchTimeout) { + clearTimeout(appState.planSearchTimeout); + } + + appState.planSearchTimeout = window.setTimeout(() => { + performPlanFoodSearch(searchTerm); + }, 300); + }); + + searchInput.addEventListener('keydown', (e) => { + navigatePlanResultsKeyboard(e as KeyboardEvent); + }); + + gramAmountInput.addEventListener('input', () => { + previewPlanCalories(); + }); +} + +function performPlanFoodSearch(searchTerm: string) { + const results = foodDatabase.filter(food => + food.name.toLowerCase().includes(searchTerm) + ).slice(0, 10); + + appState.planSearchResults = results; + appState.planCurrentHighlightIndex = -1; + displayPlanSearchResults(results); +} + +function displayPlanSearchResults(results: FoodItem[]) { + const searchResults = document.getElementById('planSearchResults'); + if (!searchResults) return; + + if (results.length === 0) { + searchResults.innerHTML = '
No foods found
'; + searchResults.classList.remove('hidden'); + return; + } + + searchResults.innerHTML = ''; + results.forEach((food, index) => { + const resultItem = document.createElement('div'); + resultItem.className = 'search-result-item'; + resultItem.setAttribute('data-index', index.toString()); + resultItem.innerHTML = ` + ${getIcon(food.info.category)} + ${getCleanName(food.name)} + ${food.info.calories} cal + `; + resultItem.addEventListener('click', () => selectPlanFood(food)); + searchResults.appendChild(resultItem); + }); + + searchResults.classList.remove('hidden'); +} + +function hidePlanSearchResults() { + const searchResults = document.getElementById('planSearchResults'); + if (searchResults) { + searchResults.classList.add('hidden'); + searchResults.innerHTML = ''; + } + appState.planSearchResults = []; + appState.planCurrentHighlightIndex = -1; +} + +function navigatePlanResultsKeyboard(e: KeyboardEvent) { + const results = appState.planSearchResults; + + if (results.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + appState.planCurrentHighlightIndex = Math.min(appState.planCurrentHighlightIndex + 1, results.length - 1); + updatePlanResultsHighlight(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + appState.planCurrentHighlightIndex = Math.max(appState.planCurrentHighlightIndex - 1, -1); + updatePlanResultsHighlight(); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (appState.planCurrentHighlightIndex >= 0) { + selectPlanFood(results[appState.planCurrentHighlightIndex]); + } + } else if (e.key === 'Escape') { + hidePlanSearchResults(); + } +} + +function updatePlanResultsHighlight() { + const searchResults = document.getElementById('planSearchResults'); + if (!searchResults) return; + + const items = searchResults.querySelectorAll('.search-result-item'); + items.forEach((item, index) => { + if (index === appState.planCurrentHighlightIndex) { + item.classList.add('highlighted'); + } else { + item.classList.remove('highlighted'); + } + }); +} + +function selectPlanFood(food: FoodItem) { + selectedPlanFood = food; + const searchInput = document.getElementById('planFoodSearchInput') as HTMLInputElement; + if (searchInput) { + searchInput.value = getCleanName(food.name); + } + hidePlanSearchResults(); + previewPlanCalories(); +} + +function previewPlanCalories() { + const gramAmountInput = document.getElementById('planGramAmount') as HTMLInputElement; + const previewContainer = document.getElementById('plan-food-preview'); + + if (!selectedPlanFood || !gramAmountInput || !previewContainer) { + if (previewContainer) previewContainer.classList.add('display-none'); + return; + } + + const grams = parseFloat(gramAmountInput.value) || 100; + const proportion = grams / 100; + + const calories = Math.round(selectedPlanFood.info.calories * proportion); + const protein = Math.round(selectedPlanFood.info.protein * proportion * 10) / 10; + const fat = Math.round(selectedPlanFood.info.fat * proportion * 10) / 10; + const carbs = Math.round(selectedPlanFood.info.carbs * proportion * 10) / 10; + const fiber = Math.round(selectedPlanFood.info.fiber * proportion * 10) / 10; + + const calorieEl = document.getElementById('planCalorieValuePreview'); + const proteinEl = document.getElementById('planProteinValuePreview'); + const fatEl = document.getElementById('planFatValuePreview'); + const carbsEl = document.getElementById('planCarboValuePreview'); + const fiberEl = document.getElementById('planFiberValuePreview'); + + if (calorieEl) calorieEl.textContent = calories.toString(); + if (proteinEl) proteinEl.textContent = protein.toString(); + if (fatEl) fatEl.textContent = fat.toString(); + if (carbsEl) carbsEl.textContent = carbs.toString(); + if (fiberEl) fiberEl.textContent = fiber.toString(); + + previewContainer.classList.remove('display-none'); +} + +export function showAddFoodToPlan(mealType: MealType) { + if (!appState.currentTemplate) { + swal('Oh no!', 'Please select or create a meal plan first', 'error'); + return; + } + + currentMealType = mealType; + const addSection = document.getElementById('addFoodToPlanSection'); + const mealTypeSpan = document.getElementById('currentMealType'); + const currentMealTypeInput = document.getElementById('currentMealTypeInput') as HTMLInputElement; + + if (addSection) addSection.classList.remove('hidden'); + if (mealTypeSpan) mealTypeSpan.textContent = mealType.charAt(0).toUpperCase() + mealType.slice(1); + if (currentMealTypeInput) currentMealTypeInput.value = mealType; + + // Scroll to form + addSection?.scrollIntoView({ behavior: 'smooth' }); +} + +export function hideAddFoodToPlan() { + const addSection = document.getElementById('addFoodToPlanSection'); + const searchInput = document.getElementById('planFoodSearchInput') as HTMLInputElement; + const gramInput = document.getElementById('planGramAmount') as HTMLInputElement; + const previewContainer = document.getElementById('plan-food-preview'); + + if (addSection) addSection.classList.add('hidden'); + if (searchInput) searchInput.value = ''; + if (gramInput) gramInput.value = '100'; + if (previewContainer) previewContainer.classList.add('display-none'); + + selectedPlanFood = null; + currentMealType = null; + hidePlanSearchResults(); +} + +export async function addFoodToPlan() { + if (!selectedPlanFood || !currentMealType || !appState.currentTemplate) { + swal('Oh no!', 'Please select a food item', 'error'); + return; + } + + const gramAmountInput = document.getElementById('planGramAmount') as HTMLInputElement; + const grams = parseFloat(gramAmountInput.value); + + if (!grams || grams <= 0) { + swal('Oh no!', 'Please enter a valid amount in grams', 'error'); + return; + } + + showLoading(); + + try { + const proportion = grams / 100; + const plannedItem: Omit = { + templateId: appState.currentTemplate.id!, + mealType: currentMealType, + name: selectedPlanFood.name, + grams: grams, + calories: Math.round(selectedPlanFood.info.calories * proportion), + protein: Math.round(selectedPlanFood.info.protein * proportion * 10) / 10, + fat: Math.round(selectedPlanFood.info.fat * proportion * 10) / 10, + carbs: Math.round(selectedPlanFood.info.carbs * proportion * 10) / 10, + fiber: Math.round(selectedPlanFood.info.fiber * proportion * 10) / 10, + alkaline: selectedPlanFood.info.alkaline + }; + + await AppwriteDB.createPlannedFoodItem(plannedItem); + await loadPlannedItemsForTemplate(appState.currentTemplate.id!); + + hideAddFoodToPlan(); + hideLoading(); + swal('Success!', 'Food added to meal plan!', 'success'); + } catch (error) { + hideLoading(); + console.error('Add food to plan error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to add food: ' + error.message, 'error'); + } + } +} + +// Display functions +export function displayPlannedItems() { + const mealTypes: MealType[] = ['breakfast', 'lunch', 'dinner', 'snacks']; + + mealTypes.forEach(mealType => { + const container = document.getElementById(`${mealType}ItemsContainer`); + if (!container) return; + + const items = appState.plannedItems.filter(item => item.mealType === mealType); + + if (items.length === 0) { + container.innerHTML = '

No foods planned for this meal

'; + return; + } + + container.innerHTML = ''; + items.forEach(item => { + const card = createPlannedItemCard(item); + container.appendChild(card); + }); + }); +} + +function createPlannedItemCard(item: PlannedFoodItem): HTMLElement { + const card = document.createElement('div'); + card.className = 'planned-item-card'; + card.innerHTML = ` +
+ ${getCleanName(item.name)} + ${item.grams}g +
+
+ ${item.calories} cal + P: ${item.protein}g + F: ${item.fat}g + C: ${item.carbs}g +
+ + `; + + const deleteBtn = card.querySelector('.delete-planned-item-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => deletePlannedItem(item.id!)); + } + + return card; +} + +export async function deletePlannedItem(itemId: string) { + const confirm = await swal({ + title: 'Delete Item', + text: 'Are you sure you want to delete this item from your meal plan?', + icon: 'warning', + buttons: { + cancel: true, + confirm: true, + }, + }); + + if (!confirm) return; + + showLoading(); + + try { + await AppwriteDB.deletePlannedFoodItem(itemId); + if (appState.currentTemplate) { + await loadPlannedItemsForTemplate(appState.currentTemplate.id!); + } + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Delete planned item error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to delete item: ' + error.message, 'error'); + } + } +} + +export function updatePlanTotals() { + const totals = appState.plannedItems.reduce((acc, item) => { + acc.calories += item.calories; + acc.protein += item.protein; + acc.fat += item.fat; + acc.carbs += item.carbs; + acc.fiber += item.fiber; + return acc; + }, { calories: 0, protein: 0, fat: 0, carbs: 0, fiber: 0 }); + + // Update totals display + const caloriesTotalEl = document.getElementById('planCaloriesTotal'); + const proteinValueEl = document.getElementById('planProteinValue'); + const fatValueEl = document.getElementById('planFatValue'); + const carboValueEl = document.getElementById('planCarboValue'); + const fiberValueEl = document.getElementById('planFiberValue'); + + if (caloriesTotalEl) caloriesTotalEl.textContent = totals.calories.toString(); + if (proteinValueEl) proteinValueEl.textContent = Math.round(totals.protein * 10) / 10 + ''; + if (fatValueEl) fatValueEl.textContent = Math.round(totals.fat * 10) / 10 + ''; + if (carboValueEl) carboValueEl.textContent = Math.round(totals.carbs * 10) / 10 + ''; + if (fiberValueEl) fiberValueEl.textContent = Math.round(totals.fiber * 10) / 10 + ''; + + // Update progress bars and goals + updatePlanProgressBars(totals); +} + +async function updatePlanProgressBars(totals: { calories: number, protein: number, fat: number, carbs: number, fiber: number }) { + try { + const settings = await AppwriteDB.getUserSettings(); + if (!settings || settings.length === 0) return; + + const userSettings = settings[settings.length - 1]; + + // Update goal texts + const caloriesGoalEl = document.getElementById('planCaloriesGoalText'); + const proteinGoalEl = document.getElementById('planProteinGoalText'); + const fatGoalEl = document.getElementById('planFatGoalText'); + const carboGoalEl = document.getElementById('planCarboGoalText'); + const fiberGoalEl = document.getElementById('planFiberGoalText'); + + if (caloriesGoalEl) caloriesGoalEl.textContent = `of ${userSettings.caloriesGoal || 0}`; + if (proteinGoalEl) proteinGoalEl.textContent = `of ${userSettings.proteinGoal}`; + if (fatGoalEl) fatGoalEl.textContent = `of ${userSettings.fatGoal}`; + if (carboGoalEl) carboGoalEl.textContent = `of ${userSettings.carboGoal}`; + if (fiberGoalEl) fiberGoalEl.textContent = `of ${userSettings.fiberGoal}`; + + // Update progress bars + updateProgressBar('planProteinProgress', totals.protein, userSettings.proteinGoal); + updateProgressBar('planFatProgress', totals.fat, userSettings.fatGoal); + updateProgressBar('planCarboProgress', totals.carbs, userSettings.carboGoal); + updateProgressBar('planFiberProgress', totals.fiber, userSettings.fiberGoal); + } catch (error) { + console.error('Update progress bars error:', error); + } +} + +function updateProgressBar(elementId: string, current: number, goal: number) { + const progressBar = document.getElementById(elementId); + if (!progressBar) return; + + const percentage = Math.min((current / goal) * 100, 100); + progressBar.style.width = `${percentage}%`; + + // Color coding: green when within range, yellow when getting close, red when over + if (percentage < 80) { + progressBar.style.backgroundColor = '#4CAF50'; // green + } else if (percentage < 100) { + progressBar.style.backgroundColor = '#FFC107'; // yellow + } else { + progressBar.style.backgroundColor = '#f44336'; // red + } +} + +// Quick add functions for food log +export async function displayQuickAddItems() { + const quickAddSection = document.getElementById('quickAddSection'); + const quickAddContainer = document.getElementById('quickAddItemsContainer'); + + if (!quickAddSection || !quickAddContainer) return; + + // Check if there's an active template + const activeTemplate = await AppwriteDB.getActiveMealPlanTemplate(); + + if (!activeTemplate) { + quickAddSection.classList.add('hidden'); + return; + } + + // Load planned items + const plannedItems = await AppwriteDB.getPlannedItemsByTemplate(activeTemplate.$id); + + if (plannedItems.length === 0) { + quickAddSection.classList.add('hidden'); + return; + } + + // Show section and populate items + quickAddSection.classList.remove('hidden'); + quickAddContainer.innerHTML = ''; + + // Group by meal type + const mealTypes: MealType[] = ['breakfast', 'lunch', 'dinner', 'snacks']; + const mealIcons = { + breakfast: '🌅', + lunch: '☀️', + dinner: '🌙', + snacks: '🍿' + }; + + mealTypes.forEach(mealType => { + const items = plannedItems.filter(item => item.mealType === mealType); + + if (items.length > 0) { + // Add meal type header + const mealHeader = document.createElement('h3'); + mealHeader.className = 'quick-add-meal-header'; + mealHeader.textContent = `${mealIcons[mealType]} ${mealType.charAt(0).toUpperCase() + mealType.slice(1)}`; + quickAddContainer.appendChild(mealHeader); + + // Add items + items.forEach(item => { + const quickAddItem = createQuickAddButton(item); + quickAddContainer.appendChild(quickAddItem); + }); + } + }); +} + +function createQuickAddButton(item: any): HTMLElement { + const button = document.createElement('button'); + button.className = 'quick-add-btn'; + button.innerHTML = ` + ${getCleanName(item.name)} + ${item.grams}g • ${item.calories} cal + `; + button.addEventListener('click', () => quickAddToFoodLog(item)); + return button; +} + +async function quickAddToFoodLog(plannedItem: any) { + showLoading(); + + try { + const now = new Date(); + const time = now.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + + const selectedDate = new Date(); // This should be the actual selected date from the app + const localDateTime = new Date(selectedDate.getTime() - (selectedDate.getTimezoneOffset() * 60000)).toISOString(); + + const foodEntry = { + name: plannedItem.name, + grams: plannedItem.grams, + calories: plannedItem.calories, + protein: plannedItem.protein, + fat: plannedItem.fat, + carbs: plannedItem.carbs, + fiber: plannedItem.fiber, + time: time, + date: localDateTime.split('T')[0], + alkaline: plannedItem.alkaline + }; + + await AppwriteDB.saveFoodEntry(foodEntry); + + // Dispatch custom event to trigger food log reload + const event = new CustomEvent('foodAdded'); + window.dispatchEvent(event); + + hideLoading(); + swal('Success!', `${getCleanName(plannedItem.name)} added to food log!`, 'success'); + } catch (error) { + hideLoading(); + console.error('Quick add to food log error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Failed to add food: ' + error.message, 'error'); + } + } +} + +// Event listener setup +export function setupMealPlannerEventListeners() { + // Navigation buttons + document.getElementById('mealPlannerBtn')?.addEventListener('click', toggleMealPlannerView); + document.getElementById('mealPlannerBtnMobile')?.addEventListener('click', handleMobileMealPlannerClick); + + // Template management + document.getElementById('newTemplateBtn')?.addEventListener('click', createNewTemplate); + document.getElementById('deleteTemplateBtn')?.addEventListener('click', deleteCurrentTemplate); + document.getElementById('templateSelector')?.addEventListener('change', (e) => { + const target = e.target as HTMLSelectElement; + handleTemplateChange(target.value); + }); + + // Add food buttons for each meal + const addMealButtons = document.querySelectorAll('.add-meal-item-btn'); + addMealButtons.forEach(button => { + button.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const mealType = target.getAttribute('data-meal-type') as MealType; + showAddFoodToPlan(mealType); + }); + }); + + // Add food to plan form + document.getElementById('addFoodToPlanBtn')?.addEventListener('click', addFoodToPlan); + document.getElementById('cancelFoodToPlanBtn')?.addEventListener('click', hideAddFoodToPlan); + + // Setup food search + setupPlanFoodSearch(); +} diff --git a/src/state.ts b/src/state.ts index 10c422d..0c32a29 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,4 +1,4 @@ -import { DailyTotalCalories, FoodItem, SharedDay } from "./types"; +import { DailyTotalCalories, FoodItem, SharedDay, MealPlanTemplate, PlannedFoodItem } from "./types"; interface AppState { currentHighlightIndex: number; @@ -7,6 +7,12 @@ interface AppState { calendarMonthlyCalories: DailyTotalCalories[]; isSharedView: boolean; sharedData: SharedDay | null; + // Meal Planner state + currentTemplate: MealPlanTemplate | null; + plannedItems: PlannedFoodItem[]; + planSearchResults: FoodItem[]; + planCurrentHighlightIndex: number; + planSearchTimeout: number | null; } export const appState: AppState = { @@ -15,5 +21,11 @@ export const appState: AppState = { searchResults: [], calendarMonthlyCalories: [], isSharedView: false, - sharedData: null + sharedData: null, + // Meal Planner state + currentTemplate: null, + plannedItems: [], + planSearchResults: [], + planCurrentHighlightIndex: -1, + planSearchTimeout: null }; diff --git a/src/types.ts b/src/types.ts index 305dcf8..f89a87c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,3 +43,27 @@ export type SharedDay = { foodEntries: string; // JSON stringified FoodStorage[] createdAt: string; }; + +export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snacks'; + +export type MealPlanTemplate = { + id?: string; + name: string; + userId: string; + isActive: boolean; + createdAt: string; +}; + +export type PlannedFoodItem = { + id?: string; + templateId: string; + mealType: MealType; + name: string; + grams: number; + calories: number; + protein: number; + fat: number; + carbs: number; + fiber: number; + alkaline: boolean | null; +};