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
+48
View File
@@ -0,0 +1,48 @@
from app.schemas.level import LevelInfo
# 체크 1회당 얻는 기본 경험치.
XP_PER_CHECK = 10
# 예정일을 놓쳤을 때의 페널티. 연속으로 놓칠수록 (miss_streak * MISS_PENALTY_BASE)로 가속되며,
# MISS_PENALTY_CAP에서 더 이상 커지지 않는다(끝없이 방치된 습관이 비정상적인 값으로 폭주하지 않도록).
MISS_PENALTY_BASE = 8
MISS_PENALTY_CAP = 80
# 연속 달성일 마일스톤(log_service.MILESTONE_STREAKS와 동일한 값) 도달 시 추가로 얹어주는 보너스 경험치.
STREAK_MILESTONE_BONUS_XP: dict[int, int] = {
7: 20,
30: 80,
66: 150,
100: 250,
200: 500,
365: 1000,
}
# 레벨업에 필요한 경험치는 LEVEL_BASE_XP에서 시작해 레벨마다 LEVEL_STEP_XP씩 늘어난다
# (초반엔 빠르게, 갈수록 완만하게 오르는 곡선).
LEVEL_BASE_XP = 20
LEVEL_STEP_XP = 10
def _xp_required_for_level(level: int) -> int:
"""`level`에서 `level + 1`로 올라가는 데 필요한 경험치."""
return LEVEL_BASE_XP + LEVEL_STEP_XP * (level - 1)
def level_from_xp(xp: int) -> LevelInfo:
xp = max(xp, 0)
level = 1
remaining = xp
while True:
needed = _xp_required_for_level(level)
if remaining < needed:
progress_pct = round(remaining / needed * 100, 1)
return LevelInfo(
xp=xp,
level=level,
xp_into_level=remaining,
xp_for_next_level=needed,
progress_pct=progress_pct,
)
remaining -= needed
level += 1