// 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 = `