feat: Update widget catalog and enhance dashboard layout features
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Added new "Dashboard-Lab-Widgets" entry to the documentation for better guidance on widget configuration.
- Updated the app_dashboard version to 1.8.0 to reflect the introduction of widget catalog features and layout entitlements.
- Enhanced widget catalog entries to include optional feature requirements for better visibility and access control.
- Improved the DashboardLabPage to manage widget visibility based on feature entitlements, ensuring a more tailored user experience.
This commit is contained in:
Lars 2026-04-08 07:21:49 +02:00
parent bc91396885
commit 9bc0cf70da
8 changed files with 242 additions and 32 deletions

View File

@ -7,6 +7,7 @@
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
## Claude Code Verantwortlichkeiten
@ -842,6 +843,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
> Library-Dateien werden mit `/document` generiert und nach größeren
> Änderungen aktualisiert.

View File

@ -0,0 +1,79 @@
"""
Dashboard-Widgets × Feature-System: Sichtbarkeit aus check_feature_access.
Katalog-Einträge optional `requires_feature` (features.id). Fehlt der Key immer erlaubt.
"""
from __future__ import annotations
import copy
from typing import Any
from widget_catalog import WIDGET_CATALOG
def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict:
"""Indirection für Tests (monkeypatch) und spätes Laden von auth (bcrypt)."""
from auth import check_feature_access
return check_feature_access(profile_id, feature_id, conn)
_WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CATALOG}
def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool:
entry = _WIDGET_ENTRY_BY_ID.get(widget_id)
if entry is None:
return False
fid = entry.get("requires_feature")
if not fid:
return True
return bool(_check_feature_access(profile_id, fid, conn)["allowed"])
def _public_row(entry: dict[str, Any], *, allowed: bool) -> dict[str, Any]:
return {
"id": entry["id"],
"title": entry["title"],
"description": entry["description"],
"allowed": allowed,
}
def widgets_catalog_for_profile(profile_id: str, conn) -> list[dict[str, Any]]:
"""Zeilen für GET /api/app/widgets/catalog (ohne internes requires_feature-Feld)."""
out: list[dict[str, Any]] = []
for e in WIDGET_CATALOG:
fid = e.get("requires_feature")
allowed = True
if fid:
allowed = bool(_check_feature_access(profile_id, fid, conn)["allowed"])
out.append(_public_row(e, allowed=allowed))
return out
def widgets_catalog_payload(profile_id: str, conn) -> dict[str, Any]:
return {
"catalog_version": 1,
"widgets": widgets_catalog_for_profile(profile_id, conn),
}
def apply_entitlements_to_layout_dict(layout: dict[str, Any], profile_id: str, conn) -> dict[str, Any]:
"""
Setzt enabled=False für Widgets ohne Berechtigung. Mindestens ein Widget bleibt aktiv (welcome).
"""
out = copy.deepcopy(layout)
widgets = out.get("widgets") or []
for w in widgets:
wid = w.get("id")
if not wid:
continue
if w.get("enabled") and not widget_id_allowed(wid, profile_id, conn):
w["enabled"] = False
if not any(w.get("enabled") for w in widgets):
for w in widgets:
if w.get("id") == "welcome":
w["enabled"] = True
break
return out

View File

