add habit leveling/XP system, explicit fail marking for missed habits, and scrollable habit tabs

- Habits and the account now accumulate XP and levels from check streaks, with
  streak/level-up celebration banners and push notifications.
- The "yesterday missed" banner on /today now lets a habit be explicitly marked
  failed (not just checked done), backed by a new HabitLog.status column so
  completion-rate/streak calculations never count a failed day as done.
- The habit management tabs scroll horizontally on narrow screens instead of
  wrapping to two lines, with a pure-CSS edge shadow indicating more content.
This commit is contained in:
2026-07-20 18:18:56 +09:00
parent 4a7ef5cc29
commit 800f42eab1
18 changed files with 665 additions and 42 deletions
+180 -7
View File
@@ -1,8 +1,8 @@
import calendar
from datetime import date, datetime, timedelta
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.models.habit_log import HabitLog
from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
from app.models.habit_log import HabitLog, HabitLogStatus
from app.schemas.habit import HabitCreate
from app.schemas.habit_log import MonthlySummaryDay
from app.schemas.push import PushKeys, PushSubscribeRequest
@@ -30,6 +30,11 @@ def _check(db_session, habit_id, log_date):
db_session.commit()
def _fail(db_session, habit_id, log_date):
db_session.add(HabitLog(habit_id=habit_id, log_date=log_date, status=HabitLogStatus.FAILED))
db_session.commit()
# ---- toggle_check ----
@@ -44,6 +49,30 @@ def test_toggle_check_sets_and_unsets(db_session, test_user):
assert db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=today).count() == 0
# ---- mark_failed ----
def test_mark_failed_creates_failed_log(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
yesterday = date.today() - timedelta(days=1)
log_service.mark_failed(db_session, habit.id, yesterday)
log = db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=yesterday).one()
assert log.status == HabitLogStatus.FAILED
def test_mark_failed_does_not_overwrite_existing_log(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
yesterday = date.today() - timedelta(days=1)
_check(db_session, habit.id, yesterday) # 이미 완료로 체크된 상태
log_service.mark_failed(db_session, habit.id, yesterday)
log = db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=yesterday).one()
assert log.status == HabitLogStatus.DONE # 실패로 덮어쓰지 않음
# ---- toggle_check_and_celebrate ----
@@ -54,16 +83,18 @@ def test_toggle_check_and_celebrate_returns_milestone_on_streak_hit(db_session,
for offset in range(6, 0, -1): # 6일 전부터 어제까지 6일 연속 체크, 오늘 체크하면 7일째
_check(db_session, habit.id, today - timedelta(days=offset))
checked, milestone = log_service.toggle_check_and_celebrate(db_session, habit, today)
checked, milestone, level_up = log_service.toggle_check_and_celebrate(db_session, habit, today)
assert checked is True
assert milestone == 7
assert level_up == 4 # 6일치(60xp, Lv3) -> 7일째 체크(70xp+마일스톤보너스20=90xp, Lv4)
def test_toggle_check_and_celebrate_returns_none_when_not_milestone(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
checked, milestone = log_service.toggle_check_and_celebrate(db_session, habit, date.today())
checked, milestone, level_up = log_service.toggle_check_and_celebrate(db_session, habit, date.today())
assert checked is True
assert milestone is None
assert level_up is None
def test_toggle_check_and_celebrate_returns_none_on_uncheck(db_session, test_user):
@@ -73,10 +104,11 @@ def test_toggle_check_and_celebrate_returns_none_on_uncheck(db_session, test_use
for offset in range(6, -1, -1): # 오늘까지 포함해 7일 연속 체크된 상태
_check(db_session, habit.id, today - timedelta(days=offset))
# 이미 체크된 오늘을 다시 토글하면 해제되어야 하고, 마일스톤 여부와 무관하게 None이어야 한다.
checked, milestone = log_service.toggle_check_and_celebrate(db_session, habit, today)
# 이미 체크된 오늘을 다시 토글하면 해제되어야 하고, 마일스톤/레벨업 여부와 무관하게 None이어야 한다.
checked, milestone, level_up = log_service.toggle_check_and_celebrate(db_session, habit, today)
assert checked is False
assert milestone is None
assert level_up is None
def test_toggle_check_and_celebrate_sends_push_on_milestone(db_session, test_user, monkeypatch):
@@ -95,7 +127,7 @@ def test_toggle_check_and_celebrate_sends_push_on_milestone(db_session, test_use
_check(db_session, habit.id, today - timedelta(days=offset))
log_service.toggle_check_and_celebrate(db_session, habit, today)
assert len(sent) == 1
assert len(sent) == 2 # 스트릭 마일스톤 푸시 1건 + 레벨업 푸시 1건
# ---- get_today_items ----
@@ -150,6 +182,14 @@ def test_get_yesterday_missed_items_excludes_habits_not_scheduled_yesterday(db_s
assert missed == []
def test_get_yesterday_missed_items_excludes_already_failed(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
_fail(db_session, habit.id, date.today() - timedelta(days=1))
missed = log_service.get_yesterday_missed_items(db_session, test_user.id)
assert missed == []
# ---- get_habit_stats: completion_rate ----
@@ -168,6 +208,21 @@ def test_get_habit_stats_completion_rate(db_session, test_user):
assert stats.completion_rate == 80.0
def test_get_habit_stats_does_not_count_failed_log_as_checked(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=4), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (4, 3, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
_fail(db_session, habit.id, today - timedelta(days=2)) # 실패로 확정된 날은 완료로 세지 않는다
stats = log_service.get_habit_stats(db_session, habit)
assert stats.scheduled_days == 5
assert stats.checked_days == 4
assert stats.completion_rate == 80.0
# ---- get_habit_stats: current_streak ----
@@ -183,6 +238,20 @@ def test_streak_today_unchecked_does_not_break_it(db_session, test_user):
assert stats.current_streak == 3
def test_streak_breaks_on_failed_day(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
_check(db_session, habit.id, today - timedelta(days=3))
_fail(db_session, habit.id, today - timedelta(days=2)) # 실패 확정은 미체크와 동일하게 스트릭을 끊는다
_check(db_session, habit.id, today - timedelta(days=1))
_check(db_session, habit.id, today)
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 2 # 어제, 오늘만 연속
def test_streak_breaks_on_past_miss(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
@@ -207,6 +276,90 @@ def test_streak_extends_through_today_when_checked(db_session, test_user):
assert stats.current_streak == 3
# ---- get_habit_stats: xp/level ----
def test_get_habit_stats_xp_accumulates_per_check(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (3, 2, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.level_info.xp == 40 # 4회 체크 * XP_PER_CHECK(10), 마일스톤 미달성
def test_get_habit_stats_consecutive_misses_accelerate_xp_loss(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=9), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
# day-9 ~ day-3: 7일 연속 체크 (마일스톤 7일 보너스 +20 발생)
for offset in range(9, 2, -1):
_check(db_session, habit.id, today - timedelta(days=offset))
# day-2, day-1: 2일 연속 미체크 (오늘(offset 0)은 아직 안 지났으니 페널티 없음)
stats = log_service.get_habit_stats(db_session, habit)
# 90(=7*10+20) - 8(1회 연속 미스) - 16(2회 연속 미스, 가속) = 66
assert stats.level_info.xp == 66
def test_get_habit_stats_xp_floors_at_zero(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=5), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
# 아무것도 체크하지 않음 -> 5일 연속 미스, 페널티가 계속 누적돼도 xp는 0 밑으로 내려가지 않는다.
stats = log_service.get_habit_stats(db_session, habit)
assert stats.level_info.xp == 0
assert stats.level_info.level == 1
def test_get_habit_stats_xp_freezes_after_completion(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=10), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(10, 5, -1): # day-10 ~ day-6, 5일 연속 체크
_check(db_session, habit.id, today - timedelta(days=offset))
habit.status = HabitStatus.COMPLETED
habit.completed_at = datetime.combine(today - timedelta(days=6), datetime.min.time()) # 마지막 체크일에 완료 처리
db_session.commit()
db_session.refresh(habit)
# 완료 이후 6일(day-5 ~ 오늘)은 전부 미체크 상태로 지나갔지만, 동결되어 페널티가 반영되지 않아야 한다.
# (동결이 없었다면 6일 연속 미스로 xp가 0까지 깎였을 것)
stats = log_service.get_habit_stats(db_session, habit)
assert stats.level_info.xp == 50
# ---- get_account_level ----
def test_get_account_level_sums_xp_across_habits(db_session, test_user):
today = date.today()
habit_a = _make_habit(db_session, test_user.id, name="A", created_at=datetime.combine(today, datetime.min.time()))
habit_b = _make_habit(db_session, test_user.id, name="B", created_at=datetime.combine(today, datetime.min.time()))
_check(db_session, habit_a.id, today)
_check(db_session, habit_b.id, today)
account_level = log_service.get_account_level(db_session, test_user.id)
assert account_level.xp == 20 # 두 습관 각각 10xp
def test_get_account_level_scoped_to_user(db_session, test_user, other_user):
today = date.today()
mine = _make_habit(db_session, test_user.id, created_at=datetime.combine(today, datetime.min.time()))
_check(db_session, mine.id, today)
others = _make_habit(db_session, other_user.id, created_at=datetime.combine(today, datetime.min.time()))
_check(db_session, others.id, today)
account_level = log_service.get_account_level(db_session, test_user.id)
assert account_level.xp == 10
# ---- get_monthly_summary ----
@@ -245,6 +398,15 @@ def test_get_monthly_summary_counts_checked_habits(db_session, test_user):
assert by_date[date(2026, 3, 6)].checked_count == 0
def test_get_monthly_summary_does_not_count_failed_as_checked(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
_fail(db_session, habit.id, date(2026, 3, 5))
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
by_date = {s.log_date: s for s in summaries}
assert by_date[date(2026, 3, 5)].checked_count == 0
# ---- summarize_completion_rate: 미래 날짜 제외 회귀 테스트 ----
# CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다
# (실제로 6.2% -> 수정 후 50%가 된 사례).
@@ -321,6 +483,17 @@ def test_get_weekly_matrix_marks_pre_creation_days_as_none(db_session, test_user
assert row.checks[date(2020, 1, 8).isoformat()] is False # 생성일(수), 미체크
def test_get_weekly_matrix_treats_failed_day_as_unchecked(db_session, test_user):
week_start = date(2020, 1, 6) # 월요일, 확실한 과거
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6))
_fail(db_session, habit.id, date(2020, 1, 6))
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.checks[date(2020, 1, 6).isoformat()] is False
def test_get_weekly_matrix_completion_rate_counts_only_past_days(db_session, test_user):
week_start = date(2020, 1, 6) # 완전히 과거인 주
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6))