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('pick'); const query = shallowRef(''); const meal = shallowRef(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(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 (
{/* Шапка */}
{step.value !== 'pick' && ( )}

{step.value === 'pick' && `${MEAL_LABELS[meal.value]} · добавить`} {step.value === 'amount' && (selectedFood.value?.name ?? '')} {step.value === 'quick' && 'Быстрая запись'} {step.value === 'food' && 'Новый продукт'}

{/* Приём пищи */}
{MEALS.map(m => ( ))}
{/* ── Шаг: выбор ── */} {step.value === 'pick' && (
(query.value = event.currentTarget.value)} class={`${fieldClass} pl-10`} />
{trimmed.value === '' && recents.value.length > 0 && (

Недавние

{recents.value.map(food => ( ))}
)} {groups.value.map(([category, list]) => (

{category}

{list.map(food => ( ))}
))} {filtered.value.length === 0 && (

В каталоге не нашлось — отсканируйте штрихкод упаковки ниже или создайте «Новый продукт».

)} {/* ── База упаковок: только штрихкоды (Open Food Facts) ── */}

Штрихкод упаковки

{scanSupported && !scannerOpen.value && ( )} {scannerOpen.value && ( void lookupBarcode(code)} onCancel={() => (scannerOpen.value = false)} /> )}
(barcodeInput.value = event.currentTarget.value.replaceAll(/\D/g, ''))} onKeydown={(event) => { if (event.key === 'Enter') void lookupBarcode(barcodeInput.value); }} class={`${fieldClass} tnum`} />
{offError.value !== '' && (

{offError.value}

)}

КБЖУ подтянутся из Open Food Facts — открытой базы упаковок. Её заполняют люди, поэтому цифры стоит сверить с этикеткой.

)} {/* ── Шаг: порция ── */} {step.value === 'amount' && selectedFood.value && (
{preview.value ? fmtKcal(preview.value.kcal) : 0}
ккал в порции
Б {' '} {fmtG(preview.value?.protein ?? 0)} Ж {' '} {fmtG(preview.value?.fat ?? 0)} У {' '} {fmtG(preview.value?.carbs ?? 0)}
(amountG.value = numeric(event.currentTarget.value))} class={`${fieldClass} text-center text-lg tnum`} />
{GRAM_PRESETS.map(grams => ( ))}
{selectedFood.value.pieceGrams && (
{[1, 2, 3].map(count => ( ))}
)}
)} {/* ── Шаг: быстрая запись ── */} {step.value === 'quick' && (

Когда некогда взвешивать — запишите оценку калорий, чтобы день остался честным. Б/Ж/У можно не указывать.

(quickName.value = event.currentTarget.value)} class={fieldClass} />
(quickKcal.value = numeric(event.currentTarget.value))} class={`${fieldClass} text-center text-lg tnum`} />
{([ ['Белки', quickProtein], ['Жиры', quickFat], ['Углеводы', quickCarbs], ] as const).map(([label, model]) => (
(model.value = numeric(event.currentTarget.value))} class={`${fieldClass} text-center tnum`} />
))}
)} {/* ── Шаг: новый продукт ── */} {step.value === 'food' && (
(foodName.value = event.currentTarget.value)} class={fieldClass} />
(foodCategory.value = event.currentTarget.value)} class={fieldClass} /> {categories.value.map(category => (
{foodBarcode.value !== '' ? (

Значения подставлены из Open Food Facts — сверьте с этикеткой и поправьте, если расходятся.

) :

Значения указываются на 100 г продукта.

}
(foodKcal.value = numeric(event.currentTarget.value))} class={`${fieldClass} tnum`} />
(foodPiece.value = numeric(event.currentTarget.value))} class={`${fieldClass} tnum`} />
{([ ['Белки', foodProtein], ['Жиры', foodFat], ['Углеводы', foodCarbs], ] as const).map(([label, model]) => (
(model.value = numeric(event.currentTarget.value))} class={`${fieldClass} text-center tnum`} />
))}
)}
); }