Merge pull request 'globaler Filter für Qualitätsgates von Trainings' (#41) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s

Reviewed-on: #41
This commit is contained in:
Lars 2026-03-24 08:44:22 +01:00
commit ac4c6760d7
9 changed files with 326 additions and 28 deletions

View File

@ -135,7 +135,7 @@ frontend/src/
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
### v9d Phase 2 ✅ (Deployed to Dev 23.03.2026)
### v9d Phase 2 ✅ (Deployed to Production 23.03.2026)
**Vitalwerte & Erholung:**
@ -154,15 +154,23 @@ frontend/src/
- Dashboard Widget mit aktuellen Ruhetagen
- ✅ **Vitalwerte erweitert (v9d Phase 2d):**
- Ruhepuls + HRV (morgens)
- Blutdruck (Systolisch/Diastolisch + Puls)
- VO2 Max (Apple Watch)
- SpO2 (Blutsauerstoffsättigung)
- Atemfrequenz
- **3-Tab Architektur:** Baseline (morgens) / Blutdruck (mehrfach täglich) / Import
- **Baseline Vitals:** Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz
- **Blutdruck:** Systolisch/Diastolisch + Puls, WHO/ISH-Klassifizierung
- **Context-Tagging:** 8 Kontexte (nüchtern, nach Essen, Training, Stress, etc.)
- **Inline-Editing:** Alle Messungen direkt in der Liste bearbeitbar
- **Smart Upsert:** Baseline lädt existierende Einträge automatisch
- **CSV Import:** Omron (Deutsch) + Apple Health (Deutsch/Englisch)
- **Mobile-optimiert:** Volle Breite Felder, Sektions-Überschriften
- Unregelmäßiger Herzschlag & AFib-Warnungen
- CSV Import: Omron (Blutdruck) + Apple Health (alle Vitals)
- Trend-Analyse (7d/14d/30d)
**Bugfixes (23.03.2026):**
- ✅ Import-Zählung korrigiert (skipped vs. updated)
- ✅ Deutsche Spaltennamen für CSV-Imports (Ruhepuls, Herzfrequenzvariabilität, etc.)
- ✅ Dezimalwerte-Parsing (safe_int/safe_float für Apple Health Exports)
- ✅ Error-Details in Import-Response (erste 10 Fehler im Frontend sichtbar)
- 🔲 **HF-Zonen + Erholungsstatus (v9d Phase 2e):**
- HF-Zonen-Verteilung pro Training
- Recovery Score basierend auf Ruhepuls + HRV + Schlaf
@ -172,8 +180,9 @@ frontend/src/
- Migration 010: sleep_log Tabelle (JSONB segments)
- Migration 011: rest_days Tabelle (Kraft, Cardio, Entspannung)
- Migration 012: Unique constraint rest_days (profile_id, date, rest_type)
- Migration 013: vitals_log Tabelle (Ruhepuls, HRV)
- Migration 014: Extended vitals (BP, VO2 Max, SpO2, respiratory_rate)
- Migration 013: vitals_log Tabelle (Ruhepuls, HRV) - deprecated
- Migration 014: Extended vitals (BP, VO2 Max, SpO2, respiratory_rate) - deprecated
- Migration 015: **Vitals Refactoring** - Trennung in vitals_baseline + blood_pressure_log
📚 Details: `.claude/docs/functional/TRAINING_TYPES.md`
@ -181,19 +190,47 @@ frontend/src/
## Feature-Roadmap
> Vollständiges Backlog: `.claude/docs/BACKLOG.md`
> Beim Implementieren: verlinkte Dok-Datei zuerst lesen!
> 📋 **Detaillierte Roadmap:** `.claude/docs/ROADMAP.md` (Phasen 0-3, Timeline, Abhängigkeiten)
> 📚 **Vollständiges Backlog:** `.claude/docs/BACKLOG.md`
> 🎯 **Gitea Issues:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
>
> **Beim Implementieren:** verlinkte Dok-Datei zuerst lesen!
| Version | Feature | Dokumentation |
|---------|---------|---------------|
| v9c | Membership (aktiv) | `technical/MEMBERSHIP_SYSTEM.md` ✅ |
| v9d | Schlaf-Modul | `functional/SLEEP_MODULE.md` (ausstehend) |
| v9d | Trainingstypen + HF | `functional/TRAINING_TYPES.md` ✅ |
| v9e | Ziele + Vitalwerte | `functional/GOALS_VITALS.md` (ausstehend) |
| v9f | KI-Prompt Flexibilisierung | `functional/AI_PROMPTS.md` ✅ |
| v9g | Meditation + Selbstwahrnehmung | `functional/MEDITATION.md` (ausstehend) |
| v9h | Connectoren + Stripe | ausstehend |
| — | Responsive UI | `functional/RESPONSIVE_UI.md` ✅ |
### Aktuelle Entwicklung (Phase 0-2, ~10-13 Wochen)
| Phase | Fokus | Dauer | Gitea Issues |
|-------|-------|-------|--------------|
| **Phase 0** | Infrastruktur (v9f) | 4-6 Wochen | #24, #28, #29, #30 |
| **Phase 1** | Foundation (Charts, Goals) | 2-3 Wochen | #26, #25 |
| **Phase 2** | Engagement (Korrelationen, KI) | 3-4 Wochen | #27, #25 |
| **Phase 3** | Begleitung (Development Routes) | später | - |
**Phase 0 Issues:**
- #24: Quality-Filter (3h, Quick Win) ← **Start hier**
- #28: AI-Prompts Flexibilisierung (16-20h, kritisch)
- #29: Abilities-Matrix UI (6-8h)
- #30: Responsive UI (8-10h, parallel)
**Phase 1 Issues:**
- #26: Charts erweitern (8-10h)
- #25: Ziele-System Basis (10-12h)
**Phase 2 Issues:**
- #27: Korrelationen (6-8h)
- #25: Goals KI-Integration (4h)
### Versions-Übersicht
| Version | Feature | Dokumentation | Status |
|---------|---------|---------------|--------|
| v9c | Membership (aktiv) | `technical/MEMBERSHIP_SYSTEM.md` | ✅ Production |
| v9d | Schlaf-Modul | `functional/SLEEP_MODULE.md` | ✅ Production |
| v9d | Trainingstypen + HF | `functional/TRAINING_TYPES.md` | ✅ Production |
| v9e | Ziele + Vitalwerte | `functional/GOALS_VITALS.md` | 🔲 Phase 1 |
| v9f | KI-Prompt Flexibilisierung | `functional/AI_PROMPTS.md` | 🔲 Phase 0 |
| v9g | Meditation + Selbstwahrnehmung | `functional/MEDITATION.md` | 🔲 Phase 3 |
| v9h | Connectoren + Stripe | ausstehend | 🔲 Später |
| — | Responsive UI | `functional/RESPONSIVE_UI.md` | 🔲 Phase 0 |
## Deployment
@ -237,6 +274,17 @@ subscriptions · coupons · coupon_redemptions · features
tier_limits · user_feature_restrictions · user_feature_usage
access_grants · user_activity_log
v9d neu (Training & Vitals):
training_types 29 Trainingstypen in 7 Kategorien
activity_type_mappings Lernendes Mapping-System (Deutsch/Englisch)
sleep_log Schlaf mit JSONB segments (Phasen)
rest_days Multi-dimensionale Ruhetage (Kraft/Cardio/Entspannung)
vitals_baseline Morgenmessung (RHR, HRV, VO2 Max, SpO2, resp_rate)
blood_pressure_log Blutdruck mehrfach täglich mit Context-Tagging
Deprecated (v9d Phase 2d):
vitals_log → vitals_log_backup_pre_015 (nach Migration 015)
Infrastruktur:
schema_migrations Tracking für automatische DB-Migrationen

View File

@ -0,0 +1,21 @@
-- Migration 016: Global Quality Filter Setting
-- Issue: #31
-- Date: 2026-03-23
-- Description: Add quality_filter_level to profiles for consistent data views
-- Add quality_filter_level column to profiles
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS quality_filter_level VARCHAR(20) DEFAULT 'all';
COMMENT ON COLUMN profiles.quality_filter_level IS 'Global quality filter for all activity views: all, quality, very_good, excellent';
-- Create index for performance (if filtering becomes common)
CREATE INDEX IF NOT EXISTS idx_profiles_quality_filter ON profiles(quality_filter_level);
-- Migration tracking
DO $$
BEGIN
RAISE NOTICE '✓ Migration 016: Added global quality filter setting';
RAISE NOTICE ' - Added profiles.quality_filter_level column';
RAISE NOTICE ' - Default: all (no filter)';
RAISE NOTICE ' - Values: all, quality, very_good, excellent';
END $$;

View File

@ -27,6 +27,7 @@ class ProfileUpdate(BaseModel):
height: Optional[float] = None
goal_weight: Optional[float] = None
goal_bf_pct: Optional[float] = None
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
# ── Tracking Models ───────────────────────────────────────────────────────────

125
backend/quality_filter.py Normal file
View File

@ -0,0 +1,125 @@
"""
Quality Filter Helper - Data Access Layer
Provides consistent quality filtering across all activity queries.
Issue: #31
"""
from typing import Optional, Dict
def get_quality_filter_sql(profile: Dict, table_alias: str = "") -> str:
"""
Returns SQL WHERE clause fragment for quality filtering.
Args:
profile: User profile dict with quality_filter_level
table_alias: Optional table alias (e.g., "a." for "a.quality_label")
Returns:
SQL fragment (e.g., "AND quality_label IN (...)") or empty string
Examples:
>>> get_quality_filter_sql({'quality_filter_level': 'all'})
''
>>> get_quality_filter_sql({'quality_filter_level': 'quality'})
"AND quality_label IN ('excellent', 'good', 'acceptable')"
>>> get_quality_filter_sql({'quality_filter_level': 'excellent'}, 'a.')
"AND a.quality_label = 'excellent'"
"""
level = profile.get('quality_filter_level', 'all')
prefix = table_alias if table_alias else ""
if level == 'all':
return '' # No filter
elif level == 'quality':
return f"AND {prefix}quality_label IN ('excellent', 'good', 'acceptable')"
elif level == 'very_good':
return f"AND {prefix}quality_label IN ('excellent', 'good')"
elif level == 'excellent':
return f"AND {prefix}quality_label = 'excellent'"
else:
# Unknown level → no filter (safe fallback)
return ''
def get_quality_filter_tuple(profile: Dict) -> tuple:
"""
Returns tuple of allowed quality labels for Python filtering.
Args:
profile: User profile dict with quality_filter_level
Returns:
Tuple of allowed quality labels or None (no filter)
Examples:
>>> get_quality_filter_tuple({'quality_filter_level': 'all'})
None
>>> get_quality_filter_tuple({'quality_filter_level': 'quality'})
('excellent', 'good', 'acceptable')
"""
level = profile.get('quality_filter_level', 'all')
if level == 'all':
return None # No filter
elif level == 'quality':
return ('excellent', 'good', 'acceptable')
elif level == 'very_good':
return ('excellent', 'good')
elif level == 'excellent':
return ('excellent',)
else:
return None # Unknown level → no filter
def filter_activities_by_quality(activities: list, profile: Dict) -> list:
"""
Filters a list of activity dicts by quality_label.
Useful for post-query filtering (e.g., when data already loaded).
Args:
activities: List of activity dicts with quality_label field
profile: User profile dict with quality_filter_level
Returns:
Filtered list of activities
"""
allowed_labels = get_quality_filter_tuple(profile)
if allowed_labels is None:
return activities # No filter
return [
act for act in activities
if act.get('quality_label') in allowed_labels
]
# Constants for frontend/documentation
QUALITY_LEVELS = {
'all': {
'label': 'Alle',
'icon': '📊',
'description': 'Alle Activities (kein Filter)',
'includes': None
},
'quality': {
'label': 'Hochwertig',
'icon': '',
'description': 'Hochwertige Activities',
'includes': ['excellent', 'good', 'acceptable']
},
'very_good': {
'label': 'Sehr gut',
'icon': '✓✓',
'description': 'Sehr gute Activities',
'includes': ['excellent', 'good']
},
'excellent': {
'label': 'Exzellent',
'icon': '',
'description': 'Nur exzellente Activities',
'includes': ['excellent']
}
}

View File

@ -16,6 +16,7 @@ from auth import require_auth, check_feature_access, increment_feature_usage
from models import ActivityEntry
from routers.profiles import get_pid
from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql
# Evaluation import with error handling (Phase 1.2)
try:
@ -36,8 +37,19 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC, start_time DESC LIMIT %s", (pid,limit))
# Issue #31: Apply global quality filter
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile)
cur.execute(f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
ORDER BY date DESC, start_time DESC
LIMIT %s
""", (pid, limit))
return [r2d(r) for r in cur.fetchall()]

View File

@ -17,6 +17,7 @@ from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
from routers.profiles import get_pid
from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql
router = APIRouter(prefix="/api", tags=["insights"])
logger = logging.getLogger(__name__)
@ -67,6 +68,9 @@ def _get_profile_data(pid: str):
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = r2d(cur.fetchone())
# Issue #31: Get global quality filter setting
quality_filter = get_quality_filter_sql(prof)
cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
weight = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
@ -75,7 +79,14 @@ def _get_profile_data(pid: str):
caliper = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
nutrition = [r2d(r) for r in cur.fetchall()]
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 90", (pid,))
# Issue #31: Global quality filter (from user profile setting)
cur.execute(f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
ORDER BY date DESC LIMIT 90
""", (pid,))
activity = [r2d(r) for r in cur.fetchall()]
# v9d Phase 2: Sleep, Rest Days, Vitals
cur.execute("SELECT * FROM sleep_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
import { api } from '../utils/api'
import { useProfile } from '../context/ProfileContext'
/**
* TrainingTypeDistribution - Pie chart showing activity distribution by type
@ -8,6 +9,7 @@ import { api } from '../utils/api'
* @param {number} days - Number of days to analyze (default: 28)
*/
export default function TrainingTypeDistribution({ days = 28 }) {
const { activeProfile } = useProfile() // Issue #31: Trigger reload on quality filter change
const [data, setData] = useState([])
const [categories, setCategories] = useState({})
const [loading, setLoading] = useState(true)
@ -41,7 +43,7 @@ export default function TrainingTypeDistribution({ days = 28 }) {
console.error('Failed to load training type distribution:', err)
setLoading(false)
})
}, [days])
}, [days, activeProfile?.quality_filter_level]) // Issue #31: Reload when quality filter changes
if (loading) {
return (

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
@ -586,12 +587,14 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
}
// Activity Section
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs }) {
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30)
if (!activities?.length) return (
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
// Issue #31: Backend already filters by global quality level - only filter by period here
const filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
const byDate={}
@ -620,6 +623,28 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
<div>
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
{/* Issue #31: Show active global quality filter */}
{globalQualityLevel && globalQualityLevel !== 'all' && (
<div style={{
marginBottom:12, padding:'8px 12px', borderRadius:8,
background:'var(--surface2)', border:'1px solid var(--border)',
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
}}>
<span>
{globalQualityLevel === 'quality' && '✓ Filter: Hochwertig (excellent, good, acceptable)'}
{globalQualityLevel === 'very_good' && '✓✓ Filter: Sehr gut (excellent, good)'}
{globalQualityLevel === 'excellent' && '⭐ Filter: Exzellent (nur excellent)'}
</span>
<a href="/settings" style={{
marginLeft:'auto', color:'var(--accent)', textDecoration:'none',
fontSize:11, fontWeight:500, whiteSpace:'nowrap'
}}>
Hier ändern
</a>
</div>
)}
<div style={{display:'flex',gap:6,marginBottom:12}}>
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
@ -899,6 +924,7 @@ const TABS = [
]
export default function History() {
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
const location = useLocation?.() || {}
const [tab, setTab] = useState((location.state?.tab)||'body')
const [weights, setWeights] = useState([])
@ -967,7 +993,7 @@ export default function History() {
</div>
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>}
</div>

View File

@ -202,6 +202,16 @@ export default function SettingsPage() {
}
}
const handleQualityFilterChange = async (level) => {
// Issue #31: Update global quality filter
await api.updateActiveProfile({ quality_filter_level: level })
await refreshProfiles()
const updated = profiles.find(p => p.id === activeProfile?.id)
if (updated) setActiveProfile({...updated, quality_filter_level: level})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleSave = async (form, profileId) => {
const data = {}
if (form.name) data.name = form.name
@ -455,6 +465,48 @@ export default function SettingsPage() {
</p>
</div>
{/* Issue #31: Global Quality Filter */}
<div className="card section-gap">
<div className="card-title">Datenqualität</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Qualitätsfilter wirkt auf <strong>alle Ansichten</strong>: Dashboard, Charts, Statistiken und KI-Analysen.
</p>
<div style={{marginBottom:8}}>
<label style={{fontSize:12,fontWeight:600,color:'var(--text3)',display:'block',marginBottom:6}}>
QUALITÄTSFILTER (GLOBAL)
</label>
<select
className="form-select"
value={activeProfile?.quality_filter_level || 'all'}
onChange={e => handleQualityFilterChange(e.target.value)}
style={{width:'100%',fontSize:13}}>
<option value="all">📊 Alle Activities</option>
<option value="quality"> Hochwertig (excellent, good, acceptable)</option>
<option value="very_good"> Sehr gut (excellent, good)</option>
<option value="excellent"> Exzellent (nur excellent)</option>
</select>
</div>
<div style={{padding:'10px 12px',background:'var(--surface2)',borderRadius:8,fontSize:11,
color:'var(--text3)',lineHeight:1.5}}>
<div style={{fontWeight:600,marginBottom:4,color:'var(--text2)'}}>Aktuell: {
{
'all': '📊 Alle Activities (kein Filter)',
'quality': '✓ Hochwertig (excellent, good, acceptable)',
'very_good': '✓✓ Sehr gut (excellent, good)',
'excellent': '⭐ Exzellent (nur excellent)'
}[activeProfile?.quality_filter_level || 'all']
}</div>
Diese Einstellung wirkt auf:
<ul style={{margin:'6px 0 0 20px',padding:0}}>
<li>Dashboard Charts</li>
<li>Verlauf & Auswertungen</li>
<li>Trainingstyp-Verteilung</li>
<li>KI-Analysen & Pipeline</li>
<li>Alle Statistiken</li>
</ul>
</div>
</div>
{saved && (
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,