vignette/apps/web/src/lib/format.ts
Yun Chan 24b1b7a6e1 feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
2026-06-25 23:37:22 +09:00

85 lines
3.1 KiB
TypeScript

/* =====================================================================
Vignette — 포맷 유틸 (시간/타이머/숫자). 전부 tabular-nums 전제.
===================================================================== */
/** 초 → "M:SS" (세션 타이머용, 0~59분). 1시간 넘으면 "H:MM:SS". */
export function formatElapsed(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(s / 3600);
const minutes = Math.floor((s % 3600) / 60);
const seconds = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
if (hours > 0) return `${hours}:${pad(minutes)}:${pad(seconds)}`;
return `${minutes}:${pad(seconds)}`;
}
/** 초 → "MM:SS" (항상 2자리 분). 자막 타임코드용. */
export function formatTimecode(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const minutes = Math.floor(s / 60);
const seconds = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(minutes)}:${pad(seconds)}`;
}
/** 초 → "12분", "1시간 5분" 등 한국어 사람친화 표기. */
export function formatDurationKo(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hours = Math.floor(s / 3600);
const minutes = Math.floor((s % 3600) / 60);
if (hours > 0 && minutes > 0) return `${hours}시간 ${minutes}`;
if (hours > 0) return `${hours}시간`;
if (minutes > 0) return `${minutes}`;
return `${s}`;
}
/** 정수에 천단위 콤마 (tabular 표기 전제). */
export function formatNumber(n: number): string {
return new Intl.NumberFormat("ko-KR").format(n);
}
/** 0~1 비율 → 백분율 정수 문자열 "72%". */
export function formatPercent(ratio: number, fractionDigits = 0): string {
const pct = clamp01(ratio) * 100;
return `${pct.toFixed(fractionDigits)}%`;
}
/** 변화량 표기: +6 / -3 / 0 (부호 명시). */
export function formatDelta(delta: number): string {
if (delta > 0) return `+${delta}`;
return String(delta);
}
/** 추세 방향 (화살표는 컴포넌트가 토큰 색으로 렌더). 이모지 아님. */
export type Trend = "up" | "down" | "flat";
export function trendOf(delta: number, deadband = 0): Trend {
if (delta > deadband) return "up";
if (delta < -deadband) return "down";
return "flat";
}
/** ISO 날짜/Date → "6/24", "2026-06-24" 등. */
export function formatDateShort(input: string | Date): string {
const d = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(d.getTime())) return "";
return `${d.getMonth() + 1}/${d.getDate()}`;
}
export function formatDateISO(input: string | Date): string {
const d = typeof input === "string" ? new Date(input) : input;
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** 0~1 클램프. */
export function clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
/** 임의 범위 클램프. */
export function clamp(v: number, min: number, max: number): number {
return Math.min(Math.max(v, min), max);
}