// app.js (() => { "use strict"; const API = { analyze: "/api/analyze", recalc: "/api/recalc", saveMeal: "/api/saveMeal", getMeals: "/api/getMeals", getDailyTotals: "/api/getDailyTotals", register: "/api/register", login: "/api/login", me: "/api/me", logout: "/api/logout", deleteMeal: "/api/deleteMeal", updateMeal: "/api/updateMeal", }; const state = { user: null, isAuthenticated: false, selectedImageBase64: "", selectedFile: null, meals: [], dailyTotals: null, selectedDate: formatDateInput(new Date()), isEditing: false, editingMealId: null, analysisResult: null, anonymousScansUsed: 0, anonymousScansLimit: 3, }; const els = {}; document.addEventListener("DOMContentLoaded", init); async function init() { cacheDom(); bindEvents(); initializeUi(); await restoreSession(); await loadMeals(); await loadDailyTotals(); } function cacheDom() { // Account els.authBox = document.getElementById("authBox"); els.authEmail = document.getElementById("authEmail"); els.authPassword = document.getElementById("authPassword"); els.registerBtn = document.getElementById("registerBtn"); els.loginBtn = document.getElementById("loginBtn"); els.currentUserBox = document.getElementById("currentUserBox"); els.logoutWrap = document.getElementById("logoutWrap"); els.logoutBtn = document.getElementById("logoutBtn"); // Scan els.photoInput = document.getElementById("photo"); els.preview = document.getElementById("preview"); els.previewImg = document.getElementById("img"); els.confirmPhotoBtn = document.getElementById("confirmPhoto"); els.retakePhotoBtn = document.getElementById("retakePhoto"); els.status = document.getElementById("status"); // Editor els.pretty = document.getElementById("pretty"); els.result = document.getElementById("result"); els.editBtn = document.getElementById("editBtn"); els.cancelEditBtn = document.getElementById("cancelEditBtn"); els.totalCalories = document.getElementById("totalCalories"); els.totalProtein = document.getElementById("totalProtein"); els.totalFat = document.getElementById("totalFat"); els.totalCarbs = document.getElementById("totalCarbs"); els.consumedAtInput = document.getElementById("consumedAtInput"); els.mealName = document.getElementById("mealName"); els.ingredientsInput = document.getElementById("ingredientsInput"); els.items = document.getElementById("items"); els.waterInput = document.getElementById("waterInput"); els.notes = document.getElementById("notes"); els.saveMealBtn = document.getElementById("saveMealBtn"); // Lists els.loadMealsBtn = document.getElementById("loadMealsBtn"); els.recentMeals = document.getElementById("recentMeals"); els.dailyDateInput = document.getElementById("dailyDateInput"); els.loadDailyTotalsBtn = document.getElementById("loadDailyTotalsBtn"); els.dailyTotalsBox = document.getElementById("dailyTotalsBox"); } function bindEvents() { if (els.registerBtn) { els.registerBtn.addEventListener("click", onRegister); } if (els.loginBtn) { els.loginBtn.addEventListener("click", onLogin); } if (els.logoutBtn) { els.logoutBtn.addEventListener("click", onLogout); } if (els.authPassword) { els.authPassword.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); onLogin(); } }); } if (els.photoInput) { els.photoInput.addEventListener("change", onPhotoSelected); } if (els.confirmPhotoBtn) { els.confirmPhotoBtn.addEventListener("click", onAnalyzePhoto); } if (els.retakePhotoBtn) { els.retakePhotoBtn.addEventListener("click", clearPhotoSelection); } if (els.consumedAtInput) { els.consumedAtInput.addEventListener("change", () => { if (!safeValue(els.mealName)) { els.mealName.value = defaultMealNameFromTime(els.consumedAtInput.value); } }); } if (els.ingredientsInput) { els.ingredientsInput.addEventListener("input", () => { // user can override summary freely }); } if (els.items) { els.items.addEventListener("input", onItemsInput); els.items.addEventListener("click", onItemsClick); } if (els.cancelEditBtn) { els.cancelEditBtn.addEventListener("click", cancelEditing); } if (els.saveMealBtn) { els.saveMealBtn.addEventListener("click", onSaveOrUpdateMeal); } if (els.loadMealsBtn) { els.loadMealsBtn.addEventListener("click", loadMeals); } if (els.dailyDateInput) { els.dailyDateInput.addEventListener("change", () => { state.selectedDate = els.dailyDateInput.value || formatDateInput(new Date()); }); } if (els.loadDailyTotalsBtn) { els.loadDailyTotalsBtn.addEventListener("click", loadDailyTotals); } } function initializeUi() { if (els.dailyDateInput) { els.dailyDateInput.value = state.selectedDate; } if (els.consumedAtInput && !els.consumedAtInput.value) { els.consumedAtInput.value = formatDateTimeLocal(new Date()); } if (els.mealName && !els.mealName.value) { els.mealName.value = defaultMealNameFromTime(els.consumedAtInput?.value); } if (els.pretty) { els.pretty.classList.add("hidden"); } if (els.preview) { els.preview.classList.add("hidden"); } if (els.result) { els.result.classList.add("hidden"); } setEditorMode(false); renderAuthUi(); renderTotals(0, 0, 0, 0); renderNotes(""); } async function restoreSession() { try { const data = await apiFetch(API.me, { method: "GET" }, true); if (data?.user) { state.user = data.user; state.isAuthenticated = true; } else { state.user = null; state.isAuthenticated = false; } } catch (_) { state.user = null; state.isAuthenticated = false; } renderAuthUi(); } function renderAuthUi() { if (state.isAuthenticated && state.user) { if (els.authBox) els.authBox.classList.add("hidden"); if (els.logoutWrap) els.logoutWrap.classList.remove("hidden"); if (els.currentUserBox) { const email = escapeHtml(state.user.email || ""); els.currentUserBox.innerHTML = ` Logged in
${email} `; els.currentUserBox.classList.remove("hidden"); } } else { if (els.authBox) els.authBox.classList.remove("hidden"); if (els.logoutWrap) els.logoutWrap.classList.add("hidden"); if (els.currentUserBox) { els.currentUserBox.innerHTML = ""; els.currentUserBox.classList.add("hidden"); } } } async function onRegister() { const email = safeValue(els.authEmail); const password = safeValue(els.authPassword); if (!email || !password) { setStatus("Enter email and password."); return; } try { setBusy(true, "Creating account..."); const data = await apiFetch(API.register, { method: "POST", body: JSON.stringify({ email, password }), }); state.user = data?.user || { email }; state.isAuthenticated = true; renderAuthUi(); clearAuthFields(); setStatus("Account created. You are logged in."); await loadMeals(); await loadDailyTotals(); } catch (err) { setStatus(getErrorMessage(err, "Sign up failed.")); } finally { setBusy(false); } } async function onLogin() { const email = safeValue(els.authEmail); const password = safeValue(els.authPassword); if (!email || !password) { setStatus("Enter email and password."); return; } try { setBusy(true, "Logging in..."); const data = await apiFetch(API.login, { method: "POST", body: JSON.stringify({ email, password }), }); state.user = data?.user || { email }; state.isAuthenticated = true; renderAuthUi(); clearAuthFields(); setStatus("Logged in."); await loadMeals(); await loadDailyTotals(); } catch (err) { setStatus(getErrorMessage(err, "Login failed.")); } finally { setBusy(false); } } async function onLogout() { try { setBusy(true, "Logging out..."); await apiFetch(API.logout, { method: "POST" }, true); } catch (_) { // ignore logout API failure and still reset UI } finally { state.user = null; state.isAuthenticated = false; renderAuthUi(); setStatus("Logged out."); setBusy(false); await loadMeals(); await loadDailyTotals(); } } async function onPhotoSelected(e) { const file = e.target.files?.[0]; if (!file) return; try { state.selectedFile = file; state.selectedImageBase64 = await fileToBase64(file); if (els.previewImg) { els.previewImg.src = state.selectedImageBase64; } if (els.preview) { els.preview.classList.remove("hidden"); } setStatus("Photo selected. Click 'Use this photo'."); } catch (err) { setStatus("Could not load photo."); } } function clearPhotoSelection() { state.selectedFile = null; state.selectedImageBase64 = ""; if (els.photoInput) els.photoInput.value = ""; if (els.previewImg) els.previewImg.src = ""; if (els.preview) els.preview.classList.add("hidden"); setStatus("Choose another photo."); } async function onAnalyzePhoto() { if (!state.selectedImageBase64) { setStatus("Choose a photo first."); return; } try { setBusy(true, "Analyzing photo..."); const data = await apiFetch(API.analyze, { method: "POST", body: JSON.stringify({ image: state.selectedImageBase64 }), }); state.analysisResult = data; fillEditorFromAnalysis(data); setEditorMode(false); if (els.pretty) els.pretty.classList.remove("hidden"); setStatus("Analysis complete. Review and save."); } catch (err) { setStatus(getErrorMessage(err, "Analyze failed.")); } finally { setBusy(false); } } function fillEditorFromAnalysis(data) { const items = normalizeItems( data?.items || data?.result?.items || data?.meal?.items || [] ); const mealName = safeString(data?.meal_name) || safeString(data?.mealName) || defaultMealNameFromTime(els.consumedAtInput?.value); const ingredients = safeString(data?.ingredients_summary) || safeString(data?.ingredientsSummary) || items.map((i) => i.name).filter(Boolean).join(", "); if (els.consumedAtInput && !els.consumedAtInput.value) { els.consumedAtInput.value = formatDateTimeLocal(new Date()); } if (els.mealName) els.mealName.value = mealName; if (els.ingredientsInput) els.ingredientsInput.value = ingredients; if (els.waterInput) els.waterInput.value = String(numberOrZero(data?.water)); renderItems(items); renderNotes(data?.notes || ""); renderTotalsFromItems(items, data); } function renderItems(items) { if (!els.items) return; const normalized = Array.isArray(items) && items.length ? items : [emptyItem()]; els.items.innerHTML = ""; normalized.forEach((item) => { els.items.appendChild(buildItemRow(item)); }); } function buildItemRow(item) { const row = document.createElement("div"); row.className = "item-row"; row.innerHTML = `
Item
`; return row; } function renderNotes(value) { if (!els.notes) return; els.notes.innerHTML = ` `; } function onItemsInput(e) { const target = e.target; if (!target) return; if ( target.classList.contains("item-name") || target.classList.contains("item-calories") || target.classList.contains("item-protein") || target.classList.contains("item-fat") || target.classList.contains("item-carbs") ) { updateIngredientsFromItemsIfBlankish(); renderTotalsFromItems(getEditorItems()); } } function onItemsClick(e) { const target = e.target; if (!(target instanceof HTMLElement)) return; if (target.classList.contains("add-item-btn")) { e.preventDefault(); els.items.appendChild(buildItemRow(emptyItem())); return; } if (target.classList.contains("remove-item-btn")) { e.preventDefault(); const row = target.closest(".item-row"); if (row) row.remove(); if (!els.items.children.length) { els.items.appendChild(buildItemRow(emptyItem())); } updateIngredientsFromItemsIfBlankish(); renderTotalsFromItems(getEditorItems()); } } function updateIngredientsFromItemsIfBlankish() { if (!els.ingredientsInput) return; const current = safeValue(els.ingredientsInput); const generated = getEditorItems().map((i) => i.name).filter(Boolean).join(", "); if (!current || current === generated) { els.ingredientsInput.value = generated; } } function getEditorItems() { if (!els.items) return []; return Array.from(els.items.querySelectorAll(".item-row")) .map((row) => ({ name: safeValue(row.querySelector(".item-name")), calories: numberOrZero(row.querySelector(".item-calories")?.value), protein: numberOrZero(row.querySelector(".item-protein")?.value), fat: numberOrZero(row.querySelector(".item-fat")?.value), carbs: numberOrZero(row.querySelector(".item-carbs")?.value), })) .filter((item) => item.name || item.calories || item.protein || item.fat || item.carbs); } function renderTotalsFromItems(items, fallbackData = null) { const calories = numberOrZero(fallbackData?.total_calories) || sum(items, "calories"); const protein = numberOrZero(fallbackData?.total_protein) || sum(items, "protein"); const fat = numberOrZero(fallbackData?.total_fat) || sum(items, "fat"); const carbs = numberOrZero(fallbackData?.total_carbs) || sum(items, "carbs"); renderTotals(calories, protein, fat, carbs); } function renderTotals(calories, protein, fat, carbs) { if (els.totalCalories) els.totalCalories.textContent = formatNumber(calories); if (els.totalProtein) els.totalProtein.textContent = formatNumber(protein); if (els.totalFat) els.totalFat.textContent = formatNumber(fat); if (els.totalCarbs) els.totalCarbs.textContent = formatNumber(carbs); } function setEditorMode(editing) { state.isEditing = editing; if (els.editBtn) { els.editBtn.textContent = editing ? "Editing meal" : "New meal"; } if (els.cancelEditBtn) { if (editing) { els.cancelEditBtn.classList.remove("hidden"); } else { els.cancelEditBtn.classList.add("hidden"); } } if (els.saveMealBtn) { els.saveMealBtn.textContent = editing ? "Update Meal" : "Save Meal"; } } function cancelEditing() { state.isEditing = false; state.editingMealId = null; setEditorMode(false); clearEditor(); setStatus("Edit canceled."); } function clearEditor() { if (els.pretty) els.pretty.classList.add("hidden"); if (els.mealName) els.mealName.value = defaultMealNameFromTime(els.consumedAtInput?.value); if (els.ingredientsInput) els.ingredientsInput.value = ""; if (els.waterInput) els.waterInput.value = "0"; renderItems([emptyItem()]); renderNotes(""); renderTotals(0, 0, 0, 0); } async function onSaveOrUpdateMeal() { const payload = buildMealPayload(); if (!payload) return; try { setBusy(true, state.isEditing ? "Updating meal..." : "Saving meal..."); if (state.isEditing && state.editingMealId) { await apiFetch(API.updateMeal, { method: "POST", body: JSON.stringify({ id: state.editingMealId, ...payload, }), }); setStatus("Meal updated."); } else { await apiFetch(API.saveMeal, { method: "POST", body: JSON.stringify(payload), }); setStatus("Meal saved."); } state.isEditing = false; state.editingMealId = null; setEditorMode(false); clearPhotoSelection(); clearEditor(); await loadMeals(); await loadDailyTotals(); } catch (err) { setStatus(getErrorMessage(err, state.isEditing ? "Update failed." : "Save failed.")); } finally { setBusy(false); } } function buildMealPayload() { const consumedAt = els.consumedAtInput?.value || formatDateTimeLocal(new Date()); const mealName = safeValue(els.mealName) || defaultMealNameFromTime(consumedAt); const ingredientsSummary = safeValue(els.ingredientsInput); const items = getEditorItems(); const notes = safeValue(document.getElementById("notesTextarea")); const water = numberOrZero(els.waterInput?.value); if (!items.length) { setStatus("Add at least one item."); return null; } return { consumed_at: new Date(consumedAt).toISOString(), meal_name: mealName, ingredients_summary: ingredientsSummary, items, items_json: items, total_calories: sum(items, "calories"), total_protein: sum(items, "protein"), total_fat: sum(items, "fat"), total_carbs: sum(items, "carbs"), water, notes, }; } async function loadMeals() { if (!els.recentMeals) return; try { const data = await apiFetch(API.getMeals, { method: "GET" }, true); state.meals = Array.isArray(data?.meals) ? data.meals : Array.isArray(data) ? data : []; renderRecentMeals(); } catch (err) { els.recentMeals.innerHTML = `
Could not load meals.
`; } } function renderRecentMeals() { if (!els.recentMeals) return; if (!state.meals.length) { els.recentMeals.innerHTML = `
No meals yet.
`; return; } els.recentMeals.innerHTML = ""; state.meals.forEach((meal) => { const item = document.createElement("div"); item.className = "status-card"; item.innerHTML = `
${escapeHtml(meal.meal_name || "Meal")}
${escapeHtml(meal.ingredients_summary || "")}
${formatMealDate(meal.consumed_at || meal.saved_at)}
${formatNumber(meal.total_calories)} cal • P ${formatNumber(meal.total_protein)} • F ${formatNumber(meal.total_fat)} • C ${formatNumber(meal.total_carbs)} • Water ${formatNumber(meal.water || 0)} ml
`; item.querySelector(".edit-meal-btn")?.addEventListener("click", () => { startEditingMeal(meal); }); item.querySelector(".delete-meal-btn")?.addEventListener("click", async () => { await deleteMeal(meal.id); }); els.recentMeals.appendChild(item); }); } function startEditingMeal(meal) { state.isEditing = true; state.editingMealId = meal.id; setEditorMode(true); if (els.pretty) els.pretty.classList.remove("hidden"); if (els.consumedAtInput) { els.consumedAtInput.value = formatDateTimeLocal(new Date(meal.consumed_at || meal.saved_at || Date.now())); } if (els.mealName) els.mealName.value = meal.meal_name || ""; if (els.ingredientsInput) els.ingredientsInput.value = meal.ingredients_summary || ""; if (els.waterInput) els.waterInput.value = String(numberOrZero(meal.water)); renderItems(normalizeItems(meal.items_json || meal.items || [])); renderNotes(meal.notes || ""); renderTotals( meal.total_calories, meal.total_protein, meal.total_fat, meal.total_carbs ); setStatus("Editing meal."); els.pretty.scrollIntoView({ behavior: "smooth", block: "start" }); } async function deleteMeal(id) { const ok = window.confirm("Delete this meal?"); if (!ok) return; try { setBusy(true, "Deleting meal..."); await apiFetch(API.deleteMeal, { method: "POST", body: JSON.stringify({ id }), }); if (state.editingMealId === id) { cancelEditing(); } setStatus("Meal deleted."); await loadMeals(); await loadDailyTotals(); } catch (err) { setStatus(getErrorMessage(err, "Delete failed.")); } finally { setBusy(false); } } async function loadDailyTotals() { if (!els.dailyTotalsBox) return; const date = els.dailyDateInput?.value || state.selectedDate || formatDateInput(new Date()); state.selectedDate = date; try { const data = await apiFetch(`${API.getDailyTotals}?date=${encodeURIComponent(date)}`, { method: "GET", }, true); const totals = data?.totals || data || {}; state.dailyTotals = totals; els.dailyTotalsBox.innerHTML = `
${escapeHtml(date)}
Calories: ${formatNumber(totals.total_calories || totals.calories || 0)}
Protein: ${formatNumber(totals.total_protein || totals.protein || 0)} g
Fat: ${formatNumber(totals.total_fat || totals.fat || 0)} g
Carbs: ${formatNumber(totals.total_carbs || totals.carbs || 0)} g
Water: ${formatNumber(totals.water || 0)} ml
`; } catch (err) { els.dailyTotalsBox.textContent = "Could not load daily totals."; } } async function apiFetch(url, options = {}, silent = false) { const response = await fetch(url, { headers: { "Content-Type": "application/json", }, credentials: "include", ...options, }); const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; } if (!response.ok) { const message = data?.error || data?.message || (typeof data === "string" ? data : "") || `Request failed (${response.status})`; throw new Error(message); } return data; } function setBusy(isBusy, message = "") { const disabled = !!isBusy; [ els.registerBtn, els.loginBtn, els.logoutBtn, els.confirmPhotoBtn, els.retakePhotoBtn, els.saveMealBtn, els.loadMealsBtn, els.loadDailyTotalsBtn, ].forEach((btn) => { if (btn) btn.disabled = disabled; }); if (message) { setStatus(message); } } function setStatus(message) { if (els.status) { els.status.textContent = message || ""; } } function clearAuthFields() { if (els.authEmail) els.authEmail.value = ""; if (els.authPassword) els.authPassword.value = ""; } function normalizeItems(raw) { let items = raw; if (typeof raw === "string") { try { items = JSON.parse(raw); } catch { items = []; } } if (!Array.isArray(items)) return []; return items.map((item) => ({ name: item?.name || item?.item || "", calories: numberOrZero(item?.calories), protein: numberOrZero(item?.protein), fat: numberOrZero(item?.fat), carbs: numberOrZero(item?.carbs), })); } function emptyItem() { return { name: "", calories: 0, protein: 0, fat: 0, carbs: 0, }; } function sum(items, key) { return round1((items || []).reduce((acc, item) => acc + numberOrZero(item?.[key]), 0)); } function safeValue(el) { return (el?.value || "").trim(); } function safeString(value) { return value == null ? "" : String(value).trim(); } function numberOrZero(value) { const n = parseFloat(value); return Number.isFinite(n) ? round1(n) : 0; } function numberOrBlank(value) { const n = parseFloat(value); return Number.isFinite(n) ? String(round1(n)) : ""; } function round1(n) { return Math.round((Number(n) + Number.EPSILON) * 10) / 10; } function formatNumber(value) { const n = numberOrZero(value); return Number.isInteger(n) ? String(n) : n.toFixed(1); } function formatDateInput(date) { const pad = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } function formatDateTimeLocal(date) { const d = date instanceof Date ? date : new Date(date); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function formatMealDate(value) { if (!value) return ""; const d = new Date(value); if (Number.isNaN(d.getTime())) return String(value); return d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function defaultMealNameFromTime(value) { const d = value ? new Date(value) : new Date(); const hour = d.getHours(); if (hour < 11) return "Breakfast"; if (hour < 15) return "Lunch"; if (hour < 19) return "Dinner"; return "Snack"; } function getErrorMessage(err, fallback) { return err?.message || fallback; } function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeAttr(value) { return escapeHtml(value); } function escapeHtmlText(value) { return escapeHtml(value); } function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || "")); reader.onerror = reject; reader.readAsDataURL(file); }); } })();