feat: global quality filter setting (Issue #31)
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:
parent
b317246bcd
commit
04306a7fef
21
backend/migrations/016_global_quality_filter.sql
Normal file
21
backend/migrations/016_global_quality_filter.sql
Normal 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
125
backend/quality_filter.py
Normal 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user