feat: global quality filter setting (Issue #31)
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Implemented global quality_filter_level in user profiles for consistent
data filtering across all views (Dashboard, History, Charts, KI-Pipeline).

Backend changes:
- Migration 016: Add quality_filter_level column to profiles table
- quality_filter.py: Centralized helper functions for SQL filtering
- insights.py: Apply global filter in _get_profile_data()
- activity.py: Apply global filter in list_activity()

Frontend changes:
- SettingsPage.jsx: Add Datenqualität section with 4-level selector
- History.jsx: Use global quality filter from profile context

Filter levels: all, quality (good+excellent+acceptable), very_good
(good+excellent), excellent (only excellent)

Closes #31

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-23 22:29:49 +01:00
parent b317246bcd
commit 04306a7fef
6 changed files with 226 additions and 10 deletions

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 $$;

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 models import ActivityEntry
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql
# Evaluation import with error handling (Phase 1.2) # Evaluation import with error handling (Phase 1.2)
try: 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) pid = get_pid(x_profile_id)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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()] 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 auth import require_auth, require_admin, check_feature_access, increment_feature_usage
from routers.profiles import get_pid from routers.profiles import get_pid
from feature_logger import log_feature_usage from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql
router = APIRouter(prefix="/api", tags=["insights"]) router = APIRouter(prefix="/api", tags=["insights"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,6 +68,9 @@ def _get_profile_data(pid: str):
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = r2d(cur.fetchone()) 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,)) 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()] 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,)) cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
@ -75,13 +79,12 @@ def _get_profile_data(pid: str):
caliper = [r2d(r) for r in cur.fetchall()] 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,)) 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()] nutrition = [r2d(r) for r in cur.fetchall()]
# Quality-Filter: nur hochwertige Aktivitäten für KI-Analyse (Issue #24)
# TODO (#28): quality_level als Parameter (all, quality, very_good, excellent) # Issue #31: Global quality filter (from user profile setting)
# Zukünftig: GET /api/insights/run/{slug}?quality_level=quality cur.execute(f"""
cur.execute("""
SELECT * FROM activity_log SELECT * FROM activity_log
WHERE profile_id=%s WHERE profile_id=%s
AND (quality_label IN ('excellent', 'good', 'acceptable') OR quality_label IS NULL) {quality_filter}
ORDER BY date DESC LIMIT 90 ORDER BY date DESC LIMIT 90
""", (pid,)) """, (pid,))
activity = [r2d(r) for r in cur.fetchall()] activity = [r2d(r) for r in cur.fetchall()]

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import { import {
LineChart, Line, BarChart, Bar, LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
@ -586,9 +587,10 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
} }
// Activity Section // Activity Section
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs }) { function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30) const [period, setPeriod] = useState(30)
const [qualityLevel, setQualityLevel] = useState('all') // Issue #24: Quality-Filter (all, quality, very_good, excellent) // Issue #31: Use global quality filter from profile as default
const [qualityLevel, setQualityLevel] = useState(globalQualityLevel || 'all')
if (!activities?.length) return ( if (!activities?.length) return (
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/> <EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
) )
@ -942,6 +944,7 @@ const TABS = [
] ]
export default function History() { export default function History() {
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
const location = useLocation?.() || {} const location = useLocation?.() || {}
const [tab, setTab] = useState((location.state?.tab)||'body') const [tab, setTab] = useState((location.state?.tab)||'body')
const [weights, setWeights] = useState([]) const [weights, setWeights] = useState([])
@ -1010,7 +1013,7 @@ export default function History() {
</div> </div>
{tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>} {tab==='body' && <BodySection weights={weights} calipers={calipers} circs={circs} profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} 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==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>} {tab==='photos' && <PhotoGrid/>}
</div> </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 handleSave = async (form, profileId) => {
const data = {} const data = {}
if (form.name) data.name = form.name if (form.name) data.name = form.name
@ -455,6 +465,48 @@ export default function SettingsPage() {
</p> </p>
</div> </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 && ( {saved && (
<div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)', <div style={{position:'fixed',bottom:80,left:'50%',transform:'translateX(-50%)',
background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20, background:'var(--accent)',color:'white',padding:'8px 20px',borderRadius:20,