Калькулятор нормы, темы и полировка

Калькулятор нормы, темы и полировка

Финальная часть цикла — про математику и обёртку. Считаем норму калорий в браузере, рисуем кольцо прогресса на SVG и добавляем тёмную тему парными токенами. Плюс история о том, как кольцо обрезалось до дуги и что пришлось пересчитать.

Формула на клиенте

Норма считается по Mifflin-St Jeor, целиком в браузере:

function calcNorms({ sex, age, height, weight, activity, goal }) {
  const bmr = 10 * weight + 6.25 * height - 5 * age + (sex === 'male' ? 5 : -161);
  const tdee = bmr * ACT_COEF[activity];        // 1.2 (минимум) … 1.9 (спортсмен)
  const goalK = goal === 'lose' ? 0.85 : goal === 'gain' ? 1.1 : 1.0;
  const kcal = Math.max(0, Math.round(tdee * goalK));
  const protein = Math.round((kcal * 0.3) / 4); // 30% калорий при 4 ккал/г
  const fat = Math.round((kcal * 0.3) / 9);     // 30% при 9 ккал/г
  const carb = Math.round((kcal * 0.4) / 4);    // 40% при 4 ккал/г
  const bmi = weight / Math.pow(height / 100, 2);
  const inches = Math.max(0, (height - 152.4) / 2.54); // 152.4 см = 5 футов
  const ideal = sex === 'male' ? 50 + 2.3 * inches : 45.5 + 2.3 * inches;
  const water = Math.round(30 * weight);        // 30 мл на кг
  return { bmr: Math.round(bmr), kcal, protein, fat, carb, bmi, ideal, water };
}

В той же функции считаются ИМТ с категорией и идеальный вес по Devine: мужчинам — 50 кг плюс 2,3 кг за каждые 2,54 см роста сверх 152,4 см, женщинам — 45,5 кг плюс столько же за те же 2,54 см. При росте ниже 152,4 см формула останавливается на базовых килограммах. Проверяли на живом примере: мужчина, 35 лет, 175 см, 90 кг, «спортсмен», «поддержание» — выходит 3465 ккал. Цифра сходится с профилем тестового пользователя в автотесте.

Считать на клиенте удобно: результат готов мгновенно, работает офлайн, и флоу-бэкенд арифметику не считает. На сервер уходит одно действие: «Применить норму» пишет готовое число в профиль одним частичным POST /api/profile. Платформа принимает любое подмножество полей, так что нормы, вода и данные тела (пол, рост, вес, активность, цель) отправляются атомарно одним патчем.

Калькулятор и профиль в связке

Поля калькулятора заполняются по порядку: сначала из профиля, если он заполнен, затем из черновика, иначе остаётся плейсхолдер. Черновик лежит в localStorage (kbzhu-calc) и остаётся на месте после закрытия вкладки, вместе с готовым результатом. В профиль попадает только то, что применили явно.

Баг не был виден, пока сценарий не прошли руками. Кнопка «Применить норму» брала результат последнего расчёта из state.calc: поменяйте вес после «Рассчитать», и норма применится от старого веса. Поэтому перед применением поля перечитываются заново:

const vals = readInputs(); // перечитываем: state.calc мог устареть
if (!vals) { toast(T.toast.fillForm, 'err'); return; }
if (!state.calcInputs || JSON.stringify(vals) !== JSON.stringify(state.calcInputs)) {
  state.calc = calcNorms(vals);
  state.calcInputs = vals;
}

Работает и в обратную сторону. Если данные тела и норма в профиле разъехались больше чем на 2% (вес обновился, норма осталась старой), появляется хинт «Данные профиля изменились — пересчитайте норму».

Калькулятор с результатом, БЖУ и водой (светлая и тёмная темы)

Кольцо, которое обрезалось

Кольцо калорий на экране «Сегодня» — круг на SVG. Первая версия: viewBox="0 0 36 36", радиус 15.9155 (он даёт честные 100 единиц длины окружности) и stroke-width: 0.625rem — толщина «как у всех», в rem. Вживую кольцо местами превращалось в «каплю», а при нулевом прогрессе сверху свисала четверть-дуга.

Причина в том, как CSS-длины попадают в SVG. 0.625rem — это 10px, а здесь один px равен одному юниту viewBox. Толщина штриха делится пополам: 5 юнитов уходит внутрь, 5 наружу. Радиус 15.92 плюс наружные 5 — край кольца выходит на 20.92 от центра, а от центра до края бокса всего 18. Кольцо не влезает и обрезается с четырёх сторон.

