From 04306a7fef129ce3c1f234593d78d9eab7c7056d Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 23 Mar 2026 22:29:49 +0100 Subject: [PATCH] feat: global quality filter setting (Issue #31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/016_global_quality_filter.sql | 21 +++ backend/quality_filter.py | 125 ++++++++++++++++++ backend/routers/activity.py | 16 ++- backend/routers/insights.py | 13 +- frontend/src/pages/History.jsx | 9 +- frontend/src/pages/SettingsPage.jsx | 52 ++++++++ 6 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/016_global_quality_filter.sql create mode 100644 backend/quality_filter.py 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 ( ) @@ -942,6 +944,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([]) @@ -1010,7 +1013,7 @@ export default function History() { {tab==='body' && } {tab==='nutrition' && } - {tab==='activity' && } + {tab==='activity' && } {tab==='correlation' && } {tab==='photos' && } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index fb49dcf..01eaf8a 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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() {

+ {/* Issue #31: Global Quality Filter */} +
+
Datenqualität
+

+ Qualitätsfilter wirkt auf alle Ansichten: Dashboard, Charts, Statistiken und KI-Analysen. +

+
+ + +
+
+
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'] + }
+ Diese Einstellung wirkt auf: +
    +
  • Dashboard Charts
  • +
  • Verlauf & Auswertungen
  • +
  • Trainingstyp-Verteilung
  • +
  • KI-Analysen & Pipeline
  • +
  • Alle Statistiken
  • +
+
+
+ {saved && (