@ -10,18 +10,23 @@ from psycopg2.extras import Json
from auth import require_auth
from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
from db import get_cursor, get_db
from routers.profiles import get_pid
from widget_catalog import catalog_response
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
@router.get("/widgets/catalog")
def get_widgets_catalog(session: dict = Depends(require_auth)) -> dict[str, Any]:
"""Metadaten aller registrierbaren Dashboard-Widgets (IDs, Titel)."""
def get_widgets_catalog(
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
) -> dict[str, Any]:
"""Katalog inkl. allowed pro Widget (Feature / Subscription, effektiver Tier)."""
_ = session
return catalog_response()
pid = get_pid(x_profile_id)
with get_db() as conn:
return widgets_catalog_payload(pid, conn)
@router.get("/dashboard-layout")
@ -40,10 +45,13 @@ def get_dashboard_layout(
row = cur.fetchone()
raw = row["dashboard_layout"] if row else None
custom, effective = coalesce_effective_layout(raw)
with get_db() as conn:
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
default_adj = apply_entitlements_to_layout_dict(default_layout_dict(), pid, conn)
return {
"custom": custom,
"layout": effective,
"default_layout": default_layout_dict(),
"default_layout": default_adj,
}
@ -59,6 +67,12 @@ def put_dashboard_layout(
payload = DashboardLayoutPayload.model_validate(body)
except Exception as e:
raise HTTPException(422, str(e)) from e
with get_db() as conn:
adjusted = apply_entitlements_to_layout_dict(payload.to_stored_dict(), pid, conn)
try:
payload = DashboardLayoutPayload.model_validate(adjusted)
except Exception as e:
raise HTTPException(422, str(e)) from e
stored = payload.to_stored_dict()
with get_db() as conn:
cur = get_cursor(conn)

View File

@ -0,0 +1,57 @@
from dashboard_layout_schema import DashboardLayoutPayload
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widget_id_allowed
def test_apply_entitlements_disables_widget_without_access(monkeypatch):
monkeypatch.setattr(
"dashboard_widget_entitlements.widget_id_allowed",
lambda wid, pid, conn: wid != "nutrition_detail_charts",
)
raw = {
"version": 1,
"widgets": [
{"id": "welcome", "enabled": True},
{"id": "nutrition_detail_charts", "enabled": True},
],
}
out = apply_entitlements_to_layout_dict(raw, "p", None)
assert {w["id"]: w["enabled"] for w in out["widgets"]} == {
"welcome": True,
"nutrition_detail_charts": False,
}
def test_apply_entitlements_leaves_welcome_on_when_all_blocked(monkeypatch):
monkeypatch.setattr(
"dashboard_widget_entitlements.widget_id_allowed",
lambda wid, pid, conn: False,
)
raw = {
"version": 1,
"widgets": [
{"id": "welcome", "enabled": False},
{"id": "nutrition_detail_charts", "enabled": False},
],
}
out = apply_entitlements_to_layout_dict(raw, "p", None)
assert any(w["id"] == "welcome" and w["enabled"] for w in out["widgets"])
def test_widget_id_allowed_false_for_unknown_id():
assert widget_id_allowed("not-a-widget", "p", None) is False
def test_full_default_layout_still_validates_after_entitlements(monkeypatch):
monkeypatch.setattr(
"dashboard_widget_entitlements.widget_id_allowed",
lambda wid, pid, conn: wid != "ai_pipeline_insight",
)
from dashboard_layout_schema import default_layout_dict
d = default_layout_dict()
d["widgets"] = [{**x, "enabled": x["id"] == "ai_pipeline_insight"} for x in d["widgets"]]
adj = apply_entitlements_to_layout_dict(d, "p", None)
p2 = DashboardLayoutPayload.model_validate(adj)
ai = next(w for w in p2.widgets if w.id == "ai_pipeline_insight")
assert ai.enabled is False
assert any(w.enabled for w in p2.widgets)

View File

@ -1,7 +1,8 @@
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
from dashboard_layout_schema import default_layout_dict
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG, catalog_response
from dashboard_widget_entitlements import widgets_catalog_payload
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
def test_catalog_ids_unique_and_match_allowed():
@ -20,8 +21,27 @@ def test_default_layout_follows_catalog_order():
assert any(w["enabled"] for w in d["widgets"])
def test_catalog_response_shape():
r = catalog_response()
def test_catalog_payload_shape(monkeypatch):
monkeypatch.setattr(
"dashboard_widget_entitlements._check_feature_access",
lambda *args, **kwargs: {"allowed": True},
)
r = widgets_catalog_payload("test-profile", None)
assert r["catalog_version"] == 1
assert len(r["widgets"]) == len(WIDGET_CATALOG)
assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS
for w in r["widgets"]:
assert set(w.keys()) == {"id", "title", "description", "allowed"}
assert w["allowed"] is True
def test_catalog_marks_disallowed_when_feature_blocks(monkeypatch):
def _check(_pid, feature_id, conn=None):
return {"allowed": feature_id != "nutrition_entries"}
monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", _check)
r = widgets_catalog_payload("p", None)
by_id = {w["id"]: w for w in r["widgets"]}
assert by_id["welcome"]["allowed"] is True
assert by_id["nutrition_detail_charts"]["allowed"] is False
assert by_id["body_overview"]["allowed"] is True

View File

@ -30,7 +30,7 @@ MODULE_VERSIONS = {
"importdata": "1.0.0",
"membership": "2.1.0",
"workflow": "0.6.0", # Phase 4: End Node Template Engine
"app_dashboard": "1.7.0", # nutrition_detail_charts, recovery_charts_panel, progress_photos
"app_dashboard": "1.8.0", # widget catalog allowed via features; layout entitlements on GET/PUT
}
CHANGELOG = [

View File

@ -6,13 +6,16 @@ Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/regist
"""
from __future__ import annotations
from typing import Any, TypedDict
from typing import Any, NotRequired, TypedDict
class WidgetCatalogEntry(TypedDict):
"""requires_feature: optional features.id; fehlt oder leer → Widget immer sichtbar (nur Auth)."""
id: str
title: str
description: str
requires_feature: NotRequired[str]
# Reihenfolge = Default-Layout-Reihenfolge. Aktiv-Flags: DEFAULT_LAB_WIDGET_IDS (Rest zunächst aus).
@ -25,7 +28,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "quick_capture",
"title": "Schnelleingabe",
"description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus)",
"description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus); Feature weight_entries",
"requires_feature": "weight_entries",
},
{
"id": "kpi_board",
@ -35,12 +39,14 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "body_overview",
"title": "Körper (Chart)",
"description": "Gewicht & Kennzahlen (optional: config chart_days 790)",
"description": "Gewicht & Kennzahlen (optional: config chart_days 790); Feature weight_entries",
"requires_feature": "weight_entries",
},
{
"id": "activity_overview",
"title": "Aktivität",
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 790",
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 790; Feature activity_entries",
"requires_feature": "activity_entries",
},
{
"id": "dashboard_greeting",
@ -50,17 +56,20 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "quick_weight_today",
"title": "Gewicht heute",
"description": "Tagesgewicht erfassen (wie Produkt-Dashboard)",
"description": "Tagesgewicht erfassen (wie Produkt-Dashboard); Feature weight_entries",
"requires_feature": "weight_entries",
},
{
"id": "body_stat_strip",
"title": "Kennzahlen-Kacheln",
"description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe",
"description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe; u. a. nutrition_entries (Ø-kcal)",
"requires_feature": "nutrition_entries",
},
{
"id": "status_pills",
"title": "Indikatoren (Pills)",
"description": "WHR, WHtR, Protein, KF",
"description": "WHR, WHtR, Protein, KF; Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{
"id": "profile_goals_progress",
@ -70,17 +79,20 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "trend_kcal_weight",
"title": "Trend Kalorien + Gewicht",
"description": "Linienchart (optional config chart_days 790, Default 30)",
"description": "Linienchart (optional config chart_days 790, Default 30); Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{
"id": "nutrition_activity_summary",
"title": "Ernährung & Aktivität Kurz",
"description": "Ø 7T Kacheln",
"description": "Ø 7T Kacheln; Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{
"id": "nutrition_detail_charts",
"title": "Ernährung — Detaillierte Charts",
"description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30)",
"description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30); Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{
"id": "recovery_charts_panel",
@ -90,7 +102,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "progress_photos",
"title": "Fortschrittsfotos",
"description": "Galerie der hochgeladenen Fotos",
"description": "Galerie der hochgeladenen Fotos; Feature photos",
"requires_feature": "photos",
},
{
"id": "recovery_sleep_rest",
@ -105,7 +118,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
{
"id": "ai_pipeline_insight",
"title": "KI Pipeline & letzte Analyse",
"description": "Pipeline starten + Gesamt-Insight",
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
"requires_feature": "ai_pipeline",
},
]
@ -120,11 +134,3 @@ DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
)
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
def catalog_response() -> dict[str, Any]:
"""Payload für GET /api/app/widgets/catalog."""
return {
"catalog_version": 1,
"widgets": list(WIDGET_CATALOG),
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api, formatFastApiDetail } from '../utils/api'
@ -43,6 +43,35 @@ export default function DashboardLabPage() {
const metaById = catalogMetaById(catalog)
const isWidgetCatalogAllowed = useCallback(
(widgetId) => {
const m = metaById[widgetId]
if (m == null) return true
return m.allowed !== false
},
[metaById],
)
const visibleEditorIndices = useMemo(
() =>
layout?.widgets?.map((_, i) => i).filter((i) => isWidgetCatalogAllowed(layout.widgets[i].id)) ?? [],
[layout, isWidgetCatalogAllowed],
)
const layoutForPreview = useMemo(
() =>
layout
? {
...layout,
widgets: layout.widgets.map((w) => ({
...w,
enabled: w.enabled && isWidgetCatalogAllowed(w.id),
})),
}
: null,
[layout, isWidgetCatalogAllowed],
)
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
const clamped = normalizeBodyChartDays(
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
@ -189,7 +218,8 @@ export default function DashboardLabPage() {
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
{layout.widgets.map((w, i) => {
{visibleEditorIndices.map((i) => {
const w = layout.widgets[i]
const label = metaById[w.id]?.title || w.id
const chartDaysVal =
w.config?.chart_days != null
@ -361,7 +391,9 @@ export default function DashboardLabPage() {
</div>
</div>
<WidgetRenderer layout={layout} refreshTick={refreshTick} requestRefresh={requestRefresh} />
{layoutForPreview && (
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
)}
</div>
)
}