feat(ui): 홈 대시보드 데일리 홈 cockpit 재설계 (안1 골격+안2 위젯+안3 분포)

확정 시안 dashboard-sage-3 의 권장 합성(안1 데일리 홈 골격 + 안2 검토/파이프라인
위젯 + 안3 도메인 분포 한 줄)으로 콘텐츠 재구조화. F1 세이지 테마 위 레이아웃 개편.
- 인사 헤더 + 오늘 요약 띠(검토 대기 + 디제스트 톱 + 스탯 띠)
- 2열: 좌(빠른 캡처·활동 타임라인) / 우(학습·도메인 분포+파이프라인 칩·고정)
- digest/도메인 분포는 기존 엔드포인트 wiring(백엔드 변경 0), 학습 streak는 링크형 degrade

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 16:57:34 +09:00
parent 4001949d78
commit 3a865978af
+312 -399
View File
@@ -1,7 +1,7 @@
<script lang="ts">
// 대시보드 — 상황판. 사용자 지시서 기반 재설계.
// 정보 위계: 헤더 → 핀 메모 → 카드 4개 → 최근 활동 → 파이프라인.
// 단일 흐름 레이아웃, 모바일 우선, 행동 유도는 승인 대기에만.
// 대시보드 — 데일리 홈 cockpit (확정 시안 dashboard-sage-3 안1 골격 + 안2 검토/파이프라인 위젯 + 안3 도메인 분포 한 줄).
// 정보 흐름: 인사 → 오늘 요약 띠(검토 대기 + 디제스트 + 스탯) → 2열(좌: 빠른 캡처·활동 / 우: 학습·도메인 분포·고정).
// 데이터는 전부 기존 엔드포인트 wiring(백엔드 변경 0). 학습 streak/복습 마감은 전용 엔드포인트 부재라 링크형으로 degrade.
import { onMount } from 'svelte';
import {
dashboardSummary,
@@ -10,67 +10,113 @@
type QueueLag,
} from '$lib/stores/system';
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
import { user } from '$lib/stores/auth';
import { api } from '$lib/api';
import Card from '$lib/components/ui/Card.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import {
Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil,
Library, Mic, Video, Sparkles,
Scale, FileText, Pin, ChevronRight, GraduationCap, Upload, Newspaper,
} from 'lucide-svelte';
import { renderMemoHtml, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
import { addToast } from '$lib/stores/toast';
let summary = $derived<DashboardSummary | null>($dashboardSummary);
let loading = $derived(summary === null);
// ─── 핀 고정 메모 ───
let pinnedMemos = $state<any[]>([]);
// 메모별 "완료 항목 펼침" 토글 — key: memo.id, value: true 면 숨겨진 체크 항목 노출
let showHiddenByMemo = $state<Record<number, boolean>>({});
// 자동 숨김 tick. 1초 해상도로 충분 (hideAfter 10초라 오차 수용).
let nowTick = $state(new Date());
// ─── 인사 헤더 ───
const greetingName = $derived($user?.username ?? 'hyungi');
const todayLabel = new Intl.DateTimeFormat('ko-KR', {
year: 'numeric', month: 'long', day: 'numeric', weekday: 'long',
}).format(new Date());
$effect(() => {
const id = setInterval(() => { nowTick = new Date(); }, 1000);
return () => clearInterval(id);
});
// ─── 디제스트 헤드라인 (best-effort, 기존 /digest) ───
interface DigestLead {
topic_label: string;
article_count: number;
importance_score: number;
country: string;
date: string;
}
let digestLead = $state<DigestLead | null>(null);
onMount(async () => {
const COUNTRY_KO: Record<string, string> = {
KR: '한국', JP: '일본', US: '미국', CN: '중국', DE: '독일',
FR: '프랑스', GB: '영국', TW: '대만',
};
function countryKo(c: string): string {
return COUNTRY_KO[c?.toUpperCase?.()] ?? c ?? '';
}
// ─── 도메인 분포 (best-effort, 기존 /documents/tree) ───
interface DomainDist { name: string; count: number; }
let domainDist = $state<DomainDist[]>([]);
let domainTotal = $derived(domainDist.reduce((s, d) => s + d.count, 0));
function domainCount(slugLike: string): number {
// domainBgClass 와 동일 매핑 기준으로 특정 도메인 건수 추출 (스탯 띠용)
const target = domainBgClass(slugLike);
return domainDist.find((d) => domainBgClass(d.name) === target)?.count ?? 0;
}
// ─── 빠른 캡처 (기존 POST /memos) ───
let captureText = $state('');
let capturing = $state(false);
async function quickCapture() {
const content = captureText.trim();
if (!content || capturing) return;
capturing = true;
try {
const res = await api<any>('/memos/?pinned=true&page_size=3&archived=false');
pinnedMemos = res.items || [];
} catch { /* 실패 시 빈 배열 유지 */ }
});
// ─── 핀 메모 체크박스 토글 ───
async function handlePinCheckbox(e: MouseEvent, memo: any) {
const target = e.target as HTMLElement;
if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return;
e.preventDefault();
e.stopPropagation(); // details 토글 충돌 방지
const input = target as HTMLInputElement;
const taskIndex = parseInt(input.dataset.taskIndex || '', 10);
if (isNaN(taskIndex)) return;
const checked = input.checked;
try {
const updated = await api<any>(`/memos/${memo.id}/tasks/${taskIndex}`, {
method: 'PATCH',
body: JSON.stringify({ checked }),
});
pinnedMemos = pinnedMemos.map((m) => (m.id === memo.id ? updated : m));
await api('/memos/', { method: 'POST', body: JSON.stringify({ content }) });
captureText = '';
addToast('success', '메모 저장됨');
} catch {
input.checked = !checked; // 롤백
addToast('error', '체크박스 변경 실패');
addToast('error', '메모 저장 실패');
} finally {
capturing = false;
}
}
function toggleShowHidden(memoId: number) {
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
function onCaptureKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void quickCapture(); }
}
// ─── 파이프라인 ───
// ─── 핀 고정 메모 (기존 /memos?pinned) ───
let pinnedMemos = $state<any[]>([]);
function pinTitle(memo: any): string {
const firstLine = memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').trim();
return memo.title || firstLine || '메모';
}
onMount(async () => {
// 핀 메모
try {
const res = await api<any>('/memos/?pinned=true&page_size=4&archived=false');
pinnedMemos = res.items || [];
} catch { /* 빈 배열 유지 */ }
// 디제스트 최신 — countries→topics flatten 후 중요도 desc(동률 시 기사수 desc) top
try {
const d = await api<any>('/digest');
const topics = (d.countries || []).flatMap((c: any) =>
(c.topics || []).map((t: any) => ({ ...t, country: c.country })));
topics.sort((a: any, b: any) =>
(b.importance_score - a.importance_score) || (b.article_count - a.article_count));
if (topics[0]) {
digestLead = {
topic_label: topics[0].topic_label,
article_count: topics[0].article_count,
importance_score: topics[0].importance_score,
country: topics[0].country,
date: d.date,
};
}
} catch { /* 디제스트 없으면 블록 자동 생략 */ }
// 도메인 분포 — 트리 top-level 노드 건수
try {
const tree = await api<any[]>('/documents/tree');
domainDist = (tree || [])
.map((n) => ({ name: n.name as string, count: n.count as number }))
.sort((a, b) => b.count - a.count);
} catch { /* 분포 없으면 카드 자동 생략 */ }
});
// ─── 파이프라인 (기존 로직 재사용, 칩 요약 + 상세 접힘) ───
const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const;
const STAGE_LABEL: Record<string, string> = {
extract: '추출', stt: '전사', classify: '분류', embed: '임베딩',
@@ -80,13 +126,10 @@
interface PipelineRow {
stage: string; label: string;
pending: number; processing: number; failed: number; total: number;
// §4 — queue_lag 의 oldest_pending_age_sec (적체 신호용)
oldestPendingAgeSec: number | null;
}
function buildPipelineRows(items: PipelineStatus[], lag: QueueLag[]): PipelineRow[] {
// §4 — 24h 누적 (pipeline_status) + 현재 시점 lag (queue_lag) 두 소스 머지.
// queue_lag 가 있으면 stage 별 pending/processing/failed 는 그쪽 (정확) 사용.
const lagMap = new Map(lag.map((l) => [l.stage, l]));
const grouped = new Map<string, { pending: number; processing: number; failed: number; ageSec: number | null }>();
for (const it of items) {
@@ -96,14 +139,12 @@
else if (it.status === 'failed') cur.failed += it.count;
grouped.set(it.stage, cur);
}
// queue_lag 로 덮어쓰기 (현재 시점 신호가 우선)
for (const l of lag) {
grouped.set(l.stage, {
pending: l.pending, processing: l.processing, failed: l.failed,
ageSec: l.oldest_pending_age_sec,
});
}
// queue_lag 만 있는 stage 도 전부 포함
const allStages = new Set([...grouped.keys(), ...lagMap.keys()]);
const orderedStages = [
...STAGE_ORDER.filter((s) => allStages.has(s)),
@@ -126,13 +167,10 @@
let pipelineMax = $derived(Math.max(1, ...pipelineRows.map((r) => r.total)));
let totalFailed = $derived(summary?.failed_count ?? 0);
let totalPending = $derived(pipelineRows.reduce((s, r) => s + r.pending, 0));
let totalProcessing = $derived(pipelineRows.reduce((s, r) => s + r.processing, 0));
// §4 — 카테고리 mini-card 데이터
const CATEGORY_CARDS: { key: string; label: string; href: string; icon: any }[] = [
{ key: 'library', label: '자료실', href: '/library', icon: Library },
{ key: 'audio', label: '오디오', href: '/audio', icon: Mic },
{ key: 'video', label: '비디오', href: '/video', icon: Video },
];
let pipelineManualClosed = $state(false);
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
function formatAge(sec: number | null): string {
if (sec == null || sec <= 0) return '';
@@ -142,21 +180,6 @@
return `${Math.floor(sec / 86400)}일 전`;
}
// 파이프라인 접힘 상태
let pipelineManualClosed = $state(false);
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
// ─── 시스템 상태 ───
function pickSystemTone(s: DashboardSummary) {
if (s.failed_count > 0) return { label: `실패 ${s.failed_count}`, tone: 'error' as const };
const backlog = s.pipeline_status.some((p) => p.status === 'pending' && p.count > 10);
if (backlog) return { label: '대기열 적체', tone: 'warning' as const };
return { label: '정상', tone: 'success' as const };
}
const TONE_DOT: Record<string, string> = { success: 'bg-success', warning: 'bg-warning', error: 'bg-error' };
const TONE_TEXT: Record<string, string> = { success: 'text-success', warning: 'text-warning', error: 'text-error' };
let systemView = $derived(summary ? pickSystemTone(summary) : null);
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const diff = Date.now() - d.getTime();
@@ -168,351 +191,251 @@
}
</script>
<div class="p-4 lg:p-6">
<div class="max-w-4xl mx-auto">
<div class="p-4 lg:p-8">
<div class="max-w-5xl mx-auto">
<!-- ═══ 1. 헤더 + 시스템 상태 ═══ -->
<div class="flex items-center justify-between mb-5">
<h2 class="text-xl font-bold text-text">대시보드</h2>
{#if systemView}
<span class="text-xs text-dim flex items-center gap-1.5 select-none">
{systemView.label}
<span class="w-2 h-2 rounded-full {TONE_DOT[systemView.tone]}"></span>
</span>
{/if}
<!-- ═══ 인사 헤더 ═══ -->
<div class="flex items-baseline gap-2.5 flex-wrap">
<h1 class="text-2xl font-bold text-text tracking-tight">안녕하세요, {greetingName}</h1>
<span class="text-sm text-dim">오늘도 지식 쌓는 날.</span>
</div>
<div class="text-xs text-faint mt-1 mb-6 tracking-wide">{todayLabel}</div>
{#if loading}
<!-- 스켈레톤 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
{#each Array(4) as _}
<Card><Skeleton w="w-20" h="h-3" /><Skeleton w="w-16" h="h-8" class="mt-3" /><Skeleton w="w-24" h="h-3" class="mt-2" /></Card>
{/each}
<div class="bg-surface border border-default rounded-card p-5 mb-5">
<Skeleton w="w-40" h="h-10" />
<Skeleton w="w-full" h="h-4" class="mt-4" />
<Skeleton w="w-2/3" h="h-4" class="mt-2" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5">
<div class="space-y-5">
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-10" class="mt-3" /></div>
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-40" class="mt-3" /></div>
</div>
<div class="space-y-5">
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-full" h="h-24" /></div>
<div class="bg-surface border border-default rounded-card p-5"><Skeleton w="w-full" h="h-32" /></div>
</div>
</div>
<Card class="mb-4"><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-40" class="mt-3" /></Card>
<Card><Skeleton w="w-24" h="h-4" /><Skeleton w="w-full" h="h-20" class="mt-3" /></Card>
{:else if summary}
<!-- ═══ 2. 핀 고정 메모 (조건부, 펼침/접힘) ═══ -->
{#if pinnedMemos.length > 0}
<div class="mb-5 space-y-1.5">
{#each pinnedMemos as memo (memo.id)}
<details class="group/pin">
<summary class="flex items-center gap-2.5 px-3 py-2 bg-surface border border-default/50 rounded-lg
hover:bg-surface-hover transition-colors text-sm cursor-pointer select-none list-none">
<Pin size={13} class="text-accent shrink-0" />
<span class="text-text truncate flex-1">
{memo.title && memo.title !== memo.content?.split('\n')[0]?.replace(/^#+\s*/, '').slice(0, 80)
? memo.title
: memo.content?.split('\n')[0] || '메모'}
</span>
<ChevronRight size={13} class="text-dim shrink-0 transition-transform group-open/pin:rotate-90" />
</summary>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text"
onclick={(e) => handlePinCheckbox(e, memo)}
>
<div
class="prose prose-sm max-w-none memo-content-pin"
class:show-hidden={showHiddenByMemo[memo.id]}
>
{@html renderMemoHtml(memo.content || '', {
compact: true,
interactive: true,
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
</div>
<div class="flex items-center gap-3 mt-2">
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
<button
type="button"
class="text-[11px] text-dim hover:text-text underline-offset-2 hover:underline"
onclick={(e) => { e.stopPropagation(); toggleShowHidden(memo.id); }}
>
{#if showHiddenByMemo[memo.id]}
완료 항목 숨기기
{:else}
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
{/if}
</button>
{/if}
<a href="/memos" class="text-[11px] text-accent hover:underline">메모함에서 보기 →</a>
</div>
<!-- ═══ 오늘 요약 띠 ═══ -->
<div class="bg-surface border border-default rounded-card p-5 lg:p-6 mb-5">
<!-- 검토 대기 + 디제스트 -->
<div class="flex flex-col sm:flex-row items-stretch gap-5">
<!-- 검토 대기 강조 -->
<div class="flex flex-col justify-center sm:pr-6 sm:border-r border-default sm:min-w-[150px]">
<span class="text-4xl font-extrabold tracking-tight leading-none {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
{summary.inbox_count.toLocaleString()}
</span>
<span class="text-[11px] text-dim mt-1.5 uppercase tracking-wide">검토 대기 문서</span>
{#if summary.inbox_count > 0}
<a href="/inbox" class="text-[11px] text-accent font-semibold mt-2 hover:underline">검토 시작 →</a>
{:else}
<span class="text-[11px] text-dim mt-2">미분류 없음</span>
{/if}
</div>
<!-- 디제스트 톱 (best-effort) -->
{#if digestLead}
<a href="/digest" class="flex-1 flex flex-col justify-center gap-1.5 group">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold text-error bg-error/10 rounded px-1.5 py-0.5 uppercase tracking-wide">속보</span>
<span class="text-[11px] text-faint">{digestLead.date} 브리핑</span>
</div>
</details>
{/each}
{#if pinnedMemos.length >= 3}
<a href="/memos" class="text-[11px] text-accent hover:underline pl-8">더보기 →</a>
<div class="text-[15px] font-semibold text-text leading-snug group-hover:text-accent transition-colors">
{digestLead.topic_label}
</div>
<div class="text-[11px] text-dim">
관련 기사 <strong class="text-text">{digestLead.article_count}</strong>
· 중요도 {digestLead.importance_score.toFixed(2)}
· {countryKo(digestLead.country)}
</div>
</a>
{:else}
<a href="/news" class="flex-1 flex items-center gap-2 text-sm text-dim hover:text-accent transition-colors">
<Newspaper size={16} /> 오늘의 뉴스 브리핑 보기 →
</a>
{/if}
</div>
{/if}
<!-- ═══ 3. 핵심 카드 4개 ═══ -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
<!-- 문서함 -->
<a href="/documents" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">문서함</p>
<FileText size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{(summary.documents_count ?? 0).toLocaleString()}</p>
<p class="text-xs text-dim mt-1">
{#if summary.today_added > 0}
<span class="text-accent">+{summary.today_added} 오늘</span>
{:else}
일반 문서
{/if}
</p>
</Card>
</a>
<!-- 메모 -->
<a href="/memos" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">메모</p>
<StickyNote size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{(summary.memos_count ?? 0).toLocaleString()}</p>
<p class="text-xs text-dim mt-1">직접 작성</p>
</Card>
</a>
<!-- 뉴스 -->
<a href="/news" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">뉴스</p>
<Newspaper size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 text-text">{(summary.news_count ?? 0).toLocaleString()}</p>
<p class="text-xs text-dim mt-1">수집 기사</p>
</Card>
</a>
<!-- 승인 대기 (액션형) -->
<a href="/inbox" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">승인 대기</p>
<Inbox size={18} class="text-faint" />
</div>
<p class="text-3xl font-bold mt-2 {summary.inbox_count > 0 ? 'text-warning' : 'text-success'}">
{summary.inbox_count}
</p>
{#if summary.inbox_count > 0}
<p class="text-xs text-accent mt-1">검토하기 →</p>
{:else}
<p class="text-xs text-dim mt-1">미분류 없음</p>
{/if}
</Card>
</a>
<!-- 스탯 띠 -->
<div class="flex flex-wrap gap-y-3 border-t border-default mt-4 pt-4">
{@render stat((summary.documents_count ?? 0).toLocaleString(), '문서', 'text-accent')}
{@render stat((summary.news_count ?? 0).toLocaleString(), '뉴스')}
{#if domainTotal > 0}
{@render stat(domainCount('Industrial_Safety').toLocaleString(), '산업안전', 'text-domain-safety')}
{@render stat(domainCount('Engineering').toLocaleString(), '엔지니어링', 'text-domain-engineering')}
{/if}
{#if summary.category_counts?.library}
{@render stat(summary.category_counts.library.toLocaleString(), '자료실')}
{/if}
{@render stat((summary.memos_count ?? 0).toLocaleString(), '메모')}
</div>
</div>
<!-- ═══ 3.5. 카테고리 + 자료실 제안 (§4) ═══ -->
{#if summary.category_counts && (Object.keys(summary.category_counts).length > 0 || summary.library_pending_suggestions > 0)}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
{#each CATEGORY_CARDS as cat}
{@const count = summary.category_counts?.[cat.key] ?? 0}
<a href={cat.href} class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">{cat.label}</p>
<cat.icon size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 text-text">{count.toLocaleString()}</p>
<p class="text-xs text-dim mt-1">카테고리</p>
</Card>
</a>
{/each}
<!-- ═══ 2열 본문 ═══ -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-5 items-start">
<!-- 자료실 제안 (action card) -->
<a href="/library" class="block">
<Card interactive class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">자료실 제안</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {summary.library_pending_suggestions > 0 ? 'text-warning' : 'text-success'}">
{summary.library_pending_suggestions}
</p>
{#if summary.library_pending_suggestions > 0}
<p class="text-xs text-accent mt-1">검토하기 →</p>
{:else}
<p class="text-xs text-dim mt-1">대기 없음</p>
{/if}
</Card>
</a>
</div>
{/if}
<!-- ─── 왼쪽 ─── -->
<div class="space-y-5">
<!-- ═══ 3.6. tier 관측성 3종 카드 (B-3) ═══ -->
{#if summary.tier_health && summary.tier_health.triage_total > 0}
{@const th = summary.tier_health}
{@const esc_rate = th.triage_total > 0 ? th.escalated_total / th.triage_total : 0}
{@const json_rate = th.triage_total > 0 ? th.triage_json_invalid / th.triage_total : 0}
{@const sup_rate = th.triage_total > 0 ? th.suppressed_total / th.triage_total : 0}
{@const deep_total = th.deep_total ?? 0}
{@const deep_err_rate = deep_total > 0 ? (th.deep_err_total ?? 0) / deep_total : 0}
<!-- Day 4 튜닝 (2026-04-27): 운영 패턴 실측 후 임계치 재조정.
3일 telemetry 기준 escalate 97% 가 정상 (safety 정책 의도) → <80% 가 진짜 신호. -->
{@const esc_tone = esc_rate < 0.80 ? 'text-error' : 'text-text'}
{@const json_tone = json_rate > 0.05 ? 'text-error' : 'text-text'}
{@const sup_tone = sup_rate > 0.10 ? 'text-warning' : 'text-text'}
{@const deep_tone = deep_err_rate > 0.05 ? 'text-error' : 'text-text'}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<!-- 에스컬레이션 비율 -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">에스컬레이션 비율 (24h)</p>
<Sparkles size={18} class="text-faint" />
<!-- 빠른 캡처 -->
<div class="bg-surface border border-default rounded-card p-5">
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-3">빠른 캡처</div>
<div class="flex gap-2 items-center">
<input
class="flex-1 h-9 border border-default rounded-md bg-bg text-text text-sm px-3.5 outline-none focus:border-accent transition-colors placeholder:text-faint"
type="text"
placeholder="메모 한 줄 남기기…"
bind:value={captureText}
onkeydown={onCaptureKeydown}
disabled={capturing}
/>
<button
class="h-9 px-4 rounded-md bg-accent text-white text-xs font-semibold hover:bg-accent-hover transition-colors disabled:opacity-50 shrink-0"
onclick={quickCapture}
disabled={capturing || !captureText.trim()}
>저장</button>
</div>
<p class="text-2xl font-bold mt-2 {esc_tone}">
{(esc_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
{th.escalated_total} / {th.triage_total}
{#if esc_rate < 0.80}<span class="text-error ml-1">(매칭 실패 증가)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">safety 정책상 95~100% 가 정상</p>
{#if Object.keys(th.escalation_by_reason).length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each Object.entries(th.escalation_by_reason).slice(0, 4) as [reason, n]}
<span class="text-[10px] px-1.5 py-0.5 rounded bg-surface-muted text-dim">
{reason} {n}
</span>
<div class="flex gap-2 mt-2.5">
<a href="/documents" class="inline-flex items-center gap-1.5 text-[11px] text-accent-hover bg-accent/10 rounded-md px-2.5 py-1 hover:bg-accent/20 transition-colors">
<Upload size={11} /> 파일 업로드
</a>
</div>
</div>
<!-- 최근 활동 타임라인 -->
<div class="bg-surface border border-default rounded-card p-5">
<div class="flex items-baseline justify-between mb-3">
<span class="text-[11px] font-bold text-dim uppercase tracking-wider">최근 활동</span>
<div class="flex items-center gap-3">
{#if summary.law_alerts > 0}
<a href="/documents?source=law_monitor"
class="text-[11px] flex items-center gap-1 px-2.5 py-1 rounded-full bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors">
<Scale size={11} /> 법령 {summary.law_alerts}
</a>
{/if}
<a href="/documents" class="text-[11px] text-accent hover:underline">전체 보기 →</a>
</div>
</div>
{#if summary.recent_documents.length > 0}
<div class="flex flex-col">
{#each summary.recent_documents as doc, i (doc.id)}
<a href="/documents/{doc.id}"
class="grid grid-cols-[auto_14px_1fr] gap-x-3 py-2.5 {i > 0 ? 'border-t border-default' : ''} group">
<div class="text-[10px] text-faint text-right pt-1 whitespace-nowrap tabular-nums w-14">{formatTime(doc.created_at)}</div>
<div class="flex flex-col items-center">
<span class="w-2 h-2 rounded-full mt-1.5 shrink-0 {domainBgClass(doc.ai_domain)}"></span>
{#if i < summary.recent_documents.length - 1}<span class="flex-1 w-px bg-default mt-1"></span>{/if}
</div>
<div class="pb-1">
<div class="text-[10px] font-bold uppercase tracking-wide text-dim mb-0.5">{domainLabel(doc.ai_domain)}</div>
<div class="text-[13px] text-text leading-snug group-hover:text-accent transition-colors truncate">{doc.title || '제목 없음'}</div>
</div>
</a>
{/each}
</div>
{/if}
</Card>
<!-- triage JSON 건강도 -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">triage JSON 건강도 (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {json_tone}">
{(json_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
깨짐 {th.triage_json_invalid}
{#if json_rate > 0.05}<span class="text-error ml-1">(프롬프트 이슈 의심)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">5% 초과 시 4B 프롬프트·모델 재검토</p>
</Card>
<!-- Backlog Suppression -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">Backlog Suppression (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {sup_tone}">
{(sup_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
억제 {th.suppressed_total}
{#if sup_rate > 0.10}<span class="text-warning ml-1">(임계치 재조정 신호)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">10% 초과 시 ratio/pending threshold 조정</p>
</Card>
<!-- Deep summary 안정성 (Day 4 신규) -->
<Card class="h-full">
<div class="flex items-start justify-between">
<p class="text-sm text-dim">Deep summary 안정성 (24h)</p>
<Sparkles size={18} class="text-faint" />
</div>
<p class="text-2xl font-bold mt-2 {deep_tone}">
{(deep_err_rate * 100).toFixed(1)}%
</p>
<p class="text-xs text-dim mt-1">
실패 {th.deep_err_total ?? 0} / {deep_total}
{#if deep_err_rate > 0.05}<span class="text-error ml-1">(MLX 안정성 점검)</span>{/if}
</p>
<p class="text-[10px] text-faint mt-1">call_failed / parse:* 합계, 5% 초과 시 점검</p>
</Card>
</div>
{/if}
<!-- ═══ 4. 최근 활동 ═══ -->
<Card class="mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-text">최근 활동</h3>
<div class="flex items-center gap-3">
{#if summary.law_alerts > 0}
<a
href="/documents?source=law_monitor"
class="text-[11px] flex items-center gap-1 px-2.5 py-1 rounded-full
bg-warning/10 text-warning border border-warning/20
hover:bg-warning/20 transition-colors"
>
<Scale size={11} /> 법령 {summary.law_alerts}
</a>
{:else}
<EmptyState
icon={FileText}
title="아직 문서가 없습니다"
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
/>
{/if}
</div>
</div>
{#if summary.recent_documents.length > 0}
<div class="divide-y divide-default/50">
{#each summary.recent_documents as doc (doc.id)}
<a
href="/documents/{doc.id}"
class="block py-2.5 first:pt-0 last:pb-0 hover:bg-surface-hover -mx-5 px-5 transition-colors"
>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-text truncate">{doc.title || '제목 없음'}</span>
<span class="text-[11px] text-dim shrink-0">{formatTime(doc.created_at)}</span>
</div>
<div class="flex items-center gap-1.5 mt-1">
<span class="w-1.5 h-1.5 rounded-full shrink-0 {domainBgClass(doc.ai_domain)}"></span>
<span class="text-[11px] text-dim truncate">{domainLabel(doc.ai_domain)}</span>
</div>
</a>
{/each}
</div>
{:else}
<EmptyState
icon={FileText}
title="아직 문서가 없습니다"
description="NAS PKM 폴더에 파일을 추가하면 자동으로 인덱싱됩니다."
/>
{/if}
</Card>
<!-- ─── 오른쪽 ─── -->
<div class="space-y-5">
<!-- ═══ 5. 파이프라인 (접힘) ═══ -->
<!-- 학습 (streak/복습 마감은 백엔드 부재로 링크형 degrade) -->
<a href="/study" class="block bg-surface border border-default rounded-card p-5 hover:bg-surface-hover transition-colors group">
<div class="flex items-center justify-between">
<span class="text-[11px] font-bold text-dim uppercase tracking-wider">학습</span>
<GraduationCap size={16} class="text-faint" />
</div>
<div class="text-[15px] font-semibold text-text mt-3 group-hover:text-accent transition-colors">암기 노트 학습 시작 →</div>
<div class="text-[11px] text-dim mt-1">검수함 · 복습함 · 암기카드</div>
</a>
<!-- 도메인 분포 + 파이프라인 -->
{#if domainDist.length > 0}
<div class="bg-surface border border-default rounded-card p-5">
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">도메인 분포</div>
<div class="text-[11px] text-faint mb-3">전체 <strong class="text-base font-bold text-text tracking-tight align-baseline">{domainTotal.toLocaleString()}</strong></div>
<!-- 분포 막대 -->
<div class="flex gap-0.5 h-2 rounded mb-4 overflow-hidden">
{#each domainDist as d (d.name)}
<div class="h-full rounded-sm {domainBgClass(d.name)}" style="width:{domainTotal > 0 ? (d.count / domainTotal) * 100 : 0}%"></div>
{/each}
</div>
<div class="flex flex-col gap-1.5">
{#each domainDist.slice(0, 6) as d (d.name)}
<a href="/documents?domain={encodeURIComponent(d.name)}" class="flex items-center gap-2 text-xs hover:text-accent transition-colors group">
<span class="w-2.5 h-2.5 rounded-sm shrink-0 {domainBgClass(d.name)}"></span>
<span class="flex-1 text-text truncate group-hover:text-accent">{domainLabel(d.name)}</span>
<span class="font-semibold text-dim tabular-nums">{d.count.toLocaleString()}</span>
</a>
{/each}
</div>
<!-- 파이프라인 칩 (안2 흡수) -->
<div class="flex items-center gap-1.5 flex-wrap mt-4 pt-3.5 border-t border-default">
<span class="text-[10px] text-faint uppercase tracking-wide mr-1">파이프라인</span>
{#if totalFailed > 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-error bg-error/10">실패 {totalFailed}</span>{/if}
{#if totalPending > 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-warning bg-warning/10">대기 {totalPending}</span>{/if}
{#if totalProcessing > 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-success bg-success/10">처리중 {totalProcessing}</span>{/if}
{#if totalFailed === 0 && totalPending === 0 && totalProcessing === 0}<span class="text-[10px] font-bold rounded px-2 py-0.5 text-success bg-success/10">정상</span>{/if}
</div>
</div>
{/if}
<!-- 고정 항목 -->
{#if pinnedMemos.length > 0}
<div class="bg-surface border border-default rounded-card p-5">
<div class="flex items-baseline justify-between mb-3">
<span class="text-[11px] font-bold text-dim uppercase tracking-wider">고정 항목</span>
<a href="/memos" class="text-[11px] text-accent hover:underline">관리 →</a>
</div>
<div class="flex flex-col gap-2">
{#each pinnedMemos as memo (memo.id)}
<a href="/memos" class="flex items-start gap-2.5 px-3 py-2.5 rounded-lg bg-bg hover:bg-surface-hover transition-colors">
<span class="text-[9px] font-bold rounded px-1.5 py-0.5 uppercase tracking-wide shrink-0 mt-0.5 text-accent-hover bg-accent/10">메모</span>
<span class="text-xs text-text leading-snug flex-1">{pinTitle(memo)}</span>
<Pin size={11} class="text-faint shrink-0 mt-0.5" />
</a>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- ═══ 파이프라인 상세 (실패 있을 때 자동 펼침) ═══ -->
<details
class="mt-5"
open={pipelineOpen}
ontoggle={(e) => { if (!e.currentTarget.open) pipelineManualClosed = true; }}
>
<summary
class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-lg
cursor-pointer hover:bg-surface-hover transition-colors select-none list-none"
>
<summary class="flex items-center justify-between px-5 py-3.5 bg-surface border border-default rounded-card cursor-pointer hover:bg-surface-hover transition-colors select-none list-none">
<span class="text-sm font-semibold text-text flex items-center gap-2">
<ChevronRight size={14} class="transition-transform details-chevron" />
파이프라인
파이프라인 상세
</span>
<span class="text-xs text-dim flex items-center gap-2.5">
{#if totalFailed > 0}
<span class="text-error font-medium">실패 {totalFailed}</span>
{/if}
{#if totalPending > 0}
<span>대기 {totalPending}</span>
{/if}
{#if totalFailed === 0 && totalPending === 0}
<span>처리 완료</span>
{/if}
{#if totalFailed > 0}<span class="text-error font-medium">실패 {totalFailed}</span>{/if}
{#if totalPending > 0}<span>대기 {totalPending}</span>{/if}
{#if totalFailed === 0 && totalPending === 0}<span>처리 완료</span>{/if}
</span>
</summary>
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-lg">
<div class="mt-2 px-5 py-4 bg-surface border border-default rounded-card">
<p class="text-xs text-dim mb-3">최근 24시간</p>
{#if pipelineRows.length > 0}
<div class="space-y-3">
@@ -522,9 +445,7 @@
<span class="text-dim">
{row.label}
{#if row.oldestPendingAgeSec && row.oldestPendingAgeSec > 600}
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">
({formatAge(row.oldestPendingAgeSec)})
</span>
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">({formatAge(row.oldestPendingAgeSec)})</span>
{/if}
</span>
<span class="text-dim tabular-nums">
@@ -551,22 +472,14 @@
</div>
</div>
{#snippet stat(value: string, label: string, colorClass = 'text-text')}
<div class="flex flex-col items-start px-4 first:pl-0 border-l border-default first:border-l-0 min-w-[64px]">
<span class="text-xl font-bold tracking-tight leading-none {colorClass}">{value}</span>
<span class="text-[10px] text-faint mt-1 uppercase tracking-wide">{label}</span>
</div>
{/snippet}
<style>
details[open] .details-chevron { transform: rotate(90deg); }
details summary::-webkit-details-marker { display: none; }
.memo-content-pin :global(p) { margin: 0.2em 0; }
.memo-content-pin :global(ul), .memo-content-pin :global(ol) { margin: 0.2em 0; padding-left: 1.5em; }
.memo-content-pin :global(code) { background: var(--bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.memo-content-pin :global(a) { color: var(--accent); }
.memo-content-pin :global(.memo-checkbox) { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent); vertical-align: middle; margin-right: 3px; }
.memo-content-pin :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
.memo-content-pin :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
/* 체크 후 10초 경과 항목 자동 숨김 (`show-hidden` 클래스로 토글 해제) */
.memo-content-pin :global(.memo-task-hidden) { display: none; }
.memo-content-pin.show-hidden :global(.memo-task-hidden) { display: list-item; }
.memo-content-pin :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 3px; }
.memo-content-pin :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
.memo-content-pin :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
.memo-content-pin :global(.due-normal) { background: var(--surface); color: var(--text-dim); }
.memo-content-pin :global(.due-done) { background: var(--surface); color: var(--text-dim); opacity: 0.6; }
</style>