feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -52,6 +52,36 @@ import "./learner-home.css";
type LoadState = "loading" | "ready" | "error";
export type LearnerHomeView = "dashboard" | "practice" | "history";
// ── D3(가운뎃점 라인당 1개) 보조 헬퍼 ────────────────────────────────────────
/**
* display_name "이름(가명) · 학년 · 주호소"
* 2 . ()
* 1 .
*/
function splitPersonaTitle(name: string): { head: string; tail: string } {
const parts = name
.split("·")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length <= 2) return { head: name.trim(), tail: "" };
return {
head: parts.slice(0, 2).join(" · "),
tail: parts.slice(2).join(" · "),
};
}
/**
* ··
* . "큰 구분", "같은 종류의 나열"
* 1 .
* ) "기초 · cbt · humanistic" "기초 · cbt, humanistic"
*/
function oneMiddot(text: string): string {
const parts = text.split(" · ");
if (parts.length <= 2) return text;
return `${parts[0]} · ${parts.slice(1).join(", ")}`;
}
interface LearnerHomeProps {
view?: LearnerHomeView;
}
@ -582,11 +612,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
spotlightPersona?.display_name ??
spotlightSession?.persona_name ??
"첫 연습 대상 선택";
// D3: 난도 + 이론 목록이 겹쳐 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
const recapPersonaSubtitle = spotlightPersona
? `${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`
? oneMiddot(
`${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`,
)
: spotlightSession
? `${spotlightSession.persona_code} · ${sessionStatusLabel(spotlightSession)}`
: "학습 대상에서 내담자를 선택하세요";
// D3: 페르소나 제목은 "이름 · 학년"만 제목 줄에 두고 주호소는 아랫줄로 내린다.
const recapTitleParts = splitPersonaTitle(recapSessionTitle);
const previewTitleParts = splitPersonaTitle(selected?.display_name ?? "");
const recapProgress = stageProgressValue(spotlightSession?.stage);
const coachHints = spotlightSession
? [
@ -683,9 +719,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<span>{session.persona_code}</span>
<b>{shortPersonaName(session.persona_name)}</b>
</div>
{/* D3: "단계 · 턴 · 날짜"로 가운뎃점이 2개였다. 날짜는 별도 컬럼으로 분리 */}
<p>
{session.stage} · {session.turn_count} ·{" "}
{sessionDateLabel(session.started_at)}
<span>
{session.stage} · {session.turn_count}
</span>
<span>{sessionDateLabel(session.started_at)}</span>
</p>
<span className="lh-session-card__progress" aria-hidden="true">
<span style={{ width: `${progress}%` }} />
@ -780,9 +819,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
</span>
<span className="lh-compact-list__body">
<b>{shortPersonaName(session.persona_name)}</b>
{/* D3: 가운뎃점 2개였던 줄 — 날짜를 오른쪽 컬럼으로 분리 */}
<small>
{session.persona_code} · {session.stage} ·{" "}
{sessionDateLabel(session.started_at)}
<span>
{session.persona_code} · {session.stage}
</span>
<span>{sessionDateLabel(session.started_at)}</span>
</small>
</span>
{mode === "review" ? (
@ -851,10 +893,14 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const usablePersona = isUsablePersona(persona);
const selectedPersona =
usablePersona && persona.code === selected?.code;
const personaMeta =
// D3: 이론이 2개면 "기초 · cbt · humanistic"처럼 가운뎃점이 2개가 된다.
const personaMeta = oneMiddot(
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
" · " +
theoryLine(persona);
" · " +
theoryLine(persona),
);
// D3: 카드 이름줄도 "이름 · 학년"까지만 두고 주호소는 아랫줄로 내린다.
const personaTitle = splitPersonaTitle(persona.display_name);
return (
<button
key={persona.code}
@ -874,12 +920,19 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<span className="lh-persona__mark">{persona.code}</span>
<span className="lh-persona__body">
<span className="lh-persona__name">
{persona.display_name}
{personaTitle.head}
</span>
{personaTitle.tail ? (
<span className="lh-persona__concern">
{personaTitle.tail}
</span>
) : null}
<span className="lh-persona__meta">
{usablePersona
? personaMeta
: `${personaMeta} · 사용 불가`}
<span>{personaMeta}</span>
{/* D3: "· 사용 불가"를 이어 붙이면 가운뎃점이 늘어나 별도 칩으로 분리 */}
{usablePersona ? null : (
<b className="lh-persona__flag"> </b>
)}
</span>
<span className="lh-persona__summary">
{usablePersona
@ -913,7 +966,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
{/* D1: 단순 섹션 라벨이라 상태 dot 제거(대시보드 화면 dot 밀집 정리) */}
<Kicker dot={false}> </Kicker>
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, ,
@ -992,7 +1046,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-work-cluster__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — 상태가 아니므로 dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2>, , .</h2>
</div>
<Button
@ -1013,12 +1068,20 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-session-focus__head">
<div>
<Kicker>
{/* D1: 실제 진행 상태는 우측 status 배지가 맡으므로 dot 제거 */}
<Kicker dot={false}>
{spotlightSession
? "오늘 이어갈 회기"
: "첫 회기 준비"}
</Kicker>
<h2>{recapSessionTitle}</h2>
<h2>
{recapTitleParts.head}
{recapTitleParts.tail ? (
<span className="lh-title-tail">
{recapTitleParts.tail}
</span>
) : null}
</h2>
<p>{recapPersonaSubtitle}</p>
</div>
<span className="lh-session-focus__status">
@ -1149,7 +1212,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
aria-label="AI 코치"
>
<div>
<Kicker>AI </Kicker>
{/* D1: 패널 이름표 — 상태가 아니므로 dot 제거 */}
<Kicker dot={false}>AI </Kicker>
<h2>{coach.title}</h2>
<p>{coach.body}</p>
</div>
@ -1194,7 +1258,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2>
{reviewCount > 0
? "리뷰 확인을 우선합니다."
@ -1248,7 +1313,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — 건수 상태는 우측 badge 가 표시한다 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
<span className="lh-panel__badge">
@ -1277,10 +1343,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
)
}
>
{/* D3: 가운뎃점 2개였던 줄 — 평가 점수는 오른쪽 컬럼으로 분리 */}
<b>
{recentFeedback[0].persona_code} ·{" "}
{recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
<span>
{recentFeedback[0].persona_code} ·{" "}
{recentFeedback[0].stage}
</span>
<span>{scoreLabel(recentFeedback[0].score)}</span>
</b>
<span>
{truncateText(recentFeedback[0].note, 86)}
@ -1310,7 +1379,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
flat: true,
})}
>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
<button
@ -1337,7 +1407,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
flat: true,
})}
>
<Kicker> </Kicker>
{/* D4: eyebrow "리뷰 대기"는 바로 아래 제목과 같은 말이라 삭제 */}
<h2> .</h2>
</div>
<button
@ -1362,6 +1432,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
{/* D1: 평가 기록에서 파생된 "현재 신호"라 dot 유지 */}
<Kicker> </Kicker>
<h2> .</h2>
</div>
@ -1391,7 +1462,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
flat: true,
})}
>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
</div>
@ -1452,7 +1524,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-panel__head">
<div>
<Kicker> </Kicker>
{/* D1: 섹션 라벨 — dot 제거 */}
<Kicker dot={false}> </Kicker>
<h2> .</h2>
</div>
</div>
@ -1484,7 +1557,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
{/* D4: eyebrow "학습 대상"은 아래 제목("연습할 내담자")과 같은 말이라 삭제 */}
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
.
@ -1529,15 +1602,26 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
: (selected?.code ?? "...")}
</span>
<h2 id="lh-preview-title">
{personaCatalogUnavailable
? "사용 가능한 내담자가 없습니다."
: (selected?.display_name ??
"내담자 정보를 불러오는 중")}
{personaCatalogUnavailable ? (
"사용 가능한 내담자가 없습니다."
) : selected ? (
<>
{previewTitleParts.head}
{previewTitleParts.tail ? (
<span className="lh-title-tail">
{previewTitleParts.tail}
</span>
) : null}
</>
) : (
"내담자 정보를 불러오는 중"
)}
</h2>
{/* D3: 인적 정보도 가운뎃점 1개만 남기고 나머지는 쉼표 나열 */}
<p>
{personaCatalogUnavailable
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
: demographicsLine(selected)}
: oneMiddot(demographicsLine(selected))}
</p>
</div>
</div>
@ -1660,7 +1744,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<>
<header className="lh-head">
<div className="lh-head__copy">
<Kicker> </Kicker>
{/* D4: eyebrow "회기 기록"은 아래 제목과 같은 말이라 삭제 */}
<h1 className="lh-title"> .</h1>
<p className="lh-sub">
, ,
@ -1702,7 +1786,7 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
>
<div className="lh-history-main__head">
<div>
<Kicker> </Kicker>
{/* D4: eyebrow "기록 목록"은 아래 제목("전체 회기"/필터명)과 겹쳐 삭제 */}
<h2>
{historyFilter === "all"
? "전체 회기"
@ -1833,12 +1917,21 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
<b>{row.code}</b>
<em>{row.name}</em>
</span>
{/* D3: 가운뎃점 3개였던 줄 — 진행/리뷰 건수를 별도 컬럼으로 쪼갠다 */}
<span className="lh-persona-progress__meta">
{row.total > 0
? `${row.total}회 · ${row.active}회 진행 · ${row.reviewReady}회 리뷰 · 평균 ${
row.averageTurns ?? 0
}`
: "아직 진행 없음"}
{row.total > 0 ? (
<>
<span>
{row.total} · {" "}
{row.averageTurns ?? 0}
</span>
<span>
{row.active} · {row.reviewReady}
</span>
</>
) : (
<span> </span>
)}
</span>
<span
className="lh-persona-progress__bar"