Files
food-tracer/client/src/Utils.tsx
T

406 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('.', ',');