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