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=일)
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):
BUILD = "build" # 만들고 싶은 습관
QUIT = "quit" # 멈추고 싶은 습관
@@ -52,13 +62,19 @@ class Habit(Base):
ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
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(
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)
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)
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 sqlalchemy import Date, ForeignKey, Integer, UniqueConstraint
from sqlalchemy import Date, Enum, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
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):
@@ -14,6 +21,11 @@ class HabitLog(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), 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)
habit: Mapped["Habit"] = relationship(back_populates="logs")