Compare commits

...
27 Commits
Author SHA1 Message Date
shinalok 74bfe34d3f Merge branch 'ios-only-plain-textarea-fallback' into main
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m38s
2026-08-27 08:16:10 +09:00
shinalokandClaude Sonnet 5 6000de166f keep EasyMDE on desktop/Android, fall back to plain textarea only on iOS
Dropping EasyMDE entirely (previous commit) fixed the Korean IME jamo split
but also removed the live markdown syntax highlighting everywhere, even on
platforms that never had the bug. Only iOS WebKit breaks CJK IME composition
under CodeMirror, so journal-editor.js now detects iOS (including the
desktop-UA-masquerading iPad, via MacIntel + multi-touch) and only skips
EasyMDE there, falling back to a native <textarea>. Everywhere else keeps
the full EasyMDE/CodeMirror editing experience as before.

window.JournalEditor now dispatches to the right engine per textarea
(checking t._easymde) so the toolbar buttons and the category-template
autofill in journal.html work unchanged regardless of which engine backs a
given textarea. Restores the easymde vendor files, base.html includes, and
service worker precache entries removed in the previous commit (cache
bumped to v8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 08:16:06 +09:00
shinalok f524157326 Merge branch 'remove-easymde-fix-ime' into main 2026-08-26 20:23:13 +09:00
shinalokandClaude Sonnet 5 ac98cf4f99 replace EasyMDE journal editor with plain textarea to fix iOS Korean IME jamo split
Forcing inputStyle to contenteditable (previous commit) didn't fix it on
iPhone, confirming this is CodeMirror 5's own long-standing weakness with
CJK IME composition on iOS WebKit, not just the iPad desktop-UA detection
issue. There's no reliable fix short of dropping CodeMirror for the journal
content field, so EasyMDE is removed entirely and the textarea goes back to
a native, uncontrolled <textarea> — nothing intercepts/re-renders it during
composition, so iOS's IME just works.

The markdown toolbar buttons (bold/italic/heading/quote/lists/code/link)
now manipulate the textarea's selection directly via a small JournalEditor
JS API instead of calling EasyMDE/CodeMirror commands, and image paste
tracks its upload placeholder by a unique text marker instead of a
CodeMirror bookmark. Also drops the vendored easymde.min.js/css (no longer
referenced) and bumps the service worker CACHE_NAME so stale cached copies
of the old vendor files get evicted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 20:23:09 +09:00
shinalok 291813820b Merge branch 'fix-ios-ime-jamo-split' into main
CI/CD / test (push) Successful in 48s
CI/CD / deploy (push) Successful in 1m33s
2026-08-26 12:43:58 +09:00
shinalokandClaude Sonnet 5 779bbb58aa force EasyMDE contenteditable input mode to fix Korean IME jamo splitting on iPad
iPadOS 13+ sends a desktop Safari UA by default, which fools CodeMirror's mobile detection into using the more fragile textarea input mode. That, combined with CodeMirror repainting the line mid-composition, breaks Korean IME composition and leaves jamo unmerged. inputStyle can't be changed after the editor is created, so it must be set in the EasyMDE constructor options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 12:43:45 +09:00
shinalok 46c412f8f3 Merge pull request 'enlarge journal calendar cells and pair day/title on one line' (#1) from journal-calendar-ui-tweaks into main
CI/CD / test (push) Successful in 30s
CI/CD / deploy (push) Successful in 2m15s
Reviewed-on: #1
2026-08-19 17:18:22 +09:00
shinalokandClaude Sonnet 5 8a49757882 enlarge journal calendar cells and pair day/title on one line
CI/CD / test (pull_request) Successful in 51s
CI/CD / deploy (pull_request) Skipped
- calendar-cell-journal: bump min-height 44px -> 76px, scale up date/dot/title font sizes
- journal calendar titles were centered; left-align them
- rework JournalCalendarDay: replace separate category_colors/titles lists
  (mismatched lengths, no way to pair a dot with its title) with a single
  entries list of (color, title) pairs so the dot and its title render on
  the same non-wrapping row per entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 17:13:38 +09:00
shinalokandClaude Opus 5 b173911269 make the app name the largest text on the landing page
CI/CD / test (push) Successful in 32s
CI/CD / deploy (push) Successful in 1m29s
The branding review no longer flags the missing purpose description, but
still reports that the app name does not match the homepage — even though
the console value and the h1 text are identical strings.

The likely reason is visual hierarchy: the app name was a 15px label while
the tagline underneath it was 32px, so the tagline read as the site's name.
The name and the tagline now swap sizes, and the logo moves above the
heading instead of sitting inline next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:34:15 +09:00
shinalokandClaude Opus 5 0e8688c25b make app name and purpose machine-readable on the landing page
CI/CD / test (push) Successful in 45s
CI/CD / deploy (push) Successful in 1m29s
Google OAuth branding review rejected the app twice with "no description of
the app purpose on the homepage" and "app name does not match the homepage",
even though both were present in Korean. Two likely causes, both addressed:

- The h1 wrapped an alt="" logo image before the text, so the app name could
  not be extracted from it. The image now sits outside the h1, leaving the
  heading as plain text.
- The page was Korean-only. The app name is now "해빗랩 (HabitLab)" (matching
  the console exactly), and the title, meta description and a new About
  section carry an English description of the app purpose and its use of
  Google account data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:29:42 +09:00
shinalok ad5a1c834c add terms page, improve landing content for branding review, and handle HTTP HEAD requests
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Successful in 1m44s
2026-08-08 21:30:54 +09:00
shinalok be8a4e6908 add terms page, improve landing content for branding review, and handle HTTP HEAD requests
CI/CD / test (push) Successful in 46s
CI/CD / deploy (push) Successful in 1m44s
2026-08-08 20:40:47 +09:00
shinalok e752367f79 remove SSH debug connection test from CI/CD workflow for cleaner output and simplify rsync verbosity
CI/CD / test (push) Successful in 42s
CI/CD / deploy (push) Successful in 43s
2026-08-08 07:38:50 +09:00
shinalok 689d32e18b update CI/CD workflow: add unused Docker image cleanup step
CI/CD / test (push) Successful in 43s
CI/CD / deploy (push) Successful in 49s
2026-08-08 07:35:14 +09:00
shinalok bf0225d53b add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Successful in 1m57s
2026-08-08 07:25:51 +09:00
shinalok 5b1a9ac683 add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 43s
CI/CD / deploy (push) Failing after 37s
2026-08-08 07:13:27 +09:00
shinalok 97c79b8b04 add SSH debug connection test to CI/CD workflow and enable batch mode for rsync
CI/CD / test (push) Successful in 53s
CI/CD / deploy (push) Failing after 43s
2026-08-08 07:07:38 +09:00
shinalok a0a7a70c14 update CI/CD workflow: decode base64 SSH key and validate key integrity
CI/CD / test (push) Successful in 40s
CI/CD / deploy (push) Failing after 35s
2026-08-07 22:32:57 +09:00
shinalok c0d14967d0 update CI/CD workflow: install development dependencies for testing with pip
CI/CD / test (push) Successful in 51s
CI/CD / deploy (push) Failing after 1m0s
2026-08-07 19:31:07 +09:00
shinalok c09aadaeb5 update CI/CD workflow: replace containerized testing and deployment with system-installed dependencies
CI/CD / test (push) Failing after 3m54s
CI/CD / deploy (push) Has been skipped
2026-08-07 19:23:47 +09:00
shinalok 61142ed55e add CI/CD workflow for testing and deployment steps on main branch push
CI/CD / test (push) Failing after 8s
CI/CD / deploy (push) Has been skipped
2026-08-07 19:04:30 +09:00
shinalokandClaude Sonnet 5 00c66f9df8 journal: paste image from clipboard, cap embedded image size, fix media persistence
- Paste-to-embed: pasting an image into the markdown editor uploads it and
  inserts ![](url) at the cursor. Unlike gallery attachments these aren't
  tied to a journal_entry (the entry may not exist yet while composing), so
  they're stored per-user under app/media/journal/{user_id}/pasted/ with no
  DB row, served through an ownership-scoped route, and never cleaned up
  automatically when an entry is deleted -- an accepted tradeoff at this
  app's personal scale.
- The markdown sanitizer was stripping all <img> tags (not on the bleach
  allowlist), which would have silently deleted every pasted image on save;
  added img/src/alt/title while keeping event-handler attributes blocked.
- Cap embedded image width in both the editor pane and the rendered preview
  so a large pasted photo can't overflow its card.
- Fix real data loss risk found while testing this: docker-compose.yml had
  no volume for app/media, so every container recreate during a deploy wiped
  uploaded photos, and deploy_sftp.py was syncing app/media/ (runtime user
  data, not source) into the remote build context. Added the volume mount
  and excluded media/ from the sync script. Recovered and relocated the
  real attachments that had already landed in the wrong place on the NAS
  during earlier deploys this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:27:30 +09:00
shinalokandClaude Sonnet 5 bdf9d0bae7 journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for
  markdown authoring: syntax highlighting, smart list continuation, and a
  custom text-based toolbar (built-in EasyMDE toolbar icons require Font
  Awesome from a CDN, which this app doesn't use). unorderedListStyle is set
  to "-" to match the app's own template convention.
- Add a preview/edit toggle button that swaps the editor for the exact same
  server-rendered markdown (via /journal/preview) shown after saving, instead
  of always showing both.
- Fix create/edit entry routes to verify the submitted category_id actually
  belongs to the current user before inserting -- every other write path in
  this app already checked ownership; this one didn't (found while manually
  testing the new editor with a typo'd category id that happened to belong to
  someone else's category, which surfaced as an IntegrityError 500 instead of
  a clean 404-equivalent).
- Fix the default "일상" category template: bare "-" bullet lines don't parse
  as list items in the markdown renderer (they need a trailing space), and
  the content_template validator was silently stripping that trailing space
  off on every save. Backfill migration updates any category still holding
  the old, broken template text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:18 +09:00
shinalokandClaude Sonnet 5 c466cc6639 journal: multi-select emotions and per-category writing templates
- Mood switches from a single enum column to a many-to-many
  JournalEntryMood table so an entry can carry several feelings at
  once; the vocabulary is trimmed to 9 named emotions (dropped the
  overlapping satisfaction scale) with unified noun-style labels.
- Categories can define a content_template that pre-fills the "new
  entry" textarea when selected (only if the user hasn't started
  typing), seeded with a Story/Feelings/Decisions/Insights/Actions
  reflection template on the default "일상" category.
- Fixes a real attribute-injection bug found while building the
  template feature: Jinja's built-in |tojson filter doesn't escape
  double quotes, which breaks a double-quoted x-data="..." attribute
  when the JSON payload contains one; added |forceescape and a
  regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 19:12:59 +09:00
shinalokandClaude Sonnet 5 54c971875c add journaling feature: categories, tags, attachments, calendar, and multi-select emotions
Adds an 8th development stage that lets users keep a free-form journal alongside
habit tracking, reusing the existing Google OAuth/DB/PWA infrastructure instead
of a separate project. Users organize entries into custom categories, filter by
a month calendar with day-detail drill-down, attach photos/videos (served via an
authenticated route, never /static), tag entries, and pick multiple emotions per
entry from a curated 9-option set. Includes a global journal prompt bank for
lightweight guided journaling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 18:40:07 +09:00
shinalokandClaude Sonnet 5 ef2a3ea082 prepare app for public app store distribution
- rebrand from 습관 트래커 to 해빗랩 across templates, manifest, service worker
- add HTTPS redirect middleware for reverse-proxied deployments
- add public landing page (/) and privacy policy page with real data
  handling disclosures
- add in-app account deletion (Apple review requirement)
- add Android TWA Digital Asset Links support (/.well-known/assetlinks.json)
- add SFTP deployment script for the Synology-hosted server

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:41:50 +09:00
shinalokandClaude Sonnet 5 34a128a79b reorder habit item badges to show level before completion rate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 13:41:24 +09:00
55 changed files with 4419 additions and 34 deletions
+4
View File
@@ -20,3 +20,7 @@ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com
# 저널 사진/영상 첨부 저장 경로(로컬 디스크)와 업로드 용량 제한(MB). 둘 다 기본값이 있어 생략 가능.
# JOURNAL_MEDIA_ROOT=app/media/journal
# JOURNAL_MAX_UPLOAD_MB=20
+72
View File
@@ -0,0 +1,72 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
- name: Python 3.13 설치
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: 테스트용 .env 준비
run: |
cat > .env <<'EOF'
SECRET_KEY=ci-dummy-secret-key
DATABASE_URL=sqlite:///./ci.db
EOF
- name: 의존성 설치
run: pip install -e ".[dev]"
- name: pytest 실행
run: pytest
deploy:
needs: test
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: 저장소 체크아웃
uses: actions/checkout@v4
- name: ssh/rsync 설치
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends openssh-client rsync
- name: SSH 키 준비
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_HOST }} >> ~/.ssh/known_hosts
# 키가 깨진 채로 저장됐다면 여기서 바로 에러가 나서 원인을 알 수 있음
ssh-keygen -y -f ~/.ssh/id_ed25519 > /dev/null
- name: 소스 동기화
run: |
rsync -avz \
--exclude='.env' --exclude='media' --exclude='__pycache__' \
-e "ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -p ${{ vars.DEPLOY_SSH_PORT }}" \
pyproject.toml alembic.ini Dockerfile docker-compose.yml app migrations scripts \
${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }}:${{ vars.DEPLOY_REMOTE_PATH }}/
- name: 컨테이너 재빌드 & 재시작
run: |
ssh -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }} \
"export PATH=\$PATH:/usr/local/bin && cd ${{ vars.DEPLOY_REMOTE_PATH }} && docker compose build && docker compose up -d"
- name: 안 쓰는 이미지 정리
run: |
ssh -p ${{ vars.DEPLOY_SSH_PORT }} ${{ vars.DEPLOY_SSH_USER }}@${{ vars.DEPLOY_SSH_HOST }} \
"export PATH=\$PATH:/usr/local/bin && docker image prune -f"
+2
View File
@@ -2,6 +2,7 @@ __pycache__/
*.pyc
.env
.env.dev
deploy.env
.venv/
venv/
*.egg-info/
@@ -12,3 +13,4 @@ node_modules/
logs/
img*.png
image.md
app/media/
+15 -5
View File
@@ -36,8 +36,8 @@ pytest tests/test_habits.py::test_name # 단일 테스트
### 요청 흐름 (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/habits.py`, `app/routers/logs.py`, `app/routers/push.py`, `app/routers/journal.py``/api/*` 하위, JSON in/out API. 라우터 레벨 `dependencies=[Depends(require_login)]`로 기본 보호되고, 유저 객체가 필요한 각 엔드포인트는 `current_user: User = Depends(require_login)`을 시그니처에 추가로 선언한다(FastAPI가 같은 요청 안에서 dependency를 캐싱하므로 DB 조회가 중복되지 않는다). 미인증 시 401 JSON을 반환. `journal.py`는 현재 첨부파일 스트리밍(`GET /api/journal/media/{attachment_id}`) 하나뿐이다.
- `app/routers/pages.py`, `app/routers/journal_pages.py` — SSR 페이지(`/login`, `/today`, `/habits`, `/history`, `/journal`)와 htmx가 폼 제출로 호출하는 액션 엔드포인트(`/habits/new`, `/habits/{id}/complete`, `/journal/new` 등). 미인증 시 401 대신 `/login`으로 303 리다이렉트한다 — `_current_user_or_redirect(request, db)``User`(로그인됨) 또는 `RedirectResponse`(미인증)를 반환하고, 각 핸들러는 `isinstance(current, RedirectResponse)`로 분기한다. 폼 액션은 대부분 처리 후 `HX-Redirect` 헤더로 같은 탭을 새로고침하는 방식으로 단순화되어 있다(부분 DOM 스왑이 아님) — 단 `/today`의 체크 토글과 `/journal`의 day-detail 내부 액션(수정/삭제)처럼 이미 htmx partial 안에 있는 경우는 예외로, 그 partial을 다시 렌더링해서 돌려준다. `journal_pages.py`는 별도 파일이지만 `pages.py``_current_user_or_redirect``templates`(Jinja2Templates 인스턴스)를 그대로 import해서 재사용한다 — `pages.py`가 계속 비대해지는 걸 막기 위해 기능별로 페이지 라우터 파일을 분리하기 시작한 첫 사례.
- `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/`에 두고 재사용할 것.
@@ -70,7 +70,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트
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 필요, 런타임 의존성 아님)로 생성.
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 필요)로 생성. Pillow는 8단계(저널링)의 첨부 이미지 썸네일 생성에도 쓰이기 시작해 지금은 런타임 의존성이다(`pyproject.toml``dependencies`에 있음, 예전엔 `dev` 전용이었음).
- **캐싱 전략 (중요)**: 처음엔 모든 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`를 모두 정리한 뒤 하나만 새로 띄울 것.
@@ -87,14 +87,24 @@ pytest tests/test_habits.py::test_name # 단일 테스트
- **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`까지 도달하는지 확인해야 한다.
8. ✅ 저널링 — 습관 체크에 곁들이는 회고와, 습관과 무관한 자유 일기를 함께 지원한다. 카테고리(사용자가 자유롭게 만드는 "일상"/"투자" 같은 저널 묶음, `JournalCategory`), 태그, 사진/영상 첨부, 월별 캘린더, 고정 질문 템플릿, 기분 트래킹, "1년 전 오늘" 회상을 이번 출시에 포함했고 AI 기반 프롬프트/피드백만 후속 업데이트로 미뤘다. 별도 프로젝트로 분리하지 않고 기존 구글 OAuth/유저/DB/PWA 인프라를 재사용했다.
- **라우터 파일이 하나 더 늘었다**: 위 "요청 흐름" 절에서 설명한 API(`habits.py`/`logs.py`/`push.py`) vs 페이지(`pages.py`) 2계열 구조를 그대로 따르되, `pages.py`를 더 비대하게 만들지 않으려고 저널 전용 파일을 새로 뺐다 — `app/routers/journal.py`(`/api/journal/*`, 현재는 첨부파일 스트리밍 `GET /api/journal/media/{attachment_id}` 하나뿐)와 `app/routers/journal_pages.py`(`/journal` 캘린더·day-detail·엔트리/카테고리 CRUD, `pages.py``_current_user_or_redirect``templates`를 그대로 import해서 재사용). 앞으로 다른 기능도 규모가 커지면 `pages.py`에 계속 얹기보다 이 패턴(기능별 페이지 라우터 파일 분리)을 따를 것.
- **데이터 모델**: `app/models/journal.py``JournalCategory`(user별 유니크 이름), `JournalEntry`(entry_date와 created_at 분리 — entry_date는 사용자가 지정 가능해 어제 일을 오늘 쓸 수 있고, 하루+카테고리당 여러 엔트리를 허용하므로 유니크 제약이 없다), `JournalTag`/`JournalEntryTag`(M:N), `JournalEntryMood`, `JournalAttachment`, `JournalPrompt`(카테고리 무관 전역 질문 뱅크, 마이그레이션에서 시드 데이터 삽입)가 있다. 카테고리 FK는 non-null이고, 대신 `journal_service.ensure_default_category`가 유저의 첫 저널 진입 시 카테고리가 하나도 없으면 "일상"을 자동 생성해 "카테고리 없음" 케이스를 아예 없앤다.
- **기분(mood)은 엔트리당 하나가 아니라 여러 개를 태그처럼 붙일 수 있다**: 처음엔 `JournalEntry.mood`가 단일 nullable enum 컬럼이었는데(0010 마이그레이션), 감정을 동시에 여러 개 고를 수 있어야 한다는 요구로 0011 마이그레이션에서 그 컬럼을 지우고 `JournalEntryMood(entry_id, mood)` 연결 테이블로 옮겼다 — `JournalTag`와 달리 mood는 고정된 enum 값 집합이라 별도 이름 엔티티 없이 값 자체를 복합 PK로 쓴다(`journal_tag`처럼 이름 조회/생성 로직이 필요 없음). `JournalMood`는 만족도 스케일 5종(최고/좋음/보통/별로/힘듦)에 구체적 감정 9종(아픔/성취/분노/신남/평온/행복/걱정/피곤/슬픔)을 더해 총 14종이다. 폼에서는 Alpine 배열(`moods: []`)로 다중 토글 pill을 만들고 쉼표로 join한 hidden input 하나로 제출한다(태그 입력과 동일한 패턴, `journal_pages._parse_moods`가 서버에서 다시 분해). pill 목록/이모지는 `journal_pages.py``JOURNAL_MOOD_OPTIONS`에 한 곳에 정의해 `templates.env.globals`로 등록, 작성/수정 폼과 day-detail 표시 양쪽에서 재사용한다.
- **첨부파일은 `/static`이 아니라 인증된 라우트로 서빙한다**: 이 앱의 유일한 `StaticFiles` 마운트(`/static`)는 완전 공개라 사진/영상처럼 유저별로 비공개여야 하는 파일을 두면 안 된다. `journal_service.save_attachment``settings.journal_media_root`(기본 `app/media/journal/{user_id}/{entry_id}/`, `.gitignore`에 등록됨) 아래 로컬 디스크에 저장하고, `GET /api/journal/media/{attachment_id}``JournalAttachment→JournalEntry.user_id` 소유권을 확인한 뒤에만 `FileResponse`로 스트리밍한다. 이미지 첨부는 Pillow로 가로 400px 썸네일(`?thumbnail=true` 쿼리로 구분)을 만들어 목록/캘린더에서 원본 대신 가볍게 로드한다 — 영상은 썸네일을 만들지 않고 재생 링크만 보여준다(ffmpeg 등 별도 도구가 필요해 v1 범위 밖으로 미룸).
- **미디어 라우트를 `/api/` 밑에 둔 이유**: `service-worker.js`의 fetch 핸들러는 `/api/`로 시작하는 GET 요청을 무조건 네트워크로 그냥 통과시키고 캐싱하지 않는다(아래 4단계 캐싱 전략 참고). 저널 미디어를 `/api/journal/media/...`에 둠으로써 이 기존 규칙에 공짜로 올라타 서비스워커를 전혀 건드리지 않고도 "비공개 사진/영상이 클라이언트 캐시에 무기한 남는" 문제를 피했다 — 만약 `/journal/media/...`처럼 `/api/` 밖에 뒀다면 정적 자산과 똑같이 stale-while-revalidate로 캐싱돼버렸을 것.
- **캘린더/day-detail은 `/history`의 기존 패턴을 그대로 재사용했다**: `calendar.Calendar(firstweekday=6).monthdatescalendar()`로 월 그리드를 만들고, 날짜를 클릭하면 htmx로 `#day-detail`에 partial을 swap하는 구조가 동일하다. 다만 의미가 달라 `heatmap_opacity`(습관 완료율 기반 투명도)는 재사용하지 않고, 그날 등장한 카테고리 색상을 점(`.journal-day-dot`)으로 표시하는 방식을 새로 만들었다.
- **엔트리 수정/삭제/첨부삭제는 `HX-Redirect`가 아니라 `#day-detail` partial을 다시 렌더링해서 돌려준다**: `/today`의 체크 토글과 같은 이유 — 이미 htmx로 `#day-detail`에 로드된 상태에서 벌어지는 액션이라 전체 페이지 리다이렉트 대신 그 자리에서 갱신하는 게 자연스럽다. 검증 실패 시에도 같은 partial을 `edit_error`/`editing_entry_id` 컨텍스트와 함께 다시 렌더링해 해당 엔트리의 수정 폼이 열린 채로 에러 메시지를 보여준다(`habit_item.html`의 Alpine `editing` 토글과 같은 아이디어를 서버 렌더링 쪽에서 구현한 것).
- **테스트는 SQLite(단위) + 실제 MariaDB(수동 curl/httpx 스모크)로 이중 검증했다**: "1년 전 오늘" 회상과 랜덤 프롬프트 뽑기를 처음엔 각각 `MONTH()`/`DAY()`, `RANDOM()` 같은 DB 함수로 짜려고 했는데, 이 프로젝트의 pytest는 SQLite 인메모리 DB를 쓰고 운영은 MariaDB라 방언이 다르면(`RANDOM()` vs `RAND()` 등) 테스트만 통과하고 운영에서 깨질 위험이 있었다. 그래서 두 기능 다 파이썬 레벨 필터링/`random.choice()`로 바꿔 방언 종속성을 아예 없앴다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다. 한글 저장/조회가 실제 MariaDB(`utf8mb4`)에서도 깨지지 않는지는 `httpx`로 직접 폼을 제출해 왕복 검증했다(터미널에 출력할 때는 콘솔 코드페이지 때문에 깨져 보일 수 있어도, 문자열 비교 자체는 정상이었다 — 실제 버그가 아니라 표시상의 문제였음을 확인).
## Docker 배포
`Dockerfile` + `docker-compose.yml` + `scripts/docker-entrypoint.sh`로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경에는 Docker가 설치되어 있지 않아서 **이미지를 직접 빌드/실행해 검증한 적은 없다** — 실제 배포 서버(Docker 있는 곳)에서 처음 빌드할 때 이 문서에 적은 가정들이 맞는지 확인할 것.
`Dockerfile` + `docker-compose.yml` + `scripts/docker-entrypoint.sh`로 구성했다(README "Docker로 배포하기" 참고). 이 저장소가 만들어진 개발 환경 자체에는 Docker가 없지만, `scripts/deploy_sftp.py`로 실제 배포 서버(시놀로지 NAS, `deploy.env` 참고)에 소스를 올린 뒤 그 서버에서 SSH로 `docker compose build && docker compose up -d`를 실행해 검증하는 흐름은 실제로 여러 번 써봤다.
- `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`의 유니크 제약 경합 방지 덕에 죽지는 않지만 애초에 여러 개 띄울 이유가 없다).
- **저널 첨부파일(`app/media/`)은 반드시 볼륨 마운트해야 한다**: `docker-compose.yml``volumes: ["./media:/app/app/media"]`가 있는데, 이게 없으면 `docker compose up -d`로 컨테이너를 재생성할 때마다(이미지 재빌드 후 흔히 하는 작업) 그 안에 쌓인 유저 업로드 사진이 컨테이너의 임시 쓰기 레이어와 함께 통째로 사라진다 — 실제로 이 마운트가 빠진 채로 배포를 여러 번 반복하다 발견한 문제였다. 또한 `scripts/deploy_sftp.py``SKIP_NAMES``"media"`가 들어있는 것도 같은 이유다 — 이게 없으면 로컬에서 테스트하며 쌓인 진짜 유저 사진이 파일 동기화 스크립트를 통해 원격 빌드 컨텍스트(`app/media/`)로 그대로 올라가버린다(소스 코드가 아니라 런타임 데이터인데도). 새로 추가되는 유저 업로드 디렉터리가 있다면 똑같이 볼륨 마운트 + `deploy_sftp.py` 제외 둘 다 챙길 것.
- **타임존**: `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"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
- **HTTPS는 배포 대상에 따라 둘 중 하나**: (1) 집 PC를 직접 서버로 쓰는 경우 → Tailscale(`tailscale serve --bg 8000`), 컨테이너 8000번이 호스트 8000번에 그대로 매핑되므로(`ports: ["8000:8000"]`) 프로세스로 직접 띄우든 컨테이너로 띄우든 Tailscale 입장에서 차이 없음. (2) **이미 리버스 프록시(nginx 등)가 앞단에 있는 서버에 배포하는 경우 → Tailscale 불필요**, 프록시가 도메인의 TLS를 처리하고 컨테이너의 8000번으로 평문 HTTP 프록시하면 된다. 이 앱은 리버스 프록시가 보내주는 `X-Forwarded-Proto` 헤더를 보고 `http`면 301로 `https`로 리다이렉트한다(`app/main.py``redirect_http_to_https` 미들웨어) — 프록시가 이 헤더를 안 보내주면(로컬 `uvicorn` 직접 실행 등) 그냥 통과하므로 로컬 개발엔 영향 없다. 이 미들웨어가 실제로 동작하려면 **프록시가 HTTP(80)와 HTTPS(443) 요청을 모두 앱까지 전달하면서 각각 `X-Forwarded-Proto: http`/`https`를 명시적으로 설정**해야 한다 — 시놀로지 NAS 역방향 프록시처럼 리다이렉트 기능 자체가 없는 프록시 뒤에 배포할 때 특히 이 헤더 설정을 빠뜨리기 쉽다(80번 포트에 대한 프록시 규칙 자체가 없으면 트래픽이 앱에 도달하지도 못하고 NAS 자체 관리 페이지 등 엉뚱한 곳으로 샐 수 있음 — 실제로 이 문제가 있었음). 프록시가 컨테이너와 같은 호스트에서 돈다면 `docker-compose.yml`의 포트 매핑을 `"127.0.0.1:8000:8000"`으로 좁혀서 컨테이너가 프록시를 우회해 외부에 직접 노출되지 않게 하는 걸 권장.
+1 -1
View File
@@ -1,4 +1,4 @@
# 습관 트래커
# 해빗랩
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
+3
View File
@@ -18,5 +18,8 @@ class Settings(BaseSettings):
session_cookie_name: str = "habit_session"
timezone: str = "Asia/Seoul"
journal_media_root: str = "app/media/journal"
journal_max_upload_mb: int = 20
settings = Settings()
+44 -3
View File
@@ -1,15 +1,16 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
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 import auth, habits, journal, journal_pages, logs, pages, push
from app.routers.pages import templates
from app.security import get_current_user_optional
from app.services import scheduler_service
@@ -19,23 +20,57 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
Path(settings.journal_media_root).mkdir(parents=True, exist_ok=True)
scheduler_service.start_scheduler()
yield
scheduler_service.shutdown_scheduler()
app = FastAPI(title="습관 트래커", lifespan=lifespan)
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.middleware("http")
async def redirect_http_to_https(request: Request, call_next):
# 리버스 프록시가 X-Forwarded-Proto로 원래 스킴을 알려줄 때만 동작한다
# (로컬 uvicorn 직접 실행 시에는 이 헤더가 없어 그냥 통과).
if request.headers.get("x-forwarded-proto") == "http":
return RedirectResponse(str(request.url.replace(scheme="https")), status_code=301)
return await call_next(request)
@app.middleware("http")
async def support_head_requests(request: Request, call_next):
# 이 Starlette 버전은 GET 라우트에 HEAD를 자동으로 열어주지 않아 크롤러의 HEAD 요청이
# 전부 405로 막힌다(구글 OAuth 브랜딩 인증 크롤러가 홈페이지를 HEAD로 먼저 확인하면서
# "콘텐츠 없음"으로 오판하는 원인이 됐음). GET과 동일하게 처리한 뒤 본문만 비워서 응답한다.
if request.method != "HEAD":
return await call_next(request)
request.scope["method"] = "GET"
response = await call_next(request)
async def _empty_body():
return
yield b"" # pragma: no cover - 제너레이터로 만들기 위한 도달 불가 코드
response.body_iterator = _empty_body()
# uvicorn은 실제 전송 바이트와 Content-Length가 다르면 예외를 던지므로 0으로 맞춘다.
response.headers["content-length"] = "0"
return response
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(habits.router)
app.include_router(journal.router)
app.include_router(logs.router)
app.include_router(push.router)
app.include_router(journal_pages.router)
app.include_router(pages.router)
@@ -75,3 +110,9 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
def service_worker():
# 서비스워커 scope가 앱 전체를 커버하려면 /static/ 하위가 아닌 루트 경로로 서빙해야 한다.
return FileResponse("app/static/service-worker.js", media_type="application/javascript")
@app.get("/.well-known/assetlinks.json")
def assetlinks():
# Android TWA(kr.co.alphalok.habit.twa)의 도메인 소유권 증명용 — 반드시 /.well-known/ 루트 경로여야 한다.
return FileResponse("app/static/.well-known/assetlinks.json", media_type="application/json")
+23
View File
@@ -0,0 +1,23 @@
import bleach
import markdown
from markupsafe import Markup
# nl2br: 빈 줄 없이 그냥 엔터만 쳐도 줄바꿈되게 한다 — 지금까지 백엔드가 순수 텍스트를
# white-space: pre-wrap으로 보여주던 것과 체감이 최대한 비슷하도록.
_MARKDOWN_EXTENSIONS = ["nl2br", "sane_lists"]
_ALLOWED_TAGS = [
"p", "br", "strong", "em", "del",
"h1", "h2", "h3", "h4",
"ul", "ol", "li",
"blockquote", "code", "pre", "hr", "a", "img",
]
_ALLOWED_ATTRS = {"a": ["href", "title"], "img": ["src", "alt", "title"]}
def render_markdown(text: str) -> Markup:
"""저널 기록 내용을 마크다운 HTML로 렌더링한다. markdown 라이브러리는 기본적으로 원본 HTML을
그대로 통과시키므로(<script> 등 포함) bleach로 허용 태그만 남기고 나머지는 전부 지운다 —
이 함수가 반환하는 Markup만 템플릿에서 이스케이프 없이(그대로 안전하게) 렌더링해야 한다."""
html = markdown.markdown(text, extensions=_MARKDOWN_EXTENSIONS)
return Markup(bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS, strip=True))
+18
View File
@@ -1,5 +1,15 @@
from app.models.habit import Habit, HabitStatus, HabitType
from app.models.habit_log import HabitLog
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalMood,
JournalPrompt,
JournalTag,
)
from app.models.notification_log import HabitNotificationLog, SummaryNotificationLog
from app.models.push_subscription import PushSubscription
from app.models.user import User
@@ -9,6 +19,14 @@ __all__ = [
"HabitStatus",
"HabitType",
"HabitLog",
"JournalAttachment",
"JournalAttachmentType",
"JournalCategory",
"JournalEntry",
"JournalEntryMood",
"JournalMood",
"JournalPrompt",
"JournalTag",
"HabitNotificationLog",
"SummaryNotificationLog",
"PushSubscription",
+134
View File
@@ -0,0 +1,134 @@
import enum
from datetime import date, datetime
from sqlalchemy import Column, Date, Enum, ForeignKey, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
from app.models.habit import _by_value
class JournalMood(str, enum.Enum):
PAIN = "pain" # 아픔
ACHIEVEMENT = "achievement" # 성취
ANGER = "anger" # 분노
EXCITED = "excited" # 신남
CALM = "calm" # 평온
HAPPY = "happy" # 행복
WORRY = "worry" # 걱정
TIRED = "tired" # 피곤
SAD = "sad" # 슬픔
class JournalAttachmentType(str, enum.Enum):
IMAGE = "image"
VIDEO = "video"
journal_entry_tag = Table(
"journal_entry_tag",
Base.metadata,
Column("entry_id", ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True),
)
class JournalCategory(Base):
__tablename__ = "journal_category"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
color: Mapped[str | None] = mapped_column(String(20), nullable=True)
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워주는 틀(예: Story/Feelings/Decisions/... 회고 양식).
# 사용자가 직접 타이핑을 시작하면 더 이상 덮어쓰지 않는다(프론트에서 처리).
content_template: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
back_populates="category", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntry(Base):
__tablename__ = "journal_entry"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
category_id: Mapped[int] = mapped_column(
ForeignKey("journal_category.id", ondelete="CASCADE"), nullable=False
)
entry_date: Mapped[date] = mapped_column(Date, nullable=False)
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
server_default=func.now(), onupdate=func.now(), nullable=False
)
category: Mapped["JournalCategory"] = relationship(back_populates="entries")
tags: Mapped[list["JournalTag"]] = relationship(
secondary=journal_entry_tag, back_populates="entries"
)
moods: Mapped[list["JournalEntryMood"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
attachments: Mapped[list["JournalAttachment"]] = relationship(
back_populates="entry", cascade="all, delete-orphan", passive_deletes=True
)
class JournalEntryMood(Base):
"""엔트리 하나에 여러 감정을 태그처럼 붙일 수 있게 하는 연결 테이블. mood 자체가 고정 enum이라
JournalTag처럼 별도 엔티티(이름 등)를 둘 필요가 없어 journal_entry_tag와 달리 값 자체를 PK로 쓴다."""
__tablename__ = "journal_entry_mood"
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True)
mood: Mapped[JournalMood] = mapped_column(
Enum(JournalMood, native_enum=False, length=20, values_callable=_by_value), primary_key=True
)
entry: Mapped["JournalEntry"] = relationship(back_populates="moods")
class JournalTag(Base):
__tablename__ = "journal_tag"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
entries: Mapped[list["JournalEntry"]] = relationship(
secondary=journal_entry_tag, back_populates="tags"
)
class JournalAttachment(Base):
__tablename__ = "journal_attachment"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
entry_id: Mapped[int] = mapped_column(ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False)
media_type: Mapped[JournalAttachmentType] = mapped_column(
Enum(JournalAttachmentType, native_enum=False, length=10, values_callable=_by_value), nullable=False
)
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
thumbnail_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
entry: Mapped["JournalEntry"] = relationship(back_populates="attachments")
class JournalPrompt(Base):
"""카테고리 무관 전역 질문 뱅크. 특정 카테고리 전용 프롬프트는 v1 범위 밖(향후 확장 여지)."""
__tablename__ = "journal_prompt"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_text: Mapped[str] = mapped_column(String(300), nullable=False)
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.schemas.journal import JournalCategoryReorderRequest
from app.security import require_login
from app.services import journal_service
router = APIRouter(prefix="/api/journal", tags=["journal"], dependencies=[Depends(require_login)])
@router.get("/media/{attachment_id}")
def get_media(
attachment_id: int,
thumbnail: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
attachment = journal_service.get_attachment(db, attachment_id, current_user.id)
if attachment is None:
raise HTTPException(status_code=404, detail="첨부파일을 찾을 수 없습니다")
path = attachment.thumbnail_path if (thumbnail and attachment.thumbnail_path) else attachment.file_path
return FileResponse(path, filename=attachment.original_filename)
@router.post("/paste-image")
def paste_image(
file: UploadFile = File(...),
current_user: User = Depends(require_login),
):
try:
filename = journal_service.save_pasted_image(current_user.id, file)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"url": f"/api/journal/pasted-media/{filename}"}
@router.get("/pasted-media/{filename}")
def get_pasted_image(filename: str, current_user: User = Depends(require_login)):
path = journal_service.get_pasted_image_path(current_user.id, filename)
if path is None:
raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다")
return FileResponse(path)
@router.post("/categories/reorder")
def reorder_categories(
data: JournalCategoryReorderRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_login),
):
journal_service.reorder_categories(db, current_user.id, data.category_ids)
return {"ok": True}
+381
View File
@@ -0,0 +1,381 @@
import calendar
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.database import get_db
from app.markdown_utils import render_markdown
from app.models.journal import JournalMood
from app.routers.pages import _current_user_or_redirect, templates
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
router = APIRouter()
# (enum 값, 이모지, 한글 라벨). 기분 선택 pill(작성/수정 폼)과 표시(day-detail)에서 공유해서 쓴다.
# 여러 개를 동시에 고를 수 있어서(다중 선택) 라벨은 전부 명사형으로 통일한다.
JOURNAL_MOOD_OPTIONS = [
(JournalMood.PAIN, "🤕", "아픔"),
(JournalMood.ACHIEVEMENT, "🏆", "성취"),
(JournalMood.ANGER, "😠", "분노"),
(JournalMood.EXCITED, "🤩", "신남"),
(JournalMood.CALM, "😌", "평온"),
(JournalMood.HAPPY, "😊", "행복"),
(JournalMood.WORRY, "😟", "걱정"),
(JournalMood.TIRED, "😪", "피곤"),
(JournalMood.SAD, "😢", "슬픔"),
]
templates.env.globals["journal_mood_options"] = JOURNAL_MOOD_OPTIONS
templates.env.globals["journal_mood_emoji"] = {m.value: emoji for m, emoji, _ in JOURNAL_MOOD_OPTIONS}
# Jinja2 내장 |tojson 필터가 이 policy를 읽어서 json.dumps에 넘긴다 — 기본값(ensure_ascii=True)이면
# 한글이 \uXXXX로 이스케이프돼 응답 본문에서 읽기 힘들어진다.
templates.env.policies["json.dumps_kwargs"] = {"ensure_ascii": False}
templates.env.filters["markdown"] = render_markdown
def _parse_moods(raw: str) -> list[JournalMood]:
moods = []
for v in raw.split(","):
v = v.strip()
if not v:
continue
try:
moods.append(JournalMood(v))
except ValueError:
continue
return moods
def _parse_tags(raw: str) -> list[str]:
return [t for t in raw.split(",")]
def _month_context(db: Session, user_id: int, year: int, month: int, category_id: int | None) -> dict:
summary_map = journal_service.get_monthly_journal_summary(db, user_id, year, month, category_id)
weeks = calendar.Calendar(firstweekday=6).monthdatescalendar(year, month)
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 {
"year": year,
"month": month,
"weeks": weeks,
"summary_map": summary_map,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
}
def _render_day_detail(request: Request, db: Session, user_id: int, entry_date: date, **extra):
return templates.TemplateResponse(
request,
"partials/journal_day_detail.html",
{
"entry_date": entry_date,
"items": journal_service.get_day_entries(db, user_id, entry_date),
"categories": journal_service.list_categories(db, user_id),
**extra,
},
)
@router.get("/journal")
def journal_page(
request: Request,
tab: str = "calendar",
year: int | None = None,
month: int | None = None,
category_id: int | None = None,
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
journal_service.ensure_default_category(db, current.id)
categories = journal_service.list_categories(db, current.id)
today = date.today()
year = year or today.year
month = month or today.month
tab = "manage" if tab == "manage" else "calendar"
return templates.TemplateResponse(
request,
"journal.html",
{
"logged_in": True,
"current_user": current,
"tab": tab,
"categories": categories,
"category_id": category_id,
"category_templates": {str(c.id): c.content_template or "" for c in categories},
"entry_counts": journal_service.count_entries_by_category(db, current.id) if tab == "manage" else {},
"on_this_day": journal_service.get_on_this_day(db, current.id, today),
"prompt": journal_service.get_random_prompt(db),
"today_iso": today.isoformat(),
**_month_context(db, current.id, year, month, category_id),
},
)
@router.get("/journal/day/{entry_date}")
def journal_day_detail(request: Request, entry_date: date, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
return _render_day_detail(request, db, current.id, entry_date)
@router.post("/journal/preview")
def preview_entry_content(request: Request, content: str = Form(""), db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if not content.strip():
return HTMLResponse('<span class="empty-state">미리보기가 여기에 표시돼요</span>')
return HTMLResponse(render_markdown(content))
@router.post("/journal/new")
def create_entry_page(
request: Request,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if journal_service.get_category(db, category_id, current.id) is None:
return HTMLResponse("카테고리를 찾을 수 없어요")
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryCreate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
except ValueError:
return HTMLResponse("입력값을 확인해주세요")
entry = journal_service.create_entry(db, current.id, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError as exc:
return HTMLResponse(str(exc))
response = Response(status_code=200)
response.headers["HX-Redirect"] = f"/journal?year={parsed_date.year}&month={parsed_date.month}"
return response
@router.post("/journal/{entry_id}/edit")
def edit_entry_page(
request: Request,
entry_id: int,
category_id: int = Form(...),
entry_date: str = Form(...),
title: str | None = Form(None),
content: str = Form(""),
moods: str = Form(""),
tags: str = Form(""),
files: list[UploadFile] = File(default=[]),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
original_date = entry.entry_date
if journal_service.get_category(db, category_id, current.id) is None:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="카테고리를 찾을 수 없어요",
editing_entry_id=entry_id,
)
try:
parsed_date = date.fromisoformat(entry_date)
data = JournalEntryUpdate(
category_id=category_id,
entry_date=parsed_date,
title=title,
content=content,
moods=_parse_moods(moods),
tags=_parse_tags(tags),
)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return _render_day_detail(
request, db, current.id, original_date, edit_error=message, editing_entry_id=entry_id
)
except ValueError:
return _render_day_detail(
request,
db,
current.id,
original_date,
edit_error="입력값을 확인해주세요",
editing_entry_id=entry_id,
)
journal_service.update_entry(db, entry, data)
for upload in files:
if not upload.filename:
continue
try:
journal_service.save_attachment(db, entry, upload)
except ValueError:
pass # 첨부 실패는 조용히 넘어간다 — 본문 수정은 이미 반영됐다
return _render_day_detail(request, db, current.id, parsed_date)
@router.post("/journal/{entry_id}/delete")
def delete_entry_page(request: Request, entry_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_entry(db, entry)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/{entry_id}/attachments/{attachment_id}/delete")
def delete_attachment_page(
request: Request, entry_id: int, attachment_id: int, db: Session = Depends(get_db)
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
entry = journal_service.get_entry(db, entry_id, current.id)
if entry is None:
return Response(status_code=404)
attachment = journal_service.get_attachment(db, attachment_id, current.id)
if attachment is None or attachment.entry_id != entry.id:
return Response(status_code=404)
entry_date_value = entry.entry_date
journal_service.delete_attachment(db, attachment)
return _render_day_detail(request, db, current.id, entry_date_value)
@router.post("/journal/categories/new")
def create_category_page(
request: Request,
name: str = Form(""),
color: str | None = Form(None),
content_template: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
try:
data = JournalCategoryCreate(name=name, color=color, content_template=content_template)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.create_category(db, current.id, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/edit")
def edit_category_page(
request: Request,
category_id: int,
name: str = Form(""),
color: str | None = Form(None),
content_template: str | None = Form(None),
db: Session = Depends(get_db),
):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
try:
data = JournalCategoryCreate(name=name, color=color, content_template=content_template)
except ValidationError as exc:
message = exc.errors()[0]["msg"].removeprefix("Value error, ")
return HTMLResponse(message)
try:
journal_service.update_category(db, category, data)
except IntegrityError:
db.rollback()
return HTMLResponse("이미 같은 이름의 카테고리가 있어요")
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
@router.post("/journal/categories/{category_id}/delete")
def delete_category_page(request: Request, category_id: int, db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
category = journal_service.get_category(db, category_id, current.id)
if category is None:
return Response(status_code=404)
journal_service.delete_category(db, category)
response = Response(status_code=200)
response.headers["HX-Redirect"] = "/journal?tab=manage"
return response
+56 -4
View File
@@ -8,12 +8,13 @@ from fastapi.templating import Jinja2Templates
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.habit import HabitDifficulty, 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.services import habit_service, log_service, user_service
from app.template_utils import (
difficulty_label,
goal_progress,
@@ -46,14 +47,65 @@ def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectR
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)
# 로그인 없이도 앱 목적을 설명하는 페이지가 있어야 한다(구글 OAuth 동의 화면 "홈페이지" 요건).
return templates.TemplateResponse(request, "home.html", {"logged_in": False, "current_user": None})
@router.get("/login")
def login_page(request: Request, db: Session = Depends(get_db)):
def login_page(request: Request, deleted: bool = False, 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})
return templates.TemplateResponse(
request, "login.html", {"logged_in": False, "current_user": None, "deleted": deleted}
)
@router.get("/privacy")
def privacy_page(request: Request, db: Session = Depends(get_db)):
user = get_current_user_optional(request, db)
return templates.TemplateResponse(
request, "privacy.html", {"logged_in": user is not None, "current_user": user}
)
@router.get("/terms")
def terms_page(request: Request, db: Session = Depends(get_db)):
user = get_current_user_optional(request, db)
return templates.TemplateResponse(
request, "terms.html", {"logged_in": user is not None, "current_user": user}
)
@router.get("/account")
def account_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, "account.html", {"logged_in": True, "current_user": current})
@router.post("/account/delete")
def delete_account_page(request: Request, confirm_email: str = Form(""), db: Session = Depends(get_db)):
current = _current_user_or_redirect(request, db)
if isinstance(current, RedirectResponse):
return current
if confirm_email.strip().lower() != current.email.lower():
return templates.TemplateResponse(
request,
"account.html",
{
"logged_in": True,
"current_user": current,
"delete_error": "입력한 이메일이 계정 이메일과 일치하지 않습니다.",
},
status_code=400,
)
user_service.delete_account(db, current)
response = RedirectResponse(url="/login?deleted=1", status_code=303)
response.delete_cookie(settings.session_cookie_name)
return response
def _today_context(db: Session, user_id: int, **extra) -> dict:
+1 -1
View File
@@ -42,5 +42,5 @@ def unsubscribe(
@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="테스트 알림입니다.")
sent = push_service.send_to_user(db, current_user.id, title="해빗랩", body="테스트 알림입니다.")
return {"sent": sent}
+144
View File
@@ -0,0 +1,144 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, field_validator
from app.models.journal import JournalAttachmentType, JournalMood
class JournalCategoryBase(BaseModel):
name: str
color: str | None = None
content_template: str | 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("color")
@classmethod
def blank_color_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
@field_validator("content_template")
@classmethod
def blank_template_to_none(cls, v: str | None) -> str | None:
# color/name과 달리 여기선 .strip()으로 값 자체를 바꾸지 않는다 — 템플릿 맨 끝의
# "- "(대시+공백)처럼 의미 있는 trailing whitespace가 있을 수 있고, 그걸 지우면
# markdown이 그 줄을 목록으로 인식하지 못하게 된다(빈 값인지 판단만 strip으로 하고,
# 실제로 저장하는 값은 원본을 그대로 쓴다).
if v is None or not v.strip():
return None
return v
class JournalCategoryCreate(JournalCategoryBase):
pass
class JournalCategoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str | None
content_template: str | None
sort_order: int | None
created_at: datetime
class JournalCategoryReorderRequest(BaseModel):
category_ids: list[int]
class JournalEntryBase(BaseModel):
category_id: int
entry_date: date
title: str | None = None
content: str
moods: list[JournalMood] = []
tags: list[str] = []
@field_validator("content")
@classmethod
def content_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("내용을 입력해주세요")
return v
@field_validator("title")
@classmethod
def blank_title_to_none(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
return v or None
@field_validator("moods")
@classmethod
def dedupe_moods(cls, v: list[JournalMood]) -> list[JournalMood]:
cleaned: list[JournalMood] = []
for mood in v:
if mood not in cleaned:
cleaned.append(mood)
return cleaned
@field_validator("tags")
@classmethod
def clean_tags(cls, v: list[str]) -> list[str]:
cleaned: list[str] = []
for raw in v:
name = raw.strip()
if name and name not in cleaned:
cleaned.append(name)
return cleaned
class JournalEntryCreate(JournalEntryBase):
pass
class JournalEntryUpdate(JournalEntryBase):
pass
class JournalAttachmentOut(BaseModel):
id: int
media_type: JournalAttachmentType
original_filename: str
has_thumbnail: bool
class JournalCalendarEntry(BaseModel):
color: str # 그 엔트리가 속한 카테고리 색상 (점 표시용)
title: str # 제목(없으면 내용 일부)
class JournalCalendarDay(BaseModel):
entry_date: date
total_count: int
entries: list[JournalCalendarEntry] # 엔트리별 (색상, 제목) 쌍 — 점과 제목이 한 줄에 붙어 나오도록 1:1로 매칭
class JournalDayDetailItem(BaseModel):
id: int
category_id: int
category_name: str
category_color: str | None
title: str | None
content: str
moods: list[JournalMood]
tags: list[str]
attachments: list[JournalAttachmentOut]
class JournalOnThisDayItem(JournalDayDetailItem):
entry_date: date
years_ago: int
+442
View File
@@ -0,0 +1,442 @@
import calendar
import mimetypes
import random
import uuid
from datetime import date
from pathlib import Path
from fastapi import UploadFile
from PIL import Image
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import settings
from app.models.journal import (
JournalAttachment,
JournalAttachmentType,
JournalCategory,
JournalEntry,
JournalEntryMood,
JournalPrompt,
JournalTag,
)
from app.schemas.journal import (
JournalAttachmentOut,
JournalCalendarDay,
JournalCalendarEntry,
JournalCategoryCreate,
JournalDayDetailItem,
JournalEntryCreate,
JournalEntryUpdate,
JournalOnThisDayItem,
)
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ALLOWED_VIDEO_TYPES = {"video/mp4", "video/quicktime"}
THUMBNAIL_WIDTH = 400
DEFAULT_CATEGORY_NAME = "일상"
# 목록 기호("- ") 뒤에 공백이 없으면(그냥 "-"만 있으면) markdown 라이브러리가 목록으로 안 잡고
# 그냥 문단 텍스트로 렌더링한다 — 그래서 다섯 줄 다 "- "(대시+공백)로 통일해야 실제로
# 빈 체크리스트 항목(<li></li>)이 만들어진다. 제목 줄과 "-" 사이에 빈 줄이 없으면 markdown이
# 그 "-"를 목록이 아니라 제목 밑줄(setext heading)로 오인해서 제목 자체가 사라지므로 빈 줄도 필수.
_BULLET = "- " # 뒤 공백이 핵심 — 트리플쿼트 문자열 끝의 trailing space는 도구를 거치며 잘려나가서
# 여기서는 따옴표 "안쪽"에 명시적으로 넣어 안 잘리게 한다.
DEFAULT_CATEGORY_TEMPLATE = "\n\n".join(
[
"**1. Story : 오늘 무슨 일이 있었나요?**",
_BULLET,
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
_BULLET,
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
_BULLET,
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
_BULLET,
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
_BULLET,
]
)
# ---- 카테고리 ----
def list_categories(db: Session, user_id: int) -> list[JournalCategory]:
stmt = select(JournalCategory).where(JournalCategory.user_id == user_id)
stmt = stmt.order_by(
JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at
)
return list(db.scalars(stmt))
def get_category(db: Session, category_id: int, user_id: int) -> JournalCategory | None:
return db.scalar(
select(JournalCategory).where(JournalCategory.id == category_id, JournalCategory.user_id == user_id)
)
def create_category(db: Session, user_id: int, data: JournalCategoryCreate) -> JournalCategory:
category = JournalCategory(
user_id=user_id, name=data.name, color=data.color, content_template=data.content_template
)
db.add(category)
db.commit()
db.refresh(category)
return category
def update_category(db: Session, category: JournalCategory, data: JournalCategoryCreate) -> JournalCategory:
category.name = data.name
category.color = data.color
category.content_template = data.content_template
db.commit()
db.refresh(category)
return category
def ensure_default_category(db: Session, user_id: int) -> JournalCategory:
"""유저가 카테고리를 하나도 만든 적 없으면 기본 카테고리를 자동 생성한다."""
existing = db.scalar(
select(JournalCategory)
.where(JournalCategory.user_id == user_id)
.order_by(JournalCategory.sort_order.is_(None), JournalCategory.sort_order, JournalCategory.created_at)
.limit(1)
)
if existing is not None:
return existing
return create_category(
db, user_id, JournalCategoryCreate(name=DEFAULT_CATEGORY_NAME, content_template=DEFAULT_CATEGORY_TEMPLATE)
)
def reorder_categories(db: Session, user_id: int, ordered_ids: list[int]) -> None:
categories = db.scalars(
select(JournalCategory).where(JournalCategory.id.in_(ordered_ids), JournalCategory.user_id == user_id)
).all()
category_map = {c.id: c for c in categories}
for index, category_id in enumerate(ordered_ids):
category = category_map.get(category_id)
if category is not None:
category.sort_order = index
db.commit()
def count_entries_by_category(db: Session, user_id: int) -> dict[int, int]:
rows = db.execute(
select(JournalEntry.category_id, func.count(JournalEntry.id))
.where(JournalEntry.user_id == user_id)
.group_by(JournalEntry.category_id)
).all()
return {row[0]: row[1] for row in rows}
def delete_category(db: Session, category: JournalCategory) -> None:
"""카테고리를 지우면 안의 엔트리도 함께 지워진다 — 첨부파일 디스크 삭제까지 하려면
ORM cascade에만 맡기지 않고 delete_entry를 하나씩 거쳐야 한다."""
entries = list(db.scalars(select(JournalEntry).where(JournalEntry.category_id == category.id)))
for entry in entries:
delete_entry(db, entry)
db.delete(category)
db.commit()
# ---- 태그 ----
def get_or_create_tags(db: Session, user_id: int, names: list[str]) -> list[JournalTag]:
tags = []
for name in names:
tag = db.scalar(select(JournalTag).where(JournalTag.user_id == user_id, JournalTag.name == name))
if tag is None:
tag = JournalTag(user_id=user_id, name=name)
db.add(tag)
db.flush()
tags.append(tag)
return tags
# ---- 엔트리 ----
def list_entries(
db: Session,
user_id: int,
category_id: int | None = None,
start: date | None = None,
end: date | None = None,
) -> list[JournalEntry]:
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
if start is not None:
stmt = stmt.where(JournalEntry.entry_date >= start)
if end is not None:
stmt = stmt.where(JournalEntry.entry_date <= end)
stmt = stmt.order_by(JournalEntry.entry_date.desc(), JournalEntry.created_at.desc())
return list(db.scalars(stmt))
def get_entry(db: Session, entry_id: int, user_id: int) -> JournalEntry | None:
return db.scalar(select(JournalEntry).where(JournalEntry.id == entry_id, JournalEntry.user_id == user_id))
def create_entry(db: Session, user_id: int, data: JournalEntryCreate) -> JournalEntry:
tags = get_or_create_tags(db, user_id, data.tags)
entry = JournalEntry(
user_id=user_id,
category_id=data.category_id,
entry_date=data.entry_date,
title=data.title,
content=data.content,
tags=tags,
moods=[JournalEntryMood(mood=m) for m in data.moods],
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def update_entry(db: Session, entry: JournalEntry, data: JournalEntryUpdate) -> JournalEntry:
tags = get_or_create_tags(db, entry.user_id, data.tags)
entry.category_id = data.category_id
entry.entry_date = data.entry_date
entry.title = data.title
entry.content = data.content
entry.tags = tags
entry.moods = [JournalEntryMood(mood=m) for m in data.moods]
db.commit()
db.refresh(entry)
return entry
def delete_entry(db: Session, entry: JournalEntry) -> None:
for attachment in list(entry.attachments):
_delete_attachment_files(attachment)
db.delete(entry)
db.commit()
# ---- 첨부파일 ----
def _media_dir(user_id: int, entry_id: int) -> Path:
return Path(settings.journal_media_root) / str(user_id) / str(entry_id)
def _delete_attachment_files(attachment: JournalAttachment) -> None:
for path_str in (attachment.file_path, attachment.thumbnail_path):
if path_str:
Path(path_str).unlink(missing_ok=True)
def save_attachment(db: Session, entry: JournalEntry, upload_file: UploadFile) -> JournalAttachment:
content_type = upload_file.content_type or ""
if content_type in ALLOWED_IMAGE_TYPES:
media_type = JournalAttachmentType.IMAGE
elif content_type in ALLOWED_VIDEO_TYPES:
media_type = JournalAttachmentType.VIDEO
else:
raise ValueError("지원하지 않는 파일 형식이에요 (사진: jpg/png/webp/gif, 영상: mp4/mov)")
data = upload_file.file.read()
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
if len(data) > max_bytes:
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
target_dir = _media_dir(entry.user_id, entry.id)
target_dir.mkdir(parents=True, exist_ok=True)
ext = mimetypes.guess_extension(content_type) or Path(upload_file.filename or "").suffix or ""
stored_name = f"{uuid.uuid4().hex}{ext}"
file_path = target_dir / stored_name
file_path.write_bytes(data)
thumbnail_path: Path | None = None
if media_type == JournalAttachmentType.IMAGE:
thumbnail_path = target_dir / f"{uuid.uuid4().hex}_thumb.jpg"
try:
with Image.open(file_path) as img:
img = img.convert("RGB")
w, h = img.size
if w > THUMBNAIL_WIDTH:
img = img.resize((THUMBNAIL_WIDTH, round(h * THUMBNAIL_WIDTH / w)))
img.save(thumbnail_path, "JPEG", quality=85)
except Exception:
# 손상되었거나 Pillow가 못 읽는 이미지여도 원본 업로드 자체는 실패시키지 않는다.
thumbnail_path.unlink(missing_ok=True)
thumbnail_path = None
attachment = JournalAttachment(
entry_id=entry.id,
media_type=media_type,
file_path=str(file_path),
thumbnail_path=str(thumbnail_path) if thumbnail_path else None,
original_filename=upload_file.filename or stored_name,
file_size=len(data),
)
db.add(attachment)
db.commit()
db.refresh(attachment)
return attachment
def get_attachment(db: Session, attachment_id: int, user_id: int) -> JournalAttachment | None:
return db.scalar(
select(JournalAttachment)
.join(JournalEntry, JournalAttachment.entry_id == JournalEntry.id)
.where(JournalAttachment.id == attachment_id, JournalEntry.user_id == user_id)
)
def delete_attachment(db: Session, attachment: JournalAttachment) -> None:
_delete_attachment_files(attachment)
db.delete(attachment)
db.commit()
# ---- 에디터에 붙여넣은 이미지 ----
# 글을 쓰는 중(아직 엔트리가 저장되기 전)에 클립보드로 붙여넣은 이미지라 JournalAttachment처럼
# entry_id에 묶을 수가 없다 — DB 행 없이 유저별 폴더에만 저장하고, 마크다운 본문에
# ![](url) 형태로 직접 참조한다. 그래서 첨부파일 갤러리(삭제 버튼 등)에는 안 뜨고, 엔트리를
# 지워도 자동으로 같이 지워지지 않는다(개인 규모 사용량이라 감수할 만한 트레이드오프).
def pasted_image_dir(user_id: int) -> Path:
return Path(settings.journal_media_root) / str(user_id) / "pasted"
def save_pasted_image(user_id: int, upload_file: UploadFile) -> str:
"""붙여넣은 이미지를 저장하고 파일명(서빙 URL에 쓸 값)을 반환한다."""
content_type = upload_file.content_type or ""
if content_type not in ALLOWED_IMAGE_TYPES:
raise ValueError("이미지 파일만 붙여넣을 수 있어요 (jpg/png/webp/gif)")
data = upload_file.file.read()
max_bytes = settings.journal_max_upload_mb * 1024 * 1024
if len(data) > max_bytes:
raise ValueError(f"파일 용량은 {settings.journal_max_upload_mb}MB를 넘을 수 없어요")
target_dir = pasted_image_dir(user_id)
target_dir.mkdir(parents=True, exist_ok=True)
ext = mimetypes.guess_extension(content_type) or ".png"
filename = f"{uuid.uuid4().hex}{ext}"
(target_dir / filename).write_bytes(data)
return filename
def get_pasted_image_path(user_id: int, filename: str) -> Path | None:
# Path(...).name이 디렉터리 구분자를 전부 제거해줘서 "../"류 경로 탈출을 막아준다.
safe_name = Path(filename).name
path = pasted_image_dir(user_id) / safe_name
return path if path.is_file() else None
# ---- 캘린더 / day-detail / 회상 ----
def _to_day_detail_item(entry: JournalEntry) -> JournalDayDetailItem:
return JournalDayDetailItem(
id=entry.id,
category_id=entry.category_id,
category_name=entry.category.name,
category_color=entry.category.color,
title=entry.title,
content=entry.content,
moods=[m.mood for m in entry.moods],
tags=[t.name for t in entry.tags],
attachments=[
JournalAttachmentOut(
id=a.id,
media_type=a.media_type,
original_filename=a.original_filename,
has_thumbnail=a.thumbnail_path is not None,
)
for a in entry.attachments
],
)
def get_monthly_journal_summary(
db: Session, user_id: int, year: int, month: int, category_id: int | None = None
) -> dict[date, JournalCalendarDay]:
"""해당 월의 날짜별 엔트리 개수와, 그날 등장한 카테고리 색상 목록(점 표시용)을 집계한다."""
days_in_month = calendar.monthrange(year, month)[1]
first_day = date(year, month, 1)
last_day = date(year, month, days_in_month)
stmt = (
select(JournalEntry.entry_date, JournalCategory.color, JournalEntry.title, JournalEntry.content)
.join(JournalCategory, JournalEntry.category_id == JournalCategory.id)
.where(JournalEntry.user_id == user_id, JournalEntry.entry_date.between(first_day, last_day))
.order_by(JournalEntry.entry_date, JournalEntry.created_at)
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
rows = db.execute(stmt).all()
counts: dict[date, int] = {}
entries_by_date: dict[date, list[JournalCalendarEntry]] = {}
for entry_date, color, title, content in rows:
counts[entry_date] = counts.get(entry_date, 0) + 1
entries = entries_by_date.setdefault(entry_date, [])
entries.append(
JournalCalendarEntry(
color=color or "var(--color-accent)",
title=title or (content[:12] + ("" if len(content) > 12 else "")),
)
)
return {
d: JournalCalendarDay(
entry_date=d,
total_count=counts[d],
entries=entries_by_date[d],
)
for d in counts
}
def get_day_entries(
db: Session, user_id: int, target_date: date, category_id: int | None = None
) -> list[JournalDayDetailItem]:
stmt = select(JournalEntry).where(
JournalEntry.user_id == user_id, JournalEntry.entry_date == target_date
)
if category_id is not None:
stmt = stmt.where(JournalEntry.category_id == category_id)
stmt = stmt.order_by(JournalEntry.created_at)
return [_to_day_detail_item(e) for e in db.scalars(stmt)]
def get_on_this_day(db: Session, user_id: int, today: date) -> list[JournalOnThisDayItem]:
"""오늘과 월/일이 같은 과거 연도의 엔트리를 반환한다("1년 전 오늘" 회상 카드).
MONTH()/DAY() 같은 DB 종속 함수 대신 파이썬에서 필터링해 SQLite(테스트)/MariaDB(운영)
양쪽에서 동일하게 동작하게 한다 — 개인 규모 데이터라 전체 스캔 비용도 무시할 만하다.
"""
stmt = select(JournalEntry).where(JournalEntry.user_id == user_id).order_by(JournalEntry.entry_date.desc())
matches = [
e
for e in db.scalars(stmt)
if e.entry_date.month == today.month
and e.entry_date.day == today.day
and e.entry_date.year != today.year
]
items = []
for e in matches:
base = _to_day_detail_item(e)
items.append(
JournalOnThisDayItem(
**base.model_dump(),
entry_date=e.entry_date,
years_ago=today.year - e.entry_date.year,
)
)
return items
# ---- 프롬프트 ----
def get_random_prompt(db: Session) -> JournalPrompt | None:
prompts = list(db.scalars(select(JournalPrompt)))
return random.choice(prompts) if prompts else None
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy.orm import Session
from app.models.user import User
def delete_account(db: Session, user: User) -> None:
"""계정과 그에 딸린 모든 데이터를 삭제한다.
Habit/PushSubscription/HabitLog/HabitNotificationLog/SummaryNotificationLog는 전부
user.id 기준 DB 레벨 ON DELETE CASCADE로 연결돼 있어(마이그레이션 0004/0005 참고),
User 행만 지우면 나머지는 MariaDB가 알아서 정리한다.
"""
db.delete(user)
db.commit()
+8
View File
@@ -0,0 +1,8 @@
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "kr.co.alphalok.habit.twa",
"sha256_cert_fingerprints": ["2A:BC:19:7D:91:65:3A:88:68:02:8C:3B:1D:58:42:C7:17:02:FA:B9:C9:4F:01:02:3D:60:B7:C9:CE:6F:05:20"]
}
}]
+545 -1
View File
@@ -111,6 +111,21 @@ p {
color: var(--color-text-muted);
}
.app-footer {
text-align: center;
padding: var(--space-4) 0 var(--space-2);
font-size: 13px;
}
.app-footer a {
color: var(--color-text-muted);
}
.app-footer a + a::before {
content: "·";
margin: 0 6px;
}
/* iOS 홈 화면 추가 안내 배너 */
.ios-install-banner {
display: none;
@@ -315,7 +330,10 @@ p {
/* 입력 */
input[type="text"],
input[type="time"],
input[type="password"] {
input[type="date"],
input[type="password"],
select,
textarea {
width: 100%;
font-family: inherit;
font-size: 15px;
@@ -326,6 +344,33 @@ input[type="password"] {
color: var(--color-text);
}
textarea {
resize: vertical;
min-height: 90px;
line-height: 1.5;
}
select {
cursor: pointer;
}
input[type="file"] {
width: 100%;
font-family: inherit;
font-size: 14px;
color: var(--color-text-muted);
}
input[type="color"] {
width: 56px;
height: 40px;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
cursor: pointer;
}
label {
display: block;
font-size: 13px;
@@ -456,6 +501,32 @@ label {
color: #fff;
}
/* 저널 기분 선택 pill */
.mood-picker {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mood-pill {
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-text-muted);
font-size: 14px;
padding: 8px 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.mood-pill.selected {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.difficulty-hint {
font-size: 12px;
color: var(--color-text-muted);
@@ -799,3 +870,476 @@ label {
.wm-dash {
color: var(--color-border);
}
/* 저널링 */
.calendar-cell-journal {
aspect-ratio: auto;
min-height: 76px;
padding: 6px 5px;
overflow: hidden;
}
.calendar-cell-journal .calendar-date {
font-size: 13px;
}
.journal-day-entries {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.journal-day-entry {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
.journal-day-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.journal-day-title {
min-width: 0;
font-size: 10px;
line-height: 1.3;
color: var(--color-text-muted);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.journal-prompt-card,
.on-this-day-card {
margin-top: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-control);
background: var(--color-success-tint);
font-size: 14px;
}
.journal-entry-item {
padding: 10px 0;
border-bottom: 1px solid var(--color-border);
}
.journal-entry-item:last-child {
border-bottom: none;
}
.journal-entry-title {
font-weight: 600;
margin-top: 6px;
}
.journal-entry-content {
margin-top: 4px;
line-height: 1.6;
}
.journal-preview {
margin-top: 6px;
padding: 10px 12px;
border: 1px dashed var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
min-height: 24px;
font-size: 14px;
}
/* 마크다운 에디터 툴바 (EasyMDE는 toolbar:false로 끄고 여기서 자체 버튼으로 대체 —
EasyMDE 기본 툴바는 Font Awesome CDN을 전제로 해서 이 앱의 "CDN 금지" 원칙과 안 맞는다) */
.markdown-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.md-tool-btn {
min-width: 30px;
height: 30px;
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: 14px;
cursor: pointer;
}
.md-tool-btn:active {
background: var(--color-bg);
}
/* EasyMDE(CodeMirror) 컨테이너를 이 앱의 입력 필드 톤에 맞춘다. iOS에서는
app/static/js/journal-editor.js가 EasyMDE 대신 순수 <textarea>로 폴백하므로(한글 IME
자소분리 회피) 이 규칙은 그 경우 그냥 매칭되지 않는다 — .markdown-editor 자체는 위 공통
textarea 규칙을 그대로 물려받아 별도 스타일 없이도 정상적으로 보인다. */
.markdown-editor + .EasyMDEContainer .CodeMirror {
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-bg);
color: var(--color-text);
font-family: inherit;
font-size: 15px;
padding: 6px 8px;
overflow: hidden; /* 안에서 뭐가 카드 폭보다 커지려 해도 밖으로 안 새어나가게 */
}
.markdown-editor + .EasyMDEContainer .CodeMirror-cursor {
border-left-color: var(--color-text);
}
.markdown-editor + .EasyMDEContainer .editor-statusbar {
color: var(--color-text-muted);
}
/* 원본 해상도가 큰 이미지를 붙여넣었을 때 에디터/미리보기 폭을 넘어가지 않게 캡핑.
.journal-entry-content img가 미리보기(.journal-preview)는 이미 커버하지만, 에디터 쪽
(CodeMirror가 마크다운 이미지를 인라인 위젯으로 그리는 경우)도 같은 규칙을 강제로 적용. */
.markdown-editor + .EasyMDEContainer .CodeMirror img {
max-width: 100% !important;
height: auto !important;
}
.journal-entry-content > *:first-child {
margin-top: 0;
}
.journal-entry-content > *:last-child {
margin-bottom: 0;
}
.journal-entry-content p,
.journal-entry-content ul,
.journal-entry-content ol,
.journal-entry-content blockquote,
.journal-entry-content pre {
margin: 0 0 8px;
}
.journal-entry-content h1,
.journal-entry-content h2,
.journal-entry-content h3,
.journal-entry-content h4 {
margin: 12px 0 6px;
line-height: 1.3;
}
.journal-entry-content h1 { font-size: 19px; }
.journal-entry-content h2 { font-size: 17px; }
.journal-entry-content h3,
.journal-entry-content h4 { font-size: 15px; }
.journal-entry-content ul,
.journal-entry-content ol {
padding-left: 20px;
}
.journal-entry-content blockquote {
margin-left: 0;
padding-left: 10px;
border-left: 3px solid var(--color-accent);
color: var(--color-text-muted);
}
.journal-entry-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 4px;
padding: 1px 5px;
}
.journal-entry-content pre {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-control);
padding: 10px;
overflow-x: auto;
}
.journal-entry-content pre code {
border: none;
padding: 0;
}
.journal-entry-content a {
color: var(--color-accent);
}
.journal-entry-content img {
max-width: 100%;
height: auto;
border-radius: var(--radius-control);
display: block;
margin: 4px 0;
}
.journal-entry-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tag-chip {
font-size: 12px;
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 2px 8px;
}
.mood-icon {
font-size: 18px;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
margin-top: 8px;
}
.attachment-thumb {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-control);
overflow: hidden;
border: 1px solid var(--color-border);
}
.attachment-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.attachment-video-link {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 12px;
text-align: center;
color: var(--color-text-muted);
text-decoration: none;
padding: 4px;
}
.attachment-delete-btn {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
/* 랜딩 페이지 (/) */
.landing {
max-width: 480px;
margin: 0 auto;
padding: var(--space-4) var(--space-2) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.landing-hero {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
text-align: center;
}
.landing-logo {
width: 56px;
height: 56px;
border-radius: 14px;
}
/* 앱 이름 h1 — 화면에서 가장 큰 글자여야 한다(위 home.html 주석 참고). */
.landing-brand {
margin: 0;
font-size: 32px;
font-weight: 700;
letter-spacing: -0.02em;
text-wrap: balance;
color: var(--color-accent);
}
/* 태그라인은 앱 이름 아래 부제 */
.landing-hero-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
text-wrap: balance;
margin: 0;
color: var(--color-text);
}
.landing-tagline {
font-size: 16px;
color: var(--color-text-muted);
margin: 0;
}
/* 요일 스트릭 시연: 실제 습관 체크 인터랙션을 그대로 축소해 보여준다 */
.landing-streak {
display: flex;
gap: 8px;
}
.landing-day {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--color-text-muted);
background: var(--color-surface);
}
.landing-day.done {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
opacity: 0;
animation: landing-day-in 0.35s ease-out forwards;
}
.landing-day.done svg {
width: 16px;
height: 16px;
}
.landing-day:nth-of-type(1).done { animation-delay: 0.1s; }
.landing-day:nth-of-type(2).done { animation-delay: 0.25s; }
.landing-day:nth-of-type(3).done { animation-delay: 0.4s; }
.landing-day:nth-of-type(4).done { animation-delay: 0.55s; }
.landing-day:nth-of-type(5).done { animation-delay: 0.7s; }
@keyframes landing-day-in {
from {
opacity: 0;
transform: scale(0.6);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.landing-day.done {
opacity: 1;
animation: none;
}
}
.landing-copy {
font-size: 15.5px;
line-height: 1.75;
color: var(--color-text);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.landing-copy strong {
color: var(--color-accent);
}
.landing-features {
display: grid;
gap: var(--space-2);
}
.landing-feature {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
}
.landing-feature svg {
flex-shrink: 0;
width: 20px;
height: 20px;
color: var(--color-accent);
}
.landing-feature p {
margin: 0;
font-size: 14px;
color: var(--color-text);
}
.landing-feature p strong {
display: block;
font-size: 14.5px;
margin-bottom: 2px;
}
/* 기능 설명 / 계정 안내 — 구글 브랜딩 심사가 홈페이지에서 확인하는 "앱의 목적" 설명 영역. */
.landing-section h2 {
font-size: 17px;
font-weight: 700;
margin: 0 0 var(--space-2);
}
.landing-list {
margin: 0;
padding-left: 1.1em;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 14.5px;
line-height: 1.65;
color: var(--color-text);
}
.landing-list strong {
color: var(--color-accent);
}
.landing-note {
margin: 0 0 var(--space-2);
font-size: 13.5px;
line-height: 1.7;
color: var(--color-text-muted);
}
.landing-note:last-child {
margin-bottom: 0;
}
.landing-note strong {
color: var(--color-text);
}
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
(function () {
function initSortable() {
var list = document.getElementById("journal-category-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-category-id"); })
.filter(Boolean)
.map(Number);
fetch("/api/journal/categories/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ category_ids: ids }),
});
},
});
}
document.addEventListener("DOMContentLoaded", initSortable);
})();
+294
View File
@@ -0,0 +1,294 @@
(function () {
// 저널 본문 입력창(.markdown-editor)의 편집 엔진을 기기별로 나눈다.
//
// EasyMDE(CodeMirror 5 기반)는 타이핑 중 문법에 색을 입혀 보여주는 진짜 마크다운 에디터
// 경험을 주지만, iOS Safari에서 한글처럼 여러 keystroke를 조합해 한 글자를 완성하는 IME
// 입력 중에 CodeMirror가 화면을 다시 그리면서 조합 버퍼를 끊어버려 자소가 분리된 채로
// 남는 문제(자소분리)가 있다. inputStyle을 contenteditable로 강제해도 아이폰에서까지
// 재현되는 걸 확인했다 — CodeMirror5 자체의 CJK IME 한계로 보고, iOS에서만 순수
// <textarea>로 폴백한다(브라우저 네이티브 입력 처리를 그대로 쓰면 IME가 깨질 이유가
// 없다). PC/안드로이드 등 iOS가 아닌 환경은 지금까지처럼 EasyMDE를 그대로 쓴다.
//
// 굵게/기울임 같은 툴바 버튼과 카테고리 템플릿 자동 채우기는 window.JournalEditor를
// 통해 호출되는데, 이 객체가 각 textarea에 EasyMDE가 붙어있는지(t._easymde) 보고
// EasyMDE 명령 또는 아래의 직접 선택 영역 조작 중 알맞은 쪽으로 위임한다 — 호출하는
// 템플릿 쪽(journal_editor_toolbar.html, journal.html)은 어느 엔진이 쓰이는지 몰라도 된다.
function isIOS() {
var ua = navigator.userAgent || "";
if (/iPad|iPhone|iPod/.test(ua)) return true;
// iPadOS 13+는 기본 설정에서 데스크톱 Safari인 척하는 UA를 보낸다("Macintosh"로 위장,
// "Request Mobile Website"를 켜지 않는 한). 이런 위장 아이패드를 잡아내는 표준적인
// 방법은 "Mac인데 멀티터치가 된다"는 조합을 보는 것이다(실제 맥은 터치스크린이 없음).
return navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
}
function fireInput(textarea) {
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
// ---- 순수 textarea 모드에서 툴바가 쓰는 선택 영역 조작 ----
function insertAtCursor(textarea, text) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
textarea.value = value.slice(0, start) + text + value.slice(end);
var pos = start + text.length;
textarea.selectionStart = textarea.selectionEnd = pos;
fireInput(textarea);
}
function replaceMarker(textarea, marker, replacement) {
var idx = textarea.value.indexOf(marker);
if (idx === -1) return;
var before = textarea.value.slice(0, idx);
var after = textarea.value.slice(idx + marker.length);
textarea.value = before + replacement + after;
var pos = before.length + replacement.length;
textarea.selectionStart = textarea.selectionEnd = pos;
fireInput(textarea);
}
function wrapSelection(textarea, prefix, suffix) {
if (suffix === undefined) suffix = prefix;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var selected = value.slice(start, end);
textarea.value = value.slice(0, start) + prefix + selected + suffix + value.slice(end);
textarea.selectionStart = start + prefix.length;
textarea.selectionEnd = start + prefix.length + selected.length;
textarea.focus();
fireInput(textarea);
}
function currentLineRange(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var value = textarea.value;
var lineStart = value.lastIndexOf("\n", start - 1) + 1;
var lineEnd = value.indexOf("\n", end);
if (lineEnd === -1) lineEnd = value.length;
return { lineStart: lineStart, lineEnd: lineEnd, block: value.slice(lineStart, lineEnd) };
}
function replaceBlock(textarea, range, newBlock) {
var value = textarea.value;
textarea.value = value.slice(0, range.lineStart) + newBlock + value.slice(range.lineEnd);
textarea.selectionStart = range.lineStart;
textarea.selectionEnd = range.lineStart + newBlock.length;
textarea.focus();
fireInput(textarea);
}
function toggleLinePrefix(textarea, prefix) {
var range = currentLineRange(textarea);
var lines = range.block.split("\n");
var allPrefixed = lines.every(function (line) { return line.indexOf(prefix) === 0; });
var newLines = lines.map(function (line) {
if (allPrefixed) return line.slice(prefix.length);
return line.indexOf(prefix) === 0 ? line : prefix + line;
});
replaceBlock(textarea, range, newLines.join("\n"));
}
function toggleOrderedListPlain(textarea) {
var range = currentLineRange(textarea);
var lines = range.block.split("\n");
var re = /^\d+\.\s/;
var allNumbered = lines.every(function (line) { return re.test(line); });
var newLines = lines.map(function (line, i) {
return allNumbered ? line.replace(re, "") : (i + 1) + ". " + line.replace(re, "");
});
replaceBlock(textarea, range, newLines.join("\n"));
}
function toggleHeadingPlain(textarea) {
var range = currentLineRange(textarea);
var re = /^#{1,6}\s/;
var newBlock = re.test(range.block) ? range.block.replace(re, "") : "### " + range.block;
replaceBlock(textarea, range, newBlock);
}
function toggleCodePlain(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
if (selected.indexOf("\n") === -1) {
wrapSelection(textarea, "`");
return;
}
var value = textarea.value;
textarea.value = value.slice(0, start) + "```\n" + selected + "\n```" + value.slice(end);
textarea.selectionStart = start + 4;
textarea.selectionEnd = start + 4 + selected.length;
textarea.focus();
fireInput(textarea);
}
function insertLinkPlain(textarea) {
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end) || "링크 텍스트";
var url = window.prompt("링크 주소를 입력하세요", "https://");
if (!url) return;
var value = textarea.value;
var markdown = "[" + selected + "](" + url + ")";
textarea.value = value.slice(0, start) + markdown + value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + markdown.length;
textarea.focus();
fireInput(textarea);
}
// journal_editor_toolbar.html의 버튼들과 journal.html의 카테고리 템플릿 자동 채우기가
// 호출하는 공개 API — 어느 엔진(EasyMDE/순수 textarea)이 붙어있는지는 t._easymde 유무로
// 판단해서 알맞은 쪽으로 위임한다.
window.JournalEditor = {
bold: function (t) { if (t) (t._easymde ? t._easymde.toggleBold() : wrapSelection(t, "**")); },
italic: function (t) { if (t) (t._easymde ? t._easymde.toggleItalic() : wrapSelection(t, "*")); },
strike: function (t) { if (t) (t._easymde ? t._easymde.toggleStrikethrough() : wrapSelection(t, "~~")); },
heading: function (t) { if (t) (t._easymde ? t._easymde.toggleHeadingSmaller() : toggleHeadingPlain(t)); },
quote: function (t) { if (t) (t._easymde ? t._easymde.toggleBlockquote() : toggleLinePrefix(t, "> ")); },
ul: function (t) { if (t) (t._easymde ? t._easymde.toggleUnorderedList() : toggleLinePrefix(t, "- ")); },
ol: function (t) { if (t) (t._easymde ? t._easymde.toggleOrderedList() : toggleOrderedListPlain(t)); },
code: function (t) { if (t) (t._easymde ? t._easymde.toggleCodeBlock() : toggleCodePlain(t)); },
link: function (t) { if (t) (t._easymde ? t._easymde.drawLink() : insertLinkPlain(t)); },
setValue: function (t, value) {
if (!t) return;
t.value = value;
if (t._easymde) t._easymde.value(value);
fireInput(t);
},
};
// ---- 클립보드 이미지 붙여넣기 ----
// 스크린샷/사진을 그대로 붙여넣기 대신 서버에 업로드하고 그 자리에 마크다운 이미지
// 문법(![](url))을 끼워넣는다. 두 엔진 다 업로드 로직은 같고, "지금 커서 위치를 어떻게
// 표시해뒀다가 나중에 찾아서 바꿔치기하는지"만 다르다.
function uploadPastedImage(file) {
var formData = new FormData();
formData.append("file", file, file.name || "pasted-image.png");
return fetch("/api/journal/paste-image", { method: "POST", body: formData }).then(function (res) {
if (!res.ok) return res.json().then(function (body) { throw new Error(body.detail || "업로드 실패"); });
return res.json();
});
}
function extractImageFile(event) {
var items = event.clipboardData && event.clipboardData.items;
if (!items) return null;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image/") === 0) return items[i].getAsFile();
}
return null;
}
// 순수 textarea 모드: 업로드 중 표식을 값에서 찾아 최종 링크로 바꾼다. 고정된 커서
// 좌표 대신 텍스트 표식으로 위치를 추적하므로, 업로드가 끝나기 전에 사용자가 다른 곳을
// 계속 타이핑해도 자리를 잃지 않는다.
function handlePlainImagePaste(textarea, event) {
var file = extractImageFile(event);
if (!file) return;
event.preventDefault();
var marker = "![업로드 중… #" + Math.random().toString(36).slice(2, 8) + "]()";
insertAtCursor(textarea, marker);
uploadPastedImage(file)
.then(function (data) { replaceMarker(textarea, marker, "![](" + data.url + ")"); })
.catch(function (err) { replaceMarker(textarea, marker, "(이미지 붙여넣기 실패: " + err.message + ")"); });
}
// EasyMDE 모드: CodeMirror 북마크로 위치를 추적한다(원본 로직 그대로).
function handleCodeMirrorImagePaste(cm, event) {
var file = extractImageFile(event);
if (!file) return;
event.preventDefault();
var doc = cm.getDoc();
var from = doc.getCursor();
var placeholder = "![업로드 중...]()";
doc.replaceRange(placeholder, from);
var to = { line: from.line, ch: from.ch + placeholder.length };
var startMark = doc.setBookmark(from);
var endMark = doc.setBookmark(to);
uploadPastedImage(file)
.then(function (data) {
var start = startMark.find();
var end = endMark.find();
if (start && end) doc.replaceRange("![](" + data.url + ")", start, end);
})
.catch(function (err) {
var start = startMark.find();
var end = endMark.find();
if (start && end) doc.replaceRange("(이미지 붙여넣기 실패: " + err.message + ")", start, end);
})
.finally(function () {
startMark.clear();
endMark.clear();
cm.save();
cm.getTextArea().dispatchEvent(new Event("input", { bubbles: true }));
});
}
function initEasyMDE(textarea) {
// 내장 툴바(toolbar: false)는 안 쓴다 — EasyMDE 기본 툴바 아이콘은 Font Awesome CDN을
// 전제로 하는데, 이 앱은 CDN을 안 쓰는 게 원칙이라 대신 journal_editor_toolbar.html의
// 자체 버튼이 위 JournalEditor를 통해 EasyMDE 인스턴스 메서드를 호출한다.
var easymde = new EasyMDE({
element: textarea,
toolbar: false,
spellChecker: false,
autoDownloadFontAwesome: false,
status: false,
placeholder: textarea.getAttribute("placeholder") || "",
minHeight: (textarea.getAttribute("rows") || 6) * 24 + "px",
// 글머리 목록 버튼/Enter 자동 이어쓰기가 기본 "*" 대신 "-"를 쓰게 한다.
// 서버 렌더링(app/markdown_utils.py)은 -/*/+ 전부 동일하게 처리하니 렌더링과는 무관하고
// 순수하게 에디터가 새로 만들어주는 글머리 기호에 대한 취향 설정이다.
unorderedListStyle: "-",
});
// CodeMirror는 원본 textarea와 실시간으로 값이 동기화되지 않는다(.save()를 명시적으로
// 불러야 함) — 매 변경마다 저장하고, htmx 미리보기(hx-trigger="input ...")와
// Alpine x-model이 둘 다 반응하도록 input 이벤트를 합성해서 던진다.
easymde.codemirror.on("change", function () {
easymde.codemirror.save();
fireInput(textarea);
});
easymde.codemirror.on("paste", function (cm, event) {
handleCodeMirrorImagePaste(cm, event);
});
textarea._easymde = easymde;
}
function initPlainTextarea(textarea) {
textarea.addEventListener("paste", function (event) { handlePlainImagePaste(textarea, event); });
}
function initEditors(root) {
var scope = root instanceof Element ? root : document;
var textareas = scope.querySelectorAll("textarea.markdown-editor:not([data-journal-editor-initialized])");
textareas.forEach(function (textarea) {
textarea.setAttribute("data-journal-editor-initialized", "true");
if (typeof EasyMDE !== "undefined" && !isIOS()) {
initEasyMDE(textarea);
} else {
initPlainTextarea(textarea);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
initEditors(document);
});
document.body.addEventListener("htmx:afterSwap", function (evt) {
initEditors(evt.target);
});
})();
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "습관 트래커",
"short_name": "습관 트래커",
"name": "해빗랩",
"short_name": "해빗랩",
"description": "형성하고 싶은 습관과 끊고 싶은 습관을 요일별로 관리하고 매일 체크하는 개인용 습관 관리 앱",
"start_url": "/today",
"scope": "/",
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>오프라인 - 습관 트래커</title>
<title>오프라인 - 해빗랩</title>
<link rel="icon" href="/static/icons/icon-192.png" />
<link rel="stylesheet" href="/static/css/style.css" />
</head>
+6 -2
View File
@@ -1,12 +1,16 @@
const CACHE_NAME = "habit-tracker-v4";
const CACHE_NAME = "habit-tracker-v8";
const APP_SHELL = [
"/static/css/style.css",
"/static/css/vendor/easymde.min.css",
"/static/js/app.js",
"/static/js/push-register.js",
"/static/js/habit-reorder.js",
"/static/js/journal-category-reorder.js",
"/static/js/journal-editor.js",
"/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js",
"/static/js/vendor/easymde.min.js",
"/static/icons/icon-192.png",
"/static/icons/icon-512.png",
"/static/icons/icon-apple-180.png",
@@ -78,7 +82,7 @@ self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "습관 트래커", {
self.registration.showNotification(data.title || "해빗랩", {
body: data.body || "",
icon: "/static/icons/icon-192.png",
badge: "/static/icons/icon-192.png",
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}페이지를 찾을 수 없어요 · 습관 트래커{% endblock %}
{% block title %}페이지를 찾을 수 없어요 · 해빗랩{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}오류가 발생했어요 · 습관 트래커{% endblock %}
{% block title %}오류가 발생했어요 · 해빗랩{% endblock %}
{% block content %}
<div style="min-height: 60vh; display:flex; align-items:center; justify-content:center; text-align:center;">
<div>
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}계정 · 해빗랩{% endblock %}
{% block content %}
<h1>계정</h1>
<div class="card">
<p><strong>이메일</strong><br />{{ current_user.email }}</p>
{% if current_user.name %}<p><strong>이름</strong><br />{{ current_user.name }}</p>{% endif %}
<p><strong>가입일</strong><br />{{ current_user.created_at.strftime('%Y-%m-%d') }}</p>
</div>
<div class="card">
<h2>계정 삭제</h2>
<p>
계정을 삭제하면 등록한 모든 습관, 체크 기록, 알림 구독 정보가 즉시 영구적으로 삭제되며 되돌릴 수 없습니다.
</p>
{% if delete_error %}<p style="color: var(--color-danger);">{{ delete_error }}</p>{% endif %}
<form method="post" action="/account/delete">
<label for="confirm_email">확인을 위해 본인 이메일({{ current_user.email }})을 입력하세요</label>
<input type="text" id="confirm_email" name="confirm_email" autocomplete="off" />
<button type="submit" class="btn btn-danger-ghost btn-block" style="margin-top: var(--space-2);">계정 영구 삭제</button>
</form>
</div>
{% endblock %}
+19 -4
View File
@@ -3,23 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{% block title %}습관 트래커{% endblock %}</title>
<title>{% block title %}해빗랩{% endblock %}</title>
{# 구글 OAuth 브랜딩 심사는 홈페이지에서 앱 이름과 목적을 확인한다 — 설명 메타 태그가 없으면 "앱의 목적에 관한 설명이 없다"로 반려된다. #}
<meta name="description" content="{% block meta_description %}HabitLab (해빗랩) is a habit tracking web app. 해빗랩은 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 관리하는 습관 기록 앱입니다.{% endblock %}" />
<link rel="manifest" href="/static/manifest.json" />
<meta name="theme-color" content="#d97757" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#e08962" media="(prefers-color-scheme: dark)" />
<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="습관 트래커" />
<meta name="apple-mobile-web-app-title" content="해빗랩" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-apple-180.png" />
<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" />
<link rel="stylesheet" href="/static/css/vendor/easymde.min.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/journal-category-reorder.js" defer></script>
<script src="/static/js/vendor/easymde.min.js" defer></script>
<script src="/static/js/journal-editor.js" defer></script>
<script src="/static/js/vendor/alpine.min.js" defer></script>
<script src="/static/js/app.js" defer></script>
</head>
@@ -31,16 +37,17 @@
<button type="button" onclick="dismissIosInstallBanner()" aria-label="닫기">&times;</button>
</div>
<nav class="top-nav">
<span class="brand">습관 트래커</span>
<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>
<a href="/journal" class="{% block nav_journal %}{% endblock %}">일기</a>
</div>
{% if current_user %}
<div class="nav-user">
{% if account_level %}<span class="nav-level-pill">Lv.{{ account_level.level }}</span>{% endif %}
<span>{{ current_user.name or current_user.email }}</span>
<a href="/account">{{ current_user.name or current_user.email }}</a>
<form method="post" action="/auth/logout">
<button type="submit" class="btn-link">로그아웃</button>
</form>
@@ -60,9 +67,17 @@
<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>
<a href="/journal" class="tab-item {{ self.nav_journal() }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" /></svg>
<span>일기</span>
</a>
</nav>
{% endif %}
{% block content %}{% endblock %}
<footer class="app-footer">
<a href="/privacy">개인정보처리방침</a>
<a href="/terms">서비스 약관</a>
</footer>
</div>
</body>
</html>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}습관 관리 · 습관 트래커{% endblock %}
{% block title %}습관 관리 · 해빗랩{% endblock %}
{% block nav_habits %}active{% endblock %}
{% block content %}
<h1>습관 관리</h1>
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}기록 · 습관 트래커{% endblock %}
{% block title %}기록 · 해빗랩{% endblock %}
{% block nav_history %}active{% endblock %}
{% block shell_class %} wide{% endblock %}
{% block content %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}해빗랩 (HabitLab) · 습관이 만들어지는 진짜 시간{% endblock %}
{% block meta_description %}HabitLab (해빗랩) is a habit tracking web app for building good habits and quitting bad ones on a weekly schedule. 해빗랩은 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 관리하는 습관 기록 앱입니다. 요일별 습관 등록과 데일리 체크, 월별·주별 기록과 완료율·연속 달성일 통계, 설정한 시각의 습관 알림, 하루를 남기는 일기를 제공합니다.{% endblock %}
{% block content %}
<div class="landing">
<div class="landing-hero">
{# 앱 이름은 h1의 순수 텍스트이면서 화면에서 가장 큰 글자여야 한다 — 구글 브랜딩 심사가 OAuth 동의 화면의
앱 이름과 홈페이지의 앱 이름을 대조하는데, 태그라인이 이름보다 크면 그쪽을 사이트 이름으로 본다.
로고는 이름 추출을 방해하지 않도록 h1 바깥에 둔다. #}
<img class="landing-logo" src="/static/icons/icon-192.png" alt="" />
<h1 class="landing-brand">해빗랩 (HabitLab)</h1>
<p class="landing-hero-title">습관이 만들어지는 진짜 시간</p>
<p class="landing-tagline">21일의 법칙 대신, 습관마다 다른 진짜 목표 기간을 알려드려요</p>
<div class="landing-streak" aria-hidden="true">
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day done">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-9" /></svg>
</div>
<div class="landing-day"></div>
<div class="landing-day"></div>
</div>
</div>
<div class="landing-copy">
<p>
"습관은 21일이면 만들어진다"는 말, 많이 들어보셨을 거예요. 사실 근거가 약한 통설이에요.
실증 연구(<strong>Lally et al., 2010, UCL</strong>)에 따르면 습관이 몸에 붙기까지 걸리는 시간은
사람마다, 습관마다 달라서 짧게는 3주, 길게는 8개월 넘게 걸리기도 해요.
</p>
<p>
<strong>해빗랩</strong>은 여러분이 만들고 싶은 습관도, 멈추고 싶은 습관도 함께 만들어가는 동반자예요.
검증된 연구를 시스템에 그대로 담아 목표 기간과 진행 상황을 짚어드리고, 그 여정을 끝까지 응원할게요.
</p>
</div>
<div class="landing-section">
<h2>해빗랩이 하는 일</h2>
<ul class="landing-list">
<li><strong>요일별 습관 등록</strong> — 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 등록하고, 습관마다 목표 기간과 달성 조건을 정합니다.</li>
<li><strong>데일리 체크</strong> — 오늘 예정된 습관을 한 화면에서 체크하고, 놓친 습관은 실패로 표시해 솔직하게 기록합니다.</li>
<li><strong>기록과 통계</strong> — 월별 달력과 주별 매트릭스로 지나온 기록을 돌아보고, 습관별 완료율과 연속 달성일을 확인합니다.</li>
<li><strong>습관 알림</strong> — 습관마다 설정한 시각에 브라우저 푸시 알림을 보내드립니다. 알림을 켠 경우에만 동작합니다.</li>
<li><strong>일기</strong> — 하루의 회고나 습관과 무관한 자유 일기를 카테고리·태그·사진과 함께 남길 수 있습니다.</li>
</ul>
</div>
<div class="landing-features">
<div class="landing-feature">
<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" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></svg>
<p><strong>연구 기반 목표 기간</strong>속설이 아니라 실제 연구값(21·66·254일)으로 목표를 잡아요</p>
</div>
<div class="landing-feature">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="6" /><line x1="8" y1="9" x2="8" y2="15" /><line x1="5" y1="12" x2="11" y2="12" /><circle cx="17" cy="12" r="6" /><line x1="14" y1="12" x2="20" y2="12" /></svg>
<p><strong>만들기와 끊기, 동시에</strong>새 습관을 만드는 것과 나쁜 습관을 끊는 것을 똑같은 무게로 다뤄요</p>
</div>
<div class="landing-feature">
<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" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
<p><strong>광고·구독 없음</strong>습관관리에만 집중해요, 그 외엔 아무것도 없어요</p>
</div>
</div>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 시작하기</a>
<div class="landing-section">
<h2>계정과 로그인</h2>
<p class="landing-note">
해빗랩은 구글 계정으로만 로그인합니다. 로그인 시 구글로부터 <strong>이름, 이메일 주소, 프로필 사진</strong>을 받아
계정을 식별하고 화면에 표시하는 용도로만 사용하며, 별도의 비밀번호는 수집하지 않습니다.
Gmail, 드라이브, 캘린더 등 다른 구글 서비스의 데이터에는 접근하지 않습니다.
직접 입력한 습관·기록·일기는 본인 계정에만 저장되고 다른 이용자에게 공개되지 않으며,
로그인 후 계정 페이지에서 언제든 계정과 모든 데이터를 직접 삭제할 수 있습니다.
</p>
<p class="landing-note">
자세한 내용은 <a href="/privacy">개인정보처리방침</a><a href="/terms">서비스 약관</a>을 확인해주세요.
문의: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a>
</p>
</div>
{# 영문 요약 — 구글 브랜딩 심사의 자동 검사가 한글만으로는 앱 목적을 인식하지 못하는 것으로 보여 병기한다. #}
<div class="landing-section" lang="en">
<h2>About HabitLab (해빗랩)</h2>
<p class="landing-note">
<strong>HabitLab (해빗랩)</strong> is a personal habit tracking web app. It helps you build the habits
you want and quit the ones you don't, on a per-weekday schedule.
</p>
<p class="landing-note">
You can register habits for specific days of the week with a target period based on published research
(Lally et al., 2010, UCL), check them off daily, review your history on monthly and weekly views with
completion rates and streaks, receive browser push reminders at a time you choose, and keep a journal.
</p>
<p class="landing-note">
HabitLab uses Google Sign-In. It receives only your name, email address, and profile picture from Google
to identify your account, and does not access Gmail, Drive, Calendar, or any other Google service data.
See our <a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
Contact: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a>
</p>
</div>
</div>
{% endblock %}
+276
View File
@@ -0,0 +1,276 @@
{% extends "base.html" %}
{% block title %}일기 · 해빗랩{% endblock %}
{% block nav_journal %}active{% endblock %}
{% block shell_class %} wide{% endblock %}
{% block content %}
<h1>일기</h1>
<div style="display:flex; align-items:center; gap: var(--space-1);">
<div class="tabs" style="flex:1; min-width:0;">
<a href="/journal?year={{ year }}&month={{ month }}" class="{{ 'active' if tab != 'manage' and not category_id }}">전체</a>
{% for category in categories %}
<a
href="/journal?year={{ year }}&month={{ month }}&category_id={{ category.id }}"
class="{{ 'active' if tab != 'manage' and category_id == category.id }}"
>{{ category.name }}</a>
{% endfor %}
</div>
<a href="/journal?tab=manage" class="btn btn-secondary{{ ' active' if tab == 'manage' }}" style="flex-shrink:0; margin-bottom: var(--space-2); text-decoration:none;">⚙ 관리</a>
</div>
{% if tab == "manage" %}
{% if categories %}
<div class="card">
<div class="day-detail-list" id="journal-category-list">
{% for category in categories %}
<div class="journal-entry-item" data-category-id="{{ category.id }}" x-data="{ editing: false }">
<div class="day-detail-item" x-show="!editing">
<span style="display:flex; align-items:center; gap: 8px;">
<span class="drag-handle" aria-hidden="true"></span>
<span class="badge" style="border-color: {{ category.color or 'var(--color-accent)' }};">{{ category.name }}</span>
<span class="badge">기록 {{ entry_counts.get(category.id, 0) }}개</span>
</span>
<span style="display:flex; gap:8px;">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
<button
type="button"
class="btn btn-danger-ghost"
hx-post="/journal/categories/{{ category.id }}/delete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ category.name }}' 카테고리를 삭제할까요? 이 카테고리의 모든 기록({{ entry_counts.get(category.id, 0) }}개)과 첨부파일도 함께 삭제됩니다."
>삭제</button>
</span>
</div>
<form
x-show="editing"
x-cloak
style="padding: 8px 0;"
hx-post="/journal/categories/{{ category.id }}/edit"
hx-target="#journal-category-edit-error-{{ category.id }}"
hx-swap="innerHTML"
>
<div class="field">
<label for="edit-category-name-{{ category.id }}">카테고리 이름</label>
<input type="text" id="edit-category-name-{{ category.id }}" name="name" required value="{{ category.name }}" />
</div>
<div class="field">
<label for="edit-category-color-{{ category.id }}">색상</label>
<input type="color" id="edit-category-color-{{ category.id }}" name="color" value="{{ category.color or '#d97757' }}" />
</div>
<div class="field">
<label for="edit-category-template-{{ category.id }}">기본 작성 틀 (선택)</label>
<textarea id="edit-category-template-{{ category.id }}" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요">{{ category.content_template or '' }}</textarea>
</div>
<div id="journal-category-edit-error-{{ category.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>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card" x-data="{ open: false }">
<button type="button" class="btn btn-secondary btn-block" @click="open = !open">
<span x-show="!open">+ 카테고리 추가</span>
<span x-show="open" x-cloak>닫기</span>
</button>
<form x-show="open" x-cloak style="margin-top: var(--space-2);" hx-post="/journal/categories/new" hx-target="#journal-category-error" hx-swap="innerHTML">
<div class="field">
<label for="new-category-name">카테고리 이름</label>
<input type="text" id="new-category-name" name="name" required placeholder="예: 투자" />
</div>
<div class="field">
<label for="new-category-color">색상 (선택)</label>
<input type="color" id="new-category-color" name="color" value="#d97757" />
</div>
<div class="field">
<label for="new-category-template">기본 작성 틀 (선택)</label>
<textarea id="new-category-template" name="content_template" rows="4" placeholder="이 카테고리로 새 기록을 쓸 때 내용칸에 미리 채워줄 틀을 입력하세요"></textarea>
</div>
<div id="journal-category-error" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
</form>
</div>
{% else %}
{% if on_this_day %}
<div class="card on-this-day-card">
<h3 style="margin-top:0;">📅 {{ on_this_day|length }}개의 지난 오늘</h3>
{% for item in on_this_day %}
<div class="day-detail-item">
<span>
<span class="badge" style="border-color: {{ item.category_color or 'var(--color-accent)' }};">{{ item.category_name }}</span>
{{ item.entry_date.year }}년 · {{ item.years_ago }}년 전
</span>
<span>{{ item.title or (item.content[:30] ~ ('…' if item.content|length > 30 else '')) }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div
class="card"
x-data="{
open: false,
moods: [],
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
categoryTemplates: {{ category_templates|tojson|forceescape }},
content: '',
lastAutoFilled: '',
previewMode: false,
togglePreview() {
this.previewMode = !this.previewMode;
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
},
onCategoryChange(categoryId) {
const tmpl = this.categoryTemplates[categoryId] || '';
if (this.content === this.lastAutoFilled) {
this.content = tmpl;
this.lastAutoFilled = tmpl;
this.$nextTick(() => { JournalEditor.setValue(this.$refs.contentField, tmpl); });
}
},
init() { this.onCategoryChange(this.$refs.categorySelect.value); },
}"
>
<button type="button" class="btn btn-primary btn-block" @click="open = !open">
<span x-show="!open">+ 새 기록 쓰기</span>
<span x-show="open" x-cloak>닫기</span>
</button>
{% if prompt %}
<div class="journal-prompt-card">💭 {{ prompt.question_text }}</div>
{% endif %}
<form
x-show="open"
x-cloak
style="margin-top: var(--space-2);"
hx-post="/journal/new"
hx-target="#journal-form-error"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div class="field">
<label for="new-entry-category">카테고리</label>
<select id="new-entry-category" name="category_id" x-ref="categorySelect" @change="onCategoryChange($event.target.value)">
{% for category in categories %}
<option value="{{ category.id }}" {{ 'selected' if category_id == category.id }}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="new-entry-date">날짜</label>
<input type="date" id="new-entry-date" name="entry_date" value="{{ today_iso }}" required />
</div>
<div class="field">
<label for="new-entry-title">제목 (선택)</label>
<input type="text" id="new-entry-title" name="title" placeholder="제목을 입력하세요" />
</div>
<div class="field">
<label for="new-entry-content">내용</label>
{% include "partials/journal_editor_toolbar.html" %}
<div x-show="!previewMode">
<textarea
id="new-entry-content"
name="content"
rows="8"
required
placeholder="오늘 하루는 어땠나요?"
class="markdown-editor"
x-model="content"
x-ref="contentField"
hx-post="/journal/preview"
hx-trigger="input changed delay:400ms, load"
hx-target="#new-entry-preview"
hx-swap="innerHTML"
hx-params="content"
></textarea>
</div>
<div id="new-entry-preview" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
</div>
<div class="field">
<label>기분 (선택, 여러 개 선택 가능)</label>
<input type="hidden" name="moods" :value="moods.join(',')" />
<div class="mood-picker">
{% for value, emoji, label in journal_mood_options %}
<button
type="button"
class="mood-pill"
:class="{ selected: moods.includes('{{ value.value }}') }"
@click="toggleMood('{{ value.value }}')"
>{{ emoji }} {{ label }}</button>
{% endfor %}
</div>
</div>
<div class="field">
<label for="new-entry-tags">태그 (선택, 쉼표로 구분)</label>
<input type="text" id="new-entry-tags" name="tags" placeholder="예: 투자, 회고" />
</div>
<div class="field">
<label for="new-entry-files">사진/영상 첨부 (선택)</label>
<input type="file" id="new-entry-files" name="files" accept="image/*,video/*" multiple />
</div>
<div id="journal-form-error" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">기록 저장</button>
</form>
</div>
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<a href="/journal?year={{ prev_year }}&month={{ prev_month }}{{ '&category_id=' ~ category_id if category_id }}" class="btn btn-secondary"></a>
<h2 style="margin:0;">{{ year }}년 {{ month }}월</h2>
<a href="/journal?year={{ next_year }}&month={{ next_month }}{{ '&category_id=' ~ category_id if category_id }}" class="btn btn-secondary"></a>
</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 day_summary = summary_map.get(d) %}
<div
class="calendar-cell calendar-cell-journal clickable{{ '' if d.month == month else ' muted' }}"
hx-get="/journal/day/{{ d.isoformat() }}"
hx-target="#day-detail"
hx-swap="innerHTML"
>
<span class="calendar-date">{{ d.day }}</span>
{% if day_summary %}
<span class="journal-day-entries">
{% for entry in day_summary.entries %}
<span class="journal-day-entry">
<span class="journal-day-dot" style="background: {{ entry.color }};"></span>
<span class="journal-day-title">{{ entry.title }}</span>
</span>
{% endfor %}
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div id="day-detail"></div>
{% endif %}
{% endblock %}
+3 -2
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block title %}로그인 · 습관 트래커{% endblock %}
{% 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>
<h1 style="text-align:center;">해빗랩</h1>
{% if deleted %}<p style="text-align:center; color: var(--color-success);">계정이 삭제되었습니다.</p>{% endif %}
<p style="text-align:center;">구글 계정으로 로그인하세요</p>
<a href="/auth/google/login" class="btn btn-primary btn-block">Google로 로그인</a>
</div>
@@ -0,0 +1,155 @@
{% set weekday_names = ["월", "화", "수", "목", "금", "토", "일"] %}
<div class="card day-detail-card">
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
<h3 style="margin:0;">{{ entry_date.strftime("%Y.%m.%d") }} ({{ weekday_names[entry_date.weekday()] }})</h3>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('day-detail').innerHTML=''">닫기</button>
</div>
{% if items %}
<div class="day-detail-list">
{% for item in items %}
<div
class="journal-entry-item"
x-data="{
editing: {{ 'true' if editing_entry_id == item.id else 'false' }},
moods: [{% for m in item.moods %}'{{ m.value }}'{{ ',' if not loop.last }}{% endfor %}],
toggleMood(v) { this.moods.includes(v) ? this.moods = this.moods.filter(m => m !== v) : this.moods.push(v) },
previewMode: false,
togglePreview() {
this.previewMode = !this.previewMode;
if (this.previewMode) { this.$refs.contentField.dispatchEvent(new Event('input')); }
},
}"
>
<div x-show="!editing">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span class="badge" style="border-color: {{ item.category_color or 'var(--color-accent)' }};">{{ item.category_name }}</span>
{% if item.moods %}
<span class="mood-icon">{% for m in item.moods %}{{ journal_mood_emoji[m.value] }}{% endfor %}</span>
{% endif %}
</div>
{% if item.title %}<div class="journal-entry-title">{{ item.title }}</div>{% endif %}
<div class="journal-entry-content">{{ item.content|markdown }}</div>
{% if item.tags %}
<div class="journal-entry-tags">
{% for tag in item.tags %}<span class="tag-chip">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if item.attachments %}
<div class="attachment-grid">
{% for attachment in item.attachments %}
<div class="attachment-thumb">
{% if attachment.media_type.value == "image" %}
<a href="/api/journal/media/{{ attachment.id }}" target="_blank">
<img src="/api/journal/media/{{ attachment.id }}?thumbnail=true" alt="{{ attachment.original_filename }}" />
</a>
{% else %}
<a href="/api/journal/media/{{ attachment.id }}" target="_blank" class="attachment-video-link">▶ {{ attachment.original_filename }}</a>
{% endif %}
<button
type="button"
class="attachment-delete-btn"
hx-post="/journal/{{ item.id }}/attachments/{{ attachment.id }}/delete"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-confirm="이 첨부파일을 삭제할까요?"
aria-label="첨부파일 삭제"
>&times;</button>
</div>
{% endfor %}
</div>
{% endif %}
<div class="habit-item-actions" style="margin-top: 8px;">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
<button
type="button"
class="btn btn-danger-ghost"
hx-post="/journal/{{ item.id }}/delete"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-confirm="이 기록을 삭제할까요?"
>삭제</button>
</div>
</div>
<form
x-show="editing"
x-cloak
style="padding: 12px 4px; border-bottom: 1px solid var(--color-border);"
hx-post="/journal/{{ item.id }}/edit"
hx-target="#day-detail"
hx-swap="innerHTML"
hx-encoding="multipart/form-data"
>
<div class="field">
<label>카테고리</label>
<select name="category_id">
{% for category in categories %}
<option value="{{ category.id }}" {{ 'selected' if category.id == item.category_id }}>{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>날짜</label>
<input type="date" name="entry_date" value="{{ entry_date.isoformat() }}" required />
</div>
<div class="field">
<label>제목</label>
<input type="text" name="title" value="{{ item.title or '' }}" />
</div>
<div class="field">
<label>내용</label>
{% include "partials/journal_editor_toolbar.html" %}
<div x-show="!previewMode">
<textarea
name="content"
rows="6"
required
class="markdown-editor"
x-ref="contentField"
hx-post="/journal/preview"
hx-trigger="input changed delay:400ms, load"
hx-target="#journal-edit-preview-{{ item.id }}"
hx-swap="innerHTML"
hx-params="content"
>{{ item.content }}</textarea>
</div>
<div id="journal-edit-preview-{{ item.id }}" class="journal-preview journal-entry-content" x-show="previewMode" x-cloak></div>
</div>
<div class="field">
<label>기분 (여러 개 선택 가능)</label>
<input type="hidden" name="moods" :value="moods.join(',')" />
<div class="mood-picker">
{% for value, emoji, label in journal_mood_options %}
<button
type="button"
class="mood-pill"
:class="{ selected: moods.includes('{{ value.value }}') }"
@click="toggleMood('{{ value.value }}')"
>{{ emoji }} {{ label }}</button>
{% endfor %}
</div>
</div>
<div class="field">
<label>태그 (쉼표로 구분)</label>
<input type="text" name="tags" value="{{ item.tags|join(', ') }}" />
</div>
<div class="field">
<label>사진/영상 추가</label>
<input type="file" name="files" accept="image/*,video/*" multiple />
</div>
{% if edit_error and editing_entry_id == item.id %}
<div class="form-error-text">{{ edit_error }}</div>
{% endif %}
<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>
{% endfor %}
</div>
{% else %}
<div class="empty-state">이 날 작성한 기록이 없어요.</div>
{% endif %}
</div>
@@ -0,0 +1,20 @@
<div class="markdown-toolbar">
<span x-show="!previewMode" style="display:flex; gap:4px; flex-wrap:wrap;">
<button type="button" class="md-tool-btn" title="굵게" @click="JournalEditor.bold($el.closest('.field').querySelector('textarea'))"><strong>B</strong></button>
<button type="button" class="md-tool-btn" title="기울임" @click="JournalEditor.italic($el.closest('.field').querySelector('textarea'))"><em>I</em></button>
<button type="button" class="md-tool-btn" title="취소선" @click="JournalEditor.strike($el.closest('.field').querySelector('textarea'))"><s>S</s></button>
<button type="button" class="md-tool-btn" title="제목" @click="JournalEditor.heading($el.closest('.field').querySelector('textarea'))">H</button>
<button type="button" class="md-tool-btn" title="인용" @click="JournalEditor.quote($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="글머리 목록" @click="JournalEditor.ul($el.closest('.field').querySelector('textarea'))"></button>
<button type="button" class="md-tool-btn" title="번호 목록" @click="JournalEditor.ol($el.closest('.field').querySelector('textarea'))">1.</button>
<button type="button" class="md-tool-btn" title="코드" @click="JournalEditor.code($el.closest('.field').querySelector('textarea'))">&lt;/&gt;</button>
<button type="button" class="md-tool-btn" title="링크" @click="JournalEditor.link($el.closest('.field').querySelector('textarea'))">🔗</button>
</span>
<button
type="button"
class="md-tool-btn md-preview-toggle"
style="margin-left:auto;"
@click="togglePreview()"
x-text="previewMode ? '✏️ 편집' : '👁 미리보기'"
></button>
</div>
+1 -1
View File
@@ -14,13 +14,13 @@
{% 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 level-badge">{{ level_tier_emoji(item.level_info.level) }} Lv.{{ item.level_info.level }}</span>
<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 %}
<span class="badge level-badge">{{ level_tier_emoji(item.level_info.level) }} Lv.{{ item.level_info.level }}</span>
</div>
<div class="xp-bar-wrap">
<div class="xp-bar"><div class="xp-bar-fill" style="width: {{ item.level_info.progress_pct }}%;"></div></div>
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}개인정보처리방침 · 해빗랩{% endblock %}
{% block content %}
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
<div class="card">
<h1>개인정보처리방침</h1>
<p>시행일자: 2026년 7월 22일</p>
<p>해빗랩(이하 "서비스")는 이용자의 개인정보를 소중히 다루며, 아래와 같이 개인정보를 수집·이용·보관합니다.</p>
<h2>1. 수집하는 개인정보 항목</h2>
<p><strong>가. 구글 로그인 시 (필수)</strong><br />
이메일 주소, 이름, 프로필 사진 URL, 구글 계정 고유 식별자(sub). 서비스는 구글 계정 정보를 이용해 자동으로 계정을 생성하며, 비밀번호는 별도로 수집·저장하지 않습니다.</p>
<p><strong>나. 서비스 이용 중 직접 입력하는 정보</strong><br />
등록한 습관의 이름, 유형(만들기/끊기), 요일 스케줄, 목표 난이도, 달성 조건, 알림 시각, 날짜별 체크·실패 기록.</p>
<p><strong>다. 알림(Web Push)을 켠 경우에만</strong><br />
브라우저가 발급하는 푸시 구독 정보(endpoint, 암호화 키)와 브라우저 종류(User-Agent). 알림 중복 발송을 막기 위한 발송 이력도 함께 기록됩니다.</p>
<p><strong>라. 자동 수집 정보</strong><br />
로그인 유지를 위한 서명된 세션 쿠키. 별도의 광고·분석·트래킹 쿠키는 사용하지 않습니다.</p>
<h2>2. 개인정보의 수집 및 이용 목적</h2>
<p>구글 계정 인증 및 로그인 유지, 습관 등록·체크·통계(완료율, 연속 달성일 등) 제공, 설정한 시각에 맞춘 습관 알림(Web Push) 발송을 위해서만 이용합니다. 광고, 마케팅, 프로필링 목적으로는 이용하지 않습니다.</p>
<h2>3. 개인정보의 보유 및 이용 기간</h2>
<p>회원 탈퇴 시까지 보관하며, 탈퇴 시 계정 정보와 습관·체크 기록, 푸시 구독 정보를 지체 없이 삭제합니다. 관계 법령에 따라 보존이 필요한 경우가 아니면 별도 보관하지 않습니다.</p>
<h2>4. 개인정보의 제3자 제공</h2>
<p>서비스는 이용자의 개인정보를 원칙적으로 외부에 제공하지 않습니다. 다만 구글 로그인 인증 과정에서 구글(Google LLC)과 통신이 발생하며, 이는 이용자 인증을 위한 목적에 한정됩니다.</p>
<h2>5. 이용자의 권리와 행사 방법</h2>
<p>이용자는 언제든지 자신의 개인정보 열람·정정·삭제를 요청할 수 있습니다. 로그인 후 <a href="/account">계정</a> 페이지에서 본인 이메일 확인 후 즉시 계정과 모든 데이터를 직접 삭제할 수 있으며, 앱을 이용하기 어려운 경우 아래 문의처로 요청하셔도 확인 후 지체 없이 처리해드립니다.</p>
<h2>6. 보안을 위한 조치</h2>
<p>비밀번호를 직접 저장하지 않는 구글 OAuth 인증 방식을 사용하며, 로그인 세션은 서명된 쿠키로 관리합니다. 모든 통신은 HTTPS로 암호화되며, 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
<h2>7. 문의처</h2>
<p>개인정보 관련 문의, 열람·정정·삭제 요청은 아래로 연락해주세요.</p>
<p>해빗랩 운영자<br />
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
<h2>8. 고지의 의무</h2>
<p>이 개인정보처리방침은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 고지합니다.</p>
</div>
</div>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}서비스 약관 · 해빗랩{% endblock %}
{% block meta_description %}해빗랩 서비스 약관 — 습관 기록 앱 해빗랩의 이용 조건, 계정, 이용자의 콘텐츠, 서비스 중단 및 책임 범위를 안내합니다.{% endblock %}
{% block content %}
<div style="max-width: 640px; margin: 0 auto; padding: 24px 16px 48px;">
<div class="card">
<h1>서비스 약관</h1>
<p>시행일자: 2026년 8월 8일</p>
<p>이 약관은 해빗랩(이하 "서비스")의 이용 조건을 정합니다. 서비스에 로그인하면 이 약관에 동의한 것으로 봅니다.</p>
<h2>1. 서비스 소개</h2>
<p>해빗랩은 이용자가 만들고 싶은 습관과 끊고 싶은 습관을 요일 단위로 등록하고, 매일 체크하며, 월별·주별 기록과 완료율·연속 달성일을 확인하고, 설정한 시각에 알림을 받고, 하루의 일기를 남길 수 있는 개인용 습관 기록 서비스입니다. 서비스는 무료로 제공되며 유료 결제 항목이 없습니다.</p>
<h2>2. 계정</h2>
<p>서비스는 구글 계정을 통한 로그인만 지원합니다. 이용자는 본인의 구글 계정으로 로그인해야 하며, 계정 접근 권한의 관리 책임은 이용자 본인에게 있습니다. 이용자는 로그인 후 계정 페이지에서 언제든지 계정과 모든 데이터를 직접 삭제할 수 있으며, 삭제 시 습관·체크 기록·일기·첨부파일·알림 구독 정보가 함께 삭제되고 복구할 수 없습니다.</p>
<h2>3. 이용자의 콘텐츠</h2>
<p>이용자가 서비스에 입력한 습관, 체크 기록, 일기, 첨부한 사진·영상 등 모든 콘텐츠의 권리는 이용자에게 있습니다. 서비스는 이 콘텐츠를 이용자에게 서비스를 제공하기 위한 목적(저장, 조회, 통계 계산, 알림 발송)으로만 처리하며, 광고·마케팅에 이용하거나 이용자의 동의 없이 제3자에게 제공하지 않습니다. 각 이용자의 데이터는 계정 단위로 분리되어 다른 이용자가 접근할 수 없습니다.</p>
<h2>4. 금지 행위</h2>
<p>이용자는 다음 행위를 해서는 안 됩니다.</p>
<p>가. 타인의 계정에 무단으로 접근하거나 접근을 시도하는 행위<br />
나. 서비스의 정상적인 운영을 방해하는 행위(비정상적인 대량 요청, 취약점 악용 등)<br />
다. 법령을 위반하거나 타인의 권리를 침해하는 콘텐츠를 저장·유포하는 행위</p>
<h2>5. 알림</h2>
<p>습관 알림(Web Push)은 이용자가 브라우저에서 알림 권한을 허용하고 직접 켠 경우에만 발송됩니다. 알림은 브라우저와 운영체제, 네트워크 상황에 따라 지연되거나 전달되지 않을 수 있으며, 서비스는 알림의 정시 도달을 보장하지 않습니다. 알림은 브라우저 설정이나 서비스 내에서 언제든 끌 수 있습니다.</p>
<h2>6. 서비스의 변경과 중단</h2>
<p>서비스는 개인이 운영하는 무료 서비스로, 기능이 변경되거나 점검·장애·운영 중단이 발생할 수 있습니다. 서비스 전체를 종료하는 경우에는 이용자가 데이터를 보관할 수 있도록 서비스 내 공지 또는 이메일로 사전에 안내합니다.</p>
<h2>7. 책임의 범위</h2>
<p>서비스는 습관 기록을 돕는 도구일 뿐이며, 의료·건강·심리 상담 등 전문적인 조언을 제공하지 않습니다. 건강과 관련된 결정은 반드시 전문가와 상의하시기 바랍니다. 서비스는 데이터 백업과 보전을 위해 합리적인 노력을 기울이지만, 무료로 제공되는 서비스의 성격상 데이터 손실이나 서비스 이용으로 발생한 손해에 대해 법령이 허용하는 범위에서 책임을 지지 않습니다.</p>
<h2>8. 개인정보</h2>
<p>개인정보의 수집·이용·보관에 관한 사항은 <a href="/privacy">개인정보처리방침</a>을 따릅니다.</p>
<h2>9. 약관의 변경</h2>
<p>이 약관은 법령이나 서비스 변경사항을 반영하기 위해 수정될 수 있으며, 변경 시 이 페이지를 통해 시행일자와 함께 고지합니다.</p>
<h2>10. 문의처</h2>
<p>해빗랩 운영자<br />
이메일: <a href="mailto:shinalok357@gmail.com">shinalok357@gmail.com</a></p>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}오늘 · 습관 트래커{% endblock %}
{% block title %}오늘 · 해빗랩{% endblock %}
{% block nav_today %}active{% endblock %}
{% block content %}
<div id="today-content">
+9
View File
@@ -0,0 +1,9 @@
# 이 파일을 복사해 deploy.env로 저장한 뒤 값을 채워넣으세요. deploy.env는 git에 커밋되지 않습니다.
# scripts/deploy_sftp.py가 이 값들로 SFTP 접속해 시놀로지 NAS에 소스를 동기화합니다.
SFTP_HOST=your-domain-or-ip
SFTP_PORT=22
SFTP_USERNAME=
SFTP_PASSWORD=
# 시놀로지 SFTP는 보통 /volume1을 세션 루트("/")로 보여주므로 /volume1 접두어 없이 적는다
SFTP_REMOTE_PATH=/docker/habit-tracker
+2
View File
@@ -9,3 +9,5 @@ services:
- TZ=Asia/Seoul
env_file:
- .env
volumes:
- ./media:/app/app/media
@@ -0,0 +1,143 @@
"""add journal tables (category, entry, tag, attachment, prompt)
Revision ID: 0010_add_journal_tables
Revises: 0009_add_habit_log_status
Create Date: 2026-08-04
습관 체크에 곁들이는 회고 + 독립적인 자유 일기를 함께 지원하는 저널링 기능을 위한 테이블.
카테고리(예: "일상", "투자")는 사용자별로 자유롭게 만들 수 있고, 하루에 같은 카테고리로
여러 엔트리를 쓸 수 있어 (habit_id, log_date) 같은 유니크 제약은 두지 않는다. journal_prompt는
카테고리 무관 전역 질문 뱅크로, 최소한의 한국어 질문 시드 데이터를 함께 넣는다(AI 기반 프롬프트는
후속 업데이트로 미룸).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0010_add_journal_tables"
down_revision: Union[str, None] = "0009_add_habit_log_status"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"journal_category",
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("name", sa.String(length=50), nullable=False),
sa.Column("color", sa.String(length=20), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "name", name="uq_journal_category_user_name"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"journal_entry",
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(
"category_id",
sa.Integer(),
sa.ForeignKey("journal_category.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("entry_date", sa.Date(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("mood", sa.String(length=20), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column(
"updated_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
server_onupdate=sa.func.now(),
),
sa.CheckConstraint(
"mood in ('great','good','neutral','bad','awful')", name="ck_journal_entry_mood"
),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"journal_tag",
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("name", sa.String(length=50), nullable=False),
sa.UniqueConstraint("user_id", "name", name="uq_journal_tag_user_name"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"journal_entry_tag",
sa.Column(
"entry_id", sa.Integer(), sa.ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True
),
sa.Column(
"tag_id", sa.Integer(), sa.ForeignKey("journal_tag.id", ondelete="CASCADE"), primary_key=True
),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"journal_attachment",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"entry_id", sa.Integer(), sa.ForeignKey("journal_entry.id", ondelete="CASCADE"), nullable=False
),
sa.Column("media_type", sa.String(length=10), nullable=False),
sa.Column("file_path", sa.String(length=500), nullable=False),
sa.Column("thumbnail_path", sa.String(length=500), nullable=True),
sa.Column("original_filename", sa.String(length=255), nullable=False),
sa.Column("file_size", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("media_type in ('image','video')", name="ck_journal_attachment_media_type"),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
op.create_table(
"journal_prompt",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("question_text", sa.String(length=300), nullable=False),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
journal_prompt = sa.table(
"journal_prompt",
sa.column("question_text", sa.String),
)
op.bulk_insert(
journal_prompt,
[
{"question_text": "오늘 가장 기억에 남는 순간은 언제였나요?"},
{"question_text": "오늘 감사했던 일은 무엇인가요?"},
{"question_text": "오늘 나를 힘들게 한 건 무엇이었고, 어떻게 다뤘나요?"},
{"question_text": "오늘 배운 것이 있다면 무엇인가요?"},
{"question_text": "내일의 나에게 해주고 싶은 말은?"},
{"question_text": "오늘 스스로를 칭찬하고 싶은 부분은?"},
{"question_text": "오늘 놓쳤지만 다음엔 다르게 해보고 싶은 것은?"},
{"question_text": "오늘 누군가에게 도움을 받았거나 준 적이 있나요?"},
{"question_text": "지금 가장 신경 쓰이는 고민은 무엇인가요?"},
{"question_text": "오늘 하루를 한 문장으로 요약한다면?"},
{"question_text": "요즘 내가 놓치고 있는 것은 없을까요?"},
{"question_text": "이번 주 목표에 얼마나 가까워졌나요?"},
],
)
def downgrade() -> None:
op.drop_table("journal_prompt")
op.drop_table("journal_attachment")
op.drop_table("journal_entry_tag")
op.drop_table("journal_tag")
op.drop_table("journal_entry")
op.drop_table("journal_category")
@@ -0,0 +1,51 @@
"""allow journal entries to have multiple moods, and expand the mood vocabulary
Revision ID: 0011_journal_entry_multi_mood
Revises: 0010_add_journal_tables
Create Date: 2026-08-04
기분을 하나만 고를 수 있던 journal_entry.mood(단일 컬럼)를, 태그처럼 여러 개를 붙일 수 있는
journal_entry_mood 연결 테이블로 바꾼다. mood 자체가 고정된 값 집합(enum)이라 journal_tag처럼
별도 이름 엔티티를 둘 필요 없이 값 자체를 PK로 쓴다. 동시에 기존 5종(만족도 스케일)에
구체적인 감정 9종을 추가한다.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0011_journal_entry_multi_mood"
down_revision: Union[str, None] = "0010_add_journal_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
MOOD_VALUES = (
"great", "good", "neutral", "bad", "awful",
"pain", "achievement", "anger", "excited", "calm", "happy", "worry", "tired", "sad",
)
def upgrade() -> None:
op.drop_constraint("ck_journal_entry_mood", "journal_entry", type_="check")
op.drop_column("journal_entry", "mood")
op.create_table(
"journal_entry_mood",
sa.Column(
"entry_id", sa.Integer(), sa.ForeignKey("journal_entry.id", ondelete="CASCADE"), primary_key=True
),
sa.Column("mood", sa.String(length=20), primary_key=True),
sa.CheckConstraint(
"mood in (" + ",".join(f"'{v}'" for v in MOOD_VALUES) + ")", name="ck_journal_entry_mood_mood"
),
mysql_engine="InnoDB",
mysql_charset="utf8mb4",
)
def downgrade() -> None:
op.drop_table("journal_entry_mood")
op.add_column("journal_entry", sa.Column("mood", sa.String(length=20), nullable=True))
op.create_check_constraint(
"ck_journal_entry_mood", "journal_entry", "mood in ('great','good','neutral','bad','awful')"
)
@@ -0,0 +1,45 @@
"""remove the 5 satisfaction-scale moods, keep only the 9 named emotions
Revision ID: 0012_remove_satisfaction_moods
Revises: 0011_journal_entry_multi_mood
Create Date: 2026-08-04
기분 선택을 다중 선택으로 바꾼 뒤(0011) 만족도 스케일 5종(최고/좋음/보통/별로/힘듦)과 구체적 감정 9종을
같이 뒀는데, 다중 선택에서는 "행복""좋음"처럼 겹치는 느낌을 주는 항목이 섞여 있으면 혼란스러워서
만족도 스케일 5종을 아예 없애고 구체적 감정 9종만 남긴다. 혹시 그 사이 저장된 행이 있으면(이 저장소
개발 환경에서는 없었음) 지워야 새 CHECK 제약을 걸 수 있다.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0012_remove_satisfaction_moods"
down_revision: Union[str, None] = "0011_journal_entry_multi_mood"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REMOVED_MOODS = ("great", "good", "neutral", "bad", "awful")
REMAINING_MOODS = ("pain", "achievement", "anger", "excited", "calm", "happy", "worry", "tired", "sad")
def upgrade() -> None:
journal_entry_mood = sa.table("journal_entry_mood", sa.column("mood", sa.String))
op.execute(journal_entry_mood.delete().where(journal_entry_mood.c.mood.in_(REMOVED_MOODS)))
op.drop_constraint("ck_journal_entry_mood_mood", "journal_entry_mood", type_="check")
op.create_check_constraint(
"ck_journal_entry_mood_mood",
"journal_entry_mood",
"mood in (" + ",".join(f"'{v}'" for v in REMAINING_MOODS) + ")",
)
def downgrade() -> None:
op.drop_constraint("ck_journal_entry_mood_mood", "journal_entry_mood", type_="check")
op.create_check_constraint(
"ck_journal_entry_mood_mood",
"journal_entry_mood",
"mood in (" + ",".join(f"'{v}'" for v in REMAINING_MOODS + REMOVED_MOODS) + ")",
)
# 삭제된 행 자체는 복구하지 않는다(내용을 알 수 없음) — 제약만 원상복구.
@@ -0,0 +1,58 @@
"""add content_template to journal_category, seed the default 일상 category's template
Revision ID: 0013_journal_category_template
Revises: 0012_remove_satisfaction_moods
Create Date: 2026-08-04
카테고리별로 "새 기록 쓰기" 폼의 내용칸을 미리 채워주는 틀(예: Story/Feelings/Decisions/
Insights/Actions 회고 양식)을 지정할 수 있게 한다. 이미 존재하는 "일상" 카테고리에는 이 틀을
바로 채워 넣는다(새로 자동 생성되는 "일상"은 ensure_default_category가 채운다).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0013_journal_category_template"
down_revision: Union[str, None] = "0012_remove_satisfaction_moods"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DAILY_TEMPLATE = """**1. Story : 오늘 무슨 일이 있었나요?**
-
**2. Feelings : 오늘 들었던 생각과 나의 감정은?**
-
**3. Decisions : 오늘 내가 내린 결정이 있나요?**
-
**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**
-
→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.
**5. Actions : 내가 다음에 할 행동은 무엇인가요?**
- """
def upgrade() -> None:
op.add_column("journal_category", sa.Column("content_template", sa.Text(), nullable=True))
journal_category = sa.table(
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
)
op.execute(
journal_category.update()
.where(journal_category.c.name == "일상")
.values(content_template=DAILY_TEMPLATE)
)
def downgrade() -> None:
op.drop_column("journal_category", "content_template")
@@ -0,0 +1,81 @@
"""fix default 일상 template: bare "-" bullets don't render as list items
Revision ID: 0014_fix_template_bullets
Revises: 0013_journal_category_template
Create Date: 2026-08-05
0013에서 넣은 기본 틀의 "-" 줄들이 뒤에 공백이 없어서, markdown 렌더러가 목록으로 인식하지 못하고
그냥 문단 텍스트("-")로 렌더링됐다(제목 줄과 "-" 사이의 빈 줄 자체는 맞게 넣어서 제목이 밑줄로
오인되는 문제는 없었음). "- "(대시+공백)로 통일해야 실제 빈 목록 항목(<li></li>)이 된다.
아직 이 기본값을 커스터마이징하지 않은(즉 0013이 넣어준 원래 문구 그대로인) "일상" 카테고리만
갱신한다 — 이미 사용자가 직접 수정한 틀은 덮어쓰지 않는다.
(참고: revision id를 "0014_fix_category_template_bullets"로 처음 만들었다가 alembic_version.
version_num이 VARCHAR(32)라 34자짜리 id가 안 들어가서 DataError가 났다 — 25자로 줄여 다시 만든
파일이다. 실제 데이터 UPDATE 자체는 그때 이미 반영됐고 버전 기록만 실패한 상태였다.)
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0014_fix_template_bullets"
down_revision: Union[str, None] = "0013_journal_category_template"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_BULLET = "- "
_OLD_TEMPLATE = "\n\n".join(
[
"**1. Story : 오늘 무슨 일이 있었나요?**",
"-",
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
"-",
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
"-",
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
"-",
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
_BULLET,
]
)
_NEW_TEMPLATE = "\n\n".join(
[
"**1. Story : 오늘 무슨 일이 있었나요?**",
_BULLET,
"**2. Feelings : 오늘 들었던 생각과 나의 감정은?**",
_BULLET,
"**3. Decisions : 오늘 내가 내린 결정이 있나요?**",
_BULLET,
"**4. Insights : 나에 대해 새롭게 알게된 사실이 있나요?**",
_BULLET,
"→ 나에 대한 인사이트는 메타인지를 키워주는 **소중한 기록**입니다. 꼭 보관하세요.",
"**5. Actions : 내가 다음에 할 행동은 무엇인가요?**",
_BULLET,
]
)
def upgrade() -> None:
journal_category = sa.table(
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
)
op.execute(
journal_category.update()
.where(journal_category.c.name == "일상", journal_category.c.content_template == _OLD_TEMPLATE)
.values(content_template=_NEW_TEMPLATE)
)
def downgrade() -> None:
journal_category = sa.table(
"journal_category", sa.column("name", sa.String), sa.column("content_template", sa.Text)
)
op.execute(
journal_category.update()
.where(journal_category.c.name == "일상", journal_category.c.content_template == _NEW_TEMPLATE)
.values(content_template=_OLD_TEMPLATE)
)
+4 -1
View File
@@ -19,12 +19,15 @@ dependencies = [
"py-vapid>=1.9",
"authlib>=1.3",
"httpx>=0.27",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성 + 저널 첨부 이미지 썸네일 생성(런타임)
"markdown>=3.7", # 저널 기록 내용을 마크다운으로 렌더링
"bleach>=6.0", # 렌더링된 마크다운 HTML을 허용 태그만 남기고 sanitize (XSS 방지)
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pillow>=10.0", # scripts/generate_icons.py 아이콘 재생성용 (런타임 미사용)
"paramiko>=3.4", # scripts/deploy_sftp.py SFTP 배포용 (런타임 미사용)
]
[tool.setuptools.packages.find]
+124
View File
@@ -0,0 +1,124 @@
"""SFTP로 시놀로지 NAS에 소스를 동기화하는 배포 스크립트.
deploy.env(git 미포함, deploy.env.example 참고)의 접속 정보를 읽어, Dockerfile이 COPY하는
파일/디렉터리(pyproject.toml, alembic.ini, app/, migrations/, scripts/)와 Dockerfile,
docker-compose.yml을 원격 경로로 동기화한다. 로컬 mtime이 원격보다 최신인 파일만 올리고,
원격에만 있는 파일은 건드리지 않는다(단방향 추가/갱신, 삭제 없음). .env는 절대 동기화하지 않는다
(원격 프로덕션 .env를 덮어쓰면 안 되므로).
컨테이너 재시작/재빌드는 이 스크립트가 하지 않는다 — 파일을 올린 뒤 직접 재시작할 것.
사용법: python scripts/deploy_sftp.py [--dry-run]
"""
import sys
from pathlib import Path
import paramiko
sys.stdout.reconfigure(encoding="utf-8")
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DEPLOY_ENV_PATH = PROJECT_ROOT / "deploy.env"
# Dockerfile이 COPY하는 것과 동일한 목록 + 컨테이너 정의 파일
SYNC_TARGETS = ["pyproject.toml", "alembic.ini", "Dockerfile", "docker-compose.yml", "app", "migrations", "scripts"]
# app/media는 소스 코드가 아니라 런타임에 생성되는 유저 업로드 데이터라 절대 동기화하면 안 된다 —
# 로컬에서 테스트하며 쌓인 실제 유저 사진이 원격 빌드 컨텍스트로 그대로 올라가버리는 사고가 있었다.
SKIP_NAMES = {"__pycache__", "media"}
SKIP_SUFFIXES = {".pyc"}
def load_deploy_env(path: Path) -> dict[str, str]:
if not path.exists():
print(f"{path}가 없습니다. deploy.env.example을 복사해 값을 채워주세요.")
sys.exit(1)
values = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
return values
def iter_local_files(target: Path):
if target.is_file():
yield target
return
for path in target.rglob("*"):
if path.is_dir():
continue
if any(part in SKIP_NAMES for part in path.parts):
continue
if path.suffix in SKIP_SUFFIXES:
continue
yield path
def ensure_remote_dir(sftp: paramiko.SFTPClient, remote_dir: str) -> None:
parts = remote_dir.strip("/").split("/")
current = ""
for part in parts:
current += "/" + part
try:
sftp.stat(current)
except FileNotFoundError:
sftp.mkdir(current)
def remote_mtime(sftp: paramiko.SFTPClient, remote_path: str) -> float | None:
try:
return sftp.stat(remote_path).st_mtime
except FileNotFoundError:
return None
def main() -> None:
dry_run = "--dry-run" in sys.argv
env = load_deploy_env(DEPLOY_ENV_PATH)
host = env["SFTP_HOST"]
port = int(env.get("SFTP_PORT", "22"))
username = env["SFTP_USERNAME"]
password = env["SFTP_PASSWORD"]
remote_root = env["SFTP_REMOTE_PATH"].rstrip("/")
print(f"{host}:{port} ({username}) -> {remote_root} 로 동기화{'(dry-run)' if dry_run else ''}")
transport = paramiko.Transport((host, port))
transport.connect(username=username, password=password)
sftp = paramiko.SFTPClient.from_transport(transport)
assert sftp is not None
uploaded, skipped = 0, 0
try:
for target_name in SYNC_TARGETS:
local_target = PROJECT_ROOT / target_name
if not local_target.exists():
continue
for local_file in iter_local_files(local_target):
rel_path = local_file.relative_to(PROJECT_ROOT).as_posix()
remote_path = f"{remote_root}/{rel_path}"
local_mtime = local_file.stat().st_mtime
existing_mtime = remote_mtime(sftp, remote_path)
if existing_mtime is not None and existing_mtime >= local_mtime:
skipped += 1
continue
print(f" 업로드: {rel_path}")
if not dry_run:
ensure_remote_dir(sftp, str(Path(remote_path).parent.as_posix()))
sftp.put(str(local_file), remote_path)
uploaded += 1
finally:
sftp.close()
transport.close()
print(f"완료: {uploaded}개 업로드, {skipped}개 변경 없음. 컨테이너 재시작/재빌드는 직접 해주세요.")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,4 +1,4 @@
# 습관 트래커 서버를 상시 실행하기 위한 스크립트.
# 해빗랩 서버를 상시 실행하기 위한 스크립트.
# Windows 작업 스케줄러 등록 시 이 스크립트를 대상으로 지정한다 (--reload 없이, conda activate 없이
# 대상 conda 환경의 python.exe를 직접 호출해 셸 활성화 없는 예약 작업에서도 안정적으로 동작하게 한다).
+155
View File
@@ -0,0 +1,155 @@
from datetime import date
from io import BytesIO
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
from app.services import journal_service
def _make_entry_with_attachment(db_session, user_id, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = journal_service.create_category(db_session, user_id, JournalCategoryCreate(name="일상"))
entry = journal_service.create_entry(
db_session,
user_id,
JournalEntryCreate(category_id=category.id, entry_date=date.today(), content="기록"),
)
buf = BytesIO()
Image.new("RGB", (800, 600), "blue").save(buf, format="PNG")
upload = UploadFile(file=BytesIO(buf.getvalue()), filename="photo.png", headers=Headers({"content-type": "image/png"}))
attachment = journal_service.save_attachment(db_session, entry, upload)
return attachment
def test_get_media_requires_login(client):
response = client.get("/api/journal/media/1")
assert response.status_code == 401
def test_get_media_returns_file_for_owner(auth_client, db_session, test_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}")
assert response.status_code == 200
assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
def test_get_media_thumbnail_variant(auth_client, db_session, test_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, test_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}?thumbnail=true")
assert response.status_code == 200
def test_get_media_returns_404_for_missing_attachment(auth_client):
response = auth_client.get("/api/journal/media/9999")
assert response.status_code == 404
def test_get_media_returns_404_for_other_users_attachment(auth_client, db_session, other_user, tmp_path, monkeypatch):
attachment = _make_entry_with_attachment(db_session, other_user.id, tmp_path, monkeypatch)
response = auth_client.get(f"/api/journal/media/{attachment.id}")
assert response.status_code == 404
def _png_bytes():
buf = BytesIO()
Image.new("RGB", (400, 300), "red").save(buf, format="PNG")
return buf.getvalue()
def test_paste_image_requires_login(client):
response = client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
assert response.status_code == 401
def test_paste_image_uploads_and_serves(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
response = auth_client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
assert response.status_code == 200
url = response.json()["url"]
assert url.startswith("/api/journal/pasted-media/")
fetched = auth_client.get(url)
assert fetched.status_code == 200
assert fetched.content[:8] == b"\x89PNG\r\n\x1a\n"
def test_paste_image_rejects_unsupported_type(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
response = auth_client.post(
"/api/journal/paste-image", files={"file": ("a.pdf", b"%PDF-1.4", "application/pdf")}
)
assert response.status_code == 400
def test_paste_image_rejects_oversized_file(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
monkeypatch.setattr(journal_service.settings, "journal_max_upload_mb", 0)
response = auth_client.post("/api/journal/paste-image", files={"file": ("a.png", _png_bytes(), "image/png")})
assert response.status_code == 400
def test_pasted_media_requires_login(client):
response = client.get("/api/journal/pasted-media/anything.png")
assert response.status_code == 401
def test_pasted_media_returns_404_for_missing_file(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
response = auth_client.get("/api/journal/pasted-media/does-not-exist.png")
assert response.status_code == 404
def test_pasted_media_is_scoped_per_user(auth_client, db_session, other_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
filename = journal_service.save_pasted_image(
other_user.id,
UploadFile(file=BytesIO(_png_bytes()), filename="a.png", headers=Headers({"content-type": "image/png"})),
)
# 파일명을 정확히 알아도 다른 유저 소유 폴더 안에 있으면 접근할 수 없어야 한다(디렉터리로 스코핑됨).
response = auth_client.get(f"/api/journal/pasted-media/{filename}")
assert response.status_code == 404
def test_pasted_media_blocks_path_traversal(auth_client, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
# 요청 자체가 상대경로 컴포넌트를 포함하면 라우팅에서 걸러지지만, 인코딩된 경로 구분자로
# 시도해도 Path(...).name이 디렉터리 구분자를 다 제거해서 상위 폴더로 못 나간다.
response = auth_client.get("/api/journal/pasted-media/..%2f..%2f..%2fetc%2fpasswd")
assert response.status_code == 404
def test_reorder_categories_requires_login(client):
response = client.post("/api/journal/categories/reorder", json={"category_ids": [1, 2]})
assert response.status_code == 401
def test_reorder_categories_updates_sort_order(auth_client, db_session, test_user):
a = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="A"))
b = journal_service.create_category(db_session, test_user.id, JournalCategoryCreate(name="B"))
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [b.id, a.id]})
assert response.status_code == 200
db_session.refresh(a)
db_session.refresh(b)
assert b.sort_order == 0
assert a.sort_order == 1
def test_reorder_categories_ignores_other_users_ids(auth_client, db_session, other_user):
other_category = journal_service.create_category(db_session, other_user.id, JournalCategoryCreate(name="남의 것"))
response = auth_client.post("/api/journal/categories/reorder", json={"category_ids": [other_category.id]})
assert response.status_code == 200
db_session.refresh(other_category)
assert other_category.sort_order is None
+315
View File
@@ -0,0 +1,315 @@
from datetime import date, timedelta
from io import BytesIO
from pathlib import Path
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate, JournalEntryUpdate
from app.services import journal_service
def _make_category(db_session, user_id, name="일상", color=None):
return journal_service.create_category(db_session, user_id, JournalCategoryCreate(name=name, color=color))
def _make_entry(db_session, user_id, category_id, entry_date=None, content="오늘의 기록", tags=None, **overrides):
data = JournalEntryCreate(
category_id=category_id,
entry_date=entry_date or date.today(),
title=overrides.pop("title", None),
content=content,
moods=overrides.pop("moods", []),
tags=tags or [],
)
return journal_service.create_entry(db_session, user_id, data)
def _png_bytes(size=(800, 600)) -> bytes:
buf = BytesIO()
Image.new("RGB", size, "red").save(buf, format="PNG")
return buf.getvalue()
def _upload(filename: str, content_type: str, data: bytes) -> UploadFile:
return UploadFile(file=BytesIO(data), filename=filename, headers=Headers({"content-type": content_type}))
# ---- 카테고리 ----
def test_ensure_default_category_creates_once(db_session, test_user):
first = journal_service.ensure_default_category(db_session, test_user.id)
assert first.name == journal_service.DEFAULT_CATEGORY_NAME
assert first.content_template == journal_service.DEFAULT_CATEGORY_TEMPLATE
second = journal_service.ensure_default_category(db_session, test_user.id)
assert second.id == first.id
assert len(journal_service.list_categories(db_session, test_user.id)) == 1
def test_default_category_template_renders_as_headers_and_lists_not_bare_dashes(db_session, test_user):
from app.markdown_utils import render_markdown
category = journal_service.ensure_default_category(db_session, test_user.id)
html = render_markdown(category.content_template)
# "-"에 뒤 공백이 없으면 markdown이 목록으로 안 잡고 그냥 "<p>-</p>"로 렌더링해버리는
# 회귀가 있었다 — 다섯 항목 전부 실제 <ul><li> 목록이어야 한다.
assert html.count("<ul>") == 5
assert html.count("<li>") == 5
# 제목 줄 바로 다음에 "-"가 오면(빈 줄 없이) markdown이 그걸 제목 밑줄로 오인해서
# 굵은 글씨가 아니라 <h1>/<h2> 제목으로 바뀌어버리는 회귀도 있었다.
assert "<h1" not in html
assert "<h2" not in html
assert html.count("<strong>") == 6
def test_category_content_template_persists_and_updates(db_session, test_user):
category = journal_service.create_category(
db_session, test_user.id, JournalCategoryCreate(name="회고", content_template="질문 1\n\n질문 2")
)
assert category.content_template == "질문 1\n\n질문 2"
updated = journal_service.update_category(
db_session, category, JournalCategoryCreate(name="회고", content_template=" ")
)
assert updated.content_template is None
def test_list_categories_scoped_to_user(db_session, test_user, other_user):
_make_category(db_session, test_user.id, name="일상")
_make_category(db_session, other_user.id, name="남의 카테고리")
mine = journal_service.list_categories(db_session, test_user.id)
assert [c.name for c in mine] == ["일상"]
def test_reorder_categories_ignores_foreign_and_unknown_ids(db_session, test_user, other_user):
a = _make_category(db_session, test_user.id, name="A")
b = _make_category(db_session, test_user.id, name="B")
other = _make_category(db_session, other_user.id, name="남의 것")
journal_service.reorder_categories(db_session, test_user.id, [b.id, a.id, other.id, 9999])
db_session.refresh(a)
db_session.refresh(b)
db_session.refresh(other)
assert b.sort_order == 0
assert a.sort_order == 1
assert other.sort_order is None
def test_count_entries_by_category(db_session, test_user, other_user):
a = _make_category(db_session, test_user.id, name="A")
b = _make_category(db_session, test_user.id, name="B")
other_category = _make_category(db_session, other_user.id, name="남의 카테고리")
_make_entry(db_session, test_user.id, a.id, content="1")
_make_entry(db_session, test_user.id, a.id, content="2")
_make_entry(db_session, test_user.id, b.id, content="3")
_make_entry(db_session, other_user.id, other_category.id, content="다른 유저 기록")
counts = journal_service.count_entries_by_category(db_session, test_user.id)
assert counts == {a.id: 2, b.id: 1}
def test_delete_category_cascades_entries_and_attachment_files(db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id, name="지울 카테고리")
entry = _make_entry(db_session, test_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
file_path = attachment.file_path
journal_service.delete_category(db_session, category)
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
assert not Path(file_path).exists()
# ---- 태그 ----
def test_get_or_create_tags_reuses_existing_by_name(db_session, test_user):
first = journal_service.get_or_create_tags(db_session, test_user.id, ["투자", "재테크"])
second = journal_service.get_or_create_tags(db_session, test_user.id, ["투자", "새태그"])
assert {t.name for t in first} == {"투자", "재테크"}
investment_id = next(t.id for t in first if t.name == "투자")
assert next(t.id for t in second if t.name == "투자") == investment_id
assert {t.name for t in second} == {"투자", "새태그"}
# ---- 엔트리 ----
def test_multiple_entries_per_day_same_category_allowed(db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
morning = _make_entry(db_session, test_user.id, category.id, entry_date=today, content="아침 기록")
evening = _make_entry(db_session, test_user.id, category.id, entry_date=today, content="저녁 기록")
entries = journal_service.list_entries(db_session, test_user.id, category_id=category.id)
assert {e.id for e in entries} == {morning.id, evening.id}
def test_get_entry_returns_none_for_other_users_entry(db_session, test_user, other_user):
category = _make_category(db_session, other_user.id)
other_entry = _make_entry(db_session, other_user.id, category.id)
assert journal_service.get_entry(db_session, other_entry.id, test_user.id) is None
assert journal_service.get_entry(db_session, other_entry.id, other_user.id) is not None
def test_create_entry_attaches_tags(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, tags=["투자", "회고"])
assert {t.name for t in entry.tags} == {"투자", "회고"}
def test_update_entry_replaces_tags(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, tags=["투자"])
updated = journal_service.update_entry(
db_session,
entry,
JournalEntryUpdate(category_id=category.id, entry_date=entry.entry_date, content="수정된 내용", tags=["새태그"]),
)
assert {t.name for t in updated.tags} == {"새태그"}
assert updated.content == "수정된 내용"
def test_create_entry_supports_multiple_moods(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, moods=["happy", "tired"])
assert {m.mood.value for m in entry.moods} == {"happy", "tired"}
def test_update_entry_replaces_moods(db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, moods=["sad"])
updated = journal_service.update_entry(
db_session,
entry,
JournalEntryUpdate(
category_id=category.id, entry_date=entry.entry_date, content="내용", moods=["happy", "calm"]
),
)
assert {m.mood.value for m in updated.moods} == {"happy", "calm"}
def test_delete_entry_removes_attachment_files(db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
file_path = Path(attachment.file_path)
thumb_path = Path(attachment.thumbnail_path)
assert file_path.exists()
assert thumb_path.exists()
journal_service.delete_entry(db_session, entry)
assert not file_path.exists()
assert not thumb_path.exists()
# ---- 첨부파일 ----
def test_save_attachment_generates_thumbnail_for_image(db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
assert attachment.thumbnail_path is not None
with Image.open(attachment.thumbnail_path) as thumb:
assert thumb.width <= journal_service.THUMBNAIL_WIDTH
assert Path(attachment.file_path).exists()
def test_save_attachment_rejects_unsupported_type(db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
try:
journal_service.save_attachment(db_session, entry, _upload("doc.pdf", "application/pdf", b"%PDF-1.4"))
assert False, "should have raised"
except ValueError as exc:
assert "지원하지 않는" in str(exc)
def test_save_attachment_rejects_oversized_file(db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
monkeypatch.setattr(journal_service.settings, "journal_max_upload_mb", 0)
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
try:
journal_service.save_attachment(db_session, entry, _upload("photo.png", "image/png", _png_bytes()))
assert False, "should have raised"
except ValueError as exc:
assert "용량" in str(exc)
def test_get_attachment_scoped_to_owner(db_session, test_user, other_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
attachment = journal_service.save_attachment(
db_session, entry, _upload("photo.png", "image/png", _png_bytes())
)
assert journal_service.get_attachment(db_session, attachment.id, test_user.id) is None
assert journal_service.get_attachment(db_session, attachment.id, other_user.id) is not None
# ---- 캘린더 / day-detail / 회상 ----
def test_get_monthly_journal_summary_counts_entries_per_day(db_session, test_user):
category = _make_category(db_session, test_user.id, color="#ff0000")
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="1")
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="2")
summary = journal_service.get_monthly_journal_summary(db_session, test_user.id, today.year, today.month)
assert summary[today].total_count == 2
assert [e.color for e in summary[today].entries] == ["#ff0000", "#ff0000"]
def test_get_day_entries_returns_entries_for_that_date(db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
yesterday = today - timedelta(days=1)
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="오늘 거")
_make_entry(db_session, test_user.id, category.id, entry_date=yesterday, content="어제 거")
items = journal_service.get_day_entries(db_session, test_user.id, today)
assert [i.content for i in items] == ["오늘 거"]
def test_get_on_this_day_matches_month_day_different_year(db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
last_year = today.replace(year=today.year - 1)
_make_entry(db_session, test_user.id, category.id, entry_date=last_year, content="작년 오늘")
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="올해 오늘")
items = journal_service.get_on_this_day(db_session, test_user.id, today)
assert [i.content for i in items] == ["작년 오늘"]
assert items[0].years_ago == 1
def test_get_random_prompt_returns_none_when_empty(db_session):
assert journal_service.get_random_prompt(db_session) is None
+53
View File
@@ -0,0 +1,53 @@
from app.markdown_utils import render_markdown
def test_bold_and_heading_render_as_html():
html = render_markdown("# 제목\n\n**굵게** 쓴 문장입니다")
assert "<h1>제목</h1>" in html
assert "<strong>굵게</strong>" in html
def test_single_newline_becomes_line_break():
html = render_markdown("첫째 줄\n둘째 줄")
assert "<br" in html
def test_list_renders_as_html_list():
html = render_markdown("- 하나\n- 둘")
assert "<ul>" in html
assert "<li>하나</li>" in html
def test_dash_and_star_bullets_render_identically():
# 마크다운 글머리 기호는 -/*/+ 전부 동일하게 처리돼야 한다 — 에디터 쪽 기본 기호(unorderedListStyle)만
# "-"로 바뀌었을 뿐, 렌더링 결과는 어떤 기호를 써도 같아야 한다.
assert render_markdown("- 하나\n- 둘") == render_markdown("* 하나\n* 둘") == render_markdown("+ 하나\n+ 둘")
def test_script_tag_is_stripped_not_executed():
html = render_markdown('<script>alert("xss")</script>본문')
assert "<script" not in html
assert "alert" not in html or "&lt;script" not in html # 태그는 지워지고 텍스트만 남아야 함
def test_javascript_href_is_neutralized():
html = render_markdown('[click me](javascript:alert(1))')
assert "javascript:" not in html
def test_onerror_attribute_is_stripped_even_though_img_is_allowed():
html = render_markdown('<img src="x.png" onerror="alert(1)">본문')
assert "onerror" not in html
assert '<img src="x.png">' in html # img 자체는 허용되지만 onerror 같은 이벤트 속성은 지워져야 함
def test_allowed_link_href_is_preserved():
html = render_markdown("[내 블로그](https://example.com)")
assert 'href="https://example.com"' in html
def test_pasted_image_markdown_renders_with_relative_src():
# 붙여넣은 이미지는 절대 URL이 아니라 /api/journal/pasted-media/... 같은 상대 경로로 참조된다 —
# bleach가 스킴 없는 상대 경로도 그대로 통과시키는지 확인.
html = render_markdown("![](/api/journal/pasted-media/abc123.png)")
assert 'src="/api/journal/pasted-media/abc123.png"' in html
+365
View File
@@ -0,0 +1,365 @@
from datetime import date
from io import BytesIO
from PIL import Image
from starlette.datastructures import Headers, UploadFile
from app.schemas.journal import JournalCategoryCreate, JournalEntryCreate
from app.services import journal_service
def _make_category(db_session, user_id, name="일상", content_template=None):
return journal_service.create_category(
db_session, user_id, JournalCategoryCreate(name=name, content_template=content_template)
)
def _make_entry(db_session, user_id, category_id, entry_date=None, content="기록"):
data = JournalEntryCreate(category_id=category_id, entry_date=entry_date or date.today(), content=content)
return journal_service.create_entry(db_session, user_id, data)
def _png_file():
buf = BytesIO()
Image.new("RGB", (400, 300), "green").save(buf, format="PNG")
buf.seek(0)
return buf
def test_journal_page_requires_login(client):
response = client.get("/journal", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_journal_page_creates_default_category_and_renders(auth_client):
response = auth_client.get("/journal")
assert response.status_code == 200
assert journal_service.DEFAULT_CATEGORY_NAME in response.text
def test_journal_page_embeds_category_content_template(auth_client):
response = auth_client.get("/journal")
assert response.status_code == 200
assert "Story" in response.text
assert "메타인지" in response.text
def test_journal_page_template_with_double_quotes_does_not_break_x_data_attribute(
auth_client, db_session, test_user
):
baseline_form_count = auth_client.get("/journal").text.count("<form")
_make_category(db_session, test_user.id, name="따옴표카테고리", content_template='내용에 "큰따옴표"가 있어요')
response = auth_client.get("/journal")
assert response.status_code == 200
# x-data="..." 속성 안에 이스케이프 안 된 "가 섞이면 속성이 거기서 끊겨 뒤 마크업이 전부
# 속성값으로 흡수되는데, 그러면 폼의 <form 태그들이 열린 속성값 텍스트에 파묻혀 개수가
# 줄어든다 — 카테고리 하나 늘었다고 <form 개수가 그대로인지로 attribute-injection 여부를 검증한다.
assert response.text.count("<form") == baseline_form_count
assert "\\&#34;큰따옴표\\&#34;" in response.text
def test_create_entry_via_multipart_form(auth_client, db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id, name="투자")
today = date.today()
response = auth_client.post(
"/journal/new",
data={
"category_id": str(category.id),
"entry_date": today.isoformat(),
"title": "첫 기록",
"content": "오늘의 투자 회고",
"moods": "happy,tired",
"tags": "투자, 회고",
},
files={"files": ("photo.png", _png_file(), "image/png")},
follow_redirects=False,
)
assert response.status_code == 200
assert response.headers["hx-redirect"] == f"/journal?year={today.year}&month={today.month}"
entries = journal_service.list_entries(db_session, test_user.id)
assert len(entries) == 1
assert entries[0].title == "첫 기록"
assert {t.name for t in entries[0].tags} == {"투자", "회고"}
assert {m.mood.value for m in entries[0].moods} == {"happy", "tired"}
assert len(entries[0].attachments) == 1
def test_create_entry_rejects_blank_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
response = auth_client.post(
"/journal/new",
data={"category_id": str(category.id), "entry_date": date.today().isoformat(), "content": " "},
)
assert response.status_code == 200
assert "내용을 입력해주세요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_create_entry_rejects_other_users_category(auth_client, db_session, test_user, other_user):
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
"/journal/new",
data={
"category_id": str(others_category.id),
"entry_date": date.today().isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
assert journal_service.list_entries(db_session, test_user.id) == []
def test_journal_day_detail_shows_entry(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="오늘의 기록")
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "오늘의 기록" in response.text
def test_journal_day_detail_renders_content_as_markdown(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(db_session, test_user.id, category.id, entry_date=today, content="**굵은 글씨** 테스트")
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<strong>굵은 글씨</strong>" in response.text
def test_journal_day_detail_strips_script_tags_from_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
today = date.today()
_make_entry(
db_session, test_user.id, category.id, entry_date=today, content='<script>alert(1)</script>본문'
)
response = auth_client.get(f"/journal/day/{today.isoformat()}")
assert response.status_code == 200
assert "<script" not in response.text
def test_journal_day_detail_requires_login(client):
response = client.get(f"/journal/day/{date.today().isoformat()}", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_preview_renders_markdown(auth_client):
response = auth_client.post("/journal/preview", data={"content": "**굵게** 그리고 - 목록"})
assert response.status_code == 200
assert "<strong>굵게</strong>" in response.text
def test_preview_strips_script_tags(auth_client):
response = auth_client.post("/journal/preview", data={"content": '<script>alert(1)</script>본문'})
assert response.status_code == 200
assert "<script" not in response.text
def test_preview_shows_placeholder_for_blank_content(auth_client):
response = auth_client.post("/journal/preview", data={"content": " "})
assert response.status_code == 200
assert "미리보기가 여기에 표시돼요" in response.text
def test_preview_requires_login(client):
response = client.post("/journal/preview", data={"content": "test"}, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_edit_entry_updates_content(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "수정된 내용",
"tags": "",
},
)
assert response.status_code == 200
assert "수정된 내용" in response.text
db_session.refresh(entry)
assert entry.content == "수정된 내용"
def test_edit_entry_blank_content_keeps_form_open_with_error(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={"category_id": str(category.id), "entry_date": entry.entry_date.isoformat(), "content": " "},
)
assert response.status_code == 200
assert "내용을 입력해주세요" in response.text
db_session.refresh(entry)
assert entry.content == "원래 내용"
def test_edit_other_users_entry_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={"category_id": str(category.id), "entry_date": entry.entry_date.isoformat(), "content": "해킹 시도"},
)
assert response.status_code == 404
def test_edit_entry_rejects_moving_to_other_users_category(auth_client, db_session, test_user, other_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="원래 내용")
others_category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(
f"/journal/{entry.id}/edit",
data={
"category_id": str(others_category.id),
"entry_date": entry.entry_date.isoformat(),
"content": "가로채기 시도",
},
)
assert response.status_code == 200
assert "카테고리를 찾을 수 없어요" in response.text
db_session.refresh(entry)
assert entry.category_id == category.id
assert entry.content == "원래 내용"
def test_delete_entry_removes_it(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id, content="지울 기록")
response = auth_client.post(f"/journal/{entry.id}/delete")
assert response.status_code == 200
assert "지울 기록" not in response.text
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
def test_delete_other_users_entry_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id)
entry = _make_entry(db_session, other_user.id, category.id)
response = auth_client.post(f"/journal/{entry.id}/delete")
assert response.status_code == 404
assert journal_service.get_entry(db_session, entry.id, other_user.id) is not None
def test_delete_attachment_removes_it(auth_client, db_session, test_user, tmp_path, monkeypatch):
monkeypatch.setattr(journal_service.settings, "journal_media_root", str(tmp_path))
category = _make_category(db_session, test_user.id)
entry = _make_entry(db_session, test_user.id, category.id)
upload = UploadFile(file=_png_file(), filename="a.png", headers=Headers({"content-type": "image/png"}))
attachment = journal_service.save_attachment(db_session, entry, upload)
response = auth_client.post(f"/journal/{entry.id}/attachments/{attachment.id}/delete")
assert response.status_code == 200
assert journal_service.get_attachment(db_session, attachment.id, test_user.id) is None
def test_create_category_and_reject_duplicate(auth_client):
response = auth_client.post("/journal/categories/new", data={"name": "투자"}, follow_redirects=False)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
dup_response = auth_client.post("/journal/categories/new", data={"name": "투자"})
assert dup_response.status_code == 200
assert "이미 같은 이름" in dup_response.text
def test_journal_page_manage_tab_shows_delete_button_for_categories(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
response = auth_client.get("/journal?tab=manage")
assert response.status_code == 200
assert "/journal/categories/" in response.text
assert "/delete" in response.text
def test_journal_page_default_tab_hides_category_management(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
response = auth_client.get("/journal")
assert response.status_code == 200
assert "/journal/categories/" not in response.text
def test_delete_category_removes_it_and_its_entries(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="지울 카테고리")
entry = _make_entry(db_session, test_user.id, category.id, content="딸려서 지워질 기록")
response = auth_client.post(f"/journal/categories/{category.id}/delete", follow_redirects=False)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
assert journal_service.get_category(db_session, category.id, test_user.id) is None
assert journal_service.get_entry(db_session, entry.id, test_user.id) is None
def test_delete_other_users_category_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(f"/journal/categories/{category.id}/delete")
assert response.status_code == 404
assert journal_service.get_category(db_session, category.id, other_user.id) is not None
def test_edit_category_updates_name_and_color(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="원래 이름")
response = auth_client.post(
f"/journal/categories/{category.id}/edit",
data={"name": "바뀐 이름", "color": "#123456"},
follow_redirects=False,
)
assert response.status_code == 200
assert response.headers["hx-redirect"] == "/journal?tab=manage"
db_session.refresh(category)
assert category.name == "바뀐 이름"
assert category.color == "#123456"
def test_edit_category_rejects_duplicate_name(auth_client, db_session, test_user):
_make_category(db_session, test_user.id, name="투자")
category = _make_category(db_session, test_user.id, name="일기")
response = auth_client.post(f"/journal/categories/{category.id}/edit", data={"name": "투자"})
assert response.status_code == 200
assert "이미 같은 이름" in response.text
def test_edit_other_users_category_returns_404(auth_client, db_session, other_user):
category = _make_category(db_session, other_user.id, name="남의 카테고리")
response = auth_client.post(f"/journal/categories/{category.id}/edit", data={"name": "가로채기"})
assert response.status_code == 404
def test_manage_tab_shows_entry_count_per_category(auth_client, db_session, test_user):
category = _make_category(db_session, test_user.id, name="투자")
_make_entry(db_session, test_user.id, category.id, content="1")
_make_entry(db_session, test_user.id, category.id, content="2")
response = auth_client.get("/journal?tab=manage")
assert "기록 2개" in response.text