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
@@ -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")