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):
|
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, "非计划日不可记录正向进度")
|
raise HTTPException(409, "非计划日不可记录正向进度")
|
||||||
paused = await db.scalar(select(HabitPause.id).where(
|
paused = await db.scalar(select(HabitPause.id).where(
|
||||||
HabitPause.habit_id == habit.id,
|
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()}
|
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 day < h.start_date: return False
|
||||||
if h.schedule_type == "daily": return True
|
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 == "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}
|
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")
|
@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.sum(HabitLog.value),
|
||||||
func.count(HabitLog.id),
|
func.count(HabitLog.id),
|
||||||
func.sum(case((HabitLog.value >= Habit.target, 1), else_=0)),
|
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)
|
.join(Habit, Habit.id == HabitLog.habit_id)
|
||||||
.where(HabitLog.habit_id.in_(habit_ids))
|
.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 = {}
|
logs_by_habit = {}
|
||||||
stats_by_habit = {}
|
stats_by_habit = {}
|
||||||
pauses_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:
|
for log in week_logs:
|
||||||
logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value
|
logs_by_habit.setdefault(log.habit_id, {})[log.day] = log.value
|
||||||
for habit_id, total, logged_days, completed_days in stats_rows:
|
habit = habits_by_id.get(log.habit_id)
|
||||||
stats_by_habit[habit_id] = {"total": total or 0, "completed_days": completed_days or 0, "logged_days": logged_days or 0}
|
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:
|
for pause in pause_rows:
|
||||||
pauses_by_habit.setdefault(pause.habit_id, []).append(pause)
|
pauses_by_habit.setdefault(pause.habit_id, []).append(pause)
|
||||||
output = []
|
output = []
|
||||||
for habit in habits:
|
for habit in habits:
|
||||||
logs = logs_by_habit.get(habit.id, {})
|
logs = logs_by_habit.get(habit.id, {})
|
||||||
pauses = pauses_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 = 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})
|
data["stats"] = stats_by_habit.get(habit.id, {"total": 0, "completed_days": 0, "logged_days": 0})
|
||||||
output.append(data)
|
output.append(data)
|
||||||
return {"days": days, "habits": output}
|
return {"days": days, "habits": output}
|
||||||
|
|||||||
@@ -443,12 +443,62 @@ def test_boolean_interval_habit_schedule(client):
|
|||||||
boot(client)
|
boot(client)
|
||||||
habit = client.post(
|
habit = client.post(
|
||||||
"/api/v1/habits",
|
"/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 habit.status_code == 201
|
||||||
assert client.post(
|
habit_id = habit.json()["id"]
|
||||||
f"/api/v1/habits/{habit.json()['id']}/logs", json={"day": local_today().isoformat(), "value": 1}
|
|
||||||
).json()["value"] == 1
|
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):
|
def test_attachment_security_ownership_and_size(client, tmp_path, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user