const STORAGE_KEY = "kcal-intakes-v1"; const THEME_KEY = "kcal-theme-v1"; const GOALS_KEY = "kcal-goals-v1"; const FOODS_URL = "foods.json"; const DEFAULT_GOALS = { kcal: 2200, protein: 140, carbs: 220, fat: 70, sugar: 60 }; const state = { foods: [], intakes: [], selectedDate: "", theme: "light", goals: { ...DEFAULT_GOALS }, foodSuggestionIndex: -1, }; const elements = { foodInput: document.getElementById("food-input"), gramsInput: document.getElementById("grams-input"), foodSuggestions: document.getElementById("food-suggestions"), foodHint: document.getElementById("food-hint"), previewBase: document.getElementById("preview-base"), previewScaled: document.getElementById("preview-scaled"), addForm: document.getElementById("add-intake"), intakeForm: document.getElementById("intake-form"), entryDate: document.getElementById("entry-date"), todayDate: document.getElementById("today-date"), todaySelectDate: document.getElementById("today-select-date"), todayTotals: document.getElementById("today-totals"), todayList: document.getElementById("today-list"), historyTotals: document.getElementById("history-totals"), historyBreakdown: document.getElementById("history-breakdown"), historyKcalChart: document.getElementById("history-kcal-chart"), historyMacroChart: document.getElementById("history-macro-chart"), kcalChartCard: document.getElementById("kcal-chart-card"), macroChartCard: document.getElementById("macro-chart-card"), settingsView: document.getElementById("settings-view"), goalsForm: document.getElementById("goals-form"), goalKcal: document.getElementById("goal-kcal"), goalProtein: document.getElementById("goal-protein"), goalCarbs: document.getElementById("goal-carbs"), goalFat: document.getElementById("goal-fat"), goalSugar: document.getElementById("goal-sugar"), goalsHint: document.getElementById("goals-hint"), exportDataButton: document.getElementById("export-data"), importDataButton: document.getElementById("import-data"), importFileInput: document.getElementById("import-file"), rangeSelect: document.getElementById("range-select"), anchorDate: document.getElementById("anchor-date"), themeToggle: document.getElementById("theme-toggle"), tabs: document.querySelectorAll(".tab"), todayView: document.getElementById("today-view"), historyView: document.getElementById("history-view"), rowTemplate: document.getElementById("intake-row"), }; const toISODate = (date) => date.toISOString().slice(0, 10); const fmtDate = (date) => date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", }); const fmtRange = (start, end) => { const options = { month: "short", day: "numeric" }; const startLabel = start.toLocaleDateString(undefined, options); const endLabel = end.toLocaleDateString(undefined, options); if (startLabel === endLabel) return startLabel; return `${startLabel} - ${endLabel}`; }; const fromISODate = (dateKey) => new Date(`${dateKey}T00:00:00`); const fmtChartDate = (dateKey) => fromISODate(dateKey).toLocaleDateString(undefined, { month: "numeric", day: "numeric", }); const SVG_NS = "http://www.w3.org/2000/svg"; const loadIntakes = () => { const raw = localStorage.getItem(STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; state.intakes = Array.isArray(parsed) ? parsed .filter((entry) => entry && typeof entry === "object") .map((entry) => ({ ...entry, sugar: typeof entry.sugar === "number" ? entry.sugar : 0, })) : []; }; const saveIntakes = () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.intakes)); }; const loadTheme = () => { const savedTheme = localStorage.getItem(THEME_KEY); if (savedTheme === "light" || savedTheme === "dark") { state.theme = savedTheme; return; } state.theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; }; const saveTheme = () => { localStorage.setItem(THEME_KEY, state.theme); }; const normalizeGoals = (rawGoals) => { const base = { ...DEFAULT_GOALS }; if (!rawGoals || typeof rawGoals !== "object") return base; const goalKeys = ["kcal", "protein", "carbs", "fat", "sugar"]; goalKeys.forEach((key) => { const value = Number(rawGoals[key]); if (Number.isFinite(value) && value >= 0) base[key] = value; }); return base; }; const loadGoals = () => { const rawGoals = localStorage.getItem(GOALS_KEY); state.goals = rawGoals ? normalizeGoals(JSON.parse(rawGoals)) : { ...DEFAULT_GOALS }; }; const saveGoals = () => { localStorage.setItem(GOALS_KEY, JSON.stringify(state.goals)); }; const getLatestIntakeDate = () => { if (!state.intakes.length) return toISODate(new Date()); return state.intakes.reduce( (latest, entry) => (entry.date > latest ? entry.date : latest), state.intakes[0].date ); }; const createBackupPayload = () => ({ app: "macros", version: 1, exportedAt: new Date().toISOString(), storage: { [STORAGE_KEY]: state.intakes, [THEME_KEY]: state.theme, [GOALS_KEY]: state.goals, }, }); const parseImportedPayload = (payload) => { if (!payload || typeof payload !== "object") return null; const source = payload.storage && typeof payload.storage === "object" ? payload.storage : payload; let importedIntakes = source[STORAGE_KEY] ?? source.intakes; let importedTheme = source[THEME_KEY] ?? source.theme; let importedGoals = source[GOALS_KEY] ?? source.goals; if (typeof importedIntakes === "string") { importedIntakes = JSON.parse(importedIntakes); } if (typeof importedTheme === "string") { importedTheme = importedTheme.trim().toLowerCase(); } if (typeof importedGoals === "string") { importedGoals = JSON.parse(importedGoals); } if (!Array.isArray(importedIntakes)) return null; if (importedTheme !== "light" && importedTheme !== "dark") importedTheme = "light"; const intakes = importedIntakes .filter( (entry) => entry && typeof entry === "object" && typeof entry.food === "string" && typeof entry.date === "string" && typeof entry.grams === "number" && typeof entry.kcal === "number" && typeof entry.protein === "number" && typeof entry.carbs === "number" && typeof entry.fat === "number" ) .map((entry) => ({ id: typeof entry.id === "string" ? entry.id : createEntryId(), food: entry.food, grams: entry.grams, date: entry.date, kcal: entry.kcal, protein: entry.protein, carbs: entry.carbs, fat: entry.fat, sugar: typeof entry.sugar === "number" ? entry.sugar : 0, })); return { intakes, theme: importedTheme, goals: normalizeGoals(importedGoals) }; }; const findFood = (name) => state.foods.find((food) => food.name.toLowerCase() === name.toLowerCase()); const fmtMacroLine = (macros) => `kcal ${macros.kcal.toFixed(0)} · P ${macros.protein.toFixed( 1 )}g · C ${macros.carbs.toFixed(1)}g · S ${macros.sugar.toFixed( 1 )}g · F ${macros.fat.toFixed(1)}g`; const renderIntakePreview = () => { const name = elements.foodInput.value.trim(); const grams = Number(elements.gramsInput.value); if (!name) { elements.previewBase.textContent = "Select a food to preview macros."; elements.previewScaled.textContent = "Enter grams to see calculated macros."; return; } const food = findFood(name); if (!food) { elements.previewBase.textContent = "Food not found in list."; elements.previewScaled.textContent = "Choose a listed food to calculate values."; return; } elements.previewBase.textContent = fmtMacroLine(food); if (!Number.isFinite(grams) || grams <= 0) { elements.previewScaled.textContent = "Enter a valid grams value."; return; } elements.previewScaled.textContent = fmtMacroLine(calcFromFood(food, grams)); }; const getFilteredFoods = (query) => { const normalized = query.trim().toLowerCase(); if (!normalized) return state.foods.slice(0, 12); const startsWith = state.foods.filter((food) => food.name.toLowerCase().startsWith(normalized) ); const includes = state.foods.filter( (food) => !food.name.toLowerCase().startsWith(normalized) && food.name.toLowerCase().includes(normalized) ); return [...startsWith, ...includes].slice(0, 12); }; const hideFoodSuggestions = () => { elements.foodSuggestions.classList.add("hidden"); elements.foodSuggestions.innerHTML = ""; state.foodSuggestionIndex = -1; }; const selectFoodSuggestion = (foodName) => { elements.foodInput.value = foodName; hideFoodSuggestions(); renderIntakePreview(); }; const renderFoodSuggestions = (query) => { const foods = getFilteredFoods(query); state.foodSuggestionIndex = -1; elements.foodSuggestions.innerHTML = ""; if (!foods.length) { hideFoodSuggestions(); return; } foods.forEach((food, index) => { const option = document.createElement("button"); option.type = "button"; option.className = "food-suggestion"; option.textContent = food.name; option.dataset.index = String(index); option.addEventListener("mousedown", (event) => { event.preventDefault(); selectFoodSuggestion(food.name); }); elements.foodSuggestions.appendChild(option); }); elements.foodSuggestions.classList.remove("hidden"); }; const moveFoodSuggestionSelection = (direction) => { const options = Array.from( elements.foodSuggestions.querySelectorAll(".food-suggestion") ); if (!options.length) return; if (elements.foodSuggestions.classList.contains("hidden")) { renderFoodSuggestions(elements.foodInput.value); return; } state.foodSuggestionIndex += direction; if (state.foodSuggestionIndex < 0) state.foodSuggestionIndex = options.length - 1; if (state.foodSuggestionIndex >= options.length) state.foodSuggestionIndex = 0; options.forEach((option, idx) => option.classList.toggle("active", idx === state.foodSuggestionIndex) ); }; const pickActiveFoodSuggestion = () => { if (state.foodSuggestionIndex < 0) return false; const active = elements.foodSuggestions.querySelector(".food-suggestion.active"); if (!active) return false; selectFoodSuggestion(active.textContent); return true; }; const initFoodAutocomplete = () => { elements.foodInput.addEventListener("focus", () => { renderFoodSuggestions(elements.foodInput.value); }); elements.foodInput.addEventListener("input", () => { renderFoodSuggestions(elements.foodInput.value); }); elements.foodInput.addEventListener("keydown", (event) => { if (event.key === "ArrowDown") { event.preventDefault(); moveFoodSuggestionSelection(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveFoodSuggestionSelection(-1); return; } if (event.key === "Enter" && pickActiveFoodSuggestion()) { event.preventDefault(); return; } if (event.key === "Escape") { hideFoodSuggestions(); } }); elements.foodInput.addEventListener("blur", () => { setTimeout(hideFoodSuggestions, 100); }); }; const calcFromFood = (food, grams) => { const ratio = grams / 100; return { kcal: food.kcal * ratio, protein: food.protein * ratio, carbs: food.carbs * ratio, fat: food.fat * ratio, sugar: (food.sugar || 0) * ratio, }; }; const createEntryId = () => { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { return globalThis.crypto.randomUUID(); } return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`; }; const aggregateTotals = (items) => items.reduce( (acc, item) => { acc.kcal += item.kcal; acc.protein += item.protein; acc.carbs += item.carbs; acc.fat += item.fat; acc.sugar += item.sugar || 0; return acc; }, { kcal: 0, protein: 0, carbs: 0, fat: 0, sugar: 0 } ); const median = (values) => { if (!values.length) return 0; const sorted = values.slice().sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); if (sorted.length % 2 === 0) { return (sorted[mid - 1] + sorted[mid]) / 2; } return sorted[mid]; }; const getDateKeysInRange = (start, end) => { const keys = []; const cursor = new Date(start); while (cursor <= end) { keys.push(toISODate(cursor)); cursor.setDate(cursor.getDate() + 1); } return keys; }; const ensureChartTooltip = (chartCard) => { let tooltip = chartCard.querySelector(".chart-tooltip"); if (!tooltip) { tooltip = document.createElement("div"); tooltip.className = "chart-tooltip hidden"; chartCard.appendChild(tooltip); } return tooltip; }; const showChartTooltip = (chartCard, text, event) => { const tooltip = ensureChartTooltip(chartCard); tooltip.textContent = text; tooltip.classList.remove("hidden"); const rect = chartCard.getBoundingClientRect(); const left = Math.max( 8, Math.min(event.clientX - rect.left + 10, rect.width - 180) ); const top = Math.max( 8, Math.min(event.clientY - rect.top - 34, rect.height - 38) ); tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; }; const hideChartTooltip = (chartCard) => { const tooltip = chartCard.querySelector(".chart-tooltip"); if (tooltip) tooltip.classList.add("hidden"); }; const getGoalStatus = (value, goal, metric) => { const tolerance = metric === "kcal" ? 1 : 0.1; if (value > goal + tolerance) return "over"; if (value < goal - tolerance) return "under"; return "on"; }; const goalDeltaLabel = (value, goal, unit, metric) => { const delta = value - goal; const status = getGoalStatus(value, goal, metric); if (status === "on") return `On goal (${goal.toFixed(0)}${unit})`; const direction = status === "over" ? "Over" : "Under"; return `${direction} by ${Math.abs(delta).toFixed(1)}${unit}`; }; const renderGoalsForm = () => { elements.goalKcal.value = state.goals.kcal.toFixed(0); elements.goalProtein.value = state.goals.protein.toFixed(0); elements.goalCarbs.value = state.goals.carbs.toFixed(0); elements.goalFat.value = state.goals.fat.toFixed(0); elements.goalSugar.value = state.goals.sugar.toFixed(0); }; const renderCaloriesChart = (series) => { elements.historyKcalChart.innerHTML = ""; hideChartTooltip(elements.kcalChartCard); const hasData = series.some((day) => day.kcal > 0); if (!hasData) { elements.historyKcalChart.innerHTML = '

