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:
@@ -4,7 +4,7 @@ from app.models.tag import Tag, task_tags
|
||||
from app.models.user_settings import UserSettings
|
||||
from app.models.habit import HabitGroup, Habit, HabitCheckin
|
||||
from app.models.anniversary import AnniversaryCategory, Anniversary
|
||||
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
|
||||
from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin, goal_tasks
|
||||
from app.models.sync_settings import SyncSettings
|
||||
from app.models.certificate import Certificate, CertificateCategory
|
||||
|
||||
@@ -12,7 +12,7 @@ __all__ = [
|
||||
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
||||
"HabitGroup", "Habit", "HabitCheckin",
|
||||
"AnniversaryCategory", "Anniversary",
|
||||
"Goal", "GoalStep", "GoalReview", "goal_tasks",
|
||||
"Goal", "GoalStep", "GoalReview", "GoalCheckin", "goal_tasks",
|
||||
"SyncSettings",
|
||||
"Certificate", "CertificateCategory",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, ForeignKey, Table, desc
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, Float, ForeignKey, Table, desc
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
@@ -23,7 +23,13 @@ class Goal(Base):
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(String(20), default="active") # active/paused/completed/abandoned
|
||||
progress = Column(Integer, default=0) # 0-100,从里程碑自动计算
|
||||
track_type = Column(String(20), default="milestone") # "milestone" | "cumulative"
|
||||
progress = Column(Integer, default=0) # 0-100,里程碑模式从里程碑自动计算,累计模式从打卡汇总计算
|
||||
target_value = Column(Float, nullable=True) # 累计模式:目标值(目标单位)
|
||||
target_unit = Column(String(20), nullable=True) # 累计模式:目标单位,如 "g"、"kg"
|
||||
input_unit = Column(String(20), nullable=True) # 累计模式:打卡输入单位,如 "kcal"、"次"
|
||||
conversion_rate = Column(Float, default=1.0) # 累计模式:换算率(多少输入单位 = 1 目标单位)
|
||||
current_value = Column(Float, default=0) # 累计模式:累计打卡值(输入单位)
|
||||
target_date = Column(Date, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
@@ -47,6 +53,11 @@ class Goal(Base):
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: desc(GoalReview.created_at),
|
||||
)
|
||||
checkins = relationship(
|
||||
"GoalCheckin", back_populates="goal",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: desc(GoalCheckin.checkin_date),
|
||||
)
|
||||
tasks = relationship("Task", secondary=goal_tasks, back_populates="goals")
|
||||
|
||||
|
||||
@@ -88,3 +99,21 @@ class GoalReview(Base):
|
||||
|
||||
# 关联关系
|
||||
goal = relationship("Goal", back_populates="reviews")
|
||||
|
||||
|
||||
class GoalCheckin(Base):
|
||||
"""目标累计打卡记录模型"""
|
||||
__tablename__ = "goal_checkins"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||
value = Column(Float, nullable=False)
|
||||
note = Column(Text, nullable=True)
|
||||
checkin_date = Column(Date, nullable=False)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
goal = relationship("Goal", back_populates="checkins")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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="删除打卡记录失败")
|
||||
|
||||
@@ -6,6 +6,37 @@ from app.schemas.category import CategoryResponse
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
|
||||
# ============ GoalCheckin Schemas ============
|
||||
|
||||
class GoalCheckinCreate(BaseModel):
|
||||
value: float = Field(..., gt=0)
|
||||
note: Optional[str] = None
|
||||
checkin_date: date
|
||||
|
||||
|
||||
class GoalCheckinUpdate(BaseModel):
|
||||
value: Optional[float] = Field(None, gt=0)
|
||||
note: Optional[str] = None
|
||||
checkin_date: Optional[date] = None
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
return {"note"}
|
||||
|
||||
|
||||
class GoalCheckinResponse(BaseModel):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
goal_id: int
|
||||
value: float
|
||||
note: Optional[str] = None
|
||||
checkin_date: date
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ GoalStep Schemas ============
|
||||
|
||||
class GoalStepBase(BaseModel):
|
||||
@@ -71,6 +102,11 @@ class GoalBase(BaseModel):
|
||||
title: str = Field(..., max_length=200)
|
||||
description: Optional[str] = None
|
||||
status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$")
|
||||
track_type: str = Field(default="milestone", pattern="^(milestone|cumulative)$")
|
||||
target_value: Optional[float] = None
|
||||
target_unit: Optional[str] = Field(None, max_length=20)
|
||||
input_unit: Optional[str] = Field(None, max_length=20)
|
||||
conversion_rate: float = Field(default=1.0)
|
||||
target_date: Optional[date] = None
|
||||
category_id: Optional[int] = None
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
@@ -86,6 +122,11 @@ class GoalUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = Field(None, pattern="^(active|paused|completed|abandoned)$")
|
||||
track_type: Optional[str] = Field(None, pattern="^(milestone|cumulative)$")
|
||||
target_value: Optional[float] = None
|
||||
target_unit: Optional[str] = Field(None, max_length=20)
|
||||
input_unit: Optional[str] = Field(None, max_length=20)
|
||||
conversion_rate: Optional[float] = None
|
||||
target_date: Optional[date] = None
|
||||
category_id: Optional[int] = None
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
@@ -94,13 +135,15 @@ class GoalUpdate(BaseModel):
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
return {"description", "target_date", "category_id"}
|
||||
return {"description", "target_date", "category_id",
|
||||
"target_value", "target_unit", "input_unit"}
|
||||
|
||||
|
||||
class GoalListResponse(GoalBase):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
progress: int
|
||||
current_value: float = 0
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -115,6 +158,7 @@ class GoalListResponse(GoalBase):
|
||||
class GoalDetailResponse(GoalListResponse):
|
||||
steps: List[GoalStepResponse] = []
|
||||
reviews: List[GoalReviewResponse] = []
|
||||
checkins: List[GoalCheckinResponse] = []
|
||||
tasks: List[TaskResponse] = []
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user