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, 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["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), **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 = log_service.toggle_check_and_celebrate(db, habit, date.today()) extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {} 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 = log_service.toggle_check_and_celebrate(db, habit, yesterday) extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {} return templates.TemplateResponse( request, "partials/today_content.html", {"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)}, ) @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, }, ) 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, **context} )