arnaucube
arnaucube/macrosPublic Self-hosted repository
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});