diff --git a/backend/migrations/030_goal_progress_log.sql b/backend/migrations/030_goal_progress_log.sql new file mode 100644 index 0000000..48080d4 --- /dev/null +++ b/backend/migrations/030_goal_progress_log.sql @@ -0,0 +1,64 @@ +-- Migration 030: Goal Progress Log +-- Date: 2026-03-27 +-- Purpose: Track progress history for all goals (especially custom goals without data source) + +-- ============================================================================ +-- Goal Progress Log Table +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS goal_progress_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + goal_id UUID NOT NULL REFERENCES goals(id) ON DELETE CASCADE, + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Progress data + date DATE NOT NULL, + value DECIMAL(10,2) NOT NULL, + note TEXT, + + -- Metadata + source VARCHAR(20) DEFAULT 'manual' CHECK (source IN ('manual', 'automatic', 'import')), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_progress_per_day UNIQUE(goal_id, date) +); + +CREATE INDEX idx_goal_progress_goal_date ON goal_progress_log(goal_id, date DESC); +CREATE INDEX idx_goal_progress_profile ON goal_progress_log(profile_id); + +COMMENT ON TABLE goal_progress_log IS 'Progress history for goals - enables manual tracking for custom goals and charts'; +COMMENT ON COLUMN goal_progress_log.value IS 'Progress value in goal unit (e.g., kg, cm, points)'; +COMMENT ON COLUMN goal_progress_log.source IS 'manual: user entered, automatic: computed from data source, import: CSV/API'; + +-- ============================================================================ +-- Function: Update goal current_value from latest progress +-- ============================================================================ + +CREATE OR REPLACE FUNCTION update_goal_current_value() +RETURNS TRIGGER AS $$ +BEGIN + -- Update current_value in goals table with latest progress entry + UPDATE goals + SET current_value = ( + SELECT value + FROM goal_progress_log + WHERE goal_id = NEW.goal_id + ORDER BY date DESC + LIMIT 1 + ), + updated_at = NOW() + WHERE id = NEW.goal_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger: Auto-update current_value when progress is added/updated +CREATE TRIGGER trigger_update_goal_current_value +AFTER INSERT OR UPDATE ON goal_progress_log +FOR EACH ROW +EXECUTE FUNCTION update_goal_current_value(); + +COMMENT ON FUNCTION update_goal_current_value IS 'Auto-update goal.current_value when new progress is logged'; diff --git a/backend/routers/goals.py b/backend/routers/goals.py index c607636..e269595 100644 --- a/backend/routers/goals.py +++ b/backend/routers/goals.py @@ -77,6 +77,17 @@ class FitnessTestCreate(BaseModel): test_date: date test_conditions: Optional[str] = None +class GoalProgressCreate(BaseModel): + """Log progress for a goal""" + date: date + value: float + note: Optional[str] = None + +class GoalProgressUpdate(BaseModel): + """Update progress entry""" + value: Optional[float] = None + note: Optional[str] = None + class GoalTypeCreate(BaseModel): """Create custom goal type definition""" type_key: str @@ -511,6 +522,117 @@ def delete_goal(goal_id: str, session: dict = Depends(require_auth)): return {"message": "Ziel gelöscht"} +# ============================================================================ +# Goal Progress Endpoints +# ============================================================================ + +@router.get("/{goal_id}/progress") +def get_goal_progress(goal_id: str, session: dict = Depends(require_auth)): + """Get progress history for a goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute( + "SELECT id FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Get progress entries + cur.execute(""" + SELECT id, date, value, note, source, created_at + FROM goal_progress_log + WHERE goal_id = %s + ORDER BY date DESC + """, (goal_id,)) + + entries = cur.fetchall() + return [r2d(e) for e in entries] + +@router.post("/{goal_id}/progress") +def create_goal_progress(goal_id: str, data: GoalProgressCreate, session: dict = Depends(require_auth)): + """Log new progress for a goal""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute( + "SELECT id, unit FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + goal = cur.fetchone() + if not goal: + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Insert progress entry + try: + cur.execute(""" + INSERT INTO goal_progress_log (goal_id, profile_id, date, value, note, source) + VALUES (%s, %s, %s, %s, %s, 'manual') + RETURNING id + """, (goal_id, pid, data.date, data.value, data.note)) + + progress_id = cur.fetchone()['id'] + + # Trigger will auto-update goals.current_value + return { + "id": progress_id, + "message": f"Fortschritt erfasst: {data.value} {goal['unit']}" + } + + except Exception as e: + if "unique_progress_per_day" in str(e): + raise HTTPException( + status_code=400, + detail=f"Für {data.date} existiert bereits ein Eintrag. Bitte bearbeite den existierenden Eintrag." + ) + raise HTTPException(status_code=500, detail=f"Fehler beim Speichern: {str(e)}") + +@router.delete("/{goal_id}/progress/{progress_id}") +def delete_goal_progress(goal_id: str, progress_id: str, session: dict = Depends(require_auth)): + """Delete progress entry""" + pid = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute( + "SELECT id FROM goals WHERE id = %s AND profile_id = %s", + (goal_id, pid) + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Ziel nicht gefunden") + + # Delete progress entry + cur.execute( + "DELETE FROM goal_progress_log WHERE id = %s AND goal_id = %s AND profile_id = %s", + (progress_id, goal_id, pid) + ) + + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="Progress-Eintrag nicht gefunden") + + # After deletion, recalculate current_value from remaining entries + cur.execute(""" + UPDATE goals + SET current_value = ( + SELECT value FROM goal_progress_log + WHERE goal_id = %s + ORDER BY date DESC + LIMIT 1 + ) + WHERE id = %s + """, (goal_id, goal_id)) + + return {"message": "Progress-Eintrag gelöscht"} + @router.get("/grouped") def get_goals_grouped(session: dict = Depends(require_auth)): """ diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a25567e..9accca8 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -345,6 +345,11 @@ export const api = { updateGoal: (id,d) => req(`/goals/${id}`, jput(d)), deleteGoal: (id) => req(`/goals/${id}`, {method:'DELETE'}), + // Goal Progress (v2.1) + listGoalProgress: (id) => req(`/goals/${id}/progress`), + createGoalProgress: (id,d) => req(`/goals/${id}/progress`, json(d)), + deleteGoalProgress: (gid,pid) => req(`/goals/${gid}/progress/${pid}`, {method:'DELETE'}), + // Goal Type Definitions (Phase 1.5) listGoalTypeDefinitions: () => req('/goals/goal-types'), createGoalType: (d) => req('/goals/goal-types', json(d)),