import { MealSuggestion, MealTemplate, Nutrients } from "../../types"; import { NUTRIENTS_IN_ORDER } from "./enums"; const TOKEN_KEY = "token"; /** Uloží token do local storage prohlížeče. */ export const storeToken = (token: string) => { localStorage.setItem(TOKEN_KEY, token); } /** Vrátí token z local storage, pokud tam je. */ export const getToken = (): string | undefined => { return localStorage.getItem(TOKEN_KEY) ?? undefined; } /** Odstraní token z local storage, pokud tam je. */ export const deleteToken = () => { localStorage.removeItem(TOKEN_KEY); } /** Vrátí datum v ISO formátu YYYY-MM-DD v lokální časové zóně. */ export function formatDate(date: Date): string { const day = String(date.getDate()).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0'); const year = String(date.getFullYear()); return `${year}-${month}-${day}`; } /** Vrátí měsíc data ve formátu YYYY-MM (hodnota pro ). */ export function formatMonth(date: Date): string { return formatDate(date).substring(0, 7); } /** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */ export function formatDateString(date: string): string { const [year, month, day] = date.split('-'); return `${day}.${month}.${year}`; } /** Převede řetězec YYYY-MM-DD na Date v lokální časové zóně. */ export function parseIsoDate(date: string): Date { const [year, month, day] = date.split('-').map(Number); return new Date(year, month - 1, day); } /** Posune datum ve formátu YYYY-MM-DD o zadaný počet dní. */ export function shiftDate(date: string, days: number): string { const shifted = parseIsoDate(date); shifted.setDate(shifted.getDate() + days); return formatDate(shifted); } /** Vrátí první den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */ export function getMonthStart(month: string): string { return `${month}-01`; } /** Vrátí poslední den měsíce (YYYY-MM) ve formátu YYYY-MM-DD. */ export function getMonthEnd(month: string): string { const [year, monthNumber] = month.split('-').map(Number); // Nultý den následujícího měsíce = poslední den tohoto měsíce return formatDate(new Date(year, monthNumber, 0)); } const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat('cs-CZ', { weekday: 'long' }); const HUMAN_DATE_FORMAT = new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'long', year: 'numeric' }); const MONTH_FORMAT = new Intl.DateTimeFormat('cs-CZ', { month: 'long', year: 'numeric' }); /** Vrátí název dne v týdnu pro datum ve formátu YYYY-MM-DD. */ export function getDayOfWeek(date: string): string { return DAY_OF_WEEK_FORMAT.format(parseIsoDate(date)); } /** Vrátí čitelný zápis data, např. "3. března 2025". */ export function getHumanDate(date: string): string { return HUMAN_DATE_FORMAT.format(parseIsoDate(date)); } /** Vrátí čitelný zápis měsíce (YYYY-MM), např. "březen 2025". */ export function getHumanMonth(month: string): string { return MONTH_FORMAT.format(parseIsoDate(`${month}-01`)); } const PRICE_FORMAT = new Intl.NumberFormat('cs-CZ', { style: 'currency', currency: 'CZK', maximumFractionDigits: 2, }); /** * Naformátuje cenu z haléřů na koruny, např. 15900 → "159,00 Kč". * Nevyplněnou cenu zobrazí jako pomlčku. */ export function formatPrice(halere?: number): string { if (halere == null) return '—'; return PRICE_FORMAT.format(halere / 100); } /** Naformátuje cenu z haléřů bez desetinných míst — pro souhrny a osy grafů. */ export function formatPriceShort(halere: number): string { return `${Math.round(halere / 100).toLocaleString('cs-CZ')} Kč`; } /** * Převede uživatelem zadanou cenu v korunách na haléře. * Přijímá desetinnou čárku i tečku. Prázdný vstup vrací undefined. */ export function parsePriceInput(value: string): number | undefined { const trimmed = value.trim().replace(',', '.'); if (!trimmed.length) return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 100) : undefined; } /** Převede haléře na text pro editační pole (koruny s desetinnou čárkou). */ export function formatPriceInput(halere?: number): string { if (halere == null) return ''; return (halere / 100).toFixed(2).replace('.', ','); } /** Naformátuje počet kalorií, např. 850 → "850 kcal". */ export function formatCalories(kcal?: number): string { if (!kcal) return '—'; return `${kcal.toLocaleString('cs-CZ')} kcal`; } /** Načte soubor jako Base64 řetězec (bez data: prefixu). */ export function readFileAsBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = String(reader.result); // FileReader vrací "data:;base64,", server chce jen data resolve(result.substring(result.indexOf(',') + 1)); }; reader.onerror = () => reject(new Error('Soubor se nepodařilo načíst')); reader.readAsDataURL(file); }); } /** * Dopočte gramáž a kalorie z jednotkových hodnot. * * Musí dávat stejné výsledky jako `deriveAmounts` na serveru — klient jen ukazuje * živý náhled toho, co server uloží. * * @param price cena v haléřích * @param pricePer100g cena za 100 g v haléřích * @param weight ručně zadaná gramáž v gramech, má přednost před dopočtem * @param caloriesPer100g energie na 100 g v kcal */ export function deriveAmounts( price?: number, pricePer100g?: number, weight?: number, caloriesPer100g?: number, ): { weight?: number, calories?: number } { const resolvedWeight = weight ?? ( price != null && pricePer100g ? Math.round(price / pricePer100g * 100) : undefined ); const calories = resolvedWeight != null && caloriesPer100g ? Math.round(resolvedWeight / 100 * caloriesPer100g) : undefined; return { weight: resolvedWeight, calories }; } /** Naformátuje gramáž, např. 400 → "400 g". */ export function formatWeight(grams?: number): string { if (grams == null) return '—'; return `${grams.toLocaleString('cs-CZ')} g`; } /** Převede celé číslo z pole na number, prázdné pole na undefined. */ export function parseIntInput(value: string): number | undefined { const trimmed = value.trim(); if (!trimmed.length) return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : undefined; } /** Naformátuje množství aktivity i s jednotkou, např. "10 000 kroků". */ export function formatQuantity(quantity: number, unitShort: string): string { return `${quantity.toLocaleString('cs-CZ')} ${unitShort}`; } /** * Dopočte spálené kalorie z množství, počtu sérií a sazby na 100 jednotek. * Musí dávat stejné výsledky jako `deriveActivityCalories` na serveru. */ export const deriveActivityCalories = ( quantity?: number, caloriesPer100Units?: number, sets?: number, ): number | undefined => { if (quantity == null || !caloriesPer100Units) return undefined; return Math.round(quantity * Math.max(sets ?? 1, 1) / 100 * caloriesPer100Units); }; /** Naformátuje bilanci se znaménkem, např. "−320 kcal" nebo "+150 kcal". */ export function formatBalance(kcal: number): string { const sign = kcal < 0 ? '−' : '+'; return `${sign}${Math.abs(kcal).toLocaleString('cs-CZ')} kcal`; } /** Naformátuje délku spánku z minut, např. 450 → "7 h 30 min". */ export const formatSleep = (minutes?: number): string => { if (!minutes) return '—'; const hours = Math.floor(minutes / 60); const rest = minutes % 60; return rest ? `${hours} h ${rest} min` : `${hours} h`; }; /** Převede minuty na hodiny s jedním desetinným místem pro editační pole. */ export const formatSleepInput = (minutes?: number): string => minutes ? (minutes / 60).toFixed(1).replace('.', ',') : ''; /** Převede hodiny zadané uživatelem (i s desetinnou čárkou) na minuty. */ export const parseSleepInput = (value: string): number | undefined => { const trimmed = value.trim().replace(',', '.'); if (!trimmed.length) return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 60) : undefined; }; /** Naformátuje hmotnost z gramů na kilogramy, např. 90000 → "90,0 kg". */ export const formatWeightKg = (grams?: number): string => grams == null ? '—' : `${(grams / 1000).toFixed(1).replace('.', ',')} kg`; /** Převede kilogramy zadané uživatelem na gramy. */ export const parseWeightKgInput = (value: string): number | undefined => { const trimmed = value.trim().replace(',', '.'); if (!trimmed.length) return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 1000) : undefined; }; /** Převede gramy na kilogramy pro editační pole. */ export const formatWeightKgInput = (grams?: number): string => grams == null ? '' : (grams / 1000).toFixed(1).replace('.', ','); /** * Naformátuje množství aktivity i se sériemi. * * Bez sérií vrací "40 opak.", se sériemi "3 × 40 opak. (120)" — celkové množství * v závorce, protože z něj vychází spálené kalorie, ale rozpad na série je to, * co odlišuje tři série s pauzami od jedné dlouhé. */ export const formatQuantityWithSets = (quantity: number, unitShort: string, sets?: number): string => { const one = formatQuantity(quantity, unitShort); if (!sets || sets <= 1) return one; return `${sets} × ${one} (${(quantity * sets).toLocaleString('cs-CZ')})`; }; /** Vrátí celkové množství aktivity — počet sérií krát množství v jedné. */ export const getTotalQuantity = (quantity?: number, sets?: number): number | undefined => quantity == null ? undefined : quantity * Math.max(sets ?? 1, 1); /** * Vrátí kalorie záznamu po započtení násobku porce. * Musí dávat stejné výsledky jako `applyMultiplier` na serveru. */ export const applyMultiplier = (calories?: number, multiplier?: number): number | undefined => calories == null ? undefined : Math.round(calories * (multiplier ?? 1)); /** Naformátuje násobek, např. 1.5 → "×1,5". */ export const formatMultiplier = (multiplier?: number): string => `×${(multiplier ?? 1).toLocaleString('cs-CZ', { maximumFractionDigits: 2 })}`; /** Převede násobek zadaný uživatelem (i s desetinnou čárkou) na číslo; nekladný vstup odmítne. */ export const parseMultiplierInput = (value: string): number | undefined => { const trimmed = value.trim().replace(',', '.'); if (!trimmed.length) return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) / 100 : undefined; }; /** Převede násobek na text pro editační pole; výchozí 1 nechá prázdné. */ export const formatMultiplierInput = (multiplier?: number): string => multiplier == null || multiplier === 1 ? '' : String(multiplier).replace('.', ','); /** Naformátuje dodatečnou zátěž z gramů, např. 12500 → "+12,5 kg". */ export const formatExtraWeight = (grams?: number): string => grams ? `+${(grams / 1000).toLocaleString('cs-CZ', { maximumFractionDigits: 2 })} kg` : ''; /** Převede gramy na kilogramy pro editační pole bez zbytečných desetinných míst. */ export const formatKgInput = (grams?: number): string => grams ? String(grams / 1000).replace('.', ',') : ''; /** Surovina receptu tak, jak ji zná výpočet — hodnoty jsou už čísla. */ type RecipeIngredientValues = { weight?: number, caloriesPer100g?: number, calories?: number, price?: number, }; /** Energie suroviny — z gramáže a energie na 100 g, jinak ručně zadaná. */ export const getIngredientCalories = (ingredient: RecipeIngredientValues): number | undefined => ingredient.weight != null && ingredient.caloriesPer100g ? Math.round(ingredient.weight / 100 * ingredient.caloriesPer100g) : ingredient.calories; /** Sečte vyplněné hodnoty; když není vyplněná žádná, vrátí undefined. */ const sumDefined = (valueList: (number | undefined)[]): number | undefined => { const definedList = valueList.filter(value => value != null); return definedList.length ? definedList.reduce((sum, value) => sum + value, 0) : undefined; }; /** * Dopočte ze surovin hodnoty hotového jídla a jedné porce. * Musí dávat stejné výsledky jako `deriveRecipe` na serveru. * * @param ingredients suroviny receptu * @param servings počet porcí, nevyplněný znamená jednu * @param cookedWeight hmotnost hotového jídla v gramech, bez ní součet surovin */ export const deriveRecipe = ( ingredients: RecipeIngredientValues[], servings?: number, cookedWeight?: number, ) => { const portions = Math.max(servings ?? 1, 1); const totalCalories = sumDefined(ingredients.map(getIngredientCalories)); const totalWeight = cookedWeight || sumDefined(ingredients.map(ingredient => ingredient.weight)) || undefined; const totalPrice = sumDefined(ingredients.map(ingredient => ingredient.price)); return { totalCalories, totalWeight, totalPrice, weight: totalWeight != null ? Math.round(totalWeight / portions) : undefined, calories: totalCalories != null ? Math.round(totalCalories / portions) : undefined, caloriesPer100g: totalCalories != null && totalWeight ? Math.round(totalCalories / totalWeight * 100) : undefined, price: totalPrice != null ? Math.round(totalPrice / portions) : undefined, }; }; /** Znormalizuje text pro hledání — bez diakritiky a malými písmeny. */ export const normalizeSearch = (value: string): string => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); /** Popíše recept jednou řádkou — zdroj a hodnoty jedné porce, co je vyplněné. */ export const describeMealTemplate = (template: MealTemplate): string => [ template.source, template.weight != null ? `porce ${formatWeight(template.weight)}` : undefined, template.calories != null ? formatCalories(template.calories) : undefined, template.price != null ? formatPrice(template.price) : undefined, ].filter(item => !!item).join(' · '); /** * Popíše jídlo z historie jednou řádkou — zdroj, gramáž, kalorie a cena, co je * vyplněné. Gramáž a kalorie se dopočtou stejně, jako je dopočte nový záznam. */ export const describeMealSuggestion = (suggestion: MealSuggestion): string => { const derived = deriveAmounts(suggestion.price, suggestion.pricePer100g, suggestion.weight, suggestion.caloriesPer100g); const calories = derived.calories ?? suggestion.calories; return [ suggestion.source, derived.weight != null ? formatWeight(derived.weight) : undefined, calories != null ? formatCalories(calories) : undefined, suggestion.price != null ? formatPrice(suggestion.price) : undefined, ].filter(item => !!item).join(' · '); }; /** Zaokrouhlí na desetiny gramu — přesnost, kterou uvádějí etikety. */ const roundTenth = (value: number): number => Math.round(value * 10) / 10; /** * Spočítá živiny porce z hodnot na 100 g, gramáže a násobku. * Musí dávat stejné výsledky jako `getPortionNutrients` na serveru. */ export const getPortionNutrients = ( nutrientsPer100g: Nutrients | undefined, weight: number | undefined, multiplier?: number, ): Nutrients | undefined => { if (!nutrientsPer100g || weight == null) return undefined; const result: Nutrients = {}; for (const key of NUTRIENTS_IN_ORDER) { const per100g = nutrientsPer100g[key]; if (per100g != null) { result[key] = roundTenth(per100g * weight / 100 * (multiplier ?? 1)); } } return result; }; /** Naformátuje množství živiny v gramech, např. 6.3 → "6,3 g". */ export const formatNutrient = (grams?: number): string => grams == null ? '—' : `${grams.toLocaleString('cs-CZ', { maximumFractionDigits: 1 })} g`; /** Převede gramy živiny zadané uživatelem (i s desetinnou čárkou) na číslo; záporný vstup odmítne. */ export const parseNutrientInput = (value: string): number | undefined => { const trimmed = value.trim().replace(',', '.'); if (!trimmed.length) return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) && parsed >= 0 ? roundTenth(parsed) : undefined; }; /** Převede gramy živiny na text pro editační pole. */ export const formatNutrientInput = (grams?: number): string => grams == null ? '' : String(grams).replace('.', ',');