From cee589bb3eeecdb52deaf9c18c75b00676d4bb5c Mon Sep 17 00:00:00 2001 From: shinalok Date: Thu, 16 Jul 2026 18:06:17 +0900 Subject: [PATCH] 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 --- .dockerignore | 13 + .env.example | 22 + .gitignore | 14 + CLAUDE.md | 100 +++ Dockerfile | 23 + README.md | 129 ++++ alembic.ini | 38 ++ app/__init__.py | 0 app/config.py | 22 + app/database.py | 21 + app/main.py | 77 +++ app/models/__init__.py | 16 + app/models/habit.py | 48 ++ app/models/habit_log.py | 19 + app/models/notification_log.py | 33 + app/models/push_subscription.py | 21 + app/models/user.py | 18 + app/routers/__init__.py | 0 app/routers/auth.py | 65 ++ app/routers/habits.py | 81 +++ app/routers/logs.py | 52 ++ app/routers/pages.py | 304 +++++++++ app/routers/push.py | 46 ++ app/schemas/__init__.py | 0 app/schemas/habit.py | 62 ++ app/schemas/habit_log.py | 45 ++ app/schemas/push.py | 11 + app/security.py | 43 ++ app/services/habit_service.py | 100 +++ app/services/log_service.py | 254 +++++++ app/services/push_service.py | 69 ++ app/services/scheduler_service.py | 180 +++++ app/static/css/style.css | 640 ++++++++++++++++++ app/static/icons/icon-192.png | Bin 0 -> 918 bytes app/static/icons/icon-512.png | Bin 0 -> 3152 bytes app/static/icons/icon-maskable-512.png | Bin 0 -> 3152 bytes app/static/js/app.js | 35 + app/static/js/habit-reorder.js | 27 + app/static/js/push-register.js | 63 ++ app/static/js/vendor/alpine.min.js | 5 + app/static/js/vendor/htmx.min.js | 1 + app/static/js/vendor/sortable.min.js | 2 + app/static/manifest.json | 17 + app/static/service-worker.js | 103 +++ app/template_utils.py | 25 + app/templates/404.html | 11 + app/templates/500.html | 11 + app/templates/base.html | 65 ++ app/templates/habits.html | 32 + app/templates/history.html | 92 +++ app/templates/login.html | 11 + .../partials/_weekday_alarm_fields.html | 28 + app/templates/partials/habit_form.html | 42 ++ app/templates/partials/habit_item.html | 94 +++ app/templates/partials/today_content.html | 43 ++ app/templates/partials/today_item.html | 27 + app/templates/today.html | 8 + docker-compose.yml | 11 + migrations/env.py | 42 ++ migrations/script.py.mako | 25 + migrations/versions/0001_initial.py | 76 +++ .../versions/0002_add_condition_text.py | 24 + migrations/versions/0003_add_user_table.py | 34 + .../versions/0004_add_user_id_columns.py | 41 ++ .../0005_add_summary_notification_log.py | 34 + pyproject.toml | 31 + scripts/claim_orphan_habits.py | 34 + scripts/docker-entrypoint.sh | 8 + scripts/generate_icons.py | 50 ++ scripts/generate_vapid_keys.py | 19 + scripts/run_server.ps1 | 23 + tests/conftest.py | 79 +++ tests/test_api_habits.py | 81 +++ tests/test_habit_service.py | 133 ++++ tests/test_log_service.py | 331 +++++++++ tests/test_pages_today.py | 40 ++ tests/test_push_service.py | 79 +++ tests/test_scheduler_service.py | 110 +++ tests/test_schemas_habit.py | 51 ++ tests/test_template_utils.py | 31 + 80 files changed, 4695 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/habit.py create mode 100644 app/models/habit_log.py create mode 100644 app/models/notification_log.py create mode 100644 app/models/push_subscription.py create mode 100644 app/models/user.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/habits.py create mode 100644 app/routers/logs.py create mode 100644 app/routers/pages.py create mode 100644 app/routers/push.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/habit.py create mode 100644 app/schemas/habit_log.py create mode 100644 app/schemas/push.py create mode 100644 app/security.py create mode 100644 app/services/habit_service.py create mode 100644 app/services/log_service.py create mode 100644 app/services/push_service.py create mode 100644 app/services/scheduler_service.py create mode 100644 app/static/css/style.css create mode 100644 app/static/icons/icon-192.png create mode 100644 app/static/icons/icon-512.png create mode 100644 app/static/icons/icon-maskable-512.png create mode 100644 app/static/js/app.js create mode 100644 app/static/js/habit-reorder.js create mode 100644 app/static/js/push-register.js create mode 100644 app/static/js/vendor/alpine.min.js create mode 100644 app/static/js/vendor/htmx.min.js create mode 100644 app/static/js/vendor/sortable.min.js create mode 100644 app/static/manifest.json create mode 100644 app/static/service-worker.js create mode 100644 app/template_utils.py create mode 100644 app/templates/404.html create mode 100644 app/templates/500.html create mode 100644 app/templates/base.html create mode 100644 app/templates/habits.html create mode 100644 app/templates/history.html create mode 100644 app/templates/login.html create mode 100644 app/templates/partials/_weekday_alarm_fields.html create mode 100644 app/templates/partials/habit_form.html create mode 100644 app/templates/partials/habit_item.html create mode 100644 app/templates/partials/today_content.html create mode 100644 app/templates/partials/today_item.html create mode 100644 app/templates/today.html create mode 100644 docker-compose.yml create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0001_initial.py create mode 100644 migrations/versions/0002_add_condition_text.py create mode 100644 migrations/versions/0003_add_user_table.py create mode 100644 migrations/versions/0004_add_user_id_columns.py create mode 100644 migrations/versions/0005_add_summary_notification_log.py create mode 100644 pyproject.toml create mode 100644 scripts/claim_orphan_habits.py create mode 100644 scripts/docker-entrypoint.sh create mode 100644 scripts/generate_icons.py create mode 100644 scripts/generate_vapid_keys.py create mode 100644 scripts/run_server.ps1 create mode 100644 tests/conftest.py create mode 100644 tests/test_api_habits.py create mode 100644 tests/test_habit_service.py create mode 100644 tests/test_log_service.py create mode 100644 tests/test_pages_today.py create mode 100644 tests/test_push_service.py create mode 100644 tests/test_scheduler_service.py create mode 100644 tests/test_schemas_habit.py create mode 100644 tests/test_template_utils.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2236ba3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.env +.git +.gitignore +.idea +__pycache__/ +**/__pycache__/ +*.pyc +.pytest_cache/ +logs/ +node_modules/ +.venv/ +venv/ +*.egg-info/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ecc2d9 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# 이 파일을 복사해 .env로 저장한 뒤 값을 채워넣으세요. .env는 git에 커밋되지 않습니다. + +# 세션 쿠키 서명에 쓰이는 임의의 긴 무작위 문자열 (예: python -c "import secrets; print(secrets.token_hex(32))") +SECRET_KEY=change-me + +# 원격 MariaDB 서버 접속 정보 +# 형식: mysql+pymysql://:@:/ +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://..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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36cd880 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.pyc +.env +.env.dev +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.idea/ +*.iml +node_modules/ +logs/ +img*.png +image.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dab8667 --- /dev/null +++ b/CLAUDE.md @@ -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://:8000`은 iOS Safari에서 보안 컨텍스트로 인정되지 않아 푸시가 동작하지 않는다. Tailscale의 `tailscale serve --bg 8000`으로 해결 — 별도 인증서 관리 없이 tailnet 내에서 신뢰된 HTTPS(`https://..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`가 실제 뷰포트에 반영되지 않는 문제가 있어, 실제 창 크기를 바꾸는 대신 `