From 9dfcbcec8f6bdb12d0095dc68fc3b531d8daceac Mon Sep 17 00:00:00 2001 From: shinalok Date: Tue, 21 Jul 2026 13:21:49 +0900 Subject: [PATCH] add day-detail view to monthly history calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 날짜 셀 클릭 시 그 날 예정된 습관별 완료/실패/미완료 상태를 htmx로 불러와 보여준다. Co-Authored-By: Claude Sonnet 5 --- app/routers/pages.py | 14 ++++++++ app/schemas/habit_log.py | 8 +++++ app/services/log_service.py | 47 +++++++++++++++++++++++++- app/static/css/style.css | 36 ++++++++++++++++++++ app/templates/history.html | 7 +++- app/templates/partials/day_detail.html | 30 ++++++++++++++++ tests/test_log_service.py | 47 ++++++++++++++++++++++++++ tests/test_pages_history.py | 41 ++++++++++++++++++++++ 8 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 app/templates/partials/day_detail.html create mode 100644 tests/test_pages_history.py diff --git a/app/routers/pages.py b/app/routers/pages.py index bab696d..b8d45f3 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -367,6 +367,20 @@ def _week_context(db: Session, user_id: int, week_start: date) -> dict: } +@router.get("/history/day/{log_date}") +def history_day_detail(request: Request, log_date: date, db: Session = Depends(get_db)): + current = _current_user_or_redirect(request, db) + if isinstance(current, RedirectResponse): + return current + + items = log_service.get_day_detail(db, current.id, log_date) + return templates.TemplateResponse( + request, + "partials/day_detail.html", + {"log_date": log_date, "items": items}, + ) + + @router.get("/history") def history_page( request: Request, diff --git a/app/schemas/habit_log.py b/app/schemas/habit_log.py index 0c3501e..19a323b 100644 --- a/app/schemas/habit_log.py +++ b/app/schemas/habit_log.py @@ -41,6 +41,14 @@ class WeeklyMatrixRow(BaseModel): completion_rate: float # 이번 주, 오늘까지 지난 예정일 중 체크한 비율 (%) +class DayDetailItem(BaseModel): + habit_id: int + name: str + habit_type: str + condition_text: str | None + status: str # "checked" | "failed" | "missed"(과거, 미결정) | "pending"(오늘/미래, 미결정) + + class HabitStats(BaseModel): completion_rate: float # 습관 생성일부터 오늘까지, 예정된 날 중 체크한 비율 (%) current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수 diff --git a/app/services/log_service.py b/app/services/log_service.py index 7ef449f..9737b1f 100644 --- a/app/services/log_service.py +++ b/app/services/log_service.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.models.habit import Habit, HabitStatus, HabitType from app.models.habit_log import HabitLog, HabitLogStatus -from app.schemas.habit_log import HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow +from app.schemas.habit_log import DayDetailItem, HabitStats, MonthlySummaryDay, TodayItem, WeeklyMatrixRow from app.schemas.level import LevelInfo from app.services import habit_service, level_service, push_service @@ -182,6 +182,51 @@ def get_monthly_summary(db: Session, user_id: int, year: int, month: int) -> lis return summaries +def get_day_detail(db: Session, user_id: int, target_date: date) -> list[DayDetailItem]: + """월별 캘린더에서 특정 날짜를 선택했을 때, 그 날 예정되어 있던 습관별 체크/실패/미결정 상태를 반환한다. + + get_monthly_summary와 같은 규칙(현재 active 습관 기준, 습관 생성일 이전 제외, 과거 요일 변경 + 이력 미추적)을 그대로 따른다. + """ + active_habits = habit_service.list_habits(db, user_id, status=HabitStatus.ACTIVE) + scheduled = [ + h for h in active_habits if h.created_at.date() <= target_date and h.is_scheduled_on(target_date.weekday()) + ] + + habit_ids = [h.id for h in scheduled] + logs_by_habit: dict[int, HabitLogStatus] = {} + if habit_ids: + rows = db.execute( + select(HabitLog.habit_id, HabitLog.status).where( + HabitLog.habit_id.in_(habit_ids), HabitLog.log_date == target_date + ) + ).all() + logs_by_habit = {r[0]: r[1] for r in rows} + + today = date.today() + items = [] + for h in scheduled: + log_status = logs_by_habit.get(h.id) + if log_status == HabitLogStatus.DONE: + status = "checked" + elif log_status == HabitLogStatus.FAILED: + status = "failed" + elif target_date < today: + status = "missed" + else: + status = "pending" + items.append( + DayDetailItem( + habit_id=h.id, + name=h.name, + habit_type=h.habit_type.value, + condition_text=h.condition_text, + status=status, + ) + ) + return items + + def summarize_completion_rate(summaries: list[MonthlySummaryDay], up_to: date) -> float: """월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다. diff --git a/app/static/css/style.css b/app/static/css/style.css index f207825..4cf30f7 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -729,6 +729,42 @@ label { color: var(--color-text-muted); } +.calendar-cell.clickable { + cursor: pointer; +} + +.day-detail-card { + margin-top: var(--space-2); +} + +.day-detail-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.day-detail-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--color-border); +} + +.day-detail-item:last-child { + border-bottom: none; +} + +.day-detail-status-checked { + color: var(--color-success); + border-color: var(--color-success); +} + +.day-detail-status-missed { + color: var(--color-danger); + border-color: var(--color-danger); +} + /* 주별 매트릭스 */ .week-matrix { width: 100%; diff --git a/app/templates/history.html b/app/templates/history.html index 21bfc59..48e19a5 100644 --- a/app/templates/history.html +++ b/app/templates/history.html @@ -32,8 +32,11 @@ {% for d in week %} {% set summary = summary_map.get(d) %}
{{ d.day }} {% if summary and summary.scheduled_count %} @@ -45,6 +48,8 @@ {% endfor %}
+
+ {% else %}
diff --git a/app/templates/partials/day_detail.html b/app/templates/partials/day_detail.html new file mode 100644 index 0000000..679c412 --- /dev/null +++ b/app/templates/partials/day_detail.html @@ -0,0 +1,30 @@ +{% set weekday_names = ["월", "화", "수", "목", "금", "토", "일"] %} +
+
+

{{ log_date.strftime("%Y.%m.%d") }} ({{ weekday_names[log_date.weekday()] }})

+ +
+ {% if items %} +
+ {% for item in items %} +
+ + {{ item.name }} + {{ "형성" if item.habit_type == "build" else "중단" }} + + {% if item.status == "checked" %} + 완료 + {% elif item.status == "failed" %} + 실패 + {% elif item.status == "missed" %} + 미완료 + {% else %} + 예정 + {% endif %} +
+ {% endfor %} +
+ {% else %} +
이 날 예정된 습관이 없어요.
+ {% endif %} +
diff --git a/tests/test_log_service.py b/tests/test_log_service.py index d04db7c..082d3d4 100644 --- a/tests/test_log_service.py +++ b/tests/test_log_service.py @@ -407,6 +407,53 @@ def test_get_monthly_summary_does_not_count_failed_as_checked(db_session, test_u assert by_date[date(2026, 3, 5)].checked_count == 0 +# ---- get_day_detail ---- + + +def test_get_day_detail_marks_checked_and_failed(db_session, test_user): + checked_habit = _make_habit(db_session, test_user.id, name="체크됨", created_at=datetime(2026, 3, 1)) + failed_habit = _make_habit(db_session, test_user.id, name="실패함", created_at=datetime(2026, 3, 1)) + _check(db_session, checked_habit.id, date(2026, 3, 5)) + _fail(db_session, failed_habit.id, date(2026, 3, 5)) + + items = log_service.get_day_detail(db_session, test_user.id, date(2026, 3, 5)) + by_name = {i.name: i for i in items} + assert by_name["체크됨"].status == "checked" + assert by_name["실패함"].status == "failed" + + +def test_get_day_detail_marks_past_unlogged_as_missed(db_session, test_user): + habit = _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1)) + + items = log_service.get_day_detail(db_session, test_user.id, date(2026, 3, 5)) + assert items[0].status == "missed" + + +def test_get_day_detail_marks_future_unlogged_as_pending(db_session, test_user): + habit = _make_habit(db_session, test_user.id) + future = date.today() + timedelta(days=1) + + items = log_service.get_day_detail(db_session, test_user.id, future) + assert items[0].status == "pending" + + +def test_get_day_detail_excludes_days_before_habit_created(db_session, test_user): + _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 10)) + + items = log_service.get_day_detail(db_session, test_user.id, date(2026, 3, 5)) + assert items == [] + + +def test_get_day_detail_excludes_unscheduled_weekdays(db_session, test_user): + monday_only_mask = 0b0000001 # bit0 = 월요일 + _make_habit(db_session, test_user.id, created_at=datetime(2026, 3, 1), weekdays_mask=monday_only_mask) + + tuesday = date(2026, 3, 3) + assert tuesday.weekday() == 1 + items = log_service.get_day_detail(db_session, test_user.id, tuesday) + assert items == [] + + # ---- summarize_completion_rate: 미래 날짜 제외 회귀 테스트 ---- # CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다 # (실제로 6.2% -> 수정 후 50%가 된 사례). diff --git a/tests/test_pages_history.py b/tests/test_pages_history.py new file mode 100644 index 0000000..2b93c59 --- /dev/null +++ b/tests/test_pages_history.py @@ -0,0 +1,41 @@ +from datetime import date, datetime + +from app.models.habit import ALL_WEEKDAYS_MASK, HabitType +from app.schemas.habit import HabitCreate +from app.services import habit_service + + +def _make_habit(db_session, user_id, name="테스트 습관", created_at=None, **overrides): + data = HabitCreate( + name=name, + habit_type=overrides.pop("habit_type", HabitType.BUILD), + weekdays_mask=overrides.pop("weekdays_mask", ALL_WEEKDAYS_MASK), + ) + habit = habit_service.create_habit(db_session, user_id, data) + if created_at is not None: + habit.created_at = created_at + db_session.commit() + db_session.refresh(habit) + return habit + + +def test_history_day_detail_shows_scheduled_habit(auth_client, db_session, test_user): + _make_habit(db_session, test_user.id, name="아침 스트레칭", created_at=datetime(2026, 3, 1)) + + response = auth_client.get("/history/day/2026-03-05") + assert response.status_code == 200 + assert "아침 스트레칭" in response.text + + +def test_history_day_detail_excludes_other_users_habits(auth_client, db_session, other_user): + _make_habit(db_session, other_user.id, name="남의 습관", created_at=datetime(2026, 3, 1)) + + response = auth_client.get("/history/day/2026-03-05") + assert response.status_code == 200 + assert "남의 습관" not in response.text + + +def test_history_day_detail_requires_login(client): + response = client.get("/history/day/2026-03-05", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/login"