No data for this chart range.

'; return; } const maxKcal = Math.max(...series.map((day) => day.kcal), 1); const kcalGoal = Math.max(state.goals.kcal, 0); const maxScale = Math.max(maxKcal, kcalGoal, 1); const width = Math.max(320, series.length * 44); const height = 180; const pad = { top: 12, right: 12, bottom: 28, left: 20 }; const plotW = width - pad.left - pad.right; const plotH = height - pad.top - pad.bottom; const step = series.length > 1 ? plotW / (series.length - 1) : 0; const points = series.map((day, index) => ({ day, x: pad.left + step * index, y: pad.top + plotH - (day.kcal / maxScale) * plotH, })); const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); svg.setAttribute("class", "line-chart-svg"); const axis = document.createElementNS(SVG_NS, "line"); axis.setAttribute("x1", String(pad.left)); axis.setAttribute("x2", String(width - pad.right)); axis.setAttribute("y1", String(height - pad.bottom)); axis.setAttribute("y2", String(height - pad.bottom)); axis.setAttribute("class", "line-axis"); svg.appendChild(axis); const linePath = points .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}` ) .join(" "); const path = document.createElementNS(SVG_NS, "path"); path.setAttribute("d", linePath); path.setAttribute("class", "line-path"); svg.appendChild(path); if (kcalGoal > 0) { const goalY = pad.top + plotH - (kcalGoal / maxScale) * plotH; const goalLine = document.createElementNS(SVG_NS, "line"); goalLine.setAttribute("x1", String(pad.left)); goalLine.setAttribute("x2", String(width - pad.right)); goalLine.setAttribute("y1", String(goalY)); goalLine.setAttribute("y2", String(goalY)); goalLine.setAttribute("class", "goal-line"); svg.appendChild(goalLine); } points.forEach((point) => { const circle = document.createElementNS(SVG_NS, "circle"); circle.setAttribute("cx", point.x.toFixed(2)); circle.setAttribute("cy", point.y.toFixed(2)); circle.setAttribute("r", "3.5"); const goalStatus = getGoalStatus(point.day.kcal, state.goals.kcal, "kcal"); const trendClass = `goal-${goalStatus}`; circle.setAttribute("class", `line-point ${trendClass}`); const pointLabel = `${fmtChartDate(point.day.date)}: ${point.day.kcal.toFixed( 0 )} kcal · ${goalDeltaLabel(point.day.kcal, state.goals.kcal, " kcal", "kcal")}`; circle.addEventListener("mouseenter", (event) => showChartTooltip(elements.kcalChartCard, pointLabel, event) ); circle.addEventListener("mousemove", (event) => showChartTooltip(elements.kcalChartCard, pointLabel, event) ); circle.addEventListener("mouseleave", () => hideChartTooltip(elements.kcalChartCard) ); svg.appendChild(circle); }); const scroll = document.createElement("div"); scroll.className = "line-chart-scroll"; scroll.appendChild(svg); elements.historyKcalChart.appendChild(scroll); const labels = document.createElement("div"); labels.className = "line-chart-labels"; labels.innerHTML = `${fmtChartDate(series[0].date)}${fmtChartDate( series[series.length - 1].date )}`; elements.historyKcalChart.appendChild(labels); }; const renderMacroChart = (series) => { elements.historyMacroChart.innerHTML = ""; hideChartTooltip(elements.macroChartCard); const macroKcals = series.map((day) => ({ ...day, proteinKcal: day.protein * 4, carbsKcal: day.carbs * 4, fatKcal: day.fat * 9, })); const maxTotal = Math.max( ...macroKcals.map((day) => day.proteinKcal + day.carbsKcal + day.fatKcal), 1 ); const hasData = macroKcals.some( (day) => day.proteinKcal + day.carbsKcal + day.fatKcal > 0 ); if (!hasData) { elements.historyMacroChart.innerHTML = '

