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:
@@ -0,0 +1,13 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
logs/
|
||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
@@ -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
@@ -0,0 +1,14 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.dev
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
node_modules/
|
||||||
|
logs/
|
||||||
|
img*.png
|
||||||
|
image.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://<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
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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")
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PushKeys(BaseModel):
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscribeRequest(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: PushKeys
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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 |
@@ -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");
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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 };
|
||||||
|
})();
|
||||||
Vendored
+5
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>홈 화면에 추가하면 알림도 받을 수 있어요. 공유 버튼 → ‘홈 화면에 추가’를 눌러보세요.</span>
|
||||||
|
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">×</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>
|
||||||
@@ -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 %}
|
||||||
@@ -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">✓</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 %}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 %}✓{% 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>
|
||||||
@@ -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 %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: habit-tracker:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -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*"]
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 컨테이너 시작 시마다 마이그레이션을 적용한다. 이미 적용된 리비전은 그냥 건너뛰므로
|
||||||
|
# 여러 번 실행해도 안전하다(idempotent).
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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]
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user