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

@@ -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",
]

View File

@@ -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")

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

View File

@@ -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] = []

View File

@@ -5,10 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-B7eroNyE.js"></script>
<script type="module" crossorigin src="/assets/index-BX5HkU7A.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-CwFI-VDq.js">
<link rel="modulepreload" crossorigin href="/assets/element-plus-D84OQMcE.js">
<link rel="stylesheet" crossorigin href="/assets/index-Z-X1qCVU.css">
<link rel="modulepreload" crossorigin href="/assets/element-plus-CljBHM1G.js">
<link rel="stylesheet" crossorigin href="/assets/index-O358hdvS.css">
</head>
<body>
<div id="app"></div>