feat: import jen některých dnů
feat: násobení jídel/cviků feat: refaktor napojování šablon
This commit is contained in:
1 parent
c3990d80f8
commit
8dac67bdd1
37 files changed
+2270
-437
No files matched your search
@@ -74,7 +74,7 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
`TemplatesPage` (šablony tréninků), `ProfilePage` (tělesné údaje a klidový výdej),
|
`TemplatesPage` (šablony tréninků), `ProfilePage` (tělesné údaje a klidový výdej),
|
||||||
`StatsPage` (statistiky + import)
|
`StatsPage` (statistiky + import)
|
||||||
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
|
- **Components:** `components/` (Header, CalorieLookup) a `components/modals/`
|
||||||
(MealModal, ActivityModal, ApplyWorkoutModal, SettingsModal, ImportModal)
|
(MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal, SettingsModal, ImportModal)
|
||||||
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
- **Klientské enumy** patří do `enums.ts` jako TypeScript `enum` s mapou popisků,
|
||||||
ne jako inline union u komponenty (`DayTab`, `StatsRange`)
|
ne jako inline union u komponenty (`DayTab`, `StatsRange`)
|
||||||
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
- **Context:** `context/auth.tsx` (JWT), `context/settings.tsx` (světlý/tmavý motiv)
|
||||||
@@ -111,7 +111,23 @@ uživatel vybere zdroj.
|
|||||||
|
|
||||||
`client/src/Utils.tsx` má stejnojmennou funkci pro živý náhled ve formuláři.
|
`client/src/Utils.tsx` má stejnojmennou funkci pro živý náhled ve formuláři.
|
||||||
**Když se změní jedna, musí se změnit i druhá** — jinak klient ukazuje něco jiného,
|
**Když se změní jedna, musí se změnit i druhá** — jinak klient ukazuje něco jiného,
|
||||||
než server uloží.
|
než server uloží. Totéž platí pro `applyMultiplier` (`server/src/utils.ts`)
|
||||||
|
a `deriveRecipe` (`server/src/mealTemplates.ts`).
|
||||||
|
|
||||||
|
## Násobek položky dne
|
||||||
|
|
||||||
|
Jídlo i aktivita mají volitelný `multiplier` (i necelý, 0,01–100): dvě sušenky
|
||||||
|
jsou ×2, nedojedená polovina ×0,5. Mění se přímo z přehledu dne
|
||||||
|
(`/meals/multiplier`, `/activities/multiplier`), bez otevření formuláře.
|
||||||
|
|
||||||
|
- **Uložené `calories` jsou vždy za jednu porci** a výsledek je
|
||||||
|
`applyMultiplier(calories, multiplier)`. Kdyby se ukládal už vynásobený
|
||||||
|
výsledek, opakovaná změna násobku by kumulovala zaokrouhlení.
|
||||||
|
- Všechny součty (`buildDayRecord`, `buildActivityDay`, `statsService`) proto
|
||||||
|
musí jít přes `applyMultiplier`, ne přes holé `calories`.
|
||||||
|
- **Cena se nenásobí** — za nedojedené jídlo se platí celé. Dvě kusy se stejnou
|
||||||
|
cenou jsou dva záznamy, nebo jedna cena zadaná za oba.
|
||||||
|
- Násobek 1 je výchozí stav a neukládá se (stejně jako jedna série).
|
||||||
|
|
||||||
## Zdroje energetických hodnot
|
## Zdroje energetických hodnot
|
||||||
|
|
||||||
@@ -159,6 +175,11 @@ umí `createMealTemplateFromMeal`, které navíc **přepíše šablonu stejného
|
|||||||
opakované "uložit jako šablonu" u téhož jídla má dát jednu šablonu, ne několik
|
opakované "uložit jako šablonu" u téhož jídla má dát jednu šablonu, ne několik
|
||||||
stejných. Záznamy ve dni zůstanou v obou případech beze změny.
|
stejných. Záznamy ve dni zůstanou v obou případech beze změny.
|
||||||
|
|
||||||
|
**Dodatečná zátěž** (`extraWeightGrams` u aktivity i cviku šablony, v gramech)
|
||||||
|
je jen informace o tréninku, kalorie **nemění** — žádný jednoduchý vzorec
|
||||||
|
spotřebu podle zátěže nedává, takže by šlo o vymyšlené číslo. Kdo chce, promítne
|
||||||
|
ji do sazby na 100 jednotek.
|
||||||
|
|
||||||
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď
|
`WorkoutTemplate` (`workouts.ts`) je pojmenovaný seznam položek. Položka je buď
|
||||||
cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se
|
cvik (`kind: CVIK`), nebo odkaz na jinou šablonu (`kind: SABLONA`), jejíž cviky se
|
||||||
při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou
|
při použití rozbalí. Použitím vzniknou běžné aktivity s vazbou `templateId` — jsou
|
||||||
@@ -196,16 +217,27 @@ takže `DayPage` si vystačí s jedním voláním.
|
|||||||
## Šablony jídel
|
## Šablony jídel
|
||||||
|
|
||||||
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je opakovaně
|
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je opakovaně
|
||||||
jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných** — je to plochý
|
jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných**. Použitím
|
||||||
záznam. Použitím vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu
|
vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu i kalorie dopočítá
|
||||||
i kalorie dopočítá stejná logika jako u ručně zadaného jídla.
|
stejná logika jako u ručně zadaného jídla. Při zakládání jídla v přehledu dne se
|
||||||
|
šablony nabízejí k rozepsanému názvu (`TemplateSuggestions`).
|
||||||
|
|
||||||
Šablona může nést buď `calories` (celá porce), nebo `caloriesPer100g` + `weight`.
|
Šablona má druh (`kind`):
|
||||||
Obojí projde `deriveAmounts`, takže stačí vyplnit, co o jídle víte.
|
|
||||||
|
|
||||||
Pole `note` slouží i na postup přípravy u domácích jídel. Strukturované suroviny
|
- **HOTOVE** — objednané nebo koupené jídlo. Nese jen výsledné hodnoty porce:
|
||||||
(recept jako seznam položek s množstvím) mohou přibýt později jako další volitelné
|
buď `calories`, nebo `caloriesPer100g` + `weight`; obojí projde `deriveAmounts`.
|
||||||
pole, aniž by se muselo měnit cokoli stávajícího.
|
- **RECEPT** — vařené jídlo se surovinami (`ingredients`), počtem porcí
|
||||||
|
(`servings`), hmotností hotového jídla (`cookedWeight`) a postupem v `note`.
|
||||||
|
`deriveRecipe` z nich při uložení spočítá hodnoty **jedné porce** a uloží je
|
||||||
|
do stejných plochých polí (`weight`, `caloriesPer100g`, `calories`, `price`),
|
||||||
|
takže použití šablony je pro oba druhy stejné. Energie na 100 g se bere
|
||||||
|
z hmotnosti hotového jídla, ne ze součtu surovin — vařením se voda odpaří
|
||||||
|
nebo nasákne. Postup se do záznamu dne nekopíruje.
|
||||||
|
|
||||||
|
Šablony uložené dřív, než druhy přibyly, nemají `kind`; `withKind` jim ho při
|
||||||
|
načtení doplní na `HOTOVE`, v úložišti se nic nepřepisuje.
|
||||||
|
`createMealTemplateFromMeal` recept stejného názvu **nepřepíše** (přišel by
|
||||||
|
o suroviny a postup) a odmítne to chybou.
|
||||||
|
|
||||||
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate`
|
Kontrakt má vlastní `MealTemplateInput`, kde je `id` volitelné — `MealTemplate`
|
||||||
ho vyžaduje a nešlo by jím posílat novou šablonu.
|
ho vyžaduje a nešlo by jím posílat novou šablonu.
|
||||||
@@ -229,7 +261,13 @@ ve všech třech formátech, které Luncher exportuje (XLSX, CSV, JSON — viz
|
|||||||
nejsou místa, takže se jako zdroj nepoužijí a záznam zůstane bez zdroje.
|
nejsou místa, takže se jako zdroj nepoužijí a záznam zůstane bez zdroje.
|
||||||
- Duplicity řeší `importKey` složený z data, pořadí řádku v rámci dne, typu,
|
- Duplicity řeší `importKey` složený z data, pořadí řádku v rámci dne, typu,
|
||||||
jídla a částky — opakovaný import stejného měsíce nic nezduplikuje.
|
jídla a částky — opakovaný import stejného měsíce nic nezduplikuje.
|
||||||
|
- Režim (`ImportMode`): **PRIDAT** (výchozí) přeskočí už naimportované řádky;
|
||||||
|
**NAHRADIT** v dnech, do kterých se něco zakládá, odebere dřívější záznamy
|
||||||
|
z importu a založí je znovu. Ručně zadaná jídla zůstávají v obou režimech.
|
||||||
- Endpoint podporuje `dryRun` pro náhled před uložením; klient ho vždy použije.
|
- Endpoint podporuje `dryRun` pro náhled před uložením; klient ho vždy použije.
|
||||||
|
Náhled vrací všechny řádky, které založit jde, i jídla, která už v dotčených
|
||||||
|
dnech jsou (`existing`). Uživatel v něm řádky odškrtá a ostrý import dostane
|
||||||
|
jen `selectedKeys`.
|
||||||
|
|
||||||
**Když se změní formát exportu v Luncheru, je potřeba upravit i tento parser**
|
**Když se změní formát exportu v Luncheru, je potřeba upravit i tento parser**
|
||||||
a testy v `server/src/tests/luncherImport.test.ts`, které si export sestavují
|
a testy v `server/src/tests/luncherImport.test.ts`, které si export sestavují
|
||||||
|
|||||||
+127
-1
@@ -702,13 +702,19 @@ body {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workout-item-weight {
|
||||||
|
width: 100px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.workout-item-sets,
|
.workout-item-sets,
|
||||||
.workout-item-quantity,
|
.workout-item-quantity,
|
||||||
.workout-item-unit,
|
.workout-item-unit,
|
||||||
.workout-item-rate {
|
.workout-item-rate,
|
||||||
|
.workout-item-weight {
|
||||||
width: auto;
|
width: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -771,3 +777,123 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--ft-text-secondary);
|
color: var(--ft-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// NÁSOBEK POLOŽKY DNE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Výchozí ×1 je nenápadné, změněný násobek musí být vidět na první pohled
|
||||||
|
.multiplier-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--ft-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 9px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
transition: var(--ft-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ft-text);
|
||||||
|
border-color: var(--ft-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
background: var(--ft-primary-light);
|
||||||
|
color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplier-popover {
|
||||||
|
.multiplier-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplier-custom {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiplier-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ŠABLONY JÍDEL — návrhy a skupiny
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Návrhy šablon pod názvem jídla ve formuláři
|
||||||
|
.template-suggestions {
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid var(--ft-border-light);
|
||||||
|
border-radius: var(--ft-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.template-suggestions-title {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
background: var(--ft-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--ft-border-light);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.87rem;
|
||||||
|
color: var(--ft-text);
|
||||||
|
transition: var(--ft-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--ft-bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-suggestion-icon {
|
||||||
|
color: var(--ft-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-suggestion-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-suggestion-detail {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.apply-template-group-title {
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { MealTemplate } from "../../types";
|
||||||
|
|
||||||
const TOKEN_KEY = "token";
|
const TOKEN_KEY = "token";
|
||||||
|
|
||||||
/** Uloží token do local storage prohlížeče. */
|
/** Uloží token do local storage prohlížeče. */
|
||||||
@@ -253,3 +255,103 @@ export const formatQuantityWithSets = (quantity: number, unitShort: string, sets
|
|||||||
/** Vrátí celkové množství aktivity — počet sérií krát množství v jedné. */
|
/** 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 =>
|
export const getTotalQuantity = (quantity?: number, sets?: number): number | undefined =>
|
||||||
quantity == null ? undefined : quantity * Math.max(sets ?? 1, 1);
|
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 šablonu jídla jednou řádkou — zdroj, gramáž porce, kalorie a cena,
|
||||||
|
* co je vyplněné. U receptu jsou to hodnoty jedné porce, které dopočítal server.
|
||||||
|
*/
|
||||||
|
export const describeMealTemplate = (template: MealTemplate): string => {
|
||||||
|
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||||
|
const calories = derived.calories ?? template.calories;
|
||||||
|
return [
|
||||||
|
template.source,
|
||||||
|
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
||||||
|
calories != null ? formatCalories(calories) : undefined,
|
||||||
|
template.price != null ? formatPrice(template.price) : undefined,
|
||||||
|
].filter(item => !!item).join(' · ');
|
||||||
|
};
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { ActivityEntry } from "../../../types";
|
import { ActivityEntry } from "../../../types";
|
||||||
import { ACTIVITY_UNIT_SHORT } from "../enums";
|
import { ACTIVITY_UNIT_SHORT } from "../enums";
|
||||||
import { formatCalories, formatQuantityWithSets } from "../Utils";
|
import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils";
|
||||||
import RowActions from "./RowActions";
|
import RowActions from "./RowActions";
|
||||||
|
import MultiplierControl from "./MultiplierControl";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entry: ActivityEntry,
|
entry: ActivityEntry,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
onDelete: () => void,
|
onDelete: () => void,
|
||||||
onSaveTemplate: () => void,
|
onSaveTemplate: () => void,
|
||||||
|
onMultiplierChange: (multiplier: number) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Jeden řádek seznamu pohybových aktivit v přehledu dne. */
|
/** Jeden řádek seznamu pohybových aktivit v přehledu dne. */
|
||||||
const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) => (
|
const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly<Props>) => (
|
||||||
<div className="meal-row">
|
<div className="meal-row">
|
||||||
<div className="meal-main">
|
<div className="meal-main">
|
||||||
<div className="meal-name">{entry.name}</div>
|
<div className="meal-name">{entry.name}</div>
|
||||||
@@ -19,11 +21,25 @@ const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props
|
|||||||
<span className="tag">
|
<span className="tag">
|
||||||
{formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)}
|
{formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)}
|
||||||
</span>
|
</span>
|
||||||
|
{!!entry.extraWeightGrams && (
|
||||||
|
<span className="tag" title="Dodatečná zátěž">{formatExtraWeight(entry.extraWeightGrams)}</span>
|
||||||
|
)}
|
||||||
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
{entry.templateId && <span className="tag tag-import">ze šablony</span>}
|
||||||
</div>
|
</div>
|
||||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="meal-price burn">{formatCalories(entry.calories)}</div>
|
<MultiplierControl
|
||||||
|
multiplier={entry.multiplier}
|
||||||
|
calories={entry.calories}
|
||||||
|
onChange={onMultiplierChange}
|
||||||
|
label="aktivity"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="meal-price burn"
|
||||||
|
title={entry.multiplier ? `${formatCalories(entry.calories)} za jedno provedení` : undefined}
|
||||||
|
>
|
||||||
|
{formatCalories(applyMultiplier(entry.calories, entry.multiplier))}
|
||||||
|
</div>
|
||||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="aktivitu" />
|
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="aktivitu" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Form } from "react-bootstrap";
|
||||||
|
import { ImportMode, ImportResult, MealEntry } from "../../../types";
|
||||||
|
import { MEAL_TYPE_ICONS } from "../enums";
|
||||||
|
import { formatPrice, getDayOfWeek, getHumanDate } from "../Utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
preview: ImportResult,
|
||||||
|
/** Klíče (`importKey`) zaškrtnutých řádků. */
|
||||||
|
selected: Set<string>,
|
||||||
|
onToggle: (key: string) => void,
|
||||||
|
onToggleDay: (date: string, checked: boolean) => void,
|
||||||
|
/** Rozhodne, jestli jde o záznam, který režim NAHRADIT odebere. */
|
||||||
|
isReplaced: (entry: MealEntry) => boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Náhled importu rozdělený po dnech.
|
||||||
|
*
|
||||||
|
* U každého dne je vidět, co v něm už je — ručně zadaná jídla i dřívější import —
|
||||||
|
* aby šlo posoudit, jestli se něco nezdvojí, a řádek odškrtnout.
|
||||||
|
*/
|
||||||
|
const ImportPreview = ({ preview, selected, onToggle, onToggleDay, isReplaced }: Readonly<Props>) => {
|
||||||
|
const dateList = Array.from(new Set(preview.entries.map(entry => entry.date))).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="import-preview">
|
||||||
|
{dateList.map(date => {
|
||||||
|
const entryList = preview.entries.filter(entry => entry.date === date);
|
||||||
|
const existingList = preview.existing.filter(entry => entry.date === date);
|
||||||
|
const checkedCount = entryList.filter(entry => selected.has(entry.importKey!)).length;
|
||||||
|
return (
|
||||||
|
<div className="import-preview-day" key={date}>
|
||||||
|
<Form.Check
|
||||||
|
className="import-preview-day-title"
|
||||||
|
id={`import-day-${date}`}
|
||||||
|
checked={checkedCount === entryList.length}
|
||||||
|
label={`${getHumanDate(date)} · ${getDayOfWeek(date)}`}
|
||||||
|
onChange={event => onToggleDay(date, event.target.checked)}
|
||||||
|
/>
|
||||||
|
{existingList.map(entry => {
|
||||||
|
// Nahrazuje se jen ve dnech, do kterých se něco zakládá
|
||||||
|
const replaced = checkedCount > 0 && isReplaced(entry);
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className={`import-preview-existing ${replaced ? 'replaced' : ''}`}>
|
||||||
|
<span>{MEAL_TYPE_ICONS[entry.mealType]}</span>
|
||||||
|
<span className="import-preview-name">
|
||||||
|
{entry.name}
|
||||||
|
<span className="import-preview-note">
|
||||||
|
{replaced
|
||||||
|
? ' · bude nahrazeno'
|
||||||
|
: ` · už máte${entry.importSource ? ' z importu' : ''}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{entryList.map(entry => (
|
||||||
|
<Form.Check
|
||||||
|
key={entry.importKey}
|
||||||
|
id={`import-row-${entry.importKey}`}
|
||||||
|
className="import-preview-row"
|
||||||
|
checked={selected.has(entry.importKey!)}
|
||||||
|
onChange={() => onToggle(entry.importKey!)}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<span className="import-preview-name">
|
||||||
|
{entry.name}
|
||||||
|
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{preview.mode === ImportMode.PRIDAT && preview.skipped > 0 && (
|
||||||
|
<div className="import-preview-skipped">
|
||||||
|
{preview.skipped} řádků se přeskočí, protože už je máte z dřívějšího importu.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportPreview;
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { MealEntry } from "../../../types";
|
import { MealEntry } from "../../../types";
|
||||||
import { formatCalories, formatPrice, formatWeight } from "../Utils";
|
import { applyMultiplier, formatCalories, formatPrice, formatWeight } from "../Utils";
|
||||||
import RowActions from "./RowActions";
|
import RowActions from "./RowActions";
|
||||||
|
import MultiplierControl from "./MultiplierControl";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
entry: MealEntry,
|
entry: MealEntry,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
onDelete: () => void,
|
onDelete: () => void,
|
||||||
onSaveTemplate: () => void,
|
onSaveTemplate: () => void,
|
||||||
|
onMultiplierChange: (multiplier: number) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Jeden řádek seznamu jídel v přehledu dne. */
|
/** Jeden řádek seznamu jídel v přehledu dne. */
|
||||||
const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) => (
|
const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly<Props>) => {
|
||||||
|
const calories = applyMultiplier(entry.calories, entry.multiplier);
|
||||||
|
return (
|
||||||
<div className="meal-row">
|
<div className="meal-row">
|
||||||
<div className="meal-main">
|
<div className="meal-main">
|
||||||
<div className="meal-name">{entry.name}</div>
|
<div className="meal-name">{entry.name}</div>
|
||||||
@@ -18,13 +22,27 @@ const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly<Props>) =
|
|||||||
{entry.source && <span className="tag">{entry.source}</span>}
|
{entry.source && <span className="tag">{entry.source}</span>}
|
||||||
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
{entry.importSource && <span className="tag tag-import">z Luncheru</span>}
|
||||||
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
{entry.weight != null && <span className="tag">{formatWeight(entry.weight)}</span>}
|
||||||
{entry.calories != null && <span className="tag">{formatCalories(entry.calories)}</span>}
|
{calories != null && (
|
||||||
|
<span
|
||||||
|
className="tag"
|
||||||
|
title={entry.multiplier ? `${formatCalories(entry.calories)} za porci` : undefined}
|
||||||
|
>
|
||||||
|
{formatCalories(calories)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entry.note && <div className="meal-note">{entry.note}</div>}
|
{entry.note && <div className="meal-note">{entry.note}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
<MultiplierControl
|
||||||
|
multiplier={entry.multiplier}
|
||||||
|
calories={entry.calories}
|
||||||
|
onChange={onMultiplierChange}
|
||||||
|
label="jídla"
|
||||||
|
/>
|
||||||
<div className="meal-price">{formatPrice(entry.price)}</div>
|
<div className="meal-price">{formatPrice(entry.price)}</div>
|
||||||
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="jídlo" />
|
<RowActions onEdit={onEdit} onDelete={onDelete} onSaveTemplate={onSaveTemplate} label="jídlo" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default MealRow;
|
export default MealRow;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { MealTemplate, MealTemplateKind } from "../../../types";
|
||||||
|
import { MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||||
|
import {
|
||||||
|
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories,
|
||||||
|
} from "../Utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
template: MealTemplate,
|
||||||
|
busy: boolean,
|
||||||
|
onEdit: () => void,
|
||||||
|
onDelete: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Karta šablony jídla na stránce Šablony.
|
||||||
|
*
|
||||||
|
* U receptu ukazuje suroviny, hodnoty celého hrnce i jedné porce a postup;
|
||||||
|
* u hotového jídla jen výsledné hodnoty, které se do dne zakládají.
|
||||||
|
*/
|
||||||
|
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
||||||
|
const isRecipe = template.kind === MealTemplateKind.RECEPT;
|
||||||
|
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
||||||
|
const calories = derived.calories ?? template.calories;
|
||||||
|
const recipe = isRecipe
|
||||||
|
? deriveRecipe(template.ingredients ?? [], template.servings, template.cookedWeight)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-panel template-card">
|
||||||
|
<div className="template-head">
|
||||||
|
<div className="template-title">
|
||||||
|
{template.name}
|
||||||
|
{template.mealType && (
|
||||||
|
<span className="tag">
|
||||||
|
{MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{template.source && <span className="tag">{template.source}</span>}
|
||||||
|
{derived.weight != null && (
|
||||||
|
<span className="tag">{isRecipe ? 'porce ' : ''}{formatWeight(derived.weight)}</span>
|
||||||
|
)}
|
||||||
|
{isRecipe && (template.servings ?? 1) > 1 && (
|
||||||
|
<span className="tag">{template.servings} porcí</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{calories != null && <span className="template-calories">{formatCalories(calories)}</span>}
|
||||||
|
{template.price != null && (
|
||||||
|
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
||||||
|
)}
|
||||||
|
<button className="btn-icon" onClick={() => onEdit()} title="Upravit" aria-label="Upravit šablonu">
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => onDelete()}
|
||||||
|
disabled={busy}
|
||||||
|
title="Smazat"
|
||||||
|
aria-label="Smazat šablonu"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRecipe && !!template.ingredients?.length && (
|
||||||
|
<ul className="template-items">
|
||||||
|
{template.ingredients.map((ingredient, index) => (
|
||||||
|
// Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní
|
||||||
|
<li key={index}>
|
||||||
|
<span className="template-item-name">{ingredient.name}</span>
|
||||||
|
<span className="template-item-quantity">
|
||||||
|
{ingredient.weight != null ? formatWeight(ingredient.weight) : ''}
|
||||||
|
</span>
|
||||||
|
<span className="template-item-calories">
|
||||||
|
{formatCalories(getIngredientCalories(ingredient))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe && (
|
||||||
|
<div className="recipe-summary">
|
||||||
|
Celkem {formatCalories(recipe.totalCalories)}
|
||||||
|
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||||
|
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||||
|
{recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{template.note && <p className="meal-template-note">{template.note}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealTemplateCard;
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Button, ButtonGroup, Form } from "react-bootstrap";
|
||||||
|
import { MealTemplate, MealTemplateKind, MealType, saveMealTemplate } from "../../../types";
|
||||||
|
import {
|
||||||
|
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||||
|
} from "../enums";
|
||||||
|
import {
|
||||||
|
deriveAmounts, deriveRecipe, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
||||||
|
parseIntInput, parsePriceInput,
|
||||||
|
} from "../Utils";
|
||||||
|
import CalorieLookup from "./CalorieLookup";
|
||||||
|
import RecipeIngredientsEditor, {
|
||||||
|
DraftIngredient, emptyIngredient, toDraftIngredient, toIngredient,
|
||||||
|
} from "./RecipeIngredientsEditor";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Upravovaná šablona. Pokud chybí, zakládá se nová. */
|
||||||
|
template?: MealTemplate,
|
||||||
|
onSaved: (templates: MealTemplate[]) => void,
|
||||||
|
onCancel: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Naplní formulář z šablony, nebo ho nechá prázdný pro novou. */
|
||||||
|
const createForm = (template?: MealTemplate) => ({
|
||||||
|
kind: template?.kind ?? MealTemplateKind.HOTOVE,
|
||||||
|
name: template?.name ?? '',
|
||||||
|
source: template?.source ?? '',
|
||||||
|
mealType: (template?.mealType ?? '') as MealType | '',
|
||||||
|
weight: template?.weight != null ? String(template.weight) : '',
|
||||||
|
caloriesPer100g: template?.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
||||||
|
calories: template?.calories != null ? String(template.calories) : '',
|
||||||
|
price: formatPriceInput(template?.price),
|
||||||
|
pricePer100g: formatPriceInput(template?.pricePer100g),
|
||||||
|
note: template?.note ?? '',
|
||||||
|
servings: template?.servings != null ? String(template.servings) : '',
|
||||||
|
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formulář šablony jídla.
|
||||||
|
*
|
||||||
|
* Hotové jídlo (objednané, koupené) si pamatuje jen výsledné hodnoty porce.
|
||||||
|
* Recept je vařené jídlo: zapisují se suroviny a postup a hodnoty porce se
|
||||||
|
* z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
|
||||||
|
*/
|
||||||
|
const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
||||||
|
const [form, setForm] = useState(() => createForm(template));
|
||||||
|
const [ingredients, setIngredients] = useState<DraftIngredient[]>(() =>
|
||||||
|
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
||||||
|
const [error, setError] = useState<string | undefined>();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const update = (changes: Partial<ReturnType<typeof createForm>>) =>
|
||||||
|
setForm(current => ({ ...current, ...changes }));
|
||||||
|
|
||||||
|
const isRecipe = form.kind === MealTemplateKind.RECEPT;
|
||||||
|
const filledIngredients = ingredients.filter(item => item.name.trim().length > 0).map(toIngredient);
|
||||||
|
const recipe = deriveRecipe(filledIngredients, parseIntInput(form.servings), parseIntInput(form.cookedWeight));
|
||||||
|
const ingredientWeight = filledIngredients.reduce((sum, item) => sum + (item.weight ?? 0), 0);
|
||||||
|
|
||||||
|
const preview = deriveAmounts(
|
||||||
|
parsePriceInput(form.price),
|
||||||
|
parsePriceInput(form.pricePer100g),
|
||||||
|
parseIntInput(form.weight),
|
||||||
|
parseIntInput(form.caloriesPer100g));
|
||||||
|
const previewCalories = preview.calories ?? parseIntInput(form.calories);
|
||||||
|
|
||||||
|
const canSave = form.name.trim().length > 0 && (!isRecipe || filledIngredients.length > 0) && !busy;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!canSave) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(undefined);
|
||||||
|
const common = {
|
||||||
|
...(template ? { id: template.id } : {}),
|
||||||
|
kind: form.kind,
|
||||||
|
name: form.name.trim(),
|
||||||
|
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||||
|
...(form.mealType ? { mealType: form.mealType } : {}),
|
||||||
|
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||||
|
};
|
||||||
|
// Hodnoty porce receptu si server dopočte ze surovin sám
|
||||||
|
const body = isRecipe
|
||||||
|
? {
|
||||||
|
...common,
|
||||||
|
ingredients: filledIngredients,
|
||||||
|
servings: parseIntInput(form.servings),
|
||||||
|
cookedWeight: parseIntInput(form.cookedWeight),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...common,
|
||||||
|
weight: parseIntInput(form.weight),
|
||||||
|
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
||||||
|
calories: parseIntInput(form.calories),
|
||||||
|
price: parsePriceInput(form.price),
|
||||||
|
pricePer100g: parsePriceInput(form.pricePer100g),
|
||||||
|
};
|
||||||
|
const response = await saveMealTemplate({ body });
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) {
|
||||||
|
onSaved(response.data);
|
||||||
|
} else {
|
||||||
|
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-panel template-form">
|
||||||
|
<h2 className="chart-title">{template ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
||||||
|
|
||||||
|
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||||
|
|
||||||
|
<ButtonGroup className="mb-2">
|
||||||
|
{MEAL_TEMPLATE_KINDS_IN_ORDER.map(kind => (
|
||||||
|
<Button
|
||||||
|
key={kind}
|
||||||
|
variant={form.kind === kind ? 'primary' : 'outline-secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => update({ kind })}
|
||||||
|
>
|
||||||
|
{MEAL_TEMPLATE_KIND_NAMES[kind]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</ButtonGroup>
|
||||||
|
<p className="text-body-secondary small">
|
||||||
|
{isRecipe
|
||||||
|
? 'Vařené jídlo — zapíšete suroviny a postup, kalorie a cena porce se dopočtou samy.'
|
||||||
|
: 'Objednané nebo koupené jídlo — stačí jednou zapsat hodnoty a příště je nemusíte dohledávat.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label>Název</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={form.name}
|
||||||
|
placeholder={isRecipe ? 'Např. Kuřecí rizoto' : 'Např. Sušenka BeBe'}
|
||||||
|
onChange={event => update({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Zdroj</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
value={form.source}
|
||||||
|
placeholder={isRecipe ? 'doma' : 'Chefie, Albert…'}
|
||||||
|
onChange={event => update({ source: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Chod</Form.Label>
|
||||||
|
<Form.Select
|
||||||
|
value={form.mealType}
|
||||||
|
onChange={event => update({ mealType: event.target.value as MealType | '' })}
|
||||||
|
>
|
||||||
|
<option value="">— vybrat při použití —</option>
|
||||||
|
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRecipe ? (
|
||||||
|
<>
|
||||||
|
<fieldset className="meal-fieldset mb-3">
|
||||||
|
<legend>Suroviny</legend>
|
||||||
|
<RecipeIngredientsEditor ingredients={ingredients} onChange={setIngredients} />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="meal-fieldset mb-3">
|
||||||
|
<legend>Výsledek</legend>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Počet porcí</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.servings}
|
||||||
|
placeholder="1"
|
||||||
|
onChange={event => update({ servings: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
|
<Form.Label>Hmotnost hotového jídla</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.cookedWeight}
|
||||||
|
placeholder={ingredientWeight ? String(ingredientWeight) : 'volitelné'}
|
||||||
|
onChange={event => update({ cookedWeight: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">g</span>
|
||||||
|
</div>
|
||||||
|
<Form.Text>vařením se mění, bez ní se vezme součet surovin</Form.Text>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label>Celkem / jedna porce</Form.Label>
|
||||||
|
<div className="recipe-summary">
|
||||||
|
{formatCalories(recipe.totalCalories)}
|
||||||
|
{recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}</>}
|
||||||
|
{recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g</>}
|
||||||
|
<div className="recipe-summary-portion">
|
||||||
|
porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)}
|
||||||
|
{recipe.price != null && <> · {formatPrice(recipe.price)}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<fieldset className="meal-fieldset mb-3">
|
||||||
|
<legend>Kalorie, gramáž a cena porce</legend>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Gramáž</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.weight}
|
||||||
|
placeholder="350"
|
||||||
|
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">g</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Energie na 100 g</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.caloriesPer100g}
|
||||||
|
placeholder="160"
|
||||||
|
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kcal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Cena</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.price}
|
||||||
|
placeholder="129,00"
|
||||||
|
onChange={event => update({ price: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">Kč</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Cena za 100 g</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.pricePer100g}
|
||||||
|
placeholder="volitelné"
|
||||||
|
onChange={event => update({ pricePer100g: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">Kč</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-3 mt-0">
|
||||||
|
{preview.calories == null && (
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Kalorie porce</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={form.calories}
|
||||||
|
placeholder="volitelné"
|
||||||
|
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kcal</span>
|
||||||
|
</div>
|
||||||
|
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="col-sm-6 d-flex align-items-end">
|
||||||
|
<CalorieLookup
|
||||||
|
name={form.name}
|
||||||
|
onPick={value => update({ caloriesPer100g: String(value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>{isRecipe ? 'Postup' : 'Poznámka'}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
rows={isRecipe ? 6 : 2}
|
||||||
|
value={form.note}
|
||||||
|
placeholder={isRecipe ? 'Jak se jídlo připravuje…' : undefined}
|
||||||
|
onChange={event => update({ note: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<div className="workout-form-footer">
|
||||||
|
{!isRecipe && previewCalories != null && (
|
||||||
|
<span className="workout-draft-calories">
|
||||||
|
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
||||||
|
{formatCalories(previewCalories)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isRecipe && recipe.calories != null && (
|
||||||
|
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
||||||
|
)}
|
||||||
|
{template && (
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
||||||
|
{template ? 'Uložit změny' : 'Vytvořit šablonu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealTemplateForm;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, Form, OverlayTrigger, Popover } from "react-bootstrap";
|
||||||
|
import { MULTIPLIER_PRESETS } from "../enums";
|
||||||
|
import { applyMultiplier, formatMultiplier, formatMultiplierInput, parseMultiplierInput } from "../Utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
multiplier?: number,
|
||||||
|
/** Kalorie jedné porce — v nabídce se z nich ukazuje, kolik vyjde po vynásobení. */
|
||||||
|
calories?: number,
|
||||||
|
onChange: (multiplier: number) => void,
|
||||||
|
/** Doplní se do popisku pro čtečky, např. "jídla". */
|
||||||
|
label: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Násobek položky dne přímo v přehledu, bez otevírání formuláře.
|
||||||
|
*
|
||||||
|
* Nabízí běžné předvolby a vlastní hodnotu, i necelou — polovina nedojedeného
|
||||||
|
* jídla je 0,5, dvě sušenky 2. Násobí se jen kalorie.
|
||||||
|
*/
|
||||||
|
const MultiplierControl = ({ multiplier, calories, onChange, label }: Readonly<Props>) => {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const current = multiplier ?? 1;
|
||||||
|
const parsed = parseMultiplierInput(input);
|
||||||
|
|
||||||
|
const pick = (value: number) => {
|
||||||
|
setShow(false);
|
||||||
|
if (value !== current) onChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const popover = (
|
||||||
|
<Popover className="multiplier-popover">
|
||||||
|
<Popover.Body>
|
||||||
|
<div className="multiplier-presets">
|
||||||
|
{MULTIPLIER_PRESETS.map(preset => (
|
||||||
|
<Button
|
||||||
|
key={preset}
|
||||||
|
size="sm"
|
||||||
|
variant={preset === current ? 'primary' : 'outline-secondary'}
|
||||||
|
onClick={() => pick(preset)}
|
||||||
|
>
|
||||||
|
{formatMultiplier(preset)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
className="multiplier-custom"
|
||||||
|
onSubmit={event => { event.preventDefault(); if (parsed != null) pick(parsed); }}
|
||||||
|
>
|
||||||
|
<Form.Control
|
||||||
|
size="sm"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={input}
|
||||||
|
placeholder="vlastní, např. 1,25"
|
||||||
|
aria-label="Vlastní násobek"
|
||||||
|
onChange={event => setInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button size="sm" type="submit" disabled={parsed == null}>OK</Button>
|
||||||
|
</Form>
|
||||||
|
{calories != null && parsed != null && (
|
||||||
|
<div className="multiplier-hint">
|
||||||
|
{calories} kcal {formatMultiplier(parsed)} = {applyMultiplier(calories, parsed)} kcal
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Popover.Body>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayTrigger
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom-end"
|
||||||
|
rootClose
|
||||||
|
show={show}
|
||||||
|
onToggle={next => { setShow(next); if (next) setInput(formatMultiplierInput(multiplier)); }}
|
||||||
|
overlay={popover}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className={`multiplier-button ${current !== 1 ? 'active' : ''}`}
|
||||||
|
title="Násobek porce — kolikrát se položka snědla nebo odcvičila"
|
||||||
|
aria-label={`Násobek ${label}`}
|
||||||
|
>
|
||||||
|
{formatMultiplier(current)}
|
||||||
|
</button>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MultiplierControl;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { RecipeIngredient } from "../../../types";
|
||||||
|
import { formatCalories, formatPriceInput, getIngredientCalories, parseIntInput, parsePriceInput } from "../Utils";
|
||||||
|
|
||||||
|
/** Rozpracovaná surovina — hodnoty jsou texty, protože se editují v polích. */
|
||||||
|
export type DraftIngredient = {
|
||||||
|
name: string,
|
||||||
|
weight: string,
|
||||||
|
caloriesPer100g: string,
|
||||||
|
calories: string,
|
||||||
|
price: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyIngredient = (): DraftIngredient => ({
|
||||||
|
name: '',
|
||||||
|
weight: '',
|
||||||
|
caloriesPer100g: '',
|
||||||
|
calories: '',
|
||||||
|
price: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Převede uloženou surovinu na rozpracovanou pro editaci. */
|
||||||
|
export const toDraftIngredient = (ingredient: RecipeIngredient): DraftIngredient => ({
|
||||||
|
name: ingredient.name,
|
||||||
|
weight: ingredient.weight != null ? String(ingredient.weight) : '',
|
||||||
|
caloriesPer100g: ingredient.caloriesPer100g != null ? String(ingredient.caloriesPer100g) : '',
|
||||||
|
calories: ingredient.calories != null ? String(ingredient.calories) : '',
|
||||||
|
price: formatPriceInput(ingredient.price),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Převede rozpracovanou surovinu na hodnoty pro výpočet a uložení. */
|
||||||
|
export const toIngredient = (draft: DraftIngredient): RecipeIngredient => ({
|
||||||
|
name: draft.name.trim(),
|
||||||
|
weight: parseIntInput(draft.weight),
|
||||||
|
caloriesPer100g: parseIntInput(draft.caloriesPer100g),
|
||||||
|
calories: parseIntInput(draft.calories),
|
||||||
|
price: parsePriceInput(draft.price),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ingredients: DraftIngredient[],
|
||||||
|
onChange: (ingredients: DraftIngredient[]) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor surovin receptu.
|
||||||
|
*
|
||||||
|
* Energie suroviny se dopočte z gramáže a hodnoty na 100 g; ručně jde zadat jen
|
||||||
|
* tam, kde dopočítat nejde (třeba „lžíce oleje" bez vážení).
|
||||||
|
*/
|
||||||
|
const RecipeIngredientsEditor = ({ ingredients, onChange }: Readonly<Props>) => {
|
||||||
|
const updateAt = (index: number, changes: Partial<DraftIngredient>) =>
|
||||||
|
onChange(ingredients.map((item, i) => (i === index ? { ...item, ...changes } : item)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ingredients.map((ingredient, index) => {
|
||||||
|
const derived = getIngredientCalories({
|
||||||
|
weight: parseIntInput(ingredient.weight),
|
||||||
|
caloriesPer100g: parseIntInput(ingredient.caloriesPer100g),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
// Suroviny nemají vlastní id, v rámci rozpracovaného receptu je index jediný klíč
|
||||||
|
<div className="workout-item-row recipe-ingredient-row" key={index}>
|
||||||
|
<Form.Control
|
||||||
|
value={ingredient.name}
|
||||||
|
placeholder="Surovina"
|
||||||
|
aria-label="Název suroviny"
|
||||||
|
onChange={event => updateAt(index, { name: event.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="input-group recipe-ingredient-weight">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ingredient.weight}
|
||||||
|
placeholder="200"
|
||||||
|
aria-label="Množství v gramech"
|
||||||
|
onChange={event => updateAt(index, { weight: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">g</span>
|
||||||
|
</div>
|
||||||
|
<Form.Control
|
||||||
|
className="recipe-ingredient-rate"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ingredient.caloriesPer100g}
|
||||||
|
placeholder="kcal/100 g"
|
||||||
|
aria-label="Energie na 100 g"
|
||||||
|
onChange={event => updateAt(index, { caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
{derived != null ? (
|
||||||
|
<div className="recipe-ingredient-calories">{formatCalories(derived)}</div>
|
||||||
|
) : (
|
||||||
|
<Form.Control
|
||||||
|
className="recipe-ingredient-calories"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={ingredient.calories}
|
||||||
|
placeholder="kcal"
|
||||||
|
title="Energie použitého množství, když ji nejde dopočítat z gramáže"
|
||||||
|
onChange={event => updateAt(index, { calories: event.target.value.replace(/\D/g, '') })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group recipe-ingredient-price">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={ingredient.price}
|
||||||
|
placeholder="cena"
|
||||||
|
aria-label="Cena použitého množství"
|
||||||
|
onChange={event => updateAt(index, { price: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">Kč</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => onChange(ingredients.filter((_, i) => i !== index))}
|
||||||
|
disabled={ingredients.length === 1}
|
||||||
|
title="Odebrat surovinu"
|
||||||
|
aria-label="Odebrat surovinu"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange([...ingredients, emptyIngredient()])}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlus} /> Surovina
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecipeIngredientsEditor;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faBookmark, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { MealTemplate, MealTemplateKind } from "../../../types";
|
||||||
|
import { describeMealTemplate, normalizeSearch } from "../Utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
templates: MealTemplate[],
|
||||||
|
/** Rozepsaný název jídla, podle kterého se šablony hledají. */
|
||||||
|
query: string,
|
||||||
|
onPick: (template: MealTemplate) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kolik návrhů se nabídne — víc by formulář zbytečně natahovalo. */
|
||||||
|
const SUGGESTION_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Návrhy šablon k rozepsanému názvu jídla.
|
||||||
|
*
|
||||||
|
* Když uživatel jí něco, co už někdy měl (sušenka, oblíbená objednávka),
|
||||||
|
* nemusí hodnoty dohledávat znovu — stačí začít psát název a vybrat šablonu.
|
||||||
|
*/
|
||||||
|
const TemplateSuggestions = ({ templates, query, onPick }: Readonly<Props>) => {
|
||||||
|
const key = normalizeSearch(query);
|
||||||
|
if (key.length < 2) return null;
|
||||||
|
const matchList = templates
|
||||||
|
.filter(template => normalizeSearch(template.name).includes(key))
|
||||||
|
.slice(0, SUGGESTION_LIMIT);
|
||||||
|
if (!matchList.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="template-suggestions">
|
||||||
|
<div className="template-suggestions-title">Už jste to měli?</div>
|
||||||
|
{matchList.map(template => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="template-suggestion"
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => onPick(template)}
|
||||||
|
title="Vyplnit hodnoty ze šablony"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={template.kind === MealTemplateKind.RECEPT ? faBookOpen : faBookmark}
|
||||||
|
className="template-suggestion-icon"
|
||||||
|
/>
|
||||||
|
<span className="template-suggestion-name">{template.name}</span>
|
||||||
|
<span className="template-suggestion-detail">{describeMealTemplate(template)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateSuggestions;
|
||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
addActivity, createWorkoutTemplateFromActivities, updateActivity,
|
addActivity, createWorkoutTemplateFromActivities, updateActivity,
|
||||||
} from "../../../../types";
|
} from "../../../../types";
|
||||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
|
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES } from "../../enums";
|
||||||
import { deriveActivityCalories, formatCalories, parseIntInput } from "../../Utils";
|
import {
|
||||||
|
applyMultiplier, deriveActivityCalories, formatCalories, formatKgInput, formatMultiplier,
|
||||||
|
formatMultiplierInput, parseIntInput, parseMultiplierInput, parseWeightKgInput,
|
||||||
|
} from "../../Utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -28,6 +31,8 @@ function emptyForm(date: string) {
|
|||||||
caloriesPer100Units: '',
|
caloriesPer100Units: '',
|
||||||
calories: '',
|
calories: '',
|
||||||
note: '',
|
note: '',
|
||||||
|
extraWeight: '',
|
||||||
|
multiplier: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +42,9 @@ function emptyForm(date: string) {
|
|||||||
* Kalorie se dopočítají z množství a spotřeby na 100 jednotek — sto proto, že
|
* Kalorie se dopočítají z množství a spotřeby na 100 jednotek — sto proto, že
|
||||||
* u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní
|
* u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní
|
||||||
* jednotku i orientační sazbu.
|
* jednotku i orientační sazbu.
|
||||||
|
*
|
||||||
|
* Dodatečná zátěž (činky, vesta) se eviduje jako informace o tréninku, kalorie
|
||||||
|
* nemění — spotřebu se zátěží si uživatel promítne do sazby.
|
||||||
*/
|
*/
|
||||||
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||||
const [form, setForm] = useState(() => emptyForm(date));
|
const [form, setForm] = useState(() => emptyForm(date));
|
||||||
@@ -56,6 +64,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
|
caloriesPer100Units: entry.caloriesPer100Units != null ? String(entry.caloriesPer100Units) : '',
|
||||||
calories: entry.calories != null ? String(entry.calories) : '',
|
calories: entry.calories != null ? String(entry.calories) : '',
|
||||||
note: entry.note ?? '',
|
note: entry.note ?? '',
|
||||||
|
extraWeight: formatKgInput(entry.extraWeightGrams),
|
||||||
|
multiplier: formatMultiplierInput(entry.multiplier),
|
||||||
}
|
}
|
||||||
: emptyForm(date));
|
: emptyForm(date));
|
||||||
}, [isOpen, entry, date]);
|
}, [isOpen, entry, date]);
|
||||||
@@ -82,8 +92,12 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets);
|
const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets);
|
||||||
const manualCalories = parseIntInput(form.calories);
|
const manualCalories = parseIntInput(form.calories);
|
||||||
const effectiveCalories = derivedCalories ?? manualCalories;
|
const effectiveCalories = derivedCalories ?? manualCalories;
|
||||||
|
const extraWeightGrams = parseWeightKgInput(form.extraWeight);
|
||||||
|
const multiplier = parseMultiplierInput(form.multiplier);
|
||||||
|
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
|
||||||
|
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
|
||||||
|
|
||||||
const canSave = form.name.trim().length > 0 && quantity != null && !saving;
|
const canSave = form.name.trim().length > 0 && quantity != null && !multiplierInvalid && !saving;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
@@ -98,6 +112,8 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
|
// Server si kalorie dopočte sám, ručně zadané použije jen jako zálohu
|
||||||
calories: derivedCalories != null ? undefined : manualCalories,
|
calories: derivedCalories != null ? undefined : manualCalories,
|
||||||
note: form.note.trim() || undefined,
|
note: form.note.trim() || undefined,
|
||||||
|
extraWeightGrams,
|
||||||
|
multiplier,
|
||||||
};
|
};
|
||||||
const response = entry
|
const response = entry
|
||||||
? await updateActivity({ body: { id: entry.id, ...body } })
|
? await updateActivity({ body: { id: entry.id, ...body } })
|
||||||
@@ -213,6 +229,32 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="row g-3 mt-0">
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Zátěž</Form.Label>
|
||||||
|
<div className="input-group">
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.extraWeight}
|
||||||
|
placeholder="bez zátěže"
|
||||||
|
title="Dodatečná váha — činky, zátěžová vesta. Kalorie nemění."
|
||||||
|
onChange={event => update({ extraWeight: event.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="input-group-text">kg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-2">
|
||||||
|
<Form.Label>Násobek</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.multiplier}
|
||||||
|
placeholder="1"
|
||||||
|
isInvalid={multiplierInvalid}
|
||||||
|
title="Kolikrát jste aktivitu provedli, třeba 2 nebo 0,5"
|
||||||
|
onChange={event => update({ multiplier: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
@@ -235,7 +277,12 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
onChange={event => setAlsoTemplate(event.target.checked)}
|
onChange={event => setAlsoTemplate(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<div className="meal-modal-summary">
|
<div className="meal-modal-summary">
|
||||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
{totalCalories != null && (
|
||||||
|
<span>
|
||||||
|
{formatCalories(totalCalories)}
|
||||||
|
{multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
||||||
<Button onClick={save} disabled={!canSave}>
|
<Button onClick={save} disabled={!canSave}>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useNavigate } from "react-router";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
import { faPlay, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
|
import { DayOverview, MealTemplate, MealType, applyMealTemplate, getMealTemplates } from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
import {
|
||||||
import { deriveAmounts, formatCalories, formatPrice, formatWeight } from "../../Utils";
|
MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES,
|
||||||
|
} from "../../enums";
|
||||||
|
import { describeMealTemplate, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
||||||
import { SABLONY_URL } from "../../routes";
|
import { SABLONY_URL } from "../../routes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -17,28 +19,19 @@ type Props = {
|
|||||||
onApplied: (day: DayOverview) => void,
|
onApplied: (day: DayOverview) => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Popíše šablonu jednou řádkou — gramáž, kalorie a cena, co je vyplněné. */
|
|
||||||
const describe = (template: MealTemplate): string => {
|
|
||||||
const derived = deriveAmounts(template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
|
||||||
const calories = derived.calories ?? template.calories;
|
|
||||||
return [
|
|
||||||
template.source,
|
|
||||||
derived.weight != null ? formatWeight(derived.weight) : undefined,
|
|
||||||
calories != null ? formatCalories(calories) : undefined,
|
|
||||||
template.price != null ? formatPrice(template.price) : undefined,
|
|
||||||
].filter(Boolean).join(' · ');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Výběr šablony jídla k založení do dne.
|
* Výběr šablony jídla k založení do dne.
|
||||||
*
|
*
|
||||||
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
|
* Zakládání a úpravy šablon se řeší na stránce Šablony, sem chodí uživatel jen
|
||||||
* vybrat hotové jídlo. Chod lze při zakládání přepsat, protože totéž jídlo může
|
* vybrat hotové jídlo nebo uvařený recept. Chod lze při zakládání přepsat,
|
||||||
* být jednou oběd a podruhé večeře.
|
* protože totéž jídlo může být jednou oběd a podruhé večeře, a násobkem se
|
||||||
|
* zapíší třeba dvě stejné sušenky naráz.
|
||||||
*/
|
*/
|
||||||
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) => {
|
||||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||||
const [mealType, setMealType] = useState<MealType | ''>('');
|
const [mealType, setMealType] = useState<MealType | ''>('');
|
||||||
|
const [multiplierInput, setMultiplierInput] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -47,16 +40,21 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMealType('');
|
setMealType('');
|
||||||
|
setMultiplierInput('');
|
||||||
|
setSearch('');
|
||||||
getMealTemplates().then(response => {
|
getMealTemplates().then(response => {
|
||||||
setTemplates(response.data ?? []);
|
setTemplates(response.data ?? []);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const multiplier = parseMultiplierInput(multiplierInput);
|
||||||
|
const multiplierInvalid = multiplierInput.trim().length > 0 && multiplier == null;
|
||||||
|
|
||||||
const apply = async (id: string) => {
|
const apply = async (id: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const response = await applyMealTemplate({
|
const response = await applyMealTemplate({
|
||||||
body: { id, date, ...(mealType ? { mealType } : {}) },
|
body: { id, date, ...(mealType ? { mealType } : {}), ...(multiplier != null ? { multiplier } : {}) },
|
||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
@@ -64,6 +62,15 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const key = normalizeSearch(search);
|
||||||
|
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
|
||||||
|
.map(kind => ({
|
||||||
|
kind,
|
||||||
|
templates: templates.filter(template => template.kind === kind
|
||||||
|
&& (!key.length || normalizeSearch(template.name).includes(key))),
|
||||||
|
}))
|
||||||
|
.filter(group => group.templates.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
@@ -80,13 +87,26 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
<div className="empty-icon"><FontAwesomeIcon icon={faBowlFood} /></div>
|
||||||
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
<div className="empty-title">Zatím nemáte žádnou šablonu jídla</div>
|
||||||
<p className="empty-description">Vytvořit si ji můžete na stránce Šablony.</p>
|
<p className="empty-description">
|
||||||
|
Vytvořit si ji můžete na stránce Šablony, nebo u jídla v přehledu dne tlačítkem
|
||||||
|
„Uložit jako šablonu".
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !!templates.length && (
|
{!loading && !!templates.length && (
|
||||||
<>
|
<>
|
||||||
<Form.Group className="mb-3">
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label>Hledat</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
autoFocus
|
||||||
|
value={search}
|
||||||
|
placeholder="Název jídla"
|
||||||
|
onChange={event => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-4">
|
||||||
<Form.Label>Založit jako</Form.Label>
|
<Form.Label>Založit jako</Form.Label>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
value={mealType}
|
value={mealType}
|
||||||
@@ -99,19 +119,47 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
</Form.Group>
|
</div>
|
||||||
|
<div className="col-sm-3">
|
||||||
|
<Form.Label>Násobek</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={multiplierInput}
|
||||||
|
placeholder="1"
|
||||||
|
isInvalid={multiplierInvalid}
|
||||||
|
title="Kolik porcí jste snědli, třeba 2 nebo 0,5"
|
||||||
|
onChange={event => setMultiplierInput(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{templates.map(template => (
|
{!groupList.length && (
|
||||||
|
<p className="text-body-secondary small">Žádná šablona neodpovídá hledání.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupList.map(group => (
|
||||||
|
<div className="apply-template-group" key={group.kind}>
|
||||||
|
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
|
||||||
|
{group.templates.map(template => (
|
||||||
<div className="workout-row" key={template.id}>
|
<div className="workout-row" key={template.id}>
|
||||||
<div className="workout-info">
|
<div className="workout-info">
|
||||||
<div className="workout-name">{template.name}</div>
|
<div className="workout-name">{template.name}</div>
|
||||||
<div className="workout-items">{describe(template)}</div>
|
<div className="workout-items">
|
||||||
|
{describeMealTemplate(template)}
|
||||||
|
{!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => apply(template.id)} disabled={busy}>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => apply(template.id)}
|
||||||
|
disabled={busy || multiplierInvalid}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={faPlay} /> Použít
|
<FontAwesomeIcon icon={faPlay} /> Použít
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap";
|
import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { ImportResult, MealType, importLuncher } from "../../../../types";
|
import { ImportMode, ImportResult, MealEntry, MealType, importLuncher } from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
import { IMPORT_MODE_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||||
import { formatDateString, formatPrice, readFileAsBase64 } from "../../Utils";
|
import { formatPrice, readFileAsBase64 } from "../../Utils";
|
||||||
|
import ImportPreview from "../ImportPreview";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -13,19 +14,24 @@ type Props = {
|
|||||||
onImported: () => void,
|
onImported: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */
|
/** Označení záznamů z importu Luncheru — shodné s `IMPORT_SOURCE` na serveru. */
|
||||||
const PREVIEW_LIMIT = 8;
|
const LUNCHER_IMPORT_SOURCE = 'luncher';
|
||||||
|
|
||||||
|
const IMPORT_MODES_IN_ORDER: ImportMode[] = [ImportMode.PRIDAT, ImportMode.NAHRADIT];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog pro import měsíčního přehledu ze stránky statistik Luncheru.
|
* Dialog pro import měsíčního přehledu ze stránky statistik Luncheru.
|
||||||
*
|
*
|
||||||
* Import běží dvoufázově — nejdřív náhled (dryRun), aby uživatel viděl, co se
|
* Import běží dvoufázově — nejdřív náhled (dryRun), ve kterém uživatel vidí,
|
||||||
* založí a kolik řádků už má, a teprve po potvrzení se data uloží.
|
* co se založí i co už v dotčených dnech má, a může jednotlivé řádky odškrtnout.
|
||||||
|
* Teprve po potvrzení se uloží vybrané řádky.
|
||||||
*/
|
*/
|
||||||
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
|
export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Props>) {
|
||||||
const [file, setFile] = useState<File | undefined>();
|
const [file, setFile] = useState<File | undefined>();
|
||||||
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
|
const [mealType, setMealType] = useState<MealType>(MealType.OBED);
|
||||||
|
const [mode, setMode] = useState<ImportMode>(ImportMode.PRIDAT);
|
||||||
const [preview, setPreview] = useState<ImportResult | undefined>();
|
const [preview, setPreview] = useState<ImportResult | undefined>();
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [result, setResult] = useState<ImportResult | undefined>();
|
const [result, setResult] = useState<ImportResult | undefined>();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -35,21 +41,30 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
setFile(undefined);
|
setFile(undefined);
|
||||||
setPreview(undefined);
|
setPreview(undefined);
|
||||||
setResult(undefined);
|
setResult(undefined);
|
||||||
|
setSelected(new Set());
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import. */
|
/** Odešle soubor na server — buď jako náhled, nebo jako ostrý import vybraných řádků. */
|
||||||
const send = async (dryRun: boolean) => {
|
const send = async (dryRun: boolean) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const content = await readFileAsBase64(file);
|
const content = await readFileAsBase64(file);
|
||||||
const response = await importLuncher({
|
const response = await importLuncher({
|
||||||
body: { fileName: file.name, content, defaultMealType: mealType, dryRun },
|
body: {
|
||||||
|
fileName: file.name,
|
||||||
|
content,
|
||||||
|
defaultMealType: mealType,
|
||||||
|
dryRun,
|
||||||
|
mode,
|
||||||
|
...(dryRun ? {} : { selectedKeys: Array.from(selected) }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!response.data) return;
|
if (!response.data) return;
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
setPreview(response.data);
|
setPreview(response.data);
|
||||||
|
setSelected(new Set(response.data.entries.map(entry => entry.importKey!)));
|
||||||
} else {
|
} else {
|
||||||
setResult(response.data);
|
setResult(response.data);
|
||||||
setPreview(undefined);
|
setPreview(undefined);
|
||||||
@@ -60,14 +75,49 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseFile = (chosen?: File) => {
|
/** Změna souboru, chodu nebo režimu mění, co se založí — náhled je pak neplatný. */
|
||||||
setFile(chosen);
|
const invalidate = () => {
|
||||||
setPreview(undefined);
|
setPreview(undefined);
|
||||||
setResult(undefined);
|
setResult(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggle = (key: string) => setSelected(current => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleDay = (date: string, checked: boolean) => setSelected(current => {
|
||||||
|
const next = new Set(current);
|
||||||
|
preview?.entries
|
||||||
|
.filter(entry => entry.date === date)
|
||||||
|
.forEach(entry => (checked ? next.add(entry.importKey!) : next.delete(entry.importKey!)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odškrtne řádky ve dnech, kde už je ručně zadané jídlo stejného chodu.
|
||||||
|
* Typicky oběd, který si uživatel zapsal dřív, než udělal import.
|
||||||
|
*/
|
||||||
|
const uncheckManualDays = () => {
|
||||||
|
const manualDays = new Set((preview?.existing ?? [])
|
||||||
|
.filter(entry => !entry.importSource && entry.mealType === mealType)
|
||||||
|
.map(entry => entry.date));
|
||||||
|
setSelected(current => new Set(Array.from(current).filter(key =>
|
||||||
|
!manualDays.has(preview?.entries.find(entry => entry.importKey === key)?.date ?? ''))));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedEntries = preview?.entries.filter(entry => selected.has(entry.importKey!)) ?? [];
|
||||||
|
const selectedDays = new Set(selectedEntries.map(entry => entry.date));
|
||||||
|
const isReplaced = (entry: MealEntry) =>
|
||||||
|
mode === ImportMode.NAHRADIT && entry.importSource === LUNCHER_IMPORT_SOURCE;
|
||||||
|
const replacedCount = (preview?.existing ?? [])
|
||||||
|
.filter(entry => isReplaced(entry) && selectedDays.has(entry.date)).length;
|
||||||
|
const hasManualConflict = (preview?.existing ?? [])
|
||||||
|
.some(entry => !entry.importSource && entry.mealType === mealType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal show={isOpen} onHide={onClose} centered size="lg">
|
<Modal show={isOpen} onHide={onClose} centered size="lg" scrollable>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>
|
<Modal.Title>
|
||||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||||
@@ -76,8 +126,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
Na stránce statistik v Luncheru si stáhněte přehled za měsíc (XLSX, CSV nebo JSON)
|
Na stránce statistik v Luncheru si stáhněte přehled za měsíc (XLSX, CSV nebo JSON)
|
||||||
a nahrajte ho sem. Záznamy se založí do dnů, ke kterým patří. Opakovaný import
|
a nahrajte ho sem. V náhledu uvidíte, co se založí i co už v jednotlivých dnech máte,
|
||||||
stejného měsíce data nezduplikuje.
|
a řádky, které nechcete, můžete odškrtnout.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
@@ -85,15 +135,16 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
<Form.Control
|
<Form.Control
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xlsx,.csv,.json"
|
accept=".xlsx,.csv,.json"
|
||||||
onChange={event => chooseFile((event.target as HTMLInputElement).files?.[0])}
|
onChange={event => { setFile((event.target as HTMLInputElement).files?.[0]); invalidate(); }}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<div className="row g-3 mb-3">
|
||||||
|
<div className="col-sm-6">
|
||||||
<Form.Label>Založit jako</Form.Label>
|
<Form.Label>Založit jako</Form.Label>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
value={mealType}
|
value={mealType}
|
||||||
onChange={event => { setMealType(event.target.value as MealType); setPreview(undefined); }}
|
onChange={event => { setMealType(event.target.value as MealType); invalidate(); }}
|
||||||
>
|
>
|
||||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
{MEAL_TYPES_IN_ORDER.map(type => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
@@ -101,11 +152,28 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
<Form.Text>Luncher řeší výběr obědů, proto je předvolený oběd.</Form.Text>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<Form.Label>Dřívější import stejných dnů</Form.Label>
|
||||||
|
{IMPORT_MODES_IN_ORDER.map(item => (
|
||||||
|
<Form.Check
|
||||||
|
key={item}
|
||||||
|
type="radio"
|
||||||
|
id={`import-mode-${item}`}
|
||||||
|
name="import-mode"
|
||||||
|
checked={mode === item}
|
||||||
|
label={IMPORT_MODE_NAMES[item]}
|
||||||
|
onChange={() => { setMode(item); invalidate(); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<Form.Text>
|
<Form.Text>
|
||||||
Luncher řeší výběr obědů, proto je předvolený oběd. Jednotlivá jídla si pak
|
{mode === ImportMode.PRIDAT
|
||||||
můžete v přehledu dne upravit nebo doplnit o další.
|
? 'Už naimportované řádky se přeskočí, nic se nemaže.'
|
||||||
|
: 'Dřív naimportované záznamy dotčených dnů se smažou a založí znovu. Ručně zadaná jídla zůstanou.'}
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{busy && (
|
{busy && (
|
||||||
<div className="text-center py-3">
|
<div className="text-center py-3">
|
||||||
@@ -114,36 +182,46 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{preview && !busy && (
|
{preview && !busy && (
|
||||||
<Alert variant={preview.imported ? 'success' : 'secondary'}>
|
<>
|
||||||
|
<Alert variant={selectedEntries.length ? 'success' : 'secondary'} className="py-2">
|
||||||
<strong>Náhled importu</strong>
|
<strong>Náhled importu</strong>
|
||||||
<div className="small mt-1">
|
<div className="small mt-1">
|
||||||
V souboru je {preview.rowCount} řádků. Založí se {preview.imported} záznamů
|
V souboru je {preview.rowCount} řádků. Vybráno {selectedEntries.length}
|
||||||
{preview.skipped > 0 && <> a {preview.skipped} se přeskočí, protože už je máte</>}.
|
{' '}z {preview.entries.length} za{' '}
|
||||||
{preview.days.length > 0 && (
|
{formatPrice(selectedEntries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
|
||||||
<> Dotčené dny: {formatDateString(preview.days[0])}
|
{selectedDays.size > 0 && <> ve {selectedDays.size} dnech</>}.
|
||||||
{preview.days.length > 1 && <> – {formatDateString(preview.days[preview.days.length - 1])}</>}
|
{replacedCount > 0 && <> Nahradí se {replacedCount} dřív naimportovaných záznamů.</>}
|
||||||
{' '}({preview.days.length}).</>
|
|
||||||
)}
|
|
||||||
{' '}Celkem za {formatPrice(preview.totalPrice)}.
|
|
||||||
</div>
|
</div>
|
||||||
{preview.entries.length > 0 && (
|
|
||||||
<ul className="import-preview-list">
|
|
||||||
{preview.entries.slice(0, PREVIEW_LIMIT).map(entry => (
|
|
||||||
<li key={entry.id}>
|
|
||||||
<span className="import-preview-date">{formatDateString(entry.date)}</span>
|
|
||||||
{entry.name}
|
|
||||||
{entry.source && <span className="text-body-secondary"> · {entry.source}</span>}
|
|
||||||
<span className="import-preview-price">{formatPrice(entry.price)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{preview.entries.length > PREVIEW_LIMIT && (
|
|
||||||
<li className="text-body-secondary">
|
|
||||||
… a dalších {preview.entries.length - PREVIEW_LIMIT}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{preview.entries.length > 0 && (
|
||||||
|
<div className="import-preview-toolbar">
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelected(new Set(preview.entries.map(entry => entry.importKey!)))}
|
||||||
|
>
|
||||||
|
Vybrat vše
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => setSelected(new Set())}>
|
||||||
|
Zrušit výběr
|
||||||
|
</Button>
|
||||||
|
{hasManualConflict && (
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => uncheckManualDays()}>
|
||||||
|
Odškrtnout dny, kde už máte {MEAL_TYPE_NAMES[mealType].toLowerCase()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ImportPreview
|
||||||
|
preview={preview}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggle}
|
||||||
|
onToggleDay={toggleDay}
|
||||||
|
isReplaced={isReplaced}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && !busy && (
|
{result && !busy && (
|
||||||
@@ -151,7 +229,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
<strong>Import dokončen</strong>
|
<strong>Import dokončen</strong>
|
||||||
<div className="small mt-1">
|
<div className="small mt-1">
|
||||||
Založeno {result.imported} záznamů ve {result.days.length} dnech
|
Založeno {result.imported} záznamů ve {result.days.length} dnech
|
||||||
{result.skipped > 0 && <>, {result.skipped} přeskočeno jako již existující</>}.
|
{result.skipped > 0 && <>, {result.skipped} přeskočeno</>}
|
||||||
|
{result.replaced > 0 && <>, {result.replaced} dřívějších nahrazeno</>}.
|
||||||
Celkem za {formatPrice(result.totalPrice)}.
|
Celkem za {formatPrice(result.totalPrice)}.
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -173,8 +252,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly<Pr
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!result && preview && (
|
{!result && preview && (
|
||||||
<Button onClick={() => send(false)} disabled={busy || preview.imported === 0}>
|
<Button onClick={() => send(false)} disabled={busy || !selectedEntries.length}>
|
||||||
Naimportovat {preview.imported} záznamů
|
Naimportovat {selectedEntries.length} záznamů
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Form, Modal } from "react-bootstrap";
|
import { Button, Form, Modal } from "react-bootstrap";
|
||||||
import {
|
import {
|
||||||
DayRecord, MealEntry, MealType, UserSettings,
|
DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings,
|
||||||
addMeal, createMealTemplateFromMeal, getSettings, updateMeal,
|
addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal,
|
||||||
} from "../../../../types";
|
} from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums";
|
||||||
import {
|
import {
|
||||||
deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput,
|
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
||||||
|
formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput,
|
||||||
} from "../../Utils";
|
} from "../../Utils";
|
||||||
import CalorieLookup from "../CalorieLookup";
|
import CalorieLookup from "../CalorieLookup";
|
||||||
|
import TemplateSuggestions from "../TemplateSuggestions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -33,12 +35,13 @@ function emptyForm(date: string) {
|
|||||||
caloriesPer100g: '',
|
caloriesPer100g: '',
|
||||||
calories: '',
|
calories: '',
|
||||||
note: '',
|
note: '',
|
||||||
|
multiplier: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
|
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
|
||||||
function normalizeSource(source: string): string {
|
function normalizeSource(source: string): string {
|
||||||
return source.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
|
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,17 +51,24 @@ function normalizeSource(source: string): string {
|
|||||||
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
|
* se dopočte z ceny a sazby, ale dá se přepsat. Z gramáže a energie na 100 g se
|
||||||
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
|
* pak dopočtou kalorie. Každá položka má vlastní sazbu, takže hlavní jídlo a salát
|
||||||
* s jiným cenováním můžou být ve stejném dni vedle sebe.
|
* s jiným cenováním můžou být ve stejném dni vedle sebe.
|
||||||
|
*
|
||||||
|
* U nového jídla se k rozepsanému názvu nabízejí šablony, takže jídlo, které už
|
||||||
|
* uživatel někdy měl, nemusí dohledávat znovu.
|
||||||
*/
|
*/
|
||||||
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Readonly<Props>) {
|
||||||
const [form, setForm] = useState(() => emptyForm(date));
|
const [form, setForm] = useState(() => emptyForm(date));
|
||||||
const [settings, setSettings] = useState<UserSettings | undefined>();
|
const [settings, setSettings] = useState<UserSettings | undefined>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
const [alsoTemplate, setAlsoTemplate] = useState(false);
|
||||||
|
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||||
|
/** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */
|
||||||
|
const [pickedTemplate, setPickedTemplate] = useState<string | undefined>();
|
||||||
|
|
||||||
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
// Při každém otevření se formulář naplní dle toho, co se upravuje
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setAlsoTemplate(false);
|
setAlsoTemplate(false);
|
||||||
|
setPickedTemplate(undefined);
|
||||||
setForm(entry
|
setForm(entry
|
||||||
? {
|
? {
|
||||||
date: entry.date,
|
date: entry.date,
|
||||||
@@ -71,10 +81,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
|
caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '',
|
||||||
calories: entry.calories != null ? String(entry.calories) : '',
|
calories: entry.calories != null ? String(entry.calories) : '',
|
||||||
note: entry.note ?? '',
|
note: entry.note ?? '',
|
||||||
|
multiplier: formatMultiplierInput(entry.multiplier),
|
||||||
}
|
}
|
||||||
: emptyForm(date));
|
: emptyForm(date));
|
||||||
}, [isOpen, entry, date]);
|
}, [isOpen, entry, date]);
|
||||||
|
|
||||||
|
// Šablony se nabízejí jen při zakládání; načtou se při každém otevření, mohly přibýt
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || entry) return;
|
||||||
|
getMealTemplates().then(response => setTemplates(response.data ?? []));
|
||||||
|
}, [isOpen, entry]);
|
||||||
|
|
||||||
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
|
// Sazby podniků se načtou jednou při otevření a slouží k předvyplnění Kč/100 g
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || settings) return;
|
if (!isOpen || settings) return;
|
||||||
@@ -83,6 +100,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
|
|
||||||
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
||||||
|
|
||||||
|
/** Vyplní formulář hodnotami ze šablony; datum a násobek zůstanou, jak jsou. */
|
||||||
|
const applyTemplate = (template: MealTemplate) => {
|
||||||
|
setPickedTemplate(template.name);
|
||||||
|
setForm(current => ({
|
||||||
|
...current,
|
||||||
|
name: template.name,
|
||||||
|
mealType: template.mealType ?? current.mealType,
|
||||||
|
source: template.source ?? '',
|
||||||
|
price: formatPriceInput(template.price),
|
||||||
|
pricePer100g: formatPriceInput(template.pricePer100g),
|
||||||
|
weight: template.weight != null ? String(template.weight) : '',
|
||||||
|
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
||||||
|
calories: template.calories != null ? String(template.calories) : '',
|
||||||
|
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
||||||
|
note: template.kind === MealTemplateKind.RECEPT ? '' : template.note ?? '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
|
/** Sazba nastavená pro právě zadaný zdroj, pokud nějaká je. */
|
||||||
const rateForSource = useMemo(() => {
|
const rateForSource = useMemo(() => {
|
||||||
const key = normalizeSource(form.source);
|
const key = normalizeSource(form.source);
|
||||||
@@ -109,8 +144,11 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
|
/** Kalorie zadané ručně se použijí jen tehdy, když nejdou dopočítat. */
|
||||||
const manualCalories = parseIntInput(form.calories);
|
const manualCalories = parseIntInput(form.calories);
|
||||||
const effectiveCalories = derived.calories ?? manualCalories;
|
const effectiveCalories = derived.calories ?? manualCalories;
|
||||||
|
const multiplier = parseMultiplierInput(form.multiplier);
|
||||||
|
const multiplierInvalid = form.multiplier.trim().length > 0 && multiplier == null;
|
||||||
|
const totalCalories = applyMultiplier(effectiveCalories, multiplier);
|
||||||
|
|
||||||
const canSave = form.name.trim().length > 0 && !saving;
|
const canSave = form.name.trim().length > 0 && !multiplierInvalid && !saving;
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
@@ -127,6 +165,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
// Server si kalorie dopočte sám, ručně poslané použije jen jako zálohu
|
||||||
calories: derived.calories != null ? undefined : manualCalories,
|
calories: derived.calories != null ? undefined : manualCalories,
|
||||||
note: form.note.trim() || undefined,
|
note: form.note.trim() || undefined,
|
||||||
|
multiplier,
|
||||||
};
|
};
|
||||||
const response = entry
|
const response = entry
|
||||||
? await updateMeal({ body: { id: entry.id, ...body } })
|
? await updateMeal({ body: { id: entry.id, ...body } })
|
||||||
@@ -159,8 +198,14 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={form.name}
|
value={form.name}
|
||||||
placeholder="Např. Kuřecí gyros s hranolkami"
|
placeholder="Např. Kuřecí gyros s hranolkami"
|
||||||
onChange={event => update({ name: event.target.value })}
|
onChange={event => { update({ name: event.target.value }); setPickedTemplate(undefined); }}
|
||||||
/>
|
/>
|
||||||
|
{!entry && pickedTemplate !== form.name && (
|
||||||
|
<TemplateSuggestions templates={templates} query={form.name} onPick={applyTemplate} />
|
||||||
|
)}
|
||||||
|
{!entry && pickedTemplate === form.name && (
|
||||||
|
<Form.Text>vyplněno ze šablony, hodnoty jde upravit</Form.Text>
|
||||||
|
)}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<div className="row g-3 mb-3">
|
<div className="row g-3 mb-3">
|
||||||
@@ -255,7 +300,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
<fieldset className="meal-fieldset mb-3">
|
<fieldset className="meal-fieldset mb-3">
|
||||||
<legend>Kalorie</legend>
|
<legend>Kalorie</legend>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-sm-4">
|
<div className="col-sm-3">
|
||||||
<Form.Label>Energie na 100 g</Form.Label>
|
<Form.Label>Energie na 100 g</Form.Label>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -267,8 +312,8 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
<span className="input-group-text">kcal</span>
|
<span className="input-group-text">kcal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-4">
|
<div className="col-sm-3">
|
||||||
<Form.Label>Kalorie celkem</Form.Label>
|
<Form.Label>Kalorie porce</Form.Label>
|
||||||
{derived.calories != null ? (
|
{derived.calories != null ? (
|
||||||
<div className="derived-value">{formatCalories(derived.calories)}</div>
|
<div className="derived-value">{formatCalories(derived.calories)}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -288,6 +333,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
</Form.Text>
|
</Form.Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-sm-2">
|
||||||
|
<Form.Label>Násobek</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.multiplier}
|
||||||
|
placeholder="1"
|
||||||
|
isInvalid={multiplierInvalid}
|
||||||
|
title="Kolik porcí jste snědli, třeba 2 nebo 0,5. Cena se nenásobí."
|
||||||
|
onChange={event => update({ multiplier: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="col-sm-4 d-flex align-items-end">
|
<div className="col-sm-4 d-flex align-items-end">
|
||||||
<CalorieLookup
|
<CalorieLookup
|
||||||
name={form.name}
|
name={form.name}
|
||||||
@@ -318,10 +374,15 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
/>
|
/>
|
||||||
<div className="meal-modal-summary">
|
<div className="meal-modal-summary">
|
||||||
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
{derived.weight != null && <span>{formatWeight(derived.weight)}</span>}
|
||||||
{effectiveCalories != null && <span>{formatCalories(effectiveCalories)}</span>}
|
{totalCalories != null && (
|
||||||
|
<span>
|
||||||
|
{formatCalories(totalCalories)}
|
||||||
|
{multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
<Button variant="outline-secondary" onClick={onClose}>Zrušit</Button>
|
||||||
<Button onClick={save} disabled={!canSave}>
|
<Button onClick={() => save()} disabled={!canSave}>
|
||||||
{saving ? 'Ukládám…' : 'Uložit'}
|
{saving ? 'Ukládám…' : 'Uložit'}
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|||||||
+18
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ActivityUnit, MealType, Pohlavi } from "../../types";
|
import { ActivityUnit, ImportMode, MealTemplateKind, MealType, Pohlavi } from "../../types";
|
||||||
|
|
||||||
/** Lidsky čitelné názvy typů jídel. */
|
/** Lidsky čitelné názvy typů jídel. */
|
||||||
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
export const MEAL_TYPE_NAMES: Record<MealType, string> = {
|
||||||
@@ -117,3 +117,20 @@ export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
|||||||
TRENINKY: 'Tréninky',
|
TRENINKY: 'Tréninky',
|
||||||
JIDLA: 'Jídla',
|
JIDLA: 'Jídla',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Lidsky čitelné názvy druhů šablon jídel. */
|
||||||
|
export const MEAL_TEMPLATE_KIND_NAMES: Record<MealTemplateKind, string> = {
|
||||||
|
HOTOVE: 'Hotové jídlo',
|
||||||
|
RECEPT: 'Recept',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEAL_TEMPLATE_KINDS_IN_ORDER: MealTemplateKind[] = [MealTemplateKind.HOTOVE, MealTemplateKind.RECEPT];
|
||||||
|
|
||||||
|
/** Názvy režimů importu. */
|
||||||
|
export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
||||||
|
PRIDAT: 'Přidat k tomu, co už mám',
|
||||||
|
NAHRADIT: 'Nahradit dřívější import',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Předvolby násobku porce nabízené v přehledu dne. */
|
||||||
|
export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3];
|
||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import {
|
import {
|
||||||
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
|
ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal,
|
||||||
deleteActivity, deleteMeal, getDayOverview, saveSleep,
|
deleteActivity, deleteMeal, getDayOverview, saveSleep, setActivityMultiplier, setMealMultiplier,
|
||||||
} from "../../../types";
|
} from "../../../types";
|
||||||
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
||||||
import {
|
import {
|
||||||
formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
applyMultiplier, formatBalance, formatCalories, formatDate, formatPrice, formatSleep,
|
||||||
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
|
formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate,
|
||||||
} from "../Utils";
|
} from "../Utils";
|
||||||
import MealModal from "../components/modals/MealModal";
|
import MealModal from "../components/modals/MealModal";
|
||||||
@@ -48,6 +48,7 @@ export default function DayPage() {
|
|||||||
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false);
|
||||||
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
const [preselectedActivities, setPreselectedActivities] = useState<string[]>([]);
|
||||||
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
const [savedNotice, setSavedNotice] = useState<string | undefined>();
|
||||||
|
const [errorNotice, setErrorNotice] = useState<string | undefined>();
|
||||||
|
|
||||||
const loadDay = useCallback(async (forDate: string) => {
|
const loadDay = useCallback(async (forDate: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -94,12 +95,26 @@ export default function DayPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveMealAsTemplate = async (entry: MealEntry) => {
|
const saveMealAsTemplate = async (entry: MealEntry) => {
|
||||||
|
setErrorNotice(undefined);
|
||||||
const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } });
|
const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } });
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
showNotice(`„${entry.name}" uloženo jako šablonu jídla.`);
|
showNotice(`„${entry.name}" uloženo jako šablonu jídla.`);
|
||||||
|
} else {
|
||||||
|
// Typicky recept stejného názvu, který se ze záznamu dne přepsat nesmí
|
||||||
|
setErrorNotice((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeMealMultiplier = async (entry: MealEntry, multiplier: number) => {
|
||||||
|
const response = await setMealMultiplier({ body: { id: entry.id, date: entry.date, multiplier } });
|
||||||
|
if (response.data) setDay(response.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeActivityMultiplier = async (entry: ActivityEntry, multiplier: number) => {
|
||||||
|
const response = await setActivityMultiplier({ body: { id: entry.id, date: entry.date, multiplier } });
|
||||||
|
if (response.data) setDay(response.data);
|
||||||
|
};
|
||||||
|
|
||||||
/** Otevře dialog pro uložení tréninku, se zaškrtnutou jednou aktivitou nebo všemi. */
|
/** Otevře dialog pro uložení tréninku, se zaškrtnutou jednou aktivitou nebo všemi. */
|
||||||
const openSaveWorkout = (entry?: ActivityEntry) => {
|
const openSaveWorkout = (entry?: ActivityEntry) => {
|
||||||
setPreselectedActivities(entry ? [entry.id] : []);
|
setPreselectedActivities(entry ? [entry.id] : []);
|
||||||
@@ -118,7 +133,7 @@ export default function DayPage() {
|
|||||||
{tab === DayTab.PRIJEM ? (
|
{tab === DayTab.PRIJEM ? (
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
|
<Button variant="outline-secondary" onClick={() => setMealTemplateModalOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faBowlFood} /> Šablony
|
<FontAwesomeIcon icon={faBowlFood} /> Ze šablony
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
<Button onClick={() => { setEditedMeal(undefined); setMealModalOpen(true); }}>
|
||||||
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
<FontAwesomeIcon icon={faPlus} /> Přidat jídlo
|
||||||
@@ -132,7 +147,7 @@ export default function DayPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
<Button variant="outline-secondary" onClick={() => setWorkoutModalOpen(true)}>
|
||||||
<FontAwesomeIcon icon={faDumbbell} /> Šablony
|
<FontAwesomeIcon icon={faDumbbell} /> Ze šablony
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
|
<Button onClick={() => { setEditedActivity(undefined); setActivityModalOpen(true); }}>
|
||||||
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
|
<FontAwesomeIcon icon={faPlus} /> Přidat aktivitu
|
||||||
@@ -224,6 +239,11 @@ export default function DayPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
{savedNotice && <Alert variant="success" className="py-2 small">{savedNotice}</Alert>}
|
||||||
|
{errorNotice && (
|
||||||
|
<Alert variant="danger" className="py-2 small" dismissible onClose={() => setErrorNotice(undefined)}>
|
||||||
|
{errorNotice}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="day-tabs">
|
<div className="day-tabs">
|
||||||
<button
|
<button
|
||||||
@@ -265,6 +285,9 @@ export default function DayPage() {
|
|||||||
<span className="meal-group-icon">{MEAL_TYPE_ICONS[group.mealType]}</span>
|
<span className="meal-group-icon">{MEAL_TYPE_ICONS[group.mealType]}</span>
|
||||||
{MEAL_TYPE_NAMES[group.mealType]}
|
{MEAL_TYPE_NAMES[group.mealType]}
|
||||||
<span className="meal-group-total">
|
<span className="meal-group-total">
|
||||||
|
{formatCalories(group.entries.reduce(
|
||||||
|
(sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0))}
|
||||||
|
{' · '}
|
||||||
{formatPrice(group.entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
|
{formatPrice(group.entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -276,6 +299,7 @@ export default function DayPage() {
|
|||||||
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
onEdit={() => { setEditedMeal(entry); setMealModalOpen(true); }}
|
||||||
onDelete={() => removeMeal(entry)}
|
onDelete={() => removeMeal(entry)}
|
||||||
onSaveTemplate={() => saveMealAsTemplate(entry)}
|
onSaveTemplate={() => saveMealAsTemplate(entry)}
|
||||||
|
onMultiplierChange={multiplier => changeMealMultiplier(entry, multiplier)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -304,6 +328,7 @@ export default function DayPage() {
|
|||||||
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
|
onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }}
|
||||||
onDelete={() => removeActivity(entry)}
|
onDelete={() => removeActivity(entry)}
|
||||||
onSaveTemplate={() => openSaveWorkout(entry)}
|
onSaveTemplate={() => openSaveWorkout(entry)}
|
||||||
|
onMultiplierChange={multiplier => changeActivityMultiplier(entry, multiplier)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,41 +1,26 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Alert, Button, Form, Spinner } from "react-bootstrap";
|
import { Spinner } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faTrash, faPenToSquare, faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
import { faBowlFood } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealTemplate, MealType, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../../../types";
|
import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
import { MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES } from "../enums";
|
||||||
import {
|
import MealTemplateCard from "../components/MealTemplateCard";
|
||||||
deriveAmounts, formatCalories, formatPrice, formatPriceInput, formatWeight,
|
import MealTemplateForm from "../components/MealTemplateForm";
|
||||||
parseIntInput, parsePriceInput,
|
|
||||||
} from "../Utils";
|
|
||||||
|
|
||||||
/** Prázdný formulář nové šablony jídla. */
|
|
||||||
const emptyForm = () => ({
|
|
||||||
name: '',
|
|
||||||
source: '',
|
|
||||||
mealType: '' as MealType | '',
|
|
||||||
weight: '',
|
|
||||||
caloriesPer100g: '',
|
|
||||||
calories: '',
|
|
||||||
price: '',
|
|
||||||
pricePer100g: '',
|
|
||||||
note: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Panel se správou šablon jídel.
|
* Panel se správou šablon jídel.
|
||||||
*
|
*
|
||||||
* Na rozdíl od šablon tréninků se neskládají z jiných — jsou to ploché záznamy.
|
* Šablony jsou dvojího druhu: hotová jídla (objednané, koupené — pamatují si
|
||||||
* Poznámka slouží i na postup přípravy u domácích jídel; strukturované suroviny
|
* výsledné hodnoty) a recepty (vařené — nesou suroviny a postup a hodnoty porce
|
||||||
* mohou přibýt později, aniž by se model musel měnit.
|
* se z nich dopočtou). Na rozdíl od šablon tréninků se neskládají z jiných.
|
||||||
*/
|
*/
|
||||||
const MealTemplatesPanel = () => {
|
const MealTemplatesPanel = () => {
|
||||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editedId, setEditedId] = useState<string | undefined>();
|
const [edited, setEdited] = useState<MealTemplate | undefined>();
|
||||||
const [form, setForm] = useState(emptyForm);
|
|
||||||
const [error, setError] = useState<string | undefined>();
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
/** Po uložení se zvýší, aby se formulář vyprázdnil i u nové šablony. */
|
||||||
|
const [formVersion, setFormVersion] = useState(0);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -46,85 +31,32 @@ const MealTemplatesPanel = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const update = (changes: Partial<ReturnType<typeof emptyForm>>) =>
|
|
||||||
setForm(current => ({ ...current, ...changes }));
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setEditedId(undefined);
|
|
||||||
setForm(emptyForm());
|
|
||||||
setError(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canSave = form.name.trim().length > 0 && !busy;
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!canSave) return;
|
|
||||||
setBusy(true);
|
|
||||||
setError(undefined);
|
|
||||||
const response = await saveMealTemplate({
|
|
||||||
body: {
|
|
||||||
...(editedId ? { id: editedId } : {}),
|
|
||||||
name: form.name.trim(),
|
|
||||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
|
||||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
|
||||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
|
||||||
weight: parseIntInput(form.weight),
|
|
||||||
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
|
||||||
calories: parseIntInput(form.calories),
|
|
||||||
price: parsePriceInput(form.price),
|
|
||||||
pricePer100g: parsePriceInput(form.pricePer100g),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setBusy(false);
|
|
||||||
if (response.data) {
|
|
||||||
setTemplates(response.data);
|
|
||||||
resetForm();
|
|
||||||
} else {
|
|
||||||
setError((response.error as { error?: string })?.error ?? 'Šablonu se nepodařilo uložit');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const response = await deleteMealTemplate({ body: { id } });
|
const response = await deleteMealTemplate({ body: { id } });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
setTemplates(response.data);
|
setTemplates(response.data);
|
||||||
if (editedId === id) resetForm();
|
if (edited?.id === id) setEdited(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const edit = (template: MealTemplate) => {
|
const edit = (template: MealTemplate) => {
|
||||||
setEditedId(template.id);
|
setEdited(template);
|
||||||
setError(undefined);
|
|
||||||
setForm({
|
|
||||||
name: template.name,
|
|
||||||
source: template.source ?? '',
|
|
||||||
mealType: template.mealType ?? '',
|
|
||||||
weight: template.weight != null ? String(template.weight) : '',
|
|
||||||
caloriesPer100g: template.caloriesPer100g != null ? String(template.caloriesPer100g) : '',
|
|
||||||
calories: template.calories != null ? String(template.calories) : '',
|
|
||||||
price: formatPriceInput(template.price),
|
|
||||||
pricePer100g: formatPriceInput(template.pricePer100g),
|
|
||||||
note: template.note ?? '',
|
|
||||||
});
|
|
||||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Živý náhled toho, co šablona při použití založí. */
|
const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER
|
||||||
const preview = deriveAmounts(
|
.map(kind => ({ kind, templates: templates.filter(template => template.kind === kind) }))
|
||||||
parsePriceInput(form.price),
|
.filter(group => group.templates.length > 0);
|
||||||
parsePriceInput(form.pricePer100g),
|
|
||||||
parseIntInput(form.weight),
|
|
||||||
parseIntInput(form.caloriesPer100g));
|
|
||||||
const previewCalories = preview.calories ?? parseIntInput(form.calories);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
Šablona jídla je opakovaně jedené jídlo, které v přehledu dne založíte jedním
|
Šablona jídla se v přehledu dne založí jedním kliknutím, nebo se nabídne, když začnete
|
||||||
kliknutím. Stačí název; gramáž, kalorie a cena se vyplňují podle toho, co o jídle
|
psát název jídla. <strong>Hotové jídlo</strong> je objednávka nebo koupená věc, u které si
|
||||||
víte. U domácích jídel jde do poznámky zapsat i postup přípravy.
|
jen pamatujete kalorie a cenu. <strong>Recept</strong> je vařené jídlo — zapíšete suroviny
|
||||||
|
a postup a kalorie porce se spočítají samy.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -141,185 +73,27 @@ const MealTemplatesPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && templates.map(template => {
|
{!loading && groupList.map(group => (
|
||||||
const derived = deriveAmounts(
|
<div key={group.kind}>
|
||||||
template.price, template.pricePer100g, template.weight, template.caloriesPer100g);
|
<h3 className="apply-template-group-title">{MEAL_TEMPLATE_KIND_NAMES[group.kind]}</h3>
|
||||||
const calories = derived.calories ?? template.calories;
|
{group.templates.map(template => (
|
||||||
return (
|
<MealTemplateCard
|
||||||
<div className="card-panel template-card" key={template.id}>
|
key={template.id}
|
||||||
<div className="template-head">
|
template={template}
|
||||||
<div className="template-title">
|
busy={busy}
|
||||||
{template.name}
|
onEdit={() => edit(template)}
|
||||||
{template.mealType && (
|
onDelete={() => remove(template.id)}
|
||||||
<span className="tag">
|
|
||||||
{MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{template.source && <span className="tag">{template.source}</span>}
|
|
||||||
{derived.weight != null && <span className="tag">{formatWeight(derived.weight)}</span>}
|
|
||||||
{template.note && <span className="tag">s postupem</span>}
|
|
||||||
</div>
|
|
||||||
{calories != null && (
|
|
||||||
<span className="template-calories">{formatCalories(calories)}</span>
|
|
||||||
)}
|
|
||||||
{template.price != null && (
|
|
||||||
<span className="meal-template-price">{formatPrice(template.price)}</span>
|
|
||||||
)}
|
|
||||||
<button className="btn-icon" onClick={() => edit(template)} title="Upravit" aria-label="Upravit šablonu">
|
|
||||||
<FontAwesomeIcon icon={faPenToSquare} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-icon danger"
|
|
||||||
onClick={() => remove(template.id)}
|
|
||||||
disabled={busy}
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat šablonu"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{template.note && <p className="meal-template-note">{template.note}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="card-panel template-form">
|
|
||||||
<h2 className="chart-title">{editedId ? 'Úprava šablony jídla' : 'Nová šablona jídla'}</h2>
|
|
||||||
|
|
||||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
|
||||||
|
|
||||||
<div className="row g-3 mb-3">
|
|
||||||
<div className="col-sm-5">
|
|
||||||
<Form.Label>Název</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
value={form.name}
|
|
||||||
placeholder="Např. Těstovinový salát"
|
|
||||||
onChange={event => update({ name: event.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="col-sm-4">
|
|
||||||
<Form.Label>Zdroj</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
value={form.source}
|
|
||||||
placeholder="Chefie, doma…"
|
|
||||||
onChange={event => update({ source: event.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Chod</Form.Label>
|
|
||||||
<Form.Select
|
|
||||||
value={form.mealType}
|
|
||||||
onChange={event => update({ mealType: event.target.value as MealType | '' })}
|
|
||||||
>
|
|
||||||
<option value="">— vybrat při použití —</option>
|
|
||||||
{MEAL_TYPES_IN_ORDER.map(type => (
|
|
||||||
<option key={type} value={type}>
|
|
||||||
{MEAL_TYPE_ICONS[type]} {MEAL_TYPE_NAMES[type]}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</Form.Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<fieldset className="meal-fieldset mb-3">
|
<MealTemplateForm
|
||||||
<legend>Kalorie, gramáž a cena</legend>
|
key={`${edited?.id ?? 'new'}-${formVersion}`}
|
||||||
<div className="row g-3">
|
template={edited}
|
||||||
<div className="col-sm-3">
|
onSaved={saved => { setTemplates(saved); setEdited(undefined); setFormVersion(current => current + 1); }}
|
||||||
<Form.Label>Gramáž</Form.Label>
|
onCancel={() => setEdited(undefined)}
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.weight}
|
|
||||||
placeholder="350"
|
|
||||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
|
||||||
/>
|
/>
|
||||||
<span className="input-group-text">g</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Energie na 100 g</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.caloriesPer100g}
|
|
||||||
placeholder="160"
|
|
||||||
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">kcal</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Cena</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="decimal"
|
|
||||||
value={form.price}
|
|
||||||
placeholder="129,00"
|
|
||||||
onChange={event => update({ price: event.target.value })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">Kč</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Cena za 100 g</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="decimal"
|
|
||||||
value={form.pricePer100g}
|
|
||||||
placeholder="volitelné"
|
|
||||||
onChange={event => update({ pricePer100g: event.target.value })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">Kč</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Kalorie celé porce dávají smysl jen tam, kde se nedají dopočítat z gramáže */}
|
|
||||||
{preview.calories == null && (
|
|
||||||
<div className="row g-3 mt-0">
|
|
||||||
<div className="col-sm-3">
|
|
||||||
<Form.Label>Kalorie porce</Form.Label>
|
|
||||||
<div className="input-group">
|
|
||||||
<Form.Control
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.calories}
|
|
||||||
placeholder="volitelné"
|
|
||||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
|
||||||
/>
|
|
||||||
<span className="input-group-text">kcal</span>
|
|
||||||
</div>
|
|
||||||
<Form.Text>když neznáte gramáž ani hodnotu na 100 g</Form.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
|
||||||
<Form.Label>Poznámka / postup</Form.Label>
|
|
||||||
<Form.Control
|
|
||||||
as="textarea"
|
|
||||||
rows={3}
|
|
||||||
value={form.note}
|
|
||||||
placeholder="U domácích jídel sem lze zapsat recept…"
|
|
||||||
onChange={event => update({ note: event.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Group>
|
|
||||||
|
|
||||||
<div className="workout-form-footer">
|
|
||||||
{previewCalories != null && (
|
|
||||||
<span className="workout-draft-calories">
|
|
||||||
{preview.weight != null && `${formatWeight(preview.weight)} · `}
|
|
||||||
{formatCalories(previewCalories)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{editedId && (
|
|
||||||
<Button variant="outline-secondary" size="sm" onClick={() => resetForm()}>Zrušit úpravu</Button>
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
|
||||||
{editedId ? 'Uložit změny' : 'Vytvořit šablonu'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -140,3 +140,69 @@
|
|||||||
color: var(--ft-warning);
|
color: var(--ft-warning);
|
||||||
padding: 3px 0;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Náhled importu po dnech — u každého dne i jídla, která v něm už jsou
|
||||||
|
.import-preview {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
.import-preview-day {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-top: 1px solid var(--ft-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-day-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ft-text);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-row {
|
||||||
|
margin-left: 22px;
|
||||||
|
|
||||||
|
.form-check-label {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-existing {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 22px;
|
||||||
|
padding: 1px 0;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
|
||||||
|
&.replaced .import-preview-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-note {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-price {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-skipped {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--ft-border-light);
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
@@ -92,3 +92,55 @@
|
|||||||
color: var(--ft-text-secondary);
|
color: var(--ft-text-secondary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Řádek suroviny receptu — název se roztáhne, číselná pole mají pevnou šířku
|
||||||
|
.recipe-ingredient-row {
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.recipe-ingredient-weight {
|
||||||
|
width: 110px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-rate {
|
||||||
|
width: 100px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-calories {
|
||||||
|
width: 90px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.87rem;
|
||||||
|
color: var(--ft-warning);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredient-price {
|
||||||
|
width: 110px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.recipe-ingredient-weight,
|
||||||
|
.recipe-ingredient-rate,
|
||||||
|
.recipe-ingredient-calories,
|
||||||
|
.recipe-ingredient-price {
|
||||||
|
width: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-summary {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.87rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
|
||||||
|
.recipe-summary-portion {
|
||||||
|
color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate,
|
||||||
} from "../../../types";
|
} from "../../../types";
|
||||||
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
|
import { ACTIVITY_PRESETS, ACTIVITY_UNITS_IN_ORDER, ACTIVITY_UNIT_NAMES, ACTIVITY_UNIT_SHORT } from "../enums";
|
||||||
import { deriveActivityCalories, formatCalories, formatQuantityWithSets, parseIntInput } from "../Utils";
|
import {
|
||||||
|
deriveActivityCalories, formatCalories, formatExtraWeight, formatKgInput, formatQuantityWithSets,
|
||||||
|
parseIntInput, parseWeightKgInput,
|
||||||
|
} from "../Utils";
|
||||||
|
|
||||||
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */
|
/** Rozpracovaná položka — hodnoty jsou texty, protože se editují v polích. */
|
||||||
type DraftItem = {
|
type DraftItem = {
|
||||||
@@ -17,6 +20,8 @@ type DraftItem = {
|
|||||||
quantity: string,
|
quantity: string,
|
||||||
sets: string,
|
sets: string,
|
||||||
caloriesPer100Units: string,
|
caloriesPer100Units: string,
|
||||||
|
/** Dodatečná zátěž v kg, jak ji uživatel píše */
|
||||||
|
extraWeight: string,
|
||||||
templateRefId: string,
|
templateRefId: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +32,7 @@ const emptyExercise = (): DraftItem => ({
|
|||||||
quantity: '',
|
quantity: '',
|
||||||
sets: '',
|
sets: '',
|
||||||
caloriesPer100Units: '',
|
caloriesPer100Units: '',
|
||||||
|
extraWeight: '',
|
||||||
templateRefId: '',
|
templateRefId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,6 +113,9 @@ const WorkoutTemplatesPanel = () => {
|
|||||||
...(parseIntInput(item.caloriesPer100Units) != null
|
...(parseIntInput(item.caloriesPer100Units) != null
|
||||||
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
|
? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(parseWeightKgInput(item.extraWeight) != null
|
||||||
|
? { extraWeightGrams: parseWeightKgInput(item.extraWeight) }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
|
const canSave = name.trim().length > 0 && toItems().length > 0 && !busy;
|
||||||
@@ -153,6 +162,7 @@ const WorkoutTemplatesPanel = () => {
|
|||||||
quantity: item.quantity != null ? String(item.quantity) : '',
|
quantity: item.quantity != null ? String(item.quantity) : '',
|
||||||
sets: item.sets != null ? String(item.sets) : '',
|
sets: item.sets != null ? String(item.sets) : '',
|
||||||
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '',
|
||||||
|
extraWeight: formatKgInput(item.extraWeightGrams),
|
||||||
templateRefId: '',
|
templateRefId: '',
|
||||||
}));
|
}));
|
||||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||||
@@ -172,7 +182,8 @@ const WorkoutTemplatesPanel = () => {
|
|||||||
<p className="text-body-secondary small">
|
<p className="text-body-secondary small">
|
||||||
Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím.
|
Šablona je pojmenovaný seznam cviků, který jde v přehledu dne založit jedním kliknutím.
|
||||||
Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku.
|
Mezi položky lze zařadit i jinou šablonu — třeba rozcvičku, kterou máte v každém tréninku.
|
||||||
Zanoření je kvůli jednoduchosti jen na jednu úroveň.
|
Zanoření je kvůli jednoduchosti jen na jednu úroveň. U cviku jde zapsat
|
||||||
|
i dodatečnou zátěž (činky, vesta) — je to jen informace, kalorie nemění.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -235,6 +246,11 @@ const WorkoutTemplatesPanel = () => {
|
|||||||
<span className="template-item-quantity">
|
<span className="template-item-quantity">
|
||||||
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
|
{formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)}
|
||||||
</span>
|
</span>
|
||||||
|
{!!item.extraWeightGrams && (
|
||||||
|
<span className="tag" title="Dodatečná zátěž">
|
||||||
|
{formatExtraWeight(item.extraWeightGrams)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="template-item-calories">
|
<span className="template-item-calories">
|
||||||
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
|
{formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))}
|
||||||
</span>
|
</span>
|
||||||
@@ -318,6 +334,14 @@ const WorkoutTemplatesPanel = () => {
|
|||||||
placeholder="kcal/100"
|
placeholder="kcal/100"
|
||||||
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })}
|
||||||
/>
|
/>
|
||||||
|
<Form.Control
|
||||||
|
className="workout-item-weight"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={item.extraWeight}
|
||||||
|
placeholder="zátěž kg"
|
||||||
|
title="Dodatečná zátěž v kg — činky, vesta. Kalorie nemění."
|
||||||
|
onChange={event => updateItem(index, { extraWeight: event.target.value })}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
import { ActivityDay, ActivityEntry, ActivityUnit } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
import { BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export function buildActivityDay(date: string, entries: ActivityEntry[]): Activi
|
|||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
entries: sorted,
|
entries: sorted,
|
||||||
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
|
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +87,9 @@ export type NormalizedActivityInput = {
|
|||||||
caloriesPer100Units?: number;
|
caloriesPer100Units?: number;
|
||||||
calories?: number;
|
calories?: number;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
/** Dodatečná zátěž v gramech — jen informace, kalorie nemění. */
|
||||||
|
extraWeightGrams?: number;
|
||||||
|
multiplier?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,6 +143,8 @@ export function normalizeActivityInput(input: unknown): NormalizedActivityInput
|
|||||||
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
|
caloriesPer100Units: normalizeNumber(value.caloriesPer100Units, 'caloriesPer100Units'),
|
||||||
calories: normalizeNumber(value.calories, 'calories'),
|
calories: normalizeNumber(value.calories, 'calories'),
|
||||||
note: normalizeText(value.note, 'note'),
|
note: normalizeText(value.note, 'note'),
|
||||||
|
extraWeightGrams: normalizeNumber(value.extraWeightGrams, 'extraWeightGrams') || undefined,
|
||||||
|
multiplier: normalizeMultiplier(value.multiplier),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +219,8 @@ export async function updateActivity(login: string, id: unknown, input: unknown)
|
|||||||
caloriesPer100Units: normalized.caloriesPer100Units,
|
caloriesPer100Units: normalized.caloriesPer100Units,
|
||||||
calories: normalized.calories,
|
calories: normalized.calories,
|
||||||
note: normalized.note,
|
note: normalized.note,
|
||||||
|
extraWeightGrams: normalized.extraWeightGrams,
|
||||||
|
multiplier: normalized.multiplier,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await storage.updateData<ActivityEntry[]>(
|
await storage.updateData<ActivityEntry[]>(
|
||||||
@@ -248,6 +255,40 @@ export async function deleteActivity(login: string, id: unknown, date: unknown):
|
|||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nastaví násobek aktivity přímo z přehledu dne. Ostatní údaje zůstanou beze změny.
|
||||||
|
*
|
||||||
|
* @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ší
|
||||||
|
* @returns datum, ve kterém aktivita je
|
||||||
|
*/
|
||||||
|
export async function setActivityMultiplier(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<ActivityEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||||
|
if (entry.id !== id) return entry;
|
||||||
|
found = true;
|
||||||
|
const updated: ActivityEntry = { ...entry, multiplier: normalized, updatedAt: new Date().toISOString() };
|
||||||
|
if (normalized == null) delete updated.multiplier;
|
||||||
|
return updated;
|
||||||
|
}));
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundError("Aktivita nebyla nalezena");
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
/** Najde datum, pod kterým je aktivita uložená. Úprava totiž může měnit i datum. */
|
/** Najde datum, pod kterým je aktivita uložená. Úprava totiž může měnit i datum. */
|
||||||
async function findActivityDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
|
async function findActivityDate(login: string, id: string, expectedDate: string): Promise<string | undefined> {
|
||||||
const expected = await storage.getData<ActivityEntry[]>(getDayKey(login, expectedDate));
|
const expected = await storage.getData<ActivityEntry[]>(getDayKey(login, expectedDate));
|
||||||
|
|||||||
+51
-12
@@ -1,7 +1,7 @@
|
|||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
|
import { ImportMode, ImportResult, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||||
import { addEntries, getExistingImportKeys, isMealType } from "./meals";
|
import { addEntries, findMealsForDays, isMealType } from "./meals";
|
||||||
import { BadRequestError, isValidIsoDate } from "./utils";
|
import { BadRequestError, isValidIsoDate } from "./utils";
|
||||||
|
|
||||||
/** Označení zdroje, kterým se importované záznamy značí. */
|
/** Označení zdroje, kterým se importované záznamy značí. */
|
||||||
@@ -363,18 +363,30 @@ function toMealEntry(row: LuncherRow, mealType: MealType, importKey: string): Me
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vrátí true, pokud je hodnota podporovaným režimem importu. */
|
||||||
|
const isImportMode = (value: unknown): value is ImportMode =>
|
||||||
|
value === ImportMode.PRIDAT || value === ImportMode.NAHRADIT;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Naimportuje přehled vyexportovaný z Luncheru do jídel uživatele.
|
* Naimportuje přehled vyexportovaný z Luncheru do jídel uživatele.
|
||||||
*
|
*
|
||||||
* Export je typicky za celý měsíc — záznamy se rozdělí do dnů, ke kterým patří.
|
* Export je typicky za celý měsíc — záznamy se rozdělí do dnů, ke kterým patří.
|
||||||
* Řádky, které už z dřívějšího importu existují, se přeskočí, takže opakovaný
|
* V režimu PRIDAT se řádky, které už z dřívějšího importu existují, přeskočí,
|
||||||
* import stejného měsíce data nezduplikuje.
|
* takže opakovaný import stejného měsíce data nezduplikuje. Režim NAHRADIT
|
||||||
|
* dřívější import dotčených dnů odebere a založí ho znovu — hodí se, když se
|
||||||
|
* v Luncheru něco opravilo. Ručně zadaná jídla zůstávají v obou režimech.
|
||||||
|
*
|
||||||
|
* Náhled vrací všechny řádky, které jde založit, a k nim jídla, která už
|
||||||
|
* v dotčených dnech jsou. Uživatel si v něm odškrtne, co nechce, a ostrý
|
||||||
|
* import pak dostane jen vybrané klíče.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param fileName název souboru (určuje formát)
|
* @param fileName název souboru (určuje formát)
|
||||||
* @param content obsah souboru
|
* @param content obsah souboru
|
||||||
* @param defaultMealType typ jídla pro importované řádky, výchozí je oběd
|
* @param defaultMealType typ jídla pro importované řádky, výchozí je oběd
|
||||||
* @param dryRun pokud true, výsledek se jen spočítá a nic se neuloží
|
* @param dryRun pokud true, výsledek se jen spočítá a nic se neuloží
|
||||||
|
* @param mode režim importu, výchozí je PRIDAT
|
||||||
|
* @param selectedKeys klíče řádků k založení; bez nich se založí všechny
|
||||||
*/
|
*/
|
||||||
export async function importLuncherExport(
|
export async function importLuncherExport(
|
||||||
login: string,
|
login: string,
|
||||||
@@ -382,13 +394,23 @@ export async function importLuncherExport(
|
|||||||
content: Buffer,
|
content: Buffer,
|
||||||
defaultMealType?: unknown,
|
defaultMealType?: unknown,
|
||||||
dryRun = false,
|
dryRun = false,
|
||||||
|
mode?: unknown,
|
||||||
|
selectedKeys?: unknown,
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
const format = detectFormat(fileName);
|
const format = detectFormat(fileName);
|
||||||
if (defaultMealType != null && !isMealType(defaultMealType)) {
|
if (defaultMealType != null && !isMealType(defaultMealType)) {
|
||||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||||
}
|
}
|
||||||
|
if (mode != null && !isImportMode(mode)) {
|
||||||
|
throw new BadRequestError("Nebyl předán platný režim importu");
|
||||||
|
}
|
||||||
|
if (selectedKeys != null && !Array.isArray(selectedKeys)) {
|
||||||
|
throw new BadRequestError("Vybrané řádky musí být pole klíčů");
|
||||||
|
}
|
||||||
// Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd
|
// Luncher řeší výběr obědů, takže bez upřesnění zakládáme importy jako oběd
|
||||||
const mealType: MealType = isMealType(defaultMealType) ? defaultMealType : MealType.OBED;
|
const mealType: MealType = isMealType(defaultMealType) ? defaultMealType : MealType.OBED;
|
||||||
|
const resolvedMode: ImportMode = isImportMode(mode) ? mode : ImportMode.PRIDAT;
|
||||||
|
const replace = resolvedMode === ImportMode.NAHRADIT;
|
||||||
|
|
||||||
let parsed: ParsedFile;
|
let parsed: ParsedFile;
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
@@ -400,10 +422,14 @@ export async function importLuncherExport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
|
const dates = Array.from(new Set(parsed.rows.map(row => row.date)));
|
||||||
const existingKeys = await getExistingImportKeys(login, dates);
|
const existing = await findMealsForDays(login, dates);
|
||||||
|
// Při nahrazení se dřívější import založí znovu, duplicitou tedy není
|
||||||
|
const existingKeys = new Set(replace
|
||||||
|
? []
|
||||||
|
: existing.map(entry => entry.importKey).filter(key => !!key));
|
||||||
|
|
||||||
const perDayIndex = new Map<string, number>();
|
const perDayIndex = new Map<string, number>();
|
||||||
const entries: MealEntry[] = [];
|
const candidates: MealEntry[] = [];
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
for (const row of parsed.rows) {
|
for (const row of parsed.rows) {
|
||||||
const indexWithinDay = perDayIndex.get(row.date) ?? 0;
|
const indexWithinDay = perDayIndex.get(row.date) ?? 0;
|
||||||
@@ -413,22 +439,35 @@ export async function importLuncherExport(
|
|||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
entries.push(toMealEntry(row, mealType, importKey));
|
candidates.push(toMealEntry(row, mealType, importKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedKeyList = selectedKeys == null ? undefined : new Set(selectedKeys.map(String));
|
||||||
|
const selected = candidates.filter(entry => !selectedKeyList || selectedKeyList.has(entry.importKey!));
|
||||||
|
skipped += candidates.length - selected.length;
|
||||||
|
|
||||||
|
// Nahrazuje se jen ve dnech, do kterých se opravdu něco zakládá
|
||||||
|
const selectedDays = new Set(selected.map(entry => entry.date));
|
||||||
|
const replaced = replace
|
||||||
|
? existing.filter(entry => entry.importSource === IMPORT_SOURCE && selectedDays.has(entry.date)).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
const days = dryRun
|
const days = dryRun
|
||||||
? Array.from(new Set(entries.map(entry => entry.date))).sort()
|
? Array.from(selectedDays).sort()
|
||||||
: await addEntries(login, entries);
|
: await addEntries(login, selected, replace ? IMPORT_SOURCE : undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dryRun,
|
dryRun,
|
||||||
format,
|
format,
|
||||||
|
mode: resolvedMode,
|
||||||
rowCount: parsed.rows.length,
|
rowCount: parsed.rows.length,
|
||||||
imported: entries.length,
|
imported: selected.length,
|
||||||
skipped,
|
skipped,
|
||||||
|
replaced,
|
||||||
days,
|
days,
|
||||||
totalPrice: entries.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
totalPrice: selected.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||||
entries,
|
entries: dryRun ? candidates : selected,
|
||||||
|
existing,
|
||||||
warnings: parsed.warnings,
|
warnings: parsed.warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+176
-20
@@ -1,9 +1,9 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { MealTemplate, MealType } from "../../types/gen/types.gen";
|
import { MealTemplate, MealTemplateKind, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { addMeal, getDay, isMealType } from "./meals";
|
import { addMeal, getDay, isMealType } from "./meals";
|
||||||
import { normalizeName } from "./calories";
|
import { normalizeName, rememberCalories } from "./calories";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate } from "./utils";
|
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -11,9 +11,24 @@ const storage = getStorage();
|
|||||||
const MAX_NAME_LENGTH = 200;
|
const MAX_NAME_LENGTH = 200;
|
||||||
const MAX_NOTE_LENGTH = 4000;
|
const MAX_NOTE_LENGTH = 4000;
|
||||||
|
|
||||||
|
/** Maximální počet surovin receptu — pojistka proti nesmyslnému vstupu. */
|
||||||
|
const MAX_INGREDIENTS = 50;
|
||||||
|
|
||||||
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
/** Klíč úložiště se šablonami jídel jednoho uživatele. */
|
||||||
const getKey = (login: string): string => `mealTemplates:${login}`;
|
const getKey = (login: string): string => `mealTemplates:${login}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplní šabloně druh, pokud chybí.
|
||||||
|
*
|
||||||
|
* Šablony uložené dřív, než přibyly recepty, pole `kind` nemají — všechny jsou
|
||||||
|
* ploché záznamy s výslednými hodnotami, tedy hotová jídla. V úložišti se nic
|
||||||
|
* nepřepisuje, druh se doplní jen při čtení.
|
||||||
|
*/
|
||||||
|
const withKind = (template: MealTemplate): MealTemplate => ({
|
||||||
|
...template,
|
||||||
|
kind: template.kind ?? MealTemplateKind.HOTOVE,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
* Vrátí šablony jídel uživatele seřazené podle názvu.
|
||||||
*
|
*
|
||||||
@@ -21,8 +36,78 @@ const getKey = (login: string): string => `mealTemplates:${login}`;
|
|||||||
*/
|
*/
|
||||||
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
export const getMealTemplates = async (login: string): Promise<MealTemplate[]> =>
|
||||||
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
(await storage.getData<MealTemplate[]>(getKey(login)) ?? [])
|
||||||
|
.map(withKind)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
.sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||||
|
|
||||||
|
/** Hodnoty hotového jídla a jedné porce receptu dopočtené ze surovin. */
|
||||||
|
export type RecipeSummary = {
|
||||||
|
/** Energie všech surovin dohromady v kcal */
|
||||||
|
totalCalories?: number;
|
||||||
|
/** Hmotnost hotového jídla v gramech — zadaná, jinak součet surovin */
|
||||||
|
totalWeight?: number;
|
||||||
|
/** Cena všech surovin v haléřích */
|
||||||
|
totalPrice?: number;
|
||||||
|
/** Gramáž jedné porce */
|
||||||
|
weight?: number;
|
||||||
|
/** Energie jedné porce v kcal */
|
||||||
|
calories?: number;
|
||||||
|
/** Energie hotového jídla na 100 g */
|
||||||
|
caloriesPer100g?: number;
|
||||||
|
/** Cena jedné porce v haléřích */
|
||||||
|
price?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Energie jedné suroviny — z gramáže a energie na 100 g, jinak ručně zadaná.
|
||||||
|
* Stejné pravidlo jako u jídla v `deriveAmounts`.
|
||||||
|
*/
|
||||||
|
const ingredientCalories = (ingredient: RecipeIngredient): 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.
|
||||||
|
*
|
||||||
|
* Energie na 100 g se počítá z hmotnosti hotového jídla, ne ze součtu surovin —
|
||||||
|
* vařením se voda odpaří nebo nasákne (rýže, těstoviny), takže by vyšla špatně.
|
||||||
|
* Porci si pak jde převážit a kalorie se dopočtou z gramáže jako u každého jídla.
|
||||||
|
*
|
||||||
|
* Klient má stejnou funkci v `Utils.tsx` pro živý náhled — musí dávat stejné výsledky.
|
||||||
|
*
|
||||||
|
* @param ingredients suroviny receptu
|
||||||
|
* @param servings počet porcí, nevyplněný znamená jednu
|
||||||
|
* @param cookedWeight hmotnost hotového jídla v gramech
|
||||||
|
*/
|
||||||
|
export const deriveRecipe = (
|
||||||
|
ingredients: RecipeIngredient[],
|
||||||
|
servings?: number,
|
||||||
|
cookedWeight?: number,
|
||||||
|
): RecipeSummary => {
|
||||||
|
const portions = Math.max(servings ?? 1, 1);
|
||||||
|
const totalCalories = sumDefined(ingredients.map(ingredientCalories));
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** Ořízne a znormalizuje volitelný textový vstup. */
|
/** Ořízne a znormalizuje volitelný textový vstup. */
|
||||||
const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => {
|
const normalizeText = (value: unknown, name: string, maxLength = MAX_NAME_LENGTH): string | undefined => {
|
||||||
if (value == null) return undefined;
|
if (value == null) return undefined;
|
||||||
@@ -50,9 +135,38 @@ const normalizeNumber = (value: unknown, name: string): number | undefined => {
|
|||||||
return rounded;
|
return rounded;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Vynechá nevyplněné hodnoty, aby se do úložiště nepsaly prázdné klíče. */
|
||||||
|
const definedOnly = <T extends object>(value: T): T =>
|
||||||
|
Object.fromEntries(Object.entries(value).filter(([, item]) => item != null)) as T;
|
||||||
|
|
||||||
|
/** Ověří a znormalizuje jednu surovinu receptu. */
|
||||||
|
const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
||||||
|
const value = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const name = normalizeText(value.name, 'name');
|
||||||
|
if (!name) {
|
||||||
|
throw new BadRequestError(`Surovina ${index + 1} nemá název`);
|
||||||
|
}
|
||||||
|
const weight = normalizeNumber(value.weight, 'weight');
|
||||||
|
const caloriesPer100g = normalizeNumber(value.caloriesPer100g, 'caloriesPer100g');
|
||||||
|
const calories = normalizeNumber(value.calories, 'calories');
|
||||||
|
const price = normalizeNumber(value.price, 'price');
|
||||||
|
return definedOnly({
|
||||||
|
name,
|
||||||
|
weight,
|
||||||
|
caloriesPer100g,
|
||||||
|
// Ručně zadaná energie platí jen tam, kde ji nejde dopočítat z gramáže
|
||||||
|
calories: weight != null && caloriesPer100g ? undefined : calories,
|
||||||
|
price,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
* Založí novou šablonu jídla, nebo přepíše existující podle předaného id.
|
||||||
*
|
*
|
||||||
|
* U hotového jídla se ukládají zadané výsledné hodnoty. U receptu se hodnoty
|
||||||
|
* porce (gramáž, energie, cena) dopočtou ze surovin a uloží do stejných polí —
|
||||||
|
* použití šablony pak funguje pro oba druhy stejně.
|
||||||
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param input data šablony
|
* @param input data šablony
|
||||||
*/
|
*/
|
||||||
@@ -65,27 +179,59 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
if (value.mealType != null && !isMealType(value.mealType)) {
|
||||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
throw new BadRequestError("Nebyl předán platný typ jídla");
|
||||||
}
|
}
|
||||||
|
if (value.kind != null && value.kind !== MealTemplateKind.HOTOVE && value.kind !== MealTemplateKind.RECEPT) {
|
||||||
|
throw new BadRequestError("Nebyl předán platný druh šablony");
|
||||||
|
}
|
||||||
|
const kind = value.kind === MealTemplateKind.RECEPT ? MealTemplateKind.RECEPT : MealTemplateKind.HOTOVE;
|
||||||
|
|
||||||
const template: MealTemplate = {
|
const common = {
|
||||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||||
|
kind,
|
||||||
name,
|
name,
|
||||||
...(value.mealType != null ? { mealType: value.mealType as MealType } : {}),
|
mealType: value.mealType != null ? value.mealType as MealType : undefined,
|
||||||
...(normalizeText(value.source, 'source') != null
|
source: normalizeText(value.source, 'source'),
|
||||||
? { source: normalizeText(value.source, 'source') } : {}),
|
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||||
...(normalizeText(value.note, 'note', MAX_NOTE_LENGTH) != null
|
|
||||||
? { note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH) } : {}),
|
|
||||||
...(normalizeNumber(value.calories, 'calories') != null
|
|
||||||
? { calories: normalizeNumber(value.calories, 'calories') } : {}),
|
|
||||||
...(normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') != null
|
|
||||||
? { caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') } : {}),
|
|
||||||
...(normalizeNumber(value.weight, 'weight') != null
|
|
||||||
? { weight: normalizeNumber(value.weight, 'weight') } : {}),
|
|
||||||
...(normalizeNumber(value.price, 'price') != null
|
|
||||||
? { price: normalizeNumber(value.price, 'price') } : {}),
|
|
||||||
...(normalizeNumber(value.pricePer100g, 'pricePer100g') != null
|
|
||||||
? { pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let template: MealTemplate;
|
||||||
|
if (kind === MealTemplateKind.RECEPT) {
|
||||||
|
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||||
|
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||||
|
}
|
||||||
|
if (value.ingredients.length > MAX_INGREDIENTS) {
|
||||||
|
throw new BadRequestError(`Recept může mít nejvýše ${MAX_INGREDIENTS} surovin`);
|
||||||
|
}
|
||||||
|
const ingredients = value.ingredients.map(normalizeIngredient);
|
||||||
|
const servings = normalizeNumber(value.servings, 'servings');
|
||||||
|
const cookedWeight = normalizeNumber(value.cookedWeight, 'cookedWeight') || undefined;
|
||||||
|
const summary = deriveRecipe(ingredients, servings, cookedWeight);
|
||||||
|
template = definedOnly({
|
||||||
|
...common,
|
||||||
|
ingredients,
|
||||||
|
// Jedna porce je výchozí stav, ukládat ji zvlášť nemá smysl
|
||||||
|
servings: servings && servings > 1 ? servings : undefined,
|
||||||
|
cookedWeight,
|
||||||
|
weight: summary.weight,
|
||||||
|
caloriesPer100g: summary.caloriesPer100g,
|
||||||
|
calories: summary.calories,
|
||||||
|
price: summary.price,
|
||||||
|
});
|
||||||
|
for (const ingredient of ingredients) {
|
||||||
|
if (ingredient.caloriesPer100g) {
|
||||||
|
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
template = definedOnly({
|
||||||
|
...common,
|
||||||
|
calories: normalizeNumber(value.calories, 'calories'),
|
||||||
|
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||||
|
weight: normalizeNumber(value.weight, 'weight'),
|
||||||
|
price: normalizeNumber(value.price, 'price'),
|
||||||
|
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
await storage.updateData<MealTemplate[]>(getKey(login), current => {
|
||||||
const others = (current ?? []).filter(item => item.id !== template.id);
|
const others = (current ?? []).filter(item => item.id !== template.id);
|
||||||
return [...others, template];
|
return [...others, template];
|
||||||
@@ -125,12 +271,14 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise<Me
|
|||||||
* @param id identifikátor šablony
|
* @param id identifikátor šablony
|
||||||
* @param date den, do kterého se jídlo založí
|
* @param date den, do kterého se jídlo založí
|
||||||
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
|
* @param mealType chod; bez něj se použije chod ze šablony, jinak oběd
|
||||||
|
* @param multiplier násobek porce, třeba 2 za dvě stejné sušenky
|
||||||
*/
|
*/
|
||||||
export const applyMealTemplate = async (
|
export const applyMealTemplate = async (
|
||||||
login: string,
|
login: string,
|
||||||
id: unknown,
|
id: unknown,
|
||||||
date: unknown,
|
date: unknown,
|
||||||
mealType?: unknown,
|
mealType?: unknown,
|
||||||
|
multiplier?: unknown,
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
if (typeof id !== 'string' || !id.length) {
|
if (typeof id !== 'string' || !id.length) {
|
||||||
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
throw new BadRequestError("Nebyl předán identifikátor šablony");
|
||||||
@@ -156,7 +304,9 @@ export const applyMealTemplate = async (
|
|||||||
pricePer100g: template.pricePer100g,
|
pricePer100g: template.pricePer100g,
|
||||||
calories: template.calories,
|
calories: template.calories,
|
||||||
caloriesPer100g: template.caloriesPer100g,
|
caloriesPer100g: template.caloriesPer100g,
|
||||||
note: template.note,
|
// Postup receptu do záznamu dne nepatří, zůstává u šablony
|
||||||
|
note: template.kind === MealTemplateKind.RECEPT ? undefined : template.note,
|
||||||
|
multiplier: normalizeMultiplier(multiplier),
|
||||||
});
|
});
|
||||||
return date;
|
return date;
|
||||||
};
|
};
|
||||||
@@ -166,6 +316,8 @@ export const applyMealTemplate = async (
|
|||||||
*
|
*
|
||||||
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
|
* Když už šablona se stejným názvem existuje, přepíše se — opakované "uložit
|
||||||
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
|
* jako šablonu" u téhož jídla má dát jednu šablonu, ne několik stejných.
|
||||||
|
* Výjimkou je recept: nese suroviny a postup, které záznam dne nemá, takže by
|
||||||
|
* přepsáním zmizely. Ze záznamu dne vzniká vždy hotové jídlo.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param date den, ve kterém jídlo je
|
* @param date den, ve kterém jídlo je
|
||||||
@@ -189,9 +341,13 @@ export const createMealTemplateFromMeal = async (
|
|||||||
|
|
||||||
const key = normalizeName(meal.name);
|
const key = normalizeName(meal.name);
|
||||||
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
const existing = (await getMealTemplates(login)).find(item => normalizeName(item.name) === key);
|
||||||
|
if (existing?.kind === MealTemplateKind.RECEPT) {
|
||||||
|
throw new BadRequestError(`Jídlo „${meal.name}" už máte jako recept, šablona se nepřepíše`);
|
||||||
|
}
|
||||||
|
|
||||||
return saveMealTemplate(login, {
|
return saveMealTemplate(login, {
|
||||||
...(existing ? { id: existing.id } : {}),
|
...(existing ? { id: existing.id } : {}),
|
||||||
|
kind: MealTemplateKind.HOTOVE,
|
||||||
name: meal.name,
|
name: meal.name,
|
||||||
source: meal.source,
|
source: meal.source,
|
||||||
mealType: meal.mealType,
|
mealType: meal.mealType,
|
||||||
|
|||||||
+53
-17
@@ -1,7 +1,9 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
|
import { DayRecord, MealEntry, MealType } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate, listDatesInRange } from "./utils";
|
import {
|
||||||
|
BadRequestError, NotFoundError, applyMultiplier, isValidIsoDate, listDatesInRange, normalizeMultiplier,
|
||||||
|
} from "./utils";
|
||||||
import { rememberCalories } from "./calories";
|
import { rememberCalories } from "./calories";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -47,7 +49,7 @@ export function buildDayRecord(date: string, entries: MealEntry[]): DayRecord {
|
|||||||
date,
|
date,
|
||||||
entries: sorted,
|
entries: sorted,
|
||||||
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
totalPrice: sorted.reduce((sum, entry) => sum + (entry.price ?? 0), 0),
|
||||||
totalCalories: sorted.reduce((sum, entry) => sum + (entry.calories ?? 0), 0),
|
totalCalories: sorted.reduce((sum, entry) => sum + (applyMultiplier(entry.calories, entry.multiplier) ?? 0), 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ export type NormalizedMealInput = {
|
|||||||
weight?: number;
|
weight?: number;
|
||||||
pricePer100g?: number;
|
pricePer100g?: number;
|
||||||
caloriesPer100g?: number;
|
caloriesPer100g?: number;
|
||||||
|
multiplier?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,6 +171,7 @@ export function normalizeInput(input: unknown): NormalizedMealInput {
|
|||||||
weight: normalizeNumber(value.weight, 'weight'),
|
weight: normalizeNumber(value.weight, 'weight'),
|
||||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g'),
|
||||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g'),
|
||||||
|
multiplier: normalizeMultiplier(value.multiplier),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +204,10 @@ export async function addMeal(login: string, input: unknown): Promise<DayRecord>
|
|||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param entries záznamy k uložení
|
* @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[]): Promise<string[]> {
|
export async function addEntries(login: string, entries: MealEntry[], replaceImportSource?: string): Promise<string[]> {
|
||||||
const byDate = new Map<string, MealEntry[]>();
|
const byDate = new Map<string, MealEntry[]>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const forDate = byDate.get(entry.date) ?? [];
|
const forDate = byDate.get(entry.date) ?? [];
|
||||||
@@ -211,7 +217,10 @@ export async function addEntries(login: string, entries: MealEntry[]): Promise<s
|
|||||||
for (const [date, forDate] of byDate) {
|
for (const [date, forDate] of byDate) {
|
||||||
await storage.updateData<MealEntry[]>(
|
await storage.updateData<MealEntry[]>(
|
||||||
getDayKey(login, date),
|
getDayKey(login, date),
|
||||||
current => [...(current ?? []), ...forDate]
|
current => [
|
||||||
|
...(current ?? []).filter(entry => !replaceImportSource || entry.importSource !== replaceImportSource),
|
||||||
|
...forDate,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Array.from(byDate.keys()).sort();
|
return Array.from(byDate.keys()).sort();
|
||||||
@@ -258,6 +267,7 @@ export async function updateMeal(login: string, id: unknown, input: unknown): Pr
|
|||||||
weight: normalized.weight,
|
weight: normalized.weight,
|
||||||
pricePer100g: normalized.pricePer100g,
|
pricePer100g: normalized.pricePer100g,
|
||||||
caloriesPer100g: normalized.caloriesPer100g,
|
caloriesPer100g: normalized.caloriesPer100g,
|
||||||
|
multiplier: normalized.multiplier,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const entries = await storage.updateData<MealEntry[]>(
|
const entries = await storage.updateData<MealEntry[]>(
|
||||||
@@ -295,6 +305,40 @@ export async function deleteMeal(login: string, id: unknown, date: unknown): Pro
|
|||||||
return buildDayRecord(date, entries);
|
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ý.
|
* 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 —
|
* Nejdřív zkusí očekávaný den, teprve pak prohledá ostatní klíče uživatele —
|
||||||
@@ -317,21 +361,13 @@ async function findEntryDate(login: string, id: string, expectedDate: string): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí klíče importovaných záznamů uživatele v předaných dnech.
|
* Vrátí všechna jídla uživatele v předaných dnech, seřazená dle dne a chodu.
|
||||||
* Slouží k rozpoznání duplicit při opakovaném importu stejného měsíce.
|
* Import podle nich rozpoznává duplicity a v náhledu je ukazuje uživateli.
|
||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param dates dny, ve kterých se má hledat
|
* @param dates dny, ve kterých se má hledat
|
||||||
*/
|
*/
|
||||||
export async function getExistingImportKeys(login: string, dates: string[]): Promise<Set<string>> {
|
export async function findMealsForDays(login: string, dates: string[]): Promise<MealEntry[]> {
|
||||||
const keys = new Set<string>();
|
const days = await Promise.all([...dates].sort().map(date => getDay(login, date)));
|
||||||
for (const date of dates) {
|
return days.flatMap(day => day.entries);
|
||||||
const entries = await storage.getData<MealEntry[]>(getDayKey(login, date));
|
|
||||||
for (const entry of entries ?? []) {
|
|
||||||
if (entry.importKey) {
|
|
||||||
keys.add(entry.importKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { addActivity, deleteActivity, updateActivity } from "../activities";
|
import { addActivity, deleteActivity, setActivityMultiplier, updateActivity } from "../activities";
|
||||||
import { getDayOverview } from "../dayOverview";
|
import { getDayOverview } from "../dayOverview";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
@@ -35,4 +35,14 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Nastaví násobek aktivity z přehledu dne. */
|
||||||
|
router.post("/multiplier", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, date, multiplier } = req.body ?? {};
|
||||||
|
const day = await setActivityMultiplier(login, id, date, multiplier);
|
||||||
|
res.status(200).json(await getDayOverview(login, day));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -14,7 +14,7 @@ const router = express.Router();
|
|||||||
router.post("/luncher", async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/luncher", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { fileName, content, defaultMealType, dryRun } = req.body ?? {};
|
const { fileName, content, defaultMealType, dryRun, mode, selectedKeys } = req.body ?? {};
|
||||||
if (typeof fileName !== 'string' || !fileName.trim().length) {
|
if (typeof fileName !== 'string' || !fileName.trim().length) {
|
||||||
throw new BadRequestError("Nebyl předán název souboru");
|
throw new BadRequestError("Nebyl předán název souboru");
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,8 @@ router.post("/luncher", async (req: Request, res: Response, next: NextFunction)
|
|||||||
if (!buffer.length) {
|
if (!buffer.length) {
|
||||||
throw new BadRequestError("Obsah souboru je prázdný nebo není platný Base64");
|
throw new BadRequestError("Obsah souboru je prázdný nebo není platný Base64");
|
||||||
}
|
}
|
||||||
const result = await importLuncherExport(login, fileName.trim(), buffer, defaultMealType, dryRun === true);
|
const result = await importLuncherExport(
|
||||||
|
login, fileName.trim(), buffer, defaultMealType, dryRun === true, mode, selectedKeys);
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import { addMeal, deleteMeal, getDay, getRange, updateMeal } from "../meals";
|
import { addMeal, deleteMeal, getDay, getRange, setMealMultiplier, updateMeal } from "../meals";
|
||||||
|
import { getDayOverview } from "../dayOverview";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
import { formatDate, getToday, parseToken, requireIsoDate } from "../utils";
|
||||||
|
|
||||||
@@ -56,4 +57,14 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Nastaví násobek porce z přehledu dne a vrátí celý přehled, protože se mění i bilance. */
|
||||||
|
router.post("/multiplier", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, date, multiplier } = req.body ?? {};
|
||||||
|
const day = await setMealMultiplier(login, id, date, multiplier);
|
||||||
|
res.status(200).json(await getDayOverview(login, day));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -36,8 +36,8 @@ router.post("/delete", async (req: Request, res: Response, next: NextFunction) =
|
|||||||
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/apply", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, date, mealType } = req.body ?? {};
|
const { id, date, mealType, multiplier } = req.body ?? {};
|
||||||
const day = await applyMealTemplate(login, id, date, mealType);
|
const day = await applyMealTemplate(login, id, date, mealType, multiplier);
|
||||||
res.status(200).json(await getDayOverview(login, day));
|
res.status(200).json(await getDayOverview(login, day));
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FoodStats, GroupTotal, MealEntry, PeriodTotal } from "../../types/gen/types.gen";
|
import { FoodStats, GroupTotal, MealEntry, PeriodTotal } from "../../types/gen/types.gen";
|
||||||
import { getRange } from "./meals";
|
import { getRange } from "./meals";
|
||||||
|
import { applyMultiplier } from "./utils";
|
||||||
|
|
||||||
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
|
/** Zdroj přiřazený záznamům, které ho nemají vyplněný. */
|
||||||
const UNKNOWN_SOURCE = 'Neuvedeno';
|
const UNKNOWN_SOURCE = 'Neuvedeno';
|
||||||
@@ -11,7 +12,7 @@ type Bucket = { price: number, calories: number, count: number };
|
|||||||
function accumulate(buckets: Map<string, Bucket>, key: string, entry: MealEntry): void {
|
function accumulate(buckets: Map<string, Bucket>, key: string, entry: MealEntry): void {
|
||||||
const bucket = buckets.get(key) ?? { price: 0, calories: 0, count: 0 };
|
const bucket = buckets.get(key) ?? { price: 0, calories: 0, count: 0 };
|
||||||
bucket.price += entry.price ?? 0;
|
bucket.price += entry.price ?? 0;
|
||||||
bucket.calories += entry.calories ?? 0;
|
bucket.calories += applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||||
bucket.count++;
|
bucket.count++;
|
||||||
buckets.set(key, bucket);
|
buckets.set(key, bucket);
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,7 @@ export async function getFoodStats(login: string, from: string, to: string): Pro
|
|||||||
accumulate(byMealType, entry.mealType, entry);
|
accumulate(byMealType, entry.mealType, entry);
|
||||||
accumulate(bySource, entry.source ?? UNKNOWN_SOURCE, entry);
|
accumulate(bySource, entry.source ?? UNKNOWN_SOURCE, entry);
|
||||||
totalPrice += entry.price ?? 0;
|
totalPrice += entry.price ?? 0;
|
||||||
totalCalories += entry.calories ?? 0;
|
totalCalories += applyMultiplier(entry.calories, entry.multiplier) ?? 0;
|
||||||
entryCount++;
|
entryCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,3 +73,42 @@ export function listDatesInRange(from: string, to: string): string[] {
|
|||||||
}
|
}
|
||||||
return dates;
|
return dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nejvyšší povolený násobek porce nebo aktivity — pojistka proti překlepu. */
|
||||||
|
const MAX_MULTIPLIER = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ověří násobek porce nebo aktivity a zaokrouhlí ho na setiny.
|
||||||
|
*
|
||||||
|
* Násobek 1 je výchozí stav, proto se vrací jako undefined a neukládá se —
|
||||||
|
* stejně jako jedna série u aktivit.
|
||||||
|
*
|
||||||
|
* @param value vstupní hodnota
|
||||||
|
*/
|
||||||
|
export const normalizeMultiplier = (value: unknown): number | undefined => {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
throw new BadRequestError("Násobek musí být číslo");
|
||||||
|
}
|
||||||
|
const rounded = Math.round(value * 100) / 100;
|
||||||
|
if (rounded <= 0) {
|
||||||
|
throw new BadRequestError("Násobek musí být kladné číslo");
|
||||||
|
}
|
||||||
|
if (rounded > MAX_MULTIPLIER) {
|
||||||
|
throw new BadRequestError(`Násobek může být nejvýše ${MAX_MULTIPLIER}`);
|
||||||
|
}
|
||||||
|
return rounded === 1 ? undefined : rounded;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí kalorie záznamu po započtení násobku.
|
||||||
|
*
|
||||||
|
* Uložené kalorie jsou vždy za jednu porci (jedno provedení), aby šlo násobek
|
||||||
|
* měnit tam a zpět bez ztráty přesnosti zaokrouhlováním. Klient má stejnou
|
||||||
|
* funkci v `Utils.tsx` — musí dávat stejné výsledky.
|
||||||
|
*
|
||||||
|
* @param calories kcal jedné porce
|
||||||
|
* @param multiplier násobek, nevyplněný znamená 1
|
||||||
|
*/
|
||||||
|
export const applyMultiplier = (calories: number | undefined, multiplier?: number): number | undefined =>
|
||||||
|
calories == null ? undefined : Math.round(calories * (multiplier ?? 1));
|
||||||
@@ -117,6 +117,7 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
|||||||
}
|
}
|
||||||
const rate = value.caloriesPer100Units;
|
const rate = value.caloriesPer100Units;
|
||||||
const sets = value.sets;
|
const sets = value.sets;
|
||||||
|
const extraWeight = value.extraWeightGrams;
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
name,
|
name,
|
||||||
@@ -129,6 +130,9 @@ const normalizeItem = (raw: unknown, index: number): WorkoutTemplateItem => {
|
|||||||
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
|
...(typeof rate === 'number' && Number.isFinite(rate) && rate >= 0
|
||||||
? { caloriesPer100Units: Math.round(rate) }
|
? { caloriesPer100Units: Math.round(rate) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof extraWeight === 'number' && Number.isFinite(extraWeight) && extraWeight > 0
|
||||||
|
? { extraWeightGrams: Math.round(extraWeight) }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,6 +273,7 @@ export const applyWorkoutTemplate = async (login: string, id: unknown, date: unk
|
|||||||
quantity: item.quantity!,
|
quantity: item.quantity!,
|
||||||
...(item.sets != null ? { sets: item.sets } : {}),
|
...(item.sets != null ? { sets: item.sets } : {}),
|
||||||
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
...(item.caloriesPer100Units != null ? { caloriesPer100Units: item.caloriesPer100Units } : {}),
|
||||||
|
...(item.extraWeightGrams != null ? { extraWeightGrams: item.extraWeightGrams } : {}),
|
||||||
calories: itemCalories(item),
|
calories: itemCalories(item),
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -317,6 +322,7 @@ export const createWorkoutTemplateFromActivities = async (
|
|||||||
...(entry.sets != null ? { sets: entry.sets } : {}),
|
...(entry.sets != null ? { sets: entry.sets } : {}),
|
||||||
// Aktivita se zadanými kaloriemi napevno sazbu nemá, šablona pak odhad nespočítá
|
// Aktivita se zadanými kaloriemi napevno sazbu nemá, šablona pak odhad nespočítá
|
||||||
...(entry.caloriesPer100Units != null ? { caloriesPer100Units: entry.caloriesPer100Units } : {}),
|
...(entry.caloriesPer100Units != null ? { caloriesPer100Units: entry.caloriesPer100Units } : {}),
|
||||||
|
...(entry.extraWeightGrams != null ? { extraWeightGrams: entry.extraWeightGrams } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return saveWorkoutTemplate(login, undefined, name, items);
|
return saveWorkoutTemplate(login, undefined, name, items);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ paths:
|
|||||||
$ref: "./paths/meals/update.yml"
|
$ref: "./paths/meals/update.yml"
|
||||||
/meals/delete:
|
/meals/delete:
|
||||||
$ref: "./paths/meals/delete.yml"
|
$ref: "./paths/meals/delete.yml"
|
||||||
|
/meals/multiplier:
|
||||||
|
$ref: "./paths/meals/multiplier.yml"
|
||||||
|
|
||||||
# Statistiky (/api/stats)
|
# Statistiky (/api/stats)
|
||||||
/stats/summary:
|
/stats/summary:
|
||||||
@@ -40,6 +42,8 @@ paths:
|
|||||||
$ref: "./paths/activities/update.yml"
|
$ref: "./paths/activities/update.yml"
|
||||||
/activities/delete:
|
/activities/delete:
|
||||||
$ref: "./paths/activities/delete.yml"
|
$ref: "./paths/activities/delete.yml"
|
||||||
|
/activities/multiplier:
|
||||||
|
$ref: "./paths/activities/multiplier.yml"
|
||||||
|
|
||||||
# Šablony tréninků (/api/workouts)
|
# Šablony tréninků (/api/workouts)
|
||||||
/workouts:
|
/workouts:
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
post:
|
||||||
|
operationId: setActivityMultiplier
|
||||||
|
summary: |
|
||||||
|
Nastaví násobek aktivity přímo z přehledu dne, bez úpravy ostatních údajů.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, date, multiplier]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Identifikátor záznamu
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
description: Datum záznamu (YYYY-MM-DD)
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
multiplier:
|
||||||
|
description: Násobek, kladné číslo (i necelé). Hodnota 1 násobek zruší.
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný přehled dne
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/DayOverview"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
|
"404":
|
||||||
|
description: Záznam neexistuje
|
||||||
@@ -20,6 +20,9 @@ post:
|
|||||||
description: Chod. Bez něj se použije chod ze šablony, jinak oběd.
|
description: Chod. Bez něj se použije chod ze šablony, jinak oběd.
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "../../schemas/_index.yml#/MealType"
|
- $ref: "../../schemas/_index.yml#/MealType"
|
||||||
|
multiplier:
|
||||||
|
description: Násobek porce, třeba 2 za dvě stejné sušenky. Bez něj 1.
|
||||||
|
type: number
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Aktualizovaný přehled dne
|
description: Aktualizovaný přehled dne
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
post:
|
||||||
|
operationId: setMealMultiplier
|
||||||
|
summary: |
|
||||||
|
Nastaví násobek porce jídla přímo z přehledu dne, bez úpravy ostatních údajů.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, date, multiplier]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Identifikátor záznamu
|
||||||
|
type: string
|
||||||
|
date:
|
||||||
|
description: Datum záznamu (YYYY-MM-DD)
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
multiplier:
|
||||||
|
description: Násobek porce, kladné číslo (i necelé). Hodnota 1 násobek zruší.
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný přehled dne
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/DayOverview"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
|
"404":
|
||||||
|
description: Záznam neexistuje
|
||||||
+140
-5
@@ -59,6 +59,13 @@ MealEntry:
|
|||||||
note:
|
note:
|
||||||
description: Poznámka uživatele k jídlu
|
description: Poznámka uživatele k jídlu
|
||||||
type: string
|
type: string
|
||||||
|
multiplier:
|
||||||
|
description: |
|
||||||
|
Násobek porce, třeba 2 za dvě sušenky nebo 0,5 za snědenou polovinu.
|
||||||
|
Násobí jen kalorie — cena zůstává, jak byla zadaná, protože za
|
||||||
|
nedojedené jídlo se platí celé. Chybí-li, platí 1. Uložené `calories`
|
||||||
|
jsou vždy za jednu porci, výsledné kcal jsou calories × multiplier.
|
||||||
|
type: number
|
||||||
importSource:
|
importSource:
|
||||||
description: Odkud byl záznam naimportován (např. "luncher"). U ručně přidaných chybí.
|
description: Odkud byl záznam naimportován (např. "luncher"). U ručně přidaných chybí.
|
||||||
type: string
|
type: string
|
||||||
@@ -123,6 +130,9 @@ MealInput:
|
|||||||
description: Poznámka uživatele k jídlu
|
description: Poznámka uživatele k jídlu
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
multiplier:
|
||||||
|
description: Násobek porce; nevyplněno znamená 1
|
||||||
|
type: number
|
||||||
|
|
||||||
DayRecord:
|
DayRecord:
|
||||||
description: Přehled jídel jednoho dne včetně součtů
|
description: Přehled jídel jednoho dne včetně součtů
|
||||||
@@ -283,6 +293,17 @@ ImportRequest:
|
|||||||
description: |
|
description: |
|
||||||
Pokud je true, import se pouze vyhodnotí a vrátí náhled, ale nic se neuloží.
|
Pokud je true, import se pouze vyhodnotí a vrátí náhled, ale nic se neuloží.
|
||||||
type: boolean
|
type: boolean
|
||||||
|
mode:
|
||||||
|
description: Jak naložit s dřívějším importem týchž dnů. Výchozí je PRIDAT.
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/ImportMode"
|
||||||
|
selectedKeys:
|
||||||
|
description: |
|
||||||
|
Klíče (`importKey`) řádků, které se mají založit. Klient je bere z náhledu,
|
||||||
|
kde si uživatel odškrtne, co nechce. Bez pole se založí všechny řádky.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
ImportResult:
|
ImportResult:
|
||||||
description: Výsledek (nebo náhled) importu přehledu z Luncheru
|
description: Výsledek (nebo náhled) importu přehledu z Luncheru
|
||||||
@@ -298,10 +319,15 @@ ImportResult:
|
|||||||
- totalPrice
|
- totalPrice
|
||||||
- entries
|
- entries
|
||||||
- warnings
|
- warnings
|
||||||
|
- mode
|
||||||
|
- replaced
|
||||||
|
- existing
|
||||||
properties:
|
properties:
|
||||||
dryRun:
|
dryRun:
|
||||||
description: True, pokud šlo pouze o náhled a data se neuložila
|
description: True, pokud šlo pouze o náhled a data se neuložila
|
||||||
type: boolean
|
type: boolean
|
||||||
|
mode:
|
||||||
|
$ref: "#/ImportMode"
|
||||||
format:
|
format:
|
||||||
description: Rozpoznaný formát vstupního souboru
|
description: Rozpoznaný formát vstupního souboru
|
||||||
type: string
|
type: string
|
||||||
@@ -313,8 +339,22 @@ ImportResult:
|
|||||||
description: Počet záznamů, které import založil (u náhledu by založil)
|
description: Počet záznamů, které import založil (u náhledu by založil)
|
||||||
type: integer
|
type: integer
|
||||||
skipped:
|
skipped:
|
||||||
description: Počet přeskočených řádků, protože už z dřívějšího importu existují
|
description: |
|
||||||
|
Počet přeskočených řádků — v režimu PRIDAT ty, které už z dřívějšího
|
||||||
|
importu existují, a v obou režimech ty, které uživatel odškrtl.
|
||||||
type: integer
|
type: integer
|
||||||
|
replaced:
|
||||||
|
description: |
|
||||||
|
Počet dříve naimportovaných záznamů, které režim NAHRADIT odebere
|
||||||
|
(u náhledu by odebral). V režimu PRIDAT je vždy 0.
|
||||||
|
type: integer
|
||||||
|
existing:
|
||||||
|
description: |
|
||||||
|
Jídla, která už v dotčených dnech jsou (ručně zadaná i dřív
|
||||||
|
naimportovaná). Slouží k posouzení náhledu, jestli se něco nezdvojí.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/MealEntry"
|
||||||
days:
|
days:
|
||||||
description: Dny (YYYY-MM-DD), kterých se import dotkl, vzestupně
|
description: Dny (YYYY-MM-DD), kterých se import dotkl, vzestupně
|
||||||
type: array
|
type: array
|
||||||
@@ -325,7 +365,9 @@ ImportResult:
|
|||||||
description: Součet cen importovaných záznamů v haléřích
|
description: Součet cen importovaných záznamů v haléřích
|
||||||
type: integer
|
type: integer
|
||||||
entries:
|
entries:
|
||||||
description: Záznamy, které import založil (u náhledu založí)
|
description: |
|
||||||
|
Záznamy, které import založil. U náhledu všechny řádky, které založit
|
||||||
|
jde, bez ohledu na `selectedKeys` — uživatel v nich teprve vybírá.
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/MealEntry"
|
$ref: "#/MealEntry"
|
||||||
@@ -521,6 +563,13 @@ ActivityEntry:
|
|||||||
note:
|
note:
|
||||||
description: Poznámka uživatele k aktivitě
|
description: Poznámka uživatele k aktivitě
|
||||||
type: string
|
type: string
|
||||||
|
extraWeightGrams:
|
||||||
|
$ref: "#/ExtraWeightGrams"
|
||||||
|
multiplier:
|
||||||
|
description: |
|
||||||
|
Násobek aktivity, třeba 2 za dvakrát odcvičenou sestavu. Násobí kalorie,
|
||||||
|
uložené `calories` jsou za jedno provedení. Chybí-li, platí 1.
|
||||||
|
type: number
|
||||||
templateId:
|
templateId:
|
||||||
description: Identifikátor šablony, ze které aktivita vznikla
|
description: Identifikátor šablony, ze které aktivita vznikla
|
||||||
type: string
|
type: string
|
||||||
@@ -568,6 +617,11 @@ ActivityInput:
|
|||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
extraWeightGrams:
|
||||||
|
$ref: "#/ExtraWeightGrams"
|
||||||
|
multiplier:
|
||||||
|
description: Násobek aktivity; nevyplněno znamená 1
|
||||||
|
type: number
|
||||||
|
|
||||||
ActivityDay:
|
ActivityDay:
|
||||||
description: Přehled pohybu jednoho dne včetně součtu
|
description: Přehled pohybu jednoho dne včetně součtu
|
||||||
@@ -615,6 +669,8 @@ WorkoutTemplateItem:
|
|||||||
caloriesPer100Units:
|
caloriesPer100Units:
|
||||||
description: Spálené kcal na 100 jednotek
|
description: Spálené kcal na 100 jednotek
|
||||||
type: integer
|
type: integer
|
||||||
|
extraWeightGrams:
|
||||||
|
$ref: "#/ExtraWeightGrams"
|
||||||
templateRefId:
|
templateRefId:
|
||||||
description: Identifikátor vnořené šablony. Povinný u položky druhu SABLONA.
|
description: Identifikátor vnořené šablony. Povinný u položky druhu SABLONA.
|
||||||
type: string
|
type: string
|
||||||
@@ -767,10 +823,13 @@ MealTemplate:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
|
- kind
|
||||||
- name
|
- name
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
kind:
|
||||||
|
$ref: "#/MealTemplateKind"
|
||||||
name:
|
name:
|
||||||
description: Název jídla
|
description: Název jídla
|
||||||
type: string
|
type: string
|
||||||
@@ -799,10 +858,21 @@ MealTemplate:
|
|||||||
description: Cena za 100 g v haléřích
|
description: Cena za 100 g v haléřích
|
||||||
type: integer
|
type: integer
|
||||||
note:
|
note:
|
||||||
description: |
|
description: Poznámka, u receptu postup přípravy
|
||||||
Poznámka nebo postup přípravy. U domácích jídel sem lze zapsat recept
|
|
||||||
volným textem; strukturované suroviny mohou přibýt později.
|
|
||||||
type: string
|
type: string
|
||||||
|
ingredients:
|
||||||
|
description: Suroviny receptu. U hotového jídla chybí.
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/RecipeIngredient"
|
||||||
|
servings:
|
||||||
|
description: Na kolik porcí recept je. Chybí-li, počítá se s jednou.
|
||||||
|
type: integer
|
||||||
|
cookedWeight:
|
||||||
|
description: |
|
||||||
|
Hmotnost hotového jídla v gramech. Vařením se voda odpaří nebo nasákne,
|
||||||
|
takže se liší od součtu surovin; bez ní se použije součet surovin.
|
||||||
|
type: integer
|
||||||
|
|
||||||
MealTemplateInput:
|
MealTemplateInput:
|
||||||
description: |
|
description: |
|
||||||
@@ -816,6 +886,10 @@ MealTemplateInput:
|
|||||||
id:
|
id:
|
||||||
description: Identifikátor upravované šablony. Bez něj se založí nová.
|
description: Identifikátor upravované šablony. Bez něj se založí nová.
|
||||||
type: string
|
type: string
|
||||||
|
kind:
|
||||||
|
description: Druh šablony. Bez něj se založí hotové jídlo.
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/MealTemplateKind"
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
source:
|
source:
|
||||||
@@ -842,3 +916,64 @@ MealTemplateInput:
|
|||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
ingredients:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/RecipeIngredient"
|
||||||
|
servings:
|
||||||
|
type: integer
|
||||||
|
cookedWeight:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
ExtraWeightGrams:
|
||||||
|
description: |
|
||||||
|
Dodatečná zátěž v gramech (činky, zátěžová vesta). Eviduje se jako
|
||||||
|
informace o tréninku a kalorie nemění — žádný jednoduchý vzorec spotřebu
|
||||||
|
podle zátěže nedává, takže ji uživatel promítne do sazby na 100 jednotek.
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
ImportMode:
|
||||||
|
description: |
|
||||||
|
Jak naložit s dřívějším importem týchž dnů.
|
||||||
|
PRIDAT — nové řádky se přidají, řádky už jednou naimportované se přeskočí.
|
||||||
|
NAHRADIT — v dnech, do kterých se importuje, se dřívější importované
|
||||||
|
záznamy odeberou a založí se znovu. Ručně zadaná jídla zůstávají vždy.
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- PRIDAT
|
||||||
|
- NAHRADIT
|
||||||
|
|
||||||
|
MealTemplateKind:
|
||||||
|
description: |
|
||||||
|
Druh šablony jídla.
|
||||||
|
HOTOVE — objednané nebo koupené jídlo; stačí si pamatovat výsledné
|
||||||
|
hodnoty porce (kcal, cenu), aby se nemusely dohledávat znovu.
|
||||||
|
RECEPT — vařené jídlo; eviduje se, z čeho a jak vzniklo, a hodnoty
|
||||||
|
porce se dopočítají ze surovin.
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- HOTOVE
|
||||||
|
- RECEPT
|
||||||
|
|
||||||
|
RecipeIngredient:
|
||||||
|
description: Jedna surovina receptu
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název suroviny
|
||||||
|
type: string
|
||||||
|
weight:
|
||||||
|
description: Použité množství v gramech
|
||||||
|
type: integer
|
||||||
|
caloriesPer100g:
|
||||||
|
description: Energie suroviny na 100 g v kcal
|
||||||
|
type: integer
|
||||||
|
calories:
|
||||||
|
description: Energie použitého množství v kcal, když nejde dopočítat z gramáže
|
||||||
|
type: integer
|
||||||
|
price:
|
||||||
|
description: Cena použitého množství v haléřích
|
||||||
|
type: integer
|
||||||
Reference in new issue
Block a user