merge: hotfix-006 dashboard copy and live fidelity

This commit is contained in:
2026-04-06 20:47:24 +09:00
7 changed files with 325 additions and 72 deletions

View File

@@ -17,6 +17,7 @@
## 활성 작업
- **SPRINT-015**: Master Dashboard v3 정보 구조 리디자인
- **HOTFIX-006**: 대시보드 문구 절제 + 실시간성 정합성 보정
## 이번 Sprint 핵심 요구사항
1. 메인 대시보드를 4자매 운영 관제의 대표 화면으로 재정의
@@ -35,5 +36,7 @@
- 구조 기준: `../ARCHITECTURE.md`
- 디자인 인덱스: `./design/index.md`
- Sprint 계획: `./sprints/SPRINT-015.md`
- 나랑 handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
- Sprint handoff: `./sprints/SPRINT-015-NARANG-HANDOFF.md`
- Hotfix 계획: `./hotfix/HOTFIX-006.md`
- Hotfix handoff: `./hotfix/HOTFIX-006-NARANG-HANDOFF.md`
- 배포 플로우: `./deploy/main-release-flow.md`

View File

@@ -204,6 +204,20 @@ User → Harang → Narang → Darang → Irang 흐름과, maker-checker loop, e
- mock 텍스트/숫자로 고정하지 말고 실제 데이터가 들어올 자리를 먼저 설계할 것
- 값이 없을 때는 `No recent activity`, `No active deployment`, `No harness rules yet`처럼 조용한 empty state 제공
- offline / missing / unavailable 상태는 숨기지 말고 명확히 드러낼 것
- websocket 연결 상태와 데이터 freshness를 같은 의미로 쓰지 말 것
- 각 섹션은 가능하면 아래 source 중 하나로 구분할 것
- `runtime live`
- `event mirrored`
- `polling snapshot`
- `doc derived`
- `empty / unavailable`
- 추정/합성값이면 사실처럼 단정하지 말고 neutral wording 사용
## 카피 원칙
- 설명문보다 라벨형 문구 우선
- 첫 화면 문구는 짧고 건조해야 함
- `~만들었어`, `~정리했어`, `~읽히게` 같은 메타 설명 금지
- 보드/패널 body는 원문 요약 또는 상태 문구 위주로 짧게 유지
## 인터랙션 원칙
- hover 없어도 핵심 정보가 읽혀야 함

View File

@@ -0,0 +1,44 @@
# HOTFIX-006 Narang Handoff
## repo / branch
- repo: `https://git.nabomhalang.co.kr/hanarang/hanarang-dashboard`
- branch: `feature/hotfix-006-dashboard-copy-live-fidelity`
## 읽을 문서
1. `.plans/hotfix/HOTFIX-006.md`
2. `.plans/design/ui/dashboard-design.md`
## 이번 수정 핵심
자기야 피드백은 명확해.
1. 문구가 너무 길고 쓸데없는 말이 많아
2. 실시간처럼 보이는데 실제론 아닌 부분이 있어
이 두 개를 바로 고쳐.
## 꼭 손볼 것
- 상단 headline / summary
- current focus 설명문
- operator meta
- pipeline node detail / fallback
- infra/meta card detail
- board 카드 body 문구
- `socket live`, `LIVE ON` 같은 라벨의 의미
## 절대 기준
- 설명문 쓰지 마
- UI 자기소개하지 마
- 실시간 아닌 걸 실시간처럼 포장하지 마
- source-of-truth 애매하면 neutral wording으로 낮춰
- 문구는 짧고 건조하게
## 구현 목표
- `ACTIVE PIPELINE`은 진짜 이벤트 기반이면 그렇게 보이고
- 아니면 `SNAPSHOT`, `DOC-DERIVED`, `NO ACTIVE HANDOFF`처럼 정직하게 보여
- `MISTAKE LOG & HARNESS`도 원문/출처/시각 중심으로 짧게 정리
- freshness 메타를 넣어서 stale 여부를 읽을 수 있게 해
## QA 전 체크
- 자기야가 봤을 때 `주구절절하다`는 느낌이 없어야 해
- `실시간인가? 아닌가?`가 헷갈리면 실패야
- build 통과는 기본

