fix: tighten dashboard copy and freshness labels

This commit is contained in:
2026-04-06 20:40:59 +09:00
parent b47248b2be
commit 6596090677
3 changed files with 117 additions and 70 deletions

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,20 @@ 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 === 'qa-log')
.map((item) => item.time)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? null,
};
return {
focusProject: focusProject
? {
@@ -222,19 +243,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 +302,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>
@@ -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>