# 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, `` 강조 포함) + 추천 칩 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`, ``, ``, ``, 테마 토글(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 # 서버 컴포넌트 진입(메타데이터) → ├─ 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 허용; 강조) 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=("오늘은 오후 미팅이 핵심이에요. 오전을 비워 분기 리포트에 " "집중하시면 좋겠어요. 14시 전엔 비가 그칠 예정이라 우산은 안 챙기셔도 돼요."), saved_today="47분", today_routed=7, )) events = [ Event(id="e1", time="09:30", title="팀 데일리 스탠드업", tag="프로덕트", dur="15분", tone="blue", sort_order=0), Event(id="e2", time="11:00", title="디자인 리뷰 — 온보딩 플로우", tag="디자인", dur="45분", tone="violet", sort_order=1), Event(id="e3", time="14:00", title="분기 전략 미팅", tag="경영진", dur="60분", tone="coral", soon=True, sort_order=2), Event(id="e4", time="16:30", title="1:1 — 민서님", tag="팀", dur="30분", tone="green", sort_order=3), ] session.add_all(events) # approve-data.js 그대로 approvals = [ Approval(id="a1", icon="cal", tone="coral", risk="low", time="07:42", title="치과 예약을 16:00로 옮겼어요", detail="14시 분기 전략 미팅과 겹침 · 병원 예약 시스템에서 빈 슬롯 확인 후 변경", undo_label="원래 시간으로", sort_order=0), Approval(id="a2", icon="mail", tone="blue", risk="low", time="06:10", title="영수증·뉴스레터 7통을 정리했어요", detail="영수증 3통 → 금융 폴더 · 뉴스레터 4통 → 읽을거리, 받은편지함은 중요한 것만 남김", undo_label="되돌리기", sort_order=1), Approval(id="a3", icon="cal", tone="violet", risk="low", time="07:40", title="내일 오전 딥 워크 2시간을 예약했어요", detail="분기 리포트 마감(내일 18시) 대비 · 9:00–11:00, 방해 금지로 설정", undo_label="블록 해제", sort_order=2), Approval(id="a4", icon="mail", tone="violet", risk="high", time="보내기 대기", title="현우님께 회신 초안이 준비됐어요", detail="“잘 받았어요! 금요일 오전까지 화면별 코멘트 정리해서 드릴게요.”", cta="보내기", alt="수정", sort_order=3), Approval(id="a5", icon="users", tone="green", risk="high", time="전달 대기", title="민서님께 ‘데이터 전처리’ 위임 요청", detail="오늘 일정 과부하 감지 · 맥락 요약과 마감(목)을 담은 요청 메시지 작성 완료", cta="전달", alt="내가 할게", sort_order=4), Approval(id="a6", icon="wallet", tone="amber", risk="high", time="확인 필요", title="Netflix 일시정지를 추천해요", detail="최근 2개월 시청 기록 없음 · 모레 17,000원 결제 예정 — 정지 절차는 준비해뒀어요", cta="일시정지", alt="유지", sort_order=5), ] session.add_all(approvals) # data.js goals (var(--x) → tone 키) goals = [ Goal(id="g1", title="분기 OKR — 사용자 리텐션", pct=68, sub="12개 중 8개 달성", tone="blue", sort_order=0), Goal(id="g2", title="주 4회 운동", pct=75, sub="이번 주 3/4회", tone="coral", sort_order=1), Goal(id="g3", title="‘딥 워크’ 책 완독", pct=40, sub="320쪽 중 128쪽", tone="violet", sort_order=2), ] session.add_all(goals) session.commit() ``` > 인박스 최근 캡처는 별도 시드하지 않는다 — `phase-4-inbox.md`에서 시드한 `inbox_item`(sinbox-data.js의 s1~s4)을 그대로 집계해 최신 3건을 사용한다. ### 3.3 백엔드 — 응답 스키마(`schemas.py`) `frontend/lib/types.ts`와 **필드명 1:1 대응**. CONTRACT의 `GET /api/dashboard → {user, briefing, saved_today, today_routed, schedule[], task_summary, goals[], approvals_summary[], inbox_recent[], badges:{appr,task,noti}}` 형태를 그대로 따른다. `briefing`은 `{weather, commute, sleep, note}`만 담고, `saved_today`·`today_routed`는 **최상위 필드**다. `goals[]`의 색상 키는 `tone`(예 `blue`), `inbox_recent[]`는 `{id,kind,raw,type,proj_label,tone}`이다. ```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": "오늘은 오후 미팅이 핵심이에요. 오전을 비워 분기 리포트에 집중하시면 좋겠어요. 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 { 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 ; } ``` > `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(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 ; if (isError || !data) { return (

