406 lines
17 KiB
TypeScript
406 lines
17 KiB
TypeScript
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 <input type="month">). */
|
||
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<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = String(reader.result);
|
||
// FileReader vrací "data:<mime>;base64,<data>", 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('.', ',');
|