Merge pull request 'globaler Filter für Qualitätsgates von Trainings' (#41) from develop into main
Reviewed-on: #41
This commit is contained in:
commit
ac4c6760d7
90
CLAUDE.md
90
CLAUDE.md
|
|
@ -135,7 +135,7 @@ frontend/src/
|
||||||
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
|
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
|
||||||
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
|
- `.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:**
|
**Vitalwerte & Erholung:**
|
||||||
|
|
||||||
|
|
@ -154,15 +154,23 @@ frontend/src/
|
||||||
- Dashboard Widget mit aktuellen Ruhetagen
|
- Dashboard Widget mit aktuellen Ruhetagen
|
||||||
|
|
||||||
- ✅ **Vitalwerte erweitert (v9d Phase 2d):**
|
- ✅ **Vitalwerte erweitert (v9d Phase 2d):**
|
||||||
- Ruhepuls + HRV (morgens)
|
- **3-Tab Architektur:** Baseline (morgens) / Blutdruck (mehrfach täglich) / Import
|
||||||
- Blutdruck (Systolisch/Diastolisch + Puls)
|
- **Baseline Vitals:** Ruhepuls, HRV, VO2 Max, SpO2, Atemfrequenz
|
||||||
- VO2 Max (Apple Watch)
|
- **Blutdruck:** Systolisch/Diastolisch + Puls, WHO/ISH-Klassifizierung
|
||||||
- SpO2 (Blutsauerstoffsättigung)
|
- **Context-Tagging:** 8 Kontexte (nüchtern, nach Essen, Training, Stress, etc.)
|
||||||
- Atemfrequenz
|
- **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
|
- Unregelmäßiger Herzschlag & AFib-Warnungen
|
||||||
- CSV Import: Omron (Blutdruck) + Apple Health (alle Vitals)
|
|
||||||
- Trend-Analyse (7d/14d/30d)
|
- 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 + Erholungsstatus (v9d Phase 2e):**
|
||||||
- HF-Zonen-Verteilung pro Training
|
- HF-Zonen-Verteilung pro Training
|
||||||
- Recovery Score basierend auf Ruhepuls + HRV + Schlaf
|
- Recovery Score basierend auf Ruhepuls + HRV + Schlaf
|
||||||
|
|
@ -172,8 +180,9 @@ frontend/src/
|
||||||
- Migration 010: sleep_log Tabelle (JSONB segments)
|
- Migration 010: sleep_log Tabelle (JSONB segments)
|
||||||
- Migration 011: rest_days Tabelle (Kraft, Cardio, Entspannung)
|
- Migration 011: rest_days Tabelle (Kraft, Cardio, Entspannung)
|
||||||
- Migration 012: Unique constraint rest_days (profile_id, date, rest_type)
|
- Migration 012: Unique constraint rest_days (profile_id, date, rest_type)
|
||||||
- Migration 013: vitals_log Tabelle (Ruhepuls, HRV)
|
- Migration 013: vitals_log Tabelle (Ruhepuls, HRV) - deprecated
|
||||||
- Migration 014: Extended vitals (BP, VO2 Max, SpO2, respiratory_rate)
|
- 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`
|
📚 Details: `.claude/docs/functional/TRAINING_TYPES.md`
|
||||||
|
|
||||||
|
|
@ -181,19 +190,47 @@ frontend/src/
|
||||||
|
|
||||||
## Feature-Roadmap
|
## Feature-Roadmap
|
||||||
|
|
||||||
> Vollständiges Backlog: `.claude/docs/BACKLOG.md`
|
> 📋 **Detaillierte Roadmap:** `.claude/docs/ROADMAP.md` (Phasen 0-3, Timeline, Abhängigkeiten)
|
||||||
> Beim Implementieren: verlinkte Dok-Datei zuerst lesen!
|
> 📚 **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 |
|
### Aktuelle Entwicklung (Phase 0-2, ~10-13 Wochen)
|
||||||
|---------|---------|---------------|
|
|
||||||
| v9c | Membership (aktiv) | `technical/MEMBERSHIP_SYSTEM.md` ✅ |
|
| Phase | Fokus | Dauer | Gitea Issues |
|
||||||
| v9d | Schlaf-Modul | `functional/SLEEP_MODULE.md` (ausstehend) |
|
|-------|-------|-------|--------------|
|
||||||
| v9d | Trainingstypen + HF | `functional/TRAINING_TYPES.md` ✅ |
|
| **Phase 0** | Infrastruktur (v9f) | 4-6 Wochen | #24, #28, #29, #30 |
|
||||||
| v9e | Ziele + Vitalwerte | `functional/GOALS_VITALS.md` (ausstehend) |
|
| **Phase 1** | Foundation (Charts, Goals) | 2-3 Wochen | #26, #25 |
|
||||||
| v9f | KI-Prompt Flexibilisierung | `functional/AI_PROMPTS.md` ✅ |
|
| **Phase 2** | Engagement (Korrelationen, KI) | 3-4 Wochen | #27, #25 |
|
||||||
| v9g | Meditation + Selbstwahrnehmung | `functional/MEDITATION.md` (ausstehend) |
|
| **Phase 3** | Begleitung (Development Routes) | später | - |
|
||||||
| v9h | Connectoren + Stripe | ausstehend |
|
|
||||||
| — | Responsive UI | `functional/RESPONSIVE_UI.md` ✅ |
|
**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
|
## Deployment
|
||||||
|
|
||||||
|
|
@ -237,6 +274,17 @@ subscriptions · coupons · coupon_redemptions · features
|
||||||
tier_limits · user_feature_restrictions · user_feature_usage
|
tier_limits · user_feature_restrictions · user_feature_usage
|
||||||
access_grants · user_activity_log
|
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:
|
Infrastruktur:
|
||||||
schema_migrations – Tracking für automatische DB-Migrationen
|
schema_migrations – Tracking für automatische DB-Migrationen
|
||||||
|
|
||||||
|
|
|
||||||
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 $$;
|
||||||
|
|
@ -27,6 +27,7 @@ class ProfileUpdate(BaseModel):
|
||||||
height: Optional[float] = None
|
height: Optional[float] = None
|
||||||
goal_weight: Optional[float] = None
|
goal_weight: Optional[float] = None
|
||||||
goal_bf_pct: Optional[float] = None
|
goal_bf_pct: Optional[float] = None
|
||||||
|
quality_filter_level: Optional[str] = None # Issue #31: Global quality filter
|
||||||
|
|
||||||
|
|
||||||
# ── Tracking Models ───────────────────────────────────────────────────────────
|
# ── Tracking Models ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
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,7 +79,14 @@ 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()]
|
||||||
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()]
|
activity = [r2d(r) for r in cur.fetchall()]
|
||||||
# v9d Phase 2: Sleep, Rest Days, Vitals
|
# v9d Phase 2: Sleep, Rest Days, Vitals
|
||||||
cur.execute("SELECT * FROM sleep_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
cur.execute("SELECT * FROM sleep_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
|
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import { useProfile } from '../context/ProfileContext'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TrainingTypeDistribution - Pie chart showing activity distribution by type
|
* 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)
|
* @param {number} days - Number of days to analyze (default: 28)
|
||||||
*/
|
*/
|
||||||
export default function TrainingTypeDistribution({ days = 28 }) {
|
export default function TrainingTypeDistribution({ days = 28 }) {
|
||||||
|
const { activeProfile } = useProfile() // Issue #31: Trigger reload on quality filter change
|
||||||
const [data, setData] = useState([])
|
const [data, setData] = useState([])
|
||||||
const [categories, setCategories] = useState({})
|
const [categories, setCategories] = useState({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -41,7 +43,7 @@ export default function TrainingTypeDistribution({ days = 28 }) {
|
||||||
console.error('Failed to load training type distribution:', err)
|
console.error('Failed to load training type distribution:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}, [days])
|
}, [days, activeProfile?.quality_filter_level]) // Issue #31: Reload when quality filter changes
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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,12 +587,14 @@ 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)
|
||||||
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"/>
|
||||||
)
|
)
|
||||||
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
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 filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
|
||||||
|
|
||||||
const byDate={}
|
const byDate={}
|
||||||
|
|
@ -620,6 +623,28 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
|
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
|
||||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
<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}}>
|
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
||||||
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
|
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
|
||||||
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
|
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
|
||||||
|
|
@ -899,6 +924,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([])
|
||||||
|
|
@ -967,7 +993,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