G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -1,5 +1,12 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useNavigate } from "react-router-dom";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
ClientAvatar,
@ -21,6 +28,19 @@ import {
shortPersonaName,
unavailablePersonaMessage,
} from "../lib/personaViewModel";
import {
parseVoicePracticeContext,
voicePracticeSearch,
VOICE_SCENE_LABEL,
} from "../lib/voicePracticeContext";
import {
parsePracticeLaunchIntent,
practiceCriterionLabel,
practiceLaunchSearch,
PRACTICE_SOURCE_SESSION_LABEL,
PRACTICE_MODE_LABEL,
PRACTICE_NOVELTY_LABEL,
} from "../lib/practiceLaunchIntent";
import {
HISTORY_FILTERS,
averageLearnerTurns,
@ -52,6 +72,13 @@ import "./learner-home.css";
type LoadState = "loading" | "ready" | "error";
export type LearnerHomeView = "dashboard" | "practice" | "history";
const DASHBOARD_TABS = [
{ id: "today", label: "오늘의 회기" },
{ id: "records", label: "기록 · 리뷰" },
{ id: "growth", label: "성장 지표" },
] as const;
type DashboardTab = (typeof DASHBOARD_TABS)[number]["id"];
// ── D3(가운뎃점 라인당 1개) 보조 헬퍼 ────────────────────────────────────────
/**
* display_name "이름(가명) · 학년 · 주호소"
@ -88,6 +115,17 @@ interface LearnerHomeProps {
export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const voicePracticeRequested = view === "practice" && searchParams.get("mode") === "voice";
const voicePracticeContext = useMemo(
() => view === "practice" ? parseVoicePracticeContext(searchParams) : null,
[searchParams, view],
);
const practiceLaunchRequested = view === "practice" && searchParams.has("launch");
const practiceLaunchIntent = useMemo(
() => view === "practice" ? parsePracticeLaunchIntent(searchParams) : null,
[searchParams, view],
);
const [personas, setPersonas] = useState<PersonaSummary[]>([]);
const [sessions, setSessions] = useState<LearnerSessionSummary[]>([]);
const [dashboard, setDashboard] = useState<LearnerDashboardResponse | null>(
@ -109,9 +147,29 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const [archiveNotice, setArchiveNotice] = useState<string | null>(null);
// 중간 폭(≤1180px)에서는 대시보드 섹션을 상단 가로 탭으로 전환한다(2026-07-15 소유자 피드백).
// 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다.
const [dashTab, setDashTab] = useState<"today" | "records" | "growth">(
"today",
);
const [dashTab, setDashTab] = useState<DashboardTab>("today");
const dashTabRefs = useRef<Array<HTMLButtonElement | null>>([]);
function handleDashTabKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
currentIndex: number,
) {
let nextIndex: number | null = null;
if (event.key === "ArrowRight") {
nextIndex = (currentIndex + 1) % DASHBOARD_TABS.length;
} else if (event.key === "ArrowLeft") {
nextIndex =
(currentIndex - 1 + DASHBOARD_TABS.length) % DASHBOARD_TABS.length;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = DASHBOARD_TABS.length - 1;
}
if (nextIndex === null) return;
event.preventDefault();
setDashTab(DASHBOARD_TABS[nextIndex].id);
dashTabRefs.current[nextIndex]?.focus();
}
useEffect(() => {
let alive = true;
@ -185,6 +243,21 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
};
}, []);
const linkedSourceSessionId =
practiceLaunchIntent?.sourceSessionId ?? voicePracticeContext?.sourceSessionId ?? null;
useEffect(() => {
if (!linkedSourceSessionId || sessionLoadState !== "ready") return;
const sourceSession = sessions.find(
(session) => session.session_id === linkedSourceSessionId,
);
if (!sourceSession) return;
const matchingPersona = personas.find(
(persona) => persona.code === sourceSession.persona_code && isUsablePersona(persona),
);
if (matchingPersona) setSelectedCode(matchingPersona.code);
}, [linkedSourceSessionId, personas, sessionLoadState, sessions]);
const usablePersonas = useMemo(
() => personas.filter(isUsablePersona),
[personas],
@ -395,8 +468,16 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
}, [spotlightSession, view]);
const startPractice = () => {
if (selected && isUsablePersona(selected))
navigate(`/learn/session/${selected.code}`);
if (selected && isUsablePersona(selected)) {
if (voicePracticeRequested && !voicePracticeContext) return;
if (practiceLaunchRequested && !practiceLaunchIntent) return;
const suffix = practiceLaunchIntent
? `?${practiceLaunchSearch(practiceLaunchIntent)}`
: voicePracticeContext
? `?${voicePracticeSearch(voicePracticeContext)}`
: "";
navigate(`/learn/session/${selected.code}${suffix}`);
}
};
const goPrimaryAction = () => {
@ -981,6 +1062,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
? `${usablePersonas.length}명 · ${historyStatus}`
: "확인 중"}
</div>
<Button
className="lh-head__primary"
size="lg"
onClick={goPrimaryAction}
data-learner-primary-action
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</div>
</header>
@ -1007,33 +1097,25 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
role="tablist"
aria-label="대시보드 영역 선택"
>
<button
type="button"
role="tab"
aria-selected={dashTab === "today"}
className={dashTab === "today" ? "is-active" : ""}
onClick={() => setDashTab("today")}
>
</button>
<button
type="button"
role="tab"
aria-selected={dashTab === "records"}
className={dashTab === "records" ? "is-active" : ""}
onClick={() => setDashTab("records")}
>
·
</button>
<button
type="button"
role="tab"
aria-selected={dashTab === "growth"}
className={dashTab === "growth" ? "is-active" : ""}
onClick={() => setDashTab("growth")}
>
</button>
{DASHBOARD_TABS.map((tab, index) => (
<button
key={tab.id}
ref={(element) => {
dashTabRefs.current[index] = element;
}}
id={`lh-tab-${tab.id}`}
type="button"
role="tab"
aria-controls={`lh-panel-${tab.id}`}
aria-selected={dashTab === tab.id}
tabIndex={dashTab === tab.id ? 0 : -1}
className={dashTab === tab.id ? "is-active" : ""}
onClick={() => setDashTab(tab.id)}
onKeyDown={(event) => handleDashTabKeyDown(event, index)}
>
{tab.label}
</button>
))}
</div>
<section
@ -1041,8 +1123,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
aria-label="오늘 학습 대시보드"
>
<section
id="lh-panel-today"
className={surfaceClassName("lh-work-cluster")}
aria-label="오늘의 회기 작업"
role="tabpanel"
aria-labelledby="lh-tab-today"
tabIndex={0}
hidden={dashTab !== "today"}
>
<div className="lh-work-cluster__head">
<div>
@ -1050,14 +1136,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<Kicker dot={false}> </Kicker>
<h2>, , .</h2>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => navigate("/learn/practice")}
trailing={<Icon name="chevron-right" size={15} />}
>
</Button>
</div>
<div className="lh-work-cluster__primary">
@ -1237,15 +1315,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{recapCoachLine(spotlightSession)}
</p>
<Button
className="lh-coach-card__cta"
size="lg"
block
onClick={goPrimaryAction}
trailing={<Icon name="chevron-right" size={18} />}
>
{coach.action}
</Button>
</aside>
</div>
@ -1289,19 +1358,6 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
? "새 회기를 시작하기 전에 이미 끝난 대화의 반응과 대안 발화를 확인하세요."
: "다음 회기에서는 감정 반영 뒤 무엇을 더 물을지 한 문장으로 정하고 들어갑니다."}
</p>
<button
type="button"
onClick={() =>
navigate(
reviewCount > 0
? "/learn/history"
: "/learn/practice",
)
}
>
{reviewCount > 0 ? "리뷰 확인하기" : "연습 시작하기"}
<Icon name="chevron-right" size={15} />
</button>
</div>
</article>
@ -1365,10 +1421,16 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<aside
className="lh-dashboard-side"
aria-label={
dashTab === "growth" ? "성장 지표 패널" : "최근 기록 패널"
}
aria-label="학습 기록과 성장 지표"
>
<div
id="lh-panel-records"
className="lh-dashboard-side__tabpanel"
role="tabpanel"
aria-labelledby="lh-tab-records"
tabIndex={0}
hidden={dashTab !== "records"}
>
<article
className={surfaceClassName("lh-panel lh-panel--records")}
>
@ -1424,6 +1486,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
)}
</article>
</div>
<div
id="lh-panel-growth"
className="lh-dashboard-side__tabpanel"
role="tabpanel"
aria-labelledby="lh-tab-growth"
tabIndex={0}
hidden={dashTab !== "growth"}
>
{weaknessInsights.length > 0 ? (
<article
className={surfaceClassName(
@ -1548,6 +1621,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div>
</article>
) : null}
</div>
</aside>
</section>
</>
@ -1572,6 +1646,107 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</div>
</header>
{voicePracticeRequested ? (
voicePracticeContext ? (
<section className="lh-voice-practice-intent" aria-labelledby="lh-voice-practice-title">
<div>
<Kicker> </Kicker>
<h2 id="lh-voice-practice-title">
{voicePracticeContext.sceneType
? VOICE_SCENE_LABEL[voicePracticeContext.sceneType]
: "선택한 장면"} .
</h2>
<p>
. .
</p>
</div>
<dl>
<div><dt></dt><dd>{PRACTICE_SOURCE_SESSION_LABEL}</dd></div>
<div><dt></dt><dd>{voicePracticeContext.sourceSceneId ?? "전체 회기"}</dd></div>
<div>
<dt></dt>
<dd>
{voicePracticeContext.sceneStartMs != null && voicePracticeContext.sceneEndMs != null
? `${Math.floor(voicePracticeContext.sceneStartMs / 1000)}${Math.ceil(voicePracticeContext.sceneEndMs / 1000)}`
: "전체 구간"}
</dd>
</div>
</dl>
</section>
) : (
<section className="lh-voice-practice-intent is-error" role="alert">
<div>
<Kicker> </Kicker>
<h2> .</h2>
<p> . .</p>
</div>
</section>
)
) : null}
{practiceLaunchRequested ? (
practiceLaunchIntent ? (
<section
className="lh-voice-practice-intent lh-practice-launch-intent"
aria-labelledby="lh-practice-launch-title"
>
<div>
<Kicker>
{practiceLaunchIntent.kind === "transfer" ? "전이 검증" : "처방 연습"}
</Kicker>
<h2 id="lh-practice-launch-title">
{PRACTICE_MODE_LABEL[practiceLaunchIntent.mode]} .
</h2>
<p>
.
, .
</p>
</div>
<details className="lh-practice-launch-intent__details">
<summary>
<span> </span>
<b>{practiceCriterionLabel(practiceLaunchIntent.criterionId)}</b>
<Icon name="chevron-right" size={16} />
</summary>
<dl>
<div>
<dt> </dt>
<dd>{PRACTICE_MODE_LABEL[practiceLaunchIntent.mode]}</dd>
</div>
<div>
<dt> </dt>
<dd>{PRACTICE_NOVELTY_LABEL[practiceLaunchIntent.novelty]}</dd>
</div>
<div>
<dt> </dt>
<dd>{practiceCriterionLabel(practiceLaunchIntent.criterionId)}</dd>
</div>
<div>
<dt></dt>
<dd>{PRACTICE_SOURCE_SESSION_LABEL}</dd>
</div>
{practiceLaunchIntent.kind === "transfer" ? (
<div>
<dt> </dt>
<dd> </dd>
</div>
) : null}
</dl>
</details>
</section>
) : (
<section className="lh-voice-practice-intent is-error" role="alert">
<div>
<Kicker> </Kicker>
<h2> .</h2>
<p>
. .
</p>
</div>
</section>
)
) : null}
<section className="lh-practice-layout" aria-label="학습 대상 선택">
<aside className="lh-list-pane">
<div className="lh-pane-head">
@ -1634,7 +1809,11 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<Button
size="lg"
onClick={startPractice}
disabled={!selected}
disabled={
!selected ||
(voicePracticeRequested && !voicePracticeContext) ||
(practiceLaunchRequested && !practiceLaunchIntent)
}
trailing={<Icon name="chevron-right" size={18} />}
>