142
.plans/hotfix/HOTFIX-006.md Normal file
View File

@@ -0,0 +1,142 @@
# HOTFIX-006: 대시보드 문구 절제 + 실시간성 정합성 보정
## 목표
Master Dashboard v3에서 주구절절한 설명형 문구를 운영 대시보드다운 짧은 문장으로 정리하고, `실시간처럼 보이지만 실제로는 폴링/요약/문서 기반인 정보`를 정확한 source-of-truth 기준으로 다시 맞춘다.
## 왜 이걸 고치나
현재 메인 대시보드는 레이아웃은 좋아졌는데 두 가지 문제가 남아 있어.
1. **문구가 길다**
- 상단 Headline / Summary
- OperatorMeta
- pipeline/detail/fallback 문장
- harness/qa 보드 설명
이런 부분이 대시보드보다 설명문처럼 읽혀.
2. **실시간성 표현이 과하다**
- `socket live`는 웹소켓 연결 상태일 뿐, 모든 카드/보드가 실시간 원본 반영이라는 뜻은 아니야.
- `ACTIVE PIPELINE`, `MISTAKE LOG & HARNESS`, 일부 infra/meta는 최근 activity / QA 문서 / polling snapshot을 조합해 만든 값이 섞여 있어.
- 따라서 `실시간 반영`처럼 보이는 문구와 라벨은 정정해야 해.
## 범위
- 대시보드 메인(`/`) 문구 전반 절제
- source-of-truth 명시 정리
- 실시간 / 폴링 / 문서기반 / 추정치 상태 구분
- 필요 시 백엔드 dashboard ops 응답에 freshness/source 메타 추가
- 프론트에서 misleading wording 제거
## 태스크
### TASK-078: 대시보드 카피를 운영형 짧은 문구로 전면 정리
- **담당:** 나랑이
- **상태:** pending
- **주요 파일:**
- `frontend/app/page.tsx`
- `frontend/components/dashboard/ActivePipeline.tsx`
- **설명:**
- 문장형 설명을 줄이고 짧은 운영 문구로 교체
- 섹션 제목/보조 문구/fallback/empty state를 모두 다시 본다
- **문구 기준:**
- 길게 설명하지 말 것
- 1문장보다 라벨형/상태형 표현 우선
- `~읽히게 만들었어`, `~정리했어` 같은 메타 설명 금지
- **완료 기준:**
- 첫 화면 문구가 대시보드 UI 문장처럼 짧고 건조함
- 자기소개/설계 설명 문장이 제거됨
### TASK-079: source-of-truth 기준표 정리
- **담당:** 나랑이
- **상태:** pending
- **주요 파일:**
- `backend/src/dashboard/dashboard.service.ts`
- 필요 시 dashboard DTO/타입 정의
- `.plans/design/ui/dashboard-design.md`
- **설명:** 각 섹션 값이 어디서 왔는지 명확히 구분한다.
- **최소 구분:**
- runtime live
- websocket event mirrored
- polling snapshot
- repo/qa doc derived
- fallback/empty
- **완료 기준:**
- 주요 카드/보드/파이프라인 값에 원본 기준을 설명할 수 있음
- 합성값을 실시간 원본처럼 표시하지 않음
### TASK-080: `LIVE` 표현 정합성 보정
- **담당:** 나랑이
- **상태:** pending
- **주요 파일:**
- `frontend/app/page.tsx`
- `frontend/lib/useSocket.ts`
- **설명:**
- 현재 `socket live`는 websocket 연결 상태만 뜻하므로 표현을 더 정확히 바꾼다.
- 예: `SOCKET ON`, `EVENT STREAM ON`, `SNAPSHOT`, `STALE`, `SYNCED 12s AGO` 같은 운영형 라벨로 정리
- **완료 기준:**
- websocket 연결 여부와 데이터 최신성이 혼동되지 않음
- LIVE ON/OFF 하나만 보고 전체가 실시간이라고 오해되지 않음
### TASK-081: `ACTIVE PIPELINE` 실데이터/추정치 경계 보정
- **담당:** 나랑이
- **상태:** pending
- **주요 파일:**
- `backend/src/dashboard/dashboard.service.ts`
- `frontend/components/dashboard/ActivePipeline.tsx`
- **설명:**
- 노드 상태/active task/focus가 최근 activity 기반 추론이면 그 사실을 숨기지 말 것
- 명시적 handoff/event가 없으면 억지 문장 대신 짧은 neutral fallback 사용
- **완료 기준:**
- `ACTIVE PIPELINE`이 추정치를 확정 사실처럼 말하지 않음
- 이벤트 없음/불충분 상태가 자연스럽게 보임
### TASK-082: `MISTAKE LOG & HARNESS` / 보드 카드 원문성 보강
- **담당:** 나랑이
- **상태:** pending
- **주요 파일:**
- `backend/src/dashboard/dashboard.service.ts`
- `frontend/app/page.tsx`
- **설명:**
- `관리자 harness 편집 로그에서 직접 가져온 기록이야.` 같은 설명형 body 제거
- 가능하면 원문 제목/원문 요약/출처/시각만 짧게 노출
- **완료 기준:**
- 보드 카드 문구가 덜 소설 같고 더 기록물처럼 읽힘
- source 라벨/시각이 더 명확함
### TASK-083: freshness 메타 추가 및 노출
- **담당:** 나랑이
- **상태:** pending
- **주요 파일:**
- `backend/src/dashboard/dashboard.controller.ts`
- `backend/src/dashboard/dashboard.service.ts`
- `frontend/app/page.tsx`
- **설명:** dashboard ops 응답에 최소 freshness 메타를 추가한다.
- **예시:**
- generatedAt
- activityLatestAt
- sistersLatestAt
- qaDocLatestAt
- **완료 기준:**
- UI에서 `last sync`, `activity lag`, `runtime lag` 같은 판단이 가능함
- stale 상태를 눈치채기 쉬움
## 문구 톤 기준 예시
### 금지
- "지금 어디가 움직이고 있는지, 첫 화면에서 바로 읽히게 만들었어."
- "장식보다 handoff 판단이 먼저 읽히게 정리했어."
- "현재 오너 기준은 ... 최근 handoff와 phase를 같이 보고 있어."
- "관리자 harness 편집 로그에서 직접 가져온 기록이야."
### 권장
- `MASTER DASHBOARD`
- `CURRENT FOCUS`
- `SOCKET ON`
- `SNAPSHOT`
- `NO ACTIVE HANDOFF`
- `NO RECENT QA`
- `LAST EVENT 2m AGO`
- `DOC-DERIVED`
## 완료 기준
- 문구가 짧고 운영 대시보드답게 바뀜
- 실시간/폴링/문서유도 값이 구분됨
- misleading `live` 표현이 제거됨
- 자기야가 봤을 때 `실시간 아닌데 실시간처럼 보인다`는 느낌이 사라짐

