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
+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>
);
}