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:
祀梦
2026-05-18 23:00:24 +08:00
parent 4ee1e39454
commit 4ce7de48c4
12 changed files with 529 additions and 20 deletions

View File

@@ -18,14 +18,14 @@ EXPORT_TABLES = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "habit_checkins",
"goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
"task_tags", "goal_tasks",
]
# 导入时的清表顺序:子表先删(避免 FK 约束报错)
TRUNCATE_ORDER = [
"task_tags", "goal_tasks",
"habit_checkins",
"habit_checkins", "goal_checkins",
"goal_reviews", "goal_steps",
"tasks", "habits", "anniversaries", "certificates",
"goals", "categories", "tags",
@@ -38,7 +38,7 @@ INSERT_ORDER = [
"categories", "tags", "user_settings", "sync_settings",
"habit_groups", "anniversary_categories", "certificate_categories",
"goals", "tasks", "habits", "anniversaries", "certificates",
"goal_steps", "goal_reviews", "habit_checkins",
"goal_steps", "goal_reviews", "goal_checkins", "habit_checkins",
"task_tags", "goal_tasks",
]

View File

@@ -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="删除打卡记录失败")