No data for this chart range.

'; return; } macroKcals.forEach((day) => { const total = day.proteinKcal + day.carbsKcal + day.fatKcal; const col = document.createElement("div"); col.className = "chart-col"; const value = document.createElement("div"); value.className = "chart-value"; value.textContent = `${total.toFixed(0)}`; const track = document.createElement("div"); track.className = "chart-track stacked"; track.style.height = `${Math.max((total / maxTotal) * 100, 2)}%`; const dayLabel = `${fmtChartDate(day.date)}: P ${day.protein.toFixed( 1 )}g (${goalDeltaLabel(day.protein, state.goals.protein, "g", "protein")}), C ${day.carbs.toFixed( 1 )}g (${goalDeltaLabel(day.carbs, state.goals.carbs, "g", "carbs")}), S ${day.sugar.toFixed( 1 )}g (${goalDeltaLabel(day.sugar, state.goals.sugar, "g", "sugar")}), F ${day.fat.toFixed( 1 )}g (${goalDeltaLabel(day.fat, state.goals.fat, "g", "fat")})`; track.addEventListener("mouseenter", (event) => showChartTooltip(elements.macroChartCard, dayLabel, event) ); track.addEventListener("mousemove", (event) => showChartTooltip(elements.macroChartCard, dayLabel, event) ); track.addEventListener("mouseleave", () => hideChartTooltip(elements.macroChartCard) ); const proteinSeg = document.createElement("div"); proteinSeg.className = "chart-seg protein"; proteinSeg.style.height = `${(day.proteinKcal / total) * 100 || 0}%`; const carbsSeg = document.createElement("div"); carbsSeg.className = "chart-seg carbs"; carbsSeg.style.height = `${(day.carbsKcal / total) * 100 || 0}%`; const fatSeg = document.createElement("div"); fatSeg.className = "chart-seg fat"; fatSeg.style.height = `${(day.fatKcal / total) * 100 || 0}%`; const label = document.createElement("div"); label.className = "chart-label"; label.textContent = fmtChartDate(day.date); track.appendChild(fatSeg); track.appendChild(carbsSeg); track.appendChild(proteinSeg); col.appendChild(value); col.appendChild(track); col.appendChild(label); elements.historyMacroChart.appendChild(col); }); }; const renderTotals = (target, totals, goals = null) => { target.innerHTML = ""; const valueWithGoal = (current, goal, digits = 0, unit = "") => { if (!goals) { return `${current.toFixed( digits )}${unit}`.trim(); } return `${current.toFixed(digits)}/${goal.toFixed( digits )}${unit}`.trim(); }; const cards = [ { label: "Calories", value: valueWithGoal(totals.kcal, goals?.kcal ?? 0, 0, "kcal"), }, { label: "Protein", value: valueWithGoal(totals.protein, goals?.protein ?? 0, 1, "g"), }, { label: "Carbs", value: valueWithGoal(totals.carbs, goals?.carbs ?? 0, 1, "g") }, { label: "Sugar", value: valueWithGoal(totals.sugar, goals?.sugar ?? 0, 1, "g") }, { label: "Fat", value: valueWithGoal(totals.fat, goals?.fat ?? 0, 1, "g") }, ]; cards.forEach((card) => { const div = document.createElement("div"); div.className = "total-card"; div.innerHTML = `${card.value}${card.label}`; target.appendChild(div); }); }; const renderList = (target, items, titleFn, onDelete, onEdit) => { target.innerHTML = ""; if (!items.length) { target.innerHTML = `

