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