|
|
# 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로** 동작한다.
|
|
|
|
|
|
1. **멀티모달 캡처 컴포저** — 텍스트를 입력하고 Enter(또는 보내기 버튼)로 제출. 음성(mic)/이미지(image)는 MVP에서 **스텁**(kind 표시 + 더미 transcript/caption 주입)으로 동작한다.
|
|
|
2. **실시간 AI 분류** — 제출 → "아리가 분류하고 있어요" 로딩 애니메이션 → 분류 결과 칩(타입·sphere·프로젝트·마감·추천 시간·extra·이유 한 줄) 표시. 백엔드 `POST /api/inbox/capture` (동기 분류) 연동. LLM(Ollama)이 다운이면 **heuristic 폴백**으로 자동 전환되고 그 사실을 UI에 안내한다.
|
|
|
3. **확인/재분류** — "좋아요, 그렇게 해줘"(confirm) → 실체화. 타입이 `task`면 실제 `task` 행이 생성되고 `inbox_item.materialized_task_id`로 연결된다. "다르게 분류"(reclassify)로 타입을 `task ↔ event ↔ idea` 순환 변경.
|
|
|
4. **분류 원칙 카드** — 행동→작업 / 시간→일정 / 막연→아이디어 / "개인은 섹션이 아니라 프로젝트" 4원칙을 원본 문구 그대로 노출.
|
|
|
5. **"오늘 어디로 갔나" 행선지 카드** — 오늘 분류된 항목들의 행선지(작업/일정/아이디어) 카운트 요약 + 작업/대시보드(일정) 페이지 링크.
|
|
|
6. **연합 일관성** — 인박스에서 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`와 동일).
|
|
|
|
|
|
```ts
|
|
|
// 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.status` enum(`new|classified|confirmed|dismissed`)에 없는 **프런트 임시 상태**다. 따라서 화면 모델에는 `thinking`을 합친 별도 타입을 쓴다.
|
|
|
|
|
|
```ts
|
|
|
// 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`/칩 표현 로직을 순수 함수로 분리한다(테스트 용이).
|
|
|
|
|
|
```ts
|
|
|
// 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.jsx` L133-135의 `&&` 패턴 동일).
|
|
|
|
|
|
### 3.3 API 클라이언트 — `lib/api.ts`
|
|
|
|
|
|
```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`)가 보유한다.
|
|
|
|
|
|
```tsx
|
|
|
// 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.jsx` L40)로 인박스 탭을 활성화한다 — 우리는 pathname(`/inbox`)으로 active를 판정하므로 페이지에서 Topbar를 따로 렌더하지 않는다. (id 매핑: `sbox` → `/inbox`.)
|
|
|
|
|
|
페이지 헤더(`pagehead`)는 원본 문구 그대로:
|
|
|
|
|
|
```tsx
|
|
|
// 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에 연결한다.
|
|
|
|
|
|
```tsx
|
|
|
"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.jsx` L147).
|
|
|
|
|
|
### 3.6 컴포저 + 목록 — `components/inbox/SmartInbox.tsx`
|
|
|
|
|
|
원본 `sinbox.jsx`의 `SmartInbox` JSX를 거의 그대로. 클래스명(`card sbox sp2`, `ch`, `ico`, `htext`, `count`, `sb-cmd`, `sb-filters`, `sb-list`)을 **동일하게 유지**한다.
|
|
|
|
|
|
```tsx
|
|
|
"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 **스텁**.
|
|
|
|
|
|
```tsx
|
|
|
"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.jsx` L118: `c.kind === "voice" ? "mic" : c.kind === "image" ? "image" : "pen"`).
|
|
|
|
|
|
### 3.8 컨텍스트 필터 — `components/inbox/SphereFilters.tsx`
|
|
|
|
|
|
원본 `sb-filters`(`sinbox.jsx` L105-112). "업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요" 힌트 문구 그대로.
|
|
|
|
|
|
```tsx
|
|
|
"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분기.
|
|
|
|
|
|
```tsx
|
|
|
"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종 + 화살표.
|
|
|
|
|
|
```tsx
|
|
|
"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.css` L69-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 폴백 안내").
|
|
|
|
|
|
```tsx
|
|
|
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`에 추가, 토큰만 사용):
|
|
|
|
|
|
```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). **문구를 글자 그대로** 옮긴다(특수 따옴표 `‘ ’` 포함).
|
|
|
|
|
|
```tsx
|
|
|
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된 항목**에서 동적으로 집계한다.
|
|
|
|
|
|
```tsx
|
|
|
"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)` |
|
|
|
|
|
|
추가로 우리가 신설하는 클래스(토큰만 사용):
|
|
|
|
|
|
```css
|
|
|
/* 빈 상태 */
|
|
|
.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 ("비행기 티켓")**
|
|
|
|
|
|
요청:
|
|
|
```http
|
|
|
POST /api/inbox/capture
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{ "kind": "text", "raw": "다음 주에 한국 놀러가는 비행기 티켓 사기" }
|
|
|
```
|
|
|
응답:
|
|
|
```json
|
|
|
{
|
|
|
"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` (본문 없음)
|
|
|
|
|
|
응답:
|
|
|
```json
|
|
|
{
|
|
|
"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`이 포함되어 **작업 페이지에 등장**(연합 완료).
|
|
|
|
|
|
**재분류 — 사용자가 타입 강제**
|
|
|
|
|
|
요청:
|
|
|
```http
|
|
|
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.css` L15).
|
|
|
- 보내기 버튼: `--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.css` L58-63).
|
|
|
- 타입 칩 색: event=`--blue`(#4f72e0), idea=`--violet`(#8b6fd4), task=`--ink`(#211f1c).
|
|
|
- extra(auto) 칩: `--amber`(#e0a23c) 9% 배경.
|
|
|
- 생각 중 점: `--coral`, `sb-pulse` 1s, nth-child delay 0.15s/0.3s.
|
|
|
- 완료 체크: green 16% 배경 원형 + `--green`(#4e9b66).
|
|
|
- 원칙 카드 아이콘: `--tone` 14% 배경(원칙별 green/blue/violet/coral).
|
|
|
- 행선지 숫자: `--font-mono`(DM Mono) 15px.
|
|
|
|
|
|
### 5.3 인터랙션
|
|
|
|
|
|
- **Enter 제출**: 원본 `onKeyDown ... e.key==="Enter" && submit()` (`sinbox.jsx` L96). 우리 동일.
|
|
|
- **타입 칩 클릭 → 재분류**: 원본 `onClick={() => c.status==="new" && reType(c.id)}` (`sinbox.jsx` L128). 우리는 `status==="classified"`일 때만 편집.
|
|
|
- **다르게 분류 순환**: 원본 `cycleType` task→event→idea→task (`sinbox.jsx` L51-55). 낙관적 미리보기 후 서버 확정.
|
|
|
- **확인 시 즉시 체크 표시**: 원본 `c.status==="done" ? <span className="sb-done">...` (`sinbox.jsx` L147).
|
|
|
- **행선지 hover**: `.dest.go:hover .dest-go { transform: translateX(2px) }` (`sinbox.css` L165).
|
|
|
- **한국어 가독성**: 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 실행 명령
|
|
|
|
|
|
```bash
|
|
|
# 백엔드 (분류 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`:
|
|
|
```ts
|
|
|
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`:
|
|
|
```ts
|
|
|
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`:
|
|
|
```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 없이도 통과해야 함):
|
|
|
```python
|
|
|
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`
|
|
|
|
|
|
```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`** 에서 종합 검증한다.
|