feat: hromadné editace
This commit is contained in:
1 parent
8fa7f601f2
commit
bc85a37fc0
18 files changed
+825
-18
No files matched your search
+161
-2
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import {
|
||||
@@ -461,6 +461,10 @@ 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;
|
||||
@@ -478,7 +482,7 @@ const hasNutrition = (entry: MealEntry): boolean =>
|
||||
* @param historyList jídla uživatele od nejnovějších (`findAllMeals`)
|
||||
*/
|
||||
export function createNutritionFiller(historyList: MealEntry[]): (entry: MealEntry) => MealEntry {
|
||||
const sourceKey = (entry: MealEntry) => `${normalizeName(entry.name)}|${normalizeName(entry.source ?? '')}`;
|
||||
const sourceKey = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source);
|
||||
const byNameMap = new Map<string, MealEntry>();
|
||||
const byNameSourceMap = new Map<string, MealEntry>();
|
||||
for (const entry of historyList.filter(hasNutrition)) {
|
||||
@@ -583,3 +587,158 @@ export async function fillSameNameMeals(
|
||||
}
|
||||
return { count: targetList.length, dates };
|
||||
}
|
||||
|
||||
/** Vrátí první vyplněnou hodnotu z jídel seřazených od nejnovějších. */
|
||||
const findLatestValue = <T>(entryList: MealEntry[], pick: (entry: MealEntry) => T | undefined): T | undefined =>
|
||||
entryList.map(pick).find(value => value != null);
|
||||
|
||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nezapisovaly. */
|
||||
const omitUndefined = <T extends object>(value: T): T =>
|
||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
||||
|
||||
/**
|
||||
* Sestaví hodnoty k předvyplnění formuláře skupiny — každou z nejnovějšího
|
||||
* jídla, které ji má. Energie a živiny se bez vlastních hodnot vezmou z jídla
|
||||
* stejného názvu odjinud (složení je stejné), sazba za 100 g a gramáž ne —
|
||||
* jinde se účtuje i nandává jinak.
|
||||
*/
|
||||
const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]): MealGroupValues => {
|
||||
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g)
|
||||
?? findLatestValue(sameNameList, entry => entry.caloriesPer100g);
|
||||
return omitUndefined({
|
||||
pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g),
|
||||
weight: findLatestValue(groupList, getManualWeight),
|
||||
caloriesPer100g,
|
||||
// Ruční kalorie porce dávají smysl jen u jídla, které energii na 100 g nemá
|
||||
calories: caloriesPer100g == null
|
||||
? findLatestValue(groupList, entry => entry.calories)
|
||||
: undefined,
|
||||
nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g)
|
||||
?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||
const entryList = await findAllMeals(login);
|
||||
const groupMap = new Map<string, MealEntry[]>();
|
||||
const nameMap = new Map<string, MealEntry[]>();
|
||||
for (const entry of entryList) {
|
||||
const key = getNameSourceKey(entry.name, entry.source);
|
||||
groupMap.set(key, [...(groupMap.get(key) ?? []), entry]);
|
||||
const nameKey = normalizeName(entry.name);
|
||||
nameMap.set(nameKey, [...(nameMap.get(nameKey) ?? []), entry]);
|
||||
}
|
||||
|
||||
const resultList = [...groupMap.values()].map((groupList): MealGroup => {
|
||||
const [latest] = groupList;
|
||||
return {
|
||||
name: latest.name,
|
||||
...(latest.source ? { source: latest.source } : {}),
|
||||
count: groupList.length,
|
||||
lastDate: latest.date,
|
||||
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
||||
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
||||
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
||||
values: createMealGroupValues(groupList, nameMap.get(normalizeName(latest.name)) ?? []),
|
||||
};
|
||||
});
|
||||
return resultList.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
||||
};
|
||||
|
||||
/** Ověří hodnoty k doplnění do skupiny jídel. Nulová sazba nebo gramáž nic neříká, bere se jako nezadaná. */
|
||||
const normalizeGroupValues = (input: unknown): MealGroupValues => {
|
||||
const value = (input ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') || undefined,
|
||||
weight: normalizeNumber(value.weight, 'weight') || undefined,
|
||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') || undefined,
|
||||
calories: normalizeNumber(value.calories, 'calories'),
|
||||
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní do jídla hodnoty, které mu chybí; co už má, nechá být.
|
||||
*
|
||||
* Gramáž se nedoplní tam, kde jde dopočítat z vlastní ceny jídla — ta je
|
||||
* přesnější než jedna gramáž pro všechny. Energie se doplní jen jídlu bez
|
||||
* kalorií, jinak by dopočet z gramáže přepsal kalorie zadané ručně. Živiny
|
||||
* se doplňují po jednotlivých položkách.
|
||||
*/
|
||||
const fillMissingValues = (entry: MealEntry, values: MealGroupValues): MealEntry => {
|
||||
const pricePer100g = entry.pricePer100g ?? values.pricePer100g;
|
||||
const weightDerivable = entry.price != null && !!pricePer100g;
|
||||
const caloriesMissing = entry.calories == null;
|
||||
const derived = deriveAmounts({
|
||||
...entry,
|
||||
pricePer100g,
|
||||
weight: entry.weight ?? (weightDerivable ? undefined : values.weight),
|
||||
caloriesPer100g: entry.caloriesPer100g ?? (caloriesMissing ? values.caloriesPer100g : undefined),
|
||||
calories: caloriesMissing ? values.calories : entry.calories,
|
||||
});
|
||||
return omitUndefined({
|
||||
...entry,
|
||||
pricePer100g,
|
||||
weight: derived.weight,
|
||||
caloriesPer100g: derived.caloriesPer100g,
|
||||
calories: caloriesMissing ? derived.calories : entry.calories,
|
||||
nutrientsPer100g: fillMissingNutrients(entry.nutrientsPer100g, values.nutrientsPer100g),
|
||||
});
|
||||
};
|
||||
|
||||
/** Doplní chybějící živiny; vyplněné zůstanou a na svém místě, aby šlo poznat, že se nic nezměnilo. */
|
||||
const fillMissingNutrients = (current?: Nutrients, values?: Nutrients): Nutrients | undefined => {
|
||||
if (!values) return current;
|
||||
const missing = Object.entries(values).filter(([key]) => current?.[key as keyof Nutrients] == null);
|
||||
return missing.length ? { ...current, ...Object.fromEntries(missing) } : current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem — typicky
|
||||
* ke starším záznamům, u kterých se kalorie nebo živiny nezadávaly.
|
||||
*
|
||||
* @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 input hodnoty k doplnění
|
||||
*/
|
||||
export const fillMealGroup = async (
|
||||
login: string,
|
||||
name: unknown,
|
||||
source: unknown,
|
||||
input: unknown,
|
||||
): Promise<FillSameNameResult> => {
|
||||
const groupName = normalizeText(name, 'name');
|
||||
if (!groupName) {
|
||||
throw new BadRequestError("Nebyl předán název jídla");
|
||||
}
|
||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||
const values = normalizeGroupValues(input);
|
||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||
|
||||
const groupDates = [...new Set((await findAllMeals(login)).filter(isInGroup).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)) return entry;
|
||||
const filled = fillMissingValues(entry, values);
|
||||
if (JSON.stringify(filled) === JSON.stringify(entry)) return entry;
|
||||
count++;
|
||||
if (!dates.includes(date)) dates.push(date);
|
||||
return { ...filled, updatedAt };
|
||||
}));
|
||||
}
|
||||
if (values.caloriesPer100g) {
|
||||
await rememberCalories(login, groupName, values.caloriesPer100g);
|
||||
}
|
||||
return { count, dates };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
|
||||
updateMeal,
|
||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
||||
getRange, setMealMultiplier, updateMeal,
|
||||
} from "../meals";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
@@ -59,6 +59,23 @@ 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ů. */
|
||||
router.get("/groups", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getMealGroups(login));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem. */
|
||||
router.post("/fillGroup", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { name, source, values } = req.body ?? {};
|
||||
res.status(200).json(await fillMealGroup(login, name, source, values));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Přidá jídlo do zvoleného dne. */
|
||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal, fillMealGroup, getDay, getMealGroups } from "../meals";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
const cina = { mealType: MealType.OBED, name: 'Čína', source: 'Chefie' };
|
||||
|
||||
describe('skupiny jídel podle názvu a zdroje', () => {
|
||||
|
||||
it('seskupí jídla podle názvu i zdroje a spočítá chybějící údaje', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'cina', price: 14900 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-03', price: 14900, weight: 400, caloriesPer100g: 150 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-04', source: 'doma' });
|
||||
|
||||
const groups = await getMealGroups(LOGIN);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toMatchObject({
|
||||
name: 'Čína', source: 'Chefie', count: 3, lastDate: '2025-03-03',
|
||||
missingCaloriesCount: 2, missingWeightCount: 2, missingNutrientsCount: 3,
|
||||
values: { weight: 400, caloriesPer100g: 150 },
|
||||
});
|
||||
expect(groups[1]).toMatchObject({ source: 'doma', count: 1 });
|
||||
});
|
||||
|
||||
it('energii a živiny předvyplní i z jídla stejného názvu odjinud, sazbu ne', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
...cina, date: '2025-03-01', source: 'doma', pricePer100g: 2000, caloriesPer100g: 140,
|
||||
nutrientsPer100g: { protein: 8 },
|
||||
});
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 13900 });
|
||||
|
||||
const group = (await getMealGroups(LOGIN)).find(item => item.source === 'Chefie');
|
||||
|
||||
expect(group?.values).toEqual({ caloriesPer100g: 140, nutrientsPer100g: { protein: 8 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hromadné doplnění údajů do skupiny', () => {
|
||||
|
||||
it('doplní jen chybějící hodnoty a kalorie dopočte z gramáže', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 14900, weight: 300 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-03', price: 14900, calories: 700 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-04', source: 'doma' });
|
||||
|
||||
const result = await fillMealGroup(LOGIN, 'cina', 'chefie', {
|
||||
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ count: 3, dates: ['2025-03-01', '2025-03-02', '2025-03-03'] });
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0]).toMatchObject({ weight: 400, calories: 600, price: 13900 });
|
||||
expect((await getDay(LOGIN, '2025-03-02')).entries[0]).toMatchObject({ weight: 300, calories: 450 });
|
||||
// Ručně zadané kalorie zůstanou, energie na 100 g by je přepočítala
|
||||
const third = (await getDay(LOGIN, '2025-03-03')).entries[0];
|
||||
expect(third).toMatchObject({ weight: 400, calories: 700, nutrientsPer100g: { protein: 8 } });
|
||||
expect(third.caloriesPer100g).toBeUndefined();
|
||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gramáž dopočte z vlastní ceny jídla, když přibude sazba', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13200 });
|
||||
|
||||
await fillMealGroup(LOGIN, 'Čína', 'Chefie', { pricePer100g: 4400, weight: 400, caloriesPer100g: 150 });
|
||||
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0]).toMatchObject({ weight: 300, calories: 450 });
|
||||
});
|
||||
|
||||
it('vyplněné živiny nepřepíše, chybějící doplní', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', nutrientsPer100g: { protein: 5 } });
|
||||
|
||||
await fillMealGroup(LOGIN, 'Čína', 'Chefie', { nutrientsPer100g: { protein: 8, fat: 4 } });
|
||||
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0].nutrientsPer100g).toEqual({ protein: 5, fat: 4 });
|
||||
});
|
||||
|
||||
it('jídla, kterým nic nechybí, nepočítá', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', weight: 300, caloriesPer100g: 150 });
|
||||
|
||||
const result = await fillMealGroup(LOGIN, 'Čína', 'Chefie', { weight: 400, caloriesPer100g: 200 });
|
||||
|
||||
expect(result).toEqual({ count: 0, dates: [] });
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0].calories).toBe(450);
|
||||
});
|
||||
|
||||
it('bez zdroje doplní jen jídla bez zdroje', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', source: undefined });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02' });
|
||||
|
||||
const result = await fillMealGroup(LOGIN, 'Čína', undefined, { calories: 600 });
|
||||
|
||||
expect(result.dates).toEqual(['2025-03-01']);
|
||||
expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user