72 KiB
Phase 5 — 대시보드(Dashboard) 페이지
한 줄 요약: 아침 브리핑 · 결재함/인박스 요약 카드 · 오늘 일정/작업/목표 요약 · 자연어 명령 입력을 한 화면에 모은 "요약/알림 허브"를
GET /api/dashboard집계 한 번으로 그려내는 읽기 중심 페이지를 완성한다.이 문서는
dev/문서 세트의 일부입니다 — 먼저overview.md를 읽으세요. 선행 문서:phase-0-foundation.md,phase-1-design-system.md,phase-2-backend.md,phase-3-tasks.md,phase-4-inbox.md.
1. 개요 & 목표
1.1 이 phase가 끝나면 무엇이 동작하는가
/dashboard 라우트를 열면 다음이 한 화면(벤토 그리드)에 시드 데이터 기준으로 표시된다.
- 히어로 브리핑 — "좋은 아침이에요, 지우님" + 날씨/출근/수면 한 줄 +
briefingNote(HTML,<b>강조 포함) + 추천 칩 3개 + 자연어 명령 입력창. - 결재함 요약 카드 —
risk === "high"대기 건 목록 3개(미니 리스트) + "오늘 47분 아껴드렸어요" 서브 + "결재함에서 승인하기" CTA(MVP에서는/결재함placeholder로 라우팅, "준비 중"). - 인박스 요약 카드 — 최근 캡처 3건 미니 리스트 + "오늘 7건" 서브 + "새로 적기 · 인박스 열기" CTA(→
/inbox, 실제 동작). - 오늘 일정 요약 —
schedule4건(시간·제목·태그·길이·"곧" 배지) + 일정 노트. - 할 일 요약 —
task_summary(남은 개수 + 상위 작업 목록, 첫 항목 "지금" 포커스). - 목표 요약 —
goals3건(제목·퍼센트·진행 막대·서브). - 자연어 명령 입력 — 히어로 하단
.cmd입력창. 입력 후 전송 시 인박스 캡처(POST /api/inbox/capture) 호출 → 토스트로 "인박스에 적어뒀어요" 안내 후/inbox로 이동(MVP 동작, 5.5 정의). - 라이트/다크 테마 — Topbar 토글에 따라 모든 카드/히어로 색이 토큰 기반으로 전환.
- 로딩 스켈레톤 / 빈 상태 / 부분 실패(위젯별) 처리.
MVP 범위 한정(CONTRACT 준수): 원본
dash-v2.jsx에는 13개 위젯(미팅 준비·위임 현황·이어서 하기·집중도 차트·메일·건강·지출·오늘의 집중 등)이 있으나, 이번 MVP 대시보드는 백엔드GET /api/dashboard가 집계하는 6개 블록(브리핑/결재함/인박스/일정/작업/목표)만 실데이터로 렌더한다. 나머지 위젯은 본 phase에서 구현하지 않는다(원본의 픽셀 보캐뷸러리만 5.8 디자인 충실도 절에서 참고로 인용). 결재함/일정 전용 페이지는 placeholder이므로 카드 CTA는 "준비 중"으로 라우팅하고 카드 본문은 읽기 전용 시드만 표시한다.
1.2 핵심 제품 원칙 반영
- "적을 때는 분류하지 않는다 — 분류·배치·자동화는 아리가." → 대시보드의 자연어 명령 입력은 사용자가 "그냥 적기"만 하면 인박스로 흘려보내 아리가 분류하도록 한다.
- "할까요?"가 아니라 "이미 해뒀어요." → 결재함 요약은 "치과 예약을 16:00로 옮겼어요"처럼 이미 실행된 결과를 보여주고 승인/되돌리기만 받는다. 일정 노트 역시 "치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요."로 표현한다.
2. 선행 조건(의존 phase) / 산출물(Deliverables)
2.1 선행 조건
| 의존 | 문서 | 이 phase에서 필요한 것 |
|---|---|---|
| 모노레포/개발환경 | phase-0-foundation.md |
frontend(Next.js App Router)·backend(FastAPI) 기동, dev 프록시 |
| 디자인 시스템 | phase-1-design-system.md |
tokens.css, <Icon>, <Topbar current="dash">, <GlassCard>, 테마 토글(next-themes), placeholder 라우팅 |
| 백엔드 | phase-2-backend.md |
event/approval/goal/briefing 시드 테이블, GET /api/dashboard 라우터, task_summary 집계, POST /api/inbox/capture |
| 작업 페이지 | phase-3-tasks.md |
작업 타입(Task, status enum), 작업 요약이 참조하는 task 시드 |
| 인박스 페이지 | phase-4-inbox.md |
inbox_item·inbox_classification 시드, POST /api/inbox/capture 동작(자연어 명령이 호출) |
대시보드는 다른 데이터를 집계하므로 빌드 순서상 마지막 페이지다(작업 → 인박스 → 대시보드).
phase-6-integration.md에서 연합 흐름 E2E로 마무리한다.
2.2 산출물(Deliverables)
backend/app/
└─ (대시보드 백엔드는 phase-2 소유) # schemas.py(DashboardOut 등)·routers/dashboard.py(GET /api/dashboard 인라인 집계)·models.py(event/approval/goal/briefing)·seed.py(_seed_dashboard)는 phase-2-backend.md §3.14가 단일 소유. 본 phase는 그 응답을 소비만 하며 백엔드를 재정의하지 않는다.
frontend/
├─ app/dashboard/page.tsx # 서버 컴포넌트 진입(메타데이터) → <DashboardClient/>
├─ components/dashboard/
│ ├─ DashboardClient.tsx # 페이지 셸 + 데이터 패칭 + 벤토 그리드
│ ├─ HeroBriefing.tsx # 히어로(브리핑 + 칩 + 명령 입력)
│ ├─ CommandInput.tsx # 자연어 명령 입력창(.cmd)
│ ├─ ApprovalSummaryCard.tsx # 결재함 요약(미니 리스트 + CTA)
│ ├─ InboxSummaryCard.tsx # 인박스 요약(미니 리스트 + CTA)
│ ├─ ScheduleCard.tsx # 오늘 일정(.sched)
│ ├─ TaskSummaryCard.tsx # 할 일(.tasks)
│ ├─ GoalsCard.tsx # 목표(.goals)
│ ├─ MiniRow.tsx # 요약 카드 공용 미니 행(approve.css .mini-row)
│ ├─ DashboardSkeleton.tsx # 로딩 스켈레톤
│ └─ WidgetError.tsx # 위젯별 부분 실패 표시
├─ lib/
│ ├─ types.ts # Dashboard* 타입(schemas.py와 1:1)
│ ├─ api.ts # getDashboard(), captureInbox()
│ └─ hooks/useDashboard.ts # SWR/React Query 훅
├─ styles/dashboard.css # dash.css + approve.css의 대시보드 관련 규칙 이식
└─ tests/ # 컴포넌트/E2E/접근성 테스트
├─ HeroBriefing.test.tsx
├─ ApprovalSummaryCard.test.tsx
├─ GoalsCard.test.tsx
└─ ...
frontend/playwright/dashboard.spec.ts # E2E
backend/tests/test_dashboard.py # API 집계 테스트
3. 상세 구현(파일별, 단계별)
3.1 백엔드 — 시드 테이블(phase-2 소유, 동일 형태 참조)
대시보드 읽기전용 테이블(briefing/event/approval/goal)은 phase-2-backend.md가 유일하게 정의·소유한다. 이 phase는 모델을 재정의하지 않고 phase-2의 동일 형태를 참조해 집계·소비만 한다(프론트 컴포넌트는 GET /api/dashboard 정본 응답을 소비). 아래는 참조 편의를 위한 phase-2 정의 발췌이며, 필드명은 CONTRACT 고정값과 원본 데이터 파일을 그대로 따른다.
# backend/app/models.py (phase-2 소유 — 참조용 발췌, 본 phase에서 재정의하지 않음)
from datetime import datetime
from sqlmodel import SQLModel, Field
class Briefing(SQLModel, table=True):
"""아침 브리핑 — 단일 row(id=1) 또는 settings로 운용."""
id: int = Field(default=1, primary_key=True)
today: str # "6월 7일 일요일"
weather_temp: int # 24
weather_cond: str # "맑음 · 한낮 28°"
weather_icon: str # "cloudSun" 저장 → 응답/표시 시 'sun' 으로 매핑(R11/R12, 디자인 충실도 노트)
commute: str # "출근 23분 · 평소보다 4분 빠름"
sleep: str # "어젯밤 7시간 12분 · 평소만큼 푹 잤어요"
note: str # briefingNote (HTML 허용; <b> 강조)
saved_today: str # "47분" (결재함 카드 서브용)
today_routed: int # 7 (인박스 카드 서브용)
class Event(SQLModel, table=True):
"""오늘 일정(읽기 전용 시드)."""
id: str = Field(primary_key=True) # "e1"
time: str # "09:30"
title: str # "팀 데일리 스탠드업"
tag: str # "프로덕트"
dur: str # "15분"
tone: str # blue|violet|coral|green
soon: bool = False
sort_order: int = 0
class Approval(SQLModel, table=True):
"""결재함(승인 큐) — 대시보드 요약 + (placeholder) 전용 페이지 공용."""
id: str = Field(primary_key=True) # "a1"
icon: str # "cal" | "mail" | "users" | "wallet"
tone: str # coral|blue|violet|green|amber
risk: str # "low" | "high" → high만 요약 카드에 노출
time: str # "07:42" | "보내기 대기" 등
title: str # "치과 예약을 16:00로 옮겼어요"
detail: str
cta: str | None = None # "보내기" (risk=high)
alt: str | None = None # "수정"
undo_label: str | None = None # "원래 시간으로" (risk=low)
sort_order: int = 0
class Goal(SQLModel, table=True):
id: str = Field(primary_key=True) # "g1"
title: str # "분기 OKR — 사용자 리텐션"
pct: int # 68
sub: str # "12개 중 8개 달성"
tone: str # "blue" (tone 키. 프론트에서 var(--blue)로 변환. 'var(--blue)'로 저장하지 않음)
sort_order: int = 0
참고: 원본
data.js는goals[].c: "var(--blue)"처럼 CSS 변수 문자열을 그대로 담지만, 백엔드 스키마는 tone 키(blue) 만 저장하고 프론트에서var(--${tone})로 변환한다(CONTRACT tone 값 집합 준수:blue|violet|coral|green|amber|ink|faint).
3.2 백엔드 — 시드 데이터(seed.py)
원본 파일의 값을 그대로 옮긴다. briefingNote/note의 HTML은 보존한다.
시드 진입점은
phase-2의run_seed(session=None, reset=True)단일 정본이며, 대시보드 시드는 그 내부에서 호출되는 비공개 헬퍼_seed_dashboard(session)로 둔다(공개 진입점으로 노출하지 않음).run_seed가 자체/주입 세션을 만들고reset후_seed_dashboard(session)를 호출한다.
# backend/app/seed.py (대시보드 시드 발췌 — run_seed 내부에서 호출되는 비공개 헬퍼)
def _seed_dashboard(session):
session.add(Briefing(
id=1,
today="6월 7일 일요일",
weather_temp=24, weather_cond="맑음 · 한낮 28°", weather_icon="cloudSun", # 표시 시 'sun' 매핑
commute="출근 23분 · 평소보다 4분 빠름",
sleep="어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
note=("오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 "
"집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."),
saved_today="47분",
today_routed=7,
))
events = [
Event(id="e1", time="09:30", title="팀 데일리 스탠드업", tag="프로덕트", dur="15분", tone="blue", sort_order=0),
Event(id="e2", time="11:00", title="디자인 리뷰 — 온보딩 플로우", tag="디자인", dur="45분", tone="violet", sort_order=1),
Event(id="e3", time="14:00", title="분기 전략 미팅", tag="경영진", dur="60분", tone="coral", soon=True, sort_order=2),
Event(id="e4", time="16:30", title="1:1 — 민서님", tag="팀", dur="30분", tone="green", sort_order=3),
]
session.add_all(events)
# approve-data.js 그대로
approvals = [
Approval(id="a1", icon="cal", tone="coral", risk="low", time="07:42",
title="치과 예약을 16:00로 옮겼어요",
detail="14시 분기 전략 미팅과 겹침 · 병원 예약 시스템에서 빈 슬롯 확인 후 변경",
undo_label="원래 시간으로", sort_order=0),
Approval(id="a2", icon="mail", tone="blue", risk="low", time="06:10",
title="영수증·뉴스레터 7통을 정리했어요",
detail="영수증 3통 → 금융 폴더 · 뉴스레터 4통 → 읽을거리, 받은편지함은 중요한 것만 남김",
undo_label="되돌리기", sort_order=1),
Approval(id="a3", icon="cal", tone="violet", risk="low", time="07:40",
title="내일 오전 딥 워크 2시간을 예약했어요",
detail="분기 리포트 마감(내일 18시) 대비 · 9:00–11:00, 방해 금지로 설정",
undo_label="블록 해제", sort_order=2),
Approval(id="a4", icon="mail", tone="violet", risk="high", time="보내기 대기",
title="현우님께 회신 초안이 준비됐어요",
detail="“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”",
cta="보내기", alt="수정", sort_order=3),
Approval(id="a5", icon="users", tone="green", risk="high", time="전달 대기",
title="민서님께 ‘데이터 전처리’ 위임 요청",
detail="오늘 일정 과부하 감지 · 맥락 요약과 마감(목)을 담은 요청 메시지 작성 완료",
cta="전달", alt="내가 할게", sort_order=4),
Approval(id="a6", icon="wallet", tone="amber", risk="high", time="확인 필요",
title="Netflix 일시정지를 추천해요",
detail="최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요",
cta="일시정지", alt="유지", sort_order=5),
]
session.add_all(approvals)
# data.js goals (var(--x) → tone 키)
goals = [
Goal(id="g1", title="분기 OKR — 사용자 리텐션", pct=68, sub="12개 중 8개 달성", tone="blue", sort_order=0),
Goal(id="g2", title="주 4회 운동", pct=75, sub="이번 주 3/4회", tone="coral", sort_order=1),
Goal(id="g3", title="‘딥 워크’ 책 완독", pct=40, sub="320쪽 중 128쪽", tone="violet", sort_order=2),
]
session.add_all(goals)
session.commit()
인박스 최근 캡처는 별도 시드하지 않는다 —
phase-4-inbox.md에서 시드한inbox_item(sinbox-data.js의 s1~s4)을 그대로 집계해 최신 3건을 사용한다.
3.3 백엔드 — 응답 스키마(schemas.py)
frontend/lib/types.ts와 필드명 1:1 대응. CONTRACT의 GET /api/dashboard → {user, briefing, saved_today, today_routed, schedule[], task_summary, goals[], approvals_summary[], inbox_recent[], badges:{appr,task,noti}} 형태를 그대로 따른다. briefing은 {weather, commute, sleep, note}만 담고, saved_today·today_routed는 최상위 필드다. goals[]의 색상 키는 tone(예 blue), inbox_recent[]는 {id,kind,raw,type,proj_label,tone}이다.
# backend/app/schemas.py (대시보드 출력)
from pydantic import BaseModel
class UserOut(BaseModel):
name: str # "지우"
initial: str # "지"
class WeatherOut(BaseModel): # phase-2 §3.2 소유 — 동일. { temp:int, cond:str, icon:str }
temp: int # 24
cond: str # "맑음 · 한낮 28°"
icon: str # "sun" (cloudSun→sun 매핑 후)
class BriefingOut(BaseModel):
today: str # "6월 7일 일요일" (phase-2 Briefing 소유 컬럼)
weather: WeatherOut # 중첩 타입(dict 아님): {temp, cond, icon}
commute: str
sleep: str
note: str # HTML 허용
# 주의: saved_today / today_routed 는 응답 최상위 필드(briefing 안이 아님)
class ScheduleItemOut(BaseModel):
id: str
time: str
title: str
tag: str
dur: str
tone: str
soon: bool
class ApprovalSummaryOut(BaseModel):
id: str
icon: str
tone: str
title: str
time: str # "보내기 대기" 등 (risk=high만)
class InboxRecentOut(BaseModel):
id: str
kind: str # text|voice|image
raw: str
type: str # 분류 type: "task"|"event"|"idea" (없으면 "" 또는 미분류)
proj_label: str # 프로젝트 표시 라벨 "개인 › 여행 — 한국" (없으면 "")
tone: str # 분류 tone (없으면 "faint")
class TaskSummaryItem(BaseModel): # phase-2 §3.2 소유 — 동일 이름·필드
id: str
title: str
project: str # 프로젝트 표시명
prio: str # 높음|보통|낮음
class TaskSummaryOut(BaseModel):
open_count: int # 미완료 작업 수
items: list[TaskSummaryItem] # 미완료(open) 작업만, 상위 N건(기본 5)
class GoalOut(BaseModel):
id: str
title: str
pct: int
sub: str
tone: str # tone 키(예 "blue"). 프론트가 var(--tone)로 변환
class BadgesOut(BaseModel):
appr: int # 결재 대기(=high risk 개수) → Topbar 결재함 badge
task: int # 작업 미완료 수 → Topbar 작업 badge
noti: int # 알림 수(시드 상수 6)
class DashboardOut(BaseModel):
user: UserOut
briefing: BriefingOut
saved_today: str # "47분" (결재함 카드 서브용, 최상위)
today_routed: int # 7 (인박스 카드 서브용, 최상위)
schedule: list[ScheduleItemOut]
task_summary: TaskSummaryOut
goals: list[GoalOut]
approvals_summary: list[ApprovalSummaryOut]
inbox_recent: list[InboxRecentOut]
badges: BadgesOut
3.4 백엔드 — 집계·라우터(phase-2 §3.14 단일 소유)
대시보드 백엔드 집계·스키마·라우터는
phase-2-backend.md§3.14가 단일 소유한다. 별도services/dashboard.py는 만들지 않으며, 집계는backend/app/routers/dashboard.py안에서 인라인으로 수행한다(라우터는APIRouter()로 내부 prefix 없이 정의하고,main.py에서prefix="/api"로 등록 — R13). 본 phase는 그 응답을 소비만 하며 백엔드 집계·스키마·라우터를 재정의하지 않는다.집계 규칙 요약(정본 참조용): 결재함 요약은
risk=="high"만 정렬 순서대로 최대 3건,task_summary.items는 미완료(open) 작업만 상위 N건(done 제외), 인박스 최근 3건 + 최신 분류,weather_icon은cloudSun→sun매핑 후briefing.weather(중첩 타입)로 응답,user는 people 시드is_me에서 채움. 구현 코드 정본은 phase-2 §3.14를 참조한다.
예시 응답(GET /api/dashboard, 시드 기준 — phase-2 정본과 동일)
{
"user": { "name": "지우", "initial": "지" },
"briefing": {
"today": "6월 7일 일요일",
"weather": { "temp": 24, "cond": "맑음 · 한낮 28°", "icon": "sun" },
"commute": "출근 23분 · 평소보다 4분 빠름",
"sleep": "어젯밤 7시간 12분 · 평소만큼 푹 잤어요",
"note": "오늘은 오후 미팅이 핵심이에요. 오전을 비워 <b>분기 리포트</b>에 집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."
},
"saved_today": "47분",
"today_routed": 7,
"schedule": [
{ "id": "e1", "time": "09:30", "title": "팀 데일리 스탠드업", "tag": "프로덕트", "dur": "15분", "tone": "blue", "soon": false },
{ "id": "e2", "time": "11:00", "title": "디자인 리뷰 — 온보딩 플로우", "tag": "디자인", "dur": "45분", "tone": "violet", "soon": false },
{ "id": "e3", "time": "14:00", "title": "분기 전략 미팅", "tag": "경영진", "dur": "60분", "tone": "coral", "soon": true },
{ "id": "e4", "time": "16:30", "title": "1:1 — 민서님", "tag": "팀", "dur": "30분", "tone": "green", "soon": false }
],
"task_summary": {
"open_count": 4,
"items": [
{ "id": "k1", "title": "분기 리포트 초안 마무리", "prio": "높음", "project": "분기 리포트" },
{ "id": "k2", "title": "온보딩 와이어프레임 피드백 정리", "prio": "높음", "project": "와이어프레임" },
{ "id": "k5", "title": "사용자 인터뷰 5건 정리", "prio": "보통", "project": "사용자 리서치" },
{ "id": "k4", "title": "구독 결제 카드 갱신", "prio": "보통", "project": "일상" }
]
},
"goals": [
{ "id": "g1", "title": "분기 OKR — 사용자 리텐션", "pct": 68, "sub": "12개 중 8개 달성", "tone": "blue" },
{ "id": "g2", "title": "주 4회 운동", "pct": 75, "sub": "이번 주 3/4회", "tone": "coral" },
{ "id": "g3", "title": "‘딥 워크’ 책 완독", "pct": 40, "sub": "320쪽 중 128쪽", "tone": "violet" }
],
"approvals_summary": [
{ "id": "a4", "icon": "mail", "tone": "violet", "title": "현우님께 회신 초안이 준비됐어요", "time": "보내기 대기" },
{ "id": "a5", "icon": "users", "tone": "green", "title": "민서님께 ‘데이터 전처리’ 위임 요청", "time": "전달 대기" },
{ "id": "a6", "icon": "wallet", "tone": "amber", "title": "Netflix 일시정지를 추천해요", "time": "확인 필요" }
],
"inbox_recent": [
{ "id": "s1", "kind": "text", "raw": "다음 주에 한국 놀러가는 비행기 티켓 사기", "type": "task", "proj_label": "개인 › 여행 — 한국", "tone": "coral" },
{ "id": "s2", "kind": "text", "raw": "수요일 11시 자전거 수리 맡기기", "type": "event", "proj_label": "개인", "tone": "blue" },
{ "id": "s3", "kind": "voice", "raw": "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기", "type": "task", "proj_label": "개인", "tone": "green" }
],
"badges": { "appr": 3, "task": 4, "noti": 6 }
}
approvals_summary는risk === "high"인 a4·a5·a6만 노출된다(원본apPending = AP.items.filter(i => i.risk === "high")와 동일).badges.appr=3은 Topbar 결재함 배지("3")와 일치,badges.task는 미완료 작업 수(시드상 4)와 일치.
3.6 프론트 — 타입(lib/types.ts)
// frontend/lib/types.ts (대시보드)
export interface User { name: string; initial: string }
export interface Weather { temp: number; cond: string; icon: string }
export interface Briefing {
today: string;
weather: Weather;
commute: string;
sleep: string;
note: string; // HTML (dangerouslySetInnerHTML)
}
export interface ScheduleItem {
id: string; time: string; title: string; tag: string; dur: string;
tone: Tone; soon: boolean;
}
export interface ApprovalSummary {
id: string; icon: string; tone: Tone; title: string; time: string;
}
export interface InboxRecent {
id: string; kind: "text" | "voice" | "image"; raw: string;
type: string; proj_label: string; tone: Tone;
}
export interface TaskSummaryItem {
id: string; title: string; project: string; prio: "높음" | "보통" | "낮음";
}
export interface TaskSummary { open_count: number; items: TaskSummaryItem[] }
export interface Goal { id: string; title: string; pct: number; sub: string; tone: Tone }
export interface Badges { appr: number; task: number; noti: number }
export interface Dashboard {
user: User;
briefing: Briefing;
saved_today: string; // "47분" (최상위)
today_routed: number; // 7 (최상위)
schedule: ScheduleItem[];
task_summary: TaskSummary;
goals: Goal[];
approvals_summary: ApprovalSummary[];
inbox_recent: InboxRecent[];
badges: Badges;
}
export type Tone = "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
3.7 프론트 — API 클라이언트(lib/api.ts)
// frontend/lib/api.ts (발췌)
import type { Dashboard } from "./types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? ""; // dev 프록시 사용 시 ""
export async function getDashboard(signal?: AbortSignal): Promise<Dashboard> {
const res = await fetch(`${BASE}/api/dashboard`, { signal, cache: "no-store" });
if (!res.ok) throw new Error(`dashboard ${res.status}`);
return res.json();
}
// 자연어 명령 → 인박스 캡처 (phase-4와 동일 엔드포인트)
export async function captureInbox(raw: string) {
const res = await fetch(`${BASE}/api/inbox/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "text", raw }),
});
if (!res.ok) throw new Error(`capture ${res.status}`);
return res.json(); // { item, classification }
}
3.8 프론트 — 데이터 훅(lib/hooks/useDashboard.ts)
React Query(또는 SWR) 기준. 위젯별 부분 실패는 단일 호출이라 "전체 실패 vs 성공"이지만, 위젯 렌더 시 각 섹션이 null/빈 배열일 때를 방어해 부분 표시(5.9)한다.
// frontend/lib/hooks/useDashboard.ts
"use client";
import { useQuery } from "@tanstack/react-query";
import { getDashboard } from "@/lib/api";
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: ({ signal }) => getDashboard(signal),
staleTime: 30_000,
retry: 1,
});
}
3.9 프론트 — 라우트 진입(app/dashboard/page.tsx)
// frontend/app/dashboard/page.tsx
import type { Metadata } from "next";
import { DashboardClient } from "@/components/dashboard/DashboardClient";
export const metadata: Metadata = { title: "대시보드 · 아리" };
export default function DashboardPage() {
return <DashboardClient />;
}
app/page.tsx(/)는phase-1-design-system.md에서redirect("/dashboard")로 구성됨.
3.10 프론트 — 페이지 셸 & 벤토 그리드(DashboardClient.tsx)
원본 dash-v2.jsx의 구조(상단 페이지 헤더 + .work > .board 벤토 그리드 + 진입 애니메이션 entered)를 재현한다. Topbar/테마는 phase-1의 앱 셸(app/layout.tsx)이 제공하므로 여기서는 페이지 본문만 그린다.
// frontend/components/dashboard/DashboardClient.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDashboard } from "@/lib/hooks/useDashboard";
import { HeroBriefing } from "./HeroBriefing";
import { ApprovalSummaryCard } from "./ApprovalSummaryCard";
import { InboxSummaryCard } from "./InboxSummaryCard";
import { ScheduleCard } from "./ScheduleCard";
import { TaskSummaryCard } from "./TaskSummaryCard";
import { GoalsCard } from "./GoalsCard";
import { DashboardSkeleton } from "./DashboardSkeleton";
import { Icon } from "@/components/Icon";
export function DashboardClient() {
const { data, isLoading, isError, refetch } = useDashboard();
const shellRef = useRef<HTMLDivElement>(null);
// 원본 진입 애니메이션: requestAnimationFrame → .entered 부여
useEffect(() => {
const el = shellRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.classList.add("entered"));
return () => cancelAnimationFrame(id);
}, [data]);
if (isLoading) return <DashboardSkeleton />;
if (isError || !data) {
return (
<div className="dash-error" role="alert">
<p>대시보드를 불러오지 못했어요.</p>
<button className="mini-cta" onClick={() => refetch()}>
<Icon name="swap" /> 다시 시도
</button>
</div>
);
}
const { user, briefing, saved_today, today_routed, schedule, task_summary, goals, approvals_summary, inbox_recent } = data;
return (
<div className="dash-page" ref={shellRef}>
{/* 페이지 헤더 (원본 .pagehead) */}
<div className="pagehead">
<div>
<div className="ph-eyebrow">
<span>{briefing.today}</span>
<span className="sep" />
<span className="wx"><Icon name={briefing.weather.icon} />{briefing.weather.cond}</span>
</div>
<h1 className="ph-title">좋은 아침이에요, {user.name}님 <em>☀</em></h1>
</div>
<div className="ph-search">
<Icon name="search" />
<input placeholder="아리에게 무엇이든…" aria-label="검색" />
</div>
</div>
<div className="work">
<div className="board">
{/* 히어로 — sp2(2칸) */}
<HeroBriefing briefing={briefing} />
{/* 요약 카드 2개 */}
<ApprovalSummaryCard items={approvals_summary} savedToday={saved_today} pending={data.badges.appr} />
<InboxSummaryCard items={inbox_recent} todayRouted={today_routed} />
{/* 오늘 요약 */}
<ScheduleCard items={schedule} />
<TaskSummaryCard summary={task_summary} />
<GoalsCard goals={goals} />
</div>
</div>
</div>
);
}
사용자명(인사 "좋은 아침이에요, {name}님")은 **
GET /api/dashboard응답 최상위user.name**을 사용한다(R12). 하드코딩하지 않는다 — 백엔드가 phase-2 people 시드의is_me인물에서{name, initial}을 채워 내려준다.
3.11 프론트 — 히어로 브리핑(HeroBriefing.tsx)
원본 .card.hero.sp2 구조 그대로: hero-spark(코럴 pill "아리 브리핑") + p.brief(HTML) + .chips(추천 칩 3개) + .cmd(자연어 명령 입력).
// frontend/components/dashboard/HeroBriefing.tsx
"use client";
import { Icon } from "@/components/Icon";
import { CommandInput } from "./CommandInput";
import type { Briefing } from "@/lib/types";
// 원본 CHIPS (dash-v2.jsx)
const CHIPS = [
{ icon: "cal", text: "내일 오후 비워줘" },
{ icon: "mail", text: "중요 메일만 요약" },
{ icon: "video", text: "14시 미팅 준비" },
];
export function HeroBriefing({ briefing }: { briefing: Briefing }) {
return (
<section className="card hero sp2">
<span className="hero-spark"><Icon name="spark" />아리 브리핑</span>
{/* briefingNote — HTML(<b> 강조) */}
<p className="brief" dangerouslySetInnerHTML={{ __html: briefing.note }} />
<div className="chips">
{CHIPS.map((c, i) => (
<button className="chip" key={i} type="button">
<Icon name={c.icon} />{c.text}
</button>
))}
</div>
<CommandInput />
</section>
);
}
보안 메모:
briefing.note는 시드 출처의 HTML이며<b>만 포함한다. 사용자 입력이 아니므로dangerouslySetInnerHTML사용이 안전하다. 향후 사용자 생성 텍스트를 넣게 되면DOMPurify같은 sanitizer를 거쳐야 한다(주석으로 명시).
3.12 프론트 — 자연어 명령 입력(CommandInput.tsx)
원본 .cmd(좌측 spark 아이콘 + input + 우측 검정 채움 send 버튼). MVP 동작은 5.5에서 정의: 전송 시 인박스 캡처 → 토스트 → /inbox 이동.
// frontend/components/dashboard/CommandInput.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Icon } from "@/components/Icon";
import { captureInbox } from "@/lib/api";
export function CommandInput() {
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const router = useRouter();
async function submit() {
const raw = value.trim();
if (!raw || busy) return;
setBusy(true);
try {
await captureInbox(raw); // POST /api/inbox/capture {kind:"text", raw}
// 토스트(phase-1 디자인 시스템 Toast): "인박스에 적어뒀어요 — 아리가 분류할게요"
window.dispatchEvent(new CustomEvent("ari:toast", {
detail: { text: "인박스에 적어뒀어요 — 아리가 분류할게요" },
}));
router.push("/inbox"); // 분류 결과 확인은 인박스에서
} catch {
window.dispatchEvent(new CustomEvent("ari:toast", {
detail: { text: "지금은 적어두지 못했어요. 잠시 후 다시 시도해 주세요.", tone: "coral" },
}));
} finally {
setBusy(false);
setValue("");
}
}
return (
<form
className="cmd"
onSubmit={(e) => { e.preventDefault(); submit(); }}
>
<Icon name="spark" />
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="오늘 하루, 무엇이든 맡겨보세요…"
aria-label="아리에게 명령 입력"
disabled={busy}
/>
<button className="send" type="submit" aria-label="보내기" disabled={busy || !value.trim()}>
<Icon name="send" />
</button>
</form>
);
}
원본 placeholder 문구 "오늘 하루, 무엇이든 맡겨보세요…" 그대로 사용. Enter 키 전송은
<form onSubmit>으로 자연스럽게 지원.
3.13 프론트 — 결재함 요약 카드(ApprovalSummaryCard.tsx)
원본 구조: .ch(라임 아이콘 ico lime + 제목 "아리 결재함" + 서브 "오늘 47분 아껴드렸어요" + count warm "대기 N건") + .mini-list(3행) + .mini-cta(검정 채움 "결재함에서 승인하기"). appr 클래스로 approve.css의 .ico.lime/.count.warm 적용.
// frontend/components/dashboard/ApprovalSummaryCard.tsx
"use client";
import Link from "next/link";
import { Icon } from "@/components/Icon";
import { MiniRow } from "./MiniRow";
import type { ApprovalSummary } from "@/lib/types";
export function ApprovalSummaryCard({
items, savedToday, pending,
}: { items: ApprovalSummary[]; savedToday: string; pending: number }) {
return (
<section className="card appr">
<div className="ch">
<div className="ico lime"><Icon name="spark" /></div>
<div className="htext">
<h3>아리 결재함</h3>
<div className="sub">오늘 {savedToday} 아껴드렸어요</div>
</div>
<span className="count warm">대기 {pending}건</span>
</div>
{items.length === 0 ? (
<div className="appr-empty">
<span className="ae-tick"><Icon name="tick" w={3} /></span>
지금은 확인할 게 없어요 — 다 처리해뒀어요.
</div>
) : (
<div className="mini-list">
{items.map((it) => (
<MiniRow key={it.id} tone={it.tone} icon={it.icon} text={it.title} sub={it.time} />
))}
</div>
)}
{/* MVP: 결재함 전용 페이지는 placeholder("준비 중") */}
<Link className="mini-cta" href="/결재함" aria-label="결재함에서 승인하기 (준비 중)">
<Icon name="tick" w={3} />결재함에서 승인하기
</Link>
</section>
);
}
미니 행은 원본
<span className="mini-text">{it.title}</span><span className="mini-sub">{it.time}</span>와 동일. CTAhref는 placeholder 라우트(예:/결재함또는/(placeholder)/approvals) — 클릭 시 phase-1의 "준비 중" 플레이스홀더 페이지를 보여준다.
3.14 프론트 — 인박스 요약 카드(InboxSummaryCard.tsx)
원본: .ico(inbox) + "스마트 인박스" + 서브 "적으면 아리가 제자리로" + count "오늘 7건" + .mini-list(3행: kind에 따라 아이콘 pen/mic/image) + .mini-cta(→ 인박스, "새로 적기 · 인박스 열기").
// frontend/components/dashboard/InboxSummaryCard.tsx
"use client";
import Link from "next/link";
import { Icon } from "@/components/Icon";
import { MiniRow } from "./MiniRow";
import type { InboxRecent } from "@/lib/types";
const kindIcon = (k: InboxRecent["kind"]) =>
k === "voice" ? "mic" : k === "image" ? "image" : "pen";
// 분류 type → 한글 라벨(없으면 "분류 중")
const typeLabel = (t: string) =>
({ task: "작업", event: "일정", idea: "아이디어" } as Record<string, string>)[t] ?? "분류 중";
export function InboxSummaryCard({
items, todayRouted,
}: { items: InboxRecent[]; todayRouted: number }) {
return (
<section className="card">
<div className="ch">
<div className="ico"><Icon name="inbox" /></div>
<div className="htext">
<h3>스마트 인박스</h3>
<div className="sub">적으면 아리가 제자리로</div>
</div>
<span className="count">오늘 {todayRouted}건</span>
</div>
{items.length === 0 ? (
<div className="appr-empty">
<span className="ae-tick"><Icon name="tick" w={3} /></span>
인박스가 비었어요 — 떠오르면 바로 적어두세요.
</div>
) : (
<div className="mini-list">
{items.map((c) => (
<MiniRow key={c.id} tone={c.tone} icon={kindIcon(c.kind)} text={c.raw} sub={typeLabel(c.type)} />
))}
</div>
)}
<Link className="mini-cta" href="/inbox">
<Icon name="plus" />새로 적기 · 인박스 열기
</Link>
</section>
);
}
3.15 프론트 — 공용 미니 행(MiniRow.tsx)
approve.css의 .mini-row/.mini-ic/.mini-text/.mini-sub를 그대로 사용. --tone 인라인 변수로 아이콘 칩 색을 입힌다.
// frontend/components/dashboard/MiniRow.tsx
import { Icon } from "@/components/Icon";
import type { Tone } from "@/lib/types";
export function MiniRow({
tone, icon, text, sub,
}: { tone: Tone; icon: string; text: string; sub: string }) {
return (
<div className="mini-row">
<span className="mini-ic" style={{ "--tone": `var(--${tone})` } as React.CSSProperties}>
<Icon name={icon} />
</span>
<span className="mini-text">{text}</span>
<span className="mini-sub">{sub}</span>
</div>
);
}
3.16 프론트 — 오늘 일정 카드(ScheduleCard.tsx)
원본 .sched > .ev 구조: 좌측 mono ev-time, ev-body(accent 좌측 바 --accent), ev-title, ev-meta(tag + dur), soon 배지("곧"), 하단 sched-note.
// frontend/components/dashboard/ScheduleCard.tsx
"use client";
import { Icon } from "@/components/Icon";
import type { ScheduleItem } from "@/lib/types";
const TONE: Record<string, string> = {
blue: "var(--blue)", violet: "var(--violet)", coral: "var(--coral)", green: "var(--green)",
};
export function ScheduleCard({ items }: { items: ScheduleItem[] }) {
return (
<section className="card">
<div className="ch">
<div className="ico"><Icon name="cal" /></div>
<div className="htext">
<h3>오늘 일정</h3>
<div className="sub">{items.length}개 · 다음까지 2시간</div>
</div>
<button className="tool" aria-label="더보기"><Icon name="more" /></button>
</div>
<div className="sched">
{items.map((e) => (
<div className="ev" key={e.id}>
<div className="ev-time mono">{e.time}</div>
<div className="ev-body" style={{ "--accent": TONE[e.tone] } as React.CSSProperties}>
<div className="ev-title">{e.title}</div>
<div className="ev-meta"><span>{e.tag}</span><span className="dur">{e.dur}</span></div>
</div>
{e.soon && <span className="soon">곧</span>}
</div>
))}
<div className="sched-note">
<Icon name="bell" />치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요.
</div>
</div>
</section>
);
}
sched-note문구는 원본 그대로. "이미 해뒀어요" 원칙을 보여주는 핵심 카피이므로 변경하지 않는다.
3.17 프론트 — 할 일 카드(TaskSummaryCard.tsx)
원본 .tasks > .task(체크박스 box + task-body(title/meta) + 우측 prio 또는 첫 항목 focus-tag "지금"). MVP에서는 읽기 전용 요약이므로 체크 토글은 비활성(클릭 시 /tasks로 이동하거나 no-op). 원본 PRIO 매핑(높음→high, 보통→mid, 낮음→low) 유지.
// frontend/components/dashboard/TaskSummaryCard.tsx
"use client";
import Link from "next/link";
import { Icon } from "@/components/Icon";
import type { TaskSummary } from "@/lib/types";
const PRIO: Record<string, string> = { 높음: "high", 보통: "mid", 낮음: "low" };
export function TaskSummaryCard({ summary }: { summary: TaskSummary }) {
return (
<section className="card">
<div className="ch">
<div className="ico"><Icon name="check" /></div>
<div className="htext"><h3>할 일</h3></div>
<span className="count">{summary.open_count}개 남음</span>
</div>
<div className="tasks">
{summary.items.map((t, idx) => {
// task_summary.items 는 미완료(open) 작업만 담긴다(백엔드가 done 제외) → 첫 항목을 focus 강조
const focus = idx === 0;
return (
<Link
key={t.id}
href={`/tasks?task=${t.id}`}
className={"task" + (focus ? " focus" : "")}
>
<div className="box"><Icon name="tick" w={3} /></div>
<div className="task-body">
<div className="task-title">{t.title}</div>
<div className="task-meta">{t.project}</div>
</div>
{focus
? <span className="focus-tag">지금</span>
: <span className={"prio " + PRIO[t.prio]}>{t.prio}</span>}
</Link>
);
})}
</div>
</section>
);
}
원본
dash-v2는 첫 미완료 항목을focus로 강조하고 "지금" 배지를 단다.task_summary.items는 백엔드(phase-2 §3.14)가 미완료(open) 작업만 내려주므로(status필드 없음, done 제외) 프론트는 첫 항목(idx === 0)을 그대로focus로 강조한다. MVP 대시보드 작업 카드는 토글이 아니라 작업 페이지로의 진입점(/tasks?task={id}, phase-3의 상세 드로어 딥링크)으로 동작시킨다.
3.18 프론트 — 목표 카드(GoalsCard.tsx)
원본 .goals > div(goal-top: title + pct / goal-bar i(width%, background) / goal-sub).
// frontend/components/dashboard/GoalsCard.tsx
import { Icon } from "@/components/Icon";
import type { Goal } from "@/lib/types";
export function GoalsCard({ goals }: { goals: Goal[] }) {
return (
<section className="card">
<div className="ch">
<div className="ico"><Icon name="target" /></div>
<div className="htext"><h3>목표</h3></div>
</div>
<div className="goals">
{goals.map((g) => (
<div key={g.id}>
<div className="goal-top">
<span className="goal-title">{g.title}</span>
<span className="goal-pct mono">{g.pct}%</span>
</div>
<div
className="goal-bar"
role="progressbar"
aria-valuenow={g.pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={g.title}
>
<i style={{ width: `${g.pct}%`, background: `var(--${g.tone})` }} />
</div>
<div className="goal-sub">{g.sub}</div>
</div>
))}
</div>
</section>
);
}
원본은
g.c에var(--blue)문자열을 넣지만, 백엔드가 tone 키(blue)를 주므로var(--${g.tone})로 변환.goal-bar에role="progressbar"+ aria 속성을 더해 접근성을 보강(원본에는 없으나 axe 통과용).
4. 데이터/타입/API 계약(이 phase 관련)
4.1 엔드포인트
| 메서드 | 경로 | 용도 | 응답 |
|---|---|---|---|
| GET | /api/dashboard |
대시보드 집계(사용자·브리핑·일정·작업요약·목표·결재요약·인박스최근·배지) | DashboardOut |
| POST | /api/inbox/capture |
자연어 명령 입력 → 인박스 캡처 + 분류(phase-4 재사용) | { item, classification } |
사용자명은
GET /api/dashboard응답 최상위user{name,initial}로 내려온다(R12) — 별도/api/people호출 불필요.
4.2 필드 매핑(원본 → 백엔드 → 프론트)
| 원본(data.js / approve-data.js / sinbox-data.js) | 백엔드 필드 | 프론트 타입 |
|---|---|---|
people is_me |
user{name,initial} (최상위) |
User |
today |
briefing.today |
Briefing.today |
weather.cond |
briefing.weather.cond |
Briefing.weather.cond |
commute / sleep |
briefing.commute / briefing.sleep |
동일 |
briefingNote |
briefing.note (HTML) |
Briefing.note |
AriApprovals.savedToday |
saved_today (최상위) |
Dashboard.saved_today |
AriSmartInbox.todayRouted |
today_routed (최상위) |
Dashboard.today_routed |
schedule[] |
schedule[] (id 부여) |
ScheduleItem[] |
AriApprovals.items[risk=="high"] |
approvals_summary[] (title/time/icon/tone) |
ApprovalSummary[] |
AriSmartInbox.items[:3] + classification |
inbox_recent[] (type/proj_label/tone) |
InboxRecent[] |
tasks[] (작업 시드) |
task_summary |
TaskSummary |
goals[] (c→tone) |
goals[] (tone) |
Goal[] |
MAIN badge appr:3 / task:4 / noti:6 |
badges |
Badges |
4.3 tone → CSS 변수
tone 값 집합 blue|violet|coral|green|amber|ink|faint. 프론트는 항상 var(--${tone})로 변환. 분류 미완료 인박스 행은 tone="faint"로 폴백.
5. 디자인 충실도 노트(원본 토큰/레이아웃/인터랙션 재현)
이식 대상 CSS는 frontend/styles/dashboard.css에 모으되, 공통 토큰은 tokens.css(phase-1) 에 이미 있으므로 여기서는 대시보드/요약 카드 전용 규칙만 옮긴다.
5.1 벤토 그리드 레이아웃(dash.css .board)
.board {
flex: 1; min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.card.sp2 { grid-column: span 2; } /* 히어로 */
.card.r2 { grid-row: span 2; } /* (MVP 미사용 위젯용) */
- 데스크톱: 4열. 히어로
.sp2가 2열 차지 → 첫 줄에 [히어로(2)] [결재함(1)] [인박스(1)]. - 둘째 줄: [일정] [할 일] [목표] + 빈 1칸(또는 MVP에서 3개 카드를 1칸씩 배치).
- 반응형:
@media (max-width:1200px) → repeat(2, 1fr),@media (max-width:720px) → 1fr(.sp2도 1칸으로). 720px 이하에서는.mainnav숨김(phase-1 Topbar에서 처리).
5.2 카드 글래스(dash.css .card)
.card {
background: var(--glass); /* rgba(255,255,255,.4) */
-webkit-backdrop-filter: var(--blur); /* blur(26px) saturate(190%) */
backdrop-filter: var(--blur);
border: 1px solid var(--glass-brd); /* rgba(255,255,255,.7) */
border-radius: var(--radius); /* 22px */
box-shadow: var(--shadow), var(--glass-hi);
padding: 20px;
display: flex; flex-direction: column;
min-width: 0;
}
5.3 히어로(dash.css .hero)
- 배경: 좌상단 코럴 글로우 + 우상단 화이트 글로우 + 글래스.
.hero { background: radial-gradient(95% 105% at 0% 0%, rgba(231,128,82,.22), transparent 56%), radial-gradient(130% 130% at 100% 0%, rgba(255,255,255,.42), transparent 50%), var(--glass); backdrop-filter: var(--blur); border: 1px solid var(--glass-brd); box-shadow: var(--shadow), var(--glass-hi); } hero-spark(코럴 pill):background: var(--coral),color:#fff,padding:5px 12px,border-radius:999px,box-shadow:0 5px 14px -5px var(--coral).p.brief:font-size: clamp(18px,1.7vw,22px),line-height:1.5,letter-spacing:-0.02em,max-width:40ch.b강조는 코럴 underline(border-bottom:2px solid color-mix(in oklab, var(--coral) 60%, transparent))..cmd(명령 입력):background:var(--glass-2),border:1px solid var(--glass-brd),border-radius:15px,padding:7px 7px 7px 16px,margin-top:auto. send 버튼38×38,border-radius:11px,background:var(--fill),color:var(--on-fill)..chips .chip:font-size:12.5px, glass-2 배경, pill.
5.4 요약 카드(approve.css .mini-*)
.mini-list { display:flex; flex-direction:column; margin-bottom:12px; }
.mini-row { display:flex; align-items:center; gap:9px; padding:9px 0; border-top:1px solid var(--line); }
.mini-row:first-child { border-top:none; padding-top:2px; }
.mini-ic {
width:26px; height:26px; border-radius:8px; flex-shrink:0;
display:grid; place-items:center;
background: color-mix(in oklab, var(--tone, var(--blue)) 13%, transparent);
color: var(--tone, var(--blue));
}
.mini-ic .ic { width:13px; height:13px; }
.mini-text { flex:1; min-width:0; font-size:12.5px; font-weight:600; letter-spacing:-0.01em;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mini-sub { font-size:10.5px; color:var(--faint); flex-shrink:0; white-space:nowrap; }
.mini-cta {
margin-top:auto; display:flex; align-items:center; justify-content:center; gap:7px;
padding:11px; border-radius:13px;
background:var(--fill); color:var(--on-fill);
font-size:13px; font-weight:600; text-decoration:none;
transition:filter .14s;
}
.mini-cta:hover { filter:brightness(1.12); }
- 결재함 카드 전용:
.appr .ico.lime { background:var(--lime); color:var(--lime-ink); },.appr .count.warm { color:#fff; background:var(--coral); }. - 빈 상태:
.appr-empty(녹색 글래스 +ae-tick).
5.5 자연어 명령 입력 — MVP 동작 정의(명세)
| 상태 | 동작 |
|---|---|
| 입력 후 Enter 또는 send 클릭 | POST /api/inbox/capture {kind:"text", raw} 호출(분류 동기 실행) |
| 성공 | 토스트 "인박스에 적어뒀어요 — 아리가 분류할게요" → /inbox로 라우팅(router.push). 분류 결과/이유는 인박스 페이지에서 확인(phase-4) |
| 실패 | 토스트(coral) "지금은 적어두지 못했어요. 잠시 후 다시 시도해 주세요." → 입력값 유지하지 않고 비움(또는 유지: 구현 선택, 본 문서는 비움) |
| 빈 입력 | send 버튼 비활성(disabled), no-op |
| 전송 중 | input/버튼 disabled(중복 전송 방지) |
제품 철학 반영: 사용자는 "그냥 적기"만 한다. 대시보드 명령 입력은 분류 UI를 띄우지 않고 인박스로 흘려보내 아리가 분류·배치하게 한다("적을 때는 분류하지 않는다"). 히어로 상단
.ph-search("아리에게 무엇이든…")는 MVP에서 장식/검색 placeholder로 남기고 동작은 명령 입력(.cmd)에 집중한다(중복 입력 UI 혼선 방지).
5.6 일정/할 일/목표 픽셀(요약)
| 요소 | 원본 값 |
|---|---|
.ev-time |
mono, 13px, width:42px, color:var(--ink-2) |
.ev-body::before |
좌측 3px accent 바, var(--accent, var(--blue)) |
.ev .soon |
10.5px, #fff/var(--coral), pill "곧" |
.task |
glass-2, border-radius:14px, padding:12px 13px |
.task .box |
21×21, border-radius:7px, 체크 시 var(--green) 채움 |
.task.focus |
코럴 그라데이션 + inset 3px 0 0 0 var(--coral), "지금" 배지(focus-tag) |
.prio.high |
coral / .mid amber / .low muted |
.goal-bar |
height:7px, var(--card-2) 트랙, i에 width%+색 |
.goal-pct |
mono, 13px, font-weight:700 |
5.7 진입 애니메이션(dash.css)
.entered .card { animation: cardin .5s cubic-bezier(.2,.7,.2,1) both; }
@keyframes cardin { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:none; } }
DashboardClient가 마운트 후 requestAnimationFrame으로 .entered를 부여(원본 shellRef 로직 동일). prefers-reduced-motion: reduce에서는 애니메이션 비활성:
@media (prefers-reduced-motion: reduce) {
.entered .card { animation: none; }
}
5.8 라이트/다크
tokens.css의 [data-theme="dark"] 오버라이드(phase-1)를 그대로 활용. 대시보드 전용 추가 다크 규칙은 없음(모든 색이 토큰 참조). next-themes의 data-theme이 <html>에 적용되면 카드·히어로 글래스·잉크가 자동 전환. 검증은 7장 QA에서 두 테마 모두 스크린샷.
날씨 아이콘 충실도(R11/R12): 백엔드는
weather_icon을 원본data.js값인'cloudSun'으로 저장하고, 집계 시 중앙 paths 맵에 존재하는 글리프'sun'으로 매핑해 응답(briefing.weather.icon)에 담는다. 프론트는<Icon name={briefing.weather.icon} />로 그대로 그린다(하드코딩 금지).
5.9 MVP에서 구현하지 않는 원본 위젯(참고)
원본 dash-v2.jsx의 다음 위젯은 MVP 대시보드 범위 밖(집계 API에 없음). 본 phase에서는 렌더하지 않으며, 향후 확장 시 동일 글래스 보캐뷸러리(.card, .ch)로 추가한다: 오늘의 집중(.foc-*), 미팅 준비(.mtg-*), 위임 현황(.dg-*), 이어서 하기/환경 우선순위(.rs-*/.env-nudge), 이번 주 집중도 차트(.chart), 메일 요약(.mails), 건강(.rings), 지출(.fin-*).
6. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스
6.1 로딩 — 스켈레톤(DashboardSkeleton.tsx)
isLoading 동안 동일 그리드 레이아웃의 스켈레톤 카드를 그린다(레이아웃 점프 방지).
// frontend/components/dashboard/DashboardSkeleton.tsx
export function DashboardSkeleton() {
return (
<div className="dash-page" aria-busy="true" aria-live="polite">
<div className="pagehead">
<div>
<div className="skel skel-eyebrow" />
<div className="skel skel-title" />
</div>
</div>
<div className="work">
<div className="board">
<section className="card hero sp2"><div className="skel skel-block" /></section>
{Array.from({ length: 5 }).map((_, i) => (
<section className="card" key={i}>
<div className="skel skel-head" />
<div className="skel skel-line" />
<div className="skel skel-line" />
<div className="skel skel-line short" />
</section>
))}
</div>
</div>
</div>
);
}
/* dashboard.css */
.skel { background: var(--card-2); border-radius: 8px; position: relative; overflow: hidden; }
.skel::after {
content:""; position:absolute; inset:0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
transform: translateX(-100%); animation: shimmer 1.4s infinite;
}
@keyframes shimmer { to { transform: translateX(100%); } }
.skel-title { height:40px; width:60%; margin-top:8px; }
.skel-eyebrow { height:14px; width:180px; }
.skel-head { height:20px; width:50%; margin-bottom:16px; }
.skel-line { height:14px; margin:8px 0; } .skel-line.short { width:60%; }
.skel-block { height:160px; }
@media (prefers-reduced-motion: reduce) { .skel::after { animation: none; } }
6.2 빈 상태(위젯별)
| 위젯 | 빈 조건 | 표시 |
|---|---|---|
| 결재함 요약 | approvals_summary.length === 0 |
.appr-empty "지금은 확인할 게 없어요 — 다 처리해뒀어요." |
| 인박스 요약 | inbox_recent.length === 0 |
.appr-empty "인박스가 비었어요 — 떠오르면 바로 적어두세요." (CTA "새로 적기"는 유지) |
| 오늘 일정 | schedule.length === 0 |
"오늘은 일정이 없어요. 여유로운 하루예요." (sched-note 대신 빈 안내) |
| 할 일 | task_summary.items.length === 0 |
"할 일이 모두 끝났어요 🎉" / count는 "0개 남음" |
| 목표 | goals.length === 0 |
"추적 중인 목표가 없어요." |
6.3 에러 — 전체 실패 vs 부분 방어
GET /api/dashboard는 단일 호출이므로 네트워크/5xx 실패 시 전체 에러 화면(role="alert"+ "다시 시도" 버튼,refetch()).- 단, 응답 JSON에서 특정 섹션이 누락/null/빈 배열일 수 있으므로 각 카드 컴포넌트는 옵셔널 체이닝/기본값으로 방어(부분 표시). 예:
briefing.note ?? "",schedule ?? []. 한 위젯이 깨져도 나머지는 렌더되도록 각 카드를 ErrorBoundary(WidgetError)로 감쌀 수 있다:
// frontend/components/dashboard/WidgetError.tsx (ErrorBoundary fallback)
export function WidgetErrorFallback({ name }: { name: string }) {
return (
<section className="card" role="alert">
<div className="ch"><div className="htext"><h3>{name}</h3></div></div>
<div className="appr-empty">이 카드를 불러오지 못했어요.</div>
</section>
);
}
사용처(선택):
<ErrorBoundary fallback={<WidgetErrorFallback name="오늘 일정" />}><ScheduleCard .../></ErrorBoundary>. React Query 단일 쿼리 구조에서는 "부분 실패"가 데이터 형태 결손으로만 발생하므로, ErrorBoundary는 렌더 예외(예상치 못한 데이터) 안전망 역할.
6.4 오프라인
navigator.onLine === false또는 fetch 실패 → 전체 에러 화면에 "오프라인 상태예요 — 연결되면 자동으로 불러올게요." 문구.online이벤트 리스너로 자동refetch.- 자연어 명령 입력은 오프라인 시 전송 비활성 + 토스트 "오프라인이라 지금은 적어둘 수 없어요."
6.5 엣지 케이스
| 케이스 | 처리 |
|---|---|
briefing.note에 <b> 외 태그 |
시드 출처라 안전. 사용자 입력 경로 없음(명령 입력은 인박스로만 감) |
approvals_summary가 3건 초과 |
백엔드에서 이미 [:3] 슬라이스 |
| 인박스 최근 항목 분류 미완료 | type=""(프론트 라벨 "분류 중"), proj_label="", tone="faint" 폴백 |
작업 요약 항목 0개지만 open_count>0 |
데이터 불일치 — open_count 우선 표시, 목록 빈 안내 |
| 매우 긴 제목 | .mini-text/.task-title ellipsis(원본 CSS) |
| 자연어 명령 연속 전송 | busy 플래그로 중복 차단 |
7. 테스팅 & 검증
7.1 실행 명령
# 백엔드 (repo root에서)
cd backend && uv run pytest tests/test_dashboard.py -v
# 또는: cd backend && pytest tests/test_dashboard.py -v
# 프론트 컴포넌트 (Vitest + RTL)
cd frontend && pnpm test # 전체
cd frontend && pnpm test dashboard # 대시보드 관련만
# E2E (Playwright) — 백엔드 + 프론트 dev 서버 기동 후
cd frontend && pnpm playwright test playwright/dashboard.spec.ts
# 접근성 (axe, Playwright 통합)
cd frontend && pnpm playwright test playwright/dashboard.a11y.spec.ts
7.2 백엔드 테스트(backend/tests/test_dashboard.py)
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_dashboard_shape():
r = client.get("/api/dashboard")
assert r.status_code == 200
d = r.json()
for k in ("user", "briefing", "saved_today", "today_routed", "schedule",
"task_summary", "goals", "approvals_summary", "inbox_recent", "badges"):
assert k in d
def test_user_top_level():
d = client.get("/api/dashboard").json()
assert d["user"]["name"] == "지우"
assert d["user"]["initial"] == "지"
def test_briefing_seed_values():
d = client.get("/api/dashboard").json()
b = d["briefing"]
assert b["today"] == "6월 7일 일요일"
assert b["weather"]["cond"] == "맑음 · 한낮 28°"
assert d["saved_today"] == "47분" # 최상위
assert d["today_routed"] == 7 # 최상위
assert "<b>분기 리포트</b>" in b["note"] # HTML 강조 보존
def test_schedule_four_items_and_soon():
d = client.get("/api/dashboard").json()
sch = d["schedule"]
assert len(sch) == 4
assert sch[0]["time"] == "09:30"
soon = [e for e in sch if e["soon"]]
assert len(soon) == 1 and soon[0]["title"] == "분기 전략 미팅"
def test_approvals_summary_only_high_risk_max3():
d = client.get("/api/dashboard").json()
ap = d["approvals_summary"]
assert len(ap) == 3
titles = [a["title"] for a in ap]
assert "현우님께 회신 초안이 준비됐어요" in titles # a4
assert "Netflix 일시정지를 추천해요" in titles # a6
# low risk(a1 치과 예약)는 요약에 없어야 함
assert all("치과 예약" not in t for t in titles)
def test_inbox_recent_three_with_type_and_proj():
d = client.get("/api/dashboard").json()
inb = d["inbox_recent"]
assert len(inb) == 3
assert inb[0]["type"] in ("task", "event", "idea", "")
assert all("proj_label" in x for x in inb)
assert all("tone" in x for x in inb)
def test_goals_three_with_tone_key():
d = client.get("/api/dashboard").json()
goals = d["goals"]
assert len(goals) == 3
assert goals[0]["pct"] == 68
assert goals[0]["tone"] == "blue" # var(--blue) 아님(tone 키)
def test_badges_match_counts():
d = client.get("/api/dashboard").json()
b = d["badges"]
assert b["appr"] == 3 # high risk 개수
assert b["task"] == d["task_summary"]["open_count"]
assert b["noti"] == 6
7.3 프론트 컴포넌트 테스트(Vitest + RTL)
HeroBriefing.test.tsx:
import { render, screen } from "@testing-library/react";
import { HeroBriefing } from "@/components/dashboard/HeroBriefing";
const briefing = {
today: "6월 7일 일요일",
weather: { temp: 24, cond: "맑음 · 한낮 28°", icon: "sun" },
commute: "출근 23분", sleep: "7시간 12분",
note: "오늘은 오후 미팅이 핵심이에요. <b>분기 리포트</b>",
};
test("브리핑 노트의 HTML 강조를 렌더한다", () => {
render(<HeroBriefing briefing={briefing as any} />);
expect(screen.getByText("아리 브리핑")).toBeInTheDocument();
const b = document.querySelector(".brief b");
expect(b?.textContent).toBe("분기 리포트");
});
test("추천 칩 3개와 명령 입력창을 렌더한다", () => {
render(<HeroBriefing briefing={briefing as any} />);
expect(screen.getByText("내일 오후 비워줘")).toBeInTheDocument();
expect(screen.getByPlaceholderText("오늘 하루, 무엇이든 맡겨보세요…")).toBeInTheDocument();
});
ApprovalSummaryCard.test.tsx:
test("대기 N건 배지와 미니 행, 결재함 CTA를 렌더한다", () => {
const items = [
{ id: "a4", icon: "mail", tone: "violet", title: "현우님께 회신 초안이 준비됐어요", time: "보내기 대기" },
];
render(<ApprovalSummaryCard items={items as any} savedToday="47분" pending={3} />);
expect(screen.getByText("아리 결재함")).toBeInTheDocument();
expect(screen.getByText("오늘 47분 아껴드렸어요")).toBeInTheDocument();
expect(screen.getByText("대기 3건")).toBeInTheDocument();
expect(screen.getByText("현우님께 회신 초안이 준비됐어요")).toBeInTheDocument();
const cta = screen.getByRole("link", { name: /결재함에서 승인하기/ });
expect(cta).toHaveAttribute("href", expect.stringContaining("결재함"));
});
test("대기 0건이면 빈 상태를 보여준다", () => {
render(<ApprovalSummaryCard items={[]} savedToday="0분" pending={0} />);
expect(screen.getByText(/다 처리해뒀어요/)).toBeInTheDocument();
});
GoalsCard.test.tsx:
test("목표 진행 막대 width와 progressbar aria를 렌더한다", () => {
const goals = [{ id:"g1", title:"분기 OKR", pct:68, sub:"12개 중 8개", tone:"blue" }];
render(<GoalsCard goals={goals as any} />);
const bar = screen.getByRole("progressbar", { name: "분기 OKR" });
expect(bar).toHaveAttribute("aria-valuenow", "68");
const fill = bar.querySelector("i");
expect(fill).toHaveStyle({ width: "68%" });
});
InboxSummaryCard.test.tsx:
test("kind별 아이콘과 새로 적기 CTA(/inbox)를 렌더한다", () => {
const items = [{ id:"s3", kind:"voice", raw:"엄마 생신 선물", type:"task", proj_label:"개인", tone:"green" }];
render(<InboxSummaryCard items={items as any} todayRouted={7} />);
expect(screen.getByText("오늘 7건")).toBeInTheDocument();
expect(screen.getByText("엄마 생신 선물")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /새로 적기/ })).toHaveAttribute("href", "/inbox");
});
CommandInput.test.tsx(captureInbox·router 모킹):
import { vi } from "vitest";
const push = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
const capture = vi.fn().mockResolvedValue({ item: {}, classification: {} });
vi.mock("@/lib/api", () => ({ captureInbox: (r: string) => capture(r) }));
test("전송 시 캡처 호출 후 /inbox로 이동", async () => {
const { default: userEvent } = await import("@testing-library/user-event");
render(<CommandInput />);
const input = screen.getByLabelText("아리에게 명령 입력");
await userEvent.type(input, "다음 주 한국 비행기 티켓 사기");
await userEvent.click(screen.getByLabelText("보내기"));
expect(capture).toHaveBeenCalledWith("다음 주 한국 비행기 티켓 사기");
expect(push).toHaveBeenCalledWith("/inbox");
});
test("빈 입력이면 send 비활성", () => {
render(<CommandInput />);
expect(screen.getByLabelText("보내기")).toBeDisabled();
});
7.4 E2E 테스트(playwright/dashboard.spec.ts)
import { test, expect } from "@playwright/test";
test.describe("대시보드", () => {
test("로드 → 집계가 시드 기준으로 표시된다", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.getByText("아리 브리핑")).toBeVisible();
await expect(page.locator(".brief b")).toHaveText("분기 리포트");
// 결재함 요약: high risk 3건
await expect(page.getByText("아리 결재함")).toBeVisible();
await expect(page.getByText("대기 3건")).toBeVisible();
await expect(page.getByText("현우님께 회신 초안이 준비됐어요")).toBeVisible();
// 일정 4건 + "곧"
await expect(page.locator(".ev")).toHaveCount(4);
await expect(page.getByText("곧")).toBeVisible();
// 목표 3건
await expect(page.getByRole("progressbar")).toHaveCount(3);
});
test("인박스 카드 '새로 적기' → /inbox", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("link", { name: /새로 적기/ }).click();
await expect(page).toHaveURL(/\/inbox$/);
});
test("결재함 카드 CTA → placeholder(준비 중)", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("link", { name: /결재함에서 승인하기/ }).click();
await expect(page.getByText(/준비 중/)).toBeVisible();
});
test("자연어 명령 입력 → 캡처 후 /inbox 이동", async ({ page }) => {
await page.goto("/dashboard");
const input = page.getByLabel("아리에게 명령 입력");
await input.fill("수요일 11시 자전거 수리 맡기기");
await page.getByLabel("보내기").click();
await expect(page).toHaveURL(/\/inbox$/);
// 인박스에 방금 캡처 항목이 보인다
await expect(page.getByText("수요일 11시 자전거 수리 맡기기")).toBeVisible();
});
test("작업 요약 항목 클릭 → /tasks 딥링크", async ({ page }) => {
await page.goto("/dashboard");
await page.getByText("분기 리포트 초안 마무리").click();
await expect(page).toHaveURL(/\/tasks\?task=/);
});
test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "테마" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.getByText("아리 브리핑")).toBeVisible();
});
});
7.5 접근성 테스트(playwright/dashboard.a11y.spec.ts)
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("대시보드 a11y — 위반 없음(라이트)", async ({ page }) => {
await page.goto("/dashboard");
await page.getByText("아리 브리핑").waitFor();
const res = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa"]).analyze();
expect(res.violations).toEqual([]);
});
test("대시보드 a11y — 다크 테마", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "테마" }).click();
const res = await new AxeBuilder({ page }).withTags(["wcag2a","wcag2aa"]).analyze();
expect(res.violations).toEqual([]);
});
접근성 체크 포인트:
- 모든 아이콘 전용 버튼에
aria-label(검색/메일/테마/보내기/더보기). goal-bar에role="progressbar"+aria-valuenow/min/max.- 로딩 영역
aria-busy="true"+aria-live="polite", 에러role="alert". - 색 대비: 잉크(
#211f1c)/배경(글래스) 4.5:1 이상, 다크 테마(#f3eee6/#2c2925)도 확인. - 키보드: 명령 입력 Enter 전송, 카드 CTA/링크 Tab 포커스 가능,
:focus-visible아웃라인(blue) 보임.
7.6 수동 QA 체크리스트
/dashboard진입 시 카드 진입 애니메이션(cardin)이 한 번 재생된다.- 히어로 코럴 pill "아리 브리핑", 브리핑 본문에 "분기 리포트"가 코럴 underline
<b>로 강조된다. - 추천 칩 3개("내일 오후 비워줘", "중요 메일만 요약", "14시 미팅 준비")가 표시된다.
- 명령 입력 placeholder "오늘 하루, 무엇이든 맡겨보세요…"가 보이고, 텍스트 입력 후 send 버튼이 활성화된다.
- 명령 전송 시 토스트가 뜨고
/inbox로 이동, 인박스에 방금 적은 항목이 보인다. - 결재함 카드: 라임 spark 아이콘, "오늘 47분 아껴드렸어요", "대기 3건"(코럴 배지), 미니 행 3개(현우 회신/민서 위임/Netflix), CTA 클릭 시 "준비 중".
- 인박스 카드: inbox 아이콘, "오늘 7건", 미니 행 3개(kind별 pen/mic/image 아이콘 + tone 색), "새로 적기 · 인박스 열기" →
/inbox. - 일정 카드: 4건, 14:00 "분기 전략 미팅"에 "곧" 배지, accent 좌측 바 색(blue/violet/coral/green), 하단 sched-note 문구.
- 할 일 카드: "N개 남음" 배지, 첫 미완료 항목에 "지금" focus 강조(코럴), 나머지에 우선순위 배지(높음=coral/보통=amber/낮음=muted), 항목 클릭 시
/tasks?task=.... - 목표 카드: 3건, 진행 막대 width(68/75/40%)와 색(blue/coral/violet), mono 퍼센트.
- 다크 토글: 모든 카드/히어로/배지가 다크 토큰으로 전환, 텍스트 가독성 유지.
- 반응형: 1200px↓ 2열, 720px↓ 1열 + Topbar 메인 nav 숨김 + 히어로 1칸.
- 로딩 중 스켈레톤이 동일 레이아웃으로 보이고 점프 없음.
- 백엔드 중단 상태에서 새로고침 → 전체 에러 화면 + "다시 시도" 동작.
prefers-reduced-motion켠 상태에서 진입/쉬머 애니메이션 비활성.
7.7 통과 기준
- 백엔드
test_dashboard.py전 케이스 통과(시드 값 정확성 포함). - 컴포넌트 테스트(Hero/Approval/Inbox/Goals/Command) 전 통과.
- E2E 6개 시나리오 전 통과(로드·인박스 링크·결재함 placeholder·명령 입력·작업 딥링크·다크).
- axe 위반 0건(라이트/다크).
- 수동 QA 체크리스트 100% 충족.
8. 완료 기준(Definition of Done)
GET /api/dashboard가DashboardOut형태로 시드 기준 집계를 반환(사용자/브리핑/saved_today·today_routed 최상위/일정/작업요약/목표/결재요약/인박스최근/배지).- 인사 "좋은 아침이에요, {name}님"이 응답 최상위
user.name을 사용(하드코딩 제거, R12). briefing.note의 HTML(<b>분기 리포트</b>)이 보존되어 프론트에서 강조 렌더.- 결재함 요약이
risk==="high"3건만(현우/민서/Netflix), "대기 3건" 배지가 Topbar 결재함 배지(3)와 일치. - 인박스 요약이 최근 3건 + 분류
type(프론트 한글 라벨)/proj_label/tone을 표시, kind별 아이콘 정확. - 일정 4건 + "곧" 배지(분기 전략 미팅), 할 일 요약(open_count + 포커스/우선순위), 목표 3건(진행 막대) 정상.
- 인박스 카드 "새로 적기" →
/inbox, 작업 항목 →/tasks?task=, 결재함 CTA → placeholder("준비 중"). - 자연어 명령 입력: 전송 →
POST /api/inbox/capture→ 토스트 →/inbox이동(MVP 동작). - 라이트/다크 테마 모두 정상 렌더(토큰 기반), 진입 애니메이션 동작, reduced-motion 대응.
- 로딩 스켈레톤/빈 상태(위젯별)/전체 에러/오프라인 처리 구현.
- 디자인 충실도:
.board4열 벤토,.card글래스(22px radius, blur(26px) saturate(190%)), 히어로 코럴/화이트 글로우,.mini-*/.appr .ico.lime/.count.warm픽셀·색 일치. - 7장의 모든 테스트/QA 통과 기준 충족.
9. 다음 단계
대시보드까지 3개 MVP 페이지(작업·인박스·대시보드)가 완성되었다. 다음은 phase-6-integration.md — 연합(federation) 흐름 통합 검증과 마무리:
- 대시보드 명령 입력 → 인박스 캡처/분류 → 확인(실체화) → 작업 생성 → 대시보드 작업 요약/배지에 반영되는 end-to-end 흐름 E2E.
- 인박스
confirm으로 만든 작업이task_summary/badges.task에 합산되는지, 결재함 처리 후badges.appr감소(추후 확장) 등 집계 정합성. - 전체 접근성(axe) 스윕, 성능(LCP/번들), 실행법(README), MVP 수용 기준 최종 점검.
참조: overview.md(전체 아키텍처), phase-2-backend.md(집계/시드 원천), phase-4-inbox.md(명령 입력이 호출하는 캡처/분류), phase-3-tasks.md(작업 요약/딥링크 대상).