feat(study): 이론공부 홈 — 오늘의 개념·진도·회독 SR (Stage S)
개념문서(가스기사 289) 소비 표면 개선 1단계. /study 허브를 데일리 랜딩으로.
- 마이그 381 study_concept_progress (개념 SR, sr_schedule 공용, documents FK 없음=락 회피)
- concept_curriculum 서비스 + /api/study (curriculum·today-concepts·concepts/{id}/read)
- read 상태 정본 = document_reads (is_read 컬럼 아님), mark_read=회독+SR 입고
- 문제풀이 표면 무접촉·additive
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,52 @@
|
||||
<script>
|
||||
// /study — 학습 hub.
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
// /study — 학습 hub + 데일리 랜딩('오늘의 공부' 대시보드).
|
||||
// 상단 = 이론 홈(진도·오늘의 개념·복습 due, 재노출 트리거). 하단 = 기존 모드 진입.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity } from 'lucide-svelte';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
let questionFlagCount = $state(0);
|
||||
|
||||
// 오늘의 공부 (이론 홈)
|
||||
let curriculum = $state(null);
|
||||
let todayConcepts = $state([]);
|
||||
let dashLoading = $state(true);
|
||||
|
||||
let readPct = $derived(
|
||||
curriculum && curriculum.total ? Math.round((curriculum.read / curriculum.total) * 100) : 0
|
||||
);
|
||||
|
||||
async function loadDashboard() {
|
||||
dashLoading = true;
|
||||
try {
|
||||
const [cur, today] = await Promise.all([
|
||||
api('/study/curriculum'),
|
||||
api('/study/today-concepts?limit=6'),
|
||||
]);
|
||||
curriculum = cur;
|
||||
todayConcepts = today?.concepts ?? [];
|
||||
} catch {
|
||||
// 대시보드 실패해도 허브 나머지는 동작 (조용히)
|
||||
} finally {
|
||||
dashLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(doc) {
|
||||
try {
|
||||
await api(`/study/concepts/${doc.doc_id}/read`, { method: 'POST' });
|
||||
todayConcepts = todayConcepts.filter((c) => c.doc_id !== doc.doc_id);
|
||||
addToast('success', `회독: ${doc.title}`);
|
||||
loadDashboard(); // 진도 갱신
|
||||
} catch {
|
||||
addToast('error', '회독 처리 실패');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
loadDashboard();
|
||||
try {
|
||||
const r = await api('/study-cards/needs-review/count');
|
||||
cardReviewCount = r?.count ?? 0;
|
||||
@@ -27,6 +66,64 @@
|
||||
<p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p>
|
||||
</header>
|
||||
|
||||
<!-- 오늘의 공부 (이론 홈 대시보드 = 데일리 트리거) -->
|
||||
<section class="mb-5 rounded-lg border border-default bg-surface p-4 md:p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<CalendarCheck size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">오늘의 공부</h2>
|
||||
{#if curriculum}
|
||||
<span class="ml-auto text-xs text-dim">이론 회독 <span class="text-text font-medium">{curriculum.read}</span> / {curriculum.total} ({readPct}%)</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dashLoading}
|
||||
<p class="text-xs text-dim">불러오는 중…</p>
|
||||
{:else}
|
||||
{#if curriculum}
|
||||
<div class="h-2 rounded-full bg-bg overflow-hidden mb-3">
|
||||
<div class="h-full bg-accent" style="width: {readPct}%"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-xs text-dim">
|
||||
{#each curriculum.subjects as s}
|
||||
<span>{s.subject} <span class="text-text">{s.read}/{s.total}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<a
|
||||
href="/study/topics/{curriculum.topic_id}/review-queue"
|
||||
class="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-dim hover:border-accent hover:text-text transition-colors"
|
||||
>
|
||||
<Repeat size={13} /> 문항 복습 <span class="font-semibold text-text">{curriculum.question_due}</span>
|
||||
</a>
|
||||
<span class="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-dim">
|
||||
<BookOpen size={13} /> 개념 재복습 <span class="font-semibold text-text">{curriculum.concept_due}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-xs text-dim mb-2">오늘의 개념</div>
|
||||
{#if todayConcepts.length === 0}
|
||||
<p class="text-xs text-dim">오늘 볼 개념이 없습니다. 잘 하고 있어요.</p>
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each todayConcepts as c (c.doc_id)}
|
||||
<li class="flex items-center gap-2 rounded border border-default px-3 py-2">
|
||||
<span class="text-accent shrink-0 text-xs" title="빈출">{#each Array(c.freq) as _}★{/each}</span>
|
||||
<a href="/documents/{c.doc_id}" class="text-sm text-text hover:text-accent truncate flex-1">{c.title}</a>
|
||||
<span class="shrink-0 text-[10px] rounded-full px-2 py-0.5 {c.reason === '재복습' ? 'bg-accent/15 text-accent' : 'bg-surface border border-default text-dim'}">{c.reason}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => markRead(c)}
|
||||
class="shrink-0 text-xs rounded border border-default px-2 py-1 text-dim hover:border-accent hover:text-accent transition-colors"
|
||||
>읽음</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<a
|
||||
href="/study/topics"
|
||||
class="block mb-3 p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
@@ -126,7 +223,8 @@
|
||||
<div class="mt-6 p-4 rounded-lg border border-dashed border-default/60 text-xs text-dim">
|
||||
<div class="font-medium text-dim mb-1">예정</div>
|
||||
<ul class="list-disc list-inside space-y-0.5">
|
||||
<li>애플워치 빠른복습 + 공부 알람(push)</li>
|
||||
<li>개념 학습 리더 (가리고 떠올리기 · 빈출★ · 관련개념 백링크)</li>
|
||||
<li>이론↔문제 연결 (개념별 정답률 · 약점 개념 지도)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user