feat(study): Phase 2-E 복습함 멀티 셀렉트 → 복습 세션

복습함 카드 단위 체크박스 + sticky bottom bar 로 N개 골라 한 quiz_session.
backend QuizSessionStartRequest 에 question_ids 파라미터 추가 — 우선순위
stage > question_ids > 기존 subject 경로. 명시되면 selection 우회 + 검증
(user × topic 소속 + 미삭제 + 최대 200 + 중복 제거 순서 보존).

Backend:
- question_ids: list[int] | None — Field 한도 200
- valid_set 검증: 다른 user/topic 또는 deleted_at 인 qid 는 silent drop
- subject_distribution 자동 계산 (결과 카드용)
- 빈 wanted / 무효 qid → 400

Frontend (review-queue 페이지):
- 카드 좌측 체크박스 (분리 영역, 본문 클릭은 기존대로 문제 페이지)
- "이 페이지 전체 선택 / 해제" 토글
- 선택 N>0 시 sticky bottom bar — `{N}개 풀이 시작` 버튼
- 탭 변경 시 선택 초기화 (다른 의도 묶음 가능성)
- 페이지 이동 시 선택 유지 (Set<question_id>)
- 진행 중 in_progress 세션 있으면 confirm 후 abandon
- 200 한도 도달 시 toast 경고

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-01 10:39:46 +09:00
parent d39882c38e
commit f42f6ff480
2 changed files with 158 additions and 2 deletions
+38
View File
@@ -1242,6 +1242,9 @@ class QuizSessionStartRequest(BaseModel):
# 우회하고 select_questions_for_quiz 사용. size 가 명시되면 size 만큼 출제 (subject 무관).
stage: str | None = Field(default=None, pattern="^(intro|learning|pre_exam)$")
size: int | None = Field(default=None, ge=1, le=200)
# Phase 2-E: 복습함에서 명시 선택된 문제로 세션. 우선순위 stage > question_ids > 기존 경로.
# 출제 순서는 클라이언트가 보낸 그대로 보존 (사용자 인지 순서). 한도 200, 중복 제거됨.
question_ids: list[int] | None = Field(default=None)
class QuizSessionSummary(BaseModel):
@@ -1572,6 +1575,7 @@ async def start_quiz_session(
await session.flush()
# Phase 1-E: stage 명시 시 bucket + 비율 기반 선별 (단일 풀이 진입점 vision).
# Phase 2-E: question_ids 명시 시 복습함 multi-select 경로 — 검증만 + 순서 보존.
# stage 미명시 시 기존 subject bucket + spacing (PR-12-B 호환).
if body.stage is not None:
from services.study.quiz_selection import select_questions_for_quiz
@@ -1588,6 +1592,40 @@ async def start_quiz_session(
raise HTTPException(status_code=400, detail=str(e))
if not qids:
raise HTTPException(status_code=400, detail="출제 가능한 문제가 없습니다")
elif body.question_ids is not None:
# 중복 제거 (순서 보존)
seen: set[int] = set()
wanted: list[int] = []
for qid in body.question_ids:
if qid not in seen:
seen.add(qid)
wanted.append(qid)
if not wanted:
raise HTTPException(status_code=400, detail="문제를 1개 이상 선택해주세요")
if len(wanted) > 200:
raise HTTPException(status_code=400, detail="한 세션에 최대 200문제까지 선택 가능합니다")
# 유효성 검증 — user × topic 소속 + 미삭제. soft-deleted 또는 다른 user/topic 의 qid 는 제거.
valid_rows = (
await session.execute(
select(StudyQuestion.id, StudyQuestion.subject)
.where(
StudyQuestion.id.in_(wanted),
StudyQuestion.user_id == user.id,
StudyQuestion.study_topic_id == topic_id,
StudyQuestion.deleted_at.is_(None),
)
)
).all()
valid_set = {r.id for r in valid_rows}
qids = [qid for qid in wanted if qid in valid_set]
if not qids:
raise HTTPException(status_code=400, detail="선택한 문제가 이 주제에 존재하지 않습니다")
# subject distribution 계산 (결과 카드 통계용)
subject_by_id = {r.id: (r.subject or "(미분류)") for r in valid_rows}
distribution: dict[str, int] = {}
for qid in qids:
sub = subject_by_id.get(qid, "(미분류)")
distribution[sub] = distribution.get(sub, 0) + 1
else:
# 기존 PR-12-B 경로: subject bucket + type spacing.
apply_spacing = body.quiz_mode == QuizMode.random
@@ -13,12 +13,15 @@
import { addToast } from '$lib/stores/toast';
import {
ArrowLeft, AlertCircle, Clock, RotateCcw, Repeat2, CheckCircle2, ChevronLeft, ChevronRight,
Play, Square, CheckSquare,
} from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
const MAX_PER_SESSION = 200;
const TABS = [
{ key: 'due_today', label: '오늘 할 일', icon: Clock, accent: 'text-warning' },
{ key: 'pending_review', label: '미확인', icon: AlertCircle, accent: 'text-error' },
@@ -39,6 +42,10 @@
let loading = $state(false);
let counts = $state({}); // { tab: total }
// 멀티 셀렉트 (현재 페이지 보이는 카드 기준 선택. 페이지 이동 시 유지).
let selected = $state(new Set()); // Set<number> — question_id
let starting = $state(false);
async function loadTopic() {
try {
const t = await api(`/study-topics/${topicId}`);
@@ -81,6 +88,7 @@
if (activeTab === key) return;
activeTab = key;
currentPage = 1;
selected = new Set(); // 탭 바뀌면 선택 초기화 — 다른 의도의 묶음일 가능성
// URL 동기 — replaceState 로 history 폭발 방지
const url = new URL($page.url);
url.searchParams.set('tab', key);
@@ -89,6 +97,71 @@
void loadTab();
}
function toggleSelect(qid) {
const next = new Set(selected);
if (next.has(qid)) next.delete(qid);
else {
if (next.size >= MAX_PER_SESSION) {
addToast('warning', `한 세션에 최대 ${MAX_PER_SESSION}문제까지 선택 가능합니다`);
return;
}
next.add(qid);
}
selected = next;
}
function toggleSelectVisible() {
const visibleIds = items.map((it) => it.question_id);
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
const next = new Set(selected);
if (allSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
for (const id of visibleIds) {
if (next.size >= MAX_PER_SESSION) break;
next.add(id);
}
}
selected = next;
}
function clearSelection() {
selected = new Set();
}
/** 선택된 N개로 quiz_session 시작. 기존 in_progress 있으면 confirm 후 abandon. */
async function startSelectedSession() {
if (selected.size === 0) return;
if (starting) return;
// 기존 in_progress 가 있는지 확인 — 토픽 페이지의 startNewQuiz 패턴 참고
let hasActive = false;
try {
const sessions = await api(`/study-topics/${topicId}/quiz-sessions?limit=1`);
hasActive = !!sessions?.active;
} catch {}
if (hasActive) {
if (!confirm(
`진행 중인 문제풀이 세션이 있습니다. 새로 시작하면 그 세션은 포기됩니다. 계속할까요?`,
)) return;
}
starting = true;
try {
const qids = Array.from(selected);
const res = await api(`/study-topics/${topicId}/quiz-sessions`, {
method: 'POST',
body: JSON.stringify({
question_ids: qids,
quiz_mode: 'random',
abandon_existing: hasActive,
}),
});
goto(`/study/topics/${topicId}/review?session=${res.id}`);
} catch (err) {
addToast('error', err?.detail || '풀이 시작 실패');
starting = false;
}
}
function setPage(p) {
if (p < 1 || (p - 1) * PAGE_SIZE >= total) return;
currentPage = p;
@@ -151,6 +224,9 @@
}
let lastPage = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE)));
let allVisibleSelected = $derived(
items.length > 0 && items.every((it) => selected.has(it.question_id))
);
</script>
<svelte:head><title>복습함 — {topicName || '주제'}</title></svelte:head>
@@ -204,13 +280,39 @@
}
/>
{:else}
<!-- 일괄 선택 동선 — 페이지 단위 -->
<div class="flex items-center gap-2 text-xs">
<button
type="button"
onclick={toggleSelectVisible}
class="flex items-center gap-1.5 text-dim hover:text-text"
>
{#if allVisibleSelected}<CheckSquare size={14} />{:else}<Square size={14} />{/if}
<span>이 페이지 {allVisibleSelected ? '선택 해제' : '전체 선택'} ({items.length})</span>
</button>
{#if selected.size > 0}
<span class="text-dim">·</span>
<button type="button" onclick={clearSelection} class="text-dim hover:text-text">선택 모두 해제</button>
{/if}
</div>
<ul class="flex flex-col gap-2">
{#each items as it (it.question_id)}
{@const ob = outcomeBadge(it.last_outcome)}
<li>
{@const checked = selected.has(it.question_id)}
<li class="rounded border bg-surface flex items-stretch
{checked ? 'border-accent/60 bg-accent/5' : 'border-default'}">
<button
type="button"
onclick={() => toggleSelect(it.question_id)}
aria-label={checked ? '선택 해제' : '선택'}
class="px-3 flex items-center hover:bg-bg/30 transition-colors border-r border-default"
>
{#if checked}<CheckSquare size={16} class="text-accent" />{:else}<Square size={16} class="text-dim" />{/if}
</button>
<a
href={`/study/topics/${topicId}/questions/${it.question_id}`}
class="block rounded border border-default bg-surface p-3 hover:bg-bg/30 transition-colors"
class="flex-1 min-w-0 p-3 hover:bg-bg/30 transition-colors"
>
<div class="text-sm text-text line-clamp-2">{it.question_text}</div>
<div class="text-[11px] text-dim mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
@@ -255,3 +357,19 @@
{/snippet}
</Card>
</div>
<!-- Phase 2-E: 선택 N개 풀이 시작 sticky bar -->
{#if selected.size > 0}
<div class="fixed bottom-0 inset-x-0 z-30 border-t border-accent/40 bg-surface/95 backdrop-blur p-3 flex items-center gap-3 max-w-3xl mx-auto sm:rounded-t-lg sm:bottom-2 sm:border sm:left-2 sm:right-2">
<span class="text-sm text-text">
<span class="font-semibold text-accent">{selected.size}</span> 개 선택됨
{#if selected.size >= MAX_PER_SESSION}
<span class="text-xs text-warning ml-1">(최대)</span>
{/if}
</span>
<button type="button" onclick={clearSelection} class="text-xs text-dim hover:text-text">해제</button>
<Button class="ml-auto" size="sm" icon={Play} loading={starting} onclick={startSelectedSession}>
{selected.size}개 풀이 시작
</Button>
</div>
{/if}