feat: add cumulative checkin tracking mode for goals
Goals can now choose between milestone-based progress (existing) and cumulative checkin-based progress (new). Cumulative mode supports cross-unit conversion (e.g. kcal → g fat) via a configurable conversion rate. New GoalCheckin model stores daily inputs; progress auto-recalculates on every checkin C/U/D. Backup import/export covers the new table. Frontend GoalDialog, GoalDetailPage and GoalPage cards adapt to show cumulative progress or milestone progress based on track_type. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.goal import Goal, GoalStep, GoalReview
|
||||
from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin
|
||||
from app.models.task import Task
|
||||
from app.schemas.goal import (
|
||||
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
|
||||
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
|
||||
GoalReviewCreate, GoalReviewResponse,
|
||||
GoalCheckinCreate, GoalCheckinUpdate, GoalCheckinResponse,
|
||||
ReorderRequest,
|
||||
)
|
||||
from app.schemas.common import DeleteResponse
|
||||
@@ -20,7 +22,20 @@ router = APIRouter(prefix="/api/goals", tags=["目标"])
|
||||
|
||||
|
||||
def recalc_progress(db: Session, goal_id: int):
|
||||
"""根据里程碑完成比例重新计算目标进度"""
|
||||
"""根据追踪类型重新计算目标进度。里程碑模式按步骤完成比例;累计模式按打卡值/换算率/目标值。"""
|
||||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||
if not goal:
|
||||
return 0
|
||||
|
||||
if goal.track_type == "cumulative" and goal.target_value and goal.target_value > 0:
|
||||
total = db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
|
||||
GoalCheckin.goal_id == goal_id,
|
||||
).scalar()
|
||||
goal.current_value = float(total)
|
||||
progress_in_target = goal.current_value / goal.conversion_rate if goal.conversion_rate else goal.current_value
|
||||
progress = int(progress_in_target / goal.target_value * 100)
|
||||
return min(progress, 100)
|
||||
|
||||
total = db.query(GoalStep).filter(
|
||||
GoalStep.goal_id == goal_id,
|
||||
GoalStep.step_type == "milestone",
|
||||
@@ -89,6 +104,12 @@ def get_goals(
|
||||
).count()
|
||||
g.total_steps = total
|
||||
g.completed_steps = completed
|
||||
# 累计模式重新计算进度和当前值
|
||||
if g.track_type == "cumulative":
|
||||
g.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
|
||||
GoalCheckin.goal_id == g.id,
|
||||
).scalar())
|
||||
g.progress = recalc_progress(db, g.id)
|
||||
result.append(g)
|
||||
|
||||
return result
|
||||
@@ -115,7 +136,7 @@ def create_goal(data: GoalCreate, db: Session = Depends(get_db)):
|
||||
|
||||
@router.get("/{goal_id}", response_model=GoalDetailResponse)
|
||||
def get_goal(goal_id: int, db: Session = Depends(get_db)):
|
||||
"""获取目标详情(含 steps 树、reviews、关联 tasks)"""
|
||||
"""获取目标详情(含 steps 树、reviews、关联 tasks、checkins)"""
|
||||
try:
|
||||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||||
goal.total_steps = db.query(GoalStep).filter(
|
||||
@@ -127,6 +148,11 @@ def get_goal(goal_id: int, db: Session = Depends(get_db)):
|
||||
GoalStep.step_type == "milestone",
|
||||
GoalStep.status == "completed",
|
||||
).count()
|
||||
if goal.track_type == "cumulative":
|
||||
goal.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
|
||||
GoalCheckin.goal_id == goal_id,
|
||||
).scalar())
|
||||
goal.progress = recalc_progress(db, goal_id)
|
||||
return goal
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -430,3 +456,101 @@ def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
|
||||
db.rollback()
|
||||
logger.error(f"取消关联任务失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="取消关联任务失败")
|
||||
|
||||
|
||||
# ============ Checkins (累计打卡) ============
|
||||
|
||||
@router.get("/{goal_id}/checkins", response_model=List[GoalCheckinResponse])
|
||||
def get_checkins(goal_id: int, db: Session = Depends(get_db)):
|
||||
"""获取目标的打卡记录列表"""
|
||||
try:
|
||||
get_or_404(db, Goal, goal_id, "目标")
|
||||
checkins = db.query(GoalCheckin).filter(
|
||||
GoalCheckin.goal_id == goal_id,
|
||||
).order_by(GoalCheckin.checkin_date.desc(), GoalCheckin.created_at.desc()).all()
|
||||
return checkins
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取打卡记录失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="获取打卡记录失败")
|
||||
|
||||
|
||||
@router.post("/{goal_id}/checkins", response_model=GoalCheckinResponse, status_code=201)
|
||||
def create_checkin(goal_id: int, data: GoalCheckinCreate, db: Session = Depends(get_db)):
|
||||
"""创建打卡记录并重算进度"""
|
||||
try:
|
||||
get_or_404(db, Goal, goal_id, "目标")
|
||||
checkin = GoalCheckin(goal_id=goal_id, **data.model_dump())
|
||||
db.add(checkin)
|
||||
db.commit()
|
||||
db.refresh(checkin)
|
||||
|
||||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||
if goal:
|
||||
goal.progress = recalc_progress(db, goal_id)
|
||||
goal.updated_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info(f"创建打卡记录成功: id={checkin.id}, goal_id={goal_id}, value={checkin.value}")
|
||||
return checkin
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"创建打卡记录失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="创建打卡记录失败")
|
||||
|
||||
|
||||
@router.put("/{goal_id}/checkins/{checkin_id}", response_model=GoalCheckinResponse)
|
||||
def update_checkin(goal_id: int, checkin_id: int, data: GoalCheckinUpdate, db: Session = Depends(get_db)):
|
||||
"""修改打卡记录并重算进度"""
|
||||
try:
|
||||
get_or_404(db, Goal, goal_id, "目标")
|
||||
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None or field in data.clearable_fields:
|
||||
setattr(checkin, field, value)
|
||||
db.commit()
|
||||
db.refresh(checkin)
|
||||
|
||||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||
if goal:
|
||||
goal.progress = recalc_progress(db, goal_id)
|
||||
goal.updated_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info(f"更新打卡记录成功: id={checkin_id}")
|
||||
return checkin
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"更新打卡记录失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="更新打卡记录失败")
|
||||
|
||||
|
||||
@router.delete("/{goal_id}/checkins/{checkin_id}")
|
||||
def delete_checkin(goal_id: int, checkin_id: int, db: Session = Depends(get_db)):
|
||||
"""删除打卡记录并重算进度"""
|
||||
try:
|
||||
get_or_404(db, Goal, goal_id, "目标")
|
||||
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
|
||||
db.delete(checkin)
|
||||
db.commit()
|
||||
|
||||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||||
if goal:
|
||||
goal.progress = recalc_progress(db, goal_id)
|
||||
goal.updated_at = utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info(f"删除打卡记录成功: id={checkin_id}")
|
||||
return DeleteResponse(message="打卡记录删除成功")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"删除打卡记录失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="删除打卡记录失败")
|
||||
|
||||
Reference in New Issue
Block a user