fix: rozdílné jednotky u receptů

feat: vazby a trvání receptů
This commit is contained in:
Ondřej Anděl committed 2026-10-19 18:40:13 +02:00
1 parent caffb6d8b4
commit 865c2c3b22
18 files changed
+952 -78

No files matched your search

+132 -25
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto';
import { MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen";
import { IngredientUnit, MealTemplate, MealType, RecipeFlavor, RecipeIngredient } from "../../types/gen/types.gen";
import getStorage from "./storage";
import { addMeal, getNameSourceKey, isMealType } from "./meals";
import { rememberCalories } from "./calories";
@@ -14,15 +14,34 @@ const MAX_NOTE_LENGTH = 4000;
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
const MAX_INGREDIENTS = 50;
/** Maximální množství suroviny v jiné jednotce než gramech. */
const MAX_AMOUNT = 100000;
/** Nejdelší čas přípravy v minutách (týden — kvašení, nakládání). */
const MAX_PREP_MINUTES = 7 * 24 * 60;
/** Maximální počet vazeb jednoho druhu u receptu. */
const MAX_LINKS = 50;
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
const definedOnly = <T extends object>(value: T): T =>
Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T;
/** Klíč úložiště s recepty jednoho uživatele. */
const getKey = (login: string): string => `mealTemplates:${login}`;
/**
* Recept s vazbami jen ze své strany, jak se ukládá. Přílohy nese hlavní jídlo
* a podobnost jen jeden recept z dvojice; druhou stranu dopočte `withLinks`.
*/
type OwnRecipe = Omit<MealTemplate, 'sideDishOfIds'>;
/**
* Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly
* `kind: 'RECEPT'` a měly i chod.
*/
type StoredMealTemplate = Omit<MealTemplate, 'ingredients' | 'flavor'> & {
type StoredMealTemplate = Omit<OwnRecipe, 'ingredients' | 'flavor'> & {
ingredients?: RecipeIngredient[];
flavor?: RecipeFlavor;
kind?: string;
@@ -42,7 +61,7 @@ const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!store
* Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod.
* Slaný recept je výchozí stav a v úložišti chuť nemá, doplní se tady.
*/
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
const toOwnRecipe = (stored: StoredRecipe): OwnRecipe => {
const { kind, pricePer100g, mealType, ...rest } = stored;
return { ...rest, flavor: rest.flavor ?? RecipeFlavor.SLANE };
};
@@ -56,13 +75,41 @@ const normalizeFlavor = (value: unknown): RecipeFlavor => {
return value;
};
/** Recept tak, jak se ukládá — výchozí slanou chuť neukládá. */
const toStored = ({ flavor, ...rest }: MealTemplate): StoredMealTemplate =>
flavor === RecipeFlavor.SLANE ? rest : { ...rest, flavor };
/** Seznam vazeb do úložiště i API — prázdný se vynechá. */
const toLinkIds = (idList?: string[]): string[] | undefined => (idList?.length ? idList : undefined);
/** Recept tak, jak se ukládá — výchozí slanou chuť ani prázdné vazby neukládá. */
const toStored = ({ flavor, sideDishIds, similarIds, ...rest }: OwnRecipe): StoredMealTemplate => definedOnly({
...rest,
flavor: flavor === RecipeFlavor.SLANE ? undefined : flavor,
sideDishIds: toLinkIds(sideDishIds),
similarIds: toLinkIds(similarIds),
});
/** Načte recepty z úložiště; šablony hotových jídel vynechá. */
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate[] =>
(storedList ?? []).filter(isRecipe).map(toMealTemplate);
const loadRecipes = (storedList: StoredMealTemplate[] | undefined): OwnRecipe[] =>
(storedList ?? []).filter(isRecipe).map(toOwnRecipe);
/**
* Doplní receptům druhou stranu vazeb: přílohu všem jídlům, která ji uvádějí,
* a podobnost oběma receptům dvojice. Vazby na smazané recepty vynechá,
* ostatní seřadí podle názvu receptu.
*/
const withLinks = (recipeList: OwnRecipe[]): MealTemplate[] => {
const nameMap = new Map(recipeList.map(recipe => [recipe.id, recipe.name]));
const sortByName = (idList: string[]): string[] | undefined => toLinkIds([...new Set(idList)]
.filter(id => nameMap.has(id))
.sort((a, b) => nameMap.get(a)!.localeCompare(nameMap.get(b)!, 'cs')));
const findLinking = (recipeId: string, getIds: (recipe: OwnRecipe) => string[] | undefined): string[] =>
recipeList.filter(other => getIds(other)?.includes(recipeId)).map(other => other.id);
return recipeList.map(recipe => definedOnly({
...recipe,
sideDishIds: sortByName(recipe.sideDishIds ?? []),
sideDishOfIds: sortByName(findLinking(recipe.id, other => other.sideDishIds)),
similarIds: sortByName([...recipe.similarIds ?? [], ...findLinking(recipe.id, other => other.similarIds)]),
}));
};
/**
* Vrátí recepty uživatele seřazené podle názvu.
@@ -70,7 +117,7 @@ const loadRecipes = (storedList: StoredMealTemplate[] | undefined): MealTemplate
* @param login přihlašovací jméno uživatele
*/
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login)))
withLinks(loadRecipes(await storage.getData<StoredMealTemplate[]>(getKey(login))))
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
@@ -113,6 +160,9 @@ const sumDefined = (valueList: (number | undefined)[]): number | undefined => {
* vařením se voda odpaří nebo nasákne (rýže, těstoviny), takže by vyšla špatně.
* Porci si pak jde převážit a kalorie se dopočtou z gramáže jako u každého jídla.
*
* Počítá se vždy z gramáže; surovina v jiné jednotce (2 ks vajec) do výpočtu
* vstupuje jen svým přepočtem na gramy, pokud ho má.
*
* Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky.
*
* @param ingredients suroviny receptu
@@ -169,9 +219,17 @@ const normalizeNumber = (value: unknown, name: string): number | undefined => {
return rounded;
};
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
const definedOnly = <T extends object>(value: T): T =>
Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T;
const isIngredientUnit = (value: unknown): value is IngredientUnit =>
Object.values(IngredientUnit).includes(value as IngredientUnit);
/** Ověří volitelné kladné množství suroviny; necelé na setiny (0,25 l). */
const normalizeAmount = (value: unknown, index: number): number | undefined => {
if (value == null) return undefined;
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > MAX_AMOUNT) {
throw new BadRequestError(`Surovina ${index + 1} nemá platné množství`);
}
return Math.round(value * 100) / 100;
};
/** Ověří a znormalizuje jednu surovinu receptu. */
const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
@@ -180,12 +238,19 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
if (!name) {
throw new BadRequestError(`Surovina ${index + 1} nemá název`);
}
if (value.unit != null && !isIngredientUnit(value.unit)) {
throw new BadRequestError(`Surovina ${index + 1} nemá platnou jednotku`);
}
const unit = value.unit ?? undefined;
const weight = normalizeNumber(value.weight, 'weight');
const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g');
const calories = normalizeNumber(value.calories, 'calories');
const price = normalizeNumber(value.price, 'price');
return definedOnly({
name,
// Bez jednotky je množstvím gramáž, samostatné množství by ji jen zdvojovalo
amount: unit ? normalizeAmount(value.amount, index) : undefined,
unit,
weight,
caloriesPer100g,
// Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže
@@ -194,8 +259,35 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
});
};
/** Ověří seznam propojených receptů — musí existovat a nesmí to být recept sám. */
const normalizeLinkIds = (value: unknown, recipeIds: Set<string>, selfId: string): string[] => {
if (value == null) return [];
if (!Array.isArray(value) || value.length > MAX_LINKS) {
throw new BadRequestError(`Recept může mít nejvýše ${MAX_LINKS} vazeb jednoho druhu`);
}
if (value.some(id => typeof id !== 'string' || id === selfId || !recipeIds.has(id))) {
throw new BadRequestError("Propojený recept nebyl nalezen");
}
return [...new Set(value as string[])];
};
/**
* Upraví vazby jiného receptu na recept `recipeId`. Vazby, které se ho týkají,
* určuje jen on: odeberou se a přílohou se jiný recept znovu stane jen tehdy,
* když je v `sideDishOfIds`. Podobnost si recept `recipeId` drží sám.
*/
const relinkOther = (other: OwnRecipe, recipeId: string, sideDishOfIds: string[] = []): OwnRecipe => ({
...other,
sideDishIds: [
...(other.sideDishIds ?? []).filter(id => id !== recipeId),
...(sideDishOfIds.includes(other.id) ? [recipeId] : []),
],
similarIds: (other.similarIds ?? []).filter(id => id !== recipeId),
});
/** Klíč jídla, ke kterému recept patří — stejně jako u skupin v seznamu jídel. */
export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(recipe.name, recipe.source);
export const getRecipeKey = (recipe: Pick<MealTemplate, 'name' | 'source'>): string =>
getNameSourceKey(recipe.name, recipe.source);
/**
* Založí nový recept, nebo přepíše existující podle předaného id.
@@ -205,6 +297,8 @@ export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(r
*
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
* Vazby na jiné recepty (přílohy, podobné) se zapíšou i z druhé strany, takže
* vazbu jde přidat i odebrat v kterémkoli z obou receptů.
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
*
* @param login přihlašovací jméno uživatele
@@ -225,10 +319,18 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
const ingredients = value.ingredients.map(normalizeIngredient);
const servings = normalizeNumber(value.servings, 'servings');
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
const prepMinutes = normalizeNumber(value.prepMinutes, 'prepMinutes') || undefined;
if (prepMinutes != null && prepMinutes > MAX_PREP_MINUTES) {
throw new BadRequestError("Čas přípravy může být nejvýše týden");
}
const summary = deriveRecipe(ingredients, servings, cookedWeight);
const id = typeof value.id === 'string' && value.id.length ? value.id : randomUUID();
const recipeList = await getMealTemplates(login);
const recipeIds = new Set(recipeList.map(item => item.id));
const sideDishOfIds = normalizeLinkIds(value.sideDishOfIds, recipeIds, id);
const template: MealTemplate = definedOnly({
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
const template: OwnRecipe = definedOnly({
id,
name,
source: normalizeText(value.source, 'source'),
category: normalizeText(value.category, 'category'),
@@ -238,12 +340,15 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
servings: servings && servings > 1 ? servings : undefined,
cookedWeight,
prepMinutes,
sideDishIds: normalizeLinkIds(value.sideDishIds, recipeIds, id),
similarIds: normalizeLinkIds(value.similarIds, recipeIds, id),
weight: summary.weight,
caloriesPer100g: summary.caloriesPer100g,
calories: summary.calories,
price: summary.price,
});
const otherRecipe = (await getMealTemplates(login))
const otherRecipe = recipeList
.find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template));
if (otherRecipe) {
throw new BadRequestError("Toto jídlo už recept má");
@@ -255,14 +360,17 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
}
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => [
...loadRecipes(current).filter(item => item.id !== template.id).map(toStored),
...loadRecipes(current)
.filter(item => item.id !== template.id)
.map(item => toStored(relinkOther(item, template.id, sideDishOfIds))),
toStored(template),
]);
return getMealTemplates(login);
};
/**
* Smaže recept. Jídla z něj založená zůstávají, jsou samostatná.
* Smaže recept i vazby ostatních receptů na něj. Jídla z něj založená
* zůstávají, jsou samostatná.
*
* @param login přihlašovací jméno uživatele
* @param id identifikátor receptu
@@ -275,7 +383,7 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise<Me
await storage.updateData<StoredMealTemplate[]>(getKey(login), current => {
const recipeList = loadRecipes(current);
found = recipeList.some(item => item.id === id);
return recipeList.filter(item => item.id !== id).map(toStored);
return recipeList.filter(item => item.id !== id).map(item => toStored(relinkOther(item, id)));
});
if (!found) {
throw new NotFoundError("Recept nebyl nalezen");
@@ -333,7 +441,7 @@ export const applyMealTemplate = async (
/**
* Přejmenuje recept jídla spolu s jídlem — při hromadném přejmenování v seznamu
* jídel, aby recept zůstal u svého jídla.
* jídel, aby recept zůstal u svého jídla. Vazby jdou přes id, přejmenování je nemění.
*
* @param login přihlašovací jméno uživatele
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
@@ -343,10 +451,9 @@ export const applyMealTemplate = async (
export const renameMealTemplate = async (
login: string, fromKey: string, name: string, source?: string,
): Promise<void> => {
const recipeList = await getMealTemplates(login);
const recipe = recipeList.find(item => getRecipeKey(item) === fromKey);
if (!recipe) return;
const renamed: MealTemplate = definedOnly({ ...recipe, name, source });
if (!(await getMealTemplates(login)).some(item => getRecipeKey(item) === fromKey)) return;
await storage.updateData<StoredMealTemplate[]>(getKey(login), current =>
loadRecipes(current).map(item => toStored(item.id === recipe.id ? renamed : item)));
loadRecipes(current).map(item => toStored(getRecipeKey(item) === fromKey
? definedOnly({ ...item, name, source })
: item)));
};
+157
View File
@@ -0,0 +1,157 @@
process.env.CALORIE_PROVIDER = 'none';
import { deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../mealTemplates";
import { addMeal } from "../meals";
import { renameMealGroup } from "../mealGroups";
import { resetMemoryStorage } from "../storage/memory";
import getStorage from "../storage";
import { IngredientUnit, MealTemplate, MealType } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
beforeEach(() => resetMemoryStorage());
const createRecipe = async (name: string, extra: Record<string, unknown> = {}): Promise<MealTemplate> => {
const recipeList = await saveMealTemplate(LOGIN, {
name, source: 'doma', ingredients: [{ name: 'Sůl', weight: 5 }], ...extra,
});
return recipeList.find(item => item.name === name)!;
};
const findRecipe = async (name: string): Promise<MealTemplate> =>
(await getMealTemplates(LOGIN)).find(item => item.name === name)!;
describe('jednotky surovin', () => {
it('surovina v kusech nese množství a přepočet na gramy počítá výživu', async () => {
const recipe = await createRecipe('Omeleta', {
ingredients: [
{ name: 'Vejce', amount: 3, unit: IngredientUnit.KS, weight: 180, caloriesPer100g: 140 },
{ name: 'Mléko', amount: 0.125, unit: IngredientUnit.L },
],
});
expect(recipe.ingredients).toEqual([
{ name: 'Vejce', amount: 3, unit: IngredientUnit.KS, weight: 180, caloriesPer100g: 140 },
// Bez přepočtu na gramy do výživy nevstupuje, množství se zaokrouhlí na setiny
{ name: 'Mléko', amount: 0.13, unit: IngredientUnit.L },
]);
expect(recipe).toMatchObject({ weight: 180, calories: 252 });
});
it('bez jednotky je množstvím gramáž a samostatné množství se zahodí', async () => {
const recipe = await createRecipe('Rýže', { ingredients: [{ name: 'Rýže', amount: 3, weight: 200 }] });
expect(recipe.ingredients).toEqual([{ name: 'Rýže', weight: 200 }]);
});
it('jednotka bez množství zůstane (špetka soli)', async () => {
const recipe = await createRecipe('Polévka', { ingredients: [{ name: 'Sůl', unit: IngredientUnit.SPETKA }] });
expect(recipe.ingredients).toEqual([{ name: 'Sůl', unit: IngredientUnit.SPETKA }]);
});
it('odmítne neznámou jednotku a nekladné množství', async () => {
await expect(createRecipe('X', { ingredients: [{ name: 'A', unit: 'VEDRO', amount: 1 }] }))
.rejects.toThrow(/jednotku/);
await expect(createRecipe('X', { ingredients: [{ name: 'A', unit: IngredientUnit.KS, amount: 0 }] }))
.rejects.toThrow(/množství/);
});
});
describe('čas přípravy', () => {
it('uloží se v minutách, nula znamená nevyplněný', async () => {
expect((await createRecipe('Guláš', { prepMinutes: 150 })).prepMinutes).toBe(150);
expect((await createRecipe('Salát', { prepMinutes: 0 })).prepMinutes).toBeUndefined();
});
it('odmítne čas delší než týden', async () => {
await expect(createRecipe('Kimči', { prepMinutes: 8 * 24 * 60 })).rejects.toThrow(/týden/);
});
});
describe('vazby mezi recepty', () => {
it('příloha se u druhého receptu ukáže jako "přílohou k"', async () => {
const ryze = await createRecipe('Rýže');
const kure = await createRecipe('Kuře na kari', { sideDishIds: [ryze.id] });
expect(kure.sideDishIds).toEqual([ryze.id]);
expect(kure.sideDishOfIds).toBeUndefined();
expect((await findRecipe('Rýže')).sideDishOfIds).toEqual([kure.id]);
});
it('přílohu jde zapsat i z přílohy a uloží se u hlavního jídla', async () => {
const kure = await createRecipe('Kuře na kari');
const ryze = await createRecipe('Rýže', { sideDishOfIds: [kure.id] });
expect((await findRecipe('Kuře na kari')).sideDishIds).toEqual([ryze.id]);
// Uložení přílohy bez vazby ji odebere i z hlavního jídla
await createRecipe('Rýže', { id: ryze.id });
expect((await findRecipe('Kuře na kari')).sideDishIds).toBeUndefined();
});
it('podobnost je oboustranná a odebrat jde z kterékoli strany', async () => {
const gulas = await createRecipe('Guláš');
const segedin = await createRecipe('Segedín', { similarIds: [gulas.id] });
expect((await findRecipe('Guláš')).similarIds).toEqual([segedin.id]);
await createRecipe('Guláš', { id: gulas.id, similarIds: [] });
expect((await findRecipe('Guláš')).similarIds).toBeUndefined();
expect((await findRecipe('Segedín')).similarIds).toBeUndefined();
});
it('úprava receptu, který vazbu zná jen z druhé strany, ji zachová', async () => {
const gulas = await createRecipe('Guláš');
const segedin = await createRecipe('Segedín', { similarIds: [gulas.id] });
const knedlik = await createRecipe('Knedlík', { sideDishOfIds: [gulas.id] });
// Klient posílá zpět, co dostal — vazby uložené u druhého receptu
const loaded = await findRecipe('Guláš');
await saveMealTemplate(LOGIN, { ...loaded, note: 'Dlouho dusit' });
expect(await findRecipe('Guláš')).toMatchObject({ similarIds: [segedin.id], sideDishIds: [knedlik.id] });
expect(await findRecipe('Segedín')).toMatchObject({ similarIds: [gulas.id] });
});
it('odmítne vazbu na sebe sama a na neexistující recept', async () => {
const gulas = await createRecipe('Guláš');
await expect(createRecipe('Guláš', { id: gulas.id, similarIds: [gulas.id] })).rejects.toThrow(/nebyl nalezen/);
await expect(createRecipe('Segedín', { sideDishIds: ['neni'] })).rejects.toThrow(/nebyl nalezen/);
});
it('smazání receptu odebere vazby na něj', async () => {
const ryze = await createRecipe('Rýže');
await createRecipe('Kuře na kari', { sideDishIds: [ryze.id], similarIds: [ryze.id] });
await deleteMealTemplate(LOGIN, ryze.id);
const stored = await getStorage().getData<Record<string, unknown>[]>(`mealTemplates:${LOGIN}`);
expect(stored).toHaveLength(1);
expect(stored![0]).not.toHaveProperty('sideDishIds');
expect(stored![0]).not.toHaveProperty('similarIds');
});
it('přejmenování jídla vazby zachová', async () => {
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Rýže', source: 'doma' });
const ryze = await createRecipe('Rýže');
const kure = await createRecipe('Kuře na kari', { sideDishIds: [ryze.id] });
await renameMealGroup(LOGIN, 'Rýže', 'doma', 'Jasmínová rýže', 'doma');
expect(await findRecipe('Jasmínová rýže')).toMatchObject({ id: ryze.id, sideDishOfIds: [kure.id] });
});
it('do úložiště se druhá strana vazby nezapisuje', async () => {
const gulas = await createRecipe('Guláš');
await createRecipe('Segedín', { similarIds: [gulas.id] });
const stored = await getStorage().getData<Record<string, unknown>[]>(`mealTemplates:${LOGIN}`);
expect(stored!.find(item => item.name === 'Guláš')).not.toHaveProperty('similarIds');
expect(stored!.find(item => item.name === 'Guláš')).not.toHaveProperty('sideDishOfIds');
});
});