Files
snippets/kcal/src/screens/AddSheet.tsx
T
2026-08-07 13:33:17 +07:00

608 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}