feat: feature enforcement imports (#37/#38) and Universal CSV validation (#71)
Some checks failed
Deploy Development / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Failing after 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 24s

Activity and nutrition legacy CSV imports enforce tier limits with UsageBadge UI; Universal CSV gets mapping validation on copy/import, format-check parity, and structured error_details.

version: 0.9u
module: activity 1.2.1, nutrition 1.0.3, csv_import 0.4.0
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-24 16:59:12 +02:00
parent 532e17c4cd
commit 79ec249bc3
27 changed files with 690 additions and 95 deletions

View File

@ -197,7 +197,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP).
**Aktuell:** Phase B abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **Phase D** (Composite-MVP).
---

View File

@ -46,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
---
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
---

View File

@ -0,0 +1,10 @@
**Automatischer Audit (Code + Playwright, 2026-07-23)**
Ziele-System ist im Repo umgesetzt:
- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress
- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js`
- Spec: `docs/issues/issue-50-phase-0a-goal-system.md`
Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler.
Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue.

View File

@ -0,0 +1,9 @@
**Automatischer Audit (Code, 2026-07-23)**
Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt:
- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python
- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c)
Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md`
Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein.

View File

@ -0,0 +1,8 @@
**Automatischer Audit (Code, 2026-07-23)**
Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`.
- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert
- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111112)
Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx`

View File

@ -0,0 +1,23 @@
**Umsetzung 2026-07-23 (nach Architektur-Abgleich)**
## Architektur-Check vor Implementierung
- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung.
- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`.
- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2).
## Änderungen
**Backend** (`routers/activity.py`):
- `check_feature_access(pid, "activity_entries")` vor Legacy-Import
- HTTP 403 bei Limit (wie `nutrition/import-csv`)
- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`)
**Frontend** (`ActivityPage.jsx` ImportPanel):
- `UsageBadge` am Import-Titel
- Drop-Zone deaktiviert bei Limit
- Usage-Reload nach Import
**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment)
Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün)

View File

@ -0,0 +1,22 @@
**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)**
## Ist-Zustand vorher
- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert**
- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar
## Änderungen
**Frontend** (`NutritionPage.jsx`, ImportPanel):
- `getFeatureUsage()``nutrition_entries`
- `UsageBadge` am Import-Titel
- Drop-Zone und Paste-Import deaktiviert bei Limit
- Usage-Reload nach erfolgreichem Import
Analog zu Activity #37 (geschlossen 2026-07-23).
**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel
## Hinweis
Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2.

View File

@ -0,0 +1,7 @@
**Automatischer Audit (Code + Playwright, 2026-07-23)**
Logout-Button im Mobile-Header neben Avatar implementiert:
- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut`
- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx`
Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI).
Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
Bitte weiterverfolgen unter #55.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
Bitte weiterverfolgen unter #56.

View File

@ -0,0 +1,5 @@
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
Bitte weiterverfolgen unter #56.

View File

@ -0,0 +1,9 @@
**Automatischer Audit (Code + Playwright, 2026-07-23)**
Nutzer-konfigurierbares Dashboard umgesetzt:
- Migration `039_dashboard_layout.sql``profiles.dashboard_layout`
- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout`
- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js`
- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py`
Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar.

View File

@ -0,0 +1,9 @@
**Automatischer Audit (Code, 2026-07-23)**
Feature-Gate-Zuordnung aus Admin/DB implementiert:
- Migration `041_widget_feature_requirements.sql`
- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py`
- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features`
- Changelog: `backend/version.py` (Admin Widgets × Features)
Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell).

View File

@ -0,0 +1,20 @@
**Implementierung abgeschlossen (2026-07-24)**
### 1. Admin „Format prüfen“ inkl. `import_row_processing`
- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern
- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`)
### 2. Profil-Mappings validieren
- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import**
- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate`
- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler
### 3. Nutzer-UI: strukturierte Fehler
- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint)
- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump
### Tests & Doku
- `backend/tests/test_csv_mapping_validation.py` — pytest grün
- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert
**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js`

View File

