'use client'; /** * PBL v2 — Completion report. * * Shown after the learner clicks the chat CTA once the final evaluator * has finished. The report combines: * 1. LLM-produced final evaluation (whatYouBuilt / whatYouLearned / * whatsNext / stars) — the global summary. * 2. Deterministic stats derived from microtask engagement caches * (concepts unlocked, independence rate, error recovery) — the * data-driven stage-by-stage view. * * The two sources are complementary: the LLM gives narrative, the * caches give verifiable facts that never hallucinate. */ import { ArrowLeft, Award, Check, Clock, Compass, Contrast, Hammer, Layers, Lightbulb, MessageCircle, ShieldCheck, Sparkles, Star, Target, Trophy, X, } from 'lucide-react'; import { motion } from 'motion/react'; import type { ReactNode } from 'react'; import type { PBLEvaluation, PBLProjectV2, PBLScenarioActGoals } from '@/lib/pbl/v2/types'; import { useI18n } from '@/lib/hooks/use-i18n'; import { stripEvaluationTail } from '@/lib/pbl/v2/operations/runtime/eval-tail-parser'; import { computeCompletionStats, type CompletionStats, type StandardCompletionStats, type ScenarioCompletionStats, type ScenarioActGoalScaffold, type StageDetail, } from '@/lib/pbl/v2/operations/runtime/completion-stats'; interface Props { readonly project: PBLProjectV2; readonly onBack?: () => void; } export interface CompletionReportViewModel { readonly totalMicrotasks: number; readonly completedMicrotasks: number; readonly stageCount: number; readonly finalEvaluation?: PBLEvaluation; readonly intro?: string; readonly whatYouBuilt: string[]; readonly whatYouLearned: string[]; readonly whatsNext?: string; readonly stars?: number; readonly stats: CompletionStats; } function formatDuration(totalSeconds: number, t: ReturnType['t']): string { if (totalSeconds <= 0) return '—'; const m = Math.floor(totalSeconds / 60); if (m < 60) return t('pbl.v2.completion.durationMinutes', { m }); const h = Math.floor(m / 60); const rm = m % 60; return rm > 0 ? t('pbl.v2.completion.durationHoursMinutes', { h, m: rm }) : t('pbl.v2.completion.durationHours', { h }); } export function buildCompletionReportViewModel(project: PBLProjectV2): CompletionReportViewModel { const totalMicrotasks = project.milestones.reduce((acc, m) => acc + m.microtasks.length, 0); const completedMicrotasks = project.milestones.reduce( (acc, m) => acc + m.microtasks.filter((t) => t.status === 'completed').length, 0, ); const finalEvaluation = project.evaluations.filter((ev) => ev.kind === 'final').at(-1); return { totalMicrotasks, completedMicrotasks, stageCount: project.milestones.length, finalEvaluation, intro: cleanCompletionIntro(finalEvaluation?.feedback), whatYouBuilt: finalEvaluation?.whatYouBuilt ?? [], whatYouLearned: finalEvaluation?.whatYouLearned ?? [], whatsNext: finalEvaluation?.whatsNext, stars: finalEvaluation?.stars, stats: computeCompletionStats(project), }; } export function cleanCompletionIntro(feedback: unknown): string | undefined { const intro = stripEvaluationTail(typeof feedback === 'string' ? feedback : '') .replace(/\{\{\s*[^}]+\s*\}\}/g, '') .trim(); return intro || undefined; } export function PBLV2Completion({ project, onBack }: Props) { const { t } = useI18n(); const report = buildCompletionReportViewModel(project); const { stats } = report; // Shared shell (celebration, hero, stars, what's-next) is identical for both // project kinds; the BODY between hero and what's-next is fully split by // `stats.kind` into two components that share NO fields — the discriminated // union makes it impossible for one to read the other's metrics. const heroCaption = stats.kind === 'scenario' ? stats.sceneCaption : undefined; const heroSummary = report.intro ?? (stats.kind === 'scenario' ? t('pbl.v2.completion.scenario.summary', { acts: stats.acts.total, }) : t('pbl.v2.completion.summary', { completed: report.completedMicrotasks, total: report.totalMicrotasks, stages: report.stageCount, })); return (
{onBack && ( )} {/* ── Celebration ── */} {/* ── Hero banner ── */}
{t('pbl.v2.completion.title')}

{project.title}

{heroCaption && (

{heroCaption}

)}

{heroSummary}

{typeof report.stars === 'number' && (
Stars
{report.stars} / 5
)}
{/* ── Body (split by project kind — no shared fields) ── */} {stats.kind === 'scenario' ? ( ) : ( )} {/* ── What's next ── */}
{t('pbl.v2.completion.whatsNext')}

{report.whatsNext ?? t('pbl.v2.completion.noWhatsNext')}

); } /** Knowledge-project body: stat cards + built/learned + stage review + * highlights. This is the ORIGINAL completion content, moved verbatim — normal * projects render byte-identically to before the scenario split. */ function StandardCompletionBody({ stats, report, }: { readonly stats: StandardCompletionStats; readonly report: CompletionReportViewModel; }) { const { t } = useI18n(); return ( <> {/* ── Stats cards ── */}
} label={t('pbl.v2.completion.statConcepts')} value={String(stats.conceptsUnlocked.length)} /> } label={t('pbl.v2.completion.statTurns')} value={String(stats.totalTurns)} /> } label={t('pbl.v2.completion.statScope')} value={`${report.stageCount} · ${report.totalMicrotasks}`} /> } label={t('pbl.v2.completion.statDuration')} value={formatDuration(stats.totalDurationSeconds, t)} /> } label={t('pbl.v2.completion.statSubmissions')} value={String(stats.totalSubmissions)} />
{/* ── LLM global summary ── */}
} title={t('pbl.v2.completion.whatYouBuilt')} items={report.whatYouBuilt} fallback={t('pbl.v2.completion.noDataFallback')} /> } title={t('pbl.v2.completion.whatYouLearned')} items={report.whatYouLearned} fallback={t('pbl.v2.completion.noDataFallback')} />
{/* ── Stage review ── */} {stats.stageDetails.length > 0 && (
{t('pbl.v2.completion.stageReview')}
    {stats.stageDetails.map((sd, idx) => ( ))}
)} {stats.highlights.length > 0 && (
{t('pbl.v2.completion.highlightsTitle')}
)} ); } /** SCENARIO body: skill-practice stat cards + what-you-did-well / skills + * per-act goal review (the externalised hidden goals, judged by the final * evaluator) + cast. Shows NO knowledge metrics. */ function ScenarioCompletionBody({ stats, report, }: { readonly stats: ScenarioCompletionStats; readonly report: CompletionReportViewModel; }) { const { t } = useI18n(); const cov = stats.goalCoverage; return ( <> {/* ── Stat cards ── */}
{cov && ( } label={t('pbl.v2.completion.scenario.statGoals')} value={`${cov.achieved}/${cov.total}`} /> )} } label={t('pbl.v2.completion.scenario.statActs')} value={String(stats.acts.total)} /> } label={t('pbl.v2.completion.statTurns')} value={String(stats.totalTurns)} /> } label={t('pbl.v2.completion.statDuration')} value={formatDuration(stats.totalDurationSeconds, t)} />
{/* ── LLM skill summary ── */}
} title={t('pbl.v2.completion.scenario.whatYouDidWell')} items={report.whatYouBuilt} fallback={t('pbl.v2.completion.noDataFallback')} /> } title={t('pbl.v2.completion.scenario.skillsPracticed')} items={report.whatYouLearned} fallback={t('pbl.v2.completion.noDataFallback')} />
{/* ── Per-act goal review (externalised hidden goals) ── */} {cov && (
{t('pbl.v2.completion.scenario.goalReviewTitle')}

{t('pbl.v2.completion.scenario.goalReviewHint', { achieved: cov.achieved, total: cov.total, })}

    {cov.acts.map((act, idx) => ( ))}
)} {/* ── Per-act goal list (FALLBACK, no verdict) ── Shown only when there is no scored `goalCoverage` — chiefly older projects finished before the act-goals evaluator shipped. Renders the authored "what each act asked you to do" read-only, so the structured review survives instead of dropping to a bare narrative. Mutually exclusive with the scored review above. */} {!cov && stats.goalScaffold && stats.goalScaffold.length > 0 && (
{t('pbl.v2.completion.scenario.goalListTitle')}

{t('pbl.v2.completion.scenario.goalListHint')}

    {stats.goalScaffold.map((act, idx) => ( ))}
)} {/* ── Cast ── */} {stats.characterNames.length > 0 && (

{t('pbl.v2.completion.scenario.castLabel', { names: stats.characterNames.join('、'), })}

)} ); } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- // Deterministic confetti layout (no Math.random at render → no hydration // mismatch). Each piece falls + drifts + spins on an infinite loop. const CONFETTI: ReadonlyArray<{ left: number; color: string; size: number; delay: number; duration: number; drift: number; rotate: number; }> = [ { left: 6, color: '#a78bfa', size: 6, delay: 0.0, duration: 3.0, drift: 14, rotate: 320 }, { left: 14, color: '#67e8f9', size: 7, delay: 0.7, duration: 3.4, drift: -10, rotate: -260 }, { left: 22, color: '#fcd34d', size: 5, delay: 1.3, duration: 2.8, drift: 8, rotate: 400 }, { left: 30, color: '#6ee7b7', size: 6, delay: 0.4, duration: 3.2, drift: -16, rotate: -340 }, { left: 38, color: '#f9a8d4', size: 7, delay: 1.0, duration: 3.6, drift: 12, rotate: 300 }, { left: 46, color: '#7dd3fc', size: 5, delay: 0.2, duration: 2.9, drift: -8, rotate: -380 }, { left: 54, color: '#a78bfa', size: 6, delay: 1.5, duration: 3.3, drift: 16, rotate: 360 }, { left: 62, color: '#fcd34d', size: 7, delay: 0.6, duration: 3.0, drift: -12, rotate: -300 }, { left: 70, color: '#6ee7b7', size: 5, delay: 1.1, duration: 3.5, drift: 10, rotate: 420 }, { left: 78, color: '#67e8f9', size: 6, delay: 0.3, duration: 2.7, drift: -14, rotate: -340 }, { left: 86, color: '#f9a8d4', size: 7, delay: 0.9, duration: 3.4, drift: 8, rotate: 320 }, { left: 94, color: '#a78bfa', size: 5, delay: 1.4, duration: 3.1, drift: -10, rotate: -360 }, { left: 18, color: '#fcd34d', size: 6, delay: 1.8, duration: 3.2, drift: 12, rotate: 380 }, { left: 82, color: '#7dd3fc', size: 6, delay: 2.0, duration: 3.3, drift: -8, rotate: -320 }, ]; function CelebrationHeader() { return (
{CONFETTI.map((c, i) => ( ))} 🎉
); } function formatHighlight( h: StandardCompletionStats['highlights'][number], t: ReturnType['t'], ): string { switch (h.kind) { case 'independence': return t('pbl.v2.completion.highlight.independence', { unlocks: h.unlocks ?? 0, total: h.total ?? 0, }); case 'resilience': return h.milestoneTitle ? t('pbl.v2.completion.highlight.resilience', { title: h.milestoneTitle, errors: h.errors ?? 0, }) : t('pbl.v2.completion.highlight.resilienceGeneral', { errors: h.errors ?? 0, }); case 'completion': default: return t('pbl.v2.completion.highlight.completion'); } } function StatCard({ icon, label, value, }: { readonly icon: ReactNode; readonly label: string; readonly value: string; }) { return (
{icon}
{value}
{label}
); } /** SCENARIO ONLY. One act's goal-coverage card: the act title + each * externalised goal with a three-state status icon, its skill label, and the * evaluator's note. The goals were hidden checkpoints during play; here they * are revealed read-only as "what this act was about". */ function ActGoalReviewItem({ act, index, }: { readonly act: PBLScenarioActGoals; readonly index: number; }) { return (
  • {index + 1} {act.actTitle}
      {act.goals.map((g, gi) => (
    • {g.goal} {g.skillFocus && ( {g.skillFocus} )} {g.note &&

      {g.note}

      }
    • ))}
  • ); } /** FALLBACK list item: the authored act + its goals, READ-ONLY (no verdict). * Used when there is no scored `goalCoverage` (older projects). Mirrors * `ActGoalReviewItem`'s layout but drops the status icon + note. */ function ActGoalListItem({ act, index, }: { readonly act: ScenarioActGoalScaffold; readonly index: number; }) { return (
  • {index + 1} {act.actTitle}
      {act.goals.map((g, gi) => (
    • •
      {g.goal} {g.skillFocus && ( {g.skillFocus} )}
    • ))}
  • ); } /** Three-state goal status icon: achieved ✓ / partial ◐ / missed ✗. */ function GoalStatusIcon({ status }: { readonly status: 'achieved' | 'partial' | 'missed' }) { const { t } = useI18n(); if (status === 'achieved') { return ( ); } if (status === 'partial') { return ( ); } return ( ); } function StageReviewItem({ detail, index, }: { readonly detail: StageDetail; readonly index: number; }) { const { t } = useI18n(); const hasContent = detail.conceptsInStage.length > 0 || detail.submissionsInStage > 0 || detail.isCoreStage; return (
  • {index + 1} {detail.milestoneTitle} {detail.isCoreStage && ( {t('pbl.v2.completion.coreStageBadge')} )}
    {hasContent ? (
      {detail.conceptsInStage.length > 0 && (
    • + {t('pbl.v2.completion.conceptsInStageLabel')} {detail.conceptsInStage.map((c, ci) => ( {c} {ci < detail.conceptsInStage.length - 1 && ( {'、'} )} ))}
    • )} {detail.submissionsInStage > 0 && (
    • + {t('pbl.v2.completion.submissionsInStage', { count: detail.submissionsInStage })}
    • )} {detail.isCoreStage && detail.coreConcept && detail.synthesisQuality && (
    • + {t('pbl.v2.completion.coreConceptGrasp', { concept: detail.coreConcept, quality: t(`pbl.v2.completion.synthesisQuality.${detail.synthesisQuality}`), })}
    • )}
    ) : (

    {t('pbl.v2.completion.stageNoData')}

    )}
  • ); } function ReportSection({ icon, title, items, fallback, }: { readonly icon: ReactNode; readonly title: string; readonly items: readonly string[]; readonly fallback: string; }) { return (
    {icon} {title}
    {items.length ? (
      {items.map((item, idx) => (
    • {item}
    • ))}
    ) : (

    {fallback}

    )}
    ); }