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}