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:
@@ -0,0 +1,36 @@
|
||||
from app.services import level_service
|
||||
|
||||
|
||||
def test_level_from_xp_zero_is_level_one():
|
||||
info = level_service.level_from_xp(0)
|
||||
assert info.level == 1
|
||||
assert info.xp_into_level == 0
|
||||
assert info.xp_for_next_level == 20
|
||||
assert info.progress_pct == 0.0
|
||||
|
||||
|
||||
def test_level_from_xp_just_below_threshold_stays_at_level():
|
||||
info = level_service.level_from_xp(19)
|
||||
assert info.level == 1
|
||||
assert info.xp_into_level == 19
|
||||
assert info.progress_pct == 95.0
|
||||
|
||||
|
||||
def test_level_from_xp_at_threshold_advances_level():
|
||||
info = level_service.level_from_xp(20)
|
||||
assert info.level == 2
|
||||
assert info.xp_into_level == 0
|
||||
assert info.xp_for_next_level == 30 # 레벨2->3 요구치는 레벨1->2보다 커진다(점점 완만해짐)
|
||||
|
||||
|
||||
def test_level_from_xp_requirement_grows_with_level():
|
||||
# Lv1->2: 20xp, Lv2->3: 30xp, Lv3->4: 40xp (누적 90xp에서 Lv4 도달)
|
||||
info = level_service.level_from_xp(90)
|
||||
assert info.level == 4
|
||||
assert info.xp_into_level == 0
|
||||
|
||||
|
||||
def test_level_from_xp_negative_floors_to_zero():
|
||||
info = level_service.level_from_xp(-50)
|
||||
assert info.level == 1
|
||||
assert info.xp == 0
|
||||
+180
-7
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user