Четверть-дуга — из той же толщины. При нулевом прогрессе штрих имеет нулевую длину, но stroke-linecap: round дорисовывает на его конце круг размером с толщину — выходит точка диаметром 10. Начало окружности находится наверху, поэтому точка и висит на двенадцати часах.

Переписали на pathLength="100" — теперь доля окружности задаётся прямо в процентах, без тригонометрии:

let fg = '';
if (norm && pct > 0) { // при pct <= 0 дуги нет вообще
  fg = pct >= 0.999
    ? `<circle class="ring-fg${over ? ' is-over' : ''}" cx="22" cy="22" r="19" pathLength="100"></circle>`
    : `<circle class="ring-fg${over ? ' is-over' : ''}" cx="22" cy="22" r="19" pathLength="100"
         stroke-dasharray="${(pct * 100).toFixed(1)} 100"></circle>`;
}

В итоговой версии бокс 44, радиус 19 и stroke-width: 4px. Дуга при нуле не рендерится вовсе, полный круг рисуется без dasharray, класс is-over красит кольцо при переборе, переход дуги длится 0.45 секунды. Автотест прогнал семь ширин экрана от 320 до 1440 — кольцо осталось целым везде.

Десктопный лэйаут с сайдбаром, кольцом и карточками приёмов (светлая и тёмная темы)

Тема, брейкпоинты, rem

Вся система размеров построена на rem, и порогов два. До 48rem работает мобильный каркас с таб-баром, от 48rem — десктоп с сайдбаром 15.5rem и контентом до 60rem. Границу проверили на семи ширинах экрана: 320, 390, 600, 713, 768, 1024 и 1440. На каждой контент умещается без горизонтальной прокрутки, включая 713px — здесь от системного масштаба зависит, какая вёрстка включится.

Тёмную тему мы собрали парными токенами, без инверсии. У неё свои --bg #131815, --surface #1c2420 и приглушённый акцент #4cbf85 вместо дневного #258a54, плюс color-scheme для нативных контролов. Переключатель работает в трёх режимах — auto, light и dark, причём auto берёт тему прямо из системы через matchMedia и ничего не пишет в localStorage. Чтобы на тёмном телефоне первым кадром не показывалась светлая тема, в <head> стоит скрипт, который выполняется до первого рендера:

var t = localStorage.getItem('kbzhu-theme');
if (t !== 'light' && t !== 'dark') {
  t = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', t);

Полоса калорий меняет цвет по мере приближения к норме: до 85% она акцентная, дальше желтеет, при переборе краснеет вместе с кольцом. Полосы белков, жиров, углеводов и воды построены на тех же токенах, поэтому в тёмной теме перекрашиваются сами.

Сюда вошла и мелочь. У кнопок воды есть aria-label, кольцо помечено как role="img" с вычисляемой подписью «Съедено N из M ккал». Если нормы ещё нет, карточка с кольцом показывает кнопку «Рассчитать норму», которая ведёт на калькулятор. Глобальный prefers-reduced-motion гасит пульс точки и переход дуги.

Профиль на тех же парных токенах (тёмная и светлая темы)

«Съел 100 ккал 20 б 3 ж 5 у»

Одно решение подсказал владелец проекта. Он попробовал записать «100 ккал, 20 б, 3 ж, 5 у» и упёрся в каталог: раньше форма принимала только продукты из списка. Для куска пирога из столовой это лишние шаги, поэтому появился таб «Своё» с четырьмя полями — ккал, белки, жиры и углеводы. Граммы и название необязательны.

Достаточно ввести «100 / 20 / 3 / 5», и запись уходит в дневник без имени (name: null, схема это позволяет). Если указать название и граммы, откроется кнопка «Сохранить в каталог», и цифры пересчитаются на 100 г для будущего поиска.

Быстрая запись цифрами без каталога (светлая и тёмная темы)

Финал цикла

Приложение доступно на calories.dynapi.ru. Ставится прямо с сайта: браузер покажет «Установить приложение» либо «На экран „Домой"». Спокойно работает офлайн и синхронизируется, когда появится сеть. Внутри — дневник с четырьмя приёмами, каталог из 131 продукта и свои позиции, вес, вода, нормы по Mifflin-St Jeor, две темы. Бэкенд под всем этим собран из флоу, в которых нет ни строчки серверного кода.

Саму платформу под всем этим, DynapiCMS со схемой, которая перестраивается на лету, стоит потрогать тем же способом, каким строился этот трекер: описать типы данных, собрать флоу и посмотреть, как из них получается продукт.

Это была седьмая, финальная часть цикла. Предыдущие части: «Офлайн и синхронизация», service worker и грабли Liquid, плюс всё, что выходило раньше них.


Все записи