Gym — Nutrition / Macro Log
A daily nutrition tracker for athletes with a calorie ring showing remaining kcal and animated Protein, Carbs and Fat goal bars up top. Meal sections for Breakfast, Lunch, Dinner and Snacks list food entries with calories and per-macro tags plus a remove button, an inline Add-food form appends entries and live-updates every ring and total, and a tappable row of water glasses logs hydration. Bold athletic dark UI in vanilla JS.
MCP
Code
:root {
--bg: #0d0f12;
--surface: #15181d;
--surface-2: #1d2127;
--elevated: #23282f;
--ink: #f4f6f8;
--ink-2: #c2c8d0;
--muted: #8b929c;
--neon: #c6ff3a;
--neon-d: #a6e016;
--neon-50: rgba(198, 255, 58, 0.12);
--orange: #ff6a2b;
--orange-soft: rgba(255, 106, 43, 0.14);
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--blue: #5db3ff;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-2: 0 10px 30px rgba(0, 0, 0, 0.45);
--macro-protein: var(--neon);
--macro-carbs: var(--orange);
--macro-fat: var(--warn);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1200px 600px at 90% -10%, rgba(198, 255, 58, 0.06), transparent 60%),
radial-gradient(900px 500px at -10% 10%, rgba(255, 106, 43, 0.05), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
max-width: 760px;
margin: 0 auto;
padding: 28px 20px 64px;
}
.eyebrow {
display: inline-block;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--neon);
}
/* ===== Topbar ===== */
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.topbar h1 {
margin: 4px 0 2px;
font-size: 30px;
font-weight: 900;
letter-spacing: -0.02em;
}
.topbar__date {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.topbar__streak {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 12px 16px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--sh-1);
min-width: 76px;
}
.streak__flame {
color: var(--orange);
font-size: 14px;
transform: rotate(0deg);
}
.streak__num {
font-size: 22px;
font-weight: 900;
line-height: 1;
}
.streak__label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
/* ===== Summary ===== */
.summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 16px;
margin-bottom: 18px;
}
.cal-card {
display: flex;
align-items: center;
gap: 18px;
padding: 20px;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
}
.ring {
position: relative;
width: 130px;
height: 130px;
flex-shrink: 0;
}
.ring__svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ring__track {
fill: none;
stroke: rgba(255, 255, 255, 0.07);
stroke-width: 11;
}
.ring__fill {
fill: none;
stroke: var(--neon);
stroke-width: 11;
stroke-linecap: round;
stroke-dasharray: 326.7;
stroke-dashoffset: 326.7;
transition: stroke-dashoffset 0.6s cubic-bezier(0.22, 1, 0.36, 1), stroke 0.3s ease;
filter: drop-shadow(0 0 6px rgba(198, 255, 58, 0.35));
}
.ring__fill.is-over {
stroke: var(--danger);
filter: drop-shadow(0 0 6px rgba(248, 113, 113, 0.4));
}
.ring__center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
}
.ring__remaining {
font-size: 26px;
font-weight: 900;
letter-spacing: -0.02em;
}
.ring__unit {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.cal-meta {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--ink-2);
}
.cal-meta__row {
display: flex;
align-items: center;
gap: 8px;
}
.cal-meta__row strong {
margin-left: auto;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.dot--goal {
background: rgba(255, 255, 255, 0.3);
}
.dot--food {
background: var(--neon);
}
.cal-meta__row--state {
margin-top: 4px;
padding: 5px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--neon-50);
color: var(--neon);
border: 1px solid rgba(198, 255, 58, 0.25);
align-self: flex-start;
}
.cal-meta__row--state.is-over {
background: rgba(248, 113, 113, 0.12);
color: var(--danger);
border-color: rgba(248, 113, 113, 0.3);
}
/* macro bars */
.macros {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
}
.macro {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 10px;
align-items: center;
}
.macro__head {
grid-column: 1 / -1;
display: flex;
align-items: baseline;
justify-content: space-between;
}
.macro__name {
font-size: 13px;
font-weight: 700;
}
.macro__val {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--ink-2);
}
.macro__goal {
color: var(--muted);
margin-left: 2px;
}
.macro__bar {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
}
.macro__fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
border-radius: 999px;
transition: width 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
.macro[data-macro="protein"] .macro__fill {
background: linear-gradient(90deg, var(--neon-d), var(--neon));
}
.macro[data-macro="carbs"] .macro__fill {
background: linear-gradient(90deg, #e0531c, var(--orange));
}
.macro[data-macro="fat"] .macro__fill {
background: linear-gradient(90deg, #d99a16, var(--warn));
}
.macro__pct {
font-size: 11px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
min-width: 34px;
text-align: right;
}
/* ===== Water ===== */
.water {
margin: 18px 0;
padding: 16px 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
}
.water__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.water__count {
font-size: 13px;
font-weight: 700;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.water__glasses {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.glass {
width: 38px;
height: 46px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
border-radius: var(--r-sm);
position: relative;
transition: transform 0.12s ease;
}
.glass:hover {
transform: translateY(-2px);
}
.glass:active {
transform: scale(0.94);
}
.glass:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 3px;
}
.glass svg {
width: 100%;
height: 100%;
display: block;
}
.glass__body {
fill: rgba(255, 255, 255, 0.05);
stroke: var(--line-2);
stroke-width: 1.5;
}
.glass__water {
fill: var(--blue);
opacity: 0;
transition: opacity 0.3s ease, transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
transform: translateY(20px);
transform-origin: bottom;
}
.glass.is-full .glass__water {
opacity: 0.85;
transform: translateY(0);
}
.glass.is-full .glass__body {
stroke: var(--blue);
}
/* ===== Meals ===== */
.meals {
display: flex;
flex-direction: column;
gap: 14px;
}
.meal {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
overflow: hidden;
}
.meal__head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
}
.meal__icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
font-size: 16px;
background: var(--surface-2);
border: 1px solid var(--line);
flex-shrink: 0;
}
.meal__title {
display: flex;
flex-direction: column;
}
.meal__name {
font-size: 15px;
font-weight: 800;
letter-spacing: -0.01em;
}
.meal__sub {
font-size: 11px;
color: var(--muted);
}
.meal__kcal {
margin-left: auto;
font-size: 14px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.meal__kcal small {
font-size: 10px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.meal__items {
list-style: none;
margin: 0;
padding: 0 18px;
}
.entry {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 0;
border-top: 1px solid var(--line);
animation: pop 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.entry.is-removing {
animation: collapse 0.28s ease forwards;
}
@keyframes collapse {
to {
opacity: 0;
transform: translateX(14px);
padding-block: 0;
height: 0;
}
}
.entry__main {
min-width: 0;
}
.entry__name {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry__macros {
display: flex;
gap: 6px;
margin-top: 3px;
}
.tag {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 5px;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
.tag--p {
background: var(--neon-50);
color: var(--neon);
}
.tag--c {
background: var(--orange-soft);
color: var(--orange);
}
.tag--f {
background: rgba(251, 191, 36, 0.13);
color: var(--warn);
}
.entry__kcal {
margin-left: auto;
font-size: 13px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--ink);
white-space: nowrap;
}
.entry__rm {
width: 28px;
height: 28px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 8px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: all 0.15s ease;
}
.entry__rm:hover {
background: rgba(248, 113, 113, 0.14);
border-color: rgba(248, 113, 113, 0.4);
color: var(--danger);
}
.entry__rm:focus-visible {
outline: 2px solid var(--danger);
outline-offset: 2px;
}
.meal__empty {
padding: 11px 0;
border-top: 1px solid var(--line);
font-size: 13px;
color: var(--muted);
font-style: italic;
}
.meal__foot {
padding: 10px 18px 16px;
}
.meal__add {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border-radius: var(--r-md);
border: 1px dashed var(--line-2);
background: transparent;
color: var(--ink-2);
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.15s ease;
}
.meal__add:hover {
border-color: var(--neon);
color: var(--neon);
background: var(--neon-50);
}
.meal__add:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
}
/* ===== Add form ===== */
.addform {
padding: 4px 0 2px;
animation: pop 0.25s ease;
}
.addform__grid {
display: grid;
grid-template-columns: 2fr repeat(4, 1fr);
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.field input {
width: 100%;
padding: 9px 10px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 14px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field input::placeholder {
color: var(--muted);
}
.field input:focus {
outline: none;
border-color: var(--neon);
box-shadow: 0 0 0 3px var(--neon-50);
}
.field input:invalid:not(:placeholder-shown) {
border-color: var(--danger);
}
.addform__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.btn {
font-family: inherit;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.02em;
padding: 9px 16px;
border-radius: var(--r-md);
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.1s ease, filter 0.15s ease, background 0.15s ease;
}
.btn:active {
transform: scale(0.97);
}
.btn:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.btn--neon {
background: var(--neon);
color: #10120a;
}
.btn--neon:hover {
filter: brightness(1.06);
box-shadow: 0 4px 16px rgba(198, 255, 58, 0.25);
}
.btn--ghost {
background: transparent;
color: var(--ink-2);
border-color: var(--line-2);
}
.btn--ghost:hover {
background: var(--surface-2);
color: var(--ink);
}
/* ===== Toast ===== */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
pointer-events: none;
width: max-content;
max-width: calc(100vw - 32px);
}
.toast {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 16px;
border-radius: var(--r-md);
background: var(--elevated);
border: 1px solid var(--line-2);
color: var(--ink);
font-size: 13px;
font-weight: 600;
box-shadow: var(--sh-2);
animation: toastIn 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--neon);
flex-shrink: 0;
}
.toast.is-out {
animation: toastOut 0.25s ease forwards;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(8px);
}
}
/* ===== Responsive ===== */
@media (max-width: 720px) {
.summary {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.app {
padding: 20px 14px 56px;
}
.topbar h1 {
font-size: 25px;
}
.cal-card {
flex-direction: column;
text-align: center;
gap: 14px;
}
.cal-meta {
width: 100%;
}
.cal-meta__row--state {
align-self: center;
}
.addform__grid {
grid-template-columns: 1fr 1fr;
}
.field--name {
grid-column: 1 / -1;
}
.glass {
width: 34px;
height: 42px;
}
.entry__macros {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
// ----- Goals -----
const GOALS = { kcal: 2200, protein: 180, carbs: 200, fat: 65 };
const WATER_TARGET = 8;
// ----- Seed data (realistic, fictional) -----
const MEALS = [
{
id: "breakfast",
name: "Breakfast",
sub: "7:10 AM",
icon: "☀️",
items: [
{ id: uid(), name: "Oats + whey scoop", kcal: 410, protein: 32, carbs: 52, fat: 8 },
{ id: uid(), name: "Banana", kcal: 105, protein: 1, carbs: 27, fat: 0 },
{ id: uid(), name: "Black coffee", kcal: 5, protein: 0, carbs: 1, fat: 0 },
],
},
{
id: "lunch",
name: "Lunch",
sub: "12:45 PM",
icon: "🍽️",
items: [
{ id: uid(), name: "Grilled chicken breast", kcal: 280, protein: 52, carbs: 0, fat: 7 },
{ id: uid(), name: "Jasmine rice (1 cup)", kcal: 205, protein: 4, carbs: 45, fat: 0 },
{ id: uid(), name: "Mixed greens + olive oil", kcal: 120, protein: 2, carbs: 6, fat: 11 },
],
},
{
id: "dinner",
name: "Dinner",
sub: "7:30 PM",
icon: "🌙",
items: [
{ id: uid(), name: "Salmon fillet", kcal: 360, protein: 40, carbs: 0, fat: 22 },
{ id: uid(), name: "Sweet potato", kcal: 180, protein: 4, carbs: 41, fat: 0 },
],
},
{
id: "snacks",
name: "Snacks",
sub: "Anytime",
icon: "🥜",
items: [
{ id: uid(), name: "Greek yogurt", kcal: 130, protein: 18, carbs: 9, fat: 3 },
{ id: uid(), name: "Almonds (28g)", kcal: 164, protein: 6, carbs: 6, fat: 14 },
],
},
];
let water = 3;
// ----- helpers -----
function uid() {
return "e" + Math.random().toString(36).slice(2, 9);
}
function num(v) {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
const $ = (sel, root) => (root || document).querySelector(sel);
// ----- Toast -----
function toast(msg) {
const wrap = $("#toastWrap");
const el = document.createElement("div");
el.className = "toast";
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => {
el.classList.add("is-out");
el.addEventListener("animationend", () => el.remove(), { once: true });
}, 2200);
}
// ----- Totals -----
function totals() {
const t = { kcal: 0, protein: 0, carbs: 0, fat: 0 };
MEALS.forEach((m) =>
m.items.forEach((it) => {
t.kcal += it.kcal;
t.protein += it.protein;
t.carbs += it.carbs;
t.fat += it.fat;
})
);
return t;
}
// ----- Render summary (ring + bars) -----
const RING_CIRC = 2 * Math.PI * 52; // r=52
function renderSummary() {
const t = totals();
const remaining = GOALS.kcal - t.kcal;
const over = remaining < 0;
// ring
const ratio = Math.min(t.kcal / GOALS.kcal, 1);
const ring = $("#calRing");
ring.style.strokeDashoffset = String(RING_CIRC * (1 - ratio));
ring.classList.toggle("is-over", over);
$("#calRemaining").textContent = over
? "+" + Math.abs(remaining)
: remaining.toLocaleString();
$("#calRemaining").nextElementSibling.textContent = over ? "kcal over" : "kcal left";
$("#calGoal").textContent = GOALS.kcal.toLocaleString();
$("#calFood").textContent = t.kcal.toLocaleString();
const state = $("#calState");
state.classList.toggle("is-over", over);
if (over) state.textContent = "Over budget";
else if (ratio >= 0.85) state.textContent = "Nearly there";
else state.textContent = "On track";
// macro bars
["protein", "carbs", "fat"].forEach((k) => {
const cap = k.charAt(0).toUpperCase() + k.slice(1);
const pct = Math.min(Math.round((t[k] / GOALS[k]) * 100), 100);
const bar = $("#bar" + cap);
bar.style.width = pct + "%";
$("#pct" + cap).textContent = Math.round((t[k] / GOALS[k]) * 100) + "%";
const card = bar.closest(".macro");
card.querySelector(".macro__cur").textContent = t[k] + "g";
card.querySelector(".macro__goal").textContent = "/ " + GOALS[k] + "g";
});
}
// ----- Render meals -----
function entryEl(mealId, it) {
const li = document.createElement("li");
li.className = "entry";
li.dataset.id = it.id;
li.innerHTML =
'<div class="entry__main">' +
'<div class="entry__name"></div>' +
'<div class="entry__macros">' +
'<span class="tag tag--p">P ' + it.protein + "</span>" +
'<span class="tag tag--c">C ' + it.carbs + "</span>" +
'<span class="tag tag--f">F ' + it.fat + "</span>" +
"</div></div>" +
'<span class="entry__kcal">' + it.kcal + " kcal</span>" +
'<button class="entry__rm" type="button" aria-label="Remove ' +
it.name.replace(/"/g, "") +
'">×</button>';
li.querySelector(".entry__name").textContent = it.name;
li.querySelector(".entry__rm").addEventListener("click", () =>
removeEntry(mealId, it.id, li)
);
return li;
}
function mealTotalKcal(meal) {
return meal.items.reduce((s, it) => s + it.kcal, 0);
}
function renderMeals() {
const root = $("#meals");
root.innerHTML = "";
MEALS.forEach((meal) => {
const sec = document.createElement("section");
sec.className = "meal";
sec.dataset.id = meal.id;
const head = document.createElement("div");
head.className = "meal__head";
head.innerHTML =
'<span class="meal__icon" aria-hidden="true">' + meal.icon + "</span>" +
'<span class="meal__title">' +
'<span class="meal__name">' + meal.name + "</span>" +
'<span class="meal__sub">' + meal.sub + "</span></span>" +
'<span class="meal__kcal">' + mealTotalKcal(meal) + " <small>kcal</small></span>";
sec.appendChild(head);
const list = document.createElement("ul");
list.className = "meal__items";
if (meal.items.length === 0) {
const empty = document.createElement("li");
empty.className = "meal__empty";
empty.textContent = "Nothing logged yet.";
list.appendChild(empty);
} else {
meal.items.forEach((it) => list.appendChild(entryEl(meal.id, it)));
}
sec.appendChild(list);
const foot = document.createElement("div");
foot.className = "meal__foot";
const addBtn = document.createElement("button");
addBtn.className = "meal__add";
addBtn.type = "button";
addBtn.innerHTML = "<span aria-hidden='true'>+</span> Add food";
addBtn.addEventListener("click", () => openForm(meal.id, foot, addBtn));
foot.appendChild(addBtn);
sec.appendChild(foot);
root.appendChild(sec);
});
}
function refreshMealKcal(mealId) {
const meal = MEALS.find((m) => m.id === mealId);
const sec = document.querySelector('.meal[data-id="' + mealId + '"]');
if (!sec) return;
sec.querySelector(".meal__kcal").innerHTML =
mealTotalKcal(meal) + " <small>kcal</small>";
// handle empty state
const list = sec.querySelector(".meal__items");
const hasEmpty = list.querySelector(".meal__empty");
if (meal.items.length === 0 && !hasEmpty) {
const empty = document.createElement("li");
empty.className = "meal__empty";
empty.textContent = "Nothing logged yet.";
list.appendChild(empty);
} else if (meal.items.length > 0 && hasEmpty) {
hasEmpty.remove();
}
}
// ----- Remove -----
function removeEntry(mealId, entryId, li) {
const meal = MEALS.find((m) => m.id === mealId);
const idx = meal.items.findIndex((it) => it.id === entryId);
if (idx === -1) return;
const removed = meal.items.splice(idx, 1)[0];
li.classList.add("is-removing");
li.addEventListener(
"animationend",
() => {
li.remove();
refreshMealKcal(mealId);
},
{ once: true }
);
renderSummary();
toast("Removed " + removed.name);
}
// ----- Add form -----
function openForm(mealId, foot, addBtn) {
if (foot.querySelector(".addform")) {
foot.querySelector(".addform input[name='name']").focus();
return;
}
const tpl = $("#addFormTpl").content.cloneNode(true);
const form = tpl.querySelector(".addform");
addBtn.hidden = true;
foot.appendChild(form);
form.querySelector("input[name='name']").focus();
function close() {
form.remove();
addBtn.hidden = false;
}
form.querySelector("[data-cancel]").addEventListener("click", close);
form.addEventListener("keydown", (e) => {
if (e.key === "Escape") close();
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const fd = new FormData(form);
const name = String(fd.get("name") || "").trim();
const kcal = num(fd.get("kcal"));
if (!name) {
form.querySelector("input[name='name']").focus();
return;
}
const it = {
id: uid(),
name: name,
kcal: kcal,
protein: num(fd.get("protein")),
carbs: num(fd.get("carbs")),
fat: num(fd.get("fat")),
};
const meal = MEALS.find((m) => m.id === mealId);
meal.items.push(it);
const list = document.querySelector(
'.meal[data-id="' + mealId + '"] .meal__items'
);
list.appendChild(entryEl(mealId, it));
refreshMealKcal(mealId);
renderSummary();
toast("Added " + name + " (" + kcal + " kcal)");
close();
});
}
// ----- Water -----
function renderWater() {
const wrap = $("#waterGlasses");
wrap.innerHTML = "";
for (let i = 0; i < WATER_TARGET; i++) {
const idx = i;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "glass";
btn.setAttribute("aria-pressed", idx < water ? "true" : "false");
btn.setAttribute("aria-label", "Glass " + (idx + 1));
btn.innerHTML =
'<svg viewBox="0 0 38 46" aria-hidden="true">' +
'<path class="glass__water" d="M7 22 L31 22 L29 41 Q28.5 43 26 43 L12 43 Q9.5 43 9 41 Z"/>' +
'<path class="glass__body" d="M6 5 L32 5 L29 41 Q28.5 43.5 26 43.5 L12 43.5 Q9.5 43.5 9 41 Z"/>' +
"</svg>";
updateGlass(btn, idx);
btn.addEventListener("click", () => {
// tapping a filled glass that's the last filled -> unfill it; else fill up to it
water = idx + 1 === water ? idx : idx + 1;
syncWater();
toast(water + " of " + WATER_TARGET + " glasses logged");
});
wrap.appendChild(btn);
}
$("#waterCount").textContent = String(water);
}
function updateGlass(btn, idx) {
const full = idx < water;
btn.classList.toggle("is-full", full);
btn.setAttribute("aria-pressed", full ? "true" : "false");
}
function syncWater() {
const glasses = $("#waterGlasses").children;
for (let i = 0; i < glasses.length; i++) updateGlass(glasses[i], i);
$("#waterCount").textContent = String(water);
}
// ----- Init -----
renderMeals();
renderWater();
renderSummary();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
<title>Gym — Nutrition / Macro Log</title>
</head>
<body>
<main class="app" role="main">
<header class="topbar">
<div class="topbar__id">
<span class="eyebrow">Daily Fuel</span>
<h1>Nutrition Log</h1>
<p class="topbar__date">Monday, June 8 · Marcus Vale · Cut phase</p>
</div>
<div class="topbar__streak" aria-label="Logging streak">
<span class="streak__flame" aria-hidden="true">▲</span>
<span class="streak__num">12</span>
<span class="streak__label">day streak</span>
</div>
</header>
<!-- ===== SUMMARY: calorie ring + macro bars ===== -->
<section class="summary" aria-label="Daily totals">
<div class="cal-card">
<div class="ring" role="img" aria-label="Calories consumed against goal">
<svg viewBox="0 0 120 120" class="ring__svg">
<circle class="ring__track" cx="60" cy="60" r="52" />
<circle class="ring__fill" id="calRing" cx="60" cy="60" r="52" />
</svg>
<div class="ring__center">
<span class="ring__remaining" id="calRemaining">0</span>
<span class="ring__unit">kcal left</span>
</div>
</div>
<div class="cal-meta">
<div class="cal-meta__row">
<span class="dot dot--goal"></span>Goal
<strong id="calGoal">2200</strong>
</div>
<div class="cal-meta__row">
<span class="dot dot--food"></span>Food
<strong id="calFood">0</strong>
</div>
<div class="cal-meta__row cal-meta__row--state" id="calState">On track</div>
</div>
</div>
<div class="macros" aria-label="Macronutrients">
<article class="macro" data-macro="protein">
<div class="macro__head">
<span class="macro__name">Protein</span>
<span class="macro__val"><span class="macro__cur">0</span><span class="macro__goal">/ 180g</span></span>
</div>
<div class="macro__bar"><span class="macro__fill" id="barProtein"></span></div>
<span class="macro__pct" id="pctProtein">0%</span>
</article>
<article class="macro" data-macro="carbs">
<div class="macro__head">
<span class="macro__name">Carbs</span>
<span class="macro__val"><span class="macro__cur">0</span><span class="macro__goal">/ 200g</span></span>
</div>
<div class="macro__bar"><span class="macro__fill" id="barCarbs"></span></div>
<span class="macro__pct" id="pctCarbs">0%</span>
</article>
<article class="macro" data-macro="fat">
<div class="macro__head">
<span class="macro__name">Fat</span>
<span class="macro__val"><span class="macro__cur">0</span><span class="macro__goal">/ 65g</span></span>
</div>
<div class="macro__bar"><span class="macro__fill" id="barFat"></span></div>
<span class="macro__pct" id="pctFat">0%</span>
</article>
</div>
</section>
<!-- ===== WATER ===== -->
<section class="water" aria-label="Water tracker">
<div class="water__head">
<span class="eyebrow">Hydration</span>
<span class="water__count"><span id="waterCount">0</span> / 8 glasses</span>
</div>
<div class="water__glasses" id="waterGlasses" role="group" aria-label="Water glasses">
<!-- glasses injected by JS -->
</div>
</section>
<!-- ===== MEALS ===== -->
<section class="meals" id="meals" aria-label="Meals">
<!-- meal sections injected by JS -->
</section>
</main>
<!-- Add-food template -->
<template id="addFormTpl">
<form class="addform" novalidate>
<div class="addform__grid">
<label class="field field--name">
<span>Food</span>
<input type="text" name="name" placeholder="e.g. Grilled chicken" autocomplete="off" required />
</label>
<label class="field">
<span>Kcal</span>
<input type="number" name="kcal" placeholder="0" min="0" inputmode="numeric" required />
</label>
<label class="field">
<span>P (g)</span>
<input type="number" name="protein" placeholder="0" min="0" inputmode="numeric" />
</label>
<label class="field">
<span>C (g)</span>
<input type="number" name="carbs" placeholder="0" min="0" inputmode="numeric" />
</label>
<label class="field">
<span>F (g)</span>
<input type="number" name="fat" placeholder="0" min="0" inputmode="numeric" />
</label>
</div>
<div class="addform__actions">
<button type="button" class="btn btn--ghost" data-cancel>Cancel</button>
<button type="submit" class="btn btn--neon">Add food</button>
</div>
</form>
</template>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Nutrition / Macro Log
A high-energy daily fuel screen for a gym or coaching app. The summary panel pairs a calorie ring — which fills toward the day’s goal, flips red when you go over, and shows the remaining (or surplus) kcal in the center — with three animated macro bars for Protein, Carbs and Fat, each tracking grams against its target with a live percentage.
Below, the day is split into Breakfast, Lunch, Dinner and Snacks sections. Each lists its food entries with calories and color-coded P/C/F tags, a running per-meal kcal total, and a one-tap remove button. The inline Add food form (name, calories, P/C/F) appends a new entry, recalculates the meal total, and instantly re-animates every ring and bar — no page reload. A row of water glasses fills with a single tap so hydration is logged alongside macros.
Everything is vanilla JS with smooth micro-interactions, an accessible toast helper, keyboard- usable controls and visible focus rings, and a responsive layout that collapses cleanly down to roughly 360px. The data is realistic but fictional — illustrative UI only, not medical or dietary advice.