Phase 5: 대시보드(Dashboard) 페이지

- 히어로 브리핑(브리핑 노트 HTML 강조 + 추천 칩 3 + 자연어 명령 입력)
- 자연어 명령 → POST capture → 토스트 → /inbox 이동
- 결재함 요약(high-risk 3건, "47분 아껴드렸어요", 대기 N건, 준비중 CTA)
- 인박스 요약(최근 3건, kind별 아이콘, /inbox CTA)
- 오늘 일정(4건·"곧" 배지·accent 바·sched-note) / 할 일(상위 5·"지금" focus·우선순위)
  / 목표(진행 막대·progressbar aria)
- 벤토 그리드(.board 4열) + cardin 진입 애니메이션 + 스켈레톤/빈/에러
- dashboard.css(dash.css+approve.css 이식), DashUser/Weather/... 타입, SWR useDashboard
- 백엔드 보정: EventOut 에 id 추가(React key), task_summary 최상위 미완료 상위 5건으로 제한

검증: 백엔드 pytest 54, 프론트 vitest 49, dashboard e2e 6 + a11y 2,
tsc/eslint/ruff clean, build OK. 라이브 시각 확인(히어로/요약/일정/할일/목표), 콘솔 에러 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Claude 2 months ago
parent cb028df08a
commit 09ed8497f3

@ -48,18 +48,24 @@ def dashboard(s: Session = Depends(get_session)):
saved_today, today_routed = "", 0 saved_today, today_routed = "", 0
schedule = [ 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() for e in s.exec(select(Event).order_by(Event.sort_order)).all()
] ]
tasks = s.exec(select(Task)).all() tasks = s.exec(select(Task)).all()
proj_name = {p.id: p.name for p in s.exec(select(Project)).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 = [ items = [
TaskSummaryItem( TaskSummaryItem(
id=t.id, title=t.title, prio=t.prio.value, project=proj_name.get(t.project_id, "") 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) task_summary = TaskSummaryOut(open_count=len(open_tasks), items=items)

@ -233,6 +233,7 @@ class BriefingOut(BaseModel):
class EventOut(BaseModel): class EventOut(BaseModel):
id: str
time: str time: str
title: str title: str
tag: str tag: str

@ -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 "<b>분기 리포트</b>" 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

@ -1,4 +1,10 @@
// frontend/app/dashboard/page.tsx (Phase 1 스텁 — Phase 5에서 교체) // frontend/app/dashboard/page.tsx
export default function Page() { import type { Metadata } from "next";
return <h1 className="ph-title"></h1>; import "@/styles/dashboard.css";
import { DashboardClient } from "@/components/dashboard/DashboardClient";
export const metadata: Metadata = { title: "대시보드 · 아리" };
export default function DashboardPage() {
return <DashboardClient />;
} }

@ -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 (
<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="/approvals" aria-label="결재함에서 승인하기 (준비 중)">
<Icon name="tick" w={3} />
</Link>
</section>
);
}

@ -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 (
<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>
);
}

@ -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<HTMLDivElement>(null);
useEffect(() => {
const el = shellRef.current;
if (!el) return;
const id = requestAnimationFrame(() => el.classList.add("entered"));
return () => cancelAnimationFrame(id);
}, [data]);
if (isLoading) return <DashboardSkeleton />;
if (error || !data) {
return (
<div className="dash-error" role="alert">
<p> .</p>
<button className="mini-cta" onClick={() => mutate()}>
<Icon name="swap" />
</button>
</div>
);
}
const {
user,
briefing,
saved_today,
today_routed,
schedule,
task_summary,
goals,
approvals_summary,
inbox_recent,
badges,
} = data;
return (
<div className="dash-page" ref={shellRef}>
<div className="pagehead">
<div>
<div className="ph-eyebrow">
<span>{briefing.today}</span>
<span className="sep" />
<span className="wx">
<Icon name={briefing.weather.icon as IconName} />
{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">
<HeroBriefing briefing={briefing} />
<ApprovalSummaryCard
items={approvals_summary}
savedToday={saved_today}
pending={badges.appr}
/>
<InboxSummaryCard items={inbox_recent} todayRouted={today_routed} />
<ScheduleCard items={schedule} />
<TaskSummaryCard summary={task_summary} />
<GoalsCard goals={goals} />
</div>
</div>
</div>
);
}

@ -0,0 +1,28 @@
// 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>
);
}

