macros/app.js
app.jsRaw
1const STORAGE_KEY = "kcal-intakes-v1";
2const THEME_KEY = "kcal-theme-v1";
3const GOALS_KEY = "kcal-goals-v1";
4const FOODS_URL = "foods.json";
5const DEFAULT_GOALS = { kcal: 2200, protein: 140, carbs: 220, fat: 70, sugar: 60 };
6
7const state = {
8 foods: [],
9 intakes: [],
10 selectedDate: "",
11 theme: "light",
12 goals: { ...DEFAULT_GOALS },
13 foodSuggestionIndex: -1,
14};
15
16const elements = {
17 foodInput: document.getElementById("food-input"),
18 gramsInput: document.getElementById("grams-input"),
19 foodSuggestions: document.getElementById("food-suggestions"),
20 foodHint: document.getElementById("food-hint"),
21 previewBase: document.getElementById("preview-base"),
22 previewScaled: document.getElementById("preview-scaled"),
23 addForm: document.getElementById("add-intake"),
24 intakeForm: document.getElementById("intake-form"),
25 entryDate: document.getElementById("entry-date"),
26 todayDate: document.getElementById("today-date"),
27 todaySelectDate: document.getElementById("today-select-date"),
28 todayTotals: document.getElementById("today-totals"),
29 todayList: document.getElementById("today-list"),
30 historyTotals: document.getElementById("history-totals"),
31 historyBreakdown: document.getElementById("history-breakdown"),
32 historyKcalChart: document.getElementById("history-kcal-chart"),
33 historyMacroChart: document.getElementById("history-macro-chart"),
34 kcalChartCard: document.getElementById("kcal-chart-card"),
35 macroChartCard: document.getElementById("macro-chart-card"),
36 settingsView: document.getElementById("settings-view"),
37 goalsForm: document.getElementById("goals-form"),
38 goalKcal: document.getElementById("goal-kcal"),
39 goalProtein: document.getElementById("goal-protein"),
40 goalCarbs: document.getElementById("goal-carbs"),
41 goalFat: document.getElementById("goal-fat"),
42 goalSugar: document.getElementById("goal-sugar"),
43 goalsHint: document.getElementById("goals-hint"),
44 exportDataButton: document.getElementById("export-data"),
45 importDataButton: document.getElementById("import-data"),
46 importFileInput: document.getElementById("import-file"),
47 rangeSelect: document.getElementById("range-select"),
48 anchorDate: document.getElementById("anchor-date"),
49 themeToggle: document.getElementById("theme-toggle"),
50 tabs: document.querySelectorAll(".tab"),
51 todayView: document.getElementById("today-view"),
52 historyView: document.getElementById("history-view"),
53 rowTemplate: document.getElementById("intake-row"),
54};
55
56const toISODate = (date) => date.toISOString().slice(0, 10);
57
58const fmtDate = (date) =>
59 date.toLocaleDateString(undefined, {
60 weekday: "short",
61 month: "short",
62 day: "numeric",
63 });
64
65const fmtRange = (start, end) => {
66 const options = { month: "short", day: "numeric" };
67 const startLabel = start.toLocaleDateString(undefined, options);
68 const endLabel = end.toLocaleDateString(undefined, options);
69 if (startLabel === endLabel) return startLabel;
70 return `${startLabel} - ${endLabel}`;
71};
72
73const fromISODate = (dateKey) => new Date(`${dateKey}T00:00:00`);
74
75const fmtChartDate = (dateKey) =>
76 fromISODate(dateKey).toLocaleDateString(undefined, {
77 month: "numeric",
78 day: "numeric",
79 });
80const SVG_NS = "http://www.w3.org/2000/svg";
81
82const loadIntakes = () => {
83 const raw = localStorage.getItem(STORAGE_KEY);
84 const parsed = raw ? JSON.parse(raw) : [];
85 state.intakes = Array.isArray(parsed)
86 ? parsed
87 .filter((entry) => entry && typeof entry === "object")
88 .map((entry) => ({
89 ...entry,
90 sugar: typeof entry.sugar === "number" ? entry.sugar : 0,
91 }))
92 : [];
93};
94
95const saveIntakes = () => {
96 localStorage.setItem(STORAGE_KEY, JSON.stringify(state.intakes));
97};
98
99const loadTheme = () => {
100 const savedTheme = localStorage.getItem(THEME_KEY);
101 if (savedTheme === "light" || savedTheme === "dark") {
102 state.theme = savedTheme;
103 return;
104 }
105 state.theme = window.matchMedia("(prefers-color-scheme: dark)").matches
106 ? "dark"
107 : "light";
108};
109
110const saveTheme = () => {
111 localStorage.setItem(THEME_KEY, state.theme);
112};
113
114const normalizeGoals = (rawGoals) => {
115 const base = { ...DEFAULT_GOALS };
116 if (!rawGoals || typeof rawGoals !== "object") return base;
117
118 const goalKeys = ["kcal", "protein", "carbs", "fat", "sugar"];
119 goalKeys.forEach((key) => {
120 const value = Number(rawGoals[key]);
121 if (Number.isFinite(value) && value >= 0) base[key] = value;
122 });
123 return base;
124};
125
126const loadGoals = () => {
127 const rawGoals = localStorage.getItem(GOALS_KEY);
128 state.goals = rawGoals ? normalizeGoals(JSON.parse(rawGoals)) : { ...DEFAULT_GOALS };
129};
130
131const saveGoals = () => {
132 localStorage.setItem(GOALS_KEY, JSON.stringify(state.goals));
133};
134
135const getLatestIntakeDate = () => {
136 if (!state.intakes.length) return toISODate(new Date());
137 return state.intakes.reduce(
138 (latest, entry) => (entry.date > latest ? entry.date : latest),
139 state.intakes[0].date
140 );
141};
142
143const createBackupPayload = () => ({
144 app: "macros",
145 version: 1,
146 exportedAt: new Date().toISOString(),
147 storage: {
148 [STORAGE_KEY]: state.intakes,
149 [THEME_KEY]: state.theme,
150 [GOALS_KEY]: state.goals,
151 },
152});
153
154const parseImportedPayload = (payload) => {
155 if (!payload || typeof payload !== "object") return null;
156
157 const source =
158 payload.storage && typeof payload.storage === "object"
159 ? payload.storage
160 : payload;
161
162 let importedIntakes = source[STORAGE_KEY] ?? source.intakes;
163 let importedTheme = source[THEME_KEY] ?? source.theme;
164 let importedGoals = source[GOALS_KEY] ?? source.goals;
165
166 if (typeof importedIntakes === "string") {
167 importedIntakes = JSON.parse(importedIntakes);
168 }
169 if (typeof importedTheme === "string") {
170 importedTheme = importedTheme.trim().toLowerCase();
171 }
172 if (typeof importedGoals === "string") {
173 importedGoals = JSON.parse(importedGoals);
174 }
175
176 if (!Array.isArray(importedIntakes)) return null;
177 if (importedTheme !== "light" && importedTheme !== "dark") importedTheme = "light";
178
179 const intakes = importedIntakes
180 .filter(
181 (entry) =>
182 entry &&
183 typeof entry === "object" &&
184 typeof entry.food === "string" &&
185 typeof entry.date === "string" &&
186 typeof entry.grams === "number" &&
187 typeof entry.kcal === "number" &&
188 typeof entry.protein === "number" &&
189 typeof entry.carbs === "number" &&
190 typeof entry.fat === "number"
191 )
192 .map((entry) => ({
193 id: typeof entry.id === "string" ? entry.id : createEntryId(),
194 food: entry.food,
195 grams: entry.grams,
196 date: entry.date,
197 kcal: entry.kcal,
198 protein: entry.protein,
199 carbs: entry.carbs,
200 fat: entry.fat,
201 sugar: typeof entry.sugar === "number" ? entry.sugar : 0,
202 }));
203
204 return { intakes, theme: importedTheme, goals: normalizeGoals(importedGoals) };
205};
206
207const findFood = (name) =>
208 state.foods.find((food) => food.name.toLowerCase() === name.toLowerCase());
209
210const fmtMacroLine = (macros) =>
211 `kcal ${macros.kcal.toFixed(0)} · P ${macros.protein.toFixed(
212 1
213 )}g · C ${macros.carbs.toFixed(1)}g · S ${macros.sugar.toFixed(
214 1
215 )}g · F ${macros.fat.toFixed(1)}g`;
216
217const renderIntakePreview = () => {
218 const name = elements.foodInput.value.trim();
219 const grams = Number(elements.gramsInput.value);
220
221 if (!name) {
222 elements.previewBase.textContent = "Select a food to preview macros.";
223 elements.previewScaled.textContent = "Enter grams to see calculated macros.";
224 return;
225 }
226
227 const food = findFood(name);
228 if (!food) {
229 elements.previewBase.textContent = "Food not found in list.";
230 elements.previewScaled.textContent = "Choose a listed food to calculate values.";
231 return;
232 }
233
234 elements.previewBase.textContent = fmtMacroLine(food);
235
236 if (!Number.isFinite(grams) || grams <= 0) {
237 elements.previewScaled.textContent = "Enter a valid grams value.";
238 return;
239 }
240
241 elements.previewScaled.textContent = fmtMacroLine(calcFromFood(food, grams));
242};
243
244const getFilteredFoods = (query) => {
245 const normalized = query.trim().toLowerCase();
246 if (!normalized) return state.foods.slice(0, 12);
247
248 const startsWith = state.foods.filter((food) =>
249 food.name.toLowerCase().startsWith(normalized)
250 );
251 const includes = state.foods.filter(
252 (food) =>
253 !food.name.toLowerCase().startsWith(normalized) &&
254 food.name.toLowerCase().includes(normalized)
255 );
256 return [...startsWith, ...includes].slice(0, 12);
257};
258
259const hideFoodSuggestions = () => {
260 elements.foodSuggestions.classList.add("hidden");
261 elements.foodSuggestions.innerHTML = "";
262 state.foodSuggestionIndex = -1;
263};
264
265const selectFoodSuggestion = (foodName) => {
266 elements.foodInput.value = foodName;
267 hideFoodSuggestions();
268 renderIntakePreview();
269};
270
271const renderFoodSuggestions = (query) => {
272 const foods = getFilteredFoods(query);
273 state.foodSuggestionIndex = -1;
274 elements.foodSuggestions.innerHTML = "";
275
276 if (!foods.length) {
277 hideFoodSuggestions();
278 return;
279 }
280
281 foods.forEach((food, index) => {
282 const option = document.createElement("button");
283 option.type = "button";
284 option.className = "food-suggestion";
285 option.textContent = food.name;
286 option.dataset.index = String(index);
287 option.addEventListener("mousedown", (event) => {
288 event.preventDefault();
289 selectFoodSuggestion(food.name);
290 });
291 elements.foodSuggestions.appendChild(option);
292 });
293
294 elements.foodSuggestions.classList.remove("hidden");
295};
296
297const moveFoodSuggestionSelection = (direction) => {
298 const options = Array.from(
299 elements.foodSuggestions.querySelectorAll(".food-suggestion")
300 );
301 if (!options.length) return;
302
303 if (elements.foodSuggestions.classList.contains("hidden")) {
304 renderFoodSuggestions(elements.foodInput.value);
305 return;
306 }
307
308 state.foodSuggestionIndex += direction;
309 if (state.foodSuggestionIndex < 0) state.foodSuggestionIndex = options.length - 1;
310 if (state.foodSuggestionIndex >= options.length) state.foodSuggestionIndex = 0;
311
312 options.forEach((option, idx) =>
313 option.classList.toggle("active", idx === state.foodSuggestionIndex)
314 );
315};
316
317const pickActiveFoodSuggestion = () => {
318 if (state.foodSuggestionIndex < 0) return false;
319 const active = elements.foodSuggestions.querySelector(".food-suggestion.active");
320 if (!active) return false;
321 selectFoodSuggestion(active.textContent);
322 return true;
323};
324
325const initFoodAutocomplete = () => {
326 elements.foodInput.addEventListener("focus", () => {
327 renderFoodSuggestions(elements.foodInput.value);
328 });
329
330 elements.foodInput.addEventListener("input", () => {
331 renderFoodSuggestions(elements.foodInput.value);
332 });
333
334 elements.foodInput.addEventListener("keydown", (event) => {
335 if (event.key === "ArrowDown") {
336 event.preventDefault();
337 moveFoodSuggestionSelection(1);
338 return;
339 }
340
341 if (event.key === "ArrowUp") {
342 event.preventDefault();
343 moveFoodSuggestionSelection(-1);
344 return;
345 }
346
347 if (event.key === "Enter" && pickActiveFoodSuggestion()) {
348 event.preventDefault();
349 return;
350 }
351
352 if (event.key === "Escape") {
353 hideFoodSuggestions();
354 }
355 });
356
357 elements.foodInput.addEventListener("blur", () => {
358 setTimeout(hideFoodSuggestions, 100);
359 });
360};
361
362const calcFromFood = (food, grams) => {
363 const ratio = grams / 100;
364 return {
365 kcal: food.kcal * ratio,
366 protein: food.protein * ratio,
367 carbs: food.carbs * ratio,
368 fat: food.fat * ratio,
369 sugar: (food.sugar || 0) * ratio,
370 };
371};
372
373const createEntryId = () => {
374 if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
375 return globalThis.crypto.randomUUID();
376 }
377
378 return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
379};
380
381const aggregateTotals = (items) =>
382 items.reduce(
383 (acc, item) => {
384 acc.kcal += item.kcal;
385 acc.protein += item.protein;
386 acc.carbs += item.carbs;
387 acc.fat += item.fat;
388 acc.sugar += item.sugar || 0;
389 return acc;
390 },
391 { kcal: 0, protein: 0, carbs: 0, fat: 0, sugar: 0 }
392 );
393
394const median = (values) => {
395 if (!values.length) return 0;
396 const sorted = values.slice().sort((a, b) => a - b);
397 const mid = Math.floor(sorted.length / 2);
398 if (sorted.length % 2 === 0) {
399 return (sorted[mid - 1] + sorted[mid]) / 2;
400 }
401 return sorted[mid];
402};
403
404const getDateKeysInRange = (start, end) => {
405 const keys = [];
406 const cursor = new Date(start);
407 while (cursor <= end) {
408 keys.push(toISODate(cursor));
409 cursor.setDate(cursor.getDate() + 1);
410 }
411 return keys;
412};
413
414const ensureChartTooltip = (chartCard) => {
415 let tooltip = chartCard.querySelector(".chart-tooltip");
416 if (!tooltip) {
417 tooltip = document.createElement("div");
418 tooltip.className = "chart-tooltip hidden";
419 chartCard.appendChild(tooltip);
420 }
421 return tooltip;
422};
423
424const showChartTooltip = (chartCard, text, event) => {
425 const tooltip = ensureChartTooltip(chartCard);
426 tooltip.textContent = text;
427 tooltip.classList.remove("hidden");
428
429 const rect = chartCard.getBoundingClientRect();
430 const left = Math.max(
431 8,
432 Math.min(event.clientX - rect.left + 10, rect.width - 180)
433 );
434 const top = Math.max(
435 8,
436 Math.min(event.clientY - rect.top - 34, rect.height - 38)
437 );
438
439 tooltip.style.left = `${left}px`;
440 tooltip.style.top = `${top}px`;
441};
442
443const hideChartTooltip = (chartCard) => {
444 const tooltip = chartCard.querySelector(".chart-tooltip");
445 if (tooltip) tooltip.classList.add("hidden");
446};
447
448const getGoalStatus = (value, goal, metric) => {
449 const tolerance = metric === "kcal" ? 1 : 0.1;
450 if (value > goal + tolerance) return "over";
451 if (value < goal - tolerance) return "under";
452 return "on";
453};
454
455const goalDeltaLabel = (value, goal, unit, metric) => {
456 const delta = value - goal;
457 const status = getGoalStatus(value, goal, metric);
458 if (status === "on") return `On goal (${goal.toFixed(0)}${unit})`;
459 const direction = status === "over" ? "Over" : "Under";
460 return `${direction} by ${Math.abs(delta).toFixed(1)}${unit}`;
461};
462
463const renderGoalsForm = () => {
464 elements.goalKcal.value = state.goals.kcal.toFixed(0);
465 elements.goalProtein.value = state.goals.protein.toFixed(0);
466 elements.goalCarbs.value = state.goals.carbs.toFixed(0);
467 elements.goalFat.value = state.goals.fat.toFixed(0);
468 elements.goalSugar.value = state.goals.sugar.toFixed(0);
469};
470
471const renderCaloriesChart = (series) => {
472 elements.historyKcalChart.innerHTML = "";
473 hideChartTooltip(elements.kcalChartCard);
474
475 const hasData = series.some((day) => day.kcal > 0);
476
477 if (!hasData) {
478 elements.historyKcalChart.innerHTML =
479 '<p class="hint">No data for this chart range.</p>';
480 return;
481 }
482
483 const maxKcal = Math.max(...series.map((day) => day.kcal), 1);
484 const kcalGoal = Math.max(state.goals.kcal, 0);
485 const maxScale = Math.max(maxKcal, kcalGoal, 1);
486 const width = Math.max(320, series.length * 44);
487 const height = 180;
488 const pad = { top: 12, right: 12, bottom: 28, left: 20 };
489 const plotW = width - pad.left - pad.right;
490 const plotH = height - pad.top - pad.bottom;
491 const step = series.length > 1 ? plotW / (series.length - 1) : 0;
492 const points = series.map((day, index) => ({
493 day,
494 x: pad.left + step * index,
495 y: pad.top + plotH - (day.kcal / maxScale) * plotH,
496 }));
497
498 const svg = document.createElementNS(SVG_NS, "svg");
499 svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
500 svg.setAttribute("class", "line-chart-svg");
501
502 const axis = document.createElementNS(SVG_NS, "line");
503 axis.setAttribute("x1", String(pad.left));
504 axis.setAttribute("x2", String(width - pad.right));
505 axis.setAttribute("y1", String(height - pad.bottom));
506 axis.setAttribute("y2", String(height - pad.bottom));
507 axis.setAttribute("class", "line-axis");
508 svg.appendChild(axis);
509
510 const linePath = points
511 .map((point, index) =>
512 `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`
513 )
514 .join(" ");
515
516 const path = document.createElementNS(SVG_NS, "path");
517 path.setAttribute("d", linePath);
518 path.setAttribute("class", "line-path");
519 svg.appendChild(path);
520
521 if (kcalGoal > 0) {
522 const goalY = pad.top + plotH - (kcalGoal / maxScale) * plotH;
523 const goalLine = document.createElementNS(SVG_NS, "line");
524 goalLine.setAttribute("x1", String(pad.left));
525 goalLine.setAttribute("x2", String(width - pad.right));
526 goalLine.setAttribute("y1", String(goalY));
527 goalLine.setAttribute("y2", String(goalY));
528 goalLine.setAttribute("class", "goal-line");
529 svg.appendChild(goalLine);
530 }
531
532 points.forEach((point) => {
533 const circle = document.createElementNS(SVG_NS, "circle");
534 circle.setAttribute("cx", point.x.toFixed(2));
535 circle.setAttribute("cy", point.y.toFixed(2));
536 circle.setAttribute("r", "3.5");
537 const goalStatus = getGoalStatus(point.day.kcal, state.goals.kcal, "kcal");
538 const trendClass = `goal-${goalStatus}`;
539 circle.setAttribute("class", `line-point ${trendClass}`);
540 const pointLabel = `${fmtChartDate(point.day.date)}: ${point.day.kcal.toFixed(
541 0
542 )} kcal · ${goalDeltaLabel(point.day.kcal, state.goals.kcal, " kcal", "kcal")}`;
543 circle.addEventListener("mouseenter", (event) =>
544 showChartTooltip(elements.kcalChartCard, pointLabel, event)
545 );
546 circle.addEventListener("mousemove", (event) =>
547 showChartTooltip(elements.kcalChartCard, pointLabel, event)
548 );
549 circle.addEventListener("mouseleave", () =>
550 hideChartTooltip(elements.kcalChartCard)
551 );
552 svg.appendChild(circle);
553 });
554
555 const scroll = document.createElement("div");
556 scroll.className = "line-chart-scroll";
557 scroll.appendChild(svg);
558 elements.historyKcalChart.appendChild(scroll);
559
560 const labels = document.createElement("div");
561 labels.className = "line-chart-labels";
562 labels.innerHTML = `<span>${fmtChartDate(series[0].date)}</span><span>${fmtChartDate(
563 series[series.length - 1].date
564 )}</span>`;
565 elements.historyKcalChart.appendChild(labels);
566};
567
568const renderMacroChart = (series) => {
569 elements.historyMacroChart.innerHTML = "";
570 hideChartTooltip(elements.macroChartCard);
571
572 const macroKcals = series.map((day) => ({
573 ...day,
574 proteinKcal: day.protein * 4,
575 carbsKcal: day.carbs * 4,
576 fatKcal: day.fat * 9,
577 }));
578 const maxTotal = Math.max(
579 ...macroKcals.map((day) => day.proteinKcal + day.carbsKcal + day.fatKcal),
580 1
581 );
582 const hasData = macroKcals.some(
583 (day) => day.proteinKcal + day.carbsKcal + day.fatKcal > 0
584 );
585
586 if (!hasData) {
587 elements.historyMacroChart.innerHTML =
588 '<p class="hint">No data for this chart range.</p>';
589 return;
590 }
591
592 macroKcals.forEach((day) => {
593 const total = day.proteinKcal + day.carbsKcal + day.fatKcal;
594 const col = document.createElement("div");
595 col.className = "chart-col";
596
597 const value = document.createElement("div");
598 value.className = "chart-value";
599 value.textContent = `${total.toFixed(0)}`;
600
601 const track = document.createElement("div");
602 track.className = "chart-track stacked";
603 track.style.height = `${Math.max((total / maxTotal) * 100, 2)}%`;
604 const dayLabel = `${fmtChartDate(day.date)}: P ${day.protein.toFixed(
605 1
606 )}g (${goalDeltaLabel(day.protein, state.goals.protein, "g", "protein")}), C ${day.carbs.toFixed(
607 1
608 )}g (${goalDeltaLabel(day.carbs, state.goals.carbs, "g", "carbs")}), S ${day.sugar.toFixed(
609 1
610 )}g (${goalDeltaLabel(day.sugar, state.goals.sugar, "g", "sugar")}), F ${day.fat.toFixed(
611 1
612 )}g (${goalDeltaLabel(day.fat, state.goals.fat, "g", "fat")})`;
613 track.addEventListener("mouseenter", (event) =>
614 showChartTooltip(elements.macroChartCard, dayLabel, event)
615 );
616 track.addEventListener("mousemove", (event) =>
617 showChartTooltip(elements.macroChartCard, dayLabel, event)
618 );
619 track.addEventListener("mouseleave", () =>
620 hideChartTooltip(elements.macroChartCard)
621 );
622
623 const proteinSeg = document.createElement("div");
624 proteinSeg.className = "chart-seg protein";
625 proteinSeg.style.height = `${(day.proteinKcal / total) * 100 || 0}%`;
626
627 const carbsSeg = document.createElement("div");
628 carbsSeg.className = "chart-seg carbs";
629 carbsSeg.style.height = `${(day.carbsKcal / total) * 100 || 0}%`;
630
631 const fatSeg = document.createElement("div");
632 fatSeg.className = "chart-seg fat";
633 fatSeg.style.height = `${(day.fatKcal / total) * 100 || 0}%`;
634
635 const label = document.createElement("div");
636 label.className = "chart-label";
637 label.textContent = fmtChartDate(day.date);
638
639 track.appendChild(fatSeg);
640 track.appendChild(carbsSeg);
641 track.appendChild(proteinSeg);
642 col.appendChild(value);
643 col.appendChild(track);
644 col.appendChild(label);
645 elements.historyMacroChart.appendChild(col);
646 });
647};
648
649
650const renderTotals = (target, totals, goals = null) => {
651 target.innerHTML = "";
652 const valueWithGoal = (current, goal, digits = 0, unit = "") => {
653 if (!goals) {
654 return `${current.toFixed(
655 digits
656 )}<span class="total-unit">${unit}</span>`.trim();
657 }
658 return `${current.toFixed(digits)}<span class="goal-soft">/${goal.toFixed(
659 digits
660 )}</span><span class="total-unit">${unit}</span>`.trim();
661 };
662
663 const cards = [
664 {
665 label: "Calories",
666 value: valueWithGoal(totals.kcal, goals?.kcal ?? 0, 0, "kcal"),
667 },
668 {
669 label: "Protein",
670 value: valueWithGoal(totals.protein, goals?.protein ?? 0, 1, "g"),
671 },
672 { label: "Carbs", value: valueWithGoal(totals.carbs, goals?.carbs ?? 0, 1, "g") },
673 { label: "Sugar", value: valueWithGoal(totals.sugar, goals?.sugar ?? 0, 1, "g") },
674 { label: "Fat", value: valueWithGoal(totals.fat, goals?.fat ?? 0, 1, "g") },
675 ];
676 cards.forEach((card) => {
677 const div = document.createElement("div");
678 div.className = "total-card";
679 div.innerHTML = `<strong>${card.value}</strong><span>${card.label}</span>`;
680 target.appendChild(div);
681 });
682};
683
684const renderList = (target, items, titleFn, onDelete, onEdit) => {
685 target.innerHTML = "";
686 if (!items.length) {
687 target.innerHTML = `<p class="hint">No entries yet.</p>`;
688 return;
689 }
690
691 items.forEach((item) => {
692 const node = elements.rowTemplate.content.cloneNode(true);
693 node.querySelector(".row-title").textContent = titleFn(item);
694 node.querySelector(".row-sub").textContent = item.sub;
695 node.querySelector(".row-kcal").textContent = `${item.kcal.toFixed(0)} kcal`;
696 const editButton = node.querySelector(".row-edit");
697 if (typeof onEdit === "function") {
698 editButton.classList.remove("hidden");
699 editButton.addEventListener("click", () => onEdit(item));
700 }
701 const deleteButton = node.querySelector(".row-delete");
702 if (typeof onDelete === "function") {
703 deleteButton.classList.remove("hidden");
704 deleteButton.addEventListener("click", () => onDelete(item));
705 }
706 target.appendChild(node);
707 });
708};
709
710const renderHistoryListByDay = (target, entries) => {
711 target.innerHTML = "";
712 if (!entries.length) {
713 target.innerHTML = '<p class="hint">No entries for this range.</p>';
714 return;
715 }
716
717 const groups = entries.reduce((acc, entry) => {
718 if (!acc[entry.date]) acc[entry.date] = [];
719 acc[entry.date].push(entry);
720 return acc;
721 }, {});
722
723 const orderedDates = Object.keys(groups).sort((a, b) => b.localeCompare(a));
724
725 orderedDates.forEach((dateKey) => {
726 const daySection = document.createElement("section");
727 daySection.className = "history-day-group";
728
729 const dayTitle = document.createElement("h3");
730 dayTitle.className = "history-day-title";
731 dayTitle.textContent = fmtDate(fromISODate(dateKey));
732 daySection.appendChild(dayTitle);
733
734 groups[dateKey].forEach((entry) => {
735 const node = elements.rowTemplate.content.cloneNode(true);
736 node.querySelector(".row-title").textContent = entry.food;
737 node.querySelector(".row-sub").textContent = `${entry.grams} g · P ${entry.protein.toFixed(
738 1
739 )} / C ${entry.carbs.toFixed(1)} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(
740 1
741 )}`;
742 node.querySelector(".row-kcal").textContent = `${entry.kcal.toFixed(0)} kcal`;
743 daySection.appendChild(node);
744 });
745
746 target.appendChild(daySection);
747 });
748};
749
750const getWeekStart = (date) => {
751 const start = new Date(date);
752 const day = start.getDay() || 7;
753 start.setDate(start.getDate() - (day - 1));
754 return start;
755};
756
757const renderHistoryListByWeek = (target, entries) => {
758 target.innerHTML = "";
759 if (!entries.length) {
760 target.innerHTML = '<p class="hint">No entries for this range.</p>';
761 return;
762 }
763
764 const groups = entries.reduce((acc, entry) => {
765 const weekStartKey = toISODate(getWeekStart(fromISODate(entry.date)));
766 if (!acc[weekStartKey]) acc[weekStartKey] = [];
767 acc[weekStartKey].push(entry);
768 return acc;
769 }, {});
770
771 const orderedWeekKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a));
772
773 orderedWeekKeys.forEach((weekStartKey) => {
774 const weekSection = document.createElement("section");
775 weekSection.className = "history-day-group";
776
777 const weekStart = fromISODate(weekStartKey);
778 const weekEnd = new Date(weekStart);
779 weekEnd.setDate(weekEnd.getDate() + 6);
780
781 const weekTitle = document.createElement("h3");
782 weekTitle.className = "history-day-title";
783 weekTitle.textContent = `Week of ${fmtRange(weekStart, weekEnd)}`;
784 weekSection.appendChild(weekTitle);
785
786 groups[weekStartKey].forEach((entry) => {
787 const entryDate = fromISODate(entry.date);
788 const node = elements.rowTemplate.content.cloneNode(true);
789 node.querySelector(".row-title").textContent = `${entry.food} · ${fmtDate(entryDate)}`;
790 node.querySelector(".row-sub").textContent = `${entry.grams} g · P ${entry.protein.toFixed(
791 1
792 )} / C ${entry.carbs.toFixed(1)} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(
793 1
794 )}`;
795 node.querySelector(".row-kcal").textContent = `${entry.kcal.toFixed(0)} kcal`;
796 weekSection.appendChild(node);
797 });
798
799 target.appendChild(weekSection);
800 });
801};
802
803const renderDay = () => {
804 const selected = new Date(state.selectedDate);
805 const selectedKey = state.selectedDate;
806 const todayKey = toISODate(new Date());
807 const label =
808 selectedKey === todayKey ? `${fmtDate(selected)} (Today)` : fmtDate(selected);
809 elements.todayDate.textContent = label;
810 elements.todaySelectDate.value = selectedKey;
811 elements.entryDate.value = selectedKey;
812
813 const dayEntries = state.intakes.filter((entry) => entry.date === selectedKey);
814 const totals = aggregateTotals(dayEntries);
815 renderTotals(elements.todayTotals, totals, state.goals);
816
817 const rows = dayEntries.map((entry) => ({
818 ...entry,
819 sub: `${entry.grams} g · P ${entry.protein.toFixed(1)} / C ${entry.carbs.toFixed(
820 1
821 )} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(1)}`,
822 }));
823
824 renderList(
825 elements.todayList,
826 rows,
827 (entry) => entry.food,
828 (entry) => deleteIntake(entry.id),
829 (entry) => editIntakeGrams(entry)
830 );
831};
832
833const getRange = (anchor, type) => {
834 const start = new Date(anchor);
835 const end = new Date(anchor);
836
837 if (type === "week") {
838 const day = start.getDay() || 7;
839 start.setDate(start.getDate() - (day - 1));
840 end.setDate(start.getDate() + 6);
841 }
842
843 if (type === "month") {
844 start.setDate(1);
845 end.setMonth(start.getMonth() + 1);
846 end.setDate(0);
847 }
848
849 if (type === "quarter") {
850 const quarter = Math.floor(start.getMonth() / 3);
851 start.setMonth(quarter * 3, 1);
852 end.setMonth(start.getMonth() + 3);
853 end.setDate(0);
854 }
855
856 return { start, end };
857};
858
859const renderHistory = () => {
860 const type = elements.rangeSelect.value;
861 const anchor = elements.anchorDate.value
862 ? new Date(elements.anchorDate.value)
863 : new Date();
864
865 const { start, end } = getRange(anchor, type);
866 const startKey = toISODate(start);
867 const endKey = toISODate(end);
868
869 const items = state.intakes.filter(
870 (entry) => entry.date >= startKey && entry.date <= endKey
871 );
872
873 const grouped = items.reduce((acc, entry) => {
874 if (!acc[entry.date]) acc[entry.date] = [];
875 acc[entry.date].push(entry);
876 return acc;
877 }, {});
878
879 const series = getDateKeysInRange(start, end).map((dateKey) => ({
880 date: dateKey,
881 ...aggregateTotals(grouped[dateKey] || []),
882 }));
883
884 const positiveValues = (key) =>
885 series.map((day) => day[key]).filter((value) => value > 0);
886
887 const medianTotals = {
888 kcal: median(positiveValues("kcal")),
889 protein: median(positiveValues("protein")),
890 carbs: median(positiveValues("carbs")),
891 fat: median(positiveValues("fat")),
892 sugar: median(positiveValues("sugar")),
893 };
894 renderTotals(elements.historyTotals, medianTotals, state.goals);
895
896 renderCaloriesChart(series);
897 renderMacroChart(series);
898
899 if (type === "quarter") {
900 elements.historyBreakdown.classList.add("hidden");
901 elements.historyBreakdown.innerHTML = "";
902 elements.historyView.querySelector(
903 ".card-header p"
904 ).textContent = `${type[0].toUpperCase() + type.slice(1)} view · ${fmtRange(
905 start,
906 end
907 )}`;
908 return;
909 }
910 elements.historyBreakdown.classList.remove("hidden");
911
912 const sortedItems = items
913 .slice()
914 .sort((a, b) => b.date.localeCompare(a.date));
915
916 if (!sortedItems.length) {
917 elements.historyBreakdown.innerHTML = '<p class="hint">No entries for this range.</p>';
918 } else if (type === "week") {
919 renderHistoryListByDay(elements.historyBreakdown, sortedItems);
920 } else if (type === "month") {
921 renderHistoryListByWeek(elements.historyBreakdown, sortedItems);
922 } else {
923 const rows = sortedItems.map((entry) => {
924 const entryDate = fromISODate(entry.date);
925 return {
926 food: `${entry.food} · ${fmtDate(entryDate)}`,
927 kcal: entry.kcal,
928 sub: `${entry.grams} g · P ${entry.protein.toFixed(
929 1
930 )} / C ${entry.carbs.toFixed(
931 1
932 )} / S ${entry.sugar.toFixed(1)} / F ${entry.fat.toFixed(1)}`,
933 };
934 });
935 renderList(elements.historyBreakdown, rows, (entry) => entry.food);
936 }
937
938 elements.historyView.querySelector(
939 ".card-header p"
940 ).textContent = `${type[0].toUpperCase() + type.slice(1)} view · ${fmtRange(
941 start,
942 end
943 )}`;
944};
945
946const addIntake = (event) => {
947 event.preventDefault();
948 const name = elements.foodInput.value.trim();
949 const grams = Number(elements.gramsInput.value);
950 const entryDate = elements.entryDate.value || state.selectedDate;
951
952 if (!name || !grams || !entryDate) return;
953
954 const food = findFood(name);
955 if (!food) {
956 elements.foodHint.textContent = "Food not found. Please pick from the list.";
957 return;
958 }
959
960 const macros = calcFromFood(food, grams);
961 const entry = {
962 id: createEntryId(),
963 food: food.name,
964 grams,
965 date: entryDate,
966 ...macros,
967 };
968
969 state.intakes.unshift(entry);
970 saveIntakes();
971 state.selectedDate = entryDate;
972 elements.addForm.reset();
973 elements.gramsInput.value = 100;
974 hideFoodSuggestions();
975 elements.entryDate.value = state.selectedDate;
976 elements.foodHint.textContent = "Logged!";
977 renderIntakePreview();
978 renderDay();
979 renderHistory();
980};
981
982const deleteIntake = (entryId) => {
983 const intake = state.intakes.find((entry) => entry.id === entryId);
984 const itemLabel = intake ? `${intake.food} (${intake.grams}g)` : "this intake";
985 const shouldDelete = confirm(`Delete ${itemLabel}?`);
986 if (!shouldDelete) return;
987
988 state.intakes = state.intakes.filter((entry) => entry.id !== entryId);
989 saveIntakes();
990 elements.foodHint.textContent = "Entry removed.";
991 renderDay();
992 renderHistory();
993};
994
995const editIntakeGrams = (entry) => {
996 const next = prompt(`Update grams for ${entry.food}:`, String(entry.grams));
997 if (next === null) return;
998
999 const grams = Number(next);
1000 if (!Number.isFinite(grams) || grams <= 0) {
1001 elements.foodHint.textContent = "Please enter a valid grams value.";
1002 return;
1003 }
1004
1005 const food = findFood(entry.food);
1006 if (!food) {
1007 elements.foodHint.textContent = "Food not found, cannot recalculate macros.";
1008 return;
1009 }
1010
1011 const updatedMacros = calcFromFood(food, grams);
1012 const intake = state.intakes.find((item) => item.id === entry.id);
1013 if (!intake) return;
1014
1015 intake.grams = grams;
1016 intake.kcal = updatedMacros.kcal;
1017 intake.protein = updatedMacros.protein;
1018 intake.carbs = updatedMacros.carbs;
1019 intake.fat = updatedMacros.fat;
1020 intake.sugar = updatedMacros.sugar;
1021
1022 saveIntakes();
1023 elements.foodHint.textContent = "Entry updated.";
1024 renderDay();
1025 renderHistory();
1026};
1027
1028const applyTheme = () => {
1029 document.documentElement.setAttribute("data-theme", state.theme);
1030 const nextMode = state.theme === "dark" ? "light" : "dark";
1031 const label = `Switch to ${nextMode} mode`;
1032 elements.themeToggle.setAttribute("aria-label", label);
1033 elements.themeToggle.setAttribute("title", label);
1034};
1035
1036const toggleTheme = () => {
1037 state.theme = state.theme === "dark" ? "light" : "dark";
1038 applyTheme();
1039 saveTheme();
1040};
1041
1042const exportLocalData = () => {
1043 const payload = createBackupPayload();
1044 const blob = new Blob([JSON.stringify(payload, null, 2)], {
1045 type: "application/json",
1046 });
1047 const url = URL.createObjectURL(blob);
1048 const link = document.createElement("a");
1049 const dateStamp = toISODate(new Date());
1050 link.href = url;
1051 link.download = `macros-backup-${dateStamp}.json`;
1052 document.body.appendChild(link);
1053 link.click();
1054 link.remove();
1055 URL.revokeObjectURL(url);
1056 elements.goalsHint.textContent = "Backup exported.";
1057};
1058
1059const importLocalData = async (event) => {
1060 const file = event.target.files?.[0];
1061 if (!file) return;
1062
1063 try {
1064 const raw = await file.text();
1065 const parsed = parseImportedPayload(JSON.parse(raw));
1066 if (!parsed) throw new Error("Invalid backup format.");
1067
1068 state.intakes = parsed.intakes;
1069 state.theme = parsed.theme;
1070 state.goals = parsed.goals;
1071 state.selectedDate = getLatestIntakeDate();
1072
1073 saveIntakes();
1074 saveTheme();
1075 saveGoals();
1076 applyTheme();
1077 renderGoalsForm();
1078
1079 elements.anchorDate.value = toISODate(new Date());
1080 elements.todaySelectDate.value = state.selectedDate;
1081 elements.entryDate.value = state.selectedDate;
1082 elements.goalsHint.textContent = `Imported ${state.intakes.length} entries.`;
1083
1084 renderDay();
1085 renderHistory();
1086 } catch (_error) {
1087 elements.goalsHint.textContent = "Import failed. Use a valid backup file.";
1088 } finally {
1089 event.target.value = "";
1090 }
1091};
1092
1093const saveGoalsFromSettings = (event) => {
1094 event.preventDefault();
1095 state.goals = normalizeGoals({
1096 kcal: Number(elements.goalKcal.value),
1097 protein: Number(elements.goalProtein.value),
1098 carbs: Number(elements.goalCarbs.value),
1099 fat: Number(elements.goalFat.value),
1100 sugar: Number(elements.goalSugar.value),
1101 });
1102 saveGoals();
1103 renderGoalsForm();
1104 elements.goalsHint.textContent = "Goals saved.";
1105 renderHistory();
1106 renderDay();
1107};
1108
1109const initTabs = () => {
1110 elements.tabs.forEach((tab) => {
1111 tab.addEventListener("click", () => {
1112 elements.tabs.forEach((btn) => btn.classList.remove("active"));
1113 tab.classList.add("active");
1114
1115 const view = tab.dataset.view;
1116 if (view === "today") {
1117 elements.intakeForm.classList.remove("hidden");
1118 elements.todayView.classList.remove("hidden");
1119 elements.historyView.classList.add("hidden");
1120 elements.settingsView.classList.add("hidden");
1121 } else if (view === "history") {
1122 elements.intakeForm.classList.add("hidden");
1123 elements.todayView.classList.add("hidden");
1124 elements.historyView.classList.remove("hidden");
1125 elements.settingsView.classList.add("hidden");
1126 } else {
1127 elements.intakeForm.classList.add("hidden");
1128 elements.todayView.classList.add("hidden");
1129 elements.historyView.classList.add("hidden");
1130 elements.settingsView.classList.remove("hidden");
1131 }
1132 });
1133 });
1134};
1135
1136const loadFoods = async () => {
1137 const response = await fetch(FOODS_URL);
1138 const foods = await response.json();
1139 state.foods = foods.map((food) => ({
1140 ...food,
1141 sugar: typeof food.sugar === "number" ? food.sugar : 0,
1142 }));
1143 elements.foodHint.textContent = `${state.foods.length} foods ready.`;
1144 renderIntakePreview();
1145};
1146
1147const init = async () => {
1148 loadIntakes();
1149 loadTheme();
1150 loadGoals();
1151 await loadFoods();
1152 state.selectedDate = toISODate(new Date());
1153 elements.anchorDate.value = toISODate(new Date());
1154 elements.todaySelectDate.value = state.selectedDate;
1155 elements.entryDate.value = state.selectedDate;
1156 applyTheme();
1157 renderGoalsForm();
1158 elements.goalsHint.textContent = "";
1159 renderDay();
1160 renderHistory();
1161};
1162
1163const registerServiceWorker = () => {
1164 if ("serviceWorker" in navigator) {
1165 window.addEventListener("load", () => {
1166 navigator.serviceWorker.register("sw.js");
1167 });
1168 }
1169};
1170
1171initTabs();
1172loadIntakes();
1173registerServiceWorker();
1174
1175window.addEventListener("DOMContentLoaded", () => {
1176 initFoodAutocomplete();
1177 elements.addForm.addEventListener("submit", addIntake);
1178 elements.entryDate.addEventListener("change", (event) => {
1179 state.selectedDate = event.target.value || state.selectedDate;
1180 renderDay();
1181 });
1182 elements.todaySelectDate.addEventListener("change", (event) => {
1183 state.selectedDate = event.target.value || state.selectedDate;
1184 renderDay();
1185 });
1186 elements.rangeSelect.addEventListener("change", renderHistory);
1187 elements.anchorDate.addEventListener("change", renderHistory);
1188 elements.themeToggle.addEventListener("click", toggleTheme);
1189 elements.exportDataButton.addEventListener("click", exportLocalData);
1190 elements.importDataButton.addEventListener("click", () => {
1191 elements.importFileInput.click();
1192 });
1193 elements.importFileInput.addEventListener("change", importLocalData);
1194 elements.goalsForm.addEventListener("submit", saveGoalsFromSettings);
1195 elements.foodInput.addEventListener("input", renderIntakePreview);
1196 elements.gramsInput.addEventListener("input", renderIntakePreview);
1197 document.addEventListener("click", (event) => {
1198 if (
1199 event.target !== elements.foodInput &&
1200 !elements.foodSuggestions.contains(event.target)
1201 ) {
1202 hideFoodSuggestions();
1203 }
1204 });
1205 init();
1206});