@ -617,6 +617,24 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
Persistenz läuft über activity_persistence_orchestrator gleiche Schicht wie Universal-CSV.
"""
pid = get_pid(x_profile_id)
# Feature-Enforcement (wie manueller Create + nutrition/import-csv): activity_entries
access = check_feature_access(pid, "activity_entries")
log_feature_usage(pid, "activity_entries", access, "import_csv")
if not access["allowed"]:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=(
f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten "
f"({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
),
)
raw = await file.read()
try: text = raw.decode('utf-8')
except: text = raw.decode('latin-1')
@ -729,4 +747,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
except Exception as e:
logger.warning(f"Import row failed: {e}")
skipped+=1
for _ in range(inserted):
increment_feature_usage(pid, "activity_entries")
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}

View File

@ -35,6 +35,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.import_errors import enrich_row_error
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
from csv_parser.template_validator import validate_csv_template
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
@ -50,6 +51,38 @@ def _load_import_limits() -> dict[str, int]:
return get_csv_import_limits(r2d(row) if row else None)
def _mapping_column_signature(m: dict) -> list[str] | None:
sig = m.get("column_signature")
if not sig:
return None
return list(sig)
def _validate_mapping_config(cur, m: dict) -> dict:
"""Strukturelle Vorlagen-Prüfung (wie Admin validate / Create)."""
return validate_csv_template(
m["module"],
m.get("field_mappings") or {},
m.get("type_conversions"),
m.get("import_row_processing"),
_mapping_column_signature(m),
cur=cur,
)
def _ensure_mapping_valid(cur, m: dict) -> dict:
report = _validate_mapping_config(cur, m)
if not report.get("valid"):
raise HTTPException(
status_code=422,
detail={
"message": "CSV-Vorlage ist strukturell ungültig.",
"validation": report,
},
)
return report
def _mapping_to_summary(m: dict) -> dict:
return {
"id": m["id"],
@ -184,6 +217,16 @@ def copy_csv_mapping(
n += 1
name = f"{base_name} {n}"
validation = _validate_mapping_config(cur, src)
if not validation.get("valid"):
raise HTTPException(
status_code=422,
detail={
"message": "Quell-Vorlage ist ungültig — Kopie abgebrochen.",
"validation": validation,
},
)
cur.execute(
"""
INSERT INTO csv_field_mappings (
@ -213,7 +256,21 @@ def copy_csv_mapping(
),
)
new_id = cur.fetchone()["id"]
return {"new_mapping_id": new_id, "mapping_name": name}
return {"new_mapping_id": new_id, "mapping_name": name, "validation": validation}
@router.get("/mappings/{mapping_id}/validate")
def validate_csv_mapping(
mapping_id: int,
session: dict = Depends(require_auth),
):
"""Strukturprüfung einer gespeicherten Vorlage (System oder eigenes Profil-Mapping)."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
m = _fetch_mapping_row(cur, mapping_id, pid)
report = _validate_mapping_config(cur, m)
return {"mapping_id": mapping_id, "mapping_name": m.get("mapping_name"), **report}
@router.post("/analyze")
@ -538,6 +595,7 @@ async def csv_import_execute(
)
_check_module_feature_access(pid, exec_module)
_ensure_mapping_valid(cur, m)
cur.execute(
"""

View File

