fix: recepty přesnuty k jídlům
feat: přidána podpora fotek
This commit is contained in:
1 parent
efed5e6e86
commit
e13f7ce8e5
39 files changed
+1266
-511
No files matched your search
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealTemplate, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError } from "./utils";
|
||||
import { normalizeName, rememberCalories } from "./calories";
|
||||
import { normalizeNutrients } from "./nutrients";
|
||||
import {
|
||||
deriveAmounts, findAllMeals, getDayKey, getManualWeight, getNameSourceKey, normalizeNumber, normalizeText,
|
||||
} from "./meals";
|
||||
import { getMealTemplates, getRecipeKey, renameMealTemplate } from "./mealTemplates";
|
||||
import { getPhotoIndex, moveMealPhotos } from "./mealPhotos";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Vrátí první vyplněnou hodnotu z jídel seřazených od nejnovějších. */
|
||||
const findLatestValue = <T>(entryList: MealEntry[], pick: (entry: MealEntry) => T | undefined): T | undefined =>
|
||||
entryList.map(pick).find(value => value != null);
|
||||
|
||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nezapisovaly. */
|
||||
const omitUndefined = <T extends object>(value: T): T =>
|
||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
||||
|
||||
/**
|
||||
* Sestaví hodnoty k předvyplnění formuláře skupiny — každou z nejnovějšího
|
||||
* jídla, které ji má. Energie a živiny se bez vlastních hodnot vezmou z jídla
|
||||
* stejného názvu odjinud (složení je stejné), sazba za 100 g a gramáž ne —
|
||||
* jinde se účtuje i nandává jinak.
|
||||
*
|
||||
* Má-li jídlo recept, gramáž porce a energie jsou z něj — spočítané ze surovin
|
||||
* jsou přesnější než cokoli, co se kdy zapsalo ke dni.
|
||||
*/
|
||||
const createMealGroupValues = (
|
||||
groupList: MealEntry[], sameNameList: MealEntry[], recipe?: MealTemplate,
|
||||
): MealGroupValues => {
|
||||
const caloriesPer100g = recipe?.caloriesPer100g
|
||||
?? findLatestValue(groupList, entry => entry.caloriesPer100g)
|
||||
?? findLatestValue(sameNameList, entry => entry.caloriesPer100g);
|
||||
return omitUndefined({
|
||||
pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g),
|
||||
weight: recipe?.weight ?? findLatestValue(groupList, getManualWeight),
|
||||
caloriesPer100g,
|
||||
// Ruční kalorie porce dávají smysl jen u jídla, které energii na 100 g nemá
|
||||
calories: caloriesPer100g == null
|
||||
? recipe?.calories ?? findLatestValue(groupList, entry => entry.calories)
|
||||
: undefined,
|
||||
nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g)
|
||||
?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g),
|
||||
});
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
||||
const isIncomplete = (entry: MealEntry): boolean =>
|
||||
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
||||
|
||||
/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */
|
||||
const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
||||
const dayMap = new Map<string, MealGroupDay>();
|
||||
for (const entry of groupList) {
|
||||
const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false };
|
||||
day.incomplete ||= isIncomplete(entry);
|
||||
dayMap.set(entry.date, day);
|
||||
}
|
||||
return [...dayMap.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
||||
* kterým chybí kalorie, gramáž nebo živiny — seznam jídel s hromadným
|
||||
* doplněním údajů a přejmenováním.
|
||||
*
|
||||
* Ke skupině patří i její recept a fotky. Recept jídla, které se zatím nejedlo,
|
||||
* tvoří skupinu sám (s nulovým počtem), aby šel v seznamu najít.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
const entryList = await findAllMeals(login);
|
||||
const recipeMap = new Map<string, MealTemplate>();
|
||||
for (const recipe of await getMealTemplates(login)) {
|
||||
if (!recipeMap.has(getRecipeKey(recipe))) recipeMap.set(getRecipeKey(recipe), recipe);
|
||||
}
|
||||
const photoIndex = await getPhotoIndex(login);
|
||||
const groupMap = new Map<string, MealEntry[]>();
|
||||
const nameMap = new Map<string, MealEntry[]>();
|
||||
for (const entry of entryList) {
|
||||
const key = getNameSourceKey(entry.name, entry.source);
|
||||
groupMap.set(key, [...(groupMap.get(key) ?? []), entry]);
|
||||
const nameKey = normalizeName(entry.name);
|
||||
nameMap.set(nameKey, [...(nameMap.get(nameKey) ?? []), entry]);
|
||||
}
|
||||
|
||||
const createGroup = (key: string, groupList: MealEntry[], name: string, source?: string): MealGroup => {
|
||||
const recipe = recipeMap.get(key);
|
||||
const photoList = photoIndex[key] ?? [];
|
||||
return omitUndefined({
|
||||
name,
|
||||
source,
|
||||
count: groupList.length,
|
||||
lastDate: groupList[0]?.date,
|
||||
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
||||
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
||||
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
||||
days: createMealGroupDays(groupList),
|
||||
values: createMealGroupValues(groupList, nameMap.get(normalizeName(name)) ?? [], recipe),
|
||||
recipe,
|
||||
photoCount: photoList.length,
|
||||
thumbnail: photoList[0]?.thumbnail,
|
||||
});
|
||||
};
|
||||
|
||||
const resultList = [...groupMap.entries()]
|
||||
.map(([key, groupList]) => createGroup(key, groupList, groupList[0].name, groupList[0].source));
|
||||
for (const [key, recipe] of recipeMap) {
|
||||
if (!groupMap.has(key)) resultList.push(createGroup(key, [], recipe.name, recipe.source));
|
||||
}
|
||||
return resultList.sort((a, b) =>
|
||||
b.count - a.count || (b.lastDate ?? '').localeCompare(a.lastDate ?? '') || a.name.localeCompare(b.name, 'cs'));
|
||||
};
|
||||
|
||||
/** Ověří hodnoty k doplnění do skupiny jídel. Nulová sazba nebo gramáž nic neříká, bere se jako nezadaná. */
|
||||
const normalizeGroupValues = (input: unknown): MealGroupValues => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') || undefined,
|
||||
weight: normalizeNumber(value.weight, 'weight') || undefined,
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') || undefined,
|
||||
calories: normalizeNumber(value.calories, 'calories'),
|
||||
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní do jídla hodnoty, které mu chybí; co už má, nechá být.
|
||||
*
|
||||
* Gramáž se nedoplní tam, kde jde dopočítat z vlastní ceny jídla — ta je
|
||||
* přesnější než jedna gramáž pro všechny. Energie se doplní jen jídlu bez
|
||||
* kalorií, jinak by dopočet z gramáže přepsal kalorie zadané ručně. Živiny
|
||||
* se doplňují po jednotlivých položkách.
|
||||
*/
|
||||
const fillMissingValues = (entry: MealEntry, values: MealGroupValues): MealEntry => {
|
||||
const pricePer100g = entry.pricePer100g ?? values.pricePer100g;
|
||||
const weightDerivable = entry.price != null && !!pricePer100g;
|
||||
const caloriesMissing = entry.calories == null;
|
||||
const derived = deriveAmounts({
|
||||
...entry,
|
||||
pricePer100g,
|
||||
weight: entry.weight ?? (weightDerivable ? undefined : values.weight),
|
||||
caloriesPer100g: entry.caloriesPer100g ?? (caloriesMissing ? values.caloriesPer100g : undefined),
|
||||
calories: caloriesMissing ? values.calories : entry.calories,
|
||||
});
|
||||
return omitUndefined({
|
||||
...entry,
|
||||
pricePer100g,
|
||||
weight: derived.weight,
|
||||
caloriesPer100g: derived.caloriesPer100g,
|
||||
calories: caloriesMissing ? derived.calories : entry.calories,
|
||||
nutrientsPer100g: fillMissingNutrients(entry.nutrientsPer100g, values.nutrientsPer100g),
|
||||
});
|
||||
};
|
||||
|
||||
/** Doplní chybějící živiny; vyplněné zůstanou a na svém místě, aby šlo poznat, že se nic nezměnilo. */
|
||||
const fillMissingNutrients = (current?: Nutrients, values?: Nutrients): Nutrients | undefined => {
|
||||
if (!values) return current;
|
||||
const missing = Object.entries(values).filter(([key]) => current?.[key as keyof Nutrients] == null);
|
||||
return missing.length ? { ...current, ...Object.fromEntries(missing) } : current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem — typicky
|
||||
* ke starším záznamům, u kterých se kalorie nebo živiny nezadávaly.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param name název jídla skupiny
|
||||
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||
* @param input hodnoty k doplnění
|
||||
*/
|
||||
export const fillMealGroup = async (
|
||||
login: string,
|
||||
name: unknown,
|
||||
source: unknown,
|
||||
input: unknown,
|
||||
): Promise<FillSameNameResult> => {
|
||||
const groupName = normalizeText(name, 'name');
|
||||
if (!groupName) {
|
||||
throw new BadRequestError("Nebyl předán název jídla");
|
||||
}
|
||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||
const values = normalizeGroupValues(input);
|
||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||
|
||||
const groupDates = [...new Set((await findAllMeals(login)).filter(isInGroup).map(entry => entry.date))].sort();
|
||||
const updatedAt = new Date().toISOString();
|
||||
let count = 0;
|
||||
const dates: string[] = [];
|
||||
for (const date of groupDates) {
|
||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||
if (!isInGroup(entry)) return entry;
|
||||
const filled = fillMissingValues(entry, values);
|
||||
if (JSON.stringify(filled) === JSON.stringify(entry)) return entry;
|
||||
count++;
|
||||
if (!dates.includes(date)) dates.push(date);
|
||||
return { ...filled, updatedAt };
|
||||
}));
|
||||
}
|
||||
if (values.caloriesPer100g) {
|
||||
await rememberCalories(login, groupName, values.caloriesPer100g);
|
||||
}
|
||||
return { count, dates };
|
||||
};
|
||||
|
||||
/**
|
||||
* Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní
|
||||
* zdroj — oprava překlepu nebo sjednocení názvu napříč všemi dny, kdy se jídlo
|
||||
* jedlo. Na rozdíl od doplnění údajů přepisuje, co jídla mají.
|
||||
*
|
||||
* Když nový název a zdroj patří jiné skupině, jídla se do ní tím sloučí.
|
||||
* Recept a fotky jdou s jídlem; dva recepty ale sloučit nejde.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param name název jídla skupiny
|
||||
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||
* @param newName nový název
|
||||
* @param newSource nový zdroj; prázdný zdroj odebere
|
||||
*/
|
||||
export const renameMealGroup = async (
|
||||
login: string,
|
||||
name: unknown,
|
||||
source: unknown,
|
||||
newName: unknown,
|
||||
newSource: unknown,
|
||||
): Promise<FillSameNameResult> => {
|
||||
const groupName = normalizeText(name, 'name');
|
||||
if (!groupName) {
|
||||
throw new BadRequestError("Nebyl předán název jídla");
|
||||
}
|
||||
const targetName = normalizeText(newName, 'newName');
|
||||
if (!targetName) {
|
||||
throw new BadRequestError("Nebyl předán nový název jídla");
|
||||
}
|
||||
const targetSource = normalizeText(newSource, 'newSource');
|
||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||
const targetKey = getNameSourceKey(targetName, targetSource);
|
||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||
|
||||
const recipeKeyList = (await getMealTemplates(login)).map(getRecipeKey);
|
||||
if (key !== targetKey && recipeKeyList.includes(key) && recipeKeyList.includes(targetKey)) {
|
||||
throw new BadRequestError("Obě jídla mají recept, sloučit je nejde — jeden z receptů nejdřív smažte");
|
||||
}
|
||||
await renameMealTemplate(login, key, targetName, targetSource);
|
||||
await moveMealPhotos(login, key, targetKey);
|
||||
|
||||
const groupList = (await findAllMeals(login)).filter(isInGroup);
|
||||
const groupDates = [...new Set(groupList.map(entry => entry.date))].sort();
|
||||
const updatedAt = new Date().toISOString();
|
||||
let count = 0;
|
||||
const dates: string[] = [];
|
||||
for (const date of groupDates) {
|
||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||
if (!isInGroup(entry) || (entry.name === targetName && entry.source === targetSource)) return entry;
|
||||
count++;
|
||||
if (!dates.includes(date)) dates.push(date);
|
||||
const renamed: MealEntry = { ...entry, name: targetName, source: targetSource, updatedAt };
|
||||
if (!targetSource) delete renamed.source;
|
||||
return renamed;
|
||||
}));
|
||||
}
|
||||
// Knihovna kalorií je podle názvu, pod novým se má jídlo najít také
|
||||
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g);
|
||||
if (count && caloriesPer100g) {
|
||||
await rememberCalories(login, targetName, caloriesPer100g);
|
||||
}
|
||||
return { count, dates };
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealPhoto } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, NotFoundError } from "./utils";
|
||||
import { getNameSourceKey, normalizeText } from "./meals";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Kolik fotek může mít jedno jídlo — pojistka, aby úložiště nerostlo bez omezení. */
|
||||
const MAX_PHOTOS_PER_MEAL = 20;
|
||||
|
||||
/**
|
||||
* Největší přípustná velikost fotky a náhledu jako data URL. Klient fotku před
|
||||
* odesláním zmenší (zhruba 1600 px), takže běžně má pár set kB; limit je rezerva.
|
||||
*/
|
||||
const MAX_IMAGE_LENGTH = 3 * 1024 * 1024;
|
||||
const MAX_THUMBNAIL_LENGTH = 100 * 1024;
|
||||
|
||||
const DATA_URL_PATTERN = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+=*$/;
|
||||
|
||||
/** Fotka v rejstříku — bez plného obrázku, ten leží pod vlastním klíčem. */
|
||||
type PhotoInfo = {
|
||||
id: string;
|
||||
thumbnail: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** Rejstřík fotek uživatele: klíč jídla (název a zdroj) → fotky od nejstarší. */
|
||||
type PhotoIndex = Record<string, PhotoInfo[]>;
|
||||
|
||||
/**
|
||||
* Rejstřík s náhledy je jeden pro uživatele, aby ho seznam jídel načetl jedním
|
||||
* čtením. Plné obrázky jsou každý pod svým klíčem — do seznamu se nenačítají.
|
||||
*/
|
||||
const getIndexKey = (login: string): string => `mealPhotos:${login}`;
|
||||
const getPhotoKey = (login: string, id: string): string => `mealPhoto:${login}:${id}`;
|
||||
|
||||
/** Ověří obrázek v podobě data URL. */
|
||||
const normalizeImage = (value: unknown, name: string, maxLength: number): string => {
|
||||
if (typeof value !== 'string' || !DATA_URL_PATTERN.test(value)) {
|
||||
throw new BadRequestError(`Pole '${name}' musí být obrázek JPEG, PNG nebo WebP jako data URL`);
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new BadRequestError(`Obrázek v poli '${name}' je příliš velký`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Ověří název jídla a vrátí klíč, pod kterým má fotky. */
|
||||
const getMealKey = (name: unknown, source: unknown): string => {
|
||||
const mealName = normalizeText(name, 'name');
|
||||
if (!mealName) {
|
||||
throw new BadRequestError("Nebyl předán název jídla");
|
||||
}
|
||||
return getNameSourceKey(mealName, normalizeText(source, 'source'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí rejstřík fotek uživatele — náhledy podle klíče jídla.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getPhotoIndex = async (login: string): Promise<PhotoIndex> =>
|
||||
await storage.getData<PhotoIndex>(getIndexKey(login)) ?? {};
|
||||
|
||||
/**
|
||||
* Vrátí fotky jednoho jídla i s plnými obrázky, od nejstarší.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param name název jídla
|
||||
* @param source zdroj jídla; prázdný znamená jídlo bez zdroje
|
||||
*/
|
||||
export const getMealPhotos = async (login: string, name: unknown, source: unknown): Promise<MealPhoto[]> => {
|
||||
const infoList = (await getPhotoIndex(login))[getMealKey(name, source)] ?? [];
|
||||
const resultList: MealPhoto[] = [];
|
||||
for (const info of infoList) {
|
||||
const image = await storage.getData<string>(getPhotoKey(login, info.id));
|
||||
if (image) resultList.push({ ...info, image });
|
||||
}
|
||||
return resultList;
|
||||
};
|
||||
|
||||
/**
|
||||
* Přidá fotku k jídlu. Fotka patří jídlu jako celku (názvu a zdroji), ne
|
||||
* jednomu dni, ve kterém se jedlo.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param name název jídla
|
||||
* @param source zdroj jídla
|
||||
* @param image zmenšená fotka jako data URL
|
||||
* @param thumbnail náhled do seznamu jídel jako data URL
|
||||
*/
|
||||
export const addMealPhoto = async (
|
||||
login: string,
|
||||
name: unknown,
|
||||
source: unknown,
|
||||
image: unknown,
|
||||
thumbnail: unknown,
|
||||
): Promise<MealPhoto> => {
|
||||
const key = getMealKey(name, source);
|
||||
const result: MealPhoto = {
|
||||
id: randomUUID(),
|
||||
image: normalizeImage(image, 'image', MAX_IMAGE_LENGTH),
|
||||
thumbnail: normalizeImage(thumbnail, 'thumbnail', MAX_THUMBNAIL_LENGTH),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (((await getPhotoIndex(login))[key]?.length ?? 0) >= MAX_PHOTOS_PER_MEAL) {
|
||||
throw new BadRequestError(`Jídlo může mít nejvýše ${MAX_PHOTOS_PER_MEAL} fotek`);
|
||||
}
|
||||
// Obrázek se ukládá první, aby rejstřík nikdy neukazoval na fotku, která neexistuje
|
||||
await storage.setData(getPhotoKey(login, result.id), result.image);
|
||||
const info: PhotoInfo = { id: result.id, thumbnail: result.thumbnail, createdAt: result.createdAt };
|
||||
await storage.updateData<PhotoIndex>(getIndexKey(login), current =>
|
||||
({ ...current, [key]: [...(current?.[key] ?? []), info] }));
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže fotku jídla.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor fotky
|
||||
*/
|
||||
export const deleteMealPhoto = async (login: string, id: unknown): Promise<void> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor fotky");
|
||||
}
|
||||
let found = false;
|
||||
await storage.updateData<PhotoIndex>(getIndexKey(login), current => Object.fromEntries(
|
||||
Object.entries(current ?? {})
|
||||
.map(([key, infoList]): [string, PhotoInfo[]] => {
|
||||
const restList = infoList.filter(info => info.id !== id);
|
||||
found ||= restList.length !== infoList.length;
|
||||
return [key, restList];
|
||||
})
|
||||
.filter(([, infoList]) => infoList.length > 0)));
|
||||
if (!found) {
|
||||
throw new NotFoundError("Fotka nebyla nalezena");
|
||||
}
|
||||
await storage.deleteData(getPhotoKey(login, id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Přesune fotky jídla pod jiný klíč — při přejmenování. Fotky jídla, se kterým
|
||||
* se tím sloučí, zůstanou a přesouvané se přidají za ně.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
||||
* @param toKey nový klíč jídla
|
||||
*/
|
||||
export const moveMealPhotos = async (login: string, fromKey: string, toKey: string): Promise<void> => {
|
||||
if (fromKey === toKey) return;
|
||||
const index = await getPhotoIndex(login);
|
||||
if (!index[fromKey]?.length) return;
|
||||
await storage.updateData<PhotoIndex>(getIndexKey(login), current => {
|
||||
const { [fromKey]: movedList = [], ...rest } = current ?? {};
|
||||
return { ...rest, [toKey]: [...(rest[toKey] ?? []), ...movedList] };
|
||||
});
|
||||
};
|
||||
+39
-11
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addMeal, isMealType } from "./meals";
|
||||
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
||||
import { rememberCalories } from "./calories";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
|
||||
@@ -19,13 +19,14 @@ const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
|
||||
/**
|
||||
* 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) a recepty nesly
|
||||
* `kind: 'RECEPT'`.
|
||||
* 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'> & {
|
||||
ingredients?: RecipeIngredient[];
|
||||
kind?: string;
|
||||
pricePer100g?: number;
|
||||
mealType?: MealType;
|
||||
};
|
||||
|
||||
type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
||||
@@ -36,9 +37,9 @@ type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
||||
*/
|
||||
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
|
||||
|
||||
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel. */
|
||||
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod. */
|
||||
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
||||
const { kind, pricePer100g, ...result } = stored;
|
||||
const { kind, pricePer100g, mealType, ...result } = stored;
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -176,9 +177,15 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||
});
|
||||
};
|
||||
|
||||
/** 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);
|
||||
|
||||
/**
|
||||
* Založí nový recept, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* Recept rozšiřuje jídlo v seznamu jídel (název a zdroj), proto ho jedno jídlo
|
||||
* může mít nejvýš jeden.
|
||||
*
|
||||
* 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é.
|
||||
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
|
||||
@@ -192,9 +199,6 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
if (!name) {
|
||||
throw new BadRequestError("Nebyl předán název receptu");
|
||||
}
|
||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||
}
|
||||
@@ -209,7 +213,6 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
const template: MealTemplate = definedOnly({
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
name,
|
||||
mealType: value.mealType != null ? value.mealType as MealType : undefined,
|
||||
source: normalizeText(value.source, 'source'),
|
||||
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||
ingredients,
|
||||
@@ -221,6 +224,11 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
calories: summary.calories,
|
||||
price: summary.price,
|
||||
});
|
||||
const otherRecipe = (await getMealTemplates(login))
|
||||
.find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template));
|
||||
if (otherRecipe) {
|
||||
throw new BadRequestError("Toto jídlo už recept má");
|
||||
}
|
||||
for (const ingredient of ingredients) {
|
||||
if (ingredient.caloriesPer100g) {
|
||||
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
||||
@@ -266,7 +274,7 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise<Me
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor receptu
|
||||
* @param date den, do kterého se jídlo založí
|
||||
* @param mealType chod; bez něj se použije chod receptu, jinak oběd
|
||||
* @param mealType chod; bez něj se založí jako oběd
|
||||
* @param multiplier násobek porce, třeba 2 za dvě porce
|
||||
*/
|
||||
export const applyMealTemplate = async (
|
||||
@@ -292,7 +300,7 @@ export const applyMealTemplate = async (
|
||||
|
||||
await addMeal(login, {
|
||||
date,
|
||||
mealType: mealType ?? template.mealType ?? MealType.OBED,
|
||||
mealType: mealType ?? MealType.OBED,
|
||||
name: template.name,
|
||||
source: template.source,
|
||||
weight: template.weight,
|
||||
@@ -303,3 +311,23 @@ export const applyMealTemplate = async (
|
||||
}, template.id);
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
||||
* @param name nový název
|
||||
* @param source nový zdroj; prázdný zdroj odebere
|
||||
*/
|
||||
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 });
|
||||
await storage.updateData<StoredMealTemplate[]>(getKey(login), current =>
|
||||
loadRecipes(current).map(item => item.id === recipe.id ? renamed : item));
|
||||
};
|
||||
+6
-232
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ export function isMealType(value: unknown): value is MealType {
|
||||
}
|
||||
|
||||
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
|
||||
function getDayKey(login: string, date: string): string {
|
||||
export function getDayKey(login: string, date: string): string {
|
||||
return `meals:${login}:${date}`;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function getRange(login: string, from: string, to: string): Promise
|
||||
}
|
||||
|
||||
/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */
|
||||
function normalizeText(value: unknown, name: string): string | undefined {
|
||||
export function normalizeText(value: unknown, name: string): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestError(`Pole '${name}' musí být text`);
|
||||
@@ -103,7 +103,7 @@ function normalizeText(value: unknown, name: string): string | undefined {
|
||||
}
|
||||
|
||||
/** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */
|
||||
function normalizeNumber(value: unknown, name: string): number | undefined {
|
||||
export function normalizeNumber(value: unknown, name: string): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||
@@ -444,7 +444,7 @@ export async function findMealsForDays(login: string, dates: string[]): Promise<
|
||||
* Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se
|
||||
* neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g.
|
||||
*/
|
||||
function getManualWeight(source: MealEntry): number | undefined {
|
||||
export function getManualWeight(source: MealEntry): number | undefined {
|
||||
const weightFromPrice = source.price != null && source.pricePer100g
|
||||
? Math.round(source.price / source.pricePer100g * 100)
|
||||
: undefined;
|
||||
@@ -476,7 +476,7 @@ function createMealSuggestion(source: MealEntry): MealSuggestion {
|
||||
}
|
||||
|
||||
/** Klíč jídla podle názvu a zdroje — jídlo stejného jména jinde bývá jinak velké i jinak účtované. */
|
||||
const getNameSourceKey = (name: string, source?: string): string =>
|
||||
export const getNameSourceKey = (name: string, source?: string): string =>
|
||||
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
|
||||
|
||||
/**
|
||||
@@ -645,229 +645,3 @@ export async function fillSameNameMeals(
|
||||
}
|
||||
return { count: targetList.length, dates };
|
||||
}
|
||||
|
||||
/** Vrátí první vyplněnou hodnotu z jídel seřazených od nejnovějších. */
|
||||
const findLatestValue = <T>(entryList: MealEntry[], pick: (entry: MealEntry) => T | undefined): T | undefined =>
|
||||
entryList.map(pick).find(value => value != null);
|
||||
|
||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nezapisovaly. */
|
||||
const omitUndefined = <T extends object>(value: T): T =>
|
||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
||||
|
||||
/**
|
||||
* Sestaví hodnoty k předvyplnění formuláře skupiny — každou z nejnovějšího
|
||||
* jídla, které ji má. Energie a živiny se bez vlastních hodnot vezmou z jídla
|
||||
* stejného názvu odjinud (složení je stejné), sazba za 100 g a gramáž ne —
|
||||
* jinde se účtuje i nandává jinak.
|
||||
*/
|
||||
const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]): MealGroupValues => {
|
||||
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g)
|
||||
?? findLatestValue(sameNameList, entry => entry.caloriesPer100g);
|
||||
return omitUndefined({
|
||||
pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g),
|
||||
weight: findLatestValue(groupList, getManualWeight),
|
||||
caloriesPer100g,
|
||||
// Ruční kalorie porce dávají smysl jen u jídla, které energii na 100 g nemá
|
||||
calories: caloriesPer100g == null
|
||||
? findLatestValue(groupList, entry => entry.calories)
|
||||
: undefined,
|
||||
nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g)
|
||||
?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g),
|
||||
});
|
||||
};
|
||||
|
||||
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
||||
const isIncomplete = (entry: MealEntry): boolean =>
|
||||
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
||||
|
||||
/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */
|
||||
const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
||||
const dayMap = new Map<string, MealGroupDay>();
|
||||
for (const entry of groupList) {
|
||||
const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false };
|
||||
day.incomplete ||= isIncomplete(entry);
|
||||
dayMap.set(entry.date, day);
|
||||
}
|
||||
return [...dayMap.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
||||
* kterým chybí kalorie, gramáž nebo živiny — seznam jídel s hromadným
|
||||
* doplněním údajů a přejmenováním.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
const entryList = await findAllMeals(login);
|
||||
const groupMap = new Map<string, MealEntry[]>();
|
||||
const nameMap = new Map<string, MealEntry[]>();
|
||||
for (const entry of entryList) {
|
||||
const key = getNameSourceKey(entry.name, entry.source);
|
||||
groupMap.set(key, [...(groupMap.get(key) ?? []), entry]);
|
||||
const nameKey = normalizeName(entry.name);
|
||||
nameMap.set(nameKey, [...(nameMap.get(nameKey) ?? []), entry]);
|
||||
}
|
||||
|
||||
const resultList = [...groupMap.values()].map((groupList): MealGroup => {
|
||||
const [latest] = groupList;
|
||||
return {
|
||||
name: latest.name,
|
||||
...(latest.source ? { source: latest.source } : {}),
|
||||
count: groupList.length,
|
||||
lastDate: latest.date,
|
||||
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
||||
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
||||
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
||||
days: createMealGroupDays(groupList),
|
||||
values: createMealGroupValues(groupList, nameMap.get(normalizeName(latest.name)) ?? []),
|
||||
};
|
||||
});
|
||||
return resultList.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
||||
};
|
||||
|
||||
/** Ověří hodnoty k doplnění do skupiny jídel. Nulová sazba nebo gramáž nic neříká, bere se jako nezadaná. */
|
||||
const normalizeGroupValues = (input: unknown): MealGroupValues => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') || undefined,
|
||||
weight: normalizeNumber(value.weight, 'weight') || undefined,
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') || undefined,
|
||||
calories: normalizeNumber(value.calories, 'calories'),
|
||||
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní do jídla hodnoty, které mu chybí; co už má, nechá být.
|
||||
*
|
||||
* Gramáž se nedoplní tam, kde jde dopočítat z vlastní ceny jídla — ta je
|
||||
* přesnější než jedna gramáž pro všechny. Energie se doplní jen jídlu bez
|
||||
* kalorií, jinak by dopočet z gramáže přepsal kalorie zadané ručně. Živiny
|
||||
* se doplňují po jednotlivých položkách.
|
||||
*/
|
||||
const fillMissingValues = (entry: MealEntry, values: MealGroupValues): MealEntry => {
|
||||
const pricePer100g = entry.pricePer100g ?? values.pricePer100g;
|
||||
const weightDerivable = entry.price != null && !!pricePer100g;
|
||||
const caloriesMissing = entry.calories == null;
|
||||
const derived = deriveAmounts({
|
||||
...entry,
|
||||
pricePer100g,
|
||||
weight: entry.weight ?? (weightDerivable ? undefined : values.weight),
|
||||
caloriesPer100g: entry.caloriesPer100g ?? (caloriesMissing ? values.caloriesPer100g : undefined),
|
||||
calories: caloriesMissing ? values.calories : entry.calories,
|
||||
});
|
||||
return omitUndefined({
|
||||
...entry,
|
||||
pricePer100g,
|
||||
weight: derived.weight,
|
||||
caloriesPer100g: derived.caloriesPer100g,
|
||||
calories: caloriesMissing ? derived.calories : entry.calories,
|
||||
nutrientsPer100g: fillMissingNutrients(entry.nutrientsPer100g, values.nutrientsPer100g),
|
||||
});
|
||||
};
|
||||
|
||||
/** Doplní chybějící živiny; vyplněné zůstanou a na svém místě, aby šlo poznat, že se nic nezměnilo. */
|
||||
const fillMissingNutrients = (current?: Nutrients, values?: Nutrients): Nutrients | undefined => {
|
||||
if (!values) return current;
|
||||
const missing = Object.entries(values).filter(([key]) => current?.[key as keyof Nutrients] == null);
|
||||
return missing.length ? { ...current, ...Object.fromEntries(missing) } : current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem — typicky
|
||||
* ke starším záznamům, u kterých se kalorie nebo živiny nezadávaly.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param name název jídla skupiny
|
||||
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||
* @param input hodnoty k doplnění
|
||||
*/
|
||||
export const fillMealGroup = async (
|
||||
login: string,
|
||||
name: unknown,
|
||||
source: unknown,
|
||||
input: unknown,
|
||||
): Promise<FillSameNameResult> => {
|
||||
const groupName = normalizeText(name, 'name');
|
||||
if (!groupName) {
|
||||
throw new BadRequestError("Nebyl předán název jídla");
|
||||
}
|
||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||
const values = normalizeGroupValues(input);
|
||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||
|
||||
const groupDates = [...new Set((await findAllMeals(login)).filter(isInGroup).map(entry => entry.date))].sort();
|
||||
const updatedAt = new Date().toISOString();
|
||||
let count = 0;
|
||||
const dates: string[] = [];
|
||||
for (const date of groupDates) {
|
||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||
if (!isInGroup(entry)) return entry;
|
||||
const filled = fillMissingValues(entry, values);
|
||||
if (JSON.stringify(filled) === JSON.stringify(entry)) return entry;
|
||||
count++;
|
||||
if (!dates.includes(date)) dates.push(date);
|
||||
return { ...filled, updatedAt };
|
||||
}));
|
||||
}
|
||||
if (values.caloriesPer100g) {
|
||||
await rememberCalories(login, groupName, values.caloriesPer100g);
|
||||
}
|
||||
return { count, dates };
|
||||
};
|
||||
|
||||
/**
|
||||
* Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní
|
||||
* zdroj — oprava překlepu nebo sjednocení názvu napříč všemi dny, kdy se jídlo
|
||||
* jedlo. Na rozdíl od doplnění údajů přepisuje, co jídla mají.
|
||||
*
|
||||
* Když nový název a zdroj patří jiné skupině, jídla se do ní tím sloučí.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param name název jídla skupiny
|
||||
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||
* @param newName nový název
|
||||
* @param newSource nový zdroj; prázdný zdroj odebere
|
||||
*/
|
||||
export const renameMealGroup = async (
|
||||
login: string,
|
||||
name: unknown,
|
||||
source: unknown,
|
||||
newName: unknown,
|
||||
newSource: unknown,
|
||||
): Promise<FillSameNameResult> => {
|
||||
const groupName = normalizeText(name, 'name');
|
||||
if (!groupName) {
|
||||
throw new BadRequestError("Nebyl předán název jídla");
|
||||
}
|
||||
const targetName = normalizeText(newName, 'newName');
|
||||
if (!targetName) {
|
||||
throw new BadRequestError("Nebyl předán nový název jídla");
|
||||
}
|
||||
const targetSource = normalizeText(newSource, 'newSource');
|
||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||
|
||||
const groupList = (await findAllMeals(login)).filter(isInGroup);
|
||||
const groupDates = [...new Set(groupList.map(entry => entry.date))].sort();
|
||||
const updatedAt = new Date().toISOString();
|
||||
let count = 0;
|
||||
const dates: string[] = [];
|
||||
for (const date of groupDates) {
|
||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||
if (!isInGroup(entry) || (entry.name === targetName && entry.source === targetSource)) return entry;
|
||||
count++;
|
||||
if (!dates.includes(date)) dates.push(date);
|
||||
const renamed: MealEntry = { ...entry, name: targetName, source: targetSource, updatedAt };
|
||||
if (!targetSource) delete renamed.source;
|
||||
return renamed;
|
||||
}));
|
||||
}
|
||||
// Knihovna kalorií je podle názvu, pod novým se má jídlo najít také
|
||||
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g);
|
||||
if (count && caloriesPer100g) {
|
||||
await rememberCalories(login, targetName, caloriesPer100g);
|
||||
}
|
||||
return { count, dates };
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
||||
getRange, renameMealGroup, setMealMultiplier, splitMeal, updateMeal,
|
||||
addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
|
||||
splitMeal, updateMeal,
|
||||
} from "../meals";
|
||||
import { fillMealGroup, getMealGroups, renameMealGroup } from "../mealGroups";
|
||||
import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||
@@ -85,6 +87,32 @@ router.post("/renameGroup", async (req: Request, res: Response, next: NextFuncti
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí fotky jídla (názvu a zdroje) i s plnými obrázky. */
|
||||
router.get("/photos", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getMealPhotos(login, req.query.name, req.query.source));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Přidá fotku k jídlu. */
|
||||
router.post("/photos/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { name, source, image, thumbnail } = req.body ?? {};
|
||||
res.status(200).json(await addMealPhoto(login, name, source, image, thumbnail));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Smaže fotku jídla. */
|
||||
router.post("/photos/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
await deleteMealPhoto(login, req.body?.id);
|
||||
res.status(204).end();
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Přidá jídlo do zvoleného dne. */
|
||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface StorageInterface {
|
||||
|
||||
setData<Type>(key: string, data: Type): Promise<void>;
|
||||
|
||||
/** Odstraní klíč i s hodnotou; neexistující klíč nevadí. */
|
||||
deleteData(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec.
|
||||
* @param contains volitelný podřetězec, který musí klíč obsahovat
|
||||
|
||||
@@ -30,6 +30,11 @@ export default class JsonStorage implements StorageInterface {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
deleteData(key: string): Promise<void> {
|
||||
db.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
listKeys(contains?: string): Promise<string[]> {
|
||||
const keys = Object.keys(db.JSON());
|
||||
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||
|
||||
@@ -25,6 +25,11 @@ export default class MemoryStorage implements StorageInterface {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
deleteData(key: string): Promise<void> {
|
||||
store.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
listKeys(contains?: string): Promise<string[]> {
|
||||
const keys = Array.from(store.keys());
|
||||
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||
|
||||
@@ -54,6 +54,10 @@ export default class RedisStorage implements StorageInterface {
|
||||
await this.getClient().set(key, JSON.stringify(data));
|
||||
}
|
||||
|
||||
async deleteData(key: string): Promise<void> {
|
||||
await this.getClient().del(key);
|
||||
}
|
||||
|
||||
async listKeys(contains?: string): Promise<string[]> {
|
||||
const keys: string[] = [];
|
||||
for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) {
|
||||
|
||||
@@ -195,10 +195,9 @@ describe('vazba jídla na recept', () => {
|
||||
it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, {
|
||||
name: 'Štrůdl',
|
||||
mealType: MealType.PULNOCNI_SVACINA,
|
||||
ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }],
|
||||
});
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
await applyMealTemplate(LOGIN, template.id, DATE, MealType.PULNOCNI_SVACINA);
|
||||
|
||||
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
||||
expect(meal.templateId).toBe(template.id);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal } from "../meals";
|
||||
import { getMealGroups, renameMealGroup } from "../mealGroups";
|
||||
import { getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||
import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
const IMAGE = 'data:image/jpeg;base64,AAAA';
|
||||
const THUMBNAIL = 'data:image/jpeg;base64,BBBB';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
const rizoto = {
|
||||
name: 'Rizoto', source: 'doma', servings: 2, cookedWeight: 800,
|
||||
ingredients: [{ name: 'Rýže', weight: 200, caloriesPer100g: 350 }, { name: 'Kuře', weight: 300, calories: 400 }],
|
||||
};
|
||||
|
||||
describe('recept jídla v seznamu jídel', () => {
|
||||
|
||||
it('patří ke skupině se stejným názvem a zdrojem a předvyplní z něj porci', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.VECERE, name: 'rizoto', source: 'Doma' });
|
||||
await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
const [group] = await getMealGroups(LOGIN);
|
||||
|
||||
// (700 + 400) kcal na 800 g, porce je polovina
|
||||
expect(group).toMatchObject({
|
||||
count: 1, recipe: { name: 'Rizoto' }, values: { weight: 400, caloriesPer100g: 138 },
|
||||
});
|
||||
});
|
||||
|
||||
it('recept jídla, které se ještě nejedlo, je v seznamu sám', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Guláš' });
|
||||
await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
const groups = await getMealGroups(LOGIN);
|
||||
|
||||
expect(groups.map(group => `${group.name} ${group.count}`)).toEqual(['Guláš 1', 'Rizoto 0']);
|
||||
expect(groups[1].lastDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('druhý recept téhož jídla nejde založit', async () => {
|
||||
await saveMealTemplate(LOGIN, rizoto);
|
||||
|
||||
await expect(saveMealTemplate(LOGIN, { ...rizoto, name: 'RIZOTO' })).rejects.toThrow(/už recept má/);
|
||||
});
|
||||
|
||||
it('přejmenování vezme recept i fotky s sebou', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.VECERE, ...rizoto });
|
||||
await saveMealTemplate(LOGIN, rizoto);
|
||||
await addMealPhoto(LOGIN, 'Rizoto', 'doma', IMAGE, THUMBNAIL);
|
||||
|
||||
await renameMealGroup(LOGIN, 'Rizoto', 'doma', 'Houbové rizoto', undefined);
|
||||
|
||||
const [recipe] = await getMealTemplates(LOGIN);
|
||||
expect(recipe.name).toBe('Houbové rizoto');
|
||||
expect(recipe).not.toHaveProperty('source');
|
||||
expect(await getMealPhotos(LOGIN, 'Houbové rizoto', undefined)).toHaveLength(1);
|
||||
expect(await getMealPhotos(LOGIN, 'Rizoto', 'doma')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('dvě jídla s recepty přejmenováním nesloučí', async () => {
|
||||
await saveMealTemplate(LOGIN, rizoto);
|
||||
await saveMealTemplate(LOGIN, { ...rizoto, name: 'Rizoto 2' });
|
||||
|
||||
await expect(renameMealGroup(LOGIN, 'Rizoto 2', 'doma', 'Rizoto', 'doma')).rejects.toThrow(/Obě jídla/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fotky jídla', () => {
|
||||
|
||||
it('patří jídlu podle názvu a zdroje a skupina nese počet a náhled', async () => {
|
||||
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Gyros', source: 'Everest' });
|
||||
await addMeal(LOGIN, { date: '2025-03-02', mealType: MealType.OBED, name: 'Gyros', source: 'Namaste' });
|
||||
await addMealPhoto(LOGIN, 'gyros', 'everest', IMAGE, THUMBNAIL);
|
||||
|
||||
const groups = await getMealGroups(LOGIN);
|
||||
|
||||
expect(groups.find(group => group.source === 'Everest')).toMatchObject({ photoCount: 1, thumbnail: THUMBNAIL });
|
||||
expect(groups.find(group => group.source === 'Namaste')).toMatchObject({ photoCount: 0 });
|
||||
expect(await getMealPhotos(LOGIN, 'Gyros', 'Everest')).toMatchObject([{ image: IMAGE }]);
|
||||
});
|
||||
|
||||
it('smazaná fotka zmizí z jídla', async () => {
|
||||
const photo = await addMealPhoto(LOGIN, 'Gyros', undefined, IMAGE, THUMBNAIL);
|
||||
|
||||
await deleteMealPhoto(LOGIN, photo.id);
|
||||
|
||||
expect(await getMealPhotos(LOGIN, 'Gyros', undefined)).toEqual([]);
|
||||
await expect(deleteMealPhoto(LOGIN, photo.id)).rejects.toThrow(/nebyla nalezena/);
|
||||
});
|
||||
|
||||
it('odmítne něco jiného než obrázek', async () => {
|
||||
await expect(addMealPhoto(LOGIN, 'Gyros', undefined, 'data:text/html;base64,AAAA', THUMBNAIL))
|
||||
.rejects.toThrow(/obrázek/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal, fillMealGroup, getDay, getMealGroups, renameMealGroup } from "../meals";
|
||||
import { addMeal, getDay } from "../meals";
|
||||
import { fillMealGroup, getMealGroups, renameMealGroup } from "../mealGroups";
|
||||
import { searchCalories } from "../calories";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
@@ -160,16 +160,15 @@ describe('recepty', () => {
|
||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||
|
||||
const [entry] = (await getDay(LOGIN, DATE)).entries;
|
||||
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 });
|
||||
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.OBED, weight: 500, calories: 515 });
|
||||
expect(entry.note).toBeUndefined();
|
||||
});
|
||||
|
||||
it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => {
|
||||
it('recept nemá chod — založí se pod zvoleným, bez volby jako oběd', async () => {
|
||||
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||
const [bezChodu] = await saveMealTemplate(LOGIN, { ...rizoto, name: 'Bez chodu', mealType: undefined });
|
||||
|
||||
await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE);
|
||||
await applyMealTemplate(LOGIN, bezChodu.id, '2025-03-04');
|
||||
await applyMealTemplate(LOGIN, template.id, '2025-03-04');
|
||||
|
||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE);
|
||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED);
|
||||
|
||||
Reference in new issue
Block a user