Ккал — локальный дневник питания
Считаете калории — приложение делает это быстрым: запись за 2–3 касания, остаток на день всегда перед глазами, все данные только в вашем браузере (IndexedDB, без бэкенда и аккаунтов).
Стек
- Vue 3.6.0-rc.2, Vapor mode — без virtual DOM; компоненты написаны на TSX
через vue-jsx-vapor (компилятор на Rust/Oxc),
createVaporAppвmain.ts. - vue-sync-engine (
../vue-sync-engine/lib, подключён какfile:-зависимость) — нормализованный entity-кэш: запросы, мутации с optimistic-патчами, персистентность в IndexedDB. - Tailwind CSS 4 как Vite-плагин, дизайн-токены в
src/app.css(@theme). - @robonen/stdlib (
clamp,groupBy), @robonen/vue (useCloseWatcher— закрытие шторок по Esc и жесту «назад» на Android, с фолбэком на keydown), @robonen/platform (focus), @robonen/tsconfig, @robonen/eslint. - date-fns (+
ru-локаль: родительный падеж месяцев из коробки). - lucide — данные иконок (пары «тег + атрибуты»); рендер своим Vapor-компонентом
через
v-html, т.к.lucide-vue-nextпостроен на vdom и в чистом Vapor не работает. - Шрифты: Golos Text (UI, кириллица) + Spectral (display-цифры и заголовки).
pnpm install
pnpm dev # vite
pnpm test # vitest, доменные расчёты
pnpm typecheck # tsc --noEmit
pnpm lint # eslint (@robonen/eslint, eslint 10)
pnpm build # tsc + vite build
vue-sync-engine должен быть собран:
cd ../vue-sync-engine && pnpm --filter vue-sync-engine build.
Как устроен local-first слой
Бэкенда нет, источник истины — IndexedDB (kcal):
defineEntity({ storage: idbStore(...) })— каждая сущность в своём object store;EntityDef.storageиспользуется и как прямой доступ к idb.- Запросы читают из этих же сторов (
readAll+ фильтр), нормализуют сущности в Mirror и возвращают списки id. Снапшоты запросов персистятся черезindexedDBAdapter— после перезагрузки данные всплывают мгновенно. - Мутации пишут в idb внутри
fetch(запись await-ится доinvalidate, поэтому рефетч всегда видит свежие данные),optimisticдаёт мгновенный UI,invalidateпо тегам (entries,foods,weights,profile) обновляет списки и статистику. - Записи дневника хранят снапшот имени и нутриентов — правка или удаление продукта не переписывает историю.
Структура: src/domain (типы, расчёты Миффлина—Сан Жеора, даты — без Vue),
src/data (дефы движка, сид-каталог ~60 продуктов, бэкап), src/screens +
src/components (TSX Vapor), src/ui (состояние навигации, иконки).
Что уже умеет
- Онбординг: BMR/TDEE по Миффлину—Сан Жеору, цель (похудение −15% / поддержание / набор +10%), белок 1.6–1.8 г/кг, предупреждение о слишком низкой цели.
- Дневник: кольцо остатка, полосы Б/Ж/У, приёмы пищи, листание дней.
- Добавление: поиск по каталогу, «Недавние» (частота + последняя порция), граммы/штуки, живой пересчёт, быстрая запись «только калории», свой продукт.
- Штрихкоды: сканер камерой (BarcodeDetector, Chrome/Android) и ручной ввод
цифр — КБЖУ подтягиваются из Open Food Facts (barcode-API отдаёт CORS
*, запрос идёт прямо из браузера). Повторный скан того же товара сразу открывает выбор порции (дедупликация поFood.barcode). Текстовый поиск OFF из браузера невозможен: legacy-эндпоинт отключён, у search-a-licious нет CORS. - Справка в профиле: 7 практических вопросов — откуда брать КБЖУ, как писать ресторанную еду, сколько точности достаточно, как считаются цели.
- Статистика: калории по дням (7/14/30) с целью-линией и выбором дня, средние, «дней в цели», журнал веса с трендом за неделю.
- Каталог: поиск, категории, правка/удаление (сид-набор редактируется как свой).
- Профиль: пересчёт целей, ручная правка, экспорт/импорт JSON, полный сброс.
Дорожная карта
- Кросс-таб синхронизация — перевести движок в режим SharedWorker
(
bootstrapWorker+createSharedWorkerClientTransport), дефы уже собираются плагином вvirtual:sync-engine-registry. - PWA — manifest + service worker, чтобы поставить на домашний экран.
- Порции-пресеты у продукта («стакан», «ложка»), копирование вчерашнего дня, конструктор рецептов (сумма ингредиентов ÷ готовый вес = свой продукт «на 100 г») — главный недостающий кусок для домашней готовки.
- Недельный отчёт: средний дефицит vs фактическое изменение веса (замыкает петлю «оценка калорий → реальность»).
- A11y: связать
label/idу полей,aria-liveна кольце, фокус-ловушка в шторках. - Индекс по дате для записей (
IDBKeyRangeвместоreadAll+фильтра), когда дневник разрастётся.