From 8a187fbe152cd639e2543d994740458bf59501a3 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Thu, 12 Feb 2026 19:45:29 -0300 Subject: [PATCH] feat: add multiple goals. by Claude --- index.html | 68 ++++++---- settings.css | 95 ++++++++++++++ src/appwrite.ts | 91 +++++++++++++ src/index.ts | 341 +++++++++++++++++++++++++++++++++++++++--------- src/types.ts | 10 +- 5 files changed, 516 insertions(+), 89 deletions(-) diff --git a/index.html b/index.html index baab997..0636f48 100644 --- a/index.html +++ b/index.html @@ -374,17 +374,16 @@

⚙️ Settings

-
Define your daily macros goal
+
Body Metrics
-
- +
- +
@@ -394,30 +393,49 @@
- -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+
Nutrition Goals
+ +
+ +
+ + + + +
AI Nutrition Cache
diff --git a/settings.css b/settings.css index 1baef4c..ad8bf25 100644 --- a/settings.css +++ b/settings.css @@ -98,3 +98,98 @@ background: #c45a5a; border: 1px solid #c45a5a; } + +.no-goals-msg { + color: #888; + padding: 15px 0; + font-size: 0.95rem; +} + +.goal-card { + backdrop-filter: blur(10px); + border-radius: 10px; + padding: 15px 20px; + background: rgba(40, 40, 40, 0.9); + border: 1px solid rgba(100, 100, 100, 0.3); + margin-bottom: 12px; +} + +.goal-card-active { + border-color: #6B8DD6; +} + +.goal-card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.goal-name { + font-size: 1.05rem; + font-weight: 600; + color: #e0e0e0; +} + +.goal-active-badge { + font-size: 0.75rem; + font-weight: 600; + color: #6B8DD6; + background: rgba(107, 141, 214, 0.15); + border: 1px solid #6B8DD6; + border-radius: 4px; + padding: 2px 7px; +} + +.goal-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + color: #b0b0b0; + font-size: 0.88rem; +} + +.goal-summary span { + background: rgba(60, 60, 60, 0.5); + border-radius: 4px; + padding: 2px 8px; +} + +.goal-actions { + display: flex; + gap: 8px; +} + +.goal-actions .btn { + padding: 6px 14px; + border-radius: 5px; + font-size: 0.85rem; + cursor: pointer; + border: 1px solid #555; + background: #333; + color: #e0e0e0; + transition: background 0.2s; +} + +.goal-actions .btn:hover { + background: #444; +} + +.goal-actions .goal-delete-btn { + border-color: #d66b6b; + color: #d66b6b; +} + +.goal-actions .goal-delete-btn:hover { + background: rgba(214, 107, 107, 0.15); +} + +.goal-actions .goal-select-btn { + border-color: #6B8DD6; + color: #6B8DD6; +} + +.goal-actions .goal-select-btn:hover { + background: rgba(107, 141, 214, 0.15); +} diff --git a/src/appwrite.ts b/src/appwrite.ts index f284bb3..ddcdbda 100644 --- a/src/appwrite.ts +++ b/src/appwrite.ts @@ -264,6 +264,97 @@ export class AppwriteDB { } } + static async updateUserSettings(documentId: string, settings: Partial) { + try { + const response = await databases.updateDocument( + DATABASE_ID, + USER_SETTINGS_COLLECTION_ID, + documentId, + { + ...settings, + userId: (await account.get()).$id, + } + ); + console.debug('User settings updated:', response); + return response; + } catch (error) { + console.error('Update user settings error:', error); + throw error; + } + } + + static async saveUserGoal(goal: UserSettings) { + try { + const response = await databases.createDocument( + DATABASE_ID, + USER_SETTINGS_COLLECTION_ID, + 'unique()', + { + ...goal, + userId: (await account.get()).$id, + } + ); + console.debug('User goal saved:', response); + return response; + } catch (error) { + console.error('Save user goal error:', error); + throw error; + } + } + + static async updateUserGoal(documentId: string, goal: UserSettings) { + try { + const response = await databases.updateDocument( + DATABASE_ID, + USER_SETTINGS_COLLECTION_ID, + documentId, + { + ...goal, + userId: (await account.get()).$id, + } + ); + console.debug('User goal updated:', response); + return response; + } catch (error) { + console.error('Update user goal error:', error); + throw error; + } + } + + static async deleteUserGoal(documentId: string) { + try { + await databases.deleteDocument( + DATABASE_ID, + USER_SETTINGS_COLLECTION_ID, + documentId + ); + console.debug('User goal deleted:', documentId); + } catch (error) { + console.error('Delete user goal error:', error); + throw error; + } + } + + static async setActiveGoal(goalId: string, allGoals: UserSettings[]) { + try { + const updates = allGoals + .filter(goal => goal.id) + .map(goal => + databases.updateDocument( + DATABASE_ID, + USER_SETTINGS_COLLECTION_ID, + goal.id!, + { isActive: goal.id === goalId } + ) + ); + await Promise.all(updates); + console.debug('Active goal set:', goalId); + } catch (error) { + console.error('Set active goal error:', error); + throw error; + } + } + // Delete food entry static async deleteFoodEntry(documentId: string) { try { diff --git a/src/index.ts b/src/index.ts index 74b3faf..f047b52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { foodDatabase } from './foodDatabase.js'; import { getButtonById, getButtonListByClassName, getDivById, getInputById, showFoodPreview } from './DomUtils'; -import { DailyTotalCalories, FoodItem, FoodStorage, MealGroup, MealPeriod } from './types.js'; +import { DailyTotalCalories, FoodItem, FoodStorage, MealGroup, MealPeriod, UserSettings } from './types.js'; import { AppwriteAuth, AppwriteDB } from './appwrite.js'; import swal from 'sweetalert'; import { closeMobileMenu, delay, getCleanName, getIcon, handleMobileCalendarClick, hideLoading, hideSearchResults, navigateResultsKeyboard, QUICK_DELAY, scrollToCalendarView, showLoading, toggleCardHandler, toggleMobileMenu } from './Utils'; @@ -192,34 +192,23 @@ async function handleSaveSettings(e: SubmitEvent) { const height = getInputById('height').value; const bmi = getInputById('bmi').value; const bmiResult = getInputById('bmiResult'); - const caloriesGoal = getInputById('caloriesGoal').value; - const proteinGoal = getInputById('proteinGoal').value; - const fatGoal = getInputById('fatGoal').value; - const carboGoal = getInputById('carboGoal').value; - const fiberGoal = getInputById('fiberGoal').value; - // Delete existing settings - keep only the new one - const documents = await AppwriteDB.getUserSettings(); - const documentsToDelete: string[] = []; - for (let i=0; i !d.goalName); + + if (globalDoc) { + await AppwriteDB.updateUserSettings(globalDoc.$id, metricsData); + } else { + await AppwriteDB.saveUserSettings(metricsData); + } hideLoading(); toggleSettingsView(); @@ -311,27 +300,39 @@ async function toggleSettingsView() { showLoading(); try { - const settings = await AppwriteDB.getUserSettings(); - const hasGoals = Array.isArray(settings) && settings.length > 0; - if (hasGoals) { - const index = settings.length - 1; - getInputById('caloriesGoal').value = settings[index].caloriesGoal; - getInputById('proteinGoal').value = settings[index].proteinGoal; - getInputById('fatGoal').value = settings[index].fatGoal; - getInputById('carboGoal').value = settings[index].carboGoal; - getInputById('fiberGoal').value = settings[index].fiberGoal; - getInputById('bodyWeight').value = settings[index].bodyWeight ?? 0; - getInputById('height').value = settings[index].height ?? 0; - getInputById('bmi').value = settings[index].bmi ?? 0; - getInputById('bmiResult').value = settings[index].bmiResult ?? ''; + const allDocs = await AppwriteDB.getUserSettings(); + const globalSettings = allDocs.find((d: any) => !d.goalName); + + if (globalSettings) { + getInputById('bodyWeight').value = globalSettings.bodyWeight ?? ''; + getInputById('height').value = globalSettings.height ?? ''; + getInputById('bmi').value = globalSettings.bmi ?? ''; + getInputById('bmiResult').value = globalSettings.bmiResult ?? ''; } else { - getInputById('caloriesGoal').value = ''; - getInputById('proteinGoal').value = ''; - getInputById('fatGoal').value = ''; - getInputById('carboGoal').value = ''; - getInputById('fiberGoal').value = ''; + getInputById('bodyWeight').value = ''; + getInputById('height').value = ''; + getInputById('bmi').value = ''; + getInputById('bmiResult').value = ''; } - + + const goals: UserSettings[] = allDocs + .filter((d: any) => !!d.goalName) + .map((d: any) => ({ + id: d.$id, + goalName: d.goalName, + isActive: d.isActive, + caloriesGoal: d.caloriesGoal, + proteinGoal: d.proteinGoal, + fatGoal: d.fatGoal, + carboGoal: d.carboGoal, + fiberGoal: d.fiberGoal, + })); + renderGoalsList(goals); + + // Hide goal form if open + const goalFormContainer = document.getElementById('goalFormContainer'); + if (goalFormContainer) goalFormContainer.classList.add('hidden'); + getButtonById('settingsBtn').textContent = '🔙 Back'; getButtonById('settingsBtnMobile').textContent = '🔙 Back'; @@ -474,6 +475,215 @@ const previewCalories = (foodSelected?: FoodItem) => { showFoodPreview(true); } +// ---- Goal management ---- + +function renderGoalsList(goals: UserSettings[]) { + const container = document.getElementById('goalsContainer'); + if (!container) return; + + if (goals.length === 0) { + container.innerHTML = '
No goals yet. Create one below.
'; + return; + } + + container.innerHTML = goals.map(goal => ` +
+
+
${goal.goalName}
+ ${goal.isActive ? '✓ Active' : ''} +
+
+ ${goal.caloriesGoal ? `${goal.caloriesGoal} cal` : ''} + ${goal.proteinGoal ? `${goal.proteinGoal}g protein` : ''} + ${goal.fatGoal ? `${goal.fatGoal}g fat` : ''} + ${goal.carboGoal ? `${goal.carboGoal}g carbs` : ''} + ${goal.fiberGoal ? `${goal.fiberGoal}g fiber` : ''} +
+
+ + + ${!goal.isActive ? `` : ''} +
+
+ `).join(''); + + document.querySelectorAll('.goal-edit-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const goalId = (e.currentTarget as HTMLElement).getAttribute('data-goal-id'); + const goal = goals.find(g => g.id === goalId); + if (goal) handleEditGoal(goal); + }); + }); + + document.querySelectorAll('.goal-delete-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const goalId = (e.currentTarget as HTMLElement).getAttribute('data-goal-id'); + if (goalId) handleDeleteGoal(goalId); + }); + }); + + document.querySelectorAll('.goal-select-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const goalId = (e.currentTarget as HTMLElement).getAttribute('data-goal-id'); + if (goalId) handleSelectGoal(goalId, goals); + }); + }); +} + +function handleAddGoal() { + const container = document.getElementById('goalFormContainer'); + if (!container) return; + container.classList.remove('hidden'); + + getInputById('goalNameInput').value = ''; + getInputById('goalCaloriesInput').value = ''; + getInputById('goalProteinInput').value = ''; + getInputById('goalFatInput').value = ''; + getInputById('goalCarboInput').value = ''; + getInputById('goalFiberInput').value = ''; + getInputById('editingGoalId').value = ''; +} + +function handleEditGoal(goal: UserSettings) { + const container = document.getElementById('goalFormContainer'); + if (!container) return; + container.classList.remove('hidden'); + + getInputById('goalNameInput').value = goal.goalName ?? ''; + getInputById('goalCaloriesInput').value = goal.caloriesGoal?.toString() ?? ''; + getInputById('goalProteinInput').value = goal.proteinGoal?.toString() ?? ''; + getInputById('goalFatInput').value = goal.fatGoal?.toString() ?? ''; + getInputById('goalCarboInput').value = goal.carboGoal?.toString() ?? ''; + getInputById('goalFiberInput').value = goal.fiberGoal?.toString() ?? ''; + getInputById('editingGoalId').value = goal.id ?? ''; +} + +async function handleSaveGoal(e: SubmitEvent) { + e.preventDefault(); + showLoading(); + + try { + const goalName = getInputById('goalNameInput').value.trim(); + const caloriesGoal = getInputById('goalCaloriesInput').value; + const proteinGoal = getInputById('goalProteinInput').value; + const fatGoal = getInputById('goalFatInput').value; + const carboGoal = getInputById('goalCarboInput').value; + const fiberGoal = getInputById('goalFiberInput').value; + const editingGoalId = getInputById('editingGoalId').value; + + const goalData: UserSettings = { + goalName, + caloriesGoal: caloriesGoal ? parseInt(caloriesGoal) : undefined, + proteinGoal: proteinGoal ? parseInt(proteinGoal) : undefined, + fatGoal: fatGoal ? parseInt(fatGoal) : undefined, + carboGoal: carboGoal ? parseInt(carboGoal) : undefined, + fiberGoal: fiberGoal ? parseInt(fiberGoal) : undefined, + }; + + if (editingGoalId) { + await AppwriteDB.updateUserGoal(editingGoalId, goalData); + } else { + await AppwriteDB.saveUserGoal(goalData); + } + + const allDocs = await AppwriteDB.getUserSettings(); + const goals: UserSettings[] = allDocs + .filter((d: any) => !!d.goalName) + .map((d: any) => ({ + id: d.$id, + goalName: d.goalName, + isActive: d.isActive, + caloriesGoal: d.caloriesGoal, + proteinGoal: d.proteinGoal, + fatGoal: d.fatGoal, + carboGoal: d.carboGoal, + fiberGoal: d.fiberGoal, + })); + renderGoalsList(goals); + + const container = document.getElementById('goalFormContainer'); + if (container) container.classList.add('hidden'); + + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Save goal error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Saving goal failed: ' + error.message, 'error'); + } + } +} + +async function handleDeleteGoal(id: string) { + const willDelete = await swal({ + title: 'Delete Goal?', + text: 'Are you sure you want to delete this goal?', + icon: 'warning', + dangerMode: true, + buttons: ['Cancel', 'Delete'], + }); + + if (!willDelete) return; + + showLoading(); + try { + await AppwriteDB.deleteUserGoal(id); + + const allDocs = await AppwriteDB.getUserSettings(); + const goals: UserSettings[] = allDocs + .filter((d: any) => !!d.goalName) + .map((d: any) => ({ + id: d.$id, + goalName: d.goalName, + isActive: d.isActive, + caloriesGoal: d.caloriesGoal, + proteinGoal: d.proteinGoal, + fatGoal: d.fatGoal, + carboGoal: d.carboGoal, + fiberGoal: d.fiberGoal, + })); + renderGoalsList(goals); + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Delete goal error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Deleting goal failed: ' + error.message, 'error'); + } + } +} + +async function handleSelectGoal(goalId: string, allGoals: UserSettings[]) { + showLoading(); + try { + await AppwriteDB.setActiveGoal(goalId, allGoals); + + const allDocs = await AppwriteDB.getUserSettings(); + const goals: UserSettings[] = allDocs + .filter((d: any) => !!d.goalName) + .map((d: any) => ({ + id: d.$id, + goalName: d.goalName, + isActive: d.isActive, + caloriesGoal: d.caloriesGoal, + proteinGoal: d.proteinGoal, + fatGoal: d.fatGoal, + carboGoal: d.carboGoal, + fiberGoal: d.fiberGoal, + })); + renderGoalsList(goals); + hideLoading(); + } catch (error) { + hideLoading(); + console.error('Select goal error:', error); + if (error instanceof Error) { + swal('Oh no!', 'Selecting goal failed: ' + error.message, 'error'); + } + } +} + +// ---- End goal management ---- + const setupEventListeners = () => { getInputById('foodSearchInput').addEventListener('change', (e: Event) => { const target = e.target as HTMLInputElement; @@ -549,6 +759,14 @@ const setupEventListeners = () => { // Clear cache button getButtonById('clearCacheBtn')?.addEventListener('click', handleClearCache); + // Goal management buttons + document.getElementById('addGoalBtn')?.addEventListener('click', handleAddGoal); + document.getElementById('cancelGoalBtn')?.addEventListener('click', () => { + const container = document.getElementById('goalFormContainer'); + if (container) container.classList.add('hidden'); + }); + document.getElementById('goalForm')?.addEventListener('submit', handleSaveGoal); + // AI nutrition checkbox document.getElementById('enableAINutrition')?.addEventListener('change', handleAICheckboxChange); @@ -1363,51 +1581,54 @@ async function loadFoodEntries(date: Date) { getDivById('fiberValue').textContent = (Math.round(totalFiber * 10) / 10).toString(); // Update goals - const settings = await AppwriteDB.getUserSettings(); - const hasGoals = Array.isArray(settings) && settings.length > 0; - if (hasGoals) { - const index = settings.length - 1; - const bodyWeight = parseInt(settings[index].bodyWeight); + const allDocs = await AppwriteDB.getUserSettings(); + const globalSettings = allDocs.find((d: any) => !d.goalName); + const activeGoal = allDocs.find((d: any) => d.goalName && d.isActive); + + if (activeGoal) { + const bodyWeight = globalSettings ? parseInt(globalSettings.bodyWeight) : 0; + getDivById('caloriesGoalText').classList.add('hidden'); - if (parseInt(settings[index].caloriesGoal) > 0) { - getDivById('caloriesGoalText').textContent = `of ${settings[index].caloriesGoal}`; + if (activeGoal.caloriesGoal && parseInt(activeGoal.caloriesGoal) > 0) { + getDivById('caloriesGoalText').textContent = `of ${activeGoal.caloriesGoal}`; getDivById('caloriesGoalText').classList.remove('hidden'); } getDivById('proteinGoalText').classList.add('hidden'); - if (parseInt(settings[index].proteinGoal) > 0) { + if (activeGoal.proteinGoal && parseInt(activeGoal.proteinGoal) > 0) { let percent = ''; if (bodyWeight > 0) { - const proteinPerKg = Math.round((parseInt(settings[index].proteinGoal) / bodyWeight) * 10) / 10; + const proteinPerKg = Math.round((parseInt(activeGoal.proteinGoal) / bodyWeight) * 10) / 10; percent = ` (${proteinPerKg} g/kg)`; } - getDivById('proteinGoalText').textContent = `of ${settings[index].proteinGoal}${percent}`; + getDivById('proteinGoalText').textContent = `of ${activeGoal.proteinGoal}${percent}`; getDivById('proteinGoalText').classList.remove('hidden'); } getDivById('fatGoalText').classList.add('hidden'); - if (parseInt(settings[index].fatGoal) > 0) { - getDivById('fatGoalText').textContent = `of ${settings[index].fatGoal}`; + if (activeGoal.fatGoal && parseInt(activeGoal.fatGoal) > 0) { + getDivById('fatGoalText').textContent = `of ${activeGoal.fatGoal}`; getDivById('fatGoalText').classList.remove('hidden'); } getDivById('carboGoalText').classList.add('hidden'); - if (parseInt(settings[index].carboGoal) > 0) { + if (activeGoal.carboGoal && parseInt(activeGoal.carboGoal) > 0) { let percent = ''; if (bodyWeight > 0) { - const carboPerKg = Math.round((parseInt(settings[index].carboGoal) / bodyWeight) * 10) / 10; + const carboPerKg = Math.round((parseInt(activeGoal.carboGoal) / bodyWeight) * 10) / 10; percent = ` (${carboPerKg} g/kg)`; } - getDivById('carboGoalText').textContent = `of ${settings[index].carboGoal}${percent}`; + getDivById('carboGoalText').textContent = `of ${activeGoal.carboGoal}${percent}`; getDivById('carboGoalText').classList.remove('hidden'); } getDivById('fiberGoalText').classList.add('hidden'); - if (parseInt(settings[index].fiberGoal) > 0) { - getDivById('fiberGoalText').textContent = `of ${settings[index].fiberGoal}`; + if (activeGoal.fiberGoal && parseInt(activeGoal.fiberGoal) > 0) { + getDivById('fiberGoalText').textContent = `of ${activeGoal.fiberGoal}`; getDivById('fiberGoalText').classList.remove('hidden'); } } else { + getDivById('caloriesGoalText').classList.add('hidden'); getDivById('proteinGoalText').classList.add('hidden'); getDivById('fatGoalText').classList.add('hidden'); getDivById('carboGoalText').classList.add('hidden'); diff --git a/src/types.ts b/src/types.ts index 4011bee..7e720cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,14 +23,16 @@ export type FoodStorage = { export type UserSettings = { id?: string; caloriesGoal?: number; - proteinGoal: number; - fatGoal: number; - carboGoal: number; - fiberGoal: number; + proteinGoal?: number; + fatGoal?: number; + carboGoal?: number; + fiberGoal?: number; bodyWeight?: number; height?: number; bmi?: number; bmiResult?: string; + goalName?: string; + isActive?: boolean; }; export type DailyTotalCalories = {