diff --git a/CLAUDE.md b/CLAUDE.md index cda1f74..b53143f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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), `StatsPage` (statistiky + import) - **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ů, ne jako inline union u komponenty (`DayTab`, `StatsRange`) - **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. **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 @@ -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 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ď 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 @@ -196,16 +217,27 @@ takže `DayPage` si vystačí s jedním voláním. ## Šablony jídel `MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:`) je opakovaně -jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných** — je to plochý -záznam. Použitím vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu -i kalorie dopočítá stejná logika jako u ručně zadaného jídla. +jedené jídlo. Na rozdíl od šablon tréninků se **neskládá z jiných**. Použitím +vznikne běžný `MealEntry` přes `addMeal`, takže gramáž, cenu i kalorie dopočítá +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`. -Obojí projde `deriveAmounts`, takže stačí vyplnit, co o jídle víte. +Šablona má druh (`kind`): -Pole `note` slouží i na postup přípravy u domácích jídel. Strukturované suroviny -(recept jako seznam položek s množstvím) mohou přibýt později jako další volitelné -pole, aniž by se muselo měnit cokoli stávajícího. +- **HOTOVE** — objednané nebo koupené jídlo. Nese jen výsledné hodnoty porce: + buď `calories`, nebo `caloriesPer100g` + `weight`; obojí projde `deriveAmounts`. +- **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` 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. - 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. +- 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. + 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** a testy v `server/src/tests/luncherImport.test.ts`, které si export sestavují diff --git a/client/src/App.scss b/client/src/App.scss index 5fb8b4e..4777664 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -702,13 +702,19 @@ body { flex: 0 0 auto; } + .workout-item-weight { + width: 100px; + flex: 0 0 auto; + } + @media (max-width: 640px) { flex-wrap: wrap; .workout-item-sets, .workout-item-quantity, .workout-item-unit, - .workout-item-rate { + .workout-item-rate, + .workout-item-weight { width: auto; flex: 1; } @@ -771,3 +777,123 @@ body { font-size: 0.85rem; 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; + } +} diff --git a/client/src/Utils.tsx b/client/src/Utils.tsx index e7d3704..dd02ac4 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -1,3 +1,5 @@ +import { MealTemplate } from "../../types"; + const TOKEN_KEY = "token"; /** 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é. */ export const getTotalQuantity = (quantity?: number, sets?: number): number | undefined => quantity == null ? undefined : quantity * Math.max(sets ?? 1, 1); + +/** + * Vrátí kalorie záznamu po započtení násobku porce. + * Musí dávat stejné výsledky jako `applyMultiplier` na serveru. + */ +export const applyMultiplier = (calories?: number, multiplier?: number): number | undefined => + calories == null ? undefined : Math.round(calories * (multiplier ?? 1)); + +/** Naformátuje násobek, např. 1.5 → "×1,5". */ +export const formatMultiplier = (multiplier?: number): string => + `×${(multiplier ?? 1).toLocaleString('cs-CZ', { maximumFractionDigits: 2 })}`; + +/** Převede násobek zadaný uživatelem (i s desetinnou čárkou) na číslo; nekladný vstup odmítne. */ +export const parseMultiplierInput = (value: string): number | undefined => { + const trimmed = value.trim().replace(',', '.'); + if (!trimmed.length) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 100) / 100 : undefined; +}; + +/** Převede násobek na text pro editační pole; výchozí 1 nechá prázdné. */ +export const formatMultiplierInput = (multiplier?: number): string => + multiplier == null || multiplier === 1 ? '' : String(multiplier).replace('.', ','); + +/** Naformátuje dodatečnou zátěž z gramů, např. 12500 → "+12,5 kg". */ +export const formatExtraWeight = (grams?: number): string => + grams ? `+${(grams / 1000).toLocaleString('cs-CZ', { maximumFractionDigits: 2 })} kg` : ''; + +/** Převede gramy na kilogramy pro editační pole bez zbytečných desetinných míst. */ +export const formatKgInput = (grams?: number): string => + grams ? String(grams / 1000).replace('.', ',') : ''; + +/** Surovina receptu tak, jak ji zná výpočet — hodnoty jsou už čísla. */ +type RecipeIngredientValues = { + weight?: number, + caloriesPer100g?: number, + calories?: number, + price?: number, +}; + +/** Energie suroviny — z gramáže a energie na 100 g, jinak ručně zadaná. */ +export const getIngredientCalories = (ingredient: RecipeIngredientValues): number | undefined => + ingredient.weight != null && ingredient.caloriesPer100g + ? Math.round(ingredient.weight / 100 * ingredient.caloriesPer100g) + : ingredient.calories; + +/** Sečte vyplněné hodnoty; když není vyplněná žádná, vrátí undefined. */ +const sumDefined = (valueList: (number | undefined)[]): number | undefined => { + const definedList = valueList.filter(value => value != null); + return definedList.length ? definedList.reduce((sum, value) => sum + value, 0) : undefined; +}; + +/** + * Dopočte ze surovin hodnoty hotového jídla a jedné porce. + * Musí dávat stejné výsledky jako `deriveRecipe` na serveru. + * + * @param ingredients suroviny receptu + * @param servings počet porcí, nevyplněný znamená jednu + * @param cookedWeight hmotnost hotového jídla v gramech, bez ní součet surovin + */ +export const deriveRecipe = ( + ingredients: RecipeIngredientValues[], + servings?: number, + cookedWeight?: number, +) => { + const portions = Math.max(servings ?? 1, 1); + const totalCalories = sumDefined(ingredients.map(getIngredientCalories)); + const totalWeight = cookedWeight || sumDefined(ingredients.map(ingredient => ingredient.weight)) || undefined; + const totalPrice = sumDefined(ingredients.map(ingredient => ingredient.price)); + return { + totalCalories, + totalWeight, + totalPrice, + weight: totalWeight != null ? Math.round(totalWeight / portions) : undefined, + calories: totalCalories != null ? Math.round(totalCalories / portions) : undefined, + caloriesPer100g: totalCalories != null && totalWeight + ? Math.round(totalCalories / totalWeight * 100) + : undefined, + price: totalPrice != null ? Math.round(totalPrice / portions) : undefined, + }; +}; + +/** Znormalizuje text pro hledání — bez diakritiky a malými písmeny. */ +export const normalizeSearch = (value: string): string => + value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim(); + +/** + * Popíše š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(' · '); +}; diff --git a/client/src/components/ActivityRow.tsx b/client/src/components/ActivityRow.tsx index 4afe801..eb2e37d 100644 --- a/client/src/components/ActivityRow.tsx +++ b/client/src/components/ActivityRow.tsx @@ -1,17 +1,19 @@ import { ActivityEntry } from "../../../types"; import { ACTIVITY_UNIT_SHORT } from "../enums"; -import { formatCalories, formatQuantityWithSets } from "../Utils"; +import { applyMultiplier, formatCalories, formatExtraWeight, formatQuantityWithSets } from "../Utils"; import RowActions from "./RowActions"; +import MultiplierControl from "./MultiplierControl"; type Props = { entry: ActivityEntry, onEdit: () => void, onDelete: () => void, onSaveTemplate: () => void, + onMultiplierChange: (multiplier: number) => void, }; /** Jeden řádek seznamu pohybových aktivit v přehledu dne. */ -const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly) => ( +const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly) => (
{entry.name}
@@ -19,11 +21,25 @@ const ActivityRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly {formatQuantityWithSets(entry.quantity, ACTIVITY_UNIT_SHORT[entry.unit], entry.sets)} + {!!entry.extraWeightGrams && ( + {formatExtraWeight(entry.extraWeightGrams)} + )} {entry.templateId && ze šablony}
{entry.note &&
{entry.note}
}
-
{formatCalories(entry.calories)}
+ +
+ {formatCalories(applyMultiplier(entry.calories, entry.multiplier))} +
); diff --git a/client/src/components/ImportPreview.tsx b/client/src/components/ImportPreview.tsx new file mode 100644 index 0000000..b6e680f --- /dev/null +++ b/client/src/components/ImportPreview.tsx @@ -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, + 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) => { + const dateList = Array.from(new Set(preview.entries.map(entry => entry.date))).sort(); + + return ( +
+ {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 ( +
+ 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 ( +
+ {MEAL_TYPE_ICONS[entry.mealType]} + + {entry.name} + + {replaced + ? ' · bude nahrazeno' + : ` · už máte${entry.importSource ? ' z importu' : ''}`} + + + {formatPrice(entry.price)} +
+ ); + })} + {entryList.map(entry => ( + onToggle(entry.importKey!)} + label={ + <> + + {entry.name} + {entry.source && · {entry.source}} + + {formatPrice(entry.price)} + + } + /> + ))} +
+ ); + })} + {preview.mode === ImportMode.PRIDAT && preview.skipped > 0 && ( +
+ {preview.skipped} řádků se přeskočí, protože už je máte z dřívějšího importu. +
+ )} +
+ ); +}; + +export default ImportPreview; diff --git a/client/src/components/MealRow.tsx b/client/src/components/MealRow.tsx index 9078c69..3af447c 100644 --- a/client/src/components/MealRow.tsx +++ b/client/src/components/MealRow.tsx @@ -1,30 +1,48 @@ import { MealEntry } from "../../../types"; -import { formatCalories, formatPrice, formatWeight } from "../Utils"; +import { applyMultiplier, formatCalories, formatPrice, formatWeight } from "../Utils"; import RowActions from "./RowActions"; +import MultiplierControl from "./MultiplierControl"; type Props = { entry: MealEntry, onEdit: () => void, onDelete: () => void, onSaveTemplate: () => void, + onMultiplierChange: (multiplier: number) => void, }; /** Jeden řádek seznamu jídel v přehledu dne. */ -const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate }: Readonly) => ( -
-
-
{entry.name}
-
- {entry.source && {entry.source}} - {entry.importSource && z Luncheru} - {entry.weight != null && {formatWeight(entry.weight)}} - {entry.calories != null && {formatCalories(entry.calories)}} +const MealRow = ({ entry, onEdit, onDelete, onSaveTemplate, onMultiplierChange }: Readonly) => { + const calories = applyMultiplier(entry.calories, entry.multiplier); + return ( +
+
+
{entry.name}
+
+ {entry.source && {entry.source}} + {entry.importSource && z Luncheru} + {entry.weight != null && {formatWeight(entry.weight)}} + {calories != null && ( + + {formatCalories(calories)} + + )} +
+ {entry.note &&
{entry.note}
}
- {entry.note &&
{entry.note}
} + +
{formatPrice(entry.price)}
+
-
{formatPrice(entry.price)}
- -
-); + ); +}; export default MealRow; diff --git a/client/src/components/MealTemplateCard.tsx b/client/src/components/MealTemplateCard.tsx new file mode 100644 index 0000000..b8df47d --- /dev/null +++ b/client/src/components/MealTemplateCard.tsx @@ -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) => { + 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 ( +
+
+
+ {template.name} + {template.mealType && ( + + {MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]} + + )} + {template.source && {template.source}} + {derived.weight != null && ( + {isRecipe ? 'porce ' : ''}{formatWeight(derived.weight)} + )} + {isRecipe && (template.servings ?? 1) > 1 && ( + {template.servings} porcí + )} +
+ {calories != null && {formatCalories(calories)}} + {template.price != null && ( + {formatPrice(template.price)} + )} + + +
+ + {isRecipe && !!template.ingredients?.length && ( +
    + {template.ingredients.map((ingredient, index) => ( + // Suroviny nemají vlastní id, v rámci jednoho receptu je index stabilní +
  • + {ingredient.name} + + {ingredient.weight != null ? formatWeight(ingredient.weight) : ''} + + + {formatCalories(getIngredientCalories(ingredient))} + +
  • + ))} +
+ )} + + {recipe && ( +
+ Celkem {formatCalories(recipe.totalCalories)} + {recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}} + {recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g} + {recipe.totalPrice != null && <> · {formatPrice(recipe.totalPrice)}} +
+ )} + + {template.note &&

{template.note}

} +
+ ); +}; + +export default MealTemplateCard; diff --git a/client/src/components/MealTemplateForm.tsx b/client/src/components/MealTemplateForm.tsx new file mode 100644 index 0000000..5fa9b62 --- /dev/null +++ b/client/src/components/MealTemplateForm.tsx @@ -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) => { + const [form, setForm] = useState(() => createForm(template)); + const [ingredients, setIngredients] = useState(() => + template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + const update = (changes: Partial>) => + 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 ( +
+

{template ? 'Úprava šablony jídla' : 'Nová šablona jídla'}

+ + {error && {error}} + + + {MEAL_TEMPLATE_KINDS_IN_ORDER.map(kind => ( + + ))} + +

+ {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.'} +

+ +
+
+ Název + update({ name: event.target.value })} + /> +
+
+ Zdroj + update({ source: event.target.value })} + /> +
+
+ Chod + update({ mealType: event.target.value as MealType | '' })} + > + + {MEAL_TYPES_IN_ORDER.map(type => ( + + ))} + +
+
+ + {isRecipe ? ( + <> +
+ Suroviny + +
+ +
+ Výsledek +
+
+ Počet porcí + update({ servings: event.target.value.replace(/\D/g, '') })} + /> +
+
+ Hmotnost hotového jídla +
+ update({ cookedWeight: event.target.value.replace(/\D/g, '') })} + /> + g +
+ vařením se mění, bez ní se vezme součet surovin +
+
+ Celkem / jedna porce +
+ {formatCalories(recipe.totalCalories)} + {recipe.totalWeight != null && <> · {formatWeight(recipe.totalWeight)}} + {recipe.caloriesPer100g != null && <> · {recipe.caloriesPer100g} kcal/100 g} +
+ porce {formatWeight(recipe.weight)} · {formatCalories(recipe.calories)} + {recipe.price != null && <> · {formatPrice(recipe.price)}} +
+
+
+
+
+ + ) : ( +
+ Kalorie, gramáž a cena porce +
+
+ Gramáž +
+ update({ weight: event.target.value.replace(/\D/g, '') })} + /> + g +
+
+
+ Energie na 100 g +
+ update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })} + /> + kcal +
+
+
+ Cena +
+ update({ price: event.target.value })} + /> + Kč +
+
+
+ Cena za 100 g +
+ update({ pricePer100g: event.target.value })} + /> + Kč +
+
+
+ +
+ {preview.calories == null && ( +
+ Kalorie porce +
+ update({ calories: event.target.value.replace(/\D/g, '') })} + /> + kcal +
+ když neznáte gramáž ani hodnotu na 100 g +
+ )} +
+ update({ caloriesPer100g: String(value) })} + /> +
+
+
+ )} + + + {isRecipe ? 'Postup' : 'Poznámka'} + update({ note: event.target.value })} + /> + + +
+ {!isRecipe && previewCalories != null && ( + + {preview.weight != null && `${formatWeight(preview.weight)} · `} + {formatCalories(previewCalories)} + + )} + {isRecipe && recipe.calories != null && ( + porce {formatCalories(recipe.calories)} + )} + {template && ( + + )} + +
+
+ ); +}; + +export default MealTemplateForm; diff --git a/client/src/components/MultiplierControl.tsx b/client/src/components/MultiplierControl.tsx new file mode 100644 index 0000000..11c62ef --- /dev/null +++ b/client/src/components/MultiplierControl.tsx @@ -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) => { + 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 = ( + + +
+ {MULTIPLIER_PRESETS.map(preset => ( + + ))} +
+
{ event.preventDefault(); if (parsed != null) pick(parsed); }} + > + setInput(event.target.value)} + /> + + + {calories != null && parsed != null && ( +
+ {calories} kcal {formatMultiplier(parsed)} = {applyMultiplier(calories, parsed)} kcal +
+ )} +
+
+ ); + + return ( + { setShow(next); if (next) setInput(formatMultiplierInput(multiplier)); }} + overlay={popover} + > + + + ); +}; + +export default MultiplierControl; diff --git a/client/src/components/RecipeIngredientsEditor.tsx b/client/src/components/RecipeIngredientsEditor.tsx new file mode 100644 index 0000000..7c0ce03 --- /dev/null +++ b/client/src/components/RecipeIngredientsEditor.tsx @@ -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) => { + const updateAt = (index: number, changes: Partial) => + 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íč +
+ updateAt(index, { name: event.target.value })} + /> +
+ updateAt(index, { weight: event.target.value.replace(/\D/g, '') })} + /> + g +
+ updateAt(index, { caloriesPer100g: event.target.value.replace(/\D/g, '') })} + /> + {derived != null ? ( +
{formatCalories(derived)}
+ ) : ( + updateAt(index, { calories: event.target.value.replace(/\D/g, '') })} + /> + )} +
+ updateAt(index, { price: event.target.value })} + /> + Kč +
+ +
+ ); + })} + + + ); +}; + +export default RecipeIngredientsEditor; diff --git a/client/src/components/TemplateSuggestions.tsx b/client/src/components/TemplateSuggestions.tsx new file mode 100644 index 0000000..b887758 --- /dev/null +++ b/client/src/components/TemplateSuggestions.tsx @@ -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) => { + 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 ( +
+
Už jste to měli?
+ {matchList.map(template => ( + + ))} +
+ ); +}; + +export default TemplateSuggestions; diff --git a/client/src/components/modals/ActivityModal.tsx b/client/src/components/modals/ActivityModal.tsx index fd183d6..d9cc2e8 100644 --- a/client/src/components/modals/ActivityModal.tsx +++ b/client/src/components/modals/ActivityModal.tsx @@ -5,7 +5,10 @@ import { addActivity, createWorkoutTemplateFromActivities, updateActivity, } from "../../../../types"; 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 = { isOpen: boolean, @@ -28,6 +31,8 @@ function emptyForm(date: string) { caloriesPer100Units: '', calories: '', 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 * u kroků by sazba na jeden krok byla zlomek. Výběr známé aktivity předvyplní * 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) { 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) : '', calories: entry.calories != null ? String(entry.calories) : '', note: entry.note ?? '', + extraWeight: formatKgInput(entry.extraWeightGrams), + multiplier: formatMultiplierInput(entry.multiplier), } : emptyForm(date)); }, [isOpen, entry, date]); @@ -82,8 +92,12 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: const derivedCalories = deriveActivityCalories(quantity, caloriesPer100Units, sets); const manualCalories = parseIntInput(form.calories); 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 () => { 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 calories: derivedCalories != null ? undefined : manualCalories, note: form.note.trim() || undefined, + extraWeightGrams, + multiplier, }; const response = entry ? await updateActivity({ body: { id: entry.id, ...body } }) @@ -213,6 +229,32 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: )}
+
+
+ Zátěž +
+ update({ extraWeight: event.target.value })} + /> + kg +
+
+
+ Násobek + update({ multiplier: event.target.value })} + /> +
+
@@ -235,7 +277,12 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }: onChange={event => setAlsoTemplate(event.target.checked)} />
- {effectiveCalories != null && {formatCalories(effectiveCalories)}} + {totalCalories != null && ( + + {formatCalories(totalCalories)} + {multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`} + + )}
+ {!groupList.length && ( +

Žádná šablona neodpovídá hledání.

+ )} + + {groupList.map(group => ( +
+

{MEAL_TEMPLATE_KIND_NAMES[group.kind]}

+ {group.templates.map(template => ( +
+
+
{template.name}
+
+ {describeMealTemplate(template)} + {!!template.ingredients?.length && ` · ${template.ingredients.length} surovin, hodnoty za porci`} +
+
+ +
+ ))}
))} diff --git a/client/src/components/modals/ImportModal.tsx b/client/src/components/modals/ImportModal.tsx index 4c0b238..af3e38f 100644 --- a/client/src/components/modals/ImportModal.tsx +++ b/client/src/components/modals/ImportModal.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from "react"; import { Alert, Button, Form, Modal, Spinner } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFileImport, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; -import { ImportResult, MealType, importLuncher } from "../../../../types"; -import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums"; -import { formatDateString, formatPrice, readFileAsBase64 } from "../../Utils"; +import { ImportMode, ImportResult, MealEntry, MealType, importLuncher } from "../../../../types"; +import { IMPORT_MODE_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums"; +import { formatPrice, readFileAsBase64 } from "../../Utils"; +import ImportPreview from "../ImportPreview"; type Props = { isOpen: boolean, @@ -13,19 +14,24 @@ type Props = { onImported: () => void, }; -/** Kolik záznamů náhledu se vypíše, než se zbytek shrne do jednoho řádku. */ -const PREVIEW_LIMIT = 8; +/** Označení záznamů z importu Luncheru — shodné s `IMPORT_SOURCE` na serveru. */ +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. * - * Import běží dvoufázově — nejdřív náhled (dryRun), aby uživatel viděl, co se - * založí a kolik řádků už má, a teprve po potvrzení se data uloží. + * Import běží dvoufázově — nejdřív náhled (dryRun), ve kterém uživatel vidí, + * 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) { const [file, setFile] = useState(); const [mealType, setMealType] = useState(MealType.OBED); + const [mode, setMode] = useState(ImportMode.PRIDAT); const [preview, setPreview] = useState(); + const [selected, setSelected] = useState>(new Set()); const [result, setResult] = useState(); const [busy, setBusy] = useState(false); @@ -35,21 +41,30 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly { if (!file) return; setBusy(true); try { const content = await readFileAsBase64(file); 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 (dryRun) { setPreview(response.data); + setSelected(new Set(response.data.entries.map(entry => entry.importKey!))); } else { setResult(response.data); setPreview(undefined); @@ -60,14 +75,49 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly { - setFile(chosen); + /** Změna souboru, chodu nebo režimu mění, co se založí — náhled je pak neplatný. */ + const invalidate = () => { setPreview(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 ( - + Import z Luncheru @@ -76,8 +126,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly

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 - stejného měsíce data nezduplikuje. + a nahrajte ho sem. V náhledu uvidíte, co se založí i co už v jednotlivých dnech máte, + a řádky, které nechcete, můžete odškrtnout.

@@ -85,27 +135,45 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly chooseFile((event.target as HTMLInputElement).files?.[0])} + onChange={event => { setFile((event.target as HTMLInputElement).files?.[0]); invalidate(); }} /> - - Založit jako - { setMealType(event.target.value as MealType); setPreview(undefined); }} - > - {MEAL_TYPES_IN_ORDER.map(type => ( - +
+
+ Založit jako + { setMealType(event.target.value as MealType); invalidate(); }} + > + {MEAL_TYPES_IN_ORDER.map(type => ( + + ))} + + Luncher řeší výběr obědů, proto je předvolený oběd. +
+
+ Dřívější import stejných dnů + {IMPORT_MODES_IN_ORDER.map(item => ( + { setMode(item); invalidate(); }} + /> ))} - - - Luncher řeší výběr obědů, proto je předvolený oběd. Jednotlivá jídla si pak - můžete v přehledu dne upravit nebo doplnit o další. - - + + {mode === ImportMode.PRIDAT + ? '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.'} + +
+
{busy && (
@@ -114,36 +182,46 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly - Náhled importu -
- V souboru je {preview.rowCount} řádků. Založí se {preview.imported} záznamů - {preview.skipped > 0 && <> a {preview.skipped} se přeskočí, protože už je máte}. - {preview.days.length > 0 && ( - <> Dotčené dny: {formatDateString(preview.days[0])} - {preview.days.length > 1 && <> – {formatDateString(preview.days[preview.days.length - 1])}} - {' '}({preview.days.length}). - )} - {' '}Celkem za {formatPrice(preview.totalPrice)}. -
+ <> + + Náhled importu +
+ V souboru je {preview.rowCount} řádků. Vybráno {selectedEntries.length} + {' '}z {preview.entries.length} za{' '} + {formatPrice(selectedEntries.reduce((sum, entry) => sum + (entry.price ?? 0), 0))} + {selectedDays.size > 0 && <> ve {selectedDays.size} dnech}. + {replacedCount > 0 && <> Nahradí se {replacedCount} dřív naimportovaných záznamů.} +
+
+ {preview.entries.length > 0 && ( -
    - {preview.entries.slice(0, PREVIEW_LIMIT).map(entry => ( -
  • - {formatDateString(entry.date)} - {entry.name} - {entry.source && · {entry.source}} - {formatPrice(entry.price)} -
  • - ))} - {preview.entries.length > PREVIEW_LIMIT && ( -
  • - … a dalších {preview.entries.length - PREVIEW_LIMIT} -
  • +
    + + + {hasManualConflict && ( + )} -
+
)} - + + + )} {result && !busy && ( @@ -151,7 +229,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: ReadonlyImport dokončen
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)}.
@@ -173,8 +252,8 @@ export default function ImportModal({ isOpen, onClose, onImported }: Readonly )} {!result && preview && ( - )} diff --git a/client/src/components/modals/MealModal.tsx b/client/src/components/modals/MealModal.tsx index d4a7d51..7c22e5f 100644 --- a/client/src/components/modals/MealModal.tsx +++ b/client/src/components/modals/MealModal.tsx @@ -1,14 +1,16 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Form, Modal } from "react-bootstrap"; import { - DayRecord, MealEntry, MealType, UserSettings, - addMeal, createMealTemplateFromMeal, getSettings, updateMeal, + DayRecord, MealEntry, MealTemplate, MealTemplateKind, MealType, UserSettings, + addMeal, createMealTemplateFromMeal, getMealTemplates, getSettings, updateMeal, } from "../../../../types"; import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../../enums"; import { - deriveAmounts, formatCalories, formatPriceInput, formatWeight, parseIntInput, parsePriceInput, + applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput, + formatWeight, parseIntInput, parseMultiplierInput, parsePriceInput, } from "../../Utils"; import CalorieLookup from "../CalorieLookup"; +import TemplateSuggestions from "../TemplateSuggestions"; type Props = { isOpen: boolean, @@ -33,12 +35,13 @@ function emptyForm(date: string) { caloriesPer100g: '', calories: '', note: '', + multiplier: '', }; } /** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */ 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 * 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. + * + * 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) { const [form, setForm] = useState(() => emptyForm(date)); const [settings, setSettings] = useState(); const [saving, setSaving] = useState(false); const [alsoTemplate, setAlsoTemplate] = useState(false); + const [templates, setTemplates] = useState([]); + /** Název šablony, ze které se formulář naposledy vyplnil — pak už se návrhy nenabízejí. */ + const [pickedTemplate, setPickedTemplate] = useState(); // Při každém otevření se formulář naplní dle toho, co se upravuje useEffect(() => { if (!isOpen) return; setAlsoTemplate(false); + setPickedTemplate(undefined); setForm(entry ? { date: entry.date, @@ -71,10 +81,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea caloriesPer100g: entry.caloriesPer100g != null ? String(entry.caloriesPer100g) : '', calories: entry.calories != null ? String(entry.calories) : '', note: entry.note ?? '', + multiplier: formatMultiplierInput(entry.multiplier), } : emptyForm(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 useEffect(() => { if (!isOpen || settings) return; @@ -83,6 +100,24 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea const update = (changes: Partial) => 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. */ const rateForSource = useMemo(() => { 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. */ const manualCalories = parseIntInput(form.calories); 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 () => { 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 calories: derived.calories != null ? undefined : manualCalories, note: form.note.trim() || undefined, + multiplier, }; const response = entry ? await updateMeal({ body: { id: entry.id, ...body } }) @@ -159,8 +198,14 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea autoFocus value={form.name} 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 && ( + + )} + {!entry && pickedTemplate === form.name && ( + vyplněno ze šablony, hodnoty jde upravit + )}
@@ -255,7 +300,7 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
Kalorie
-
+
Energie na 100 g
kcal
-
- Kalorie celkem +
+ Kalorie porce {derived.calories != null ? (
{formatCalories(derived.calories)}
) : ( @@ -288,6 +333,17 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea )}
+
+ Násobek + update({ multiplier: event.target.value })} + /> +
{derived.weight != null && {formatWeight(derived.weight)}} - {effectiveCalories != null && {formatCalories(effectiveCalories)}} + {totalCalories != null && ( + + {formatCalories(totalCalories)} + {multiplier != null && multiplier !== 1 && ` (${formatMultiplier(multiplier)})`} + + )}
- diff --git a/client/src/enums.ts b/client/src/enums.ts index 273dcc9..470563a 100644 --- a/client/src/enums.ts +++ b/client/src/enums.ts @@ -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. */ export const MEAL_TYPE_NAMES: Record = { @@ -117,3 +117,20 @@ export const TEMPLATE_TAB_NAMES: Record = { TRENINKY: 'Tréninky', JIDLA: 'Jídla', }; + +/** Lidsky čitelné názvy druhů šablon jídel. */ +export const MEAL_TEMPLATE_KIND_NAMES: Record = { + 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 = { + 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]; diff --git a/client/src/pages/DayPage.tsx b/client/src/pages/DayPage.tsx index f79bbb8..77a9bc8 100644 --- a/client/src/pages/DayPage.tsx +++ b/client/src/pages/DayPage.tsx @@ -7,11 +7,11 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { ActivityEntry, DayOverview, MealEntry, createMealTemplateFromMeal, - deleteActivity, deleteMeal, getDayOverview, saveSleep, + deleteActivity, deleteMeal, getDayOverview, saveSleep, setActivityMultiplier, setMealMultiplier, } from "../../../types"; import { DayTab, DAY_TAB_NAMES, MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; import { - formatBalance, formatCalories, formatDate, formatPrice, formatSleep, + applyMultiplier, formatBalance, formatCalories, formatDate, formatPrice, formatSleep, formatSleepInput, getDayOfWeek, getHumanDate, parseSleepInput, shiftDate, } from "../Utils"; import MealModal from "../components/modals/MealModal"; @@ -48,6 +48,7 @@ export default function DayPage() { const [saveWorkoutOpen, setSaveWorkoutOpen] = useState(false); const [preselectedActivities, setPreselectedActivities] = useState([]); const [savedNotice, setSavedNotice] = useState(); + const [errorNotice, setErrorNotice] = useState(); const loadDay = useCallback(async (forDate: string) => { setLoading(true); @@ -94,12 +95,26 @@ export default function DayPage() { }; const saveMealAsTemplate = async (entry: MealEntry) => { + setErrorNotice(undefined); const response = await createMealTemplateFromMeal({ body: { id: entry.id, date: entry.date } }); if (response.data) { 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. */ const openSaveWorkout = (entry?: ActivityEntry) => { setPreselectedActivities(entry ? [entry.id] : []); @@ -118,7 +133,7 @@ export default function DayPage() { {tab === DayTab.PRIJEM ? (
)}
{savedNotice && {savedNotice}} + {errorNotice && ( + setErrorNotice(undefined)}> + {errorNotice} + + )}
@@ -304,6 +328,7 @@ export default function DayPage() { onEdit={() => { setEditedActivity(entry); setActivityModalOpen(true); }} onDelete={() => removeActivity(entry)} onSaveTemplate={() => openSaveWorkout(entry)} + onMultiplierChange={multiplier => changeActivityMultiplier(entry, multiplier)} /> ))}
diff --git a/client/src/pages/MealTemplatesPanel.tsx b/client/src/pages/MealTemplatesPanel.tsx index bd6fdf2..ddf6c27 100644 --- a/client/src/pages/MealTemplatesPanel.tsx +++ b/client/src/pages/MealTemplatesPanel.tsx @@ -1,41 +1,26 @@ 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 { faTrash, faPenToSquare, faBowlFood } from "@fortawesome/free-solid-svg-icons"; -import { MealTemplate, MealType, deleteMealTemplate, getMealTemplates, saveMealTemplate } from "../../../types"; -import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums"; -import { - deriveAmounts, formatCalories, formatPrice, formatPriceInput, formatWeight, - parseIntInput, parsePriceInput, -} from "../Utils"; - -/** Prázdný formulář nové šablony jídla. */ -const emptyForm = () => ({ - name: '', - source: '', - mealType: '' as MealType | '', - weight: '', - caloriesPer100g: '', - calories: '', - price: '', - pricePer100g: '', - note: '', -}); +import { faBowlFood } from "@fortawesome/free-solid-svg-icons"; +import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types"; +import { MEAL_TEMPLATE_KINDS_IN_ORDER, MEAL_TEMPLATE_KIND_NAMES } from "../enums"; +import MealTemplateCard from "../components/MealTemplateCard"; +import MealTemplateForm from "../components/MealTemplateForm"; /** * 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. - * Poznámka slouží i na postup přípravy u domácích jídel; strukturované suroviny - * mohou přibýt později, aniž by se model musel měnit. + * Šablony jsou dvojího druhu: hotová jídla (objednané, koupené — pamatují si + * výsledné hodnoty) a recepty (vařené — nesou suroviny a postup a hodnoty porce + * se z nich dopočtou). Na rozdíl od šablon tréninků se neskládají z jiných. */ const MealTemplatesPanel = () => { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); - const [editedId, setEditedId] = useState(); - const [form, setForm] = useState(emptyForm); - const [error, setError] = useState(); + const [edited, setEdited] = useState(); 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 () => { setLoading(true); @@ -46,85 +31,32 @@ const MealTemplatesPanel = () => { useEffect(() => { load(); }, [load]); - const update = (changes: Partial>) => - 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) => { setBusy(true); const response = await deleteMealTemplate({ body: { id } }); setBusy(false); if (response.data) { setTemplates(response.data); - if (editedId === id) resetForm(); + if (edited?.id === id) setEdited(undefined); } }; const edit = (template: MealTemplate) => { - setEditedId(template.id); - 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 ?? '', - }); + setEdited(template); globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }; - /** Živý náhled toho, co šablona při použití založí. */ - const preview = deriveAmounts( - parsePriceInput(form.price), - parsePriceInput(form.pricePer100g), - parseIntInput(form.weight), - parseIntInput(form.caloriesPer100g)); - const previewCalories = preview.calories ?? parseIntInput(form.calories); + const groupList = MEAL_TEMPLATE_KINDS_IN_ORDER + .map(kind => ({ kind, templates: templates.filter(template => template.kind === kind) })) + .filter(group => group.templates.length > 0); return ( <>

- Šablona jídla je opakovaně jedené jídlo, které v přehledu dne založíte jedním - kliknutím. Stačí název; gramáž, kalorie a cena se vyplňují podle toho, co o jídle - víte. U domácích jídel jde do poznámky zapsat i postup přípravy. + Šablona jídla se v přehledu dne založí jedním kliknutím, nebo se nabídne, když začnete + psát název jídla. Hotové jídlo je objednávka nebo koupená věc, u které si + jen pamatujete kalorie a cenu. Recept je vařené jídlo — zapíšete suroviny + a postup a kalorie porce se spočítají samy.

{loading && ( @@ -141,185 +73,27 @@ const MealTemplatesPanel = () => {
)} - {!loading && templates.map(template => { - const derived = deriveAmounts( - template.price, template.pricePer100g, template.weight, template.caloriesPer100g); - const calories = derived.calories ?? template.calories; - return ( -
-
-
- {template.name} - {template.mealType && ( - - {MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]} - - )} - {template.source && {template.source}} - {derived.weight != null && {formatWeight(derived.weight)}} - {template.note && s postupem} -
- {calories != null && ( - {formatCalories(calories)} - )} - {template.price != null && ( - {formatPrice(template.price)} - )} - - -
- {template.note &&

{template.note}

} -
- ); - })} - -
-

{editedId ? 'Úprava šablony jídla' : 'Nová šablona jídla'}

- - {error && {error}} - -
-
- Název - update({ name: event.target.value })} + {!loading && groupList.map(group => ( +
+

{MEAL_TEMPLATE_KIND_NAMES[group.kind]}

+ {group.templates.map(template => ( + edit(template)} + onDelete={() => remove(template.id)} /> -
-
- Zdroj - update({ source: event.target.value })} - /> -
-
- Chod - update({ mealType: event.target.value as MealType | '' })} - > - - {MEAL_TYPES_IN_ORDER.map(type => ( - - ))} - -
+ ))}
+ ))} -
- Kalorie, gramáž a cena -
-
- Gramáž -
- update({ weight: event.target.value.replace(/\D/g, '') })} - /> - g -
-
-
- Energie na 100 g -
- update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })} - /> - kcal -
-
-
- Cena -
- update({ price: event.target.value })} - /> - Kč -
-
-
- Cena za 100 g -
- update({ pricePer100g: event.target.value })} - /> - Kč -
-
-
- - {/* Kalorie celé porce dávají smysl jen tam, kde se nedají dopočítat z gramáže */} - {preview.calories == null && ( -
-
- Kalorie porce -
- update({ calories: event.target.value.replace(/\D/g, '') })} - /> - kcal -
- když neznáte gramáž ani hodnotu na 100 g -
-
- )} -
- - - Poznámka / postup - update({ note: event.target.value })} - /> - - -
- {previewCalories != null && ( - - {preview.weight != null && `${formatWeight(preview.weight)} · `} - {formatCalories(previewCalories)} - - )} - {editedId && ( - - )} - -
-
+ { setTemplates(saved); setEdited(undefined); setFormVersion(current => current + 1); }} + onCancel={() => setEdited(undefined)} + /> ); }; diff --git a/client/src/pages/StatsPage.scss b/client/src/pages/StatsPage.scss index d61db5d..29c5907 100644 --- a/client/src/pages/StatsPage.scss +++ b/client/src/pages/StatsPage.scss @@ -140,3 +140,69 @@ color: var(--ft-warning); 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; +} diff --git a/client/src/pages/TemplatesPage.scss b/client/src/pages/TemplatesPage.scss index 9aa4e4f..dc6420c 100644 --- a/client/src/pages/TemplatesPage.scss +++ b/client/src/pages/TemplatesPage.scss @@ -92,3 +92,55 @@ color: var(--ft-text-secondary); 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); + } +} diff --git a/client/src/pages/WorkoutTemplatesPanel.tsx b/client/src/pages/WorkoutTemplatesPanel.tsx index 5a46169..96d9d4f 100644 --- a/client/src/pages/WorkoutTemplatesPanel.tsx +++ b/client/src/pages/WorkoutTemplatesPanel.tsx @@ -7,7 +7,10 @@ import { deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate, } from "../../../types"; 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. */ type DraftItem = { @@ -17,6 +20,8 @@ type DraftItem = { quantity: string, sets: string, caloriesPer100Units: string, + /** Dodatečná zátěž v kg, jak ji uživatel píše */ + extraWeight: string, templateRefId: string, }; @@ -27,6 +32,7 @@ const emptyExercise = (): DraftItem => ({ quantity: '', sets: '', caloriesPer100Units: '', + extraWeight: '', templateRefId: '', }); @@ -107,6 +113,9 @@ const WorkoutTemplatesPanel = () => { ...(parseIntInput(item.caloriesPer100Units) != null ? { caloriesPer100Units: parseIntInput(item.caloriesPer100Units) } : {}), + ...(parseWeightKgInput(item.extraWeight) != null + ? { extraWeightGrams: parseWeightKgInput(item.extraWeight) } + : {}), }); const canSave = name.trim().length > 0 && toItems().length > 0 && !busy; @@ -153,6 +162,7 @@ const WorkoutTemplatesPanel = () => { quantity: item.quantity != null ? String(item.quantity) : '', sets: item.sets != null ? String(item.sets) : '', caloriesPer100Units: item.caloriesPer100Units != null ? String(item.caloriesPer100Units) : '', + extraWeight: formatKgInput(item.extraWeightGrams), templateRefId: '', })); globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); @@ -172,7 +182,8 @@ const WorkoutTemplatesPanel = () => {

Š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. - 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í.

{loading && ( @@ -235,6 +246,11 @@ const WorkoutTemplatesPanel = () => { {formatQuantityWithSets(item.quantity ?? 0, ACTIVITY_UNIT_SHORT[item.unit!], item.sets)} + {!!item.extraWeightGrams && ( + + {formatExtraWeight(item.extraWeightGrams)} + + )} {formatCalories(deriveActivityCalories(item.quantity, item.caloriesPer100Units, item.sets))} @@ -318,6 +334,14 @@ const WorkoutTemplatesPanel = () => { placeholder="kcal/100" onChange={event => updateItem(index, { caloriesPer100Units: event.target.value.replace(/\D/g, '') })} /> + updateItem(index, { extraWeight: event.target.value })} + /> )}