feat: add kcal app

This commit is contained in:
2026-08-07 13:33:17 +07:00
parent 1ee76faf55
commit c02448f7d9
39 changed files with 6966 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store
+86
View File
@@ -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`+фильтра), когда
дневник разрастётся.
+16
View File
@@ -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',
},
},
);
+13
View File
@@ -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>
+41
View File
@@ -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"
}
}
+3260
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
allowBuilds:
unrs-resolver: true
minimumReleaseAgeExclude:
- vite@8.2.1
peerDependencyRules:
allowAny:
- vue
- rollup
+117
View File
@@ -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>
);
}
+134
View File
@@ -0,0 +1,134 @@
@import 'tailwindcss';
/*
* «Вечерний гроссбух»: тёплый угольный фон, кремовые чернила, янтарный акцент.
* Цвета данных (ember/protein/fat/carbs) проверены на CVD-разделимость и
* контраст к поверхности (OKLCH L 0.480.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;
}
+94
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+57
View File
@@ -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);
}
+25
View File
@@ -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;
});
}
+232
View File
@@ -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,
];
+29
View File
@@ -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,
});
}
+71
View File
@@ -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();
});
});
+89
View File
@@ -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 });
}
+100
View File
@@ -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,
}));
+76
View File
@@ -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 });
});
});
+86
View File
@@ -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;
}
+41
View File
@@ -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']);
});
});
+41
View File
@@ -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;
}
+22
View File
@@ -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)} г`;
}
+102
View File
@@ -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;
+13
View File
@@ -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[]>;
}
+20
View File
@@ -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');
+607
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+188
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+88
View File
@@ -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, сладкий кофе 150300.',
'У сетей (Вкусно и точка, 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>
);
}
+351
View File
@@ -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>
);
}
+303
View File
@@ -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>
);
}
+69
View File
@@ -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);
+28
View File
@@ -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();
}
+12
View File
@@ -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"]
}
+29
View File
@@ -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'],
},
});