Files
snippets/kcal

Ккал — локальный дневник питания

Считаете калории — приложение делает это быстрым: запись за 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+фильтра), когда дневник разрастётся.