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,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
|
||||
+109
-21
@@ -5,9 +5,10 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.habit import Habit, HabitStatus, HabitType
|
||||
from app.models.habit_log import HabitLog
|
||||
from app.models.habit_log import HabitLog, HabitLogStatus
|
||||
from app.schemas.habit_log import HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
|
||||
from app.services import habit_service, push_service
|
||||
from app.schemas.level import LevelInfo
|
||||
from app.services import habit_service, level_service, push_service
|
||||
|
||||
# 체크 시 축하 푸시/배지를 트리거하는 연속 달성일 마일스톤.
|
||||
MILESTONE_STREAKS = {7, 30, 66, 100, 200, 365}
|
||||
@@ -25,6 +26,7 @@ def _to_today_item(db: Session, habit: Habit, checked: bool) -> TodayItem:
|
||||
completion_rate=stats.completion_rate,
|
||||
current_streak=stats.current_streak,
|
||||
scheduled_days=stats.scheduled_days,
|
||||
level_info=stats.level_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,7 +42,9 @@ def get_today_items(db: Session, user_id: int, target_date: date) -> tuple[list[
|
||||
checked_ids = set(
|
||||
db.scalars(
|
||||
select(HabitLog.habit_id).where(
|
||||
HabitLog.log_date == target_date, HabitLog.habit_id.in_(habit_ids)
|
||||
HabitLog.log_date == target_date,
|
||||
HabitLog.habit_id.in_(habit_ids),
|
||||
HabitLog.status == HabitLogStatus.DONE,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -51,10 +55,28 @@ def get_today_items(db: Session, user_id: int, target_date: date) -> tuple[list[
|
||||
|
||||
|
||||
def get_yesterday_missed_items(db: Session, user_id: int) -> list[TodayItem]:
|
||||
"""어제 예정되어 있었지만 아직 체크하지 않은 습관 목록 (형성+중단 합쳐서, /today 화면의 소급 체크용)."""
|
||||
"""어제 예정되어 있었지만 아직 완료/실패가 결정되지 않은 습관 목록
|
||||
|
||||
(형성+중단 합쳐서, /today 화면의 소급 체크·실패 확정용). 완료로 체크했거나 실패로 확정한
|
||||
습관은 둘 다 "결정됨"으로 보고 목록에서 뺀다.
|
||||
"""
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
build_items, quit_items = get_today_items(db, user_id, yesterday)
|
||||
return [item for item in build_items + quit_items if not item.checked]
|
||||
all_items = build_items + quit_items
|
||||
|
||||
habit_ids = [item.habit_id for item in all_items]
|
||||
failed_ids: set[int] = set()
|
||||
if habit_ids:
|
||||
failed_ids = set(
|
||||
db.scalars(
|
||||
select(HabitLog.habit_id).where(
|
||||
HabitLog.log_date == yesterday,
|
||||
HabitLog.habit_id.in_(habit_ids),
|
||||
HabitLog.status == HabitLogStatus.FAILED,
|
||||
)
|
||||
)
|
||||
)
|
||||
return [item for item in all_items if not item.checked and item.habit_id not in failed_ids]
|
||||
|
||||
|
||||
def toggle_check(db: Session, habit_id: int, log_date: date) -> bool:
|
||||
@@ -72,25 +94,43 @@ def toggle_check(db: Session, habit_id: int, log_date: date) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def toggle_check_and_celebrate(db: Session, habit: Habit, log_date: date) -> tuple[bool, int | None]:
|
||||
"""체크를 토글하고, 새로 체크되어 스트릭이 마일스톤에 도달했으면 축하 푸시를 보낸다.
|
||||
def mark_failed(db: Session, habit_id: int, log_date: date) -> None:
|
||||
"""해당 날짜를 명시적으로 실패로 확정한다. 이미 완료/실패가 결정된 날짜면 아무 것도 하지 않는다."""
|
||||
existing = db.scalar(select(HabitLog).where(HabitLog.habit_id == habit_id, HabitLog.log_date == log_date))
|
||||
if existing:
|
||||
return
|
||||
db.add(HabitLog(habit_id=habit_id, log_date=log_date, status=HabitLogStatus.FAILED))
|
||||
db.commit()
|
||||
|
||||
반환값: (checked, milestone_streak). milestone_streak은 이번 토글로 막 달성한 마일스톤 값이면 그 값,
|
||||
체크 해제거나 마일스톤이 아니면 None.
|
||||
|
||||
def toggle_check_and_celebrate(db: Session, habit: Habit, log_date: date) -> tuple[bool, int | None, int | None]:
|
||||
"""체크를 토글하고, 새로 체크되어 스트릭 마일스톤이나 레벨업에 도달했으면 축하 푸시를 보낸다.
|
||||
|
||||
반환값: (checked, milestone_streak, level_up).
|
||||
- milestone_streak: 이번 토글로 막 달성한 연속 달성일 마일스톤 값, 아니면 None.
|
||||
- level_up: 이번 토글로 막 오른 새 레벨, 아니면 None.
|
||||
체크 해제인 경우 둘 다 None.
|
||||
"""
|
||||
level_before = get_habit_stats(db, habit).level_info.level
|
||||
checked = toggle_check(db, habit.id, log_date)
|
||||
if not checked:
|
||||
return checked, None
|
||||
return checked, None, None
|
||||
|
||||
streak = get_habit_stats(db, habit).current_streak
|
||||
if streak not in MILESTONE_STREAKS:
|
||||
return checked, None
|
||||
stats = get_habit_stats(db, habit)
|
||||
streak = stats.current_streak
|
||||
milestone_streak = streak if streak in MILESTONE_STREAKS else None
|
||||
level_up = stats.level_info.level if stats.level_info.level > level_before else None
|
||||
|
||||
if habit.user_id is not None:
|
||||
push_service.send_to_user(
|
||||
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
|
||||
)
|
||||
return checked, streak
|
||||
if milestone_streak is not None:
|
||||
push_service.send_to_user(
|
||||
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
|
||||
)
|
||||
if level_up is not None:
|
||||
push_service.send_to_user(
|
||||
db, habit.user_id, title=f"⭐ {habit.name}", body=f"레벨 {level_up}로 올랐어요!", url="/today"
|
||||
)
|
||||
return checked, milestone_streak, level_up
|
||||
|
||||
|
||||
def list_logs(
|
||||
@@ -112,7 +152,11 @@ def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(HabitLog.log_date, func.count(HabitLog.id))
|
||||
.where(HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(start, end))
|
||||
.where(
|
||||
HabitLog.habit_id.in_(habit_ids),
|
||||
HabitLog.log_date.between(start, end),
|
||||
HabitLog.status == HabitLogStatus.DONE,
|
||||
)
|
||||
.group_by(HabitLog.log_date)
|
||||
).all()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
@@ -175,6 +219,7 @@ def get_period_completion_rate(db: Session, user_id: int, start: date, end: date
|
||||
|
||||
def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
|
||||
"""습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다.
|
||||
같은 순회에서 레벨/경험치(XP)도 함께 누적한다.
|
||||
|
||||
요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다
|
||||
(과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화).
|
||||
@@ -182,16 +227,45 @@ def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
|
||||
today = date.today()
|
||||
start = habit.created_at.date()
|
||||
|
||||
checked_dates = set(db.scalars(select(HabitLog.log_date).where(HabitLog.habit_id == habit.id)))
|
||||
# 완료/포기된 습관은 그 시점 이후로 XP가 더 이상 변하지 않는다(동결) — completion_rate/current_streak
|
||||
# 계산 범위는 건드리지 않고 XP 누적에만 이 경계를 적용한다.
|
||||
freeze_date: date | None = None
|
||||
if habit.status != HabitStatus.ACTIVE and (habit.completed_at or habit.abandoned_at):
|
||||
freeze_date = (habit.completed_at or habit.abandoned_at).date()
|
||||
|
||||
checked_dates = set(
|
||||
db.scalars(
|
||||
select(HabitLog.log_date).where(
|
||||
HabitLog.habit_id == habit.id, HabitLog.status == HabitLogStatus.DONE
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
scheduled_days = 0
|
||||
checked_days = 0
|
||||
xp = 0
|
||||
hit_streak = 0
|
||||
miss_streak = 0
|
||||
d = start
|
||||
while d <= today:
|
||||
if habit.is_scheduled_on(d.weekday()):
|
||||
scheduled_days += 1
|
||||
if d in checked_dates:
|
||||
is_checked = d in checked_dates
|
||||
if is_checked:
|
||||
checked_days += 1
|
||||
if freeze_date is None or d <= freeze_date:
|
||||
if d == today and not is_checked:
|
||||
pass # 오늘은 아직 안 지났으니 XP 변동 없음 (current_streak과 같은 규칙)
|
||||
elif is_checked:
|
||||
hit_streak += 1
|
||||
miss_streak = 0
|
||||
xp += level_service.XP_PER_CHECK
|
||||
xp += level_service.STREAK_MILESTONE_BONUS_XP.get(hit_streak, 0)
|
||||
else:
|
||||
hit_streak = 0
|
||||
miss_streak += 1
|
||||
penalty = min(level_service.MISS_PENALTY_BASE * miss_streak, level_service.MISS_PENALTY_CAP)
|
||||
xp = max(0, xp - penalty)
|
||||
d += timedelta(days=1)
|
||||
|
||||
completion_rate = round(checked_days / scheduled_days * 100, 1) if scheduled_days else 0.0
|
||||
@@ -212,6 +286,7 @@ def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
|
||||
current_streak=streak,
|
||||
scheduled_days=scheduled_days,
|
||||
checked_days=checked_days,
|
||||
level_info=level_service.level_from_xp(xp),
|
||||
)
|
||||
|
||||
|
||||
@@ -226,7 +301,9 @@ def get_weekly_matrix(db: Session, user_id: int, week_start: date) -> list[Weekl
|
||||
if habit_ids:
|
||||
rows = db.execute(
|
||||
select(HabitLog.habit_id, HabitLog.log_date).where(
|
||||
HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(week_days[0], week_days[-1])
|
||||
HabitLog.habit_id.in_(habit_ids),
|
||||
HabitLog.log_date.between(week_days[0], week_days[-1]),
|
||||
HabitLog.status == HabitLogStatus.DONE,
|
||||
)
|
||||
).all()
|
||||
checked_pairs = {(r[0], r[1]) for r in rows}
|
||||
@@ -259,3 +336,14 @@ def get_weekly_matrix(db: Session, user_id: int, week_start: date) -> list[Weekl
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def get_account_level(db: Session, user_id: int) -> LevelInfo:
|
||||
"""유저가 가진 모든 습관(활성+완료+포기)의 XP를 합산해 계정 전체 레벨을 계산한다.
|
||||
|
||||
완료/포기된 습관도 (동결된 상태로) 합산에 포함한다 — 완료해도 그동안 쌓은 계정 레벨은 유지되어야 한다.
|
||||
습관별 레벨과 같은 곡선(level_service.level_from_xp)을 그대로 재사용한다.
|
||||
"""
|
||||
habits = habit_service.list_habits(db, user_id)
|
||||
total_xp = sum(get_habit_stats(db, h).level_info.xp for h in habits)
|
||||
return level_service.level_from_xp(total_xp)
|
||||
|
||||
Reference in New Issue
Block a user