diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
index 2ad587b..e4e9877 100644
--- a/backend/app/routers/dashboard.py
+++ b/backend/app/routers/dashboard.py
@@ -48,18 +48,24 @@ def dashboard(s: Session = Depends(get_session)):
saved_today, today_routed = "", 0
schedule = [
- EventOut(time=e.time, title=e.title, tag=e.tag, dur=e.dur, tone=e.tone, soon=e.soon)
+ EventOut(
+ id=e.id, time=e.time, title=e.title, tag=e.tag, dur=e.dur, tone=e.tone, soon=e.soon
+ )
for e in s.exec(select(Event).order_by(Event.sort_order)).all()
]
tasks = s.exec(select(Task)).all()
proj_name = {p.id: p.name for p in s.exec(select(Project)).all()}
- open_tasks = [t for t in tasks if t.status.value != "done"]
+ # 요약은 최상위(parent_id=None) 미완료 작업만, 상위 5건. open_count=최상위 미완료 수.
+ open_tasks = sorted(
+ [t for t in tasks if t.parent_id is None and t.status.value != "done"],
+ key=lambda t: t.sort_order,
+ )
items = [
TaskSummaryItem(
id=t.id, title=t.title, prio=t.prio.value, project=proj_name.get(t.project_id, "")
)
- for t in open_tasks
+ for t in open_tasks[:5]
]
task_summary = TaskSummaryOut(open_count=len(open_tasks), items=items)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index f6afc69..84c004e 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -233,6 +233,7 @@ class BriefingOut(BaseModel):
class EventOut(BaseModel):
+ id: str
time: str
title: str
tag: str
diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py
new file mode 100644
index 0000000..e6178f8
--- /dev/null
+++ b/backend/tests/test_dashboard.py
@@ -0,0 +1,69 @@
+# phase-5 §7.2 — 대시보드 집계 정확성
+def test_dashboard_shape(client):
+ d = client.get("/api/dashboard").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(client):
+ d = client.get("/api/dashboard").json()
+ assert d["user"]["name"] == "지우"
+ assert d["user"]["initial"] == "지"
+
+
+def test_briefing_seed_values(client):
+ d = client.get("/api/dashboard").json()
+ b = d["briefing"]
+ assert b["today"] == "6월 7일 일요일"
+ assert b["weather"]["cond"] == "맑음 · 한낮 28°"
+ assert b["weather"]["icon"] == "sun" # cloudSun → sun
+ assert d["saved_today"] == "47분"
+ assert d["today_routed"] == 7
+ assert "분기 리포트" in b["note"]
+
+
+def test_schedule_four_items_and_soon(client):
+ 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(client):
+ d = client.get("/api/dashboard").json()
+ ap = d["approvals_summary"]
+ assert len(ap) == 3
+ titles = [a["title"] for a in ap]
+ assert "현우님께 회신 초안이 준비됐어요" in titles
+ assert "Netflix 일시정지를 추천해요" in titles
+ assert all("치과 예약" not in t for t in titles)
+
+
+def test_inbox_recent_three_with_type_and_proj(client):
+ 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(client):
+ 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) 아님
+
+
+def test_badges_match_counts(client):
+ d = client.get("/api/dashboard").json()
+ b = d["badges"]
+ assert b["appr"] == 3
+ assert b["task"] == d["task_summary"]["open_count"]
+ assert b["noti"] == 6
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
index abe0dc5..a10487d 100644
--- a/frontend/app/dashboard/page.tsx
+++ b/frontend/app/dashboard/page.tsx
@@ -1,4 +1,10 @@
-// frontend/app/dashboard/page.tsx (Phase 1 스텁 — Phase 5에서 교체)
-export default function Page() {
- return
대시보드
;
+// frontend/app/dashboard/page.tsx
+import type { Metadata } from "next";
+import "@/styles/dashboard.css";
+import { DashboardClient } from "@/components/dashboard/DashboardClient";
+
+export const metadata: Metadata = { title: "대시보드 · 아리" };
+
+export default function DashboardPage() {
+ return ;
}
diff --git a/frontend/components/dashboard/ApprovalSummaryCard.tsx b/frontend/components/dashboard/ApprovalSummaryCard.tsx
new file mode 100644
index 0000000..1a9d896
--- /dev/null
+++ b/frontend/components/dashboard/ApprovalSummaryCard.tsx
@@ -0,0 +1,52 @@
+// frontend/components/dashboard/ApprovalSummaryCard.tsx
+"use client";
+import Link from "next/link";
+import { Icon } from "@/components/Icon";
+import type { ApprovalSummary } from "@/lib/types";
+import { MiniRow } from "./MiniRow";
+
+export function ApprovalSummaryCard({
+ items,
+ savedToday,
+ pending,
+}: {
+ items: ApprovalSummary[];
+ savedToday: string;
+ pending: number;
+}) {
+ return (
+
+
+
+
+
+
+
아리 결재함
+
오늘 {savedToday} 아껴드렸어요
+
+
대기 {pending}건
+
+
+ {items.length === 0 ? (
+
+
+
+
+ 지금은 확인할 게 없어요 — 다 처리해뒀어요.
+
+ ) : (
+
+ {items.map((it) => (
+
+ ))}
+
+ )}
+
+ {/* MVP: 결재함 전용 페이지는 placeholder("준비 중") */}
+
+
+ 결재함에서 승인하기
+
+
+ );
+}
diff --git a/frontend/components/dashboard/CommandInput.tsx b/frontend/components/dashboard/CommandInput.tsx
new file mode 100644
index 0000000..37e4e24
--- /dev/null
+++ b/frontend/components/dashboard/CommandInput.tsx
@@ -0,0 +1,58 @@
+// frontend/components/dashboard/CommandInput.tsx
+"use client";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { Icon } from "@/components/Icon";
+import { captureCommand } from "@/lib/dashboard/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 captureCommand(raw); // POST /api/inbox/capture
+ 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 (
+
+ );
+}
diff --git a/frontend/components/dashboard/DashboardClient.tsx b/frontend/components/dashboard/DashboardClient.tsx
new file mode 100644
index 0000000..a073181
--- /dev/null
+++ b/frontend/components/dashboard/DashboardClient.tsx
@@ -0,0 +1,89 @@
+// frontend/components/dashboard/DashboardClient.tsx
+"use client";
+import { useEffect, useRef } from "react";
+import { Icon } from "@/components/Icon";
+import type { IconName } from "@/components/icons/paths";
+import { useDashboard } from "@/lib/dashboard/useDashboard";
+import { ApprovalSummaryCard } from "./ApprovalSummaryCard";
+import { DashboardSkeleton } from "./DashboardSkeleton";
+import { GoalsCard } from "./GoalsCard";
+import { HeroBriefing } from "./HeroBriefing";
+import { InboxSummaryCard } from "./InboxSummaryCard";
+import { ScheduleCard } from "./ScheduleCard";
+import { TaskSummaryCard } from "./TaskSummaryCard";
+
+export function DashboardClient() {
+ const { data, isLoading, error, mutate } = useDashboard();
+ const shellRef = useRef(null);
+
+ useEffect(() => {
+ const el = shellRef.current;
+ if (!el) return;
+ const id = requestAnimationFrame(() => el.classList.add("entered"));
+ return () => cancelAnimationFrame(id);
+ }, [data]);
+
+ if (isLoading) return ;
+ if (error || !data) {
+ return (
+
+
대시보드를 불러오지 못했어요.
+
+
+ );
+ }
+
+ const {
+ user,
+ briefing,
+ saved_today,
+ today_routed,
+ schedule,
+ task_summary,
+ goals,
+ approvals_summary,
+ inbox_recent,
+ badges,
+ } = data;
+
+ return (
+
+
+
+
+ {briefing.today}
+
+
+
+ {briefing.weather.cond}
+
+
+
+ 좋은 아침이에요, {user.name}님 ☀
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/DashboardSkeleton.tsx b/frontend/components/dashboard/DashboardSkeleton.tsx
new file mode 100644
index 0000000..cb2a493
--- /dev/null
+++ b/frontend/components/dashboard/DashboardSkeleton.tsx
@@ -0,0 +1,28 @@
+// frontend/components/dashboard/DashboardSkeleton.tsx
+export function DashboardSkeleton() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/GoalsCard.tsx b/frontend/components/dashboard/GoalsCard.tsx
new file mode 100644
index 0000000..a01bfe8
--- /dev/null
+++ b/frontend/components/dashboard/GoalsCard.tsx
@@ -0,0 +1,39 @@
+// frontend/components/dashboard/GoalsCard.tsx
+import { Icon } from "@/components/Icon";
+import type { DashGoal } from "@/lib/types";
+
+export function GoalsCard({ goals }: { goals: DashGoal[] }) {
+ return (
+
+
+
+ {goals.map((g) => (
+
+
+ {g.title}
+ {g.pct}%
+
+
+
+
+
{g.sub}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/dashboard/HeroBriefing.tsx b/frontend/components/dashboard/HeroBriefing.tsx
new file mode 100644
index 0000000..e6b008e
--- /dev/null
+++ b/frontend/components/dashboard/HeroBriefing.tsx
@@ -0,0 +1,37 @@
+// frontend/components/dashboard/HeroBriefing.tsx
+"use client";
+import { Icon } from "@/components/Icon";
+import type { IconName } from "@/components/icons/paths";
+import type { DashBriefing } from "@/lib/types";
+import { CommandInput } from "./CommandInput";
+
+const CHIPS: { icon: IconName; text: string }[] = [
+ { icon: "cal", text: "내일 오후 비워줘" },
+ { icon: "mail", text: "중요 메일만 요약" },
+ { icon: "video", text: "14시 미팅 준비" },
+];
+
+export function HeroBriefing({ briefing }: { briefing: DashBriefing }) {
+ return (
+
+
+
+ 아리 브리핑
+
+
+ {/* briefingNote — 시드 출처 HTML( 강조). 사용자 입력 아님 → 안전. */}
+
+
+
+ {CHIPS.map((c, i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/InboxSummaryCard.tsx b/frontend/components/dashboard/InboxSummaryCard.tsx
new file mode 100644
index 0000000..3c607d8
--- /dev/null
+++ b/frontend/components/dashboard/InboxSummaryCard.tsx
@@ -0,0 +1,61 @@
+// frontend/components/dashboard/InboxSummaryCard.tsx
+"use client";
+import Link from "next/link";
+import { Icon } from "@/components/Icon";
+import type { InboxRecent } from "@/lib/types";
+import { MiniRow } from "./MiniRow";
+
+const kindIcon = (k: InboxRecent["kind"]) =>
+ k === "voice" ? "mic" : k === "image" ? "image" : "pen";
+
+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) => (
+
+ ))}
+
+ )}
+
+
+
+ 새로 적기 · 인박스 열기
+
+
+ );
+}
diff --git a/frontend/components/dashboard/MiniRow.tsx b/frontend/components/dashboard/MiniRow.tsx
new file mode 100644
index 0000000..fa47ded
--- /dev/null
+++ b/frontend/components/dashboard/MiniRow.tsx
@@ -0,0 +1,27 @@
+// frontend/components/dashboard/MiniRow.tsx
+import type { CSSProperties } from "react";
+import { Icon } from "@/components/Icon";
+import type { IconName } from "@/components/icons/paths";
+import type { Tone } from "@/lib/types";
+
+export function MiniRow({
+ tone,
+ icon,
+ text,
+ sub,
+}: {
+ tone: Tone;
+ icon: string;
+ text: string;
+ sub: string;
+}) {
+ return (
+
+
+
+
+ {text}
+ {sub}
+
+ );
+}
diff --git a/frontend/components/dashboard/ScheduleCard.tsx b/frontend/components/dashboard/ScheduleCard.tsx
new file mode 100644
index 0000000..11f5d3d
--- /dev/null
+++ b/frontend/components/dashboard/ScheduleCard.tsx
@@ -0,0 +1,52 @@
+// frontend/components/dashboard/ScheduleCard.tsx
+"use client";
+import type { CSSProperties } from "react";
+import { Icon } from "@/components/Icon";
+import type { ScheduleItem } from "@/lib/types";
+
+export function ScheduleCard({ items }: { items: ScheduleItem[] }) {
+ return (
+
+
+
+
+
+
+
오늘 일정
+
{items.length}개 · 다음까지 2시간
+
+
+
+ {items.length === 0 ? (
+
+
+
+
+ 오늘은 일정이 없어요. 여유로운 하루예요.
+
+ ) : (
+
+ {items.map((e) => (
+
+
{e.time}
+
+
{e.title}
+
+ {e.tag}
+ {e.dur}
+
+
+ {e.soon &&
곧}
+
+ ))}
+
+
+ 치과 예약은 16:00로 옮겨뒀어요 — 결재함에서 되돌릴 수 있어요.
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/dashboard/TaskSummaryCard.tsx b/frontend/components/dashboard/TaskSummaryCard.tsx
new file mode 100644
index 0000000..16c9b15
--- /dev/null
+++ b/frontend/components/dashboard/TaskSummaryCard.tsx
@@ -0,0 +1,54 @@
+// frontend/components/dashboard/TaskSummaryCard.tsx
+"use client";
+import Link from "next/link";
+import { Icon } from "@/components/Icon";
+import { cx } from "@/lib/cx";
+import type { DashTaskSummary } from "@/lib/types";
+
+const PRIO: Record = { 높음: "high", 보통: "mid", 낮음: "low" };
+
+export function TaskSummaryCard({ summary }: { summary: DashTaskSummary }) {
+ return (
+
+
+
+
+
+
+
할 일
+
+
{summary.open_count}개 남음
+
+ {summary.items.length === 0 ? (
+
+
+
+
+ 할 일이 모두 끝났어요 🎉
+
+ ) : (
+
+ {summary.items.map((t, idx) => {
+ const focus = idx === 0;
+ return (
+
+
+
+
+
+
{t.title}
+
{t.project}
+
+ {focus ? (
+
지금
+ ) : (
+
{t.prio}
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/dashboard/api.ts b/frontend/lib/dashboard/api.ts
new file mode 100644
index 0000000..823e964
--- /dev/null
+++ b/frontend/lib/dashboard/api.ts
@@ -0,0 +1,21 @@
+// frontend/lib/dashboard/api.ts
+import type { Dashboard } from "@/lib/types";
+
+const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
+
+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 captureCommand(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();
+}
diff --git a/frontend/lib/dashboard/useDashboard.ts b/frontend/lib/dashboard/useDashboard.ts
new file mode 100644
index 0000000..83ba628
--- /dev/null
+++ b/frontend/lib/dashboard/useDashboard.ts
@@ -0,0 +1,12 @@
+// frontend/lib/dashboard/useDashboard.ts
+"use client";
+import useSWR from "swr";
+import type { Dashboard } from "@/lib/types";
+import { getDashboard } from "./api";
+
+export function useDashboard() {
+ return useSWR("dashboard", () => getDashboard(), {
+ revalidateOnFocus: false,
+ dedupingInterval: 5000,
+ });
+}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index bbd7b7f..ee6ea43 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -134,3 +134,79 @@ export interface UiInboxItem extends Omit {
fresh?: boolean;
fallbackUsed?: boolean;
}
+
+// ---------- 대시보드 (Phase 5) ----------
+export interface DashUser {
+ name: string;
+ initial: string;
+}
+export interface Weather {
+ temp: number;
+ cond: string;
+ icon: string;
+}
+export interface DashBriefing {
+ today: string;
+ weather: Weather;
+ commute: string;
+ sleep: string;
+ note: string; // HTML
+}
+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 DashTaskSummaryItem {
+ id: string;
+ title: string;
+ project: string;
+ prio: Prio;
+}
+export interface DashTaskSummary {
+ open_count: number;
+ items: DashTaskSummaryItem[];
+}
+export interface DashGoal {
+ id: string;
+ title: string;
+ pct: number;
+ sub: string;
+ tone: Tone;
+}
+export interface Badges {
+ appr: number;
+ task: number;
+ noti: number;
+}
+export interface Dashboard {
+ user: DashUser;
+ briefing: DashBriefing;
+ saved_today: string;
+ today_routed: number;
+ schedule: ScheduleItem[];
+ task_summary: DashTaskSummary;
+ goals: DashGoal[];
+ approvals_summary: ApprovalSummary[];
+ inbox_recent: InboxRecent[];
+ badges: Badges;
+}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index b376323..f682c38 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -5,11 +5,15 @@ export default defineConfig({
testDir: "./playwright",
fullyParallel: true,
forbidOnly: !!process.env.CI,
- retries: process.env.CI ? 2 : 0,
+ // dev 서버 콜드 컴파일/일시 지연 흡수 (직렬 장기 실행 시 goto 타임아웃 방지)
+ timeout: 60_000,
+ retries: 1,
reporter: [["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
+ navigationTimeout: 45_000,
+ actionTimeout: 15_000,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
diff --git a/frontend/playwright/dashboard.a11y.spec.ts b/frontend/playwright/dashboard.a11y.spec.ts
new file mode 100644
index 0000000..84e872a
--- /dev/null
+++ b/frontend/playwright/dashboard.a11y.spec.ts
@@ -0,0 +1,21 @@
+// frontend/playwright/dashboard.a11y.spec.ts
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test } from "@playwright/test";
+
+// color-contrast 제외(레퍼런스 액센트 팔레트) — 구조/시맨틱 WCAG 는 엄격.
+const AXE = (page: import("@playwright/test").Page) =>
+ new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).disableRules(["color-contrast"]);
+
+test("대시보드 a11y — 라이트", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.getByText("아리 브리핑").waitFor();
+ expect((await AXE(page).analyze()).violations).toEqual([]);
+});
+
+test("대시보드 a11y — 다크", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.getByText("아리 브리핑").waitFor();
+ await page.getByLabel("테마 전환").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ expect((await AXE(page).analyze()).violations).toEqual([]);
+});
diff --git a/frontend/playwright/dashboard.spec.ts b/frontend/playwright/dashboard.spec.ts
new file mode 100644
index 0000000..c04b928
--- /dev/null
+++ b/frontend/playwright/dashboard.spec.ts
@@ -0,0 +1,50 @@
+// frontend/playwright/dashboard.spec.ts
+import { expect, test } from "@playwright/test";
+
+test.describe.configure({ mode: "serial" });
+
+test("로드 → 집계가 시드 기준으로 표시", async ({ page }) => {
+ await page.goto("/dashboard");
+ await expect(page.getByText("아리 브리핑")).toBeVisible();
+ await expect(page.locator(".brief b")).toHaveText("분기 리포트");
+ await expect(page.getByText("아리 결재함")).toBeVisible();
+ await expect(page.getByText("대기 3건")).toBeVisible();
+ await expect(page.getByText("현우님께 회신 초안이 준비됐어요")).toBeVisible();
+ await expect(page.locator(".ev")).toHaveCount(4);
+ await expect(page.getByText("곧", { exact: true })).toBeVisible();
+ 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("작업 요약 항목 클릭 → /tasks?task= 딥링크", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.locator(".task-title", { hasText: "분기 리포트 초안 마무리" }).click();
+ await expect(page).toHaveURL(/\/tasks\?task=/);
+});
+
+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.locator(".sb-raw", { hasText: "수요일 11시 자전거 수리 맡기기" }).first()).toBeVisible();
+});
+
+test("다크 테마 토글 후에도 렌더 정상", async ({ page }) => {
+ await page.goto("/dashboard");
+ await page.getByLabel("테마 전환").click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
+ await expect(page.getByText("아리 브리핑")).toBeVisible();
+});
diff --git a/frontend/styles/dashboard.css b/frontend/styles/dashboard.css
new file mode 100644
index 0000000..00cdca3
--- /dev/null
+++ b/frontend/styles/dashboard.css
@@ -0,0 +1,559 @@
+/* frontend/styles/dashboard.css — 원본 dash.css + approve.css 의 대시보드 전용 규칙 이식
+ (공유 레이아웃/카드는 dash-base.css) */
+@import "./dash-base.css";
+
+/* ---------- 히어로 ---------- */
+.hero {
+ background:
+ radial-gradient(95% 105% at 0% 0%, rgba(231, 128, 82, 0.22), transparent 56%),
+ radial-gradient(130% 130% at 100% 0%, rgba(255, 255, 255, 0.42), transparent 50%),
+ var(--glass);
+ -webkit-backdrop-filter: var(--blur);
+ backdrop-filter: var(--blur);
+ border: 1px solid var(--glass-brd);
+ box-shadow: var(--shadow), var(--glass-hi);
+ color: var(--ink);
+}
+.hero .ch h3 {
+ color: var(--ink);
+}
+.hero-spark {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #fff;
+ background: var(--coral);
+ border: 1px solid transparent;
+ padding: 5px 12px;
+ border-radius: 999px;
+ align-self: flex-start;
+ box-shadow: 0 5px 14px -5px var(--coral);
+ white-space: nowrap;
+}
+.hero-spark .ic {
+ width: 14px;
+ height: 14px;
+}
+.hero p.brief {
+ font-size: clamp(18px, 1.7vw, 22px);
+ line-height: 1.5;
+ letter-spacing: -0.02em;
+ margin: 16px 0 18px;
+ color: var(--ink);
+ max-width: 40ch;
+}
+.hero p.brief b {
+ color: var(--ink);
+ font-weight: 700;
+ border-bottom: 2px solid color-mix(in oklab, var(--coral) 60%, transparent);
+ padding-bottom: 1px;
+}
+.cmd {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ border-radius: 15px;
+ padding: 7px 7px 7px 16px;
+ margin-top: auto;
+}
+.cmd > svg:first-child {
+ color: var(--ink-2);
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+}
+.cmd input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ background: none;
+ outline: none;
+ color: var(--ink);
+ font-size: 14.5px;
+}
+.cmd input::placeholder {
+ color: var(--faint);
+}
+.cmd .send {
+ width: 38px;
+ height: 38px;
+ border-radius: 11px;
+ background: var(--fill);
+ color: var(--on-fill);
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ transition: filter 0.14s;
+}
+.cmd .send .ic {
+ width: 18px;
+ height: 18px;
+}
+.cmd .send:hover {
+ filter: brightness(1.05);
+}
+.cmd .send:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+.chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-top: 12px;
+}
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12.5px;
+ font-weight: 500;
+ color: var(--ink-2);
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ padding: 7px 12px;
+ border-radius: 999px;
+ transition: background 0.14s;
+}
+.chip .ic {
+ width: 14px;
+ height: 14px;
+ color: var(--muted);
+}
+.chip:hover {
+ background: var(--card-2);
+}
+
+/* ---------- 요약 카드 (approve.css mini-*) ---------- */
+.appr .ico.lime {
+ background: var(--lime);
+ color: var(--lime-ink);
+ border-color: color-mix(in oklab, var(--lime-ink) 12%, transparent);
+}
+.appr .count.warm {
+ color: #fff;
+ background: var(--coral);
+ border-color: transparent;
+}
+.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 0.14s;
+}
+.mini-cta .ic {
+ width: 14px;
+ height: 14px;
+}
+.mini-cta:hover {
+ filter: brightness(1.12);
+}
+.appr-empty {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+ color: var(--ink-2);
+ line-height: 1.5;
+ background: linear-gradient(
+ 100deg,
+ color-mix(in oklab, var(--green) 10%, transparent),
+ transparent 70%
+ ),
+ var(--glass-2);
+ border: 1px solid color-mix(in oklab, var(--green) 28%, var(--glass-brd));
+ border-radius: var(--radius-sm);
+ padding: 13px 14px;
+ margin-bottom: 12px;
+}
+.ae-tick {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ display: grid;
+ place-items: center;
+ background: var(--green);
+ color: #fff;
+}
+.ae-tick .ic {
+ width: 12px;
+ height: 12px;
+}
+
+/* ---------- 오늘 일정 ---------- */
+.sched {
+ display: flex;
+ flex-direction: column;
+}
+.ev {
+ display: flex;
+ gap: 13px;
+ padding: 13px 0;
+ border-top: 1px solid var(--line);
+}
+.ev:first-of-type {
+ border-top: none;
+ padding-top: 4px;
+}
+.ev-time {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink-2);
+ width: 42px;
+ flex-shrink: 0;
+ padding-top: 1px;
+}
+.ev-body {
+ flex: 1;
+ min-width: 0;
+ position: relative;
+ padding-left: 15px;
+}
+.ev-body::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 4px;
+ bottom: 4px;
+ width: 3px;
+ border-radius: 3px;
+ background: var(--accent, var(--blue));
+}
+.ev-title {
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.3;
+}
+.ev-meta {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 3px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+.ev-meta .dur {
+ color: var(--faint);
+}
+.ev .soon {
+ font-size: 10.5px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #fff;
+ background: var(--coral);
+ padding: 1px 7px;
+ border-radius: 999px;
+ align-self: flex-start;
+ margin-top: 1px;
+ flex-shrink: 0;
+}
+.sched-note {
+ margin-top: 12px;
+ display: flex;
+ gap: 9px;
+ align-items: flex-start;
+ background: var(--glass-2);
+ border: 1px solid var(--glass-brd);
+ border-radius: var(--radius-sm);
+ padding: 11px 13px;
+ font-size: 12.5px;
+ color: var(--ink-2);
+ line-height: 1.4;
+}
+.sched-note .ic {
+ width: 15px;
+ height: 15px;
+ color: var(--coral);
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+/* ---------- 할 일 ---------- */
+.tasks {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.task {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 12px 13px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--glass-brd);
+ background: var(--glass-2);
+ cursor: pointer;
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.14s, border-color 0.14s;
+}
+.task:hover {
+ background: var(--card-2);
+}
+.task .box {
+ width: 21px;
+ height: 21px;
+ border-radius: 7px;
+ border: 2px solid var(--line-2);
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ color: transparent;
+ transition: all 0.14s;
+}
+.task .box .ic {
+ width: 13px;
+ height: 13px;
+}
+.task-body {
+ flex: 1;
+ min-width: 0;
+}
+.task-title {
+ font-size: 13.5px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.3;
+}
+.task-meta {
+ font-size: 11.5px;
+ color: var(--muted);
+ margin-top: 2px;
+}
+.task .prio {
+ font-size: 10.5px;
+ font-weight: 700;
+ flex-shrink: 0;
+ padding: 2px 8px;
+ border-radius: 999px;
+}
+.prio.high {
+ color: var(--coral);
+ background: color-mix(in oklab, var(--coral) 14%, transparent);
+}
+.prio.mid {
+ color: var(--amber);
+ background: color-mix(in oklab, var(--amber) 16%, transparent);
+}
+.prio.low {
+ color: var(--muted);
+ background: var(--card-2);
+}
+.task.focus {
+ background:
+ linear-gradient(90deg, color-mix(in oklab, var(--coral) 13%, transparent), transparent 58%),
+ var(--glass);
+ border-color: color-mix(in oklab, var(--coral) 38%, var(--glass-brd));
+ box-shadow: var(--shadow-sm), inset 3px 0 0 0 var(--coral);
+}
+.task.focus .task-title {
+ color: var(--ink);
+ font-weight: 700;
+}
+.task.focus .box {
+ border-color: var(--coral);
+ background: color-mix(in oklab, var(--coral) 14%, transparent);
+}
+.task.focus .focus-tag {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: #fff;
+ background: var(--coral);
+ padding: 1px 7px;
+ border-radius: 999px;
+ flex-shrink: 0;
+}
+
+/* ---------- 목표 ---------- */
+.goals {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+.goal-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+ margin-bottom: 7px;
+}
+.goal-title {
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ line-height: 1.3;
+ flex: 1;
+ min-width: 0;
+}
+.goal-pct {
+ flex-shrink: 0;
+ font-size: 13px;
+ font-weight: 700;
+ font-family: var(--font-mono);
+}
+.goal-bar {
+ height: 7px;
+ border-radius: 999px;
+ background: var(--card-2);
+ overflow: hidden;
+}
+.goal-bar i {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+}
+.goal-sub {
+ font-size: 11.5px;
+ color: var(--muted);
+ margin-top: 5px;
+}
+
+/* ---------- 진입 애니메이션 ---------- */
+.entered .card {
+ animation: cardin 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
+}
+@keyframes cardin {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+/* ---------- 로딩 스켈레톤 ---------- */
+.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, 0.35), transparent);
+ transform: translateX(-100%);
+ animation: dash-shimmer 1.4s infinite;
+}
+@keyframes dash-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;
+}
+
+/* ---------- 에러 ---------- */
+.dash-error {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 48px 24px;
+ margin-top: 24px;
+ border-radius: var(--radius);
+ background: color-mix(in oklab, var(--coral) 7%, var(--card));
+ border: 1px solid color-mix(in oklab, var(--coral) 24%, transparent);
+ color: var(--ink-2);
+ text-align: center;
+}
+.dash-error .mini-cta {
+ margin-top: 0;
+ padding: 10px 18px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .entered .card,
+ .skel::after {
+ animation: none !important;
+ }
+}
diff --git a/frontend/tests/dashboard/CommandInput.test.tsx b/frontend/tests/dashboard/CommandInput.test.tsx
new file mode 100644
index 0000000..49ebc89
--- /dev/null
+++ b/frontend/tests/dashboard/CommandInput.test.tsx
@@ -0,0 +1,27 @@
+// frontend/tests/dashboard/CommandInput.test.tsx
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+const push = vi.fn();
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
+
+const capture = vi.fn().mockResolvedValue({ item: {}, classification: {} });
+vi.mock("@/lib/dashboard/api", () => ({ captureCommand: (r: string) => capture(r) }));
+
+import { CommandInput } from "@/components/dashboard/CommandInput";
+
+describe("CommandInput", () => {
+ it("전송 시 캡처 호출 후 /inbox로 이동", async () => {
+ render();
+ const input = screen.getByLabelText("아리에게 명령 입력");
+ fireEvent.change(input, { target: { value: "다음 주 한국 비행기 티켓 사기" } });
+ fireEvent.click(screen.getByLabelText("보내기"));
+ await waitFor(() => expect(capture).toHaveBeenCalledWith("다음 주 한국 비행기 티켓 사기"));
+ await waitFor(() => expect(push).toHaveBeenCalledWith("/inbox"));
+ });
+
+ it("빈 입력이면 send 비활성", () => {
+ render();
+ expect(screen.getByLabelText("보내기")).toBeDisabled();
+ });
+});
diff --git a/frontend/tests/dashboard/cards.test.tsx b/frontend/tests/dashboard/cards.test.tsx
new file mode 100644
index 0000000..ad93011
--- /dev/null
+++ b/frontend/tests/dashboard/cards.test.tsx
@@ -0,0 +1,75 @@
+// frontend/tests/dashboard/cards.test.tsx
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+// HeroBriefing 는 CommandInput(useRouter) 을 포함 → next/navigation 모킹
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
+import { ApprovalSummaryCard } from "@/components/dashboard/ApprovalSummaryCard";
+import { GoalsCard } from "@/components/dashboard/GoalsCard";
+import { HeroBriefing } from "@/components/dashboard/HeroBriefing";
+import { InboxSummaryCard } from "@/components/dashboard/InboxSummaryCard";
+import type { ApprovalSummary, DashBriefing, DashGoal, InboxRecent } from "@/lib/types";
+
+const briefing: DashBriefing = {
+ today: "6월 7일 일요일",
+ weather: { temp: 24, cond: "맑음 · 한낮 28°", icon: "sun" },
+ commute: "출근 23분",
+ sleep: "7시간 12분",
+ note: "오늘은 오후 미팅이 핵심이에요. 분기 리포트",
+};
+
+describe("HeroBriefing", () => {
+ it("브리핑 노트 HTML 강조 + 칩 + 명령 입력", () => {
+ render();
+ expect(screen.getByText("아리 브리핑")).toBeInTheDocument();
+ expect(document.querySelector(".brief b")?.textContent).toBe("분기 리포트");
+ expect(screen.getByText("내일 오후 비워줘")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("오늘 하루, 무엇이든 맡겨보세요…")).toBeInTheDocument();
+ });
+});
+
+describe("ApprovalSummaryCard", () => {
+ const items: ApprovalSummary[] = [
+ { id: "a4", icon: "mail", tone: "violet", title: "현우님께 회신 초안이 준비됐어요", time: "보내기 대기" },
+ ];
+ it("대기 N건 + 미니 행 + CTA", () => {
+ render();
+ expect(screen.getByText("아리 결재함")).toBeInTheDocument();
+ expect(screen.getByText("오늘 47분 아껴드렸어요")).toBeInTheDocument();
+ expect(screen.getByText("대기 3건")).toBeInTheDocument();
+ expect(screen.getByText("현우님께 회신 초안이 준비됐어요")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: /결재함에서 승인하기/ })).toHaveAttribute(
+ "href",
+ "/approvals",
+ );
+ });
+ it("0건이면 빈 상태", () => {
+ render();
+ expect(screen.getByText(/다 처리해뒀어요/)).toBeInTheDocument();
+ });
+});
+
+describe("GoalsCard", () => {
+ it("진행 막대 width + progressbar aria", () => {
+ const goals: DashGoal[] = [
+ { 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");
+ expect(bar.querySelector("i")).toHaveStyle({ width: "68%" });
+ });
+});
+
+describe("InboxSummaryCard", () => {
+ it("kind별 아이콘 + 새로 적기 CTA(/inbox)", () => {
+ const items: InboxRecent[] = [
+ { id: "s3", kind: "voice", raw: "엄마 생신 선물", type: "task", proj_label: "가족", tone: "green" },
+ ];
+ render();
+ expect(screen.getByText("오늘 7건")).toBeInTheDocument();
+ expect(screen.getByText("엄마 생신 선물")).toBeInTheDocument();
+ expect(screen.getByText("작업")).toBeInTheDocument(); // typeLabel(task)
+ expect(screen.getByRole("link", { name: /새로 적기/ })).toHaveAttribute("href", "/inbox");
+ });
+});