@ -0,0 +1,107 @@
"""Feature-Enforcement für Legacy Activity CSV-Import (#37)."""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from routers.activity import import_activity_csv
@pytest.mark.asyncio
async def test_import_activity_csv_blocks_when_limit_reached():
upload = AsyncMock()
upload.read = AsyncMock(return_value=b"Workout Type,Start\n")
with patch("routers.activity.get_pid", return_value="profile-1"), patch(
"routers.activity.check_feature_access",
return_value={
"allowed": False,
"reason": "limit_exceeded",
"used": 30,
"limit": 30,
},
), patch("routers.activity.log_feature_usage"):
with pytest.raises(HTTPException) as exc:
await import_activity_csv(
file=upload,
x_profile_id=None,
session={"profile_id": "profile-1"},
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_import_activity_csv_increments_usage_per_insert(monkeypatch):
upload = AsyncMock()
upload.read = AsyncMock(
return_value=(
"Workout Type,Start,Duration,Aktive Energie (kJ),Ruheeinträge (kJ),"
"Durchschn. Herzfrequenz (count/min),Max. Herzfrequenz (count/min),Distanz (km),End\n"
"Running,2026-07-22 10:00:00 +0200,0:45:00,1000,500,140,160,5.0,\n"
).encode("utf-8")
)
increments = []
class FakeCursor:
def execute(self, *args, **kwargs):
return None
def fetchone(self):
return None
class FakeConn:
def __enter__(self):
return self
def __exit__(self, *args):
return False
fake_cur = FakeCursor()
monkeypatch.setattr("routers.activity.get_pid", lambda _h: "profile-1")
monkeypatch.setattr(
"routers.activity.check_feature_access",
lambda *_a, **_k: {"allowed": True, "reason": "ok", "used": 0, "limit": 100},
)
monkeypatch.setattr("routers.activity.log_feature_usage", lambda *_a, **_k: None)
monkeypatch.setattr("routers.activity.get_db", lambda: FakeConn())
monkeypatch.setattr("routers.activity.get_cursor", lambda _c: fake_cur)
monkeypatch.setattr(
"routers.activity.normalize_activity_start",
lambda _s: ("2026-07-22", "10:00:00"),
)
monkeypatch.setattr(
"routers.activity.get_training_type_for_activity",
lambda *_a, **_k: (1, "cardio", None),
)
monkeypatch.setattr(
"routers.activity.find_activity_duplicate_id",
lambda *_a, **_k: None,
)
monkeypatch.setattr(
"routers.activity.new_activity_id",
lambda: "new-id",
)
monkeypatch.setattr(
"routers.activity.insert_activity_csv_minimal",
lambda *_a, **_k: None,
)
monkeypatch.setattr(
"routers.activity.run_activity_post_write_hooks_import",
lambda *_a, **_k: None,
)
monkeypatch.setattr(
"routers.activity.increment_feature_usage",
lambda _pid, feature: increments.append(feature),
)
result = await import_activity_csv(
file=upload,
x_profile_id=None,
session={"profile_id": "profile-1"},
)
assert result["inserted"] == 1
assert increments == ["activity_entries"]

View File

@ -0,0 +1,26 @@
"""Tests für CSV-Vorlagen-Validierung (#71)."""
from csv_parser.template_validator import validate_csv_template
def test_validate_csv_template_rejects_invalid_row_processing():
report = validate_csv_template(
"nutrition",
{"Datum": "date", "kcal": "kcal"},
None,
{"group_by": ["date"], "aggregates": {"kcal": "not_an_op"}},
["Datum", "kcal"],
)
assert report["valid"] is False
assert any(e.get("code") == "invalid_import_row_processing" for e in report["errors"])
def test_validate_csv_template_accepts_nutrition_aggregation():
report = validate_csv_template(
"nutrition",
{"Datum": "date", "kcal": "kcal", "protein": "protein_g"},
None,
{"group_by": ["date"], "aggregates": {"kcal": "sum", "protein_g": "sum"}},
["Datum", "kcal", "protein"],
)
assert report["valid"] is True

View File

@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
- PATCH: Bugfix, kleine Änderung, Refactor
"""
APP_VERSION = "0.9t"
BUILD_DATE = "2026-04-20"
APP_VERSION = "0.9u"
BUILD_DATE = "2026-07-24"
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
MODULE_VERSIONS = {
@ -19,8 +19,8 @@ MODULE_VERSIONS = {
"weight": "1.0.3",
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.2.0", # GET /activity: optional days= window + limit
"nutrition": "1.0.2",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
"nutrition": "1.0.3", # FDDB import UI: UsageBadge + blocked import zone
"photos": "1.0.0",
"insights": "1.3.0",
"prompts": "1.1.0",
@ -31,11 +31,20 @@ MODULE_VERSIONS = {
"membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
"csv_import": "0.4.0", # Mapping validation on copy/import; validate endpoint; error_details UI
"admin_csv_templates": "0.3.1", # Format check includes import_row_processing parity
}
CHANGELOG = [
{
"version": "0.9u",
"date": "2026-07-24",
"changes": [
"Gitea #37: Activity legacy CSV import — activity_entries feature enforcement + UsageBadge UI",
"Gitea #38: Nutrition FDDB import — UsageBadge + deaktivierte Import-Zone bei Limit",
"Gitea #71: Universal CSV — Dry-Run/Format-Check Parität import_row_processing; Mapping-Validierung bei Copy/Import; strukturierte error_details in Nutzer-UI",
],
},
{
"version": "0.9t",
"date": "2026-04-20",

View File

@ -0,0 +1,76 @@
/**
* Strukturierte Zeilenfehler aus Universal-CSV-Import (error_details mit hint/code).
*/
export default function CsvImportErrorDetails({ errors, title, defaultOpen = true, maxVisible = 20 }) {
if (!errors?.length) return null
const shown = errors.slice(0, maxVisible)
const rest = errors.length - shown.length
return (
<details
className="card"
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
open={defaultOpen}
>
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
{title || `Zeilenfehler (${errors.length})`}
</summary>
<ul
style={{
margin: '12px 0 0 0',
padding: 0,
listStyle: 'none',
fontSize: 13,
lineHeight: 1.5,
}}
>
{shown.map((err, i) => {
const row = err.row ?? err.row_index
const message = err.error ?? err.message ?? (typeof err === 'string' ? err : JSON.stringify(err))
return (
<li
key={i}
style={{
padding: '10px 12px',
marginBottom: 8,
background: 'var(--surface2)',
borderRadius: 8,
borderLeft: '3px solid var(--danger)',
}}
>
<div style={{ color: 'var(--text1)' }}>
{row != null && (
<strong style={{ marginRight: 6 }}>Zeile {row}:</strong>
)}
{message}
{err.code && (
<code
style={{
marginLeft: 8,
fontSize: 11,
color: 'var(--text3)',
background: 'var(--surface)',
padding: '1px 6px',
borderRadius: 4,
}}
>
{err.code}
</code>
)}
</div>
{err.hint && (
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text2)' }}>{err.hint}</div>
)}
</li>
)
})}
</ul>
{rest > 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
und {rest} weitere Fehler (Details in der Import-Historie).
</p>
)}
</details>
)
}

View File

@ -364,13 +364,15 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
}
// Import Panel
function ImportPanel({ onImported }) {
function ImportPanel({ onImported, usage = null }) {
const fileRef = useRef()
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [dragging, setDragging] = useState(false)
const atLimit = usage && !usage.allowed
const runImport = async (file) => {
if (atLimit) return
setStatus('loading'); setError(null)
try {
const result = await api.importActivityCsv(file)
@ -382,24 +384,29 @@ function ImportPanel({ onImported }) {
return (
<div className="card section-gap">
<div className="card-title">📥 Apple Health Import</div>
<div className="card-title badge-container-right">
<span>📥 Apple Health Import</span>
{usage && <UsageBadge {...usage} />}
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
<strong>Health Auto Export App</strong> Workouts exportieren CSV hier hochladen.<br/>
Nur die <em>Workouts-csv</em> Datei wird benötigt (nicht die Detaildateien).
</p>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
disabled={atLimit}
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
<div
onDragOver={e=>{e.preventDefault();setDragging(true)}}
onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
onDragLeave={()=>setDragging(false)}
onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}}
onClick={()=>fileRef.current.click()}
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; const f=e.dataTransfer.files[0]; if(f) runImport(f) }}
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer',transition:'all 0.15s'}}>
cursor:atLimit?'not-allowed':'pointer',opacity:atLimit?0.65:1,transition:'all 0.15s'}}>
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
{atLimit ? '🔒 Limit erreicht — Import nicht möglich' : dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen'}
</div>
</div>
{status==='loading' && (
@ -945,7 +952,12 @@ export default function ActivityPage() {
</div>
)}
{tab==='import' && <ImportPanel onImported={load}/>}
{tab==='import' && (
<ImportPanel
usage={activityUsage}
onImported={() => { load(); loadUsage() }}
/>
)}
{tab==='categorize' && (
<div className="card section-gap">

View File

@ -122,6 +122,68 @@ function buildImportRowProcessingSimple(modFields, fm, groupBy, mode, multiRowPo
return out
}
/** Gleiche Logik wie beim Speichern — für Formatprüfung und Create/Update. */
function resolveEditorImportRowProcessing({
aggregateSleepImport,
rowAggUseCustom,
rowAggIrregular,
rowAggJsonText,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
modFields,
fieldMappings,
assignedTargets,
}) {
if (aggregateSleepImport || !rowAggUseCustom) {
return { import_row_processing: null, error: null }
}
if (rowAggIrregular) {
try {
const import_row_processing = JSON.parse(rowAggJsonText || '{}')
if (!import_row_processing || typeof import_row_processing !== 'object') {
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
}
const gb = import_row_processing.group_by
if (!Array.isArray(gb) || !gb.length) {
return {
import_row_processing: null,
error: 'Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.',
}
}
return { import_row_processing, error: null }
} catch {
return { import_row_processing: null, error: 'Zeilenaggregation: ungültiges JSON.' }
}
}
if (!rowAggGroupBy.length) {
return { import_row_processing: null, error: 'Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.' }
}
if (!rowAggMode) {
return { import_row_processing: null, error: 'Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).' }
}
for (const g of rowAggGroupBy) {
if (!assignedTargets.has(g)) {
return {
import_row_processing: null,
error: `Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`,
}
}
}
return {
import_row_processing: buildImportRowProcessingSimple(
modFields,
fieldMappings,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
),
error: null,
}
}
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
function normalizeDecimalInputString(raw) {
let s = String(raw).trim().replace(/\s/g, '')
@ -621,11 +683,28 @@ export default function AdminCsvTemplateEditorPage() {
}
setValidating(true)
try {
const { import_row_processing, error: irpError } = resolveEditorImportRowProcessing({
aggregateSleepImport,
rowAggUseCustom,
rowAggIrregular,
rowAggJsonText,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
modFields: modMeta?.fields,
fieldMappings,
assignedTargets,
})
if (irpError) {
setError(irpError)
return
}
const r = await api.adminValidateCsvTemplate({
module,
field_mappings: fieldMappings,
type_conversions: tc,
import_row_processing: null,
import_row_processing,
column_signature: columnSignature.length ? columnSignature : null,
})
setValidationReport(r)
@ -692,44 +771,25 @@ export default function AdminCsvTemplateEditorPage() {
}
let import_row_processing = null
if (!aggregateSleepImport && rowAggUseCustom) {
if (rowAggIrregular) {
try {
import_row_processing = JSON.parse(rowAggJsonText || '{}')
if (!import_row_processing || typeof import_row_processing !== 'object') throw new Error('bad')
} catch {
setError('Zeilenaggregation: ungültiges JSON.')
return
}
const gb = import_row_processing.group_by
if (!Array.isArray(gb) || !gb.length) {
setError('Zeilenaggregation (JSON): „group_by“ muss eine nicht-leere Liste sein.')
return
}
} else {
if (!rowAggGroupBy.length) {
setError('Zeilenaggregation: mindestens ein Schlüsselfeld auswählen.')
return
}
if (!rowAggMode) {
setError('Zeilenaggregation: eine Funktion wählen (Summe, Mittelwert, …).')
return
}
for (const g of rowAggGroupBy) {
if (!assignedTargets.has(g)) {
setError(`Zeilenaggregation: Schlüsselfeld „${g}“ muss einer CSV-Spalte zugeordnet sein.`)
return
}
}
import_row_processing = buildImportRowProcessingSimple(
modMeta?.fields,
fieldMappings,
{
const resolved = resolveEditorImportRowProcessing({
aggregateSleepImport,
rowAggUseCustom,
rowAggIrregular,
rowAggJsonText,
rowAggGroupBy,
rowAggMode,
rowAggMultiRowPolicy,
rowAggDedupeIdentical,
)
modFields: modMeta?.fields,
fieldMappings,
assignedTargets,
})
if (resolved.error) {
setError(resolved.error)
return
}
import_row_processing = resolved.import_row_processing
}
const payload = {
@ -1494,12 +1554,13 @@ export default function AdminCsvTemplateEditorPage() {
{validationReport.valid ? <span style={{ color: 'var(--accent)' }}> speicherfähig</span> : <span style={{ color: 'var(--danger)' }}> Fehler beheben</span>}
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 10 }}>
Ohne Zeilenaggregations-JSON; vollständige Prüfung inkl. Aggregation beim Speichern. Warnungen blockieren nicht.
Inkl. Zeilenaggregation (wie beim Speichern). Warnungen blockieren nicht.
</p>
{validationReport.errors?.length ? (
<ul style={{ margin: '0 0 12px 1rem', color: 'var(--danger)', fontSize: 14 }}>
{validationReport.errors.map((e, i) => (
<li key={`e-${i}`}>
{e.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{e.code}</code> : null}
{e.message}
{e.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 4 }}>{e.hint}</span> : null}
</li>
@ -1510,6 +1571,7 @@ export default function AdminCsvTemplateEditorPage() {
<ul style={{ margin: '0 0 0 1rem', color: 'var(--text2)', fontSize: 13 }}>
{validationReport.warnings.map((w, i) => (
<li key={`w-${i}`}>
{w.code ? <code style={{ fontSize: 11, marginRight: 6 }}>{w.code}</code> : null}
{w.message}
{w.hint ? <span style={{ display: 'block', fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{w.hint}</span> : null}
</li>

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { Upload, CheckCircle, TrendingUp, Info } from 'lucide-react'
import UsageBadge from '../components/UsageBadge'
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
@ -433,15 +434,17 @@ function ImportHistory() {
}
// Import Panel
function ImportPanel({ onImported }) {
function ImportPanel({ onImported, usage = null }) {
const fileRef = useRef()
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [dragging,setDragging]= useState(false)
const [tab, setTab] = useState('file') // 'file' | 'paste'
const [pasteText, setPasteText] = useState('')
const atLimit = usage && !usage.allowed
const runImport = async (file) => {
if (atLimit) return
setStatus('loading'); setError(null)
try {
const result = await nutritionApi.importCsv(file)
@ -468,7 +471,7 @@ function ImportPanel({ onImported }) {
}
const handlePasteImport = async () => {
if (!pasteText.trim()) return
if (!pasteText.trim() || atLimit) return
const blob = new Blob([pasteText], { type: 'text/csv' })
const file = new File([blob], 'paste.csv', { type: 'text/csv' })
await runImport(file)
@ -476,7 +479,10 @@ function ImportPanel({ onImported }) {
return (
<div className="card section-gap">
<div className="card-title">📥 FDDB CSV Import</div>
<div className="card-title badge-container-right">
<span>📥 FDDB CSV Import</span>
{usage && <UsageBadge {...usage} />}
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
In FDDB: <strong>Mein Tagebuch Exportieren CSV</strong> dann hier importieren.
</p>
@ -498,23 +504,26 @@ function ImportPanel({ onImported }) {
<>
{/* Drag & Drop Zone */}
<div
onDragOver={e=>{e.preventDefault();setDragging(true)}}
onDragOver={e=>{ if(!atLimit){ e.preventDefault(); setDragging(true) }}}
onDragLeave={()=>setDragging(false)}
onDrop={handleDrop}
onClick={()=>fileRef.current.click()}
onDrop={e=>{ e.preventDefault(); setDragging(false); if(atLimit) return; handleDrop(e) }}
onClick={()=>{ if(!atLimit) fileRef.current?.click() }}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}
style={{
border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,
borderRadius:10, padding:'24px 16px', textAlign:'center',
background: dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer', transition:'all 0.15s',
cursor:atLimit?'not-allowed':'pointer', opacity:atLimit?0.65:1, transition:'all 0.15s',
}}>
<Upload size={28} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:8}}/>
<div style={{fontSize:14,fontWeight:500,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
{atLimit
? '🔒 Limit erreicht — Import nicht möglich'
: dragging ? 'Datei loslassen…' : 'CSV hierher ziehen oder tippen zum Auswählen'}
</div>
<div style={{fontSize:11,color:'var(--text3)',marginTop:4}}>.csv Dateien</div>
</div>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} onChange={handleFile}/>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}} disabled={atLimit} onChange={handleFile}/>
</>
)}
@ -523,14 +532,20 @@ function ImportPanel({ onImported }) {
<textarea
style={{width:'100%',minHeight:120,padding:10,fontFamily:'monospace',fontSize:11,
background:'var(--surface2)',border:'1.5px solid var(--border2)',borderRadius:8,
color:'var(--text1)',resize:'vertical',boxSizing:'border-box'}}
color:'var(--text1)',resize:'vertical',boxSizing:'border-box',
opacity:atLimit?0.65:1}}
placeholder="datum_tag_monat_jahr_stunde_minute;bezeichnung;&#10;13.03.2026 21:54;50 g Hähnchen;..."
value={pasteText}
onChange={e=>setPasteText(e.target.value)}
disabled={atLimit}
/>
<button className="btn btn-primary btn-full" style={{marginTop:8}}
onClick={handlePasteImport} disabled={status==='loading'||!pasteText.trim()}>
{status==='loading'
onClick={handlePasteImport}
disabled={status==='loading'||!pasteText.trim()||atLimit}
title={atLimit ? `Limit erreicht (${usage.used}/${usage.limit})` : ''}>
{atLimit
? '🔒 Limit erreicht'
: status==='loading'
? <><div className="spinner" style={{width:14,height:14}}/> Importiere</>
: <><Upload size={15}/> CSV-Text importieren</>}
</button>
@ -781,6 +796,14 @@ export default function NutritionPage() {
const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false)
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
const [nutritionUsage, setNutritionUsage] = useState(null)
const loadUsage = () => {
nutritionApi.getFeatureUsage().then(features => {
const nutritionFeature = features.find(f => f.feature_id === 'nutrition_entries')
setNutritionUsage(nutritionFeature ?? null)
}).catch(err => console.error('Failed to load usage:', err))
}
const load = async () => {
setLoad(true)
@ -800,7 +823,7 @@ export default function NutritionPage() {
finally { setLoad(false) }
}
useEffect(() => { load() }, [])
useEffect(() => { load(); loadUsage() }, [])
return (
<div className="capture-page">
@ -822,7 +845,10 @@ export default function NutritionPage() {
{/* Import Panel + History */}
{inputTab==='import' && (
<>
<ImportPanel onImported={() => { load(); setImportHistoryKey(Date.now()) }}/>
<ImportPanel
usage={nutritionUsage}
onImported={() => { load(); loadUsage(); setImportHistoryKey(Date.now()) }}
/>
<ImportHistory key={importHistoryKey}/>
</>
)}

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2, Upload } from 'lucide-react'
import { api } from '../utils/api'
import { csvPreviewTdStyle } from '../utils/csvPreviewCells'
import CsvImportErrorDetails from '../components/CsvImportErrorDetails'
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
const EXECUTOR_READY = new Set([
@ -288,29 +289,11 @@ export default function UniversalCsvImportPage() {
)}
{lastImport?.error_details?.length > 0 && (
<details
className="card"
style={{ marginBottom: 16, padding: 16, cursor: 'pointer' }}
open
>
<summary style={{ fontWeight: 600, color: 'var(--text1)' }}>
Zeilenfehler vom letzten Import ({lastImport.error_details.length}) zum Kopieren aufklappen
</summary>
<pre
style={{
marginTop: 12,
fontSize: 12,
overflow: 'auto',
maxHeight: 320,
background: 'var(--surface2)',
padding: 12,
borderRadius: 8,
color: 'var(--text1)',
}}
>
{JSON.stringify(lastImport.error_details, null, 2)}
</pre>
</details>
<CsvImportErrorDetails
errors={lastImport.error_details}
title={`Zeilenfehler vom letzten Import (${lastImport.error_details.length})`}
defaultOpen
/>
)}
<div className="card" style={{ marginBottom: 16, padding: 16 }}>

View File

@ -29,6 +29,13 @@ export function formatFastApiDetail(detail, fallback = '') {
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
}
if (typeof detail === 'object') {
if (detail.validation?.errors?.length) {
const parts = detail.validation.errors
.map((e) => (e && typeof e === 'object' ? e.message || e.msg || '' : String(e)))
.filter(Boolean)
const prefix = detail.message || 'Vorlage ungültig'
return parts.length ? `${prefix}: ${parts.join(' · ')}` : prefix
}
if (Array.isArray(detail.errors) && detail.errors.length > 0) {
const parts = detail.errors
.map((e) => {
@ -713,6 +720,7 @@ export const api = {
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
copyCsvMapping: (mappingId, body = null) =>
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
validateCsvMapping: (mappingId) => req(`/csv/mappings/${mappingId}/validate`),
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
/** Import-Diagnose: keine Datenbank-Schreibung, erste Zeilen + Mapping-Auflösung */
diagnoseUniversalCsv: async (file, mappingId, module = null) => {

67
tests/issue-audit.spec.js Normal file
View File

@ -0,0 +1,67 @@
/**
* Gitea Issue-Audit UI-Verifikation gegen dev.mitai.jinkendo.de
* Einmaliger Audit-Lauf; keine dauerhafte CI-Pflicht.
*/
const { test, expect } = require('@playwright/test');
const TEST_EMAIL = process.env.TEST_EMAIL || 'lars@stommer.com';
const TEST_PASSWORD = process.env.TEST_PASSWORD || '5112';
async function login(page) {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button:has-text("Anmelden")');
await page.waitForLoadState('networkidle');
await expect(page.locator('button:has-text("Anmelden")')).toHaveCount(0, { timeout: 15000 });
}
test.describe('Issue-Audit UI', () => {
test('#40 Logout-Button im Mobile-Header sichtbar', async ({ page }) => {
await login(page);
const logoutBtn = page.locator('header.app-header--mobile button[title="Abmelden"]');
await expect(logoutBtn).toBeVisible();
});
test('#25 Goals-Seite erreichbar und rendert Inhalt', async ({ page }) => {
await login(page);
await page.goto('/goals');
await page.waitForLoadState('networkidle');
await expect(page.locator('body')).not.toContainText('404', { timeout: 5000 });
const hasGoalsUi =
(await page.getByText(/Ziel/i).count()) > 0 ||
(await page.locator('.card').count()) > 0;
expect(hasGoalsUi).toBeTruthy();
});
test('#65 Dashboard-Layout-Konfiguration erreichbar', async ({ page }) => {
await login(page);
await page.goto('/settings/dashboard-layout');
await page.waitForLoadState('networkidle');
const body = await page.locator('body').innerText();
expect(body).toMatch(/Widget|Übersicht|Layout|Dashboard/i);
});
test('#30 Desktop-Sidebar bei breitem Viewport', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
const sidebar = page.locator('.desktop-sidebar, [class*="desktop-sidebar"]');
await expect(sidebar.first()).toBeVisible({ timeout: 10000 });
});
test('#38 Nutrition CSV Import — FDDB-Panel mit Usage-Integration', async ({ page }) => {
await login(page);
await page.goto('/nutrition');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /Import/i }).click();
await page.waitForLoadState('networkidle');
const importCard = page.locator('.card').filter({ hasText: 'FDDB CSV Import' });
await expect(importCard).toBeVisible();
// Nach Deploy: badge-container-right am Titel; UsageBadge nur bei endlichem Limit (Premium = kein Badge)
const titleRow = importCard.locator('.card-title.badge-container-right');
if (await titleRow.count()) {
await expect(titleRow).toBeVisible();
}
});
});