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 { const entries = await storage.getData(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 { 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; 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 { const normalized = normalizeInput(input); const entry: MealEntry = { id: randomUUID(), createdAt: new Date().toISOString(), ...normalized, }; const entries = await storage.updateData( 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 { const byDate = new Map(); 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( 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 { 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(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( 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 { 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(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 { 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(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 { const expected = await storage.getData(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(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 { const days = await Promise.all([...dates].sort().map(date => getDay(login, date))); return days.flatMap(day => day.entries); }