You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1532 lines
72 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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` 라우트를 열면 다음이 한 화면(벤토 그리드)에 시드 데이터 기준으로 표시된다.
1. **히어로 브리핑** — "좋은 아침이에요, 지우님" + 날씨/출근/수면 한 줄 + `briefingNote`(HTML, `<b>` 강조 포함) + 추천 칩 3개 + **자연어 명령 입력창**.
2. **결재함 요약 카드** — `risk === "high"` 대기 건 목록 3개(미니 리스트) + "오늘 47분 아껴드렸어요" 서브 + "결재함에서 승인하기" CTA(MVP에서는 `/결재함` placeholder로 라우팅, "준비 중").
3. **인박스 요약 카드** — 최근 캡처 3건 미니 리스트 + "오늘 7건" 서브 + "새로 적기 · 인박스 열기" CTA(→ `/inbox`, 실제 동작).
4. **오늘 일정 요약** — `schedule` 4건(시간·제목·태그·길이·"곧" 배지) + 일정 노트.
5. **할 일 요약** — `task_summary`(남은 개수 + 상위 작업 목록, 첫 항목 "지금" 포커스).
6. **목표 요약** — `goals` 3건(제목·퍼센트·진행 막대·서브).
7. **자연어 명령 입력** — 히어로 하단 `.cmd` 입력창. 입력 후 전송 시 인박스 캡처(`POST /api/inbox/capture`) 호출 → 토스트로 "인박스에 적어뒀어요" 안내 후 `/inbox`로 이동(MVP 동작, 5.5 정의).
8. **라이트/다크 테마** — Topbar 토글에 따라 모든 카드/히어로 색이 토큰 기반으로 전환.
9. **로딩 스켈레톤 / 빈 상태 / 부분 실패(위젯별)** 처리.
> 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 고정값과 원본 데이터 파일을 그대로 따른다.
```python
# 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)`를 호출한다.
```python
# 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:0011: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}`이다.
```python
# 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 정본과 동일)
```json
{
"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`)
```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`)
```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)한다.
```ts
// 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`)
```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`)이 제공하므로 여기서는 페이지 본문만 그린다.
```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`(자연어 명령 입력).
```tsx
// 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` 이동**.
```tsx
// 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` 적용.
```tsx
// 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>`와 동일. CTA `href`는 placeholder 라우트(예: `/결재함` 또는 `/(placeholder)/approvals`) — 클릭 시 phase-1의 "준비 중" 플레이스홀더 페이지를 보여준다.
### 3.14 프론트 — 인박스 요약 카드(`InboxSummaryCard.tsx`)
원본: `.ico`(inbox) + "스마트 인박스" + 서브 "적으면 아리가 제자리로" + `count` "오늘 7건" + `.mini-list`(3행: kind에 따라 아이콘 pen/mic/image) + `.mini-cta`(→ 인박스, "새로 적기 · 인박스 열기").
```tsx
// 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` 인라인 변수로 아이콘 칩 색을 입힌다.
```tsx
// 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`.
```tsx
// 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`) 유지.
```tsx
// 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).
```tsx
// 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`)
```css
.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`)
```css
.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`)
- 배경: 좌상단 코럴 글로우 + 우상단 화이트 글로우 + 글래스.
```css
.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-*`)
```css
.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`)
```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`에서는 애니메이션 비활성:
```css
@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` 동안 동일 그리드 레이아웃의 스켈레톤 카드를 그린다(레이아웃 점프 방지).
```tsx
// 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>
);
}
```
```css
/* 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`)로 감쌀 수 있다:
```tsx
// 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 실행 명령
```bash
# 백엔드 (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`)
```python
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`:
```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`:
```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`:
```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`:
```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 모킹):
```tsx
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`)
```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`)
```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 대응.
- [ ] 로딩 스켈레톤/빈 상태(위젯별)/전체 에러/오프라인 처리 구현.
- [ ] 디자인 충실도: `.board` 4열 벤토, `.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`(작업 요약/딥링크 대상).