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=일)
|
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
@@ -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
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 "매일"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user