Compare commits

..

2 Commits

Author SHA1 Message Date
hyungi bb9e0905f2 feat(study): 습관 루프 — 오늘의 몫·스트릭/잔디·아침 리마인더 webhook
organic 사용 0 진단(트리거 없음·due 산더미·반복 감각 부재) 대응 3조각:
- 오늘의 몫: /api/study/daily (문제5·카드5·개념1·검수3, 가용량 보정) + /study 홈 최상단
  체크리스트/완료 상태. 진도·복습 백로그는 접이식 강등(무변경+cap, D2).
- 스트릭/잔디: KST 일 단위 활동 집계(attempts+document_reads+카드평가 근사).
- 아침 리마인더: study_reminder 09 KST 슬롯 한정 Synology Chat incoming webhook
  (STUDY_REMINDER_WEBHOOK_URL, 빈 값=off, 몫 완료 시 skip). 07-01 push 폐기 결정의
  flip 조건(홈 pull 실패) 충족에 따른 사용자 합의 D1.
- 마이그 383: study_memo_cards.reviewed_at (검수 처리 집계용, 승인/수정확정/폐기 시 박힘).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:18:02 +09:00
hyungi 371ee4ebe6 feat(presegment): 영구 실패 Chat 알람 — ALERT_FAIL_STAGES allowlist(기본 deep_summary,summarize) 2026-07-03 08:14:12 +09:00
9 changed files with 460 additions and 10 deletions
+11 -1
View File
@@ -249,7 +249,12 @@ async def approve_batch(
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review,
)
.values(needs_review=False, flagged_by=None, flagged_at=None)
.values(
needs_review=False,
flagged_by=None,
flagged_at=None,
reviewed_at=datetime.now(timezone.utc),
)
.returning(StudyMemoCard.id)
)
approved_ids = list(result.scalars().all())
@@ -397,6 +402,7 @@ async def update_card(
card.needs_review = False
card.flagged_by = None
card.flagged_at = None
card.reviewed_at = datetime.now(timezone.utc)
elif "needs_review" in fields_set:
card.needs_review = bool(body.needs_review)
if card.needs_review:
@@ -405,6 +411,7 @@ async def update_card(
else:
card.flagged_by = None
card.flagged_at = None
card.reviewed_at = datetime.now(timezone.utc)
# 발행 재투영/tombstone(같은 tx) — 검수완료=발행·검수대기복귀=tombstone(상태 기반). S-2.
if settings.study_publish_enabled:
@@ -431,6 +438,9 @@ async def delete_card(
card = await session.get(StudyMemoCard, card_id)
card = _verify_card(card, user)
card.deleted_at = datetime.now(timezone.utc)
if card.needs_review:
# 검수 대기분의 폐기 = 검수 처리의 한 형태 ('오늘의 몫' 검수 집계 포함).
card.reviewed_at = card.deleted_at
# 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트. S-2.
if settings.study_publish_enabled:
await enqueue_card_publish(session, card)
+13
View File
@@ -16,6 +16,7 @@ from core.database import get_session
from models.user import User
from services.study import concept_curriculum as cc
from services.study import concept_links as cl
from services.study import daily_unit as du
router = APIRouter()
@@ -23,6 +24,18 @@ router = APIRouter()
DEFAULT_TOPIC_ID = 4
@router.get("/daily")
async def get_daily(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
topic_id: int = DEFAULT_TOPIC_ID,
):
"""오늘의 몫(유한한 데일리 단위) + 스트릭/잔디 — 습관 루프 홈 재료. read-only."""
state = await du.daily_state(session, user.id, topic_id)
sg = await du.streak_and_grass(session, user.id)
return {**state, **sg}
@router.get("/curriculum")
async def get_curriculum(
user: Annotated[User, Depends(get_current_user)],
+5
View File
@@ -209,6 +209,9 @@ class Settings(BaseModel):
maintenance_note: str = ""
# 뷰어 write-back ingest(study-to-viewer P2) 게이트. /ingest/study/attempts 활성. 기본 false=inert(503).
study_ingest_enabled: bool = False
# 습관 루프(2026-07-03 D1): '오늘의 몫' 아침 리마인더 → Synology Chat incoming webhook URL.
# 빈 값 = 발신 안 함 (명시적 off = kill switch. study_reminder 워커가 skip 로그 남김).
study_reminder_webhook_url: str = ""
# internal endpoint Bearer token (Mac mini derived-worker 호출용)
internal_worker_token: str = ""
@@ -228,6 +231,7 @@ def load_settings() -> Settings:
maintenance_mode = os.getenv("MAINTENANCE_MODE", "false").lower() in ("1", "true", "yes")
maintenance_note = os.getenv("MAINTENANCE_NOTE", "")
study_ingest_enabled = os.getenv("STUDY_INGEST_ENABLED", "false").lower() in ("1", "true", "yes")
study_reminder_webhook_url = os.getenv("STUDY_REMINDER_WEBHOOK_URL", "")
internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "")
viewer_sync_token = os.getenv("VIEWER_SYNC_TOKEN", "")
jwt_secret = os.getenv("JWT_SECRET", "")
@@ -371,6 +375,7 @@ def load_settings() -> Settings:
maintenance_mode=maintenance_mode,
maintenance_note=maintenance_note,
study_ingest_enabled=study_ingest_enabled,
study_reminder_webhook_url=study_reminder_webhook_url,
internal_worker_token=internal_worker_token,
viewer_sync_token=viewer_sync_token,
pipeline_held_stages=pipeline_held_stages,
+2
View File
@@ -65,6 +65,8 @@ class StudyMemoCard(Base):
needs_review: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
flagged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
flagged_by: Mapped[str | None] = mapped_column(String(40))
# 검수 처리 시각 — 승인/수정확정/soft-delete 시 박힘 (migration 383, '오늘의 몫' 검수 집계).
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
model: Mapped[str | None] = mapped_column(String(120))
generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+215
View File
@@ -0,0 +1,215 @@
"""daily_unit — '오늘의 몫' (유한한 데일리 학습 단위) 집계 + 스트릭/잔디.
습관 루프 트랙(2026-07-03): 문제엔진·SR·이론 홈·암기카드까지 완성인데 organic 사용 0.
진단 = 트리거 없음 + 열면 due 산더미 + 반복의 감각 부재. 이 모듈은 그 중 '유한한 단위'
'반복의 감각(스트릭)'의 read-only 재료를 만든다. write 0 (기존 attempt/rate/read 가 정본).
targets 기본(사용자 합의 D4) = 문제 5 · 카드 5 · 개념 1 · 카드검수 3.
가용량(effective) 보정: 남은 재료가 target 미만이면 target 을 낮춰 '영영 완주 불가' 방지.
'오늘' 경계 = KST(Asia/Seoul, 스케줄러 tz 와 동일). DB 저장은 UTC 라 경계 변환 후 비교.
스트릭/잔디 근사 주의: 카드 평가는 per-event 로그가 없어(progress.last_reviewed_at 만)
과거일이 과소집계될 수 있다(같은 카드 재평가 시 이전 날짜 증발). 문제풀이·회독은 전이력
정확. 오늘 몫에 문제풀이가 포함되므로 완주일은 attempts 로 정확히 남는다.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from models.study_concept_progress import StudyConceptProgress
from models.study_memo_card import StudyMemoCard
from models.study_memo_card_progress import StudyMemoCardProgress
from models.study_question import StudyQuestionAttempt
from services.study import concept_curriculum as cc
KST = ZoneInfo("Asia/Seoul")
# 기본 targets (사용자 합의 2026-07-03 D4 — 착수 장벽 최소화가 관건이라 소량).
TARGET_QUESTIONS = 5
TARGET_CARDS = 5
TARGET_CONCEPTS = 1
TARGET_REVIEWS = 3
GRASS_DAYS = 84 # 12주
def kst_day_start(now: datetime) -> datetime:
"""오늘(KST) 0시의 UTC datetime."""
local = now.astimezone(KST)
start_local = local.replace(hour=0, minute=0, second=0, microsecond=0)
return start_local.astimezone(timezone.utc)
async def _count(session: AsyncSession, stmt) -> int:
return (await session.execute(stmt)).scalar_one()
async def daily_state(
session: AsyncSession, user_id: int, topic_id: int, now: datetime | None = None
) -> dict:
"""오늘의 몫 상태 — 항목별 done/target + 완주 여부 + 다음 개념 CTA 재료."""
now = now or datetime.now(timezone.utc)
start = kst_day_start(now)
# ── 금일 완료량 ──
q_done = await _count(
session,
select(func.count())
.select_from(StudyQuestionAttempt)
.where(
StudyQuestionAttempt.user_id == user_id,
StudyQuestionAttempt.answered_at >= start,
),
)
card_done = await _count(
session,
select(func.count())
.select_from(StudyMemoCardProgress)
.where(
StudyMemoCardProgress.user_id == user_id,
StudyMemoCardProgress.last_reviewed_at.is_not(None),
StudyMemoCardProgress.last_reviewed_at >= start,
),
)
concept_done = await _count(
session,
select(func.count())
.select_from(StudyConceptProgress)
.where(
StudyConceptProgress.user_id == user_id,
StudyConceptProgress.last_read_at.is_not(None),
StudyConceptProgress.last_read_at >= start,
),
)
review_done = await _count(
session,
select(func.count())
.select_from(StudyMemoCard)
.where(
StudyMemoCard.user_id == user_id,
StudyMemoCard.reviewed_at.is_not(None),
StudyMemoCard.reviewed_at >= start,
),
)
# ── 가용량 (effective target 보정용) ──
# 카드: /study-cards/due 와 동일 술어(신규 progress 없음 OR 예정 due) — cold-start 정합.
P = StudyMemoCardProgress
card_avail = await _count(
session,
select(func.count())
.select_from(StudyMemoCard)
.outerjoin(P, and_(P.card_id == StudyMemoCard.id, P.user_id == user_id))
.where(
StudyMemoCard.user_id == user_id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(False),
or_(
P.id.is_(None),
and_(
P.due_at.is_not(None),
P.due_at <= now,
or_(P.review_stage.is_(None), P.review_stage < 4),
),
),
),
)
review_avail = await _count(
session,
select(func.count())
.select_from(StudyMemoCard)
.where(
StudyMemoCard.user_id == user_id,
StudyMemoCard.deleted_at.is_(None),
StudyMemoCard.needs_review.is_(True),
),
)
# 개념: today_concepts 재사용 (재복습 due + 미독) — 첫 항목은 CTA 딥링크 재료.
today = await cc.today_concepts(session, user_id, topic_id, limit=1)
concept_avail = today["due_total"] + today["unread_total"]
next_concept = today["concepts"][0] if today["concepts"] else None
def eff(target: int, done: int, avail: int) -> int:
# 남은 재료가 target 에 못 미치면 낮춤 (완주 가능 보장). 이미 done>=target 이면 target.
return min(target, done + avail)
items = {
"questions": {"done": q_done, "target": TARGET_QUESTIONS}, # 문제는 2,100 — 가용 보정 불요
"cards": {"done": card_done, "target": eff(TARGET_CARDS, card_done, card_avail)},
"concepts": {"done": concept_done, "target": eff(TARGET_CONCEPTS, concept_done, concept_avail)},
"reviews": {"done": review_done, "target": eff(TARGET_REVIEWS, review_done, review_avail)},
}
for it in items.values():
it["complete"] = it["target"] == 0 or it["done"] >= it["target"]
active_targets = [it for it in items.values() if it["target"] > 0]
complete = bool(active_targets) and all(it["complete"] for it in active_targets)
return {
"topic_id": topic_id,
"date_kst": now.astimezone(KST).date().isoformat(),
"items": items,
"complete": complete,
"next_concept": (
{"doc_id": next_concept["doc_id"], "title": next_concept["title"]}
if next_concept
else None
),
}
# 잔디 = KST 일 단위 활동량. attempts + document_reads 는 전이력 정확, 카드 평가일은 근사(docstring).
_GRASS_SQL = text(
"""
SELECT d, sum(n)::int AS n FROM (
SELECT (answered_at AT TIME ZONE 'Asia/Seoul')::date AS d, count(*) AS n
FROM study_question_attempts
WHERE user_id = :uid AND answered_at >= :since
GROUP BY 1
UNION ALL
SELECT (read_at AT TIME ZONE 'Asia/Seoul')::date AS d, count(*) AS n
FROM document_reads
WHERE user_id = :uid AND read_at >= :since
GROUP BY 1
UNION ALL
SELECT (last_reviewed_at AT TIME ZONE 'Asia/Seoul')::date AS d, count(*) AS n
FROM study_memo_card_progress
WHERE user_id = :uid AND last_reviewed_at IS NOT NULL AND last_reviewed_at >= :since
GROUP BY 1
) u GROUP BY d
"""
)
async def streak_and_grass(
session: AsyncSession, user_id: int, now: datetime | None = None, days: int = GRASS_DAYS
) -> dict:
"""잔디(최근 days일, KST 일 단위 활동량) + 스트릭(오늘 또는 어제부터 역산 연속 활동일)."""
now = now or datetime.now(timezone.utc)
today_local = now.astimezone(KST).date()
since = kst_day_start(now) - timedelta(days=days - 1)
rows = (
await session.execute(_GRASS_SQL, {"uid": user_id, "since": since})
).all()
by_day = {r.d: r.n for r in rows}
grass = []
for i in range(days - 1, -1, -1):
d = today_local - timedelta(days=i)
grass.append({"d": d.isoformat(), "n": int(by_day.get(d, 0))})
# 스트릭: 오늘 활동 있으면 오늘부터, 없으면 어제부터 역산 (오늘은 아직 진행 중일 수 있음).
streak = 0
cursor = today_local if by_day.get(today_local, 0) > 0 else today_local - timedelta(days=1)
while by_day.get(cursor, 0) > 0:
streak += 1
cursor -= timedelta(days=1)
return {"streak": streak, "today_active": by_day.get(today_local, 0) > 0, "grass": grass}
+32
View File
@@ -23,6 +23,15 @@ logger = setup_logger("queue_consumer")
# pipeline.held_stages 안내 로그는 1분 사이클마다 반복하지 않고 최초 1회만.
_hold_logged = False
# PR3 후속(2026-07-03): 영구 실패 알람 — 사람이 개입해야 풀리는 상태라 Chat 웹훅 발화.
# allowlist 로 소음 제한: embed/chunk 류 대량 배치가 일제히 실패하면 문서 수만큼 알람이
# 쏟아지므로, 건당 가치가 높고 발생률이 낮은 LLM 스테이지만 기본 대상으로 한다.
_ALERT_FAIL_STAGES = {
s.strip()
for s in os.getenv("ALERT_FAIL_STAGES", "deep_summary,summarize").split(",")
if s.strip()
}
# stage별 배치 크기
# stt 는 GPU 단일 점유 + 회의 30분짜리도 가능 → 배치 1. thumbnail 은 ffmpeg subprocess 로 가벼움.
# deep_summary (PR-B B-1) 는 MLX 26B 단일 Semaphore(1) 경유 → 배치 1.
@@ -353,6 +362,8 @@ async def _process_stage(stage, worker_fn):
except Exception as e:
# 실패 처리
permanently_failed = False
doc_title = None
async with async_session() as session:
item = await session.get(ProcessingQueue, queue_id)
if not item:
@@ -363,7 +374,16 @@ async def _process_stage(stage, worker_fn):
item.error_message = err_text[:500]
if item.attempts >= item.max_attempts:
item.status = "failed"
permanently_failed = True
logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}")
if stage in _ALERT_FAIL_STAGES:
# 알람용 제목 best-effort — 실패해도 알람 자체는 발화한다.
try:
from models.document import Document
_doc = await session.get(Document, document_id)
doc_title = getattr(_doc, "title", None)
except Exception:
doc_title = None
# B3: marker_worker 는 변환 시작 시 doc.md_status='processing' 으로 표시한다.
# 변환이 _fail()/_set_skipped() 를 거치지 않고 예외로 죽으면(예: 대형
# batch ReadTimeout) doc.md_status 가 'processing' 에 영구 고착 = orphan
@@ -385,6 +405,18 @@ async def _process_stage(stage, worker_fn):
item.started_at = None
logger.warning(f"[{stage}] document_id={document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}")
await session.commit()
if permanently_failed and stage in _ALERT_FAIL_STAGES:
# 영구 실패 = 무인 파이프라인이 스스로 못 푸는 상태 → 유인 전환 알람.
# send_alert 는 절대 raise 하지 않음(no-op/실패 = False 반환뿐).
from services.alerts import send_alert
await send_alert(
f"[DS] {stage} 영구 실패 — doc {document_id}",
(
f"{doc_title or '(제목 미상)'}\n"
f"에러: {err_text[:300]}\n"
f"확인: scripts/presegment_attended.py list (보류/거부 사유) 또는 큐 재큐"
),
)
async def consume_queue():
+56
View File
@@ -14,21 +14,72 @@ due 0 이면 row 미생성(noise 방지). 놓친 시각은 그냥 skip(stale 복
from __future__ import annotations
import json
import logging
from collections import defaultdict
from datetime import datetime, timezone
import httpx
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from core.config import settings
from core.database import async_session
from models.study_question_progress import StudyQuestionProgress
from models.study_reminder import StudyReminder
from models.study_topic import StudyTopic
from models.user import User # noqa: F401 (mapper 초기화 defensive)
from services.study.daily_unit import KST, daily_state
logger = logging.getLogger("study_reminder")
# 습관 루프 D1(2026-07-03): push 채널 flip — Synology Chat incoming webhook, 아침 1회만.
# 13/19 KST 슬롯은 집계만(노이즈 방지). '오늘의 몫' 완료 시 발신 skip(초대 프레임, 위협 아님).
WEBHOOK_HOUR_KST = 9
STUDY_HOME_URL = "https://document.hyungi.net/study"
_ITEM_LABELS = [("questions", "문제"), ("cards", "카드"), ("concepts", "개념"), ("reviews", "검수")]
async def _send_daily_nudge(by_user: dict[int, dict], now: datetime) -> None:
"""아침 슬롯 한정 — 오늘의 몫 미완 사용자에게 Synology Chat webhook 1건."""
url = settings.study_reminder_webhook_url
if not url:
logger.info("study_reminder nudge skip — STUDY_REMINDER_WEBHOOK_URL 미설정(off)")
return
async with async_session() as session:
for uid, agg in by_user.items():
if not agg["names"]:
continue
topic_id = agg["names"][0]["topic_id"]
try:
state = await daily_state(session, uid, topic_id, now)
except Exception:
logger.exception("study_reminder nudge daily_state 실패 user=%s", uid)
continue
if state["complete"]:
continue # 오늘 몫 완료 — 발신 없음
parts = [
f"{label} {it['target']}"
for key, label in _ITEM_LABELS
if (it := state["items"][key])["target"] > 0 and not it["complete"]
]
if not parts:
continue
msg = f"공부 오늘의 몫 — {' · '.join(parts)} (약 6분)\n{STUDY_HOME_URL}"
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
url, data={"payload": json.dumps({"text": msg}, ensure_ascii=False)}
)
resp.raise_for_status()
logger.info("study_reminder nudge sent user=%s", uid)
except Exception:
# 발신 실패 = 내일 다시 (재시도 없음 — 아침 1회 원칙, 실패는 로그만)
logger.exception("study_reminder nudge 발신 실패 user=%s", uid)
async def run() -> None:
"""APScheduler cron 진입점. focus 토픽 due 집계 → study_reminders append."""
@@ -90,3 +141,8 @@ async def run() -> None:
await session.commit()
if inserted:
logger.info("study_reminder fired slot=%s users=%d", slot.isoformat(), inserted)
# 아침(09 KST) 슬롯만 채널 발신 — due 유무와 무관하게 '오늘의 몫' 미완이 기준
# (due 0 이어도 미독 개념/신규 카드가 몫을 구성할 수 있음).
if now.astimezone(KST).hour == WEBHOOK_HOUR_KST:
await _send_daily_nudge(by_user, now)
+123 -9
View File
@@ -1,15 +1,20 @@
<script>
// /study — 학습 hub + 데일리 랜딩('오늘의 공부' 대시보드).
// 상단 = 이론 홈(진도·오늘의 개념·복습 due, 재노출 트리거). 하단 = 기존 모드 진입.
// /study — 학습 hub + 데일리 랜딩. 최상단 = '오늘의 몫'(유한한 데일리 단위 + 스트릭/잔디,
// 습관 루프 2026-07-03). 진도·복습 백로그는 접이식으로 강등(열자마자 due 산더미 금지, D2).
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target } from 'lucide-svelte';
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target, Flame, Play, CheckCircle2 } from 'lucide-svelte';
let cardReviewCount = $state(0);
let questionFlagCount = $state(0);
// 오늘의 공부 (이론 홈)
// 오늘의 몫 (데일리 단위)
let daily = $state(null);
let dailyLoading = $state(true);
// 이론 홈 (백로그 접이식 내부)
let curriculum = $state(null);
let todayConcepts = $state([]);
let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음)
@@ -19,6 +24,46 @@
curriculum && curriculum.total ? Math.round((curriculum.read / curriculum.total) * 100) : 0
);
// 오늘의 몫 4항목 (target 0 = 재료 없음 → 행 숨김)
const DAILY_ITEMS = [
{ key: 'questions', label: '문제 풀이', icon: GraduationCap },
{ key: 'cards', label: '카드 복습', icon: Repeat },
{ key: 'concepts', label: '개념 읽기', icon: BookOpen },
{ key: 'reviews', label: '카드 검수', icon: Layers },
];
function grassClass(n) {
if (n <= 0) return 'bg-bg border border-default/50';
if (n <= 2) return 'bg-accent/30';
if (n <= 9) return 'bg-accent/60';
return 'bg-accent';
}
async function loadDaily() {
dailyLoading = true;
try {
daily = await api('/study/daily');
} catch {
// 실패해도 허브 나머지는 동작 (조용히)
} finally {
dailyLoading = false;
}
}
/** 오늘의 몫 문제 5 — learning stage 5문항 세션 시작(진행 중 세션 있으면 이어풀기). */
async function startDailyQuiz() {
const topicId = daily?.topic_id ?? 4;
try {
const res = await api(`/study-topics/${topicId}/quiz-sessions`, {
method: 'POST',
body: JSON.stringify({ stage: 'learning', size: 5, quiz_mode: 'random' }),
});
goto(`/study/topics/${topicId}/review?session=${res.id}`);
} catch (err) {
addToast('error', err?.detail || '문제풀이 시작 실패');
}
}
async function loadDashboard() {
dashLoading = true;
try {
@@ -46,12 +91,14 @@
todayConcepts = todayConcepts.filter((c) => c.doc_id !== doc.doc_id);
addToast('success', `회독: ${doc.title}`);
loadDashboard(); // 진도 갱신
loadDaily(); // 오늘의 몫 갱신
} catch {
addToast('error', '회독 처리 실패');
}
}
onMount(async () => {
loadDaily();
loadDashboard();
try {
const r = await api('/study-cards/needs-review/count');
@@ -72,16 +119,82 @@
<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>
<h2 class="text-base font-semibold text-text">오늘의 </h2>
{#if daily}
<span class="ml-auto flex items-center gap-1.5 text-xs {daily.streak > 0 ? 'text-accent' : 'text-dim'}">
<Flame size={14} />
{#if daily.streak > 0}{daily.streak}일 연속{:else}오늘부터 시작{/if}
</span>
{/if}
</div>
{#if dailyLoading}
<p class="text-xs text-dim">불러오는 중…</p>
{:else if daily}
{#if daily.complete}
<div class="mb-3 flex items-center gap-2 rounded border border-accent/40 bg-accent/10 px-3 py-2.5 text-sm text-accent">
<CheckCircle2 size={16} /> 오늘 몫 끝. 내일 또 만나요.
</div>
{/if}
<ul class="space-y-1.5 mb-4">
{#each DAILY_ITEMS as item (item.key)}
{@const it = daily.items[item.key]}
{#if it && it.target > 0}
<li class="flex items-center gap-2.5 rounded border px-3 py-2 {it.complete ? 'border-accent/40 bg-accent/5' : 'border-default'}">
<item.icon size={15} class={it.complete ? 'text-accent' : 'text-dim'} />
<span class="text-sm {it.complete ? 'text-dim line-through' : 'text-text'}">{item.label}</span>
<span class="text-xs {it.complete ? 'text-accent' : 'text-dim'} font-medium">{Math.min(it.done, it.target)}/{it.target}</span>
<span class="ml-auto"></span>
{#if it.complete}
<CheckCircle2 size={15} class="text-accent shrink-0" />
{:else if item.key === 'questions'}
<button
type="button"
onclick={startDailyQuiz}
class="shrink-0 flex items-center gap-1 rounded bg-accent px-2.5 py-1 text-xs font-medium text-white hover:opacity-90 transition-opacity"
><Play size={11} /> 풀기</button>
{:else if item.key === 'cards'}
<a href="/study/cards-study" class="shrink-0 rounded border border-default px-2.5 py-1 text-xs text-dim hover:border-accent hover:text-accent transition-colors">복습</a>
{:else if item.key === 'concepts'}
<a href={daily.next_concept ? `/study/read/${daily.next_concept.doc_id}` : '/study/sources'}
class="shrink-0 rounded border border-default px-2.5 py-1 text-xs text-dim hover:border-accent hover:text-accent transition-colors">읽기</a>
{:else}
<a href="/study/cards-review" class="shrink-0 rounded border border-default px-2.5 py-1 text-xs text-dim hover:border-accent hover:text-accent transition-colors">검수</a>
{/if}
</li>
{/if}
{/each}
</ul>
<!-- 잔디 (최근 12주, KST 일 단위 활동량) -->
{#if daily.grass?.length}
<div class="overflow-x-auto">
<div class="grid grid-rows-7 grid-flow-col gap-[3px] w-max">
{#each daily.grass as g (g.d)}
<span class="w-2.5 h-2.5 rounded-[2px] {grassClass(g.n)}" title="{g.d}: {g.n}"></span>
{/each}
</div>
</div>
{/if}
{:else}
<p class="text-xs text-dim">오늘의 몫을 불러오지 못했습니다.</p>
{/if}
</section>
<!-- 진도·복습 백로그 (강등 — 첫 화면에서 due 산더미 노출 금지) -->
<details class="mb-5 rounded-lg border border-default bg-surface">
<summary class="cursor-pointer select-none px-4 py-3 text-sm text-dim hover:text-text transition-colors">
진도 · 복습 백로그
{#if curriculum}
<span class="text-xs text-faint">(이론 회독 {readPct}% · 문항 복습 {curriculum.question_due} · 개념 재복습 {curriculum.concept_due})</span>
{/if}
</summary>
<div class="px-4 pb-4 md:px-5 md:pb-5">
{#if dashLoading}
<p class="text-xs text-dim">불러오는 중…</p>
{:else}
@@ -144,7 +257,8 @@
</div>
{/if}
{/if}
</section>
</div>
</details>
<a
href="/study/topics"
@@ -0,0 +1,3 @@
-- 383: 카드 검수 처리 시각 (습관 루프 '오늘의 몫' 검수 N장/일 집계용).
-- 승인(needs_review false 전환)·수정확정·soft-delete 시 박힘. NULL = 미처리.
ALTER TABLE study_memo_cards ADD COLUMN IF NOT EXISTS reviewed_at TIMESTAMPTZ