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

Финальная часть цикла — про математику и обёртку. Считаем норму калорий в браузере, рисуем кольцо прогресса на 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, плюс всё, что выходило раньше них.
Все записи