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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user