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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
|
|
@ -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} />}
|
||||
>
|
||||
새 회기 시작
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue