feat: Implement focus area usage types management in API and UI
- Added endpoints for listing and updating focus area usage types in the backend. - Enhanced the AdminFocusAreasPage to display and manage allowed usage types for focus areas. - Introduced a new state for usage types catalog and integrated it into the focus area editing process. - Updated API utility functions to support new usage types operations.
This commit is contained in:
parent
49e9c9c214
commit
e7dedd527f
20
backend/focus_area_usage_helpers.py
Normal file
20
backend/focus_area_usage_helpers.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Kleine Helfer für Focus-Area-Nutzungstypen (ohne Router-/Auth-Abhängigkeiten)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, List
|
||||
|
||||
|
||||
def coerce_usage_type_keys(raw: Any) -> List[str]:
|
||||
"""json_agg / JSON-Spalte zu list[str] normalisieren."""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(x) for x in raw]
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return [str(x) for x in data] if isinstance(data, list) else []
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return []
|
||||
36
backend/migrations/036_focus_area_usage_types.sql
Normal file
36
backend/migrations/036_focus_area_usage_types.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
-- Migration 036: Focus Area — erlaubte Nutzungstypen (Referenz + M:N)
|
||||
-- Date: 2026-04-06
|
||||
-- Purpose: System-seeded usage types; optional Zuordnung pro Focus Area (kein Auto-Backfill)
|
||||
|
||||
-- Referenztabelle: feste, systemdefinierte Nutzungstypen
|
||||
CREATE TABLE IF NOT EXISTS focus_area_usage_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key VARCHAR(64) UNIQUE NOT NULL,
|
||||
label_de VARCHAR(160),
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE focus_area_usage_types IS
|
||||
'Systemdefinierte Nutzungsarten für Focus Areas (kein Admin-CRUD in v1)';
|
||||
|
||||
-- M:N: welche Nutzungstypen für eine Focus Area erlaubt sind (leer = noch nicht klassifiziert)
|
||||
CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types (
|
||||
focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE,
|
||||
usage_type_id UUID NOT NULL REFERENCES focus_area_usage_types(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (focus_area_id, usage_type_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fadut_focus_area ON focus_area_definition_usage_types(focus_area_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fadut_usage_type ON focus_area_definition_usage_types(usage_type_id);
|
||||
|
||||
COMMENT ON TABLE focus_area_definition_usage_types IS
|
||||
'Zuordnung Focus Area → erlaubte Nutzungstypen (kein automatisches Befüllen bestehender Areas)';
|
||||
|
||||
-- Seed: nur die drei Typen — keine Zeilen in der Junction-Tabelle
|
||||
INSERT INTO focus_area_usage_types (key, label_de, sort_order) VALUES
|
||||
('goal_priority', 'Ziele / Prioritäten', 1),
|
||||
('expected_training_effect', 'Erwartetes Trainingseffekt-Profil', 2),
|
||||
('concrete_training_contribution', 'Konkrete Trainings-Beiträge / Belastungsausprägung', 3)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
|
@ -3,10 +3,11 @@ Focus Areas Router
|
|||
Manages dynamic focus area definitions and user preferences
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from focus_area_usage_helpers import coerce_usage_type_keys
|
||||
|
||||
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
||||
|
||||
|
|
@ -36,6 +37,11 @@ class UserFocusPreferences(BaseModel):
|
|||
"""User's focus area weightings (dynamic)"""
|
||||
preferences: dict # {focus_area_id: weight_pct}
|
||||
|
||||
|
||||
class FocusAreaUsageTypesUpdate(BaseModel):
|
||||
"""Replace all usage-type assignments for one focus area (admin)."""
|
||||
usage_type_keys: List[str] = Field(default_factory=list)
|
||||
|
||||
# ============================================================================
|
||||
# Focus Area Definitions (Admin)
|
||||
# ============================================================================
|
||||
|
|
@ -58,14 +64,27 @@ def list_focus_area_definitions(
|
|||
|
||||
query = """
|
||||
SELECT id, key, name_de, name_en, icon, description, category, is_active,
|
||||
created_at, updated_at
|
||||
created_at, updated_at,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key)
|
||||
FROM focus_area_definition_usage_types fadut
|
||||
JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id
|
||||
WHERE fadut.focus_area_id = focus_area_definitions.id
|
||||
),
|
||||
'[]'::json
|
||||
) AS allowed_usage_type_keys
|
||||
FROM focus_area_definitions
|
||||
WHERE is_active = true OR %s
|
||||
ORDER BY category, name_de
|
||||
"""
|
||||
|
||||
cur.execute(query, (include_inactive,))
|
||||
areas = [r2d(row) for row in cur.fetchall()]
|
||||
areas = []
|
||||
for row in cur.fetchall():
|
||||
d = r2d(row)
|
||||
d['allowed_usage_type_keys'] = coerce_usage_type_keys(d.get('allowed_usage_type_keys'))
|
||||
areas.append(d)
|
||||
|
||||
# Group by category
|
||||
grouped = {}
|
||||
|
|
@ -75,6 +94,10 @@ def list_focus_area_definitions(
|
|||
grouped[cat] = []
|
||||
grouped[cat].append(area)
|
||||
|
||||
if session.get('role') != 'admin':
|
||||
for area in areas:
|
||||
area.pop('allowed_usage_type_keys', None)
|
||||
|
||||
return {
|
||||
"areas": areas,
|
||||
"grouped": grouped,
|
||||
|
|
@ -226,6 +249,92 @@ def delete_focus_area_definition(
|
|||
|
||||
return {"message": "Focus Area gelöscht"}
|
||||
|
||||
|
||||
@router.get("/usage-types")
|
||||
def list_focus_area_usage_types(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Liste aller systemdefinierten Nutzungstypen (Admin, Konfigurations-UI).
|
||||
Keine freie Anlage neuer Typen über die API.
|
||||
"""
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, key, label_de, sort_order
|
||||
FROM focus_area_usage_types
|
||||
ORDER BY sort_order, key
|
||||
""")
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
return {"usage_types": rows, "total": len(rows)}
|
||||
|
||||
|
||||
@router.put("/definitions/{area_id}/usage-types")
|
||||
def replace_focus_area_usage_types(
|
||||
area_id: str,
|
||||
data: FocusAreaUsageTypesUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Ersetzt die Nutzungstyp-Zuweisungen einer Focus Area (Admin).
|
||||
Leere Liste entfernt alle Zuordnungen. Unbekannte Keys → 400.
|
||||
"""
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
keys = list(dict.fromkeys(data.usage_type_keys)) # dedupe, preserve order
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"SELECT id FROM focus_area_definitions WHERE id = %s",
|
||||
(area_id,),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
||||
|
||||
if not keys:
|
||||
cur.execute(
|
||||
"DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s",
|
||||
(area_id,),
|
||||
)
|
||||
return {"message": "Nutzungstyp-Zuweisungen entfernt", "usage_type_keys": []}
|
||||
|
||||
placeholders = ','.join(['%s'] * len(keys))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, key FROM focus_area_usage_types
|
||||
WHERE key IN ({placeholders})
|
||||
""",
|
||||
keys,
|
||||
)
|
||||
found = {row['key']: row['id'] for row in cur.fetchall()}
|
||||
missing = [k for k in keys if k not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte Nutzungstyp-Keys: {', '.join(missing)}",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s",
|
||||
(area_id,),
|
||||
)
|
||||
for k in keys:
|
||||
ut_id = found[k]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (focus_area_id, usage_type_id) DO NOTHING
|
||||
""",
|
||||
(area_id, ut_id),
|
||||
)
|
||||
|
||||
return {"message": "Nutzungstyp-Zuweisungen aktualisiert", "usage_type_keys": keys}
|
||||
|
||||
# ============================================================================
|
||||
# User Focus Preferences
|
||||
# ============================================================================
|
||||
|
|
|
|||
127
backend/tests/test_focus_area_usage_types.py
Normal file
127
backend/tests/test_focus_area_usage_types.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""
|
||||
Tests: Focus Area Nutzungstypen (Migration 036, Router-Helfer).
|
||||
|
||||
Ohne MITAI_INTEGRATION_DB=1 werden nur SQL-Datei und reine Python-Helfer geprüft.
|
||||
Mit gesetztem Flag optional Verifikation gegen eine PostgreSQL-Instanz (Migration 036 angewendet).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
|
||||
def test_migration_036_defines_schema_and_seeds_keys():
|
||||
p = BACKEND_ROOT / "migrations" / "036_focus_area_usage_types.sql"
|
||||
assert p.is_file(), f"expected {p}"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
assert "CREATE TABLE IF NOT EXISTS focus_area_usage_types" in text
|
||||
assert "CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types" in text
|
||||
for key in (
|
||||
"goal_priority",
|
||||
"expected_training_effect",
|
||||
"concrete_training_contribution",
|
||||
):
|
||||
assert key in text
|
||||
assert "ON CONFLICT (key) DO NOTHING" in text
|
||||
# Explizit: kein automatisches Befüllen der M:N-Tabelle
|
||||
assert "INSERT INTO focus_area_definition_usage_types" not in text
|
||||
|
||||
|
||||
def test_coerce_usage_type_keys_normalizes_values():
|
||||
from focus_area_usage_helpers import coerce_usage_type_keys
|
||||
|
||||
assert coerce_usage_type_keys(None) == []
|
||||
assert coerce_usage_type_keys([]) == []
|
||||
assert coerce_usage_type_keys(["goal_priority", "expected_training_effect"]) == [
|
||||
"goal_priority",
|
||||
"expected_training_effect",
|
||||
]
|
||||
assert coerce_usage_type_keys('["concrete_training_contribution"]') == [
|
||||
"concrete_training_contribution"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.getenv("MITAI_INTEGRATION_DB") != "1",
|
||||
reason="Set MITAI_INTEGRATION_DB=1 plus DB_* env to run DB checks (nur Dev/CI!)",
|
||||
)
|
||||
def test_integration_focus_area_usage_types_seeded_and_junction_writable():
|
||||
"""Nur gegen Dev-DB ausführen. Nutzt temporäre focus_area_definitions-Zeile, keine bestehenden Daten."""
|
||||
from db import get_db, get_cursor
|
||||
from focus_area_usage_helpers import coerce_usage_type_keys
|
||||
|
||||
tmp_id = str(uuid.uuid4())
|
||||
tmp_key = f"tmp_usage_{tmp_id[:8]}"
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT key FROM focus_area_usage_types
|
||||
ORDER BY sort_order, key
|
||||
"""
|
||||
)
|
||||
keys = [row["key"] for row in cur.fetchall()]
|
||||
assert keys == [
|
||||
"goal_priority",
|
||||
"expected_training_effect",
|
||||
"concrete_training_contribution",
|
||||
]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO focus_area_definitions
|
||||
(id, key, name_de, category, is_active)
|
||||
VALUES (%s, %s, 'tmp_test_usage', 'custom', false)
|
||||
""",
|
||||
(tmp_id, tmp_key),
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(
|
||||
(
|
||||
SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key)
|
||||
FROM focus_area_definition_usage_types fadut
|
||||
JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id
|
||||
WHERE fadut.focus_area_id = %s
|
||||
),
|
||||
'[]'::json
|
||||
) AS allowed_usage_type_keys
|
||||
""",
|
||||
(tmp_id,),
|
||||
)
|
||||
assert coerce_usage_type_keys(cur.fetchone()["allowed_usage_type_keys"]) == []
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id)
|
||||
SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s
|
||||
""",
|
||||
(tmp_id, "goal_priority"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id)
|
||||
SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s
|
||||
""",
|
||||
(tmp_id, "expected_training_effect"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n
|
||||
FROM focus_area_definition_usage_types
|
||||
WHERE focus_area_id = %s
|
||||
""",
|
||||
(tmp_id,),
|
||||
)
|
||||
assert cur.fetchone()["n"] == 2
|
||||
|
||||
cur.execute("DELETE FROM focus_area_definitions WHERE id = %s", (tmp_id,))
|
||||
|
|
@ -14,8 +14,19 @@ const CATEGORIES = [
|
|||
{ value: 'custom', label: 'Eigene' }
|
||||
]
|
||||
|
||||
function groupAreasByCategory(areas) {
|
||||
const grouped = {}
|
||||
for (const area of areas) {
|
||||
const cat = area.category || 'other'
|
||||
if (!grouped[cat]) grouped[cat] = []
|
||||
grouped[cat].push(area)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export default function AdminFocusAreasPage() {
|
||||
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
|
||||
const [usageTypesCatalog, setUsageTypesCatalog] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
|
|
@ -34,11 +45,35 @@ export default function AdminFocusAreasPage() {
|
|||
loadData()
|
||||
}, [showInactive])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await api.listFocusAreaUsageTypes()
|
||||
if (!cancelled) setUsageTypesCatalog(r.usage_types || [])
|
||||
} catch (e) {
|
||||
console.error('usage-types catalog:', e)
|
||||
if (!cancelled) setUsageTypesCatalog([])
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await api.listFocusAreaDefinitions(showInactive)
|
||||
setData(result)
|
||||
const areas = (result.areas || []).map(a => ({
|
||||
...a,
|
||||
allowed_usage_type_keys: Array.isArray(a.allowed_usage_type_keys)
|
||||
? a.allowed_usage_type_keys
|
||||
: []
|
||||
}))
|
||||
setData({
|
||||
areas,
|
||||
grouped: groupAreasByCategory(areas),
|
||||
total: result.total ?? areas.length
|
||||
})
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load focus areas:', err)
|
||||
|
|
@ -74,14 +109,20 @@ export default function AdminFocusAreasPage() {
|
|||
const handleUpdate = async (id) => {
|
||||
try {
|
||||
const area = data.areas.find(a => a.id === id)
|
||||
await api.updateFocusAreaDefinition(id, {
|
||||
name_de: area.name_de,
|
||||
name_en: area.name_en,
|
||||
icon: area.icon,
|
||||
description: area.description,
|
||||
category: area.category,
|
||||
is_active: area.is_active
|
||||
})
|
||||
const usageKeys = Array.isArray(area.allowed_usage_type_keys)
|
||||
? area.allowed_usage_type_keys
|
||||
: []
|
||||
await Promise.all([
|
||||
api.updateFocusAreaDefinition(id, {
|
||||
name_de: area.name_de,
|
||||
name_en: area.name_en,
|
||||
icon: area.icon,
|
||||
description: area.description,
|
||||
category: area.category,
|
||||
is_active: area.is_active
|
||||
}),
|
||||
api.setFocusAreaUsageTypes(id, usageKeys)
|
||||
])
|
||||
setEditingId(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
|
|
@ -113,12 +154,25 @@ export default function AdminFocusAreasPage() {
|
|||
}
|
||||
|
||||
const updateField = (id, field, value) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
areas: prev.areas.map(a =>
|
||||
setData(prev => {
|
||||
const areas = prev.areas.map(a =>
|
||||
a.id === id ? { ...a, [field]: value } : a
|
||||
)
|
||||
}))
|
||||
return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleUsageTypeKey = (areaId, key, checked) => {
|
||||
setData(prev => {
|
||||
const areas = prev.areas.map(a => {
|
||||
if (a.id !== areaId) return a
|
||||
const cur = new Set(Array.isArray(a.allowed_usage_type_keys) ? a.allowed_usage_type_keys : [])
|
||||
if (checked) cur.add(key)
|
||||
else cur.delete(key)
|
||||
return { ...a, allowed_usage_type_keys: [...cur] }
|
||||
})
|
||||
return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length }
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -364,6 +418,45 @@ export default function AdminFocusAreasPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
Erlaubte Nutzungstypen
|
||||
</label>
|
||||
{usageTypesCatalog.length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
Kein Katalog geladen (Backend / Migration prüfen).
|
||||
</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{usageTypesCatalog.map(ut => (
|
||||
<label
|
||||
key={ut.id}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ marginTop: 3 }}
|
||||
checked={(area.allowed_usage_type_keys || []).includes(ut.key)}
|
||||
onChange={(e) =>
|
||||
toggleUsageTypeKey(area.id, ut.key, e.target.checked)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span style={{ display: 'block' }}>{ut.label_de}</span>
|
||||
<code style={{ fontSize: 11, color: 'var(--text3)' }}>{ut.key}</code>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
|
|
@ -430,6 +523,37 @@ export default function AdminFocusAreasPage() {
|
|||
{area.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(area.allowed_usage_type_keys || []).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6
|
||||
}}
|
||||
>
|
||||
{(area.allowed_usage_type_keys || []).map(k => {
|
||||
const ut = usageTypesCatalog.find(x => x.key === k)
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--surface2)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text2)'
|
||||
}}
|
||||
title={k}
|
||||
>
|
||||
{ut?.label_de || k}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
|
|
|
|||
|
|
@ -375,6 +375,9 @@ export const api = {
|
|||
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
|
||||
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
|
||||
getFocusAreaStats: () => req('/focus-areas/stats'),
|
||||
listFocusAreaUsageTypes: () => req('/focus-areas/usage-types'),
|
||||
setFocusAreaUsageTypes: (id, usageTypeKeys) =>
|
||||
req(`/focus-areas/definitions/${id}/usage-types`, jput({ usage_type_keys: usageTypeKeys })),
|
||||
|
||||
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
|
||||
// Nutrition Charts (E1-E5)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user