@ -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 (
<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>
);
}

@ -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 (
<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>
);
}

@ -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<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 || "faint"}
icon={kindIcon(c.kind)}
text={c.raw}
sub={typeLabel(c.type)}
/>
))}
</div>
)}
<Link className="mini-cta" href="/inbox">
<Icon name="plus" />
·
</Link>
</section>
);
}

@ -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 (
<div className="mini-row">
<span className="mini-ic" style={{ "--tone": `var(--${tone})` } as CSSProperties}>
<Icon name={icon as IconName} />
</span>
<span className="mini-text">{text}</span>
<span className="mini-sub">{sub}</span>
</div>
);
}

@ -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 (
<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>
{items.length === 0 ? (
<div className="appr-empty">
<span className="ae-tick">
<Icon name="tick" w={3} />
</span>
. .
</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": `var(--${e.tone})` } as 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" key="sched-note">
<Icon name="bell" />
16:00 .
</div>
</div>
)}
</section>
);
}

@ -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<string, string> = { : "high", : "mid", : "low" };
export function TaskSummaryCard({ summary }: { summary: DashTaskSummary }) {
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>
{summary.items.length === 0 ? (
<div className="appr-empty">
<span className="ae-tick">
<Icon name="tick" w={3} />
</span>
🎉
</div>
) : (
<div className="tasks">
{summary.items.map((t, idx) => {
const focus = idx === 0;
return (
<Link key={t.id} href={`/tasks?task=${t.id}`} className={cx("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>
);
}

@ -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<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 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();
}

@ -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>("dashboard", () => getDashboard(), {
revalidateOnFocus: false,
dedupingInterval: 5000,
});
}

@ -134,3 +134,79 @@ export interface UiInboxItem extends Omit<InboxItem, "status"> {
fresh?: boolean; fresh?: boolean;
fallbackUsed?: 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;
}

@ -5,11 +5,15 @@ export default defineConfig({
testDir: "./playwright", testDir: "./playwright",
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, // dev 서버 콜드 컴파일/일시 지연 흡수 (직렬 장기 실행 시 goto 타임아웃 방지)
timeout: 60_000,
retries: 1,
reporter: [["list"]], reporter: [["list"]],
use: { use: {
baseURL: "http://localhost:3000", baseURL: "http://localhost:3000",
trace: "on-first-retry", trace: "on-first-retry",
navigationTimeout: 45_000,
actionTimeout: 15_000,
}, },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: { webServer: {

@ -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([]);
});

@ -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();
});

@ -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;
}
}

@ -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(<CommandInput />);
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(<CommandInput />);
expect(screen.getByLabelText("보내기")).toBeDisabled();
});
});

@ -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: "오늘은 오후 미팅이 핵심이에요. <b>분기 리포트</b>",
};
describe("HeroBriefing", () => {
it("브리핑 노트 HTML 강조 + 칩 + 명령 입력", () => {
render(<HeroBriefing briefing={briefing} />);
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(<ApprovalSummaryCard items={items} savedToday="47분" pending={3} />);
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(<ApprovalSummaryCard items={[]} savedToday="0분" pending={0} />);
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(<GoalsCard goals={goals} />);
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(<InboxSummaryCard items={items} todayRouted={7} />);
expect(screen.getByText("오늘 7건")).toBeInTheDocument();
expect(screen.getByText("엄마 생신 선물")).toBeInTheDocument();
expect(screen.getByText("작업")).toBeInTheDocument(); // typeLabel(task)
expect(screen.getByRole("link", { name: /새로 적기/ })).toHaveAttribute("href", "/inbox");
});
});
Loading…
Cancel
Save