feat: Enhance activity session metrics handling and frontend display
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s

- Updated the `ACTIVITY_LOG_PATCHABLE_COLUMNS` and `ACTIVITY_LOG_PATCH_FORBIDDEN` sets to improve validation of CSV imports, ensuring only allowed fields are patched.
- Refactored the `_coerce_raw_value_for_parameter` function to handle string inputs for integer and float types, enhancing data coercion accuracy.
- Modified the `SessionMetricsFields` component to display orphan metrics that do not match the current schema, improving user visibility of imported data discrepancies.
- Enhanced the frontend to handle and display additional metrics, ensuring a more comprehensive representation of session data.
This commit is contained in:
Lars 2026-04-15 08:55:43 +02:00
parent 574af61349
commit c570e67a09
2 changed files with 82 additions and 6 deletions

View File

@ -11,10 +11,21 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence
logger = logging.getLogger(__name__)
# activity_log-Spalten (ohne Kernfelder aus CSV-Minimal-Insert), die über source_field beschrieben werden können.
# activity_log-Spalten, die per training_parameters.source_field aus CSV (Parameter-Key) befüllt werden dürfen.
# Muss mit sync_column_backed_session_metrics übereinstimmen (inkl. Kernmetriken wie hr_avg).
ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset(
{
"start_time",
"end_time",
"activity_type",
"duration_min",
"kcal_active",
"kcal_resting",
"hr_avg",
"hr_max",
"hr_min",
"distance_km",
"rpe",
"pace_min_per_km",
"cadence",
"avg_power",
@ -23,11 +34,24 @@ ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset(
"humidity_percent",
"avg_hr_percent",
"kcal_per_km",
"rpe",
"notes",
}
)
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
{
"id",
"profile_id",
"date",
"created",
"training_type_id",
"training_category",
"training_subcategory",
"source",
}
)
class ActivitySessionMetricsError(Exception):
"""Raised by Layer 1; routers map to HTTP (404/400)."""
@ -218,8 +242,14 @@ def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
if data_type == "integer":
if isinstance(raw, bool):
raise TypeError("boolean nicht als integer erlaubt")
if isinstance(raw, str):
s = raw.strip().replace(",", ".")
return int(round(float(s)))
return int(round(float(raw)))
if data_type == "float":
if isinstance(raw, str):
s = raw.strip().replace(",", ".")
return float(s)
return float(raw)
if data_type == "string":
return str(raw) if raw is not None else ""
@ -248,7 +278,9 @@ def resolve_activity_log_column_patch_from_csv(
patch: Dict[str, Any] = {}
for spec in schema:
src_col = (spec.get("source_field") or "").strip()
if not src_col or src_col not in ACTIVITY_LOG_PATCHABLE_COLUMNS:
if not src_col or src_col in ACTIVITY_LOG_PATCH_FORBIDDEN:
continue
if src_col not in ACTIVITY_LOG_PATCHABLE_COLUMNS:
continue
pkey = spec["key"]
if pkey not in mapped:

View File

@ -118,13 +118,18 @@ function buildMetricsPayload(schema, draft) {
return out
}
function SessionMetricsFields({ schema, values, setValues }) {
if (!schema || schema.length === 0) return null
function SessionMetricsFields({ schema, values, setValues, metrics }) {
const schemaList = Array.isArray(schema) ? schema : []
const metricRows = Array.isArray(metrics) ? metrics : []
const schemaKeys = new Set(schemaList.map((s) => s.key))
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key))
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
return (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
{schema.map((s) => (
{schemaList.map((s) => (
<div key={s.key} className="form-row">
<label className="form-label">
{s.name_de}
@ -157,6 +162,44 @@ function SessionMetricsFields({ schema, values, setValues }) {
<span className="form-unit" />
</div>
))}
{orphanMetrics.length > 0 && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
Werte aus Import/älteren Daten, die zum <strong>aktuellen</strong> Trainingsprofil dieser Session (Kategorie/Typ
in activity_log) nicht ins Schema passen nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der
Datenbank stehen.
</div>
{orphanMetrics.map((row) => {
const disp =
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
? '—'
: String(values[row.key])
return (
<div key={row.key} className="form-row">
<label className="form-label">
{row.key}
{row.unit ? ` (${row.unit})` : ''}
</label>
{row.data_type === 'boolean' ? (
<input type="checkbox" style={{ width: 'auto', marginRight: 'auto' }} checked={!!values[row.key]} readOnly disabled />
) : (
<div
className="form-input"
style={{
background: 'var(--surface2)',
cursor: 'default',
color: 'var(--text1)',
}}
>
{disp}
</div>
)}
<span className="form-unit" />
</div>
)
})}
</div>
)}
</div>
)
}
@ -699,6 +742,7 @@ export default function ActivityPage() {
)}
<SessionMetricsFields
schema={sessionDetail?.schema}
metrics={sessionDetail?.metrics}
values={metricDraft}
setValues={setMetricDraft}
/>