feat: hromadné editace
This commit is contained in:
1 parent
8fa7f601f2
commit
bc85a37fc0
18 files changed
+825
-18
No files matched your search
@@ -75,7 +75,8 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
|
||||
- **Pages:** `pages/` — `DayPage` (přehled dne se záložkami Příjem/Výdej),
|
||||
`TemplatesPage` (šablony tréninků a recepty), `ProfilePage` (Profil — ukazatele,
|
||||
vývoj hmotnosti, tělesné údaje, cíle a klidový výdej),
|
||||
`StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`)
|
||||
`StatsPage` (statistiky příjmu a výdeje + import; výdej v `ActivityStatsPanel`),
|
||||
`MealGroupsPage` (`/statistiky/doplneni` — hromadné doplnění údajů jídel)
|
||||
- **Components:** `components/` (Header, CalorieLookup, MealSuggestions, NutrientsFieldset)
|
||||
a `components/modals/` (MealModal, ActivityModal, ApplyWorkoutModal, ApplyMealModal —
|
||||
výběr z historie a receptů, SettingsModal, ImportModal)
|
||||
@@ -275,6 +276,15 @@ s kaloriemi `DayPage` nabídne (`FillSameNameOffer`) doplnit stejné hodnoty do
|
||||
ostatních jídel stejného názvu, která je nemají (`fillSameNameMeals`,
|
||||
`POST /api/meals/fillSameName`); jídla s kaloriemi se nepřepisují.
|
||||
|
||||
**Hromadné doplnění** (`MealGroupsPage`, odkaz ze Statistik → Příjem) seskupí
|
||||
jídla podle znormalizovaného názvu **a zdroje** (`getMealGroups`,
|
||||
`GET /api/meals/groups`) a ukáže, kolika chybí kalorie, gramáž a živiny.
|
||||
`fillMealGroup` (`POST /api/meals/fillGroup`) doplní do skupiny **jen chybějící
|
||||
hodnoty**: gramáž ne tam, kde jde dopočítat z vlastní ceny a sazby, energii jen
|
||||
jídlům bez kalorií (jinak by dopočet přepsal ručně zadané kalorie), živiny po
|
||||
jednotlivých položkách. Předvyplněné hodnoty jsou z nejnovějšího jídla skupiny;
|
||||
energie a živiny bez nich i z jídla stejného názvu odjinud, sazba a gramáž ne.
|
||||
|
||||
Pole Zdroj (`SourceInput`) nabízí zdroje z historie (`getMealSources`,
|
||||
`GET /api/meals/sources`, nejčastější první) a podniky se sazbou z nastavení;
|
||||
psát jde i nový.
|
||||
|
||||
@@ -7,7 +7,8 @@ import DayPage from "./pages/DayPage";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import TemplatesPage from "./pages/TemplatesPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import { DEN_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
|
||||
import MealGroupsPage from "./pages/MealGroupsPage";
|
||||
import { DEN_URL, DOPLNENI_JIDEL_URL, PROFIL_URL, SABLONY_URL, STATISTIKY_URL } from "./routes";
|
||||
|
||||
export default function AppRoutes() {
|
||||
const auth = useAuth();
|
||||
@@ -25,6 +26,7 @@ export default function AppRoutes() {
|
||||
<Route path={STATISTIKY_URL} element={<StatsPage />} />
|
||||
<Route path={SABLONY_URL} element={<TemplatesPage />} />
|
||||
<Route path={PROFIL_URL} element={<ProfilePage />} />
|
||||
<Route path={DOPLNENI_JIDEL_URL} element={<MealGroupsPage />} />
|
||||
<Route path="*" element={<Navigate to={DEN_URL} replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,10 @@ export default function Header() {
|
||||
|
||||
const effectiveDark = settings?.effectiveDark ?? false;
|
||||
|
||||
/** Podstránka (doplnění údajů ve statistikách) nechá zvýrazněnou svou nadřazenou položku. */
|
||||
const isActive = (url: string) =>
|
||||
location.pathname === url || (url !== DEN_URL && location.pathname.startsWith(`${url}/`));
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||
settings?.setThemePreference(next);
|
||||
@@ -39,7 +43,7 @@ export default function Header() {
|
||||
{NAV_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.url}
|
||||
className={`nav-pill ${location.pathname === item.url ? 'active' : ''}`}
|
||||
className={`nav-pill ${isActive(item.url) ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.url)}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Form } from "react-bootstrap";
|
||||
import { toast } from "react-toastify";
|
||||
import { MealGroup, fillMealGroup } from "../../../types";
|
||||
import { NUTRIENTS_IN_ORDER } from "../enums";
|
||||
import { formatPriceInput, parseIntInput, parseNutrientInput, parsePriceInput } from "../Utils";
|
||||
import CalorieLookup from "./CalorieLookup";
|
||||
import NutrientsFieldset, { toNutrientInputs, toNutrients } from "./NutrientsFieldset";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
/** Zavolá se po doplnění, aby se počty chybějících údajů načetly znovu. */
|
||||
onFilled: () => void,
|
||||
onCancel: () => void,
|
||||
};
|
||||
|
||||
const formatIntInput = (value?: number): string => value != null ? String(value) : '';
|
||||
|
||||
/** Popisek výsledku doplnění se skloňovaným počtem jídel. */
|
||||
const describeFilled = (count: number): string => {
|
||||
if (!count) return 'Všechna jídla už tyto údaje mají, nic se nezměnilo.';
|
||||
if (count === 1) return 'Údaje doplněny do 1 jídla.';
|
||||
return `Údaje doplněny do ${count} jídel.`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formulář hromadného doplnění údajů do všech jídel jedné skupiny (stejný
|
||||
* název a zdroj). Předvyplní se hodnotami, které už některé jídlo skupiny má.
|
||||
*
|
||||
* Doplňuje se jen to, co jídlům chybí — cena ani nic jiného, co už mají, se
|
||||
* nepřepíše. Proto tu není náhled kalorií porce: každé jídlo má jinou cenu,
|
||||
* a tedy i jinou gramáž dopočtenou ze sazby.
|
||||
*/
|
||||
const MealGroupForm = ({ group, onFilled, onCancel }: Readonly<Props>) => {
|
||||
const [form, setForm] = useState(() => ({
|
||||
pricePer100g: formatPriceInput(group.values.pricePer100g),
|
||||
weight: formatIntInput(group.values.weight),
|
||||
caloriesPer100g: formatIntInput(group.values.caloriesPer100g),
|
||||
calories: formatIntInput(group.values.calories),
|
||||
nutrients: toNutrientInputs(group.values.nutrientsPer100g),
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const update = (changes: Partial<typeof form>) => setForm(current => ({ ...current, ...changes }));
|
||||
|
||||
const weight = parseIntInput(form.weight);
|
||||
const nutrientsInvalid = NUTRIENTS_IN_ORDER
|
||||
.some(key => form.nutrients[key].trim().length > 0 && parseNutrientInput(form.nutrients[key]) == null);
|
||||
const canSave = !nutrientsInvalid && !saving;
|
||||
|
||||
const save = async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
const response = await fillMealGroup({
|
||||
body: {
|
||||
name: group.name,
|
||||
source: group.source,
|
||||
values: {
|
||||
pricePer100g: parsePriceInput(form.pricePer100g),
|
||||
weight,
|
||||
caloriesPer100g: parseIntInput(form.caloriesPer100g),
|
||||
calories: parseIntInput(form.calories),
|
||||
nutrientsPer100g: toNutrients(form.nutrients),
|
||||
},
|
||||
},
|
||||
});
|
||||
setSaving(false);
|
||||
if (response.data) {
|
||||
toast.success(describeFilled(response.data.count));
|
||||
onFilled();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form className="meal-group-form" onSubmit={event => { event.preventDefault(); save(); }}>
|
||||
<p className="text-body-secondary small">
|
||||
Doplní se jen to, co jídlům chybí — vyplněné hodnoty zůstanou. Gramáž se nedoplní tam, kde jde
|
||||
dopočítat z ceny a sazby za 100 g, a energie jen jídlům, která kalorie nemají.
|
||||
</p>
|
||||
|
||||
<fieldset className="meal-fieldset mb-3">
|
||||
<legend>Gramáž a kalorie</legend>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Cena za 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="decimal"
|
||||
value={form.pricePer100g}
|
||||
placeholder="např. 44,00"
|
||||
onChange={event => update({ pricePer100g: event.target.value })}
|
||||
/>
|
||||
<span className="input-group-text">Kč</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Gramáž porce</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.weight}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ weight: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">g</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Energie na 100 g</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.caloriesPer100g}
|
||||
placeholder="např. 172"
|
||||
onChange={event => update({ caloriesPer100g: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-3">
|
||||
<Form.Label>Kalorie porce</Form.Label>
|
||||
<div className="input-group">
|
||||
<Form.Control
|
||||
inputMode="numeric"
|
||||
value={form.calories}
|
||||
placeholder="volitelné"
|
||||
onChange={event => update({ calories: event.target.value.replace(/\D/g, '') })}
|
||||
/>
|
||||
<span className="input-group-text">kcal</span>
|
||||
</div>
|
||||
<Form.Text>jen u jídel, kde nejdou dopočítat</Form.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CalorieLookup
|
||||
name={group.name}
|
||||
onPick={value => update({ caloriesPer100g: String(value) })}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<NutrientsFieldset
|
||||
values={form.nutrients}
|
||||
weight={weight}
|
||||
onChange={nutrients => update({ nutrients })}
|
||||
/>
|
||||
|
||||
<div className="d-flex justify-content-end gap-2">
|
||||
<Button variant="outline-secondary" onClick={() => onCancel()}>Zrušit</Button>
|
||||
<Button type="submit" disabled={!canSave}>
|
||||
{saving ? 'Doplňuji…' : 'Doplnit do jídel'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealGroupForm;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPenToSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup } from "../../../types";
|
||||
import { formatDateString } from "../Utils";
|
||||
import MealGroupForm from "./MealGroupForm";
|
||||
|
||||
type Props = {
|
||||
group: MealGroup,
|
||||
expanded: boolean,
|
||||
onToggle: () => void,
|
||||
onFilled: () => void,
|
||||
};
|
||||
|
||||
/** Kolik jídel skupiny daný údaj nemá — "u všech", nebo "3 z 5". */
|
||||
const describeMissing = (missing: number, count: number): string =>
|
||||
missing === count ? 'u všech' : `${missing} z ${count}`;
|
||||
|
||||
/**
|
||||
* Řádek skupiny jídel (stejný název a zdroj) s přehledem chybějících údajů.
|
||||
* Po rozbalení nabídne formulář, kterým se údaje doplní všem jídlům skupiny.
|
||||
*/
|
||||
const MealGroupRow = ({ group, expanded, onToggle, onFilled }: Readonly<Props>) => (
|
||||
<div className="card-panel meal-group">
|
||||
<div className="meal-group-head">
|
||||
<div className="meal-group-info">
|
||||
<div className="meal-group-name">{group.name}</div>
|
||||
<div className="meal-meta">
|
||||
{group.source && <span className="tag">{group.source}</span>}
|
||||
<span className="tag">{group.count}× · naposledy {formatDateString(group.lastDate)}</span>
|
||||
{group.missingCaloriesCount > 0 && (
|
||||
<span className="tag tag-missing">
|
||||
bez kalorií {describeMissing(group.missingCaloriesCount, group.count)}
|
||||
</span>
|
||||
)}
|
||||
{group.missingWeightCount > 0 && (
|
||||
<span className="tag tag-missing">
|
||||
bez gramáže {describeMissing(group.missingWeightCount, group.count)}
|
||||
</span>
|
||||
)}
|
||||
{group.missingNutrientsCount > 0 && (
|
||||
<span className="tag tag-missing">
|
||||
bez živin {describeMissing(group.missingNutrientsCount, group.count)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant={expanded ? 'outline-secondary' : 'primary'} size="sm" onClick={() => onToggle()}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} /> {expanded ? 'Zavřít' : 'Doplnit'}
|
||||
</Button>
|
||||
</div>
|
||||
{expanded && <MealGroupForm group={group} onFilled={onFilled} onCancel={() => onToggle()} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MealGroupRow;
|
||||
@@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Nutrients } from "../../../types";
|
||||
import { NUTRIENTS_IN_ORDER, NUTRIENT_NAMES } from "../enums";
|
||||
import { formatNutrient, getPortionNutrients, parseNutrientInput } from "../Utils";
|
||||
import { formatNutrient, formatNutrientInput, getPortionNutrients, parseNutrientInput } from "../Utils";
|
||||
|
||||
/** Rozepsané hodnoty živin tak, jak je uživatel píše do polí. */
|
||||
export type NutrientInputs = Record<keyof Nutrients, string>;
|
||||
@@ -25,6 +25,10 @@ export const emptyNutrientInputs = (): NutrientInputs => ({
|
||||
protein: '', carbohydrates: '', sugars: '', fat: '', saturatedFat: '', fiber: '', salt: '',
|
||||
});
|
||||
|
||||
/** Převede uložené živiny na text pro editační pole. */
|
||||
export const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs =>
|
||||
Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs;
|
||||
|
||||
/** Převede rozepsané hodnoty na živiny k odeslání; bez vyplněné hodnoty vrátí undefined. */
|
||||
export const toNutrients = (values: NutrientInputs): Nutrients | undefined => {
|
||||
const result: Nutrients = {};
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import {
|
||||
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, Nutrients, UserSettings,
|
||||
DayRecord, MealEntry, MealSuggestion, MealTemplate, MealType, UserSettings,
|
||||
addMeal, getMealHistory, getMealTemplates, getSettings, updateMeal,
|
||||
} from "../../../../types";
|
||||
import { MEAL_TYPES_IN_ORDER, MEAL_TYPE_ICONS, MEAL_TYPE_NAMES, NUTRIENTS_IN_ORDER } from "../../enums";
|
||||
import {
|
||||
applyMultiplier, deriveAmounts, formatCalories, formatMultiplier, formatMultiplierInput, formatPriceInput,
|
||||
formatNutrientInput, formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
|
||||
formatWeight, normalizeSearch, parseIntInput, parseMultiplierInput, parseNutrientInput,
|
||||
parsePriceInput,
|
||||
} from "../../Utils";
|
||||
import CalorieLookup from "../CalorieLookup";
|
||||
import MealSuggestions from "../MealSuggestions";
|
||||
import NutrientsFieldset, { NutrientInputs, emptyNutrientInputs, toNutrients } from "../NutrientsFieldset";
|
||||
import NutrientsFieldset, {
|
||||
NutrientInputs, emptyNutrientInputs, toNutrientInputs, toNutrients,
|
||||
} from "../NutrientsFieldset";
|
||||
import SourceInput from "../SourceInput";
|
||||
|
||||
type Props = {
|
||||
@@ -65,10 +67,6 @@ const mergeSuggestion = (current: MealForm, values: Partial<MealForm>, onlyEmpty
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Převede uložené živiny na text pro editační pole. */
|
||||
const toNutrientInputs = (nutrients?: Nutrients): NutrientInputs =>
|
||||
Object.fromEntries(NUTRIENTS_IN_ORDER.map(key => [key, formatNutrientInput(nutrients?.[key])])) as NutrientInputs;
|
||||
|
||||
/** Znormalizuje název zdroje pro porovnání se sazbami z nastavení. */
|
||||
function normalizeSource(source: string): string {
|
||||
return source.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
.meal-groups-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 18px;
|
||||
|
||||
.form-control {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.meal-group {
|
||||
margin-bottom: 10px;
|
||||
padding: 14px 18px;
|
||||
|
||||
.meal-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meal-group-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meal-group-name {
|
||||
font-weight: 700;
|
||||
color: var(--ft-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meal-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-missing {
|
||||
background: color-mix(in srgb, var(--ft-warning) 15%, transparent);
|
||||
color: var(--ft-warning);
|
||||
}
|
||||
|
||||
.meal-group-form {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--ft-border-light);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Form, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faArrowLeft, faCircleCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { MealGroup, getMealGroups } from "../../../types";
|
||||
import { normalizeSearch } from "../Utils";
|
||||
import { STATISTIKY_URL } from "../routes";
|
||||
import MealGroupRow from "../components/MealGroupRow";
|
||||
import "./MealGroupsPage.scss";
|
||||
|
||||
/** Skupina se pozná podle názvu a zdroje, porovnávaných stejně jako na serveru. */
|
||||
const getGroupKey = (group: MealGroup): string =>
|
||||
`${normalizeSearch(group.name)}|${normalizeSearch(group.source ?? '')}`;
|
||||
|
||||
const isIncomplete = (group: MealGroup): boolean =>
|
||||
group.missingCaloriesCount + group.missingWeightCount + group.missingNutrientsCount > 0;
|
||||
|
||||
/**
|
||||
* Hromadné doplnění údajů ke starším jídlům. Jídla se stejným názvem a zdrojem
|
||||
* tvoří skupinu a chybějící kalorie, gramáž nebo živiny se jim doplní najednou,
|
||||
* bez procházení jednotlivých dnů.
|
||||
*/
|
||||
const MealGroupsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [groupList, setGroupList] = useState<MealGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState('');
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(true);
|
||||
const [expandedKey, setExpandedKey] = useState<string | undefined>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const response = await getMealGroups();
|
||||
setGroupList(response.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const visibleList = useMemo(() => {
|
||||
const search = normalizeSearch(query);
|
||||
return groupList.filter(group =>
|
||||
(!onlyIncomplete || isIncomplete(group))
|
||||
&& (!search.length || normalizeSearch(`${group.name} ${group.source ?? ''}`).includes(search)));
|
||||
}, [groupList, query, onlyIncomplete]);
|
||||
|
||||
const incompleteCount = groupList.filter(group => isIncomplete(group)).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Doplnění údajů jídel</h1>
|
||||
<p className="page-subtitle">
|
||||
Jídla se stejným názvem a zdrojem doplníte najednou — kalorie, gramáž i nutriční hodnoty
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline-secondary" onClick={() => navigate(STATISTIKY_URL)}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Statistiky
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="card-panel meal-groups-filter">
|
||||
<Form.Control
|
||||
value={query}
|
||||
placeholder="Hledat jídlo nebo zdroj…"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
<Form.Check
|
||||
type="switch"
|
||||
id="meal-groups-incomplete"
|
||||
label={`Jen s chybějícími údaji (${incompleteCount})`}
|
||||
checked={onlyIncomplete}
|
||||
onChange={event => setOnlyIncomplete(event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="empty-state">
|
||||
<Spinner animation="border" variant="success" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !visibleList.length && (
|
||||
<div className="card-panel empty-state">
|
||||
<div className="empty-icon"><FontAwesomeIcon icon={faCircleCheck} /></div>
|
||||
<div className="empty-title">
|
||||
{onlyIncomplete && !query.trim().length ? 'Všechna jídla mají údaje vyplněné' : 'Nic nenalezeno'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && visibleList.map(group => {
|
||||
const key = getGroupKey(group);
|
||||
return (
|
||||
<MealGroupRow
|
||||
key={key}
|
||||
group={group}
|
||||
expanded={expandedKey === key}
|
||||
onToggle={() => setExpandedKey(current => current === key ? undefined : key)}
|
||||
onFilled={() => { setExpandedKey(undefined); load(); }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MealGroupsPage;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Spinner } from "react-bootstrap";
|
||||
import { useNavigate } from "react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCoins, faFire, faListCheck, faCalendarDay, faFileImport, faChartColumn, faUtensils, faPersonRunning,
|
||||
faPenToSquare,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Legend,
|
||||
@@ -12,6 +14,7 @@ import { DAY_TAB_NAMES, DayTab, MEAL_TYPE_NAMES, StatsRange, STATS_RANGE_NAMES }
|
||||
import {
|
||||
formatCalories, formatMonth, formatPrice, formatPriceShort, getMonthEnd, getMonthStart,
|
||||
} from "../Utils";
|
||||
import { DOPLNENI_JIDEL_URL } from "../routes";
|
||||
import ImportModal from "../components/modals/ImportModal";
|
||||
import PeriodPicker, { EARLIEST_YEAR } from "../components/PeriodPicker";
|
||||
import ActivityStatsPanel from "./ActivityStatsPanel";
|
||||
@@ -37,6 +40,7 @@ export default function StatsPage() {
|
||||
const [stats, setStats] = useState<FoodStats | undefined>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
/** Přepočte hranice období z aktuálně zvoleného rozsahu. */
|
||||
const range = useMemo(() => {
|
||||
@@ -87,9 +91,14 @@ export default function StatsPage() {
|
||||
</p>
|
||||
</div>
|
||||
{tab === DayTab.PRIJEM && (
|
||||
<Button onClick={() => setImportOpen(true)}>
|
||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||
</Button>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
<Button variant="outline-secondary" onClick={() => navigate(DOPLNENI_JIDEL_URL)}>
|
||||
<FontAwesomeIcon icon={faPenToSquare} /> Doplnit údaje jídel
|
||||
</Button>
|
||||
<Button onClick={() => setImportOpen(true)}>
|
||||
<FontAwesomeIcon icon={faFileImport} /> Import z Luncheru
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ export const DEN_URL = '/';
|
||||
export const STATISTIKY_URL = '/statistiky';
|
||||
export const SABLONY_URL = '/sablony';
|
||||
export const PROFIL_URL = '/profil';
|
||||
export const DOPLNENI_JIDEL_URL = '/statistiky/doplneni';
|
||||
+161
-2
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DayRecord, FillSameNameResult, MealEntry, MealSuggestion, MealType, Nutrients,
|
||||
DayRecord, FillSameNameResult, MealEntry, MealGroup, MealGroupValues, MealSuggestion, MealType, Nutrients,
|
||||
} from "../../types/gen/types.gen";
|
||||
import getStorage from "./storage";
|
||||
import {
|
||||
@@ -461,6 +461,10 @@ export async function findAllMeals(login: string): Promise<MealEntry[]> {
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
/** 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 =>
|
||||
`${normalizeName(name)}|${normalizeName(source ?? '')}`;
|
||||
|
||||
/** Nese záznam něco o energii nebo živinách — jen takový má smysl přebírat. */
|
||||
const hasNutrition = (entry: MealEntry): boolean =>
|
||||
entry.calories != null || !!entry.caloriesPer100g || !!entry.nutrientsPer100g;
|
||||
@@ -478,7 +482,7 @@ const hasNutrition = (entry: MealEntry): boolean =>
|
||||
* @param historyList jídla uživatele od nejnovějších (`findAllMeals`)
|
||||
*/
|
||||
export function createNutritionFiller(historyList: MealEntry[]): (entry: MealEntry) => MealEntry {
|
||||
const sourceKey = (entry: MealEntry) => `${normalizeName(entry.name)}|${normalizeName(entry.source ?? '')}`;
|
||||
const sourceKey = (entry: MealEntry) => getNameSourceKey(entry.name, entry.source);
|
||||
const byNameMap = new Map<string, MealEntry>();
|
||||
const byNameSourceMap = new Map<string, MealEntry>();
|
||||
for (const entry of historyList.filter(hasNutrition)) {
|
||||
@@ -583,3 +587,158 @@ export async function fillSameNameMeals(
|
||||
}
|
||||
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í jídla uživatele seskupená podle názvu a zdroje, i s počty jídel,
|
||||
* kterým chybí kalorie, gramáž nebo živiny — podklad pro hromadné doplnění
|
||||
* údajů ke starším záznamů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,
|
||||
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 };
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
addMeal, deleteMeal, fillSameNameMeals, getDay, getMealHistory, getMealSources, getRange, setMealMultiplier,
|
||||
updateMeal,
|
||||
addMeal, deleteMeal, fillMealGroup, fillSameNameMeals, getDay, getMealGroups, getMealHistory, getMealSources,
|
||||
getRange, setMealMultiplier, updateMeal,
|
||||
} from "../meals";
|
||||
import { getDayOverview } from "../dayOverview";
|
||||
import { getLogin } from "../auth";
|
||||
@@ -59,6 +59,23 @@ router.post("/fillSameName", async (req: Request, res: Response, next: NextFunct
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Vrátí jídla seskupená podle názvu a zdroje — podklad pro hromadné doplnění údajů. */
|
||||
router.get("/groups", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
res.status(200).json(await getMealGroups(login));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem. */
|
||||
router.post("/fillGroup", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { name, source, values } = req.body ?? {};
|
||||
res.status(200).json(await fillMealGroup(login, name, source, values));
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
/** Přidá jídlo do zvoleného dne. */
|
||||
router.post("/add", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
process.env.CALORIE_PROVIDER = 'none';
|
||||
|
||||
import { addMeal, fillMealGroup, getDay, getMealGroups } from "../meals";
|
||||
import { resetMemoryStorage } from "../storage/memory";
|
||||
import { MealType } from "../../../types/gen/types.gen";
|
||||
|
||||
const LOGIN = 'tester';
|
||||
|
||||
beforeEach(() => resetMemoryStorage());
|
||||
|
||||
const cina = { mealType: MealType.OBED, name: 'Čína', source: 'Chefie' };
|
||||
|
||||
describe('skupiny jídel podle názvu a zdroje', () => {
|
||||
|
||||
it('seskupí jídla podle názvu i zdroje a spočítá chybějící údaje', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', name: 'cina', price: 14900 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-03', price: 14900, weight: 400, caloriesPer100g: 150 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-04', source: 'doma' });
|
||||
|
||||
const groups = await getMealGroups(LOGIN);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toMatchObject({
|
||||
name: 'Čína', source: 'Chefie', count: 3, lastDate: '2025-03-03',
|
||||
missingCaloriesCount: 2, missingWeightCount: 2, missingNutrientsCount: 3,
|
||||
values: { weight: 400, caloriesPer100g: 150 },
|
||||
});
|
||||
expect(groups[1]).toMatchObject({ source: 'doma', count: 1 });
|
||||
});
|
||||
|
||||
it('energii a živiny předvyplní i z jídla stejného názvu odjinud, sazbu ne', async () => {
|
||||
await addMeal(LOGIN, {
|
||||
...cina, date: '2025-03-01', source: 'doma', pricePer100g: 2000, caloriesPer100g: 140,
|
||||
nutrientsPer100g: { protein: 8 },
|
||||
});
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 13900 });
|
||||
|
||||
const group = (await getMealGroups(LOGIN)).find(item => item.source === 'Chefie');
|
||||
|
||||
expect(group?.values).toEqual({ caloriesPer100g: 140, nutrientsPer100g: { protein: 8 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hromadné doplnění údajů do skupiny', () => {
|
||||
|
||||
it('doplní jen chybějící hodnoty a kalorie dopočte z gramáže', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13900 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02', price: 14900, weight: 300 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-03', price: 14900, calories: 700 });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-04', source: 'doma' });
|
||||
|
||||
const result = await fillMealGroup(LOGIN, 'cina', 'chefie', {
|
||||
weight: 400, caloriesPer100g: 150, nutrientsPer100g: { protein: 8 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ count: 3, dates: ['2025-03-01', '2025-03-02', '2025-03-03'] });
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0]).toMatchObject({ weight: 400, calories: 600, price: 13900 });
|
||||
expect((await getDay(LOGIN, '2025-03-02')).entries[0]).toMatchObject({ weight: 300, calories: 450 });
|
||||
// Ručně zadané kalorie zůstanou, energie na 100 g by je přepočítala
|
||||
const third = (await getDay(LOGIN, '2025-03-03')).entries[0];
|
||||
expect(third).toMatchObject({ weight: 400, calories: 700, nutrientsPer100g: { protein: 8 } });
|
||||
expect(third.caloriesPer100g).toBeUndefined();
|
||||
expect((await getDay(LOGIN, '2025-03-04')).entries[0].calories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gramáž dopočte z vlastní ceny jídla, když přibude sazba', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', price: 13200 });
|
||||
|
||||
await fillMealGroup(LOGIN, 'Čína', 'Chefie', { pricePer100g: 4400, weight: 400, caloriesPer100g: 150 });
|
||||
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0]).toMatchObject({ weight: 300, calories: 450 });
|
||||
});
|
||||
|
||||
it('vyplněné živiny nepřepíše, chybějící doplní', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', nutrientsPer100g: { protein: 5 } });
|
||||
|
||||
await fillMealGroup(LOGIN, 'Čína', 'Chefie', { nutrientsPer100g: { protein: 8, fat: 4 } });
|
||||
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0].nutrientsPer100g).toEqual({ protein: 5, fat: 4 });
|
||||
});
|
||||
|
||||
it('jídla, kterým nic nechybí, nepočítá', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', weight: 300, caloriesPer100g: 150 });
|
||||
|
||||
const result = await fillMealGroup(LOGIN, 'Čína', 'Chefie', { weight: 400, caloriesPer100g: 200 });
|
||||
|
||||
expect(result).toEqual({ count: 0, dates: [] });
|
||||
expect((await getDay(LOGIN, '2025-03-01')).entries[0].calories).toBe(450);
|
||||
});
|
||||
|
||||
it('bez zdroje doplní jen jídla bez zdroje', async () => {
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-01', source: undefined });
|
||||
await addMeal(LOGIN, { ...cina, date: '2025-03-02' });
|
||||
|
||||
const result = await fillMealGroup(LOGIN, 'Čína', undefined, { calories: 600 });
|
||||
|
||||
expect(result.dates).toEqual(['2025-03-01']);
|
||||
expect((await getDay(LOGIN, '2025-03-02')).entries[0].calories).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,10 @@ paths:
|
||||
$ref: "./paths/meals/sources.yml"
|
||||
/meals/fillSameName:
|
||||
$ref: "./paths/meals/fillSameName.yml"
|
||||
/meals/groups:
|
||||
$ref: "./paths/meals/groups.yml"
|
||||
/meals/fillGroup:
|
||||
$ref: "./paths/meals/fillGroup.yml"
|
||||
|
||||
# Statistiky (/api/stats)
|
||||
/stats/summary:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
post:
|
||||
operationId: fillMealGroup
|
||||
summary: Doplní chybějící údaje do všech jídel se stejným názvem a zdrojem.
|
||||
description: |
|
||||
Doplňují se jen prázdné hodnoty, co už jídla mají, zůstane. Gramáž se
|
||||
nedoplní tam, kde jde dopočítat z ceny a sazby za 100 g, a energie jen
|
||||
jídlům, která kalorie nemají. Kalorie se pak dopočtou z gramáže jako
|
||||
při běžném uložení.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, values]
|
||||
properties:
|
||||
name:
|
||||
description: Název jídla skupiny
|
||||
type: string
|
||||
source:
|
||||
description: Zdroj skupiny; bez něj jde o jídla bez zdroje
|
||||
type: string
|
||||
values:
|
||||
$ref: "../../schemas/_index.yml#/MealGroupValues"
|
||||
responses:
|
||||
"200":
|
||||
description: Jídla, do kterých se něco doplnilo
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "../../schemas/_index.yml#/FillSameNameResult"
|
||||
"400":
|
||||
description: Neplatná data
|
||||
"401":
|
||||
description: Neautentizovaný uživatel
|
||||
@@ -0,0 +1,18 @@
|
||||
get:
|
||||
operationId: getMealGroups
|
||||
summary: Vrátí jídla přihlášeného uživatele seskupená podle názvu a zdroje.
|
||||
description: |
|
||||
Slouží k hromadnému doplnění chybějících údajů ke starším jídlům. Názvy
|
||||
i zdroje se porovnávají bez diakritiky a velikosti písmen. Seřazeno od
|
||||
nejčastěji jedených, při shodě od naposledy jedených.
|
||||
responses:
|
||||
"200":
|
||||
description: Skupiny jídel
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "../../schemas/_index.yml#/MealGroup"
|
||||
"401":
|
||||
description: Neautentizovaný uživatel
|
||||
@@ -193,6 +193,72 @@ FillSameNameResult:
|
||||
type: string
|
||||
format: date
|
||||
|
||||
MealGroupValues:
|
||||
description: |
|
||||
Hodnoty popisující jídlo samo — to, co mají všechna jídla jedné skupiny
|
||||
(stejný název a zdroj) společné. Chod, datum, cena ani násobek mezi ně
|
||||
nepatří, liší se den ode dne.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
pricePer100g:
|
||||
description: Cena za 100 g v haléřích
|
||||
type: integer
|
||||
weight:
|
||||
description: Gramáž porce zadaná ručně
|
||||
type: integer
|
||||
caloriesPer100g:
|
||||
type: integer
|
||||
calories:
|
||||
description: Kalorie porce, jen pokud nejdou dopočítat z gramáže a energie na 100 g
|
||||
type: integer
|
||||
nutrientsPer100g:
|
||||
$ref: "#/Nutrients"
|
||||
|
||||
MealGroup:
|
||||
description: |
|
||||
Všechna jídla uživatele se stejným názvem a zdrojem. Slouží k hromadnému
|
||||
doplnění údajů, které u starších záznamů chybí.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- name
|
||||
- count
|
||||
- lastDate
|
||||
- missingCaloriesCount
|
||||
- missingWeightCount
|
||||
- missingNutrientsCount
|
||||
- values
|
||||
properties:
|
||||
name:
|
||||
description: Název jídla tak, jak byl zapsán naposledy
|
||||
type: string
|
||||
source:
|
||||
description: Zdroj tak, jak byl zapsán naposledy
|
||||
type: string
|
||||
count:
|
||||
description: Počet jídel ve skupině
|
||||
type: integer
|
||||
lastDate:
|
||||
description: Den, kdy uživatel jídlo jedl naposledy
|
||||
type: string
|
||||
format: date
|
||||
missingCaloriesCount:
|
||||
description: Počet jídel, u kterých nejsou kalorie porce (do součtu dne nevstupují)
|
||||
type: integer
|
||||
missingWeightCount:
|
||||
description: Počet jídel bez gramáže
|
||||
type: integer
|
||||
missingNutrientsCount:
|
||||
description: Počet jídel bez nutričních hodnot
|
||||
type: integer
|
||||
values:
|
||||
description: |
|
||||
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
|
||||
názvu z jiného zdroje (složení je stejné, sazba za 100 g ne).
|
||||
$ref: "#/MealGroupValues"
|
||||
|
||||
MealSuggestion:
|
||||
description: |
|
||||
Jídlo z historie uživatele nabízené při zakládání nového. Každý název je
|
||||
|
||||
Reference in new issue
Block a user