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
+13
View File
@@ -0,0 +1,13 @@
.env
.git
.gitignore
.idea
__pycache__/
**/__pycache__/
*.pyc
.pytest_cache/
logs/
node_modules/
.venv/
venv/
*.egg-info/
+22
View File
@@ -0,0 +1,22 @@
# 이 파일을 복사해 .env로 저장한 뒤 값을 채워넣으세요. .env는 git에 커밋되지 않습니다.
# 세션 쿠키 서명에 쓰이는 임의의 긴 무작위 문자열 (예: python -c "import secrets; print(secrets.token_hex(32))")
SECRET_KEY=change-me
# 원격 MariaDB 서버 접속 정보
# 형식: mysql+pymysql://<user>:<password>@<host>:<port>/<database>
DATABASE_URL=mysql+pymysql://habit_tracker:password@your-db-host:3306/habit_tracker
# 구글 로그인용 OAuth 클라이언트 정보.
# https://console.cloud.google.com/apis/credentials 에서 "OAuth 클라이언트 ID"(웹 애플리케이션)를
# 만들고, "승인된 리디렉션 URI"에 GOOGLE_REDIRECT_URI와 정확히 같은 값을 등록하세요.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# 로컬 개발: http://localhost:8000/auth/google/callback
# 배포(Tailscale HTTPS 등): https://<host>.<tailnet>.ts.net/auth/google/callback
GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
# Web Push용 VAPID 키 (scripts/generate_vapid_keys.py로 생성)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.pyc
.env
.env.dev
.venv/
venv/
*.egg-info/
.pytest_cache/
.idea/
*.iml
node_modules/
logs/
img*.png
image.md
+100
View File
@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
개인용 습관 관리 PWA (`habit-tracker`). 만들고 싶은 습관 / 끊고 싶은 습관을 요일 단위로 관리하고, 데일리 체크와 월별/주별 기록 확인, Web Push 알람을 제공한다. 아이폰(홈 화면에 추가)과 PC 브라우저 양쪽에서 같은 서버에 접속해 데이터를 공유하는 구조. 상세 설계는 최초 구현 시 작성된 계획 문서를 참고 (요구사항, 마일스톤, 디자인 토큰 등).
Python 3.13, conda 환경 이름은 `py_web` (`.iml`의 SDK 이름과 동일).
## Commands
```bash
conda activate py_web
pip install -e . # 의존성 설치 (pyproject.toml)
alembic upgrade head # DB 마이그레이션 적용
alembic revision -m "설명" # 새 마이그레이션 추가 (모델 변경 시 수동 작성 권장 — MariaDB 접속 없이 autogenerate가 안 되는 경우가 있음)
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 # 개발 서버 실행 (반드시 저장소 루트에서 실행 — templates/static 상대경로 의존)
pytest # 전체 테스트
pytest tests/test_habits.py::test_name # 단일 테스트
```
`.env`가 없으면 `app/config.py``Settings()`가 곧바로 실패한다 (`SECRET_KEY`, `DATABASE_URL` 필수). `.env.example`을 복사해서 채울 것. `DATABASE_URL`의 비밀번호에 `@`, `%` 같은 특수문자가 있으면 반드시 URL-encode해야 한다 (`urllib.parse.quote(pw, safe='')`) — 그렇지 않으면 SQLAlchemy가 자격증명/호스트 구분을 잘못 파싱한다.
구글 로그인을 쓰려면 `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/`GOOGLE_REDIRECT_URI`를 채워야 한다(`.env.example` 주석 참고, Google Cloud Console에서 OAuth 클라이언트를 만들고 `GOOGLE_REDIRECT_URI`와 정확히 같은 값을 "승인된 리디렉션 URI"에 등록). VAPID 키는 `scripts/generate_vapid_keys.py`로 생성.
## Architecture
- **백엔드**: FastAPI + SQLAlchemy 2.0 (declarative) + Alembic + MariaDB(PyMySQL 드라이버). 인증은 구글 OAuth(`authlib`) 기반 멀티유저 구조다 — `User` 테이블(`google_sub`/`email` 등)에 로그인한 계정이 저장되고, 성공 시 `itsdangerous`로 서명한 세션 쿠키(`{"user_id": ...}`)를 발급한다(`app/security.py`, `app/routers/auth.py`). 아무 구글 계정이나 로그인하면 자동으로 새 계정이 생성된다(화이트리스트 없음). `Habit`/`PushSubscription``user_id`(nullable FK)로 소유자가 갈린다 — 서비스 계층(`habit_service`, `log_service`, `push_service`) 함수는 거의 전부 `user_id`를 필수 인자로 받아 필터링한다(IDOR 방지). 예외는 스케줄러 전용 `habit_service.list_active_habits_with_reminders`뿐 — 전체 유저를 순회해야 하므로 유저 스코핑이 없고, 그래서 이름에 의도를 명시했다.
- **프론트엔드**: 서버사이드 렌더링(Jinja2) + htmx(폼 제출/부분 갱신) + Alpine.js(요일 토글, 알람 입력 등 클라이언트 UI 상태). React 등 SPA 프레임워크 없음. htmx/Alpine은 CDN이 아니라 `app/static/js/vendor/`에 로컬 vendoring된 파일을 사용 (PWA 오프라인 캐싱을 CDN 의존 없이 동작시키기 위함).
- **디자인 시스템**: `app/static/css/style.css`에 CSS 커스텀 프로퍼티로 정의된 Claude.ai 톤의 디자인 토큰(크림 배경 + 테라코타 포인트 컬러, 라이트/다크 모드는 `prefers-color-scheme` 기반). 새 화면을 추가할 때는 여기 정의된 토큰과 기존 컴포넌트 클래스(`.card`, `.btn-*`, `.weekday-pill`, `.habit-item` 등)를 재사용할 것.
- **네비게이션 (상단 탭 + 모바일 하단 탭바 이중 구조)**: `app/templates/base.html``.top-nav`(오늘/습관 관리/기록 링크)는 넓은 화면 전용이고, 같은 3개 링크를 `.bottom-tab-bar`로 한 번 더 렌더링해서 `max-width: 480px`에서만 `.top-nav .nav-links`를 숨기고 `.bottom-tab-bar`를 고정 하단바로 노출한다(`app/static/css/style.css`). "활성 탭" 표시(`{% block nav_today %}` 등, 각 페이지 템플릿이 `active`로 오버라이드)를 두 네비게이션이 공유해야 해서, 하단 탭바 쪽은 블록을 다시 정의하지 않고 `{{ self.nav_today() }}`로 이미 정의된 블록의 렌더링 결과를 재사용한다 — 새 페이지를 추가할 때 `nav_today`/`nav_habits`/`nav_history` 블록 중 하나를 오버라이드하면 상단/하단 양쪽에 자동으로 반영된다. `.app-shell``padding-bottom`(하단바 높이 + `env(safe-area-inset-bottom)`)을 줘서 콘텐츠가 하단바에 가려지지 않게 했다.
### 요청 흐름 (2가지 라우터 계열이 공존)
- `app/routers/habits.py`, `app/routers/logs.py`, `app/routers/push.py``/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환.
- `app/routers/pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)``User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님).
- `app/routers/auth.py``/auth/*`, 페이지 네비게이션(리다이렉트/폼 POST)이라 `/api/*` 프리픽스를 쓰지 않는다. `GET /auth/google/login`이 구글 동의 화면으로 리다이렉트하고, `GET /auth/google/callback``authlib`로 id_token을 검증해 `User`를 조회/생성한 뒤 세션 쿠키를 발급, `POST /auth/logout`이 쿠키를 지운다. OAuth 핸드셰이크 중 state/nonce를 담는 `SessionMiddleware`(`app/main.py`)는 로그인 유지용 쿠키(`habit_session`)와 별개의 임시 쿠키(`oauth_session`)를 쓴다.
두 계열이 같은 `app/services/*`, `app/schemas/*`를 공유한다 — 새 기능을 추가할 때 API와 페이지 라우터 양쪽에서 비즈니스 로직을 중복 구현하지 말고 `app/services/`에 두고 재사용할 것.
### 데이터 모델 (`app/models/`)
- `User`: `google_sub`/`email` unique, `name`/`picture_url` nullable, 구글 로그인 시 조회/생성(`app/routers/auth.py``google_callback`).
- `Habit`: `user_id`(nullable FK→`user.id`, ondelete=CASCADE — nullable인 이유는 PIN 시절 데이터 이관 때문, 아래 "구글 OAuth 전환" 참고), `habit_type`(build/quit), `status`(active/completed), `weekdays_mask`는 비트마스크(bit0=월…bit6=일, Python `date.weekday()`와 동일한 인덱스, `Habit.is_scheduled_on(weekday)`로 조회), `condition_text`(달성 조건, nullable, 빈 문자열은 `HabitBase.blank_condition_to_none` 검증기가 자동으로 None으로 변환), `reminder_time`은 nullable.
- `HabitLog`: "행이 존재하면 그 날 체크 완료"라는 설계 — 별도 boolean 컬럼 없음. `(habit_id, log_date)` unique. 체크 해제는 행 삭제. 유저 스코핑은 `Habit`을 조인해서 한다(`log_service.list_logs`).
- `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작).
### 통계 (완료율 / 연속 달성일)
`log_service.get_habit_stats(db, habit)``/habits` 목록과 `/today` 체크리스트 양쪽에서 습관마다 배지로 표시된다(`/today``TodayItem``completion_rate`/`current_streak`/`scheduled_days`를 직접 포함시켜 `log_service._to_today_item`이 습관마다 `get_habit_stats`를 호출한다). 요일 스케줄은 **현재의** `weekdays_mask`를 습관 생성일부터 오늘까지 그대로 적용한 것으로 계산한다 — 과거에 요일을 바꾼 이력은 추적하지 않는다(월별/주별 집계와 같은 단순화 원칙). `current_streak`은 오늘부터 거슬러 올라가되, **오늘 아직 체크 안 한 것은 스트릭을 끊지 않는다**(하루가 아직 안 끝났으므로) — 오늘보다 이전 날짜의 미체크만 스트릭을 끊는다.
### 습관 순서 드래그 재정렬
`/habits` 목록은 `app/static/js/vendor/sortable.min.js`(SortableJS, 로컬 vendoring)로 드래그 재정렬을 지원한다. **`forceFallback: true`가 필수**다 — iOS Safari는 네이티브 HTML5 Drag and Drop의 터치 지원이 불안정해서, SortableJS 자체 포인터 이벤트 기반 폴백을 강제하지 않으면 아이폰에서 드래그가 아예 안 먹힌다(`app/static/js/habit-reorder.js`). 각 `habit_item.html``.drag-handle`(⠿ 아이콘)만 드래그를 시작할 수 있게 `handle` 옵션으로 제한했다 — 그래야 수정/완료/삭제 버튼 클릭이 드래그와 충돌하지 않는다. 드롭이 끝나면(`onEnd`) `#habit-list`의 현재 DOM 순서 그대로 `POST /api/habits/reorder`로 보내 `Habit.sort_order`를 일괄 갱신한다(`habit_service.reorder_habits`). `sort_order``NULL`인 습관(한 번도 재정렬 안 됨)은 항상 뒤로 밀려서(`list_habits``ORDER BY sort_order IS NULL, sort_order, created_at`) 새로 추가한 습관이 자동으로 맨 뒤에 붙는다.
**테스트 관련 주의**: 이 harness의 브라우저 자동화는 실제 드래그 제스처(연속된 포인터 이동)를 합성 이벤트로 재현하지 못한다 — `left_click_drag`, 합성 `MouseEvent`, 합성 `PointerEvent` 세 가지 방식 모두 SortableJS의 폴백 드래그를 트리거하지 못했다. 이 기능을 만질 때는 재정렬 로직 자체(`POST /api/habits/reorder`, `habit_service.reorder_habits`)는 curl로 직접 검증하고, 실제 드래그 제스처 UX는 사람이 진짜 기기(마우스 또는 터치)로 확인해야 한다.
`/history`에도 완료율이 있다 — 월별 뷰 상단에 "이번 달 완료율" 배지, 주별 매트릭스에 습관별 "완료율" 열. 두 곳 모두 계산 시 **오늘 이후(미래) 날짜는 제외**하고(`summarize_completion_rate``up_to` 파라미터, 주별은 `d <= today` 체크) **습관 생성일 이전 날짜도 제외**한다(`get_monthly_summary`/`get_weekly_matrix`에서 `h.created_at.date() <= d` 비교) — 이 두 필터가 없으면 "아직 시작 안 한 습관"과 "아직 안 지난 미래"가 전부 "예정됐지만 안 함"으로 잡혀 완료율이 실제보다 크게 낮게 나온다(실제로 이 버그로 6.2%가 나왔다가 고친 뒤 50%가 된 사례가 있었음 — 새로 비슷한 집계를 추가할 때 같은 함정을 주의).
### 마이그레이션
`migrations/env.py``alembic.ini`의 configparser 보간을 우회하고 `settings.database_url`을 직접 엔진 생성에 사용한다 — 비밀번호에 `%`가 포함되면 `config.set_main_option`이 interpolation 에러를 내기 때문. 마이그레이션을 새로 작성할 때 이 패턴을 건드리지 말 것. 원격 MariaDB만 사용하므로 `alembic revision --autogenerate`는 항상 실제 DB 접속이 필요하다 — 접속이 안 되는 환경에서는 `migrations/versions/0001_initial.py`처럼 수동으로 `op.create_table` 스크립트를 작성하는 것도 방법.
## 개발 단계
1. ✅ 기반 셋업 + 습관 CRUD (`/habits` 화면, PIN 로그인 — 7단계에서 구글 OAuth로 대체됨)
2. ✅ 데일리 체크 & `/today` 화면 — `/habits`와 달리 여기는 실제 htmx 부분 갱신을 쓴다: 체크 버튼이 `#today-content`를 통째로 `partials/today_content.html`로 교체한다(개별 아이템만 스왑하지 않는 이유: 진행률 배지도 같이 갱신해야 해서). 이 partial은 `today.html`의 최초 렌더와 토글 응답에서 동일하게 재사용된다 (`app/routers/pages.py``_today_context`).
3. ✅ 기록 확인 화면 (월별/주별) — `/history`. 두 뷰 모두 순수 SSR(링크 기반 이전/다음 네비게이션)이고 htmx 상호작용은 없다. 월별 집계와 주별 매트릭스는 모두 **현재 active 상태인 습관만** 기준으로 계산한다(`app/services/log_service.py``get_monthly_summary`/`get_weekly_matrix`) — 완료 처리되었거나 삭제된 습관은 과거 날짜라도 집계에서 빠진다. 즉 "그 날 실제로 무엇이 예정되어 있었는가"를 재구성하지 않고 "지금 진행 중인 습관 기준으로 최근 기록이 어떤지"를 보여주는 단순화된 설계다. 히트맵 투명도는 `app/template_utils.py``heatmap_opacity()`가 계산해 `--color-accent-rgb` CSS 변수와 조합한다.
4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘) — `manifest.json``/static/manifest.json`에 있고 `base.html``/static/manifest.json`으로 직접 링크한다(루트 경로 라우트 아님, manifest는 자체 `scope` 필드로 범위를 지정하므로 파일 위치가 중요하지 않음). 반면 `service-worker.js`는 앱 전체를 커버해야 해서 `app/main.py``GET /service-worker.js``app/static/service-worker.js`를 루트 경로로 직접 서빙한다 — 이 둘의 서빙 방식이 다른 이유이니 헷갈리지 말 것. 아이콘은 `scripts/generate_icons.py`(Pillow 필요, 런타임 의존성 아님)로 생성.
- **캐싱 전략 (중요)**: 처음엔 모든 GET을 stale-while-revalidate로 캐싱했는데, `/habits`·`/today`처럼 사용자가 직접 데이터를 바꾸고 곧바로 재방문하는 페이지에서 "방금 저장한 게 사라진 것처럼" 보이는 실사용 버그로 이어졌다(습관 생성/수정 후 `HX-Redirect`로 재이동했을 때 캐시된 옛 페이지가 먼저 뜸). `service-worker.js`의 fetch 핸들러는 이제 `request.mode === "navigate"`(페이지 탐색)와 그 외(정적 자산)를 분리한다 — 탐색은 **네트워크 우선**(오프라인일 때만 캐시 폴백), 정적 자산만 캐시 우선 stale-while-revalidate 유지. 서비스워커 캐시 로직을 바꿀 때마다 `CACHE_NAME` 버전을 올려야 `activate` 핸들러가 구버전 캐시를 정리한다(현재 `habit-tracker-v2`).
5. ✅ Web Push 알림 (APScheduler 매분 tick + VAPID) — VAPID 키 원시 바이트는 `py_vapid``b64urlencode`로 직접 인코딩해서 `.env`에 저장한다(`Vapid02``public_key_str` 같은 헬퍼가 없음, `scripts/generate_vapid_keys.py` 참고). `scheduler_service._tick()`은 습관별 job을 등록/해제하는 대신 **매분 폴링** 방식으로 전체 active 습관을 훑어 지금 시각+요일이 맞고 오늘 아직 `habit_notification_log`에 없는 것만 발송한다 — 습관 CRUD와 스케줄러 job을 동기화할 필요가 없어서 이 방식을 택함. `push_service.send_to_user(db, user_id, ...)`(멀티유저 전환 전에는 `send_to_all`이었음)는 만료된 구독(404/410)만 자동 삭제하고 다른 오류는 무시하고 다음 구독자로 넘어간다(한 구독자 오류가 전체 발송을 막지 않도록). 프론트엔드 구독 흐름은 `app/static/js/push-register.js`(`window.habitPush`), `/today` 카드에 "알림 켜기" 버튼으로 노출. **주의**: Chrome 네이티브 알림 권한 프롬프트는 브라우저 크롬 UI 영역이라 자동화 도구로 클릭할 수 없다 — 구독 저장/발송/스케줄러 로직은 curl과 직접 함수 호출로 검증했지만, 실제 브라우저 권한 승인 → 진짜 푸시 수신까지의 마지막 단계는 사람이 직접 확인해야 한다.
- **경합 방지**: `_tick()`은 발송 전에 `habit_notification_log`에 먼저 커밋해 "선점"하고(`_claim_notification_slot`), `(habit_id, notify_date)` 유니크 제약을 경합 방지 락처럼 쓴다 — 선점에 실패(`IntegrityError`)하면 이미 다른 워커/틱이 처리한 것으로 보고 조용히 건너뛴다. 이 앱은 `uvicorn --reload`로 개발 중 실행하는 경우가 많은데, **포트가 겹친 채로 오래된 워커 프로세스가 안 죽고 새 프로세스와 동시에 떠 있으면 각자 스케줄러를 따로 띄워서 같은 알림을 동시에 두 번 기록하려다 죽는 사고**가 실제로 있었다(웹 요청도 임의로 둘 중 한 프로세스로 라우팅되어 "가끔 옛날 코드로 응답"하는 것처럼 보이는 증상과 세트로 나타남). 이상 동작이 보이면 먼저 `Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue`(PowerShell, `netstat`보다 정확함)로 실제 리스너가 1개인지 확인하고, 여러 개면 관련 `python.exe`/`uvicorn.exe`를 모두 정리한 뒤 하나만 새로 띄울 것.
6. ✅ 다듬기 & Windows 상시 실행 등록 — 여러 개선을 진행했다:
- **HTTPS 문제**: 서비스워커/Web Push는 보안 컨텍스트(HTTPS 또는 `localhost`)에서만 동작하는데, 아이폰이 접속하는 `http://<LAN IP>:8000`은 iOS Safari에서 보안 컨텍스트로 인정되지 않아 푸시가 동작하지 않는다. Tailscale의 `tailscale serve --bg 8000`으로 해결 — 별도 인증서 관리 없이 tailnet 내에서 신뢰된 HTTPS(`https://<PC>.<tailnet>.ts.net`)를 제공한다(README "HTTPS로 접속하기" 참고).
- **폼 검증 UX**: `POST /habits/new`에서 `name: str = Form(...)`(필수)로 두면 빈 문자열 제출 시 FastAPI 자체 검증이 `HabitCreate`의 커스텀 검증보다 먼저 걸려 못생긴 JSON 422가 나온다 — `Form("")`(기본값 빈 문자열)로 바꿔 항상 우리 쪽 `HabitCreate` 검증까지 도달하게 해야 친절한 한글 에러 메시지(`app/routers/pages.py``create_habit_page`)가 나간다. 에러는 `hx-target="#habit-form-error-{habit_type}"`로 폼 내부에 표시된다.
- **에러 페이지**: `app/main.py``StarletteHTTPException`(404를 페이지 경로에서만 스타일링, `/api/`·`/static/`은 JSON 유지)과 전역 `Exception` 핸들러(트레이스백은 서버 로그에만, 사용자에게는 `500.html`) 등록.
- **반응형**: 이 환경의 브라우저 자동화 도구는 `resize_window`가 실제 뷰포트에 반영되지 않는 문제가 있어, 실제 창 크기를 바꾸는 대신 `<iframe>`을 임의 픽셀 크기로 만들어 그 안에서 페이지를 로드하는 방식으로 좁은 뷰포트를 재현해 검증했다(iframe은 자신만의 진짜 `window.innerWidth`를 가지므로 media query가 실제로 다르게 평가됨). 이 과정에서 긴 습관 이름 + 액션 버튼이 있는 `.habit-item`이 줄바꿈되지 않고 버튼 텍스트가 세로로 쪼개지는 문제를 발견 — `.btn``white-space: nowrap``flex-shrink: 0`이 빠져있었던 게 원인. `.habit-item``flex-wrap: wrap`을 추가해 좁은 화면에서 액션 버튼이 이름 아래 줄로 자연스럽게 내려가도록 수정.
- **상시 실행**: `scripts/run_server.ps1` — conda activate 대신 대상 환경의 `python.exe`를 직접 호출(예약 작업은 인터랙티브 셸이 아니므로). **주의**: 이 스크립트에서 `$ErrorActionPreference = "Stop"`을 네이티브 프로세스의 `*>>` 스트림 리다이렉션과 같이 쓰면 안 된다 — PowerShell 5.1은 리다이렉션된 stderr의 각 줄(uvicorn의 정상 INFO 로그 포함)을 `NativeCommandError`로 감싸는데, `-Stop`이 걸려있으면 첫 로그 줄에서 즉시 스크립트가 종료돼 서버가 바로 죽는다. 반드시 `ErrorActionPreference`를 기본값(`Continue`)으로 둘 것. 작업 스케줄러 등록 명령은 README "상시 실행 (Windows)" 참고.
7. ✅ 구글 OAuth 로그인 + 진짜 멀티유저 전환 — PIN 로그인(`APP_PIN_HASH`, `scripts/hash_pin.py`)을 완전히 제거하고 구글 로그인만 남겼다. 화이트리스트 없이 아무 구글 계정이나 로그인하면 자동으로 `User` 행이 생성된다(1단계에서 언급한 "PIN 로그인"은 이제 존재하지 않음).
- **왜 유저 테이블을 nullable FK로 연결했나**: `docker-entrypoint.sh`가 컨테이너 기동마다 자동으로 `alembic upgrade head`를 돌리는데, "먼저 구글 로그인을 해야 User가 생긴다"는 순서와 "마이그레이션은 무인 자동 실행"이 충돌한다. `Habit.user_id`/`PushSubscription.user_id`를 NOT NULL로 강제하지 않고 nullable FK로 둬서 이 문제를 피했다 — 기존 PIN 시절 데이터는 마이그레이션 후 `user_id IS NULL`인 채로 남고, 배포자가 구글로 한 번 로그인한 뒤 `python scripts/claim_orphan_habits.py <이메일>`을 실행해 자신에게 연결한다(README "기존 데이터 이관" 참고). `PushSubscription`은 이관 스크립트가 안 건드린다 — 브라우저가 재구독하면 `push_service.save_subscription`이 기존 endpoint 행의 `user_id`를 자동으로 최신 로그인 유저로 갱신하기 때문에 자연스럽게 새 유저에게 붙는다.
- **IDOR 방지**: 전환 전에는 `habit_service.get_habit(db, habit_id)`가 PK만으로 조회해서 다른 유저의 habit_id를 넣어도 접근 가능한 구멍이었다. 지금은 `habit_service`/`log_service`/`push_service`의 거의 모든 함수가 `user_id`를 필수 인자로 받아 `WHERE user_id = ...`로 필터링한다. 새 서비스 함수를 추가할 때 이 패턴을 깨지 말 것 — 스코핑 안 된 조회 함수를 실수로 API에 노출하면 바로 크로스 유저 데이터 유출이 된다.
- **스케줄러도 크로스 유저 발송 버그가 될 뻔했다**: `push_service.send_to_all()`이 전체 구독자에게 보내던 구조를 그대로 뒀다면, 유저 A의 습관 알림 시각에 유저 B의 기기로도 알림이 갔을 것이다. `scheduler_service._tick()`은 유저 스코핑 없는 `habit_service.list_active_habits_with_reminders(db)`로 전체 유저의 알림 예약 습관을 훑되(스케줄러는 요청 컨텍스트가 없어 애초에 "현재 유저"가 없으므로 이 함수만 예외적으로 스코핑이 없음), 발송은 `habit.user_id` 기준 `push_service.send_to_user(db, habit.user_id, ...)`로 좁혔다. `habit.user_id is None`(아직 이관 안 된 습관)은 발송 대상에서 제외한다.
- **OAuth 핸드셰이크와 로그인 세션은 별개의 쿠키**: `authlib`가 리다이렉트 도중 state/nonce를 저장하려면 `request.session`이 있어야 해서 `app/main.py``SessionMiddleware`(쿠키명 `oauth_session`, `max_age=600`)를 추가했다. 로그인 유지용 쿠키(`habit_session`, `itsdangerous` 서명, 30일)와는 완전히 다른 메커니즘이니 헷갈리지 말 것 — `create_session_token(user_id)`가 담는 페이로드도 `{"authenticated": True}`에서 `{"user_id": ...}`로 바뀌었다.
- **리디렉션 URI는 동적 추론이 아니라 `.env`에 명시**: 이 앱은 Tailscale로 리버스 프록시 없이 HTTPS를 받는 배포가 흔한데(위 6단계 HTTPS 문제 참고), `request.url_for()`로 콜백 URL을 추론하면 프록시 뒤에서 scheme이 `http`로 잘못 잡힐 위험이 있다. 그래서 `GOOGLE_REDIRECT_URI``.env`에 명시적으로 두고 Google Cloud Console의 "승인된 리디렉션 URI"와 정확히 일치시키는 방식을 택했다 — 값이 하나라도 다르면 구글이 `redirect_uri_mismatch`로 콜백을 거부한다.
- **테스트 관련 주의**: 실제 구글 계정으로 로그인/동의 화면을 클릭하는 마지막 단계는 브라우저 자동화로 재현할 수 없다(진짜 구글 계정 자격증명이 필요한 영역) — `GET /auth/google/login``accounts.google.com`으로 302 리다이렉트하는지, 로그인 후 발급된 세션 쿠키로 API가 정상 동작하는지는 curl로 검증할 수 있지만, 구글 동의 화면 자체는 사람이 직접 로그인해서 `/today`까지 도달하는지 확인해야 한다.
## Docker 배포
`Dockerfile` + `docker-compose.yml` + `scripts/docker-entrypoint.sh`로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경에는 Docker가 설치되어 있지 않아서 **이미지를 직접 빌드/실행해 검증한 적은 없다** — 실제 배포 서버(Docker 있는 곳)에서 처음 빌드할 때 이 문서에 적은 가정들이 맞는지 확인할 것.
- `pip install .`(non-editable)로 설치하지만 `app/main.py``StaticFiles(directory="app/static")`/`Jinja2Templates(directory="app/templates")`는 **상대경로**라 컨테이너의 현재 작업 디렉터리(`WORKDIR /app`)에 실제 소스 트리가 `/app/app/...`로 그대로 COPY되어 있어야 동작한다 — 로컬 개발 시 "저장소 루트에서 uvicorn 실행" 관례와 동일한 이유. Dockerfile의 `COPY app ./app` 구조를 바꾸면 이 상대경로도 깨진다.
- `scripts/docker-entrypoint.sh`가 컨테이너 시작마다 `alembic upgrade head`를 먼저 실행한 뒤 `uvicorn``exec`한다 — 이미 적용된 리비전은 건너뛰므로 재시작마다 실행돼도 안전(idempotent)하다.
- `.env`는 이미지에 COPY하지 않고(`.dockerignore`) `docker-compose.yml``env_file`로 런타임에 주입한다 — 이미지 레이어에 비밀번호가 남지 않게 하기 위함.
- **컨테이너는 반드시 1개만 실행**해야 한다 — `scheduler_service`가 프로세스 안에서 APScheduler를 직접 돌리므로, replica를 늘리면 각자 스케줄러를 따로 띄워 같은 알림을 중복 처리하려 든다(`_claim_notification_slot`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다).
- **타임존**: `date.today()`(`/today`, 완료율/스트릭 계산 등 날짜 관련 로직 전반)는 컨테이너의 시스템 로컬 타임존을 그대로 쓴다. `python:3.13-slim` 베이스 이미지는 기본 타임존이 UTC라서, `Dockerfile``TZ=Asia/Seoul` + `tzdata` 설치 + `/etc/localtime` 심볼릭 링크를 명시하지 않으면 자정~오전 9시(KST) 사이에 서버가 "아직 어제"로 날짜를 계산한다 — 실제로 이 때문에 매일 아침 `/today`가 전날 체크 상태 그대로 보이고 날짜가 안 넘어가는 버그가 있었다. 코드 로직(`date.today()`) 자체는 문제가 아니라 컨테이너 타임존 설정 누락이 원인이었으니, 비슷한 날짜 관련 이상 증상이 배포 환경에서만 재현되면 먼저 컨테이너 타임존을 의심할 것.
- **HTTPS는 배포 대상에 따라 둘 중 하나**: (1) 집 PC를 직접 서버로 쓰는 경우 → Tailscale(`tailscale serve --bg 8000`), 컨테이너 8000번이 호스트 8000번에 그대로 매핑되므로(`ports: ["8000:8000"]`) 프로세스로 직접 띄우든 컨테이너로 띄우든 Tailscale 입장에서 차이 없음. (2) **이미 리버스 프록시(nginx 등)가 앞단에 있는 서버에 배포하는 경우 → Tailscale 불필요**, 프록시가 도메인의 TLS를 처리하고 컨테이너의 8000번으로 평문 HTTP 프록시하면 된다. 이 앱 자체는 어느 쪽이든 코드 변경 없이 평문 HTTP로만 응답하면 되므로(`app/main.py`에 HTTPS 강제/리다이렉트 로직 없음), 배포 방식은 순전히 인프라 레이어에서 결정된다. 프록시가 컨테이너와 같은 호스트에서 돈다면 `docker-compose.yml`의 포트 매핑을 `"127.0.0.1:8000:8000"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.13-slim
ENV TZ=Asia/Seoul
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
&& echo $TZ > /etc/timezone \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml alembic.ini ./
COPY app ./app
COPY migrations ./migrations
COPY scripts ./scripts
RUN pip install --no-cache-dir . \
&& chmod +x scripts/docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["scripts/docker-entrypoint.sh"]
+129
View File
@@ -0,0 +1,129 @@
# 습관 트래커
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
## 설치
```bash
conda activate py_web
pip install -e .
```
## 환경 설정
1. `.env.example`을 복사해 `.env`로 저장합니다.
2. `DATABASE_URL`에 원격 MariaDB 접속 정보를 입력합니다 (`mysql+pymysql://user:password@host:3306/habit_tracker`).
3. `SECRET_KEY`를 임의의 무작위 문자열로 채웁니다 (`python -c "import secrets; print(secrets.token_hex(32))"`).
4. 구글 로그인을 설정합니다: [Google Cloud Console](https://console.cloud.google.com/apis/credentials)에서 "OAuth 클라이언트 ID"(웹 애플리케이션)를 만들고, "승인된 리디렉션 URI"에 `GOOGLE_REDIRECT_URI`와 정확히 같은 값(로컬은 `http://localhost:8000/auth/google/callback`)을 등록한 뒤 발급된 클라이언트 ID/시크릿을 `.env``GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`에 붙여넣습니다.
5. 알림(Web Push)을 쓰려면 `python scripts/generate_vapid_keys.py` 실행 후 출력된 `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY``.env`에 붙여넣습니다.
## DB 마이그레이션
```bash
alembic upgrade head
```
### 기존 데이터 이관 (PIN 로그인 시절 데이터가 있는 경우)
구글 로그인 도입 전 PIN으로 쓰던 습관 데이터는 마이그레이션 후에도 소유자가 없는 상태로 남아있습니다. 아래 순서로 한 번만 연결해주면 됩니다.
1. 마이그레이션을 적용하고 서버를 띄운 뒤, 구글 계정으로 한 번 로그인합니다(계정이 자동 생성됩니다).
2. `python scripts/claim_orphan_habits.py <로그인한 이메일>`을 실행합니다 — 소유자 없는 습관을 전부 그 계정에 연결합니다.
## 실행
```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
PC 브라우저에서 `http://localhost:8000`, 아이폰에서는 같은 Wi-Fi 내에서 `http://<PC의 로컬 IP>:8000`으로 접속합니다.
아이폰에서는 Safari로 접속한 뒤 공유 버튼 → "홈 화면에 추가"를 해야 앱처럼(standalone) 실행되고, 푸시 알림도 이 상태에서만 받을 수 있습니다. 홈 화면에 추가한 뒤 앱을 열어서 "오늘" 화면 상단의 "알림 켜기" 버튼을 눌러 알림 권한을 허용해야 리마인더를 받을 수 있습니다.
**주의**: 서비스워커(오프라인 캐싱)와 Web Push는 브라우저 보안 정책상 HTTPS 또는 `localhost`에서만 동작합니다. `http://<PC의 로컬 IP>:8000`처럼 평문 HTTP로 접속하면 습관 체크/조회는 문제없지만 아이폰에서 오프라인 캐싱과 푸시 알림은 동작하지 않습니다.
- **집 PC를 직접 서버로 쓴다면** → 아래 "HTTPS로 접속하기 (Tailscale)" 참고.
- **이미 앞단에 리버스 프록시(nginx 등)가 있는 서버에 배포한다면** → Tailscale은 필요 없습니다. 프록시가 도메인에 대한 TLS를 처리하고 내부적으로 이 앱의 8000번 포트로 평문 HTTP 프록시하면 됩니다 — 브라우저 입장에서는 프록시가 내준 도메인이 `https://`이기만 하면 서비스워커/푸시가 정상 동작합니다. Docker 배포 시 참고사항은 아래 "Docker로 배포하기" 절에 있습니다.
## HTTPS로 접속하기 (Tailscale, 집 PC를 직접 서버로 쓰는 경우)
리버스 프록시 없이 집 PC를 그대로 서버로 쓴다면, [Tailscale](https://tailscale.com)로 별도 인증서 관리 없이 신뢰된 HTTPS를 무료로 얻을 수 있습니다. (앞단에 리버스 프록시가 이미 있다면 이 섹션은 건너뛰세요.)
1. 집 PC와 아이폰 모두에 Tailscale 앱을 설치하고 같은 계정으로 로그인합니다 (같은 tailnet에 연결됨).
2. 집 PC에서 서버가 실행 중인 상태(포트 8000)에서 아래 명령을 실행합니다:
```powershell
tailscale serve --bg 8000
```
3. 발급된 주소를 확인합니다:
```powershell
tailscale serve status
```
`https://<PC-이름>.<tailnet-이름>.ts.net` 형태의 주소가 표시됩니다.
4. 아이폰에서 Tailscale 앱을 켠 상태로 Safari에서 위 주소로 접속합니다. 인증서 경고 없이 정상적으로 HTTPS 연결이 됩니다.
5. 이 상태로 "홈 화면에 추가" 후 "알림 켜기"를 누르면 서비스워커와 푸시가 모두 정상 동작합니다.
`tailscale serve` 설정은 재부팅 후에도 유지되므로 최초 1회만 실행하면 됩니다. 외부(인터넷)에는 노출되지 않고 같은 tailnet에 연결된 기기끼리만 접속할 수 있습니다.
## 상시 실행 (Windows)
PC를 켤 때마다 수동으로 서버를 실행하지 않으려면 Windows 작업 스케줄러에 등록합니다. `scripts/run_server.ps1`이 conda 환경의 `python.exe`를 직접 호출해 `--reload` 없이(운영용) 서버를 실행하고 로그를 `logs/`에 남깁니다.
```powershell
schtasks /create /tn "HabitTrackerServer" /tr "powershell.exe -ExecutionPolicy Bypass -File \"C:\workspace\habit-tracker\scripts\run_server.ps1\"" /sc onlogon /rl highest /f
```
등록 후 로그온 시 자동으로 서버가 시작됩니다. 확인/삭제는 다음 명령으로 합니다:
```powershell
schtasks /query /tn "HabitTrackerServer"
schtasks /delete /tn "HabitTrackerServer" /f
```
## Docker로 배포하기
Docker가 설치된 서버라면 `scripts/run_server.ps1` + 작업 스케줄러 조합 대신 Docker의 재시작 정책으로 상시 실행을 훨씬 간단하게 처리할 수 있습니다.
1. 배포 서버에 저장소를 올리고, `.env.example`을 복사해 `.env`로 저장한 뒤 값을 채웁니다 (`DATABASE_URL`, `SECRET_KEY`, `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`/`GOOGLE_REDIRECT_URI`, `VAPID_*` — 로컬 설치 때와 동일하게 "환경 설정" 절 참고). `GOOGLE_REDIRECT_URI`는 배포 서버가 실제로 응답하는 도메인 기준으로 채우고 Google Cloud Console에도 동일하게 등록해야 합니다. **`.env`는 이미지에 포함되지 않고 컨테이너 실행 시점에 주입되므로, `.env` 안의 값은 배포 서버 기준으로 채워야 합니다** — 특히 `DATABASE_URL`이 원격 MariaDB를 가리킨다면 배포 서버에서 그 주소로 접속 가능한지 먼저 확인하세요.
2. 빌드 후 실행합니다:
```bash
docker compose up -d --build
```
내부적으로 컨테이너가 시작될 때마다 `alembic upgrade head`를 먼저 실행한 뒤 `uvicorn`을 띄웁니다(`scripts/docker-entrypoint.sh`) — 이미 적용된 마이그레이션은 건너뛰므로 재시작할 때마다 실행돼도 안전합니다.
3. `docker-compose.yml`의 `restart: unless-stopped`가 서버 재부팅/컨테이너 크래시 시 자동 재시작을 담당합니다 — Windows 작업 스케줄러 등록이 더 이상 필요 없습니다.
4. 로그 확인: `docker compose logs -f`
**주의**:
- 이 앱은 알림 스케줄러(APScheduler)를 프로세스 안에서 직접 돌립니다(`app/services/scheduler_service.py`) — **반드시 컨테이너를 1개만 실행**하세요. 여러 개(replica)를 띄우면 각자 스케줄러가 따로 돌아서 같은 알림을 중복 시도하게 됩니다(경합 자체는 `_claim_notification_slot`이 방어하지만, 굳이 여러 개 띄울 이유가 없습니다).
- **이미 리버스 프록시가 있는 서버라면 Tailscale은 필요 없습니다.** 프록시가 도메인을 HTTPS로 받아서 컨테이너의 8000번 포트로 평문 HTTP 프록시하도록 설정하면 됩니다(예: nginx `proxy_pass http://127.0.0.1:8000;`). 이 앱은 별도 설정 없이 8000번에서 평문 HTTP로만 응답하므로 그대로 붙이면 됩니다. `docker-compose.yml`의 `ports: ["8000:8000"]`는 모든 인터페이스(`0.0.0.0`)에 노출하는데, 프록시가 같은 호스트에서 돈다면 `"127.0.0.1:8000:8000"`으로 바꿔서 컨테이너가 프록시를 거치지 않고 외부에 직접 노출되지 않게 하는 걸 권장합니다.
## 아이콘 재생성
```bash
pip install -e ".[dev]"
python scripts/generate_icons.py
```
## 테스트
`pytest`는 dev 의존성이라 기본 설치(`pip install -e .`)에는 포함되지 않습니다. 먼저 아래 명령으로 설치합니다.
```bash
pip install -e ".[dev]"
```
이후 테스트는 원격 MariaDB가 아니라 임시 SQLite 인메모리 DB로 실행되므로(`tests/conftest.py`), `.env`의 `DATABASE_URL`이나 실제 DB 접속 여부와 무관하게 돌아갑니다.
```bash
pytest # 전체 테스트
pytest tests/test_habit_service.py # 파일 단위
pytest tests/test_log_service.py::test_streak_breaks_on_past_miss # 단일 테스트
```
## 개발 단계
1. ✅ 기반 셋업 + 습관 CRUD
2. ✅ 데일리 체크 & 오늘 화면
3. ✅ 기록 확인 화면 (월별/주별)
4. ✅ PWA 기본 (manifest, 서비스워커, 아이콘, iOS 홈 화면 추가 안내)
5. ✅ Web Push 알림 (VAPID, 구독 흐름, APScheduler 매분 tick)
6. 다듬기 & 상시 실행 설정 (현재 단계)
+38
View File
@@ -0,0 +1,38 @@
[alembic]
script_location = migrations
prepend_sys_path = .
version_path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
View File
+22
View File
@@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
secret_key: str
database_url: str
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = ""
vapid_public_key: str = ""
vapid_private_key: str = ""
vapid_subject: str = "mailto:you@example.com"
session_cookie_name: str = "habit_session"
timezone: str = "Asia/Seoul"
settings = Settings()
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+77
View File
@@ -0,0 +1,77 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.database import SessionLocal
from app.routers import auth, habits, logs, pages, push
from app.routers.pages import templates
from app.security import get_current_user_optional
from app.services import scheduler_service
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler_service.start_scheduler()
yield
scheduler_service.shutdown_scheduler()
app = FastAPI(title="습관 트래커", lifespan=lifespan)
# 구글 OAuth 핸드셰이크 중 state/nonce를 임시로 저장하는 데만 쓰는 세션이다.
# 로그인 유지용 쿠키(habit_session)와는 별개.
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key, session_cookie="oauth_session", max_age=600)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(habits.router)
app.include_router(logs.router)
app.include_router(push.router)
app.include_router(pages.router)
def _is_api_path(path: str) -> bool:
return path.startswith("/api/") or path.startswith("/static/")
def _current_user_context(request: Request) -> dict:
db = SessionLocal()
try:
current_user = get_current_user_optional(request, db)
finally:
db.close()
return {"logged_in": current_user is not None, "current_user": current_user}
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
if exc.status_code == 404 and not _is_api_path(request.url.path):
return templates.TemplateResponse(
request, "404.html", _current_user_context(request), status_code=404
)
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception("처리되지 않은 오류")
if _is_api_path(request.url.path):
return JSONResponse({"detail": "서버 오류가 발생했습니다"}, status_code=500)
return templates.TemplateResponse(
request, "500.html", _current_user_context(request), status_code=500
)
@app.get("/service-worker.js")
def service_worker():
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
+16
View File
@@ -0,0 +1,16 @@
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.models.push_subscription import PushSubscription
from app.models.user import User
__all__ = [
"Habit",
"HabitStatus",
"HabitType",
"HabitLog",
"HabitNotificationLog",
"SummaryNotificationLog",
"PushSubscription",
"User",
]
+48
View File
@@ -0,0 +1,48 @@
import enum
from datetime import datetime, time
from sqlalchemy import Enum, ForeignKey, Integer, SmallInteger, String, Time
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
ALL_WEEKDAYS_MASK = 0b1111111 # 월~일 전부 (bit0=월 ... bit6=일)
class HabitType(str, enum.Enum):
BUILD = "build" # 만들고 싶은 습관
QUIT = "quit" # 멈추고 싶은 습관
class HabitStatus(str, enum.Enum):
ACTIVE = "active"
COMPLETED = "completed"
class Habit(Base):
__tablename__ = "habit"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
habit_type: Mapped[HabitType] = mapped_column(Enum(HabitType, native_enum=False, length=20), nullable=False)
status: Mapped[HabitStatus] = mapped_column(
Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE
)
weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK)
condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
logs: Mapped[list["HabitLog"]] = relationship(
back_populates="habit", cascade="all, delete-orphan", passive_deletes=True
)
def is_scheduled_on(self, weekday: int) -> bool:
"""weekday: Python date.weekday() 기준 (월=0 ... 일=6)"""
return bool(self.weekdays_mask & (1 << weekday))
+19
View File
@@ -0,0 +1,19 @@
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
class HabitLog(Base):
__tablename__ = "habit_log"
__table_args__ = (UniqueConstraint("habit_id", "log_date", name="uq_habit_log_habit_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), nullable=False)
log_date: Mapped[date] = mapped_column(Date, nullable=False)
checked_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
habit: Mapped["Habit"] = relationship(back_populates="logs")
+33
View File
@@ -0,0 +1,33 @@
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class HabitNotificationLog(Base):
__tablename__ = "habit_notification_log"
__table_args__ = (UniqueConstraint("habit_id", "notify_date", name="uq_notification_habit_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
habit_id: Mapped[int] = mapped_column(ForeignKey("habit.id", ondelete="CASCADE"), nullable=False)
notify_date: Mapped[date] = mapped_column(Date, nullable=False)
sent_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
class SummaryNotificationLog(Base):
"""주간/월간 요약 알림 중복 발송 방지용 클레임 테이블 (habit_notification_log와 동일한 패턴).
period_type은 "weekly"/"monthly", period_start는 그 기간의 시작일(주간=월요일, 월간=1일)이다.
"""
__tablename__ = "summary_notification_log"
__table_args__ = (UniqueConstraint("user_id", "period_type", "period_start", name="uq_summary_user_period"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
period_type: Mapped[str] = mapped_column(String(20), nullable=False)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
sent_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class PushSubscription(Base):
__tablename__ = "push_subscription"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
endpoint: Mapped[str] = mapped_column(String(512), nullable=False, unique=True)
p256dh_key: Mapped[str] = mapped_column(String(255), nullable=False)
auth_key: Mapped[str] = mapped_column(String(255), nullable=False)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
google_sub: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
picture_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
View File
+65
View File
@@ -0,0 +1,65 @@
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.security import SESSION_MAX_AGE_SECONDS, create_session_token
router = APIRouter(prefix="/auth", tags=["auth"])
oauth = OAuth()
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_id=settings.google_client_id,
client_secret=settings.google_client_secret,
client_kwargs={"scope": "openid email profile"},
)
@router.get("/google/login")
async def google_login(request: Request):
return await oauth.google.authorize_redirect(request, settings.google_redirect_uri)
@router.get("/google/callback")
async def google_callback(request: Request, db: Session = Depends(get_db)):
token = await oauth.google.authorize_access_token(request)
userinfo = token["userinfo"]
google_sub = userinfo["sub"]
email = userinfo["email"]
name = userinfo.get("name")
picture_url = userinfo.get("picture")
user = db.scalar(select(User).where(User.google_sub == google_sub))
if user is None:
user = User(google_sub=google_sub, email=email, name=name, picture_url=picture_url)
db.add(user)
else:
user.email = email
user.name = name
user.picture_url = picture_url
db.commit()
db.refresh(user)
session_token = create_session_token(user.id)
response = RedirectResponse(url="/today", status_code=303)
response.set_cookie(
settings.session_cookie_name,
session_token,
httponly=True,
samesite="lax",
max_age=SESSION_MAX_AGE_SECONDS,
)
return response
@router.post("/logout")
def logout():
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(settings.session_cookie_name)
return response
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.habit import HabitStatus, HabitType
from app.models.user import User
from app.schemas.habit import HabitCreate, HabitOut, HabitReorderRequest, HabitUpdate
from app.schemas.habit_log import HabitStats
from app.security import require_login
from app.services import habit_service, log_service
router = APIRouter(prefix="/api/habits", tags=["habits"], dependencies=[Depends(require_login)])
def _get_habit_or_404(db: Session, habit_id: int, user_id: int):
habit = habit_service.get_habit(db, habit_id, user_id)
if habit is None:
raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다")
return habit
@router.get("", response_model=list[HabitOut])
def list_habits(
type: HabitType | None = None,
status: HabitStatus | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
return habit_service.list_habits(db, current_user.id, habit_type=type, status=status)
@router.post("", response_model=HabitOut, status_code=201)
def create_habit(
data: HabitCreate, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
return habit_service.create_habit(db, current_user.id, data)
@router.post("/reorder")
def reorder_habits(
data: HabitReorderRequest, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
habit_service.reorder_habits(db, current_user.id, data.habit_ids)
return {"ok": True}
@router.get("/{habit_id}", response_model=HabitOut)
def get_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
return _get_habit_or_404(db, habit_id, current_user.id)
@router.put("/{habit_id}", response_model=HabitOut)
def update_habit(
habit_id: int, data: HabitUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return habit_service.update_habit(db, habit, data)
@router.delete("/{habit_id}", status_code=204)
def delete_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
habit_service.delete_habit(db, habit)
@router.post("/{habit_id}/complete", response_model=HabitOut)
def complete_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return habit_service.complete_habit(db, habit)
@router.post("/{habit_id}/reactivate", response_model=HabitOut)
def reactivate_habit(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return habit_service.reactivate_habit(db, habit)
@router.get("/{habit_id}/stats", response_model=HabitStats)
def get_habit_stats(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = _get_habit_or_404(db, habit_id, current_user.id)
return log_service.get_habit_stats(db, habit)
+52
View File
@@ -0,0 +1,52 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.habit_log import HabitLogOut, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
from app.security import require_login
from app.services import habit_service, log_service
router = APIRouter(prefix="/api", tags=["logs"], dependencies=[Depends(require_login)])
@router.get("/today", response_model=list[TodayItem])
def get_today(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
build_items, quit_items = log_service.get_today_items(db, current_user.id, date.today())
return build_items + quit_items
@router.post("/logs/{habit_id}/toggle")
def toggle_log(habit_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)):
habit = habit_service.get_habit(db, habit_id, current_user.id)
if habit is None:
raise HTTPException(status_code=404, detail="습관을 찾을 수 없습니다")
checked, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today())
return {"checked": checked, "milestone_streak": milestone_streak}
@router.get("/logs", response_model=list[HabitLogOut])
def list_logs(
habit_id: int | None = None,
start: date | None = None,
end: date | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
return log_service.list_logs(db, current_user.id, habit_id=habit_id, start=start, end=end)
@router.get("/logs/summary/monthly", response_model=list[MonthlySummaryDay])
def monthly_summary(
year: int, month: int, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
return log_service.get_monthly_summary(db, current_user.id, year, month)
@router.get("/logs/summary/weekly", response_model=list[WeeklyMatrixRow])
def weekly_summary(
start_date: date, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
return log_service.get_weekly_matrix(db, current_user.id, start_date)
+304
View File
@@ -0,0 +1,304 @@
import calendar
from datetime import date, timedelta
from datetime import time as time_type
from fastapi import APIRouter, Depends, Form, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.habit import HabitStatus, HabitType
from app.models.user import User
from app.schemas.habit import HabitCreate, HabitUpdate
from app.security import get_current_user_optional
from app.services import habit_service, log_service
from app.template_utils import heatmap_opacity, is_milestone_streak, weekday_label
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
templates.env.globals["weekday_label"] = weekday_label
templates.env.globals["heatmap_opacity"] = heatmap_opacity
templates.env.globals["is_milestone_streak"] = is_milestone_streak
def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse:
user = get_current_user_optional(request, db)
if user is None:
return RedirectResponse(url="/login", status_code=303)
return user
@router.get("/")
def index(request: Request, db: Session = Depends(get_db)):
if get_current_user_optional(request, db) is not None:
return RedirectResponse(url="/today", status_code=303)
return RedirectResponse(url="/login", status_code=303)
@router.get("/login")
def login_page(request: Request, db: Session = Depends(get_db)):
if get_current_user_optional(request, db) is not None:
return RedirectResponse(url="/today", status_code=303)
return templates.TemplateResponse(request, "login.html", {"logged_in": False, "current_user": None})
def _today_context(db: Session, user_id: int, **extra) -> dict:
build_items, quit_items = log_service.get_today_items(db, user_id, date.today())
all_items = build_items + quit_items
return {
"build_items": build_items,
"quit_items": quit_items,
"total_count": len(all_items),
"checked_count": sum(1 for item in all_items if item.checked),
**extra,
}
@router.get("/today")
def today_page(request: Request, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return templates.TemplateResponse(
request,
"today.html",
{"logged_in": True, "current_user": current, **_today_context(db, current.id)},
)
@router.post("/today/{habit_id}/toggle")
def toggle_today_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None:
return Response(status_code=404)
_, milestone_streak = log_service.toggle_check_and_celebrate(db, habit, date.today())
extra = {"celebrate_habit_name": habit.name, "celebrate_streak": milestone_streak} if milestone_streak else {}
return templates.TemplateResponse(
request,
"partials/today_content.html",
{"logged_in": True, "current_user": current, **_today_context(db, current.id, **extra)},
)
@router.get("/habits")
def habits_page(request: Request, tab: str = "build", db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if tab == "completed":
habits = habit_service.list_habits(db, current.id, status=HabitStatus.COMPLETED)
else:
if tab not in ("build", "quit"):
tab = "build"
habits = habit_service.list_habits(db, current.id, habit_type=HabitType(tab), status=HabitStatus.ACTIVE)
stats_map = {h.id: log_service.get_habit_stats(db, h) for h in habits}
return templates.TemplateResponse(
request,
"habits.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"habits": habits,
"habit_type": tab,
"stats_map": stats_map,
},
)
@router.post("/habits/new")
def create_habit_page(
request: Request,
name: str = Form(""),
habit_type: str = Form(...),
weekdays_mask: int = Form(...),
condition_text: str | None = Form(None),
reminder_time: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None
data = HabitCreate(
name=name,
habit_type=HabitType(habit_type),
weekdays_mask=weekdays_mask,
condition_text=condition_text,
reminder_time=parsed_time,
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
habit_service.create_habit(db, current.id, data)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={habit_type}"
return response
@router.post("/habits/{habit_id}/edit")
def edit_habit_page(
request: Request,
habit_id: int,
name: str = Form(""),
weekdays_mask: int = Form(...),
condition_text: str | None = Form(None),
reminder_time: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None:
return Response(status_code=404)
try:
parsed_time = time_type.fromisoformat(reminder_time) if reminder_time else None
data = HabitUpdate(
name=name,
habit_type=habit.habit_type,
weekdays_mask=weekdays_mask,
condition_text=condition_text,
reminder_time=parsed_time,
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
habit_service.update_habit(db, habit, data)
tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
@router.post("/habits/{habit_id}/complete")
def complete_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None:
return Response(status_code=404)
tab = habit.habit_type.value
habit_service.complete_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
@router.post("/habits/{habit_id}/reactivate")
def reactivate_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None:
return Response(status_code=404)
habit_service.reactivate_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/habits?tab=completed"
return response
@router.delete("/habits/{habit_id}/delete")
def delete_habit_page(request: Request, habit_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
habit = habit_service.get_habit(db, habit_id, current.id)
if habit is None:
return Response(status_code=404)
tab = "completed" if habit.status == HabitStatus.COMPLETED else habit.habit_type.value
habit_service.delete_habit(db, habit)
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/habits?tab={tab}"
return response
def _month_context(db: Session, user_id: int, year: int, month: int) -> dict:
summaries = log_service.get_monthly_summary(db, user_id, year, month)
summary_map = {s.log_date: s for s in summaries}
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month) # 6=일요일(calendar 모듈 기준)부터 시작
completion_rate = log_service.summarize_completion_rate(summaries, date.today())
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return {
"view": "month",
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"completion_rate": completion_rate,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _week_context(db: Session, user_id: int, week_start: date) -> dict:
rows = log_service.get_weekly_matrix(db, user_id, week_start)
return {
"view": "week",
"week_start": week_start,
"week_end": week_start + timedelta(days=6),
"rows": rows,
"prev_week": (week_start - timedelta(days=7)).isoformat(),
"next_week": (week_start + timedelta(days=7)).isoformat(),
}
@router.get("/history")
def history_page(
request: Request,
view: str = "month",
year: int | None = None,
month: int | None = None,
start: str | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
today = date.today()
if view == "week":
week_start = date.fromisoformat(start) if start else today
# date.weekday()는 월=0..일=6이라, 일요일까지 거슬러 올라가려면 +1 해서 나머지를 구해야 한다
# (일요일 자신은 0일 전, 월요일은 1일 전, ... 토요일은 6일 전).
week_start = week_start - timedelta(days=(week_start.weekday() + 1) % 7)
context = _week_context(db, current.id, week_start)
else:
view = "month"
context = _month_context(db, current.id, year or today.year, month or today.month)
return templates.TemplateResponse(
request, "history.html", {"logged_in": True, "current_user": current, **context}
)
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.schemas.push import PushSubscribeRequest
from app.security import require_login
from app.services import push_service
router = APIRouter(prefix="/api/push", tags=["push"], dependencies=[Depends(require_login)])
class UnsubscribeRequest(BaseModel):
endpoint: str
@router.get("/vapid-public-key")
def get_vapid_public_key():
return {"publicKey": settings.vapid_public_key}
@router.post("/subscribe")
def subscribe(
data: PushSubscribeRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
push_service.save_subscription(db, current_user.id, data, user_agent=request.headers.get("user-agent"))
return {"ok": True}
@router.post("/unsubscribe")
def unsubscribe(
data: UnsubscribeRequest, db: Session = Depends(get_db), current_user: User = Depends(require_login)
):
push_service.delete_subscription(db, data.endpoint)
return {"ok": True}
@router.post("/test")
def send_test(db: Session = Depends(get_db), current_user: User = Depends(require_login)):
sent = push_service.send_to_user(db, current_user.id, title="습관 트래커", body="테스트 알림입니다.")
return {"sent": sent}
View File
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime, time
from pydantic import BaseModel, ConfigDict, field_validator
from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
class HabitBase(BaseModel):
name: str
habit_type: HabitType
weekdays_mask: int = ALL_WEEKDAYS_MASK
condition_text: str | None = None
reminder_time: time | None = None
@field_validator("name")
@classmethod
def name_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("습관 이름을 입력해주세요")
return v
@field_validator("condition_text")
@classmethod
def blank_condition_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
@field_validator("weekdays_mask")
@classmethod
def mask_in_range(cls, v: int) -> int:
if not (1 <= v <= ALL_WEEKDAYS_MASK):
raise ValueError("요일을 최소 하루 이상 선택해주세요")
return v
class HabitCreate(HabitBase):
pass
class HabitUpdate(HabitBase):
pass
class HabitOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
habit_type: HabitType
status: HabitStatus
weekdays_mask: int
condition_text: str | None
reminder_time: time | None
created_at: datetime
completed_at: datetime | None
class HabitReorderRequest(BaseModel):
habit_ids: list[int]
+45
View File
@@ -0,0 +1,45 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class HabitLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
habit_id: int
log_date: date
checked_at: datetime
class TodayItem(BaseModel):
habit_id: int
name: str
habit_type: str
condition_text: str | None
reminder_time: str | None
checked: bool
completion_rate: float
current_streak: int
scheduled_days: int
class MonthlySummaryDay(BaseModel):
log_date: date
scheduled_count: int
checked_count: int
class WeeklyMatrixRow(BaseModel):
habit_id: int
name: str
habit_type: str
checks: dict[str, bool | None] # ISO 날짜 문자열 -> 체크 여부 (None이면 그 요일에 예정되지 않음)
completion_rate: float # 이번 주, 오늘까지 지난 예정일 중 체크한 비율 (%)
class HabitStats(BaseModel):
completion_rate: float # 습관 생성일부터 오늘까지, 예정된 날 중 체크한 비율 (%)
current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수
scheduled_days: int
checked_days: int
+11
View File
@@ -0,0 +1,11 @@
from pydantic import BaseModel
class PushKeys(BaseModel):
p256dh: str
auth: str
class PushSubscribeRequest(BaseModel):
endpoint: str
keys: PushKeys
+43
View File
@@ -0,0 +1,43 @@
from fastapi import Depends, HTTPException, Request, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
_serializer = URLSafeTimedSerializer(settings.secret_key, salt="habit-session")
SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 # 30일
def create_session_token(user_id: int) -> str:
return _serializer.dumps({"user_id": user_id})
def get_session_user_id(request: Request) -> int | None:
token = request.cookies.get(settings.session_cookie_name)
if not token:
return None
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return None
return data.get("user_id")
def require_login(request: Request, db: Session = Depends(get_db)) -> User:
user_id = get_session_user_id(request)
user = db.get(User, user_id) if user_id is not None else None
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="로그인이 필요합니다")
return user
def get_current_user_optional(request: Request, db: Session) -> User | None:
user_id = get_session_user_id(request)
return db.get(User, user_id) if user_id is not None else None
def is_logged_in(request: Request) -> bool:
return get_session_user_id(request) is not None
+100
View File
@@ -0,0 +1,100 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType
from app.schemas.habit import HabitCreate, HabitUpdate
def list_habits(
db: Session, user_id: int, habit_type: HabitType | None = None, status: HabitStatus | None = None
) -> list[Habit]:
stmt = select(Habit).where(Habit.user_id == user_id)
if habit_type is not None:
stmt = stmt.where(Habit.habit_type == habit_type)
if status is not None:
stmt = stmt.where(Habit.status == status)
stmt = stmt.order_by(Habit.sort_order.is_(None), Habit.sort_order, Habit.created_at)
return list(db.scalars(stmt))
def list_active_habits_with_reminders(db: Session) -> list[Habit]:
"""스케줄러 전용: 유저 스코핑 없이 알림 시각이 설정된 전체 active 습관을 반환한다."""
stmt = select(Habit).where(Habit.status == HabitStatus.ACTIVE, Habit.reminder_time.isnot(None))
return list(db.scalars(stmt))
def list_active_user_ids(db: Session) -> list[int]:
"""스케줄러 전용: 유저 스코핑 없이, active 습관을 하나 이상 가진 유저 id 목록을 반환한다(주간/월간 요약 알림 대상)."""
stmt = (
select(Habit.user_id)
.where(Habit.status == HabitStatus.ACTIVE, Habit.user_id.isnot(None))
.distinct()
)
return list(db.scalars(stmt))
def get_habit(db: Session, habit_id: int, user_id: int) -> Habit | None:
return db.scalar(select(Habit).where(Habit.id == habit_id, Habit.user_id == user_id))
def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit:
habit = Habit(
user_id=user_id,
name=data.name,
habit_type=data.habit_type,
weekdays_mask=data.weekdays_mask,
condition_text=data.condition_text,
reminder_time=data.reminder_time,
status=HabitStatus.ACTIVE,
)
db.add(habit)
db.commit()
db.refresh(habit)
return habit
def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit:
habit.name = data.name
habit.habit_type = data.habit_type
habit.weekdays_mask = data.weekdays_mask
habit.condition_text = data.condition_text
habit.reminder_time = data.reminder_time
db.commit()
db.refresh(habit)
return habit
def delete_habit(db: Session, habit: Habit) -> None:
db.delete(habit)
db.commit()
def complete_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.COMPLETED
habit.completed_at = datetime.now()
db.commit()
db.refresh(habit)
return habit
def reactivate_habit(db: Session, habit: Habit) -> Habit:
habit.status = HabitStatus.ACTIVE
habit.completed_at = None
db.commit()
db.refresh(habit)
return habit
def reorder_habits(db: Session, user_id: int, ordered_ids: list[int]) -> None:
"""ordered_ids에 나온 순서대로 sort_order를 다시 매긴다. 목록에 없는 id나 다른 유저의 habit은 무시한다."""
habits = db.scalars(
select(Habit).where(Habit.id.in_(ordered_ids), Habit.user_id == user_id)
).all()
habit_map = {h.id: h for h in habits}
for index, habit_id in enumerate(ordered_ids):
habit = habit_map.get(habit_id)
if habit is not None:
habit.sort_order = index
db.commit()
+254
View File
@@ -0,0 +1,254 @@
import calendar
from datetime import date, timedelta
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.schemas.habit_log import HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow
from app.services import habit_service, push_service
# 체크 시 축하 푸시/배지를 트리거하는 연속 달성일 마일스톤.
MILESTONE_STREAKS = {7, 30, 66, 100, 200, 365}
def _to_today_item(db: Session, habit: Habit, checked: bool) -> TodayItem:
stats = get_habit_stats(db, habit)
return TodayItem(
habit_id=habit.id,
name=habit.name,
habit_type=habit.habit_type.value,
condition_text=habit.condition_text,
reminder_time=habit.reminder_time.strftime("%H:%M") if habit.reminder_time else None,
checked=checked,
completion_rate=stats.completion_rate,
current_streak=stats.current_streak,
scheduled_days=stats.scheduled_days,
)
def get_today_items(db: Session, user_id: int, target_date: date) -> tuple[list[TodayItem], list[TodayItem]]:
"""오늘 요일에 예정된 active 습관을 형성/중단으로 나누어 체크 여부와 함께 반환한다."""
weekday = target_date.weekday()
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
scheduled = [h for h in active_habits if h.is_scheduled_on(weekday)]
checked_ids: set[int] = set()
habit_ids = [h.id for h in scheduled]
if habit_ids:
checked_ids = set(
db.scalars(
select(HabitLog.habit_id).where(
HabitLog.log_date == target_date, HabitLog.habit_id.in_(habit_ids)
)
)
)
build_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.BUILD]
quit_items = [_to_today_item(db, h, h.id in checked_ids) for h in scheduled if h.habit_type == HabitType.QUIT]
return build_items, quit_items
def toggle_check(db: Session, habit_id: int, log_date: date) -> bool:
"""체크 상태를 반전시키고 토글 후의 체크 여부를 반환한다.
호출측에서 이미 habit_service.get_habit(db, habit_id, user_id)로 소유권을 검증한 뒤에만 불러야 한다.
"""
existing = db.scalar(select(HabitLog).where(HabitLog.habit_id == habit_id, HabitLog.log_date == log_date))
if existing:
db.delete(existing)
db.commit()
return False
db.add(HabitLog(habit_id=habit_id, log_date=log_date))
db.commit()
return True
def toggle_check_and_celebrate(db: Session, habit: Habit, log_date: date) -> tuple[bool, int | None]:
"""체크를 토글하고, 새로 체크되어 스트릭이 마일스톤에 도달했으면 축하 푸시를 보낸다.
반환값: (checked, milestone_streak). milestone_streak은 이번 토글로 막 달성한 마일스톤 값이면 그 값,
체크 해제거나 마일스톤이 아니면 None.
"""
checked = toggle_check(db, habit.id, log_date)
if not checked:
return checked, None
streak = get_habit_stats(db, habit).current_streak
if streak not in MILESTONE_STREAKS:
return checked, None
if habit.user_id is not None:
push_service.send_to_user(
db, habit.user_id, title=f"🔥 {habit.name}", body=f"{streak}일 연속 달성했어요!", url="/today"
)
return checked, streak
def list_logs(
db: Session, user_id: int, habit_id: int | None = None, start: date | None = None, end: date | None = None
) -> list[HabitLog]:
stmt = select(HabitLog).join(Habit, HabitLog.habit_id == Habit.id).where(Habit.user_id == user_id)
if habit_id is not None:
stmt = stmt.where(HabitLog.habit_id == habit_id)
if start is not None:
stmt = stmt.where(HabitLog.log_date >= start)
if end is not None:
stmt = stmt.where(HabitLog.log_date <= end)
stmt = stmt.order_by(HabitLog.log_date)
return list(db.scalars(stmt))
def _checked_counts_by_date(db: Session, habit_ids: list[int], start: date, end: date) -> dict[date, int]:
if not habit_ids:
return {}
rows = db.execute(
select(HabitLog.log_date, func.count(HabitLog.id))
.where(HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(start, end))
.group_by(HabitLog.log_date)
).all()
return {row[0]: row[1] for row in rows}
def get_monthly_summary(db: Session, user_id: int, year: int, month: int) -> list[MonthlySummaryDay]:
"""해당 월의 날짜별 예정 습관 수 / 체크된 습관 수를 집계한다 (현재 active 습관 기준)."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], first_day, last_day)
summaries = []
for day_num in range(1, days_in_month + 1):
d = date(year, month, day_num)
# 습관이 생성되기 전 날짜는 "예정되었지만 안 함"으로 잘못 잡히지 않도록 제외한다.
scheduled = sum(1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday()))
summaries.append(
MonthlySummaryDay(log_date=d, scheduled_count=scheduled, checked_count=checked_counts.get(d, 0))
)
return summaries
def summarize_completion_rate(summaries: list[MonthlySummaryDay], up_to: date) -> float:
"""월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다.
아직 지나지 않은 미래 날짜는 scheduled_count는 있어도 checked_count가 항상 0이라
포함시키면 완료율이 부당하게 낮아지므로 제외한다.
"""
past = [s for s in summaries if s.log_date <= up_to]
total_scheduled = sum(s.scheduled_count for s in past)
total_checked = sum(s.checked_count for s in past)
return round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
def get_period_completion_rate(db: Session, user_id: int, start: date, end: date) -> tuple[float, int, int]:
"""[start, end] 구간(양끝 포함)의 예정/체크 수를 집계해 완료율(%)과 함께 반환한다.
get_monthly_summary와 같은 규칙(현재 active 습관 기준, 습관 생성일 이전 제외)을 임의 기간에
적용한 버전 — 주간 요약 알림처럼 달력 월 경계에 안 맞는 기간을 집계할 때 쓴다.
"""
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
checked_counts = _checked_counts_by_date(db, [h.id for h in active_habits], start, end)
total_scheduled = 0
total_checked = 0
d = start
while d <= end:
total_scheduled += sum(
1 for h in active_habits if h.created_at.date() <= d and h.is_scheduled_on(d.weekday())
)
total_checked += checked_counts.get(d, 0)
d += timedelta(days=1)
rate = round(total_checked / total_scheduled * 100, 1) if total_scheduled else 0.0
return rate, total_scheduled, total_checked
def get_habit_stats(db: Session, habit: Habit) -> HabitStats:
"""습관 생성일부터 오늘까지의 완료율과, 오늘(또는 어제)부터 거슬러 올라간 연속 달성일을 계산한다.
요일 스케줄은 현재 습관의 weekdays_mask를 과거에도 그대로 적용한 것으로 간주한다
(과거 요일 변경 이력은 추적하지 않음 — 월별/주별 집계와 같은 단순화).
"""
today = date.today()
start = habit.created_at.date()
checked_dates = set(db.scalars(select(HabitLog.log_date).where(HabitLog.habit_id == habit.id)))
scheduled_days = 0
checked_days = 0
d = start
while d <= today:
if habit.is_scheduled_on(d.weekday()):
scheduled_days += 1
if d in checked_dates:
checked_days += 1
d += timedelta(days=1)
completion_rate = round(checked_days / scheduled_days * 100, 1) if scheduled_days else 0.0
streak = 0
d = today
while d >= start:
if habit.is_scheduled_on(d.weekday()):
if d in checked_dates:
streak += 1
elif d != today:
break
# d가 오늘이고 아직 체크 전이면: 하루가 아직 안 끝났으니 스트릭을 끊지 않고 계속 거슬러 올라간다.
d -= timedelta(days=1)
return HabitStats(
completion_rate=completion_rate,
current_streak=streak,
scheduled_days=scheduled_days,
checked_days=checked_days,
)
def get_weekly_matrix(db: Session, user_id: int, week_start: date) -> list[WeeklyMatrixRow]:
"""week_start(호출측에서 정한 주 시작일, 현재 /history는 일요일을 사용)부터 7일간,
active 습관별 요일 체크 매트릭스를 반환한다. 이 함수 자체는 week_start가 어떤 요일이든 상관없다."""
week_days = [week_start + timedelta(days=i) for i in range(7)]
active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE)
habit_ids = [h.id for h in active_habits]
checked_pairs: set[tuple[int, date]] = set()
if habit_ids:
rows = db.execute(
select(HabitLog.habit_id, HabitLog.log_date).where(
HabitLog.habit_id.in_(habit_ids), HabitLog.log_date.between(week_days[0], week_days[-1])
)
).all()
checked_pairs = {(r[0], r[1]) for r in rows}
today = date.today()
result = []
for h in active_habits:
checks: dict[str, bool | None] = {}
scheduled_past = 0
checked_past = 0
for d in week_days:
# 습관이 생성되기 전 날짜는 요일이 맞아도 "예정 없음"으로 취급한다.
if d < h.created_at.date() or not h.is_scheduled_on(d.weekday()):
checks[d.isoformat()] = None
continue
is_checked = (h.id, d) in checked_pairs
checks[d.isoformat()] = is_checked
if d <= today:
scheduled_past += 1
if is_checked:
checked_past += 1
completion_rate = round(checked_past / scheduled_past * 100, 1) if scheduled_past else 0.0
result.append(
WeeklyMatrixRow(
habit_id=h.id,
name=h.name,
habit_type=h.habit_type.value,
checks=checks,
completion_rate=completion_rate,
)
)
return result
+69
View File
@@ -0,0 +1,69 @@
import json
from pywebpush import WebPushException, webpush
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.push_subscription import PushSubscription
from app.schemas.push import PushSubscribeRequest
def list_subscriptions(db: Session, user_id: int) -> list[PushSubscription]:
return list(db.scalars(select(PushSubscription).where(PushSubscription.user_id == user_id)))
def save_subscription(
db: Session, user_id: int, data: PushSubscribeRequest, user_agent: str | None = None
) -> PushSubscription:
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint))
if existing:
existing.user_id = user_id # 같은 기기에서 다른 유저가 재구독하면 소유자를 갱신한다.
existing.p256dh_key = data.keys.p256dh
existing.auth_key = data.keys.auth
db.commit()
return existing
sub = PushSubscription(
user_id=user_id,
endpoint=data.endpoint,
p256dh_key=data.keys.p256dh,
auth_key=data.keys.auth,
user_agent=user_agent,
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
def delete_subscription(db: Session, endpoint: str) -> None:
existing = db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
if existing:
db.delete(existing)
db.commit()
def send_to_user(db: Session, user_id: int, title: str, body: str, url: str = "/today") -> int:
"""해당 유저의 구독자에게만 알림을 보낸다. 만료된(410/404) 구독은 자동으로 삭제한다. 성공 발송 건수를 반환."""
payload = json.dumps({"title": title, "body": body, "url": url}, ensure_ascii=False)
sent = 0
for sub in list_subscriptions(db, user_id):
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh_key, "auth": sub.auth_key},
},
data=payload,
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
)
sent += 1
except WebPushException as exc:
status_code = exc.response.status_code if exc.response is not None else None
if status_code in (404, 410):
db.delete(sub)
db.commit()
# 그 외 오류(일시적 네트워크 문제 등)는 건너뛰고 다음 구독자에게 계속 발송한다.
return sent
+180
View File
@@ -0,0 +1,180 @@
import logging
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.config import settings
from app.database import SessionLocal
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.services import habit_service, log_service, push_service
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler(timezone=settings.timezone)
def _claim_notification_slot(db, habit_id: int, notify_date) -> bool:
"""habit_notification_log에 (habit_id, notify_date) 행을 먼저 "선점"한다.
(habit_id, notify_date) 유니크 제약을 경합 방지용 락으로 쓴다 — reload로 겹친 워커나
APScheduler가 중복 기동된 상황에서도 두 프로세스가 동시에 같은 알림을 보내지 않도록,
실제 발송 전에 먼저 이 행을 커밋해서 선점에 성공한 쪽만 발송하게 한다.
"""
db.add(HabitNotificationLog(habit_id=habit_id, notify_date=notify_date))
try:
db.commit()
return True
except IntegrityError:
db.rollback()
return False
def _claim_summary_slot(db: Session, user_id: int, period_type: str, period_start: date) -> bool:
"""summary_notification_log에 (user_id, period_type, period_start) 행을 선점한다.
_claim_notification_slot과 같은 목적 — 리로드로 겹친 워커나 중복 기동된 스케줄러가
같은 주간/월간 요약을 두 번 보내지 않도록 유니크 제약을 경합 방지 락으로 쓴다.
"""
db.add(SummaryNotificationLog(user_id=user_id, period_type=period_type, period_start=period_start))
try:
db.commit()
return True
except IntegrityError:
db.rollback()
return False
def _tick() -> None:
"""매분 실행되어, 지금 이 순간이 알람 시각+요일에 맞는 active 습관에 대해
오늘 아직 안 보낸 알림만 골라 발송한다."""
db = SessionLocal()
try:
now = datetime.now(ZoneInfo(settings.timezone))
today = now.date()
weekday = today.weekday()
habits = habit_service.list_active_habits_with_reminders(db)
for habit in habits:
if habit.user_id is None or not habit.is_scheduled_on(weekday):
continue
if habit.reminder_time.hour != now.hour or habit.reminder_time.minute != now.minute:
continue
already_sent = db.scalar(
select(HabitNotificationLog).where(
HabitNotificationLog.habit_id == habit.id,
HabitNotificationLog.notify_date == today,
)
)
if already_sent:
continue
try:
claimed = _claim_notification_slot(db, habit.id, today)
if not claimed:
continue
push_service.send_to_user(
db, habit.user_id, title=habit.name, body="지금 실천할 시간이에요", url="/today"
)
except Exception:
logger.exception("습관(id=%s) 알림 발송 중 오류", habit.id)
db.rollback()
except Exception:
logger.exception("습관 알림 tick 처리 중 오류")
finally:
db.close()
def _send_period_summaries(
db: Session, *, period_type: str, period_start: date, range_start: date, range_end: date, title: str, url: str
) -> None:
"""유저별로 [range_start, range_end] 완료율을 계산해 요약 푸시를 보낸다 (주간/월간 tick 공통 로직).
예정된 습관이 하나도 없던(scheduled == 0) 유저에게는 의미 없는 알림을 보내지 않고 건너뛴다.
"""
for user_id in habit_service.list_active_user_ids(db):
rate, scheduled, checked = log_service.get_period_completion_rate(db, user_id, range_start, range_end)
if scheduled == 0:
continue
try:
if not _claim_summary_slot(db, user_id, period_type, period_start):
continue
push_service.send_to_user(
db, user_id, title=title, body=f"완료율 {rate}% ({checked}/{scheduled})이에요.", url=url
)
except Exception:
logger.exception("유저(id=%s) %s 요약 알림 발송 중 오류", user_id, period_type)
db.rollback()
def _weekly_summary_tick() -> None:
"""매주 일요일 21시에 실행되어, 이번 주(월요일~오늘)의 완료율을 유저별로 요약해 발송한다."""
db = SessionLocal()
try:
today = datetime.now(ZoneInfo(settings.timezone)).date()
week_start = today - timedelta(days=today.weekday())
_send_period_summaries(
db,
period_type="weekly",
period_start=week_start,
range_start=week_start,
range_end=today,
title="이번 주 습관 리포트",
url=f"/history?view=week&start={week_start.isoformat()}",
)
except Exception:
logger.exception("주간 요약 tick 처리 중 오류")
finally:
db.close()
def _monthly_summary_tick() -> None:
"""매월 마지막 날 21:30에 실행되어, 이번 달(1일~오늘)의 완료율을 유저별로 요약해 발송한다."""
db = SessionLocal()
try:
today = datetime.now(ZoneInfo(settings.timezone)).date()
month_start = today.replace(day=1)
_send_period_summaries(
db,
period_type="monthly",
period_start=month_start,
range_start=month_start,
range_end=today,
title="이번 달 습관 리포트",
url=f"/history?view=month&year={today.year}&month={today.month}",
)
except Exception:
logger.exception("월간 요약 tick 처리 중 오류")
finally:
db.close()
def start_scheduler() -> None:
scheduler.add_job(_tick, "cron", minute="*", id="habit_reminder_tick", replace_existing=True)
scheduler.add_job(
_weekly_summary_tick,
"cron",
day_of_week="sun",
hour=21,
minute=0,
id="weekly_summary_tick",
replace_existing=True,
)
scheduler.add_job(
_monthly_summary_tick,
"cron",
day="last",
hour=21,
minute=30,
id="monthly_summary_tick",
replace_existing=True,
)
scheduler.start()
def shutdown_scheduler() -> None:
scheduler.shutdown(wait=False)
+640
View File
@@ -0,0 +1,640 @@
:root {
--color-bg: #f5f4ef;
--color-surface: #ffffff;
--color-text: #2b2a27;
--color-text-muted: #6b6a66;
--color-accent: #d97757;
--color-accent-hover: #c15f3c;
--color-accent-rgb: 217, 119, 87;
--color-border: #e8e6df;
--color-success: #4f7a5a;
--color-success-tint: rgba(79, 122, 90, 0.08);
--color-danger: #b3543f;
--color-gold: #a8791a;
--color-gold-tint: rgba(168, 121, 26, 0.12);
--radius-card: 14px;
--radius-control: 10px;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.04);
--space-1: 8px;
--space-2: 16px;
--space-3: 24px;
--space-4: 32px;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Pretendard", "Malgun Gothic", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1f1e1b;
--color-surface: #2a2926;
--color-text: #edebe4;
--color-text-muted: #a8a69f;
--color-accent: #e08962;
--color-accent-hover: #eb9c78;
--color-accent-rgb: 224, 137, 98;
--color-border: #3a3833;
--color-success: #6fa47c;
--color-success-tint: rgba(111, 164, 124, 0.14);
--color-danger: #d97c68;
--color-gold: #d9b84f;
--color-gold-tint: rgba(217, 184, 79, 0.16);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.2);
}
}
* {
box-sizing: border-box;
}
[x-cloak] {
display: none !important;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
.app-shell {
max-width: 640px;
margin: 0 auto;
padding: var(--space-3) var(--space-2) var(--space-4);
}
@media (min-width: 900px) {
.app-shell {
max-width: 760px;
padding-top: var(--space-4);
}
.app-shell.wide {
max-width: 960px;
}
}
h1,
h2,
h3 {
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
margin: 0 0 var(--space-2);
}
h1 {
font-size: 22px;
}
h2 {
font-size: 17px;
}
p {
margin: 0 0 var(--space-2);
color: var(--color-text-muted);
}
/* iOS 홈 화면 추가 안내 배너 */
.ios-install-banner {
display: none;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
box-shadow: var(--shadow-card);
padding: 10px 12px;
margin-bottom: var(--space-2);
font-size: 13px;
color: var(--color-text);
}
.ios-install-banner button {
flex-shrink: 0;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
/* 상단 네비게이션 */
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
row-gap: var(--space-1);
margin-bottom: var(--space-3);
}
.top-nav .brand {
font-weight: 600;
font-size: 17px;
white-space: nowrap;
}
.top-nav .nav-links {
display: flex;
gap: var(--space-1);
}
.top-nav .nav-links a {
text-decoration: none;
color: var(--color-text-muted);
font-size: 14px;
white-space: nowrap;
padding: 6px 10px;
border-radius: var(--radius-control);
}
.top-nav .nav-links a.active {
color: var(--color-text);
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.top-nav .nav-user {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--space-1);
font-size: 13px;
color: var(--color-text-muted);
}
.top-nav .nav-user span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 96px;
}
.btn-link {
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 13px;
color: var(--color-text-muted);
text-decoration: underline;
white-space: nowrap;
flex-shrink: 0;
cursor: pointer;
}
/* 하단 탭바 (모바일 전용) — 기본은 숨김, 좁은 화면에서만 노출 */
.bottom-tab-bar {
display: none;
}
/* 좁은 화면: 상단은 브랜드+유저정보만 남기고, 이동 네비게이션은 하단 탭바로 옮긴다 */
@media (max-width: 480px) {
.top-nav .nav-links {
display: none;
}
.app-shell {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px) + var(--space-2));
}
.bottom-tab-bar {
display: flex;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.bottom-tab-bar .tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 4px 6px;
text-decoration: none;
color: var(--color-text-muted);
font-size: 11px;
}
.bottom-tab-bar .tab-item svg {
width: 22px;
height: 22px;
}
.bottom-tab-bar .tab-item.active {
color: var(--color-accent);
}
}
/* 카드 */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: var(--space-3);
margin-bottom: var(--space-2);
}
/* 버튼 */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 6px;
border: none;
border-radius: var(--radius-control);
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
font-family: inherit;
transition: background-color 0.15s ease, opacity 0.15s ease;
}
.btn-primary {
background: var(--color-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--color-accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg);
}
.btn-danger-ghost {
background: transparent;
color: var(--color-danger);
border: 1px solid var(--color-border);
}
.btn-block {
width: 100%;
}
/* 입력 */
input[type="text"],
input[type="time"],
input[type="password"] {
width: 100%;
font-family: inherit;
font-size: 15px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
color: var(--color-text);
}
label {
display: block;
font-size: 13px;
color: var(--color-text-muted);
margin-bottom: 6px;
}
.field {
margin-bottom: var(--space-2);
}
.form-error-text {
color: var(--color-danger);
font-size: 13px;
margin: -8px 0 var(--space-2);
min-height: 1em;
}
/* 탭 */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.tabs a {
text-decoration: none;
color: var(--color-text-muted);
font-size: 14px;
padding: 10px 4px;
margin-right: var(--space-2);
border-bottom: 2px solid transparent;
}
.tabs a.active {
color: var(--color-text);
border-bottom-color: var(--color-accent);
font-weight: 600;
}
/* 요일 선택 pill */
.weekday-picker {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.weekday-pill {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.weekday-pill.selected {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.everyday-btn {
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
font-size: 13px;
padding: 8px 14px;
cursor: pointer;
margin-left: 4px;
}
.everyday-btn.selected {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* 습관 리스트 아이템 */
.habit-item {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px var(--space-2);
padding: 12px 4px;
border-bottom: 1px solid var(--color-border);
}
.habit-item:last-child {
border-bottom: none;
}
.habit-item.today-checked {
background: var(--color-success-tint);
border-radius: var(--radius-control);
margin: 0 -4px;
padding-left: 8px;
padding-right: 8px;
}
.habit-item-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1 1 auto;
}
.habit-item-name {
font-size: 15px;
overflow-wrap: break-word;
word-break: break-word;
}
.habit-item-condition {
font-size: 13px;
color: var(--color-text-muted);
margin-top: 2px;
}
.habit-item-meta {
font-size: 12px;
color: var(--color-text-muted);
}
.habit-item-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
}
.drag-handle {
flex-shrink: 0;
cursor: grab;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
padding: 4px 2px;
touch-action: none;
user-select: none;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-chosen .habit-item {
background: var(--color-bg);
border-radius: var(--radius-control);
}
.habit-item-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.check-indicator {
width: 26px;
height: 26px;
padding: 0;
margin: 0;
border-radius: 50%;
border: 2px solid var(--color-border);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
background: transparent;
font-family: inherit;
font-size: 13px;
line-height: 1;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.check-indicator.checked {
background: var(--color-success);
border-color: var(--color-success);
color: #fff;
}
.empty-state {
text-align: center;
color: var(--color-text-muted);
padding: var(--space-4) var(--space-2);
font-size: 14px;
}
.badge {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.badge-milestone {
background: var(--color-gold-tint);
color: var(--color-gold);
border-color: var(--color-gold);
font-weight: 600;
}
.celebration-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin-bottom: var(--space-2);
border-radius: var(--radius-card);
background: var(--color-gold-tint);
border: 1px solid var(--color-gold);
color: var(--color-text);
font-size: 14px;
animation: celebration-pop 0.4s ease-out;
}
@keyframes celebration-pop {
from {
opacity: 0;
transform: scale(0.95) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 월별 캘린더 */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 4px;
}
.calendar-weekday {
text-align: center;
font-size: 12px;
color: var(--color-text-muted);
padding: 4px 0;
}
.calendar-cell {
aspect-ratio: 1;
border-radius: 8px;
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
.calendar-cell.muted {
opacity: 0.35;
}
.calendar-date {
font-size: 12px;
font-weight: 500;
}
.calendar-ratio {
font-size: 10px;
color: var(--color-text-muted);
}
/* 주별 매트릭스 */
.week-matrix {
width: 100%;
border-collapse: collapse;
font-size: 13px;
white-space: nowrap;
}
.week-matrix th,
.week-matrix td {
padding: 10px 8px;
text-align: center;
border-bottom: 1px solid var(--color-border);
}
.week-matrix th:first-child,
.week-matrix td:first-child {
text-align: left;
}
.week-matrix th {
color: var(--color-text-muted);
font-weight: 500;
font-size: 12px;
}
.wm-check {
color: var(--color-success);
font-weight: 700;
}
.wm-dash {
color: var(--color-border);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+35
View File
@@ -0,0 +1,35 @@
(function () {
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/service-worker.js").catch(function (err) {
console.error("서비스워커 등록 실패", err);
});
});
}
function isIos() {
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
}
function isStandalone() {
return (
("standalone" in window.navigator && window.navigator.standalone) ||
window.matchMedia("(display-mode: standalone)").matches
);
}
document.addEventListener("DOMContentLoaded", function () {
var banner = document.getElementById("ios-install-banner");
if (!banner) return;
var dismissed = localStorage.getItem("iosInstallBannerDismissed");
if (isIos() && !isStandalone() && !dismissed) {
banner.style.display = "flex";
}
});
window.dismissIosInstallBanner = function () {
var banner = document.getElementById("ios-install-banner");
if (banner) banner.style.display = "none";
localStorage.setItem("iosInstallBannerDismissed", "1");
};
})();
+27
View File
@@ -0,0 +1,27 @@
(function () {
function initSortable() {
var list = document.getElementById("habit-list");
if (!list || typeof Sortable === "undefined") return;
Sortable.create(list, {
handle: ".drag-handle",
animation: 150,
forceFallback: true, // iOS Safari는 네이티브 HTML5 D&D 터치 지원이 불안정해서 자체 포인터 시뮬레이션을 강제한다
fallbackTolerance: 3,
onEnd: function () {
var ids = Array.from(list.children)
.map(function (el) { return el.getAttribute("data-habit-id"); })
.filter(Boolean)
.map(Number);
fetch("/api/habits/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ habit_ids: ids }),
});
},
});
}
document.addEventListener("DOMContentLoaded", initSortable);
})();
+63
View File
@@ -0,0 +1,63 @@
(function () {
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
const output = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
output[i] = rawData.charCodeAt(i);
}
return output;
}
function isPushSupported() {
return "serviceWorker" in navigator && "PushManager" in window;
}
async function getSubscriptionState() {
if (!isPushSupported()) return "unsupported";
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return sub ? "subscribed" : "unsubscribed";
}
async function subscribePush() {
if (!isPushSupported()) {
alert("이 브라우저는 알림을 지원하지 않아요.");
return false;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
alert("알림 권한이 허용되지 않았어요.");
return false;
}
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await fetch("/api/push/vapid-public-key").then((r) => r.json());
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
return true;
}
async function unsubscribePush() {
if (!isPushSupported()) return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) return;
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
window.habitPush = { getSubscriptionState, subscribePush, unsubscribePush };
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
{
"name": "습관 트래커",
"short_name": "습관 트래커",
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
"start_url": "/today",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f5f4ef",
"theme_color": "#d97757",
"lang": "ko",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/static/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+103
View File
@@ -0,0 +1,103 @@
const CACHE_NAME = "habit-tracker-v3";
const APP_SHELL = [
"/static/css/style.css",
"/static/js/app.js",
"/static/js/push-register.js",
"/static/js/habit-reorder.js",
"/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js",
"/static/icons/icon-192.png",
"/static/icons/icon-512.png",
"/static/manifest.json",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) return;
// 페이지 탐색(/today, /habits, /history 등)은 습관 추가·수정 직후 리다이렉트되는 화면이라
// 캐시된 옛 내용이 먼저 보이면 "저장한 게 사라졌다"처럼 보인다. 네트워크를 우선 시도하고
// 오프라인일 때만 캐시로 폴백한다.
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request))
);
return;
}
// 정적 자산은 캐시 우선 응답 후 백그라운드로 갱신(stale-while-revalidate) — 자주 안 바뀌므로 속도 우선.
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => cached);
return cached || network;
})
);
});
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "습관 트래커", {
body: data.body || "",
icon: "/static/icons/icon-192.png",
badge: "/static/icons/icon-192.png",
data: { url: data.url || "/today" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || "/today";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsList) => {
for (const client of clientsList) {
if (client.url.includes(targetUrl) && "focus" in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
);
});
+25
View File
@@ -0,0 +1,25 @@
from app.models.habit import ALL_WEEKDAYS_MASK
from app.services.log_service import MILESTONE_STREAKS
_DAY_LABELS = ["", "", "", "", "", "", ""]
def is_milestone_streak(streak: int) -> bool:
return streak in MILESTONE_STREAKS
def weekday_label(mask: int) -> str:
if mask == ALL_WEEKDAYS_MASK:
return "매일"
days = [label for i, label in enumerate(_DAY_LABELS) if mask & (1 << i)]
return ", ".join(days) if days else "선택된 요일 없음"
def heatmap_opacity(checked_count: int, scheduled_count: int) -> float:
"""월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다."""
if not scheduled_count:
return 0.0
ratio = checked_count / scheduled_count
if ratio <= 0:
return 0.0
return round(0.12 + ratio * 0.78, 2)
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
<h1>페이지를 찾을 수 없어요</h1>
<p>주소가 잘못되었거나 삭제된 페이지예요.</p>
<a href="/today" class="btn btn-primary">오늘 화면으로</a>
</div>
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
<h1>문제가 발생했어요</h1>
<p>일시적인 오류일 수 있어요. 잠시 후 다시 시도해주세요.</p>
<a href="/today" class="btn btn-primary">오늘 화면으로</a>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}습관 트래커{% endblock %}</title>
<link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#d97757" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="습관 트래커" />
<link rel="apple-touch-icon" href="/static/icons/icon-192.png" />
<link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" />
<script src="/static/js/vendor/htmx.min.js" defer></script>
<script src="/static/js/push-register.js" defer></script>
<script src="/static/js/vendor/sortable.min.js" defer></script>
<script src="/static/js/habit-reorder.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script>
</head>
<body>
<div class="app-shell{% block shell_class %}{% endblock %}">
{% if logged_in %}
<div id="ios-install-banner" class="ios-install-banner">
<span>홈 화면에 추가하면 알림도 받을 수 있어요. 공유 버튼 → &lsquo;홈 화면에 추가&rsquo;를 눌러보세요.</span>
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">&times;</button>
</div>
<nav class="top-nav">
<span class="brand">습관 트래커</span>
<div class="nav-links">
<a href="/today" class="{% block nav_today %}{% endblock %}">오늘</a>
<a href="/habits" class="{% block nav_habits %}{% endblock %}">습관 관리</a>
<a href="/history" class="{% block nav_history %}{% endblock %}">기록</a>
</div>
{% if current_user %}
<div class="nav-user">
<span>{{ current_user.name or current_user.email }}</span>
<form method="post" action="/auth/logout">
<button type="submit" class="btn-link">로그아웃</button>
</form>
</div>
{% endif %}
</nav>
<nav class="bottom-tab-bar">
<a href="/today" class="tab-item {{ self.nav_today() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M8 12l3 3 5-6" /></svg>
<span>오늘</span>
</a>
<a href="/habits" class="tab-item {{ self.nav_habits() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6" /><line x1="4" y1="12" x2="20" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /></svg>
<span>습관 관리</span>
</a>
<a href="/history" class="tab-item {{ self.nav_history() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2" /><line x1="3" y1="10" x2="21" y2="10" /><line x1="8" y1="3" x2="8" y2="7" /><line x1="16" y1="3" x2="16" y2="7" /></svg>
<span>기록</span>
</a>
</nav>
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}습관 관리 · 습관 트래커{% endblock %}
{% block nav_habits %}active{% endblock %}
{% block content %}
<h1>습관 관리</h1>
<div class="tabs">
<a href="/habits?tab=build" class="{{ 'active' if tab == 'build' }}">만들고 싶은 습관</a>
<a href="/habits?tab=quit" class="{{ 'active' if tab == 'quit' }}">멈추고 싶은 습관</a>
<a href="/habits?tab=completed" class="{{ 'active' if tab == 'completed' }}">완료된 습관</a>
</div>
{% if tab in ("build", "quit") %}
{% include "partials/habit_form.html" %}
{% endif %}
<div class="card">
{% if habits %}
<div id="habit-list">
{% for habit in habits %}
{% include "partials/habit_item.html" %}
{% endfor %}
</div>
{% else %}
<div class="empty-state">
{% if tab == "build" %}만들고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
{% elif tab == "quit" %}끊고 싶은 습관이 아직 없어요. 위에서 추가해보세요.
{% else %}아직 완료된 습관이 없어요.{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}기록 · 습관 트래커{% endblock %}
{% block nav_history %}active{% endblock %}
{% block shell_class %} wide{% endblock %}
{% block content %}
<h1>기록</h1>
<div class="tabs">
<a href="/history?view=month" class="{{ 'active' if view == 'month' }}">월별</a>
<a href="/history?view=week" class="{{ 'active' if view == 'week' }}">주별</a>
</div>
{% if view == "month" %}
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<a href="/history?view=month&year={{ prev_year }}&month={{ prev_month }}" class="btn btn-secondary"></a>
<h2 style="margin:0;">{{ year }}년 {{ month }}월</h2>
<a href="/history?view=month&year={{ next_year }}&month={{ next_month }}" class="btn btn-secondary"></a>
</div>
<div style="text-align:center; margin-bottom: var(--space-2);">
<span class="badge">이번 달 완료율 {{ completion_rate }}%</span>
</div>
<div class="calendar-grid calendar-grid-header">
{% for wd in ["일", "월", "화", "수", "목", "금", "토"] %}
<div class="calendar-weekday">{{ wd }}</div>
{% endfor %}
</div>
{% for week in weeks %}
<div class="calendar-grid">
{% for d in week %}
{% set summary = summary_map.get(d) %}
<div
class="calendar-cell{{ '' if d.month == month else ' muted' }}"
style="background: rgba(var(--color-accent-rgb), {{ heatmap_opacity(summary.checked_count, summary.scheduled_count) if summary else 0 }});"
>
<span class="calendar-date">{{ d.day }}</span>
{% if summary and summary.scheduled_count %}
<span class="calendar-ratio">{{ summary.checked_count }}/{{ summary.scheduled_count }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="overflow-x:auto;">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<a href="/history?view=week&start={{ prev_week }}" class="btn btn-secondary"></a>
<h2 style="margin:0;">{{ week_start.strftime("%Y.%m.%d") }} - {{ week_end.strftime("%m.%d") }}</h2>
<a href="/history?view=week&start={{ next_week }}" class="btn btn-secondary"></a>
</div>
{% if rows %}
<table class="week-matrix">
<thead>
<tr>
<th>습관</th>
{% for wd in ["일", "월", "화", "수", "목", "금", "토"] %}
<th>{{ wd }}</th>
{% endfor %}
<th>완료율</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
{{ row.name }}
<span class="badge">{{ "형성" if row.habit_type == "build" else "중단" }}</span>
</td>
{% for iso_date, checked in row.checks.items() %}
<td class="week-matrix-cell">
{% if checked is none %}<span class="wm-dash"></span>
{% elif checked %}<span class="wm-check">&#10003;</span>
{% else %}<span class="wm-empty"></span>
{% endif %}
</td>
{% endfor %}
<td class="week-matrix-cell">{{ row.completion_rate }}%</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">진행 중인 습관이 없어요.</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}로그인 · 습관 트래커{% endblock %}
{% block content %}
<div style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
<div class="card" style="width: 100%; max-width: 320px;">
<h1 style="text-align:center;">습관 트래커</h1>
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
<div class="field">
<label>시행 요일</label>
<div class="weekday-picker">
<template x-for="(day, idx) in days" :key="idx">
<button
type="button"
class="weekday-pill"
:class="{ selected: (mask & (1 << idx)) !== 0 }"
@click="if (mask !== (1 << idx)) mask = mask ^ (1 << idx)"
x-text="day"
></button>
</template>
<button
type="button"
class="everyday-btn"
:class="{ selected: mask === 127 }"
@click="mask = 127"
>매일</button>
</div>
</div>
<div class="field">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" x-model="alarmOn" style="width:auto;" />
알람 사용
</label>
<input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" />
</div>
+42
View File
@@ -0,0 +1,42 @@
<div
class="card"
x-data="{
open: false,
mask: 127,
alarmOn: false,
days: ['월', '화', '수', '목', '금', '토', '일'],
}"
>
<button type="button" class="btn btn-secondary btn-block" @click="open = !open">
<span x-show="!open">{{ '+ 멈추고 싶은 습관 추가' if habit_type == 'quit' else '+ 만들고 싶은 습관 추가' }}</span>
<span x-show="open" x-cloak>닫기</span>
</button>
<form
x-show="open"
x-cloak
style="margin-top: var(--space-2);"
hx-post="/habits/new"
hx-target="#habit-form-error-{{ habit_type }}"
hx-swap="innerHTML"
>
<input type="hidden" name="habit_type" value="{{ habit_type }}" />
<input type="hidden" name="weekdays_mask" :value="mask" />
<div class="field">
<label for="name-{{ habit_type }}">습관 이름</label>
<input type="text" id="name-{{ habit_type }}" name="name" required placeholder="{{ '예: 물 2L 마시기' if habit_type == 'build' else '예: 야식 끊기' }}" />
</div>
<div class="field">
<label for="condition-{{ habit_type }}">달성 조건 (선택)</label>
<input type="text" id="condition-{{ habit_type }}" name="condition_text" placeholder="{{ '예: 하루 8잔 이상' if habit_type == 'build' else '예: 주 3회 이하로 줄이기' }}" />
</div>
{% include "partials/_weekday_alarm_fields.html" %}
<div id="habit-form-error-{{ habit_type }}" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
</form>
</div>
+94
View File
@@ -0,0 +1,94 @@
<div
data-habit-id="{{ habit.id }}"
x-data="{
editing: false,
mask: {{ habit.weekdays_mask }},
alarmOn: {{ 'true' if habit.reminder_time else 'false' }},
days: ['월', '화', '수', '목', '금', '토', '일'],
}"
>
<div class="habit-item" x-show="!editing">
<div class="habit-item-main">
<span class="drag-handle" aria-hidden="true"></span>
<div>
<div class="habit-item-name">{{ habit.name }}</div>
{% if habit.condition_text %}<div class="habit-item-condition">{{ habit.condition_text }}</div>{% endif %}
<div class="habit-item-meta">
{{ weekday_label(habit.weekdays_mask) }}
{% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
</div>
{% set stats = stats_map[habit.id] %}
{% if stats.scheduled_days > 0 %}
<div class="habit-item-stats">
<span class="badge">완료율 {{ stats.completion_rate }}%</span>
{% if stats.current_streak > 0 %}
<span class="badge{{ ' badge-milestone' if is_milestone_streak(stats.current_streak) }}">
{{ '🏆' if is_milestone_streak(stats.current_streak) else '🔥' }} 연속 {{ stats.current_streak }}일
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="habit-item-actions">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
{% if habit.status.value == "active" %}
<button
type="button"
class="btn btn-secondary"
hx-post="/habits/{{ habit.id }}/complete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?"
>완료 처리</button>
{% else %}
<button
type="button"
class="btn btn-secondary"
hx-post="/habits/{{ habit.id }}/reactivate"
hx-target="body"
hx-swap="none"
>다시 진행</button>
{% endif %}
<button
type="button"
class="btn btn-danger-ghost"
hx-delete="/habits/{{ habit.id }}/delete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 삭제할까요? 기록도 함께 삭제됩니다."
>삭제</button>
</div>
</div>
<form
x-show="editing"
x-cloak
style="padding: 12px 4px; border-bottom: 1px solid var(--color-border);"
hx-post="/habits/{{ habit.id }}/edit"
hx-target="#habit-edit-error-{{ habit.id }}"
hx-swap="innerHTML"
>
<input type="hidden" name="weekdays_mask" :value="mask" />
<div class="field">
<label for="edit-name-{{ habit.id }}">습관 이름</label>
<input type="text" id="edit-name-{{ habit.id }}" name="name" required value="{{ habit.name }}" />
</div>
<div class="field">
<label for="edit-condition-{{ habit.id }}">달성 조건 (선택)</label>
<input type="text" id="edit-condition-{{ habit.id }}" name="condition_text" value="{{ habit.condition_text or '' }}" />
</div>
{% set reminder_value = habit.reminder_time.strftime("%H:%M") if habit.reminder_time else "" %}
{% include "partials/_weekday_alarm_fields.html" %}
<div id="habit-edit-error-{{ habit.id }}" class="form-error-text"></div>
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
<button type="button" class="btn btn-secondary" @click="editing = false">취소</button>
</div>
</form>
</div>
+43
View File
@@ -0,0 +1,43 @@
{% if celebrate_streak %}
<div class="celebration-banner" x-data="{ show: true }" x-init="setTimeout(() => show = false, 4000)" x-show="show" x-transition>
🎉 <strong>{{ celebrate_habit_name }}</strong> {{ celebrate_streak }}일 연속 달성! 축하해요!
</div>
{% endif %}
<div
class="card"
style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;"
x-data="{ pushState: 'checking' }"
x-init="habitPush.getSubscriptionState().then(s => pushState = s)"
>
<h2 style="margin:0;">오늘</h2>
<div style="display:flex; align-items:center; gap:8px;">
<span class="badge">{{ checked_count }}/{{ total_count }} 완료</span>
<button
type="button"
class="btn btn-secondary"
x-show="pushState === 'unsubscribed'"
x-cloak
@click="habitPush.subscribePush().then(ok => { if (ok) pushState = 'subscribed'; })"
>알림 켜기</button>
<span class="badge" x-show="pushState === 'subscribed'" x-cloak>🔔 알림 켜짐</span>
</div>
</div>
<h2>형성 습관</h2>
<div class="card">
{% for item in build_items %}
{% include "partials/today_item.html" %}
{% else %}
<div class="empty-state">오늘 예정된 형성 습관이 없어요.</div>
{% endfor %}
</div>
<h2>중단 습관</h2>
<div class="card">
{% for item in quit_items %}
{% include "partials/today_item.html" %}
{% else %}
<div class="empty-state">오늘 예정된 중단 습관이 없어요.</div>
{% endfor %}
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="habit-item{{ ' today-checked' if item.checked }}">
<div class="habit-item-main">
<button
type="button"
class="check-indicator{{ ' checked' if item.checked }}"
hx-post="/today/{{ item.habit_id }}/toggle"
hx-target="#today-content"
hx-swap="innerHTML"
aria-label="{{ item.name }} 체크"
>{% if item.checked %}&#10003;{% endif %}</button>
<div>
<div class="habit-item-name">{{ item.name }}</div>
{% if item.condition_text %}<div class="habit-item-condition">{{ item.condition_text }}</div>{% endif %}
{% if item.reminder_time %}<div class="habit-item-meta">알람 {{ item.reminder_time }}</div>{% endif %}
{% if item.scheduled_days > 0 %}
<div class="habit-item-stats">
<span class="badge">완료율 {{ item.completion_rate }}%</span>
{% if item.current_streak > 0 %}
<span class="badge{{ ' badge-milestone' if is_milestone_streak(item.current_streak) }}">
{{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}오늘 · 습관 트래커{% endblock %}
{% block nav_today %}active{% endblock %}
{% block content %}
<div id="today-content">
{% include "partials/today_content.html" %}
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
services:
app:
build: .
image: habit-tracker:latest
restart: unless-stopped
ports:
- "8000:8000"
environment:
- TZ=Asia/Seoul
env_file:
- .env
+42
View File
@@ -0,0 +1,42 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import create_engine, pool
from app import models # noqa: F401 (모델을 등록해 metadata에 반영)
from app.config import settings
from app.database import Base
config = context.config
# settings.database_url은 비밀번호에 %가 포함될 수 있어 configparser 보간을 거치지 않고 직접 사용한다.
DATABASE_URL = settings.database_url
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = create_engine(DATABASE_URL, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+76
View File
@@ -0,0 +1,76 @@
"""initial schema: habit, habit_log, push_subscription, habit_notification_log
Revision ID: 0001_initial
Revises:
Create Date: 2026-07-09
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"habit",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("habit_type", sa.String(length=20), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
sa.Column("weekdays_mask", sa.SmallInteger(), nullable=False, server_default="127"),
sa.Column("reminder_time", sa.Time(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.CheckConstraint("habit_type in ('build','quit')", name="ck_habit_habit_type"),
sa.CheckConstraint("status in ('active','completed')", name="ck_habit_status"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"habit_log",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("habit_id", sa.Integer(), sa.ForeignKey("habit.id", ondelete="CASCADE"), nullable=False),
sa.Column("log_date", sa.Date(), nullable=False),
sa.Column("checked_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("habit_id", "log_date", name="uq_habit_log_habit_date"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"push_subscription",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("endpoint", sa.String(length=512), nullable=False, unique=True),
sa.Column("p256dh_key", sa.String(length=255), nullable=False),
sa.Column("auth_key", sa.String(length=255), nullable=False),
sa.Column("user_agent", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"habit_notification_log",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("habit_id", sa.Integer(), sa.ForeignKey("habit.id", ondelete="CASCADE"), nullable=False),
sa.Column("notify_date", sa.Date(), nullable=False),
sa.Column("sent_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("habit_id", "notify_date", name="uq_notification_habit_date"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
def downgrade() -> None:
op.drop_table("habit_notification_log")
op.drop_table("push_subscription")
op.drop_table("habit_log")
op.drop_table("habit")
@@ -0,0 +1,24 @@
"""add condition_text to habit
Revision ID: 0002_add_condition_text
Revises: 0001_initial
Create Date: 2026-07-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002_add_condition_text"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("habit", sa.Column("condition_text", sa.String(length=300), nullable=True))
def downgrade() -> None:
op.drop_column("habit", "condition_text")
@@ -0,0 +1,34 @@
"""add user table
Revision ID: 0003_add_user_table
Revises: 0002_add_condition_text
Create Date: 2026-07-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0003_add_user_table"
down_revision: Union[str, None] = "0002_add_condition_text"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("google_sub", sa.String(length=255), nullable=False, unique=True),
sa.Column("email", sa.String(length=255), nullable=False, unique=True),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("picture_url", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
def downgrade() -> None:
op.drop_table("user")
@@ -0,0 +1,41 @@
"""add user_id to habit and push_subscription
Revision ID: 0004_add_user_id_columns
Revises: 0003_add_user_table
Create Date: 2026-07-15
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004_add_user_id_columns"
down_revision: Union[str, None] = "0003_add_user_table"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("habit", sa.Column("user_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_habit_user_id", "habit", "user", ["user_id"], ["id"], ondelete="CASCADE"
)
op.add_column("push_subscription", sa.Column("user_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_push_subscription_user_id",
"push_subscription",
"user",
["user_id"],
["id"],
ondelete="CASCADE",
)
def downgrade() -> None:
op.drop_constraint("fk_push_subscription_user_id", "push_subscription", type_="foreignkey")
op.drop_column("push_subscription", "user_id")
op.drop_constraint("fk_habit_user_id", "habit", type_="foreignkey")
op.drop_column("habit", "user_id")
@@ -0,0 +1,34 @@
"""add summary_notification_log table
Revision ID: 0005_add_summary_notification_log
Revises: 0004_add_user_id_columns
Create Date: 2026-07-16
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005_add_summary_notif_log"
down_revision: Union[str, None] = "0004_add_user_id_columns"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"summary_notification_log",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False),
sa.Column("period_type", sa.String(length=20), nullable=False),
sa.Column("period_start", sa.Date(), nullable=False),
sa.Column("sent_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "period_type", "period_start", name="uq_summary_user_period"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
def downgrade() -> None:
op.drop_table("summary_notification_log")
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "habit-tracker"
version = "0.1.0"
description = "개인용 습관 관리 PWA"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy>=2.0",
"alembic>=1.13",
"pymysql>=1.1",
"pydantic-settings>=2.5",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"itsdangerous>=2.2",
"bcrypt>=4.0",
"apscheduler>=3.10",
"pywebpush>=2.0",
"py-vapid>=1.9",
"authlib>=1.3",
"httpx>=0.27",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성용 (런타임 미사용)
]
[tool.setuptools.packages.find]
include = ["app*"]
+34
View File
@@ -0,0 +1,34 @@
"""PIN 로그인 시절부터 쌓인, 소유자 없는(user_id IS NULL) 습관을 지정한 이메일의 계정으로 연결합니다.
먼저 구글 로그인을 해서 계정이 생성된 뒤에 실행하세요.
사용법:
python scripts/claim_orphan_habits.py <이메일>
"""
import sys
from sqlalchemy import select, update
from app.database import SessionLocal
from app.models.habit import Habit
from app.models.user import User
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("사용법: python scripts/claim_orphan_habits.py <이메일>")
email = sys.argv[1]
db = SessionLocal()
try:
user = db.scalar(select(User).where(User.email == email))
if user is None:
raise SystemExit(f"'{email}' 계정을 찾을 수 없습니다. 먼저 구글 로그인을 한 번 해주세요.")
result = db.execute(
update(Habit).where(Habit.user_id.is_(None)).values(user_id=user.id)
)
db.commit()
print(f"{result.rowcount}개의 습관을 '{email}' 계정으로 연결했습니다.")
finally:
db.close()
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
# 컨테이너 시작 시마다 마이그레이션을 적용한다. 이미 적용된 리비전은 그냥 건너뛰므로
# 여러 번 실행해도 안전하다(idempotent).
alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
+50
View File
@@ -0,0 +1,50 @@
"""PWA 아이콘(icon-192, icon-512, icon-maskable-512)을 생성한다.
Pillow가 필요하다 (런타임 의존성 아님, 아이콘을 새로 만들 때만 `pip install pillow` 1 실행).
사용법: python scripts/generate_icons.py
"""
from pathlib import Path
from PIL import Image, ImageDraw
ACCENT = (217, 119, 87, 255) # --color-accent
WHITE = (255, 255, 255, 255)
OUT_DIR = Path(__file__).resolve().parent.parent / "app" / "static" / "icons"
# 24x24 viewBox 기준 체크마크 좌표 (elbow 3점)
CHECK_POINTS = [(4.8, 12.0), (9.0, 16.2), (19.0, 6.4)]
def draw_icon(size: int) -> Image.Image:
img = Image.new("RGBA", (size, size), ACCENT)
draw = ImageDraw.Draw(img)
check_span = size * 0.58 # 24유닛이 차지할 실제 픽셀 크기
scale = check_span / 24
offset = (size - check_span) / 2
points = [(offset + x * scale, offset + y * scale) for x, y in CHECK_POINTS]
stroke_width = max(2, round(size * 0.066))
draw.line(points, fill=WHITE, width=stroke_width, joint="curve")
# 선 끝을 둥글게 마무리
r = stroke_width / 2
for x, y in (points[0], points[-1]):
draw.ellipse([x - r, y - r, x + r, y + r], fill=WHITE)
return img
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
draw_icon(192).save(OUT_DIR / "icon-192.png")
draw_icon(512).save(OUT_DIR / "icon-512.png")
# 배경이 전체를 채우고 체크마크가 중앙 58%에만 있어 마스커블 세이프존(중앙 80%)을 만족한다.
draw_icon(512).save(OUT_DIR / "icon-maskable-512.png")
print(f"generated icons in {OUT_DIR}")
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
"""Web Push용 VAPID 키쌍을 생성합니다. 출력값을 .env의 VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY에 붙여넣으세요.
사용법: python scripts/generate_vapid_keys.py
"""
from cryptography.hazmat.primitives import serialization
from py_vapid import Vapid02, b64urlencode
if __name__ == "__main__":
vapid = Vapid02()
vapid.generate_keys()
private_raw = vapid.private_key.private_numbers().private_value.to_bytes(32, "big")
public_raw = vapid.public_key.public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
)
print("VAPID_PUBLIC_KEY=" + b64urlencode(public_raw))
print("VAPID_PRIVATE_KEY=" + b64urlencode(private_raw))
+23
View File
@@ -0,0 +1,23 @@
# 습관 트래커 서버를 상시 실행하기 위한 스크립트.
# Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이
# 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $ProjectRoot
$PythonExe = Join-Path $env:USERPROFILE "anaconda3\envs\py_web\python.exe"
if (-not (Test-Path $PythonExe)) {
Write-Error "conda 환경의 python.exe를 찾을 수 없습니다: $PythonExe (경로를 확인하세요)"
exit 1
}
$LogDir = Join-Path $ProjectRoot "logs"
if (-not (Test-Path $LogDir)) {
New-Item -ItemType Directory -Path $LogDir | Out-Null
}
$LogFile = Join-Path $LogDir ("server_{0}.log" -f (Get-Date -Format "yyyyMMdd"))
# 네이티브 프로세스의 stderr(uvicorn의 INFO 로그 포함)를 PowerShell 5.1에서 *>>로 직접 리다이렉션하면
# 각 줄이 NativeCommandError로 감싸져 $ErrorActionPreference=Stop과 충돌해 즉시 종료되는 문제가 있어
# ErrorActionPreference를 건드리지 않고 기본값(Continue)으로 둔 채 리다이렉션한다.
& $PythonExe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 *>> $LogFile
+79
View File
@@ -0,0 +1,79 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.main import app
from app.models.user import User
from app.security import create_session_token
from app.config import settings
@pytest.fixture()
def engine():
eng = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@event.listens_for(eng, "connect")
def _enable_fk(dbapi_connection, _):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(eng)
yield eng
Base.metadata.drop_all(eng)
eng.dispose()
@pytest.fixture()
def db_session(engine):
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = session_factory()
try:
yield session
finally:
session.close()
@pytest.fixture()
def client(db_session):
def _override_get_db():
yield db_session
app.dependency_overrides[get_db] = _override_get_db
# TestClient를 컨텍스트 매니저(`with`)로 쓰지 않으므로 lifespan(스케줄러 기동)이 실행되지 않는다 —
# 테스트가 실제 운영 MariaDB에 붙는 APScheduler 백그라운드 잡을 우연히 건드리지 않게 하기 위함.
test_client = TestClient(app)
yield test_client
app.dependency_overrides.clear()
@pytest.fixture()
def test_user(db_session):
user = User(google_sub="test-sub-1", email="tester@example.com", name="테스터")
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture()
def other_user(db_session):
user = User(google_sub="test-sub-2", email="other@example.com", name="다른유저")
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture()
def auth_client(client, test_user):
token = create_session_token(test_user.id)
client.cookies.set(settings.session_cookie_name, token)
return client
+81
View File
@@ -0,0 +1,81 @@
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.schemas.habit import HabitCreate
from app.services import habit_service
def _make_habit(db_session, user_id, name="테스트 습관"):
data = HabitCreate(name=name, habit_type=HabitType.BUILD, weekdays_mask=ALL_WEEKDAYS_MASK)
return habit_service.create_habit(db_session, user_id, data)
def test_list_habits_requires_login(client):
response = client.get("/api/habits")
assert response.status_code == 401
def test_create_and_list_habit(auth_client):
response = auth_client.post(
"/api/habits", json={"name": "아침 운동", "habit_type": "build", "weekdays_mask": ALL_WEEKDAYS_MASK}
)
assert response.status_code == 201
created = response.json()
assert created["name"] == "아침 운동"
listed = auth_client.get("/api/habits").json()
assert [h["name"] for h in listed] == ["아침 운동"]
def test_create_habit_rejects_blank_name(auth_client):
response = auth_client.post(
"/api/habits", json={"name": " ", "habit_type": "build", "weekdays_mask": ALL_WEEKDAYS_MASK}
)
assert response.status_code == 422
def test_get_other_users_habit_returns_404(auth_client, db_session, other_user):
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
response = auth_client.get(f"/api/habits/{others_habit.id}")
assert response.status_code == 404
def test_delete_other_users_habit_returns_404_and_does_not_delete(auth_client, db_session, other_user):
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
response = auth_client.delete(f"/api/habits/{others_habit.id}")
assert response.status_code == 404
assert habit_service.get_habit(db_session, others_habit.id, other_user.id) is not None
def test_update_own_habit(auth_client, db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="원래 이름")
response = auth_client.put(
f"/api/habits/{habit.id}",
json={"name": "새 이름", "habit_type": "build", "weekdays_mask": ALL_WEEKDAYS_MASK},
)
assert response.status_code == 200
assert response.json()["name"] == "새 이름"
def test_complete_and_reactivate_habit(auth_client, db_session, test_user):
habit = _make_habit(db_session, test_user.id)
complete_res = auth_client.post(f"/api/habits/{habit.id}/complete")
assert complete_res.status_code == 200
assert complete_res.json()["status"] == "completed"
reactivate_res = auth_client.post(f"/api/habits/{habit.id}/reactivate")
assert reactivate_res.status_code == 200
assert reactivate_res.json()["status"] == "active"
def test_reorder_endpoint(auth_client, db_session, test_user):
a = _make_habit(db_session, test_user.id, name="A")
b = _make_habit(db_session, test_user.id, name="B")
response = auth_client.post("/api/habits/reorder", json={"habit_ids": [b.id, a.id]})
assert response.status_code == 200
listed = auth_client.get("/api/habits").json()
assert [h["name"] for h in listed] == ["B", "A"]
+133
View File
@@ -0,0 +1,133 @@
from datetime import date, time
from app.models.habit import ALL_WEEKDAYS_MASK, Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.schemas.habit import HabitCreate, HabitUpdate
from app.services import habit_service
def _make_habit(db_session, user_id, name="아침 일찍 일어나기", **overrides):
data = HabitCreate(
name=name,
habit_type=overrides.pop("habit_type", HabitType.BUILD),
weekdays_mask=overrides.pop("weekdays_mask", ALL_WEEKDAYS_MASK),
condition_text=overrides.pop("condition_text", None),
reminder_time=overrides.pop("reminder_time", None),
)
return habit_service.create_habit(db_session, user_id, data)
def test_create_habit_defaults_to_active(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
assert habit.status == HabitStatus.ACTIVE
assert habit.user_id == test_user.id
assert habit.id is not None
def test_list_habits_scoped_to_user(db_session, test_user, other_user):
_make_habit(db_session, test_user.id, name="내 습관")
_make_habit(db_session, other_user.id, name="남의 습관")
mine = habit_service.list_habits(db_session, test_user.id)
assert [h.name for h in mine] == ["내 습관"]
def test_get_habit_returns_none_for_other_users_habit(db_session, test_user, other_user):
other_habit = _make_habit(db_session, other_user.id, name="남의 습관")
assert habit_service.get_habit(db_session, other_habit.id, test_user.id) is None
assert habit_service.get_habit(db_session, other_habit.id, other_user.id) is not None
def test_list_habits_filters_by_type_and_status(db_session, test_user):
build = _make_habit(db_session, test_user.id, name="빌드", habit_type=HabitType.BUILD)
quit_ = _make_habit(db_session, test_user.id, name="퀴트", habit_type=HabitType.QUIT)
habit_service.complete_habit(db_session, quit_)
active_builds = habit_service.list_habits(db_session, test_user.id, habit_type=HabitType.BUILD)
assert [h.id for h in active_builds] == [build.id]
completed = habit_service.list_habits(db_session, test_user.id, status=HabitStatus.COMPLETED)
assert [h.id for h in completed] == [quit_.id]
def test_update_habit_overwrites_fields(db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="원래 이름")
updated = habit_service.update_habit(
db_session,
habit,
HabitUpdate(name="바뀐 이름", habit_type=HabitType.BUILD, weekdays_mask=0b0000001, condition_text=" "),
)
assert updated.name == "바뀐 이름"
assert updated.weekdays_mask == 0b0000001
assert updated.condition_text is None # blank_condition_to_none 검증기 통과 확인
def test_delete_habit_removes_row_and_cascades_logs(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
db_session.add(HabitLog(habit_id=habit.id, log_date=date.today()))
db_session.commit()
habit_service.delete_habit(db_session, habit)
assert db_session.get(Habit, habit.id) is None
assert db_session.query(HabitLog).filter_by(habit_id=habit.id).count() == 0
def test_complete_and_reactivate_habit(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
completed = habit_service.complete_habit(db_session, habit)
assert completed.status == HabitStatus.COMPLETED
assert completed.completed_at is not None
reactivated = habit_service.reactivate_habit(db_session, habit)
assert reactivated.status == HabitStatus.ACTIVE
assert reactivated.completed_at is None
def test_reorder_habits_applies_given_order(db_session, test_user):
a = _make_habit(db_session, test_user.id, name="A")
b = _make_habit(db_session, test_user.id, name="B")
c = _make_habit(db_session, test_user.id, name="C")
habit_service.reorder_habits(db_session, test_user.id, [c.id, a.id, b.id])
ordered = habit_service.list_habits(db_session, test_user.id)
assert [h.name for h in ordered] == ["C", "A", "B"]
def test_reorder_habits_ignores_other_users_ids(db_session, test_user, other_user):
mine = _make_habit(db_session, test_user.id, name="내 것")
others = _make_habit(db_session, other_user.id, name="남의 것")
# 남의 habit_id가 섞여 들어와도 그 습관의 sort_order는 바뀌지 않아야 한다 (IDOR 방지).
habit_service.reorder_habits(db_session, test_user.id, [others.id, mine.id])
assert others.sort_order is None
assert mine.sort_order is not None
def test_list_active_habits_with_reminders_is_not_user_scoped(db_session, test_user, other_user):
with_reminder = _make_habit(db_session, test_user.id, name="알림 있음", reminder_time=time(9, 0))
_make_habit(db_session, other_user.id, name="알림 없음")
result = habit_service.list_active_habits_with_reminders(db_session)
assert [h.id for h in result] == [with_reminder.id]
def test_list_active_user_ids_is_not_user_scoped(db_session, test_user, other_user):
_make_habit(db_session, test_user.id)
_make_habit(db_session, other_user.id)
result = habit_service.list_active_user_ids(db_session)
assert set(result) == {test_user.id, other_user.id}
def test_list_active_user_ids_excludes_completed_only_users(db_session, test_user, other_user):
_make_habit(db_session, test_user.id)
completed_only = _make_habit(db_session, other_user.id)
habit_service.complete_habit(db_session, completed_only)
result = habit_service.list_active_user_ids(db_session)
assert result == [test_user.id]
+331
View File
@@ -0,0 +1,331 @@
import calendar
from datetime import date, datetime, timedelta
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.models.habit_log import HabitLog
from app.schemas.habit import HabitCreate
from app.schemas.habit_log import MonthlySummaryDay
from app.schemas.push import PushKeys, PushSubscribeRequest
from app.services import habit_service, log_service, push_service
def _make_habit(db_session, user_id, created_at=None, weekdays_mask=ALL_WEEKDAYS_MASK, **overrides):
data = HabitCreate(
name=overrides.pop("name", "테스트 습관"),
habit_type=overrides.pop("habit_type", HabitType.BUILD),
weekdays_mask=weekdays_mask,
condition_text=overrides.pop("condition_text", None),
reminder_time=overrides.pop("reminder_time", None),
)
habit = habit_service.create_habit(db_session, user_id, data)
if created_at is not None:
habit.created_at = created_at
db_session.commit()
db_session.refresh(habit)
return habit
def _check(db_session, habit_id, log_date):
db_session.add(HabitLog(habit_id=habit_id, log_date=log_date))
db_session.commit()
# ---- toggle_check ----
def test_toggle_check_sets_and_unsets(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
today = date.today()
assert log_service.toggle_check(db_session, habit.id, today) is True
assert db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=today).count() == 1
assert log_service.toggle_check(db_session, habit.id, today) is False
assert db_session.query(HabitLog).filter_by(habit_id=habit.id, log_date=today).count() == 0
# ---- toggle_check_and_celebrate ----
def test_toggle_check_and_celebrate_returns_milestone_on_streak_hit(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=6), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(6, 0, -1): # 6일 전부터 어제까지 6일 연속 체크, 오늘 체크하면 7일째
_check(db_session, habit.id, today - timedelta(days=offset))
checked, milestone = log_service.toggle_check_and_celebrate(db_session, habit, today)
assert checked is True
assert milestone == 7
def test_toggle_check_and_celebrate_returns_none_when_not_milestone(db_session, test_user):
habit = _make_habit(db_session, test_user.id)
checked, milestone = log_service.toggle_check_and_celebrate(db_session, habit, date.today())
assert checked is True
assert milestone is None
def test_toggle_check_and_celebrate_returns_none_on_uncheck(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=6), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(6, -1, -1): # 오늘까지 포함해 7일 연속 체크된 상태
_check(db_session, habit.id, today - timedelta(days=offset))
# 이미 체크된 오늘을 다시 토글하면 해제되어야 하고, 마일스톤 여부와 무관하게 None이어야 한다.
checked, milestone = log_service.toggle_check_and_celebrate(db_session, habit, today)
assert checked is False
assert milestone is None
def test_toggle_check_and_celebrate_sends_push_on_milestone(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
push_service.save_subscription(
db_session,
test_user.id,
PushSubscribeRequest(endpoint="https://push.example.com/x", keys=PushKeys(p256dh="p", auth="a")),
)
today = date.today()
created_at = datetime.combine(today - timedelta(days=6), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in range(6, 0, -1):
_check(db_session, habit.id, today - timedelta(days=offset))
log_service.toggle_check_and_celebrate(db_session, habit, today)
assert len(sent) == 1
# ---- get_today_items ----
def test_get_today_items_splits_by_type_and_checked_state(db_session, test_user):
today = date.today()
build = _make_habit(db_session, test_user.id, name="빌드", habit_type=HabitType.BUILD)
quit_ = _make_habit(db_session, test_user.id, name="퀴트", habit_type=HabitType.QUIT)
_check(db_session, build.id, today)
build_items, quit_items = log_service.get_today_items(db_session, test_user.id, today)
assert len(build_items) == 1 and build_items[0].checked is True
assert len(quit_items) == 1 and quit_items[0].checked is False
def test_get_today_items_excludes_habits_not_scheduled_today(db_session, test_user):
today = date.today()
other_day_mask = 1 << ((today.weekday() + 1) % 7) # 오늘이 아닌 요일 하나만 선택
_make_habit(db_session, test_user.id, weekdays_mask=other_day_mask)
build_items, quit_items = log_service.get_today_items(db_session, test_user.id, today)
assert build_items == []
assert quit_items == []
# ---- get_habit_stats: completion_rate ----
def test_get_habit_stats_completion_rate(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=4), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
# 5일(day-4..day0) 중 4일만 체크 (day-2만 스킵)
for offset in (4, 3, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.scheduled_days == 5
assert stats.checked_days == 4
assert stats.completion_rate == 80.0
# ---- get_habit_stats: current_streak ----
def test_streak_today_unchecked_does_not_break_it(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (3, 2, 1): # 오늘(offset=0)은 의도적으로 체크 안 함
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 3
def test_streak_breaks_on_past_miss(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=3), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
_check(db_session, habit.id, today - timedelta(days=3))
# day-2, day-1, 오늘 모두 미체크 -> day-1(과거)에서 스트릭이 끊긴다
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 0
def test_streak_extends_through_today_when_checked(db_session, test_user):
today = date.today()
created_at = datetime.combine(today - timedelta(days=2), datetime.min.time())
habit = _make_habit(db_session, test_user.id, created_at=created_at)
for offset in (2, 1, 0):
_check(db_session, habit.id, today - timedelta(days=offset))
stats = log_service.get_habit_stats(db_session, habit)
assert stats.current_streak == 3
# ---- get_monthly_summary ----
def test_get_monthly_summary_excludes_days_before_habit_created(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 10))
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
by_date = {s.log_date: s for s in summaries}
assert by_date[date(2026, 3, 5)].scheduled_count == 0 # 생성일 이전
assert by_date[date(2026, 3, 10)].scheduled_count == 1 # 생성일 당일부터 포함
assert by_date[date(2026, 3, 20)].scheduled_count == 1
def test_get_monthly_summary_respects_weekdays_mask(db_session, test_user):
monday_only_mask = 0b0000001 # bit0 = 월요일
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1), weekdays_mask=monday_only_mask)
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
days_in_month = calendar.monthrange(2026, 3)[1]
expected_mondays = {
date(2026, 3, d) for d in range(1, days_in_month + 1) if date(2026, 3, d).weekday() == 0
}
scheduled_dates = {s.log_date for s in summaries if s.scheduled_count == 1}
assert scheduled_dates == expected_mondays
def test_get_monthly_summary_counts_checked_habits(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
_check(db_session, habit.id, date(2026, 3, 5))
summaries = log_service.get_monthly_summary(db_session, test_user.id, 2026, 3)
by_date = {s.log_date: s for s in summaries}
assert by_date[date(2026, 3, 5)].checked_count == 1
assert by_date[date(2026, 3, 6)].checked_count == 0
# ---- summarize_completion_rate: 미래 날짜 제외 회귀 테스트 ----
# CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다
# (실제로 6.2% -> 수정 후 50%가 된 사례).
def test_summarize_completion_rate_excludes_future_dates():
up_to = date(2026, 3, 15)
summaries = [
MonthlySummaryDay(log_date=date(2026, 3, 13), scheduled_count=1, checked_count=1),
MonthlySummaryDay(log_date=date(2026, 3, 14), scheduled_count=1, checked_count=1),
MonthlySummaryDay(log_date=date(2026, 3, 15), scheduled_count=1, checked_count=1),
# 아래 두 날짜는 미래라서 아직 체크될 수 없는데, 집계에 섞이면 완료율이 부당하게 낮아진다.
MonthlySummaryDay(log_date=date(2026, 3, 16), scheduled_count=1, checked_count=0),
MonthlySummaryDay(log_date=date(2026, 3, 17), scheduled_count=1, checked_count=0),
]
rate = log_service.summarize_completion_rate(summaries, up_to)
assert rate == 100.0
def test_summarize_completion_rate_zero_when_nothing_scheduled():
summaries = [MonthlySummaryDay(log_date=date(2026, 3, 1), scheduled_count=0, checked_count=0)]
assert log_service.summarize_completion_rate(summaries, date(2026, 3, 1)) == 0.0
# ---- get_period_completion_rate ----
def test_get_period_completion_rate_basic(db_session, test_user):
habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1))
_check(db_session, habit.id, date(2026, 3, 2))
_check(db_session, habit.id, date(2026, 3, 3))
rate, scheduled, checked = log_service.get_period_completion_rate(
db_session, test_user.id, date(2026, 3, 1), date(2026, 3, 4)
)
assert scheduled == 4
assert checked == 2
assert rate == 50.0
def test_get_period_completion_rate_excludes_days_before_habit_created(db_session, test_user):
_make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 3))
rate, scheduled, checked = log_service.get_period_completion_rate(
db_session, test_user.id, date(2026, 3, 1), date(2026, 3, 5)
)
assert scheduled == 3 # 3/3, 3/4, 3/5만 포함 (생성일 이전인 3/1, 3/2 제외)
assert checked == 0
assert rate == 0.0
def test_get_period_completion_rate_zero_when_nothing_scheduled(db_session, test_user):
rate, scheduled, checked = log_service.get_period_completion_rate(
db_session, test_user.id, date(2026, 3, 1), date(2026, 3, 5)
)
assert scheduled == 0
assert checked == 0
assert rate == 0.0
# ---- get_weekly_matrix ----
def test_get_weekly_matrix_marks_pre_creation_days_as_none(db_session, test_user):
week_start = date(2020, 1, 6) # 월요일, 확실한 과거
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 8)) # 수요일
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.checks[date(2020, 1, 6).isoformat()] is None # 생성 전(월)
assert row.checks[date(2020, 1, 7).isoformat()] is None # 생성 전(화)
assert row.checks[date(2020, 1, 8).isoformat()] is False # 생성일(수), 미체크
def test_get_weekly_matrix_completion_rate_counts_only_past_days(db_session, test_user):
week_start = date(2020, 1, 6) # 완전히 과거인 주
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 6))
_check(db_session, habit.id, date(2020, 1, 6))
_check(db_session, habit.id, date(2020, 1, 7))
# 나머지 5일은 미체크
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.completion_rate == round(2 / 7 * 100, 1)
def test_get_weekly_matrix_future_week_has_zero_completion_rate(db_session, test_user):
week_start = date(2099, 1, 5) # 완전히 미래인 주
habit = _make_habit(db_session, test_user.id, created_at=datetime(2020, 1, 1))
rows = log_service.get_weekly_matrix(db_session, test_user.id, week_start)
row = next(r for r in rows if r.habit_id == habit.id)
assert row.completion_rate == 0.0
# ---- list_logs: 유저 스코핑 ----
def test_list_logs_scoped_to_user(db_session, test_user, other_user):
today = date.today()
mine = _make_habit(db_session, test_user.id)
others = _make_habit(db_session, other_user.id)
_check(db_session, mine.id, today)
_check(db_session, others.id, today)
logs = log_service.list_logs(db_session, test_user.id)
assert [log.habit_id for log in logs] == [mine.id]
+40
View File
@@ -0,0 +1,40 @@
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.schemas.habit import HabitCreate
from app.services import habit_service
def _make_habit(db_session, user_id, name="아침 운동"):
data = HabitCreate(name=name, habit_type=HabitType.BUILD, weekdays_mask=ALL_WEEKDAYS_MASK)
return habit_service.create_habit(db_session, user_id, data)
def test_today_page_redirects_to_login_when_not_authenticated(client):
response = client.get("/today", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_today_page_shows_created_habit(auth_client, db_session, test_user):
_make_habit(db_session, test_user.id, name="아침 운동")
response = auth_client.get("/today")
assert response.status_code == 200
assert "아침 운동" in response.text
def test_toggle_today_page_updates_checked_state(auth_client, db_session, test_user):
habit = _make_habit(db_session, test_user.id, name="아침 운동")
response = auth_client.post(f"/today/{habit.id}/toggle")
assert response.status_code == 200
# 서버가 실제로 로그를 남겼는지 today API로 재확인
today_items = auth_client.get("/api/today").json()
assert today_items[0]["checked"] is True
def test_toggle_today_page_for_other_users_habit_returns_404(auth_client, db_session, other_user):
others_habit = _make_habit(db_session, other_user.id, name="남의 습관")
response = auth_client.post(f"/today/{others_habit.id}/toggle")
assert response.status_code == 404
+79
View File
@@ -0,0 +1,79 @@
from pywebpush import WebPushException
from app.models.push_subscription import PushSubscription
from app.schemas.push import PushKeys, PushSubscribeRequest
from app.services import push_service
class _FakeResponse:
def __init__(self, status_code):
self.status_code = status_code
def _subscribe_request(endpoint="https://push.example.com/1"):
return PushSubscribeRequest(endpoint=endpoint, keys=PushKeys(p256dh="p256dh-key", auth="auth-key"))
# ---- save_subscription ----
def test_save_subscription_creates_new_row(db_session, test_user):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
assert sub.user_id == test_user.id
assert sub.endpoint == "https://push.example.com/1"
def test_save_subscription_reassigns_existing_endpoint_to_new_user(db_session, test_user, other_user):
request = _subscribe_request()
push_service.save_subscription(db_session, other_user.id, request)
# 같은 기기(endpoint)에서 다른 유저(test_user)로 재구독하면 소유자가 갱신되어야 한다.
updated = push_service.save_subscription(db_session, test_user.id, request)
assert updated.user_id == test_user.id
assert db_session.query(PushSubscription).filter_by(endpoint=request.endpoint).count() == 1
def test_delete_subscription_removes_row(db_session, test_user):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
push_service.delete_subscription(db_session, sub.endpoint)
assert db_session.query(PushSubscription).filter_by(endpoint=sub.endpoint).count() == 0
# ---- send_to_user ----
def test_send_to_user_counts_successful_sends(db_session, test_user, monkeypatch):
push_service.save_subscription(db_session, test_user.id, _subscribe_request("https://push.example.com/a"))
push_service.save_subscription(db_session, test_user.id, _subscribe_request("https://push.example.com/b"))
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: None)
sent = push_service.send_to_user(db_session, test_user.id, title="제목", body="본문")
assert sent == 2
def test_send_to_user_deletes_expired_subscription_on_410(db_session, test_user, monkeypatch):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
def _raise_gone(**kwargs):
raise WebPushException("gone", response=_FakeResponse(410))
monkeypatch.setattr(push_service, "webpush", _raise_gone)
sent = push_service.send_to_user(db_session, test_user.id, title="제목", body="본문")
assert sent == 0
assert db_session.query(PushSubscription).filter_by(id=sub.id).count() == 0
def test_send_to_user_keeps_subscription_on_other_errors(db_session, test_user, monkeypatch):
sub = push_service.save_subscription(db_session, test_user.id, _subscribe_request())
def _raise_server_error(**kwargs):
raise WebPushException("server error", response=_FakeResponse(500))
monkeypatch.setattr(push_service, "webpush", _raise_server_error)
sent = push_service.send_to_user(db_session, test_user.id, title="제목", body="본문")
assert sent == 0
assert db_session.query(PushSubscription).filter_by(id=sub.id).count() == 1
+110
View File
@@ -0,0 +1,110 @@
from datetime import date, datetime, timedelta
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.models.notification_log import SummaryNotificationLog
from app.schemas.habit import HabitCreate
from app.schemas.push import PushKeys, PushSubscribeRequest
from app.services import habit_service, push_service, scheduler_service
# scheduler_service._tick/_weekly_summary_tick/_monthly_summary_tick은 자체적으로
# app.database.SessionLocal()을 열어 실제 운영 DB에 붙으므로(conftest의 client 픽스처가 lifespan을
# 건너뛰는 이유와 동일) 여기서는 db_session을 직접 주입할 수 있는 _send_period_summaries/
# _claim_summary_slot만 단위 테스트한다.
def _make_habit(db_session, user_id, created_at=None, **overrides):
data = HabitCreate(
name=overrides.pop("name", "테스트 습관"),
habit_type=overrides.pop("habit_type", HabitType.BUILD),
weekdays_mask=overrides.pop("weekdays_mask", ALL_WEEKDAYS_MASK),
condition_text=overrides.pop("condition_text", None),
reminder_time=overrides.pop("reminder_time", None),
)
habit = habit_service.create_habit(db_session, user_id, data)
if created_at is not None:
habit.created_at = created_at
db_session.commit()
db_session.refresh(habit)
return habit
def _subscribe(db_session, user_id, endpoint):
push_service.save_subscription(
db_session, user_id, PushSubscribeRequest(endpoint=endpoint, keys=PushKeys(p256dh="p", auth="a"))
)
# ---- _claim_summary_slot ----
def test_claim_summary_slot_prevents_duplicate(db_session, test_user):
today = date.today()
assert scheduler_service._claim_summary_slot(db_session, test_user.id, "weekly", today) is True
assert scheduler_service._claim_summary_slot(db_session, test_user.id, "weekly", today) is False
assert db_session.query(SummaryNotificationLog).filter_by(user_id=test_user.id).count() == 1
# ---- _send_period_summaries ----
def test_send_period_summaries_sends_push_and_claims_slot(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
_subscribe(db_session, test_user.id, "https://push.example.com/x")
today = date.today()
_make_habit(db_session, test_user.id, created_at=datetime.combine(today, datetime.min.time()))
scheduler_service._send_period_summaries(
db_session,
period_type="weekly",
period_start=today,
range_start=today,
range_end=today,
title="이번 주 습관 리포트",
url="/history",
)
assert len(sent) == 1
assert (
db_session.query(SummaryNotificationLog).filter_by(user_id=test_user.id, period_type="weekly").count() == 1
)
def test_send_period_summaries_skips_when_nothing_scheduled_in_range(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
_subscribe(db_session, test_user.id, "https://push.example.com/y")
today = date.today()
# 습관이 range_end 이후에 생성되어, 요청한 기간에는 예정된 게 하나도 없다.
_make_habit(db_session, test_user.id, created_at=datetime.combine(today + timedelta(days=1), datetime.min.time()))
scheduler_service._send_period_summaries(
db_session, period_type="weekly", period_start=today, range_start=today, range_end=today, title="t", url="/h"
)
assert sent == []
assert db_session.query(SummaryNotificationLog).count() == 0
def test_send_period_summaries_does_not_resend_when_already_claimed(db_session, test_user, monkeypatch):
sent = []
monkeypatch.setattr(push_service, "webpush", lambda **kwargs: sent.append(kwargs))
_subscribe(db_session, test_user.id, "https://push.example.com/z")
today = date.today()
_make_habit(db_session, test_user.id, created_at=datetime.combine(today, datetime.min.time()))
for _ in range(2):
scheduler_service._send_period_summaries(
db_session,
period_type="weekly",
period_start=today,
range_start=today,
range_end=today,
title="t",
url="/h",
)
assert len(sent) == 1
+51
View File
@@ -0,0 +1,51 @@
import pytest
from pydantic import ValidationError
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
from app.schemas.habit import HabitCreate
def _base_kwargs(**overrides):
kwargs = {"name": "습관", "habit_type": HabitType.BUILD}
kwargs.update(overrides)
return kwargs
def test_blank_name_rejected():
with pytest.raises(ValidationError):
HabitCreate(**_base_kwargs(name=" "))
def test_name_is_stripped():
habit = HabitCreate(**_base_kwargs(name=" 아침 운동 "))
assert habit.name == "아침 운동"
def test_blank_condition_text_becomes_none():
habit = HabitCreate(**_base_kwargs(condition_text=" "))
assert habit.condition_text is None
def test_condition_text_is_stripped_when_present():
habit = HabitCreate(**_base_kwargs(condition_text=" 30분 이상 "))
assert habit.condition_text == "30분 이상"
def test_weekdays_mask_zero_rejected():
with pytest.raises(ValidationError):
HabitCreate(**_base_kwargs(weekdays_mask=0))
def test_weekdays_mask_over_max_rejected():
with pytest.raises(ValidationError):
HabitCreate(**_base_kwargs(weekdays_mask=ALL_WEEKDAYS_MASK + 1))
def test_weekdays_mask_single_day_accepted():
habit = HabitCreate(**_base_kwargs(weekdays_mask=0b0000001))
assert habit.weekdays_mask == 0b0000001
def test_weekdays_mask_defaults_to_all_days():
habit = HabitCreate(**_base_kwargs())
assert habit.weekdays_mask == ALL_WEEKDAYS_MASK
+31
View File
@@ -0,0 +1,31 @@
from app.models.habit import ALL_WEEKDAYS_MASK
from app.template_utils import heatmap_opacity, weekday_label
def test_weekday_label_all_days_shows_daily():
assert weekday_label(ALL_WEEKDAYS_MASK) == "매일"
def test_weekday_label_lists_selected_days_in_order():
monday_and_wednesday = 0b0000001 | 0b0000100
assert weekday_label(monday_and_wednesday) == "월, 수"
def test_weekday_label_no_days_selected():
assert weekday_label(0) == "선택된 요일 없음"
def test_heatmap_opacity_zero_when_nothing_scheduled():
assert heatmap_opacity(checked_count=0, scheduled_count=0) == 0.0
def test_heatmap_opacity_zero_when_nothing_checked():
assert heatmap_opacity(checked_count=0, scheduled_count=5) == 0.0
def test_heatmap_opacity_full_ratio_caps_at_point_nine():
assert heatmap_opacity(checked_count=5, scheduled_count=5) == 0.9
def test_heatmap_opacity_partial_ratio():
assert heatmap_opacity(checked_count=1, scheduled_count=2) == round(0.12 + 0.5 * 0.78, 2)