diff --git a/backend/migrations/016_global_quality_filter.sql b/backend/migrations/016_global_quality_filter.sql
new file mode 100644
index 0000000..ab0a968
--- /dev/null
+++ b/backend/migrations/016_global_quality_filter.sql
@@ -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 $$;
diff --git a/backend/quality_filter.py b/backend/quality_filter.py
new file mode 100644
index 0000000..ec6e312
--- /dev/null
+++ b/backend/quality_filter.py
@@ -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']
+ }
+}
diff --git a/backend/routers/activity.py b/backend/routers/activity.py
index a7a2e64..c3b57a0 100644
--- a/backend/routers/activity.py
+++ b/backend/routers/activity.py
@@ -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()]
diff --git a/backend/routers/insights.py b/backend/routers/insights.py
index 4643418..0127241 100644
--- a/backend/routers/insights.py
+++ b/backend/routers/insights.py
@@ -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,13 +79,12 @@ 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()]
- # Quality-Filter: nur hochwertige Aktivitäten für KI-Analyse (Issue #24)
- # TODO (#28): quality_level als Parameter (all, quality, very_good, excellent)
- # Zukünftig: GET /api/insights/run/{slug}?quality_level=quality
- cur.execute("""
+
+ # Issue #31: Global quality filter (from user profile setting)
+ cur.execute(f"""
SELECT * FROM activity_log
WHERE profile_id=%s
- AND (quality_label IN ('excellent', 'good', 'acceptable') OR quality_label IS NULL)
+ {quality_filter}
ORDER BY date DESC LIMIT 90
""", (pid,))
activity = [r2d(r) for r in cur.fetchall()]
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx
index 22c966a..cd6f36a 100644
--- a/frontend/src/pages/History.jsx
+++ b/frontend/src/pages/History.jsx
@@ -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,9 +587,10 @@ 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)
- 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 (
+ Qualitätsfilter wirkt auf alle Ansichten: Dashboard, Charts, Statistiken und KI-Analysen. +
+