add habit goal-period difficulty and habit formation research notes
Ground the 21-day habit myth in actual research (Lally et al. 2010) and let users pick a target period (21/66/254 days or unlimited) matching that study's easy/median/hard automaticity timelines when creating a habit, with progress shown on the habits list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,7 @@ pytest tests/test_habits.py::test_name # 단일 테스트
|
|||||||
### 데이터 모델 (`app/models/`)
|
### 데이터 모델 (`app/models/`)
|
||||||
|
|
||||||
- `User`: `google_sub`/`email` unique, `name`/`picture_url` nullable, 구글 로그인 시 조회/생성(`app/routers/auth.py`의 `google_callback`).
|
- `User`: `google_sub`/`email` unique, `name`/`picture_url` nullable, 구글 로그인 시 조회/생성(`app/routers/auth.py`의 `google_callback`).
|
||||||
- `Habit`: `user_id`(nullable FK→`user.id`, ondelete=CASCADE — nullable인 이유는 PIN 시절 데이터 이관 때문, 아래 "구글 OAuth 전환" 참고), `habit_type`(build/quit), `status`(active/completed), `weekdays_mask`는 비트마스크(bit0=월…bit6=일, Python `date.weekday()`와 동일한 인덱스, `Habit.is_scheduled_on(weekday)`로 조회), `condition_text`(달성 조건, nullable, 빈 문자열은 `HabitBase.blank_condition_to_none` 검증기가 자동으로 None으로 변환), `reminder_time`은 nullable.
|
- `Habit`: `user_id`(nullable FK→`user.id`, ondelete=CASCADE — nullable인 이유는 PIN 시절 데이터 이관 때문, 아래 "구글 OAuth 전환" 참고), `habit_type`(build/quit), `status`(active/completed), `weekdays_mask`는 비트마스크(bit0=월…bit6=일, Python `date.weekday()`와 동일한 인덱스, `Habit.is_scheduled_on(weekday)`로 조회), `difficulty`(easy/medium/hard/unlimited — 습관 등록 시 선택하는 목표 기간 난이도, `HABIT_DIFFICULTY_TARGET_DAYS`가 각각 21/66/254일/무제한으로 매핑한다. 이 숫자들은 임의로 정한 게 아니라 Lally et al.(2010, UCL)의 습관 자동화 소요 기간 실증 연구값을 그대로 가져온 것 — 근거는 `habit-formation-research.md` 참고. `Habit.target_days`/`Habit.goal_target_date`가 `created_at` 기준으로 목표 종료일을 계산하고, `template_utils.goal_progress()`가 이를 "D-N"/"N일차" 형태로 `/habits` 목록에 표시한다. 요일과 마찬가지로 과거에 난이도를 바꾼 이력은 추적하지 않는다), `condition_text`(달성 조건, nullable, 빈 문자열은 `HabitBase.blank_condition_to_none` 검증기가 자동으로 None으로 변환), `reminder_time`은 nullable.
|
||||||
- `HabitLog`: "행이 존재하면 그 날 체크 완료"라는 설계 — 별도 boolean 컬럼 없음. `(habit_id, log_date)` unique. 체크 해제는 행 삭제. 유저 스코핑은 `Habit`을 조인해서 한다(`log_service.list_logs`).
|
- `HabitLog`: "행이 존재하면 그 날 체크 완료"라는 설계 — 별도 boolean 컬럼 없음. `(habit_id, log_date)` unique. 체크 해제는 행 삭제. 유저 스코핑은 `Habit`을 조인해서 한다(`log_service.list_logs`).
|
||||||
- `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작).
|
- `PushSubscription`(`user_id` nullable FK 포함), `HabitNotificationLog`: Web Push 구독 정보와 중복 알림 방지용 발송 기록 (5단계 마일스톤에서 실제로 사용 시작).
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
|
개인용 습관 관리 PWA. 아이폰과 PC에서 같은 서버(MariaDB)에 접속해 습관을 관리합니다.
|
||||||
|
|
||||||
|
참고: 습관이 실제로 어떻게 형성되는지에 대한 연구 정리는 [habit-formation-research.md](./habit-formation-research.md) 참고.
|
||||||
|
|
||||||
## 설치
|
## 설치
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+39
-1
@@ -1,5 +1,5 @@
|
|||||||
import enum
|
import enum
|
||||||
from datetime import datetime, time
|
from datetime import date, datetime, time, timedelta
|
||||||
|
|
||||||
from sqlalchemy import Enum, ForeignKey, Integer, SmallInteger, String, Time
|
from sqlalchemy import Enum, ForeignKey, Integer, SmallInteger, String, Time
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
@@ -21,6 +21,29 @@ class HabitStatus(str, enum.Enum):
|
|||||||
ABANDONED = "abandoned"
|
ABANDONED = "abandoned"
|
||||||
|
|
||||||
|
|
||||||
|
class HabitDifficulty(str, enum.Enum):
|
||||||
|
"""목표 기간 난이도. Lally et al.(2010, UCL)의 습관 자동화 소요 기간 실증 연구를 근거로 한다
|
||||||
|
(자세한 내용은 habit-formation-research.md 참고).
|
||||||
|
- EASY: 기존 습관에 붙이기 쉬운 단순 행동이 자동화되는 데 걸린 기간(약 3주)
|
||||||
|
- MEDIUM: 전체 참가자의 자동화 소요 기간 중앙값
|
||||||
|
- HARD: 노력이 많이 드는 행동이 자동화되는 데 걸린 기간의 상한(연구 관찰 범위 18~254일 중 최대값)
|
||||||
|
- UNLIMITED: 목표 종료 시점 없이 계속 트래킹만 하는 습관
|
||||||
|
"""
|
||||||
|
|
||||||
|
EASY = "easy"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HARD = "hard"
|
||||||
|
UNLIMITED = "unlimited"
|
||||||
|
|
||||||
|
|
||||||
|
HABIT_DIFFICULTY_TARGET_DAYS: dict[HabitDifficulty, int | None] = {
|
||||||
|
HabitDifficulty.EASY: 21,
|
||||||
|
HabitDifficulty.MEDIUM: 66,
|
||||||
|
HabitDifficulty.HARD: 254,
|
||||||
|
HabitDifficulty.UNLIMITED: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Habit(Base):
|
class Habit(Base):
|
||||||
__tablename__ = "habit"
|
__tablename__ = "habit"
|
||||||
|
|
||||||
@@ -34,6 +57,9 @@ class Habit(Base):
|
|||||||
Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE
|
Enum(HabitStatus, native_enum=False, length=20), nullable=False, default=HabitStatus.ACTIVE
|
||||||
)
|
)
|
||||||
weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK)
|
weekdays_mask: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=ALL_WEEKDAYS_MASK)
|
||||||
|
difficulty: Mapped[HabitDifficulty] = mapped_column(
|
||||||
|
Enum(HabitDifficulty, native_enum=False, length=20), nullable=False, default=HabitDifficulty.MEDIUM
|
||||||
|
)
|
||||||
condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
condition_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||||
reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
reminder_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||||
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
sort_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
@@ -48,3 +74,15 @@ class Habit(Base):
|
|||||||
def is_scheduled_on(self, weekday: int) -> bool:
|
def is_scheduled_on(self, weekday: int) -> bool:
|
||||||
"""weekday: Python date.weekday() 기준 (월=0 ... 일=6)"""
|
"""weekday: Python date.weekday() 기준 (월=0 ... 일=6)"""
|
||||||
return bool(self.weekdays_mask & (1 << weekday))
|
return bool(self.weekdays_mask & (1 << weekday))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_days(self) -> int | None:
|
||||||
|
return HABIT_DIFFICULTY_TARGET_DAYS[self.difficulty]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def goal_target_date(self) -> date | None:
|
||||||
|
"""목표 기간이 끝나는 날짜. UNLIMITED면 None."""
|
||||||
|
days = self.target_days
|
||||||
|
if days is None:
|
||||||
|
return None
|
||||||
|
return self.created_at.date() + timedelta(days=days - 1)
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ from pydantic import ValidationError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.habit import HabitStatus, HabitType
|
from app.models.habit import HabitDifficulty, HabitStatus, HabitType
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.habit import HabitCreate, HabitUpdate
|
from app.schemas.habit import HabitCreate, HabitUpdate
|
||||||
from app.security import get_current_user_optional
|
from app.security import get_current_user_optional
|
||||||
from app.services import habit_service, log_service
|
from app.services import habit_service, log_service
|
||||||
from app.template_utils import heatmap_opacity, is_milestone_streak, weekday_label
|
from app.template_utils import difficulty_label, goal_progress, heatmap_opacity, is_milestone_streak, weekday_label
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -22,6 +22,9 @@ templates = Jinja2Templates(directory="app/templates")
|
|||||||
templates.env.globals["weekday_label"] = weekday_label
|
templates.env.globals["weekday_label"] = weekday_label
|
||||||
templates.env.globals["heatmap_opacity"] = heatmap_opacity
|
templates.env.globals["heatmap_opacity"] = heatmap_opacity
|
||||||
templates.env.globals["is_milestone_streak"] = is_milestone_streak
|
templates.env.globals["is_milestone_streak"] = is_milestone_streak
|
||||||
|
templates.env.globals["difficulty_label"] = difficulty_label
|
||||||
|
templates.env.globals["goal_progress"] = goal_progress
|
||||||
|
templates.env.globals["habit_difficulties"] = list(HabitDifficulty)
|
||||||
|
|
||||||
|
|
||||||
def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse:
|
def _current_user_or_redirect(request: Request, db: Session) -> User | RedirectResponse:
|
||||||
@@ -155,6 +158,7 @@ def create_habit_page(
|
|||||||
name: str = Form(""),
|
name: str = Form(""),
|
||||||
habit_type: str = Form(...),
|
habit_type: str = Form(...),
|
||||||
weekdays_mask: int = Form(...),
|
weekdays_mask: int = Form(...),
|
||||||
|
difficulty: str = Form(HabitDifficulty.MEDIUM.value),
|
||||||
condition_text: str | None = Form(None),
|
condition_text: str | None = Form(None),
|
||||||
reminder_time: str | None = Form(None),
|
reminder_time: str | None = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -169,6 +173,7 @@ def create_habit_page(
|
|||||||
name=name,
|
name=name,
|
||||||
habit_type=HabitType(habit_type),
|
habit_type=HabitType(habit_type),
|
||||||
weekdays_mask=weekdays_mask,
|
weekdays_mask=weekdays_mask,
|
||||||
|
difficulty=HabitDifficulty(difficulty),
|
||||||
condition_text=condition_text,
|
condition_text=condition_text,
|
||||||
reminder_time=parsed_time,
|
reminder_time=parsed_time,
|
||||||
)
|
)
|
||||||
@@ -190,6 +195,7 @@ def edit_habit_page(
|
|||||||
habit_id: int,
|
habit_id: int,
|
||||||
name: str = Form(""),
|
name: str = Form(""),
|
||||||
weekdays_mask: int = Form(...),
|
weekdays_mask: int = Form(...),
|
||||||
|
difficulty: str = Form(HabitDifficulty.MEDIUM.value),
|
||||||
condition_text: str | None = Form(None),
|
condition_text: str | None = Form(None),
|
||||||
reminder_time: str | None = Form(None),
|
reminder_time: str | None = Form(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -208,6 +214,7 @@ def edit_habit_page(
|
|||||||
name=name,
|
name=name,
|
||||||
habit_type=habit.habit_type,
|
habit_type=habit.habit_type,
|
||||||
weekdays_mask=weekdays_mask,
|
weekdays_mask=weekdays_mask,
|
||||||
|
difficulty=HabitDifficulty(difficulty),
|
||||||
condition_text=condition_text,
|
condition_text=condition_text,
|
||||||
reminder_time=parsed_time,
|
reminder_time=parsed_time,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ from datetime import datetime, time
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
from app.models.habit import ALL_WEEKDAYS_MASK, HabitStatus, HabitType
|
from app.models.habit import ALL_WEEKDAYS_MASK, HabitDifficulty, HabitStatus, HabitType
|
||||||
|
|
||||||
|
|
||||||
class HabitBase(BaseModel):
|
class HabitBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
habit_type: HabitType
|
habit_type: HabitType
|
||||||
weekdays_mask: int = ALL_WEEKDAYS_MASK
|
weekdays_mask: int = ALL_WEEKDAYS_MASK
|
||||||
|
difficulty: HabitDifficulty = HabitDifficulty.MEDIUM
|
||||||
condition_text: str | None = None
|
condition_text: str | None = None
|
||||||
reminder_time: time | None = None
|
reminder_time: time | None = None
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ class HabitOut(BaseModel):
|
|||||||
habit_type: HabitType
|
habit_type: HabitType
|
||||||
status: HabitStatus
|
status: HabitStatus
|
||||||
weekdays_mask: int
|
weekdays_mask: int
|
||||||
|
difficulty: HabitDifficulty
|
||||||
condition_text: str | None
|
condition_text: str | None
|
||||||
reminder_time: time | None
|
reminder_time: time | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def create_habit(db: Session, user_id: int, data: HabitCreate) -> Habit:
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
habit_type=data.habit_type,
|
habit_type=data.habit_type,
|
||||||
weekdays_mask=data.weekdays_mask,
|
weekdays_mask=data.weekdays_mask,
|
||||||
|
difficulty=data.difficulty,
|
||||||
condition_text=data.condition_text,
|
condition_text=data.condition_text,
|
||||||
reminder_time=data.reminder_time,
|
reminder_time=data.reminder_time,
|
||||||
status=HabitStatus.ACTIVE,
|
status=HabitStatus.ACTIVE,
|
||||||
@@ -59,6 +60,7 @@ def update_habit(db: Session, habit: Habit, data: HabitUpdate) -> Habit:
|
|||||||
habit.name = data.name
|
habit.name = data.name
|
||||||
habit.habit_type = data.habit_type
|
habit.habit_type = data.habit_type
|
||||||
habit.weekdays_mask = data.weekdays_mask
|
habit.weekdays_mask = data.weekdays_mask
|
||||||
|
habit.difficulty = data.difficulty
|
||||||
habit.condition_text = data.condition_text
|
habit.condition_text = data.condition_text
|
||||||
habit.reminder_time = data.reminder_time
|
habit.reminder_time = data.reminder_time
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -409,6 +409,40 @@ label {
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 목표 기간 난이도 선택 */
|
||||||
|
.difficulty-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-pill {
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-pill.selected {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.difficulty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-progress-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* 습관 리스트 아이템 */
|
/* 습관 리스트 아이템 */
|
||||||
.habit-item {
|
.habit-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+29
-1
@@ -1,8 +1,17 @@
|
|||||||
from app.models.habit import ALL_WEEKDAYS_MASK
|
from datetime import date
|
||||||
|
|
||||||
|
from app.models.habit import ALL_WEEKDAYS_MASK, HABIT_DIFFICULTY_TARGET_DAYS, Habit, HabitDifficulty
|
||||||
from app.services.log_service import MILESTONE_STREAKS
|
from app.services.log_service import MILESTONE_STREAKS
|
||||||
|
|
||||||
_DAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]
|
_DAY_LABELS = ["월", "화", "수", "목", "금", "토", "일"]
|
||||||
|
|
||||||
|
_DIFFICULTY_LABELS: dict[HabitDifficulty, str] = {
|
||||||
|
HabitDifficulty.EASY: "하 (21일)",
|
||||||
|
HabitDifficulty.MEDIUM: "중 (66일)",
|
||||||
|
HabitDifficulty.HARD: "상 (254일)",
|
||||||
|
HabitDifficulty.UNLIMITED: "기간 무제한",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_milestone_streak(streak: int) -> bool:
|
def is_milestone_streak(streak: int) -> bool:
|
||||||
return streak in MILESTONE_STREAKS
|
return streak in MILESTONE_STREAKS
|
||||||
@@ -15,6 +24,25 @@ def weekday_label(mask: int) -> str:
|
|||||||
return ", ".join(days) if days else "선택된 요일 없음"
|
return ", ".join(days) if days else "선택된 요일 없음"
|
||||||
|
|
||||||
|
|
||||||
|
def difficulty_label(difficulty: HabitDifficulty) -> str:
|
||||||
|
return _DIFFICULTY_LABELS[difficulty]
|
||||||
|
|
||||||
|
|
||||||
|
def goal_progress(habit: Habit) -> dict | None:
|
||||||
|
"""습관의 목표 기간 진행 상황. 기간 무제한(UNLIMITED)이면 None."""
|
||||||
|
target_days = HABIT_DIFFICULTY_TARGET_DAYS[habit.difficulty]
|
||||||
|
if target_days is None:
|
||||||
|
return None
|
||||||
|
elapsed = (date.today() - habit.created_at.date()).days + 1 # 시작일을 1일차로 계산
|
||||||
|
elapsed = max(elapsed, 1)
|
||||||
|
return {
|
||||||
|
"elapsed": min(elapsed, target_days),
|
||||||
|
"target": target_days,
|
||||||
|
"remaining": max(target_days - elapsed, 0),
|
||||||
|
"reached": elapsed >= target_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def heatmap_opacity(checked_count: int, scheduled_count: int) -> float:
|
def heatmap_opacity(checked_count: int, scheduled_count: int) -> float:
|
||||||
"""월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다."""
|
"""월별 캘린더 히트맵 셀의 배경 투명도(0~0.9)를 계산한다."""
|
||||||
if not scheduled_count:
|
if not scheduled_count:
|
||||||
|
|||||||
@@ -26,3 +26,18 @@
|
|||||||
</label>
|
</label>
|
||||||
<input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" />
|
<input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>목표 기간 난이도</label>
|
||||||
|
<div class="difficulty-picker">
|
||||||
|
{% for d in habit_difficulties %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="difficulty-pill"
|
||||||
|
:class="{ selected: difficulty === '{{ d.value }}' }"
|
||||||
|
@click="difficulty = '{{ d.value }}'"
|
||||||
|
>{{ difficulty_label(d) }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="difficulty-hint">습관 자동화까지 걸리는 기간에 대한 실증 연구(Lally et al., 2010, UCL) 기준입니다 — 하는 단순한 습관이 자동화되는 데 걸리는 기간(약 3주), 중은 전체 중앙값, 상은 노력이 많이 드는 습관의 상한(약 8개월)입니다.</p>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
x-data="{
|
x-data="{
|
||||||
open: false,
|
open: false,
|
||||||
mask: 127,
|
mask: 127,
|
||||||
|
difficulty: 'medium',
|
||||||
alarmOn: false,
|
alarmOn: false,
|
||||||
days: ['월', '화', '수', '목', '금', '토', '일'],
|
days: ['월', '화', '수', '목', '금', '토', '일'],
|
||||||
}"
|
}"
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="habit_type" value="{{ habit_type }}" />
|
<input type="hidden" name="habit_type" value="{{ habit_type }}" />
|
||||||
<input type="hidden" name="weekdays_mask" :value="mask" />
|
<input type="hidden" name="weekdays_mask" :value="mask" />
|
||||||
|
<input type="hidden" name="difficulty" :value="difficulty" />
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="name-{{ habit_type }}">습관 이름</label>
|
<label for="name-{{ habit_type }}">습관 이름</label>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
x-data="{
|
x-data="{
|
||||||
editing: false,
|
editing: false,
|
||||||
mask: {{ habit.weekdays_mask }},
|
mask: {{ habit.weekdays_mask }},
|
||||||
|
difficulty: '{{ habit.difficulty.value }}',
|
||||||
alarmOn: {{ 'true' if habit.reminder_time else 'false' }},
|
alarmOn: {{ 'true' if habit.reminder_time else 'false' }},
|
||||||
days: ['월', '화', '수', '목', '금', '토', '일'],
|
days: ['월', '화', '수', '목', '금', '토', '일'],
|
||||||
}"
|
}"
|
||||||
@@ -17,6 +18,14 @@
|
|||||||
{{ weekday_label(habit.weekdays_mask) }}
|
{{ weekday_label(habit.weekdays_mask) }}
|
||||||
{% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
|
{% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% set progress = goal_progress(habit) %}
|
||||||
|
<div class="goal-progress-badge">
|
||||||
|
{% if progress %}
|
||||||
|
목표 {{ difficulty_label(habit.difficulty) }} · {{ progress.elapsed }}/{{ progress.target }}일차{% if progress.reached %} · 목표 기간 달성!{% else %} (D-{{ progress.remaining }}){% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ difficulty_label(habit.difficulty) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% set stats = stats_map[habit.id] %}
|
{% set stats = stats_map[habit.id] %}
|
||||||
{% if stats.scheduled_days > 0 %}
|
{% if stats.scheduled_days > 0 %}
|
||||||
<div class="habit-item-stats">
|
<div class="habit-item-stats">
|
||||||
@@ -78,6 +87,7 @@
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="weekdays_mask" :value="mask" />
|
<input type="hidden" name="weekdays_mask" :value="mask" />
|
||||||
|
<input type="hidden" name="difficulty" :value="difficulty" />
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="edit-name-{{ habit.id }}">습관 이름</label>
|
<label for="edit-name-{{ habit.id }}">습관 이름</label>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 습관은 어떻게 형성되는가 — 연구 자료 정리
|
||||||
|
|
||||||
|
심리학·신경과학 연구와 대중서를 바탕으로 "습관이 만들어지는 과정"을 정리한 문서. `habit-tracker` 앱의 기능(연속 달성일, 완료율, 요일 스케줄, 알림 등)을 설계/보완할 때 참고할 수 있도록 실제 연구 결과 위주로 작성했다.
|
||||||
|
|
||||||
|
## 1. 습관의 정의
|
||||||
|
|
||||||
|
심리학에서 습관(habit)은 **특정 맥락(context)에서 반복적으로 수행되어 자동화된 행동**으로 정의된다. 핵심은 "의식적 의도 없이도 촉발되는 행동"이라는 점이다. Wendy Wood(USC)의 연구에 따르면 하루 행동의 약 43%가 의도적 결정이 아니라 습관에 의해 이루어지며, Duke University의 2006년 연구는 이 수치를 40% 이상으로 추산했다.
|
||||||
|
|
||||||
|
## 2. 습관 루프(Habit Loop) — Charles Duhigg
|
||||||
|
|
||||||
|
Charles Duhigg는 *The Power of Habit*(2012)에서 신경과학 연구(MIT 쥐 미로 실험 등)를 근거로 습관을 3단계 순환 구조로 설명했다.
|
||||||
|
|
||||||
|
1. **신호(Cue)** — 뇌에게 "자동 모드로 전환하라"고 알리는 트리거. 특정 시간, 장소, 감정 상태, 앞선 행동, 주변 사람 등이 신호가 될 수 있다.
|
||||||
|
2. **반복 행동(Routine)** — 실제 수행되는 행동 자체(신체적/정신적/감정적 모두 가능).
|
||||||
|
3. **보상(Reward)** — 행동 완료 후 뇌가 얻는 만족감. 뇌는 이 보상을 신호와 연결지어 "이 신호가 오면 이 루프를 반복할 가치가 있다"고 학습한다.
|
||||||
|
|
||||||
|
쥐 미로 실험에서는 반복이 충분히 누적되면 미로를 달리는 중간 과정의 뇌 활동은 줄고 **신호와 보상 시점에서만 활동이 튄다** — 즉 뇌가 전체 행동 시퀀스를 하나의 자동화된 덩어리(chunk)로 묶어버린다는 것을 보여준다. 이 저장·실행을 담당하는 것이 대뇌 기저핵(basal ganglia)이며, 그 결과 전전두피질(prefrontal cortex, 의식적 사고 담당)은 다른 일에 자원을 쓸 수 있게 된다.
|
||||||
|
|
||||||
|
이 루프가 반복되면서 신호만 봐도 보상을 기대하는 **갈망(craving)**이 형성되고, 이 갈망이 루프를 계속 돌리는 동력이 된다.
|
||||||
|
|
||||||
|
## 3. 신경과학적 기반 — 기저핵과 도파민
|
||||||
|
|
||||||
|
- 습관 형성은 **배측 선조체(dorsal striatum)** 및 그와 연결된 피질 회로에서 일어난다. 학습 초기에는 연합 선조체(associative striatum)가, 자동화가 진행된 후에는 감각운동 선조체(sensorimotor striatum)가 더 활성화된다 — 즉 "무엇을 할지 고민하는 뇌"에서 "몸이 알아서 하는 뇌"로 활동 부위 자체가 이동한다.
|
||||||
|
- 보상이 주어질 때 분비되는 **도파민**이 해당 행동을 강화하는 시냅스 가소성(synaptic plasticity)을 일으킨다. 보상을 얻은 행동에 대해 "Go 경로"는 강해지고 "No-Go 경로"는 약해지는 과정이 수백 번 반복되면 행동이 거의 자동으로 발화하는 수준에 도달한다.
|
||||||
|
- 이 과정은 강화학습의 **예측 오차(reward prediction error)** 신호로 설명된다 — 실제 보상이 예상보다 크면 도파민이 늘고, 예상대로면 도파민 반응이 줄어드는 식으로 학습이 진행된다. 습관 시스템과 목표지향 시스템(goal-directed system)은 서로 다른 도파민 뉴런 집단이 인코딩하는 별개의 예측 오차를 사용한다는 것이 최근 연구의 핵심 발견이다.
|
||||||
|
|
||||||
|
## 4. 습관이 자동화되기까지 걸리는 시간 — Lally et al. (2010)
|
||||||
|
|
||||||
|
흔히 "습관 형성에 21일이 걸린다"고 알려져 있지만, 이는 근거가 약한 대중적 통설이다. 실제 실증 연구는 다음과 같다.
|
||||||
|
|
||||||
|
- **Phillippa Lally 외 (UCL), *European Journal of Social Psychology*, 2010**, "How are habits formed: Modelling habit formation in the real world"
|
||||||
|
- 참가자 96명이 각자 새로 만들고 싶은 습관(음식/음료/활동)을 선택, 84일간 매일 수행 여부와 "이 행동이 얼마나 자동적으로 느껴지는가"(Self-Report Habit Index)를 기록.
|
||||||
|
- **자동성이 95% 수준(점근값)에 도달하는 데 걸린 시간의 중앙값은 66일**이었으나, 개인차가 매우 커서 **범위는 18일~254일**이었다.
|
||||||
|
- 단순한 행동(아침식사와 함께 물 마시기, 양치 후 비타민 먹기처럼 기존 습관에 붙이기 쉬운 것)은 약 3주 만에 자동화된 반면, 더 노력이 필요한 행동(아침 식전 윗몸일으키기 50회, 저녁식사 후 15분 걷기)은 2~8개월이 걸렸다.
|
||||||
|
- 대중적으로 인용될 때 "48%만 성공적으로 자동성에 도달했고 그중에서도 단순한 행동 위주로 중앙값 66일이었다"는 전제가 종종 생략된다는 점에 주의할 것.
|
||||||
|
|
||||||
|
## 5. 맥락(context)과 반복의 역할 — Wendy Wood
|
||||||
|
|
||||||
|
Wendy Wood(*Good Habits, Bad Habits*, 2019)의 30년치 연구 종합에 따르면:
|
||||||
|
|
||||||
|
- 습관 시스템은 특정 **맥락(장소, 시간, 앞선 행동, 감정 상태 등)과 행동을 연합**시키는 방식으로 작동한다. 같은 행동이라도 맥락이 바뀌면(예: 집 소파 vs. 남의 집 소파) 습관적으로 촉발되지 않는다 — 이것이 "환경을 바꾸면 습관이 쉽게 깨지거나 새로 만들어지는" 이유다.
|
||||||
|
- 같은 맥락에서 같은 행동을 반복할수록 맥락-행동 신경 연결이 강화된다. 연합이 형성된 이후에도 그 연결이 약한 상태에서 강한 자동화 상태로 가려면 추가 반복이 필요하다.
|
||||||
|
- 시사점: 습관을 만들려면 "의지력"보다 **일관된 맥락(같은 시간, 같은 장소, 같은 선행 행동)** 을 고정하는 것이 훨씬 효과적이다.
|
||||||
|
|
||||||
|
## 6. 의도적으로 습관을 설계하는 기법
|
||||||
|
|
||||||
|
### 6.1 실행 의도 (Implementation Intentions) — Peter Gollwitzer
|
||||||
|
|
||||||
|
- Gollwitzer(1999)가 제안한 **"if-then" 계획**: "만약 X 상황이 오면, 나는 Y 행동을 한다"처럼 구체적인 상황(when/where)과 행동을 미리 연결해두는 것.
|
||||||
|
- Gollwitzer & Sheeran(2006)의 메타분석(94개 독립 연구)에서 평균 효과크기 **d = 0.65**(심리학 연구 기준 중간~큰 효과)로 목표 달성률을 유의미하게 높이는 것으로 나타났다.
|
||||||
|
- if-then 계획이 만드는 상황-반응 연합은 습관의 신호-반응 연합과 구조적으로 유사하다 — 차이는 **의식적으로 설계된 습관**이라는 점.
|
||||||
|
|
||||||
|
### 6.2 작은 습관 (Tiny Habits) — BJ Fogg
|
||||||
|
|
||||||
|
- Stanford의 BJ Fogg는 행동을 `B = MAP`(Behavior = Motivation × Ability × Prompt)로 모델링 — 동기가 낮아도 **행동을 극단적으로 쉽게(Ability↑) 만들고 명확한 신호(Prompt)를 주면** 행동이 일어난다는 것.
|
||||||
|
- 그의 "anchor–behavior–celebration" 패턴: 이미 확립된 행동(anchor) 뒤에 새 행동을 붙이고, 완료 즉시 스스로를 축하(celebration)해 긍정 정서 보상을 즉각 연결한다.
|
||||||
|
|
||||||
|
### 6.3 습관 쌓기 (Habit Stacking) & 4가지 행동 변화 법칙 — James Clear
|
||||||
|
|
||||||
|
- James Clear(*Atomic Habits*, 2018)는 Duhigg의 신호-루틴-보상 루프를 **신호(Cue) → 갈망(Craving) → 반응(Response) → 보상(Reward)** 4단계로 재구성하고, 각 단계에 대응하는 "행동 변화의 4가지 법칙"을 제시했다.
|
||||||
|
1. 신호를 명확하게 만들어라 (Make it obvious)
|
||||||
|
2. 매력적으로 만들어라 (Make it attractive)
|
||||||
|
3. 쉽게 만들어라 (Make it easy)
|
||||||
|
4. 만족스럽게 만들어라 (Make it satisfying)
|
||||||
|
- "습관 쌓기(habit stacking)"는 Fogg의 anchor-behavior 패턴을 대중화한 이름으로, "기존 습관 뒤에 새 습관을 붙인다"(예: "커피를 내린 직후, 나는 5분 명상을 한다")는 실행 의도 기법의 응용이다.
|
||||||
|
|
||||||
|
## 7. 종합 — 습관 형성의 공통 요소
|
||||||
|
|
||||||
|
여러 연구/이론을 종합하면 습관이 형성되려면 다음 요소가 반복적으로 함께 작동해야 한다.
|
||||||
|
|
||||||
|
| 요소 | 역할 | 관련 연구 |
|
||||||
|
|---|---|---|
|
||||||
|
| 안정된 신호(맥락) | 언제/어디서 행동할지 트리거 | Wood(맥락 의존성), Gollwitzer(if-then) |
|
||||||
|
| 낮은 실행 난이도 | 의지력 없이도 실행 가능해야 반복이 유지됨 | Fogg(B=MAP) |
|
||||||
|
| 즉각적 보상/만족 | 도파민 기반 강화가 이루어져야 자동화가 진행됨 | Duhigg(습관 루프), 기저핵/도파민 신경과학 |
|
||||||
|
| 충분한 반복 횟수·기간 | 자동성은 점진적으로 증가하며 개인·행동 난이도에 따라 18~254일 소요 | Lally et al.(2010) |
|
||||||
|
| 행동의 단순성 | 단순한 행동일수록 빠르게 자동화됨 | Lally et al.(2010) |
|
||||||
|
|
||||||
|
## 8. habit-tracker 앱 설계에 대한 시사점
|
||||||
|
|
||||||
|
- **목표 기간 난이도(difficulty)**: 습관 등록 시 하(21일)/중(66일)/상(254일)/기간무제한 중 선택하도록 구현했다(`app/models/habit.py`의 `HabitDifficulty`, `HABIT_DIFFICULTY_TARGET_DAYS`). 21일이라는 대중적 통설 대신 Lally et al.(2010)의 실제 데이터 구간을 그대로 옵션화했다 — 하는 "단순 행동이 자동화되는 데 걸린 기간"(3주), 중은 "전체 참가자 중앙값"(66일), 상은 "노력이 많이 드는 행동의 관찰 범위 상한"(254일), 기간무제한은 종료 시점을 두지 않고 계속 트래킹만 하는 습관(예: 금연처럼 "언제 끝난다"고 말하기 애매한 습관)을 위한 선택지다. `/habits` 목록에서 습관 생성일 기준 경과일과 목표까지 남은 D-day를 함께 보여준다.
|
||||||
|
- **연속 달성일(streak)**: Lally 연구가 시사하듯 자동화는 "끊김 없는 반복"에서 나온다. 현재 앱의 `current_streak` 로직(오늘 미체크는 스트릭을 끊지 않음)은 하루 단위의 유연성을 주면서도 반복의 연속성을 시각화한다는 점에서 연구와 방향이 맞는다.
|
||||||
|
- **요일 스케줄(weekdays_mask)**: Wood의 맥락 의존성 연구를 고려하면, 습관은 "매일"보다 "특정 요일/맥락"에 고정할 때 오히려 더 잘 자리잡을 수 있다 — 무리하게 매일로 설정하기보다 실제로 지킬 수 있는 요일부터 시작하는 것이 실행 의도(if-then) 관점에서도 유리하다.
|
||||||
|
- **알림(reminder_time)**: Gollwitzer의 if-then 계획과 유사하게, 고정된 시각 알림은 "언제"라는 신호를 명확히 제공해 습관 형성을 돕는 역할을 한다.
|
||||||
|
- **조건 텍스트(condition_text)**: "달성 조건"을 구체적으로 적어두게 하는 것은 Fogg의 "행동을 명확하고 작게 만들기" 원칙과 맞닿아 있다 — 모호한 목표보다 구체적인 완료 기준이 실행률을 높인다.
|
||||||
|
- **완료율/통계 배지**: 즉각적인 시각적 피드백(체크 후 배지 갱신)은 Duhigg의 "보상" 단계를 앱 UI 차원에서 대체/보강하는 역할로 볼 수 있다.
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
- [How are habits formed: Modelling habit formation in the real world (Wiley, Lally et al., 2010)](https://onlinelibrary.wiley.com/doi/abs/10.1002/ejsp.674)
|
||||||
|
- [How long does it take to form a habit? — UCL News](https://www.ucl.ac.uk/news/2009/aug/how-long-does-it-take-form-habit)
|
||||||
|
- [How Long Does It Take to Form a Habit? What Lally et al. Actually Found](https://www.thebehavioralscientist.com/articles/how-long-to-form-a-habit)
|
||||||
|
- [Cortical and basal ganglia contributions to habit learning and automaticity (PMC)](https://pmc.ncbi.nlm.nih.gov/articles/PMC2862890/)
|
||||||
|
- [Neurobiology of habit formation (ScienceDirect)](https://www.sciencedirect.com/science/article/abs/pii/S235215461730089X)
|
||||||
|
- [Dorsal Striatal Circuits for Habits, Compulsions and Addictions (Frontiers)](https://www.frontiersin.org/journals/systems-neuroscience/articles/10.3389/fnsys.2019.00028/full)
|
||||||
|
- [Implementation intention — Wikipedia](https://en.wikipedia.org/wiki/Implementation_intention)
|
||||||
|
- [Implementation Intentions (Gollwitzer, NCI DCCPS)](https://cancercontrol.cancer.gov/brp/research/constructs/implementation-intentions)
|
||||||
|
- [Good Habits, Bad Habits by Wendy Wood — Book Overview](https://www.shortform.com/blog/good-habits-bad-habits-book/)
|
||||||
|
- [Atomic Habits Summary by James Clear](https://jamesclear.com/atomic-habits-summary)
|
||||||
|
- [How To Start New Habits That Actually Stick — James Clear](https://jamesclear.com/three-steps-habit-change)
|
||||||
|
- [The Habit Loop: Cue, Routine, Reward Explained by Science](https://successodysseyhub.com/blog/habit-loop-explained)
|
||||||
|
|
||||||
|
### 원전 서적 (직접 인용 시 참고)
|
||||||
|
|
||||||
|
- Charles Duhigg, *The Power of Habit: Why We Do What We Do in Life and Business* (2012)
|
||||||
|
- Wendy Wood, *Good Habits, Bad Habits: The Science of Making Positive Changes That Stick* (2019)
|
||||||
|
- James Clear, *Atomic Habits: An Easy & Proven Way to Build Good Habits & Break Bad Ones* (2018)
|
||||||
|
- BJ Fogg, *Tiny Habits: The Small Changes That Change Everything* (2019)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add difficulty (target period) to habit
|
||||||
|
|
||||||
|
Revision ID: 0007_add_habit_difficulty
|
||||||
|
Revises: 0006_add_habit_abandoned
|
||||||
|
Create Date: 2026-07-17
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0007_add_habit_difficulty"
|
||||||
|
down_revision: Union[str, None] = "0006_add_habit_abandoned"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"habit",
|
||||||
|
sa.Column("difficulty", sa.String(length=20), nullable=False, server_default="medium"),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_habit_difficulty", "habit", "difficulty in ('easy','medium','hard','unlimited')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_habit_difficulty", "habit", type_="check")
|
||||||
|
op.drop_column("habit", "difficulty")
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.models.habit import ALL_WEEKDAYS_MASK, HabitType
|
from app.models.habit import ALL_WEEKDAYS_MASK, HabitDifficulty, HabitType
|
||||||
from app.schemas.habit import HabitCreate
|
from app.schemas.habit import HabitCreate
|
||||||
|
|
||||||
|
|
||||||
@@ -49,3 +49,18 @@ def test_weekdays_mask_single_day_accepted():
|
|||||||
def test_weekdays_mask_defaults_to_all_days():
|
def test_weekdays_mask_defaults_to_all_days():
|
||||||
habit = HabitCreate(**_base_kwargs())
|
habit = HabitCreate(**_base_kwargs())
|
||||||
assert habit.weekdays_mask == ALL_WEEKDAYS_MASK
|
assert habit.weekdays_mask == ALL_WEEKDAYS_MASK
|
||||||
|
|
||||||
|
|
||||||
|
def test_difficulty_defaults_to_medium():
|
||||||
|
habit = HabitCreate(**_base_kwargs())
|
||||||
|
assert habit.difficulty == HabitDifficulty.MEDIUM
|
||||||
|
|
||||||
|
|
||||||
|
def test_difficulty_accepts_explicit_value():
|
||||||
|
habit = HabitCreate(**_base_kwargs(difficulty=HabitDifficulty.UNLIMITED))
|
||||||
|
assert habit.difficulty == HabitDifficulty.UNLIMITED
|
||||||
|
|
||||||
|
|
||||||
|
def test_difficulty_rejects_invalid_value():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
HabitCreate(**_base_kwargs(difficulty="impossible"))
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
from app.models.habit import ALL_WEEKDAYS_MASK
|
from datetime import datetime, timedelta
|
||||||
from app.template_utils import heatmap_opacity, weekday_label
|
|
||||||
|
from app.models.habit import ALL_WEEKDAYS_MASK, Habit, HabitDifficulty, HabitType
|
||||||
|
from app.template_utils import difficulty_label, goal_progress, heatmap_opacity, weekday_label
|
||||||
|
|
||||||
|
|
||||||
|
def _habit_created_days_ago(days_ago: int, difficulty: HabitDifficulty) -> Habit:
|
||||||
|
return Habit(
|
||||||
|
name="테스트",
|
||||||
|
habit_type=HabitType.BUILD,
|
||||||
|
difficulty=difficulty,
|
||||||
|
weekdays_mask=ALL_WEEKDAYS_MASK,
|
||||||
|
created_at=datetime.now() - timedelta(days=days_ago),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_weekday_label_all_days_shows_daily():
|
def test_weekday_label_all_days_shows_daily():
|
||||||
@@ -29,3 +41,43 @@ def test_heatmap_opacity_full_ratio_caps_at_point_nine():
|
|||||||
|
|
||||||
def test_heatmap_opacity_partial_ratio():
|
def test_heatmap_opacity_partial_ratio():
|
||||||
assert heatmap_opacity(checked_count=1, scheduled_count=2) == round(0.12 + 0.5 * 0.78, 2)
|
assert heatmap_opacity(checked_count=1, scheduled_count=2) == round(0.12 + 0.5 * 0.78, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_difficulty_label_maps_to_target_days():
|
||||||
|
assert difficulty_label(HabitDifficulty.EASY) == "하 (21일)"
|
||||||
|
assert difficulty_label(HabitDifficulty.MEDIUM) == "중 (66일)"
|
||||||
|
assert difficulty_label(HabitDifficulty.HARD) == "상 (254일)"
|
||||||
|
assert difficulty_label(HabitDifficulty.UNLIMITED) == "기간 무제한"
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_progress_none_for_unlimited():
|
||||||
|
habit = _habit_created_days_ago(10, HabitDifficulty.UNLIMITED)
|
||||||
|
assert goal_progress(habit) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_progress_day_one_on_creation_day():
|
||||||
|
habit = _habit_created_days_ago(0, HabitDifficulty.EASY)
|
||||||
|
progress = goal_progress(habit)
|
||||||
|
assert progress == {"elapsed": 1, "target": 21, "remaining": 20, "reached": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_progress_mid_period():
|
||||||
|
habit = _habit_created_days_ago(10, HabitDifficulty.MEDIUM)
|
||||||
|
progress = goal_progress(habit)
|
||||||
|
assert progress == {"elapsed": 11, "target": 66, "remaining": 55, "reached": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_progress_reached_caps_elapsed_at_target():
|
||||||
|
habit = _habit_created_days_ago(100, HabitDifficulty.EASY)
|
||||||
|
progress = goal_progress(habit)
|
||||||
|
assert progress == {"elapsed": 21, "target": 21, "remaining": 0, "reached": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_days_and_goal_target_date_on_model():
|
||||||
|
habit = _habit_created_days_ago(0, HabitDifficulty.EASY)
|
||||||
|
assert habit.target_days == 21
|
||||||
|
assert habit.goal_target_date == habit.created_at.date() + timedelta(days=20)
|
||||||
|
|
||||||
|
unlimited = _habit_created_days_ago(0, HabitDifficulty.UNLIMITED)
|
||||||
|
assert unlimited.target_days is None
|
||||||
|
assert unlimited.goal_target_date is None
|
||||||
|
|||||||
Reference in New Issue
Block a user