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.

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 라우트를 열면 다음이 한 화면(벤토 그리드)에 시드 데이터 기준으로 표시된다.

  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 고정값과 원본 데이터 파일을 그대로 따른다.

# 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.jsgoals[].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-2run_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: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}이다.

# 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_iconcloudSun→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_summaryrisk === "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>와 동일. CTA href는 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.cvar(--blue) 문자열을 넣지만, 백엔드가 tone 키(blue)를 주므로 var(--${g.tone})로 변환. goal-barrole="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-barrole="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/dashboardDashboardOut 형태로 시드 기준 집계를 반환(사용자/브리핑/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(작업 요약/딥링크 대상).