feat: zlepšení šablon, přidány osobní údaje a spánkový režim
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealTemplate, MealType } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { addMeal, getDay, isMealType } from "./meals";
|
||||
import { normalizeName } from "./calories";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
/** Maximální délka textových polí. Poznámka je delší, aby se do ní vešel postup. */
|
||||
const MAX_NAME_LENGTH = 200;
|
||||
const MAX_NOTE_LENGTH = 4000;
|
||||
|
||||
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
|
||||
/**
|
||||
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
|
||||
/** Ořízne a znormalizuje volitelný textový vstup. */
|
||||
const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestError(`Pole '${name}' musí být text`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.length) return undefined;
|
||||
if (trimmed.length > maxLength) {
|
||||
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${maxLength} znaků`);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
/** Ověří volitelnou nezápornou celočíselnou hodnotu. */
|
||||
const 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`);
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) {
|
||||
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
|
||||
}
|
||||
return rounded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param input data šablony
|
||||
*/
|
||||
export const saveMealTemplate = async (login: string, input: unknown): Promise<MealTemplate[]> => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
const name = normalizeText(value.name, 'name');
|
||||
if (!name) {
|
||||
throw new BadRequestError("Nebyl předán název šablony");
|
||||
}
|
||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
|
||||
const template: MealTemplate = {
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
name,
|
||||
...(value.mealType != null ? { mealType: value.mealType as MealType } : {}),
|
||||
...(normalizeText(value.source, 'source') != null
|
||||
? { source: normalizeText(value.source, 'source') } : {}),
|
||||
...(normalizeText(value.note, 'note', MAX_NOTE_LENGTH) != null
|
||||
? { note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH) } : {}),
|
||||
...(normalizeNumber(value.calories, 'calories') != null
|
||||
? { calories: normalizeNumber(value.calories, 'calories') } : {}),
|
||||
...(normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') != null
|
||||
? { caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') } : {}),
|
||||
...(normalizeNumber(value.weight, 'weight') != null
|
||||
? { weight: normalizeNumber(value.weight, 'weight') } : {}),
|
||||
...(normalizeNumber(value.price, 'price') != null
|
||||
? { price: normalizeNumber(value.price, 'price') } : {}),
|
||||
...(normalizeNumber(value.pricePer100g, 'pricePer100g') != null
|
||||
? { pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') } : {}),
|
||||
};
|
||||
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(item => item.id !== template.id);
|
||||
return [...others, template];
|
||||
});
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Smaže šablonu jídla.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
*/
|
||||
export const deleteMealTemplate = async (login: string, id: unknown): Promise<MealTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
}
|
||||
let found = false;
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
found = (current ?? []).some(item => item.id === id);
|
||||
return (current ?? []).filter(item => item.id !== id);
|
||||
});
|
||||
if (!found) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
}
|
||||
return getMealTemplates(login);
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí do zvoleného dne jídlo ze šablony.
|
||||
*
|
||||
* Vznikne z ní běžný záznam jídla, takže jde pak upravit nebo smazat, aniž by
|
||||
* to ovlivnilo šablonu. Gramáž, cenu a kalorie dopočítá `addMeal` stejně jako
|
||||
* u ručně zadaného jídla.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor šablony
|
||||
* @param date den, do kterého se jídlo založí
|
||||
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
|
||||
*/
|
||||
export const applyMealTemplate = async (
|
||||
login: string,
|
||||
id: unknown,
|
||||
date: unknown,
|
||||
mealType?: unknown,
|
||||
): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (mealType != null && !isMealType(mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
const template = (await getMealTemplates(login)).find(item => item.id === id);
|
||||
if (!template) {
|
||||
throw new NotFoundError("Šablona nebyla nalezena");
|
||||
}
|
||||
|
||||
await addMeal(login, {
|
||||
date,
|
||||
mealType: mealType ?? template.mealType ?? MealType.OBED,
|
||||
name: template.name,
|
||||
source: template.source,
|
||||
weight: template.weight,
|
||||
price: template.price,
|
||||
pricePer100g: template.pricePer100g,
|
||||
calories: template.calories,
|
||||
caloriesPer100g: template.caloriesPer100g,
|
||||
note: template.note,
|
||||
});
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vytvoří šablonu jídla z existujícího záznamu v přehledu dne.
|
||||
*
|
||||
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
|
||||
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den, ve kterém jídlo je
|
||||
* @param id identifikátor jídla
|
||||
*/
|
||||
export const createMealTemplateFromMeal = async (
|
||||
login: string,
|
||||
date: unknown,
|
||||
id: unknown,
|
||||
): Promise<MealTemplate[]> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor jídla");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
const meal = (await getDay(login, date)).entries.find(entry => entry.id === id);
|
||||
if (!meal) {
|
||||
throw new NotFoundError("Jídlo nebylo nalezeno");
|
||||
}
|
||||
|
||||
const key = normalizeName(meal.name);
|
||||
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
||||
|
||||
return saveMealTemplate(login, {
|
||||
...(existing ? { id: existing.id } : {}),
|
||||
name: meal.name,
|
||||
source: meal.source,
|
||||
mealType: meal.mealType,
|
||||
weight: meal.weight,
|
||||
price: meal.price,
|
||||
pricePer100g: meal.pricePer100g,
|
||||
// Absolutní kalorie ukládáme jen tam, kde je nejde dopočítat z gramáže
|
||||
calories: meal.caloriesPer100g ? undefined : meal.calories,
|
||||
caloriesPer100g: meal.caloriesPer100g,
|
||||
note: meal.note,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user