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:
1 parent
b880b36edd
commit
efed5e6e86
30 files changed
+514
-153
No files matched your search
+78
-21
@@ -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 };
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user