Initial commit: habit tracker PWA with Google OAuth, push notifications

FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine
server-rendered frontend. Multi-user via Google OAuth, daily habit
tracking, monthly/weekly history views, Web Push reminders via
APScheduler, and PWA support (manifest, service worker, offline caching).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:06:17 +09:00
co-authored by Claude Sonnet 5
commit cee589bb3e
80 changed files with 4695 additions and 0 deletions
View File
+65
View File
@@ -0,0 +1,65 @@
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.security import SESSION_MAX_AGE_SECONDS, create_session_token
router = APIRouter(prefix="/auth", tags=["auth"])
oauth = OAuth()
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
client_kwargs={"scope": "openid email profile"},
)
@router.get("/google/login")
async def google_login(request: Request):
return await oauth.google.authorize_redirect(request, settings.google_redirect_uri)
@router.get("/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
token = await oauth.google.authorize_access_token(request)
userinfo = token["userinfo"]
google_sub = userinfo["sub"]
email = userinfo["email"]
name = userinfo.get("name")
picture_url = userinfo.get("picture")
user = db.scalar(select(User).where(User.google_sub == google_sub))
if user is None:
user = User(google_sub=google_sub, email=email, name=name, picture_url=picture_url)
db.add(user)
else:
user.email = email
user.name = name
user.picture_url = picture_url
db.commit()
db.refresh(user)
session_token = create_session_token(user.id)
response = RedirectResponse(url="/today", status_code=303)
response.set_cookie(
settings.session_cookie_name,
session_token,
httponly=True,
samesite="lax",
max_age=SESSION_MAX_AGE_SECONDS,
)
return response
@router.post("/logout")
def logout():
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(settings.session_cookie_name)
return response
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException
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, HabitOut, HabitReorderRequest, HabitUpdate
from app.schemas.habit_log import HabitStats
from app.security import require_login
from app.services import habit_service, log_service
router = APIRouter(prefix="/api/habits", tags=["habits"], dependencies=[Depends(require_login)])
def _get_habit_or_404(db: Session, habit_id: int, user_id: int):
habit = habit_service.get_habit(db, habit_id, user_id)
if habit is None:
raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다")
return habit
@router.get("", response_model=list[HabitOut])
def list_habits(
type: HabitType | None = None,
status: HabitStatus | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
return habit_service.list_habits(db, current_user.id, habit_type=type, status=status)
@router.post("", response_model=HabitOut, status_code=201)
def create_habit(
data: HabitCreate, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
return habit_service.create_habit(db, current_user.id, data)
@router.post("/reorder")
def reorder_habits(
data: HabitReorderRequest, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
habit_service.reorder_habits(db, current_user.id, data.habit_ids)
return {"ok": True}
@router.get("/{habit_id}", response_model=HabitOut)
def get_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
return _get_habit_or_404(db, habit_id, current_user.id)
@router.put("/{habit_id}", response_model=HabitOut)
def update_habit(
habit_id: int, data: HabitUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return habit_service.update_habit(db, habit, data)
@router.delete("/{habit_id}", status_code=204)
def delete_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
habit_service.delete_habit(db, habit)
@router.post("/{habit_id}/complete", response_model=HabitOut)
def complete_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return habit_service.complete_habit(db, habit)
@router.post("/{habit_id}/reactivate", response_model=HabitOut)
def reactivate_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return habit_service.reactivate_habit(db, habit)
@router.get("/{habit_id}/stats", response_model=HabitStats)
def get_habit_stats(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return log_service.get_habit_stats(db, habit)
+52
View File
@@ -0,0 +1,52 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.habit_log import HabitLogOut, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
from app.security import require_login
from app.services import habit_service, log_service
router = APIRouter(prefix="/api", tags=["logs"], dependencies=[Depends(require_login)])
@router.get("/today", response_model=list[TodayItem])
def get_today(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
build_items, quit_items = log_service.get_today_items(db, current_user.id, date.today())
return build_items + quit_items
@router.post("/logs/{habit_id}/toggle")
def toggle_log(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
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}
@router.get("/logs", response_model=list[HabitLogOut])
def list_logs(
habit_id: int | None = None,
start: date | None = None,
end: date | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
return log_service.list_logs(db, current_user.id, habit_id=habit_id, start=start, end=end)
@router.get("/logs/summary/monthly", response_model=list[MonthlySummaryDay])
def monthly_summary(
year: int, month: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
return log_service.get_monthly_summary(db, current_user.id, year, month)
@router.get("/logs/summary/weekly", response_model=list[WeeklyMatrixRow])
def weekly_summary(
start_date: date, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
return log_service.get_weekly_matrix(db, current_user.id, start_date)
+304
View File
@@ -0,0 +1,304 @@
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,
"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.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}
)
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.schemas.push import PushSubscribeRequest
from app.security import require_login
from app.services import push_service
router = APIRouter(prefix="/api/push", tags=["push"], dependencies=[Depends(require_login)])
class UnsubscribeRequest(BaseModel):
endpoint: str
@router.get("/vapid-public-key")
def get_vapid_public_key():
return {"publicKey": settings.vapid_public_key}
@router.post("/subscribe")
def subscribe(
data: PushSubscribeRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
push_service.save_subscription(db, current_user.id, data, user_agent=request.headers.get("user-agent"))
return {"ok": True}
@router.post("/unsubscribe")
def unsubscribe(
data: UnsubscribeRequest, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
push_service.delete_subscription(db, data.endpoint)
return {"ok": True}
@router.post("/test")
def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
sent = push_service.send_to_user(db, current_user.id, title="습관 트래커", body="테스트 알림입니다.")
return {"sent": sent}