fix: recepty přesnuty k jídlům
feat: přidána podpora fotek
This commit is contained in:
1 parent
efed5e6e86
commit
e13f7ce8e5
39 files changed
+1266
-511
No files matched your search
@@ -59,8 +59,9 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
- **Entry:** `index.ts` — Express app, auth middleware, error middleware
|
- **Entry:** `index.ts` — Express app, auth middleware, error middleware
|
||||||
- **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
|
- **Routes:** `routes/` — `dayRoutes`, `mealRoutes`, `activityRoutes`, `workoutRoutes`,
|
||||||
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
|
`statsRoutes`, `calorieRoutes`, `settingsRoutes`, `importRoutes`
|
||||||
- **Domain:** `meals.ts` (jídlo a historie jídel), `nutrients.ts` (živiny),
|
- **Domain:** `meals.ts` (jídlo a historie jídel), `mealGroups.ts` (seznam jídel —
|
||||||
`mealTemplates.ts` (recepty), `activities.ts` (pohyb), `workouts.ts` (šablony
|
skupiny podle názvu a zdroje, doplnění a přejmenování), `mealPhotos.ts` (fotky
|
||||||
|
jídel), `nutrients.ts` (živiny), `mealTemplates.ts` (recepty), `activities.ts` (pohyb), `workouts.ts` (šablony
|
||||||
tréninků), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts`
|
tréninků), `sleep.ts` (spánek), `weights.ts` (vážení po dnech), `profile.ts`
|
||||||
(tělesné údaje a výpočet BMR), `bodyMetrics.ts` (BMI, podíl tuku, cíle),
|
(tělesné údaje a výpočet BMR), `bodyMetrics.ts` (BMI, podíl tuku, cíle),
|
||||||
`dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
|
`dayOverview.ts` (spojení dne a bilance), `statsService.ts` (agregace),
|
||||||
@@ -75,7 +76,7 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
|||||||
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
||||||
`MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje,
|
`MealGroupsPage` (`/jidla` — seznam jídel podle názvu a zdroje s filtrem zdroje,
|
||||||
hromadným doplněním údajů a přejmenováním),
|
hromadným doplněním údajů a přejmenováním),
|
||||||
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
|
`WorkoutsPage` (`/treninky` — šablony tréninků), `ProfilePage` (Profil — ukazatele,
|
||||||
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
||||||
`StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
|
`StatsPage` (statistiky se záložkami Příjem / Výdej / Útrata a import; panely
|
||||||
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
`IntakeStatsPanel`, `ActivityStatsPanel`, `SpendingStatsPanel`)
|
||||||
@@ -305,7 +306,21 @@ Druhou akcí skupiny je **přejmenování** (`renameMealGroup`,
|
|||||||
`POST /api/meals/renameGroup`) — změní název i zdroj všem jídlům skupiny ve
|
`POST /api/meals/renameGroup`) — změní název i zdroj všem jídlům skupiny ve
|
||||||
všech dnech. Je oddělené od doplnění schválně: doplnění jen připisuje, co chybí,
|
všech dnech. Je oddělené od doplnění schválně: doplnění jen připisuje, co chybí,
|
||||||
přejmenování přepisuje. Když nový název a zdroj patří jiné skupině, jídla se do
|
přejmenování přepisuje. Když nový název a zdroj patří jiné skupině, jídla se do
|
||||||
ní sloučí (formulář na to předem upozorní).
|
ní sloučí (formulář na to předem upozorní). Recept a fotky jdou s jídlem; dvě
|
||||||
|
jídla s recepty sloučit nejde.
|
||||||
|
|
||||||
|
**Recept a fotky jsou rozšířením jídla v seznamu**, ne samostatnou evidencí —
|
||||||
|
patří dvojici název a zdroj, ne dni. V přehledu dne se nezobrazují.
|
||||||
|
|
||||||
|
- Recept se k jídlu váže **shodou názvu a zdroje** (`getRecipeKey`), jedno jídlo
|
||||||
|
má nejvýš jeden (`saveMealTemplate` druhý odmítne). Recept jídla, které se
|
||||||
|
ještě nejedlo, je v seznamu jako skupina s `count: 0` a bez `lastDate`.
|
||||||
|
Má-li jídlo recept, doplnění údajů předvyplní gramáž porce a energii z něj.
|
||||||
|
- Fotky (`mealPhotos.ts`): klient je před nahráním zmenší (`imageResize.ts`,
|
||||||
|
~1600 px JPEG) a pošle i náhled (240 px). Rejstřík s náhledy je jeden pro
|
||||||
|
uživatele (`mealPhotos:<login>`, klíč jídla → fotky), aby ho seznam jídel
|
||||||
|
načetl jedním čtením; plné obrázky leží každý zvlášť (`mealPhoto:<login>:<id>`)
|
||||||
|
a načtou se, až když se fotky jídla otevřou.
|
||||||
|
|
||||||
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
|
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
|
||||||
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
|
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
|
||||||
@@ -313,7 +328,9 @@ psát jde i nový.
|
|||||||
|
|
||||||
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je **recept** —
|
`MealTemplate` (`mealTemplates.ts`, klíč `mealTemplates:<login>`) je **recept** —
|
||||||
vařené jídlo se surovinami (`ingredients`), počtem porcí (`servings`), hmotností
|
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
|
hotového jídla (`cookedWeight`) a postupem v `note`. **Chod nemá** — patří ke dni,
|
||||||
|
ne k jídlu; použije se zvolený při použití, jinak oběd (starší recepty ho v úložišti
|
||||||
|
mít můžou, `toMealTemplate` ho zahodí). `deriveRecipe` z nich při
|
||||||
uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (`weight`,
|
uložení spočítá hodnoty **jedné porce** a uloží je do plochých polí (`weight`,
|
||||||
`caloriesPer100g`, `calories`, `price`); použitím vznikne běžný `MealEntry` přes
|
`caloriesPer100g`, `calories`, `price`); použitím vznikne běžný `MealEntry` přes
|
||||||
`addMeal`. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu
|
`addMeal`. Energie na 100 g se bere z hmotnosti hotového jídla, ne ze součtu
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import Header from "./components/Header";
|
|||||||
import Login from "./Login";
|
import Login from "./Login";
|
||||||
import DayPage from "./pages/DayPage";
|
import DayPage from "./pages/DayPage";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
import TemplatesPage from "./pages/TemplatesPage";
|
import WorkoutsPage from "./pages/WorkoutsPage";
|
||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
import MealGroupsPage from "./pages/MealGroupsPage";
|
import MealGroupsPage from "./pages/MealGroupsPage";
|
||||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
|
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "./routes";
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -25,7 +25,7 @@ export default function AppRoutes() {
|
|||||||
<Route path={DEN_URL} element={<DayPage />} />
|
<Route path={DEN_URL} element={<DayPage />} />
|
||||||
<Route path={JIDLA_URL} element={<MealGroupsPage />} />
|
<Route path={JIDLA_URL} element={<MealGroupsPage />} />
|
||||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||||
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
<Route path={TRENINKY_URL} element={<WorkoutsPage />} />
|
||||||
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
||||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|||||||
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import { DEN_URL, JIDLA_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "../routes";
|
import { DEN_URL, JIDLA_URL, PROFIL_URL, STATISTIKY_URL, TRENINKY_URL } from "../routes";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
|
|
||||||
/** Položky hlavní navigace. */
|
/** Položky hlavní navigace. */
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ url: DEN_URL, label: 'Můj den' },
|
{ url: DEN_URL, label: 'Můj den' },
|
||||||
{ url: JIDLA_URL, label: 'Jídla' },
|
{ url: JIDLA_URL, label: 'Jídla' },
|
||||||
{ url: SABLONY_URL, label: 'Šablony' },
|
{ url: TRENINKY_URL, label: 'Tréninky' },
|
||||||
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
{ url: STATISTIKY_URL, label: 'Statistiky' },
|
||||||
{ url: PROFIL_URL, label: 'Profil' },
|
{ url: PROFIL_URL, label: 'Profil' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, Modal, Spinner } from "react-bootstrap";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faCamera, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { MealGroup, MealPhoto, addMealPhoto, deleteMealPhoto, getMealPhotos } from "../../../types";
|
||||||
|
import { prepareMealPhoto } from "../imageResize";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
group: MealGroup,
|
||||||
|
/** Zavolá se po přidání nebo smazání fotky, aby se v seznamu obnovil náhled a počet. */
|
||||||
|
onChanged: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fotky jídla — patří jídlu jako celku (názvu a zdroji), ne dni, kdy se jedlo.
|
||||||
|
* Fotky se před nahráním zmenší v prohlížeči.
|
||||||
|
*/
|
||||||
|
const MealGroupPhotos = ({ group, onChanged }: Readonly<Props>) => {
|
||||||
|
const [photoList, setPhotoList] = useState<MealPhoto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [shown, setShown] = useState<MealPhoto | undefined>();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMealPhotos({ query: { name: group.name, source: group.source } }).then(response => {
|
||||||
|
setPhotoList(response.data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [group.name, group.source]);
|
||||||
|
|
||||||
|
const upload = async (fileList: FileList | null) => {
|
||||||
|
if (!fileList?.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
const addedList: MealPhoto[] = [];
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
try {
|
||||||
|
const prepared = await prepareMealPhoto(file);
|
||||||
|
const response = await addMealPhoto({ body: { name: group.name, source: group.source, ...prepared } });
|
||||||
|
if (response.data) addedList.push(response.data);
|
||||||
|
} catch {
|
||||||
|
toast.error(`Soubor ${file.name} se nepodařilo načíst jako obrázek.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
if (addedList.length) {
|
||||||
|
setPhotoList(current => [...current, ...addedList]);
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (photo: MealPhoto) => {
|
||||||
|
setBusy(true);
|
||||||
|
const response = await deleteMealPhoto({ body: { id: photo.id } });
|
||||||
|
setBusy(false);
|
||||||
|
if (!response.error) {
|
||||||
|
setPhotoList(current => current.filter(item => item.id !== photo.id));
|
||||||
|
setShown(undefined);
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meal-group-form">
|
||||||
|
{loading && <Spinner animation="border" variant="success" size="sm" />}
|
||||||
|
{!loading && (
|
||||||
|
<div className="meal-photos">
|
||||||
|
{photoList.map(photo => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={photo.id}
|
||||||
|
className="meal-photo"
|
||||||
|
onClick={() => setShown(photo)}
|
||||||
|
title="Zobrazit"
|
||||||
|
>
|
||||||
|
<img src={photo.thumbnail} alt={group.name} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<label className={`meal-photo meal-photo-add ${busy ? 'disabled' : ''}`} title="Přidat fotky">
|
||||||
|
{busy ? <Spinner animation="border" size="sm" /> : <FontAwesomeIcon icon={faCamera} />}
|
||||||
|
<span>Přidat</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={busy}
|
||||||
|
onChange={event => upload(event.target.files)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal show={!!shown} onHide={() => setShown(undefined)} centered size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{group.name}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="text-center">
|
||||||
|
{shown && <img src={shown.image} alt={group.name} className="meal-photo-full" />}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="outline-danger" disabled={busy} onClick={() => shown && remove(shown)}>
|
||||||
|
<FontAwesomeIcon icon={faTrash} /> Smazat fotku
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-secondary" onClick={() => setShown(undefined)}>Zavřít</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealGroupPhotos;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { MealGroup, deleteMealTemplate } from "../../../types";
|
||||||
|
import MealTemplateCard from "./MealTemplateCard";
|
||||||
|
import MealTemplateForm from "./MealTemplateForm";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
group: MealGroup,
|
||||||
|
/** Zavolá se po uložení nebo smazání receptu, aby se seznam načetl znovu. */
|
||||||
|
onChanged: () => void,
|
||||||
|
onClose: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recept jídla v seznamu jídel. Recept rozšiřuje jídlo (název a zdroj) o suroviny
|
||||||
|
* a postup; jídlo bez receptu nabídne rovnou formulář na jeho založení.
|
||||||
|
*/
|
||||||
|
const MealGroupRecipe = ({ group, onChanged, onClose }: Readonly<Props>) => {
|
||||||
|
const [editing, setEditing] = useState(!group.recipe);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
const response = await deleteMealTemplate({ body: { id } });
|
||||||
|
setBusy(false);
|
||||||
|
if (response.data) onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meal-group-form meal-group-recipe">
|
||||||
|
{group.recipe && !editing && (
|
||||||
|
<MealTemplateCard
|
||||||
|
template={group.recipe}
|
||||||
|
busy={busy}
|
||||||
|
onEdit={() => setEditing(true)}
|
||||||
|
onDelete={() => remove(group.recipe!.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<MealTemplateForm
|
||||||
|
template={group.recipe}
|
||||||
|
meal={group}
|
||||||
|
onSaved={() => onChanged()}
|
||||||
|
onCancel={() => group.recipe ? setEditing(false) : onClose()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealGroupRecipe;
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "react-bootstrap";
|
import { Button } from "react-bootstrap";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCalendarDays, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
import { faBookOpen, faCalendarDays, faCamera, faPen, faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealGroup } from "../../../types";
|
import { MealGroup } from "../../../types";
|
||||||
import { MealGroupAction } from "../enums";
|
import { MealGroupAction } from "../enums";
|
||||||
import { formatDateString } from "../Utils";
|
import { formatDateString } from "../Utils";
|
||||||
import MealGroupForm from "./MealGroupForm";
|
import MealGroupForm from "./MealGroupForm";
|
||||||
import MealGroupRenameForm from "./MealGroupRenameForm";
|
import MealGroupRenameForm from "./MealGroupRenameForm";
|
||||||
import MealGroupDays from "./MealGroupDays";
|
import MealGroupDays from "./MealGroupDays";
|
||||||
|
import MealGroupRecipe from "./MealGroupRecipe";
|
||||||
|
import MealGroupPhotos from "./MealGroupPhotos";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
group: MealGroup,
|
group: MealGroup,
|
||||||
@@ -16,8 +18,10 @@ type Props = {
|
|||||||
/** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */
|
/** Rozbalená akce; nejvýš jeden řádek seznamu má nějakou otevřenou. */
|
||||||
action?: MealGroupAction,
|
action?: MealGroupAction,
|
||||||
onAction: (action?: MealGroupAction) => void,
|
onAction: (action?: MealGroupAction) => void,
|
||||||
/** Zavolá se po doplnění nebo přejmenování, aby se seznam načetl znovu. */
|
/** Zavolá se po doplnění, přejmenování nebo změně receptu — seznam se načte znovu a akce zavře. */
|
||||||
onChanged: () => void,
|
onChanged: () => void,
|
||||||
|
/** Obnoví seznam a otevřenou akci nechá — po změně fotek, aby šlo přidávat dál. */
|
||||||
|
onRefresh: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */
|
/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */
|
||||||
@@ -26,20 +30,35 @@ const describeMissing = (missing: number, count: number): string =>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů.
|
* Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů.
|
||||||
* Nabízí doplnění údajů a změnu názvu a zdroje všem jídlům skupiny a dny,
|
* Nabízí recept a fotky jídla, doplnění údajů a změnu názvu a zdroje všem
|
||||||
* kdy se jídlo jedlo, jako odkazy na přehled dne.
|
* jídlům skupiny a dny, kdy se jídlo jedlo, jako odkazy na přehled dne.
|
||||||
*/
|
*/
|
||||||
const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonly<Props>) => {
|
const MealGroupRow = ({ group, groupList, action, onAction, onChanged, onRefresh }: Readonly<Props>) => {
|
||||||
const [daysOpen, setDaysOpen] = useState(false);
|
const [daysOpen, setDaysOpen] = useState(false);
|
||||||
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
const toggle = (value: MealGroupAction) => onAction(action === value ? undefined : value);
|
||||||
return (
|
return (
|
||||||
<div className="card-panel meal-group">
|
<div className="card-panel meal-group">
|
||||||
<div className="meal-group-head">
|
<div className="meal-group-head">
|
||||||
|
{group.thumbnail && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="meal-group-thumb"
|
||||||
|
title="Fotky jídla"
|
||||||
|
onClick={() => toggle(MealGroupAction.FOTKY)}
|
||||||
|
>
|
||||||
|
<img src={group.thumbnail} alt={group.name} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="meal-group-info">
|
<div className="meal-group-info">
|
||||||
<div className="meal-group-name">{group.name}</div>
|
<div className="meal-group-name">{group.name}</div>
|
||||||
<div className="meal-meta">
|
<div className="meal-meta">
|
||||||
{group.source && <span className="tag">{group.source}</span>}
|
{group.source && <span className="tag">{group.source}</span>}
|
||||||
<span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
{group.lastDate
|
||||||
|
? <span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
||||||
|
: <span className="tag">zatím nejedeno</span>}
|
||||||
|
{group.recipe && (
|
||||||
|
<span className="tag tag-recipe"><FontAwesomeIcon icon={faBookOpen} /> recept</span>
|
||||||
|
)}
|
||||||
{group.missingCaloriesCount > 0 && (
|
{group.missingCaloriesCount > 0 && (
|
||||||
<span className="tag tag-missing">
|
<span className="tag tag-missing">
|
||||||
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
|
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
|
||||||
@@ -58,7 +77,7 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="meal-group-actions">
|
<div className="meal-group-actions">
|
||||||
{!action && (
|
{!action && group.count > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -68,6 +87,24 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
|
|||||||
<FontAwesomeIcon icon={faCalendarDays} /> Dny
|
<FontAwesomeIcon icon={faCalendarDays} /> Dny
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
active={action === MealGroupAction.RECEPT}
|
||||||
|
title={group.recipe ? 'Zobrazit recept' : 'Zapsat k jídlu recept'}
|
||||||
|
onClick={() => toggle(MealGroupAction.RECEPT)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} /> Recept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
active={action === MealGroupAction.FOTKY}
|
||||||
|
title="Fotky jídla"
|
||||||
|
onClick={() => toggle(MealGroupAction.FOTKY)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCamera} /> {group.photoCount || ''}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -77,19 +114,26 @@ const MealGroupRow = ({ group, groupList, action, onAction, onChanged }: Readonl
|
|||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPen} /> Přejmenovat
|
<FontAwesomeIcon icon={faPen} /> Přejmenovat
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{group.count > 0 && (
|
||||||
variant={action === MealGroupAction.DOPLNIT ? 'outline-secondary' : 'primary'}
|
<Button
|
||||||
size="sm"
|
variant={action === MealGroupAction.DOPLNIT ? 'outline-secondary' : 'primary'}
|
||||||
onClick={() => toggle(MealGroupAction.DOPLNIT)}
|
size="sm"
|
||||||
>
|
onClick={() => toggle(MealGroupAction.DOPLNIT)}
|
||||||
<FontAwesomeIcon icon={faPenToSquare} /> {action === MealGroupAction.DOPLNIT ? 'Zavřít' : 'Doplnit'}
|
>
|
||||||
</Button>
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
{' '}{action === MealGroupAction.DOPLNIT ? 'Zavřít' : 'Doplnit'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && <MealGroupDays days={group.days} />}
|
{(action === MealGroupAction.DOPLNIT || (!action && daysOpen)) && <MealGroupDays days={group.days} />}
|
||||||
{action === MealGroupAction.DOPLNIT && (
|
{action === MealGroupAction.DOPLNIT && (
|
||||||
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
|
<MealGroupForm group={group} onFilled={onChanged} onCancel={() => onAction(undefined)} />
|
||||||
)}
|
)}
|
||||||
|
{action === MealGroupAction.RECEPT && (
|
||||||
|
<MealGroupRecipe group={group} onChanged={onChanged} onClose={() => onAction(undefined)} />
|
||||||
|
)}
|
||||||
|
{action === MealGroupAction.FOTKY && <MealGroupPhotos group={group} onChanged={onRefresh} />}
|
||||||
{action === MealGroupAction.PREJMENOVAT && (
|
{action === MealGroupAction.PREJMENOVAT && (
|
||||||
<MealGroupRenameForm
|
<MealGroupRenameForm
|
||||||
group={group}
|
group={group}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { faPenToSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealTemplate } from "../../../types";
|
import { MealTemplate } from "../../../types";
|
||||||
import { MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
|
||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, getIngredientCalories } from "../Utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,8 +11,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Karta receptu na stránce Šablony — suroviny, hodnoty celého hrnce i jedné
|
* Karta receptu uvnitř jídla v seznamu jídel — suroviny, hodnoty celého hrnce
|
||||||
* porce a postup.
|
* i jedné porce a postup. Název a zdroj neopakuje, nese je jídlo.
|
||||||
*/
|
*/
|
||||||
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>) => {
|
||||||
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
const recipe = deriveRecipe(template.ingredients, template.servings, template.cookedWeight);
|
||||||
@@ -22,13 +21,7 @@ const MealTemplateCard = ({ template, busy, onEdit, onDelete }: Readonly<Props>)
|
|||||||
<div className="card-panel template-card">
|
<div className="card-panel template-card">
|
||||||
<div className="template-head">
|
<div className="template-head">
|
||||||
<div className="template-title">
|
<div className="template-title">
|
||||||
{template.name}
|
Recept
|
||||||
{template.mealType && (
|
|
||||||
<span className="tag">
|
|
||||||
{MEAL_TYPE_ICONS[template.mealType]} {MEAL_TYPE_NAMES[template.mealType]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{template.source && <span className="tag">{template.source}</span>}
|
|
||||||
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
{template.weight != null && <span className="tag">porce {formatWeight(template.weight)}</span>}
|
||||||
{(template.servings ?? 1) > 1 && (
|
{(template.servings ?? 1) > 1 && (
|
||||||
<span className="tag">{template.servings} porcí</span>
|
<span className="tag">{template.servings} porcí</span>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Button, Form } from "react-bootstrap";
|
import { Alert, Button, Form } from "react-bootstrap";
|
||||||
import { MealTemplate, MealType, saveMealTemplate } from "../../../types";
|
import { MealTemplate, saveMealTemplate } from "../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES } from "../enums";
|
|
||||||
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
import { deriveRecipe, formatCalories, formatPrice, formatWeight, parseIntInput } from "../Utils";
|
||||||
import SourceInput from "./SourceInput";
|
import SourceInput from "./SourceInput";
|
||||||
import RecipeIngredientsEditor, {
|
import RecipeIngredientsEditor, {
|
||||||
@@ -11,15 +10,19 @@ import RecipeIngredientsEditor, {
|
|||||||
type Props = {
|
type Props = {
|
||||||
/** Upravovaný recept. Pokud chybí, zakládá se nový. */
|
/** Upravovaný recept. Pokud chybí, zakládá se nový. */
|
||||||
template?: MealTemplate,
|
template?: MealTemplate,
|
||||||
|
/**
|
||||||
|
* Jídlo ze seznamu jídel, ke kterému recept patří. Název a zdroj pak nejdou
|
||||||
|
* měnit — mění se přejmenováním jídla, aby recept zůstal u svých záznamů.
|
||||||
|
*/
|
||||||
|
meal?: { name: string, source?: string },
|
||||||
onSaved: (templates: MealTemplate[]) => void,
|
onSaved: (templates: MealTemplate[]) => void,
|
||||||
onCancel: () => void,
|
onCancel: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
|
/** Naplní formulář z receptu, nebo ho nechá prázdný pro nový. */
|
||||||
const createForm = (template?: MealTemplate) => ({
|
const createForm = (template?: MealTemplate, meal?: Props['meal']) => ({
|
||||||
name: template?.name ?? '',
|
name: meal?.name ?? template?.name ?? '',
|
||||||
source: template?.source ?? '',
|
source: meal?.source ?? template?.source ?? '',
|
||||||
mealType: (template?.mealType ?? '') as MealType | '',
|
|
||||||
note: template?.note ?? '',
|
note: template?.note ?? '',
|
||||||
servings: template?.servings != null ? String(template.servings) : '',
|
servings: template?.servings != null ? String(template.servings) : '',
|
||||||
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
cookedWeight: template?.cookedWeight != null ? String(template.cookedWeight) : '',
|
||||||
@@ -29,8 +32,8 @@ const createForm = (template?: MealTemplate) => ({
|
|||||||
* Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty
|
* Formulář receptu — vařeného jídla. Zapisují se suroviny a postup a hodnoty
|
||||||
* porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
|
* porce se z nich dopočtou — stejně jako na serveru, tady jen jako živý náhled.
|
||||||
*/
|
*/
|
||||||
const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
const MealTemplateForm = ({ template, meal, onSaved, onCancel }: Readonly<Props>) => {
|
||||||
const [form, setForm] = useState(() => createForm(template));
|
const [form, setForm] = useState(() => createForm(template, meal));
|
||||||
const [ingredients, setIngredients] = useState<DraftIngredient[]>(() =>
|
const [ingredients, setIngredients] = useState<DraftIngredient[]>(() =>
|
||||||
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
template?.ingredients?.length ? template.ingredients.map(toDraftIngredient) : [emptyIngredient()]);
|
||||||
const [error, setError] = useState<string | undefined>();
|
const [error, setError] = useState<string | undefined>();
|
||||||
@@ -54,7 +57,6 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
|||||||
...(template ? { id: template.id } : {}),
|
...(template ? { id: template.id } : {}),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
...(form.source.trim() ? { source: form.source.trim() } : {}),
|
||||||
...(form.mealType ? { mealType: form.mealType } : {}),
|
|
||||||
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
...(form.note.trim() ? { note: form.note.trim() } : {}),
|
||||||
ingredients: filledIngredients,
|
ingredients: filledIngredients,
|
||||||
servings: parseIntInput(form.servings),
|
servings: parseIntInput(form.servings),
|
||||||
@@ -75,38 +77,26 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
|||||||
|
|
||||||
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
{error && <Alert variant="danger" className="py-2 small">{error}</Alert>}
|
||||||
|
|
||||||
<div className="row g-3 mb-3">
|
{!meal && (
|
||||||
<div className="col-sm-5">
|
<div className="row g-3 mb-3">
|
||||||
<Form.Label>Název</Form.Label>
|
<div className="col-sm-7">
|
||||||
<Form.Control
|
<Form.Label>Název</Form.Label>
|
||||||
value={form.name}
|
<Form.Control
|
||||||
placeholder="Např. Kuřecí rizoto"
|
value={form.name}
|
||||||
onChange={event => update({ name: event.target.value })}
|
placeholder="Např. Kuřecí rizoto"
|
||||||
/>
|
onChange={event => update({ name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-5">
|
||||||
|
<Form.Label>Zdroj</Form.Label>
|
||||||
|
<SourceInput
|
||||||
|
value={form.source}
|
||||||
|
placeholder="doma"
|
||||||
|
onChange={source => update({ source })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-sm-4">
|
)}
|
||||||
<Form.Label>Zdroj</Form.Label>
|
|
||||||
<SourceInput
|
|
||||||
value={form.source}
|
|
||||||
placeholder="doma"
|
|
||||||
onChange={source => update({ source })}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<fieldset className="meal-fieldset mb-3">
|
<fieldset className="meal-fieldset mb-3">
|
||||||
<legend>Suroviny</legend>
|
<legend>Suroviny</legend>
|
||||||
@@ -168,9 +158,7 @@ const MealTemplateForm = ({ template, onSaved, onCancel }: Readonly<Props>) => {
|
|||||||
{recipe.calories != null && (
|
{recipe.calories != null && (
|
||||||
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
<span className="workout-draft-calories">porce {formatCalories(recipe.calories)}</span>
|
||||||
)}
|
)}
|
||||||
{template && (
|
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit</Button>
|
||||||
<Button variant="outline-secondary" size="sm" onClick={() => onCancel()}>Zrušit úpravu</Button>
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
<Button size="sm" onClick={() => save()} disabled={!canSave}>
|
||||||
{template ? 'Uložit změny' : 'Vytvořit recept'}
|
{template ? 'Uložit změny' : 'Vytvořit recept'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function ActivityModal({ isOpen, date, entry, onClose, onSaved }:
|
|||||||
? response.data.activities.entries.find(item => item.id === entry.id)
|
? response.data.activities.entries.find(item => item.id === entry.id)
|
||||||
: [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
: [...response.data.activities.entries].sort((a, b) => a.createdAt.localeCompare(b.createdAt)).pop();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
// Šablona z jednoho cviku; víc jich jde spojit na stránce Šablony
|
// Šablona z jednoho cviku; víc jich jde spojit na stránce Tréninky
|
||||||
await createWorkoutTemplateFromActivities({
|
await createWorkoutTemplateFromActivities({
|
||||||
body: { date: saved.date, name: saved.name, ids: [saved.id] },
|
body: { date: saved.date, name: saved.name, ids: [saved.id] },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { faPlay, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import {
|
import {
|
||||||
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
MealSuggestion, MealTemplate, MealType, addMeal, applyMealTemplate, getMealHistory, getMealTemplates,
|
||||||
} from "../../../../types";
|
} from "../../../../types";
|
||||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, TemplateTab } from "../../enums";
|
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, RecipeFilter } from "../../enums";
|
||||||
import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
import { describeMealSuggestion, describeMealTemplate, getMealKey, normalizeSearch, parseMultiplierInput } from "../../Utils";
|
||||||
import { SABLONY_URL } from "../../routes";
|
import { JIDLA_URL } from "../../routes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -114,7 +114,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
<div className="empty-title">Zatím nemáte co vybírat</div>
|
<div className="empty-title">Zatím nemáte co vybírat</div>
|
||||||
<p className="empty-description">
|
<p className="empty-description">
|
||||||
Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako
|
Každé zapsané jídlo se tu příště nabídne. Vařená jídla si můžete zapsat jako
|
||||||
recept na stránce Šablony.
|
recept u jídla na stránce Jídla.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +135,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
<Form.Label>Založit jako</Form.Label>
|
<Form.Label>Založit jako</Form.Label>
|
||||||
<Form.Select
|
<Form.Select
|
||||||
value={mealType}
|
value={mealType}
|
||||||
title="Bez volby se jídlo z historie založí jako oběd a recept pod svým chodem"
|
title="Bez volby se jídlo založí jako oběd"
|
||||||
onChange={event => setMealType(event.target.value as MealType | '')}
|
onChange={event => setMealType(event.target.value as MealType | '')}
|
||||||
>
|
>
|
||||||
<option value="">— výchozí —</option>
|
<option value="">— výchozí —</option>
|
||||||
@@ -218,7 +218,7 @@ const ApplyMealModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>) =
|
|||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button
|
<Button
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
onClick={() => { onClose(); navigate(SABLONY_URL, { state: { tab: TemplateTab.RECEPTY } }); }}
|
onClick={() => { onClose(); navigate(JIDLA_URL, { state: { recipeFilter: RecipeFilter.S_RECEPTEM } }); }}
|
||||||
>
|
>
|
||||||
Spravovat recepty
|
Spravovat recepty
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { faPlay, faLayerGroup, faDumbbell } from "@fortawesome/free-solid-svg-ic
|
|||||||
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
|
import { DayOverview, WorkoutItemKind, WorkoutTemplate, applyWorkoutTemplate, getWorkoutTemplates } from "../../../../types";
|
||||||
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
import { ACTIVITY_UNIT_SHORT } from "../../enums";
|
||||||
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
import { formatCalories, formatQuantityWithSets } from "../../Utils";
|
||||||
import { SABLONY_URL } from "../../routes";
|
import { TRENINKY_URL } from "../../routes";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
@@ -70,7 +70,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
|
<div className="empty-icon"><FontAwesomeIcon icon={faDumbbell} /></div>
|
||||||
<div className="empty-title">Zatím nemáte žádnou šablonu</div>
|
<div className="empty-title">Zatím nemáte žádnou šablonu</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 Tréninky.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ const ApplyWorkoutModal = ({ isOpen, date, onClose, onApplied }: Readonly<Props>
|
|||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button
|
<Button
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
onClick={() => { onClose(); navigate(SABLONY_URL); }}
|
onClick={() => { onClose(); navigate(TRENINKY_URL); }}
|
||||||
>
|
>
|
||||||
Spravovat šablony
|
Spravovat šablony
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ export default function MealModal({ isOpen, date, entry, onClose, onSaved }: Rea
|
|||||||
setPickedSuggestion(recipe.name);
|
setPickedSuggestion(recipe.name);
|
||||||
setForm(current => mergeSuggestion(current, {
|
setForm(current => mergeSuggestion(current, {
|
||||||
name: recipe.name,
|
name: recipe.name,
|
||||||
...(recipe.mealType ? { mealType: recipe.mealType } : {}),
|
|
||||||
source: recipe.source ?? '',
|
source: recipe.source ?? '',
|
||||||
price: formatPriceInput(recipe.price),
|
price: formatPriceInput(recipe.price),
|
||||||
pricePer100g: '',
|
pricePer100g: '',
|
||||||
|
|||||||
+16
-12
@@ -134,17 +134,6 @@ export const BMI_KATEGORIE_NAMES: Record<BmiKategorie, string> = {
|
|||||||
/** Nabízená týdenní tempa změny hmotnosti v gramech; záporné je hubnutí. */
|
/** Nabízená týdenní tempa změny hmotnosti v gramech; záporné je hubnutí. */
|
||||||
export const WEEKLY_CHANGE_OPTIONS = [-1000, -750, -500, -250, 0, 250, 500];
|
export const WEEKLY_CHANGE_OPTIONS = [-1000, -750, -500, -250, 0, 250, 500];
|
||||||
|
|
||||||
/** Záložky stránky se šablonami. */
|
|
||||||
export enum TemplateTab {
|
|
||||||
TRENINKY = 'TRENINKY',
|
|
||||||
RECEPTY = 'RECEPTY',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TEMPLATE_TAB_NAMES: Record<TemplateTab, string> = {
|
|
||||||
TRENINKY: 'Tréninky',
|
|
||||||
RECEPTY: 'Recepty',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
|
/** Živiny v pořadí, v jakém je uvádí etiketa potravin. */
|
||||||
export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [
|
export const NUTRIENTS_IN_ORDER: (keyof Nutrients)[] = [
|
||||||
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
|
'protein', 'carbohydrates', 'sugars', 'fat', 'saturatedFat', 'fiber', 'salt',
|
||||||
@@ -177,12 +166,27 @@ export const IMPORT_MODE_NAMES: Record<ImportMode, string> = {
|
|||||||
/** Předvolby násobku porce nabízené v přehledu dne. */
|
/** Předvolby násobku porce nabízené v přehledu dne. */
|
||||||
export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3];
|
export const MULTIPLIER_PRESETS = [0.5, 1, 1.5, 2, 3];
|
||||||
|
|
||||||
/** Akce nad skupinou jídel v seznamu jídel — doplnění jen připisuje, přejmenování přepisuje. */
|
/** Rozbalitelné části jídla v seznamu jídel. Doplnění jen připisuje, přejmenování přepisuje. */
|
||||||
export enum MealGroupAction {
|
export enum MealGroupAction {
|
||||||
|
RECEPT = 'RECEPT',
|
||||||
|
FOTKY = 'FOTKY',
|
||||||
DOPLNIT = 'DOPLNIT',
|
DOPLNIT = 'DOPLNIT',
|
||||||
PREJMENOVAT = 'PREJMENOVAT',
|
PREJMENOVAT = 'PREJMENOVAT',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filtr seznamu jídel podle receptu. */
|
||||||
|
export enum RecipeFilter {
|
||||||
|
VSE = 'VSE',
|
||||||
|
S_RECEPTEM = 'S_RECEPTEM',
|
||||||
|
BEZ_RECEPTU = 'BEZ_RECEPTU',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECIPE_FILTER_NAMES: Record<RecipeFilter, string> = {
|
||||||
|
VSE: 'S receptem i bez',
|
||||||
|
S_RECEPTEM: 'S receptem',
|
||||||
|
BEZ_RECEPTU: 'Bez receptu',
|
||||||
|
};
|
||||||
|
|
||||||
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
|
/** Zvláštní volby filtru zdroje v seznamu jídel; jinak se filtruje konkrétním zdrojem. */
|
||||||
export enum SourceFilter {
|
export enum SourceFilter {
|
||||||
VSE = 'VSE',
|
VSE = 'VSE',
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** Delší strana fotky jídla — na telefonu i monitoru stačí, a velikost zůstane ve stovkách kB. */
|
||||||
|
const PHOTO_MAX_SIZE = 1600;
|
||||||
|
/** Delší strana náhledu v seznamu jídel. */
|
||||||
|
const THUMBNAIL_MAX_SIZE = 240;
|
||||||
|
|
||||||
|
/** Překreslí obrázek do JPEGu s delší stranou nejvýš `maxSize`; menší obrázek nezvětšuje. */
|
||||||
|
const toJpegDataUrl = (image: ImageBitmap, maxSize: number, quality: number): string => {
|
||||||
|
const scale = Math.min(1, maxSize / Math.max(image.width, image.height));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.round(image.width * scale);
|
||||||
|
canvas.height = Math.round(image.height * scale);
|
||||||
|
canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
return canvas.toDataURL('image/jpeg', quality);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Připraví fotku z fotoaparátu k uložení — zmenšenou fotku a malý náhled, obojí
|
||||||
|
* jako data URL. Originál z telefonu má několik MB, ukládat ho celý nemá smysl.
|
||||||
|
* `createImageBitmap` zároveň srovná otočení fotky podle EXIF.
|
||||||
|
*/
|
||||||
|
export const prepareMealPhoto = async (file: File): Promise<{ image: string, thumbnail: string }> => {
|
||||||
|
const image = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
image: toJpegDataUrl(image, PHOTO_MAX_SIZE, 0.85),
|
||||||
|
thumbnail: toJpegDataUrl(image, THUMBNAIL_MAX_SIZE, 0.7),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
image.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
flex: 1 1 240px;
|
flex: 1 1 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meal-groups-source {
|
.meal-groups-source,
|
||||||
flex: 0 1 220px;
|
.meal-groups-recipe {
|
||||||
|
flex: 0 1 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check {
|
.form-check {
|
||||||
@@ -93,3 +94,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.meal-group {
|
||||||
|
.meal-group-thumb {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-group-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-recipe {
|
||||||
|
background: var(--ft-primary-light);
|
||||||
|
color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recept je uvnitř karty jídla, druhý rámeček kolem by jen zabíral místo
|
||||||
|
.meal-group-recipe .card-panel {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-photo {
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--ft-border);
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
background: var(--ft-bg-card);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-photo-add {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--ft-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ft-primary);
|
||||||
|
border-color: var(--ft-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-photo-full {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 75vh;
|
||||||
|
border-radius: var(--ft-radius-sm);
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Form, Spinner } from "react-bootstrap";
|
import { Button, Form, Spinner } from "react-bootstrap";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faCircleCheck } from "@fortawesome/free-solid-svg-icons";
|
import { faBookOpen, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { MealGroup, getMealGroups } from "../../../types";
|
import { MealGroup, getMealGroups } from "../../../types";
|
||||||
import { MealGroupAction, SourceFilter } from "../enums";
|
import { MealGroupAction, RECIPE_FILTER_NAMES, RecipeFilter, SourceFilter } from "../enums";
|
||||||
import { getMealKey, normalizeSearch } from "../Utils";
|
import { getMealKey, normalizeSearch } from "../Utils";
|
||||||
import MealGroupRow from "../components/MealGroupRow";
|
import MealGroupRow from "../components/MealGroupRow";
|
||||||
|
import MealTemplateForm from "../components/MealTemplateForm";
|
||||||
|
import "./templates.scss";
|
||||||
import "./MealGroupsPage.scss";
|
import "./MealGroupsPage.scss";
|
||||||
|
|
||||||
const isIncomplete = (group: MealGroup): boolean =>
|
const isIncomplete = (group: MealGroup): boolean =>
|
||||||
@@ -32,6 +34,15 @@ const listSources = (groupList: MealGroup[]): { value: string, label: string }[]
|
|||||||
.map(([value, item]) => ({ value, label: item.label }));
|
.map(([value, item]) => ({ value, label: item.label }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const matchesRecipe = (group: MealGroup, recipeFilter: RecipeFilter): boolean => {
|
||||||
|
if (recipeFilter === RecipeFilter.S_RECEPTEM) return !!group.recipe;
|
||||||
|
if (recipeFilter === RecipeFilter.BEZ_RECEPTU) return !group.recipe;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stav, se kterým stránku může otevřít odkaz odjinud (statistiky, výběr z historie). */
|
||||||
|
type LocationState = { onlyIncomplete?: boolean, recipeFilter?: RecipeFilter } | null;
|
||||||
|
|
||||||
const matchesSource = (group: MealGroup, sourceFilter: string): boolean => {
|
const matchesSource = (group: MealGroup, sourceFilter: string): boolean => {
|
||||||
if (sourceFilter === SourceFilter.VSE) return true;
|
if (sourceFilter === SourceFilter.VSE) return true;
|
||||||
if (sourceFilter === SourceFilter.BEZ_ZDROJE) return !group.source;
|
if (sourceFilter === SourceFilter.BEZ_ZDROJE) return !group.source;
|
||||||
@@ -43,6 +54,8 @@ const matchesSource = (group: MealGroup, sourceFilter: string): boolean => {
|
|||||||
*
|
*
|
||||||
* Skupině jde hromadně doplnit chybějící kalorie, gramáž nebo živiny a změnit
|
* Skupině jde hromadně doplnit chybějící kalorie, gramáž nebo živiny a změnit
|
||||||
* název a zdroj, a to ve všech dnech naráz, bez procházení jednotlivých dnů.
|
* název a zdroj, a to ve všech dnech naráz, bez procházení jednotlivých dnů.
|
||||||
|
* Jídlo jde rozšířit o recept a fotky; recept jídla, které se ještě nejedlo,
|
||||||
|
* se zakládá tlačítkem v hlavičce a v seznamu je pak samo.
|
||||||
*/
|
*/
|
||||||
const MealGroupsPage = () => {
|
const MealGroupsPage = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -50,9 +63,11 @@ const MealGroupsPage = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [sourceFilter, setSourceFilter] = useState<string>(SourceFilter.VSE);
|
const [sourceFilter, setSourceFilter] = useState<string>(SourceFilter.VSE);
|
||||||
const [onlyIncomplete, setOnlyIncomplete] = useState(() =>
|
const [onlyIncomplete, setOnlyIncomplete] = useState(() => !!(location.state as LocationState)?.onlyIncomplete);
|
||||||
!!(location.state as { onlyIncomplete?: boolean } | null)?.onlyIncomplete);
|
const [recipeFilter, setRecipeFilter] = useState<RecipeFilter>(() =>
|
||||||
|
(location.state as LocationState)?.recipeFilter ?? RecipeFilter.VSE);
|
||||||
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
const [opened, setOpened] = useState<{ key: string, action: MealGroupAction } | undefined>();
|
||||||
|
const [newRecipeOpen, setNewRecipeOpen] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const response = await getMealGroups();
|
const response = await getMealGroups();
|
||||||
@@ -69,11 +84,12 @@ const MealGroupsPage = () => {
|
|||||||
return groupList.filter(group =>
|
return groupList.filter(group =>
|
||||||
(!onlyIncomplete || isIncomplete(group))
|
(!onlyIncomplete || isIncomplete(group))
|
||||||
&& matchesSource(group, sourceFilter)
|
&& matchesSource(group, sourceFilter)
|
||||||
|
&& matchesRecipe(group, recipeFilter)
|
||||||
&& (!search.length || normalizeSearch(group.name).includes(search)));
|
&& (!search.length || normalizeSearch(group.name).includes(search)));
|
||||||
}, [groupList, query, sourceFilter, onlyIncomplete]);
|
}, [groupList, query, sourceFilter, recipeFilter, onlyIncomplete]);
|
||||||
|
|
||||||
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
||||||
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE;
|
const filtered = !!query.trim().length || sourceFilter !== SourceFilter.VSE || recipeFilter !== RecipeFilter.VSE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -84,8 +100,18 @@ const MealGroupsPage = () => {
|
|||||||
Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou
|
Všechna jídla podle názvu a zdroje — údaje doplníte nebo název změníte ve všech dnech najednou
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline-secondary" onClick={() => setNewRecipeOpen(open => !open)}>
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} /> Nový recept
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{newRecipeOpen && (
|
||||||
|
<MealTemplateForm
|
||||||
|
onSaved={() => { setNewRecipeOpen(false); load(); }}
|
||||||
|
onCancel={() => setNewRecipeOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card-panel meal-groups-filter">
|
<div className="card-panel meal-groups-filter">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
value={query}
|
value={query}
|
||||||
@@ -101,6 +127,15 @@ const MealGroupsPage = () => {
|
|||||||
<option value={SourceFilter.BEZ_ZDROJE}>Bez zdroje</option>
|
<option value={SourceFilter.BEZ_ZDROJE}>Bez zdroje</option>
|
||||||
{sourceList.map(source => <option key={source.value} value={source.value}>{source.label}</option>)}
|
{sourceList.map(source => <option key={source.value} value={source.value}>{source.label}</option>)}
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
|
<Form.Select
|
||||||
|
className="meal-groups-recipe"
|
||||||
|
value={recipeFilter}
|
||||||
|
onChange={event => setRecipeFilter(event.target.value as RecipeFilter)}
|
||||||
|
>
|
||||||
|
{Object.values(RecipeFilter).map(value => (
|
||||||
|
<option key={value} value={value}>{RECIPE_FILTER_NAMES[value]}</option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
<Form.Check
|
<Form.Check
|
||||||
type="switch"
|
type="switch"
|
||||||
id="meal-groups-incomplete"
|
id="meal-groups-incomplete"
|
||||||
@@ -135,6 +170,7 @@ const MealGroupsPage = () => {
|
|||||||
action={opened?.key === key ? opened.action : undefined}
|
action={opened?.key === key ? opened.action : undefined}
|
||||||
onAction={action => setOpened(action ? { key, action } : undefined)}
|
onAction={action => setOpened(action ? { key, action } : undefined)}
|
||||||
onChanged={() => { setOpened(undefined); load(); }}
|
onChanged={() => { setOpened(undefined); load(); }}
|
||||||
|
onRefresh={() => load()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { Spinner } from "react-bootstrap";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { MealTemplate, deleteMealTemplate, getMealTemplates } from "../../../types";
|
|
||||||
import MealTemplateCard from "../components/MealTemplateCard";
|
|
||||||
import MealTemplateForm from "../components/MealTemplateForm";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Panel se správou receptů — vařených jídel se surovinami a postupem, ze kterých
|
|
||||||
* se hodnoty porce dopočtou. Objednaná a koupená jídla šablony nemají, při
|
|
||||||
* zakládání jídla se nabízejí z historie.
|
|
||||||
*/
|
|
||||||
const MealTemplatesPanel = () => {
|
|
||||||
const [templates, setTemplates] = useState<MealTemplate[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [edited, setEdited] = useState<MealTemplate | undefined>();
|
|
||||||
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);
|
|
||||||
const response = await getMealTemplates();
|
|
||||||
setTemplates(response.data ?? []);
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
|
||||||
setBusy(true);
|
|
||||||
const response = await deleteMealTemplate({ body: { id } });
|
|
||||||
setBusy(false);
|
|
||||||
if (response.data) {
|
|
||||||
setTemplates(response.data);
|
|
||||||
if (edited?.id === id) setEdited(undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const edit = (template: MealTemplate) => {
|
|
||||||
setEdited(template);
|
|
||||||
globalThis.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className="text-body-secondary small">
|
|
||||||
Recept je vařené jídlo — zapíšete suroviny a postup a kalorie porce se spočítají samy.
|
|
||||||
V přehledu dne se porce založí jedním kliknutím, nebo se recept nabídne, když začnete psát
|
|
||||||
název jídla. Objednaná a koupená jídla si zapisovat nemusíte, nabízejí se z historie.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="empty-state">
|
|
||||||
<Spinner animation="border" variant="success" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !templates.length && (
|
|
||||||
<div className="card-panel empty-state">
|
|
||||||
<div className="empty-icon"><FontAwesomeIcon icon={faBookOpen} /></div>
|
|
||||||
<div className="empty-title">Zatím nemáte žádný recept</div>
|
|
||||||
<p className="empty-description">Zapište si ho ve formuláři níže.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && templates.map(template => (
|
|
||||||
<MealTemplateCard
|
|
||||||
key={template.id}
|
|
||||||
template={template}
|
|
||||||
busy={busy}
|
|
||||||
onEdit={() => edit(template)}
|
|
||||||
onDelete={() => remove(template.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<MealTemplateForm
|
|
||||||
key={`${edited?.id ?? 'new'}-${formVersion}`}
|
|
||||||
template={edited}
|
|
||||||
onSaved={saved => { setTemplates(saved); setEdited(undefined); setFormVersion(current => current + 1); }}
|
|
||||||
onCancel={() => setEdited(undefined)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MealTemplatesPanel;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useLocation } from "react-router";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faDumbbell, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { TemplateTab, TEMPLATE_TAB_NAMES } from "../enums";
|
|
||||||
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
|
||||||
import MealTemplatesPanel from "./MealTemplatesPanel";
|
|
||||||
import "./TemplatesPage.scss";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Šablony — tréninky a recepty vedle sebe.
|
|
||||||
*
|
|
||||||
* Obojí jsou předpřipravené položky zakládané do dne jedním kliknutím, ale mají
|
|
||||||
* jinou strukturu (tréninky se skládají z jiných šablon, recepty ze surovin),
|
|
||||||
* proto mají vlastní panely a společné jen záložky. Hotová jídla šablony
|
|
||||||
* nemají, nabízejí se z historie.
|
|
||||||
*/
|
|
||||||
const TemplatesPage = () => {
|
|
||||||
// Odkaz může rovnou otevřít konkrétní záložku, třeba recepty z výběru jídla
|
|
||||||
const location = useLocation();
|
|
||||||
const [tab, setTab] = useState<TemplateTab>(() =>
|
|
||||||
(location.state as { tab?: TemplateTab } | null)?.tab ?? TemplateTab.TRENINKY);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>Šablony</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="day-tabs">
|
|
||||||
<button
|
|
||||||
className={`day-tab ${tab === TemplateTab.TRENINKY ? 'active' : ''}`}
|
|
||||||
onClick={() => setTab(TemplateTab.TRENINKY)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faDumbbell} /> {TEMPLATE_TAB_NAMES[TemplateTab.TRENINKY]}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`day-tab ${tab === TemplateTab.RECEPTY ? 'active' : ''}`}
|
|
||||||
onClick={() => setTab(TemplateTab.RECEPTY)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faBookOpen} /> {TEMPLATE_TAB_NAMES[TemplateTab.RECEPTY]}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === TemplateTab.TRENINKY ? <WorkoutTemplatesPanel /> : <MealTemplatesPanel />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TemplatesPage;
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import WorkoutTemplatesPanel from "./WorkoutTemplatesPanel";
|
||||||
|
import "./templates.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tréninky — šablony tréninků zakládané do dne jedním kliknutím. Recepty sem
|
||||||
|
* dřív patřily také, teď jsou rozšířením jídla v seznamu jídel.
|
||||||
|
*/
|
||||||
|
const WorkoutsPage = () => (
|
||||||
|
<>
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Tréninky</h1>
|
||||||
|
</div>
|
||||||
|
<WorkoutTemplatesPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default WorkoutsPage;
|
||||||
@@ -66,6 +66,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.template-form {
|
.template-form {
|
||||||
|
.chart-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ft-text-secondary);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
export const DEN_URL = '/';
|
export const DEN_URL = '/';
|
||||||
export const STATISTIKY_URL = '/statistiky';
|
export const STATISTIKY_URL = '/statistiky';
|
||||||
export const SABLONY_URL = '/sablony';
|
export const TRENINKY_URL = '/treninky';
|
||||||
export const PROFIL_URL = '/profil';
|
export const PROFIL_URL = '/profil';
|
||||||
export const JIDLA_URL = '/jidla';
|
export const JIDLA_URL = '/jidla';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import {
|
||||||
|
FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealTemplate, Nutrients,
|
||||||
|
} from "../../types/gen/types.gen";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { BadRequestError } from "./utils";
|
||||||
|
import { normalizeName, rememberCalories } from "./calories";
|
||||||
|
import { normalizeNutrients } from "./nutrients";
|
||||||
|
import {
|
||||||
|
deriveAmounts, findAllMeals, getDayKey, getManualWeight, getNameSourceKey, normalizeNumber, normalizeText,
|
||||||
|
} from "./meals";
|
||||||
|
import { getMealTemplates, getRecipeKey, renameMealTemplate } from "./mealTemplates";
|
||||||
|
import { getPhotoIndex, moveMealPhotos } from "./mealPhotos";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/** Vrátí první vyplněnou hodnotu z jídel seřazených od nejnovějších. */
|
||||||
|
const findLatestValue = <T>(entryList: MealEntry[], pick: (entry: MealEntry) => T | undefined): T | undefined =>
|
||||||
|
entryList.map(pick).find(value => value != null);
|
||||||
|
|
||||||
|
/** Vynechá nevyplněné hodnoty, aby se do úložiště nezapisovaly. */
|
||||||
|
const omitUndefined = <T extends object>(value: T): T =>
|
||||||
|
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sestaví hodnoty k předvyplnění formuláře skupiny — každou z nejnovějšího
|
||||||
|
* jídla, které ji má. Energie a živiny se bez vlastních hodnot vezmou z jídla
|
||||||
|
* stejného názvu odjinud (složení je stejné), sazba za 100 g a gramáž ne —
|
||||||
|
* jinde se účtuje i nandává jinak.
|
||||||
|
*
|
||||||
|
* Má-li jídlo recept, gramáž porce a energie jsou z něj — spočítané ze surovin
|
||||||
|
* jsou přesnější než cokoli, co se kdy zapsalo ke dni.
|
||||||
|
*/
|
||||||
|
const createMealGroupValues = (
|
||||||
|
groupList: MealEntry[], sameNameList: MealEntry[], recipe?: MealTemplate,
|
||||||
|
): MealGroupValues => {
|
||||||
|
const caloriesPer100g = recipe?.caloriesPer100g
|
||||||
|
?? findLatestValue(groupList, entry => entry.caloriesPer100g)
|
||||||
|
?? findLatestValue(sameNameList, entry => entry.caloriesPer100g);
|
||||||
|
return omitUndefined({
|
||||||
|
pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g),
|
||||||
|
weight: recipe?.weight ?? findLatestValue(groupList, getManualWeight),
|
||||||
|
caloriesPer100g,
|
||||||
|
// Ruční kalorie porce dávají smysl jen u jídla, které energii na 100 g nemá
|
||||||
|
calories: caloriesPer100g == null
|
||||||
|
? recipe?.calories ?? findLatestValue(groupList, entry => entry.calories)
|
||||||
|
: undefined,
|
||||||
|
nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g)
|
||||||
|
?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
||||||
|
const isIncomplete = (entry: MealEntry): boolean =>
|
||||||
|
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
||||||
|
|
||||||
|
/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */
|
||||||
|
const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
||||||
|
const dayMap = new Map<string, MealGroupDay>();
|
||||||
|
for (const entry of groupList) {
|
||||||
|
const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false };
|
||||||
|
day.incomplete ||= isIncomplete(entry);
|
||||||
|
dayMap.set(entry.date, day);
|
||||||
|
}
|
||||||
|
return [...dayMap.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
||||||
|
* kterým chybí kalorie, gramáž nebo živiny — seznam jídel s hromadným
|
||||||
|
* doplněním údajů a přejmenováním.
|
||||||
|
*
|
||||||
|
* Ke skupině patří i její recept a fotky. Recept jídla, které se zatím nejedlo,
|
||||||
|
* tvoří skupinu sám (s nulovým počtem), aby šel v seznamu najít.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
*/
|
||||||
|
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
||||||
|
const entryList = await findAllMeals(login);
|
||||||
|
const recipeMap = new Map<string, MealTemplate>();
|
||||||
|
for (const recipe of await getMealTemplates(login)) {
|
||||||
|
if (!recipeMap.has(getRecipeKey(recipe))) recipeMap.set(getRecipeKey(recipe), recipe);
|
||||||
|
}
|
||||||
|
const photoIndex = await getPhotoIndex(login);
|
||||||
|
const groupMap = new Map<string, MealEntry[]>();
|
||||||
|
const nameMap = new Map<string, MealEntry[]>();
|
||||||
|
for (const entry of entryList) {
|
||||||
|
const key = getNameSourceKey(entry.name, entry.source);
|
||||||
|
groupMap.set(key, [...(groupMap.get(key) ?? []), entry]);
|
||||||
|
const nameKey = normalizeName(entry.name);
|
||||||
|
nameMap.set(nameKey, [...(nameMap.get(nameKey) ?? []), entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createGroup = (key: string, groupList: MealEntry[], name: string, source?: string): MealGroup => {
|
||||||
|
const recipe = recipeMap.get(key);
|
||||||
|
const photoList = photoIndex[key] ?? [];
|
||||||
|
return omitUndefined({
|
||||||
|
name,
|
||||||
|
source,
|
||||||
|
count: groupList.length,
|
||||||
|
lastDate: groupList[0]?.date,
|
||||||
|
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
||||||
|
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
||||||
|
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
||||||
|
days: createMealGroupDays(groupList),
|
||||||
|
values: createMealGroupValues(groupList, nameMap.get(normalizeName(name)) ?? [], recipe),
|
||||||
|
recipe,
|
||||||
|
photoCount: photoList.length,
|
||||||
|
thumbnail: photoList[0]?.thumbnail,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resultList = [...groupMap.entries()]
|
||||||
|
.map(([key, groupList]) => createGroup(key, groupList, groupList[0].name, groupList[0].source));
|
||||||
|
for (const [key, recipe] of recipeMap) {
|
||||||
|
if (!groupMap.has(key)) resultList.push(createGroup(key, [], recipe.name, recipe.source));
|
||||||
|
}
|
||||||
|
return resultList.sort((a, b) =>
|
||||||
|
b.count - a.count || (b.lastDate ?? '').localeCompare(a.lastDate ?? '') || a.name.localeCompare(b.name, 'cs'));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ověří hodnoty k doplnění do skupiny jídel. Nulová sazba nebo gramáž nic neříká, bere se jako nezadaná. */
|
||||||
|
const normalizeGroupValues = (input: unknown): MealGroupValues => {
|
||||||
|
const value = (input ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') || undefined,
|
||||||
|
weight: normalizeNumber(value.weight, 'weight') || undefined,
|
||||||
|
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') || undefined,
|
||||||
|
calories: normalizeNumber(value.calories, 'calories'),
|
||||||
|
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplní do jídla hodnoty, které mu chybí; co už má, nechá být.
|
||||||
|
*
|
||||||
|
* Gramáž se nedoplní tam, kde jde dopočítat z vlastní ceny jídla — ta je
|
||||||
|
* přesnější než jedna gramáž pro všechny. Energie se doplní jen jídlu bez
|
||||||
|
* kalorií, jinak by dopočet z gramáže přepsal kalorie zadané ručně. Živiny
|
||||||
|
* se doplňují po jednotlivých položkách.
|
||||||
|
*/
|
||||||
|
const fillMissingValues = (entry: MealEntry, values: MealGroupValues): MealEntry => {
|
||||||
|
const pricePer100g = entry.pricePer100g ?? values.pricePer100g;
|
||||||
|
const weightDerivable = entry.price != null && !!pricePer100g;
|
||||||
|
const caloriesMissing = entry.calories == null;
|
||||||
|
const derived = deriveAmounts({
|
||||||
|
...entry,
|
||||||
|
pricePer100g,
|
||||||
|
weight: entry.weight ?? (weightDerivable ? undefined : values.weight),
|
||||||
|
caloriesPer100g: entry.caloriesPer100g ?? (caloriesMissing ? values.caloriesPer100g : undefined),
|
||||||
|
calories: caloriesMissing ? values.calories : entry.calories,
|
||||||
|
});
|
||||||
|
return omitUndefined({
|
||||||
|
...entry,
|
||||||
|
pricePer100g,
|
||||||
|
weight: derived.weight,
|
||||||
|
caloriesPer100g: derived.caloriesPer100g,
|
||||||
|
calories: caloriesMissing ? derived.calories : entry.calories,
|
||||||
|
nutrientsPer100g: fillMissingNutrients(entry.nutrientsPer100g, values.nutrientsPer100g),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Doplní chybějící živiny; vyplněné zůstanou a na svém místě, aby šlo poznat, že se nic nezměnilo. */
|
||||||
|
const fillMissingNutrients = (current?: Nutrients, values?: Nutrients): Nutrients | undefined => {
|
||||||
|
if (!values) return current;
|
||||||
|
const missing = Object.entries(values).filter(([key]) => current?.[key as keyof Nutrients] == null);
|
||||||
|
return missing.length ? { ...current, ...Object.fromEntries(missing) } : current;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem — typicky
|
||||||
|
* ke starším záznamům, u kterých se kalorie nebo živiny nezadávaly.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param name název jídla skupiny
|
||||||
|
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||||
|
* @param input hodnoty k doplnění
|
||||||
|
*/
|
||||||
|
export const fillMealGroup = async (
|
||||||
|
login: string,
|
||||||
|
name: unknown,
|
||||||
|
source: unknown,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<FillSameNameResult> => {
|
||||||
|
const groupName = normalizeText(name, 'name');
|
||||||
|
if (!groupName) {
|
||||||
|
throw new BadRequestError("Nebyl předán název jídla");
|
||||||
|
}
|
||||||
|
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||||
|
const values = normalizeGroupValues(input);
|
||||||
|
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||||
|
|
||||||
|
const groupDates = [...new Set((await findAllMeals(login)).filter(isInGroup).map(entry => entry.date))].sort();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
let count = 0;
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (const date of groupDates) {
|
||||||
|
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||||
|
if (!isInGroup(entry)) return entry;
|
||||||
|
const filled = fillMissingValues(entry, values);
|
||||||
|
if (JSON.stringify(filled) === JSON.stringify(entry)) return entry;
|
||||||
|
count++;
|
||||||
|
if (!dates.includes(date)) dates.push(date);
|
||||||
|
return { ...filled, updatedAt };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (values.caloriesPer100g) {
|
||||||
|
await rememberCalories(login, groupName, values.caloriesPer100g);
|
||||||
|
}
|
||||||
|
return { count, dates };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní
|
||||||
|
* zdroj — oprava překlepu nebo sjednocení názvu napříč všemi dny, kdy se jídlo
|
||||||
|
* jedlo. Na rozdíl od doplnění údajů přepisuje, co jídla mají.
|
||||||
|
*
|
||||||
|
* Když nový název a zdroj patří jiné skupině, jídla se do ní tím sloučí.
|
||||||
|
* Recept a fotky jdou s jídlem; dva recepty ale sloučit nejde.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param name název jídla skupiny
|
||||||
|
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
||||||
|
* @param newName nový název
|
||||||
|
* @param newSource nový zdroj; prázdný zdroj odebere
|
||||||
|
*/
|
||||||
|
export const renameMealGroup = async (
|
||||||
|
login: string,
|
||||||
|
name: unknown,
|
||||||
|
source: unknown,
|
||||||
|
newName: unknown,
|
||||||
|
newSource: unknown,
|
||||||
|
): Promise<FillSameNameResult> => {
|
||||||
|
const groupName = normalizeText(name, 'name');
|
||||||
|
if (!groupName) {
|
||||||
|
throw new BadRequestError("Nebyl předán název jídla");
|
||||||
|
}
|
||||||
|
const targetName = normalizeText(newName, 'newName');
|
||||||
|
if (!targetName) {
|
||||||
|
throw new BadRequestError("Nebyl předán nový název jídla");
|
||||||
|
}
|
||||||
|
const targetSource = normalizeText(newSource, 'newSource');
|
||||||
|
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
||||||
|
const targetKey = getNameSourceKey(targetName, targetSource);
|
||||||
|
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
||||||
|
|
||||||
|
const recipeKeyList = (await getMealTemplates(login)).map(getRecipeKey);
|
||||||
|
if (key !== targetKey && recipeKeyList.includes(key) && recipeKeyList.includes(targetKey)) {
|
||||||
|
throw new BadRequestError("Obě jídla mají recept, sloučit je nejde — jeden z receptů nejdřív smažte");
|
||||||
|
}
|
||||||
|
await renameMealTemplate(login, key, targetName, targetSource);
|
||||||
|
await moveMealPhotos(login, key, targetKey);
|
||||||
|
|
||||||
|
const groupList = (await findAllMeals(login)).filter(isInGroup);
|
||||||
|
const groupDates = [...new Set(groupList.map(entry => entry.date))].sort();
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
let count = 0;
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (const date of groupDates) {
|
||||||
|
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
||||||
|
if (!isInGroup(entry) || (entry.name === targetName && entry.source === targetSource)) return entry;
|
||||||
|
count++;
|
||||||
|
if (!dates.includes(date)) dates.push(date);
|
||||||
|
const renamed: MealEntry = { ...entry, name: targetName, source: targetSource, updatedAt };
|
||||||
|
if (!targetSource) delete renamed.source;
|
||||||
|
return renamed;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Knihovna kalorií je podle názvu, pod novým se má jídlo najít také
|
||||||
|
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g);
|
||||||
|
if (count && caloriesPer100g) {
|
||||||
|
await rememberCalories(login, targetName, caloriesPer100g);
|
||||||
|
}
|
||||||
|
return { count, dates };
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { MealPhoto } from "../../types/gen/types.gen";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { BadRequestError, NotFoundError } from "./utils";
|
||||||
|
import { getNameSourceKey, normalizeText } from "./meals";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
/** Kolik fotek může mít jedno jídlo — pojistka, aby úložiště nerostlo bez omezení. */
|
||||||
|
const MAX_PHOTOS_PER_MEAL = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Největší přípustná velikost fotky a náhledu jako data URL. Klient fotku před
|
||||||
|
* odesláním zmenší (zhruba 1600 px), takže běžně má pár set kB; limit je rezerva.
|
||||||
|
*/
|
||||||
|
const MAX_IMAGE_LENGTH = 3 * 1024 * 1024;
|
||||||
|
const MAX_THUMBNAIL_LENGTH = 100 * 1024;
|
||||||
|
|
||||||
|
const DATA_URL_PATTERN = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+=*$/;
|
||||||
|
|
||||||
|
/** Fotka v rejstříku — bez plného obrázku, ten leží pod vlastním klíčem. */
|
||||||
|
type PhotoInfo = {
|
||||||
|
id: string;
|
||||||
|
thumbnail: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rejstřík fotek uživatele: klíč jídla (název a zdroj) → fotky od nejstarší. */
|
||||||
|
type PhotoIndex = Record<string, PhotoInfo[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejstřík s náhledy je jeden pro uživatele, aby ho seznam jídel načetl jedním
|
||||||
|
* čtením. Plné obrázky jsou každý pod svým klíčem — do seznamu se nenačítají.
|
||||||
|
*/
|
||||||
|
const getIndexKey = (login: string): string => `mealPhotos:${login}`;
|
||||||
|
const getPhotoKey = (login: string, id: string): string => `mealPhoto:${login}:${id}`;
|
||||||
|
|
||||||
|
/** Ověří obrázek v podobě data URL. */
|
||||||
|
const normalizeImage = (value: unknown, name: string, maxLength: number): string => {
|
||||||
|
if (typeof value !== 'string' || !DATA_URL_PATTERN.test(value)) {
|
||||||
|
throw new BadRequestError(`Pole '${name}' musí být obrázek JPEG, PNG nebo WebP jako data URL`);
|
||||||
|
}
|
||||||
|
if (value.length > maxLength) {
|
||||||
|
throw new BadRequestError(`Obrázek v poli '${name}' je příliš velký`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ověří název jídla a vrátí klíč, pod kterým má fotky. */
|
||||||
|
const getMealKey = (name: unknown, source: unknown): string => {
|
||||||
|
const mealName = normalizeText(name, 'name');
|
||||||
|
if (!mealName) {
|
||||||
|
throw new BadRequestError("Nebyl předán název jídla");
|
||||||
|
}
|
||||||
|
return getNameSourceKey(mealName, normalizeText(source, 'source'));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí rejstřík fotek uživatele — náhledy podle klíče jídla.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
*/
|
||||||
|
export const getPhotoIndex = async (login: string): Promise<PhotoIndex> =>
|
||||||
|
await storage.getData<PhotoIndex>(getIndexKey(login)) ?? {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí fotky jednoho jídla i s plnými obrázky, od nejstarší.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param name název jídla
|
||||||
|
* @param source zdroj jídla; prázdný znamená jídlo bez zdroje
|
||||||
|
*/
|
||||||
|
export const getMealPhotos = async (login: string, name: unknown, source: unknown): Promise<MealPhoto[]> => {
|
||||||
|
const infoList = (await getPhotoIndex(login))[getMealKey(name, source)] ?? [];
|
||||||
|
const resultList: MealPhoto[] = [];
|
||||||
|
for (const info of infoList) {
|
||||||
|
const image = await storage.getData<string>(getPhotoKey(login, info.id));
|
||||||
|
if (image) resultList.push({ ...info, image });
|
||||||
|
}
|
||||||
|
return resultList;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přidá fotku k jídlu. Fotka patří jídlu jako celku (názvu a zdroji), ne
|
||||||
|
* jednomu dni, ve kterém se jedlo.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param name název jídla
|
||||||
|
* @param source zdroj jídla
|
||||||
|
* @param image zmenšená fotka jako data URL
|
||||||
|
* @param thumbnail náhled do seznamu jídel jako data URL
|
||||||
|
*/
|
||||||
|
export const addMealPhoto = async (
|
||||||
|
login: string,
|
||||||
|
name: unknown,
|
||||||
|
source: unknown,
|
||||||
|
image: unknown,
|
||||||
|
thumbnail: unknown,
|
||||||
|
): Promise<MealPhoto> => {
|
||||||
|
const key = getMealKey(name, source);
|
||||||
|
const result: MealPhoto = {
|
||||||
|
id: randomUUID(),
|
||||||
|
image: normalizeImage(image, 'image', MAX_IMAGE_LENGTH),
|
||||||
|
thumbnail: normalizeImage(thumbnail, 'thumbnail', MAX_THUMBNAIL_LENGTH),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (((await getPhotoIndex(login))[key]?.length ?? 0) >= MAX_PHOTOS_PER_MEAL) {
|
||||||
|
throw new BadRequestError(`Jídlo může mít nejvýše ${MAX_PHOTOS_PER_MEAL} fotek`);
|
||||||
|
}
|
||||||
|
// Obrázek se ukládá první, aby rejstřík nikdy neukazoval na fotku, která neexistuje
|
||||||
|
await storage.setData(getPhotoKey(login, result.id), result.image);
|
||||||
|
const info: PhotoInfo = { id: result.id, thumbnail: result.thumbnail, createdAt: result.createdAt };
|
||||||
|
await storage.updateData<PhotoIndex>(getIndexKey(login), current =>
|
||||||
|
({ ...current, [key]: [...(current?.[key] ?? []), info] }));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smaže fotku jídla.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param id identifikátor fotky
|
||||||
|
*/
|
||||||
|
export const deleteMealPhoto = async (login: string, id: unknown): Promise<void> => {
|
||||||
|
if (typeof id !== 'string' || !id.length) {
|
||||||
|
throw new BadRequestError("Nebyl předán identifikátor fotky");
|
||||||
|
}
|
||||||
|
let found = false;
|
||||||
|
await storage.updateData<PhotoIndex>(getIndexKey(login), current => Object.fromEntries(
|
||||||
|
Object.entries(current ?? {})
|
||||||
|
.map(([key, infoList]): [string, PhotoInfo[]] => {
|
||||||
|
const restList = infoList.filter(info => info.id !== id);
|
||||||
|
found ||= restList.length !== infoList.length;
|
||||||
|
return [key, restList];
|
||||||
|
})
|
||||||
|
.filter(([, infoList]) => infoList.length > 0)));
|
||||||
|
if (!found) {
|
||||||
|
throw new NotFoundError("Fotka nebyla nalezena");
|
||||||
|
}
|
||||||
|
await storage.deleteData(getPhotoKey(login, id));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přesune fotky jídla pod jiný klíč — při přejmenování. Fotky jídla, se kterým
|
||||||
|
* se tím sloučí, zůstanou a přesouvané se přidají za ně.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
||||||
|
* @param toKey nový klíč jídla
|
||||||
|
*/
|
||||||
|
export const moveMealPhotos = async (login: string, fromKey: string, toKey: string): Promise<void> => {
|
||||||
|
if (fromKey === toKey) return;
|
||||||
|
const index = await getPhotoIndex(login);
|
||||||
|
if (!index[fromKey]?.length) return;
|
||||||
|
await storage.updateData<PhotoIndex>(getIndexKey(login), current => {
|
||||||
|
const { [fromKey]: movedList = [], ...rest } = current ?? {};
|
||||||
|
return { ...rest, [toKey]: [...(rest[toKey] ?? []), ...movedList] };
|
||||||
|
});
|
||||||
|
};
|
||||||
+39
-11
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
import { MealTemplate, MealType, RecipeIngredient } from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { addMeal, isMealType } from "./meals";
|
import { addMeal, getNameSourceKey, isMealType } from "./meals";
|
||||||
import { rememberCalories } from "./calories";
|
import { rememberCalories } from "./calories";
|
||||||
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
import { BadRequestError, NotFoundError, isValidIsoDate, normalizeMultiplier } from "./utils";
|
||||||
|
|
||||||
@@ -19,13 +19,14 @@ const getKey = (login: string): string => `mealTemplates:${login}`;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly
|
* Záznam tak, jak může ležet v úložišti. Dřív se pod stejným klíčem ukládaly
|
||||||
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu) a recepty nesly
|
* i šablony hotových jídel (druh `HOTOVE`, nebo bez druhu), recepty nesly
|
||||||
* `kind: 'RECEPT'`.
|
* `kind: 'RECEPT'` a měly i chod.
|
||||||
*/
|
*/
|
||||||
type StoredMealTemplate = Omit<MealTemplate, 'ingredients'> & {
|
type StoredMealTemplate = Omit<MealTemplate, 'ingredients'> & {
|
||||||
ingredients?: RecipeIngredient[];
|
ingredients?: RecipeIngredient[];
|
||||||
kind?: string;
|
kind?: string;
|
||||||
pricePer100g?: number;
|
pricePer100g?: number;
|
||||||
|
mealType?: MealType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
||||||
@@ -36,9 +37,9 @@ type StoredRecipe = StoredMealTemplate & { ingredients: RecipeIngredient[] };
|
|||||||
*/
|
*/
|
||||||
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
|
const isRecipe = (stored: StoredMealTemplate): stored is StoredRecipe => !!stored.ingredients?.length;
|
||||||
|
|
||||||
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel. */
|
/** Odstraní z receptu pole, která patřila jen šablonám hotových jídel, a zaniklý chod. */
|
||||||
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
const toMealTemplate = (stored: StoredRecipe): MealTemplate => {
|
||||||
const { kind, pricePer100g, ...result } = stored;
|
const { kind, pricePer100g, mealType, ...result } = stored;
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,9 +177,15 @@ const normalizeIngredient = (raw: unknown, index: number): RecipeIngredient => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Klíč jídla, ke kterému recept patří — stejně jako u skupin v seznamu jídel. */
|
||||||
|
export const getRecipeKey = (recipe: MealTemplate): string => getNameSourceKey(recipe.name, recipe.source);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Založí nový recept, nebo přepíše existující podle předaného id.
|
* Založí nový recept, nebo přepíše existující podle předaného id.
|
||||||
*
|
*
|
||||||
|
* Recept rozšiřuje jídlo v seznamu jídel (název a zdroj), proto ho jedno jídlo
|
||||||
|
* může mít nejvýš jeden.
|
||||||
|
*
|
||||||
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
|
* Hodnoty jedné porce (gramáž, energie, cena) se dopočtou ze surovin a uloží
|
||||||
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
|
* do plochých polí, takže použití receptu založí běžné jídlo jako každé jiné.
|
||||||
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
|
* Zápis zároveň z úložiště odklidí zaniklé šablony hotových jídel.
|
||||||
@@ -192,9 +199,6 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
throw new BadRequestError("Nebyl předán název receptu");
|
throw new BadRequestError("Nebyl předán název receptu");
|
||||||
}
|
}
|
||||||
if (value.mealType != null && !isMealType(value.mealType)) {
|
|
||||||
throw new BadRequestError("Nebyl předán platný typ jídla");
|
|
||||||
}
|
|
||||||
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
if (!Array.isArray(value.ingredients) || !value.ingredients.length) {
|
||||||
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
throw new BadRequestError("Recept musí mít alespoň jednu surovinu");
|
||||||
}
|
}
|
||||||
@@ -209,7 +213,6 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
const template: MealTemplate = definedOnly({
|
const template: MealTemplate = definedOnly({
|
||||||
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
id: typeof value.id === 'string' && value.id.length ? value.id : randomUUID(),
|
||||||
name,
|
name,
|
||||||
mealType: value.mealType != null ? value.mealType as MealType : undefined,
|
|
||||||
source: normalizeText(value.source, 'source'),
|
source: normalizeText(value.source, 'source'),
|
||||||
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
note: normalizeText(value.note, 'note', MAX_NOTE_LENGTH),
|
||||||
ingredients,
|
ingredients,
|
||||||
@@ -221,6 +224,11 @@ export const saveMealTemplate = async (login: string, input: unknown): Promise<M
|
|||||||
calories: summary.calories,
|
calories: summary.calories,
|
||||||
price: summary.price,
|
price: summary.price,
|
||||||
});
|
});
|
||||||
|
const otherRecipe = (await getMealTemplates(login))
|
||||||
|
.find(item => item.id !== template.id && getRecipeKey(item) === getRecipeKey(template));
|
||||||
|
if (otherRecipe) {
|
||||||
|
throw new BadRequestError("Toto jídlo už recept má");
|
||||||
|
}
|
||||||
for (const ingredient of ingredients) {
|
for (const ingredient of ingredients) {
|
||||||
if (ingredient.caloriesPer100g) {
|
if (ingredient.caloriesPer100g) {
|
||||||
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
await rememberCalories(login, ingredient.name, ingredient.caloriesPer100g);
|
||||||
@@ -266,7 +274,7 @@ export const deleteMealTemplate = async (login: string, id: unknown): Promise<Me
|
|||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
* @param id identifikátor receptu
|
* @param id identifikátor receptu
|
||||||
* @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 receptu, jinak oběd
|
* @param mealType chod; bez něj se založí jako oběd
|
||||||
* @param multiplier násobek porce, třeba 2 za dvě porce
|
* @param multiplier násobek porce, třeba 2 za dvě porce
|
||||||
*/
|
*/
|
||||||
export const applyMealTemplate = async (
|
export const applyMealTemplate = async (
|
||||||
@@ -292,7 +300,7 @@ export const applyMealTemplate = async (
|
|||||||
|
|
||||||
await addMeal(login, {
|
await addMeal(login, {
|
||||||
date,
|
date,
|
||||||
mealType: mealType ?? template.mealType ?? MealType.OBED,
|
mealType: mealType ?? MealType.OBED,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
source: template.source,
|
source: template.source,
|
||||||
weight: template.weight,
|
weight: template.weight,
|
||||||
@@ -303,3 +311,23 @@ export const applyMealTemplate = async (
|
|||||||
}, template.id);
|
}, template.id);
|
||||||
return date;
|
return date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přejmenuje recept jídla spolu s jídlem — při hromadném přejmenování v seznamu
|
||||||
|
* jídel, aby recept zůstal u svého jídla.
|
||||||
|
*
|
||||||
|
* @param login přihlašovací jméno uživatele
|
||||||
|
* @param fromKey dosavadní klíč jídla (`getNameSourceKey`)
|
||||||
|
* @param name nový název
|
||||||
|
* @param source nový zdroj; prázdný zdroj odebere
|
||||||
|
*/
|
||||||
|
export const renameMealTemplate = async (
|
||||||
|
login: string, fromKey: string, name: string, source?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const recipeList = await getMealTemplates(login);
|
||||||
|
const recipe = recipeList.find(item => getRecipeKey(item) === fromKey);
|
||||||
|
if (!recipe) return;
|
||||||
|
const renamed: MealTemplate = definedOnly({ ...recipe, name, source });
|
||||||
|
await storage.updateData<StoredMealTemplate[]>(getKey(login), current =>
|
||||||
|
loadRecipes(current).map(item => item.id === recipe.id ? renamed : item));
|
||||||
|
};
|
||||||
+6
-232
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupDay, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
|
||||||
} from "../../types/gen/types.gen";
|
} from "../../types/gen/types.gen";
|
||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +34,7 @@ export function isMealType(value: unknown): value is MealType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
|
/** Sestaví klíč úložiště pro jeden den jednoho uživatele. */
|
||||||
function getDayKey(login: string, date: string): string {
|
export function getDayKey(login: string, date: string): string {
|
||||||
return `meals:${login}:${date}`;
|
return `meals:${login}:${date}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export async function getRange(login: string, from: string, to: string): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */
|
/** Ořízne a znormalizuje volitelný textový vstup. Prázdný text vrací jako undefined. */
|
||||||
function normalizeText(value: unknown, name: string): string | undefined {
|
export function normalizeText(value: unknown, name: string): string | undefined {
|
||||||
if (value == null) return undefined;
|
if (value == null) return undefined;
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestError(`Pole '${name}' musí být text`);
|
throw new BadRequestError(`Pole '${name}' musí být text`);
|
||||||
@@ -103,7 +103,7 @@ function normalizeText(value: unknown, name: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */
|
/** Ověří volitelnou nezápornou celočíselnou hodnotu (cena v haléřích, kalorie). */
|
||||||
function normalizeNumber(value: unknown, name: string): number | undefined {
|
export function normalizeNumber(value: unknown, name: string): number | undefined {
|
||||||
if (value == null) return undefined;
|
if (value == null) return undefined;
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
throw new BadRequestError(`Pole '${name}' musí být číslo`);
|
||||||
@@ -444,7 +444,7 @@ export async function findMealsForDays(login: string, dates: string[]): Promise<
|
|||||||
* Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se
|
* Vrátí gramáž záznamu, pokud byla zadaná ručně. Gramáž dopočtená z ceny se
|
||||||
* neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g.
|
* neukládá zvlášť, pozná se tak, že odpovídá ceně a sazbě za 100 g.
|
||||||
*/
|
*/
|
||||||
function getManualWeight(source: MealEntry): number | undefined {
|
export function getManualWeight(source: MealEntry): number | undefined {
|
||||||
const weightFromPrice = source.price != null && source.pricePer100g
|
const weightFromPrice = source.price != null && source.pricePer100g
|
||||||
? Math.round(source.price / source.pricePer100g * 100)
|
? Math.round(source.price / source.pricePer100g * 100)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -476,7 +476,7 @@ function createMealSuggestion(source: MealEntry): MealSuggestion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Klíč jídla podle názvu a zdroje — jídlo stejného jména jinde bývá jinak velké i jinak účtované. */
|
/** Klíč jídla podle názvu a zdroje — jídlo stejného jména jinde bývá jinak velké i jinak účtované. */
|
||||||
const getNameSourceKey = (name: string, source?: string): string =>
|
export const getNameSourceKey = (name: string, source?: string): string =>
|
||||||
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
|
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -645,229 +645,3 @@ export async function fillSameNameMeals(
|
|||||||
}
|
}
|
||||||
return { count: targetList.length, dates };
|
return { count: targetList.length, dates };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vrátí první vyplněnou hodnotu z jídel seřazených od nejnovějších. */
|
|
||||||
const findLatestValue = <T>(entryList: MealEntry[], pick: (entry: MealEntry) => T | undefined): T | undefined =>
|
|
||||||
entryList.map(pick).find(value => value != null);
|
|
||||||
|
|
||||||
/** Vynechá nevyplněné hodnoty, aby se do úložiště nezapisovaly. */
|
|
||||||
const omitUndefined = <T extends object>(value: T): T =>
|
|
||||||
Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sestaví hodnoty k předvyplnění formuláře skupiny — každou z nejnovějšího
|
|
||||||
* jídla, které ji má. Energie a živiny se bez vlastních hodnot vezmou z jídla
|
|
||||||
* stejného názvu odjinud (složení je stejné), sazba za 100 g a gramáž ne —
|
|
||||||
* jinde se účtuje i nandává jinak.
|
|
||||||
*/
|
|
||||||
const createMealGroupValues = (groupList: MealEntry[], sameNameList: MealEntry[]): MealGroupValues => {
|
|
||||||
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g)
|
|
||||||
?? findLatestValue(sameNameList, entry => entry.caloriesPer100g);
|
|
||||||
return omitUndefined({
|
|
||||||
pricePer100g: findLatestValue(groupList, entry => entry.pricePer100g),
|
|
||||||
weight: findLatestValue(groupList, getManualWeight),
|
|
||||||
caloriesPer100g,
|
|
||||||
// Ruční kalorie porce dávají smysl jen u jídla, které energii na 100 g nemá
|
|
||||||
calories: caloriesPer100g == null
|
|
||||||
? findLatestValue(groupList, entry => entry.calories)
|
|
||||||
: undefined,
|
|
||||||
nutrientsPer100g: findLatestValue(groupList, entry => entry.nutrientsPer100g)
|
|
||||||
?? findLatestValue(sameNameList, entry => entry.nutrientsPer100g),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Vrátí true, pokud jídlu chybí kalorie, gramáž nebo živiny — tedy to, co doplnění řeší. */
|
|
||||||
const isIncomplete = (entry: MealEntry): boolean =>
|
|
||||||
entry.calories == null || entry.weight == null || !entry.nutrientsPer100g;
|
|
||||||
|
|
||||||
/** Dny, kdy se jídla skupiny jedla, ve stejném pořadí jako skupina (od nejnovějšího). */
|
|
||||||
const createMealGroupDays = (groupList: MealEntry[]): MealGroupDay[] => {
|
|
||||||
const dayMap = new Map<string, MealGroupDay>();
|
|
||||||
for (const entry of groupList) {
|
|
||||||
const day = dayMap.get(entry.date) ?? { date: entry.date, incomplete: false };
|
|
||||||
day.incomplete ||= isIncomplete(entry);
|
|
||||||
dayMap.set(entry.date, day);
|
|
||||||
}
|
|
||||||
return [...dayMap.values()];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
|
||||||
* kterým chybí kalorie, gramáž nebo živiny — seznam jídel s hromadným
|
|
||||||
* doplněním údajů a přejmenováním.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno uživatele
|
|
||||||
*/
|
|
||||||
export const getMealGroups = async (login: string): Promise<MealGroup[]> => {
|
|
||||||
const entryList = await findAllMeals(login);
|
|
||||||
const groupMap = new Map<string, MealEntry[]>();
|
|
||||||
const nameMap = new Map<string, MealEntry[]>();
|
|
||||||
for (const entry of entryList) {
|
|
||||||
const key = getNameSourceKey(entry.name, entry.source);
|
|
||||||
groupMap.set(key, [...(groupMap.get(key) ?? []), entry]);
|
|
||||||
const nameKey = normalizeName(entry.name);
|
|
||||||
nameMap.set(nameKey, [...(nameMap.get(nameKey) ?? []), entry]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultList = [...groupMap.values()].map((groupList): MealGroup => {
|
|
||||||
const [latest] = groupList;
|
|
||||||
return {
|
|
||||||
name: latest.name,
|
|
||||||
...(latest.source ? { source: latest.source } : {}),
|
|
||||||
count: groupList.length,
|
|
||||||
lastDate: latest.date,
|
|
||||||
missingCaloriesCount: groupList.filter(entry => entry.calories == null).length,
|
|
||||||
missingWeightCount: groupList.filter(entry => entry.weight == null).length,
|
|
||||||
missingNutrientsCount: groupList.filter(entry => !entry.nutrientsPer100g).length,
|
|
||||||
days: createMealGroupDays(groupList),
|
|
||||||
values: createMealGroupValues(groupList, nameMap.get(normalizeName(latest.name)) ?? []),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return resultList.sort((a, b) => b.count - a.count || b.lastDate.localeCompare(a.lastDate));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Ověří hodnoty k doplnění do skupiny jídel. Nulová sazba nebo gramáž nic neříká, bere se jako nezadaná. */
|
|
||||||
const normalizeGroupValues = (input: unknown): MealGroupValues => {
|
|
||||||
const value = (input ?? {}) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
pricePer100g: normalizeNumber(value.pricePer100g, 'pricePer100g') || undefined,
|
|
||||||
weight: normalizeNumber(value.weight, 'weight') || undefined,
|
|
||||||
caloriesPer100g: normalizeNumber(value.caloriesPer100g, 'caloriesPer100g') || undefined,
|
|
||||||
calories: normalizeNumber(value.calories, 'calories'),
|
|
||||||
nutrientsPer100g: normalizeNutrients(value.nutrientsPer100g, 'nutrientsPer100g'),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Doplní do jídla hodnoty, které mu chybí; co už má, nechá být.
|
|
||||||
*
|
|
||||||
* Gramáž se nedoplní tam, kde jde dopočítat z vlastní ceny jídla — ta je
|
|
||||||
* přesnější než jedna gramáž pro všechny. Energie se doplní jen jídlu bez
|
|
||||||
* kalorií, jinak by dopočet z gramáže přepsal kalorie zadané ručně. Živiny
|
|
||||||
* se doplňují po jednotlivých položkách.
|
|
||||||
*/
|
|
||||||
const fillMissingValues = (entry: MealEntry, values: MealGroupValues): MealEntry => {
|
|
||||||
const pricePer100g = entry.pricePer100g ?? values.pricePer100g;
|
|
||||||
const weightDerivable = entry.price != null && !!pricePer100g;
|
|
||||||
const caloriesMissing = entry.calories == null;
|
|
||||||
const derived = deriveAmounts({
|
|
||||||
...entry,
|
|
||||||
pricePer100g,
|
|
||||||
weight: entry.weight ?? (weightDerivable ? undefined : values.weight),
|
|
||||||
caloriesPer100g: entry.caloriesPer100g ?? (caloriesMissing ? values.caloriesPer100g : undefined),
|
|
||||||
calories: caloriesMissing ? values.calories : entry.calories,
|
|
||||||
});
|
|
||||||
return omitUndefined({
|
|
||||||
...entry,
|
|
||||||
pricePer100g,
|
|
||||||
weight: derived.weight,
|
|
||||||
caloriesPer100g: derived.caloriesPer100g,
|
|
||||||
calories: caloriesMissing ? derived.calories : entry.calories,
|
|
||||||
nutrientsPer100g: fillMissingNutrients(entry.nutrientsPer100g, values.nutrientsPer100g),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Doplní chybějící živiny; vyplněné zůstanou a na svém místě, aby šlo poznat, že se nic nezměnilo. */
|
|
||||||
const fillMissingNutrients = (current?: Nutrients, values?: Nutrients): Nutrients | undefined => {
|
|
||||||
if (!values) return current;
|
|
||||||
const missing = Object.entries(values).filter(([key]) => current?.[key as keyof Nutrients] == null);
|
|
||||||
return missing.length ? { ...current, ...Object.fromEntries(missing) } : current;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem — typicky
|
|
||||||
* ke starším záznamům, u kterých se kalorie nebo živiny nezadávaly.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno uživatele
|
|
||||||
* @param name název jídla skupiny
|
|
||||||
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
|
||||||
* @param input hodnoty k doplnění
|
|
||||||
*/
|
|
||||||
export const fillMealGroup = async (
|
|
||||||
login: string,
|
|
||||||
name: unknown,
|
|
||||||
source: unknown,
|
|
||||||
input: unknown,
|
|
||||||
): Promise<FillSameNameResult> => {
|
|
||||||
const groupName = normalizeText(name, 'name');
|
|
||||||
if (!groupName) {
|
|
||||||
throw new BadRequestError("Nebyl předán název jídla");
|
|
||||||
}
|
|
||||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
|
||||||
const values = normalizeGroupValues(input);
|
|
||||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
|
||||||
|
|
||||||
const groupDates = [...new Set((await findAllMeals(login)).filter(isInGroup).map(entry => entry.date))].sort();
|
|
||||||
const updatedAt = new Date().toISOString();
|
|
||||||
let count = 0;
|
|
||||||
const dates: string[] = [];
|
|
||||||
for (const date of groupDates) {
|
|
||||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
|
||||||
if (!isInGroup(entry)) return entry;
|
|
||||||
const filled = fillMissingValues(entry, values);
|
|
||||||
if (JSON.stringify(filled) === JSON.stringify(entry)) return entry;
|
|
||||||
count++;
|
|
||||||
if (!dates.includes(date)) dates.push(date);
|
|
||||||
return { ...filled, updatedAt };
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (values.caloriesPer100g) {
|
|
||||||
await rememberCalories(login, groupName, values.caloriesPer100g);
|
|
||||||
}
|
|
||||||
return { count, dates };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Přejmenuje všechna jídla se stejným názvem a zdrojem a případně jim změní
|
|
||||||
* zdroj — oprava překlepu nebo sjednocení názvu napříč všemi dny, kdy se jídlo
|
|
||||||
* jedlo. Na rozdíl od doplnění údajů přepisuje, co jídla mají.
|
|
||||||
*
|
|
||||||
* Když nový název a zdroj patří jiné skupině, jídla se do ní tím sloučí.
|
|
||||||
*
|
|
||||||
* @param login přihlašovací jméno uživatele
|
|
||||||
* @param name název jídla skupiny
|
|
||||||
* @param source zdroj skupiny; prázdný znamená jídla bez zdroje
|
|
||||||
* @param newName nový název
|
|
||||||
* @param newSource nový zdroj; prázdný zdroj odebere
|
|
||||||
*/
|
|
||||||
export const renameMealGroup = async (
|
|
||||||
login: string,
|
|
||||||
name: unknown,
|
|
||||||
source: unknown,
|
|
||||||
newName: unknown,
|
|
||||||
newSource: unknown,
|
|
||||||
): Promise<FillSameNameResult> => {
|
|
||||||
const groupName = normalizeText(name, 'name');
|
|
||||||
if (!groupName) {
|
|
||||||
throw new BadRequestError("Nebyl předán název jídla");
|
|
||||||
}
|
|
||||||
const targetName = normalizeText(newName, 'newName');
|
|
||||||
if (!targetName) {
|
|
||||||
throw new BadRequestError("Nebyl předán nový název jídla");
|
|
||||||
}
|
|
||||||
const targetSource = normalizeText(newSource, 'newSource');
|
|
||||||
const key = getNameSourceKey(groupName, normalizeText(source, 'source'));
|
|
||||||
const isInGroup = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source) === key;
|
|
||||||
|
|
||||||
const groupList = (await findAllMeals(login)).filter(isInGroup);
|
|
||||||
const groupDates = [...new Set(groupList.map(entry => entry.date))].sort();
|
|
||||||
const updatedAt = new Date().toISOString();
|
|
||||||
let count = 0;
|
|
||||||
const dates: string[] = [];
|
|
||||||
for (const date of groupDates) {
|
|
||||||
await storage.updateData<MealEntry[]>(getDayKey(login, date), current => (current ?? []).map(entry => {
|
|
||||||
if (!isInGroup(entry) || (entry.name === targetName && entry.source === targetSource)) return entry;
|
|
||||||
count++;
|
|
||||||
if (!dates.includes(date)) dates.push(date);
|
|
||||||
const renamed: MealEntry = { ...entry, name: targetName, source: targetSource, updatedAt };
|
|
||||||
if (!targetSource) delete renamed.source;
|
|
||||||
return renamed;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Knihovna kalorií je podle názvu, pod novým se má jídlo najít také
|
|
||||||
const caloriesPer100g = findLatestValue(groupList, entry => entry.caloriesPer100g);
|
|
||||||
if (count && caloriesPer100g) {
|
|
||||||
await rememberCalories(login, targetName, caloriesPer100g);
|
|
||||||
}
|
|
||||||
return { count, dates };
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import express, { Request, Response, NextFunction } from "express";
|
import express, { Request, Response, NextFunction } from "express";
|
||||||
import {
|
import {
|
||||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
|
||||||
getRange, renameMealGroup, setMealMultiplier, splitMeal, updateMeal,
|
splitMeal, updateMeal,
|
||||||
} from "../meals";
|
} from "../meals";
|
||||||
|
import { fillMealGroup, getMealGroups, renameMealGroup } from "../mealGroups";
|
||||||
|
import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos";
|
||||||
import { getDayOverview } from "../dayOverview";
|
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";
|
||||||
@@ -85,6 +87,32 @@ router.post("/renameGroup", async (req: Request, res: Response, next: NextFuncti
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Vrátí fotky jídla (názvu a zdroje) i s plnými obrázky. */
|
||||||
|
router.get("/photos", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
res.status(200).json(await getMealPhotos(login, req.query.name, req.query.source));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Přidá fotku k jídlu. */
|
||||||
|
router.post("/photos/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { name, source, image, thumbnail } = req.body ?? {};
|
||||||
|
res.status(200).json(await addMealPhoto(login, name, source, image, thumbnail));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Smaže fotku jídla. */
|
||||||
|
router.post("/photos/delete", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
await deleteMealPhoto(login, req.body?.id);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
/** Přidá jídlo do zvoleného dne. */
|
/** Přidá jídlo do zvoleného dne. */
|
||||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface StorageInterface {
|
|||||||
|
|
||||||
setData<Type>(key: string, data: Type): Promise<void>;
|
setData<Type>(key: string, data: Type): Promise<void>;
|
||||||
|
|
||||||
|
/** Odstraní klíč i s hodnotou; neexistující klíč nevadí. */
|
||||||
|
deleteData(key: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec.
|
* Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec.
|
||||||
* @param contains volitelný podřetězec, který musí klíč obsahovat
|
* @param contains volitelný podřetězec, který musí klíč obsahovat
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export default class JsonStorage implements StorageInterface {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteData(key: string): Promise<void> {
|
||||||
|
db.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
listKeys(contains?: string): Promise<string[]> {
|
listKeys(contains?: string): Promise<string[]> {
|
||||||
const keys = Object.keys(db.JSON());
|
const keys = Object.keys(db.JSON());
|
||||||
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export default class MemoryStorage implements StorageInterface {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteData(key: string): Promise<void> {
|
||||||
|
store.delete(key);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
listKeys(contains?: string): Promise<string[]> {
|
listKeys(contains?: string): Promise<string[]> {
|
||||||
const keys = Array.from(store.keys());
|
const keys = Array.from(store.keys());
|
||||||
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export default class RedisStorage implements StorageInterface {
|
|||||||
await this.getClient().set(key, JSON.stringify(data));
|
await this.getClient().set(key, JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteData(key: string): Promise<void> {
|
||||||
|
await this.getClient().del(key);
|
||||||
|
}
|
||||||
|
|
||||||
async listKeys(contains?: string): Promise<string[]> {
|
async listKeys(contains?: string): Promise<string[]> {
|
||||||
const keys: string[] = [];
|
const keys: string[] = [];
|
||||||
for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) {
|
for await (const key of this.getClient().scanIterator({ MATCH: contains ? `*${contains}*` : '*', COUNT: 1000 })) {
|
||||||
|
|||||||
@@ -195,10 +195,9 @@ describe('vazba jídla na recept', () => {
|
|||||||
it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => {
|
it('jídlo z receptu si pamatuje, ze kterého vzniklo', async () => {
|
||||||
const [template] = await saveMealTemplate(LOGIN, {
|
const [template] = await saveMealTemplate(LOGIN, {
|
||||||
name: 'Štrůdl',
|
name: 'Štrůdl',
|
||||||
mealType: MealType.PULNOCNI_SVACINA,
|
|
||||||
ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }],
|
ingredients: [{ name: 'Těsto', weight: 300, calories: 900 }],
|
||||||
});
|
});
|
||||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
await applyMealTemplate(LOGIN, template.id, DATE, MealType.PULNOCNI_SVACINA);
|
||||||
|
|
||||||
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
const [meal] = (await getDay(LOGIN, DATE)).entries;
|
||||||
expect(meal.templateId).toBe(template.id);
|
expect(meal.templateId).toBe(template.id);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
|
import { addMeal } from "../meals";
|
||||||
|
import { getMealGroups, renameMealGroup } from "../mealGroups";
|
||||||
|
import { getMealTemplates, saveMealTemplate } from "../mealTemplates";
|
||||||
|
import { addMealPhoto, deleteMealPhoto, getMealPhotos } from "../mealPhotos";
|
||||||
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
|
import { MealType } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const LOGIN = 'tester';
|
||||||
|
const IMAGE = 'data:image/jpeg;base64,AAAA';
|
||||||
|
const THUMBNAIL = 'data:image/jpeg;base64,BBBB';
|
||||||
|
|
||||||
|
beforeEach(() => resetMemoryStorage());
|
||||||
|
|
||||||
|
const rizoto = {
|
||||||
|
name: 'Rizoto', source: 'doma', servings: 2, cookedWeight: 800,
|
||||||
|
ingredients: [{ name: 'Rýže', weight: 200, caloriesPer100g: 350 }, { name: 'Kuře', weight: 300, calories: 400 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('recept jídla v seznamu jídel', () => {
|
||||||
|
|
||||||
|
it('patří ke skupině se stejným názvem a zdrojem a předvyplní z něj porci', async () => {
|
||||||
|
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.VECERE, name: 'rizoto', source: 'Doma' });
|
||||||
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
|
||||||
|
const [group] = await getMealGroups(LOGIN);
|
||||||
|
|
||||||
|
// (700 + 400) kcal na 800 g, porce je polovina
|
||||||
|
expect(group).toMatchObject({
|
||||||
|
count: 1, recipe: { name: 'Rizoto' }, values: { weight: 400, caloriesPer100g: 138 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recept jídla, které se ještě nejedlo, je v seznamu sám', async () => {
|
||||||
|
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Guláš' });
|
||||||
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
|
||||||
|
const groups = await getMealGroups(LOGIN);
|
||||||
|
|
||||||
|
expect(groups.map(group => `${group.name} ${group.count}`)).toEqual(['Guláš 1', 'Rizoto 0']);
|
||||||
|
expect(groups[1].lastDate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('druhý recept téhož jídla nejde založit', async () => {
|
||||||
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
|
||||||
|
await expect(saveMealTemplate(LOGIN, { ...rizoto, name: 'RIZOTO' })).rejects.toThrow(/už recept má/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('přejmenování vezme recept i fotky s sebou', async () => {
|
||||||
|
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.VECERE, ...rizoto });
|
||||||
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
await addMealPhoto(LOGIN, 'Rizoto', 'doma', IMAGE, THUMBNAIL);
|
||||||
|
|
||||||
|
await renameMealGroup(LOGIN, 'Rizoto', 'doma', 'Houbové rizoto', undefined);
|
||||||
|
|
||||||
|
const [recipe] = await getMealTemplates(LOGIN);
|
||||||
|
expect(recipe.name).toBe('Houbové rizoto');
|
||||||
|
expect(recipe).not.toHaveProperty('source');
|
||||||
|
expect(await getMealPhotos(LOGIN, 'Houbové rizoto', undefined)).toHaveLength(1);
|
||||||
|
expect(await getMealPhotos(LOGIN, 'Rizoto', 'doma')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dvě jídla s recepty přejmenováním nesloučí', async () => {
|
||||||
|
await saveMealTemplate(LOGIN, rizoto);
|
||||||
|
await saveMealTemplate(LOGIN, { ...rizoto, name: 'Rizoto 2' });
|
||||||
|
|
||||||
|
await expect(renameMealGroup(LOGIN, 'Rizoto 2', 'doma', 'Rizoto', 'doma')).rejects.toThrow(/Obě jídla/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fotky jídla', () => {
|
||||||
|
|
||||||
|
it('patří jídlu podle názvu a zdroje a skupina nese počet a náhled', async () => {
|
||||||
|
await addMeal(LOGIN, { date: '2025-03-01', mealType: MealType.OBED, name: 'Gyros', source: 'Everest' });
|
||||||
|
await addMeal(LOGIN, { date: '2025-03-02', mealType: MealType.OBED, name: 'Gyros', source: 'Namaste' });
|
||||||
|
await addMealPhoto(LOGIN, 'gyros', 'everest', IMAGE, THUMBNAIL);
|
||||||
|
|
||||||
|
const groups = await getMealGroups(LOGIN);
|
||||||
|
|
||||||
|
expect(groups.find(group => group.source === 'Everest')).toMatchObject({ photoCount: 1, thumbnail: THUMBNAIL });
|
||||||
|
expect(groups.find(group => group.source === 'Namaste')).toMatchObject({ photoCount: 0 });
|
||||||
|
expect(await getMealPhotos(LOGIN, 'Gyros', 'Everest')).toMatchObject([{ image: IMAGE }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smazaná fotka zmizí z jídla', async () => {
|
||||||
|
const photo = await addMealPhoto(LOGIN, 'Gyros', undefined, IMAGE, THUMBNAIL);
|
||||||
|
|
||||||
|
await deleteMealPhoto(LOGIN, photo.id);
|
||||||
|
|
||||||
|
expect(await getMealPhotos(LOGIN, 'Gyros', undefined)).toEqual([]);
|
||||||
|
await expect(deleteMealPhoto(LOGIN, photo.id)).rejects.toThrow(/nebyla nalezena/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('odmítne něco jiného než obrázek', async () => {
|
||||||
|
await expect(addMealPhoto(LOGIN, 'Gyros', undefined, 'data:text/html;base64,AAAA', THUMBNAIL))
|
||||||
|
.rejects.toThrow(/obrázek/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
process.env.CALORIE_PROVIDER = 'none';
|
process.env.CALORIE_PROVIDER = 'none';
|
||||||
|
|
||||||
import { addMeal, fillMealGroup, getDay, getMealGroups, renameMealGroup } from "../meals";
|
import { addMeal, getDay } from "../meals";
|
||||||
|
import { fillMealGroup, getMealGroups, renameMealGroup } from "../mealGroups";
|
||||||
import { searchCalories } from "../calories";
|
import { searchCalories } from "../calories";
|
||||||
import { resetMemoryStorage } from "../storage/memory";
|
import { resetMemoryStorage } from "../storage/memory";
|
||||||
import { MealType } from "../../../types/gen/types.gen";
|
import { MealType } from "../../../types/gen/types.gen";
|
||||||
|
|||||||
@@ -160,16 +160,15 @@ describe('recepty', () => {
|
|||||||
await applyMealTemplate(LOGIN, template.id, DATE);
|
await applyMealTemplate(LOGIN, template.id, DATE);
|
||||||
|
|
||||||
const [entry] = (await getDay(LOGIN, DATE)).entries;
|
const [entry] = (await getDay(LOGIN, DATE)).entries;
|
||||||
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.VECERE, weight: 500, calories: 515 });
|
expect(entry).toMatchObject({ name: 'Kuřecí rizoto', mealType: MealType.OBED, weight: 500, calories: 515 });
|
||||||
expect(entry.note).toBeUndefined();
|
expect(entry.note).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zvolený chod přebije ten z receptu a bez obou se založí oběd', async () => {
|
it('recept nemá chod — založí se pod zvoleným, bez volby jako oběd', async () => {
|
||||||
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
const [template] = await saveMealTemplate(LOGIN, rizoto);
|
||||||
const [bezChodu] = await saveMealTemplate(LOGIN, { ...rizoto, name: 'Bez chodu', mealType: undefined });
|
|
||||||
|
|
||||||
await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE);
|
await applyMealTemplate(LOGIN, template.id, DATE, MealType.SNIDANE);
|
||||||
await applyMealTemplate(LOGIN, bezChodu.id, '2025-03-04');
|
await applyMealTemplate(LOGIN, template.id, '2025-03-04');
|
||||||
|
|
||||||
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE);
|
expect((await getDay(LOGIN, DATE)).entries[0].mealType).toBe(MealType.SNIDANE);
|
||||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED);
|
expect((await getDay(LOGIN, '2025-03-04')).entries[0].mealType).toBe(MealType.OBED);
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ paths:
|
|||||||
$ref: "./paths/meals/fillGroup.yml"
|
$ref: "./paths/meals/fillGroup.yml"
|
||||||
/meals/renameGroup:
|
/meals/renameGroup:
|
||||||
$ref: "./paths/meals/renameGroup.yml"
|
$ref: "./paths/meals/renameGroup.yml"
|
||||||
|
/meals/photos:
|
||||||
|
$ref: "./paths/meals/photos.yml"
|
||||||
|
/meals/photos/add:
|
||||||
|
$ref: "./paths/meals/photoAdd.yml"
|
||||||
|
/meals/photos/delete:
|
||||||
|
$ref: "./paths/meals/photoDelete.yml"
|
||||||
|
|
||||||
# Statistiky (/api/stats)
|
# Statistiky (/api/stats)
|
||||||
/stats/summary:
|
/stats/summary:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
post:
|
||||||
|
operationId: addMealPhoto
|
||||||
|
summary: Přidá fotku k jídlu (názvu a zdroji, ne ke dni).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name, image, thumbnail]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název jídla
|
||||||
|
type: string
|
||||||
|
source:
|
||||||
|
description: Zdroj jídla; bez něj jde o jídlo bez zdroje
|
||||||
|
type: string
|
||||||
|
image:
|
||||||
|
description: Zmenšená fotka jako data URL
|
||||||
|
type: string
|
||||||
|
thumbnail:
|
||||||
|
description: Náhled do seznamu jídel jako data URL
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Uložená fotka
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealPhoto"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data nebo příliš mnoho fotek
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
post:
|
||||||
|
operationId: deleteMealPhoto
|
||||||
|
summary: Smaže fotku jídla.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Identifikátor fotky
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Fotka smazána
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
|
"404":
|
||||||
|
description: Fotka neexistuje
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
get:
|
||||||
|
operationId: getMealPhotos
|
||||||
|
summary: Vrátí fotky jídla i s plnými obrázky, od nejstarší.
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Název jídla
|
||||||
|
- in: query
|
||||||
|
name: source
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Zdroj jídla; bez něj jde o jídlo bez zdroje
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Fotky jídla
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealPhoto"
|
||||||
|
"400":
|
||||||
|
description: Neplatná data
|
||||||
|
"401":
|
||||||
|
description: Neautentizovaný uživatel
|
||||||
+40
-12
@@ -218,18 +218,19 @@ MealGroupValues:
|
|||||||
MealGroup:
|
MealGroup:
|
||||||
description: |
|
description: |
|
||||||
Všechna jídla uživatele se stejným názvem a zdrojem. Položka seznamu jídel,
|
Všechna jídla uživatele se stejným názvem a zdrojem. Položka seznamu jídel,
|
||||||
ze kterého se doplňují chybějící údaje a hromadně přejmenovává.
|
ze kterého se doplňují chybějící údaje a hromadně přejmenovává. Nese i recept
|
||||||
|
a fotky jídla; recept jídla, které se zatím nejedlo, tvoří položku sám.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
- count
|
- count
|
||||||
- lastDate
|
|
||||||
- missingCaloriesCount
|
- missingCaloriesCount
|
||||||
- missingWeightCount
|
- missingWeightCount
|
||||||
- missingNutrientsCount
|
- missingNutrientsCount
|
||||||
- days
|
- days
|
||||||
- values
|
- values
|
||||||
|
- photoCount
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
description: Název jídla tak, jak byl zapsán naposledy
|
description: Název jídla tak, jak byl zapsán naposledy
|
||||||
@@ -238,10 +239,10 @@ MealGroup:
|
|||||||
description: Zdroj tak, jak byl zapsán naposledy
|
description: Zdroj tak, jak byl zapsán naposledy
|
||||||
type: string
|
type: string
|
||||||
count:
|
count:
|
||||||
description: Počet jídel ve skupině
|
description: Počet jídel ve skupině; nula u receptu, podle kterého se ještě nejedlo
|
||||||
type: integer
|
type: integer
|
||||||
lastDate:
|
lastDate:
|
||||||
description: Den, kdy uživatel jídlo jedl naposledy
|
description: Den, kdy uživatel jídlo jedl naposledy; chybí, pokud se ještě nejedlo
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
missingCaloriesCount:
|
missingCaloriesCount:
|
||||||
@@ -262,8 +263,42 @@ MealGroup:
|
|||||||
description: |
|
description: |
|
||||||
Hodnoty k předvyplnění — z nejnovějšího jídla skupiny, které je má.
|
Hodnoty k předvyplnění — z nejnovějšího jídla skupiny, které je má.
|
||||||
Energie a živiny se, když je skupina nemá, vezmou z jídla stejného
|
Energie a živiny se, když je skupina nemá, vezmou z jídla stejného
|
||||||
názvu z jiného zdroje (složení je stejné, sazba za 100 g ne).
|
názvu z jiného zdroje (složení je stejné, sazba za 100 g ne). Gramáž
|
||||||
|
porce a energie mají přednost z receptu.
|
||||||
$ref: "#/MealGroupValues"
|
$ref: "#/MealGroupValues"
|
||||||
|
recipe:
|
||||||
|
description: Recept jídla, pokud ho má
|
||||||
|
$ref: "#/MealTemplate"
|
||||||
|
photoCount:
|
||||||
|
description: Počet fotek jídla
|
||||||
|
type: integer
|
||||||
|
thumbnail:
|
||||||
|
description: Náhled první fotky jako data URL
|
||||||
|
type: string
|
||||||
|
|
||||||
|
MealPhoto:
|
||||||
|
description: |
|
||||||
|
Fotka jídla. Patří jídlu jako celku (názvu a zdroji), ne jednomu dni.
|
||||||
|
Obrázky jsou data URL, klient je před nahráním zmenší.
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- image
|
||||||
|
- thumbnail
|
||||||
|
- createdAt
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
image:
|
||||||
|
description: Fotka jako data URL (JPEG, PNG nebo WebP)
|
||||||
|
type: string
|
||||||
|
thumbnail:
|
||||||
|
description: Malý náhled do seznamu jídel jako data URL
|
||||||
|
type: string
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
MealSuggestion:
|
MealSuggestion:
|
||||||
description: |
|
description: |
|
||||||
@@ -1135,10 +1170,6 @@ MealTemplate:
|
|||||||
source:
|
source:
|
||||||
description: Zdroj jídla, typicky "doma"
|
description: Zdroj jídla, typicky "doma"
|
||||||
type: string
|
type: string
|
||||||
mealType:
|
|
||||||
description: Chod, pod kterým se jídlo zakládá. Bez něj se použije oběd.
|
|
||||||
allOf:
|
|
||||||
- $ref: "#/MealType"
|
|
||||||
calories:
|
calories:
|
||||||
description: Energetická hodnota jedné porce v kcal
|
description: Energetická hodnota jedné porce v kcal
|
||||||
type: integer
|
type: integer
|
||||||
@@ -1186,9 +1217,6 @@ MealTemplateInput:
|
|||||||
type: string
|
type: string
|
||||||
source:
|
source:
|
||||||
type: string
|
type: string
|
||||||
mealType:
|
|
||||||
allOf:
|
|
||||||
- $ref: "#/MealType"
|
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
ingredients:
|
ingredients:
|
||||||
|
|||||||
Reference in new issue
Block a user