From 55918d56d1f75f6286e4ae76a03b108ef53e5c95 Mon Sep 17 00:00:00 2001 From: shinalok Date: Fri, 17 Jul 2026 20:19:35 +0900 Subject: [PATCH] add abandoned status to distinguish given-up habits from completed ones Completed and abandoned habits were both lumped under a single terminal state, making it impossible to tell "finished successfully" apart from "gave up" when reviewing habit history. --- app/models/habit.py | 2 + app/routers/habits.py | 6 +++ app/routers/pages.py | 33 ++++++++++++-- app/schemas/habit.py | 1 + app/services/habit_service.py | 9 ++++ app/templates/habits.html | 2 + app/templates/partials/habit_item.html | 8 ++++ .../0006_add_habit_abandoned_status.py | 28 ++++++++++++ tests/test_api_habits.py | 12 +++++ tests/test_habit_service.py | 24 ++++++++++ tests/test_pages_habits.py | 45 +++++++++++++++++++ 11 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/0006_add_habit_abandoned_status.py create mode 100644 tests/test_pages_habits.py diff --git a/app/models/habit.py b/app/models/habit.py index ad0fba3..e0ca595 100644 --- a/app/models/habit.py +++ b/app/models/habit.py @@ -18,6 +18,7 @@ class HabitType(str, enum.Enum): class HabitStatus(str, enum.Enum): ACTIVE = "active" COMPLETED = "completed" + ABANDONED = "abandoned" class Habit(Base): @@ -38,6 +39,7 @@ class Habit(Base): sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) completed_at: Mapped[datetime | None] = mapped_column(nullable=True) + abandoned_at: Mapped[datetime | None] = mapped_column(nullable=True) logs: Mapped[list["HabitLog"]] = relationship( back_populates="habit", cascade="all, delete-orphan", passive_deletes=True diff --git a/app/routers/habits.py b/app/routers/habits.py index b900bd8..bfb9ecd 100644 --- a/app/routers/habits.py +++ b/app/routers/habits.py @@ -69,6 +69,12 @@ def complete_habit(habit_id: int, db: Session = Depends(get_db), current_user: U return habit_service.complete_habit(db, habit) +@router.post("/{habit_id}/abandon", response_model=HabitOut) +def abandon_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)): + habit = _get_habit_or_404(db, habit_id, current_user.id) + return habit_service.abandon_habit(db, habit) + + @router.post("/{habit_id}/reactivate", response_model=HabitOut) def reactivate_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)): habit = _get_habit_or_404(db, habit_id, current_user.id) diff --git a/app/routers/pages.py b/app/routers/pages.py index 14fceb1..7208c71 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -118,6 +118,8 @@ def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_ if tab == "completed": habits = habit_service.list_habits(db, current.id, status=HabitStatus.COMPLETED) + elif tab == "abandoned": + habits = habit_service.list_habits(db, current.id, status=HabitStatus.ABANDONED) else: if tab not in ("build", "quit"): tab = "build" @@ -139,6 +141,14 @@ def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_ ) +def _tab_for_habit(habit) -> str: + if habit.status == HabitStatus.COMPLETED: + return "completed" + if habit.status == HabitStatus.ABANDONED: + return "abandoned" + return habit.habit_type.value + + @router.post("/habits/new") def create_habit_page( request: Request, @@ -208,7 +218,7 @@ def edit_habit_page( return HTMLResponse("입력값을 확인해주세요") habit_service.update_habit(db, habit, data) - tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value + tab = _tab_for_habit(habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response @@ -230,6 +240,22 @@ def complete_habit_page(request: Request, habit_id: int, db: Session = Depends(g return response +@router.post("/habits/{habit_id}/abandon") +def abandon_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): + current = _current_user_or_redirect(request, db) + if isinstance(current, RedirectResponse): + return current + + habit = habit_service.get_habit(db, habit_id, current.id) + if habit is None: + return Response(status_code=404) + tab = habit.habit_type.value + habit_service.abandon_habit(db, habit) + response = Response(status_code=200) + response.headers["HX-Redirect"] = f"/habits?tab={tab}" + return response + + @router.post("/habits/{habit_id}/reactivate") def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)): current = _current_user_or_redirect(request, db) @@ -239,9 +265,10 @@ def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) + tab = _tab_for_habit(habit) habit_service.reactivate_habit(db, habit) response = Response(status_code=200) - response.headers["HX-Redirect"] = "/habits?tab=completed" + response.headers["HX-Redirect"] = f"/habits?tab={tab}" return response @@ -254,7 +281,7 @@ def delete_habit_page(request: Request, habit_id: int, db: Session = Depends(get habit = habit_service.get_habit(db, habit_id, current.id) if habit is None: return Response(status_code=404) - tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value + tab = _tab_for_habit(habit) habit_service.delete_habit(db, habit) response = Response(status_code=200) response.headers["HX-Redirect"] = f"/habits?tab={tab}" diff --git a/app/schemas/habit.py b/app/schemas/habit.py index 5873c8b..dd5c0a6 100644 --- a/app/schemas/habit.py +++ b/app/schemas/habit.py @@ -56,6 +56,7 @@ class HabitOut(BaseModel): reminder_time: time | None created_at: datetime completed_at: datetime | None + abandoned_at: datetime | None class HabitReorderRequest(BaseModel): diff --git a/app/services/habit_service.py b/app/services/habit_service.py index 78e561d..5fc9895 100644 --- a/app/services/habit_service.py +++ b/app/services/habit_service.py @@ -79,9 +79,18 @@ def complete_habit(db: Session, habit: Habit) -> Habit: return habit +def abandon_habit(db: Session, habit: Habit) -> Habit: + habit.status = HabitStatus.ABANDONED + habit.abandoned_at = datetime.now() + db.commit() + db.refresh(habit) + return habit + + def reactivate_habit(db: Session, habit: Habit) -> Habit: habit.status = HabitStatus.ACTIVE habit.completed_at = None + habit.abandoned_at = None db.commit() db.refresh(habit) return habit diff --git a/app/templates/habits.html b/app/templates/habits.html index 3394888..56c438d 100644 --- a/app/templates/habits.html +++ b/app/templates/habits.html @@ -8,6 +8,7 @@ 만들고 싶은 습관 멈추고 싶은 습관 완료된 습관 + 포기한 습관 {% if tab in ("build", "quit") %} @@ -25,6 +26,7 @@
{% if tab == "build" %}만들고 싶은 습관이 아직 없어요. 위에서 추가해보세요. {% elif tab == "quit" %}끊고 싶은 습관이 아직 없어요. 위에서 추가해보세요. + {% elif tab == "abandoned" %}아직 포기한 습관이 없어요. {% else %}아직 완료된 습관이 없어요.{% endif %}
{% endif %} diff --git a/app/templates/partials/habit_item.html b/app/templates/partials/habit_item.html index cab23ce..1a85ac1 100644 --- a/app/templates/partials/habit_item.html +++ b/app/templates/partials/habit_item.html @@ -41,6 +41,14 @@ hx-swap="none" hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?" >완료 처리 + {% else %}