Users often only realize a habit was missed the next day; a same-day-only check made it impossible to record it after the fact.
326 lines
12 KiB
Python
326 lines
12 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 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 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
|
|
|
|
|
|
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)
|
|
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,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/habits/new")
|
|
def create_habit_page(
|
|
request: Request,
|
|
name: str = Form(""),
|
|
habit_type: str = Form(...),
|
|
weekdays_mask: int = Form(...),
|
|
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,
|
|
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(...),
|
|
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,
|
|
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 = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
|
|
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}/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)
|
|
habit_service.reactivate_habit(db, habit)
|
|
response = Response(status_code=200)
|
|
response.headers["HX-Redirect"] = "/habits?tab=completed"
|
|
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 = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
|
|
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}
|
|
)
|