feat: Goal Progress Log - backend + API (v2.1)
Implemented progress tracking system for all goals.
**Backend:**
- Migration 030: goal_progress_log table with unique constraint per day
- Trigger: Auto-update goal.current_value from latest progress
- Endpoints: GET/POST/DELETE /api/goals/{id}/progress
- Pydantic Models: GoalProgressCreate, GoalProgressUpdate
**Features:**
- Manual progress tracking for custom goals (flexibility, strength, etc.)
- Full history with date, value, note
- current_value always reflects latest progress entry
- One entry per day per goal (unique constraint)
- Cascade delete when goal is deleted
**API:**
- GET /api/goals/{goal_id}/progress - List all entries
- POST /api/goals/{goal_id}/progress - Log new progress
- DELETE /api/goals/{goal_id}/progress/{progress_id} - Delete entry
**Next:** Frontend UI (progress button, modal, history list)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ce37afb2bb
commit
7db98a4fa6
64
backend/migrations/030_goal_progress_log.sql
Normal file
64
backend/migrations/030_goal_progress_log.sql
Normal file
|
|
@ -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';
|
||||
|
|
@ -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)):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user