feat: import jen některých dnů

feat: násobení jídel/cviků
feat: refaktor napojování šablon
This commit is contained in:
Ondřej Anděl committed 2026-09-30 19:10:54 +02:00
1 parent c3990d80f8
commit 8dac67bdd1
37 files changed
+2314 -481

No files matched your search

+43 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+11 -1
View File
@@ -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;
+3 -2
View File
@@ -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); }
});
+12 -1
View File
@@ -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;
+2 -2
View File
@@ -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); }
});
+3 -2
View File
@@ -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++;
}
}
+39
View File
@@ -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));
+6
View File
@@ -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);