diff --git a/app/models/habit.py b/app/models/habit.py index e445ae1..05f404d 100644 --- a/app/models/habit.py +++ b/app/models/habit.py @@ -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) diff --git a/app/models/habit_log.py b/app/models/habit_log.py index f033131..8b2b5bb 100644 --- a/app/models/habit_log.py +++ b/app/models/habit_log.py @@ -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") diff --git a/app/routers/logs.py b/app/routers/logs.py index 7374bfb..4eae148 100644 --- a/app/routers/logs.py +++ b/app/routers/logs.py @@ -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) if habit is None: raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다") - checked, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today()) - return {"checked": checked, "milestone_streak": milestone_streak} + checked, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, date.today()) + return {"checked": checked, "milestone_streak": milestone_streak, "level_up": level_up} @router.get("/logs", response_model=list[HabitLogOut]) diff --git a/app/routers/pages.py b/app/routers/pages.py index e7fd266..bab696d 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -14,7 +14,14 @@ from app.models.user import User from app.schemas.habit import HabitCreate, HabitUpdate from app.security import get_current_user_optional 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() @@ -24,6 +31,7 @@ templates.env.globals["heatmap_opacity"] = heatmap_opacity templates.env.globals["is_milestone_streak"] = is_milestone_streak templates.env.globals["difficulty_label"] = difficulty_label templates.env.globals["goal_progress"] = goal_progress +templates.env.globals["level_tier_emoji"] = level_tier_emoji 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), "total_count": len(all_items), "checked_count": sum(1 for item in all_items if item.checked), + "account_level": log_service.get_account_level(db, user_id), **extra, } @@ -84,8 +93,12 @@ def toggle_today_page(request: Request, habit_id: int, db: Session = Depends(get if habit is None: return Response(status_code=404) - _, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today()) - extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {} + _, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, date.today()) + 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( request, "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) yesterday = date.today() - timedelta(days=1) - _, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, yesterday) - extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {} + _, milestone_streak, level_up = log_service.toggle_check_and_celebrate(db, habit, yesterday) + 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( request, "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") def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_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, "habit_type": tab, "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) 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, + }, ) diff --git a/app/schemas/habit_log.py b/app/schemas/habit_log.py index 304bf9a..0c3501e 100644 --- a/app/schemas/habit_log.py +++ b/app/schemas/habit_log.py @@ -2,6 +2,8 @@ from datetime import date, datetime from pydantic import BaseModel, ConfigDict +from app.schemas.level import LevelInfo + class HabitLogOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -22,6 +24,7 @@ class TodayItem(BaseModel): completion_rate: float current_streak: int scheduled_days: int + level_info: LevelInfo class MonthlySummaryDay(BaseModel): @@ -43,3 +46,4 @@ class HabitStats(BaseModel): current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수 scheduled_days: int checked_days: int + level_info: LevelInfo diff --git a/app/schemas/level.py b/app/schemas/level.py new file mode 100644 index 0000000..f3c9fa4 --- /dev/null +++ b/app/schemas/level.py @@ -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) diff --git a/app/services/level_service.py b/app/services/level_service.py new file mode 100644 index 0000000..07f71ff --- /dev/null +++ b/app/services/level_service.py @@ -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 diff --git a/app/services/log_service.py b/app/services/log_service.py index d4c9c4f..7ef449f 100644 --- a/app/services/log_service.py +++ b/app/services/log_service.py @@ -5,9 +5,10 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session 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.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} @@ -25,6 +26,7 @@ def _to_today_item(db: Session, habit: Habit, checked: bool) -> TodayItem: completion_rate=stats.completion_rate, current_streak=stats.current_streak, 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( db.scalars( 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]: - """어제 예정되어 있었지만 아직 체크하지 않은 습관 목록 (형성+중단 합쳐서, /today 화면의 소급 체크용).""" + """어제 예정되어 있었지만 아직 완료/실패가 결정되지 않은 습관 목록 + + (형성+중단 합쳐서, /today 화면의 소급 체크·실패 확정용). 완료로 체크했거나 실패로 확정한 + 습관은 둘 다 "결정됨"으로 보고 목록에서 뺀다. + """ yesterday = date.today() - timedelta(days=1) 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: @@ -72,25 +94,43 @@ def toggle_check(db: Session, habit_id: int, log_date: date) -> bool: 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) if not checked: - return checked, None + return checked, None, None - streak = get_habit_stats(db, habit).current_streak - if streak not in MILESTONE_STREAKS: - return checked, None + stats = get_habit_stats(db, habit) + streak = stats.current_streak + 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: - push_service.send_to_user( - db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today" - ) - return checked, streak + if milestone_streak is not None: + push_service.send_to_user( + db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today" + ) + 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( @@ -112,7 +152,11 @@ def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end: return {} rows = db.execute( 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) ).all() 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: """습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다. + 같은 순회에서 레벨/경험치(XP)도 함께 누적한다. 요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다 (과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화). @@ -182,16 +227,45 @@ def get_habit_stats(db: Session, habit: Habit) -> HabitStats: today = date.today() 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 checked_days = 0 + xp = 0 + hit_streak = 0 + miss_streak = 0 d = start while d <= today: if habit.is_scheduled_on(d.weekday()): scheduled_days += 1 - if d in checked_dates: + is_checked = d in checked_dates + if is_checked: 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) 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, scheduled_days=scheduled_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: rows = db.execute( 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() 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 + + +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) diff --git a/app/static/css/style.css b/app/static/css/style.css index 30fd1f8..f207825 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -344,13 +344,30 @@ label { min-height: 1em; } -/* 탭 */ +/* 탭 — 좁은 화면에서 4개가 다 안 들어가면 줄바꿈 대신 가로 스크롤. + 양 끝에 스크롤 가능 방향으로만 그림자가 지도록 하는 순수 CSS 트릭: + 앞쪽 두 레이어(배경색→투명 그라데이션, background-attachment: local)는 콘텐츠와 같이 스크롤되면서 + 탭 텍스트를 가려 페이드아웃시키고, 뒤쪽 두 레이어(radial-gradient 그림자, attachment: scroll)는 + 스크롤 컨테이너 기준으로 고정되어 있다 — 콘텐츠 레이어가 그 위를 덮을 수 있을 때만(=그 방향으로 + 더 스크롤할 내용이 있을 때만) 그림자가 드러나 보인다. 끝까지 스크롤하면 콘텐츠 레이어가 그림자를 + 완전히 덮어 자연스럽게 사라진다. */ .tabs { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; gap: 6px; margin-bottom: var(--space-2); 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 { @@ -360,6 +377,9 @@ label { padding: 10px 4px; margin-right: var(--space-2); border-bottom: 2px solid transparent; + white-space: nowrap; + flex-shrink: 0; + touch-action: manipulation; } .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 { display: grid; diff --git a/app/template_utils.py b/app/template_utils.py index 3d51505..d8d5507 100644 --- a/app/template_utils.py +++ b/app/template_utils.py @@ -17,6 +17,16 @@ def is_milestone_streak(streak: int) -> bool: 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: if mask == ALL_WEEKDAYS_MASK: return "매일" diff --git a/app/templates/partials/habit_item.html b/app/templates/partials/habit_item.html index e7bdafd..4316fcb 100644 --- a/app/templates/partials/habit_item.html +++ b/app/templates/partials/habit_item.html @@ -35,6 +35,11 @@ {{ '🏆' if is_milestone_streak(stats.current_streak) else '🔥' }} 연속 {{ stats.current_streak }}일 {% endif %} + {{ level_tier_emoji(stats.level_info.level) }} Lv.{{ stats.level_info.level }} + +
{% endif %} diff --git a/app/templates/partials/today_content.html b/app/templates/partials/today_content.html index 8cb9dab..224d9d5 100644 --- a/app/templates/partials/today_content.html +++ b/app/templates/partials/today_content.html @@ -3,6 +3,19 @@ 🎉 {{ celebrate_habit_name }} {{ celebrate_streak }}일 연속 달성! 축하해요! {% endif %} +{% if celebrate_level_up %} + +{% endif %} + +