fix: recepty přesnuty k jídlům

feat: přidána podpora fotek
This commit is contained in:
Ondřej Anděl committed 2026-10-12 10:20:12 +02:00
1 parent efed5e6e86
commit e13f7ce8e5
39 files changed
+1266 -511

No files matched your search

+22 -5
View File
@@ -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
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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' },
]; ];
+115
View File
@@ -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;
+50
View File
@@ -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;
+58 -14
View File
@@ -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}
+3 -10
View File
@@ -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>
+31 -43
View File
@@ -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
View File
@@ -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',
+31
View File
@@ -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();
}
};
+93 -2
View File
@@ -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);
}
+43 -7
View File
@@ -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()}
/> />
); );
})} })}
-88
View File
@@ -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;
-50
View File
@@ -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;
+17
View File
@@ -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;
} }
+1 -1
View File
@@ -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';
+274
View File
@@ -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 };
};
+159
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
};
+30 -2
View File
@@ -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 {
+3
View File
@@ -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
+5
View File
@@ -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);
+5
View File
@@ -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);
+4
View File
@@ -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 })) {
+1 -2
View File
@@ -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);
+100
View File
@@ -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/);
});
});
+2 -1
View File
@@ -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);
+6
View File
@@ -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:
+34
View File
@@ -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
+23
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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: