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
+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)