fixes graphics
This commit is contained in:
@@ -52,6 +52,17 @@ class WeightCreate(BaseModel):
|
||||
recorded_at: str | None = None
|
||||
|
||||
|
||||
class BodyMetricUpdate(BaseModel):
|
||||
weight_kg: float | None = Field(default=None, gt=0)
|
||||
body_fat_pct: float | None = Field(default=None, ge=0, le=100)
|
||||
chest_cm: float | None = Field(default=None, ge=0)
|
||||
waist_cm: float | None = Field(default=None, ge=0)
|
||||
neck_cm: float | None = Field(default=None, ge=0)
|
||||
hip_cm: float | None = Field(default=None, ge=0)
|
||||
notes: str | None = None
|
||||
recorded_at: str | None = None
|
||||
|
||||
|
||||
class BodyCompositionCalc(BaseModel):
|
||||
weight_kg: float | None = None
|
||||
height_cm: float | None = None
|
||||
@@ -341,6 +352,22 @@ def list_metrics(
|
||||
return FitnessService(db, user.id).list_body_metrics(limit=limit)
|
||||
|
||||
|
||||
@router.patch("/fitness/body-metrics/{metric_id}")
|
||||
def update_body_metric(
|
||||
metric_id: int,
|
||||
payload: BodyMetricUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
result = FitnessService(db, user.id).update_body_metric(metric_id, updates)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/fitness/meals/{log_id}")
|
||||
def update_meal(
|
||||
log_id: int,
|
||||
|
||||
@@ -4,13 +4,15 @@ from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import engine
|
||||
from app.db.models import FitnessProfile, StepLog
|
||||
from app.db.models import BodyMetric, FitnessProfile, StepLog
|
||||
from app.fitness.body_composition import compute_body_composition
|
||||
from app.fitness.calculators import DEFAULT_NEAT_KCAL, compute_targets, macro_targets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TDEE_V2_BACKFILL = "fitness_tdee_v2_backfill"
|
||||
MACROS_GKG_BACKFILL = "fitness_macros_gkg_v1"
|
||||
NAVY_BODY_FAT_CM_BACKFILL = "fitness_navy_body_fat_cm_v1"
|
||||
|
||||
|
||||
def _table_exists(table: str) -> bool:
|
||||
@@ -142,6 +144,52 @@ def backfill_macros_gkg(*, force: bool = False) -> int:
|
||||
return updated
|
||||
|
||||
|
||||
def backfill_navy_body_fat_cm(*, force: bool = False) -> int:
|
||||
"""Recalculate Navy body composition after fixing the cm-based formula."""
|
||||
if not _table_exists("body_metrics"):
|
||||
_ensure_schema_migrations_table()
|
||||
if not _migration_applied(NAVY_BODY_FAT_CM_BACKFILL):
|
||||
_mark_migration_applied(NAVY_BODY_FAT_CM_BACKFILL)
|
||||
return 0
|
||||
_ensure_schema_migrations_table()
|
||||
if not force and _migration_applied(NAVY_BODY_FAT_CM_BACKFILL):
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
with Session(engine) as db:
|
||||
profiles = {
|
||||
profile.user_id: profile
|
||||
for profile in db.scalars(select(FitnessProfile)).all()
|
||||
}
|
||||
rows = db.scalars(
|
||||
select(BodyMetric).where(BodyMetric.body_fat_method == "navy")
|
||||
).all()
|
||||
for row in rows:
|
||||
profile = profiles.get(row.user_id)
|
||||
computed = compute_body_composition(
|
||||
sex=str(profile.sex if profile else "male"),
|
||||
height_cm=float(profile.height_cm if profile else 170),
|
||||
weight_kg=row.weight_kg,
|
||||
neck_cm=row.neck_cm,
|
||||
waist_cm=row.waist_cm,
|
||||
hip_cm=row.hip_cm,
|
||||
)
|
||||
if computed.get("body_fat_pct") is None:
|
||||
continue
|
||||
row.body_fat_pct = computed.get("body_fat_pct")
|
||||
row.body_fat_method = computed.get("body_fat_method")
|
||||
row.whr = computed.get("whr")
|
||||
row.lbm_kg = computed.get("lbm_kg")
|
||||
row.ffmi = computed.get("ffmi")
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
if not _migration_applied(NAVY_BODY_FAT_CM_BACKFILL):
|
||||
_mark_migration_applied(NAVY_BODY_FAT_CM_BACKFILL)
|
||||
logger.info("Navy body fat cm backfill: updated %s body metric(s)", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def run_fitness_migrations() -> None:
|
||||
inspector = inspect(engine)
|
||||
|
||||
@@ -223,3 +271,4 @@ def run_fitness_migrations() -> None:
|
||||
|
||||
backfill_tdee_targets()
|
||||
backfill_macros_gkg()
|
||||
backfill_navy_body_fat_cm()
|
||||
|
||||
@@ -6,10 +6,6 @@ def _is_female(sex: str) -> bool:
|
||||
return sex.lower() in ("f", "female", "ж", "женский", "woman")
|
||||
|
||||
|
||||
def _cm_to_inches(cm: float) -> float:
|
||||
return cm / 2.54
|
||||
|
||||
|
||||
def _clamp_bf(value: float) -> float:
|
||||
return round(max(3.0, min(50.0, value)), 1)
|
||||
|
||||
@@ -25,30 +21,25 @@ def navy_body_fat_pct(
|
||||
if height_cm <= 0 or neck_cm <= 0 or waist_cm <= 0:
|
||||
return None
|
||||
|
||||
height_in = _cm_to_inches(height_cm)
|
||||
neck_in = _cm_to_inches(neck_cm)
|
||||
waist_in = _cm_to_inches(waist_cm)
|
||||
|
||||
if _is_female(sex):
|
||||
if hip_cm is None or hip_cm <= 0:
|
||||
return None
|
||||
hip_in = _cm_to_inches(hip_cm)
|
||||
sum_in = waist_in + hip_in - neck_in
|
||||
if sum_in <= 0:
|
||||
measurement_sum_cm = waist_cm + hip_cm - neck_cm
|
||||
if measurement_sum_cm <= 0:
|
||||
return None
|
||||
denom = (
|
||||
1.29579
|
||||
- 0.35004 * math.log10(sum_in)
|
||||
+ 0.22100 * math.log10(height_in)
|
||||
- 0.35004 * math.log10(measurement_sum_cm)
|
||||
+ 0.22100 * math.log10(height_cm)
|
||||
)
|
||||
else:
|
||||
diff_in = waist_in - neck_in
|
||||
if diff_in <= 0:
|
||||
measurement_diff_cm = waist_cm - neck_cm
|
||||
if measurement_diff_cm <= 0:
|
||||
return None
|
||||
denom = (
|
||||
1.0324
|
||||
- 0.19077 * math.log10(diff_in)
|
||||
+ 0.15456 * math.log10(height_in)
|
||||
- 0.19077 * math.log10(measurement_diff_cm)
|
||||
+ 0.15456 * math.log10(height_cm)
|
||||
)
|
||||
|
||||
if denom <= 0:
|
||||
|
||||
@@ -541,6 +541,91 @@ class FitnessService:
|
||||
},
|
||||
}
|
||||
|
||||
def update_body_metric(
|
||||
self,
|
||||
metric_id: int,
|
||||
updates: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
row = self.db.get(BodyMetric, metric_id)
|
||||
if not row or row.user_id != self.user_id:
|
||||
return None
|
||||
|
||||
if "weight_kg" in updates and updates["weight_kg"] is not None:
|
||||
row.weight_kg = float(updates["weight_kg"])
|
||||
for field in ("chest_cm", "waist_cm", "neck_cm", "hip_cm"):
|
||||
if field in updates:
|
||||
value = updates[field]
|
||||
setattr(row, field, float(value) if value is not None else None)
|
||||
if "notes" in updates and updates["notes"] is not None:
|
||||
row.notes = str(updates["notes"])[:1000]
|
||||
if "recorded_at" in updates and updates["recorded_at"] is not None:
|
||||
row.recorded_at = self._resolve_logged_at(logged_at=updates["recorded_at"])
|
||||
|
||||
profile_row = self._get_profile_row()
|
||||
sex = profile_row.sex if profile_row else "male"
|
||||
height_cm = float(profile_row.height_cm if profile_row else 170)
|
||||
if "body_fat_pct" in updates:
|
||||
manual_body_fat = updates["body_fat_pct"]
|
||||
elif row.body_fat_method == "manual":
|
||||
manual_body_fat = row.body_fat_pct
|
||||
else:
|
||||
manual_body_fat = None
|
||||
|
||||
computed = compute_body_composition(
|
||||
sex=str(sex),
|
||||
height_cm=height_cm,
|
||||
weight_kg=row.weight_kg,
|
||||
neck_cm=row.neck_cm,
|
||||
waist_cm=row.waist_cm,
|
||||
hip_cm=row.hip_cm,
|
||||
body_fat_pct=float(manual_body_fat) if manual_body_fat is not None else None,
|
||||
)
|
||||
row.body_fat_pct = computed.get("body_fat_pct")
|
||||
row.body_fat_method = computed.get("body_fat_method")
|
||||
row.whr = computed.get("whr")
|
||||
row.lbm_kg = computed.get("lbm_kg")
|
||||
row.ffmi = computed.get("ffmi")
|
||||
|
||||
latest_id = self.db.scalar(
|
||||
select(BodyMetric.id)
|
||||
.where(BodyMetric.user_id == self.user_id)
|
||||
.order_by(BodyMetric.recorded_at.desc(), BodyMetric.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if profile_row and latest_id == row.id:
|
||||
profile_row.weight_kg = row.weight_kg
|
||||
targets = compute_targets(
|
||||
{
|
||||
"sex": profile_row.sex,
|
||||
"age": profile_row.age,
|
||||
"height_cm": profile_row.height_cm,
|
||||
"weight_kg": row.weight_kg,
|
||||
"goal": profile_row.goal,
|
||||
"neat_base_kcal": profile_row.neat_base_kcal,
|
||||
}
|
||||
)
|
||||
profile_row.calorie_target = targets["calorie_target"]
|
||||
profile_row.protein_g = targets["protein_g"]
|
||||
profile_row.fat_g = targets["fat_g"]
|
||||
profile_row.carbs_g = targets["carbs_g"]
|
||||
profile_row.water_l = targets["water_l"]
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
metric = self._body_metric_to_dict(row)
|
||||
return {
|
||||
"ok": True,
|
||||
"metric": metric,
|
||||
"computed": {
|
||||
"body_fat_pct": computed.get("body_fat_pct"),
|
||||
"body_fat_method": computed.get("body_fat_method"),
|
||||
"whr": computed.get("whr"),
|
||||
"lbm_kg": computed.get("lbm_kg"),
|
||||
"ffmi": computed.get("ffmi"),
|
||||
"warnings": computed.get("warnings") or [],
|
||||
},
|
||||
}
|
||||
|
||||
def log_workout(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import BodyMetric, FitnessProfile, User
|
||||
from app.fitness.body_composition import (
|
||||
compute_body_composition,
|
||||
ffmi,
|
||||
@@ -16,8 +20,7 @@ def test_navy_male_reasonable_range():
|
||||
neck_cm=38,
|
||||
waist_cm=84,
|
||||
)
|
||||
assert bf is not None
|
||||
assert 5 <= bf <= 35
|
||||
assert bf == pytest.approx(15.3)
|
||||
|
||||
|
||||
def test_navy_female_requires_hip():
|
||||
@@ -36,8 +39,7 @@ def test_navy_female_requires_hip():
|
||||
waist_cm=72,
|
||||
hip_cm=98,
|
||||
)
|
||||
assert bf is not None
|
||||
assert 10 <= bf <= 45
|
||||
assert bf == pytest.approx(26.4)
|
||||
|
||||
|
||||
def test_navy_invalid_waist_neck():
|
||||
@@ -100,3 +102,57 @@ def test_compute_female_warning_without_hip():
|
||||
)
|
||||
assert result["body_fat_pct"] is None
|
||||
assert any("бёдер" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_update_body_metric_recomputes_composition_and_latest_profile_weight():
|
||||
from app.fitness.service import FitnessService
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
with Session() as db:
|
||||
user = User(username="tester", api_token_hash="x")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(
|
||||
FitnessProfile(
|
||||
user_id=user.id,
|
||||
sex="male",
|
||||
age=30,
|
||||
height_cm=180,
|
||||
weight_kg=82,
|
||||
goal="maintain",
|
||||
)
|
||||
)
|
||||
metric = BodyMetric(
|
||||
user_id=user.id,
|
||||
weight_kg=82,
|
||||
body_fat_pct=9.0,
|
||||
body_fat_method="navy",
|
||||
waist_cm=84,
|
||||
neck_cm=38,
|
||||
)
|
||||
db.add(metric)
|
||||
db.commit()
|
||||
metric_id = metric.id
|
||||
user_id = user.id
|
||||
|
||||
result = FitnessService(db, user_id).update_body_metric(
|
||||
metric_id,
|
||||
{"weight_kg": 83, "waist_cm": 86, "neck_cm": 38},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
expected = compute_body_composition(
|
||||
sex="male",
|
||||
height_cm=180,
|
||||
weight_kg=83,
|
||||
waist_cm=86,
|
||||
neck_cm=38,
|
||||
)
|
||||
assert result["metric"]["weight_kg"] == 83
|
||||
assert result["metric"]["body_fat_method"] == "navy"
|
||||
assert result["metric"]["body_fat_pct"] == expected["body_fat_pct"]
|
||||
assert result["metric"]["lbm_kg"] == expected["lbm_kg"]
|
||||
assert result["metric"]["ffmi"] == expected["ffmi"]
|
||||
assert db.query(FitnessProfile).one().weight_kg == 83
|
||||
|
||||
@@ -5,7 +5,8 @@ from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.models import FitnessProfile, User
|
||||
from app.db.models import BodyMetric, FitnessProfile, User
|
||||
from app.fitness.body_composition import compute_body_composition
|
||||
from app.fitness.calculators import compute_targets
|
||||
|
||||
|
||||
@@ -111,6 +112,54 @@ class TdeeBackfillTests(unittest.TestCase):
|
||||
self.assertEqual(row.fat_g, expected["fat_g"])
|
||||
self.assertEqual(row.carbs_g, expected["carbs_g"])
|
||||
|
||||
def test_navy_body_fat_backfill_recalculates_saved_metrics(self) -> None:
|
||||
from app.db import migrate_fitness
|
||||
|
||||
Session = sessionmaker(bind=self.engine)
|
||||
with Session() as db:
|
||||
user = User(username="navy", api_token_hash="x")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(
|
||||
FitnessProfile(
|
||||
user_id=user.id,
|
||||
sex="male",
|
||||
age=30,
|
||||
height_cm=180,
|
||||
weight_kg=82,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BodyMetric(
|
||||
user_id=user.id,
|
||||
weight_kg=82,
|
||||
body_fat_pct=9.0,
|
||||
body_fat_method="navy",
|
||||
waist_cm=84,
|
||||
neck_cm=38,
|
||||
lbm_kg=74.6,
|
||||
ffmi=23.0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with patch.object(migrate_fitness, "engine", self.engine):
|
||||
updated = migrate_fitness.backfill_navy_body_fat_cm(force=True)
|
||||
self.assertEqual(updated, 1)
|
||||
|
||||
with Session() as db:
|
||||
row = db.query(BodyMetric).one()
|
||||
expected = compute_body_composition(
|
||||
sex="male",
|
||||
height_cm=180,
|
||||
weight_kg=82,
|
||||
waist_cm=84,
|
||||
neck_cm=38,
|
||||
)
|
||||
self.assertEqual(row.body_fat_pct, expected["body_fat_pct"])
|
||||
self.assertEqual(row.lbm_kg, expected["lbm_kg"])
|
||||
self.assertEqual(row.ffmi, expected["ffmi"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -435,6 +435,17 @@ export interface BodyCompositionComputed {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface BodyMetricUpdatePayload {
|
||||
weight_kg?: number;
|
||||
body_fat_pct?: number | null;
|
||||
chest_cm?: number | null;
|
||||
waist_cm?: number | null;
|
||||
neck_cm?: number | null;
|
||||
hip_cm?: number | null;
|
||||
notes?: string;
|
||||
recorded_at?: string;
|
||||
}
|
||||
|
||||
export interface FitnessReminder {
|
||||
id: number;
|
||||
kind: string;
|
||||
@@ -832,6 +843,16 @@ export const api = {
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
updateBodyMetric: (id: number, payload: BodyMetricUpdatePayload) =>
|
||||
request<{ ok: boolean; metric: BodyMetric; computed: BodyCompositionComputed }>(
|
||||
`/api/v1/fitness/body-metrics/${id}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
|
||||
deleteFitnessMeal: (id: number) =>
|
||||
request<{ ok: boolean }>(`/api/v1/fitness/meals/${id}`, { method: "DELETE" }),
|
||||
|
||||
|
||||
@@ -282,6 +282,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fitness-table-input {
|
||||
width: 4.8rem;
|
||||
padding: 0.25rem 0.35rem;
|
||||
border: 1px solid #242b38;
|
||||
border-radius: 4px;
|
||||
background: #0c0f14;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.fitness-log-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
+232
-27
@@ -3,6 +3,7 @@ import {
|
||||
api,
|
||||
BodyCompositionComputed,
|
||||
BodyMetric,
|
||||
BodyMetricUpdatePayload,
|
||||
FoodLogItem,
|
||||
FitnessChartsResponse,
|
||||
FitnessCompareResponse,
|
||||
@@ -37,6 +38,20 @@ function formatDayLabel(iso: string) {
|
||||
});
|
||||
}
|
||||
|
||||
type BodyMetricEditDraft = {
|
||||
weight_kg: string;
|
||||
body_fat_pct: string;
|
||||
chest_cm: string;
|
||||
neck_cm: string;
|
||||
waist_cm: string;
|
||||
hip_cm: string;
|
||||
};
|
||||
|
||||
function optionalMetricNumber(value: string): number | null {
|
||||
const clean = value.trim();
|
||||
return clean === "" ? null : Number(clean);
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
label,
|
||||
current,
|
||||
@@ -122,6 +137,15 @@ export default function Fitness() {
|
||||
});
|
||||
const [editingWaterId, setEditingWaterId] = useState<number | null>(null);
|
||||
const [waterEditDraft, setWaterEditDraft] = useState("");
|
||||
const [editingBodyMetricId, setEditingBodyMetricId] = useState<number | null>(null);
|
||||
const [bodyMetricEditDraft, setBodyMetricEditDraft] = useState<BodyMetricEditDraft>({
|
||||
weight_kg: "",
|
||||
body_fat_pct: "",
|
||||
chest_cm: "",
|
||||
neck_cm: "",
|
||||
waist_cm: "",
|
||||
hip_cm: "",
|
||||
});
|
||||
const [mergeMealIds, setMergeMealIds] = useState<number[]>([]);
|
||||
const [mergeWaterIds, setMergeWaterIds] = useState<number[]>([]);
|
||||
const [mergeStepIds, setMergeStepIds] = useState<number[]>([]);
|
||||
@@ -281,6 +305,73 @@ export default function Fitness() {
|
||||
}
|
||||
};
|
||||
|
||||
const startEditBodyMetric = (metric: BodyMetric) => {
|
||||
setEditingBodyMetricId(metric.id);
|
||||
setBodyMetricEditDraft({
|
||||
weight_kg: String(metric.weight_kg),
|
||||
body_fat_pct:
|
||||
metric.body_fat_method === "manual" && metric.body_fat_pct != null
|
||||
? String(metric.body_fat_pct)
|
||||
: "",
|
||||
chest_cm: metric.chest_cm != null ? String(metric.chest_cm) : "",
|
||||
neck_cm: metric.neck_cm != null ? String(metric.neck_cm) : "",
|
||||
waist_cm: metric.waist_cm != null ? String(metric.waist_cm) : "",
|
||||
hip_cm: metric.hip_cm != null ? String(metric.hip_cm) : "",
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEditBodyMetric = () => {
|
||||
setEditingBodyMetricId(null);
|
||||
setBodyMetricEditDraft({
|
||||
weight_kg: "",
|
||||
body_fat_pct: "",
|
||||
chest_cm: "",
|
||||
neck_cm: "",
|
||||
waist_cm: "",
|
||||
hip_cm: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBodyMetric = async (id: number) => {
|
||||
const weight_kg = Number(bodyMetricEditDraft.weight_kg);
|
||||
const optionalFields = {
|
||||
body_fat_pct: optionalMetricNumber(bodyMetricEditDraft.body_fat_pct),
|
||||
chest_cm: optionalMetricNumber(bodyMetricEditDraft.chest_cm),
|
||||
neck_cm: optionalMetricNumber(bodyMetricEditDraft.neck_cm),
|
||||
waist_cm: optionalMetricNumber(bodyMetricEditDraft.waist_cm),
|
||||
hip_cm: optionalMetricNumber(bodyMetricEditDraft.hip_cm),
|
||||
};
|
||||
const values = [
|
||||
weight_kg,
|
||||
...Object.values(optionalFields).filter((v): v is number => v !== null),
|
||||
];
|
||||
if (!Number.isFinite(weight_kg) || weight_kg <= 0 || values.some((v) => !Number.isFinite(v) || v < 0)) {
|
||||
setMessage("Заполните корректные значения антропометрии");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: BodyMetricUpdatePayload = {
|
||||
weight_kg,
|
||||
body_fat_pct: optionalFields.body_fat_pct,
|
||||
chest_cm: optionalFields.chest_cm,
|
||||
neck_cm: optionalFields.neck_cm,
|
||||
waist_cm: optionalFields.waist_cm,
|
||||
hip_cm: optionalFields.hip_cm,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await api.updateBodyMetric(id, payload);
|
||||
const warnings = result.computed.warnings?.length
|
||||
? ` · ${result.computed.warnings.join(" ")}`
|
||||
: "";
|
||||
setMessage(`Антропометрия обновлена${warnings}`);
|
||||
cancelEditBodyMetric();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Ошибка сохранения антропометрии");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompareDays = async () => {
|
||||
setCompareLoading(true);
|
||||
try {
|
||||
@@ -1092,34 +1183,148 @@ export default function Fitness() {
|
||||
<section className="fitness-section">
|
||||
<h3>Антропометрия</h3>
|
||||
<div className="fitness-table-wrap">
|
||||
<table className="fitness-table fitness-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>кг</th>
|
||||
<th>%</th>
|
||||
<th>шея</th>
|
||||
<th>талия</th>
|
||||
<th>бёдра</th>
|
||||
<th>WHR</th>
|
||||
<th>метод</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(snapshot?.body_metrics ?? []).map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.recorded_at?.slice(0, 10)}</td>
|
||||
<td>{m.weight_kg}</td>
|
||||
<td>{m.body_fat_pct != null ? `≈${m.body_fat_pct}` : "—"}</td>
|
||||
<td>{m.neck_cm ?? "—"}</td>
|
||||
<td>{m.waist_cm ?? "—"}</td>
|
||||
<td>{m.hip_cm ?? "—"}</td>
|
||||
<td>{m.whr ?? "—"}</td>
|
||||
<td>{m.body_fat_method ?? "—"}</td>
|
||||
<table className="fitness-table fitness-table-wide">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>кг</th>
|
||||
<th>%</th>
|
||||
<th>грудь</th>
|
||||
<th>шея</th>
|
||||
<th>талия</th>
|
||||
<th>бёдра</th>
|
||||
<th>WHR</th>
|
||||
<th>метод</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(snapshot?.body_metrics ?? []).map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.recorded_at?.slice(0, 10)}</td>
|
||||
{editingBodyMetricId === m.id ? (
|
||||
<>
|
||||
<td>
|
||||
<input
|
||||
className="fitness-table-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={bodyMetricEditDraft.weight_kg}
|
||||
onChange={(e) =>
|
||||
setBodyMetricEditDraft((d) => ({ ...d, weight_kg: e.target.value }))
|
||||
}
|
||||
aria-label="Вес"
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="fitness-table-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.1"
|
||||
placeholder={
|
||||
m.body_fat_method === "navy" && m.body_fat_pct != null
|
||||
? `Navy ${m.body_fat_pct}`
|
||||
: "Navy"
|
||||
}
|
||||
value={bodyMetricEditDraft.body_fat_pct}
|
||||
onChange={(e) =>
|
||||
setBodyMetricEditDraft((d) => ({ ...d, body_fat_pct: e.target.value }))
|
||||
}
|
||||
aria-label="Процент жира"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="fitness-table-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={bodyMetricEditDraft.chest_cm}
|
||||
onChange={(e) =>
|
||||
setBodyMetricEditDraft((d) => ({ ...d, chest_cm: e.target.value }))
|
||||
}
|
||||
aria-label="Грудь"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="fitness-table-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={bodyMetricEditDraft.neck_cm}
|
||||
onChange={(e) =>
|
||||
setBodyMetricEditDraft((d) => ({ ...d, neck_cm: e.target.value }))
|
||||
}
|
||||
aria-label="Шея"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="fitness-table-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={bodyMetricEditDraft.waist_cm}
|
||||
onChange={(e) =>
|
||||
setBodyMetricEditDraft((d) => ({ ...d, waist_cm: e.target.value }))
|
||||
}
|
||||
aria-label="Талия"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
className="fitness-table-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={bodyMetricEditDraft.hip_cm}
|
||||
onChange={(e) =>
|
||||
setBodyMetricEditDraft((d) => ({ ...d, hip_cm: e.target.value }))
|
||||
}
|
||||
aria-label="Бёдра"
|
||||
/>
|
||||
</td>
|
||||
<td>{m.whr ?? "—"}</td>
|
||||
<td>{m.body_fat_method ?? "—"}</td>
|
||||
<td>
|
||||
<span className="fitness-log-actions">
|
||||
<button type="button" onClick={() => handleSaveBodyMetric(m.id).catch(console.error)}>
|
||||
OK
|
||||
</button>
|
||||
<button type="button" onClick={cancelEditBodyMetric}>
|
||||
Отмена
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>{m.weight_kg}</td>
|
||||
<td>{m.body_fat_pct != null ? `≈${m.body_fat_pct}` : "—"}</td>
|
||||
<td>{m.chest_cm ?? "—"}</td>
|
||||
<td>{m.neck_cm ?? "—"}</td>
|
||||
<td>{m.waist_cm ?? "—"}</td>
|
||||
<td>{m.hip_cm ?? "—"}</td>
|
||||
<td>{m.whr ?? "—"}</td>
|
||||
<td>{m.body_fat_method ?? "—"}</td>
|
||||
<td>
|
||||
<span className="fitness-log-actions">
|
||||
<button type="button" title="Изменить" onClick={() => startEditBodyMetric(m)}>
|
||||
✎
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user