From 09ed8497f3a7ebb6109a1474517cf35677525b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 05:32:52 -0800 Subject: [PATCH] =?UTF-8?q?Phase=205:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C(Dashboard)=20=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 히어로 브리핑(브리핑 노트 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) --- backend/app/routers/dashboard.py | 12 +- backend/app/schemas.py | 1 + backend/tests/test_dashboard.py | 69 +++ frontend/app/dashboard/page.tsx | 12 +- .../dashboard/ApprovalSummaryCard.tsx | 52 ++ .../components/dashboard/CommandInput.tsx | 58 ++ .../components/dashboard/DashboardClient.tsx | 89 +++ .../dashboard/DashboardSkeleton.tsx | 28 + frontend/components/dashboard/GoalsCard.tsx | 39 ++ .../components/dashboard/HeroBriefing.tsx | 37 ++ .../components/dashboard/InboxSummaryCard.tsx | 61 ++ frontend/components/dashboard/MiniRow.tsx | 27 + .../components/dashboard/ScheduleCard.tsx | 52 ++ .../components/dashboard/TaskSummaryCard.tsx | 54 ++ frontend/lib/dashboard/api.ts | 21 + frontend/lib/dashboard/useDashboard.ts | 12 + frontend/lib/types.ts | 76 +++ frontend/playwright.config.ts | 6 +- frontend/playwright/dashboard.a11y.spec.ts | 21 + frontend/playwright/dashboard.spec.ts | 50 ++ frontend/styles/dashboard.css | 559 ++++++++++++++++++ .../tests/dashboard/CommandInput.test.tsx | 27 + frontend/tests/dashboard/cards.test.tsx | 75 +++ 23 files changed, 1431 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_dashboard.py create mode 100644 frontend/components/dashboard/ApprovalSummaryCard.tsx create mode 100644 frontend/components/dashboard/CommandInput.tsx create mode 100644 frontend/components/dashboard/DashboardClient.tsx create mode 100644 frontend/components/dashboard/DashboardSkeleton.tsx create mode 100644 frontend/components/dashboard/GoalsCard.tsx create mode 100644 frontend/components/dashboard/HeroBriefing.tsx create mode 100644 frontend/components/dashboard/InboxSummaryCard.tsx create mode 100644 frontend/components/dashboard/MiniRow.tsx create mode 100644 frontend/components/dashboard/ScheduleCard.tsx create mode 100644 frontend/components/dashboard/TaskSummaryCard.tsx create mode 100644 frontend/lib/dashboard/api.ts create mode 100644 frontend/lib/dashboard/useDashboard.ts create mode 100644 frontend/playwright/dashboard.a11y.spec.ts create mode 100644 frontend/playwright/dashboard.spec.ts create mode 100644 frontend/styles/dashboard.css create mode 100644 frontend/tests/dashboard/CommandInput.test.tsx create mode 100644 frontend/tests/dashboard/cards.test.tsx 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 ( +
{ + e.preventDefault(); + submit(); + }} + > + + setValue(e.target.value)} + placeholder="오늘 하루, 무엇이든 맡겨보세요…" + aria-label="아리에게 명령 입력" + disabled={busy} + /> + + + ); +} 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"); + }); +});