374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
|
|
import getStorage from "./storage";
|
|
import {
|
|
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
|
|
} from "./utils";
|
|
import { rememberCalories } from "./calories";
|
|
|
|
const storage = getStorage();
|
|
|
|
/** Maximální délka textových polí, aby se do úložiště nedostaly nesmyslně velké hodnoty. */
|
|
const MAX_TEXT_LENGTH = 500;
|
|
|
|
/**
|
|
* Pořadí typů jídel v rámci dne. Podle něj se řadí záznamy dne,
|
|
* aby přehled odpovídal skutečnému průběhu dne, ne pořadí zadání.
|
|
*/
|
|
const MEAL_TYPE_ORDER: MealType[] = [
|
|
MealType.SNIDANE,
|
|
MealType.DOPOLEDNI_SVACINA,
|
|
MealType.OBED,
|
|
MealType.ODPOLEDNI_SVACINA,
|
|
MealType.VECERE,
|
|
MealType.JINE,
|
|
];
|
|
|
|
/** Vrátí true, pokud je hodnota podporovaným typem jídla. */
|
|
export function isMealType(value: unknown): value is MealType {
|
|
return typeof value === 'string' && (MEAL_TYPE_ORDER as string[]).includes(value);
|
|
}
|
|
|
|
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
|
|
function getDayKey(login: string, date: string): string {
|
|
return `meals:${login}:${date}`;
|
|
}
|
|
|
|
/** Seřadí jídla dne dle typu (snídaně -> večeře) a při shodě dle času vzniku. */
|
|
function sortEntries(entries: MealEntry[]): MealEntry[] {
|
|
return [...entries].sort((a, b) => {
|
|
const typeDiff = MEAL_TYPE_ORDER.indexOf(a.mealType) - MEAL_TYPE_ORDER.indexOf(b.mealType);
|
|
return typeDiff !== 0 ? typeDiff : a.createdAt.localeCompare(b.createdAt);
|
|
});
|
|
}
|
|
|
|
/** Sestaví přehled dne včetně součtů z předaných záznamů. */
|
|
export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
|
|
const sorted = sortEntries(entries);
|
|
return {
|
|
date,
|
|
entries: sorted,
|
|
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
|
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Vrátí jídla uživatele za jeden den.
|
|
*
|
|
* @param login přihlašovací jméno uživatele
|
|
* @param date datum ve formátu YYYY-MM-DD
|
|
*/
|
|
export async function getDay(login: string, date: string): Promise<DayRecord> {
|
|
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
|
|
return buildDayRecord(date, entries ?? []);
|
|
}
|
|
|
|
/**
|
|
* Vrátí dny uživatele v rozsahu, které obsahují alespoň jeden záznam.
|
|
*
|
|
* @param login přihlašovací jméno uživatele
|
|
* @param from první den rozsahu (včetně), YYYY-MM-DD
|
|
* @param to poslední den rozsahu (včetně), YYYY-MM-DD
|
|
*/
|
|
export async function getRange(login: string, from: string, to: string): Promise<DayRecord[]> {
|
|
if (from > to) {
|
|
throw new BadRequestError("Začátek rozsahu je až za jeho koncem");
|
|
}
|
|
const days = await Promise.all(
|
|
listDatesInRange(from, to).map(date => getDay(login, date))
|
|
);
|
|
return days.filter(day => day.entries.length > 0);
|
|
}
|
|
|
|
/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */
|
|
function normalizeText(value: unknown, name: string): string | undefined {
|
|
if (value == null) return undefined;
|
|
if (typeof value !== 'string') {
|
|
throw new BadRequestError(`Pole '${name}' musí být text`);
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed.length) return undefined;
|
|
if (trimmed.length > MAX_TEXT_LENGTH) {
|
|
throw new BadRequestError(`Pole '${name}' může mít nejvýše ${MAX_TEXT_LENGTH} znaků`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
/** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */
|
|
function normalizeNumber(value: unknown, name: string): number | undefined {
|
|
if (value == null) return undefined;
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
|
}
|
|
const rounded = Math.round(value);
|
|
if (rounded < 0) {
|
|
throw new BadRequestError(`Pole '${name}' nesmí být záporné`);
|
|
}
|
|
return rounded;
|
|
}
|
|
|
|
/**
|
|
* Znormalizovaný vstup jídla. Oproti {@link MealInput} jsou datum, typ i název po
|
|
* validaci vždy vyplněné a nevyplněná volitelná pole jsou `undefined` místo `null`,
|
|
* takže je lze rovnou rozprostřít do {@link MealEntry}.
|
|
*/
|
|
export type NormalizedMealInput = {
|
|
date: string;
|
|
mealType: MealType;
|
|
name: string;
|
|
source?: string;
|
|
note?: string;
|
|
price?: number;
|
|
calories?: number;
|
|
weight?: number;
|
|
pricePer100g?: number;
|
|
caloriesPer100g?: number;
|
|
multiplier?: number;
|
|
};
|
|
|
|
/**
|
|
* Dopočte gramáž a kalorie z jednotkových hodnot.
|
|
*
|
|
* Podniky jako TechTower účtují podle váhy, takže z ceny a sazby za 100 g jde
|
|
* gramáž odvodit. Ručně zadaná gramáž má přednost — porce se dá zvážit přesněji,
|
|
* než kolik řekne cena, a u položek s jinou sazbou (salát) uživatel sazbu přepíše.
|
|
* Kalorie se stejně tak dopočtou z gramáže, pokud je známá energie na 100 g.
|
|
*/
|
|
export function deriveAmounts(input: NormalizedMealInput): NormalizedMealInput {
|
|
const weight = input.weight ?? (
|
|
input.price != null && input.pricePer100g
|
|
? Math.round(input.price / input.pricePer100g * 100)
|
|
: undefined
|
|
);
|
|
const calories = weight != null && input.caloriesPer100g
|
|
? Math.round(weight / 100 * input.caloriesPer100g)
|
|
: input.calories;
|
|
return { ...input, weight, calories };
|
|
}
|
|
|
|
/** Ověří a znormalizuje vstupní data jídla. */
|
|
export function normalizeInput(input: unknown): NormalizedMealInput {
|
|
const value = (input ?? {}) as Record<string, unknown>;
|
|
if (!isValidIsoDate(value.date)) {
|
|
throw new BadRequestError("Nebylo předáno platné datum ve formátu YYYY-MM-DD");
|
|
}
|
|
if (!isMealType(value.mealType)) {
|
|
throw new BadRequestError("Nebyl předán platný typ jídla");
|
|
}
|
|
const name = normalizeText(value.name, 'name');
|
|
if (!name) {
|
|
throw new BadRequestError("Nebyl předán název jídla");
|
|
}
|
|
return deriveAmounts({
|
|
date: value.date,
|
|
mealType: value.mealType,
|
|
name,
|
|
source: normalizeText(value.source, 'source'),
|
|
note: normalizeText(value.note, 'note'),
|
|
price: normalizeNumber(value.price, 'price'),
|
|
calories: normalizeNumber(value.calories, 'calories'),
|
|
weight: normalizeNumber(value.weight, 'weight'),
|
|
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
|
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
|
multiplier: normalizeMultiplier(value.multiplier),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Přidá jídlo do zvoleného dne a vrátí aktualizovaný přehled dne.
|
|
*
|
|
* @param login přihlašovací jméno uživatele
|
|
* @param input data jídla
|
|
*/
|
|
export async function addMeal(login: string, input: unknown): Promise<DayRecord> {
|
|
const normalized = normalizeInput(input);
|
|
const entry: MealEntry = {
|
|
id: randomUUID(),
|
|
createdAt: new Date().toISOString(),
|
|
...normalized,
|
|
};
|
|
const entries = await storage.updateData<MealEntry[]>(
|
|
getDayKey(login, normalized.date),
|
|
current => [...(current ?? []), entry]
|
|
);
|
|
if (normalized.caloriesPer100g) {
|
|
await rememberCalories(login, normalized.name, normalized.caloriesPer100g);
|
|
}
|
|
return buildDayRecord(normalized.date, entries);
|
|
}
|
|
|
|
/**
|
|
* Uloží najednou více záznamů (používá import). Záznamy se rozdělí dle data.
|
|
* Vrátí dny, kterých se zápis dotkl.
|
|
*
|
|
* @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[], replaceImportSource?: string): Promise<string[]> {
|
|
const byDate = new Map<string, MealEntry[]>();
|
|
for (const entry of entries) {
|
|
const forDate = byDate.get(entry.date) ?? [];
|
|
forDate.push(entry);
|
|
byDate.set(entry.date, forDate);
|
|
}
|
|
for (const [date, forDate] of byDate) {
|
|
await storage.updateData<MealEntry[]>(
|
|
getDayKey(login, date),
|
|
current => [
|
|
...(current ?? []).filter(entry => !replaceImportSource || entry.importSource !== replaceImportSource),
|
|
...forDate,
|
|
]
|
|
);
|
|
}
|
|
return Array.from(byDate.keys()).sort();
|
|
}
|
|
|
|
/**
|
|
* Upraví existující záznam o jídle a vrátí aktualizovaný přehled dne.
|
|
*
|
|
* Změna data znamená přesun mezi dny — záznam se ze starého dne odebere
|
|
* a do nového přidá. Vrací se vždy přehled cílového (nového) dne.
|
|
*
|
|
* @param login přihlašovací jméno uživatele
|
|
* @param id identifikátor upravovaného záznamu
|
|
* @param input nová data jídla
|
|
*/
|
|
export async function updateMeal(login: string, id: unknown, input: unknown): Promise<DayRecord> {
|
|
if (typeof id !== 'string' || !id.length) {
|
|
throw new BadRequestError("Nebyl předán identifikátor záznamu");
|
|
}
|
|
const normalized = normalizeInput(input);
|
|
const originalDate = await findEntryDate(login, id, normalized.date);
|
|
if (!originalDate) {
|
|
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
|
}
|
|
|
|
let original: MealEntry | undefined;
|
|
await storage.updateData<MealEntry[]>(getDayKey(login, originalDate), current => {
|
|
original = (current ?? []).find(entry => entry.id === id);
|
|
return (current ?? []).filter(entry => entry.id !== id);
|
|
});
|
|
if (!original) {
|
|
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
|
}
|
|
|
|
// Cena, kalorie, zdroj i poznámka se přepisují včetně vymazání na prázdno,
|
|
// proto se berou z normalizovaného vstupu a ne z původního záznamu.
|
|
const updated: MealEntry = {
|
|
...original,
|
|
...normalized,
|
|
price: normalized.price,
|
|
calories: normalized.calories,
|
|
source: normalized.source,
|
|
note: normalized.note,
|
|
weight: normalized.weight,
|
|
pricePer100g: normalized.pricePer100g,
|
|
caloriesPer100g: normalized.caloriesPer100g,
|
|
multiplier: normalized.multiplier,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
const entries = await storage.updateData<MealEntry[]>(
|
|
getDayKey(login, normalized.date),
|
|
current => [...(current ?? []), updated]
|
|
);
|
|
if (normalized.caloriesPer100g) {
|
|
await rememberCalories(login, normalized.name, normalized.caloriesPer100g);
|
|
}
|
|
return buildDayRecord(normalized.date, entries);
|
|
}
|
|
|
|
/**
|
|
* Smaže záznam o jídle a vrátí aktualizovaný přehled dne.
|
|
*
|
|
* @param login přihlašovací jméno uživatele
|
|
* @param id identifikátor mazaného záznamu
|
|
* @param date datum záznamu (YYYY-MM-DD)
|
|
*/
|
|
export async function deleteMeal(login: string, id: unknown, date: unknown): Promise<DayRecord> {
|
|
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");
|
|
}
|
|
let found = false;
|
|
const entries = await storage.updateData<MealEntry[]>(getDayKey(login, date), current => {
|
|
found = (current ?? []).some(entry => entry.id === id);
|
|
return (current ?? []).filter(entry => entry.id !== id);
|
|
});
|
|
if (!found) {
|
|
throw new NotFoundError("Záznam o jídle nebyl nalezen");
|
|
}
|
|
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 —
|
|
* úprava záznamu totiž může měnit i datum.
|
|
*/
|
|
async function findEntryDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
|
|
const expected = await storage.getData<MealEntry[]>(getDayKey(login, expectedDate));
|
|
if (expected?.some(entry => entry.id === id)) {
|
|
return expectedDate;
|
|
}
|
|
const prefix = `meals:${login}:`;
|
|
for (const key of await storage.listKeys(prefix)) {
|
|
if (!key.startsWith(prefix)) continue;
|
|
const entries = await storage.getData<MealEntry[]>(key);
|
|
if (entries?.some(entry => entry.id === id)) {
|
|
return key.substring(prefix.length);
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 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);
|
|
}
|