feat: add kcal app
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,86 @@
|
||||
# Ккал — локальный дневник питания
|
||||
|
||||
Считаете калории — приложение делает это быстрым: запись за 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-цифры и заголовки).
|
||||
|
||||
```bash
|
||||
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`+фильтра), когда
|
||||
дневник разрастётся.
|
||||
@@ -0,0 +1,16 @@
|
||||
import { base, compose, imports, stylistic, typescript, vitest } from '@robonen/eslint';
|
||||
|
||||
export default compose(
|
||||
base,
|
||||
typescript,
|
||||
imports,
|
||||
stylistic,
|
||||
vitest,
|
||||
{
|
||||
name: 'kcal/overrides',
|
||||
rules: {
|
||||
// Дефы движка и доменные расчёты плотно работают с числовыми литералами.
|
||||
'unicorn/no-zero-fractions': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b0d12" />
|
||||
<title>Ккал — дневник питания</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "kcal",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Локальный дневник питания: калории, БЖУ, вес. Vue Vapor + vue-sync-engine + IndexedDB, без бэкенда.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/golos-text": "^5.3.0",
|
||||
"@fontsource/spectral": "^5.3.0",
|
||||
"@robonen/platform": "^0.0.5",
|
||||
"@robonen/stdlib": "^0.0.12",
|
||||
"@robonen/vue": "^0.2.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide": "^1.28.0",
|
||||
"vue": "3.6.0-rc.2",
|
||||
"vue-sync-engine": "file:../vue-sync-engine/lib"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@robonen/eslint": "^0.0.1",
|
||||
"@robonen/tsconfig": "^0.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^25.9.1",
|
||||
"eslint": "^10.8.0",
|
||||
"jiti": "^2.7.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.8",
|
||||
"vue-jsx-vapor": "^3.2.19"
|
||||
}
|
||||
}
|
||||
Generated
+3260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
minimumReleaseAgeExclude:
|
||||
- vite@8.2.1
|
||||
peerDependencyRules:
|
||||
allowAny:
|
||||
- vue
|
||||
- rollup
|
||||
@@ -0,0 +1,117 @@
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { useQuery } from 'vue-sync-engine';
|
||||
import { profileQuery } from './data/defs';
|
||||
import type { Meal } from './domain/types';
|
||||
import { activeTab, addSheet, editEntryId, foodForm, openAddSheet } from './ui/state';
|
||||
import type { Tab } from './ui/state';
|
||||
import { IconApple, IconBook, IconChart, IconPlus, IconUser } from './ui/icons';
|
||||
import DiaryScreen from './screens/DiaryScreen';
|
||||
import StatsScreen from './screens/StatsScreen';
|
||||
import FoodsScreen from './screens/FoodsScreen';
|
||||
import ProfileScreen from './screens/ProfileScreen';
|
||||
import AddSheet from './screens/AddSheet';
|
||||
import EditEntrySheet from './screens/EditEntrySheet';
|
||||
import FoodFormSheet from './screens/FoodFormSheet';
|
||||
|
||||
const TABS: ReadonlyArray<{ id: Tab; label: string }> = [
|
||||
{ id: 'diary', label: 'Дневник' },
|
||||
{ id: 'stats', label: 'Статистика' },
|
||||
{ id: 'foods', label: 'Продукты' },
|
||||
{ id: 'profile', label: 'Профиль' },
|
||||
];
|
||||
|
||||
/** Приём пищи по времени суток — разумный дефолт для кнопки «+». */
|
||||
function mealByHour(hour: number): Meal {
|
||||
if (hour < 11) return 'breakfast';
|
||||
if (hour < 16) return 'lunch';
|
||||
if (hour < 21) return 'dinner';
|
||||
return 'snack';
|
||||
}
|
||||
|
||||
function tabIcon(tab: Tab, active: boolean) {
|
||||
const cls = `size-5.5 ${active ? 'text-ember-bright' : 'text-ink-faint'}`;
|
||||
if (tab === 'diary') return <IconBook class={cls} />;
|
||||
if (tab === 'stats') return <IconChart class={cls} />;
|
||||
if (tab === 'foods') return <IconApple class={cls} />;
|
||||
return <IconUser class={cls} />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const profileQ = useQuery(profileQuery, () => undefined);
|
||||
const ready = computed(() => profileQ.data.value !== undefined);
|
||||
const hasProfile = computed(() => profileQ.data.value?.exists === true);
|
||||
|
||||
const sheetOpen = computed(() => addSheet.open || editEntryId.value !== null || foodForm.open);
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('overflow-hidden', sheetOpen.value);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="mx-auto flex min-h-dvh w-full max-w-105 flex-col px-4 pt-5">
|
||||
{!ready.value && (
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<span class="text-display animate-pulse text-2xl font-light text-ember-bright">Ккал</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ready.value && !hasProfile.value && (
|
||||
<div class="pb-10">
|
||||
<ProfileScreen onboarding />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ready.value && hasProfile.value && (
|
||||
<>
|
||||
<main class="flex-1 pb-30">
|
||||
{activeTab.value === 'diary' && <DiaryScreen />}
|
||||
{activeTab.value === 'stats' && <StatsScreen />}
|
||||
{activeTab.value === 'foods' && <FoodsScreen />}
|
||||
{activeTab.value === 'profile' && <ProfileScreen />}
|
||||
</main>
|
||||
|
||||
{/* Нижняя навигация */}
|
||||
<nav class="fixed inset-x-0 bottom-0 z-40 flex justify-center">
|
||||
<div class="flex w-full max-w-105 items-end border-t hairline bg-[#151210]/92 px-2 pt-2 pb-[max(env(safe-area-inset-bottom),10px)] backdrop-blur-md">
|
||||
{TABS.slice(0, 2).map(tab => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-xl py-1.5 transition hover:bg-white/4"
|
||||
onClick={() => (activeTab.value = tab.id)}
|
||||
>
|
||||
{tabIcon(tab.id, activeTab.value === tab.id)}
|
||||
<span class={`text-[10px] ${activeTab.value === tab.id ? 'text-ember-bright' : 'text-ink-faint'}`}>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div class="flex flex-1 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Добавить запись"
|
||||
class="grid size-13 -translate-y-3 place-items-center rounded-full bg-ember text-[#1a1006] shadow-[0_10px_30px_rgba(207,119,40,0.35)] transition hover:bg-ember-bright active:scale-95"
|
||||
onClick={() => openAddSheet(mealByHour(new Date().getHours()))}
|
||||
>
|
||||
<IconPlus class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{TABS.slice(2).map(tab => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 flex-col items-center gap-1 rounded-xl py-1.5 transition hover:bg-white/4"
|
||||
onClick={() => (activeTab.value = tab.id)}
|
||||
>
|
||||
{tabIcon(tab.id, activeTab.value === tab.id)}
|
||||
<span class={`text-[10px] ${activeTab.value === tab.id ? 'text-ember-bright' : 'text-ink-faint'}`}>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
{addSheet.open && <AddSheet />}
|
||||
{editEntryId.value !== null && <EditEntrySheet />}
|
||||
{foodForm.open && <FoodFormSheet />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* «Вечерний гроссбух»: тёплый угольный фон, кремовые чернила, янтарный акцент.
|
||||
* Цвета данных (ember/protein/fat/carbs) проверены на CVD-разделимость и
|
||||
* контраст к поверхности (OKLCH L 0.48–0.67, C ≥ 0.1, ΔE пар ≥ 8).
|
||||
*/
|
||||
@theme {
|
||||
--font-sans: 'Golos Text Variable', 'Golos Text', system-ui, sans-serif;
|
||||
--font-display: 'Spectral', 'Iowan Old Style', Georgia, serif;
|
||||
|
||||
--color-bg: #12100d;
|
||||
--color-surface: #1b1815;
|
||||
--color-raised: #262119;
|
||||
--color-ink: #ece4d6;
|
||||
--color-ink-soft: #b3a894;
|
||||
--color-ink-faint: #80766a;
|
||||
|
||||
--color-ember: #cf7728;
|
||||
--color-ember-bright: #eda05b;
|
||||
--color-protein: #2aa08b;
|
||||
--color-fat: #ae8e2c;
|
||||
--color-carbs: #6284d0;
|
||||
--color-over: #cf5f4d;
|
||||
--color-over-bright: #e8836f;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-bg);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--color-ink);
|
||||
background:
|
||||
radial-gradient(120% 70% at 50% 0%, #201b14 0%, var(--color-bg) 55%),
|
||||
var(--color-bg);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Зерно поверх фона — атмосфера бумаги при свете лампы. */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 70;
|
||||
opacity: 0.05;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@utility text-display {
|
||||
font-family: var(--font-display);
|
||||
font-variant-numeric: lining-nums;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@utility tnum {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
@utility hairline {
|
||||
border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);
|
||||
}
|
||||
|
||||
@utility scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Появление контента: мягкий подъём, каскад через animation-delay. */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-rise {
|
||||
animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes sheet-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-sheet-up {
|
||||
animation: sheet-up 0.38s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-fade-in {
|
||||
animation: fade-in 0.25s ease-out both;
|
||||
}
|
||||
|
||||
/* Кольцо калорий: плавное перетекание дуги при изменении данных. */
|
||||
.ring-arc {
|
||||
transition: stroke-dashoffset 0.7s cubic-bezier(0.22, 1, 0.36, 1), stroke 0.4s ease;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Прячем спиннеры числовых инпутов — порции вводятся с клавиатуры и чипсами. */
|
||||
input[type='number']::-webkit-outer-spin-button,
|
||||
input[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
|
||||
import { useRef } from 'vue-jsx-vapor';
|
||||
|
||||
/** Есть ли в этом браузере нативное распознавание штрихкодов (Chromium). */
|
||||
export function isBarcodeScanSupported(): boolean {
|
||||
return typeof BarcodeDetector !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||||
}
|
||||
|
||||
const SCAN_INTERVAL_MS = 300;
|
||||
|
||||
/**
|
||||
* Камера + BarcodeDetector: распознали EAN/UPC — отдали наверх и погасили
|
||||
* камеру. Компонент монтируется только после isBarcodeScanSupported().
|
||||
*/
|
||||
export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) {
|
||||
const videoEl = useRef();
|
||||
const error = shallowRef('');
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let finished = false;
|
||||
|
||||
const stop = () => {
|
||||
if (timer !== null) clearInterval(timer);
|
||||
timer = null;
|
||||
stream?.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const video = videoEl.value as HTMLVideoElement | null;
|
||||
if (!video) return;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment' },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
const detector = new BarcodeDetector({
|
||||
formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'],
|
||||
});
|
||||
timer = setInterval(async () => {
|
||||
if (finished || video.readyState < 2) return;
|
||||
try {
|
||||
const barcodes = await detector.detect(video);
|
||||
const code = barcodes[0]?.rawValue;
|
||||
if (code) {
|
||||
finished = true;
|
||||
stop();
|
||||
props.onDetected(code);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Единичный сбой кадра — просто ждём следующий тик.
|
||||
}
|
||||
}, SCAN_INTERVAL_MS);
|
||||
}
|
||||
catch (cause) {
|
||||
error.value = cause instanceof DOMException && cause.name === 'NotAllowedError'
|
||||
? 'Нет доступа к камере — разрешите его в настройках браузера.'
|
||||
: 'Не удалось включить камеру.';
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(stop);
|
||||
|
||||
return (
|
||||
<div class="overflow-hidden rounded-2xl border hairline bg-black/60">
|
||||
{error.value === ''
|
||||
? (
|
||||
<div class="relative">
|
||||
<video ref={videoEl} muted playsinline class="aspect-[4/3] w-full object-cover" />
|
||||
{/* Рамка прицела */}
|
||||
<div class="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div class="h-24 w-56 rounded-xl border-2 border-ember-bright/80 shadow-[0_0_0_999px_rgba(0,0,0,0.35)]" />
|
||||
</div>
|
||||
<p class="absolute right-0 bottom-2 left-0 text-center text-[12px] text-ink/90">
|
||||
Наведите на штрихкод упаковки
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: <p class="px-4 py-6 text-center text-[13px] text-over-bright">{error.value}</p>}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full border-t hairline py-2.5 text-[13px] text-ink-soft transition hover:bg-white/5"
|
||||
onClick={props.onCancel}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { computed } from 'vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import { fmtG } from '../domain/format';
|
||||
|
||||
const COLORS = {
|
||||
protein: 'var(--color-protein)',
|
||||
fat: 'var(--color-fat)',
|
||||
carbs: 'var(--color-carbs)',
|
||||
} as const;
|
||||
|
||||
/** Полоса «съедено/цель» по одному макронутриенту, в граммах. */
|
||||
export default function MacroBar(props: {
|
||||
label: string;
|
||||
value: number;
|
||||
target: number;
|
||||
color: keyof typeof COLORS;
|
||||
}) {
|
||||
const percent = computed(() => (props.target > 0 ? clamp((props.value / props.target) * 100, 0, 100) : 0));
|
||||
|
||||
return (
|
||||
<div class="flex-1">
|
||||
<div class="mb-1.5 flex items-center gap-1.5 text-[12px] text-ink-soft">
|
||||
<span class="size-1.5 shrink-0 rounded-full" style={{ background: COLORS[props.color] }} />
|
||||
{props.label}
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
class="bar-fill h-full rounded-full"
|
||||
style={{ width: `${percent.value}%`, background: COLORS[props.color] }}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] whitespace-nowrap text-ink-faint tnum">
|
||||
{`${fmtG(props.value)} / ${fmtG(props.target)} г`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { computed } from 'vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
import { fmtKcal } from '../domain/format';
|
||||
|
||||
const SIZE = 216;
|
||||
const RADIUS = 92;
|
||||
const STROKE = 11;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
|
||||
/**
|
||||
* Главный индикатор дня: съедено против цели, в центре — остаток.
|
||||
* При переборе дуга замыкается и меняет цвет, остаток становится «сверх цели».
|
||||
*/
|
||||
export default function ProgressRing(props: { eaten: number; target: number }) {
|
||||
const over = computed(() => props.eaten > props.target);
|
||||
const fraction = computed(() => (props.target > 0 ? clamp(props.eaten / props.target, 0, 1) : 0));
|
||||
const dashOffset = computed(() => CIRCUMFERENCE * (1 - fraction.value));
|
||||
const remaining = computed(() => Math.abs(props.target - props.eaten));
|
||||
|
||||
return (
|
||||
<div class="relative mx-auto size-54">
|
||||
<svg class="size-full -rotate-90" viewBox={`0 0 ${SIZE} ${SIZE}`}>
|
||||
<circle
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke="color-mix(in oklab, var(--color-ink) 9%, transparent)"
|
||||
stroke-width={STROKE}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="ember-arc" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="var(--color-ember)" />
|
||||
<stop offset="100%" stop-color="var(--color-ember-bright)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
class="ring-arc"
|
||||
cx={SIZE / 2}
|
||||
cy={SIZE / 2}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={over.value ? 'var(--color-over)' : 'url(#ember-arc)'}
|
||||
stroke-width={STROKE}
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray={String(CIRCUMFERENCE)}
|
||||
stroke-dashoffset={String(dashOffset.value)}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<div class="text-display text-[52px] leading-none font-light tracking-tight">
|
||||
{fmtKcal(remaining.value)}
|
||||
</div>
|
||||
<div class={`mt-2 text-[13px] ${over.value ? 'text-over-bright' : 'text-ink-soft'}`}>
|
||||
{over.value ? 'ккал сверх цели' : 'ккал осталось'}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-faint tnum">
|
||||
{`${fmtKcal(props.eaten)} из ${fmtKcal(props.target)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { entryStore, foodStore, profileStore, weightStore } from './defs';
|
||||
import type { Entry, Food, Profile, WeightLog } from '../domain/types';
|
||||
|
||||
export interface BackupPayload {
|
||||
app: 'kcal';
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
foods: Food[];
|
||||
entries: Entry[];
|
||||
weights: WeightLog[];
|
||||
profile: Profile | null;
|
||||
}
|
||||
|
||||
/** Полный снимок данных для файла-бэкапа. */
|
||||
export async function exportBackup(): Promise<BackupPayload> {
|
||||
const [foods, entries, weights, profiles] = await Promise.all([
|
||||
foodStore.readAll(),
|
||||
entryStore.readAll(),
|
||||
weightStore.readAll(),
|
||||
profileStore.readAll(),
|
||||
]);
|
||||
return {
|
||||
app: 'kcal',
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
foods,
|
||||
entries,
|
||||
weights,
|
||||
profile: profiles[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстановление из бэкапа: данные дописываются поверх текущих (merge по id).
|
||||
* После импорта проще всего перезагрузить страницу — кэш запросов пересоберётся.
|
||||
*/
|
||||
export async function importBackup(payload: BackupPayload): Promise<void> {
|
||||
if (payload.app !== 'kcal' || payload.version !== 1) {
|
||||
throw new Error('Файл не похож на бэкап приложения «Ккал»');
|
||||
}
|
||||
await Promise.all([
|
||||
payload.foods.length > 0 ? foodStore.write(payload.foods.map(f => ({ key: f.id, value: f }))) : Promise.resolve(),
|
||||
payload.entries.length > 0 ? entryStore.write(payload.entries.map(e => ({ key: e.id, value: e }))) : Promise.resolve(),
|
||||
payload.weights.length > 0 ? weightStore.write(payload.weights.map(w => ({ key: w.id, value: w }))) : Promise.resolve(),
|
||||
payload.profile ? profileStore.write([{ key: payload.profile.id, value: payload.profile }]) : Promise.resolve(),
|
||||
]);
|
||||
}
|
||||
|
||||
export function downloadBackupFile(payload: BackupPayload): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `kcal-backup-${payload.exportedAt.slice(0, 10)}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { computed, toValue } from 'vue';
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
|
||||
import { useEngine } from 'vue-sync-engine';
|
||||
import type { EntityDef } from 'vue-sync-engine';
|
||||
|
||||
/**
|
||||
* Реактивный список сущностей по массиву id из нормализованного результата
|
||||
* запроса. Mirror отдаёт per-entity версии, поэтому optimistic-патч одной
|
||||
* записи не пересобирает соседей.
|
||||
*/
|
||||
export function useEntities<T>(
|
||||
def: EntityDef<T>,
|
||||
ids: MaybeRefOrGetter<readonly string[] | undefined>,
|
||||
): ComputedRef<T[]> {
|
||||
const engine = useEngine();
|
||||
return computed(() => {
|
||||
const list = toValue(ids) ?? [];
|
||||
const items: T[] = [];
|
||||
for (const id of list) {
|
||||
const item = engine.mirror.getEntity<T>(def.name, id);
|
||||
if (item !== undefined) items.push(item);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { defineEntity, defineMutation, defineQuery, idbStore } from 'vue-sync-engine';
|
||||
import type { Entry, Food, Profile, WeightLog } from '../domain/types';
|
||||
import { PROFILE_ID } from '../domain/types';
|
||||
|
||||
/**
|
||||
* Бэкенда нет: источник истины — IndexedDB. Каждая сущность персистится в свой
|
||||
* object store базы `kcal`; query.fetch читает оттуда же, mutation.fetch туда же
|
||||
* пишет (и await'ит запись — поэтому рефетч после invalidate гарантированно
|
||||
* видит свежие данные). Optimistic-патчи дают мгновенный UI поверх этого.
|
||||
*/
|
||||
export const DB_NAME = 'kcal';
|
||||
|
||||
export const FoodEntity = defineEntity<Food>({
|
||||
name: 'food',
|
||||
id: food => food.id,
|
||||
storage: idbStore<Food>({ dbName: DB_NAME }),
|
||||
});
|
||||
|
||||
export const EntryEntity = defineEntity<Entry>({
|
||||
name: 'entry',
|
||||
id: entry => entry.id,
|
||||
storage: idbStore<Entry>({ dbName: DB_NAME }),
|
||||
});
|
||||
|
||||
export const WeightEntity = defineEntity<WeightLog>({
|
||||
name: 'weight',
|
||||
id: weight => weight.id,
|
||||
storage: idbStore<WeightLog>({ dbName: DB_NAME }),
|
||||
});
|
||||
|
||||
export const ProfileEntity = defineEntity<Profile>({
|
||||
name: 'profile',
|
||||
id: () => PROFILE_ID,
|
||||
storage: idbStore<Profile>({ dbName: DB_NAME }),
|
||||
});
|
||||
|
||||
// defineEntity уже инстанцировал KeyedStore-ы — используем их как прямой доступ к idb.
|
||||
export const foodStore = FoodEntity.storage!;
|
||||
export const entryStore = EntryEntity.storage!;
|
||||
export const weightStore = WeightEntity.storage!;
|
||||
export const profileStore = ProfileEntity.storage!;
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const foodsQuery = defineQuery<void, Food[], { ids: string[] }>({
|
||||
name: 'foods.all',
|
||||
key: () => ['foods'],
|
||||
fetch: () => foodStore.readAll(),
|
||||
normalize: items => ({
|
||||
entities: { food: items },
|
||||
result: { ids: [...items].sort((a, b) => a.name.localeCompare(b.name, 'ru')).map(f => f.id) },
|
||||
}),
|
||||
tags: () => ['foods'],
|
||||
});
|
||||
|
||||
export const entriesByDayQuery = defineQuery<{ date: string }, Entry[], { ids: string[] }>({
|
||||
name: 'entries.byDay',
|
||||
key: args => ['entries', args.date],
|
||||
fetch: async ({ date }) => (await entryStore.readAll()).filter(e => e.date === date),
|
||||
normalize: items => ({
|
||||
entities: { entry: items },
|
||||
result: { ids: [...items].sort((a, b) => a.createdAt - b.createdAt).map(e => e.id) },
|
||||
}),
|
||||
tags: () => ['entries'],
|
||||
});
|
||||
|
||||
export interface DaySummary {
|
||||
date: string;
|
||||
kcal: number;
|
||||
protein: number;
|
||||
fat: number;
|
||||
carbs: number;
|
||||
entries: number;
|
||||
}
|
||||
|
||||
/** Агрегаты по дням для статистики; сущности в кэш не тянем. */
|
||||
export const daySummariesQuery = defineQuery<void, Entry[], DaySummary[]>({
|
||||
name: 'stats.daySummaries',
|
||||
key: () => ['stats', 'days'],
|
||||
fetch: () => entryStore.readAll(),
|
||||
normalize: (items) => {
|
||||
const byDate = new Map<string, DaySummary>();
|
||||
for (const entry of items) {
|
||||
let day = byDate.get(entry.date);
|
||||
if (!day) {
|
||||
day = { date: entry.date, kcal: 0, protein: 0, fat: 0, carbs: 0, entries: 0 };
|
||||
byDate.set(entry.date, day);
|
||||
}
|
||||
day.kcal += entry.kcal;
|
||||
day.protein += entry.protein;
|
||||
day.fat += entry.fat;
|
||||
day.carbs += entry.carbs;
|
||||
day.entries += 1;
|
||||
}
|
||||
return { result: [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)) };
|
||||
},
|
||||
tags: () => ['entries'],
|
||||
});
|
||||
|
||||
export const weightsQuery = defineQuery<void, WeightLog[], { ids: string[] }>({
|
||||
name: 'weights.all',
|
||||
key: () => ['weights'],
|
||||
fetch: () => weightStore.readAll(),
|
||||
normalize: items => ({
|
||||
entities: { weight: items },
|
||||
result: { ids: [...items].sort((a, b) => a.date.localeCompare(b.date)).map(w => w.id) },
|
||||
}),
|
||||
tags: () => ['weights'],
|
||||
});
|
||||
|
||||
export const profileQuery = defineQuery<void, Profile | undefined, { exists: boolean }>({
|
||||
name: 'profile.get',
|
||||
key: () => ['profile'],
|
||||
fetch: () => profileStore.read(PROFILE_ID),
|
||||
normalize: profile => ({
|
||||
entities: { profile: profile ? [profile] : [] },
|
||||
result: { exists: profile !== undefined },
|
||||
}),
|
||||
tags: () => ['profile'],
|
||||
});
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const addEntryMutation = defineMutation<{ entry: Entry }, Entry>({
|
||||
name: 'entry.add',
|
||||
fetch: async ({ entry }) => {
|
||||
await entryStore.write([{ key: entry.id, value: entry }]);
|
||||
if (entry.foodId) {
|
||||
const food = await foodStore.read(entry.foodId);
|
||||
if (food) {
|
||||
await foodStore.write([{
|
||||
key: food.id,
|
||||
value: {
|
||||
...food,
|
||||
usedCount: food.usedCount + 1,
|
||||
lastUsedAt: entry.createdAt,
|
||||
lastAmountG: entry.amountG ?? food.lastAmountG,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
optimistic: ({ entry }, ctx) => ctx.upsertEntity(EntryEntity, entry),
|
||||
invalidate: () => ['entries', 'foods'],
|
||||
});
|
||||
|
||||
export const updateEntryMutation = defineMutation<{ id: string; patch: Partial<Entry> }, Entry | null>({
|
||||
name: 'entry.update',
|
||||
fetch: async ({ id, patch }) => {
|
||||
const current = await entryStore.read(id);
|
||||
if (!current) return null;
|
||||
const next = { ...current, ...patch };
|
||||
await entryStore.write([{ key: id, value: next }]);
|
||||
return next;
|
||||
},
|
||||
optimistic: ({ id, patch }, ctx) => ctx.patchEntity(EntryEntity, id, patch),
|
||||
invalidate: () => ['entries'],
|
||||
});
|
||||
|
||||
export const removeEntryMutation = defineMutation<{ id: string }, string>({
|
||||
name: 'entry.remove',
|
||||
fetch: async ({ id }) => {
|
||||
await entryStore.delete(id);
|
||||
return id;
|
||||
},
|
||||
optimistic: ({ id }, ctx) => ctx.removeEntity(EntryEntity, id),
|
||||
invalidate: () => ['entries'],
|
||||
});
|
||||
|
||||
export const upsertFoodMutation = defineMutation<{ food: Food }, Food>({
|
||||
name: 'food.upsert',
|
||||
fetch: async ({ food }) => {
|
||||
await foodStore.write([{ key: food.id, value: food }]);
|
||||
return food;
|
||||
},
|
||||
optimistic: ({ food }, ctx) => ctx.upsertEntity(FoodEntity, food),
|
||||
invalidate: () => ['foods'],
|
||||
});
|
||||
|
||||
export const removeFoodMutation = defineMutation<{ id: string }, string>({
|
||||
name: 'food.remove',
|
||||
fetch: async ({ id }) => {
|
||||
await foodStore.delete(id);
|
||||
return id;
|
||||
},
|
||||
optimistic: ({ id }, ctx) => ctx.removeEntity(FoodEntity, id),
|
||||
invalidate: () => ['foods'],
|
||||
});
|
||||
|
||||
export const saveProfileMutation = defineMutation<{ profile: Profile }, Profile>({
|
||||
name: 'profile.save',
|
||||
fetch: async ({ profile }) => {
|
||||
await profileStore.write([{ key: PROFILE_ID, value: profile }]);
|
||||
return profile;
|
||||
},
|
||||
optimistic: ({ profile }, ctx) => ctx.upsertEntity(ProfileEntity, profile),
|
||||
invalidate: () => ['profile'],
|
||||
});
|
||||
|
||||
export const logWeightMutation = defineMutation<{ weight: WeightLog }, WeightLog>({
|
||||
name: 'weight.log',
|
||||
fetch: async ({ weight }) => {
|
||||
await weightStore.write([{ key: weight.id, value: weight }]);
|
||||
return weight;
|
||||
},
|
||||
optimistic: ({ weight }, ctx) => ctx.upsertEntity(WeightEntity, weight),
|
||||
invalidate: () => ['weights'],
|
||||
});
|
||||
|
||||
export const removeWeightMutation = defineMutation<{ id: string }, string>({
|
||||
name: 'weight.remove',
|
||||
fetch: async ({ id }) => {
|
||||
await weightStore.delete(id);
|
||||
return id;
|
||||
},
|
||||
optimistic: ({ id }, ctx) => ctx.removeEntity(WeightEntity, id),
|
||||
invalidate: () => ['weights'],
|
||||
});
|
||||
|
||||
export const allEntities = [FoodEntity, EntryEntity, WeightEntity, ProfileEntity];
|
||||
export const allQueries = [foodsQuery, entriesByDayQuery, daySummariesQuery, weightsQuery, profileQuery];
|
||||
export const allMutations = [
|
||||
addEntryMutation,
|
||||
updateEntryMutation,
|
||||
removeEntryMutation,
|
||||
upsertFoodMutation,
|
||||
removeFoodMutation,
|
||||
saveProfileMutation,
|
||||
logWeightMutation,
|
||||
removeWeightMutation,
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createEngine, indexedDBAdapter } from 'vue-sync-engine';
|
||||
import { DB_NAME, allEntities, allMutations, allQueries, foodStore } from './defs';
|
||||
import { SEED_FOODS } from './seed';
|
||||
|
||||
export const CACHE_DEFAULTS = {
|
||||
// Локальные чтения стоят миллисекунды — рефетчим при каждой подписке,
|
||||
// консистентность важнее экономии на readAll.
|
||||
staleTime: 0,
|
||||
gcTime: 10 * 60_000,
|
||||
};
|
||||
|
||||
/** Наполняет каталог стартовым набором при первом запуске. */
|
||||
export async function seedIfEmpty(): Promise<void> {
|
||||
const existing = await foodStore.readAll();
|
||||
if (existing.length > 0) return;
|
||||
await foodStore.write(SEED_FOODS.map(food => ({ key: food.id, value: food })));
|
||||
}
|
||||
|
||||
/** Inline-движок: QueryGraph и Mirror в одном треде, снапшоты запросов — в idb. */
|
||||
export function createKcalEngine() {
|
||||
return createEngine({
|
||||
entities: allEntities,
|
||||
queries: allQueries,
|
||||
mutations: allMutations,
|
||||
storage: indexedDBAdapter({ dbName: DB_NAME }),
|
||||
defaultStaleTime: CACHE_DEFAULTS.staleTime,
|
||||
defaultGcTime: CACHE_DEFAULTS.gcTime,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeOffProduct } from './off';
|
||||
|
||||
// Фикстуры — реальные ответы API от 2026-08-07 (curl, поля обрезаны до запрошенных).
|
||||
|
||||
describe(normalizeOffProduct, () => {
|
||||
it('barcode-API: Coca-Cola 5449000000996', () => {
|
||||
const product = normalizeOffProduct({
|
||||
code: '5449000000996',
|
||||
brands: 'Coca-Cola',
|
||||
product_name: 'coca-cola',
|
||||
product_name_ru: 'Coca Cola',
|
||||
serving_quantity: 330,
|
||||
nutriments: {
|
||||
'energy-kcal_100g': 42,
|
||||
energy_100g: 180,
|
||||
carbohydrates_100g: 10.6,
|
||||
fat_100g: 0,
|
||||
},
|
||||
});
|
||||
expect(product).toEqual({
|
||||
code: '5449000000996',
|
||||
name: 'Coca Cola',
|
||||
brand: 'Coca-Cola',
|
||||
kcal: 42,
|
||||
protein: 0,
|
||||
fat: 0,
|
||||
carbs: 10.6,
|
||||
servingGrams: 330,
|
||||
});
|
||||
});
|
||||
|
||||
it('brands-массив и строковые числа', () => {
|
||||
const product = normalizeOffProduct({
|
||||
code: '5900951310935',
|
||||
brands: ['Snickers'],
|
||||
product_name_ru: 'Сникерс Тройной',
|
||||
nutriments: { 'energy-kcal_100g': '435', proteins_100g: '0', fat_100g: 20.4, carbohydrates_100g: 53.3 },
|
||||
});
|
||||
expect(product?.brand).toBe('Snickers');
|
||||
expect(product?.kcal).toBe(435);
|
||||
expect(product?.fat).toBe(20.4);
|
||||
});
|
||||
|
||||
it('без kcal, но с кДж — пересчитывает', () => {
|
||||
const product = normalizeOffProduct({
|
||||
code: '123',
|
||||
product_name: 'Test',
|
||||
nutriments: { energy_100g: 180 },
|
||||
});
|
||||
expect(product?.kcal).toBe(43); // 180 кДж / 4.184
|
||||
});
|
||||
|
||||
it('запись без калорийности отбрасывается', () => {
|
||||
expect(normalizeOffProduct({ code: '1', product_name: 'X', nutriments: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('запись без имени отбрасывается', () => {
|
||||
expect(normalizeOffProduct({ code: '1', nutriments: { 'energy-kcal_100g': 100 } })).toBeNull();
|
||||
});
|
||||
|
||||
it('нереалистичный вес порции не попадает в servingGrams', () => {
|
||||
const product = normalizeOffProduct({
|
||||
code: '1',
|
||||
product_name: 'X',
|
||||
serving_quantity: 5000,
|
||||
nutriments: { 'energy-kcal_100g': 100 },
|
||||
});
|
||||
expect(product?.servingGrams).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { round1 } from '../domain/calc';
|
||||
|
||||
/**
|
||||
* Open Food Facts — открытая база упакованных продуктов. Barcode-API отдаёт
|
||||
* `Access-Control-Allow-Origin: *`, поэтому работает прямо из браузера без
|
||||
* бэкенда. Текстовый поиск у OFF из браузера недоступен (legacy-эндпоинт
|
||||
* отключён, у search-a-licious нет CORS), поэтому фича — только штрихкоды:
|
||||
* сканер или цифры с упаковки.
|
||||
*/
|
||||
export interface OffProduct {
|
||||
/** EAN/UPC штрихкод. */
|
||||
code: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
/** На 100 г. */
|
||||
kcal: number;
|
||||
protein: number;
|
||||
fat: number;
|
||||
carbs: number;
|
||||
/** Вес порции/упаковки с этикетки, если указан. */
|
||||
servingGrams?: number;
|
||||
}
|
||||
|
||||
const FIELDS = 'code,product_name,product_name_ru,brands,nutriments,serving_quantity';
|
||||
|
||||
export interface OffApiProduct {
|
||||
code?: string;
|
||||
product_name?: string;
|
||||
product_name_ru?: string;
|
||||
/** Barcode-API отдаёт строку через запятую, search-a-licious — массив. */
|
||||
brands?: string | string[];
|
||||
serving_quantity?: string | number;
|
||||
nutriments?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
function toNumber(value: string | number | undefined): number | null {
|
||||
if (value === undefined) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/** kcal/100 г: предпочитаем готовое поле, иначе пересчёт из кДж. */
|
||||
function kcalPer100(nutriments: Record<string, string | number>): number | null {
|
||||
const direct = toNumber(nutriments['energy-kcal_100g']);
|
||||
if (direct !== null && direct > 0) return direct;
|
||||
const kj = toNumber(nutriments.energy_100g);
|
||||
if (kj !== null && kj > 0) return kj / 4.184;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** null — в записи нет калорийности или имени, для дневника она бесполезна. */
|
||||
export function normalizeOffProduct(raw: OffApiProduct): OffProduct | null {
|
||||
const nutriments = raw.nutriments ?? {};
|
||||
const kcal = kcalPer100(nutriments);
|
||||
if (kcal === null || !raw.code) return null;
|
||||
const name = (raw.product_name_ru || raw.product_name || '').trim();
|
||||
if (name === '') return null;
|
||||
|
||||
const serving = toNumber(raw.serving_quantity);
|
||||
const brandsRaw = Array.isArray(raw.brands) ? raw.brands[0] : raw.brands?.split(',')[0];
|
||||
const brand = brandsRaw?.trim();
|
||||
return {
|
||||
code: raw.code,
|
||||
name,
|
||||
...(brand ? { brand } : {}),
|
||||
kcal: Math.round(kcal),
|
||||
protein: round1(toNumber(nutriments.proteins_100g) ?? 0),
|
||||
fat: round1(toNumber(nutriments.fat_100g) ?? 0),
|
||||
carbs: round1(toNumber(nutriments.carbohydrates_100g) ?? 0),
|
||||
...(serving !== null && serving >= 1 && serving <= 1500 ? { servingGrams: serving } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Продукт по штрихкоду; null — штрихкода нет в базе. */
|
||||
export async function fetchOffByBarcode(barcode: string): Promise<OffProduct | null> {
|
||||
const url = `https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(barcode)}.json?fields=${FIELDS}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
}
|
||||
catch {
|
||||
throw new Error('Нет соединения с базой — проверьте интернет.');
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`База недоступна (${response.status})`);
|
||||
const data = await response.json() as { status?: number; product?: OffApiProduct };
|
||||
if (!data.product || data.status === 0) return null;
|
||||
return normalizeOffProduct({ ...data.product, code: data.product.code ?? barcode });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Food } from '../domain/types';
|
||||
|
||||
type SeedFood = Pick<Food, 'name' | 'kcal' | 'protein' | 'fat' | 'carbs' | 'category' | 'pieceGrams'>;
|
||||
|
||||
/**
|
||||
* Стартовый каталог: типовые продукты по таблицам пищевой ценности, на 100 г.
|
||||
* Значения округлены — для дневника важна стабильность оценки, а не лабораторная
|
||||
* точность. Всё редактируется как свой продукт.
|
||||
*/
|
||||
const SEED: readonly SeedFood[] = [
|
||||
// Крупы и гарниры (в готовом виде — так, как еда попадает на весы)
|
||||
{ category: 'Крупы и гарниры', name: 'Гречка варёная', kcal: 110, protein: 4.2, fat: 1.1, carbs: 21.3 },
|
||||
{ category: 'Крупы и гарниры', name: 'Рис варёный', kcal: 130, protein: 2.4, fat: 0.4, carbs: 28.6 },
|
||||
{ category: 'Крупы и гарниры', name: 'Булгур варёный', kcal: 83, protein: 3.1, fat: 0.2, carbs: 18.6 },
|
||||
{ category: 'Крупы и гарниры', name: 'Овсянка на воде', kcal: 88, protein: 3, fat: 1.7, carbs: 15 },
|
||||
{ category: 'Крупы и гарниры', name: 'Овсянка на молоке', kcal: 102, protein: 4.1, fat: 3.2, carbs: 14.2 },
|
||||
{ category: 'Крупы и гарниры', name: 'Макароны варёные', kcal: 155, protein: 5.3, fat: 0.9, carbs: 30.9 },
|
||||
{ category: 'Крупы и гарниры', name: 'Картофель варёный', kcal: 82, protein: 2, fat: 0.4, carbs: 17 },
|
||||
{ category: 'Крупы и гарниры', name: 'Картофельное пюре', kcal: 106, protein: 2.2, fat: 3.3, carbs: 16.7 },
|
||||
{ category: 'Крупы и гарниры', name: 'Картофель жареный', kcal: 192, protein: 2.8, fat: 9.5, carbs: 23.4 },
|
||||
|
||||
// Мясо, птица, рыба (приготовленные)
|
||||
{ category: 'Мясо и рыба', name: 'Куриная грудка запечённая', kcal: 165, protein: 31, fat: 3.6, carbs: 0 },
|
||||
{ category: 'Мясо и рыба', name: 'Куриное бедро без кожи', kcal: 195, protein: 24.4, fat: 10.9, carbs: 0 },
|
||||
{ category: 'Мясо и рыба', name: 'Котлета куриная жареная', kcal: 222, protein: 18, fat: 14, carbs: 6.5 },
|
||||
{ category: 'Мясо и рыба', name: 'Говядина тушёная', kcal: 232, protein: 25.8, fat: 14.2, carbs: 0 },
|
||||
{ category: 'Мясо и рыба', name: 'Свинина запечённая', kcal: 271, protein: 25, fat: 19, carbs: 0 },
|
||||
{ category: 'Мясо и рыба', name: 'Лосось запечённый', kcal: 208, protein: 22, fat: 13, carbs: 0 },
|
||||
{ category: 'Мясо и рыба', name: 'Треска запечённая', kcal: 90, protein: 20, fat: 0.9, carbs: 0 },
|
||||
{ category: 'Мясо и рыба', name: 'Тунец консервированный', kcal: 116, protein: 25.5, fat: 1, carbs: 0.2 },
|
||||
{ category: 'Мясо и рыба', name: 'Креветки варёные', kcal: 99, protein: 21, fat: 1.2, carbs: 0.2 },
|
||||
{ category: 'Мясо и рыба', name: 'Сосиска молочная', kcal: 261, protein: 11, fat: 23.9, carbs: 1.6, pieceGrams: 50 },
|
||||
{ category: 'Мясо и рыба', name: 'Колбаса докторская', kcal: 257, protein: 13.7, fat: 22.8, carbs: 0 },
|
||||
|
||||
// Молочное и яйца
|
||||
{ category: 'Молочное и яйца', name: 'Яйцо куриное', kcal: 157, protein: 12.7, fat: 11.5, carbs: 0.7, pieceGrams: 55 },
|
||||
{ category: 'Молочное и яйца', name: 'Творог 5%', kcal: 121, protein: 17.2, fat: 5, carbs: 1.8 },
|
||||
{ category: 'Молочное и яйца', name: 'Творог 9%', kcal: 159, protein: 16.7, fat: 9, carbs: 2 },
|
||||
{ category: 'Молочное и яйца', name: 'Сыр твёрдый', kcal: 364, protein: 23.2, fat: 29.5, carbs: 0.3 },
|
||||
{ category: 'Молочное и яйца', name: 'Молоко 2,5%', kcal: 52, protein: 2.8, fat: 2.5, carbs: 4.7 },
|
||||
{ category: 'Молочное и яйца', name: 'Кефир 2,5%', kcal: 50, protein: 2.8, fat: 2.5, carbs: 3.9 },
|
||||
{ category: 'Молочное и яйца', name: 'Йогурт греческий 2%', kcal: 66, protein: 8, fat: 2, carbs: 3.5 },
|
||||
{ category: 'Молочное и яйца', name: 'Сметана 15%', kcal: 158, protein: 2.6, fat: 15, carbs: 3 },
|
||||
{ category: 'Молочное и яйца', name: 'Масло сливочное', kcal: 748, protein: 0.5, fat: 82.5, carbs: 0.8 },
|
||||
|
||||
// Овощи и фрукты
|
||||
{ category: 'Овощи и фрукты', name: 'Огурец', kcal: 15, protein: 0.8, fat: 0.1, carbs: 2.8 },
|
||||
{ category: 'Овощи и фрукты', name: 'Помидор', kcal: 20, protein: 1.1, fat: 0.2, carbs: 3.7 },
|
||||
{ category: 'Овощи и фрукты', name: 'Капуста белокочанная', kcal: 28, protein: 1.8, fat: 0.2, carbs: 4.7 },
|
||||
{ category: 'Овощи и фрукты', name: 'Морковь', kcal: 35, protein: 1.3, fat: 0.1, carbs: 6.9 },
|
||||
{ category: 'Овощи и фрукты', name: 'Салат овощной с маслом', kcal: 90, protein: 1.2, fat: 7, carbs: 5.5 },
|
||||
{ category: 'Овощи и фрукты', name: 'Банан', kcal: 96, protein: 1.5, fat: 0.2, carbs: 21.8, pieceGrams: 120 },
|
||||
{ category: 'Овощи и фрукты', name: 'Яблоко', kcal: 47, protein: 0.4, fat: 0.4, carbs: 9.8, pieceGrams: 180 },
|
||||
{ category: 'Овощи и фрукты', name: 'Апельсин', kcal: 43, protein: 0.9, fat: 0.2, carbs: 8.1, pieceGrams: 150 },
|
||||
{ category: 'Овощи и фрукты', name: 'Авокадо', kcal: 160, protein: 2, fat: 14.7, carbs: 8.5 },
|
||||
{ category: 'Овощи и фрукты', name: 'Виноград', kcal: 72, protein: 0.6, fat: 0.6, carbs: 15.4 },
|
||||
|
||||
// Хлеб и выпечка
|
||||
{ category: 'Хлеб и выпечка', name: 'Хлеб белый', kcal: 265, protein: 8.1, fat: 3.2, carbs: 50.1, pieceGrams: 25 },
|
||||
{ category: 'Хлеб и выпечка', name: 'Хлеб ржаной', kcal: 210, protein: 6.6, fat: 1.2, carbs: 41.4, pieceGrams: 30 },
|
||||
{ category: 'Хлеб и выпечка', name: 'Лаваш тонкий', kcal: 275, protein: 9.1, fat: 1.1, carbs: 56 },
|
||||
{ category: 'Хлеб и выпечка', name: 'Печенье овсяное', kcal: 437, protein: 6.5, fat: 14.4, carbs: 71.8, pieceGrams: 20 },
|
||||
|
||||
// Готовые блюда
|
||||
{ category: 'Готовые блюда', name: 'Борщ', kcal: 49, protein: 1.6, fat: 2.2, carbs: 5.5 },
|
||||
{ category: 'Готовые блюда', name: 'Суп куриный с лапшой', kcal: 68, protein: 3.9, fat: 2.1, carbs: 8.2 },
|
||||
{ category: 'Готовые блюда', name: 'Плов с курицей', kcal: 190, protein: 9.5, fat: 7.5, carbs: 21 },
|
||||
{ category: 'Готовые блюда', name: 'Пельмени варёные', kcal: 275, protein: 11.9, fat: 12.4, carbs: 29 },
|
||||
{ category: 'Готовые блюда', name: 'Пицца', kcal: 266, protein: 11, fat: 10.4, carbs: 32.9, pieceGrams: 120 },
|
||||
{ category: 'Готовые блюда', name: 'Сырники жареные', kcal: 220, protein: 15.5, fat: 9.5, carbs: 18.2, pieceGrams: 75 },
|
||||
{ category: 'Готовые блюда', name: 'Блины', kcal: 233, protein: 6.1, fat: 12.3, carbs: 26, pieceGrams: 50 },
|
||||
|
||||
// Орехи и сладкое
|
||||
{ category: 'Орехи и сладкое', name: 'Грецкий орех', kcal: 654, protein: 15.2, fat: 65.2, carbs: 7 },
|
||||
{ category: 'Орехи и сладкое', name: 'Миндаль', kcal: 609, protein: 18.6, fat: 53.7, carbs: 13 },
|
||||
{ category: 'Орехи и сладкое', name: 'Арахисовая паста', kcal: 588, protein: 25, fat: 50, carbs: 20 },
|
||||
{ category: 'Орехи и сладкое', name: 'Шоколад молочный', kcal: 554, protein: 9.8, fat: 34.7, carbs: 50.4, pieceGrams: 6 },
|
||||
{ category: 'Орехи и сладкое', name: 'Мёд', kcal: 329, protein: 0.8, fat: 0, carbs: 81.5 },
|
||||
{ category: 'Орехи и сладкое', name: 'Сахар', kcal: 398, protein: 0, fat: 0, carbs: 99.7, pieceGrams: 5 },
|
||||
{ category: 'Орехи и сладкое', name: 'Мороженое пломбир', kcal: 227, protein: 3.2, fat: 15, carbs: 20.8 },
|
||||
{ category: 'Орехи и сладкое', name: 'Чипсы картофельные', kcal: 536, protein: 5.5, fat: 34, carbs: 51 },
|
||||
|
||||
// Напитки
|
||||
{ category: 'Напитки', name: 'Кола', kcal: 42, protein: 0, fat: 0, carbs: 10.6 },
|
||||
{ category: 'Напитки', name: 'Сок яблочный', kcal: 46, protein: 0.5, fat: 0.1, carbs: 10.1 },
|
||||
{ category: 'Напитки', name: 'Капучино', kcal: 45, protein: 2.1, fat: 2.3, carbs: 4, pieceGrams: 200 },
|
||||
{ category: 'Напитки', name: 'Латте', kcal: 47, protein: 2.4, fat: 2.4, carbs: 4.2, pieceGrams: 300 },
|
||||
{ category: 'Напитки', name: 'Пиво светлое', kcal: 42, protein: 0.5, fat: 0, carbs: 3.5 },
|
||||
{ category: 'Напитки', name: 'Вино сухое', kcal: 68, protein: 0.2, fat: 0, carbs: 2.6 },
|
||||
];
|
||||
|
||||
/** Стабильные id — сидинг идемпотентен и не плодит дубликатов. */
|
||||
export const SEED_FOODS: readonly Food[] = SEED.map((item, index) => ({
|
||||
...item,
|
||||
id: `seed-${String(index + 1).padStart(3, '0')}`,
|
||||
builtin: true,
|
||||
usedCount: 0,
|
||||
lastUsedAt: 0,
|
||||
createdAt: 0,
|
||||
}));
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bmr, computeTargets, portionNutrients, safeKcalFloor, sumNutrients, tdee } from './calc';
|
||||
|
||||
describe('bmr (Миффлин—Сан Жеор)', () => {
|
||||
it('мужчина 30 лет, 180 см, 80 кг', () => {
|
||||
// 10*80 + 6.25*180 - 5*30 + 5 = 800 + 1125 - 150 + 5
|
||||
expect(bmr('male', 30, 180, 80)).toBe(1780);
|
||||
});
|
||||
|
||||
it('женщина 25 лет, 165 см, 60 кг', () => {
|
||||
// 10*60 + 6.25*165 - 5*25 - 161 = 600 + 1031.25 - 125 - 161
|
||||
expect(bmr('female', 25, 165, 60)).toBeCloseTo(1345.25, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe(tdee, () => {
|
||||
it('умножает BMR на коэффициент активности', () => {
|
||||
expect(tdee('male', 30, 180, 80, 1.55)).toBeCloseTo(1780 * 1.55, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe(computeTargets, () => {
|
||||
const params = { sex: 'male' as const, age: 30, heightCm: 180, weightKg: 80, activity: 1.55 };
|
||||
|
||||
it('похудение: дефицит 15%, белок 1.8 г/кг, жир 0.9 г/кг', () => {
|
||||
const t = computeTargets({ ...params, goal: 'lose' });
|
||||
expect(t.kcal).toBe(2350); // 2759 * 0.85 = 2345.15 → к ближайшим 10
|
||||
expect(t.protein).toBe(144);
|
||||
expect(t.fat).toBe(72);
|
||||
// Углеводы добираются из остатка: (2350 - 144*4 - 72*9) / 4
|
||||
expect(t.carbs).toBe(Math.round((2350 - 144 * 4 - 72 * 9) / 4));
|
||||
});
|
||||
|
||||
it('поддержание: без дефицита', () => {
|
||||
const t = computeTargets({ ...params, goal: 'maintain' });
|
||||
expect(t.kcal).toBe(2760);
|
||||
expect(t.protein).toBe(128);
|
||||
});
|
||||
|
||||
it('набор: профицит 10%', () => {
|
||||
const t = computeTargets({ ...params, goal: 'gain' });
|
||||
expect(t.kcal).toBe(3030); // 2759 * 1.1 = 3034.9 → к ближайшим 10
|
||||
});
|
||||
|
||||
it('углеводы не уходят в минус на экстремальных входных', () => {
|
||||
const t = computeTargets({ sex: 'female', age: 70, heightCm: 150, weightKg: 45, activity: 1.2, goal: 'lose' });
|
||||
expect(t.carbs).toBeGreaterThanOrEqual(0);
|
||||
expect(t.kcal).toBeLessThan(safeKcalFloor('female') + 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe(portionNutrients, () => {
|
||||
const buckwheat = { kcal: 110, protein: 4.2, fat: 1.1, carbs: 21.3 };
|
||||
|
||||
it('масштабирует значения на 100 г к порции', () => {
|
||||
expect(portionNutrients(buckwheat, 250)).toEqual({ kcal: 275, protein: 10.5, fat: 2.8, carbs: 53.3 });
|
||||
});
|
||||
|
||||
it('порция 0 г — нули', () => {
|
||||
expect(portionNutrients(buckwheat, 0)).toEqual({ kcal: 0, protein: 0, fat: 0, carbs: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe(sumNutrients, () => {
|
||||
it('складывает записи и округляет макросы до десятых', () => {
|
||||
const total = sumNutrients([
|
||||
{ kcal: 275, protein: 10.5, fat: 2.8, carbs: 53.3 },
|
||||
{ kcal: 157, protein: 12.7, fat: 11.5, carbs: 0.7 },
|
||||
]);
|
||||
expect(total).toEqual({ kcal: 432, protein: 23.2, fat: 14.3, carbs: 54 });
|
||||
});
|
||||
|
||||
it('пустой список — нули', () => {
|
||||
expect(sumNutrients([])).toEqual({ kcal: 0, protein: 0, fat: 0, carbs: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Food, Goal, Nutrients, Sex } from './types';
|
||||
|
||||
/** Базовый обмен по Миффлину—Сан Жеору, ккал/сутки. */
|
||||
export function bmr(sex: Sex, age: number, heightCm: number, weightKg: number): number {
|
||||
const base = 10 * weightKg + 6.25 * heightCm - 5 * age;
|
||||
return sex === 'male' ? base + 5 : base - 161;
|
||||
}
|
||||
|
||||
/** Суточный расход с учётом активности. */
|
||||
export function tdee(sex: Sex, age: number, heightCm: number, weightKg: number, activity: number): number {
|
||||
return bmr(sex, age, heightCm, weightKg) * activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пресеты целей: множитель калорийности от TDEE и нормы белка/жира на кг веса.
|
||||
* Похудение — умеренный дефицит 15%, белок повышен для сохранения мышц;
|
||||
* набор — профицит 10%. Углеводы добираются из остатка калорий.
|
||||
*/
|
||||
const GOAL_PRESETS: Record<Goal, { kcalFactor: number; proteinPerKg: number; fatPerKg: number }> = {
|
||||
lose: { kcalFactor: 0.85, proteinPerKg: 1.8, fatPerKg: 0.9 },
|
||||
maintain: { kcalFactor: 1.0, proteinPerKg: 1.6, fatPerKg: 1.0 },
|
||||
gain: { kcalFactor: 1.1, proteinPerKg: 1.8, fatPerKg: 1.0 },
|
||||
};
|
||||
|
||||
/** Безопасный минимум калорийности; ниже — предупреждаем, но не запрещаем. */
|
||||
export function safeKcalFloor(sex: Sex): number {
|
||||
return sex === 'male' ? 1500 : 1200;
|
||||
}
|
||||
|
||||
export interface TargetInput {
|
||||
sex: Sex;
|
||||
age: number;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
activity: number;
|
||||
goal: Goal;
|
||||
}
|
||||
|
||||
/** Дневные цели по калориям и БЖУ из параметров тела и цели. */
|
||||
export function computeTargets(input: TargetInput): Nutrients {
|
||||
const preset = GOAL_PRESETS[input.goal];
|
||||
const kcal = roundTo(tdee(input.sex, input.age, input.heightCm, input.weightKg, input.activity) * preset.kcalFactor, 10);
|
||||
const protein = Math.round(input.weightKg * preset.proteinPerKg);
|
||||
const fat = Math.round(input.weightKg * preset.fatPerKg);
|
||||
const carbs = Math.max(0, Math.round((kcal - protein * 4 - fat * 9) / 4));
|
||||
return { kcal, protein, fat, carbs };
|
||||
}
|
||||
|
||||
/** Нутриенты порции продукта: значения каталога даны на 100 г. */
|
||||
export function portionNutrients(food: Nutrients, amountG: number): Nutrients {
|
||||
const k = amountG / 100;
|
||||
return {
|
||||
kcal: Math.round(food.kcal * k),
|
||||
protein: round1(food.protein * k),
|
||||
fat: round1(food.fat * k),
|
||||
carbs: round1(food.carbs * k),
|
||||
};
|
||||
}
|
||||
|
||||
/** Сумма нутриентов по записям. */
|
||||
export function sumNutrients(items: readonly Nutrients[]): Nutrients {
|
||||
const total = { kcal: 0, protein: 0, fat: 0, carbs: 0 };
|
||||
for (const item of items) {
|
||||
total.kcal += item.kcal;
|
||||
total.protein += item.protein;
|
||||
total.fat += item.fat;
|
||||
total.carbs += item.carbs;
|
||||
}
|
||||
total.protein = round1(total.protein);
|
||||
total.fat = round1(total.fat);
|
||||
total.carbs = round1(total.carbs);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Порция по умолчанию для продукта: прошлая → одна штука → 100 г. */
|
||||
export function defaultAmount(food: Food): number {
|
||||
return food.lastAmountG ?? food.pieceGrams ?? 100;
|
||||
}
|
||||
|
||||
export function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function roundTo(value: number, step: number): number {
|
||||
return Math.round(value / step) * step;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dayTitle, lastDays, shiftISODate, toISODate } from './dates';
|
||||
|
||||
describe(toISODate, () => {
|
||||
it('локальная дата без сдвига в UTC', () => {
|
||||
expect(toISODate(new Date(2026, 7, 7))).toBe('2026-08-07');
|
||||
expect(toISODate(new Date(2026, 0, 1, 0, 30))).toBe('2026-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe(shiftISODate, () => {
|
||||
it('переход через границу месяца и года', () => {
|
||||
expect(shiftISODate('2026-08-01', -1)).toBe('2026-07-31');
|
||||
expect(shiftISODate('2025-12-31', 1)).toBe('2026-01-01');
|
||||
expect(shiftISODate('2026-08-07', 0)).toBe('2026-08-07');
|
||||
});
|
||||
|
||||
it('високальный февраль', () => {
|
||||
expect(shiftISODate('2028-02-28', 1)).toBe('2028-02-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe(dayTitle, () => {
|
||||
const today = '2026-08-07';
|
||||
|
||||
it('относительные дни', () => {
|
||||
expect(dayTitle('2026-08-07', today)).toBe('Сегодня');
|
||||
expect(dayTitle('2026-08-06', today)).toBe('Вчера');
|
||||
expect(dayTitle('2026-08-08', today)).toBe('Завтра');
|
||||
});
|
||||
|
||||
it('дальние дни — день недели и число по-русски', () => {
|
||||
expect(dayTitle('2026-08-01', today)).toMatch(/1 августа/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(lastDays, () => {
|
||||
it('последние N дней по возрастанию, включая сегодня', () => {
|
||||
expect(lastDays(3, '2026-08-07')).toEqual(['2026-08-05', '2026-08-06', '2026-08-07']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { addDays, format, parseISO } from 'date-fns';
|
||||
import { ru } from 'date-fns/locale';
|
||||
|
||||
/** Локальная дата в формате YYYY-MM-DD (не UTC — день дневника «человеческий»). */
|
||||
export function toISODate(date: Date): string {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
}
|
||||
|
||||
export function todayISO(): string {
|
||||
return toISODate(new Date());
|
||||
}
|
||||
|
||||
/** Локальная полночь указанного дня (date-fns трактует date-only строки как локальные). */
|
||||
export function parseISODate(iso: string): Date {
|
||||
return parseISO(iso);
|
||||
}
|
||||
|
||||
export function shiftISODate(iso: string, days: number): string {
|
||||
return toISODate(addDays(parseISODate(iso), days));
|
||||
}
|
||||
|
||||
/** «Сегодня» / «Вчера» / «сб, 1 августа» — ru-локаль даёт родительный падеж месяца. */
|
||||
export function dayTitle(iso: string, today: string = todayISO()): string {
|
||||
if (iso === today) return 'Сегодня';
|
||||
if (iso === shiftISODate(today, -1)) return 'Вчера';
|
||||
if (iso === shiftISODate(today, 1)) return 'Завтра';
|
||||
return format(parseISODate(iso), 'EEEEEE, d MMMM', { locale: ru });
|
||||
}
|
||||
|
||||
/** Короткая подпись дня для графиков: «5.08». */
|
||||
export function dayShort(iso: string): string {
|
||||
return format(parseISODate(iso), 'd.MM');
|
||||
}
|
||||
|
||||
/** Последние `count` дней, включая сегодняшний, по возрастанию. */
|
||||
export function lastDays(count: number, today: string = todayISO()): string[] {
|
||||
const end = parseISODate(today);
|
||||
const days: string[] = [];
|
||||
for (let i = count - 1; i >= 0; i--) days.push(toISODate(addDays(end, -i)));
|
||||
return days;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const kcalFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 });
|
||||
const gramFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 });
|
||||
|
||||
/** «1 840» — целые ккал с неразрывной группировкой. */
|
||||
export function fmtKcal(value: number): string {
|
||||
return kcalFormat.format(Math.round(value));
|
||||
}
|
||||
|
||||
/** «82,5» — граммы с одним знаком, без хвоста «,0». */
|
||||
export function fmtG(value: number): string {
|
||||
return gramFormat.format(value);
|
||||
}
|
||||
|
||||
/** Подпись порции записи: «150 г» или «2 шт · 110 г». */
|
||||
export function fmtAmount(amountG: number | undefined, pieceGrams: number | undefined): string {
|
||||
if (amountG === undefined) return 'порция';
|
||||
if (pieceGrams && amountG % pieceGrams === 0) {
|
||||
const pieces = amountG / pieceGrams;
|
||||
return pieces === 1 ? `1 шт · ${fmtG(amountG)} г` : `${pieces} шт · ${fmtG(amountG)} г`;
|
||||
}
|
||||
return `${fmtG(amountG)} г`;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/** Приём пищи, к которому привязана запись дневника. */
|
||||
export type Meal = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
export const MEALS: readonly Meal[] = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||
|
||||
export const MEAL_LABELS: Record<Meal, string> = {
|
||||
breakfast: 'Завтрак',
|
||||
lunch: 'Обед',
|
||||
dinner: 'Ужин',
|
||||
snack: 'Перекус',
|
||||
};
|
||||
|
||||
/** Пищевая ценность. Для Food — на 100 г, для Entry — на порцию целиком. */
|
||||
export interface Nutrients {
|
||||
kcal: number;
|
||||
protein: number;
|
||||
fat: number;
|
||||
carbs: number;
|
||||
}
|
||||
|
||||
/** Продукт личного каталога. Все значения — на 100 г. */
|
||||
export interface Food extends Nutrients {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
/** Вес одной штуки в граммах — включает режим «в штуках» при вводе порции. */
|
||||
pieceGrams?: number;
|
||||
/** EAN/UPC с упаковки — для дедупликации при повторном сканировании. */
|
||||
barcode?: string;
|
||||
/** Продукт из стартового набора (можно редактировать как свой). */
|
||||
builtin?: boolean;
|
||||
/** Сколько раз добавляли в дневник — для сортировки «недавних». */
|
||||
usedCount: number;
|
||||
/** Момент последнего добавления в дневник. */
|
||||
lastUsedAt: number;
|
||||
/** Последняя введённая порция в граммах — подставляется по умолчанию. */
|
||||
lastAmountG?: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись дневника. Хранит снапшот имени и итоговых нутриентов на порцию:
|
||||
* правка или удаление продукта из каталога не меняет историю.
|
||||
*/
|
||||
export interface Entry extends Nutrients {
|
||||
id: string;
|
||||
/** Локальная дата дня дневника в формате YYYY-MM-DD. */
|
||||
date: string;
|
||||
meal: Meal;
|
||||
/** Ссылка на продукт каталога; отсутствует у быстрых записей «только ккал». */
|
||||
foodId?: string;
|
||||
name: string;
|
||||
/** Размер порции в граммах; отсутствует у быстрых записей. */
|
||||
amountG?: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Замер веса; один на день, id совпадает с датой. */
|
||||
export interface WeightLog {
|
||||
id: string;
|
||||
date: string;
|
||||
kg: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type Sex = 'male' | 'female';
|
||||
export type Goal = 'lose' | 'maintain' | 'gain';
|
||||
|
||||
/** Коэффициенты активности к BMR (множители Харриса—Бенедикта). */
|
||||
export const ACTIVITY_LEVELS = [
|
||||
{ value: 1.2, label: 'Минимальная', hint: 'сидячая работа, без тренировок' },
|
||||
{ value: 1.375, label: 'Лёгкая', hint: '1–3 тренировки в неделю' },
|
||||
{ value: 1.55, label: 'Средняя', hint: '3–5 тренировок в неделю' },
|
||||
{ value: 1.725, label: 'Высокая', hint: '6–7 тренировок в неделю' },
|
||||
{ value: 1.9, label: 'Экстремальная', hint: 'физический труд + тренировки' },
|
||||
] as const;
|
||||
|
||||
export const GOAL_LABELS: Record<Goal, string> = {
|
||||
lose: 'Похудение',
|
||||
maintain: 'Поддержание',
|
||||
gain: 'Набор массы',
|
||||
};
|
||||
|
||||
/** Профиль пользователя — единственная запись с id = 'profile'. */
|
||||
export interface Profile {
|
||||
id: 'profile';
|
||||
sex: Sex;
|
||||
age: number;
|
||||
heightCm: number;
|
||||
weightKg: number;
|
||||
activity: number;
|
||||
goal: Goal;
|
||||
/** Дневные цели. Пересчитываются из параметров, но правятся и вручную. */
|
||||
targetKcal: number;
|
||||
targetProtein: number;
|
||||
targetFat: number;
|
||||
targetCarbs: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const PROFILE_ID = 'profile' as const;
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// BarcodeDetector (Chromium): в lib.dom его ещё нет — минимальная декларация.
|
||||
interface DetectedBarcode {
|
||||
rawValue: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
declare class BarcodeDetector {
|
||||
constructor(options?: { formats?: string[] });
|
||||
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
|
||||
static getSupportedFormats(): Promise<string[]>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import '@fontsource-variable/golos-text';
|
||||
import '@fontsource/spectral/300.css';
|
||||
import '@fontsource/spectral/500.css';
|
||||
import './app.css';
|
||||
import { createVaporApp } from 'vue';
|
||||
import type { App as VueApp } from 'vue';
|
||||
import { installEngine } from 'vue-sync-engine';
|
||||
import App from './App';
|
||||
import { CACHE_DEFAULTS, createKcalEngine, seedIfEmpty } from './data/engine';
|
||||
|
||||
// Стартовый каталог должен лечь в idb до первой подписки на foods-запрос.
|
||||
await seedIfEmpty();
|
||||
|
||||
const engine = createKcalEngine();
|
||||
const app = createVaporApp(App);
|
||||
|
||||
// installEngine типизирован под vdom-App; VaporApp имеет совместимые provide/config.
|
||||
installEngine(app as unknown as VueApp, engine, { defaults: CACHE_DEFAULTS });
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,607 @@
|
||||
import { computed, onMounted, shallowRef } from 'vue';
|
||||
import { useRef } from 'vue-jsx-vapor';
|
||||
import { useMutation, useQuery } from 'vue-sync-engine';
|
||||
import { useCloseWatcher } from '@robonen/vue';
|
||||
import { groupBy } from '@robonen/stdlib';
|
||||
import { focus } from '@robonen/platform/browsers';
|
||||
import { FoodEntity, addEntryMutation, foodsQuery, upsertFoodMutation } from '../data/defs';
|
||||
import { useEntities } from '../data/composables';
|
||||
import { fetchOffByBarcode } from '../data/off';
|
||||
import type { OffProduct } from '../data/off';
|
||||
import { defaultAmount, portionNutrients } from '../domain/calc';
|
||||
import { fmtG, fmtKcal } from '../domain/format';
|
||||
import { MEALS, MEAL_LABELS } from '../domain/types';
|
||||
import type { Entry, Food, Meal } from '../domain/types';
|
||||
import { addSheet, selectedDate } from '../ui/state';
|
||||
import { IconBarcode, IconChevronLeft, IconClose, IconSearch } from '../ui/icons';
|
||||
import BarcodeScanner, { isBarcodeScanSupported } from '../components/BarcodeScanner';
|
||||
|
||||
type Step = 'pick' | 'amount' | 'quick' | 'food';
|
||||
|
||||
const GRAM_PRESETS = [50, 100, 150, 200, 300];
|
||||
|
||||
export default function AddSheet() {
|
||||
const step = shallowRef<Step>('pick');
|
||||
const query = shallowRef('');
|
||||
const meal = shallowRef<Meal>(addSheet.meal);
|
||||
const searchEl = useRef();
|
||||
|
||||
const foodsQ = useQuery(foodsQuery, () => undefined);
|
||||
const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids);
|
||||
|
||||
const addEntry = useMutation(addEntryMutation);
|
||||
const upsertFood = useMutation(upsertFoodMutation);
|
||||
|
||||
const close = () => (addSheet.open = false);
|
||||
useCloseWatcher().onClose(close);
|
||||
onMounted(() => focus(searchEl.value as HTMLElement | null));
|
||||
|
||||
// ── выбор продукта ────────────────────────────────────────────────────────
|
||||
const trimmed = computed(() => query.value.trim().toLowerCase());
|
||||
const filtered = computed(() =>
|
||||
trimmed.value === ''
|
||||
? foods.value
|
||||
: foods.value.filter(food => food.name.toLowerCase().includes(trimmed.value)),
|
||||
);
|
||||
const recents = computed(() =>
|
||||
[...foods.value]
|
||||
.filter(food => food.usedCount > 0)
|
||||
.sort((a, b) => b.lastUsedAt - a.lastUsedAt)
|
||||
.slice(0, 8),
|
||||
);
|
||||
const groups = computed(() => {
|
||||
const grouped = groupBy(filtered.value, food => food.category);
|
||||
return Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0], 'ru'));
|
||||
});
|
||||
|
||||
// ── порция ────────────────────────────────────────────────────────────────
|
||||
const selectedFood = shallowRef<Food | null>(null);
|
||||
const amountG = shallowRef(100);
|
||||
|
||||
const pickFood = (food: Food) => {
|
||||
selectedFood.value = food;
|
||||
amountG.value = defaultAmount(food);
|
||||
step.value = 'amount';
|
||||
};
|
||||
|
||||
const preview = computed(() =>
|
||||
selectedFood.value ? portionNutrients(selectedFood.value, amountG.value) : null,
|
||||
);
|
||||
|
||||
const submitAmount = () => {
|
||||
const food = selectedFood.value;
|
||||
if (!food || amountG.value <= 0) return;
|
||||
const entry: Entry = {
|
||||
id: crypto.randomUUID(),
|
||||
date: selectedDate.value,
|
||||
meal: meal.value,
|
||||
foodId: food.id,
|
||||
name: food.name,
|
||||
amountG: amountG.value,
|
||||
...portionNutrients(food, amountG.value),
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
addEntry.mutate({ entry });
|
||||
close();
|
||||
};
|
||||
|
||||
// ── быстрая запись ────────────────────────────────────────────────────────
|
||||
const quickName = shallowRef('');
|
||||
const quickKcal = shallowRef(0);
|
||||
const quickProtein = shallowRef(0);
|
||||
const quickFat = shallowRef(0);
|
||||
const quickCarbs = shallowRef(0);
|
||||
|
||||
const submitQuick = () => {
|
||||
if (quickKcal.value <= 0) return;
|
||||
const entry: Entry = {
|
||||
id: crypto.randomUUID(),
|
||||
date: selectedDate.value,
|
||||
meal: meal.value,
|
||||
name: quickName.value.trim() || 'Быстрая запись',
|
||||
kcal: Math.round(quickKcal.value),
|
||||
protein: quickProtein.value,
|
||||
fat: quickFat.value,
|
||||
carbs: quickCarbs.value,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
addEntry.mutate({ entry });
|
||||
close();
|
||||
};
|
||||
|
||||
// ── новый продукт ─────────────────────────────────────────────────────────
|
||||
const foodName = shallowRef('');
|
||||
const foodCategory = shallowRef('Моё');
|
||||
const foodKcal = shallowRef(0);
|
||||
const foodProtein = shallowRef(0);
|
||||
const foodFat = shallowRef(0);
|
||||
const foodCarbs = shallowRef(0);
|
||||
const foodPiece = shallowRef(0);
|
||||
const foodBarcode = shallowRef('');
|
||||
const categories = computed(() => [...new Set(foods.value.map(food => food.category))].sort((a, b) => a.localeCompare(b, 'ru')));
|
||||
|
||||
const submitFood = async () => {
|
||||
if (foodName.value.trim() === '' || foodKcal.value <= 0) return;
|
||||
const food: Food = {
|
||||
id: crypto.randomUUID(),
|
||||
name: foodName.value.trim(),
|
||||
category: foodCategory.value.trim() || 'Моё',
|
||||
kcal: foodKcal.value,
|
||||
protein: foodProtein.value,
|
||||
fat: foodFat.value,
|
||||
carbs: foodCarbs.value,
|
||||
...(foodPiece.value > 0 ? { pieceGrams: foodPiece.value } : {}),
|
||||
...(foodBarcode.value !== '' ? { barcode: foodBarcode.value } : {}),
|
||||
usedCount: 0,
|
||||
lastUsedAt: 0,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
await upsertFood.mutateAsync({ food });
|
||||
pickFood(food);
|
||||
};
|
||||
|
||||
// ── база упаковок (Open Food Facts, только штрихкоды) ─────────────────────
|
||||
const offBusy = shallowRef(false);
|
||||
const offError = shallowRef('');
|
||||
const scannerOpen = shallowRef(false);
|
||||
const barcodeInput = shallowRef('');
|
||||
const scanSupported = isBarcodeScanSupported();
|
||||
|
||||
/** Продукт из базы: уже сканировали раньше — сразу к порции, иначе на проверку формы. */
|
||||
const applyOffProduct = (product: OffProduct) => {
|
||||
const existing = foods.value.find(food => food.barcode === product.code);
|
||||
if (existing) {
|
||||
pickFood(existing);
|
||||
return;
|
||||
}
|
||||
foodName.value = product.brand && !product.name.toLowerCase().includes(product.brand.toLowerCase())
|
||||
? `${product.name} (${product.brand})`
|
||||
: product.name;
|
||||
foodCategory.value = 'Упакованное';
|
||||
foodKcal.value = product.kcal;
|
||||
foodProtein.value = product.protein;
|
||||
foodFat.value = product.fat;
|
||||
foodCarbs.value = product.carbs;
|
||||
foodPiece.value = product.servingGrams ?? 0;
|
||||
foodBarcode.value = product.code;
|
||||
step.value = 'food';
|
||||
};
|
||||
|
||||
const lookupBarcode = async (code: string) => {
|
||||
const digits = code.trim();
|
||||
if (digits === '' || offBusy.value) return;
|
||||
scannerOpen.value = false;
|
||||
offBusy.value = true;
|
||||
offError.value = '';
|
||||
try {
|
||||
const product = await fetchOffByBarcode(digits);
|
||||
if (product) {
|
||||
barcodeInput.value = '';
|
||||
applyOffProduct(product);
|
||||
}
|
||||
else {
|
||||
offError.value = `Штрихкод ${digits} не найден в базе — заведите продукт вручную с упаковки.`;
|
||||
}
|
||||
}
|
||||
catch (cause) {
|
||||
offError.value = cause instanceof Error ? cause.message : 'База недоступна';
|
||||
}
|
||||
finally {
|
||||
offBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const numeric = (raw: string): number => {
|
||||
const value = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
|
||||
const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint';
|
||||
const chipClass = (active: boolean) =>
|
||||
`rounded-full border px-3 py-1.5 text-[13px] transition ${active
|
||||
? 'border-ember/60 bg-ember/15 text-ember-bright'
|
||||
: 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<div class="animate-fade-in absolute inset-0 bg-black/65 backdrop-blur-[2px]" onClick={close} />
|
||||
|
||||
<div class="animate-sheet-up relative flex max-h-[90dvh] w-full max-w-105 flex-col rounded-t-3xl border border-b-0 hairline bg-[#191511] shadow-[0_-24px_80px_rgba(0,0,0,0.5)]">
|
||||
{/* Шапка */}
|
||||
<div class="flex items-center gap-2 px-5 pt-4 pb-3">
|
||||
{step.value !== 'pick' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Назад"
|
||||
class="grid size-9 place-items-center rounded-full text-ink-soft transition hover:bg-white/6"
|
||||
onClick={() => (step.value = 'pick')}
|
||||
>
|
||||
<IconChevronLeft class="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<h2 class="text-display flex-1 text-lg font-medium">
|
||||
{step.value === 'pick' && `${MEAL_LABELS[meal.value]} · добавить`}
|
||||
{step.value === 'amount' && (selectedFood.value?.name ?? '')}
|
||||
{step.value === 'quick' && 'Быстрая запись'}
|
||||
{step.value === 'food' && 'Новый продукт'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
class="grid size-9 place-items-center rounded-full text-ink-soft transition hover:bg-white/6"
|
||||
onClick={close}
|
||||
>
|
||||
<IconClose class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Приём пищи */}
|
||||
<div class="flex gap-1.5 px-5 pb-3">
|
||||
{MEALS.map(m => (
|
||||
<button type="button" class={chipClass(meal.value === m)} onClick={() => (meal.value = m)}>
|
||||
{MEAL_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
|
||||
{/* ── Шаг: выбор ── */}
|
||||
{step.value === 'pick' && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative">
|
||||
<IconSearch class="absolute top-1/2 left-3.5 size-4.5 -translate-y-1/2 text-ink-faint" />
|
||||
<input
|
||||
ref={searchEl}
|
||||
type="search"
|
||||
placeholder="Найти продукт…"
|
||||
value={query.value}
|
||||
onInput={event => (query.value = event.currentTarget.value)}
|
||||
class={`${fieldClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-ember/40 hover:text-ember-bright"
|
||||
onClick={() => (step.value = 'quick')}
|
||||
>
|
||||
Только калории
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-xl border border-dashed border-white/15 px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-ember/40 hover:text-ember-bright"
|
||||
onClick={() => (step.value = 'food')}
|
||||
>
|
||||
Новый продукт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{trimmed.value === '' && recents.value.length > 0 && (
|
||||
<div>
|
||||
<h3 class="mb-2 text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">Недавние</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{recents.value.map(food => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 bg-raised/60 px-3 py-1.5 text-[13px] text-ink transition hover:border-ember/50 hover:text-ember-bright"
|
||||
onClick={() => pickFood(food)}
|
||||
>
|
||||
{food.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.value.map(([category, list]) => (
|
||||
<div>
|
||||
<h3 class="mb-1.5 text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">{category}</h3>
|
||||
<div class="overflow-hidden rounded-2xl border hairline bg-surface/50">
|
||||
{list.map(food => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b hairline px-4 py-2.5 text-left transition last:border-b-0 hover:bg-white/4"
|
||||
onClick={() => pickFood(food)}
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-[14px]">{food.name}</span>
|
||||
<span class="shrink-0 text-[12px] text-ink-faint tnum">
|
||||
{fmtKcal(food.kcal)}
|
||||
{' '}
|
||||
/ 100 г
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.value.length === 0 && (
|
||||
<p class="py-2 text-center text-[13px] text-ink-faint">
|
||||
В каталоге не нашлось — отсканируйте штрихкод упаковки ниже
|
||||
или создайте «Новый продукт».
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── База упаковок: только штрихкоды (Open Food Facts) ── */}
|
||||
<div class="flex flex-col gap-2 border-t hairline pt-4">
|
||||
<h3 class="text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">Штрихкод упаковки</h3>
|
||||
|
||||
{scanSupported && !scannerOpen.value && (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-white/12 px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-ember/40 hover:text-ember-bright"
|
||||
onClick={() => (scannerOpen.value = true)}
|
||||
>
|
||||
<IconBarcode class="size-5" />
|
||||
Сканировать камерой
|
||||
</button>
|
||||
)}
|
||||
|
||||
{scannerOpen.value && (
|
||||
<BarcodeScanner
|
||||
onDetected={code => void lookupBarcode(code)}
|
||||
onCancel={() => (scannerOpen.value = false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="Цифры под штрихкодом…"
|
||||
value={barcodeInput.value}
|
||||
onInput={event => (barcodeInput.value = event.currentTarget.value.replaceAll(/\D/g, ''))}
|
||||
onKeydown={(event) => {
|
||||
if (event.key === 'Enter') void lookupBarcode(barcodeInput.value);
|
||||
}}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-xl border border-white/12 px-4 text-[13px] text-ink-soft transition not-disabled:hover:border-ember/40 not-disabled:hover:text-ember-bright disabled:opacity-40"
|
||||
disabled={barcodeInput.value.length < 8 || offBusy.value}
|
||||
onClick={() => void lookupBarcode(barcodeInput.value)}
|
||||
>
|
||||
{offBusy.value ? 'Ищем…' : 'Найти'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{offError.value !== '' && (
|
||||
<p class="rounded-xl border border-over/25 bg-over/8 px-3.5 py-2.5 text-[12px] leading-relaxed text-over-bright">
|
||||
{offError.value}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p class="text-[11px] leading-relaxed text-ink-faint/80">
|
||||
КБЖУ подтянутся из Open Food Facts — открытой базы упаковок.
|
||||
Её заполняют люди, поэтому цифры стоит сверить с этикеткой.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Шаг: порция ── */}
|
||||
{step.value === 'amount' && selectedFood.value && (
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="rounded-2xl border hairline bg-surface/60 px-5 py-4 text-center">
|
||||
<div class="text-display text-[44px] leading-none font-light">{preview.value ? fmtKcal(preview.value.kcal) : 0}</div>
|
||||
<div class="mt-1 text-[12px] text-ink-faint">ккал в порции</div>
|
||||
<div class="mt-3 flex justify-center gap-4 text-[12px] text-ink-soft tnum">
|
||||
<span>
|
||||
<span class="text-protein">Б</span>
|
||||
{' '}
|
||||
{fmtG(preview.value?.protein ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-fat">Ж</span>
|
||||
{' '}
|
||||
{fmtG(preview.value?.fat ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-carbs">У</span>
|
||||
{' '}
|
||||
{fmtG(preview.value?.carbs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Порция, граммы</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="1"
|
||||
value={amountG.value}
|
||||
onInput={event => (amountG.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center text-lg tnum`}
|
||||
/>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{GRAM_PRESETS.map(grams => (
|
||||
<button type="button" class={chipClass(amountG.value === grams)} onClick={() => (amountG.value = grams)}>
|
||||
{grams}
|
||||
{' '}
|
||||
г
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedFood.value.pieceGrams && (
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{[1, 2, 3].map(count => (
|
||||
<button
|
||||
type="button"
|
||||
class={chipClass(amountG.value === count * (selectedFood.value?.pieceGrams ?? 0))}
|
||||
onClick={() => (amountG.value = count * (selectedFood.value?.pieceGrams ?? 0))}
|
||||
>
|
||||
{`${count} шт · ${count * (selectedFood.value?.pieceGrams ?? 0)} г`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={amountG.value <= 0}
|
||||
onClick={submitAmount}
|
||||
>
|
||||
Добавить в
|
||||
{' '}
|
||||
{MEAL_LABELS[meal.value].toLowerCase()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Шаг: быстрая запись ── */}
|
||||
{step.value === 'quick' && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-[13px] leading-relaxed text-ink-faint">
|
||||
Когда некогда взвешивать — запишите оценку калорий, чтобы день остался честным.
|
||||
Б/Ж/У можно не указывать.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название (необязательно)"
|
||||
value={quickName.value}
|
||||
onInput={event => (quickName.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Калории</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
placeholder="350"
|
||||
value={quickKcal.value || ''}
|
||||
onInput={event => (quickKcal.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center text-lg tnum`}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['Белки', quickProtein],
|
||||
['Жиры', quickFat],
|
||||
['Углеводы', quickCarbs],
|
||||
] as const).map(([label, model]) => (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">
|
||||
{label}
|
||||
, г
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={model.value || ''}
|
||||
onInput={event => (model.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center tnum`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={quickKcal.value <= 0}
|
||||
onClick={submitQuick}
|
||||
>
|
||||
Записать
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Шаг: новый продукт ── */}
|
||||
{step.value === 'food' && (
|
||||
<div class="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название продукта"
|
||||
value={foodName.value}
|
||||
onInput={event => (foodName.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Категория</label>
|
||||
<input
|
||||
type="text"
|
||||
list="food-categories"
|
||||
value={foodCategory.value}
|
||||
onInput={event => (foodCategory.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<datalist id="food-categories">
|
||||
{categories.value.map(category => (
|
||||
<option value={category} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{foodBarcode.value !== ''
|
||||
? (
|
||||
<p class="rounded-xl border border-ember/25 bg-ember/8 px-3.5 py-2.5 text-[12px] leading-relaxed text-ember-bright/90">
|
||||
Значения подставлены из Open Food Facts — сверьте с этикеткой и поправьте, если расходятся.
|
||||
</p>
|
||||
)
|
||||
: <p class="text-[12px] text-ink-faint">Значения указываются на 100 г продукта.</p>}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Ккал / 100 г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={foodKcal.value || ''}
|
||||
onInput={event => (foodKcal.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Вес 1 шт, г (если есть)</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={foodPiece.value || ''}
|
||||
onInput={event => (foodPiece.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['Белки', foodProtein],
|
||||
['Жиры', foodFat],
|
||||
['Углеводы', foodCarbs],
|
||||
] as const).map(([label, model]) => (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">
|
||||
{label}
|
||||
, г
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={model.value || ''}
|
||||
onInput={event => (model.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center tnum`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={foodName.value.trim() === '' || foodKcal.value <= 0}
|
||||
onClick={submitFood}
|
||||
>
|
||||
Сохранить и выбрать порцию
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { computed } from 'vue';
|
||||
import { useEntity, useQuery } from 'vue-sync-engine';
|
||||
import { EntryEntity, FoodEntity, ProfileEntity, entriesByDayQuery, foodsQuery } from '../data/defs';
|
||||
import { useEntities } from '../data/composables';
|
||||
import { sumNutrients } from '../domain/calc';
|
||||
import { dayTitle, shiftISODate, todayISO } from '../domain/dates';
|
||||
import { fmtAmount, fmtKcal } from '../domain/format';
|
||||
import { MEALS, MEAL_LABELS, PROFILE_ID } from '../domain/types';
|
||||
import type { Entry, Meal } from '../domain/types';
|
||||
import { editEntryId, goToday, openAddSheet, selectedDate } from '../ui/state';
|
||||
import { IconChevronLeft, IconChevronRight, IconPlus } from '../ui/icons';
|
||||
import ProgressRing from '../components/ProgressRing';
|
||||
import MacroBar from '../components/MacroBar';
|
||||
|
||||
export default function DiaryScreen() {
|
||||
const profile = useEntity(ProfileEntity, () => PROFILE_ID);
|
||||
const day = useQuery(entriesByDayQuery, () => ({ date: selectedDate.value }));
|
||||
const entries = useEntities(EntryEntity, () => day.data.value?.ids);
|
||||
// Каталог нужен только ради pieceGrams — чтобы подписывать порции «2 шт · 110 г».
|
||||
const foodsQ = useQuery(foodsQuery, () => undefined);
|
||||
const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids);
|
||||
const pieceByFoodId = computed(() => {
|
||||
const map = new Map<string, number | undefined>();
|
||||
for (const food of foods.value) map.set(food.id, food.pieceGrams);
|
||||
return map;
|
||||
});
|
||||
const totals = computed(() => sumNutrients(entries.value));
|
||||
const isToday = computed(() => selectedDate.value === todayISO());
|
||||
|
||||
const byMeal = computed(() => {
|
||||
const map = new Map<Meal, Entry[]>();
|
||||
for (const meal of MEALS) map.set(meal, []);
|
||||
for (const entry of entries.value) map.get(entry.meal)?.push(entry);
|
||||
return map;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-5 pb-6">
|
||||
{/* Навигация по дням */}
|
||||
<div class="animate-rise flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущий день"
|
||||
class="grid size-10 place-items-center rounded-full text-ink-soft transition hover:bg-white/5 hover:text-ink"
|
||||
onClick={() => (selectedDate.value = shiftISODate(selectedDate.value, -1))}
|
||||
>
|
||||
<IconChevronLeft />
|
||||
</button>
|
||||
<div class="text-center">
|
||||
<div class="text-display text-xl font-medium">{dayTitle(selectedDate.value)}</div>
|
||||
{!isToday.value && (
|
||||
<button type="button" class="mt-0.5 text-xs text-ember-bright/90 hover:text-ember-bright" onClick={goToday}>
|
||||
вернуться к сегодня
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующий день"
|
||||
class="grid size-10 place-items-center rounded-full text-ink-soft transition hover:bg-white/5 hover:text-ink"
|
||||
onClick={() => (selectedDate.value = shiftISODate(selectedDate.value, 1))}
|
||||
>
|
||||
<IconChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Итог дня: кольцо + макросы */}
|
||||
<section
|
||||
class="animate-rise rounded-3xl border hairline bg-surface/80 px-5 pt-6 pb-5"
|
||||
style={{ animationDelay: '40ms' }}
|
||||
>
|
||||
<ProgressRing eaten={totals.value.kcal} target={profile.value?.targetKcal ?? 2000} />
|
||||
<div class="mt-6 flex gap-4">
|
||||
<MacroBar label="Белки" color="protein" value={totals.value.protein} target={profile.value?.targetProtein ?? 120} />
|
||||
<MacroBar label="Жиры" color="fat" value={totals.value.fat} target={profile.value?.targetFat ?? 70} />
|
||||
<MacroBar label="Углеводы" color="carbs" value={totals.value.carbs} target={profile.value?.targetCarbs ?? 250} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Приёмы пищи */}
|
||||
{MEALS.map((meal, index) => {
|
||||
const list = byMeal.value.get(meal) ?? [];
|
||||
const mealKcal = list.reduce((acc, entry) => acc + entry.kcal, 0);
|
||||
return (
|
||||
<section class="animate-rise" style={{ animationDelay: `${80 + index * 40}ms` }}>
|
||||
<div class="mb-2 flex items-baseline justify-between px-1">
|
||||
<h2 class="text-display text-[17px] font-medium">{MEAL_LABELS[meal]}</h2>
|
||||
{list.length > 0 && (
|
||||
<span class="text-[13px] text-ink-faint tnum">
|
||||
{fmtKcal(mealKcal)}
|
||||
{' '}
|
||||
ккал
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-2xl border hairline bg-surface/60">
|
||||
{list.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b hairline px-4 py-3 text-left transition last:border-b-0 hover:bg-white/4"
|
||||
onClick={() => (editEntryId.value = entry.id)}
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[15px]">{entry.name}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-faint">
|
||||
{fmtAmount(entry.amountG, entry.foodId ? pieceByFoodId.value.get(entry.foodId) : undefined)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[15px] text-ink-soft tnum">{fmtKcal(entry.kcal)}</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-4 py-3 text-[14px] text-ember-bright/90 transition hover:bg-ember/8 hover:text-ember-bright"
|
||||
onClick={() => openAddSheet(meal)}
|
||||
>
|
||||
<IconPlus class="size-4" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{entries.value.length === 0 && (
|
||||
<p class="animate-rise px-6 text-center text-[13px] leading-relaxed text-ink-faint" style={{ animationDelay: '240ms' }}>
|
||||
Пока пусто. Нажмите «Добавить» в любом приёме пищи —
|
||||
недавние продукты будут под рукой, запись занимает пару касаний.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import { useEntity, useMutation } from 'vue-sync-engine';
|
||||
import { useCloseWatcher } from '@robonen/vue';
|
||||
import { EntryEntity, removeEntryMutation, updateEntryMutation } from '../data/defs';
|
||||
import { round1 } from '../domain/calc';
|
||||
import { fmtG, fmtKcal } from '../domain/format';
|
||||
import { MEALS, MEAL_LABELS } from '../domain/types';
|
||||
import type { Entry, Meal } from '../domain/types';
|
||||
import { editEntryId } from '../ui/state';
|
||||
import { IconClose, IconTrash } from '../ui/icons';
|
||||
|
||||
/**
|
||||
* Правка записи: порция (пересчёт нутриентов пропорцией — работает и для
|
||||
* записей без продукта-источника), приём пищи, удаление.
|
||||
*/
|
||||
export default function EditEntrySheet() {
|
||||
const entry = useEntity(EntryEntity, () => editEntryId.value ?? undefined);
|
||||
|
||||
const update = useMutation(updateEntryMutation);
|
||||
const remove = useMutation(removeEntryMutation);
|
||||
|
||||
const close = () => (editEntryId.value = null);
|
||||
useCloseWatcher().onClose(close);
|
||||
|
||||
// Локальные правки поверх записи; null — «не менялось».
|
||||
const draftAmount = shallowRef<number | null>(null);
|
||||
const draftKcal = shallowRef<number | null>(null);
|
||||
const draftMeal = shallowRef<Meal | null>(null);
|
||||
|
||||
const amount = computed(() => draftAmount.value ?? entry.value?.amountG ?? 0);
|
||||
const meal = computed(() => draftMeal.value ?? entry.value?.meal ?? 'snack');
|
||||
|
||||
/** Нутриенты после правки порции: масштабируем снапшот записи. */
|
||||
const scaled = computed(() => {
|
||||
const current = entry.value;
|
||||
if (!current) return null;
|
||||
if (current.amountG && draftAmount.value !== null && draftAmount.value > 0) {
|
||||
const factor = draftAmount.value / current.amountG;
|
||||
return {
|
||||
kcal: Math.round(current.kcal * factor),
|
||||
protein: round1(current.protein * factor),
|
||||
fat: round1(current.fat * factor),
|
||||
carbs: round1(current.carbs * factor),
|
||||
};
|
||||
}
|
||||
if (!current.amountG && draftKcal.value !== null) {
|
||||
return { kcal: Math.round(draftKcal.value), protein: current.protein, fat: current.fat, carbs: current.carbs };
|
||||
}
|
||||
return { kcal: current.kcal, protein: current.protein, fat: current.fat, carbs: current.carbs };
|
||||
});
|
||||
|
||||
const save = () => {
|
||||
const current = entry.value;
|
||||
const nutrients = scaled.value;
|
||||
if (!current || !nutrients) return;
|
||||
const patch: Partial<Entry> = { ...nutrients, meal: meal.value };
|
||||
if (current.amountG && draftAmount.value !== null && draftAmount.value > 0) {
|
||||
patch.amountG = draftAmount.value;
|
||||
}
|
||||
update.mutate({ id: current.id, patch });
|
||||
close();
|
||||
};
|
||||
|
||||
const removeEntry = () => {
|
||||
const current = entry.value;
|
||||
if (!current) return;
|
||||
remove.mutate({ id: current.id });
|
||||
close();
|
||||
};
|
||||
|
||||
const numeric = (raw: string): number => {
|
||||
const value = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
|
||||
const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50';
|
||||
const chipClass = (active: boolean) =>
|
||||
`rounded-full border px-3 py-1.5 text-[13px] transition ${active
|
||||
? 'border-ember/60 bg-ember/15 text-ember-bright'
|
||||
: 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`;
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<div class="animate-fade-in absolute inset-0 bg-black/65 backdrop-blur-[2px]" onClick={close} />
|
||||
|
||||
<div class="animate-sheet-up relative flex w-full max-w-105 flex-col rounded-t-3xl border border-b-0 hairline bg-[#191511] shadow-[0_-24px_80px_rgba(0,0,0,0.5)]">
|
||||
<div class="flex items-center gap-2 px-5 pt-4 pb-3">
|
||||
<h2 class="text-display min-w-0 flex-1 truncate text-lg font-medium">{entry.value?.name ?? ''}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
class="grid size-9 shrink-0 place-items-center rounded-full text-ink-soft transition hover:bg-white/6"
|
||||
onClick={close}
|
||||
>
|
||||
<IconClose class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entry.value && (
|
||||
<div class="flex flex-col gap-4 px-5 pb-8">
|
||||
<div class="rounded-2xl border hairline bg-surface/60 px-5 py-3.5 text-center">
|
||||
<span class="text-display text-[34px] leading-none font-light">{fmtKcal(scaled.value?.kcal ?? 0)}</span>
|
||||
<span class="ml-1.5 text-[12px] text-ink-faint">ккал</span>
|
||||
<div class="mt-2 flex justify-center gap-4 text-[12px] text-ink-soft tnum">
|
||||
<span>
|
||||
<span class="text-protein">Б</span>
|
||||
{' '}
|
||||
{fmtG(scaled.value?.protein ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-fat">Ж</span>
|
||||
{' '}
|
||||
{fmtG(scaled.value?.fat ?? 0)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-carbs">У</span>
|
||||
{' '}
|
||||
{fmtG(scaled.value?.carbs ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{MEALS.map(m => (
|
||||
<button type="button" class={chipClass(meal.value === m)} onClick={() => (draftMeal.value = m)}>
|
||||
{MEAL_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{entry.value.amountG
|
||||
? (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Порция, граммы</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="1"
|
||||
value={amount.value}
|
||||
onInput={event => (draftAmount.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center text-lg tnum`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Калории</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="1"
|
||||
value={draftKcal.value ?? entry.value.kcal}
|
||||
onInput={event => (draftKcal.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center text-lg tnum`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить запись"
|
||||
class="grid size-12 shrink-0 place-items-center rounded-2xl border border-over/30 text-over-bright transition hover:bg-over/15"
|
||||
onClick={removeEntry}
|
||||
>
|
||||
<IconTrash class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright"
|
||||
onClick={save}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import { useEntity, useMutation, useQuery } from 'vue-sync-engine';
|
||||
import { useCloseWatcher } from '@robonen/vue';
|
||||
import { FoodEntity, foodsQuery, removeFoodMutation, upsertFoodMutation } from '../data/defs';
|
||||
import { useEntities } from '../data/composables';
|
||||
import type { Food } from '../domain/types';
|
||||
import { foodForm } from '../ui/state';
|
||||
import { IconClose, IconTrash } from '../ui/icons';
|
||||
|
||||
/** Создание и правка продукта каталога. Значения нутриентов — на 100 г. */
|
||||
export default function FoodFormSheet() {
|
||||
const existing = useEntity(FoodEntity, () => foodForm.foodId ?? undefined);
|
||||
const foodsQ = useQuery(foodsQuery, () => undefined);
|
||||
const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids);
|
||||
|
||||
const upsert = useMutation(upsertFoodMutation);
|
||||
const remove = useMutation(removeFoodMutation);
|
||||
|
||||
const close = () => (foodForm.open = false);
|
||||
useCloseWatcher().onClose(close);
|
||||
|
||||
const source = existing.value;
|
||||
const name = shallowRef(source?.name ?? '');
|
||||
const category = shallowRef(source?.category ?? 'Моё');
|
||||
const kcal = shallowRef(source?.kcal ?? 0);
|
||||
const protein = shallowRef(source?.protein ?? 0);
|
||||
const fat = shallowRef(source?.fat ?? 0);
|
||||
const carbs = shallowRef(source?.carbs ?? 0);
|
||||
const pieceGrams = shallowRef(source?.pieceGrams ?? 0);
|
||||
|
||||
const categories = computed(() => [...new Set(foods.value.map(food => food.category))].sort((a, b) => a.localeCompare(b, 'ru')));
|
||||
|
||||
const save = () => {
|
||||
if (name.value.trim() === '' || kcal.value <= 0) return;
|
||||
const base = existing.value;
|
||||
const food: Food = {
|
||||
id: base?.id ?? crypto.randomUUID(),
|
||||
name: name.value.trim(),
|
||||
category: category.value.trim() || 'Моё',
|
||||
kcal: kcal.value,
|
||||
protein: protein.value,
|
||||
fat: fat.value,
|
||||
carbs: carbs.value,
|
||||
...(pieceGrams.value > 0 ? { pieceGrams: pieceGrams.value } : {}),
|
||||
usedCount: base?.usedCount ?? 0,
|
||||
lastUsedAt: base?.lastUsedAt ?? 0,
|
||||
...(base?.lastAmountG ? { lastAmountG: base.lastAmountG } : {}),
|
||||
createdAt: base?.createdAt ?? Date.now(),
|
||||
};
|
||||
upsert.mutate({ food });
|
||||
close();
|
||||
};
|
||||
|
||||
const removeFood = () => {
|
||||
const base = existing.value;
|
||||
if (!base) return;
|
||||
remove.mutate({ id: base.id });
|
||||
close();
|
||||
};
|
||||
|
||||
const numeric = (raw: string): number => {
|
||||
const value = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
|
||||
const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint';
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center">
|
||||
<div class="animate-fade-in absolute inset-0 bg-black/65 backdrop-blur-[2px]" onClick={close} />
|
||||
|
||||
<div class="animate-sheet-up relative flex max-h-[90dvh] w-full max-w-105 flex-col rounded-t-3xl border border-b-0 hairline bg-[#191511] shadow-[0_-24px_80px_rgba(0,0,0,0.5)]">
|
||||
<div class="flex items-center gap-2 px-5 pt-4 pb-3">
|
||||
<h2 class="text-display flex-1 text-lg font-medium">{existing.value ? 'Продукт' : 'Новый продукт'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть"
|
||||
class="grid size-9 place-items-center rounded-full text-ink-soft transition hover:bg-white/6"
|
||||
onClick={close}
|
||||
>
|
||||
<IconClose class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 pb-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название"
|
||||
value={name.value}
|
||||
onInput={event => (name.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Категория</label>
|
||||
<input
|
||||
type="text"
|
||||
list="food-form-categories"
|
||||
value={category.value}
|
||||
onInput={event => (category.value = event.currentTarget.value)}
|
||||
class={fieldClass}
|
||||
/>
|
||||
<datalist id="food-form-categories">
|
||||
{categories.value.map(item => (
|
||||
<option value={item} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<p class="text-[12px] text-ink-faint">Значения указываются на 100 г продукта.</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Ккал / 100 г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={kcal.value || ''}
|
||||
onInput={event => (kcal.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Вес 1 шт, г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={pieceGrams.value || ''}
|
||||
onInput={event => (pieceGrams.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} tnum`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
['Белки', protein],
|
||||
['Жиры', fat],
|
||||
['Углеводы', carbs],
|
||||
] as const).map(([label, model]) => (
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">
|
||||
{label}
|
||||
, г
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
value={model.value || ''}
|
||||
onInput={event => (model.value = numeric(event.currentTarget.value))}
|
||||
class={`${fieldClass} text-center tnum`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{existing.value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить продукт"
|
||||
class="grid size-12 shrink-0 place-items-center rounded-2xl border border-over/30 text-over-bright transition hover:bg-over/15"
|
||||
onClick={removeFood}
|
||||
>
|
||||
<IconTrash class="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={name.value.trim() === '' || kcal.value <= 0}
|
||||
onClick={save}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{existing.value && (
|
||||
<p class="text-[12px] leading-relaxed text-ink-faint">
|
||||
Записи в дневнике хранят свой снимок значений — правка продукта
|
||||
не меняет уже записанные дни.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import { useQuery } from 'vue-sync-engine';
|
||||
import { groupBy } from '@robonen/stdlib';
|
||||
import { FoodEntity, foodsQuery } from '../data/defs';
|
||||
import { useEntities } from '../data/composables';
|
||||
import { fmtG, fmtKcal } from '../domain/format';
|
||||
import { foodForm } from '../ui/state';
|
||||
import { IconPlus, IconSearch } from '../ui/icons';
|
||||
|
||||
export default function FoodsScreen() {
|
||||
const foodsQ = useQuery(foodsQuery, () => undefined);
|
||||
const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids);
|
||||
const query = shallowRef('');
|
||||
|
||||
const filtered = computed(() => {
|
||||
const needle = query.value.trim().toLowerCase();
|
||||
return needle === '' ? foods.value : foods.value.filter(food => food.name.toLowerCase().includes(needle));
|
||||
});
|
||||
|
||||
const groups = computed(() => {
|
||||
const grouped = groupBy(filtered.value, food => food.category);
|
||||
return Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0], 'ru'));
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<div class="animate-rise flex items-center justify-between">
|
||||
<h1 class="text-display text-xl font-medium">Продукты</h1>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-full border border-ember/40 px-3.5 py-1.5 text-[13px] text-ember-bright transition hover:bg-ember/12"
|
||||
onClick={() => {
|
||||
foodForm.foodId = null;
|
||||
foodForm.open = true;
|
||||
}}
|
||||
>
|
||||
<IconPlus class="size-4" />
|
||||
Новый
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="animate-rise relative" style={{ animationDelay: '40ms' }}>
|
||||
<IconSearch class="absolute top-1/2 left-3.5 size-4.5 -translate-y-1/2 text-ink-faint" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Поиск по каталогу…"
|
||||
value={query.value}
|
||||
onInput={event => (query.value = event.currentTarget.value)}
|
||||
class="w-full rounded-xl border hairline bg-raised/70 py-2.5 pr-3.5 pl-10 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groups.value.map(([category, list], index) => (
|
||||
<section class="animate-rise" style={{ animationDelay: `${80 + index * 30}ms` }}>
|
||||
<h2 class="mb-1.5 px-1 text-[11px] font-medium tracking-[0.14em] text-ink-faint uppercase">{category}</h2>
|
||||
<div class="overflow-hidden rounded-2xl border hairline bg-surface/60">
|
||||
{list.map(food => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b hairline px-4 py-3 text-left transition last:border-b-0 hover:bg-white/4"
|
||||
onClick={() => {
|
||||
foodForm.foodId = food.id;
|
||||
foodForm.open = true;
|
||||
}}
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[14px]">{food.name}</div>
|
||||
<div class="mt-0.5 text-[11px] text-ink-faint tnum">
|
||||
Б
|
||||
{' '}
|
||||
{fmtG(food.protein)}
|
||||
{' '}
|
||||
· Ж
|
||||
{' '}
|
||||
{fmtG(food.fat)}
|
||||
{' '}
|
||||
· У
|
||||
{' '}
|
||||
{fmtG(food.carbs)}
|
||||
{food.pieceGrams ? ` · 1 шт = ${fmtG(food.pieceGrams)} г` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 text-[13px] text-ink-soft tnum">
|
||||
{fmtKcal(food.kcal)}
|
||||
{' '}
|
||||
<span class="text-[11px] text-ink-faint">/100 г</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{filtered.value.length === 0 && (
|
||||
<p class="py-8 text-center text-[13px] text-ink-faint">Ничего не нашлось.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { IconChevronRight } from '../ui/icons';
|
||||
|
||||
interface FaqItem {
|
||||
q: string;
|
||||
a: string[];
|
||||
}
|
||||
|
||||
const FAQ: FaqItem[] = [
|
||||
{
|
||||
q: 'Откуда брать калории и БЖУ?',
|
||||
a: [
|
||||
'Упакованные продукты: КБЖУ на 100 г напечатаны на этикетке — заведите «Новый продукт» и перепишите 4 числа, либо отсканируйте штрихкод, и значения подставятся из открытой базы. Делается один раз: дальше продукт живёт в каталоге и всплывает в «Недавних».',
|
||||
'Обычная еда без этикетки (крупы, мясо, овощи): большая часть уже в каталоге, гарниры — в готовом виде, как на весах. Если чего-то нет — поищите «название калорийность на 100 г»: Calorizator, health-diet.ru (таблицы Скурихина), FatSecret дают рабочие цифры.',
|
||||
'Со временем ввод почти исчезает: вы едите одни и те же 20–30 продуктов, и все они окажутся в каталоге за первые пару недель.',
|
||||
],
|
||||
},
|
||||
{
|
||||
q: 'Как записывать ресторан и доставку?',
|
||||
a: [
|
||||
'Точных цифр тут не знает никто — используйте «Только калории» и записывайте оценку. День с примерной записью полезнее дня без записи.',
|
||||
'Ориентиры: тарелка бизнес-ланча 600–800 ккал, бургер с картошкой ~1000, кусок пиццы 250–300, салат с заправкой 150–250, сладкий кофе 150–300.',
|
||||
'У сетей (Вкусно и точка, KFC, Додо) КБЖУ опубликованы на сайте или в приложении — можно завести как продукт.',
|
||||
],
|
||||
},
|
||||
{
|
||||
q: 'Насколько точно нужно считать?',
|
||||
a: [
|
||||
'Не точно — консистентно. Ошибка ±20% на глаз — это нормально: она систематическая, то есть постоянная, и её выравнивает обратная связь по весу.',
|
||||
'Раз в день (утром, натощак) записывайте вес и смотрите на тренд за 1–2 недели, а не на суточные скачки. Записываете ~2000 ккал, а вес стоит при цели «похудение»? Значит, реальные калории выше — просто уменьшите цель на 200–300.',
|
||||
'Главная причина бросить дневник — перфекционизм. Лучше записать примерно, чем не записать вовсе.',
|
||||
],
|
||||
},
|
||||
{
|
||||
q: 'Как считать домашние блюда?',
|
||||
a: [
|
||||
'Пока — приближением: в каталоге есть типовые готовые блюда (борщ, плов, сырники), их значения усреднённые, но стабильные.',
|
||||
'Хотите точнее — заведите свой продукт: сложите КБЖУ сырых ингредиентов всей кастрюли, разделите на общий готовый вес и получите значения «на 100 г» своего блюда.',
|
||||
'Конструктор рецептов, который посчитает это сам, — в планах.',
|
||||
],
|
||||
},
|
||||
{
|
||||
q: 'Как считаются мои цели?',
|
||||
a: [
|
||||
'База — формула Миффлина—Сан Жеора: базовый обмен из пола, возраста, роста и веса, умноженный на коэффициент активности, даёт суточный расход.',
|
||||
'Похудение — умеренный дефицит 15% (комфортный темп ~0,5 кг в неделю), набор — профицит 10%. Белок 1,6–1,8 г на кг веса — для сытости и сохранения мышц; жиры ~1 г/кг; углеводы — остаток калорий.',
|
||||
'Это стартовая точка, а не приговор: цели можно править вручную в профиле, и стоит корректировать их по фактическому тренду веса.',
|
||||
],
|
||||
},
|
||||
{
|
||||
q: 'Как работает сканер штрихкодов?',
|
||||
a: [
|
||||
'Сканер и поле «Цифры под штрихкодом» ищут упаковку в Open Food Facts — открытой базе продуктов, которую заполняют люди. Запрос уходит напрямую из браузера, свой сервер для этого не нужен.',
|
||||
'Качество данных разное: перед сохранением приложение показывает форму с подставленными значениями — сверьте их с этикеткой. Повторное сканирование того же товара сразу открывает выбор порции.',
|
||||
'Камера работает в Chrome и на Android (нужно разрешение); если сканера нет — введите цифры, напечатанные под полосками штрихкода.',
|
||||
],
|
||||
},
|
||||
{
|
||||
q: 'Где хранятся мои данные?',
|
||||
a: [
|
||||
'Только на этом устройстве, в IndexedDB браузера. Никаких аккаунтов, серверов и синхронизации — данные не покидают браузер.',
|
||||
'Обратная сторона: браузер может удалить данные сайта (очистка хранилища, «стереть данные посещённых сайтов»). Поэтому время от времени делайте «Экспорт JSON» в разделе «Данные» — файл восстановит всё через «Импорт».',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Справка: практические ответы для первых недель ведения дневника. */
|
||||
export default function HelpSection() {
|
||||
return (
|
||||
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '160ms' }}>
|
||||
<h2 class="mb-2 text-[13px] text-ink-soft">Справка</h2>
|
||||
<div class="flex flex-col">
|
||||
{FAQ.map(item => (
|
||||
<details class="group border-t hairline first:border-t-0">
|
||||
<summary class="flex cursor-pointer list-none items-center gap-2 py-3 text-[14px] text-ink transition group-open:text-ember-bright hover:text-ember-bright [&::-webkit-details-marker]:hidden">
|
||||
<IconChevronRight class="size-4 shrink-0 text-ink-faint transition-transform group-open:rotate-90" />
|
||||
{item.q}
|
||||
</summary>
|
||||
<div class="flex flex-col gap-2 pb-4 pl-6">
|
||||
{item.a.map(paragraph => (
|
||||
<p class="text-[13px] leading-relaxed text-ink-soft">{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { computed, shallowRef, watch } from 'vue';
|
||||
import { Status, useEntity, useMutation } from 'vue-sync-engine';
|
||||
import { DB_NAME, ProfileEntity, saveProfileMutation } from '../data/defs';
|
||||
import { downloadBackupFile, exportBackup, importBackup } from '../data/backup';
|
||||
import type { BackupPayload } from '../data/backup';
|
||||
import { bmr, computeTargets, roundTo, safeKcalFloor, tdee } from '../domain/calc';
|
||||
import { fmtKcal } from '../domain/format';
|
||||
import { ACTIVITY_LEVELS, GOAL_LABELS, PROFILE_ID } from '../domain/types';
|
||||
import type { Goal, Profile, Sex } from '../domain/types';
|
||||
import { IconDownload, IconUpload } from '../ui/icons';
|
||||
import HelpSection from './HelpSection';
|
||||
|
||||
const GOALS: readonly Goal[] = ['lose', 'maintain', 'gain'];
|
||||
|
||||
/** Онбординг при первом входе и настройки в дальнейшем — одна форма. */
|
||||
export default function ProfileScreen(props: { onboarding?: boolean }) {
|
||||
const profile = useEntity(ProfileEntity, () => PROFILE_ID);
|
||||
const save = useMutation(saveProfileMutation);
|
||||
|
||||
const source = profile.value;
|
||||
const sex = shallowRef<Sex>(source?.sex ?? 'male');
|
||||
const age = shallowRef(source?.age ?? 30);
|
||||
const heightCm = shallowRef(source?.heightCm ?? 175);
|
||||
const weightKg = shallowRef(source?.weightKg ?? 75);
|
||||
const activity = shallowRef(source?.activity ?? 1.375);
|
||||
const goal = shallowRef<Goal>(source?.goal ?? 'lose');
|
||||
|
||||
const targetKcal = shallowRef(source?.targetKcal ?? 0);
|
||||
const targetProtein = shallowRef(source?.targetProtein ?? 0);
|
||||
const targetFat = shallowRef(source?.targetFat ?? 0);
|
||||
const targetCarbs = shallowRef(source?.targetCarbs ?? 0);
|
||||
|
||||
const valid = computed(() =>
|
||||
age.value >= 10 && age.value <= 100
|
||||
&& heightCm.value >= 120 && heightCm.value <= 230
|
||||
&& weightKg.value >= 30 && weightKg.value <= 300,
|
||||
);
|
||||
|
||||
const recompute = () => {
|
||||
if (!valid.value) return;
|
||||
const targets = computeTargets({
|
||||
sex: sex.value,
|
||||
age: age.value,
|
||||
heightCm: heightCm.value,
|
||||
weightKg: weightKg.value,
|
||||
activity: activity.value,
|
||||
goal: goal.value,
|
||||
});
|
||||
targetKcal.value = targets.kcal;
|
||||
targetProtein.value = targets.protein;
|
||||
targetFat.value = targets.fat;
|
||||
targetCarbs.value = targets.carbs;
|
||||
};
|
||||
|
||||
// Цели следуют за параметрами; ручную правку ниже можно сделать перед сохранением.
|
||||
watch([sex, age, heightCm, weightKg, activity, goal], recompute);
|
||||
if (!source) recompute();
|
||||
|
||||
const bmrValue = computed(() =>
|
||||
valid.value ? Math.round(bmr(sex.value, age.value, heightCm.value, weightKg.value)) : 0,
|
||||
);
|
||||
const tdeeValue = computed(() =>
|
||||
valid.value ? roundTo(tdee(sex.value, age.value, heightCm.value, weightKg.value, activity.value), 10) : 0,
|
||||
);
|
||||
const belowFloor = computed(() => targetKcal.value > 0 && targetKcal.value < safeKcalFloor(sex.value));
|
||||
|
||||
const submit = () => {
|
||||
if (!valid.value || targetKcal.value <= 0) return;
|
||||
const now = Date.now();
|
||||
const next: Profile = {
|
||||
id: PROFILE_ID,
|
||||
sex: sex.value,
|
||||
age: age.value,
|
||||
heightCm: heightCm.value,
|
||||
weightKg: weightKg.value,
|
||||
activity: activity.value,
|
||||
goal: goal.value,
|
||||
targetKcal: targetKcal.value,
|
||||
targetProtein: targetProtein.value,
|
||||
targetFat: targetFat.value,
|
||||
targetCarbs: targetCarbs.value,
|
||||
createdAt: profile.value?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
save.mutate({ profile: next });
|
||||
};
|
||||
|
||||
// ── бэкап ─────────────────────────────────────────────────────────────────
|
||||
const importing = shallowRef(false);
|
||||
const notice = shallowRef('');
|
||||
|
||||
const doExport = async () => {
|
||||
downloadBackupFile(await exportBackup());
|
||||
};
|
||||
|
||||
const doImport = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
importing.value = true;
|
||||
try {
|
||||
const payload = JSON.parse(await file.text()) as BackupPayload;
|
||||
await importBackup(payload);
|
||||
location.reload();
|
||||
}
|
||||
catch (error) {
|
||||
notice.value = error instanceof Error ? error.message : 'Не удалось прочитать файл';
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const wipeAll = () => {
|
||||
if (!globalThis.confirm('Удалить все данные приложения? Действие необратимо.')) return;
|
||||
indexedDB.deleteDatabase(DB_NAME);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const numeric = (raw: string): number => {
|
||||
const value = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
|
||||
const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 tnum';
|
||||
const chipClass = (active: boolean) =>
|
||||
`flex-1 rounded-xl border px-3 py-2 text-[13px] transition ${active
|
||||
? 'border-ember/60 bg-ember/15 text-ember-bright'
|
||||
: 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`;
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-5 pb-6">
|
||||
<div class="animate-rise">
|
||||
{props.onboarding
|
||||
? (
|
||||
<div class="pt-6 text-center">
|
||||
<div class="text-display text-[40px] leading-none font-light text-ember-bright">Ккал</div>
|
||||
<h1 class="text-display mt-4 text-xl font-medium">Настроим дневник</h1>
|
||||
<p class="mx-auto mt-2 max-w-xs text-[13px] leading-relaxed text-ink-soft">
|
||||
Пара параметров — и посчитаем вашу дневную норму калорий и белка.
|
||||
Всё хранится только на этом устройстве.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: <h1 class="text-display text-xl font-medium">Профиль</h1>}
|
||||
</div>
|
||||
|
||||
{/* Параметры тела */}
|
||||
<section class="animate-rise flex flex-col gap-4 rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '40ms' }}>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class={chipClass(sex.value === 'male')} onClick={() => (sex.value = 'male')}>Мужчина</button>
|
||||
<button type="button" class={chipClass(sex.value === 'female')} onClick={() => (sex.value = 'female')}>Женщина</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Возраст</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="10"
|
||||
max="100"
|
||||
value={age.value}
|
||||
onInput={event => (age.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Рост, см</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="120"
|
||||
max="230"
|
||||
value={heightCm.value}
|
||||
onInput={event => (heightCm.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Вес, кг</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="30"
|
||||
max="300"
|
||||
step="0.1"
|
||||
value={weightKg.value}
|
||||
onInput={event => (weightKg.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Активность</label>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{ACTIVITY_LEVELS.map(level => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-baseline justify-between rounded-xl border px-3.5 py-2.5 text-left transition ${activity.value === level.value
|
||||
? 'border-ember/60 bg-ember/12'
|
||||
: 'border-white/10 hover:border-white/20'}`}
|
||||
onClick={() => (activity.value = level.value)}
|
||||
>
|
||||
<span class={`text-[14px] ${activity.value === level.value ? 'text-ember-bright' : 'text-ink'}`}>{level.label}</span>
|
||||
<span class="text-[12px] text-ink-faint">{level.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Цель</label>
|
||||
<div class="flex gap-2">
|
||||
{GOALS.map(value => (
|
||||
<button type="button" class={chipClass(goal.value === value)} onClick={() => (goal.value = value)}>
|
||||
{GOAL_LABELS[value]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Расчёт и цели */}
|
||||
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '80ms' }}>
|
||||
<div class="mb-4 flex justify-around text-center">
|
||||
<div>
|
||||
<div class="text-display text-[24px] font-light tnum">{fmtKcal(bmrValue.value)}</div>
|
||||
<div class="mt-0.5 text-[11px] text-ink-faint">базовый обмен</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-display text-[24px] font-light tnum">{fmtKcal(tdeeValue.value)}</div>
|
||||
<div class="mt-0.5 text-[11px] text-ink-faint">суточный расход</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Цель, ккал</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
value={targetKcal.value}
|
||||
onInput={event => (targetKcal.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Белки, г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
value={targetProtein.value}
|
||||
onInput={event => (targetProtein.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Жиры, г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
value={targetFat.value}
|
||||
onInput={event => (targetFat.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-[12px] text-ink-faint">Углеводы, г</label>
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
min="0"
|
||||
value={targetCarbs.value}
|
||||
onInput={event => (targetCarbs.value = numeric(event.currentTarget.value))}
|
||||
class={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{belowFloor.value && (
|
||||
<p class="mt-3 rounded-xl border border-over/30 bg-over/10 px-3.5 py-2.5 text-[12px] leading-relaxed text-over-bright">
|
||||
Цель ниже безопасного минимума (
|
||||
{fmtKcal(safeKcalFloor(sex.value))}
|
||||
{' '}
|
||||
ккал).
|
||||
Долгий жёсткий дефицит вредит — лучше худеть медленнее.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p class="mt-3 text-[12px] leading-relaxed text-ink-faint">
|
||||
Цели пересчитываются из параметров выше, но перед сохранением их можно
|
||||
поправить вручную. Формула — Миффлина—Сан Жеора.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full rounded-2xl bg-ember py-3.5 text-[15px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={!valid.value || targetKcal.value <= 0}
|
||||
onClick={submit}
|
||||
>
|
||||
{props.onboarding ? 'Начать вести дневник' : 'Сохранить'}
|
||||
</button>
|
||||
{!props.onboarding && save.status.value === Status.Success && (
|
||||
<p class="mt-2 text-center text-[12px] text-protein">Сохранено</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Данные */}
|
||||
{!props.onboarding && (
|
||||
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '120ms' }}>
|
||||
<h2 class="mb-3 text-[13px] text-ink-soft">Данные</h2>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-2 rounded-xl border hairline px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-white/20 hover:text-ink"
|
||||
onClick={doExport}
|
||||
>
|
||||
<IconDownload class="size-4" />
|
||||
Экспорт JSON
|
||||
</button>
|
||||
<label class="flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-xl border hairline px-3 py-2.5 text-[13px] text-ink-soft transition hover:border-white/20 hover:text-ink">
|
||||
<IconUpload class="size-4" />
|
||||
{importing.value ? 'Импорт…' : 'Импорт JSON'}
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
class="hidden"
|
||||
onChange={event => void doImport(event.currentTarget.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{notice.value && <p class="mt-2 text-[12px] text-over-bright">{notice.value}</p>}
|
||||
<p class="mt-3 text-[12px] leading-relaxed text-ink-faint">
|
||||
Всё хранится в IndexedDB этого браузера. Делайте экспорт время от
|
||||
времени — браузер может очистить данные сайта.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 w-full rounded-xl border border-over/30 py-2.5 text-[13px] text-over-bright transition hover:bg-over/12"
|
||||
onClick={wipeAll}
|
||||
>
|
||||
Стереть все данные
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!props.onboarding && <HelpSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { computed, shallowRef } from 'vue';
|
||||
import { useEntity, useMutation, useQuery } from 'vue-sync-engine';
|
||||
import {
|
||||
ProfileEntity,
|
||||
WeightEntity,
|
||||
daySummariesQuery,
|
||||
logWeightMutation,
|
||||
removeWeightMutation,
|
||||
weightsQuery,
|
||||
} from '../data/defs';
|
||||
import type { DaySummary } from '../data/defs';
|
||||
import { useEntities } from '../data/composables';
|
||||
import { dayShort, dayTitle, lastDays, todayISO } from '../domain/dates';
|
||||
import { fmtG, fmtKcal } from '../domain/format';
|
||||
import { PROFILE_ID } from '../domain/types';
|
||||
import type { WeightLog } from '../domain/types';
|
||||
import { activeTab, selectedDate } from '../ui/state';
|
||||
import { IconScale, IconTrash } from '../ui/icons';
|
||||
|
||||
const PERIODS = [7, 14, 30] as const;
|
||||
|
||||
export default function StatsScreen() {
|
||||
const profile = useEntity(ProfileEntity, () => PROFILE_ID);
|
||||
const summariesQ = useQuery(daySummariesQuery, () => undefined);
|
||||
const weightsQ = useQuery(weightsQuery, () => undefined);
|
||||
const weights = useEntities(WeightEntity, () => weightsQ.data.value?.ids);
|
||||
|
||||
const logWeight = useMutation(logWeightMutation);
|
||||
const removeWeight = useMutation(removeWeightMutation);
|
||||
|
||||
const period = shallowRef<7 | 14 | 30>(14);
|
||||
const selectedBar = shallowRef(todayISO());
|
||||
|
||||
const target = computed(() => profile.value?.targetKcal ?? 2000);
|
||||
|
||||
const days = computed(() => {
|
||||
const byDate = new Map((summariesQ.data.value ?? []).map(day => [day.date, day]));
|
||||
return lastDays(period.value).map(date =>
|
||||
byDate.get(date) ?? { date, kcal: 0, protein: 0, fat: 0, carbs: 0, entries: 0 },
|
||||
);
|
||||
});
|
||||
|
||||
const chartMax = computed(() => {
|
||||
const peak = Math.max(target.value, ...days.value.map(day => day.kcal));
|
||||
return peak * 1.08;
|
||||
});
|
||||
|
||||
const selectedDay = computed(() =>
|
||||
days.value.find(day => day.date === selectedBar.value) ?? null,
|
||||
);
|
||||
|
||||
const tracked = computed(() => days.value.filter(day => day.entries > 0));
|
||||
const averages = computed(() => {
|
||||
const list = tracked.value;
|
||||
if (list.length === 0) return null;
|
||||
const total = list.reduce(
|
||||
(acc, day) => ({ kcal: acc.kcal + day.kcal, protein: acc.protein + day.protein }),
|
||||
{ kcal: 0, protein: 0 },
|
||||
);
|
||||
const onTarget = list.filter(day => day.kcal <= target.value).length;
|
||||
return {
|
||||
kcal: total.kcal / list.length,
|
||||
protein: total.protein / list.length,
|
||||
onTargetShare: Math.round((onTarget / list.length) * 100),
|
||||
};
|
||||
});
|
||||
|
||||
// ── вес ───────────────────────────────────────────────────────────────────
|
||||
const weightInput = shallowRef(0);
|
||||
const latestWeight = computed(() => weights.value.at(-1) ?? null);
|
||||
const weightDelta = computed(() => {
|
||||
const list = weights.value;
|
||||
const latest = list.at(-1);
|
||||
if (!latest || list.length < 2) return null;
|
||||
const weekAgoDate = lastDays(8)[0] ?? latest.date;
|
||||
const reference = [...list].reverse().find(item => item.date <= weekAgoDate) ?? list[0];
|
||||
if (!reference || reference.id === latest.id) return null;
|
||||
return latest.kg - reference.kg;
|
||||
});
|
||||
|
||||
const sparkPoints = computed(() => {
|
||||
const list = weights.value.slice(-30);
|
||||
if (list.length < 2) return '';
|
||||
const min = Math.min(...list.map(item => item.kg));
|
||||
const max = Math.max(...list.map(item => item.kg));
|
||||
const span = Math.max(max - min, 0.5);
|
||||
return list
|
||||
.map((item, index) => {
|
||||
const x = (index / (list.length - 1)) * 100;
|
||||
const y = 26 - ((item.kg - min) / span) * 22 + 2;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const submitWeight = () => {
|
||||
if (weightInput.value <= 0) return;
|
||||
const date = todayISO();
|
||||
const weight: WeightLog = { id: date, date, kg: weightInput.value, createdAt: Date.now() };
|
||||
logWeight.mutate({ weight });
|
||||
weightInput.value = 0;
|
||||
};
|
||||
|
||||
const openInDiary = (day: DaySummary) => {
|
||||
selectedDate.value = day.date;
|
||||
activeTab.value = 'diary';
|
||||
};
|
||||
|
||||
const numeric = (raw: string): number => {
|
||||
const value = Number(raw.replace(',', '.'));
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-5 pb-6">
|
||||
<div class="animate-rise flex items-center justify-between">
|
||||
<h1 class="text-display text-xl font-medium">Статистика</h1>
|
||||
<div class="flex gap-1 rounded-full border hairline bg-surface/60 p-1">
|
||||
{PERIODS.map(value => (
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded-full px-3 py-1 text-[12px] transition ${period.value === value ? 'bg-ember/20 text-ember-bright' : 'text-ink-faint hover:text-ink'}`}
|
||||
onClick={() => (period.value = value)}
|
||||
>
|
||||
{value}
|
||||
{' '}
|
||||
дн
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* График калорий по дням */}
|
||||
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '40ms' }}>
|
||||
<div class="mb-3 flex items-baseline justify-between">
|
||||
<h2 class="text-[13px] text-ink-soft">Калории по дням</h2>
|
||||
<span class="text-[12px] text-ink-faint tnum">
|
||||
{`цель ${fmtKcal(target.value)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedDay.value && (
|
||||
<button
|
||||
type="button"
|
||||
class="mb-3 flex w-full items-baseline justify-between rounded-xl bg-raised/50 px-3.5 py-2 text-left transition hover:bg-raised"
|
||||
onClick={() => selectedDay.value && openInDiary(selectedDay.value)}
|
||||
>
|
||||
<span class="text-[13px] text-ink-soft">{dayTitle(selectedDay.value.date)}</span>
|
||||
<span class="text-[13px] tnum">
|
||||
{selectedDay.value.entries > 0
|
||||
? (
|
||||
<span>
|
||||
{fmtKcal(selectedDay.value.kcal)}
|
||||
{' '}
|
||||
ккал · Б
|
||||
{' '}
|
||||
{fmtG(selectedDay.value.protein)}
|
||||
</span>
|
||||
)
|
||||
: <span class="text-ink-faint">нет записей</span>}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div class="relative h-36">
|
||||
{/* Линия цели */}
|
||||
<div
|
||||
class="absolute right-0 left-0 z-10 border-t border-dashed border-ink-soft/40"
|
||||
style={{ bottom: `${(target.value / chartMax.value) * 100}%` }}
|
||||
/>
|
||||
<div class="flex h-full items-end gap-[3px]">
|
||||
{days.value.map(day => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${day.date}: ${Math.round(day.kcal)} ккал`}
|
||||
class="group flex h-full flex-1 flex-col items-center justify-end"
|
||||
onClick={() => (selectedBar.value = day.date)}
|
||||
>
|
||||
<div
|
||||
class={`w-full rounded-t-[4px] transition-colors ${day.kcal > 0 ? '' : 'min-h-[2px]'} ${selectedBar.value === day.date
|
||||
? 'bg-ember-bright'
|
||||
: day.kcal > target.value ? 'bg-over/80 group-hover:bg-over' : 'bg-ember/65 group-hover:bg-ember'}`}
|
||||
style={{ height: `${Math.max((day.kcal / chartMax.value) * 100, day.kcal > 0 ? 2 : 1)}%` }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex justify-between text-[10px] text-ink-faint tnum">
|
||||
<span>{dayShort(days.value[0]?.date ?? todayISO())}</span>
|
||||
<span>{dayShort(days.value.at(-1)?.date ?? todayISO())}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Средние за период */}
|
||||
{averages.value && (
|
||||
<section class="animate-rise grid grid-cols-3 gap-2" style={{ animationDelay: '80ms' }}>
|
||||
<div class="rounded-2xl border hairline bg-surface/60 px-3 py-3 text-center">
|
||||
<div class="text-display text-[22px] font-light tnum">{fmtKcal(averages.value.kcal)}</div>
|
||||
<div class="mt-0.5 text-[11px] text-ink-faint">ккал в среднем</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border hairline bg-surface/60 px-3 py-3 text-center">
|
||||
<div class="text-display text-[22px] font-light tnum">{fmtG(averages.value.protein)}</div>
|
||||
<div class="mt-0.5 text-[11px] text-ink-faint">белка в день, г</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border hairline bg-surface/60 px-3 py-3 text-center">
|
||||
<div class="text-display text-[22px] font-light tnum">
|
||||
{averages.value.onTargetShare}
|
||||
%
|
||||
</div>
|
||||
<div class="mt-0.5 text-[11px] text-ink-faint">дней в цели</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Вес */}
|
||||
<section class="animate-rise rounded-3xl border hairline bg-surface/80 p-5" style={{ animationDelay: '120ms' }}>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<IconScale class="size-4.5 text-ink-soft" />
|
||||
<h2 class="flex-1 text-[13px] text-ink-soft">Вес</h2>
|
||||
{weightDelta.value !== null && (
|
||||
<span class={`text-[12px] tnum ${weightDelta.value <= 0 ? 'text-protein' : 'text-ink-faint'}`}>
|
||||
{weightDelta.value > 0 ? '+' : ''}
|
||||
{fmtG(weightDelta.value)}
|
||||
{' '}
|
||||
кг за неделю
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{latestWeight.value && (
|
||||
<div class="mb-3 flex items-end justify-between">
|
||||
<div>
|
||||
<span class="text-display text-[36px] leading-none font-light tnum">{fmtG(latestWeight.value.kg)}</span>
|
||||
<span class="ml-1 text-[13px] text-ink-faint">кг</span>
|
||||
</div>
|
||||
{sparkPoints.value && (
|
||||
<svg class="h-8 w-36" viewBox="0 0 100 30" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points={sparkPoints.value}
|
||||
fill="none"
|
||||
stroke="var(--color-ember-bright)"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
min="1"
|
||||
step="0.1"
|
||||
placeholder="Вес сегодня, кг"
|
||||
value={weightInput.value || ''}
|
||||
onInput={event => (weightInput.value = numeric(event.currentTarget.value))}
|
||||
class="w-full flex-1 rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint tnum"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl bg-ember px-4 text-[14px] font-medium text-[#1a1006] transition hover:bg-ember-bright disabled:opacity-40"
|
||||
disabled={weightInput.value <= 0}
|
||||
onClick={submitWeight}
|
||||
>
|
||||
Записать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{weights.value.length > 0 && (
|
||||
<div class="mt-3 flex flex-col">
|
||||
{[...weights.value].reverse().slice(0, 5).map(item => (
|
||||
<div class="flex items-center gap-2 border-t hairline py-2 text-[13px]">
|
||||
<span class="flex-1 text-ink-faint">{dayTitle(item.date)}</span>
|
||||
<span class="tnum">
|
||||
{fmtG(item.kg)}
|
||||
{' '}
|
||||
кг
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить замер"
|
||||
class="grid size-7 place-items-center rounded-full text-ink-faint transition hover:bg-over/15 hover:text-over-bright"
|
||||
onClick={() => removeWeight.mutate({ id: item.id })}
|
||||
>
|
||||
<IconTrash class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p class="mt-3 text-[12px] leading-relaxed text-ink-faint">
|
||||
Взвешивайтесь утром натощак. Смотрите на тренд за неделю, а не на
|
||||
ежедневные колебания — вода и еда в желудке шумят на ±1 кг.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Apple,
|
||||
BookOpen,
|
||||
ChartColumn,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Plus,
|
||||
ScanBarcode,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserRound,
|
||||
Weight,
|
||||
X,
|
||||
} from 'lucide';
|
||||
import type { IconNode } from 'lucide';
|
||||
|
||||
interface IconProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Иконки — данные из `lucide` (пары [тег, атрибуты] без обёртки svg).
|
||||
* Компоненты lucide-vue-next построены на vdom и в чистом Vapor не работают,
|
||||
* поэтому рендерим содержимое сами: данные статичны и доверенны — сериализуем
|
||||
* их в разметку один раз на модуль и вставляем через v-html.
|
||||
*/
|
||||
function toMarkup(node: IconNode): string {
|
||||
return node
|
||||
.map(([tag, attrs]) => {
|
||||
const serialized = Object.entries(attrs)
|
||||
.map(([key, value]) => `${key}="${String(value)}"`)
|
||||
.join(' ');
|
||||
return `<${tag} ${serialized}/>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function createIcon(node: IconNode) {
|
||||
const markup = toMarkup(node);
|
||||
return (props: IconProps) => (
|
||||
<svg
|
||||
class={props.class ?? 'size-5'}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
v-html={markup}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const IconBook = createIcon(BookOpen);
|
||||
export const IconChart = createIcon(ChartColumn);
|
||||
export const IconApple = createIcon(Apple);
|
||||
export const IconUser = createIcon(UserRound);
|
||||
export const IconPlus = createIcon(Plus);
|
||||
export const IconChevronLeft = createIcon(ChevronLeft);
|
||||
export const IconChevronRight = createIcon(ChevronRight);
|
||||
export const IconClose = createIcon(X);
|
||||
export const IconTrash = createIcon(Trash2);
|
||||
export const IconSearch = createIcon(Search);
|
||||
export const IconScale = createIcon(Weight);
|
||||
export const IconBarcode = createIcon(ScanBarcode);
|
||||
export const IconDownload = createIcon(Download);
|
||||
export const IconUpload = createIcon(Upload);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { reactive, shallowRef } from 'vue';
|
||||
import type { Meal } from '../domain/types';
|
||||
import { todayISO } from '../domain/dates';
|
||||
|
||||
export type Tab = 'diary' | 'stats' | 'foods' | 'profile';
|
||||
|
||||
export const activeTab = shallowRef<Tab>('diary');
|
||||
|
||||
/** День, открытый в дневнике. Записи добавляются именно в него. */
|
||||
export const selectedDate = shallowRef(todayISO());
|
||||
|
||||
/** Шторка добавления записи. */
|
||||
export const addSheet = reactive({ open: false, meal: 'breakfast' as Meal });
|
||||
|
||||
/** Шторка редактирования записи дневника. */
|
||||
export const editEntryId = shallowRef<string | null>(null);
|
||||
|
||||
/** Шторка формы продукта; null в foodId — создание нового. */
|
||||
export const foodForm = reactive({ open: false, foodId: null as string | null });
|
||||
|
||||
export function openAddSheet(meal: Meal): void {
|
||||
addSheet.meal = meal;
|
||||
addSheet.open = true;
|
||||
}
|
||||
|
||||
export function goToday(): void {
|
||||
selectedDate.value = todayISO();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@robonen/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue-jsx-vapor",
|
||||
"types": ["node", "vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "eslint.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference types="node" />
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vueJsxVapor from 'vue-jsx-vapor/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { syncEnginePlugin } from 'vue-sync-engine/plugin';
|
||||
|
||||
// JSX компилируется сразу в Vapor-код (без interop и virtual DOM).
|
||||
// Tailwind v4 подключён как Vite-плагин, конфиг живёт в src/app.css (@theme).
|
||||
// syncEnginePlugin собирает дефы в virtual:sync-engine-registry — его требует
|
||||
// DevTools-ветка движка в dev-режиме.
|
||||
export default defineConfig({
|
||||
plugins: [vueJsxVapor(), tailwindcss(), syncEnginePlugin({ definitions: ['/src/data/defs.ts'] })],
|
||||
optimizeDeps: {
|
||||
// Движок ходит в virtual-модуль — пребандл прятал бы его от плагина.
|
||||
exclude: ['vue-sync-engine'],
|
||||
},
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: 'false',
|
||||
__VUE_PROD_DEVTOOLS__: 'false',
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
||||
// Dev-ветки vue-sync-engine (DevTools-панель) остаются в `vite dev`,
|
||||
// вырезаются из прод-сборки.
|
||||
__SYNC_ENGINE_DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user