fixes graphics

This commit is contained in:
2026-07-01 05:04:47 +00:00
parent 1313a12a20
commit 3e48b3a41c
9 changed files with 543 additions and 50 deletions
+27
View File
@@ -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,
+50 -1
View File
@@ -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()
+8 -17
View File
@@ -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:
+85
View File
@@ -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,
*,
+60 -4
View File
@@ -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
+50 -1
View File
@@ -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()
+21
View File
@@ -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" }),
+10
View File
@@ -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
View File
@@ -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>