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:
+19
-3
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user