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
+19 -3
View File
@@ -10,6 +10,16 @@ from app.database import Base
ALL_WEEKDAYS_MASK = 0b1111111 # 월~일 전부 (bit0=월 ... bit6=일) ALL_WEEKDAYS_MASK = 0b1111111 # 월~일 전부 (bit0=월 ... bit6=일)
def _by_value(enum_cls: type[enum.Enum]) -> list[str]:
"""SQLAlchemy Enum이 DB에 멤버 이름(예: "MEDIUM")이 아니라 값(예: "medium")을 쓰도록 강제한다.
values_callable을 안 주면 SQLAlchemy는 기본적으로 .name을 저장하는데, 이 앱의 CHECK 제약조건과
마이그레이션 기본값(server_default)은 전부 소문자 .value를 기준으로 작성되어 있어 불일치가 생긴다
(실제로 difficulty 컬럼에서 이 불일치로 LookupError가 발생한 사례가 있었음).
"""
return [member.value for member in enum_cls]
class HabitType(str, enum.Enum): class HabitType(str, enum.Enum):
BUILD = "build" # 만들고 싶은 습관 BUILD = "build" # 만들고 싶은 습관
QUIT = "quit" # 멈추고 싶은 습관 QUIT = "quit" # 멈추고 싶은 습관
@@ -52,13 +62,19 @@ class Habit(Base):
ForeignKey("user.id", ondelete="CASCADE"), nullable=True ForeignKey("user.id", ondelete="CASCADE"), nullable=True
) )
name: Mapped[str] = mapped_column(String(200), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False)
habit_type: Mapped[HabitType] = mapped_column(Enum(HabitType, native_enum=False, length=20), nullable=False) habit_type: Mapped[HabitType] = mapped_column(
Enum(HabitType, native_enum=False, length=20, values_callable=_by_value), nullable=False
)
status: Mapped[HabitStatus] = mapped_column( status: Mapped[HabitStatus] = mapped_column(
Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE Enum(HabitStatus, native_enum=False, length=20, values_callable=_by_value),
nullable=False,
default=HabitStatus.ACTIVE,
) )
weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK) weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK)
difficulty: Mapped[HabitDifficulty] = mapped_column( difficulty: Mapped[HabitDifficulty] = mapped_column(
Enum(HabitDifficulty, native_enum=False, length=20), nullable=False, default=HabitDifficulty.MEDIUM Enum(HabitDifficulty, native_enum=False, length=20, values_callable=_by_value),
nullable=False,
default=HabitDifficulty.MEDIUM,
) )
condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True) condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True) reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True)
+13 -1
View File
@@ -1,10 +1,17 @@
import enum
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, UniqueConstraint from sqlalchemy import Date, Enum, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.database import Base from app.database import Base
from app.models.habit import _by_value
class HabitLogStatus(str, enum.Enum):
DONE = "done"
FAILED = "failed" # /today의 "어제 놓친 습관" 배너에서 사용자가 명시적으로 실패를 확정한 경우
class HabitLog(Base): class HabitLog(Base):
@@ -14,6 +21,11 @@ class HabitLog(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), nullable=False) habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), nullable=False)
log_date: Mapped[date] = mapped_column(Date, nullable=False) log_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[HabitLogStatus] = mapped_column(
Enum(HabitLogStatus, native_enum=False, length=20, values_callable=_by_value),
nullable=False,
default=HabitLogStatus.DONE,
)
checked_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) checked_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
habit: Mapped["Habit"] = relationship(back_populates="logs") habit: Mapped["Habit"] = relationship(back_populates="logs")
+2 -2
View File
@@ -23,8 +23,8 @@ def toggle_log(habit_id: int, db: Session = Depends(get_db), current_user: User
habit = habit_service.get_habit(db, habit_id, current_user.id) habit = habit_service.get_habit(db, habit_id, current_user.id)
if habit is None: if habit is None:
raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다") raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다")
checked, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today()) checked, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, date.today())
return {"checked": checked, "milestone_streak": milestone_streak} return {"checked": checked, "milestone_streak": milestone_streak, "level_up": level_up}
@router.get("/logs", response_model=list[HabitLogOut]) @router.get("/logs", response_model=list[HabitLogOut])
+50 -6
View File
@@ -14,7 +14,14 @@ from app.models.user import User
from app.schemas.habit import HabitCreate, HabitUpdate from app.schemas.habit import HabitCreate, HabitUpdate
from app.security import get_current_user_optional from app.security import get_current_user_optional
from app.services import habit_service, log_service from app.services import habit_service, log_service
from app.template_utils import difficulty_label, goal_progress, heatmap_opacity, is_milestone_streak, weekday_label from app.template_utils import (
difficulty_label,
goal_progress,
heatmap_opacity,
is_milestone_streak,
level_tier_emoji,
weekday_label,
)
router = APIRouter() router = APIRouter()
@@ -24,6 +31,7 @@ templates.env.globals["heatmap_opacity"] = heatmap_opacity
templates.env.globals["is_milestone_streak"] = is_milestone_streak templates.env.globals["is_milestone_streak"] = is_milestone_streak
templates.env.globals["difficulty_label"] = difficulty_label templates.env.globals["difficulty_label"] = difficulty_label
templates.env.globals["goal_progress"] = goal_progress templates.env.globals["goal_progress"] = goal_progress
templates.env.globals["level_tier_emoji"] = level_tier_emoji
templates.env.globals["habit_difficulties"] = list(HabitDifficulty) templates.env.globals["habit_difficulties"] = list(HabitDifficulty)
@@ -57,6 +65,7 @@ def _today_context(db: Session, user_id: int, **extra) -> dict:
"yesterday_missed": log_service.get_yesterday_missed_items(db, user_id), "yesterday_missed": log_service.get_yesterday_missed_items(db, user_id),
"total_count": len(all_items), "total_count": len(all_items),
"checked_count": sum(1 for item in all_items if item.checked), "checked_count": sum(1 for item in all_items if item.checked),
"account_level": log_service.get_account_level(db, user_id),
**extra, **extra,
} }
@@ -84,8 +93,12 @@ def toggle_today_page(request: Request, habit_id: int, db: Session = Depends(get
if habit is None: if habit is None:
return Response(status_code=404) return Response(status_code=404)
_, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today()) _, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, date.today())
extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {} extra = {"celebrate_habit_name": habit.name} if (milestone_streak or level_up) else {}
if milestone_streak:
extra["celebrate_streak"] = milestone_streak
if level_up:
extra["celebrate_level_up"] = level_up
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"partials/today_content.html", "partials/today_content.html",
@@ -104,8 +117,12 @@ def toggle_yesterday_page(request: Request, habit_id: int, db: Session = Depends
return Response(status_code=404) return Response(status_code=404)
yesterday = date.today() - timedelta(days=1) yesterday = date.today() - timedelta(days=1)
_, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, yesterday) _, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, yesterday)
extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {} extra = {"celebrate_habit_name": habit.name} if (milestone_streak or level_up) else {}
if milestone_streak:
extra["celebrate_streak"] = milestone_streak
if level_up:
extra["celebrate_level_up"] = level_up
return templates.TemplateResponse( return templates.TemplateResponse(
request, request,
"partials/today_content.html", "partials/today_content.html",
@@ -113,6 +130,25 @@ def toggle_yesterday_page(request: Request, habit_id: int, db: Session = Depends
) )
@router.post("/today/{habit_id}/fail-yesterday")
def fail_yesterday_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)
yesterday = date.today() - timedelta(days=1)
log_service.mark_failed(db, habit.id, yesterday)
return templates.TemplateResponse(
request,
"partials/today_content.html",
{"logged_in": True, "current_user": current, **_today_context(db, current.id)},
)
@router.get("/habits") @router.get("/habits")
def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_db)): def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db) current = _current_user_or_redirect(request, db)
@@ -140,6 +176,7 @@ def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_
"habits": habits, "habits": habits,
"habit_type": tab, "habit_type": tab,
"stats_map": stats_map, "stats_map": stats_map,
"account_level": log_service.get_account_level(db, current.id),
}, },
) )
@@ -355,5 +392,12 @@ def history_page(
context = _month_context(db, current.id, year or today.year, month or today.month) context = _month_context(db, current.id, year or today.year, month or today.month)
return templates.TemplateResponse( return templates.TemplateResponse(
request, "history.html", {"logged_in": True, "current_user": current, **context} request,
"history.html",
{
"logged_in": True,
"current_user": current,
"account_level": log_service.get_account_level(db, current.id),
**context,
},
) )
+4
View File
@@ -2,6 +2,8 @@ from datetime import date, datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from app.schemas.level import LevelInfo
class HabitLogOut(BaseModel): class HabitLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -22,6 +24,7 @@ class TodayItem(BaseModel):
completion_rate: float completion_rate: float
current_streak: int current_streak: int
scheduled_days: int scheduled_days: int
level_info: LevelInfo
class MonthlySummaryDay(BaseModel): class MonthlySummaryDay(BaseModel):
@@ -43,3 +46,4 @@ class HabitStats(BaseModel):
current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수 current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수
scheduled_days: int scheduled_days: int
checked_days: int checked_days: int
level_info: LevelInfo
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class LevelInfo(BaseModel):
xp: int # 누적 경험치 (0 이상)
level: int # 현재 레벨 (1부터 시작)
xp_into_level: int # 현재 레벨 진입 후 쌓인 xp
xp_for_next_level: int # 다음 레벨까지 필요한 xp
progress_pct: float # 다음 레벨까지 진행률 (0~100)
+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
+106 -18
View File
@@ -5,9 +5,10 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType 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.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} 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, completion_rate=stats.completion_rate,
current_streak=stats.current_streak, current_streak=stats.current_streak,
scheduled_days=stats.scheduled_days, 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( checked_ids = set(
db.scalars( db.scalars(
select(HabitLog.habit_id).where( 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]: def get_yesterday_missed_items(db: Session, user_id: int) -> list[TodayItem]:
"""어제 예정되어 있었지만 아직 체크하지 않은 습관 목록 (형성+중단 합쳐서, /today 화면의 소급 체크용).""" """어제 예정되어 있었지만 아직 완료/실패가 결정되지 않은 습관 목록
(형성+중단 합쳐서, /today 화면의 소급 체크·실패 확정용). 완료로 체크했거나 실패로 확정한
습관은 둘 다 "결정됨"으로 보고 목록에서 뺀다.
"""
yesterday = date.today() - timedelta(days=1) yesterday = date.today() - timedelta(days=1)
build_items, quit_items = get_today_items(db, user_id, yesterday) 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: 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 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) checked = toggle_check(db, habit.id, log_date)
if not checked: if not checked:
return checked, None return checked, None, None
streak = get_habit_stats(db, habit).current_streak stats = get_habit_stats(db, habit)
if streak not in MILESTONE_STREAKS: streak = stats.current_streak
return checked, None 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: if habit.user_id is not None:
if milestone_streak is not None:
push_service.send_to_user( push_service.send_to_user(
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today" db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
) )
return checked, streak 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( def list_logs(
@@ -112,7 +152,11 @@ def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end:
return {} return {}
rows = db.execute( rows = db.execute(
select(HabitLog.log_date, func.count(HabitLog.id)) 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) .group_by(HabitLog.log_date)
).all() ).all()
return {row[0]: row[1] for row in rows} 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: def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
"""습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다. """습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다.
같은 순회에서 레벨/경험치(XP)도 함께 누적한다.
요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다 요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다
(과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화). (과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화).
@@ -182,16 +227,45 @@ def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
today = date.today() today = date.today()
start = habit.created_at.date() 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 scheduled_days = 0
checked_days = 0 checked_days = 0
xp = 0
hit_streak = 0
miss_streak = 0
d = start d = start
while d <= today: while d <= today:
if habit.is_scheduled_on(d.weekday()): if habit.is_scheduled_on(d.weekday()):
scheduled_days += 1 scheduled_days += 1
if d in checked_dates: is_checked = d in checked_dates
if is_checked:
checked_days += 1 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) d += timedelta(days=1)
completion_rate = round(checked_days / scheduled_days * 100, 1) if scheduled_days else 0.0 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, current_streak=streak,
scheduled_days=scheduled_days, scheduled_days=scheduled_days,
checked_days=checked_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: if habit_ids:
rows = db.execute( rows = db.execute(
select(HabitLog.habit_id, HabitLog.log_date).where( 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() ).all()
checked_pairs = {(r[0], r[1]) for r in rows} 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 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)
+88 -2
View File
@@ -344,13 +344,30 @@ label {
min-height: 1em; min-height: 1em;
} }
/* 탭 */ /* 탭 — 좁은 화면에서 4개가 다 안 들어가면 줄바꿈 대신 가로 스크롤.
양 끝에 스크롤 가능 방향으로만 그림자가 지도록 하는 순수 CSS 트릭:
앞쪽 두 레이어(배경색→투명 그라데이션, background-attachment: local)는 콘텐츠와 같이 스크롤되면서
탭 텍스트를 가려 페이드아웃시키고, 뒤쪽 두 레이어(radial-gradient 그림자, attachment: scroll)는
스크롤 컨테이너 기준으로 고정되어 있다 — 콘텐츠 레이어가 그 위를 덮을 수 있을 때만(=그 방향으로
더 스크롤할 내용이 있을 때만) 그림자가 드러나 보인다. 끝까지 스크롤하면 콘텐츠 레이어가 그림자를
완전히 덮어 자연스럽게 사라진다. */
.tabs { .tabs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
gap: 6px; gap: 6px;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background:
linear-gradient(to right, var(--color-bg) 50%, transparent) left,
linear-gradient(to left, var(--color-bg) 50%, transparent) right,
radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.2), transparent) left,
radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.2), transparent) right;
background-repeat: no-repeat;
background-color: var(--color-bg);
background-size: 20px 100%, 20px 100%, 10px 100%, 10px 100%;
background-attachment: local, local, scroll, scroll;
} }
.tabs a { .tabs a {
@@ -360,6 +377,9 @@ label {
padding: 10px 4px; padding: 10px 4px;
margin-right: var(--space-2); margin-right: var(--space-2);
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
white-space: nowrap;
flex-shrink: 0;
touch-action: manipulation;
} }
.tabs a.active { .tabs a.active {
@@ -603,6 +623,72 @@ label {
} }
} }
/* 레벨 배지 & XP 진행바 */
.level-badge {
background: var(--color-gold-tint);
color: var(--color-gold);
border-color: var(--color-gold);
font-weight: 600;
}
.xp-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.xp-bar {
flex: 1 1 auto;
height: 5px;
border-radius: 999px;
background: var(--color-bg);
border: 1px solid var(--color-border);
overflow: hidden;
}
.xp-bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-accent);
}
.xp-bar-label {
flex-shrink: 0;
font-size: 11px;
color: var(--color-text-muted);
}
.account-level-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.account-level-card .level-badge {
font-size: 13px;
padding: 4px 12px;
}
.account-level-card .xp-bar-wrap {
flex: 1 1 auto;
max-width: 220px;
margin-top: 0;
}
.nav-level-pill {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
background: var(--color-gold-tint);
color: var(--color-gold);
border: 1px solid var(--color-gold);
flex-shrink: 0;
}
/* 월별 캘린더 */ /* 월별 캘린더 */
.calendar-grid { .calendar-grid {
display: grid; display: grid;
+10
View File
@@ -17,6 +17,16 @@ def is_milestone_streak(streak: int) -> bool:
return streak in MILESTONE_STREAKS return streak in MILESTONE_STREAKS
def level_tier_emoji(level: int) -> str:
if level >= 20:
return "💎"
if level >= 10:
return ""
if level >= 5:
return "🔥"
return "🌱"
def weekday_label(mask: int) -> str: def weekday_label(mask: int) -> str:
if mask == ALL_WEEKDAYS_MASK: if mask == ALL_WEEKDAYS_MASK:
return "매일" return "매일"
+5
View File
@@ -35,6 +35,11 @@
{{ '🏆' if is_milestone_streak(stats.current_streak) else '🔥' }} 연속 {{ stats.current_streak }}일 {{ '🏆' if is_milestone_streak(stats.current_streak) else '🔥' }} 연속 {{ stats.current_streak }}일
</span> </span>
{% endif %} {% endif %}
<span class="badge level-badge">{{ level_tier_emoji(stats.level_info.level) }} Lv.{{ stats.level_info.level }}</span>
</div>
<div class="xp-bar-wrap">
<div class="xp-bar"><div class="xp-bar-fill" style="width: {{ stats.level_info.progress_pct }}%;"></div></div>
<span class="xp-bar-label">{{ stats.level_info.xp_into_level }}/{{ stats.level_info.xp_for_next_level }} xp</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
+13
View File
@@ -3,6 +3,19 @@
🎉 <strong>{{ celebrate_habit_name }}</strong> {{ celebrate_streak }}일 연속 달성! 축하해요! 🎉 <strong>{{ celebrate_habit_name }}</strong> {{ celebrate_streak }}일 연속 달성! 축하해요!
</div> </div>
{% endif %} {% endif %}
{% if celebrate_level_up %}
<div class="celebration-banner" x-data="{ show: true }" x-init="setTimeout(() => show = false, 4000)" x-show="show" x-transition>
<strong>{{ celebrate_habit_name }}</strong> 레벨 {{ celebrate_level_up }}로 올랐어요!
</div>
{% endif %}
<div class="card account-level-card">
<span class="badge level-badge">{{ level_tier_emoji(account_level.level) }} 계정 Lv.{{ account_level.level }}</span>
<div class="xp-bar-wrap">
<div class="xp-bar"><div class="xp-bar-fill" style="width: {{ account_level.progress_pct }}%;"></div></div>
<span class="xp-bar-label">{{ account_level.xp_into_level }}/{{ account_level.xp_for_next_level }} xp</span>
</div>
</div>
<div <div
class="card" class="card"
+5
View File
@@ -20,6 +20,11 @@
{{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일 {{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일
</span> </span>
{% endif %} {% endif %}
<span class="badge level-badge">{{ level_tier_emoji(item.level_info.level) }} Lv.{{ item.level_info.level }}</span>
</div>
<div class="xp-bar-wrap">
<div class="xp-bar"><div class="xp-bar-fill" style="width: {{ item.level_info.progress_pct }}%;"></div></div>
<span class="xp-bar-label">{{ item.level_info.xp_into_level }}/{{ item.level_info.xp_for_next_level }} xp</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -13,4 +13,14 @@
{% if item.condition_text %}<div class="habit-item-condition">{{ item.condition_text }}</div>{% endif %} {% if item.condition_text %}<div class="habit-item-condition">{{ item.condition_text }}</div>{% endif %}
</div> </div>
</div> </div>
<div class="habit-item-actions">
<button
type="button"
class="btn btn-danger-ghost"
hx-post="/today/{{ item.habit_id }}/fail-yesterday"
hx-target="#today-content"
hx-swap="innerHTML"
aria-label="{{ item.name }} 어제 실패로 표시"
>실패로 표시</button>
</div>
</div> </div>
@@ -0,0 +1,32 @@
"""normalize habit_type/status/difficulty column casing to lowercase
Revision ID: 0008_normalize_habit_enum_casing
Revises: 0007_add_habit_difficulty
Create Date: 2026-07-19
SQLAlchemy의 Enum 타입은 values_callable을 안 주면 기본적으로 enum 멤버의 .name(대문자, 예:
"MEDIUM")을 DB에 저장하는데, 이 앱의 CHECK 제약조건과 difficulty 컬럼의 server_default는 전부
소문자 .value(예: "medium")를 기준으로 작성되어 있었다. habit_type/status는 지금까지 ORM으로
삽입된 대문자 값만 있어서 우연히 안 터졌고, difficulty는 마이그레이션이 직접 SQL로 넣은 소문자
기본값과 섞여 LookupError가 발생했다. 모델 쪽은 values_callable을 추가해 앞으로는 항상 소문자로
쓰도록 고쳤고, 이 마이그레이션은 기존에 대문자로 저장된 행을 소문자로 정규화한다.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0008_normalize_habit_enum_casing"
down_revision: Union[str, None] = "0007_add_habit_difficulty"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("UPDATE habit SET habit_type = LOWER(habit_type)")
op.execute("UPDATE habit SET status = LOWER(status)")
op.execute("UPDATE habit SET difficulty = LOWER(difficulty)")
def downgrade() -> None:
# 대소문자 정규화는 되돌릴 필요가 없는 데이터 정리이므로 downgrade는 아무 것도 하지 않는다.
pass
@@ -0,0 +1,32 @@
"""add status (done/failed) to habit_log
Revision ID: 0009_add_habit_log_status
Revises: 0008_normalize_habit_enum_casing
Create Date: 2026-07-20
"어제 놓친 습관" 배너에서 사용자가 명시적으로 실패를 확정할 수 있게 하기 위해, "로그 행이 존재하면
그 날 체크 완료"라는 기존 불변식을 유지하면서도 "완료""실패"를 구분해야 한다. status 컬럼을
추가하고 기존 행은 전부 완료였으므로 기본값 'done'으로 채운다.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0009_add_habit_log_status"
down_revision: Union[str, None] = "0008_normalize_habit_enum_casing"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"habit_log",
sa.Column("status", sa.String(length=20), nullable=False, server_default="done"),
)
op.create_check_constraint("ck_habit_log_status", "habit_log", "status in ('done','failed')")
def downgrade() -> None:
op.drop_constraint("ck_habit_log_status", "habit_log", type_="check")
op.drop_column("habit_log", "status")
+36
View File
@@ -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
View File
@@ -1,8 +1,8 @@
import calendar import calendar
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
from app.models.habit_log import HabitLog from app.models.habit_log import HabitLog, HabitLogStatus
from app.schemas.habit import HabitCreate from app.schemas.habit import HabitCreate
from app.schemas.habit_log import MonthlySummaryDay from app.schemas.habit_log import MonthlySummaryDay
from app.schemas.push import PushKeys, PushSubscribeRequest from app.schemas.push import PushKeys, PushSubscribeRequest
@@ -30,6 +30,11 @@ def _check(db_session, habit_id, log_date):
db_session.commit() 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 ---- # ---- 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 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 ---- # ---- 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일째 for offset in range(6, 0, -1): # 6일 전부터 어제까지 6일 연속 체크, 오늘 체크하면 7일째
_check(db_session, habit.id, today - timedelta(days=offset)) _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 checked is True
assert milestone == 7 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): def test_toggle_check_and_celebrate_returns_none_when_not_milestone(db_session, test_user):
habit = _make_habit(db_session, test_user.id) 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 checked is True
assert milestone is None assert milestone is None
assert level_up is None
def test_toggle_check_and_celebrate_returns_none_on_uncheck(db_session, test_user): 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일 연속 체크된 상태 for offset in range(6, -1, -1): # 오늘까지 포함해 7일 연속 체크된 상태
_check(db_session, habit.id, today - timedelta(days=offset)) _check(db_session, habit.id, today - timedelta(days=offset))
# 이미 체크된 오늘을 다시 토글하면 해제되어야 하고, 마일스톤 여부와 무관하게 None이어야 한다. # 이미 체크된 오늘을 다시 토글하면 해제되어야 하고, 마일스톤/레벨업 여부와 무관하게 None이어야 한다.
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 False assert checked is False
assert milestone is None assert milestone is None
assert level_up is None
def test_toggle_check_and_celebrate_sends_push_on_milestone(db_session, test_user, monkeypatch): 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)) _check(db_session, habit.id, today - timedelta(days=offset))
log_service.toggle_check_and_celebrate(db_session, habit, today) log_service.toggle_check_and_celebrate(db_session, habit, today)
assert len(sent) == 1 assert len(sent) == 2 # 스트릭 마일스톤 푸시 1건 + 레벨업 푸시 1건
# ---- get_today_items ---- # ---- get_today_items ----
@@ -150,6 +182,14 @@ def test_get_yesterday_missed_items_excludes_habits_not_scheduled_yesterday(db_s
assert missed == [] 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 ---- # ---- 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 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 ---- # ---- 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 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): def test_streak_breaks_on_past_miss(db_session, test_user):
today = date.today() today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time()) 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 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 ---- # ---- 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 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: 미래 날짜 제외 회귀 테스트 ---- # ---- summarize_completion_rate: 미래 날짜 제외 회귀 테스트 ----
# CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다 # CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다
# (실제로 6.2% -> 수정 후 50%가 된 사례). # (실제로 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 # 생성일(수), 미체크 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): def test_get_weekly_matrix_completion_rate_counts_only_past_days(db_session, test_user):
week_start = date(2020, 1, 6) # 완전히 과거인 주 week_start = date(2020, 1, 6) # 완전히 과거인 주
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6)) habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6))