fix: graf cena per zdroj nahrazen koláčem

feat: stránka doplnit údaje předělána na samostatnou stránku jídel + přidáno přejmenování
This commit is contained in:
Ondřej Anděl committed 2026-10-12 09:49:41 +02:00
1 parent b880b36edd
commit efed5e6e86
30 files changed
+514 -153

No files matched your search

+78 -21
View File
@@ -475,12 +475,17 @@ 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 =>
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
/**
* Vrátí jídla z celé historie uživatele, každý název jednou — slouží
* k našeptávání při zakládání jídla místo ručně ukládaných šablon.
* Vrátí jídla z celé historie uživatele, každou dvojici název a zdroj jednou —
* slouží k našeptávání při zakládání jídla místo ručně ukládaných šablon.
*
* Názvy se porovnávají znormalizované (bez diakritiky a velikosti písmen), takže
* "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Hodnoty návrhu jsou
* Názvy i zdroje se porovnávají znormalizované (bez diakritiky a velikosti písmen),
* takže "Kuřecí gyros" a "kureci gyros" jsou jedno jídlo. Stejnojmenné jídlo
* z jiného zdroje je jiné jídlo — jinde se vaří, nandává i účtuje jinak. Hodnoty návrhu jsou
* z posledního záznamu, protože cena i gramáž se časem mění. Když poslední
* záznam nemá kalorie (typicky import z Luncheru), doplní se z dřívějšího stejně
* jako při importu. Řadí se od nejčastěji jedených, při shodě od naposledy jedených.
@@ -492,7 +497,7 @@ export async function getMealHistory(login: string): Promise<MealSuggestion[]> {
const fillNutrition = createNutritionFiller(entryList);
const resultMap = new Map<string, MealSuggestion>();
for (const entry of entryList) {
const key = normalizeName(entry.name);
const key = getNameSourceKey(entry.name, entry.source);
const existing = resultMap.get(key);
if (existing) {
existing.count++;
@@ -517,10 +522,6 @@ export async function findAllMeals(login: string): Promise<MealEntry[]> {
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
}
/** 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 =>
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */
const hasNutrition = (entry: MealEntry): boolean =>
entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g;
@@ -595,8 +596,9 @@ export async function getMealSources(login: string): Promise<string[]> {
}
/**
* Doplní energii a živiny jídla do ostatních jídel stejného názvu, která je
* nemají — typicky po ručním doplnění kalorií jednomu naimportovanému jídlu.
* Doplní energii a živiny jídla do ostatních jídel stejného názvu a zdroje,
* která je nemají — typicky po ručním doplnění kalorií jednomu naimportovanému
* jídlu. Stejnojmenné jídlo z jiného zdroje je jiné jídlo a nechá se být.
*
* Přebírá se stejně jako při importu (\`createNutritionFiller\`), takže u jídel
* ze stejného zdroje se gramáž dopočte z jejich vlastní ceny. Jídla, která
@@ -627,9 +629,9 @@ export async function fillSameNameMeals(
throw new BadRequestError("Jídlo nemá kalorie ani živiny, není co doplnit");
}
const key = normalizeName(source.name);
const targetList = (await findAllMeals(login))
.filter(entry => entry.id !== source.id && normalizeName(entry.name) === key && !hasNutrition(entry));
const key = getNameSourceKey(source.name, source.source);
const targetList = (await findAllMeals(login)).filter(entry =>
entry.id !== source.id && getNameSourceKey(entry.name, entry.source) === key && !hasNutrition(entry));
const dates = [...new Set(targetList.map(entry => entry.date))].sort();
if (!dryRun) {
@@ -674,13 +676,6 @@ const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]
});
};
/**
* 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 — podklad pro hromadné doplnění
* údajů ke starším záznamům.
*
* @param login přihlašovací jméno uživatele
*/
/** 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;
@@ -696,6 +691,13 @@ const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
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[]>();
@@ -814,3 +816,58 @@ export const fillMealGroup = async (
}
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 };
};
+11 -2
View File
@@ -1,7 +1,7 @@
import express, { Request, Response, NextFunction } from "express";
import {
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
getRange, setMealMultiplier, splitMeal, updateMeal,
getRange, renameMealGroup, setMealMultiplier, splitMeal, updateMeal,
} from "../meals";
import { getDayOverview } from "../dayOverview";
import { getLogin } from "../auth";
@@ -59,7 +59,7 @@ router.post("/fillSameName", async (req: Request, res: Response, next: NextFunct
} catch (e) { next(e); }
});
/** Vrátí jídla seskupená podle názvu a zdroje — podklad pro hromadné doplnění údajů. */
/** Vrátí jídla seskupená podle názvu a zdroje — seznam jídel. */
router.get("/groups", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
@@ -76,6 +76,15 @@ router.post("/fillGroup", async (req: Request, res: Response, next: NextFunction
} catch (e) { next(e); }
});
/** Přejmenuje všechna jídla se stejným názvem a zdrojem, případně jim změní zdroj. */
router.post("/renameGroup", async (req: Request, res: Response, next: NextFunction) => {
try {
const login = getLogin(parseToken(req));
const { name, source, newName, newSource } = req.body ?? {};
res.status(200).json(await renameMealGroup(login, name, source, newName, newSource));
} catch (e) { next(e); }
});
/** Přidá jídlo do zvoleného dne. */
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
try {
+54 -1
View File
@@ -1,6 +1,7 @@
process.env.CALORIE_PROVIDER = 'none';
import { addMeal, fillMealGroup, getDay, getMealGroups } from "../meals";
import { addMeal, fillMealGroup, getDay, getMealGroups, renameMealGroup } from "../meals";
import { searchCalories } from "../calories";
import { resetMemoryStorage } from "../storage/memory";
import { MealType } from "../../../types/gen/types.gen";
@@ -118,3 +119,55 @@ describe('hromadné doplnění údajů do skupiny', () => {
expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined();
});
});
describe('přejmenování skupiny jídel', () => {
it('přejmenuje a přesune do jiného zdroje všechna jídla skupiny', async () => {
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'cina' });
await addMeal(LOGIN, { ...cina, date: '2025-03-02', source: 'doma' });
const result = await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Kuře kung-pao', 'Everest');
expect(result).toEqual({ count: 2, dates: ['2025-03-01', '2025-03-02'] });
expect((await getDay(LOGIN, '2025-03-01')).entries[0])
.toMatchObject({ name: 'Kuře kung-pao', source: 'Everest', price: 13900 });
const second = (await getDay(LOGIN, '2025-03-02')).entries;
expect(second.map(entry => `${entry.name}|${entry.source}`).sort())
.toEqual(['Kuře kung-pao|Everest', 'Čína|doma']);
});
it('prázdný zdroj zdroj odebere a jídla se sloučí se skupinou bez zdroje', async () => {
await addMeal(LOGIN, { ...cina, date: '2025-03-01' });
await addMeal(LOGIN, { ...cina, date: '2025-03-02', source: undefined });
await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Čína', undefined);
const groups = await getMealGroups(LOGIN);
expect(groups).toHaveLength(1);
expect(groups[0].count).toBe(2);
expect((await getDay(LOGIN, '2025-03-01')).entries[0]).not.toHaveProperty('source');
});
it('jídla se shodným zápisem nepočítá', async () => {
await addMeal(LOGIN, { ...cina, date: '2025-03-01' });
await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'čína' });
const result = await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Čína', 'Chefie');
expect(result).toEqual({ count: 1, dates: ['2025-03-02'] });
});
it('energii na 100 g si knihovna zapamatuje i pod novým názvem', async () => {
await addMeal(LOGIN, { ...cina, date: '2025-03-01', weight: 400, caloriesPer100g: 150 });
await renameMealGroup(LOGIN, 'Čína', 'Chefie', 'Kuře kung-pao', 'Chefie');
const { suggestions } = await searchCalories(LOGIN, 'Kuře kung-pao');
expect(suggestions[0]).toMatchObject({ name: 'Kuře kung-pao', caloriesPer100g: 150 });
});
it('odmítne prázdný nový název', async () => {
await expect(renameMealGroup(LOGIN, 'Čína', 'Chefie', ' ', 'Chefie')).rejects.toThrow(/nový název/);
});
});
+12
View File
@@ -81,6 +81,16 @@ describe('návrhy jídel z historie', () => {
expect(history.map(item => item.name)).toEqual(['Častější', 'Novější', 'Starší']);
});
it('stejnojmenná jídla z různých zdrojů nabídne zvlášť', async () => {
await addMeal(LOGIN, { ...gyros, source: 'Everest', price: 15900 });
await addMeal(LOGIN, { ...gyros, source: 'Namaste', price: 16900 });
const history = await getMealHistory(LOGIN);
expect(history.map(item => `${item.source}: ${item.price}`).sort())
.toEqual(['Everest: 15900', 'Namaste: 16900']);
});
it('historie je oddělená mezi uživateli', async () => {
await addMeal(LOGIN, gyros);
@@ -181,6 +191,7 @@ describe('doplnění do jídel stejného názvu', () => {
await addMeal(LOGIN, { ...salat, date: '2025-03-01' });
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'těstovinový SALÁT' });
await addMeal(LOGIN, { ...salat, date: '2025-03-02', name: 'Jiné jídlo' });
await addMeal(LOGIN, { ...salat, date: '2025-03-05', source: 'Everest' });
await addMeal(LOGIN, { ...salat, date: '2025-03-04', calories: 300, caloriesPer100g: 120 });
const day = await addMeal(LOGIN, {
...salat, date: DATE, weight: 350, caloriesPer100g: 160, nutrientsPer100g: { protein: 6 },
@@ -207,6 +218,7 @@ describe('doplnění do jídel stejného názvu', () => {
const second = (await getDay(LOGIN, '2025-03-02')).entries;
expect(second.find(entry => entry.name === 'těstovinový SALÁT')?.calories).toBe(560);
expect(second.find(entry => entry.name === 'Jiné jídlo')?.calories).toBeUndefined();
expect((await getDay(LOGIN, '2025-03-05')).entries[0].calories).toBeUndefined();
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBe(300);
});