From d519843a9fc905f4c19daed0b206790101447dbd Mon Sep 17 00:00:00 2001 From: bboysoul Date: Tue, 15 Sep 2026 11:59:01 +0800 Subject: [PATCH] feat: carry overdue interval habits forward --- backend/mvp.py | 36 ++++++++++++++++++++---- tests/test_mvp_backend.py | 58 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/backend/mvp.py b/backend/mvp.py index 3ad4847..b912a3d 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -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} diff --git a/tests/test_mvp_backend.py b/tests/test_mvp_backend.py index 5a99cfd..7d28fd0 100644 --- a/tests/test_mvp_backend.py +++ b/tests/test_mvp_backend.py @@ -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):