Compare commits

...

2 Commits

Author SHA1 Message Date
caebc37da0 feat: goal categories UI - complete rebuild
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
Completed frontend for multi-dimensional goal priorities.

**UI Changes:**
- Category-grouped goal display with color-coded headers
- Each category shows: icon, name, description, goal count
- Priority stars (//) replace "PRIMÄR" badge
- Goals have category-colored left border
- Form fields: Category dropdown + Priority selector
- Removed "Gewichtung gesamt" display (useless UX)

**Categories:**
- 📉 Körper (body): Gewicht, Körperfett, Muskelmasse
- 🏋️ Training: Kraft, Frequenz, Performance
- 🍎 Ernährung: Kalorien, Makros, Essgewohnheiten
- 😴 Erholung: Schlaf, Regeneration, Ruhetage
- ❤️ Gesundheit: Vitalwerte, Blutdruck, HRV
- 📌 Sonstiges: Weitere Ziele

**Priority Levels:**
-  Hoch (1)
-  Mittel (2)
-  Niedrig (3)

**Implementation:**
- Load groupedGoals via api.listGoalsGrouped()
- GOAL_CATEGORIES + PRIORITY_LEVELS constants
- handleEditGoal/handleSaveGoal/handleCreateGoal extended
- Backward compatible (is_primary still exists)

Next: Test migration + UI, then update Dashboard to show top-1 per category

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 12:33:17 +01:00
6a3a782bff feat: goal categories and priorities - backend + API
Implemented multi-dimensional goal priorities (Option B).

**Backend Changes:**
- Migration 028: Added `category` + `priority` columns to goals table
- Auto-migration of existing goals to categories based on goal_type
- GoalCreate/GoalUpdate models extended with category + priority
- New endpoint: GET /api/goals/grouped (returns goals by category)
- Categories: body, training, nutrition, recovery, health, other
- Priorities: 1=high (), 2=medium (), 3=low ()

**API Changes:**
- Added api.listGoalsGrouped() binding

**Frontend (partial):**
- Added GOAL_CATEGORIES + PRIORITY_LEVELS constants
- Extended formData with category + priority fields
- Removed "Gewichtung gesamt" display (useless)
- Load groupedGoals in addition to flat goals list

Next: Complete frontend UI rebuild for category grouping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 12:30:59 +01:00
4 changed files with 343 additions and 214 deletions

View File

@ -0,0 +1,58 @@
-- Migration 028: Goal Categories and Priorities
-- Date: 2026-03-27
-- Purpose: Multi-dimensional goal priorities (one primary goal per category)
-- ============================================================================
-- Add category and priority columns
-- ============================================================================
ALTER TABLE goals
ADD COLUMN category VARCHAR(50),
ADD COLUMN priority INTEGER DEFAULT 2 CHECK (priority >= 1 AND priority <= 3);
COMMENT ON COLUMN goals.category IS 'Goal category: body, training, nutrition, recovery, health, other';
COMMENT ON COLUMN goals.priority IS 'Priority level: 1=high, 2=medium, 3=low';
-- ============================================================================
-- Migrate existing goals to categories based on goal_type
-- ============================================================================
UPDATE goals SET category = CASE
-- Body composition goals
WHEN goal_type IN ('weight', 'body_fat', 'lean_mass') THEN 'body'
-- Training goals
WHEN goal_type IN ('strength', 'flexibility', 'training_frequency') THEN 'training'
-- Health/cardio goals
WHEN goal_type IN ('vo2max', 'rhr', 'bp', 'hrv') THEN 'health'
-- Recovery goals
WHEN goal_type IN ('sleep_quality', 'sleep_duration', 'rest_days') THEN 'recovery'
-- Nutrition goals
WHEN goal_type IN ('calories', 'protein', 'healthy_eating') THEN 'nutrition'
-- Default
ELSE 'other'
END
WHERE category IS NULL;
-- ============================================================================
-- Set priority based on is_primary
-- ============================================================================
UPDATE goals SET priority = CASE
WHEN is_primary = true THEN 1 -- Primary goals get priority 1
ELSE 2 -- Others get priority 2 (medium)
END;
-- ============================================================================
-- Create index for category-based queries
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_goals_category_priority
ON goals(profile_id, category, priority)
WHERE is_active = true;
COMMENT ON INDEX idx_goals_category_priority IS 'Fast lookup for category-grouped goals sorted by priority';

View File

@ -42,10 +42,12 @@ class FocusAreasUpdate(BaseModel):
class GoalCreate(BaseModel): class GoalCreate(BaseModel):
"""Create or update a concrete goal""" """Create or update a concrete goal"""
goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr goal_type: str # weight, body_fat, lean_mass, vo2max, strength, flexibility, bp, rhr
is_primary: bool = False is_primary: bool = False # Kept for backward compatibility
target_value: float target_value: float
unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps unit: str # kg, %, ml/kg/min, bpm, mmHg, cm, reps
target_date: Optional[date] = None target_date: Optional[date] = None
category: Optional[str] = 'other' # body, training, nutrition, recovery, health, other
priority: Optional[int] = 2 # 1=high, 2=medium, 3=low
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
@ -54,7 +56,9 @@ class GoalUpdate(BaseModel):
target_value: Optional[float] = None target_value: Optional[float] = None
target_date: Optional[date] = None target_date: Optional[date] = None
status: Optional[str] = None # active, reached, abandoned, expired status: Optional[str] = None # active, reached, abandoned, expired
is_primary: Optional[bool] = None is_primary: Optional[bool] = None # Kept for backward compatibility
category: Optional[str] = None # body, training, nutrition, recovery, health, other
priority: Optional[int] = None # 1=high, 2=medium, 3=low
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
@ -403,13 +407,13 @@ def create_goal(data: GoalCreate, session: dict = Depends(require_auth)):
INSERT INTO goals ( INSERT INTO goals (
profile_id, goal_type, is_primary, profile_id, goal_type, is_primary,
target_value, current_value, start_value, unit, target_value, current_value, start_value, unit,
target_date, name, description target_date, category, priority, name, description
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", ( """, (
pid, data.goal_type, data.is_primary, pid, data.goal_type, data.is_primary,
data.target_value, current_value, current_value, data.unit, data.target_value, current_value, current_value, data.unit,
data.target_date, data.name, data.description data.target_date, data.category, data.priority, data.name, data.description
)) ))
goal_id = cur.fetchone()['id'] goal_id = cur.fetchone()['id']
@ -461,6 +465,14 @@ def update_goal(goal_id: str, data: GoalUpdate, session: dict = Depends(require_
updates.append("is_primary = %s") updates.append("is_primary = %s")
params.append(data.is_primary) params.append(data.is_primary)
if data.category is not None:
updates.append("category = %s")
params.append(data.category)
if data.priority is not None:
updates.append("priority = %s")
params.append(data.priority)
if data.name is not None: if data.name is not None:
updates.append("name = %s") updates.append("name = %s")
params.append(data.name) params.append(data.name)
@ -499,6 +511,52 @@ def delete_goal(goal_id: str, session: dict = Depends(require_auth)):
return {"message": "Ziel gelöscht"} return {"message": "Ziel gelöscht"}
@router.get("/grouped")
def get_goals_grouped(session: dict = Depends(require_auth)):
"""
Get goals grouped by category, sorted by priority.
Returns structure:
{
"body": [{"id": "...", "goal_type": "weight", "priority": 1, ...}, ...],
"training": [...],
"nutrition": [...],
"recovery": [...],
"health": [...],
"other": [...]
}
"""
pid = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get all active goals with type definitions
cur.execute("""
SELECT
g.id, g.goal_type, g.target_value, g.current_value, g.start_value,
g.unit, g.target_date, g.status, g.is_primary, g.category, g.priority,
g.name, g.description, g.progress_pct, g.linear_projection,
g.created_at, g.updated_at,
gt.label_de, gt.icon, gt.category as type_category
FROM goals g
LEFT JOIN goal_type_definitions gt ON g.goal_type = gt.type_key
WHERE g.profile_id = %s AND g.is_active = true
ORDER BY g.category, g.priority ASC, g.created_at DESC
""", (pid,))
goals = cur.fetchall()
# Group by category
grouped = {}
for goal in goals:
cat = goal['category'] or 'other'
if cat not in grouped:
grouped[cat] = []
grouped[cat].append(r2d(goal))
return grouped
# ============================================================================ # ============================================================================
# Training Phases # Training Phases
# ============================================================================ # ============================================================================

View File

@ -5,44 +5,22 @@ import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
// Goal Mode Definitions // Goal Categories
const GOAL_MODES = [ const GOAL_CATEGORIES = {
{ body: { label: 'Körper', icon: '📉', color: '#D85A30', description: 'Gewicht, Körperfett, Muskelmasse' },
id: 'weight_loss', training: { label: 'Training', icon: '🏋️', color: '#378ADD', description: 'Kraft, Frequenz, Performance' },
icon: '📉', nutrition: { label: 'Ernährung', icon: '🍎', color: '#1D9E75', description: 'Kalorien, Makros, Essgewohnheiten' },
label: 'Gewichtsreduktion', recovery: { label: 'Erholung', icon: '😴', color: '#7B68EE', description: 'Schlaf, Regeneration, Ruhetage' },
description: 'Kaloriendefizit, Fettabbau', health: { label: 'Gesundheit', icon: '❤️', color: '#E67E22', description: 'Vitalwerte, Blutdruck, HRV' },
color: '#D85A30' other: { label: 'Sonstiges', icon: '📌', color: '#94A3B8', description: 'Weitere Ziele' }
}, }
{
id: 'strength', // Priority Levels
icon: '💪', const PRIORITY_LEVELS = {
label: 'Kraftaufbau', 1: { label: 'Hoch', stars: '⭐⭐⭐', color: 'var(--accent)' },
description: 'Muskelwachstum, progressive Belastung', 2: { label: 'Mittel', stars: '⭐⭐', color: '#94A3B8' },
color: '#378ADD' 3: { label: 'Niedrig', stars: '⭐', color: '#CBD5E1' }
}, }
{
id: 'endurance',
icon: '🏃',
label: 'Ausdauer',
description: 'VO2Max, aerobe Kapazität',
color: '#1D9E75'
},
{
id: 'recomposition',
icon: '⚖️',
label: 'Körperkomposition',
description: 'Gleichzeitig Fett ab- & Muskeln aufbauen',
color: '#7B68EE'
},
{
id: 'health',
icon: '❤️',
label: 'Allgemeine Gesundheit',
description: 'Ausgewogen, präventiv',
color: '#E67E22'
}
]
export default function GoalsPage() { export default function GoalsPage() {
const [goalMode, setGoalMode] = useState(null) const [goalMode, setGoalMode] = useState(null)
@ -56,7 +34,8 @@ export default function GoalsPage() {
flexibility_pct: 0, flexibility_pct: 0,
health_pct: 0 health_pct: 0
}) })
const [goals, setGoals] = useState([]) const [goals, setGoals] = useState([]) // Kept for backward compat
const [groupedGoals, setGroupedGoals] = useState({}) // Category-grouped goals
const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5) const [goalTypes, setGoalTypes] = useState([]) // Dynamic from DB (Phase 1.5)
const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup const [goalTypesMap, setGoalTypesMap] = useState({}) // For quick lookup
const [showGoalForm, setShowGoalForm] = useState(false) const [showGoalForm, setShowGoalForm] = useState(false)
@ -69,6 +48,8 @@ export default function GoalsPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
goal_type: 'weight', goal_type: 'weight',
is_primary: false, is_primary: false,
category: 'body',
priority: 2,
target_value: '', target_value: '',
unit: 'kg', unit: 'kg',
target_date: '', target_date: '',
@ -84,14 +65,16 @@ export default function GoalsPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const [modeData, goalsData, typesData, focusData] = await Promise.all([ const [modeData, goalsData, groupedData, typesData, focusData] = await Promise.all([
api.getGoalMode(), api.getGoalMode(),
api.listGoals(), api.listGoals(),
api.listGoalsGrouped(), // v2.1: Load grouped by category
api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB api.listGoalTypeDefinitions(), // Phase 1.5: Load from DB
api.getFocusAreas() // v2.0: Load focus areas api.getFocusAreas() // v2.0: Load focus areas
]) ])
setGoalMode(modeData.goal_mode) setGoalMode(modeData.goal_mode)
setGoals(goalsData) setGoals(goalsData)
setGroupedGoals(groupedData)
// Ensure all focus fields are present and numeric // Ensure all focus fields are present and numeric
const sanitizedFocus = { const sanitizedFocus = {
@ -158,6 +141,8 @@ export default function GoalsPage() {
setFormData({ setFormData({
goal_type: firstType, goal_type: firstType,
is_primary: goals.length === 0, // First goal is primary by default is_primary: goals.length === 0, // First goal is primary by default
category: 'body',
priority: 2,
target_value: '', target_value: '',
unit: goalTypesMap[firstType]?.unit || 'kg', unit: goalTypesMap[firstType]?.unit || 'kg',
target_date: '', target_date: '',
@ -172,6 +157,8 @@ export default function GoalsPage() {
setFormData({ setFormData({
goal_type: goal.goal_type, goal_type: goal.goal_type,
is_primary: goal.is_primary, is_primary: goal.is_primary,
category: goal.category || 'other',
priority: goal.priority || 2,
target_value: goal.target_value, target_value: goal.target_value,
unit: goal.unit, unit: goal.unit,
target_date: goal.target_date || '', target_date: goal.target_date || '',
@ -199,6 +186,8 @@ export default function GoalsPage() {
const data = { const data = {
goal_type: formData.goal_type, goal_type: formData.goal_type,
is_primary: formData.is_primary, is_primary: formData.is_primary,
category: formData.category,
priority: formData.priority,
target_value: parseFloat(formData.target_value), target_value: parseFloat(formData.target_value),
unit: formData.unit, unit: formData.unit,
target_date: formData.target_date || null, target_date: formData.target_date || null,
@ -367,32 +356,6 @@ export default function GoalsPage() {
})} })}
</div> </div>
{/* Weight Total Display */}
<div style={{
padding: 12,
background: 'var(--surface2)',
border: '1px solid var(--border)',
borderRadius: 8,
marginBottom: 16
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: 'var(--text2)' }}>
Gewichtung gesamt:
</span>
<span style={{ fontSize: 18, fontWeight: 600, color: 'var(--text1)' }}>
{(() => {
const total = Object.entries(focusTemp)
.filter(([k]) => k.endsWith('_pct'))
.reduce((acc, [k, v]) => acc + (Number(v) || 0), 0)
return (total / 10).toFixed(1)
})()}
</span>
</div>
<div style={{ fontSize: 12, marginTop: 4, color: 'var(--text3)' }}>
💡 Die Prozentanteile werden automatisch berechnet
</div>
</div>
{/* Action Buttons */} {/* Action Buttons */}
<div style={{ display: 'flex', gap: 12 }}> <div style={{ display: 'flex', gap: 12 }}>
<button <button
@ -482,7 +445,7 @@ export default function GoalsPage() {
)} )}
</div> </div>
{/* Tactical Goals List */} {/* Tactical Goals List - Category Grouped */}
<div className="card"> <div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>🎯 Konkrete Ziele</h2> <h2 style={{ margin: 0 }}>🎯 Konkrete Ziele</h2>
@ -491,7 +454,7 @@ export default function GoalsPage() {
</button> </button>
</div> </div>
{goals.length === 0 ? ( {Object.keys(groupedGoals).length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text2)' }}> <div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text2)' }}>
<Target size={48} style={{ opacity: 0.3, marginBottom: 16 }} /> <Target size={48} style={{ opacity: 0.3, marginBottom: 16 }} />
<p>Noch keine Ziele definiert</p> <p>Noch keine Ziele definiert</p>
@ -500,128 +463,150 @@ export default function GoalsPage() {
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
{goals.map(goal => { {Object.entries(GOAL_CATEGORIES).map(([catKey, catInfo]) => {
const typeInfo = goalTypesMap[goal.goal_type] || { label: goal.goal_type, unit: '', icon: '📊' } const categoryGoals = groupedGoals[catKey] || []
if (categoryGoals.length === 0) return null
return ( return (
<div <div key={catKey}>
key={goal.id} {/* Category Header */}
className="card" <div style={{
style={{ display: 'flex',
background: 'var(--surface2)', alignItems: 'center',
border: goal.is_primary ? '2px solid var(--accent)' : '1px solid var(--border)' gap: 12,
}} marginBottom: 16,
> paddingBottom: 12,
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}> borderBottom: `3px solid ${catInfo.color}`
}}>
<span style={{ fontSize: 28 }}>{catInfo.icon}</span>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}> <h3 style={{ margin: 0, color: catInfo.color, fontSize: 18 }}>{catInfo.label}</h3>
<span style={{ fontSize: 20 }}>{typeInfo.icon}</span> <p style={{ margin: 0, fontSize: 13, color: 'var(--text3)' }}>{catInfo.description}</p>
<span style={{ fontWeight: 600 }}> </div>
{goal.name || typeInfo.label} <span style={{
</span> background: catInfo.color,
{goal.is_primary && ( color: 'white',
<span style={{ padding: '4px 12px',
background: 'var(--accent)', borderRadius: 16,
color: 'white', fontSize: 13,
fontSize: 11, fontWeight: 600
padding: '2px 8px', }}>
borderRadius: 4 {categoryGoals.length} {categoryGoals.length === 1 ? 'Ziel' : 'Ziele'}
}}> </span>
PRIMÄR </div>
</span>
)}
<span style={{
background: goal.status === 'active' ? '#E6F4F1' : '#F3F4F6',
color: goal.status === 'active' ? 'var(--accent)' : 'var(--text2)',
fontSize: 11,
padding: '2px 8px',
borderRadius: 4
}}>
{goal.status === 'active' ? 'AKTIV' : goal.status?.toUpperCase()}
</span>
</div>
<div style={{ display: 'flex', gap: 24, marginBottom: 12, fontSize: 14 }}> {/* Goals in Category */}
<div> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '} {categoryGoals.map(goal => {
<strong>{goal.start_value} {goal.unit}</strong> const typeInfo = goalTypesMap[goal.goal_type] || { label_de: goal.goal_type, unit: '', icon: '📊' }
</div> const priorityInfo = PRIORITY_LEVELS[goal.priority] || PRIORITY_LEVELS[2]
<div>
<span style={{ color: 'var(--text2)' }}>Aktuell:</span>{' '}
<strong>{goal.current_value || '—'} {goal.unit}</strong>
</div>
<div>
<span style={{ color: 'var(--text2)' }}>Ziel:</span>{' '}
<strong>{goal.target_value} {goal.unit}</strong>
</div>
{goal.target_date && (
<div>
<Calendar size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{dayjs(goal.target_date).format('DD.MM.YYYY')}
</div>
)}
</div>
{goal.progress_pct !== null && ( return (
<div> <div
<div style={{ key={goal.id}
display: 'flex', className="card"
justifyContent: 'space-between', style={{
fontSize: 12, background: 'var(--surface2)',
marginBottom: 4 border: `1px solid var(--border)`,
}}> borderLeft: `5px solid ${catInfo.color}`
<span>Fortschritt</span> }}
<span style={{ fontWeight: 600 }}>{goal.progress_pct}%</span> >
</div> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
<div style={{ <div style={{ flex: 1 }}>
width: '100%', <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
height: 6, <span style={{ fontSize: 20 }}>{typeInfo.icon}</span>
background: 'var(--surface)', <span style={{ fontWeight: 600, fontSize: 16 }}>
borderRadius: 3, {goal.name || typeInfo.label_de}
overflow: 'hidden'
}}>
<div style={{
width: `${Math.min(100, Math.max(0, goal.progress_pct))}%`,
height: '100%',
background: getProgressColor(goal.progress_pct),
transition: 'width 0.3s ease'
}} />
</div>
{goal.on_track !== null && (
<div style={{ marginTop: 8, fontSize: 12 }}>
{goal.on_track ? (
<span style={{ color: 'var(--accent)' }}>
Ziel voraussichtlich erreichbar bis {dayjs(goal.target_date).format('DD.MM.YYYY')}
</span> </span>
) : ( <span style={{ fontSize: 16 }} title={priorityInfo.label}>
<span style={{ color: '#D85A30' }}> {priorityInfo.stars}
Prognose: {goal.projection_date ? dayjs(goal.projection_date).format('DD.MM.YYYY') : 'Offen'}
{goal.target_date && ' (später als geplant)'}
</span> </span>
<span style={{
background: goal.status === 'active' ? '#E6F4F1' : '#F3F4F6',
color: goal.status === 'active' ? 'var(--accent)' : 'var(--text2)',
fontSize: 11,
padding: '3px 8px',
borderRadius: 4,
fontWeight: 500
}}>
{goal.status === 'active' ? 'AKTIV' : goal.status?.toUpperCase() || 'AKTIV'}
</span>
</div>
<div style={{ display: 'flex', gap: 20, marginBottom: 12, fontSize: 14, flexWrap: 'wrap' }}>
<div>
<span style={{ color: 'var(--text2)' }}>Start:</span>{' '}
<strong>{goal.start_value} {goal.unit}</strong>
</div>
<div>
<span style={{ color: 'var(--text2)' }}>Aktuell:</span>{' '}
<strong>{goal.current_value || '—'} {goal.unit}</strong>
</div>
<div>
<span style={{ color: 'var(--text2)' }}>Ziel:</span>{' '}
<strong style={{ color: catInfo.color }}>{goal.target_value} {goal.unit}</strong>
</div>
{goal.target_date && (
<div style={{ color: 'var(--text2)' }}>
<Calendar size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />
{dayjs(goal.target_date).format('DD.MM.YYYY')}
</div>
)}
</div>
{goal.progress_pct !== null && (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
marginBottom: 4,
color: 'var(--text2)'
}}>
<span>Fortschritt</span>
<span style={{ fontWeight: 600, color: 'var(--text1)' }}>{goal.progress_pct}%</span>
</div>
<div style={{
width: '100%',
height: 8,
background: 'var(--surface)',
borderRadius: 4,
overflow: 'hidden'
}}>
<div style={{
width: `${Math.min(100, Math.max(0, goal.progress_pct))}%`,
height: '100%',
background: catInfo.color,
transition: 'width 0.3s ease'
}} />
</div>
</div>
)} )}
</div> </div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button <button
className="btn-secondary" className="btn-secondary"
onClick={() => handleEditGoal(goal)} onClick={() => handleEditGoal(goal)}
style={{ padding: '6px 12px' }} style={{ padding: '6px 12px' }}
> title="Bearbeiten"
<Pencil size={14} /> >
</button> <Pencil size={14} />
<button </button>
className="btn-secondary" <button
onClick={() => handleDeleteGoal(goal.id)} className="btn-secondary"
style={{ padding: '6px 12px', color: '#DC2626' }} onClick={() => handleDeleteGoal(goal.id)}
> style={{ padding: '6px 12px', color: '#DC2626' }}
<Trash2 size={14} /> title="Löschen"
</button> >
</div> <Trash2 size={14} />
</button>
</div>
</div>
</div>
)
})}
</div> </div>
</div> </div>
) )
@ -817,34 +802,61 @@ export default function GoalsPage() {
/> />
</div> </div>
{/* Primärziel */} {/* Category & Priority */}
<div style={{ <div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, marginTop: 20, color: 'var(--text1)' }}>
padding: 12, 📂 Kategorisierung
background: 'var(--surface2)', </div>
borderRadius: 8,
marginBottom: 20 <div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
}}> {/* Category */}
<label style={{ <div style={{ flex: 1 }}>
display: 'flex', <label style={{
alignItems: 'flex-start', display: 'block',
gap: 10, fontSize: 13,
cursor: 'pointer' fontWeight: 500,
}}> marginBottom: 4,
<input color: 'var(--text2)'
type="checkbox" }}>
checked={formData.is_primary} Kategorie *
onChange={e => setFormData(f => ({ ...f, is_primary: e.target.checked }))} </label>
style={{ marginTop: 2 }} <select
/> className="form-input"
<div> style={{ width: '100%' }}
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}> value={formData.category}
Als Primärziel setzen onChange={e => setFormData(f => ({ ...f, category: e.target.value }))}
</div> >
<div style={{ fontSize: 12, color: 'var(--text2)' }}> {Object.entries(GOAL_CATEGORIES).map(([key, info]) => (
Dein Primärziel hat höchste Priorität in Analysen und Charts <option key={key} value={key}>
</div> {info.icon} {info.label}
</div> </option>
</label> ))}
</select>
</div>
{/* Priority */}
<div style={{ flex: 1 }}>
<label style={{
display: 'block',
fontSize: 13,
fontWeight: 500,
marginBottom: 4,
color: 'var(--text2)'
}}>
Priorität *
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.priority}
onChange={e => setFormData(f => ({ ...f, priority: parseInt(e.target.value) }))}
>
{Object.entries(PRIORITY_LEVELS).map(([level, info]) => (
<option key={level} value={level}>
{info.stars} {info.label}
</option>
))}
</select>
</div>
</div> </div>
{/* Buttons */} {/* Buttons */}

View File

@ -340,6 +340,7 @@ export const api = {
updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)), updateFocusAreas: (d) => req('/goals/focus-areas', jput(d)),
listGoals: () => req('/goals/list'), listGoals: () => req('/goals/list'),
listGoalsGrouped: () => req('/goals/grouped'),
createGoal: (d) => req('/goals/create', json(d)), createGoal: (d) => req('/goals/create', json(d)),
updateGoal: (id,d) => req(`/goals/${id}`, jput(d)), updateGoal: (id,d) => req(`/goals/${id}`, jput(d)),
deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}), deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}),