From 711b81f8f09122278d5e076f362e7b4273df6915 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Fri, 1 May 2026 10:48:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20Phase=202-F=20due=5Fat=20?= =?UTF-8?q?=EC=A0=95=EC=B2=B4=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20overdue?= =?UTF-8?q?=20redistribute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 며칠 안 들어오면 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) --- app/api/study_question_progress.py | 94 +++++++++++++++++++ .../topics/[id]/review-queue/+page.svelte | 37 ++++++++ 2 files changed, 131 insertions(+) diff --git a/app/api/study_question_progress.py b/app/api/study_question_progress.py index b349ebe..0849d3c 100644 --- a/app/api/study_question_progress.py +++ b/app/api/study_question_progress.py @@ -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 ) diff --git a/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte b/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte index adced28..3de59ae 100644 --- a/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/review-queue/+page.svelte @@ -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 — 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 @@

복습함

+ + {#if activeTab === 'due_today' && overdueCount > 0} +
+ +
+
오늘 할 일 중 {overdueCount}건이 어제 이전부터 정체된 복습입니다.
+
1~7일에 균등 분산해서 페이스를 회복할 수 있습니다.
+
+ +
+ {/if} +
{#each TABS as t}