Compare commits
1 Commits
feat/fail-alert
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bb9e0905f2 |
+11
-1
@@ -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)
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user