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:
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PushKeys(BaseModel):
|
||||
p256dh: str
|
||||
auth: str
|
||||
|
||||
|
||||
class PushSubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: PushKeys
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 |
@@ -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");
|
||||
};
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>홈 화면에 추가하면 알림도 받을 수 있어요. 공유 버튼 → ‘홈 화면에 추가’를 눌러보세요.</span>
|
||||
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">×</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>
|
||||
@@ -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 %}
|
||||
@@ -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">✓</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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}✓{% 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>
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user