View File

@@ -23,6 +23,13 @@ interface OpsBoardItem {
source: string;
}
interface FreshnessMeta {
generatedAt: string;
activityLatestAt: string | null;
sistersLatestAt: string | null;
qaDocLatestAt: string | null;
}
const QA_PATH_CANDIDATES = ['.plans/qa/', '.qa/'];
const SISTER_ROLES: Record<string, string> = {
harang: 'Plan & Assign',
@@ -64,7 +71,7 @@ function nodeStateFromEvidence(params: {
function summarizeActivity(
activity?: ActivityRecord | null,
fallback = '최근 이벤트 없음',
fallback = 'NO EVENT',
) {
return (
activity?.detail?.trim() || activity?.action?.replace(/_/g, ' ') || fallback
@@ -149,8 +156,8 @@ export class DashboardService {
activity: latestProjectActivity,
}),
detail: latestProjectActivity
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, '최근 프로젝트 이벤트 없음')}`
: '명시적으로 기록된 handoff / request 로그가 아직 없어.',
? `${latestProjectActivity.project?.name ?? 'project'} · ${summarizeActivity(latestProjectActivity, 'NO EVENT')}`
: 'NO ACTIVE HANDOFF',
},
...sisters.map((sister) => {
const activity = latestBySister.get(sister.name) ?? null;
@@ -169,10 +176,10 @@ export class DashboardService {
detail: activity
? summarizeActivity(activity)
: sister.status === 'offline'
? '최근 런타임 응답이 없어서 offline 상태야.'
? 'RUNTIME OFFLINE'
: sister.lastSeen
? `최근 활동 기록은 ${new Date(sister.lastSeen).toISOString()} 체크 기준이야.`
: '최근 활동 로그가 아직 없어.',
? 'SNAPSHOT ONLY'
: 'NO EVENT',
};
}),
];
@@ -184,8 +191,8 @@ export class DashboardService {
(item) =>
({
id: `harness-${item.id}`,
title: item.detail ?? 'Harness updated',
body: '관리자 harness 편집 로그에서 직접 가져온 기록이야.',
title: item.detail ?? 'HARNESS UPDATED',
body: item.detail ?? 'HARNESS UPDATED',
tone: toneFromText(`${item.action} ${item.detail ?? ''}`),
author: item.sister?.name
? this.toDisplayName(item.sister.name)
@@ -208,6 +215,23 @@ export class DashboardService {
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 6);
const freshness: FreshnessMeta = {
generatedAt: new Date().toISOString(),
activityLatestAt: activities[0]?.createdAt?.toISOString() ?? null,
sistersLatestAt:
sisters
.map((sister) => sister.lastSeen)
.filter((value): value is Date => value instanceof Date)
.sort((a, b) => b.getTime() - a.getTime())[0]
?.toISOString() ?? null,
qaDocLatestAt:
board
.filter((item) => item.category === 'DOC-DERIVED')
.map((item) => item.time)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ??
null,
};
return {
focusProject: focusProject
? {
@@ -222,19 +246,17 @@ export class DashboardService {
pipeline: {
activeTask: focusProject
? `${focusProject.name} · ${focusProject.currentSprint ?? focusProject.phase}`
: 'No active pipeline',
: 'NO ACTIVE PIPELINE',
focus: latestProjectActivity
? summarizeActivity(
latestProjectActivity,
'최근 프로젝트 이벤트 없음',
)
: '최근 프로젝트 activity 기준으로 확정된 handoff가 아직 없어.',
? summarizeActivity(latestProjectActivity, 'NO EVENT')
: 'SNAPSHOT / NO ACTIVE HANDOFF',
reviewLoopCount,
escalationCount,
deployState: focusProject?.deployStatus ?? 'standby',
deployState: focusProject?.deployStatus ?? 'STANDBY',
nodes,
},
board,
freshness,
};
}
@@ -283,7 +305,7 @@ export class DashboardService {
tone,
author: inferQaAuthor(latestPath, content),
time: project.updatedAt,
category: 'qa-log',
category: 'DOC-DERIVED',
source: `${project.repoName}:${latestPath}`,
} satisfies OpsBoardItem,
];

View File

@@ -79,6 +79,12 @@ interface DashboardOpsData {
nodes: PipelineNode[];
};
board: BoardItem[];
freshness: {
generatedAt: string;
activityLatestAt: string | null;
sistersLatestAt: string | null;
qaDocLatestAt: string | null;
};
}
const pulse = keyframes`0%{opacity:.35}50%{opacity:.7}100%{opacity:.35}`;
@@ -724,6 +730,18 @@ function formatTime(iso?: string | null): string {
return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
}
function formatSyncLabel(iso?: string | null): string {
if (!iso) return 'NO SYNC';
const diff = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(diff) || diff < 0) return 'SYNC UNKNOWN';
const sec = Math.floor(diff / 1000);
if (sec < 60) return `SYNC ${sec}s AGO`;
const min = Math.floor(sec / 60);
if (min < 60) return `SYNC ${min}m AGO`;
const hr = Math.floor(min / 60);
return `SYNC ${hr}h AGO`;
}
function normalizePercent(value?: number | null): number {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(0, Math.min(100, value));
@@ -749,10 +767,10 @@ function buildSisterSummary(sister: SisterItem, activityItems: ActivityItem[], p
if (latestActivity?.detail) return latestActivity.detail;
const ownerProject = projects.find((project) => project.ownerSister === sister.name && project.phase !== 'DEPLOYED');
if (ownerProject) return `${ownerProject.name} · ${ownerProject.phase}`;
if (sister.status === 'offline') return '최근 런타임 응답 없음';
if (sister.status === 'working') return 'active pipeline 처리 중';
if (sister.status === 'online') return '새 handoff 대기 중';
return '운영 상태 확인 중';
if (sister.status === 'offline') return 'RUNTIME OFFLINE';
if (sister.status === 'working') return 'ACTIVE SNAPSHOT';
if (sister.status === 'online') return 'RUNTIME READY';
return 'SNAPSHOT ONLY';
}
function actionTone(action?: string, detail?: string | null): 'default' | 'warn' | 'ok' | 'active' {
@@ -837,6 +855,12 @@ export default function DashboardPage() {
}, []);
const derived = useMemo(() => {
const freshness = opsData?.freshness ?? {
generatedAt: null,
activityLatestAt: null,
sistersLatestAt: null,
qaDocLatestAt: null,
};
const onlineCount = sisters.filter((item) => item.status === 'online' || item.status === 'working').length;
const offlineCount = sisters.filter((item) => item.status === 'offline').length;
const activeProject = opsData?.focusProject
@@ -878,24 +902,24 @@ export default function DashboardPage() {
const infraCards = [
{
title: 'Gateway / Runtime',
meta: connected ? 'Socket live · runtime heartbeat 수신 중' : 'Polling mode · websocket 연결 재시도 중',
detail: `${onlineCount}/${Math.max(sisters.length, 4)} sisters active`,
title: 'Runtime',
meta: connected ? 'SOCKET / EVENT MIRRORED' : 'POLLING SNAPSHOT',
detail: `${onlineCount}/${Math.max(sisters.length, 4)} ACTIVE · ${formatSyncLabel(freshness.sistersLatestAt)}`,
},
{
title: 'Project Surface',
meta: `${projects.length} projects tracked · ${totalSprints} sprints indexed`,
detail: readyDeploy > 0 ? `${readyDeploy} ready for deploy` : 'deploy-ready backlog 없음',
title: 'Projects',
meta: `SNAPSHOT · ${projects.length} PROJECTS`,
detail: readyDeploy > 0 ? `${readyDeploy} READY FOR DEPLOY` : 'NO DEPLOY QUEUE',
},
{
title: 'Review Loop',
meta: `${reviewProjects} QA queue · first-pass ${firstPassRate}%`,
detail: escalationCount > 0 ? `blocker project ${escalationCount}` : 'escalation 없음',
title: 'QA',
meta: `DOC-DERIVED · ${firstPassRate}%`,
detail: escalationCount > 0 ? `${escalationCount} BLOCKER PROJECT` : 'NO BLOCKER',
},
{
title: 'Activity Feed',
meta: `${activityItems.length} recent events mirrored`,
detail: activityItems[0]?.detail ?? activityItems[0]?.action ?? '최근 이벤트 없음',
title: 'Events',
meta: `EVENTS ${activityItems.length}`,
detail: formatSyncLabel(freshness.activityLatestAt),
},
];
@@ -916,8 +940,9 @@ export default function DashboardPage() {
pipelineNodes,
infraCards,
board: opsData?.board ?? [],
activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'No active pipeline'),
deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'standby'),
activeTask: opsData?.pipeline.activeTask ?? (activeProject ? `${activeProject.name} · ${activeProject.currentSprint ?? activeProject.phase}` : 'NO ACTIVE PIPELINE'),
deployState: opsData?.pipeline.deployState ?? (activeProject?.deployStatus ?? 'STANDBY'),
freshness,
};
}, [activityItems, connected, opsData, projects, sisters]);
@@ -928,24 +953,22 @@ export default function DashboardPage() {
<TopBar>
<TopBlock>
<div>
<Eyebrow>Hanarang / master dashboard v3</Eyebrow>
<Headline> , .</Headline>
<Eyebrow>Master dashboard</Eyebrow>
<Headline>MASTER PIPELINE BOARD</Headline>
</div>
<Summary>
4 , active pipeline, activity, metrics, infra, . handoff .
</Summary>
<Summary>RUNTIME / EVENTS / DOCS</Summary>
<InlineStats>
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket live' : 'polling mode'}</Chip>
<Chip $tone="default">projects {String(projects.length).padStart(2, '0')}</Chip>
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa queue {String(derived.reviewProjects).padStart(2, '0')}</Chip>
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>ready deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
<Chip $tone={connected ? 'active' : 'muted'}>{connected ? 'socket on' : 'socket off'}</Chip>
<Chip $tone="default">snapshot {formatSyncLabel(derived.freshness.generatedAt)}</Chip>
<Chip $tone={derived.reviewProjects > 0 ? 'warning' : 'default'}>qa {String(derived.reviewProjects).padStart(2, '0')}</Chip>
<Chip $tone={derived.readyDeploy > 0 ? 'active' : 'muted'}>deploy {String(derived.readyDeploy).padStart(2, '0')}</Chip>
</InlineStats>
</TopBlock>
<FocusPanel>
<div>
<Eyebrow>Current operating focus</Eyebrow>
<FocusTitle>{derived.activeProject?.name ?? 'No active sprint'}</FocusTitle>
<Eyebrow>Current focus</Eyebrow>
<FocusTitle>{derived.activeProject?.name ?? 'NO ACTIVE SPRINT'}</FocusTitle>
</div>
<FocusBody>{derived.focusSummary}</FocusBody>
<TechBar>
@@ -954,11 +977,11 @@ export default function DashboardPage() {
<FocusMetaGrid>
<MetaCard>
<MetaLabel>current sprint</MetaLabel>
<MetaValue>{derived.activeProject?.currentSprint ?? 'backlog watch'}</MetaValue>
<MetaValue>{derived.activeProject?.currentSprint ?? 'SNAPSHOT'}</MetaValue>
</MetaCard>
<MetaCard>
<MetaLabel>phase</MetaLabel>
<MetaValue>{derived.activeProject?.phase ?? 'monitoring'}</MetaValue>
<MetaValue>{derived.activeProject?.phase ?? 'SNAPSHOT'}</MetaValue>
</MetaCard>
<MetaCard>
<MetaLabel>sprint progress</MetaLabel>
@@ -966,7 +989,7 @@ export default function DashboardPage() {
</MetaCard>
<MetaCard>
<MetaLabel>deploy state</MetaLabel>
<MetaValue>{derived.activeProject?.deployStatus ?? 'no deploy signal'}</MetaValue>
<MetaValue>{derived.activeProject?.deployStatus ?? 'NO SIGNAL'}</MetaValue>
</MetaCard>
</FocusMetaGrid>
</FocusPanel>
@@ -976,11 +999,11 @@ export default function DashboardPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<ConnectionDot $connected={connected} />
<div>
<Eyebrow>Runtime link</Eyebrow>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'Discord / runtime connected' : 'Polling fallback active'}</div>
<Eyebrow>Event stream</Eyebrow>
<div style={{ fontSize: 14, color: 'var(--text-primary)', marginTop: 4 }}>{connected ? 'SOCKET ON / EVENT MIRRORED' : 'SOCKET OFF / POLLING'}</div>
</div>
</div>
<LabelMeta><span>LIVE:</span>{connected ? 'ON' : 'OFF'}</LabelMeta>
<LabelMeta><span>SYNC:</span>{formatSyncLabel(derived.freshness.activityLatestAt)}</LabelMeta>
</ConnectionCard>
<OperatorRow>
@@ -988,7 +1011,7 @@ export default function DashboardPage() {
<OperatorText>
<OperatorName>{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.label ?? '나랑이'}</OperatorName>
<OperatorMeta>
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'}. handoff와 phase를 .
{SISTER_META[derived.activeProject?.ownerSister ?? 'narang']?.role ?? 'Implementation'} / SNAPSHOT OWNER
</OperatorMeta>
</OperatorText>
</OperatorRow>
@@ -1018,7 +1041,7 @@ export default function DashboardPage() {
{loading
? Array.from({ length: 4 }).map((_, index) => <SkeletonCard key={index} />)
: sisters.length === 0
? <EmptyState> .</EmptyState>
? <EmptyState>NO RUNTIME DATA.</EmptyState>
: sisters.map((sister) => {
const sisterMeta = SISTER_META[sister.name] ?? { label: sister.name, role: sister.role ?? 'unknown', accent: '#888888' };
return (
@@ -1080,8 +1103,8 @@ export default function DashboardPage() {
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Activity feed</Eyebrow>
<PanelTitle> handoff / review / deploy </PanelTitle>
<PanelDesc> , , .</PanelDesc>
<PanelTitle>EVENT MIRROR</PanelTitle>
<PanelDesc>WEBSOCKET + POLLING SNAPSHOT</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>EVENT:</span>{String(activityItems.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
@@ -1089,7 +1112,7 @@ export default function DashboardPage() {
{loading ? (
<SkeletonCard />
) : activityItems.length === 0 ? (
<EmptyState>No recent activity.</EmptyState>
<EmptyState>NO RECENT EVENT.</EmptyState>
) : (
<ActivityList>
{activityItems.slice(0, 6).map((item) => (
@@ -1117,8 +1140,8 @@ export default function DashboardPage() {
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Sprint metrics</Eyebrow>
<PanelTitle> </PanelTitle>
<PanelDesc>fake /QA/activity에서 .</PanelDesc>
<PanelTitle>SPRINT METRICS</PanelTitle>
<PanelDesc>POLLING SNAPSHOT</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>SPRINT:</span>{String(derived.totalSprints).padStart(2, '0')}</LabelMeta>
</PanelHeader>
@@ -1127,22 +1150,22 @@ export default function DashboardPage() {
<MetricCard>
<MetaLabel>total tasks view</MetaLabel>
<MetricValue>{derived.totalSprints}</MetricValue>
<MetricHelp> sprint .</MetricHelp>
<MetricHelp>INDEXED SPRINT COUNT</MetricHelp>
</MetricCard>
<MetricCard>
<MetaLabel>done sprint</MetaLabel>
<MetricValue>{derived.doneSprints}</MetricValue>
<MetricHelp> sprint .</MetricHelp>
<MetricHelp>DONE SPRINTS</MetricHelp>
</MetricCard>
<MetricCard>
<MetaLabel>first-pass rate</MetaLabel>
<MetricValue>{derived.firstPassRate}%</MetricValue>
<MetricHelp> QA blocker .</MetricHelp>
<MetricHelp>QA DOC DERIVED</MetricHelp>
</MetricCard>
<MetricCard>
<MetaLabel>escalations</MetaLabel>
<MetricValue>{derived.escalationCount}</MetricValue>
<MetricHelp>blocker escalation .</MetricHelp>
<MetricHelp>BLOCKER PROJECTS</MetricHelp>
</MetricCard>
</MetricsGrid>
@@ -1163,8 +1186,8 @@ export default function DashboardPage() {
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Infrastructure overview</Eyebrow>
<PanelTitle>gateway / sisters / deploy surface</PanelTitle>
<PanelDesc> .</PanelDesc>
<PanelTitle>RUNTIME SURFACE</PanelTitle>
<PanelDesc>LIVE / SNAPSHOT / DOC-DERIVED</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>NODE:</span>{String(sisters.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
@@ -1174,7 +1197,7 @@ export default function DashboardPage() {
<InfraCard key={card.title}>
<InfraTitle>{card.title}</InfraTitle>
<InfraMeta>{card.meta}</InfraMeta>
<EventTag $tone={card.title === 'Gateway / Runtime' && connected ? 'ok' : card.detail.includes('없음') ? 'default' : 'active'}>{card.detail}</EventTag>
<EventTag $tone={card.title === 'Runtime' && connected ? 'ok' : card.detail.startsWith('NO ') ? 'default' : 'active'}>{card.detail}</EventTag>
</InfraCard>
))}
</InfraMap>
@@ -1184,14 +1207,14 @@ export default function DashboardPage() {
<PanelHeader>
<PanelTitleBlock>
<Eyebrow>Mistake log & harness</Eyebrow>
<PanelTitle> </PanelTitle>
<PanelDesc> QA harness .</PanelDesc>
<PanelTitle>RULE / LOG BOARD</PanelTitle>
<PanelDesc>DOC-DERIVED / ACTIVITY</PanelDesc>
</PanelTitleBlock>
<LabelMeta><span>RULE:</span>{String(derived.board.length).padStart(2, '0')}</LabelMeta>
</PanelHeader>
{derived.board.length === 0 ? (
<EmptyState> QA / harness .</EmptyState>
<EmptyState>NO RULE LOG.</EmptyState>
) : (
<LessonList>
{derived.board.map((item) => (
@@ -1203,7 +1226,11 @@ export default function DashboardPage() {
<EventTag>{formatRelativeTime(item.time)}</EventTag>
</LessonMeta>
<LessonBody>{item.body}</LessonBody>
<LessonBody>Source · {item.source}</LessonBody>
<LessonMeta>
<EventTag>SRC</EventTag>
<EventTag>{item.source}</EventTag>
<EventTag>{formatTime(item.time)}</EventTag>
</LessonMeta>
</LessonCard>
))}
</LessonList>

View File

@@ -64,6 +64,7 @@ const Summary = styled.p`
color: var(--text-secondary);
line-height: 1.6;
max-width: 780px;
text-transform: uppercase;
`;
const HeaderStats = styled.div`
@@ -248,7 +249,7 @@ export default function ActivePipeline({
<PipelineCard>
<Header>
<div>
<Eyebrow>Master flow / active pipeline</Eyebrow>
<Eyebrow>Active pipeline / snapshot</Eyebrow>
<Title>{activeTask}</Title>
<Summary>{focus}</Summary>
</div>
@@ -282,7 +283,7 @@ export default function ActivePipeline({
<StateBadge $state={node.state}>{stateText[node.state]}</StateBadge>
</NodeTop>
<NodeDetail>{node.detail}</NodeDetail>
<NodeHint>stage {String(index + 1).padStart(2, '0')}</NodeHint>
<NodeHint>snapshot {String(index + 1).padStart(2, '0')}</NodeHint>
</NodeCard>
{index < nodes.length - 1 && <Connector $active={connectorActive} />}
</NodeWrap>