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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:06:17 +09:00
co-authored by Claude Sonnet 5
commit cee589bb3e
80 changed files with 4695 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType
from app.schemas.habit import HabitCreate, HabitUpdate
def list_habits(
db: Session, user_id: int, habit_type: HabitType | None = None, status: HabitStatus | None = None
) -> list[Habit]:
stmt = select(Habit).where(Habit.user_id == user_id)
if habit_type is not None:
stmt = stmt.where(Habit.habit_type == habit_type)
if status is not None:
stmt = stmt.where(Habit.status == status)
stmt = stmt.order_by(Habit.sort_order.is_(None), Habit.sort_order, Habit.created_at)
return list(db.scalars(stmt))
def list_active_habits_with_reminders(db: Session) -> list[Habit]:
"""스케줄러 전용: 유저 스코핑 없이 알림 시각이 설정된 전체 active 습관을 반환한다."""
stmt = select(Habit).where(Habit.status == HabitStatus.ACTIVE, Habit.reminder_time.isnot(None))
return list(db.scalars(stmt))
def list_active_user_ids(db: Session) -> list[int]:
"""스케줄러 전용: 유저 스코핑 없이, active 습관을 하나 이상 가진 유저 id 목록을 반환한다(주간/월간 요약 알림 대상)."""
stmt = (
select(Habit.user_id)
.where(Habit.status == HabitStatus.ACTIVE, Habit.user_id.isnot(None))
.distinct()
)
return list(db.scalars(stmt))
def get_habit(db: Session, habit_id: int, user_id: int) -> Habit | None:
return db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id))
def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit:
habit = Habit(
user_id=user_id,
name=data.name,
habit_type=data.habit_type,
weekdays_mask=data.weekdays_mask,
condition_text=data.condition_text,
reminder_time=data.reminder_time,
status=HabitStatus.ACTIVE,
)
db.add(habit)
db.commit()
db.refresh(habit)
return habit
def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit:
habit.name = data.name
habit.habit_type = data.habit_type
habit.weekdays_mask = data.weekdays_mask
habit.condition_text = data.condition_text
habit.reminder_time = data.reminder_time
db.commit()
db.refresh(habit)
return habit
def delete_habit(db: Session, habit: Habit) -> None:
db.delete(habit)
db.commit()
def complete_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.COMPLETED
habit.completed_at = datetime.now()
db.commit()
db.refresh(habit)
return habit
def reactivate_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.ACTIVE
habit.completed_at = None
db.commit()
db.refresh(habit)
return habit
def reorder_habits(db: Session, user_id: int, ordered_ids: list[int]) -> None:
"""ordered_ids에 나온 순서대로 sort_order를 다시 매긴다. 목록에 없는 id나 다른 유저의 habit은 무시한다."""
habits = db.scalars(
select(Habit).where(Habit.id.in_(ordered_ids), Habit.user_id == user_id)
).all()
habit_map = {h.id: h for h in habits}
for index, habit_id in enumerate(ordered_ids):
habit = habit_map.get(habit_id)
if habit is not None:
habit.sort_order = index
db.commit()
+254
View File
@@ -0,0 +1,254 @@
import calendar
from datetime import date, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.schemas.habit_log import HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
from app.services import habit_service, push_service
# 체크 시 축하 푸시/배지를 트리거하는 연속 달성일 마일스톤.
MILESTONE_STREAKS = {7, 30, 66, 100, 200, 365}
def _to_today_item(db: Session, habit: Habit, checked: bool) -> TodayItem:
stats = get_habit_stats(db, habit)
return TodayItem(
habit_id=habit.id,
name=habit.name,
habit_type=habit.habit_type.value,
condition_text=habit.condition_text,
reminder_time=habit.reminder_time.strftime("%H:%M") if habit.reminder_time else None,
checked=checked,
completion_rate=stats.completion_rate,
current_streak=stats.current_streak,
scheduled_days=stats.scheduled_days,
)
def get_today_items(db: Session, user_id: int, target_date: date) -> tuple[list[TodayItem], list[TodayItem]]:
"""오늘 요일에 예정된 active 습관을 형성/중단으로 나누어 체크 여부와 함께 반환한다."""
weekday = target_date.weekday()
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
scheduled = [h for h in active_habits if h.is_scheduled_on(weekday)]
checked_ids: set[int] = set()
habit_ids = [h.id for h in scheduled]
if habit_ids:
checked_ids = set(
db.scalars(
select(HabitLog.habit_id).where(
HabitLog.log_date == target_date, HabitLog.habit_id.in_(habit_ids)
)
)
)
build_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.BUILD]
quit_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.QUIT]
return build_items, quit_items
def toggle_check(db: Session, habit_id: int, log_date: date) -> bool:
"""체크 상태를 반전시키고 토글 후의 체크 여부를 반환한다.
호출측에서 이미 habit_service.get_habit(db, habit_id, user_id)로 소유권을 검증한 뒤에만 불러야 한다.
"""
existing = db.scalar(select(HabitLog).where(HabitLog.habit_id == habit_id, HabitLog.log_date == log_date))
if existing:
db.delete(existing)
db.commit()
return False
db.add(HabitLog(habit_id=habit_id, log_date=log_date))
db.commit()
return True
def toggle_check_and_celebrate(db: Session, habit: Habit, log_date: date) -> tuple[bool, int | None]:
"""체크를 토글하고, 새로 체크되어 스트릭이 마일스톤에 도달했으면 축하 푸시를 보낸다.
반환값: (checked, milestone_streak). milestone_streak은 이번 토글로 막 달성한 마일스톤 값이면 그 값,
체크 해제거나 마일스톤이 아니면 None.
"""
checked = toggle_check(db, habit.id, log_date)
if not checked:
return checked, None
streak = get_habit_stats(db, habit).current_streak
if streak not in MILESTONE_STREAKS:
return checked, None
if habit.user_id is not None:
push_service.send_to_user(
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
)
return checked, streak
def list_logs(
db: Session, user_id: int, habit_id: int | None = None, start: date | None = None, end: date | None = None
) -> list[HabitLog]:
stmt = select(HabitLog).join(Habit, HabitLog.habit_id == Habit.id).where(Habit.user_id == user_id)
if habit_id is not None:
stmt = stmt.where(HabitLog.habit_id == habit_id)
if start is not None:
stmt = stmt.where(HabitLog.log_date >= start)
if end is not None:
stmt = stmt.where(HabitLog.log_date <= end)
stmt = stmt.order_by(HabitLog.log_date)
return list(db.scalars(stmt))
def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end: date) -> dict[date, int]:
if not habit_ids:
return {}
rows = db.execute(
select(HabitLog.log_date, func.count(HabitLog.id))
.where(HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(start, end))
.group_by(HabitLog.log_date)
).all()
return {row[0]: row[1] for row in rows}
def get_monthly_summary(db: Session, user_id: int, year: int, month: int) -> list[MonthlySummaryDay]:
"""해당 월의 날짜별 예정 습관 수 / 체크된 습관 수를 집계한다 (현재 active 습관 기준)."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], first_day, last_day)
summaries = []
for day_num in range(1, days_in_month + 1):
d = date(year, month, day_num)
# 습관이 생성되기 전 날짜는 "예정되었지만 안 함"으로 잘못 잡히지 않도록 제외한다.
scheduled = sum(1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday()))
summaries.append(
MonthlySummaryDay(log_date=d, scheduled_count=scheduled, checked_count=checked_counts.get(d, 0))
)
return summaries
def summarize_completion_rate(summaries: list[MonthlySummaryDay], up_to: date) -> float:
"""월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다.
아직 지나지 않은 미래 날짜는 scheduled_count는 있어도 checked_count가 항상 0이라
포함시키면 완료율이 부당하게 낮아지므로 제외한다.
"""
past = [s for s in summaries if s.log_date <= up_to]
total_scheduled = sum(s.scheduled_count for s in past)
total_checked = sum(s.checked_count for s in past)
return round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
def get_period_completion_rate(db: Session, user_id: int, start: date, end: date) -> tuple[float, int, int]:
"""[start, end] 구간(양끝 포함)의 예정/체크 수를 집계해 완료율(%)과 함께 반환한다.
get_monthly_summary와 같은 규칙(현재 active 습관 기준, 습관 생성일 이전 제외)을 임의 기간에
적용한 버전 — 주간 요약 알림처럼 달력 월 경계에 안 맞는 기간을 집계할 때 쓴다.
"""
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], start, end)
total_scheduled = 0
total_checked = 0
d = start
while d <= end:
total_scheduled += sum(
1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday())
)
total_checked += checked_counts.get(d, 0)
d += timedelta(days=1)
rate = round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
return rate, total_scheduled, total_checked
def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
"""습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다.
요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다
(과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화).
"""
today = date.today()
start = habit.created_at.date()
checked_dates = set(db.scalars(select(HabitLog.log_date).where(HabitLog.habit_id == habit.id)))
scheduled_days = 0
checked_days = 0
d = start
while d <= today:
if habit.is_scheduled_on(d.weekday()):
scheduled_days += 1
if d in checked_dates:
checked_days += 1
d += timedelta(days=1)
completion_rate = round(checked_days / scheduled_days * 100, 1) if scheduled_days else 0.0
streak = 0
d = today
while d >= start:
if habit.is_scheduled_on(d.weekday()):
if d in checked_dates:
streak += 1
elif d != today:
break
# d가 오늘이고 아직 체크 전이면: 하루가 아직 안 끝났으니 스트릭을 끊지 않고 계속 거슬러 올라간다.
d -= timedelta(days=1)
return HabitStats(
completion_rate=completion_rate,
current_streak=streak,
scheduled_days=scheduled_days,
checked_days=checked_days,
)
def get_weekly_matrix(db: Session, user_id: int, week_start: date) -> list[WeeklyMatrixRow]:
"""week_start(호출측에서 정한 주 시작일, 현재 /history는 일요일을 사용)부터 7일간,
active 습관별 요일 체크 매트릭스를 반환한다. 이 함수 자체는 week_start가 어떤 요일이든 상관없다."""
week_days = [week_start + timedelta(days=i) for i in range(7)]
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
habit_ids = [h.id for h in active_habits]
checked_pairs: set[tuple[int, date]] = set()
if habit_ids:
rows = db.execute(
select(HabitLog.habit_id, HabitLog.log_date).where(
HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(week_days[0], week_days[-1])
)
).all()
checked_pairs = {(r[0], r[1]) for r in rows}
today = date.today()
result = []
for h in active_habits:
checks: dict[str, bool | None] = {}
scheduled_past = 0
checked_past = 0
for d in week_days:
# 습관이 생성되기 전 날짜는 요일이 맞아도 "예정 없음"으로 취급한다.
if d < h.created_at.date() or not h.is_scheduled_on(d.weekday()):
checks[d.isoformat()] = None
continue
is_checked = (h.id, d) in checked_pairs
checks[d.isoformat()] = is_checked
if d <= today:
scheduled_past += 1
if is_checked:
checked_past += 1
completion_rate = round(checked_past / scheduled_past * 100, 1) if scheduled_past else 0.0
result.append(
WeeklyMatrixRow(
habit_id=h.id,
name=h.name,
habit_type=h.habit_type.value,
checks=checks,
completion_rate=completion_rate,
)
)
return result
+69
View File
@@ -0,0 +1,69 @@
import json
from pywebpush import WebPushException, webpush
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.push_subscription import PushSubscription
from app.schemas.push import PushSubscribeRequest
def list_subscriptions(db: Session, user_id: int) -> list[PushSubscription]:
return list(db.scalars(select(PushSubscription).where(PushSubscription.user_id == user_id)))
def save_subscription(
db: Session, user_id: int, data: PushSubscribeRequest, user_agent: str | None = None
) -> PushSubscription:
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint))
if existing:
existing.user_id = user_id # 같은 기기에서 다른 유저가 재구독하면 소유자를 갱신한다.
existing.p256dh_key = data.keys.p256dh
existing.auth_key = data.keys.auth
db.commit()
return existing
sub = PushSubscription(
user_id=user_id,
endpoint=data.endpoint,
p256dh_key=data.keys.p256dh,
auth_key=data.keys.auth,
user_agent=user_agent,
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
def delete_subscription(db: Session, endpoint: str) -> None:
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
if existing:
db.delete(existing)
db.commit()
def send_to_user(db: Session, user_id: int, title: str, body: str, url: str = "/today") -> int:
"""해당 유저의 구독자에게만 알림을 보낸다. 만료된(410/404) 구독은 자동으로 삭제한다. 성공 발송 건수를 반환."""
payload = json.dumps({"title": title, "body": body, "url": url}, ensure_ascii=False)
sent = 0
for sub in list_subscriptions(db, user_id):
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
},
data=payload,
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
)
sent += 1
except WebPushException as exc:
status_code = exc.response.status_code if exc.response is not None else None
if status_code in (404, 410):
db.delete(sub)
db.commit()
# 그 외 오류(일시적 네트워크 문제 등)는 건너뛰고 다음 구독자에게 계속 발송한다.
return sent
+180
View File
@@ -0,0 +1,180 @@
import logging
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import settings
from app.database import SessionLocal
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.services import habit_service, log_service, push_service
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler(timezone=settings.timezone)
def _claim_notification_slot(db, habit_id: int, notify_date) -> bool:
"""habit_notification_log에 (habit_id, notify_date) 행을 먼저 "선점"한다.
(habit_id, notify_date) 유니크 제약을 경합 방지용 락으로 쓴다 — reload로 겹친 워커나
APScheduler가 중복 기동된 상황에서도 두 프로세스가 동시에 같은 알림을 보내지 않도록,
실제 발송 전에 먼저 이 행을 커밋해서 선점에 성공한 쪽만 발송하게 한다.
"""
db.add(HabitNotificationLog(habit_id=habit_id, notify_date=notify_date))
try:
db.commit()
return True
except IntegrityError:
db.rollback()
return False
def _claim_summary_slot(db: Session, user_id: int, period_type: str, period_start: date) -> bool:
"""summary_notification_log에 (user_id, period_type, period_start) 행을 선점한다.
_claim_notification_slot과 같은 목적 — 리로드로 겹친 워커나 중복 기동된 스케줄러가
같은 주간/월간 요약을 두 번 보내지 않도록 유니크 제약을 경합 방지 락으로 쓴다.
"""
db.add(SummaryNotificationLog(user_id=user_id, period_type=period_type, period_start=period_start))
try:
db.commit()
return True
except IntegrityError:
db.rollback()
return False
def _tick() -> None:
"""매분 실행되어, 지금 이 순간이 알람 시각+요일에 맞는 active 습관에 대해
오늘 아직 안 보낸 알림만 골라 발송한다."""
db = SessionLocal()
try:
now = datetime.now(ZoneInfo(settings.timezone))
today = now.date()
weekday = today.weekday()
habits = habit_service.list_active_habits_with_reminders(db)
for habit in habits:
if habit.user_id is None or not habit.is_scheduled_on(weekday):
continue
if habit.reminder_time.hour != now.hour or habit.reminder_time.minute != now.minute:
continue
already_sent = db.scalar(
select(HabitNotificationLog).where(
HabitNotificationLog.habit_id == habit.id,
HabitNotificationLog.notify_date == today,
)
)
if already_sent:
continue
try:
claimed = _claim_notification_slot(db, habit.id, today)
if not claimed:
continue
push_service.send_to_user(
db, habit.user_id, title=habit.name, body="지금 실천할 시간이에요", url="/today"
)
except Exception:
logger.exception("습관(id=%s) 알림 발송 중 오류", habit.id)
db.rollback()
except Exception:
logger.exception("습관 알림 tick 처리 중 오류")
finally:
db.close()
def _send_period_summaries(
db: Session, *, period_type: str, period_start: date, range_start: date, range_end: date, title: str, url: str
) -> None:
"""유저별로 [range_start, range_end] 완료율을 계산해 요약 푸시를 보낸다 (주간/월간 tick 공통 로직).
예정된 습관이 하나도 없던(scheduled == 0) 유저에게는 의미 없는 알림을 보내지 않고 건너뛴다.
"""
for user_id in habit_service.list_active_user_ids(db):
rate, scheduled, checked = log_service.get_period_completion_rate(db, user_id, range_start, range_end)
if scheduled == 0:
continue
try:
if not _claim_summary_slot(db, user_id, period_type, period_start):
continue
push_service.send_to_user(
db, user_id, title=title, body=f"완료율 {rate}% ({checked}/{scheduled})이에요.", url=url
)
except Exception:
logger.exception("유저(id=%s) %s 요약 알림 발송 중 오류", user_id, period_type)
db.rollback()
def _weekly_summary_tick() -> None:
"""매주 일요일 21시에 실행되어, 이번 주(월요일~오늘)의 완료율을 유저별로 요약해 발송한다."""
db = SessionLocal()
try:
today = datetime.now(ZoneInfo(settings.timezone)).date()
week_start = today - timedelta(days=today.weekday())
_send_period_summaries(
db,
period_type="weekly",
period_start=week_start,
range_start=week_start,
range_end=today,
title="이번 주 습관 리포트",
url=f"/history?view=week&start={week_start.isoformat()}",
)
except Exception:
logger.exception("주간 요약 tick 처리 중 오류")
finally:
db.close()
def _monthly_summary_tick() -> None:
"""매월 마지막 날 21:30에 실행되어, 이번 달(1일~오늘)의 완료율을 유저별로 요약해 발송한다."""
db = SessionLocal()
try:
today = datetime.now(ZoneInfo(settings.timezone)).date()
month_start = today.replace(day=1)
_send_period_summaries(
db,
period_type="monthly",
period_start=month_start,
range_start=month_start,
range_end=today,
title="이번 달 습관 리포트",
url=f"/history?view=month&year={today.year}&month={today.month}",
)
except Exception:
logger.exception("월간 요약 tick 처리 중 오류")
finally:
db.close()
def start_scheduler() -> None:
scheduler.add_job(_tick, "cron", minute="*", id="habit_reminder_tick", replace_existing=True)
scheduler.add_job(
_weekly_summary_tick,
"cron",
day_of_week="sun",
hour=21,
minute=0,
id="weekly_summary_tick",
replace_existing=True,
)
scheduler.add_job(
_monthly_summary_tick,
"cron",
day="last",
hour=21,
minute=30,
id="monthly_summary_tick",
replace_existing=True,
)
scheduler.start()
def shutdown_scheduler() -> None:
scheduler.shutdown(wait=False)