60 KiB
Phase 4 — 인박스(Smart Inbox) 페이지
한 줄 요약: "일단 적으면" 아리가 실시간으로 분류·배치·자동화하는 멀티모달 캡처 인박스를 완성하고, 확인(confirm) 한 번으로 실제 작업/일정/아이디어로 실체화(federation) 시킨다.
이 문서는
dev/문서 세트의 일부입니다 — 먼저overview.md를 읽으세요. 선행 문서:phase-0-foundation.md,phase-1-design-system.md,phase-2-backend.md,phase-3-tasks.md. 다음 문서:phase-5-dashboard.md.
1. 개요 & 목표
1.1 이 phase가 끝나면 무엇이 동작하는가
/inbox 라우트에서 다음이 end-to-end로 동작한다.
- 멀티모달 캡처 컴포저 — 텍스트를 입력하고 Enter(또는 보내기 버튼)로 제출. 음성(mic)/이미지(image)는 MVP에서 스텁(kind 표시 + 더미 transcript/caption 주입)으로 동작한다.
- 실시간 AI 분류 — 제출 → "아리가 분류하고 있어요" 로딩 애니메이션 → 분류 결과 칩(타입·sphere·프로젝트·마감·추천 시간·extra·이유 한 줄) 표시. 백엔드
POST /api/inbox/capture(동기 분류) 연동. LLM(Ollama)이 다운이면 heuristic 폴백으로 자동 전환되고 그 사실을 UI에 안내한다. - 확인/재분류 — "좋아요, 그렇게 해줘"(confirm) → 실체화. 타입이
task면 실제task행이 생성되고inbox_item.materialized_task_id로 연결된다. "다르게 분류"(reclassify)로 타입을task ↔ event ↔ idea순환 변경. - 분류 원칙 카드 — 행동→작업 / 시간→일정 / 막연→아이디어 / "개인은 섹션이 아니라 프로젝트" 4원칙을 원본 문구 그대로 노출.
- "오늘 어디로 갔나" 행선지 카드 — 오늘 분류된 항목들의 행선지(작업/일정/아이디어) 카운트 요약 + 작업/대시보드(일정) 페이지 링크.
- 연합 일관성 — 인박스에서 confirm한 "비행기 티켓 사기"가 작업 페이지(phase-3) 의
개인 › 여행 — 한국프로젝트에 실제로 나타난다.
1.2 디자인 기준(픽셀 충실 재현)
본 페이지는 다음 원본 파일을 1:1 재현한다(REF = design-reference).
| 원본 파일 | 역할 | 본 문서 재현 대상 |
|---|---|---|
REF/assets/sinbox.jsx |
SmartInbox 위젯(컴포저+분류 목록+칩+이유+확인) |
components/inbox/SmartInbox.tsx 외 |
REF/assets/sinbox-page.jsx |
인박스 전용 페이지(헤더+원칙 카드+행선지 카드) | app/inbox/page.tsx |
REF/assets/sinbox.css |
컴포저/칩/이유/원칙/행선지 스타일 | styles/inbox.css (또는 모듈) |
REF/assets/sinbox-data.js |
시드 4건(s1~s4) + todayRouted: 7 |
backend/app/seed.py |
REF/assets/shell.jsx |
아이콘 path 맵(spark/inbox/arrow/tick/swap/brain/clock/zap/cal/check/pen/mic/image/route) |
components/Icon.tsx (phase-1) |
원본 진입 HTML은
REF/인박스.html이며, 로드 순서는dash.css→sinbox.css,shell.jsx→sinbox-data.js→sinbox.jsx→sinbox-page.jsx다. 우리 구현은 Next.js App Router 컴포넌트 트리로 이 순서를 컴포넌트 합성으로 대체한다.
2. 선행 조건(의존 phase) / 산출물(Deliverables)
2.1 선행 조건
| 의존 | 문서 | 이 phase가 사용하는 산출물 |
|---|---|---|
| 디자인 토큰·앱 셸·Icon | phase-1-design-system.md |
styles/tokens.css(--coral --lime --glass-2 등), components/Icon.tsx, components/Topbar.tsx, GlassCard |
| 백엔드 인박스/분류 API·LLM 추상화 | phase-2-backend.md |
GET/POST /api/inbox*, OllamaProvider/HeuristicProvider, inbox_item/inbox_classification 테이블, 시드 |
| 작업 트리·task 생성 | phase-3-tasks.md |
GET /api/tree, POST /api/tasks, 작업 페이지(실체화 결과가 나타나는 곳), 개인 › 여행 — 한국 프로젝트 존재 보장 |
Phase-2가 인박스 API와 분류 서비스의 소유자다. 본 문서는 그 API를 소비하면서, 본 페이지가 요구하는 응답 필드/동작이 정확히 무엇인지 "프런트 관점 계약"으로 재기술한다(섹션 4). API 시그니처는
overview.md/phase-2-backend.md와 동일해야 한다.
2.2 산출물(Deliverables)
frontend/
├─ app/
│ └─ inbox/
│ └─ page.tsx 인박스 페이지(서버 컴포넌트: 초기 데이터 fetch → 클라이언트 뷰에 주입)
├─ components/
│ └─ inbox/
│ ├─ InboxView.tsx 클라이언트 루트(상태/낙관적 업데이트/필터 보유)
│ ├─ SmartInbox.tsx 컴포저 + 캡처 목록(원본 SmartInbox 재현)
│ ├─ CaptureComposer.tsx 입력창 + mic/image 스텁 + 보내기
│ ├─ SphereFilters.tsx 전체/업무/개인 필터 + 힌트
│ ├─ CaptureRow.tsx 한 건(raw + 생각중/결과 분기)
│ ├─ RouteChips.tsx 분류 결과 칩 묶음(type/sphere/proj/due/when/extra)
│ ├─ ReasonLine.tsx 이유 한 줄
│ ├─ ClassifyPrinciples.tsx "아리의 분류 원칙" 카드(RULES 4건)
│ └─ TodayRouted.tsx "오늘 어디로 갔나" 행선지 카드(DEST)
├─ lib/
│ ├─ api.ts inbox API 클라이언트 함수 추가
│ ├─ types.ts InboxItem / Classification / RouteType 등
│ └─ inbox-presentation.ts 백엔드 Classification → 화면 칩 표현 매핑(라벨/색/아이콘)
├─ styles/
│ └─ inbox.css sinbox.css 이식
├─ tests/
│ ├─ RouteChips.test.tsx 칩 렌더/타입 변경
│ ├─ CaptureComposer.test.tsx Enter 제출 / mic·image 스텁
│ └─ inbox-presentation.test.ts 매핑 함수 단위 테스트
└─ playwright/
└─ inbox.spec.ts 캡처→분류→좋아요→작업 등장, 재분류, 4 골든, 폴백, a11y
백엔드 측 추가 작업이 있으면(예: capture 응답에 todayRouted 포함) phase-2-backend.md에 반영하고 본 문서 섹션 4에 명시한다.
3. 상세 구현 (파일별·단계별)
3.0 데이터 흐름 한눈에
[CaptureComposer] 입력+Enter
│ POST /api/inbox/capture {kind, raw}
▼
[InboxView] 낙관적: status="thinking" 행 즉시 prepend
│ 응답 {item, classification}
▼
[CaptureRow] status="classified" → [RouteChips]+[ReasonLine]+액션(좋아요/다르게)
│ "다르게 분류" → POST /api/inbox/{id}/reclassify {type}
│ "좋아요" → POST /api/inbox/{id}/confirm
▼
[confirm 응답] materialized_task_id (task인 경우)
│ → status="confirmed", 체크 표시
▼
작업 페이지(phase-3) GET /api/tasks 에서 신규 task 노출 (연합 완료)
원본은 setTimeout(900ms)로 분류를 흉내냈지만(sinbox.jsx L69), 우리는 실제 API 왕복으로 대체한다. "생각 중" 애니메이션은 요청 in-flight 동안 유지한다.
3.1 타입 정의 — lib/types.ts
CONTRACT의 inbox_classification 필드명과 1:1로 맞춘다(backend/app/schemas.py와 동일).
// lib/types.ts (inbox 관련 부분)
export type InboxKind = "text" | "voice" | "image";
export type InboxStatus = "new" | "classified" | "confirmed" | "dismissed";
export type RouteType = "task" | "event" | "idea"; // 분류 타입
export type Sphere = "work" | "life"; // 컨텍스트
export type Tone = "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
/** 백엔드 inbox_classification 1:1 */
export interface Classification {
id: string;
inbox_item_id: string;
type: RouteType;
sphere: Sphere;
project_id: string | null;
proj_label: string; // 예: "개인 › 여행 — 한국"
tone: Tone; // 프로젝트 칩 색
due_text: string | null; // 예: "출발 전 · ~6/14"
when_text: string | null; // 예: "오늘 21:00 빈 시간 추천"
extra: string | null; // 예: "가격 추적 알림 켜둠"
reason: string; // 한국어 한두 문장
confidence: number; // 0~1
model: string; // "ollama:<model>" | "heuristic"
created_at: string;
}
/** 백엔드 inbox_item (+ 최신 classification) */
export interface InboxItem {
id: string;
kind: InboxKind;
raw: string;
status: InboxStatus;
created_at: string;
materialized_task_id: string | null;
classification: Classification | null;
}
/** capture/reclassify 응답 */
export interface CaptureResult {
item: InboxItem;
classification: Classification;
}
/** confirm 응답: 실체화된 엔티티 */
export interface ConfirmResult {
item: InboxItem; // status="confirmed", materialized_task_id 채워짐
task?: import("./types").Task; // type=task 인 경우
// event/idea 도 동일 패턴으로 확장 가능(MVP: task만 실제 생성)
}
UI 전용 상태 한 가지 추가. 원본에는
thinking상태가 있는데, 이는 백엔드의inbox_item.statusenum(new|classified|confirmed|dismissed)에 없는 프런트 임시 상태다. 따라서 화면 모델에는thinking을 합친 별도 타입을 쓴다.
// lib/types.ts (UI 전용)
export type UiInboxStatus = InboxStatus | "thinking" | "error";
export interface UiInboxItem extends Omit<InboxItem, "status"> {
status: UiInboxStatus;
time: string; // "방금" / "32분 전" 등 표시용 상대 시간
fresh?: boolean; // 방금 추가된 행 강조(.fresh)
fallbackUsed?: boolean;// 분류가 heuristic 폴백으로 처리됨
}
3.2 백엔드 Classification → 화면 표현 매핑 — lib/inbox-presentation.ts
원본 sinbox.jsx의 SPHERE/TYPE_ICON/칩 표현 로직을 순수 함수로 분리한다(테스트 용이).
// lib/inbox-presentation.ts
import type { RouteType, Sphere } from "./types";
/** 원본 sinbox.jsx L8-11 */
export const SPHERE: Record<Sphere, { label: string; tone: string }> = {
work: { label: "업무", tone: "var(--blue)" },
life: { label: "개인", tone: "var(--green)" },
};
/** 원본 sinbox.jsx L12 */
export const TYPE_ICON: Record<RouteType, string> = {
task: "check",
event: "cal",
idea: "brain",
};
/** 타입 라벨 (원본 cycleType의 label 맵) */
export const TYPE_LABEL: Record<RouteType, string> = {
task: "작업",
event: "일정",
idea: "아이디어",
};
/** 원본 cycleType — 다르게 분류 시 다음 타입 (낙관적 미리보기용) */
export function nextType(t: RouteType): RouteType {
return ({ task: "event", event: "idea", idea: "task" } as const)[t];
}
칩 색은 두 종류다: sphere 칩은
SPHERE[sphere].tone(고정 blue/green), proj 칩은var(--${classification.tone})로 동적이다.due/when/extra칩 표시는 값 존재 여부에 따른다(원본sinbox.jsxL133-135의&&패턴 동일).
3.3 API 클라이언트 — lib/api.ts
// lib/api.ts (inbox 부분 발췌)
import type { InboxItem, CaptureResult, ConfirmResult, RouteType } from "./types";
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
async function j<T>(res: Response): Promise<T> {
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json() as Promise<T>;
}
export class ApiError extends Error {
constructor(public status: number, public body: string) { super(`API ${status}`); }
}
export async function getInbox(): Promise<InboxItem[]> {
return j(await fetch(`${BASE}/api/inbox`, { cache: "no-store" }));
}
export async function captureInbox(input: {
kind: "text" | "voice" | "image"; raw: string;
}): Promise<CaptureResult> {
return j(await fetch(`${BASE}/api/inbox/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}));
}
export async function reclassifyInbox(id: string, type?: RouteType): Promise<CaptureResult> {
return j(await fetch(`${BASE}/api/inbox/${id}/reclassify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(type ? { type } : {}),
}));
}
export async function confirmInbox(id: string): Promise<ConfirmResult> {
return j(await fetch(`${BASE}/api/inbox/${id}/confirm`, { method: "POST" }));
}
export async function dismissInbox(id: string): Promise<{ ok: true }> {
return j(await fetch(`${BASE}/api/inbox/${id}/dismiss`, { method: "POST" }));
}
3.4 페이지 진입 — app/inbox/page.tsx
원본 sinbox-page.jsx의 App()을 서버 컴포넌트로. 초기 인박스 데이터는 서버에서 fetch하고, 인터랙션 상태는 클라이언트 컴포넌트(InboxView)가 보유한다.
// app/inbox/page.tsx
import { getInbox } from "@/lib/api";
import type { InboxItem } from "@/lib/types";
import InboxView from "@/components/inbox/InboxView";
export const dynamic = "force-dynamic"; // 시드 데이터 항상 최신
export default async function InboxPage() {
let items: InboxItem[] = [];
let loadError = false;
try {
items = await getInbox();
} catch {
loadError = true; // 빈 상태 + 에러 배너로 처리(섹션 6)
}
// 원본 sinbox-data.js todayRouted: 7 — 백엔드 GET /api/dashboard 또는 inbox 메타에서.
// MVP: capture 응답/별도 메타에 포함. 없으면 confirmed 카운트로 대체.
return <InboxView initialItems={items} initialLoadError={loadError} />;
}
Topbar는 phase-1의 앱 셸(
app/layout.tsx)에서 전역으로 렌더된다. 원본은<Topbar current="sbox" />(sinbox-page.jsxL40)로 인박스 탭을 활성화한다 — 우리는 pathname(/inbox)으로 active를 판정하므로 페이지에서 Topbar를 따로 렌더하지 않는다. (id 매핑:sbox→/inbox.)
페이지 헤더(pagehead)는 원본 문구 그대로:
// InboxView 내부 상단(또는 page.tsx)
<div className="pagehead">
<div>
<div className="ph-eyebrow">
<span>오늘 {todayRouted}건 정리</span>
<span className="sep" />
<span>분류를 고민한 시간 0초</span>
</div>
<h1 className="ph-title">스마트 인박스 <em>일단 적으세요</em></h1>
</div>
</div>
3.5 클라이언트 루트 — components/inbox/InboxView.tsx
상태·낙관적 업데이트·필터를 보유. 원본 SmartInbox의 useState/submit/confirm/reType/filter 로직을 실제 API에 연결한다.
"use client";
import { useState, useRef, useCallback } from "react";
import type { InboxItem, UiInboxItem, RouteType, Sphere } from "@/lib/types";
import { captureInbox, reclassifyInbox, confirmInbox } from "@/lib/api";
import { nextType } from "@/lib/inbox-presentation";
import SmartInbox from "./SmartInbox";
import ClassifyPrinciples from "./ClassifyPrinciples";
import TodayRouted from "./TodayRouted";
function toUi(it: InboxItem): UiInboxItem {
return { ...it, status: it.status, time: relTime(it.created_at) };
}
export default function InboxView({
initialItems, initialLoadError,
}: { initialItems: InboxItem[]; initialLoadError: boolean }) {
const [items, setItems] = useState<UiInboxItem[]>(() => initialItems.map(toUi));
const [filter, setFilter] = useState<"all" | Sphere>("all");
const tmpSeq = useRef(0);
/* ---- 캡처 제출 ---- */
const submit = useCallback(async (raw: string, kind: "text"|"voice"|"image") => {
const text = raw.trim();
if (!text) return;
const tmpId = "tmp-" + (++tmpSeq.current);
// 낙관적: 생각 중 행을 맨 위에 추가 (원본 L67)
setItems((xs) => [
{ id: tmpId, kind, raw: text, status: "thinking", time: "방금",
created_at: new Date().toISOString(), materialized_task_id: null,
classification: null, fresh: true } as UiInboxItem,
...xs,
]);
try {
const { item, classification } = await captureInbox({ kind, raw: text });
setItems((xs) => xs.map((x) => x.id === tmpId
? { ...item, classification, status: "classified", time: "방금", fresh: true,
fallbackUsed: classification.model === "heuristic" }
: x));
} catch {
setItems((xs) => xs.map((x) => x.id === tmpId
? { ...x, status: "error" } : x));
}
}, []);
/* ---- 다르게 분류 (타입 순환) ---- */
const reType = useCallback(async (id: string) => {
// 낙관적: 즉시 다음 타입으로 미리보기
setItems((xs) => xs.map((x) => x.id === id && x.classification
? { ...x, classification: { ...x.classification, type: nextType(x.classification.type) } }
: x));
const cur = items.find((x) => x.id === id);
const wanted = cur?.classification ? cur.classification.type : undefined; // 위에서 이미 바뀜 → 서버에 확정 요청
try {
const { item, classification } = await reclassifyInbox(id, wanted as RouteType);
setItems((xs) => xs.map((x) => x.id === id
? { ...item, classification, status: "classified", time: x.time } : x));
} catch {
// 롤백: 직전 타입으로 복구는 nextType의 역순(idea→event→task) — 간단히 재조회 권장
}
}, [items]);
/* ---- 좋아요(confirm) → 실체화 ---- */
const confirm = useCallback(async (id: string) => {
setItems((xs) => xs.map((x) => x.id === id ? { ...x, status: "confirmed" } : x)); // 낙관적
try {
const res = await confirmInbox(id);
setItems((xs) => xs.map((x) => x.id === id
? { ...res.item, classification: x.classification, status: "confirmed", time: x.time } : x));
// 연합: 작업 페이지는 별도 라우트이므로 별도 새로고침 시 반영(SWR 캐시 무효화 권장)
} catch {
setItems((xs) => xs.map((x) => x.id === id ? { ...x, status: "classified" } : x)); // 롤백
}
}, []);
const todayRouted = items.filter((x) => x.status === "confirmed").length || 7;
const shown = items.filter((x) =>
filter === "all" || (x.classification && x.classification.sphere === filter));
return (
<div className="dash">
{/* pagehead (섹션 3.4) */}
<div className="work">
<div className="board">
<SmartInbox
items={shown} todayRouted={todayRouted} filter={filter}
onFilter={setFilter} onSubmit={submit} onConfirm={confirm} onReType={reType}
/>
<ClassifyPrinciples />
<TodayRouted items={items} todayRouted={todayRouted} />
</div>
</div>
</div>
);
}
function relTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "방금";
if (m < 60) return `${m}분 전`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}시간 전`;
return `${Math.floor(h / 24)}일 전`;
}
낙관적 업데이트 원칙(원본 정신: "이미 해뒀어요"): 사용자의 모든 동작은 UI에서 즉시 반영하고, 실패 시 롤백 + 에러 배너. confirm은 특히 즉시 체크 표시(
.sb-done)를 보여준다(원본sinbox.jsxL147).
3.6 컴포저 + 목록 — components/inbox/SmartInbox.tsx
원본 sinbox.jsx의 SmartInbox JSX를 거의 그대로. 클래스명(card sbox sp2, ch, ico, htext, count, sb-cmd, sb-filters, sb-list)을 동일하게 유지한다.
"use client";
import Icon from "@/components/Icon";
import CaptureComposer from "./CaptureComposer";
import SphereFilters from "./SphereFilters";
import CaptureRow from "./CaptureRow";
import type { UiInboxItem, Sphere, RouteType } from "@/lib/types";
export default function SmartInbox({
items, todayRouted, filter, onFilter, onSubmit, onConfirm, onReType,
}: {
items: UiInboxItem[];
todayRouted: number;
filter: "all" | Sphere;
onFilter: (f: "all" | Sphere) => void;
onSubmit: (raw: string, kind: "text" | "voice" | "image") => void;
onConfirm: (id: string) => void;
onReType: (id: string) => void;
}) {
return (
<section className="card sbox sp2">
<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>
<CaptureComposer onSubmit={onSubmit} />
<SphereFilters filter={filter} onFilter={onFilter} />
<div className="sb-list">
{items.length === 0 ? (
<div className="sb-empty">아직 비어 있어요. 위에 아무거나 적어보세요 — 분류는 아리가 할게요.</div>
) : items.map((c) => (
<CaptureRow key={c.id} item={c} onConfirm={onConfirm} onReType={onReType} />
))}
</div>
</section>
);
}
3.7 캡처 컴포저 — components/inbox/CaptureComposer.tsx
원본 sb-cmd 마크업(sinbox.jsx L91-102) + mic/image 스텁.
"use client";
import { useState } from "react";
import Icon from "@/components/Icon";
// MVP 스텁: 음성/이미지는 실제 캡처 대신 더미 transcript/caption 주입
const VOICE_STUB = "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기";
const IMAGE_STUB = "이미지 캡처 — 영수증/스크린샷 (자동 인식 결과)";
export default function CaptureComposer({
onSubmit,
}: { onSubmit: (raw: string, kind: "text" | "voice" | "image") => void }) {
const [input, setInput] = useState("");
const submitText = () => {
const t = input.trim();
if (!t) return;
onSubmit(t, "text");
setInput("");
};
return (
<div className="sb-cmd">
<Icon name="spark" />
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitText()}
placeholder="갑자기 생각난 것 아무거나… 예) 다음 주 한국 가는 비행기 티켓 사기"
aria-label="인박스에 빠르게 캡처"
/>
{/* MVP 스텁: 클릭 시 더미 transcript/caption을 그대로 캡처(kind만 다름) */}
<button className="sb-mode" aria-label="음성으로 캡처(MVP 스텁)"
title="음성 입력은 MVP에서 더미 메모로 동작합니다"
onClick={() => onSubmit(VOICE_STUB, "voice")}>
<Icon name="mic" />
</button>
<button className="sb-mode" aria-label="이미지로 캡처(MVP 스텁)"
title="이미지 입력은 MVP에서 더미 캡션으로 동작합니다"
onClick={() => onSubmit(IMAGE_STUB, "image")}>
<Icon name="image" />
</button>
<button className="sb-send" onClick={submitText} aria-label="보내기">
<Icon name="arrow" />
</button>
</div>
);
}
스텁 설계 결정. CONTRACT는 "음성(mic)/이미지(image) 캡처는 MVP에서 스텁(kind 표시 + 더미 transcript/caption)"을 요구한다. 따라서 mic/image 버튼은 브라우저 미디어 API를 호출하지 않고, 미리 정의한 더미 문자열을
kind="voice"|"image"로POST /api/inbox/capture에 보낸다. 백엔드 분류는 텍스트(raw)만 보므로 분류 경로는 텍스트와 동일하다. 캡처 목록에서는kind에 따라 아이콘만 다르게(원본sinbox.jsxL118:c.kind === "voice" ? "mic" : c.kind === "image" ? "image" : "pen").
3.8 컨텍스트 필터 — components/inbox/SphereFilters.tsx
원본 sb-filters(sinbox.jsx L105-112). "업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요" 힌트 문구 그대로.
"use client";
import { SPHERE } from "@/lib/inbox-presentation";
import type { Sphere } from "@/lib/types";
const TABS: [("all" | Sphere), string][] = [["all", "전체"], ["work", "업무"], ["life", "개인"]];
export default function SphereFilters({
filter, onFilter,
}: { filter: "all" | Sphere; onFilter: (f: "all" | Sphere) => void }) {
return (
<div className="sb-filters">
{TABS.map(([k, l]) => (
<button key={k} className={"sb-f" + (filter === k ? " on" : "")}
onClick={() => onFilter(k)} aria-pressed={filter === k}>
{k !== "all" && <span className="fdot" style={{ background: SPHERE[k as Sphere].tone }} />}
{l}
</button>
))}
<span className="sb-hint">업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요</span>
</div>
);
}
3.9 캡처 한 건 — components/inbox/CaptureRow.tsx
원본 sb-cap 분기(sinbox.jsx L116-148). thinking / error / 결과 3분기.
"use client";
import Icon from "@/components/Icon";
import RouteChips from "./RouteChips";
import ReasonLine from "./ReasonLine";
import type { UiInboxItem } from "@/lib/types";
export default function CaptureRow({
item: c, onConfirm, onReType,
}: { item: UiInboxItem; onConfirm: (id: string) => void; onReType: (id: string) => void }) {
const kindIcon = c.kind === "voice" ? "mic" : c.kind === "image" ? "image" : "pen";
const canAct = c.status === "classified"; // 원본의 status==="new" 에 해당
return (
<div className={"sb-cap" + (c.fresh ? " fresh" : "")}>
<div className={"cap-k " + c.kind}><Icon name={kindIcon} /></div>
<div className="sb-body">
<div className="sb-raw">{c.raw}</div>
{c.status === "thinking" ? (
<div className="sb-think" role="status" aria-live="polite">
<span className="tdot" /><span className="tdot" /><span className="tdot" />
아리가 분류하고 있어요
</div>
) : c.status === "error" ? (
<div className="sb-err" role="alert">
분류에 실패했어요. <button className="sb-retry" onClick={() => onReType(c.id)}>다시 시도</button>
</div>
) : c.classification ? (
<>
<RouteChips r={c.classification} editable={canAct} onReType={() => onReType(c.id)} />
<ReasonLine reason={c.classification.reason} fallbackUsed={c.fallbackUsed} />
{canAct && (
<div className="sb-acts">
<button className="sb-ok" onClick={() => onConfirm(c.id)}>
<Icon name="tick" w={3} />좋아요, 그렇게 해줘
</button>
<button className="sb-alt" onClick={() => onReType(c.id)}>
<Icon name="swap" />다르게 분류
</button>
</div>
)}
</>
) : null}
</div>
<span className="cap-time">
{c.status === "confirmed"
? <span className="sb-done"><Icon name="tick" w={3} /></span>
: c.time}
</span>
</div>
);
}
원본의
status매핑: 원본new(분류 완료, 액션 가능) → 우리classified. 원본done→ 우리confirmed. 원본thinking유지.error는 신규(섹션 6).
3.10 분류 결과 칩 — components/inbox/RouteChips.tsx
원본 sb-route(sinbox.jsx L126-136). 칩 5종 + 화살표.
"use client";
import Icon from "@/components/Icon";
import { SPHERE, TYPE_ICON, TYPE_LABEL } from "@/lib/inbox-presentation";
import type { Classification } from "@/lib/types";
export default function RouteChips({
r, editable, onReType,
}: { r: Classification; editable: boolean; onReType: () => void }) {
return (
<div className="sb-route">
<span className="r-arrow"><Icon name="arrow" /></span>
{/* 타입 칩: 클릭 시 다르게 분류(편집 가능할 때만) */}
<button className={"r-chip type " + r.type}
onClick={() => editable && onReType()}
disabled={!editable}
aria-label={`타입: ${TYPE_LABEL[r.type]}${editable ? " (눌러서 변경)" : ""}`}>
<Icon name={TYPE_ICON[r.type]} />{TYPE_LABEL[r.type]}
</button>
{/* sphere 칩: 고정색(work=blue, life=green) */}
<span className="r-chip sphere">
<span className="pdot" style={{ background: SPHERE[r.sphere].tone }} />
{SPHERE[r.sphere].label}
</span>
{/* proj 칩: 동적 tone */}
<span className="r-chip proj">
<span className="pdot" style={{ background: `var(--${r.tone})` }} />
{r.proj_label}
</span>
{r.due_text && <span className="r-chip"><Icon name="cal" />{r.due_text}</span>}
{r.when_text && <span className="r-chip"><Icon name="clock" />{r.when_text}</span>}
{r.extra && <span className="r-chip auto"><Icon name="zap" />{r.extra}</span>}
</div>
);
}
칩 색 규칙(원본
sinbox.cssL69-81). 타입 칩은.r-chip.type에.event(blue) /.idea(violet) 변형이 붙어 텍스트·아이콘·테두리 색이 바뀐다(task는 기본 ink)..r-chip.auto(extra)는 amber 계열로 강조된다. 이 클래스 규약을 그대로 따른다 —className={"r-chip type " + r.type}가 핵심.
3.11 이유 한 줄 + 폴백 안내 — components/inbox/ReasonLine.tsx
원본 sb-reason(sinbox.jsx L137). 폴백 사용 시 작은 배지 추가(원본에 없지만 CONTRACT 요구: "LLM 다운 시 heuristic 폴백 안내").
import Icon from "@/components/Icon";
export default function ReasonLine({
reason, fallbackUsed,
}: { reason: string; fallbackUsed?: boolean }) {
return (
<div className="sb-reason">
{fallbackUsed && (
<span className="sb-fallback" title="LLM에 연결할 수 없어 규칙 기반으로 분류했어요">
<Icon name="zap" />규칙 기반(오프라인)
</span>
)}
{reason}
</div>
);
}
폴백 배지 스타일(inbox.css에 추가, 토큰만 사용):
.sb-fallback {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10.5px; font-weight: 700; color: var(--amber);
margin-right: 6px; padding: 1px 7px; border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--amber) 36%, var(--glass-brd));
background: color-mix(in oklab, var(--amber) 9%, transparent);
}
.sb-fallback .ic { width: 10px; height: 10px; }
3.12 분류 원칙 카드 — components/inbox/ClassifyPrinciples.tsx
원본 sinbox-page.jsx의 RULES(L8-29) + prin-list 마크업(L59-75). 문구를 글자 그대로 옮긴다(특수 따옴표 ‘ ’ 포함).
import Icon from "@/components/Icon";
const RULES = [
{ icon: "check", tone: "green", title: "행동이 있으면 → 작업",
desc: "‘비행기 티켓 사기’도 작업이에요. 작업 트리의 ‘개인 › 여행’ 프로젝트로 들어가 다른 작업과 똑같이 보여요." },
{ icon: "cal", tone: "blue", title: "시간이 정해지면 → 일정",
desc: "‘수요일 11시 자전거 수리’는 작업이 아니라 캘린더에 바로 등록돼요." },
{ icon: "brain", tone: "violet", title: "막연하면 → 아이디어",
desc: "행동이 정해지지 않은 생각은 보드에 보관하고, 관련 프로젝트에 연결해둬요." },
{ icon: "clock", tone: "coral", title: "개인은 섹션이 아니라 프로젝트",
desc: "따로 숨기거나 분리하지 않아요. 같은 작업 트리에서 필터로만 구분하고, 배치만 저녁·주말 빈 시간으로 추천해요." },
] as const;
export default function ClassifyPrinciples() {
return (
<section className="card">
<div className="ch">
<div className="ico"><Icon name="route" /></div>
<div className="htext">
<h3>아리의 분류 원칙</h3>
<div className="sub">적을 때 고민하지 마세요</div>
</div>
</div>
<div className="prin-list">
{RULES.map((r, i) => (
<div className="prin" key={i} style={{ ["--tone" as any]: `var(--${r.tone})` }}>
<span className="prin-ic"><Icon name={r.icon} /></span>
<span className="prin-body">
<span className="prin-title">{r.title}</span>
<span className="prin-desc">{r.desc}</span>
</span>
</div>
))}
</div>
</section>
);
}
제품 철학 반영. 4번 원칙("개인은 섹션이 아니라 프로젝트")은 CONTRACT의 핵심 모델 원칙("개인 일은 숨기지 않는다 … 같은 트리에서 필터로만 구분 … 배치는 추천만")을 그대로 시각화한 것이다. 절대 문구를 의역하지 말 것.
3.13 "오늘 어디로 갔나" 행선지 카드 — components/inbox/TodayRouted.tsx
원본 sinbox-page.jsx의 DEST(L31-35) + dest-list 마크업(L78-102). 원본은 정적 카운트(작업4/일정2/아이디어1)였지만, 우리는 오늘 confirmed된 항목에서 동적으로 집계한다.
"use client";
import Icon from "@/components/Icon";
import { TYPE_LABEL } from "@/lib/inbox-presentation";
import type { UiInboxItem, RouteType } from "@/lib/types";
// 행선지 타입별 메타(원본 DEST의 tone/href)
const DEST_META: Record<RouteType, { tone: string; href: string | null }> = {
task: { tone: "green", href: "/tasks" },
event: { tone: "blue", href: "/dashboard" }, // 일정은 MVP에서 대시보드 요약으로
idea: { tone: "violet", href: null }, // 아이디어 보드는 MVP 범위 밖
};
const ORDER: RouteType[] = ["task", "event", "idea"];
export default function TodayRouted({
items, todayRouted,
}: { items: UiInboxItem[]; todayRouted: number }) {
// 오늘 분류/확정된 항목을 타입별로 집계
const routed = items.filter((x) => x.classification && (x.status === "confirmed" || x.status === "classified"));
const byType = (t: RouteType) => routed.filter((x) => x.classification!.type === t);
// sub 문구: 개인/업무 분포 (예: "개인 3 · 업무 1")
const subFor = (t: RouteType) => {
const g = byType(t);
const life = g.filter((x) => x.classification!.sphere === "life").length;
const work = g.filter((x) => x.classification!.sphere === "work").length;
if (t === "event") return "캘린더 등록";
if (t === "idea") return "보드 보관";
return `개인 ${life} · 업무 ${work}`;
};
return (
<section className="card">
<div className="ch">
<div className="ico"><Icon name="arrow" /></div>
<div className="htext">
<h3>오늘 어디로 갔나</h3>
<div className="sub">{todayRouted}건의 행선지</div>
</div>
</div>
<div className="dest-list">
{ORDER.map((t) => {
const meta = DEST_META[t];
const n = byType(t).length;
const inner = (
<>
<span className="dest-dot" style={{ background: `var(--${meta.tone})` }} />
<span className="dest-label">{TYPE_LABEL[t]}</span>
<span className="dest-sub">{subFor(t)}</span>
<span className="dest-n">{n}</span>
{meta.href && <span className="dest-go"><Icon name="arrow" /></span>}
</>
);
return meta.href
? <a className="dest go" key={t} href={meta.href}>{inner}</a>
: <div className="dest" key={t}>{inner}</div>;
})}
</div>
<div className="auton-note dim">
<Icon name="swap" />분류가 마음에 안 들면 칩을 눌러 바꾸세요 — 아리가 다음부터 기억해요.
</div>
</section>
);
}
원본의 일정 링크는
일정.html(MVP 밖)이었다. MVP에서는 일정 전용 페이지가 없으므로 행선지 "일정" 링크를 대시보드(/dashboard)의 일정 요약 카드로 보낸다(읽기 전용, phase-5). "작업"은/tasks(phase-3). "아이디어"는 보드가 MVP 밖이라 링크 없음(원본도href:null).
3.14 스타일 이식 — styles/inbox.css
REF/assets/sinbox.css를 그대로 이식한다. 핵심 셀렉터와 값(원본 그대로):
| 셀렉터 | 핵심 값(원본) |
|---|---|
.sb-cmd |
gap:9px; background:var(--glass-2); border:1px solid var(--glass-brd); border-radius:15px; padding:6px 6px 6px 15px |
.sb-cmd > svg:first-child |
width:17px; height:17px; color:var(--coral) (spark 아이콘 코랄) |
.sb-send |
36×36; border-radius:11px; background:var(--fill); color:var(--on-fill) |
.sb-mode |
34×34; border-radius:10px; color:var(--muted); hover→card-2 |
.sb-f.on |
background:var(--fill); color:var(--on-fill); border-color:transparent |
.sb-cap.fresh |
margin:0 -10px; padding:13px 10px; coral 9% 그라데이션 + inset 1px coral 26% 테두리 |
.sb-raw |
font-size:13.5px; font-weight:600; letter-spacing:-0.01em |
.r-chip.type.event |
color:var(--blue); border-color:coral... → blue 30% |
.r-chip.type.idea |
color:var(--violet) |
.r-chip.auto |
color:var(--amber); background:amber 9% |
.sb-reason |
font-size:11.5px; color:var(--muted); max-width:62ch |
.sb-ok |
color:var(--lime-ink); background:var(--lime); border-radius:999px; box-shadow lime |
.sb-think .tdot |
5×5; background:var(--coral); animation:sb-pulse 1s infinite (delay 0.15/0.3s) |
.sb-done |
20×20 원형; background:green 16%; color:var(--green) |
.prin-ic |
28×28; border-radius:9px; background:var(--tone) 14%; color:var(--tone) |
.dest-n |
font-family:var(--font-mono); font-size:15px; font-weight:500 |
.dest.go:hover .dest-go |
transform:translateX(2px); background:var(--card-2) |
추가로 우리가 신설하는 클래스(토큰만 사용):
/* 빈 상태 */
.sb-empty { font-size: 12.5px; color: var(--muted); padding: 16px 2px; }
/* 행 에러 */
.sb-err { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--coral); margin-top: 9px; }
.sb-retry { font-weight: 700; color: var(--ink); text-decoration: underline; }
/* .r-chip.type[disabled] — 확정 후 비편집 시 커서/투명도 */
.r-chip.type:disabled { cursor: default; }
.r-chip,.cap-k,.pdot,.cap-time,.count,.ch/.ico/.htext,.card,.pagehead/.ph-eyebrow/.ph-title,.auton-note.dim,.work/.board등은 phase-1에서dash.css이식 시 이미 정의되어 있어야 한다(sinbox.css주석: "dash.css 뒤에 로드 — .cap-k / .r-chip / .pdot / .cap-time 재사용"). 누락 시 칩/카드가 깨진다.
4. 데이터/타입/API 계약 (이 phase 관련)
4.1 사용하는 엔드포인트
| 메서드 | 경로 | 요청 | 응답 | 본 페이지에서 |
|---|---|---|---|---|
| GET | /api/inbox |
— | InboxItem[](+최신 classification) |
초기 목록 |
| POST | /api/inbox/capture |
{kind, raw} |
{item, classification} |
캡처+동기 분류 |
| POST | /api/inbox/{id}/reclassify |
{type?} |
{item, classification} |
다르게 분류 |
| POST | /api/inbox/{id}/confirm |
— | {item, task?} (실체화) |
좋아요→작업 생성 |
| POST | /api/inbox/{id}/dismiss |
— | {ok:true} |
(선택) 무시 |
4.2 요청/응답 예시 (JSON)
캡처 — 골든 케이스 1 ("비행기 티켓")
요청:
POST /api/inbox/capture
Content-Type: application/json
{ "kind": "text", "raw": "다음 주에 한국 놀러가는 비행기 티켓 사기" }
응답:
{
"item": {
"id": "s5",
"kind": "text",
"raw": "다음 주에 한국 놀러가는 비행기 티켓 사기",
"status": "classified",
"created_at": "2026-06-08T12:00:00",
"materialized_task_id": null,
"classification": { "...": "아래" }
},
"classification": {
"id": "c12",
"inbox_item_id": "s5",
"type": "task",
"sphere": "life",
"project_id": "life-trip",
"proj_label": "개인 › 여행 — 한국",
"tone": "coral",
"due_text": "출발 전 · ~6/14",
"when_text": "오늘 21:00 빈 시간 추천",
"extra": "가격 추적 알림 켜둠",
"reason": "구매라는 행동이 있으니 '작업' 맞아요. 작업 트리의 '개인' 아래에 '여행 — 한국' 프로젝트를 만들어 넣었어요 — 따로 섹션이 생기는 게 아니라 다른 작업과 똑같이 보여요. 출발까지 일주일이라 가격 알림도 걸어뒀어요.",
"confidence": 0.92,
"model": "ollama:<OLLAMA_MODEL>",
"created_at": "2026-06-08T12:00:00"
}
}
확인(confirm) — 실체화
요청: POST /api/inbox/s5/confirm (본문 없음)
응답:
{
"item": {
"id": "s5", "status": "confirmed",
"materialized_task_id": "kx7", "...": "..."
},
"task": {
"id": "kx7",
"project_id": "life-trip",
"parent_id": null,
"title": "다음 주에 한국 놀러가는 비행기 티켓 사기",
"status": "todo",
"assignee_id": "jiwoo",
"due": null,
"prio": "보통",
"notes": "스마트 인박스에서 실체화된 작업이에요. 가격 추적 알림 켜둠",
"est": null, "delegated": false
}
}
→ 이후 GET /api/tasks?area=life&project_id=life-trip 에 kx7이 포함되어 작업 페이지에 등장(연합 완료).
재분류 — 사용자가 타입 강제
요청:
POST /api/inbox/s5/reclassify
{ "type": "idea" }
응답: {item, classification}(type:"idea"로 갱신, reason도 idea 문맥으로 재생성).
4.3 분류 결과 ↔ 칩 매핑표
| Classification 필드 | 칩/요소 | 표시 규칙 |
|---|---|---|
type |
.r-chip.type(아이콘+라벨) |
task→check/작업(ink), event→cal/일정(blue), idea→brain/아이디어(violet). 편집 가능 시 클릭→reclassify |
sphere |
.r-chip.sphere(pdot+라벨) |
work→blue/업무, life→green/개인 (고정색) |
proj_label + tone |
.r-chip.proj(pdot+라벨) |
pdot=var(--${tone}), 라벨=proj_label |
due_text |
.r-chip(cal) |
값 있을 때만 |
when_text |
.r-chip(clock) |
값 있을 때만 |
extra |
.r-chip.auto(zap, amber) |
값 있을 때만, 자동화 강조 |
reason |
.sb-reason |
항상 |
model="heuristic" |
.sb-fallback 배지 |
폴백일 때만 |
4.4 시드 데이터 (백엔드 seed.py로 이식 — sinbox-data.js 그대로)
todayRouted: 7, 그리고 4건(상태는 백엔드 enum으로 매핑):
| id | kind | 원본 status | 백엔드 status | raw | type/sphere/proj/tone |
|---|---|---|---|---|---|
| s1 | text | new | new | 다음 주에 한국 놀러가는 비행기 티켓 사기 | task/life/개인 › 여행 — 한국/coral · due "출발 전 · ~6/14" · when "오늘 21:00 빈 시간 추천" · extra "가격 추적 알림 켜둠" |
| s2 | text | done | confirmed | 수요일 11시 자전거 수리 맡기기 | event/life/개인 캘린더/blue · due "수 6/10 11:00" · when "캘린더 등록 완료" |
| s3 | voice | done | confirmed | 음성 메모 0:09 — 엄마 생신 선물 미리 알아보기 | task/life/가족/green · due "6/20 전" · when "주말 오전 블록" |
| s4 | text | done | confirmed | 온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까 | idea/work/온보딩 리디자인 · 아이디어 보드/violet |
s1은 일반 매핑 규칙(원본
new→ 백엔드classified)의 예외로 백엔드 status를new로 유지한다 — 사용자가 직접 확인(confirm)할 카드를 데모용으로 노출하기 위함이다.
원본
reason문구도 시드에 그대로 넣는다(섹션 7 골든 테스트의 기대값이 여기서 나온다). s2의 due"수 6/10 11:00"은 CONTRACT의 "오늘=6/8" 가상 주간과 일치.
5. 디자인 충실도 노트
5.1 레이아웃
원본 sinbox-page.jsx는 .dash > .pagehead + .work > .board > [SmartInbox, 분류원칙, 행선지] 단일 컬럼이다. .board는 phase-1의 그리드 정의를 따른다(대시보드와 동일 셸). 본 페이지는 카드 3개를 세로로 쌓는다.
5.2 색/토큰 (원본 값 인용)
- 컴포저 spark 아이콘:
--coral(#df7256), 17×17px (sinbox.cssL15). - 보내기 버튼:
--fill(#29241f) 배경 +--on-fill(#f4efe6), 36×36, radius 11px. - "좋아요" CTA:
--lime(#c2f24a) 배경 +--lime-ink(#233006), hover--lime-hi(#cdf85e) +translateY(-1px). - fresh 행: coral 9% 그라데이션 +
inset 0 0 0 1px coral 26%(방금 추가/분류된 항목 강조,sinbox.cssL58-63). - 타입 칩 색: event=
--blue(#4f72e0), idea=--violet(#8b6fd4), task=--ink(#211f1c). - extra(auto) 칩:
--amber(#e0a23c) 9% 배경. - 생각 중 점:
--coral,sb-pulse1s, nth-child delay 0.15s/0.3s. - 완료 체크: green 16% 배경 원형 +
--green(#4e9b66). - 원칙 카드 아이콘:
--tone14% 배경(원칙별 green/blue/violet/coral). - 행선지 숫자:
--font-mono(DM Mono) 15px.
5.3 인터랙션
- Enter 제출: 원본
onKeyDown ... e.key==="Enter" && submit()(sinbox.jsxL96). 우리 동일. - 타입 칩 클릭 → 재분류: 원본
onClick={() => c.status==="new" && reType(c.id)}(sinbox.jsxL128). 우리는status==="classified"일 때만 편집. - 다르게 분류 순환: 원본
cycleTypetask→event→idea→task (sinbox.jsxL51-55). 낙관적 미리보기 후 서버 확정. - 확인 시 즉시 체크 표시: 원본
c.status==="done" ? <span className="sb-done">...(sinbox.jsxL147). - 행선지 hover:
.dest.go:hover .dest-go { transform: translateX(2px) }(sinbox.cssL165). - 한국어 가독성: body 전역
word-break: keep-all; letter-spacing:-0.011em(phase-1 토큰). 칩/이유 줄바꿈이 단어 단위로 끊기도록.
5.4 아이콘 (phase-1 paths 맵에서 사용 — 누락 금지)
spark, inbox, arrow, tick, swap, brain, clock, zap, cal, check, pen, mic, image, route. 이들 path는 REF/assets/shell.jsx의 P 맵에 정의됨(예: tick: "M20 6 9 17l-5-5", swap: "M16 3h5v5|...", zap: "M13 2 3 14h9l-1 8 10-12h-9z"). 하나라도 빠지면 칩/액션 렌더가 깨진다. <Icon name="tick" w={3} />처럼 stroke-width 조정 prop 지원 필요(원본 <I d="tick" w="3" />).
6. 상태 처리(로딩/빈/에러/오프라인) & 엣지 케이스
| 상태 | 트리거 | UI | 구현 |
|---|---|---|---|
| 초기 로딩 | 페이지 진입 | 서버 컴포넌트가 fetch → 즉시 목록 렌더 | app/inbox/page.tsx await |
| 초기 로드 실패 | GET /api/inbox 실패 |
상단 에러 배너 + 빈 목록(컴포저는 동작) | initialLoadError |
| 분류 진행 중 | capture in-flight | .sb-think 3점 펄스 + "아리가 분류하고 있어요" |
status="thinking", aria-live="polite" |
| 분류 실패 | capture 응답 에러 | .sb-err "분류에 실패했어요. 다시 시도" |
status="error" |
| LLM 다운(폴백) | 백엔드가 heuristic 사용(model="heuristic") |
결과는 정상 표시 + .sb-fallback "규칙 기반(오프라인)" 배지 |
fallbackUsed |
| 빈 인박스 | 항목 0 (필터 결과 포함) | .sb-empty 안내 문구 |
items.length===0 |
| confirm 실패 | confirm 응답 에러 | 낙관적 체크 롤백 → classified 복귀 + 토스트 |
catch 롤백 |
| reclassify 실패 | reclassify 에러 | 재조회로 정합성 복구 | catch → refetch |
| 중복 제출 | 빈 입력/Enter 연타 | 빈 텍스트 무시, in-flight 동안 send 비활성(선택) | if(!t) return |
폴백 정책(핵심). CONTRACT: Ollama 미가용/오프라인/테스트 시
HeuristicProvider로 자동 폴백한다. 프런트는 폴백 여부를 분류 실패로 취급하지 않는다 — 결과(classification)는 정상적으로 받고,model필드로 폴백을 판별해 작은 배지만 보여준다. 진짜 실패(네트워크/500)일 때만status="error".
엣지: 타입 변경 후 confirm. reclassify로
task→event로 바꾼 뒤 confirm하면event로 실체화되어야 한다(서버가 최신 classification 기준으로 실체화). MVP는task만 실제task행 생성,event/idea는 상태만confirmed(작업 행 미생성) — 작업 페이지에는 task 타입만 나타난다.
7. 테스팅 & 검증 (가장 중요)
7.1 실행 명령
# 백엔드 (분류 API 동작 전제)
cd backend && uv run pytest -q # 또는: pytest -q
cd backend && uv run uvicorn app.main:app --reload # 로컬 서버
# 프런트 단위/컴포넌트
cd frontend && pnpm test # Vitest + RTL
cd frontend && pnpm test inbox # 인박스 관련만
# E2E (백엔드+프런트 기동 필요)
cd frontend && pnpm playwright test inbox.spec.ts
cd frontend && pnpm playwright test inbox.spec.ts --headed # 시각 확인
# 접근성 (axe, Playwright 통합)
cd frontend && pnpm playwright test inbox.spec.ts -g "a11y"
7.2 컴포넌트 테스트 (Vitest + RTL)
tests/RouteChips.test.tsx:
import { render, screen } from "@testing-library/react";
import RouteChips from "@/components/inbox/RouteChips";
const base = {
id:"c1", inbox_item_id:"s1", sphere:"life" as const, project_id:"p",
proj_label:"개인 › 여행 — 한국", tone:"coral" as const,
due_text:"출발 전", when_text:"오늘 21:00", extra:"가격 추적 알림 켜둠",
reason:"r", confidence:0.9, model:"heuristic", created_at:"",
};
test("task 칩: 작업 라벨 + check", () => {
render(<RouteChips r={{...base, type:"task"}} editable onReType={()=>{}} />);
expect(screen.getByText("작업")).toBeInTheDocument();
expect(screen.getByText("개인")).toBeInTheDocument(); // sphere=life
expect(screen.getByText("개인 › 여행 — 한국")).toBeInTheDocument();
expect(screen.getByText("가격 추적 알림 켜둠")).toBeInTheDocument(); // extra(auto)
});
test("event 칩: type 클래스에 event 포함(blue)", () => {
const { container } = render(<RouteChips r={{...base, type:"event"}} editable onReType={()=>{}} />);
expect(container.querySelector(".r-chip.type.event")).toBeTruthy();
expect(screen.getByText("일정")).toBeInTheDocument();
});
test("due/when/extra 없으면 칩 미표시", () => {
render(<RouteChips r={{...base, type:"idea", due_text:null, when_text:null, extra:null}} editable onReType={()=>{}} />);
expect(screen.queryByText("출발 전")).toBeNull();
expect(screen.getByText("아이디어")).toBeInTheDocument();
});
test("editable=false면 타입 칩 disabled", () => {
render(<RouteChips r={{...base, type:"task"}} editable={false} onReType={()=>{}} />);
expect(screen.getByRole("button", { name:/타입: 작업/ })).toBeDisabled();
});
tests/CaptureComposer.test.tsx:
import { render, screen, fireEvent } from "@testing-library/react";
import CaptureComposer from "@/components/inbox/CaptureComposer";
test("Enter로 text 제출 후 입력 비움", () => {
const onSubmit = vi.fn();
render(<CaptureComposer onSubmit={onSubmit} />);
const input = screen.getByLabelText("인박스에 빠르게 캡처");
fireEvent.change(input, { target:{ value:"엄마 생신 선물 알아보기" }});
fireEvent.keyDown(input, { key:"Enter" });
expect(onSubmit).toHaveBeenCalledWith("엄마 생신 선물 알아보기", "text");
expect((input as HTMLInputElement).value).toBe("");
});
test("mic 버튼: voice kind + 더미 transcript", () => {
const onSubmit = vi.fn();
render(<CaptureComposer onSubmit={onSubmit} />);
fireEvent.click(screen.getByLabelText(/음성으로 캡처/));
expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("음성 메모"), "voice");
});
test("빈 입력은 제출 무시", () => {
const onSubmit = vi.fn();
render(<CaptureComposer onSubmit={onSubmit} />);
fireEvent.keyDown(screen.getByLabelText("인박스에 빠르게 캡처"), { key:"Enter" });
expect(onSubmit).not.toHaveBeenCalled();
});
tests/inbox-presentation.test.ts:
import { nextType, TYPE_LABEL, SPHERE, TYPE_ICON } from "@/lib/inbox-presentation";
test("nextType 순환 task→event→idea→task", () => {
expect(nextType("task")).toBe("event");
expect(nextType("event")).toBe("idea");
expect(nextType("idea")).toBe("task");
});
test("라벨/아이콘 맵", () => {
expect(TYPE_LABEL.task).toBe("작업");
expect(TYPE_ICON.idea).toBe("brain");
expect(SPHERE.life.label).toBe("개인");
});
7.3 백엔드 골든 분류 테스트 (pytest — phase-2 소유, 여기서 재확인)
backend/tests/test_classification.py (HeuristicProvider 기준 — LLM 없이도 통과해야 함):
import pytest
from app.llm.heuristic import HeuristicProvider
GOLDEN = [
("다음 주에 한국 놀러가는 비행기 티켓 사기", "task", "life", "여행"),
("수요일 11시 자전거 수리 맡기기", "event", "life", "캘린더"),
("엄마 생신 선물 미리 알아보기", "task", "life", "가족"),
("온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까", "idea", "work", "아이디어"),
]
@pytest.mark.parametrize("raw,exp_type,exp_sphere,proj_kw", GOLDEN)
def test_golden(raw, exp_type, exp_sphere, proj_kw):
c = HeuristicProvider().classify_capture(raw, context={})
assert c.type == exp_type
assert c.sphere == exp_sphere
assert proj_kw in c.proj_label
assert c.reason # 한국어 이유 비어있지 않음
def test_capture_then_confirm_materializes(client):
r = client.post("/api/inbox/capture",
json={"kind":"text","raw":"다음 주에 한국 놀러가는 비행기 티켓 사기"})
item = r.json(); iid = item["item"]["id"]
assert item["classification"]["type"] == "task"
c = client.post(f"/api/inbox/{iid}/confirm").json()
assert c["item"]["status"] == "confirmed"
assert c["item"]["materialized_task_id"]
# 작업 트리에 등장
tasks = client.get("/api/tasks?area=life").json()
titles = _flatten_titles(tasks)
assert any("비행기 티켓" in t for t in titles)
7.4 E2E 테스트 (Playwright) — playwright/inbox.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.beforeEach(async ({ page }) => { await page.goto("/inbox"); });
test("캡처 → 분류 → 좋아요 → 작업 페이지에 등장(연합)", async ({ page }) => {
const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill("다음 주에 한국 놀러가는 비행기 티켓 사기");
await input.press("Enter");
// 생각 중
await expect(page.getByText("아리가 분류하고 있어요")).toBeVisible();
// 결과 칩
await expect(page.getByText("작업")).toBeVisible();
await expect(page.getByText("개인 › 여행 — 한국")).toBeVisible();
await expect(page.getByText("가격 추적 알림 켜둠")).toBeVisible();
// 좋아요
await page.getByRole("button", { name: /좋아요, 그렇게 해줘/ }).click();
// 작업 페이지에서 확인(연합)
await page.goto("/tasks?area=life");
await expect(page.getByText("다음 주에 한국 놀러가는 비행기 티켓 사기")).toBeVisible();
});
test("다르게 분류 — 타입 순환 task→event", async ({ page }) => {
const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill("리포트 회신 보내기"); await input.press("Enter");
await expect(page.getByText("작업")).toBeVisible();
await page.getByRole("button", { name: /다르게 분류/ }).click();
await expect(page.getByText("일정")).toBeVisible(); // task→event
});
const GOLDEN = [
{ raw:"다음 주에 한국 놀러가는 비행기 티켓 사기", type:"작업", proj:"개인 › 여행 — 한국" },
{ raw:"수요일 11시 자전거 수리 맡기기", type:"일정", proj:"개인 캘린더" },
{ raw:"엄마 생신 선물 미리 알아보기", type:"작업", proj:"가족" },
{ raw:"온보딩 환영 화면에 짧은 애니메이션 넣으면 어떨까", type:"아이디어", proj:"아이디어 보드" },
];
for (const g of GOLDEN) {
test(`골든: "${g.raw}" → ${g.type}`, async ({ page }) => {
const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill(g.raw); await input.press("Enter");
await expect(page.locator(".sb-cap.fresh").first().getByText(g.type)).toBeVisible();
await expect(page.getByText(new RegExp(g.proj))).toBeVisible();
});
}
test("LLM 폴백 경로 — heuristic 배지", async ({ page }) => {
// 백엔드를 LLM 비활성(OLLAMA 미가용) 상태로 기동했을 때
const input = page.getByLabel("인박스에 빠르게 캡처");
await input.fill("엄마 생신 선물 미리 알아보기"); await input.press("Enter");
await expect(page.getByText("작업")).toBeVisible();
await expect(page.getByText("규칙 기반(오프라인)")).toBeVisible();
});
test("음성 스텁 — voice 아이콘 행", async ({ page }) => {
await page.getByLabel(/음성으로 캡처/).click();
await expect(page.locator(".cap-k.voice").first()).toBeVisible();
});
test("a11y — axe 위반 없음", async ({ page }) => {
await expect(page.getByRole("heading", { name: "스마트 인박스" })).toBeVisible();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
폴백 E2E는 별도 프로젝트/env로 백엔드를
OLLAMA_HOST미연결 상태로 기동(pnpm playwright test --project=fallback)하거나,route.fulfill로 capture 응답의model을"heuristic"으로 모킹해 배지를 검증한다.
7.5 수동 QA 체크리스트
- 텍스트 입력 후 Enter → "아리가 분류하고 있어요" 3점 펄스가 보이고, 곧 칩으로 바뀐다.
- 결과 칩: 타입(작업/일정/아이디어) 색이 맞다(작업 ink, 일정 blue, 아이디어 violet).
- sphere 칩 색: 업무 blue, 개인 green. proj 칩 dot 색이 tone과 일치.
- extra(예: "가격 추적 알림 켜둠") 칩은 amber로 강조되고 zap 아이콘.
- 이유 한 줄이 칩 아래에 muted로 표시, 62ch에서 줄바꿈, 한국어 단어 단위(keep-all).
- "좋아요, 그렇게 해줘"(라임 버튼) → 우측에 green 체크 원형(.sb-done), 액션 버튼 사라짐.
- "다르게 분류" 또는 타입 칩 클릭 → 타입이 task→event→idea→task 순으로 바뀐다.
- 필터 전체/업무/개인 전환 → 목록이 sphere로 필터링, on 상태는 검정 채움(--fill).
- 힌트 "업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요" 노출.
- mic 버튼 → voice 아이콘 행 추가(더미 메모). image 버튼 → image 아이콘 행.
- 분류 원칙 카드 4건 문구가 원본과 글자 단위로 동일(특수 따옴표 포함).
- "오늘 어디로 갔나": 작업/일정/아이디어 카운트 표시, 작업→/tasks, 일정→/dashboard 이동, 아이디어 링크 없음. hover 시 화살표 우측 이동.
- confirm한 "비행기 티켓"이 /tasks 의 "개인 › 여행 — 한국"에 실제로 보인다.
- 다크 테마 토글 시 토큰 오버라이드로 모든 칩/카드 색이 자연스럽다.
- LLM 다운 상태: 분류는 되고 "규칙 기반(오프라인)" 배지가 붙는다(에러 아님).
- 키보드 only: input → mic → image → send → 칩/액션까지 Tab 순회, Enter/Space 동작.
7.6 통과 기준
- 컴포넌트 테스트 전부 green, 백엔드 골든 4건 + 실체화 테스트 green.
- E2E: 연합 시나리오 + 4 골든 + 재분류 + 폴백 + a11y 전부 green.
- axe 위반 0건. capture→칩 표시 체감 1초 이내(로컬, in-flight 동안 thinking 유지).
8. 완료 기준 (Definition of Done)
/inbox라우트 렌더: 컴포저 + 캡처 목록 + 분류 원칙 카드 + 행선지 카드.- 캡처(텍스트 Enter/보내기, mic·image 스텁) →
POST /api/inbox/capture동기 분류 동작. - 분류 결과 칩(type/sphere/proj/due/when/extra) + 이유 한 줄이 원본 스타일로 표시.
- "좋아요"(confirm) → 실체화: task 타입은 실제
task생성 +materialized_task_id연결. - "다르게 분류"(reclassify) 타입 순환(task↔event↔idea) 동작.
- 연합 end-to-end: 인박스 confirm → 작업 페이지(
/tasks)에 신규 task 등장("비행기 티켓" → 개인 › 여행 — 한국). - 4 골든 케이스가 의도대로 분류(task/event/idea + sphere + proj 키워드).
- LLM 다운 시 heuristic 폴백 + UI 배지 안내(분류 자체는 성공).
- 빈/로딩/에러/오프라인 상태 모두 처리, 낙관적 업데이트 + 롤백.
- 디자인 충실도:
sinbox.css토큰/클래스/문구를 그대로 재현(섹션 5 표 기준). - 7장 테스트/QA 전부 통과.
9. 다음 단계
다음 문서는 phase-5-dashboard.md (대시보드). 대시보드는 인박스/작업/결재/일정/목표를 집계하므로 페이지 구현 순서상 마지막에 가깝다. 본 phase의 산출물 중 다음이 대시보드에서 재사용된다.
inbox_recent[]요약(대시보드의 인박스 요약 카드) —GET /api/dashboard의inbox_recent.- "오늘 어디로 갔나"의 일정 행선지 링크가 대시보드 일정 요약 카드(
/dashboard)로 연결됨. - 분류 결과 칩/이유 컴포넌트(
RouteChips/ReasonLine)는 대시보드 인박스 미니 위젯에서 부분 재사용 가능.
연합 전체 흐름(인박스→작업→대시보드)과 E2E/접근성/성능 통합은 phase-6-integration.md 에서 종합 검증한다.