add day-detail view to monthly history calendar
날짜 셀 클릭 시 그 날 예정된 습관별 완료/실패/미완료 상태를 htmx로 불러와 보여준다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
@router.get("/history")
|
||||||
def history_page(
|
def history_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ class WeeklyMatrixRow(BaseModel):
|
|||||||
completion_rate: float # 이번 주, 오늘까지 지난 예정일 중 체크한 비율 (%)
|
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):
|
class HabitStats(BaseModel):
|
||||||
completion_rate: float # 습관 생성일부터 오늘까지, 예정된 날 중 체크한 비율 (%)
|
completion_rate: float # 습관 생성일부터 오늘까지, 예정된 날 중 체크한 비율 (%)
|
||||||
current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수
|
current_streak: int # 오늘(또는 어제)부터 거슬러 올라가며 끊기지 않고 체크한 예정일 수
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.models.habit import Habit, HabitStatus, HabitType
|
from app.models.habit import Habit, HabitStatus, HabitType
|
||||||
from app.models.habit_log import HabitLog, HabitLogStatus
|
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.schemas.level import LevelInfo
|
||||||
from app.services import habit_service, level_service, push_service
|
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
|
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:
|
def summarize_completion_rate(summaries: list[MonthlySummaryDay], up_to: date) -> float:
|
||||||
"""월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다.
|
"""월별 요약에서 up_to(보통 오늘)까지 지난 날짜만 모아 전체 완료율(%)을 계산한다.
|
||||||
|
|
||||||
|
|||||||
@@ -729,6 +729,42 @@ label {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.week-matrix {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -32,8 +32,11 @@
|
|||||||
{% for d in week %}
|
{% for d in week %}
|
||||||
{% set summary = summary_map.get(d) %}
|
{% set summary = summary_map.get(d) %}
|
||||||
<div
|
<div
|
||||||
class="calendar-cell{{ '' if d.month == month else ' muted' }}"
|
class="calendar-cell clickable{{ '' if d.month == month else ' muted' }}"
|
||||||
style="background: rgba(var(--color-accent-rgb), {{ heatmap_opacity(summary.checked_count, summary.scheduled_count) if summary else 0 }});"
|
style="background: rgba(var(--color-accent-rgb), {{ heatmap_opacity(summary.checked_count, summary.scheduled_count) if summary else 0 }});"
|
||||||
|
hx-get="/history/day/{{ d.isoformat() }}"
|
||||||
|
hx-target="#day-detail"
|
||||||
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
<span class="calendar-date">{{ d.day }}</span>
|
<span class="calendar-date">{{ d.day }}</span>
|
||||||
{% if summary and summary.scheduled_count %}
|
{% if summary and summary.scheduled_count %}
|
||||||
@@ -45,6 +48,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="day-detail"></div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="overflow-x:auto;">
|
<div class="card" style="overflow-x:auto;">
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom: var(--space-2);">
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% 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;">{{ log_date.strftime("%Y.%m.%d") }} ({{ weekday_names[log_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="day-detail-item">
|
||||||
|
<span>
|
||||||
|
{{ item.name }}
|
||||||
|
<span class="badge">{{ "형성" if item.habit_type == "build" else "중단" }}</span>
|
||||||
|
</span>
|
||||||
|
{% if item.status == "checked" %}
|
||||||
|
<span class="badge day-detail-status-checked">완료</span>
|
||||||
|
{% elif item.status == "failed" %}
|
||||||
|
<span class="badge day-detail-status-missed">실패</span>
|
||||||
|
{% elif item.status == "missed" %}
|
||||||
|
<span class="badge day-detail-status-missed">미완료</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">예정</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">이 날 예정된 습관이 없어요.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -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
|
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: 미래 날짜 제외 회귀 테스트 ----
|
# ---- summarize_completion_rate: 미래 날짜 제외 회귀 테스트 ----
|
||||||
# CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다
|
# CLAUDE.md에 기록된 실제 버그: 미래 날짜를 포함시키면 완료율이 부당하게 낮게 나온다
|
||||||
# (실제로 6.2% -> 수정 후 50%가 된 사례).
|
# (실제로 6.2% -> 수정 후 50%가 된 사례).
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user