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>
This commit is contained in:
2026-08-04 18:40:07 +09:00
co-authored by Claude Sonnet 5
parent ef2a3ea082
commit 54c971875c
24 changed files with 2485 additions and 7 deletions
@@ -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) + ")",
)
# 삭제된 행 자체는 복구하지 않는다(내용을 알 수 없음) — 제약만 원상복구.