feat: import jen některých dnů
feat: násobení jídel/cviků feat: refaktor napojování šablon
This commit is contained in:
1 parent
c3990d80f8
commit
8dac67bdd1
37 files changed
+2314
-481
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
||||
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -33,7 +33,7 @@ export function buildActivityDay(date: string, entries: ActivityEntry[]): Activi
|
||||
return {
|
||||
date,
|
||||
entries: sorted,
|
||||
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
|
||||
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ export type NormalizedActivityInput = {
|
||||
caloriesPer100Units?: number;
|
||||
calories?: number;
|
||||
note?: string;
|
||||
/** Dodatečná zátěž v gramech — jen informace, kalorie nemění. */
|
||||
extraWeightGrams?: number;
|
||||
multiplier?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -140,6 +143,8 @@ export function normalizeActivityInput(input: unknown): NormalizedActivityInput
|
||||
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
|
||||
calories: normalizeNumber(value.calories, 'calories'),
|
||||
note: normalizeText(value.note, 'note'),
|
||||
extraWeightGrams: normalizeNumber(value.extraWeightGrams, 'extraWeightGrams') || undefined,
|
||||
multiplier: normalizeMultiplier(value.multiplier),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,6 +219,8 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
||||
caloriesPer100Units: normalized.caloriesPer100Units,
|
||||
calories: normalized.calories,
|
||||
note: normalized.note,
|
||||
extraWeightGrams: normalized.extraWeightGrams,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await storage.updateData<ActivityEntry[]>(
|
||||
@@ -248,6 +255,40 @@ export async function deleteActivity(login: string, id: unknown, date: unknown):
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastaví násobek aktivity přímo z přehledu dne. Ostatní údaje zůstanou beze změny.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor záznamu
|
||||
* @param date datum záznamu (YYYY-MM-DD)
|
||||
* @param multiplier nový násobek; 1 násobek zruší
|
||||
* @returns datum, ve kterém aktivita je
|
||||
*/
|
||||
export async function setActivityMultiplier(login: string, id: unknown, date: unknown, multiplier: unknown): Promise<string> {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor záznamu");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (multiplier == null) {
|
||||
throw new BadRequestError("Nebyl předán násobek");
|
||||
}
|
||||
const normalized = normalizeMultiplier(multiplier);
|
||||
let found = false;
|
||||
await storage.updateData<ActivityEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||
if (entry.id !== id) return entry;
|
||||
found = true;
|
||||
const updated: ActivityEntry = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() };
|
||||
if (normalized == null) delete updated.multiplier;
|
||||
return updated;
|
||||
}));
|
||||
if (!found) {
|
||||
throw new NotFoundError("Aktivita nebyla nalezena");
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Najde datum, pod kterým je aktivita uložená. Úprava totiž může měnit i datum. */
|
||||
async function findActivityDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
|
||||
const expected = await storage.getData<ActivityEntry[]>(getDayKey(login, expectedDate));
|
||||
|
||||
+51
-12
@@ -1,7 +1,7 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||
import { addEntries, getExistingImportKeys, isMealType } from "./meals";
|
||||
import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||
import { addEntries, findMealsForDays, isMealType } from "./meals";
|
||||
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||
|
||||
/** Označení zdroje, kterým se importované záznamy značí. */
|
||||
@@ -363,18 +363,30 @@ function toMealEntry(row: LuncherRow, mealType: MealType, importKey: string): Me
|
||||
};
|
||||
}
|
||||
|
||||
/** Vrátí true, pokud je hodnota podporovaným režimem importu. */
|
||||
const isImportMode = (value: unknown): value is ImportMode =>
|
||||
value === ImportMode.PRIDAT || value === ImportMode.NAHRADIT;
|
||||
|
||||
/**
|
||||
* Naimportuje přehled vyexportovaný z Luncheru do jídel uživatele.
|
||||
*
|
||||
* Export je typicky za celý měsíc — záznamy se rozdělí do dnů, ke kterým patří.
|
||||
* Řádky, které už z dřívějšího importu existují, se přeskočí, takže opakovaný
|
||||
* import stejného měsíce data nezduplikuje.
|
||||
* V režimu PRIDAT se řádky, které už z dřívějšího importu existují, přeskočí,
|
||||
* takže opakovaný import stejného měsíce data nezduplikuje. Režim NAHRADIT
|
||||
* dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se
|
||||
* v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech.
|
||||
*
|
||||
* Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už
|
||||
* v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý
|
||||
* import pak dostane jen vybrané klíče.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param fileName název souboru (určuje formát)
|
||||
* @param content obsah souboru
|
||||
* @param defaultMealType typ jídla pro importované řádky, výchozí je oběd
|
||||
* @param dryRun pokud true, výsledek se jen spočítá a nic se neuloží
|
||||
* @param mode režim importu, výchozí je PRIDAT
|
||||
* @param selectedKeys klíče řádků k založení; bez nich se založí všechny
|
||||
*/
|
||||
export async function importLuncherExport(
|
||||
login: string,
|
||||
@@ -382,13 +394,23 @@ export async function importLuncherExport(
|
||||
content: Buffer,
|
||||
defaultMealType?: unknown,
|
||||
dryRun = false,
|
||||
mode?: unknown,
|
||||
selectedKeys?: unknown,
|
||||
): Promise<ImportResult> {
|
||||
const format = detectFormat(fileName);
|
||||
if (defaultMealType != null && !isMealType(defaultMealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
if (mode != null && !isImportMode(mode)) {
|
||||
throw new BadRequestError("Nebyl předán platný režim importu");
|
||||
}
|
||||
if (selectedKeys != null && !Array.isArray(selectedKeys)) {
|
||||
throw new BadRequestError("Vybrané řádky musí být pole klíčů");
|
||||
}
|
||||
// Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd
|
||||
const mealType: MealType = isMealType(defaultMealType) ? defaultMealType : MealType.OBED;
|
||||
const resolvedMode: ImportMode = isImportMode(mode) ? mode : ImportMode.PRIDAT;
|
||||
const replace = resolvedMode === ImportMode.NAHRADIT;
|
||||
|
||||
let parsed: ParsedFile;
|
||||
if (format === 'csv') {
|
||||
@@ -400,10 +422,14 @@ export async function importLuncherExport(
|
||||
}
|
||||
|
||||
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
|
||||
const existingKeys = await getExistingImportKeys(login, dates);
|
||||
const existing = await findMealsForDays(login, dates);
|
||||
// Při nahrazení se dřívější import založí znovu, duplicitou tedy není
|
||||
const existingKeys = new Set(replace
|
||||
? []
|
||||
: existing.map(entry => entry.importKey).filter(key => !!key));
|
||||
|
||||
const perDayIndex = new Map<string, number>();
|
||||
const entries: MealEntry[] = [];
|
||||
const candidates: MealEntry[] = [];
|
||||
let skipped = 0;
|
||||
for (const row of parsed.rows) {
|
||||
const indexWithinDay = perDayIndex.get(row.date) ?? 0;
|
||||
@@ -413,22 +439,35 @@ export async function importLuncherExport(
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
entries.push(toMealEntry(row, mealType, importKey));
|
||||
candidates.push(toMealEntry(row, mealType, importKey));
|
||||
}
|
||||
|
||||
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
|
||||
const selected = candidates.filter(entry => !selectedKeyList || selectedKeyList.has(entry.importKey!));
|
||||
skipped += candidates.length - selected.length;
|
||||
|
||||
// Nahrazuje se jen ve dnech, do kterých se opravdu něco zakládá
|
||||
const selectedDays = new Set(selected.map(entry => entry.date));
|
||||
const replaced = replace
|
||||
? existing.filter(entry => entry.importSource === IMPORT_SOURCE && selectedDays.has(entry.date)).length
|
||||
: 0;
|
||||
|
||||
const days = dryRun
|
||||
? Array.from(new Set(entries.map(entry => entry.date))).sort()
|
||||
: await addEntries(login, entries);
|
||||
? Array.from(selectedDays).sort()
|
||||
: await addEntries(login, selected, replace ? IMPORT_SOURCE : undefined);
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
format,
|
||||
mode: resolvedMode,
|
||||
rowCount: parsed.rows.length,
|
||||
imported: entries.length,
|
||||
imported: selected.length,
|
||||
skipped,
|
||||
replaced,
|
||||
days,
|
||||
totalPrice: entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||
entries,
|
||||
totalPrice: selected.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||
entries: dryRun ? candidates : selected,
|
||||
existing,
|
||||
warnings: parsed.warnings,
|
||||
};
|
||||
}
|
||||
+176
-20
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MealTemplate, MealType } from "../../types/gen/types.gen";
|
||||
import { MealTemplate, MealTemplateKind, MealType, RecipeIngredient } 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";
|
||||
import { normalizeName, rememberCalories } from "./calories";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -11,9 +11,24 @@ const storage = getStorage();
|
||||
const MAX_NAME_LENGTH = 200;
|
||||
const MAX_NOTE_LENGTH = 4000;
|
||||
|
||||
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
||||
const MAX_INGREDIENTS = 50;
|
||||
|
||||
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
|
||||
/**
|
||||
* Doplní šabloně druh, pokud chybí.
|
||||
*
|
||||
* Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou
|
||||
* ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic
|
||||
* nepřepisuje, druh se doplní jen při čtení.
|
||||
*/
|
||||
const withKind = (template: MealTemplate): MealTemplate => ({
|
||||
...template,
|
||||
kind: template.kind ?? MealTemplateKind.HOTOVE,
|
||||
});
|
||||
|
||||
/**
|
||||
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
||||
*
|
||||
@@ -21,8 +36,78 @@ const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||
*/
|
||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
||||
.map(withKind)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
|
||||
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
||||
export type RecipeSummary = {
|
||||
/** Energie všech surovin dohromady v kcal */
|
||||
totalCalories?: number;
|
||||
/** Hmotnost hotového jídla v gramech — zadaná, jinak součet surovin */
|
||||
totalWeight?: number;
|
||||
/** Cena všech surovin v haléřích */
|
||||
totalPrice?: number;
|
||||
/** Gramáž jedné porce */
|
||||
weight?: number;
|
||||
/** Energie jedné porce v kcal */
|
||||
calories?: number;
|
||||
/** Energie hotového jídla na 100 g */
|
||||
caloriesPer100g?: number;
|
||||
/** Cena jedné porce v haléřích */
|
||||
price?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Energie jedné suroviny — z gramáže a energie na 100 g, jinak ručně zadaná.
|
||||
* Stejné pravidlo jako u jídla v `deriveAmounts`.
|
||||
*/
|
||||
const ingredientCalories = (ingredient: RecipeIngredient): number | undefined =>
|
||||
ingredient.weight != null && ingredient.caloriesPer100g
|
||||
? Math.round(ingredient.weight / 100 * ingredient.caloriesPer100g)
|
||||
: ingredient.calories;
|
||||
|
||||
/** Sečte vyplněné hodnoty; když není vyplněná žádná, vrátí undefined. */
|
||||
const sumDefined = (valueList: (number | undefined)[]): number | undefined => {
|
||||
const definedList = valueList.filter(value => value != null);
|
||||
return definedList.length ? definedList.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dopočte ze surovin hodnoty hotového jídla a jedné porce.
|
||||
*
|
||||
* Energie na 100 g se počítá z hmotnosti hotového jídla, ne ze součtu surovin —
|
||||
* 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.
|
||||
*
|
||||
* Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky.
|
||||
*
|
||||
* @param ingredients suroviny receptu
|
||||
* @param servings počet porcí, nevyplněný znamená jednu
|
||||
* @param cookedWeight hmotnost hotového jídla v gramech
|
||||
*/
|
||||
export const deriveRecipe = (
|
||||
ingredients: RecipeIngredient[],
|
||||
servings?: number,
|
||||
cookedWeight?: number,
|
||||
): RecipeSummary => {
|
||||
const portions = Math.max(servings ?? 1, 1);
|
||||
const totalCalories = sumDefined(ingredients.map(ingredientCalories));
|
||||
const totalWeight = cookedWeight || sumDefined(ingredients.map(ingredient => ingredient.weight)) || undefined;
|
||||
const totalPrice = sumDefined(ingredients.map(ingredient => ingredient.price));
|
||||
|
||||
return {
|
||||
totalCalories,
|
||||
totalWeight,
|
||||
totalPrice,
|
||||
weight: totalWeight != null ? Math.round(totalWeight / portions) : undefined,
|
||||
calories: totalCalories != null ? Math.round(totalCalories / portions) : undefined,
|
||||
caloriesPer100g: totalCalories != null && totalWeight
|
||||
? Math.round(totalCalories / totalWeight * 100)
|
||||
: undefined,
|
||||
price: totalPrice != null ? Math.round(totalPrice / portions) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/** Ořízne a znormalizuje volitelný textový vstup. */
|
||||
const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => {
|
||||
if (value == null) return undefined;
|
||||
@@ -50,9 +135,38 @@ 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;
|
||||
|
||||
/** Ověří a znormalizuje jednu surovinu receptu. */
|
||||
const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||
const value = (raw ?? {}) as Record<string, unknown>;
|
||||
const name = normalizeText(value.name, 'name');
|
||||
if (!name) {
|
||||
throw new BadRequestError(`Surovina ${index + 1} nemá název`);
|
||||
}
|
||||
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,
|
||||
weight,
|
||||
caloriesPer100g,
|
||||
// Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže
|
||||
calories: weight != null && caloriesPer100g ? undefined : calories,
|
||||
price,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
||||
*
|
||||
* U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty
|
||||
* porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí —
|
||||
* použití šablony pak funguje pro oba druhy stejně.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param input data šablony
|
||||
*/
|
||||
@@ -65,27 +179,59 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||
}
|
||||
if (value.kind != null && value.kind !== MealTemplateKind.HOTOVE && value.kind !== MealTemplateKind.RECEPT) {
|
||||
throw new BadRequestError("Nebyl předán platný druh šablony");
|
||||
}
|
||||
const kind = value.kind === MealTemplateKind.RECEPT ? MealTemplateKind.RECEPT : MealTemplateKind.HOTOVE;
|
||||
|
||||
const template: MealTemplate = {
|
||||
const common = {
|
||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||
kind,
|
||||
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') } : {}),
|
||||
mealType: value.mealType != null ? value.mealType as MealType : undefined,
|
||||
source: normalizeText(value.source, 'source'),
|
||||
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||
};
|
||||
|
||||
let template: MealTemplate;
|
||||
if (kind === MealTemplateKind.RECEPT) {
|
||||
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||
}
|
||||
if (value.ingredients.length > MAX_INGREDIENTS) {
|
||||
throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
|
||||
}
|
||||
const ingredients = value.ingredients.map(normalizeIngredient);
|
||||
const servings = normalizeNumber(value.servings, 'servings');
|
||||
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
||||
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
||||
template = definedOnly({
|
||||
...common,
|
||||
ingredients,
|
||||
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||
servings: servings && servings > 1 ? servings : undefined,
|
||||
cookedWeight,
|
||||
weight: summary.weight,
|
||||
caloriesPer100g: summary.caloriesPer100g,
|
||||
calories: summary.calories,
|
||||
price: summary.price,
|
||||
});
|
||||
for (const ingredient of ingredients) {
|
||||
if (ingredient.caloriesPer100g) {
|
||||
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
template = definedOnly({
|
||||
...common,
|
||||
calories: normalizeNumber(value.calories, 'calories'),
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||
weight: normalizeNumber(value.weight, 'weight'),
|
||||
price: normalizeNumber(value.price, 'price'),
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||
});
|
||||
}
|
||||
|
||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||
const others = (current ?? []).filter(item => item.id !== template.id);
|
||||
return [...others, template];
|
||||
@@ -125,12 +271,14 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise<Me
|
||||
* @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
|
||||
* @param multiplier násobek porce, třeba 2 za dvě stejné sušenky
|
||||
*/
|
||||
export const applyMealTemplate = async (
|
||||
login: string,
|
||||
id: unknown,
|
||||
date: unknown,
|
||||
mealType?: unknown,
|
||||
multiplier?: unknown,
|
||||
): Promise<string> => {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||
@@ -156,7 +304,9 @@ export const applyMealTemplate = async (
|
||||
pricePer100g: template.pricePer100g,
|
||||
calories: template.calories,
|
||||
caloriesPer100g: template.caloriesPer100g,
|
||||
note: template.note,
|
||||
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
||||
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
|
||||
multiplier: normalizeMultiplier(multiplier),
|
||||
});
|
||||
return date;
|
||||
};
|
||||
@@ -166,6 +316,8 @@ export const applyMealTemplate = async (
|
||||
*
|
||||
* 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.
|
||||
* Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by
|
||||
* přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param date den, ve kterém jídlo je
|
||||
@@ -189,9 +341,13 @@ export const createMealTemplateFromMeal = async (
|
||||
|
||||
const key = normalizeName(meal.name);
|
||||
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
||||
if (existing?.kind === MealTemplateKind.RECEPT) {
|
||||
throw new BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`);
|
||||
}
|
||||
|
||||
return saveMealTemplate(login, {
|
||||
...(existing ? { id: existing.id } : {}),
|
||||
kind: MealTemplateKind.HOTOVE,
|
||||
name: meal.name,
|
||||
source: meal.source,
|
||||
mealType: meal.mealType,
|
||||
|
||||
+53
-17
@@ -1,7 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import { BadRequestError, NotFoundError, isValidIsoDate, listDatesInRange } from "./utils";
|
||||
import {
|
||||
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
|
||||
} from "./utils";
|
||||
import { rememberCalories } from "./calories";
|
||||
|
||||
const storage = getStorage();
|
||||
@@ -47,7 +49,7 @@ export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
|
||||
date,
|
||||
entries: sorted,
|
||||
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
|
||||
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,6 +124,7 @@ export type NormalizedMealInput = {
|
||||
weight?: number;
|
||||
pricePer100g?: number;
|
||||
caloriesPer100g?: number;
|
||||
multiplier?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -168,6 +171,7 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
|
||||
weight: normalizeNumber(value.weight, 'weight'),
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||
multiplier: normalizeMultiplier(value.multiplier),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,8 +204,10 @@ export async function addMeal(login: string, input: unknown): Promise<DayRecord>
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param entries záznamy k uložení
|
||||
* @param replaceImportSource pokud je vyplněný, odeberou se z dotčených dnů nejdřív
|
||||
* dřívější záznamy z tohoto importu; ručně zadaná jídla zůstanou
|
||||
*/
|
||||
export async function addEntries(login: string, entries: MealEntry[]): Promise<string[]> {
|
||||
export async function addEntries(login: string, entries: MealEntry[], replaceImportSource?: string): Promise<string[]> {
|
||||
const byDate = new Map<string, MealEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const forDate = byDate.get(entry.date) ?? [];
|
||||
@@ -211,7 +217,10 @@ export async function addEntries(login: string, entries: MealEntry[]): Promise<s
|
||||
for (const [date, forDate] of byDate) {
|
||||
await storage.updateData<MealEntry[]>(
|
||||
getDayKey(login, date),
|
||||
current => [...(current ?? []), ...forDate]
|
||||
current => [
|
||||
...(current ?? []).filter(entry => !replaceImportSource || entry.importSource !== replaceImportSource),
|
||||
...forDate,
|
||||
]
|
||||
);
|
||||
}
|
||||
return Array.from(byDate.keys()).sort();
|
||||
@@ -258,6 +267,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
||||
weight: normalized.weight,
|
||||
pricePer100g: normalized.pricePer100g,
|
||||
caloriesPer100g: normalized.caloriesPer100g,
|
||||
multiplier: normalized.multiplier,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const entries = await storage.updateData<MealEntry[]>(
|
||||
@@ -295,6 +305,40 @@ export async function deleteMeal(login: string, id: unknown, date: unknown): Pro
|
||||
return buildDayRecord(date, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastaví násobek porce jednoho jídla. Ostatní údaje záznamu zůstanou beze změny,
|
||||
* takže jde násobek měnit přímo z přehledu dne bez otevření formuláře.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param id identifikátor záznamu
|
||||
* @param date datum záznamu (YYYY-MM-DD)
|
||||
* @param multiplier nový násobek; 1 násobek zruší
|
||||
*/
|
||||
export async function setMealMultiplier(login: string, id: unknown, date: unknown, multiplier: unknown): Promise<string> {
|
||||
if (typeof id !== 'string' || !id.length) {
|
||||
throw new BadRequestError("Nebyl předán identifikátor záznamu");
|
||||
}
|
||||
if (!isValidIsoDate(date)) {
|
||||
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
||||
}
|
||||
if (multiplier == null) {
|
||||
throw new BadRequestError("Nebyl předán násobek");
|
||||
}
|
||||
const normalized = normalizeMultiplier(multiplier);
|
||||
let found = false;
|
||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||
if (entry.id !== id) return entry;
|
||||
found = true;
|
||||
const updated: MealEntry = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() };
|
||||
if (normalized == null) delete updated.multiplier;
|
||||
return updated;
|
||||
}));
|
||||
if (!found) {
|
||||
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Najde datum, pod kterým je záznam uložený.
|
||||
* Nejdřív zkusí očekávaný den, teprve pak prohledá ostatní klíče uživatele —
|
||||
@@ -317,21 +361,13 @@ async function findEntryDate(login: string, id: string, expectedDate: string): P
|
||||
}
|
||||
|
||||
/**
|
||||
* Vrátí klíče importovaných záznamů uživatele v předaných dnech.
|
||||
* Slouží k rozpoznání duplicit při opakovaném importu stejného měsíce.
|
||||
* Vrátí všechna jídla uživatele v předaných dnech, seřazená dle dne a chodu.
|
||||
* Import podle nich rozpoznává duplicity a v náhledu je ukazuje uživateli.
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
* @param dates dny, ve kterých se má hledat
|
||||
*/
|
||||
export async function getExistingImportKeys(login: string, dates: string[]): Promise<Set<string>> {
|
||||
const keys = new Set<string>();
|
||||
for (const date of dates) {
|
||||
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.importKey) {
|
||||
keys.add(entry.importKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
export async function findMealsForDays(login: string, dates: string[]): Promise<MealEntry[]> {
|
||||
const days = await Promise.all([...dates].sort().map(date => getDay(login, date)));
|
||||
return days.flatMap(day => day.entries);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { addActivity, deleteActivity, updateActivity } from "../activities";
|
||||
import { addActivity, deleteActivity, setActivityMultiplier, updateActivity } from "../activities";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
@@ -35,4 +35,14 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Nastaví násobek aktivity z přehledu dne. */
|
||||
router.post("/multiplier", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, multiplier } = req.body ?? {};
|
||||
const day = await setActivityMultiplier(login, id, date, multiplier);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -14,7 +14,7 @@ const router = express.Router();
|
||||
router.post("/luncher", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { fileName, content, defaultMealType, dryRun } = req.body ?? {};
|
||||
const { fileName, content, defaultMealType, dryRun, mode, selectedKeys } = req.body ?? {};
|
||||
if (typeof fileName !== 'string' || !fileName.trim().length) {
|
||||
throw new BadRequestError("Nebyl předán název souboru");
|
||||
}
|
||||
@@ -25,7 +25,8 @@ router.post("/luncher", async (req: Request, res: Response, next: NextFunction)
|
||||
if (!buffer.length) {
|
||||
throw new BadRequestError("Obsah souboru je prázdný nebo není platný Base64");
|
||||
}
|
||||
const result = await importLuncherExport(login, fileName.trim(), buffer, defaultMealType, dryRun === true);
|
||||
const result = await importLuncherExport(
|
||||
login, fileName.trim(), buffer, defaultMealType, dryRun === true, mode, selectedKeys);
|
||||
res.status(200).json(result);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import { addMeal, deleteMeal, getDay, getRange, updateMeal } from "../meals";
|
||||
import { addMeal, deleteMeal, getDay, getRange, setMealMultiplier, updateMeal } from "../meals";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||
|
||||
@@ -56,4 +57,14 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Nastaví násobek porce z přehledu dne a vrátí celý přehled, protože se mění i bilance. */
|
||||
router.post("/multiplier", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, multiplier } = req.body ?? {};
|
||||
const day = await setMealMultiplier(login, id, date, multiplier);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -36,8 +36,8 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
||||
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, date, mealType } = req.body ?? {};
|
||||
const day = await applyMealTemplate(login, id, date, mealType);
|
||||
const { id, date, mealType, multiplier } = req.body ?? {};
|
||||
const day = await applyMealTemplate(login, id, date, mealType, multiplier);
|
||||
res.status(200).json(await getDayOverview(login, day));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FoodStats, GroupTotal, MealEntry, PeriodTotal } from "../../types/gen/types.gen";
|
||||
import { getRange } from "./meals";
|
||||
import { applyMultiplier } from "./utils";
|
||||
|
||||
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
|
||||
const UNKNOWN_SOURCE = 'Neuvedeno';
|
||||
@@ -11,7 +12,7 @@ type Bucket = { price: number, calories: number, count: number };
|
||||
function accumulate(buckets: Map<string, Bucket>, key: string, entry: MealEntry): void {
|
||||
const bucket = buckets.get(key) ?? { price: 0, calories: 0, count: 0 };
|
||||
bucket.price += entry.price ?? 0;
|
||||
bucket.calories += entry.calories ?? 0;
|
||||
bucket.calories += applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
bucket.count++;
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
@@ -61,7 +62,7 @@ export async function getFoodStats(login: string, from: string, to: string): Pro
|
||||
accumulate(byMealType, entry.mealType, entry);
|
||||
accumulate(bySource, entry.source ?? UNKNOWN_SOURCE, entry);
|
||||
totalPrice += entry.price ?? 0;
|
||||
totalCalories += entry.calories ?? 0;
|
||||
totalCalories += applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||
entryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,3 +73,42 @@ export function listDatesInRange(from: string, to: string): string[] {
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
|
||||
const MAX_MULTIPLIER = 100;
|
||||
|
||||
/**
|
||||
* Ověří násobek porce nebo aktivity a zaokrouhlí ho na setiny.
|
||||
*
|
||||
* Násobek 1 je výchozí stav, proto se vrací jako undefined a neukládá se —
|
||||
* stejně jako jedna série u aktivit.
|
||||
*
|
||||
* @param value vstupní hodnota
|
||||
*/
|
||||
export const normalizeMultiplier = (value: unknown): number | undefined => {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new BadRequestError("Násobek musí být číslo");
|
||||
}
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
if (rounded <= 0) {
|
||||
throw new BadRequestError("Násobek musí být kladné číslo");
|
||||
}
|
||||
if (rounded > MAX_MULTIPLIER) {
|
||||
throw new BadRequestError(`Násobek může být nejvýše ${MAX_MULTIPLIER}`);
|
||||
}
|
||||
return rounded === 1 ? undefined : rounded;
|
||||
};
|
||||
|
||||
/**
|
||||
* Vrátí kalorie záznamu po započtení násobku.
|
||||
*
|
||||
* Uložené kalorie jsou vždy za jednu porci (jedno provedení), aby šlo násobek
|
||||
* měnit tam a zpět bez ztráty přesnosti zaokrouhlováním. Klient má stejnou
|
||||
* funkci v `Utils.tsx` — musí dávat stejné výsledky.
|
||||
*
|
||||
* @param calories kcal jedné porce
|
||||
* @param multiplier násobek, nevyplněný znamená 1
|
||||
*/
|
||||
export const applyMultiplier = (calories: number | undefined, multiplier?: number): number | undefined =>
|
||||
calories == null ? undefined : Math.round(calories * (multiplier ?? 1));
|
||||
@@ -117,6 +117,7 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
}
|
||||
const rate = value.caloriesPer100Units;
|
||||
const sets = value.sets;
|
||||
const extraWeight = value.extraWeightGrams;
|
||||
return {
|
||||
kind,
|
||||
name,
|
||||
@@ -129,6 +130,9 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
||||
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
|
||||
? { caloriesPer100Units: Math.round(rate) }
|
||||
: {}),
|
||||
...(typeof extraWeight === 'number' && Number.isFinite(extraWeight) && extraWeight > 0
|
||||
? { extraWeightGrams: Math.round(extraWeight) }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -269,6 +273,7 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
|
||||
quantity: item.quantity!,
|
||||
...(item.sets != null ? { sets: item.sets } : {}),
|
||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
|
||||
calories: itemCalories(item),
|
||||
templateId: template.id,
|
||||
createdAt: now,
|
||||
@@ -317,6 +322,7 @@ export const createWorkoutTemplateFromActivities = async (
|
||||
...(entry.sets != null ? { sets: entry.sets } : {}),
|
||||
// Aktivita se zadanými kaloriemi napevno sazbu nemá, šablona pak odhad nespočítá
|
||||
...(entry.caloriesPer100Units != null ? { caloriesPer100Units: entry.caloriesPer100Units } : {}),
|
||||
...(entry.extraWeightGrams != null ? { extraWeightGrams: entry.extraWeightGrams } : {}),
|
||||
}));
|
||||
|
||||
return saveWorkoutTemplate(login, undefined, name, items);
|
||||
|
||||
Reference in new issue
Block a user