대시보드를 불러오지 못했어요.

); } const { user, briefing, saved_today, today_routed, schedule, task_summary, goals, approvals_summary, inbox_recent } = data; return (
{/* 페이지 헤더 (원본 .pagehead) */}
{briefing.today} {briefing.weather.cond}

좋은 아침이에요, {user.name}님

{/* 히어로 — sp2(2칸) */} {/* 요약 카드 2개 */} {/* 오늘 요약 */}
); } ``` > 사용자명(인사 "좋은 아침이에요, {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 (
아리 브리핑 {/* briefingNote — HTML( 강조) */}

{CHIPS.map((c, i) => ( ))}
); } ``` > 보안 메모: `briefing.note`는 시드 출처의 HTML이며 ``만 포함한다. 사용자 입력이 아니므로 `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 (
{ e.preventDefault(); submit(); }} > setValue(e.target.value)} placeholder="오늘 하루, 무엇이든 맡겨보세요…" aria-label="아리에게 명령 입력" disabled={busy} /> ); } ``` > 원본 placeholder 문구 "오늘 하루, 무엇이든 맡겨보세요…" 그대로 사용. Enter 키 전송은 `
`으로 자연스럽게 지원. ### 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 (

아리 결재함

오늘 {savedToday} 아껴드렸어요
대기 {pending}건
{items.length === 0 ? (
지금은 확인할 게 없어요 — 다 처리해뒀어요.
) : (
{items.map((it) => ( ))}
)} {/* MVP: 결재함 전용 페이지는 placeholder("준비 중") */} 결재함에서 승인하기
); } ``` > 미니 행은 원본 `{it.title}{it.time}`와 동일. 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)[t] ?? "분류 중"; export function InboxSummaryCard({ items, todayRouted, }: { items: InboxRecent[]; todayRouted: number }) { return (

스마트 인박스

적으면 아리가 제자리로
오늘 {todayRouted}건
{items.length === 0 ? (
인박스가 비었어요 — 떠오르면 바로 적어두세요.
) : (
{items.map((c) => ( ))}
)} 새로 적기 · 인박스 열기
); } ``` ### 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 (
{text} {sub}
); } ``` ### 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 = { blue: "var(--blue)", violet: "var(--violet)", coral: "var(--coral)", green: "var(--green)", }; export function ScheduleCard({ items }: { items: ScheduleItem[] }) { return (

오늘 일정

{items.length}개 · 다음까지 2시간
{items.map((e) => (
{e.time}
{e.title}
{e.tag}{e.dur}
{e.soon && }
))}
치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요.
); } ``` > `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 = { 높음: "high", 보통: "mid", 낮음: "low" }; export function TaskSummaryCard({ summary }: { summary: TaskSummary }) { return (

할 일

{summary.open_count}개 남음
{summary.items.map((t, idx) => { // task_summary.items 는 미완료(open) 작업만 담긴다(백엔드가 done 제외) → 첫 항목을 focus 강조 const focus = idx === 0; return (
{t.title}
{t.project}
{focus ? 지금 : {t.prio}} ); })}
); } ``` > 원본 `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 (

목표

{goals.map((g) => (
{g.title} {g.pct}%
{g.sub}
))}
); } ``` > 원본은 `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`이 ``에 적용되면 카드·히어로 글래스·잉크가 자동 전환. 검증은 7장 QA에서 두 테마 모두 스크린샷. > 날씨 아이콘 충실도(R11/R12): 백엔드는 `weather_icon`을 원본 `data.js` 값인 `'cloudSun'`으로 저장하고, 집계 시 중앙 paths 맵에 존재하는 글리프 `'sun'`으로 매핑해 응답(`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 (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } ``` ```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 (

{name}

이 카드를 불러오지 못했어요.
); } ``` > 사용처(선택): `}>`. React Query 단일 쿼리 구조에서는 "부분 실패"가 데이터 형태 결손으로만 발생하므로, ErrorBoundary는 렌더 예외(예상치 못한 데이터) 안전망 역할. ### 6.4 오프라인 - `navigator.onLine === false` 또는 fetch 실패 → 전체 에러 화면에 "오프라인 상태예요 — 연결되면 자동으로 불러올게요." 문구. `online` 이벤트 리스너로 자동 `refetch`. - 자연어 명령 입력은 오프라인 시 전송 비활성 + 토스트 "오프라인이라 지금은 적어둘 수 없어요." ### 6.5 엣지 케이스 | 케이스 | 처리 | |---|---| | `briefing.note`에 `` 외 태그 | 시드 출처라 안전. 사용자 입력 경로 없음(명령 입력은 인박스로만 감) | | `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 "분기 리포트" 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: "오늘은 오후 미팅이 핵심이에요. 분기 리포트", }; test("브리핑 노트의 HTML 강조를 렌더한다", () => { render(); expect(screen.getByText("아리 브리핑")).toBeInTheDocument(); const b = document.querySelector(".brief b"); expect(b?.textContent).toBe("분기 리포트"); }); test("추천 칩 3개와 명령 입력창을 렌더한다", () => { render(); 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(); 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(); 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(); 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(); 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(); const input = screen.getByLabelText("아리에게 명령 입력"); await userEvent.type(input, "다음 주 한국 비행기 티켓 사기"); await userEvent.click(screen.getByLabelText("보내기")); expect(capture).toHaveBeenCalledWith("다음 주 한국 비행기 티켓 사기"); expect(push).toHaveBeenCalledWith("/inbox"); }); test("빈 입력이면 send 비활성", () => { render(); 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 ``로 강조된다. - [ ] 추천 칩 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(`분기 리포트`)이 보존되어 프론트에서 강조 렌더. - [ ] 결재함 요약이 `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`(작업 요약/딥링크 대상).