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
+22
View File
@@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
secret_key: str
database_url: str
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = ""
vapid_public_key: str = ""
vapid_private_key: str = ""
vapid_subject: str = "mailto:you@example.com"
session_cookie_name: str = "habit_session"
timezone: str = "Asia/Seoul"
settings = Settings()
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+77
View File
@@ -0,0 +1,77 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import SessionLocal
from app.routers import auth, habits, logs, pages, push
from app.routers.pages import templates
from app.security import get_current_user_optional
from app.services import scheduler_service
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler_service.start_scheduler()
yield
scheduler_service.shutdown_scheduler()
app = FastAPI(title="습관 트래커", lifespan=lifespan)
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
# 로그인 유지용 쿠키(habit_session)와는 별개.
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(habits.router)
app.include_router(logs.router)
app.include_router(push.router)
app.include_router(pages.router)
def _is_api_path(path: str) -> bool:
return path.startswith("/api/") or path.startswith("/static/")
def _current_user_context(request: Request) -> dict:
db = SessionLocal()
try:
current_user = get_current_user_optional(request, db)
finally:
db.close()
return {"logged_in": current_user is not None, "current_user": current_user}
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
if exc.status_code == 404 and not _is_api_path(request.url.path):
return templates.TemplateResponse(
request, "404.html", _current_user_context(request), status_code=404
)
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception("처리되지 않은 오류")
if _is_api_path(request.url.path):
return JSONResponse({"detail": "서버 오류가 발생했습니다"}, status_code=500)
return templates.TemplateResponse(
request, "500.html", _current_user_context(request), status_code=500
)
@app.get("/service-worker.js")
def service_worker():
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
+16
View File
@@ -0,0 +1,16 @@
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.models.push_subscription import PushSubscription
from app.models.user import User
__all__ = [
"Habit",
"HabitStatus",
"HabitType",
"HabitLog",
"HabitNotificationLog",
"SummaryNotificationLog",
"PushSubscription",
"User",
]
+48
View File
@@ -0,0 +1,48 @@
import enum
from datetime import datetime, time
from sqlalchemy import Enum, ForeignKey, Integer, SmallInteger, String, Time
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
ALL_WEEKDAYS_MASK = 0b1111111 # 월~일 전부 (bit0=월 ... bit6=일)
class HabitType(str, enum.Enum):
BUILD = "build" # 만들고 싶은 습관
QUIT = "quit" # 멈추고 싶은 습관
class HabitStatus(str, enum.Enum):
ACTIVE = "active"
COMPLETED = "completed"
class Habit(Base):
__tablename__ = "habit"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
habit_type: Mapped[HabitType] = mapped_column(Enum(HabitType, native_enum=False, length=20), nullable=False)
status: Mapped[HabitStatus] = mapped_column(
Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE
)
weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK)
condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
logs: Mapped[list["HabitLog"]] = relationship(
back_populates="habit", cascade="all, delete-orphan", passive_deletes=True
)
def is_scheduled_on(self, weekday: int) -> bool:
"""weekday: Python date.weekday() 기준 (월=0 ... 일=6)"""
return bool(self.weekdays_mask & (1 << weekday))
+19
View File
@@ -0,0 +1,19 @@
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class HabitLog(Base):
__tablename__ = "habit_log"
__table_args__ = (UniqueConstraint("habit_id", "log_date", name="uq_habit_log_habit_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), nullable=False)
log_date: Mapped[date] = mapped_column(Date, nullable=False)
checked_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
habit: Mapped["Habit"] = relationship(back_populates="logs")
+33
View File
@@ -0,0 +1,33 @@
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class HabitNotificationLog(Base):
__tablename__ = "habit_notification_log"
__table_args__ = (UniqueConstraint("habit_id", "notify_date", name="uq_notification_habit_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), nullable=False)
notify_date: Mapped[date] = mapped_column(Date, nullable=False)
sent_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
class SummaryNotificationLog(Base):
"""주간/월간 요약 알림 중복 발송 방지용 클레임 테이블 (habit_notification_log와 동일한 패턴).
period_type은 "weekly"/"monthly", period_start는 그 기간의 시작일(주간=월요일, 월간=1일)이다.
"""
__tablename__ = "summary_notification_log"
__table_args__ = (UniqueConstraint("user_id", "period_type", "period_start", name="uq_summary_user_period"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
period_type: Mapped[str] = mapped_column(String(20), nullable=False)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
sent_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class PushSubscription(Base):
__tablename__ = "push_subscription"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
endpoint: Mapped[str] = mapped_column(String(512), nullable=False, unique=True)
p256dh_key: Mapped[str] = mapped_column(String(255), nullable=False)
auth_key: Mapped[str] = mapped_column(String(255), nullable=False)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
google_sub: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
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}
View File
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime, time
from pydantic import BaseModel, ConfigDict, field_validator
from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
class HabitBase(BaseModel):
name: str
habit_type: HabitType
weekdays_mask: int = ALL_WEEKDAYS_MASK
condition_text: str | None = None
reminder_time: time | None = None
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("습관 이름을 입력해주세요")
return v
@field_validator("condition_text")
@classmethod
def blank_condition_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
@field_validator("weekdays_mask")
@classmethod
def mask_in_range(cls, v: int) -> int:
if not (1 <= v <= ALL_WEEKDAYS_MASK):
raise ValueError("요일을 최소 하루 이상 선택해주세요")
return v
class HabitCreate(HabitBase):
pass
class HabitUpdate(HabitBase):
pass
class HabitOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
habit_type: HabitType
status: HabitStatus
weekdays_mask: int
condition_text: str | None
reminder_time: time | None
created_at: datetime
completed_at: datetime | None
class HabitReorderRequest(BaseModel):
habit_ids: list[int]
+45
View File
@@ -0,0 +1,45 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class HabitLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
habit_id: int
log_date: date
checked_at: datetime
class TodayItem(BaseModel):
habit_id: int
name: str
habit_type: str
condition_text: str | None
reminder_time: str | None
checked: bool
completion_rate: float
current_streak: int
scheduled_days: int
class MonthlySummaryDay(BaseModel):
log_date: date
scheduled_count: int
checked_count: int
class WeeklyMatrixRow(BaseModel):
habit_id: int
name: str
habit_type: str
checks: dict[str, bool | None] # ISO 날짜 문자열 -> 체크 여부 (None이면 그 요일에 예정되지 않음)
completion_rate: float # 이번 주, 오늘까지 지난 예정일 중 체크한 비율 (%)
class HabitStats(BaseModel):
completion_rate: float # 습관 생성일부터 오늘까지, 예정된 날 중 체크한 비율 (%)
current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수
scheduled_days: int
checked_days: int
+11
View File
@@ -0,0 +1,11 @@
from pydantic import BaseModel
class PushKeys(BaseModel):
p256dh: str
auth: str
class PushSubscribeRequest(BaseModel):
endpoint: str
keys: PushKeys
+43
View File
@@ -0,0 +1,43 @@
from fastapi import Depends, HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
_serializer = URLSafeTimedSerializer(settings.secret_key, salt="habit-session")
SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 # 30일
def create_session_token(user_id: int) -> str:
return _serializer.dumps({"user_id": user_id})
def get_session_user_id(request: Request) -> int | None:
token = request.cookies.get(settings.session_cookie_name)
if not token:
return None
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return None
return data.get("user_id")
def require_login(request: Request, db: Session = Depends(get_db)) -> User:
user_id = get_session_user_id(request)
user = db.get(User, user_id) if user_id is not None else None
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="로그인이 필요합니다")
return user
def get_current_user_optional(request: Request, db: Session) -> User | None:
user_id = get_session_user_id(request)
return db.get(User, user_id) if user_id is not None else None
def is_logged_in(request: Request) -> bool:
return get_session_user_id(request) is not None
+100
View File
@@ -0,0 +1,100 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType
from app.schemas.habit import HabitCreate, HabitUpdate
def list_habits(
db: Session, user_id: int, habit_type: HabitType | None = None, status: HabitStatus | None = None
) -> list[Habit]:
stmt = select(Habit).where(Habit.user_id == user_id)
if habit_type is not None:
stmt = stmt.where(Habit.habit_type == habit_type)
if status is not None:
stmt = stmt.where(Habit.status == status)
stmt = stmt.order_by(Habit.sort_order.is_(None), Habit.sort_order, Habit.created_at)
return list(db.scalars(stmt))
def list_active_habits_with_reminders(db: Session) -> list[Habit]:
"""스케줄러 전용: 유저 스코핑 없이 알림 시각이 설정된 전체 active 습관을 반환한다."""
stmt = select(Habit).where(Habit.status == HabitStatus.ACTIVE, Habit.reminder_time.isnot(None))
return list(db.scalars(stmt))
def list_active_user_ids(db: Session) -> list[int]:
"""스케줄러 전용: 유저 스코핑 없이, active 습관을 하나 이상 가진 유저 id 목록을 반환한다(주간/월간 요약 알림 대상)."""
stmt = (
select(Habit.user_id)
.where(Habit.status == HabitStatus.ACTIVE, Habit.user_id.isnot(None))
.distinct()
)
return list(db.scalars(stmt))
def get_habit(db: Session, habit_id: int, user_id: int) -> Habit | None:
return db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id))
def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit:
habit = Habit(
user_id=user_id,
name=data.name,
habit_type=data.habit_type,
weekdays_mask=data.weekdays_mask,
condition_text=data.condition_text,
reminder_time=data.reminder_time,
status=HabitStatus.ACTIVE,
)
db.add(habit)
db.commit()
db.refresh(habit)
return habit
def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit:
habit.name = data.name
habit.habit_type = data.habit_type
habit.weekdays_mask = data.weekdays_mask
habit.condition_text = data.condition_text
habit.reminder_time = data.reminder_time
db.commit()
db.refresh(habit)
return habit
def delete_habit(db: Session, habit: Habit) -> None:
db.delete(habit)
db.commit()
def complete_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.COMPLETED
habit.completed_at = datetime.now()
db.commit()
db.refresh(habit)
return habit
def reactivate_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.ACTIVE
habit.completed_at = None
db.commit()
db.refresh(habit)
return habit
def reorder_habits(db: Session, user_id: int, ordered_ids: list[int]) -> None:
"""ordered_ids에 나온 순서대로 sort_order를 다시 매긴다. 목록에 없는 id나 다른 유저의 habit은 무시한다."""
habits = db.scalars(
select(Habit).where(Habit.id.in_(ordered_ids), Habit.user_id == user_id)
).all()
habit_map = {h.id: h for h in habits}
for index, habit_id in enumerate(ordered_ids):
habit = habit_map.get(habit_id)
if habit is not None:
habit.sort_order = index
db.commit()
+254
View File
@@ -0,0 +1,254 @@
import calendar
from datetime import date, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.schemas.habit_log import HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
from app.services import habit_service, push_service
# 체크 시 축하 푸시/배지를 트리거하는 연속 달성일 마일스톤.
MILESTONE_STREAKS = {7, 30, 66, 100, 200, 365}
def _to_today_item(db: Session, habit: Habit, checked: bool) -> TodayItem:
stats = get_habit_stats(db, habit)
return TodayItem(
habit_id=habit.id,
name=habit.name,
habit_type=habit.habit_type.value,
condition_text=habit.condition_text,
reminder_time=habit.reminder_time.strftime("%H:%M") if habit.reminder_time else None,
checked=checked,
completion_rate=stats.completion_rate,
current_streak=stats.current_streak,
scheduled_days=stats.scheduled_days,
)
def get_today_items(db: Session, user_id: int, target_date: date) -> tuple[list[TodayItem], list[TodayItem]]:
"""오늘 요일에 예정된 active 습관을 형성/중단으로 나누어 체크 여부와 함께 반환한다."""
weekday = target_date.weekday()
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
scheduled = [h for h in active_habits if h.is_scheduled_on(weekday)]
checked_ids: set[int] = set()
habit_ids = [h.id for h in scheduled]
if habit_ids:
checked_ids = set(
db.scalars(
select(HabitLog.habit_id).where(
HabitLog.log_date == target_date, HabitLog.habit_id.in_(habit_ids)
)
)
)
build_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.BUILD]
quit_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.QUIT]
return build_items, quit_items
def toggle_check(db: Session, habit_id: int, log_date: date) -> bool:
"""체크 상태를 반전시키고 토글 후의 체크 여부를 반환한다.
호출측에서 이미 habit_service.get_habit(db, habit_id, user_id)로 소유권을 검증한 뒤에만 불러야 한다.
"""
existing = db.scalar(select(HabitLog).where(HabitLog.habit_id == habit_id, HabitLog.log_date == log_date))
if existing:
db.delete(existing)
db.commit()
return False
db.add(HabitLog(habit_id=habit_id, log_date=log_date))
db.commit()
return True
def toggle_check_and_celebrate(db: Session, habit: Habit, log_date: date) -> tuple[bool, int | None]:
"""체크를 토글하고, 새로 체크되어 스트릭이 마일스톤에 도달했으면 축하 푸시를 보낸다.
반환값: (checked, milestone_streak). milestone_streak은 이번 토글로 막 달성한 마일스톤 값이면 그 값,
체크 해제거나 마일스톤이 아니면 None.
"""
checked = toggle_check(db, habit.id, log_date)
if not checked:
return checked, None
streak = get_habit_stats(db, habit).current_streak
if streak not in MILESTONE_STREAKS:
return checked, None
if habit.user_id is not None:
push_service.send_to_user(
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
)
return checked, streak
def list_logs(
db: Session, user_id: int, habit_id: int | None = None, start: date | None = None, end: date | None = None
) -> list[HabitLog]:
stmt = select(HabitLog).join(Habit, HabitLog.habit_id == Habit.id).where(Habit.user_id == user_id)
if habit_id is not None:
stmt = stmt.where(HabitLog.habit_id == habit_id)
if start is not None:
stmt = stmt.where(HabitLog.log_date >= start)
if end is not None:
stmt = stmt.where(HabitLog.log_date <= end)
stmt = stmt.order_by(HabitLog.log_date)
return list(db.scalars(stmt))
def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end: date) -> dict[date, int]:
if not habit_ids:
return {}
rows = db.execute(
select(HabitLog.log_date, func.count(HabitLog.id))
.where(HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(start, end))
.group_by(HabitLog.log_date)
).all()
return {row[0]: row[1] for row in rows}
def get_monthly_summary(db: Session, user_id: int, year: int, month: int) -> list[MonthlySummaryDay]:
"""해당 월의 날짜별 예정 습관 수 / 체크된 습관 수를 집계한다 (현재 active 습관 기준)."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], first_day, last_day)
summaries = []
for day_num in range(1, days_in_month + 1):
d = date(year, month, day_num)
# 습관이 생성되기 전 날짜는 "예정되었지만 안 함"으로 잘못 잡히지 않도록 제외한다.
scheduled = sum(1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday()))
summaries.append(
MonthlySummaryDay(log_date=d, scheduled_count=scheduled, checked_count=checked_counts.get(d, 0))
)
return summaries
def summarize_completion_rate(summaries: list[MonthlySummaryDay], up_to: date) -> float:
"""월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다.
아직 지나지 않은 미래 날짜는 scheduled_count는 있어도 checked_count가 항상 0이라
포함시키면 완료율이 부당하게 낮아지므로 제외한다.
"""
past = [s for s in summaries if s.log_date <= up_to]
total_scheduled = sum(s.scheduled_count for s in past)
total_checked = sum(s.checked_count for s in past)
return round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
def get_period_completion_rate(db: Session, user_id: int, start: date, end: date) -> tuple[float, int, int]:
"""[start, end] 구간(양끝 포함)의 예정/체크 수를 집계해 완료율(%)과 함께 반환한다.
get_monthly_summary와 같은 규칙(현재 active 습관 기준, 습관 생성일 이전 제외)을 임의 기간에
적용한 버전 — 주간 요약 알림처럼 달력 월 경계에 안 맞는 기간을 집계할 때 쓴다.
"""
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], start, end)
total_scheduled = 0
total_checked = 0
d = start
while d <= end:
total_scheduled += sum(
1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday())
)
total_checked += checked_counts.get(d, 0)
d += timedelta(days=1)
rate = round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
return rate, total_scheduled, total_checked
def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
"""습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다.
요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다
(과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화).
"""
today = date.today()
start = habit.created_at.date()
checked_dates = set(db.scalars(select(HabitLog.log_date).where(HabitLog.habit_id == habit.id)))
scheduled_days = 0
checked_days = 0
d = start
while d <= today:
if habit.is_scheduled_on(d.weekday()):
scheduled_days += 1
if d in checked_dates:
checked_days += 1
d += timedelta(days=1)
completion_rate = round(checked_days / scheduled_days * 100, 1) if scheduled_days else 0.0
streak = 0
d = today
while d >= start:
if habit.is_scheduled_on(d.weekday()):
if d in checked_dates:
streak += 1
elif d != today:
break
# d가 오늘이고 아직 체크 전이면: 하루가 아직 안 끝났으니 스트릭을 끊지 않고 계속 거슬러 올라간다.
d -= timedelta(days=1)
return HabitStats(
completion_rate=completion_rate,
current_streak=streak,
scheduled_days=scheduled_days,
checked_days=checked_days,
)
def get_weekly_matrix(db: Session, user_id: int, week_start: date) -> list[WeeklyMatrixRow]:
"""week_start(호출측에서 정한 주 시작일, 현재 /history는 일요일을 사용)부터 7일간,
active 습관별 요일 체크 매트릭스를 반환한다. 이 함수 자체는 week_start가 어떤 요일이든 상관없다."""
week_days = [week_start + timedelta(days=i) for i in range(7)]
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
habit_ids = [h.id for h in active_habits]
checked_pairs: set[tuple[int, date]] = set()
if habit_ids:
rows = db.execute(
select(HabitLog.habit_id, HabitLog.log_date).where(
HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(week_days[0], week_days[-1])
)
).all()
checked_pairs = {(r[0], r[1]) for r in rows}
today = date.today()
result = []
for h in active_habits:
checks: dict[str, bool | None] = {}
scheduled_past = 0
checked_past = 0
for d in week_days:
# 습관이 생성되기 전 날짜는 요일이 맞아도 "예정 없음"으로 취급한다.
if d < h.created_at.date() or not h.is_scheduled_on(d.weekday()):
checks[d.isoformat()] = None
continue
is_checked = (h.id, d) in checked_pairs
checks[d.isoformat()] = is_checked
if d <= today:
scheduled_past += 1
if is_checked:
checked_past += 1
completion_rate = round(checked_past / scheduled_past * 100, 1) if scheduled_past else 0.0
result.append(
WeeklyMatrixRow(
habit_id=h.id,
name=h.name,
habit_type=h.habit_type.value,
checks=checks,
completion_rate=completion_rate,
)
)
return result
+69
View File
@@ -0,0 +1,69 @@
import json
from pywebpush import WebPushException, webpush
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.push_subscription import PushSubscription
from app.schemas.push import PushSubscribeRequest
def list_subscriptions(db: Session, user_id: int) -> list[PushSubscription]:
return list(db.scalars(select(PushSubscription).where(PushSubscription.user_id == user_id)))
def save_subscription(
db: Session, user_id: int, data: PushSubscribeRequest, user_agent: str | None = None
) -> PushSubscription:
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint))
if existing:
existing.user_id = user_id # 같은 기기에서 다른 유저가 재구독하면 소유자를 갱신한다.
existing.p256dh_key = data.keys.p256dh
existing.auth_key = data.keys.auth
db.commit()
return existing
sub = PushSubscription(
user_id=user_id,
endpoint=data.endpoint,
p256dh_key=data.keys.p256dh,
auth_key=data.keys.auth,
user_agent=user_agent,
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
def delete_subscription(db: Session, endpoint: str) -> None:
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
if existing:
db.delete(existing)
db.commit()
def send_to_user(db: Session, user_id: int, title: str, body: str, url: str = "/today") -> int:
"""해당 유저의 구독자에게만 알림을 보낸다. 만료된(410/404) 구독은 자동으로 삭제한다. 성공 발송 건수를 반환."""
payload = json.dumps({"title": title, "body": body, "url": url}, ensure_ascii=False)
sent = 0
for sub in list_subscriptions(db, user_id):
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
},
data=payload,
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
)
sent += 1
except WebPushException as exc:
status_code = exc.response.status_code if exc.response is not None else None
if status_code in (404, 410):
db.delete(sub)
db.commit()
# 그 외 오류(일시적 네트워크 문제 등)는 건너뛰고 다음 구독자에게 계속 발송한다.
return sent
+180
View File
@@ -0,0 +1,180 @@
import logging
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import settings
from app.database import SessionLocal
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.services import habit_service, log_service, push_service
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler(timezone=settings.timezone)
def _claim_notification_slot(db, habit_id: int, notify_date) -> bool:
"""habit_notification_log에 (habit_id, notify_date) 행을 먼저 "선점"한다.
(habit_id, notify_date) 유니크 제약을 경합 방지용 락으로 쓴다 — reload로 겹친 워커나
APScheduler가 중복 기동된 상황에서도 두 프로세스가 동시에 같은 알림을 보내지 않도록,
실제 발송 전에 먼저 이 행을 커밋해서 선점에 성공한 쪽만 발송하게 한다.
"""
db.add(HabitNotificationLog(habit_id=habit_id, notify_date=notify_date))
try:
db.commit()
return True
except IntegrityError:
db.rollback()
return False
def _claim_summary_slot(db: Session, user_id: int, period_type: str, period_start: date) -> bool:
"""summary_notification_log에 (user_id, period_type, period_start) 행을 선점한다.
_claim_notification_slot과 같은 목적 — 리로드로 겹친 워커나 중복 기동된 스케줄러가
같은 주간/월간 요약을 두 번 보내지 않도록 유니크 제약을 경합 방지 락으로 쓴다.
"""
db.add(SummaryNotificationLog(user_id=user_id, period_type=period_type, period_start=period_start))
try:
db.commit()
return True
except IntegrityError:
db.rollback()
return False
def _tick() -> None:
"""매분 실행되어, 지금 이 순간이 알람 시각+요일에 맞는 active 습관에 대해
오늘 아직 안 보낸 알림만 골라 발송한다."""
db = SessionLocal()
try:
now = datetime.now(ZoneInfo(settings.timezone))
today = now.date()
weekday = today.weekday()
habits = habit_service.list_active_habits_with_reminders(db)
for habit in habits:
if habit.user_id is None or not habit.is_scheduled_on(weekday):
continue
if habit.reminder_time.hour != now.hour or habit.reminder_time.minute != now.minute:
continue
already_sent = db.scalar(
select(HabitNotificationLog).where(
HabitNotificationLog.habit_id == habit.id,
HabitNotificationLog.notify_date == today,
)
)
if already_sent:
continue
try:
claimed = _claim_notification_slot(db, habit.id, today)
if not claimed:
continue
push_service.send_to_user(
db, habit.user_id, title=habit.name, body="지금 실천할 시간이에요", url="/today"
)
except Exception:
logger.exception("습관(id=%s) 알림 발송 중 오류", habit.id)
db.rollback()
except Exception:
logger.exception("습관 알림 tick 처리 중 오류")
finally:
db.close()
def _send_period_summaries(
db: Session, *, period_type: str, period_start: date, range_start: date, range_end: date, title: str, url: str
) -> None:
"""유저별로 [range_start, range_end] 완료율을 계산해 요약 푸시를 보낸다 (주간/월간 tick 공통 로직).
예정된 습관이 하나도 없던(scheduled == 0) 유저에게는 의미 없는 알림을 보내지 않고 건너뛴다.
"""
for user_id in habit_service.list_active_user_ids(db):
rate, scheduled, checked = log_service.get_period_completion_rate(db, user_id, range_start, range_end)
if scheduled == 0:
continue
try:
if not _claim_summary_slot(db, user_id, period_type, period_start):
continue
push_service.send_to_user(
db, user_id, title=title, body=f"완료율 {rate}% ({checked}/{scheduled})이에요.", url=url
)
except Exception:
logger.exception("유저(id=%s) %s 요약 알림 발송 중 오류", user_id, period_type)
db.rollback()
def _weekly_summary_tick() -> None:
"""매주 일요일 21시에 실행되어, 이번 주(월요일~오늘)의 완료율을 유저별로 요약해 발송한다."""
db = SessionLocal()
try:
today = datetime.now(ZoneInfo(settings.timezone)).date()
week_start = today - timedelta(days=today.weekday())
_send_period_summaries(
db,
period_type="weekly",
period_start=week_start,
range_start=week_start,
range_end=today,
title="이번 주 습관 리포트",
url=f"/history?view=week&start={week_start.isoformat()}",
)
except Exception:
logger.exception("주간 요약 tick 처리 중 오류")
finally:
db.close()
def _monthly_summary_tick() -> None:
"""매월 마지막 날 21:30에 실행되어, 이번 달(1일~오늘)의 완료율을 유저별로 요약해 발송한다."""
db = SessionLocal()
try:
today = datetime.now(ZoneInfo(settings.timezone)).date()
month_start = today.replace(day=1)
_send_period_summaries(
db,
period_type="monthly",
period_start=month_start,
range_start=month_start,
range_end=today,
title="이번 달 습관 리포트",
url=f"/history?view=month&year={today.year}&month={today.month}",
)
except Exception:
logger.exception("월간 요약 tick 처리 중 오류")
finally:
db.close()
def start_scheduler() -> None:
scheduler.add_job(_tick, "cron", minute="*", id="habit_reminder_tick", replace_existing=True)
scheduler.add_job(
_weekly_summary_tick,
"cron",
day_of_week="sun",
hour=21,
minute=0,
id="weekly_summary_tick",
replace_existing=True,
)
scheduler.add_job(
_monthly_summary_tick,
"cron",
day="last",
hour=21,
minute=30,
id="monthly_summary_tick",
replace_existing=True,
)
scheduler.start()
def shutdown_scheduler() -> None:
scheduler.shutdown(wait=False)
+640
View File
@@ -0,0 +1,640 @@
:root {
--color-bg: #f5f4ef;
--color-surface: #ffffff;
--color-text: #2b2a27;
--color-text-muted: #6b6a66;
--color-accent: #d97757;
--color-accent-hover: #c15f3c;
--color-accent-rgb: 217, 119, 87;
--color-border: #e8e6df;
--color-success: #4f7a5a;
--color-success-tint: rgba(79, 122, 90, 0.08);
--color-danger: #b3543f;
--color-gold: #a8791a;
--color-gold-tint: rgba(168, 121, 26, 0.12);
--radius-card: 14px;
--radius-control: 10px;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04);
--space-1: 8px;
--space-2: 16px;
--space-3: 24px;
--space-4: 32px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Pretendard", "Malgun Gothic", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1f1e1b;
--color-surface: #2a2926;
--color-text: #edebe4;
--color-text-muted: #a8a69f;
--color-accent: #e08962;
--color-accent-hover: #eb9c78;
--color-accent-rgb: 224, 137, 98;
--color-border: #3a3833;
--color-success: #6fa47c;
--color-success-tint: rgba(111, 164, 124, 0.14);
--color-danger: #d97c68;
--color-gold: #d9b84f;
--color-gold-tint: rgba(217, 184, 79, 0.16);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
* {
box-sizing: border-box;
}
[x-cloak] {
display: none !important;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
.app-shell {
max-width: 640px;
margin: 0 auto;
padding: var(--space-3) var(--space-2) var(--space-4);
}
@media (min-width: 900px) {
.app-shell {
max-width: 760px;
padding-top: var(--space-4);
}
.app-shell.wide {
max-width: 960px;
}
}
h1,
h2,
h3 {
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
margin: 0 0 var(--space-2);
}
h1 {
font-size: 22px;
}
h2 {
font-size: 17px;
}
p {
margin: 0 0 var(--space-2);
color: var(--color-text-muted);
}
/* iOS 홈 화면 추가 안내 배너 */
.ios-install-banner {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
box-shadow: var(--shadow-card);
padding: 10px 12px;
margin-bottom: var(--space-2);
font-size: 13px;
color: var(--color-text);
}
.ios-install-banner button {
flex-shrink: 0;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
/* 상단 네비게이션 */
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
row-gap: var(--space-1);
margin-bottom: var(--space-3);
}
.top-nav .brand {
font-weight: 600;
font-size: 17px;
white-space: nowrap;
}
.top-nav .nav-links {
display: flex;
gap: var(--space-1);
}
.top-nav .nav-links a {
text-decoration: none;
color: var(--color-text-muted);
font-size: 14px;
white-space: nowrap;
padding: 6px 10px;
border-radius: var(--radius-control);
}
.top-nav .nav-links a.active {
color: var(--color-text);
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.top-nav .nav-user {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--space-1);
font-size: 13px;
color: var(--color-text-muted);
}
.top-nav .nav-user span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 96px;
}
.btn-link {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 13px;
color: var(--color-text-muted);
text-decoration: underline;
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
}
/* 하단 탭바 (모바일 전용) — 기본은 숨김, 좁은 화면에서만 노출 */
.bottom-tab-bar {
display: none;
}
/* 좁은 화면: 상단은 브랜드+유저정보만 남기고, 이동 네비게이션은 하단 탭바로 옮긴다 */
@media (max-width: 480px) {
.top-nav .nav-links {
display: none;
}
.app-shell {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + var(--space-2));
}
.bottom-tab-bar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.bottom-tab-bar .tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 4px 6px;
text-decoration: none;
color: var(--color-text-muted);
font-size: 11px;
}
.bottom-tab-bar .tab-item svg {
width: 22px;
height: 22px;
}
.bottom-tab-bar .tab-item.active {
color: var(--color-accent);
}
}
/* 카드 */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--space-3);
margin-bottom: var(--space-2);
}
/* 버튼 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 6px;
border: none;
border-radius: var(--radius-control);
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
font-family: inherit;
transition: background-color 0.15s ease, opacity 0.15s ease;
}
.btn-primary {
background: var(--color-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg);
}
.btn-danger-ghost {
background: transparent;
color: var(--color-danger);
border: 1px solid var(--color-border);
}
.btn-block {
width: 100%;
}
/* 입력 */
input[type="text"],
input[type="time"],
input[type="password"] {
width: 100%;
font-family: inherit;
font-size: 15px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
color: var(--color-text);
}
label {
display: block;
font-size: 13px;
color: var(--color-text-muted);
margin-bottom: 6px;
}
.field {
margin-bottom: var(--space-2);
}
.form-error-text {
color: var(--color-danger);
font-size: 13px;
margin: -8px 0 var(--space-2);
min-height: 1em;
}
/* 탭 */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.tabs a {
text-decoration: none;
color: var(--color-text-muted);
font-size: 14px;
padding: 10px 4px;
margin-right: var(--space-2);
border-bottom: 2px solid transparent;
}
.tabs a.active {
color: var(--color-text);
border-bottom-color: var(--color-accent);
font-weight: 600;
}
/* 요일 선택 pill */
.weekday-picker {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.weekday-pill {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.weekday-pill.selected {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.everyday-btn {
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
font-size: 13px;
padding: 8px 14px;
cursor: pointer;
margin-left: 4px;
}
.everyday-btn.selected {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* 습관 리스트 아이템 */
.habit-item {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px var(--space-2);
padding: 12px 4px;
border-bottom: 1px solid var(--color-border);
}
.habit-item:last-child {
border-bottom: none;
}
.habit-item.today-checked {
background: var(--color-success-tint);
border-radius: var(--radius-control);
margin: 0 -4px;
padding-left: 8px;
padding-right: 8px;
}
.habit-item-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1 1 auto;
}
.habit-item-name {
font-size: 15px;
overflow-wrap: break-word;
word-break: break-word;
}
.habit-item-condition {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 2px;
}
.habit-item-meta {
font-size: 12px;
color: var(--color-text-muted);
}
.habit-item-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.drag-handle {
flex-shrink: 0;
cursor: grab;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
padding: 4px 2px;
touch-action: none;
user-select: none;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen .habit-item {
background: var(--color-bg);
border-radius: var(--radius-control);
}
.habit-item-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.check-indicator {
width: 26px;
height: 26px;
padding: 0;
margin: 0;
border-radius: 50%;
border: 2px solid var(--color-border);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
background: transparent;
font-family: inherit;
font-size: 13px;
line-height: 1;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.check-indicator.checked {
background: var(--color-success);
border-color: var(--color-success);
color: #fff;
}
.empty-state {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-4) var(--space-2);
font-size: 14px;
}
.badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.badge-milestone {
background: var(--color-gold-tint);
color: var(--color-gold);
border-color: var(--color-gold);
font-weight: 600;
}
.celebration-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin-bottom: var(--space-2);
border-radius: var(--radius-card);
background: var(--color-gold-tint);
border: 1px solid var(--color-gold);
color: var(--color-text);
font-size: 14px;
animation: celebration-pop 0.4s ease-out;
}
@keyframes celebration-pop {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 월별 캘린더 */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.calendar-weekday {
text-align: center;
font-size: 12px;
color: var(--color-text-muted);
padding: 4px 0;
}
.calendar-cell {
aspect-ratio: 1;
border-radius: 8px;
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.calendar-cell.muted {
opacity: 0.35;
}
.calendar-date {
font-size: 12px;
font-weight: 500;
}
.calendar-ratio {
font-size: 10px;
color: var(--color-text-muted);
}
/* 주별 매트릭스 */
.week-matrix {
width: 100%;
border-collapse: collapse;
font-size: 13px;
white-space: nowrap;
}
.week-matrix th,
.week-matrix td {
padding: 10px 8px;
text-align: center;
border-bottom: 1px solid var(--color-border);
}
.week-matrix th:first-child,
.week-matrix td:first-child {
text-align: left;
}
.week-matrix th {
color: var(--color-text-muted);
font-weight: 500;
font-size: 12px;
}
.wm-check {
color: var(--color-success);
font-weight: 700;
}
.wm-dash {
color: var(--color-border);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+35
View File
@@ -0,0 +1,35 @@
(function () {
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/service-worker.js").catch(function (err) {
console.error("서비스워커 등록 실패", err);
});
});
}
function isIos() {
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
}
function isStandalone() {
return (
("standalone" in window.navigator && window.navigator.standalone) ||
window.matchMedia("(display-mode: standalone)").matches
);
}
document.addEventListener("DOMContentLoaded", function () {
var banner = document.getElementById("ios-install-banner");
if (!banner) return;
var dismissed = localStorage.getItem("iosInstallBannerDismissed");
if (isIos() && !isStandalone() && !dismissed) {
banner.style.display = "flex";
}
});
window.dismissIosInstallBanner = function () {
var banner = document.getElementById("ios-install-banner");
if (banner) banner.style.display = "none";
localStorage.setItem("iosInstallBannerDismissed", "1");
};
})();
+27
View File
@@ -0,0 +1,27 @@
(function () {
function initSortable() {
var list = document.getElementById("habit-list");
if (!list || typeof Sortable === "undefined") return;
Sortable.create(list, {
handle: ".drag-handle",
animation: 150,
forceFallback: true, // iOS Safari는 네이티브 HTML5 D&D 터치 지원이 불안정해서 자체 포인터 시뮬레이션을 강제한다
fallbackTolerance: 3,
onEnd: function () {
var ids = Array.from(list.children)
.map(function (el) { return el.getAttribute("data-habit-id"); })
.filter(Boolean)
.map(Number);
fetch("/api/habits/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ habit_ids: ids }),
});
},
});
}
document.addEventListener("DOMContentLoaded", initSortable);
})();
+63
View File
@@ -0,0 +1,63 @@
(function () {
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
const output = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
output[i] = rawData.charCodeAt(i);
}
return output;
}
function isPushSupported() {
return "serviceWorker" in navigator && "PushManager" in window;
}
async function getSubscriptionState() {
if (!isPushSupported()) return "unsupported";
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return sub ? "subscribed" : "unsubscribed";
}
async function subscribePush() {
if (!isPushSupported()) {
alert("이 브라우저는 알림을 지원하지 않아요.");
return false;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
alert("알림 권한이 허용되지 않았어요.");
return false;
}
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await fetch("/api/push/vapid-public-key").then((r) => r.json());
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
return true;
}
async function unsubscribePush() {
if (!isPushSupported()) return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) return;
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
window.habitPush = { getSubscriptionState, subscribePush, unsubscribePush };
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
{
"name": "습관 트래커",
"short_name": "습관 트래커",
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
"start_url": "/today",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f5f4ef",
"theme_color": "#d97757",
"lang": "ko",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/static/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+103
View File
@@ -0,0 +1,103 @@
const CACHE_NAME = "habit-tracker-v3";
const APP_SHELL = [
"/static/css/style.css",
"/static/js/app.js",
"/static/js/push-register.js",
"/static/js/habit-reorder.js",
"/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js",
"/static/icons/icon-192.png",
"/static/icons/icon-512.png",
"/static/manifest.json",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) return;
// 페이지 탐색(/today, /habits, /history 등)은 습관 추가·수정 직후 리다이렉트되는 화면이라
// 캐시된 옛 내용이 먼저 보이면 "저장한 게 사라졌다"처럼 보인다. 네트워크를 우선 시도하고
// 오프라인일 때만 캐시로 폴백한다.
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request))
);
return;
}
// 정적 자산은 캐시 우선 응답 후 백그라운드로 갱신(stale-while-revalidate) — 자주 안 바뀌므로 속도 우선.
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => cached);
return cached || network;
})
);
});
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "습관 트래커", {
body: data.body || "",
icon: "/static/icons/icon-192.png",
badge: "/static/icons/icon-192.png",
data: { url: data.url || "/today" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || "/today";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsList) => {
for (const client of clientsList) {
if (client.url.includes(targetUrl) && "focus" in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
);
});
+25
View File
@@ -0,0 +1,25 @@
from app.models.habit import ALL_WEEKDAYS_MASK
from app.services.log_service import MILESTONE_STREAKS
_DAY_LABELS = ["", "", "", "", "", "", ""]
def is_milestone_streak(streak: int) -> bool:
return streak in MILESTONE_STREAKS
def weekday_label(mask: int) -> str:
if mask == ALL_WEEKDAYS_MASK:
return "매일"
days = [label for i, label in enumerate(_DAY_LABELS) if mask & (1 << i)]
return ", ".join(days) if days else "선택된 요일 없음"
def heatmap_opacity(checked_count: int, scheduled_count: int) -> float:
"""월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다."""
if not scheduled_count:
return 0.0
ratio = checked_count / scheduled_count
if ratio <= 0:
return 0.0
return round(0.12 + ratio * 0.78, 2)
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
<h1>페이지를 찾을 수 없어요</h1>
<p>주소가 잘못되었거나 삭제된 페이지예요.</p>
<a href="/today" class="btn btn-primary">오늘 화면으로</a>
</div>
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
<h1>문제가 발생했어요</h1>
<p>일시적인 오류일 수 있어요. 잠시 후 다시 시도해주세요.</p>
<a href="/today" class="btn btn-primary">오늘 화면으로</a>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}습관 트래커{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#d97757" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="습관 트래커" />
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" />
<link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" />
<script src="/static/js/vendor/htmx.min.js" defer></script>
<script src="/static/js/push-register.js" defer></script>
<script src="/static/js/vendor/sortable.min.js" defer></script>
<script src="/static/js/habit-reorder.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script>
</head>
<body>
<div class="app-shell{% block shell_class %}{% endblock %}">
{% if logged_in %}
<div id="ios-install-banner" class="ios-install-banner">
<span>홈 화면에 추가하면 알림도 받을 수 있어요. 공유 버튼 → &lsquo;홈 화면에 추가&rsquo;를 눌러보세요.</span>
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">&times;</button>
</div>
<nav class="top-nav">
<span class="brand">습관 트래커</span>
<div class="nav-links">
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
</div>
{% if current_user %}
<div class="nav-user">
<span>{{ current_user.name or current_user.email }}</span>
<form method="post" action="/auth/logout">
<button type="submit" class="btn-link">로그아웃</button>
</form>
</div>
{% endif %}
</nav>
<nav class="bottom-tab-bar">
<a href="/today" class="tab-item {{ self.nav_today() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M8 12l3 3 5-6" /></svg>
<span>오늘</span>
</a>
<a href="/habits" class="tab-item {{ self.nav_habits() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6" /><line x1="4" y1="12" x2="20" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /></svg>
<span>습관 관리</span>
</a>
<a href="/history" class="tab-item {{ self.nav_history() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg>
<span>기록</span>
</a>
</nav>
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}습관 관리 · 습관 트래커{% endblock %}
{% block nav_habits %}active{% endblock %}
{% block content %}
<h1>습관 관리</h1>
<div class="tabs">
<a href="/habits?tab=build" class="{{ 'active' if tab == 'build' }}">만들고 싶은 습관</a>
<a href="/habits?tab=quit" class="{{ 'active' if tab == 'quit' }}">멈추고 싶은 습관</a>
<a href="/habits?tab=completed" class="{{ 'active' if tab == 'completed' }}">완료된 습관</a>
</div>
{% if tab in ("build", "quit") %}
{% include "partials/habit_form.html" %}
{% endif %}
<div class="card">
{% if habits %}
<div id="habit-list">
{% for habit in habits %}
{% include "partials/habit_item.html" %}
{% endfor %}
</div>
{% else %}
<div class="empty-state">
{% if tab == "build" %}만들고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
{% elif tab == "quit" %}끊고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
{% else %}아직 완료된 습관이 없어요.{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}기록 · 습관 트래커{% endblock %}
{% block nav_history %}active{% endblock %}
{% block shell_class %} wide{% endblock %}
{% block content %}
<h1>기록</h1>
<div class="tabs">
<a href="/history?view=month" class="{{ 'active' if view == 'month' }}">월별</a>
<a href="/history?view=week" class="{{ 'active' if view == 'week' }}">주별</a>
</div>
{% if view == "month" %}
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<a href="/history?view=month&year={{ prev_year }}&month={{ prev_month }}" class="btn btn-secondary"></a>
<h2 style="margin:0;">{{ year }}년 {{ month }}월</h2>
<a href="/history?view=month&year={{ next_year }}&month={{ next_month }}" class="btn btn-secondary"></a>
</div>
<div style="text-align:center; margin-bottom: var(--space-2);">
<span class="badge">이번 달 완료율 {{ completion_rate }}%</span>
</div>
<div class="calendar-grid calendar-grid-header">
{% for wd in ["일", "월", "화", "수", "목", "금", "토"] %}
<div class="calendar-weekday">{{ wd }}</div>
{% endfor %}
</div>
{% for week in weeks %}
<div class="calendar-grid">
{% for d in week %}
{% set summary = summary_map.get(d) %}
<div
class="calendar-cell{{ '' if d.month == month else ' muted' }}"
style="background: rgba(var(--color-accent-rgb), {{ heatmap_opacity(summary.checked_count, summary.scheduled_count) if summary else 0 }});"
>
<span class="calendar-date">{{ d.day }}</span>
{% if summary and summary.scheduled_count %}
<span class="calendar-ratio">{{ summary.checked_count }}/{{ summary.scheduled_count }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="overflow-x:auto;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<a href="/history?view=week&start={{ prev_week }}" class="btn btn-secondary"></a>
<h2 style="margin:0;">{{ week_start.strftime("%Y.%m.%d") }} - {{ week_end.strftime("%m.%d") }}</h2>
<a href="/history?view=week&start={{ next_week }}" class="btn btn-secondary"></a>
</div>
{% if rows %}
<table class="week-matrix">
<thead>
<tr>
<th>습관</th>
{% for wd in ["일", "월", "화", "수", "목", "금", "토"] %}
<th>{{ wd }}</th>
{% endfor %}
<th>완료율</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
{{ row.name }}
<span class="badge">{{ "형성" if row.habit_type == "build" else "중단" }}</span>
</td>
{% for iso_date, checked in row.checks.items() %}
<td class="week-matrix-cell">
{% if checked is none %}<span class="wm-dash"></span>
{% elif checked %}<span class="wm-check">&#10003;</span>
{% else %}<span class="wm-empty"></span>
{% endif %}
</td>
{% endfor %}
<td class="week-matrix-cell">{{ row.completion_rate }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">진행 중인 습관이 없어요.</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}로그인 · 습관 트래커{% endblock %}
{% block content %}
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
<div class="card" style="width: 100%; max-width: 320px;">
<h1 style="text-align:center;">습관 트래커</h1>
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
<div class="field">
<label>시행 요일</label>
<div class="weekday-picker">
<template x-for="(day, idx) in days" :key="idx">
<button
type="button"
class="weekday-pill"
:class="{ selected: (mask & (1 << idx)) !== 0 }"
@click="if (mask !== (1 << idx)) mask = mask ^ (1 << idx)"
x-text="day"
></button>
</template>
<button
type="button"
class="everyday-btn"
:class="{ selected: mask === 127 }"
@click="mask = 127"
>매일</button>
</div>
</div>
<div class="field">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" x-model="alarmOn" style="width:auto;" />
알람 사용
</label>
<input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" />
</div>
+42
View File
@@ -0,0 +1,42 @@
<div
class="card"
x-data="{
open: false,
mask: 127,
alarmOn: false,
days: ['월', '화', '수', '목', '금', '토', '일'],
}"
>
<button type="button" class="btn btn-secondary btn-block" @click="open = !open">
<span x-show="!open">{{ '+ 멈추고 싶은 습관 추가' if habit_type == 'quit' else '+ 만들고 싶은 습관 추가' }}</span>
<span x-show="open" x-cloak>닫기</span>
</button>
<form
x-show="open"
x-cloak
style="margin-top: var(--space-2);"
hx-post="/habits/new"
hx-target="#habit-form-error-{{ habit_type }}"
hx-swap="innerHTML"
>
<input type="hidden" name="habit_type" value="{{ habit_type }}" />
<input type="hidden" name="weekdays_mask" :value="mask" />
<div class="field">
<label for="name-{{ habit_type }}">습관 이름</label>
<input type="text" id="name-{{ habit_type }}" name="name" required placeholder="{{ '예: 물 2L 마시기' if habit_type == 'build' else '예: 야식 끊기' }}" />
</div>
<div class="field">
<label for="condition-{{ habit_type }}">달성 조건 (선택)</label>
<input type="text" id="condition-{{ habit_type }}" name="condition_text" placeholder="{{ '예: 하루 8잔 이상' if habit_type == 'build' else '예: 주 3회 이하로 줄이기' }}" />
</div>
{% include "partials/_weekday_alarm_fields.html" %}
<div id="habit-form-error-{{ habit_type }}" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
</form>
</div>
+94
View File
@@ -0,0 +1,94 @@
<div
data-habit-id="{{ habit.id }}"
x-data="{
editing: false,
mask: {{ habit.weekdays_mask }},
alarmOn: {{ 'true' if habit.reminder_time else 'false' }},
days: ['월', '화', '수', '목', '금', '토', '일'],
}"
>
<div class="habit-item" x-show="!editing">
<div class="habit-item-main">
<span class="drag-handle" aria-hidden="true"></span>
<div>
<div class="habit-item-name">{{ habit.name }}</div>
{% if habit.condition_text %}<div class="habit-item-condition">{{ habit.condition_text }}</div>{% endif %}
<div class="habit-item-meta">
{{ weekday_label(habit.weekdays_mask) }}
{% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
</div>
{% set stats = stats_map[habit.id] %}
{% if stats.scheduled_days > 0 %}
<div class="habit-item-stats">
<span class="badge">완료율 {{ stats.completion_rate }}%</span>
{% if stats.current_streak > 0 %}
<span class="badge{{ ' badge-milestone' if is_milestone_streak(stats.current_streak) }}">
{{ '🏆' if is_milestone_streak(stats.current_streak) else '🔥' }} 연속 {{ stats.current_streak }}일
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="habit-item-actions">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
{% if habit.status.value == "active" %}
<button
type="button"
class="btn btn-secondary"
hx-post="/habits/{{ habit.id }}/complete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?"
>완료 처리</button>
{% else %}
<button
type="button"
class="btn btn-secondary"
hx-post="/habits/{{ habit.id }}/reactivate"
hx-target="body"
hx-swap="none"
>다시 진행</button>
{% endif %}
<button
type="button"
class="btn btn-danger-ghost"
hx-delete="/habits/{{ habit.id }}/delete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 삭제할까요? 기록도 함께 삭제됩니다."
>삭제</button>
</div>
</div>
<form
x-show="editing"
x-cloak
style="padding: 12px 4px; border-bottom: 1px solid var(--color-border);"
hx-post="/habits/{{ habit.id }}/edit"
hx-target="#habit-edit-error-{{ habit.id }}"
hx-swap="innerHTML"
>
<input type="hidden" name="weekdays_mask" :value="mask" />
<div class="field">
<label for="edit-name-{{ habit.id }}">습관 이름</label>
<input type="text" id="edit-name-{{ habit.id }}" name="name" required value="{{ habit.name }}" />
</div>
<div class="field">
<label for="edit-condition-{{ habit.id }}">달성 조건 (선택)</label>
<input type="text" id="edit-condition-{{ habit.id }}" name="condition_text" value="{{ habit.condition_text or '' }}" />
</div>
{% set reminder_value = habit.reminder_time.strftime("%H:%M") if habit.reminder_time else "" %}
{% include "partials/_weekday_alarm_fields.html" %}
<div id="habit-edit-error-{{ habit.id }}" class="form-error-text"></div>
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
<button type="button" class="btn btn-secondary" @click="editing = false">취소</button>
</div>
</form>
</div>
+43
View File
@@ -0,0 +1,43 @@
{% if celebrate_streak %}
<div class="celebration-banner" x-data="{ show: true }" x-init="setTimeout(() => show = false, 4000)" x-show="show" x-transition>
🎉 <strong>{{ celebrate_habit_name }}</strong> {{ celebrate_streak }}일 연속 달성! 축하해요!
</div>
{% endif %}
<div
class="card"
style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;"
x-data="{ pushState: 'checking' }"
x-init="habitPush.getSubscriptionState().then(s => pushState = s)"
>
<h2 style="margin:0;">오늘</h2>
<div style="display:flex; align-items:center; gap:8px;">
<span class="badge">{{ checked_count }}/{{ total_count }} 완료</span>
<button
type="button"
class="btn btn-secondary"
x-show="pushState === 'unsubscribed'"
x-cloak
@click="habitPush.subscribePush().then(ok => { if (ok) pushState = 'subscribed'; })"
>알림 켜기</button>
<span class="badge" x-show="pushState === 'subscribed'" x-cloak>🔔 알림 켜짐</span>
</div>
</div>
<h2>형성 습관</h2>
<div class="card">
{% for item in build_items %}
{% include "partials/today_item.html" %}
{% else %}
<div class="empty-state">오늘 예정된 형성 습관이 없어요.</div>
{% endfor %}
</div>
<h2>중단 습관</h2>
<div class="card">
{% for item in quit_items %}
{% include "partials/today_item.html" %}
{% else %}
<div class="empty-state">오늘 예정된 중단 습관이 없어요.</div>
{% endfor %}
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="habit-item{{ ' today-checked' if item.checked }}">
<div class="habit-item-main">
<button
type="button"
class="check-indicator{{ ' checked' if item.checked }}"
hx-post="/today/{{ item.habit_id }}/toggle"
hx-target="#today-content"
hx-swap="innerHTML"
aria-label="{{ item.name }} 체크"
>{% if item.checked %}&#10003;{% endif %}</button>
<div>
<div class="habit-item-name">{{ item.name }}</div>
{% if item.condition_text %}<div class="habit-item-condition">{{ item.condition_text }}</div>{% endif %}
{% if item.reminder_time %}<div class="habit-item-meta">알람 {{ item.reminder_time }}</div>{% endif %}
{% if item.scheduled_days > 0 %}
<div class="habit-item-stats">
<span class="badge">완료율 {{ item.completion_rate }}%</span>
{% if item.current_streak > 0 %}
<span class="badge{{ ' badge-milestone' if is_milestone_streak(item.current_streak) }}">
{{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}오늘 · 습관 트래커{% endblock %}
{% block nav_today %}active{% endblock %}
{% block content %}
<div id="today-content">
{% include "partials/today_content.html" %}
</div>
{% endblock %}