Files
habit-tracker/app/routers/pages.py
T
shinalok 800f42eab1 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.
2026-07-20 18:18:56 +09:00

404 lines
14 KiB
Python

import calendar
from datetime import date, timedelta
from datetime import time as time_type
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.habit import HabitDifficulty, HabitStatus, HabitType
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,
level_tier_emoji,
weekday_label,
)
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["weekday_label"] = weekday_label
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)
def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse:
user = get_current_user_optional(request, db)
if user is None:
return RedirectResponse(url="/login", status_code=303)
return user
@router.get("/")
def index(request: Request, db: Session = Depends(get_db)):
if get_current_user_optional(request, db) is not None:
return RedirectResponse(url="/today", status_code=303)
return RedirectResponse(url="/login", status_code=303)
@router.get("/login")
def login_page(request: Request, db: Session = Depends(get_db)):
if get_current_user_optional(request, db) is not None:
return RedirectResponse(url="/today", status_code=303)
return templates.TemplateResponse(request, "login.html", {"logged_in": False, "current_user": None})
def _today_context(db: Session, user_id: int, **extra) -> dict:
build_items, quit_items = log_service.get_today_items(db, user_id, date.today())
all_items = build_items + quit_items
return {
"build_items": build_items,
"quit_items": quit_items,
"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,
}
@router.get("/today")
def today_page(request: Request, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return templates.TemplateResponse(
request,
"today.html",
{"logged_in": True, "current_user": current, **_today_context(db, current.id)},
)
@router.post("/today/{habit_id}/toggle")
def toggle_today_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)
_, 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",
{"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)},
)
@router.post("/today/{habit_id}/toggle-yesterday")
def toggle_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)
_, 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",
{"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)},
)
@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)
if isinstance(current, RedirectResponse):
return current
if tab == "completed":
habits = habit_service.list_habits(db, current.id, status=HabitStatus.COMPLETED)
elif tab == "abandoned":
habits = habit_service.list_habits(db, current.id, status=HabitStatus.ABANDONED)
else:
if tab not in ("build", "quit"):
tab = "build"
habits = habit_service.list_habits(db, current.id, habit_type=HabitType(tab), status=HabitStatus.ACTIVE)
stats_map = {h.id: log_service.get_habit_stats(db, h) for h in habits}
return templates.TemplateResponse(
request,
"habits.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"habits": habits,
"habit_type": tab,
"stats_map": stats_map,
"account_level": log_service.get_account_level(db, current.id),
},
)
def _tab_for_habit(habit) -> str:
if habit.status == HabitStatus.COMPLETED:
return "completed"
if habit.status == HabitStatus.ABANDONED:
return "abandoned"
return habit.habit_type.value
@router.post("/habits/new")
def create_habit_page(
request: Request,
name: str = Form(""),
habit_type: str = Form(...),
weekdays_mask: int = Form(...),
difficulty: str = Form(HabitDifficulty.MEDIUM.value),
condition_text: str | None = Form(None),
reminder_time: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None
data = HabitCreate(
name=name,
habit_type=HabitType(habit_type),
weekdays_mask=weekdays_mask,
difficulty=HabitDifficulty(difficulty),
condition_text=condition_text,
reminder_time=parsed_time,
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
habit_service.create_habit(db, current.id, data)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={habit_type}"
return response
@router.post("/habits/{habit_id}/edit")
def edit_habit_page(
request: Request,
habit_id: int,
name: str = Form(""),
weekdays_mask: int = Form(...),
difficulty: str = Form(HabitDifficulty.MEDIUM.value),
condition_text: str | None = Form(None),
reminder_time: str | None = Form(None),
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)
try:
parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None
data = HabitUpdate(
name=name,
habit_type=habit.habit_type,
weekdays_mask=weekdays_mask,
difficulty=HabitDifficulty(difficulty),
condition_text=condition_text,
reminder_time=parsed_time,
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
habit_service.update_habit(db, habit, data)
tab = _tab_for_habit(habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
@router.post("/habits/{habit_id}/complete")
def complete_habit_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)
tab = habit.habit_type.value
habit_service.complete_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
@router.post("/habits/{habit_id}/abandon")
def abandon_habit_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)
tab = habit.habit_type.value
habit_service.abandon_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
@router.post("/habits/{habit_id}/reactivate")
def reactivate_habit_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)
tab = _tab_for_habit(habit)
habit_service.reactivate_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
@router.delete("/habits/{habit_id}/delete")
def delete_habit_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)
tab = _tab_for_habit(habit)
habit_service.delete_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
def _month_context(db: Session, user_id: int, year: int, month: int) -> dict:
summaries = log_service.get_monthly_summary(db, user_id, year, month)
summary_map = {s.log_date: s for s in summaries}
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month) # 6=일요일(calendar 모듈 기준)부터 시작
completion_rate = log_service.summarize_completion_rate(summaries, date.today())
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return {
"view": "month",
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"completion_rate": completion_rate,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _week_context(db: Session, user_id: int, week_start: date) -> dict:
rows = log_service.get_weekly_matrix(db, user_id, week_start)
return {
"view": "week",
"week_start": week_start,
"week_end": week_start + timedelta(days=6),
"rows": rows,
"prev_week": (week_start - timedelta(days=7)).isoformat(),
"next_week": (week_start + timedelta(days=7)).isoformat(),
}
@router.get("/history")
def history_page(
request: Request,
view: str = "month",
year: int | None = None,
month: int | None = None,
start: str | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
today = date.today()
if view == "week":
week_start = date.fromisoformat(start) if start else today
# date.weekday()는 월=0..일=6이라, 일요일까지 거슬러 올라가려면 +1 해서 나머지를 구해야 한다
# (일요일 자신은 0일 전, 월요일은 1일 전, ... 토요일은 6일 전).
week_start = week_start - timedelta(days=(week_start.weekday() + 1) % 7)
context = _week_context(db, current.id, week_start)
else:
view = "month"
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,
"account_level": log_service.get_account_level(db, current.id),
**context,
},
)