diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index 2633540..dddcca9 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -21,6 +21,8 @@ Modules: - utils: Shared functions (confidence, baseline, outliers) - training_profile: Template-based training evaluation scaffold (Layer 1) Import: ``from data_layer.training_profile import resolve_training_evaluation`` + - reference_values: Profile reference value reads + summary (Layer 1) + Import: ``from data_layer import reference_values`` or submodule imports Phase 0c: Multi-Layer Architecture Version: 1.0 diff --git a/backend/data_layer/reference_values.py b/backend/data_layer/reference_values.py new file mode 100644 index 0000000..bc4f8b2 --- /dev/null +++ b/backend/data_layer/reference_values.py @@ -0,0 +1,179 @@ +""" +Reference values — Layer 1 (read paths + structured rows) + +Structured retrieval for profile reference values and the active type catalog. +Mutations (INSERT/UPDATE/DELETE) stay in routers/reference_values.py with validation. + +Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API layers. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Optional + +from db import get_cursor, get_db, r2d + + +def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]: + """Normalize DB row dict for JSON (dates → ISO, Decimal → float).""" + if not d: + return d + out = dict(d) + for k in ("effective_date", "created_at", "updated_at"): + v = out.get(k) + if v is not None and hasattr(v, "isoformat"): + out[k] = v.isoformat() + vn = out.get("value_numeric") + if vn is not None and isinstance(vn, Decimal): + out["value_numeric"] = float(vn) + return out + + +def fetch_reference_type_by_key(cur, key: str, require_active: bool = True) -> Optional[dict[str, Any]]: + """Single type row by key; for use inside router transactions (shared cursor).""" + q = ( + "SELECT id, key, label, description, default_unit, active, category, " + "value_data_type, validation_rules, metadata " + "FROM reference_value_types WHERE key = %s " + ) + if require_active: + q += "AND active = TRUE " + cur.execute(q, (key,)) + row = cur.fetchone() + return r2d(row) if row else None + + +def list_active_reference_value_types_data() -> list[dict[str, Any]]: + """All active reference_value_types rows, catalog sort order.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT + id, key, label, description, default_unit, sort_order, active, + category, value_data_type, validation_rules, metadata, created_at + FROM reference_value_types + WHERE active = TRUE + ORDER BY sort_order ASC, id ASC + """ + ) + return [normalize_reference_row(r2d(r)) for r in cur.fetchall()] + + +def list_profile_reference_values_for_type( + profile_id: str, type_key: str +) -> Optional[list[dict[str, Any]]]: + """ + Historical entries for one type, newest first. + Returns None if type_key does not resolve to an active type. + """ + with get_db() as conn: + cur = get_cursor(conn) + t = fetch_reference_type_by_key(cur, type_key, require_active=True) + if not t: + return None + cur.execute( + """ + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.profile_id = %s AND rt.key = %s + ORDER BY v.effective_date DESC, v.created_at DESC + """, + (profile_id, t["key"]), + ) + return [normalize_reference_row(r2d(r)) for r in cur.fetchall()] + + +def build_summary_tiles_from_ranked_rows(raw_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Build /summary.tile payloads from SQL rows (rn 1..2 per type). + Each row still contains rn, type_sort_order, value_data_type before stripping. + """ + by_key: dict[str, dict[str, Any]] = {} + skip_cols = frozenset({"rn", "type_sort_order", "value_data_type"}) + for row in raw_rows: + rn = int(row.get("rn") or 0) + key = row["type_key"] + if key not in by_key: + by_key[key] = { + "type_key": key, + "type_label": row.get("type_label") or key, + "value_data_type": (row.get("value_data_type") or "decimal").strip().lower(), + "sort_key": (row.get("type_sort_order") or 0, key), + "latest": None, + "previous": None, + } + entry = {k: v for k, v in row.items() if k not in skip_cols} + api_entry = normalize_reference_row(entry) + if rn == 1: + by_key[key]["latest"] = api_entry + elif rn == 2: + by_key[key]["previous"] = api_entry + + tiles = sorted(by_key.values(), key=lambda t: t["sort_key"]) + for t in tiles: + t.pop("sort_key", None) + return tiles + + +def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]: + """latest + previous entry per type (active types only), tiles sorted like catalog.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + WITH ranked AS ( + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label, + rt.sort_order AS type_sort_order, + rt.value_data_type, + ROW_NUMBER() OVER ( + PARTITION BY v.reference_value_type_id + ORDER BY v.effective_date DESC, v.created_at DESC + ) AS rn + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.profile_id = %s AND rt.active = TRUE + ) + SELECT * FROM ranked WHERE rn <= 2 + ORDER BY type_sort_order ASC, type_key ASC, rn ASC + """, + (profile_id,), + ) + raw_rows = [r2d(r) for r in cur.fetchall()] + + tiles = build_summary_tiles_from_ranked_rows(raw_rows) + return {"tiles": tiles} diff --git a/backend/routers/reference_values.py b/backend/routers/reference_values.py index 8dd1d69..ddbfaa5 100644 --- a/backend/routers/reference_values.py +++ b/backend/routers/reference_values.py @@ -3,6 +3,8 @@ Persönliche Referenzwerte (profilorientiert) Typkatalog system-seeded; Nutzer pflegt historische Einträge pro aktivem Profil. Einheit immer aus dem Typ; Wert je value_data_type validiert. + +Reads (Liste, Summary, Katalog) → data_layer.reference_values (Layer 1). """ from __future__ import annotations @@ -15,6 +17,13 @@ from pydantic import BaseModel, Field from psycopg2.extras import Json from auth import require_auth +from data_layer.reference_values import ( + fetch_reference_type_by_key, + get_profile_reference_values_summary, + list_active_reference_value_types_data, + list_profile_reference_values_for_type, + normalize_reference_row, +) from db import get_db, get_cursor, r2d from reference_value_validation import ( REF_VALUE_CONFIDENCE, @@ -32,40 +41,6 @@ from routers.profiles import get_pid router = APIRouter(prefix="/api", tags=["reference-values"]) -def _row_to_api(d: dict[str, Any]) -> dict[str, Any]: - if not d: - return d - out = dict(d) - ed = out.get("effective_date") - if ed is not None and hasattr(ed, "isoformat"): - out["effective_date"] = ed.isoformat() - ca = out.get("created_at") - if ca is not None and hasattr(ca, "isoformat"): - out["created_at"] = ca.isoformat() - ua = out.get("updated_at") - if ua is not None and hasattr(ua, "isoformat"): - out["updated_at"] = ua.isoformat() - vn = out.get("value_numeric") - if vn is not None and isinstance(vn, Decimal): - out["value_numeric"] = float(vn) - return out - - -def _get_type_by_key(cur, key: str, require_active: bool = True) -> dict: - q = ( - "SELECT id, key, label, description, default_unit, active, category, " - "value_data_type, validation_rules, metadata " - "FROM reference_value_types WHERE key = %s " - ) - if require_active: - q += "AND active = TRUE " - cur.execute(q, (key,)) - row = cur.fetchone() - if not row: - raise HTTPException(404, "Referenztyp nicht gefunden") - return r2d(row) - - class ProfileReferenceValueCreate(BaseModel): reference_value_type_key: str = Field(..., min_length=1, max_length=64) effective_date: str @@ -92,19 +67,7 @@ class ProfileReferenceValueUpdate(BaseModel): @router.get("/reference-value-types") def list_reference_value_types(session: dict = Depends(require_auth)): """Alle aktiven Referenztyp-Definitionen (dynamische UI inkl. Validierungsmetadaten).""" - with get_db() as conn: - cur = get_cursor(conn) - cur.execute( - """ - SELECT - id, key, label, description, default_unit, sort_order, active, - category, value_data_type, validation_rules, metadata, created_at - FROM reference_value_types - WHERE active = TRUE - ORDER BY sort_order ASC, id ASC - """ - ) - return [_row_to_api(r2d(r)) for r in cur.fetchall()] + return list_active_reference_value_types_data() @router.get("/reference-value-meta/enums") @@ -127,71 +90,7 @@ def profile_reference_values_summary( plus der unmittelbar vorherige (gleiche Sortierung wie Liste), für Tendenz-Anzeigen. """ pid = get_pid(x_profile_id) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute( - """ - WITH ranked AS ( - SELECT - v.id, - v.profile_id, - v.reference_value_type_id, - v.effective_date, - v.value_numeric, - v.value_text, - v.unit, - v.source, - v.confidence, - v.method, - v.notes, - v.extra, - v.created_at, - v.updated_at, - rt.key AS type_key, - rt.label AS type_label, - rt.sort_order AS type_sort_order, - rt.value_data_type, - ROW_NUMBER() OVER ( - PARTITION BY v.reference_value_type_id - ORDER BY v.effective_date DESC, v.created_at DESC - ) AS rn - FROM profile_reference_values v - JOIN reference_value_types rt ON rt.id = v.reference_value_type_id - WHERE v.profile_id = %s AND rt.active = TRUE - ) - SELECT * FROM ranked WHERE rn <= 2 - ORDER BY type_sort_order ASC, type_key ASC, rn ASC - """, - (pid,), - ) - raw_rows = [r2d(r) for r in cur.fetchall()] - - by_key: dict[str, dict[str, Any]] = {} - skip_cols = frozenset({"rn", "type_sort_order", "value_data_type"}) - for row in raw_rows: - rn = int(row.get("rn") or 0) - key = row["type_key"] - if key not in by_key: - by_key[key] = { - "type_key": key, - "type_label": row.get("type_label") or key, - "value_data_type": (row.get("value_data_type") or "decimal").strip().lower(), - "sort_key": (row.get("type_sort_order") or 0, key), - "latest": None, - "previous": None, - } - entry = {k: v for k, v in row.items() if k not in skip_cols} - api_entry = _row_to_api(entry) - if rn == 1: - by_key[key]["latest"] = api_entry - elif rn == 2: - by_key[key]["previous"] = api_entry - - tiles = sorted(by_key.values(), key=lambda t: t["sort_key"]) - for t in tiles: - t.pop("sort_key", None) - - return {"tiles": tiles} + return get_profile_reference_values_summary(pid) @router.get("/profile-reference-values") @@ -202,36 +101,10 @@ def list_profile_reference_values( ): """Historische Einträge eines Typs für das aktive Profil (neueste zuerst).""" pid = get_pid(x_profile_id) - with get_db() as conn: - cur = get_cursor(conn) - t = _get_type_by_key(cur, type_key, require_active=True) - cur.execute( - """ - SELECT - v.id, - v.profile_id, - v.reference_value_type_id, - v.effective_date, - v.value_numeric, - v.value_text, - v.unit, - v.source, - v.confidence, - v.method, - v.notes, - v.extra, - v.created_at, - v.updated_at, - rt.key AS type_key, - rt.label AS type_label - FROM profile_reference_values v - JOIN reference_value_types rt ON rt.id = v.reference_value_type_id - WHERE v.profile_id = %s AND rt.key = %s - ORDER BY v.effective_date DESC, v.created_at DESC - """, - (pid, t["key"]), - ) - return [_row_to_api(r2d(r)) for r in cur.fetchall()] + rows = list_profile_reference_values_for_type(pid, type_key) + if rows is None: + raise HTTPException(404, "Referenztyp nicht gefunden") + return rows @router.post("/profile-reference-values") @@ -252,7 +125,9 @@ def create_profile_reference_value( with get_db() as conn: cur = get_cursor(conn) - t = _get_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True) + t = fetch_reference_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True) + if not t: + raise HTTPException(404, "Referenztyp nicht gefunden") vdt = (t.get("value_data_type") or "decimal").strip().lower() rules = t.get("validation_rules") or {} vnum, vtxt = validate_value_for_data_type(vdt, rules, body.value_numeric, body.value_text) @@ -309,7 +184,7 @@ def create_profile_reference_value( """, (new_id, pid), ) - return _row_to_api(r2d(cur.fetchone())) + return normalize_reference_row(r2d(cur.fetchone())) @router.put("/profile-reference-values/{entry_id}") @@ -424,7 +299,7 @@ def update_profile_reference_value( """, (entry_id, pid), ) - return _row_to_api(r2d(cur.fetchone())) + return normalize_reference_row(r2d(cur.fetchone())) @router.delete("/profile-reference-values/{entry_id}") diff --git a/backend/tests/test_reference_values_data_layer.py b/backend/tests/test_reference_values_data_layer.py new file mode 100644 index 0000000..aca4dd6 --- /dev/null +++ b/backend/tests/test_reference_values_data_layer.py @@ -0,0 +1,48 @@ +"""Unit tests for data_layer.reference_values (summary assembly, no DB).""" + +from data_layer.reference_values import build_summary_tiles_from_ranked_rows + + +def test_build_summary_tiles_single_type_two_rows(): + raw = [ + { + "type_key": "hr_max", + "type_label": "HF max", + "type_sort_order": 1, + "value_data_type": "decimal", + "rn": 1, + "id": 2, + "effective_date": "2026-04-01", + "value_numeric": 180.0, + "value_text": None, + "unit": "bpm", + }, + { + "type_key": "hr_max", + "type_label": "HF max", + "type_sort_order": 1, + "value_data_type": "decimal", + "rn": 2, + "id": 1, + "effective_date": "2026-03-01", + "value_numeric": 175.0, + "value_text": None, + "unit": "bpm", + }, + ] + tiles = build_summary_tiles_from_ranked_rows(raw) + assert len(tiles) == 1 + t = tiles[0] + assert t["type_key"] == "hr_max" + assert t["latest"]["value_numeric"] == 180.0 + assert t["previous"]["value_numeric"] == 175.0 + assert "sort_key" not in t + + +def test_build_summary_tiles_multi_type_order(): + raw = [ + {"type_key": "b", "type_label": "B", "type_sort_order": 2, "value_data_type": "decimal", "rn": 1, "id": 1}, + {"type_key": "a", "type_label": "A", "type_sort_order": 1, "value_data_type": "decimal", "rn": 1, "id": 2}, + ] + tiles = build_summary_tiles_from_ranked_rows(raw) + assert [x["type_key"] for x in tiles] == ["a", "b"] diff --git a/backend/version.py b/backend/version.py index 3421439..47d2c3d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -14,7 +14,7 @@ DB_SCHEMA_VERSION = "20260406b" # Migration 038 MODULE_VERSIONS = { "auth": "1.2.0", "profiles": "1.1.0", - "reference_values": "1.2.0", + "reference_values": "1.3.0", "admin_reference_value_types": "1.0.0", "weight": "1.0.3", "circumference": "1.0.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a8f2f7f..cd4f495 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,6 +24,7 @@ import Analysis from './pages/Analysis' import SettingsPage from './pages/SettingsPage' import SettingsShell from './layouts/SettingsShell' import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' +import PilotVizPage from './pages/PilotVizPage' import GuidePage from './pages/GuidePage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminFeaturesPage from './pages/AdminFeaturesPage' @@ -253,6 +254,7 @@ function AppShell() { }/> }/> + } /> diff --git a/frontend/src/components/pilot/ReferenceValuesSummaryWidget.jsx b/frontend/src/components/pilot/ReferenceValuesSummaryWidget.jsx new file mode 100644 index 0000000..df30d61 --- /dev/null +++ b/frontend/src/components/pilot/ReferenceValuesSummaryWidget.jsx @@ -0,0 +1,88 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../../utils/api' + +function formatEntryValue(row) { + if (row.value_numeric != null && row.value_numeric !== '') { + const n = Number(row.value_numeric) + return Number.isFinite(n) ? String(n) : String(row.value_numeric) + } + return row.value_text != null ? String(row.value_text) : '–' +} + +export default function ReferenceValuesSummaryWidget() { + const [tiles, setTiles] = useState([]) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const data = await api.listProfileReferenceValuesSummary() + if (!cancelled) { + setTiles(Array.isArray(data?.tiles) ? data.tiles : []) + setErr(null) + } + } catch (e) { + if (!cancelled) { + setErr(e.message || 'Laden fehlgeschlagen') + setTiles([]) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + + if (loading) { + return ( +
+
+
+ ) + } + if (err) { + return
{err}
+ } + if (tiles.length === 0) { + return ( +

+ Noch keine Referenzwerte erfasst.{' '} + + Zur Erfassung + +

+ ) + } + + return ( +
+ {tiles + .filter((t) => t?.latest) + .map((tile) => ( +
+
{tile.type_label}
+
+ {formatEntryValue(tile.latest)} + {tile.latest.unit ? ( + + {tile.latest.unit} + + ) : null} +
+
+ Stand {String(tile.latest.effective_date || '').slice(0, 10)} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/pages/PilotVizPage.jsx b/frontend/src/pages/PilotVizPage.jsx new file mode 100644 index 0000000..a539fab --- /dev/null +++ b/frontend/src/pages/PilotVizPage.jsx @@ -0,0 +1,49 @@ +import { FlaskConical } from 'lucide-react' +import { Link } from 'react-router-dom' +import { getPilotWidgetsInOrder } from '../pilot/widgetRegistry' + +/** + * Pilot: Widget-Schicht (Layer 3b) parallel zum produktiven Dashboard. + * Daten für Referenzwerte: Layer 1 (backend/data_layer/reference_values.py) → API. + */ +export default function PilotVizPage() { + const widgets = getPilotWidgetsInOrder() + + return ( +
+
+ + ← Einstellungen + +

+ + Pilot: Visualisierungs-Module +

+

+ Testumgebung für die zukünftige Widget-Registry. Die produktive Übersicht und der Verlauf bleiben + unverändert. Referenzwerte-Summary wird über die gleiche API geladen wie in der Erfassungs-UI, angeboten + durch den Data Layer (Layer 1). +

+
+ + {widgets.map((def) => { + const { Component } = def + return ( +
+
{def.title}
+ {def.description && ( +

+ {def.description} +

+ )} + +
+ ) + })} +
+ ) +} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index a3d881c..59ea368 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -441,6 +441,25 @@ export default function SettingsPage() {
+
+
+ Pilot: Visualisierungs-Module +
+

+ Testumgebung für die Widget-Schicht (Layer 3b). Die Übersicht und der Verlauf bleiben unverändert. +

+ + Pilot öffnen + +
+ {/* Auth actions */}
🔐 Konto
diff --git a/frontend/src/pilot/widgetRegistry.js b/frontend/src/pilot/widgetRegistry.js new file mode 100644 index 0000000..3840a56 --- /dev/null +++ b/frontend/src/pilot/widgetRegistry.js @@ -0,0 +1,20 @@ +import ReferenceValuesSummaryWidget from '../components/pilot/ReferenceValuesSummaryWidget' + +/** + * Pilot: konfigurierbare Widget-Reihenfolge (Layer 3b-Vorspiel). + * Nur für /pilot/viz – produktives Dashboard bleibt unverändert. + */ +export const PILOT_WIDGET_ORDER = ['reference_values_summary'] + +export const PILOT_WIDGET_DEFS = { + reference_values_summary: { + id: 'reference_values_summary', + title: 'Referenzwerte', + description: 'Aktuellste Kennwerte pro Typ (Daten via Layer 1 → bestehende API).', + Component: ReferenceValuesSummaryWidget, + }, +} + +export function getPilotWidgetsInOrder() { + return PILOT_WIDGET_ORDER.map((id) => PILOT_WIDGET_DEFS[id]).filter(Boolean) +}