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
+2 -2
View File
@@ -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])
+50 -6
View File
@@ -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,
},
)