Implement workout item and set deletion endpoints, enhance API response handling for empty responses

This commit is contained in:
Artem Kashaev
2026-05-28 14:08:15 +05:00
parent 2f5fd2f3d4
commit c17c65fcfa
5 changed files with 431 additions and 56 deletions
+296 -55
View File
@@ -1,8 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { api, type AuthState } from "./api";
import type { CatalogEntity, Workout } from "./types";
import type { CatalogEntity, Workout, WorkoutItem as WorkoutItemType } from "./types";
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics";
@@ -236,11 +236,43 @@ function ActiveWorkout({ token }: { token: string }) {
const { equipment, exercises } = useCatalog(token);
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
const [kind, setKind] = useState<"exercise" | "equipment">("exercise");
const [entityId, setEntityId] = useState("");
const [activeItemId, setActiveItemId] = useState("");
const [catalogKind, setCatalogKind] = useState<"exercise" | "equipment">("exercise");
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [weight, setWeight] = useState(60);
const [reps, setReps] = useState(8);
const [showFinishModal, setShowFinishModal] = useState(false);
const [finishNotes, setFinishNotes] = useState("");
const [elapsed, setElapsed] = useState("");
const current = useMemo(
() => activeWorkout ?? workouts.data?.find((w) => !w.finished_at) ?? null,
[activeWorkout, workouts.data],
);
useEffect(() => {
if (!current?.started_at) return;
const started = new Date(current.started_at).getTime();
const tick = () => {
const diff = Date.now() - started;
const m = Math.floor(diff / 60000);
const s = Math.floor((diff % 60000) / 1000);
setElapsed(`${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`);
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [current?.started_at]);
const addedEntityIds = useMemo(() => {
const ids = new Set<string>();
current?.items.forEach((item) => {
if (item.exercise_id) ids.add(item.exercise_id);
if (item.equipment_id) ids.add(item.equipment_id);
});
return ids;
}, [current?.items]);
const catalogList = catalogKind === "exercise" ? exercises.data : equipment.data;
const startMutation = useMutation({
mutationFn: () => api.createWorkout(token),
@@ -249,31 +281,42 @@ function ActiveWorkout({ token }: { token: string }) {
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
},
});
const addItemMutation = useMutation({
mutationFn: () => {
if (!activeWorkout) throw new Error("Нет активной тренировки");
return api.addWorkoutItem(token, activeWorkout.id, {
exercise_id: kind === "exercise" ? entityId : null,
equipment_id: kind === "equipment" ? entityId : null,
planned_working_weight: weight,
mutationFn: (entity: CatalogEntity) => {
if (!current) throw new Error("Нет активной тренировки");
return api.addWorkoutItem(token, current.id, {
exercise_id: catalogKind === "exercise" ? entity.id : null,
equipment_id: catalogKind === "equipment" ? entity.id : null,
});
},
onSuccess: (item) => {
setActiveItemId(item.id);
setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout);
setActiveWorkout((w) => (w ? { ...w, items: [...w.items, item] } : w));
setExpandedItemId(item.id);
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
},
});
const removeItemMutation = useMutation({
mutationFn: (itemId: string) => api.removeWorkoutItem(token, itemId),
onSuccess: (_, itemId) => {
setActiveWorkout((w) => (w ? { ...w, items: w.items.filter((i) => i.id !== itemId) } : w));
if (expandedItemId === itemId) setExpandedItemId(null);
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
void queryClient.invalidateQueries({ queryKey: ["calories"] });
},
});
const addSetMutation = useMutation({
mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }),
onSuccess: (workoutSet) => {
setActiveWorkout((workout) => {
if (!workout) return workout;
mutationFn: (itemId: string) => api.addWorkoutSet(token, itemId, { weight, reps }),
onSuccess: (workoutSet, itemId) => {
setActiveWorkout((w) => {
if (!w) return w;
return {
...workout,
estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0),
items: workout.items.map((item) =>
item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
...w,
estimated_calories: w.estimated_calories + (workoutSet.calories ?? 0),
items: w.items.map((item) =>
item.id === itemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
),
};
});
@@ -282,55 +325,253 @@ function ActiveWorkout({ token }: { token: string }) {
},
});
const current = activeWorkout ?? workouts.data?.find((workout) => !workout.finished_at) ?? null;
const choices = kind === "exercise" ? exercises.data : equipment.data;
const removeSetMutation = useMutation({
mutationFn: ({ itemId, setId }: { itemId: string; setId: string }) =>
api.removeWorkoutSet(token, itemId, setId),
onSuccess: (_, { itemId, setId }) => {
setActiveWorkout((w) => {
if (!w) return w;
const items = w.items.map((item) =>
item.id === itemId ? { ...item, sets: item.sets.filter((s) => s.id !== setId) } : item,
);
return { ...w, items };
});
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
void queryClient.invalidateQueries({ queryKey: ["calories"] });
},
});
const finishMutation = useMutation({
mutationFn: () => {
if (!current) throw new Error("Нет активной тренировки");
return api.finishWorkout(token, current.id, finishNotes || undefined);
},
onSuccess: () => {
setActiveWorkout(null);
setShowFinishModal(false);
setFinishNotes("");
setExpandedItemId(null);
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
void queryClient.invalidateQueries({ queryKey: ["calories"] });
},
});
if (!current) {
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Workout</p>
<h2>Тренировка</h2>
</div>
</div>
<section className="card workout-cta">
<h3>Готов к тренировке?</h3>
<p>Добавляй упражнения и тренажеры, записывай подходы всё в одном месте.</p>
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
{startMutation.isPending ? "Создание..." : "Начать тренировку"}
</button>
</section>
</section>
);
}
const totalSets = current.items.reduce((sum, item) => sum + item.sets.length, 0);
return (
<section className="stack">
<div className="page-header">
<div>
<p className="eyebrow">Active workout</p>
<p className="eyebrow">Workout</p>
<h2>Текущая тренировка</h2>
</div>
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>Новая тренировка</button>
<div className="workout-live-stats">
<span className="timer-badge">{elapsed || "00:00"}</span>
<span className="kcal-badge">{Math.round(current.estimated_calories)} ккал</span>
</div>
</div>
{current ? (
<>
<section className="card form-grid">
<section className="card catalog-picker-section">
<div className="catalog-picker-header">
<h3>Каталог</h3>
<div className="segmented">
<button className={catalogKind === "exercise" ? "active" : ""} onClick={() => setCatalogKind("exercise")}>Упражнения</button>
<button className={catalogKind === "equipment" ? "active" : ""} onClick={() => setCatalogKind("equipment")}>Тренажеры</button>
</div>
</div>
<div className="catalog-grid workout-catalog-grid">
{catalogList?.map((entity) => {
const added = addedEntityIds.has(entity.id);
return (
<article className={`card catalog-card workout-pick-card${added ? " added" : ""}`} key={entity.id}>
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
<div>
<h3>{entity.name}</h3>
<button
className={`pick-btn${added ? " picked" : " primary"}`}
disabled={added || addItemMutation.isPending}
onClick={() => addItemMutation.mutate(entity)}
>
{added ? "В тренировке" : "Добавить"}
</button>
</div>
</article>
);
})}
</div>
</section>
<section className="card workout-cart">
<h3>Тренировка ({current.items.length} эл., {totalSets} подх.)</h3>
{current.items.length === 0 ? (
<p className="muted">Добавь упражнения из каталога выше.</p>
) : (
<div className="cart-items">
{current.items.map((item) => (
<CartItemRow
key={item.id}
item={item}
exercises={exercises.data}
equipment={equipment.data}
expanded={expandedItemId === item.id}
onToggle={() => setExpandedItemId(expandedItemId === item.id ? null : item.id)}
onRemoveItem={() => removeItemMutation.mutate(item.id)}
onAddSet={expandedItemId === item.id ? () => addSetMutation.mutate(item.id) : undefined}
onRemoveSet={(setId) => removeSetMutation.mutate({ itemId: item.id, setId })}
weight={weight}
reps={reps}
onWeightChange={setWeight}
onRepsChange={setReps}
isAddingSet={addSetMutation.isPending}
isRemovingItem={removeItemMutation.isPending}
/>
))}
</div>
)}
<button
className="primary finish-btn"
disabled={current.items.length === 0 || finishMutation.isPending}
onClick={() => setShowFinishModal(true)}
>
Завершить тренировку
</button>
</section>
{showFinishModal && (
<div className="modal-overlay" onClick={() => setShowFinishModal(false)}>
<div className="modal-card card" onClick={(e) => e.stopPropagation()}>
<h2>Завершить тренировку?</h2>
<div className="modal-stats">
<Metric label="Упражнений" value={current.items.length} />
<Metric label="Подходов" value={totalSets} />
<Metric label="Ккал" value={Math.round(current.estimated_calories)} />
</div>
<label>
Тип
<select value={kind} onChange={(event) => setKind(event.target.value as "exercise" | "equipment")}>
<option value="exercise">Упражнение</option>
<option value="equipment">Тренажер</option>
</select>
Заметки
<input value={finishNotes} onChange={(e) => setFinishNotes(e.target.value)} placeholder="Как прошла тренировка?" />
</label>
<label>
Элемент
<select value={entityId} onChange={(event) => setEntityId(event.target.value)}>
<option value="">Выбрать</option>
{choices?.map((entity) => <option key={entity.id} value={entity.id}>{entity.name}</option>)}
</select>
</label>
<label>
Вес
<input type="number" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
</label>
<label>
Повторы
<input type="number" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
</label>
<button className="primary" disabled={!entityId || addItemMutation.isPending} onClick={() => addItemMutation.mutate()}>Добавить элемент</button>
<button disabled={!activeItemId || addSetMutation.isPending} onClick={() => addSetMutation.mutate()}>Записать подход</button>
</section>
<WorkoutSummary workout={current} />
</>
) : (
<section className="card empty-state"><h3>Нет активной тренировки</h3><p>Начни тренировку и добавь упражнения или тренажеры.</p></section>
<div className="modal-actions">
<button className="ghost" onClick={() => setShowFinishModal(false)}>Отмена</button>
<button className="primary" disabled={finishMutation.isPending} onClick={() => finishMutation.mutate()}>
{finishMutation.isPending ? "Сохранение..." : "Завершить"}
</button>
</div>
</div>
</div>
)}
</section>
);
}
function CartItemRow({
item,
exercises,
equipment,
expanded,
onToggle,
onRemoveItem,
onAddSet,
onRemoveSet,
weight,
reps,
onWeightChange,
onRepsChange,
isAddingSet,
isRemovingItem,
}: {
item: WorkoutItemType;
exercises: CatalogEntity[] | undefined;
equipment: CatalogEntity[] | undefined;
expanded: boolean;
onToggle: () => void;
onRemoveItem: () => void;
onAddSet: (() => void) | undefined;
onRemoveSet: (setId: string) => void;
weight: number;
reps: number;
onWeightChange: (v: number) => void;
onRepsChange: (v: number) => void;
isAddingSet: boolean;
isRemovingItem: boolean;
}) {
const entityName = useMemo(() => {
if (item.exercise_id) return exercises?.find((e) => e.id === item.exercise_id)?.name ?? "Упражнение";
if (item.equipment_id) return equipment?.find((e) => e.id === item.equipment_id)?.name ?? "Тренажер";
return "Неизвестно";
}, [item.exercise_id, item.equipment_id, exercises, equipment]);
return (
<div className={`cart-item${expanded ? " expanded" : ""}`}>
<div className="cart-item-header" onClick={onToggle}>
<div className="cart-item-info">
<span className="cart-item-name">{entityName}</span>
<span className="cart-item-summary">
{item.sets.length > 0 ? `${item.sets.length} × ${item.sets[item.sets.length - 1].weight} кг` : "нет подходов"}
</span>
</div>
<button className="cart-item-remove" disabled={isRemovingItem} onClick={(e) => { e.stopPropagation(); onRemoveItem(); }}>
×
</button>
</div>
{expanded && (
<div className="cart-item-body">
{item.sets.length > 0 && (
<div className="sets-list">
{item.sets.map((set) => (
<div className="set-row" key={set.id}>
<span className="set-index">{set.set_index}</span>
<span className="set-data">{set.weight} кг × {set.reps}</span>
<span className="set-cal">{set.calories ? Math.round(set.calories) : "—"} ккал</span>
<button className="set-remove" onClick={() => onRemoveSet(set.id)}>×</button>
</div>
))}
</div>
)}
{onAddSet && (
<div className="add-set-row">
<input
type="number"
value={weight}
onChange={(e) => onWeightChange(Number(e.target.value))}
placeholder="Вес"
/>
<input
type="number"
value={reps}
onChange={(e) => onRepsChange(Number(e.target.value))}
placeholder="Повторы"
/>
<button className="primary" disabled={isAddingSet} onClick={onAddSet}>
{isAddingSet ? "..." : "Подход"}
</button>
</div>
)}
</div>
)}
</div>
);
}
function History({ token }: { token: string }) {
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
return (
+15
View File
@@ -21,6 +21,9 @@ async function request<T>(path: string, options: RequestInit = {}, token?: strin
const body = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
}
if (response.status === 204 || response.headers.get("content-length") === "0") {
return undefined as T;
}
return response.json() as Promise<T>;
}
@@ -72,6 +75,18 @@ export const api = {
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
},
removeWorkoutItem(token: string, itemId: string) {
return request<void>(`/workout-items/${itemId}`, { method: "DELETE" }, token);
},
removeWorkoutSet(token: string, itemId: string, setId: string) {
return request<void>(`/workout-items/${itemId}/sets/${setId}`, { method: "DELETE" }, token);
},
finishWorkout(token: string, workoutId: string, notes?: string) {
return request<Workout>(`/workouts/${workoutId}`, {
method: "PATCH",
body: JSON.stringify({ finished_at: new Date().toISOString(), notes }),
}, token);
},
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
const params = new URLSearchParams({ kind });
if (entityId) params.set("entity_id", entityId);
+55 -1
View File
@@ -54,6 +54,54 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad
.pill.user { background: #dcfce7; color: #166534; }
.empty-state { text-align: center; padding: 48px; }
.workout-card { display: grid; gap: 18px; }
.workout-cta { text-align: center; padding: 48px 32px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
.workout-cta h3 { margin: 0; font-size: 24px; }
.workout-cta p { color: #6b7280; max-width: 420px; }
.workout-live-stats { display: flex; gap: 10px; align-items: center; }
.timer-badge { background: #151515; color: #dbff5b; font-family: "SF Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 18px; font-weight: 800; padding: 8px 16px; border-radius: 14px; letter-spacing: 0.04em; }
.kcal-badge { background: #f3e8ff; color: #7c3aed; font-weight: 800; padding: 8px 14px; border-radius: 14px; font-size: 14px; }
.catalog-picker-section { padding: 20px 24px; }
.catalog-picker-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.catalog-picker-header h3 { margin: 0; }
.workout-catalog-grid { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
.workout-pick-card { padding: 0; cursor: default; transition: box-shadow 0.2s; }
.workout-pick-card.added { opacity: 0.55; }
.workout-pick-card .image-placeholder { height: 100px; }
.workout-pick-card img { height: 100px; }
.workout-pick-card div:last-child { padding: 14px; display: flex; flex-direction: column; gap: 8px; }
.workout-pick-card h3 { margin: 0; font-size: 14px; }
.pick-btn { border-radius: 10px; padding: 8px 12px; font-weight: 800; font-size: 12px; width: 100%; }
.pick-btn.picked { background: #dcfce7; color: #166534; cursor: default; }
.workout-cart { display: flex; flex-direction: column; gap: 16px; }
.workout-cart > h3 { margin: 0; }
.cart-items { display: flex; flex-direction: column; gap: 10px; }
.cart-item { border: 1px solid rgba(17,24,39,0.08); border-radius: 16px; overflow: hidden; transition: border-color 0.2s; }
.cart-item.expanded { border-color: #8a5cf6; }
.cart-item-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; cursor: pointer; }
.cart-item-header:hover { background: rgba(0,0,0,0.02); }
.cart-item-info { display: flex; flex-direction: column; gap: 2px; }
.cart-item-name { font-weight: 700; font-size: 15px; }
.cart-item-summary { color: #6b7280; font-size: 13px; }
.cart-item-remove { background: transparent; color: #9ca3af; font-size: 20px; padding: 4px 8px; border-radius: 8px; line-height: 1; }
.cart-item-remove:hover { color: #b91c1c; background: #fef2f2; }
.cart-item-body { border-top: 1px solid rgba(17,24,39,0.06); padding: 14px 18px; display: flex; flex-direction: column; gap: 12px; }
.sets-list { display: flex; flex-direction: column; gap: 6px; }
.set-row { display: grid; grid-template-columns: 28px 1fr 70px 28px; gap: 8px; align-items: center; padding: 6px 0; }
.set-index { font-weight: 800; color: #8a5cf6; font-size: 13px; }
.set-data { font-size: 14px; font-weight: 600; }
.set-cal { color: #6b7280; font-size: 12px; }
.set-remove { background: transparent; color: #d1d5db; font-size: 16px; padding: 2px 6px; border-radius: 6px; line-height: 1; }
.set-remove:hover { color: #b91c1c; background: #fef2f2; }
.add-set-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: center; }
.add-set-row input { padding: 10px 12px; font-size: 14px; border-radius: 12px; }
.add-set-row button { padding: 10px 16px; font-size: 13px; white-space: nowrap; }
.finish-btn { align-self: flex-end; margin-top: 8px; }
.modal-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(17,24,39,0.45); display: grid; place-items: center; backdrop-filter: blur(4px); }
.modal-card { max-width: 440px; width: calc(100% - 32px); display: flex; flex-direction: column; gap: 18px; }
.modal-card h2 { margin: 0; }
.modal-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.modal-stats .metric { padding: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 12px; }
.bars { display: grid; gap: 12px; }
.bar-row { display: grid; grid-template-columns: 110px 1fr 70px; gap: 12px; align-items: center; }
.bar-row div { height: 16px; border-radius: 999px; background: #e5e7eb; overflow: hidden; }
@@ -70,10 +118,16 @@ input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; pad
.hero-panel { min-height: 48vh; padding: 28px; }
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid, .stats-grid { grid-template-columns: 1fr; }
.workout-catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.workout-live-stats { gap: 6px; }
.timer-badge { font-size: 15px; padding: 6px 12px; }
.catalog-picker-header { flex-direction: column; align-items: stretch; gap: 10px; }
}
@media (max-width: 620px) {
.page-header { align-items: stretch; flex-direction: column; }
.catalog-grid, .workout-stats { grid-template-columns: 1fr; }
.bar-row { grid-template-columns: 1fr; }
.workout-catalog-grid { grid-template-columns: 1fr; }
.add-set-row { grid-template-columns: 1fr 1fr; }
.add-set-row button { grid-column: 1 / -1; }
}
+10
View File
@@ -174,6 +174,16 @@ async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUs
)
@app.delete("/workout-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_workout_item(item_id: str, user: CurrentUser) -> None:
await logic_request("DELETE", f"/internal/workout-items/{item_id}", user)
@app.delete("/workout-items/{item_id}/sets/{set_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_workout_set(item_id: str, set_id: str, user: CurrentUser) -> None:
await logic_request("DELETE", f"/internal/workout-items/{item_id}/sets/{set_id}", user)
@app.get("/analytics/progression")
async def progression(
user: CurrentUser,
+55
View File
@@ -323,6 +323,61 @@ def add_workout_set(
return workout_set
@app.delete(
"/internal/workout-items/{item_id}",
dependencies=[InternalAuth],
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_workout_item(item_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> None:
item = db.scalar(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
workout = db.get(Workout, item.workout_id)
if workout and workout.finished_at:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished")
workout_id = item.workout_id
db.delete(item)
db.flush()
recalculate_workout_calories(db, workout_id)
db.commit()
@app.delete(
"/internal/workout-items/{item_id}/sets/{set_id}",
dependencies=[InternalAuth],
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_workout_set(
item_id: uuid.UUID, set_id: uuid.UUID, db: Db, user_id: CurrentUserId
) -> None:
ws = db.scalar(
select(WorkoutSet)
.join(WorkoutItem)
.join(Workout)
.where(
WorkoutSet.id == set_id,
WorkoutItem.id == item_id,
Workout.user_id == user_id,
)
)
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Set not found")
workout = db.scalar(
select(Workout).join(WorkoutItem).where(WorkoutItem.id == item_id)
)
if workout and workout.finished_at:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished")
db.delete(ws)
db.flush()
if workout:
recalculate_workout_calories(db, workout.id)
db.commit()
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float:
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
return round(