feat(study): Phase 2-F due_at 정체 정리 — overdue redistribute

사용자가 며칠 안 들어오면 due_today 가 누적되어 학습 페이스 압박. Phase 1
plan 위험 항목 처리. 자동 batch 대신 사용자 명시 액션으로 통제권 보장.

Backend:
- POST /study-topics/{tid}/review-queue/redistribute — overdue 를 round-robin
  분산. days_offset = i % spread_days + 1 (오늘 + 1~7일). 같은 날 안에서도
  i*7분 spread 로 시간 분산. review_stage 는 보존 (재배치만, stage 리셋 X).
  body { spread_days: 1~14, default 7 }. 응답 { redistributed_count, spread_days }.
- GET /review-queue?tab=due_today 응답에 overdue_count: int 옵션 필드 — UI 가
  경고 + [정리] 노출 판단. due_at < today 0시 (UTC) + stage<4 카운트.

Frontend (review-queue):
- due_today 탭에서 overdue_count>0 시 노란 banner — "정체 N건" + [정리] 버튼.
- 정리 클릭 → confirm → POST → toast (N건을 7일에 분산) → 카운트/목록 reload.
- 다른 탭에서는 banner 미노출 (backend 가 overdue_count=0 응답).

Plan: ~/.claude/plans/crispy-petting-dijkstra.md (Phase 2-F)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-01 10:48:00 +09:00
parent f42f6ff480
commit 711b81f8f0
2 changed files with 131 additions and 0 deletions
+94
View File
@@ -102,6 +102,9 @@ class ReviewQueueResponse(BaseModel):
items: list[ReviewQueueItem]
page: int
page_size: int
# Phase 2-F: due_today 탭에서만 채움. due_at < today 0시 (UTC) + stage < 4.
# UI 가 "정체 N건" 경고 + [정리] 버튼 노출 판단에 사용.
overdue_count: int = 0
def _truncate(text: str, n: int = 80) -> str:
@@ -210,8 +213,99 @@ async def review_queue(
for (p, q) in rows
]
# Phase 2-F: due_today 탭일 때 overdue 카운트 (오늘 0시 UTC 이전 due) — UI 경고 노출용
overdue_count = 0
if tab == "due_today":
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
overdue_row = await session.execute(
select(func.count())
.select_from(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at < today_start,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
)
overdue_count = int(overdue_row.scalar() or 0)
return ReviewQueueResponse(
tab=tab, total=total, items=items, page=page, page_size=page_size,
overdue_count=overdue_count,
)
# ─── redistribute (Phase 2-F due_at 정체 정리) ───
class RedistributeRequest(BaseModel):
spread_days: int = 7 # 1~14 일 사이. default 7.
class RedistributeResponse(BaseModel):
redistributed_count: int
spread_days: int
@router.post(
"/{topic_id}/review-queue/redistribute", response_model=RedistributeResponse
)
async def redistribute_overdue(
topic_id: int,
body: RedistributeRequest,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""overdue (due_at < today 0시 UTC + stage < 4) 를 내일~spread_days 일에 round-robin 분산.
동작:
- 오늘 0시 이전에 due 된 항목 모두 fetch (오래된 순)
- i % spread_days + 1 일 후 자정 + i*7분 (분산용 분단위) 로 due_at 갱신
- review_stage 는 건드리지 않음 (정체 처리는 시간 재배치만)
"""
if not (1 <= body.spread_days <= 14):
raise HTTPException(status_code=400, detail="spread_days 는 1~14 사이여야 합니다")
topic = await session.get(StudyTopic, topic_id)
_verify_topic_owner(topic, user)
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
overdue = (
await session.execute(
select(StudyQuestionProgress)
.where(
StudyQuestionProgress.user_id == user.id,
StudyQuestionProgress.study_topic_id == topic_id,
StudyQuestionProgress.due_at.is_not(None),
StudyQuestionProgress.due_at < today_start,
or_(
StudyQuestionProgress.review_stage.is_(None),
StudyQuestionProgress.review_stage < 4,
),
)
.order_by(StudyQuestionProgress.due_at.asc())
)
).scalars().all()
if not overdue:
return RedistributeResponse(redistributed_count=0, spread_days=body.spread_days)
base_day = today_start # 오늘 0시 기준 — +1일부터 분산
for i, p in enumerate(overdue):
days_offset = (i % body.spread_days) + 1
# 같은 날 안에서도 분산하려고 i*7분 추가 (200건 까지 24시간 안에 겹침 없이 spread)
minute_offset = (i * 7) % (24 * 60)
p.due_at = base_day + timedelta(days=days_offset, minutes=minute_offset)
await session.commit()
return RedistributeResponse(
redistributed_count=len(overdue), spread_days=body.spread_days
)
@@ -41,10 +41,12 @@
let items = $state([]);
let loading = $state(false);
let counts = $state({}); // { tab: total }
let overdueCount = $state(0);
// 멀티 셀렉트 (현재 페이지 보이는 카드 기준 선택. 페이지 이동 시 유지).
let selected = $state(new Set()); // Set<number> — question_id
let starting = $state(false);
let redistributing = $state(false);
async function loadTopic() {
try {
@@ -75,15 +77,38 @@
);
total = r?.total ?? 0;
items = r?.items ?? [];
overdueCount = r?.overdue_count ?? 0;
} catch (err) {
addToast('error', err?.detail || '복습함 조회 실패');
total = 0;
items = [];
overdueCount = 0;
} finally {
loading = false;
}
}
async function redistributeOverdue() {
if (overdueCount === 0 || redistributing) return;
if (!confirm(
`정체된 복습 ${overdueCount}건을 1~7일에 걸쳐 균등 분산합니다. 학습 단계는 그대로 유지됩니다. 진행할까요?`,
)) return;
redistributing = true;
try {
const res = await api(`/study-topics/${topicId}/review-queue/redistribute`, {
method: 'POST',
body: JSON.stringify({ spread_days: 7 }),
});
addToast('success', `${res.redistributed_count}건을 ${res.spread_days}일에 분산했습니다`);
// 카운트 + 목록 새로고침
await Promise.all([loadCounts(), loadTab()]);
} catch (err) {
addToast('error', err?.detail || '정리 실패');
} finally {
redistributing = false;
}
}
function setTab(key) {
if (activeTab === key) return;
activeTab = key;
@@ -247,6 +272,18 @@
<div class="p-5 flex flex-col gap-4">
<h1 class="text-base font-semibold text-text">복습함</h1>
<!-- Phase 2-F: due_today 탭에서만 정체 경고 + 정리 버튼 -->
{#if activeTab === 'due_today' && overdueCount > 0}
<div class="rounded border border-warning/40 bg-warning/5 p-3 text-xs text-text flex items-center gap-3 flex-wrap">
<AlertCircle size={14} class="text-warning shrink-0" />
<div class="flex-1 min-w-0">
<div>오늘 할 일 중 <span class="text-warning font-medium">{overdueCount}</span>이 어제 이전부터 정체된 복습입니다.</div>
<div class="text-dim mt-0.5">1~7일에 균등 분산해서 페이스를 회복할 수 있습니다.</div>
</div>
<Button size="sm" variant="ghost" loading={redistributing} onclick={redistributeOverdue}>정리</Button>
</div>
{/if}
<!-- 탭 -->
<div class="flex flex-wrap gap-1 border-b border-default">
{#each TABS as t}