No entries yet.

`; return; } items.forEach((item) => { const node = elements.rowTemplate.content.cloneNode(true); node.querySelector(".row-title").textContent = titleFn(item); node.querySelector(".row-sub").textContent = item.sub; node.querySelector(".row-kcal").textContent = `${item.kcal.toFixed(0)} kcal`; const editButton = node.querySelector(".row-edit"); if (typeof onEdit === "function") { editButton.classList.remove("hidden"); editButton.addEventListener("click", () => onEdit(item)); } const deleteButton = node.querySelector(".row-delete"); if (typeof onDelete === "function") { deleteButton.classList.remove("hidden"); deleteButton.addEventListener("click", () => onDelete(item)); } target.appendChild(node); }); }; const renderHistoryListByDay = (target, entries) => { target.innerHTML = ""; if (!entries.length) { target.innerHTML = '

No entries for this range.

'; return; } const groups = entries.reduce((acc, entry) => { if (!acc[entry.date]) acc[entry.date] = []; acc[entry.date].push(entry); return acc; }, {}); const orderedDates = Object.keys(groups).sort((a, b) => b.localeCompare(a)); orderedDates.forEach((dateKey) => { const daySection = document.createElement("section"); daySection.className = "history-day-group"; const dayTitle = document.createElement("h3"); dayTitle.className = "history-day-title"; dayTitle.textContent = fmtDate(fromISODate(dateKey)); daySection.appendChild(dayTitle); groups[dateKey].forEach((entry) => { const node = elements.rowTemplate.content.cloneNode(true); node.querySelector(".row-title").textContent = entry.food; node.querySelector(".row-sub").textContent = `${entry.grams} g · P ${entry.protein.toFixed( 1 )} / C ${entry.carbs.toFixed(1)} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed( 1 )}`; node.querySelector(".row-kcal").textContent = `${entry.kcal.toFixed(0)} kcal`; daySection.appendChild(node); }); target.appendChild(daySection); }); }; const getWeekStart = (date) => { const start = new Date(date); const day = start.getDay() || 7; start.setDate(start.getDate() - (day - 1)); return start; }; const renderHistoryListByWeek = (target, entries) => { target.innerHTML = ""; if (!entries.length) { target.innerHTML = '

No entries for this range.

'; return; } const groups = entries.reduce((acc, entry) => { const weekStartKey = toISODate(getWeekStart(fromISODate(entry.date))); if (!acc[weekStartKey]) acc[weekStartKey] = []; acc[weekStartKey].push(entry); return acc; }, {}); const orderedWeekKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a)); orderedWeekKeys.forEach((weekStartKey) => { const weekSection = document.createElement("section"); weekSection.className = "history-day-group"; const weekStart = fromISODate(weekStartKey); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6); const weekTitle = document.createElement("h3"); weekTitle.className = "history-day-title"; weekTitle.textContent = `Week of ${fmtRange(weekStart, weekEnd)}`; weekSection.appendChild(weekTitle); groups[weekStartKey].forEach((entry) => { const entryDate = fromISODate(entry.date); const node = elements.rowTemplate.content.cloneNode(true); node.querySelector(".row-title").textContent = `${entry.food} · ${fmtDate(entryDate)}`; node.querySelector(".row-sub").textContent = `${entry.grams} g · P ${entry.protein.toFixed( 1 )} / C ${entry.carbs.toFixed(1)} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed( 1 )}`; node.querySelector(".row-kcal").textContent = `${entry.kcal.toFixed(0)} kcal`; weekSection.appendChild(node); }); target.appendChild(weekSection); }); }; const renderDay = () => { const selected = new Date(state.selectedDate); const selectedKey = state.selectedDate; const todayKey = toISODate(new Date()); const label = selectedKey === todayKey ? `${fmtDate(selected)} (Today)` : fmtDate(selected); elements.todayDate.textContent = label; elements.todaySelectDate.value = selectedKey; elements.entryDate.value = selectedKey; const dayEntries = state.intakes.filter((entry) => entry.date === selectedKey); const totals = aggregateTotals(dayEntries); renderTotals(elements.todayTotals, totals, state.goals); const rows = dayEntries.map((entry) => ({ ...entry, sub: `${entry.grams} g · P ${entry.protein.toFixed(1)} / C ${entry.carbs.toFixed( 1 )} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(1)}`, })); renderList( elements.todayList, rows, (entry) => entry.food, (entry) => deleteIntake(entry.id), (entry) => editIntakeGrams(entry) ); }; const getRange = (anchor, type) => { const start = new Date(anchor); const end = new Date(anchor); if (type === "week") { const day = start.getDay() || 7; start.setDate(start.getDate() - (day - 1)); end.setDate(start.getDate() + 6); } if (type === "month") { start.setDate(1); end.setMonth(start.getMonth() + 1); end.setDate(0); } if (type === "quarter") { const quarter = Math.floor(start.getMonth() / 3); start.setMonth(quarter * 3, 1); end.setMonth(start.getMonth() + 3); end.setDate(0); } return { start, end }; }; const renderHistory = () => { const type = elements.rangeSelect.value; const anchor = elements.anchorDate.value ? new Date(elements.anchorDate.value) : new Date(); const { start, end } = getRange(anchor, type); const startKey = toISODate(start); const endKey = toISODate(end); const items = state.intakes.filter( (entry) => entry.date >= startKey && entry.date <= endKey ); const grouped = items.reduce((acc, entry) => { if (!acc[entry.date]) acc[entry.date] = []; acc[entry.date].push(entry); return acc; }, {}); const series = getDateKeysInRange(start, end).map((dateKey) => ({ date: dateKey, ...aggregateTotals(grouped[dateKey] || []), })); const positiveValues = (key) => series.map((day) => day[key]).filter((value) => value > 0); const medianTotals = { kcal: median(positiveValues("kcal")), protein: median(positiveValues("protein")), carbs: median(positiveValues("carbs")), fat: median(positiveValues("fat")), sugar: median(positiveValues("sugar")), }; renderTotals(elements.historyTotals, medianTotals, state.goals); renderCaloriesChart(series); renderMacroChart(series); if (type === "quarter") { elements.historyBreakdown.classList.add("hidden"); elements.historyBreakdown.innerHTML = ""; elements.historyView.querySelector( ".card-header p" ).textContent = `${type[0].toUpperCase() + type.slice(1)} view · ${fmtRange( start, end )}`; return; } elements.historyBreakdown.classList.remove("hidden"); const sortedItems = items .slice() .sort((a, b) => b.date.localeCompare(a.date)); if (!sortedItems.length) { elements.historyBreakdown.innerHTML = '

No entries for this range.

'; } else if (type === "week") { renderHistoryListByDay(elements.historyBreakdown, sortedItems); } else if (type === "month") { renderHistoryListByWeek(elements.historyBreakdown, sortedItems); } else { const rows = sortedItems.map((entry) => { const entryDate = fromISODate(entry.date); return { food: `${entry.food} · ${fmtDate(entryDate)}`, kcal: entry.kcal, sub: `${entry.grams} g · P ${entry.protein.toFixed( 1 )} / C ${entry.carbs.toFixed( 1 )} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(1)}`, }; }); renderList(elements.historyBreakdown, rows, (entry) => entry.food); } elements.historyView.querySelector( ".card-header p" ).textContent = `${type[0].toUpperCase() + type.slice(1)} view · ${fmtRange( start, end )}`; }; const addIntake = (event) => { event.preventDefault(); const name = elements.foodInput.value.trim(); const grams = Number(elements.gramsInput.value); const entryDate = elements.entryDate.value || state.selectedDate; if (!name || !grams || !entryDate) return; const food = findFood(name); if (!food) { elements.foodHint.textContent = "Food not found. Please pick from the list."; return; } const macros = calcFromFood(food, grams); const entry = { id: createEntryId(), food: food.name, grams, date: entryDate, ...macros, }; state.intakes.unshift(entry); saveIntakes(); state.selectedDate = entryDate; elements.addForm.reset(); elements.gramsInput.value = 100; hideFoodSuggestions(); elements.entryDate.value = state.selectedDate; elements.foodHint.textContent = "Logged!"; renderIntakePreview(); renderDay(); renderHistory(); }; const deleteIntake = (entryId) => { const intake = state.intakes.find((entry) => entry.id === entryId); const itemLabel = intake ? `${intake.food} (${intake.grams}g)` : "this intake"; const shouldDelete = confirm(`Delete ${itemLabel}?`); if (!shouldDelete) return; state.intakes = state.intakes.filter((entry) => entry.id !== entryId); saveIntakes(); elements.foodHint.textContent = "Entry removed."; renderDay(); renderHistory(); }; const editIntakeGrams = (entry) => { const next = prompt(`Update grams for ${entry.food}:`, String(entry.grams)); if (next === null) return; const grams = Number(next); if (!Number.isFinite(grams) || grams <= 0) { elements.foodHint.textContent = "Please enter a valid grams value."; return; } const food = findFood(entry.food); if (!food) { elements.foodHint.textContent = "Food not found, cannot recalculate macros."; return; } const updatedMacros = calcFromFood(food, grams); const intake = state.intakes.find((item) => item.id === entry.id); if (!intake) return; intake.grams = grams; intake.kcal = updatedMacros.kcal; intake.protein = updatedMacros.protein; intake.carbs = updatedMacros.carbs; intake.fat = updatedMacros.fat; intake.sugar = updatedMacros.sugar; saveIntakes(); elements.foodHint.textContent = "Entry updated."; renderDay(); renderHistory(); }; const applyTheme = () => { document.documentElement.setAttribute("data-theme", state.theme); const nextMode = state.theme === "dark" ? "light" : "dark"; const label = `Switch to ${nextMode} mode`; elements.themeToggle.setAttribute("aria-label", label); elements.themeToggle.setAttribute("title", label); }; const toggleTheme = () => { state.theme = state.theme === "dark" ? "light" : "dark"; applyTheme(); saveTheme(); }; const exportLocalData = () => { const payload = createBackupPayload(); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); const dateStamp = toISODate(new Date()); link.href = url; link.download = `macros-backup-${dateStamp}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); elements.goalsHint.textContent = "Backup exported."; }; const importLocalData = async (event) => { const file = event.target.files?.[0]; if (!file) return; try { const raw = await file.text(); const parsed = parseImportedPayload(JSON.parse(raw)); if (!parsed) throw new Error("Invalid backup format."); state.intakes = parsed.intakes; state.theme = parsed.theme; state.goals = parsed.goals; state.selectedDate = getLatestIntakeDate(); saveIntakes(); saveTheme(); saveGoals(); applyTheme(); renderGoalsForm(); elements.anchorDate.value = toISODate(new Date()); elements.todaySelectDate.value = state.selectedDate; elements.entryDate.value = state.selectedDate; elements.goalsHint.textContent = `Imported ${state.intakes.length} entries.`; renderDay(); renderHistory(); } catch (_error) { elements.goalsHint.textContent = "Import failed. Use a valid backup file."; } finally { event.target.value = ""; } }; const saveGoalsFromSettings = (event) => { event.preventDefault(); state.goals = normalizeGoals({ kcal: Number(elements.goalKcal.value), protein: Number(elements.goalProtein.value), carbs: Number(elements.goalCarbs.value), fat: Number(elements.goalFat.value), sugar: Number(elements.goalSugar.value), }); saveGoals(); renderGoalsForm(); elements.goalsHint.textContent = "Goals saved."; renderHistory(); renderDay(); }; const initTabs = () => { elements.tabs.forEach((tab) => { tab.addEventListener("click", () => { elements.tabs.forEach((btn) => btn.classList.remove("active")); tab.classList.add("active"); const view = tab.dataset.view; if (view === "today") { elements.intakeForm.classList.remove("hidden"); elements.todayView.classList.remove("hidden"); elements.historyView.classList.add("hidden"); elements.settingsView.classList.add("hidden"); } else if (view === "history") { elements.intakeForm.classList.add("hidden"); elements.todayView.classList.add("hidden"); elements.historyView.classList.remove("hidden"); elements.settingsView.classList.add("hidden"); } else { elements.intakeForm.classList.add("hidden"); elements.todayView.classList.add("hidden"); elements.historyView.classList.add("hidden"); elements.settingsView.classList.remove("hidden"); } }); }); }; const loadFoods = async () => { const response = await fetch(FOODS_URL); const foods = await response.json(); state.foods = foods.map((food) => ({ ...food, sugar: typeof food.sugar === "number" ? food.sugar : 0, })); elements.foodHint.textContent = `${state.foods.length} foods ready.`; renderIntakePreview(); }; const init = async () => { loadIntakes(); loadTheme(); loadGoals(); await loadFoods(); state.selectedDate = toISODate(new Date()); elements.anchorDate.value = toISODate(new Date()); elements.todaySelectDate.value = state.selectedDate; elements.entryDate.value = state.selectedDate; applyTheme(); renderGoalsForm(); elements.goalsHint.textContent = ""; renderDay(); renderHistory(); }; const registerServiceWorker = () => { if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker.register("sw.js"); }); } }; initTabs(); loadIntakes(); registerServiceWorker(); window.addEventListener("DOMContentLoaded", () => { initFoodAutocomplete(); elements.addForm.addEventListener("submit", addIntake); elements.entryDate.addEventListener("change", (event) => { state.selectedDate = event.target.value || state.selectedDate; renderDay(); }); elements.todaySelectDate.addEventListener("change", (event) => { state.selectedDate = event.target.value || state.selectedDate; renderDay(); }); elements.rangeSelect.addEventListener("change", renderHistory); elements.anchorDate.addEventListener("change", renderHistory); elements.themeToggle.addEventListener("click", toggleTheme); elements.exportDataButton.addEventListener("click", exportLocalData); elements.importDataButton.addEventListener("click", () => { elements.importFileInput.click(); }); elements.importFileInput.addEventListener("change", importLocalData); elements.goalsForm.addEventListener("submit", saveGoalsFromSettings); elements.foodInput.addEventListener("input", renderIntakePreview); elements.gramsInput.addEventListener("input", renderIntakePreview); document.addEventListener("click", (event) => { if ( event.target !== elements.foodInput && !elements.foodSuggestions.contains(event.target) ) { hideFoodSuggestions(); } }); init(); });