feat: carry overdue interval habits forward
This commit is contained in:
+30
-6
@@ -938,7 +938,14 @@ def require_active_habit(habit):
|
||||
|
||||
|
||||
async def require_positive_log_day(db, habit, day):
|
||||
if not scheduled(habit, day):
|
||||
completed_days = []
|
||||
if habit.schedule_type == "interval":
|
||||
completed_days = list((await db.scalars(select(HabitLog.day).where(
|
||||
HabitLog.habit_id == habit.id,
|
||||
HabitLog.day < day,
|
||||
HabitLog.value >= habit.target,
|
||||
).order_by(HabitLog.day))).all())
|
||||
if not scheduled(habit, day, completed_days):
|
||||
raise HTTPException(409, "非计划日不可记录正向进度")
|
||||
paused = await db.scalar(select(HabitPause.id).where(
|
||||
HabitPause.habit_id == habit.id,
|
||||
@@ -1128,12 +1135,20 @@ async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(
|
||||
row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
|
||||
|
||||
|
||||
def scheduled(h, day):
|
||||
def scheduled(h, day, completed_days=None):
|
||||
if day < h.start_date: return False
|
||||
if h.schedule_type == "daily": return True
|
||||
if h.schedule_type == "weekly": return day.weekday() in {int(x) for x in (h.weekdays or "").split(",") if x}
|
||||
if h.schedule_type == "monthly": return day.day in {int(x) for x in (h.month_days or "").split(",") if x}
|
||||
return (day - h.start_date).days % h.interval_days == 0
|
||||
completed_days = [
|
||||
completed_day
|
||||
for completed_day in (completed_days or [])
|
||||
if h.start_date <= completed_day < day
|
||||
]
|
||||
last_completed = max(completed_days, default=None)
|
||||
base = last_completed if last_completed is not None else h.start_date
|
||||
next_due = base + timedelta(days=h.interval_days) if last_completed is not None else h.start_date
|
||||
return day >= next_due
|
||||
|
||||
|
||||
@router.get("/habits/grid")
|
||||
@@ -1150,6 +1165,7 @@ async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncS
|
||||
func.sum(HabitLog.value),
|
||||
func.count(HabitLog.id),
|
||||
func.sum(case((HabitLog.value >= Habit.target, 1), else_=0)),
|
||||
func.max(case((HabitLog.value >= Habit.target, HabitLog.day), else_=None)).filter(HabitLog.day < days[0]),
|
||||
)
|
||||
.join(Habit, Habit.id == HabitLog.habit_id)
|
||||
.where(HabitLog.habit_id.in_(habit_ids))
|
||||
@@ -1159,18 +1175,26 @@ async def habits_grid(week: date, user: User = Depends(current_user), db: AsyncS
|
||||
logs_by_habit = {}
|
||||
stats_by_habit = {}
|
||||
pauses_by_habit = {}
|
||||
completed_days_by_habit = {}
|
||||
habits_by_id = {habit.id: habit for habit in habits}
|
||||
for habit_id, total, logged_days, completed_days, previous_completed in stats_rows:
|
||||
stats_by_habit[habit_id] = {"total": total or 0, "completed_days": completed_days or 0, "logged_days": logged_days or 0}
|
||||
if previous_completed is not None:
|
||||
completed_days_by_habit[habit_id] = [previous_completed]
|
||||
for log in week_logs:
|
||||
logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value
|
||||
for habit_id, total, logged_days, completed_days in stats_rows:
|
||||
stats_by_habit[habit_id] = {"total": total or 0, "completed_days": completed_days or 0, "logged_days": logged_days or 0}
|
||||
habit = habits_by_id.get(log.habit_id)
|
||||
if habit is not None and habit.schedule_type == "interval" and log.value >= habit.target:
|
||||
completed_days_by_habit.setdefault(log.habit_id, []).append(log.day)
|
||||
for pause in pause_rows:
|
||||
pauses_by_habit.setdefault(pause.habit_id, []).append(pause)
|
||||
output = []
|
||||
for habit in habits:
|
||||
logs = logs_by_habit.get(habit.id, {})
|
||||
pauses = pauses_by_habit.get(habit.id, [])
|
||||
completed_days = completed_days_by_habit.get(habit.id, []) if habit.schedule_type == "interval" else []
|
||||
data = habit_dict(habit)
|
||||
data["cells"] = [{"day": day, "scheduled": scheduled(habit, day), "paused": any(pause.start_date <= day <= pause.end_date for pause in pauses), "value": logs.get(day, 0)} for day in days]
|
||||
data["cells"] = [{"day": day, "scheduled": scheduled(habit, day, completed_days), "paused": any(pause.start_date <= day <= pause.end_date for pause in pauses), "value": logs.get(day, 0)} for day in days]
|
||||
data["stats"] = stats_by_habit.get(habit.id, {"total": 0, "completed_days": 0, "logged_days": 0})
|
||||
output.append(data)
|
||||
return {"days": days, "habits": output}
|
||||
|
||||
@@ -443,12 +443,62 @@ def test_boolean_interval_habit_schedule(client):
|
||||
boot(client)
|
||||
habit = client.post(
|
||||
"/api/v1/habits",
|
||||
json={"name": "拉伸", "kind": "boolean", "schedule_type": "interval", "interval_days": 2},
|
||||
json={
|
||||
"name": "拉伸",
|
||||
"kind": "boolean",
|
||||
"schedule_type": "interval",
|
||||
"interval_days": 3,
|
||||
"start_date": "2026-09-01",
|
||||
},
|
||||
)
|
||||
assert habit.status_code == 201
|
||||
assert client.post(
|
||||
f"/api/v1/habits/{habit.json()['id']}/logs", json={"day": local_today().isoformat(), "value": 1}
|
||||
).json()["value"] == 1
|
||||
habit_id = habit.json()["id"]
|
||||
|
||||
overdue_grid = client.get("/api/v1/habits/grid", params={"week": "2026-09-07"}).json()
|
||||
overdue_cells = {cell["day"]: cell for cell in overdue_grid["habits"][0]["cells"]}
|
||||
assert overdue_cells["2026-09-07"]["scheduled"] is True
|
||||
assert overdue_cells["2026-09-08"]["scheduled"] is True
|
||||
assert overdue_cells["2026-09-09"]["scheduled"] is True
|
||||
|
||||
completed = client.post(
|
||||
f"/api/v1/habits/{habit_id}/logs", json={"day": "2026-09-09", "value": 1}
|
||||
)
|
||||
assert completed.status_code == 200
|
||||
|
||||
next_grid = client.get("/api/v1/habits/grid", params={"week": "2026-09-07"}).json()
|
||||
next_cells = {cell["day"]: cell for cell in next_grid["habits"][0]["cells"]}
|
||||
assert next_cells["2026-09-09"]["scheduled"] is True
|
||||
assert next_cells["2026-09-10"]["scheduled"] is False
|
||||
assert next_cells["2026-09-11"]["scheduled"] is False
|
||||
assert next_cells["2026-09-12"]["scheduled"] is True
|
||||
assert next_cells["2026-09-13"]["scheduled"] is True
|
||||
|
||||
|
||||
def test_interval_habit_partial_progress_does_not_restart_interval(client):
|
||||
boot(client)
|
||||
habit = client.post(
|
||||
"/api/v1/habits",
|
||||
json={
|
||||
"name": "喝水",
|
||||
"kind": "numeric",
|
||||
"target": 3,
|
||||
"schedule_type": "interval",
|
||||
"interval_days": 3,
|
||||
"start_date": "2026-09-01",
|
||||
},
|
||||
).json()
|
||||
|
||||
assert client.put(f"/api/v1/habits/{habit['id']}/logs/2026-09-04", json={"value": 2}).status_code == 200
|
||||
grid = client.get("/api/v1/habits/grid", params={"week": "2026-09-07"}).json()
|
||||
cells = {cell["day"]: cell for cell in grid["habits"][0]["cells"]}
|
||||
assert cells["2026-09-07"]["scheduled"] is True
|
||||
|
||||
assert client.put(f"/api/v1/habits/{habit['id']}/logs/2026-09-08", json={"value": 3}).status_code == 200
|
||||
grid = client.get("/api/v1/habits/grid", params={"week": "2026-09-07"}).json()
|
||||
cells = {cell["day"]: cell for cell in grid["habits"][0]["cells"]}
|
||||
assert cells["2026-09-09"]["scheduled"] is False
|
||||
assert cells["2026-09-10"]["scheduled"] is False
|
||||
assert cells["2026-09-11"]["scheduled"] is True
|
||||
|
||||
|
||||
def test_attachment_security_ownership_and_size(client, tmp_path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user