# Phase 12 — 여정 + 하루 마감 (집계 narrative)
> 한 줄 요약: 흩어진 작업·일정·사람·결재·자동화 데이터를 **하나의 이야기**로 엮는 두 페이지 — **여정**(아침→오전→오후→마무리 4단계 흐름 + 작업↔사람↔미팅 베지어 협업 맵)과 **하루 마감**(이브닝 브리핑: 오늘 숫자·해낸 일·내일 이월·아리 사전 준비·한 줄 회고·수면 모드). 두 페이지 모두 **거의 전부가 집계(read-only aggregation)** 이므로 포스트-MVP의 사실상 마지막 페이지군이다.
> 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요.
> 선행: `phase-7-approvals-automation.md`(결재함 `savedToday`·자동 처리 건수·`approval`/`automation_run_log`), `phase-8-calendar-meetings.md`(미팅 액션→작업), `phase-3-tasks.md`(작업 트리·status), `phase-2-backend.md`(모델/라우터/시드 규약), `phase-1-design-system.md`(토큰·Icon·셸). 후속: `phase-13-integrations.md`.
---
## 0. 목차
1. [개요 & 목표](#1-개요--목표)
2. [선행 조건 / 산출물](#2-선행-조건--산출물)
3. [상세 구현 — 프론트엔드](#3-상세-구현--프론트엔드)
- 3.1 여정 페이지 (`/journey`)
- 3.2 하루 마감 페이지 (`/wrap`, 풀스크린)
4. [상세 구현 — 백엔드 (집계 중심)](#4-상세-구현--백엔드-집계-중심)
5. [데이터 / 타입 / API 계약](#5-데이터--타입--api-계약)
6. [디자인 충실도 노트](#6-디자인-충실도-노트)
7. [상태 처리 · 엣지 케이스](#7-상태-처리--엣지-케이스)
8. [연합 이벤트 (발행/구독)](#8-연합-이벤트-발행구독)
9. [테스팅 & 검증](#9-테스팅--검증)
10. [완료 기준 (DoD)](#10-완료-기준-dod)
11. [다음 단계](#11-다음-단계)
---
## 1. 개요 & 목표
### 1.1 이 phase가 끝나면 무엇이 동작하는가
- **여정 `/journey`**: 하루를 4단계(`아침 준비 → 오전 집중 → 오후 협업 → 마무리 & 내일`)로 보여주는 큰 글래스 패널. 단계별 카드(routine/task/meet/tile)와 **작업↔사람↔미팅을 잇는 실시간 베지어 커넥터**(DOM 위치 측정 → SVG 곡선)가 렌더되고, 카드에 호버하면 연결된 흐름이 강조(`lit`)·나머지는 흐려진다(`mute`). 하단에 오늘 작업 테이블 + 반원 게이지 2개. 데이터는 `GET /api/journey` 가 작업·일정·사람을 집계해 공급한다.
- **하루 마감 `/wrap`**: 상단 메뉴 없는 **전용 풀스크린**(라이트 글래스 + 옅은 바이올렛/코럴 글로우). 오늘 숫자 4개(완료/딥워크/미팅/아리 자동 처리·아낀 시간), 해낸 일(highlights), 미완료→내일 자동 이월(rollover, 되돌리기), 내일 미리보기(tomorrow), 아리 사전 준비(prep), 한 줄 회고(localStorage 영속), 수면 모드 토글. 데이터는 `GET /api/wrap` 가 **작업·일정·결재함(phase-7)·자동화**에서 계산한다.
- 회고 한 줄과 수면 모드는 **상태 저장**(MVP 데모는 localStorage 미러 + 선택적 백엔드 `PUT /api/wrap/reflection`·`PUT /api/wrap/sleep-mode` 영속). 신규 영속 테이블은 **최소**(`day_reflection`, `day_state`)로만 추가.
### 1.2 왜 마지막인가 (집계 의존성)
여정·하루 마감은 **자신의 1차 데이터를 거의 갖지 않는다.** 두 페이지는 다음을 읽어 집계한다.
```
작업(phase-3 task) ─┐
일정/미팅(phase-8 event) ─┤
사람(phase-2 person) ─┼─▶ GET /api/journey ─▶ 4단계 + 카드 + 베지어 링크 + 작업테이블
결재함(phase-7 approval) ─┤
자동화(phase-7 automation_*) ─┘
└─▶ GET /api/wrap ─▶ stats(완료·딥워크·미팅·아리자동+아낀시간)
+ highlights + rollover + tomorrow + prep
```
따라서 빌드 순서상 **phase-7~11 이후**에 위치한다(`post-mvp-overview.md` 로드맵: `7→8→9→10→11→12→13→14→15`). overview.md §12.3 의 "대시보드 = 집계 화면이라 마지막에 빌드" 와 동일한 원칙을, 더 넓은 데이터 표면(결재·자동화·미팅까지)으로 확장한 것이다.
### 1.3 제품 철학과의 정합 (`overview.md` §3, `PROJECT-README.md` §5)
- **"이미 해뒀어요"**: 하루 마감의 rollover/prep 은 *질문*이 아니라 *완료 보고*다 — `"남은 일은 내일로 옮겨뒀어요"`, `"아리가 미리 끝내둔 준비"`. 사용자는 **확인과 되돌리기**만 한다(원본 `wrap-data.js` 주석: *"사용자가 할 일은 '확인'과 '되돌리기'뿐"*).
- **"개인=프로젝트"**: 여정 작업 테이블·rollover 모두 업무(`분기 리포트`)와 개인(`구독 결제 카드 갱신`/`독서모임 책 2장`)을 같은 목록에서 보여준다 — 개인을 숨기지 않는다.
- **연합으로 만드는 가치**: 하루 마감 stats 의 `아리 자동 처리 12건 · 47분 아낌` 은 결재함(`savedToday`)·자동화 실행 로그의 **합산**이다. 회고 한 줄은 **내일 아침 브리핑**(대시보드 `briefing.note`)으로 흘러간다.
---
## 2. 선행 조건 / 산출물
### 2.1 선행 조건 (의존 phase)
| 의존 | 무엇을 가져오는가 |
|---|---|
| `phase-2-backend.md` | 모델/스키마/라우터/시드 규약, `person`/`task`/`event`(읽기전용)/`run_seed(session, reset)` 패턴, TEXT PK, tone 키 규약. |
| `phase-3-tasks.md` | `task`(status 5단·due·assignee·prio)·`columns` accent. 여정 작업 테이블·하루 마감 완료 수가 이 데이터를 읽는다. |
| `phase-7-approvals-automation.md` | `approval`(savedToday 합산)·`autonomy_setting`·`automation_run_log`/`automation_stats`. 하루 마감 `아리 자동 처리`·`아낀 시간` 의 출처. |
| `phase-8-calendar-meetings.md` | `event`/`meeting`(미팅 액션→작업). 여정 오후 협업 단계·하루 마감 `미팅 소화`·미팅 액션 highlight 의 출처. |
| `phase-1-design-system.md` | `tokens.css`(글래스/잉크/라운드/tone), `Icon`(paths 맵), 13항목 `Topbar`. 여정은 셸 사용, 하루 마감은 셸 **미사용**(자체 상단바). |
> phase-9~11(메일/알림/리서치/여행/라이프)는 **선택 의존**이다. 가용하면 하루 마감 stats/prep 에 신호를 더 보탤 수 있으나(예: prep 의 `우산 리마인더`), MVP 데모는 시드 고정값(`wrap-data.js`)으로 결정적으로 채운다. 미가용 시 해당 항목은 생략(빈 상태 §7).
### 2.2 산출물 (Deliverables)
```
frontend/
├─ app/
│ ├─ journey/page.tsx 여정 (앱 셸 안, current="journey")
│ └─ wrap/
│ ├─ layout.tsx 하루 마감 전용 레이아웃 (Topbar 미렌더, body.wrap-page)
│ └─ page.tsx 하루 마감 풀스크린
├─ components/journey/
│ ├─ JourneyBoard.tsx 4컬럼 보드 + 베지어 SVG 커넥터 (원본 Board)
│ ├─ JourneyColumn.tsx 단계 컬럼 (원본 Col)
│ ├─ FlowCard.tsx 흐름 카드 (routine/task/meet)
│ ├─ FinishTile.tsx 마무리 타일 (원본 tile)
│ ├─ PeopleStack.tsx 협업 아바타 스택 (원본 jx-stack)
│ ├─ JourneyTable.tsx 하단 작업 테이블 (원본 jtable)
│ └─ Gauge.tsx 반원 게이지 (원본 Gauge)
├─ components/wrap/
│ ├─ WrapHero.tsx 인사 + lede
│ ├─ WrapStats.tsx 오늘 숫자 4개
│ ├─ Highlights.tsx 오늘 해낸 일
│ ├─ Rollover.tsx 내일로 이월 (되돌리기)
│ ├─ TomorrowPreview.tsx 내일 미리보기 + prep
│ ├─ Reflection.tsx 한 줄 회고 (localStorage + PUT)
│ └─ SleepMode.tsx 수면 모드 토글
├─ lib/hooks/
│ ├─ useJourney.ts GET /api/journey
│ └─ useWrap.ts GET /api/wrap (+ reflection/sleep mutate)
└─ styles/
├─ journey.css 원본 journey-clean.css 이식
└─ wrap.css 원본 wrap.css 이식
backend/
├─ app/
│ ├─ models.py (+ DayReflection, DayState — 신규 최소)
│ ├─ schemas.py (+ Journey*, Wrap* 응답 스키마)
│ ├─ routers/
│ │ ├─ journey.py GET /api/journey
│ │ └─ wrap.py GET /api/wrap, PUT reflection, PUT sleep-mode, POST rollover/{id}
│ ├─ services/
│ │ ├─ journey.py build_journey(): 작업·일정·사람 → 4단계+카드+링크
│ │ └─ wrap.py build_wrap(): stats/highlights/rollover/tomorrow/prep 집계
│ └─ seed.py (+ journey_link 설정, day 상태 기본값)
└─ tests/
├─ test_api_journey.py
├─ test_api_wrap.py
└─ test_wrap_aggregation.py stats 가 시드와 일치하는지
```
---
## 3. 상세 구현 — 프론트엔드
### 3.1 여정 페이지 (`/journey`)
원본 `REF/assets/journey-clean.jsx` / `journey-clean.css` / `journey-clean-data.js` 를 React+TS 컴포넌트로 픽셀 충실 이식한다. 데이터는 시드 하드코딩이 아니라 `GET /api/journey` 응답을 그대로 소비한다.
#### 3.1.1 페이지 컨테이너 + 페이지 헤더
원본 `App()` 의 구조: 상단바(셸) → 페이지 헤더(`pagehead`) → 흐름 패널(`jx-panel`) → 하단 2열(`jx-bottom`). 상단바는 **공용 셸**(`Topbar current="journey"`, 13항목)을 사용한다. 원본 `MAIN` 배열의 여정 항목은 `{ id:"journey", label:"여정", icon:"route", href:"여정.html" }` — Next 라우트는 `/journey`.
```tsx
// frontend/app/journey/page.tsx
"use client";
import { useJourney } from "@/lib/hooks/useJourney";
import { Icon } from "@/components/Icon";
import { JourneyBoard } from "@/components/journey/JourneyBoard";
import { PeopleStack } from "@/components/journey/PeopleStack";
import { JourneyTable } from "@/components/journey/JourneyTable";
import { Gauge } from "@/components/journey/Gauge";
import "@/styles/journey.css";
export default function JourneyPage() {
const { data, isLoading, error, retry } = useJourney();
// 로딩/에러 처리는 §7 참조. 아래는 성공 경로.
if (isLoading || !data) return ;
if (error) return ;
const { header, meta, people, stages, cards, links, rows, table, gauges, gaugeFoot } = data;
return (
{/* ===== 페이지 헤더 (원본 .pagehead) ===== */}
{/* ===== 흐름 패널 ===== */}
오늘의 흐름 · {meta.stageCount}단계 · {meta.itemCount}개 항목 · {meta.collabCount}명 협업
{stages.map((s) => (
{s.title}{s.time}
))}
카드에 마우스를 올리면 연결된 작업 · 사람 · 미팅 흐름이 강조돼요
{/* ===== 하단 2열 ===== */}
{gauges.map((g, i) => )}
{gaugeFoot}
);
}
```
> 원본 `App()` 은 자체 `topbar`/`theme` state 를 가졌지만, 우리 셸(`phase-1-design-system.md` Topbar + next-themes)이 이를 대체한다 — 페이지는 본문만 렌더한다. 원본 `MAIN` 13항목·badge(결재함3/작업4/알림6)는 셸에 이미 이식되어 있다(`overview.md` §5.3, §11.6). **여정은 `route` 아이콘으로 active**.
> **진입 애니메이션**: 원본은 `useEffect` 로 `ref.current.classList.add("entered")`. 우리는 컨테이너에 `entered` 를 즉시 부여하거나(SSR 후 hydration 시 1프레임 지연 부여) `@keyframes jxin`(0.5s)을 그대로 사용. `prefers-reduced-motion` 가드는 CSS 에 이미 존재.
#### 3.1.2 베지어 커넥터 보드 — `JourneyBoard.tsx`
원본 `Board()` 의 핵심: **DOM 위치를 실시간 측정해 SVG 베지어 곡선을 그린다.** 각 카드에 `ref` 를 붙여(`nodes.current[id]`), `getBoundingClientRect()` 로 시작/끝 좌표를 구해 cubic Bézier path 를 만든다. resize/폰트로드/탭변경에 대응하기 위해 `ResizeObserver` + `requestAnimationFrame` + `document.fonts.ready` 로 재계산한다.
원본 path 공식(그대로 이식):
```
x1 = a.right - boardLeft, y1 = a.top - boardTop + a.height/2 // from 카드의 오른쪽 중앙
x2 = b.left - boardLeft, y2 = b.top - boardTop + b.height/2 // to 카드의 왼쪽 중앙
dx = max(36, (x2 - x1) * 0.5)
d = `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`
```
```tsx
// frontend/components/journey/JourneyBoard.tsx
"use client";
import { useRef, useState, useCallback, useLayoutEffect, useMemo } from "react";
import { JourneyColumn } from "./JourneyColumn";
import { FlowCard } from "./FlowCard";
import { FinishTile } from "./FinishTile";
import type { JourneyStage, JourneyCards, JourneyLink, JourneyPerson } from "@/lib/types";
type Props = {
stages: JourneyStage[];
cards: JourneyCards; // { s1:[], s2:[], s3:[], s4:[] }
links: JourneyLink[]; // [{ from, to, tone, dash }]
people: JourneyPerson[];
};
export function JourneyBoard({ stages, cards, links, people }: Props) {
const boardRef = useRef(null);
const nodes = useRef>({});
const [paths, setPaths] = useState([]);
const [size, setSize] = useState({ w: 0, h: 0 });
const [hover, setHover] = useState(null);
// 오전 집중(s2) 작업의 done 토글은 로컬 state (원본 toggle)
const [done, setDone] = useState>(
Object.fromEntries(cards.s2.map((c) => [c.node, !!c.done]))
);
const peopleById = useMemo(() => Object.fromEntries(people.map((p) => [p.id, p])), [people]);
const setNode = (id: string) => (el: HTMLElement | null) => { if (el) nodes.current[id] = el; };
const compute = useCallback(() => {
const board = boardRef.current;
if (!board) return;
const br = board.getBoundingClientRect();
const out: ComputedPath[] = [];
for (const lk of links) {
const a = nodes.current[lk.from], b = nodes.current[lk.to];
if (!a || !b) continue;
const ar = a.getBoundingClientRect(), bb = b.getBoundingClientRect();
const x1 = ar.right - br.left, y1 = ar.top - br.top + ar.height / 2;
const x2 = bb.left - br.left, y2 = bb.top - br.top + bb.height / 2;
const dx = Math.max(36, (x2 - x1) * 0.5);
out.push({
key: lk.from + ">" + lk.to, from: lk.from, to: lk.to,
color: `var(--${lk.tone})`, dash: lk.dash,
d: `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`,
x1, y1, x2, y2,
});
}
setPaths(out);
setSize({ w: br.width, h: br.height });
}, [links]);
useLayoutEffect(() => {
compute();
const raf = requestAnimationFrame(compute);
const onR = () => compute();
window.addEventListener("resize", onR);
const ro = new ResizeObserver(compute);
if (boardRef.current) ro.observe(boardRef.current);
if (document.fonts?.ready) document.fonts.ready.then(compute);
return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onR); ro.disconnect(); };
}, [compute, done]);
// 이웃 맵(양방향) — 호버 강조용 (원본 neighbors)
const neighbors = useMemo(() => {
const m: Record> = {};
for (const l of links) {
(m[l.from] = m[l.from] || new Set()).add(l.to);
(m[l.to] = m[l.to] || new Set()).add(l.from);
}
return m;
}, [links]);
const isLit = (node: string) => !!hover && (node === hover || neighbors[hover]?.has(node));
const cls = (node: string, base: string) =>
base + (isLit(node) ? " lit" : "") + (hover && !isLit(node) ? " mute" : "");
const pathState = (p: ComputedPath) =>
!hover ? "" : (p.from === hover || p.to === hover) ? " on" : " off";
const on = (node: string) => ({
onMouseEnter: () => setHover(node), onMouseLeave: () => setHover(null),
});
const toggle = (node: string) => setDone((d) => ({ ...d, [node]: !d[node] }));
return (
{paths.map((p) => (
))}
{/* 1 — 아침 준비 (routine) */}
{cards.s1.map((c) => (
))}
{/* 2 — 오전 집중 (task, 체크 토글) */}
!done[t.node]).length}/4`}>
{cards.s2.map((t) => {
const pe = peopleById[t.who];
const d = done[t.node];
return (
{ e.stopPropagation(); toggle(t.node); }}>
{pe.initial}
);
})}
{/* 3 — 오후 협업 (meet) */}
{cards.s3.map((e) => {
const pe = peopleById[e.who];
return (
{pe.initial}
{e.soon ?
곧 :
{e.time}
}
);
})}
{/* 4 — 마무리 & 내일 (tile) */}
{cards.s4.map((t) => (
))}
);
}
type ComputedPath = {
key: string; from: string; to: string; color: string; dash?: boolean;
d: string; x1: number; y1: number; x2: number; y2: number;
};
```
> **tone 매핑 정본**: 원본 데이터(`journey-clean-data.js`)의 링크 색은 HEX(`C.blue` = `#4f72e0` 등) 였다. CONTRACT 의 tone 규약(`overview.md` §9.3)을 따라 백엔드는 **tone 키**(`blue|violet|coral|green|amber|ink|faint`)로 내려주고, 프론트가 `var(--${tone})` 로 변환한다. 원본 HEX→tone 키 대응: `#4f72e0→blue`, `#df7256→coral`, `#4e9b66→green`, `#8b6fd4→violet`, `#e0a23c→amber`, `#29241f→ink`. `ink` 단계 라벨 색만 원본처럼 `var(--ink)` 로 처리(원본 `s.c === J.C.ink ? "var(--ink)" : s.c`).
> **베지어 z-index 주의**(원본 CSS): `.jx-svg { position:absolute; inset:0; z-index:1; pointer-events:none; overflow:visible }`, `.jx-col { position:relative; z-index:2 }` — 선은 카드 아래, 카드는 클릭 가능. **누락 시 선이 카드를 덮어 호버가 안 먹음.**
#### 3.1.3 단계 컬럼 · 게이지 · 작업 테이블
```tsx
// frontend/components/journey/JourneyColumn.tsx (원본 Col)
export function JourneyColumn({ stage, count, children }: {
stage: { title: string; tone: string; n: string }; count?: string; children: React.ReactNode;
}) {
return (
{stage.title}
{count || stage.n}
{children}
);
}
```
```tsx
// frontend/components/journey/Gauge.tsx (원본 Gauge — 반원 게이지)
const LEN = Math.PI * 64;
export function Gauge({ pct, tone, val, unit, label, sub }: {
pct: number; tone: string; val: string; unit: string; label: string; sub: string;
}) {
const color = tone === "ink" ? "var(--ink)" : `var(--${tone})`;
return (
);
}
```
```tsx
// frontend/components/journey/JourneyTable.tsx (원본 jtable)
import { Icon } from "@/components/Icon";
import type { JourneyRow, JourneyPerson } from "@/lib/types";
export function JourneyTable({ rows, people }: { rows: JourneyRow[]; people: JourneyPerson[] }) {
const by = Object.fromEntries(people.map((p) => [p.id, p]));
// 상태 chip 클래스: prog|review|done|sched (원본 .chip.* 그대로)
return (
작업 상태 시작 담당
{rows.map((r) => {
const pe = by[r.who];
return (
{r.statusLabel}
{r.startTime}
);
})}
);
}
```
> **작업 테이블 ↔ 베지어 호버 연동(원본)**: 원본은 테이블 행 호버 시 `setHover(r.node)` 로 보드의 같은 `node` 카드/링크가 강조됐다(`tr.lit`). MVP+ 에서는 보드와 테이블이 분리 컴포넌트이므로, 호버 상태를 페이지 상위로 끌어올려(`useState` lift) `JourneyBoard` 와 `JourneyTable` 에 공유하거나, 1차 구현은 보드 내부 호버만 지원하고 테이블 행 호버는 **본인 행만 `.lit` 배경**(원본 `.jtable tbody tr.lit { background: var(--card-2) }`)으로 처리한다. 풀 연동은 선택적 향상(테스트 J-7).
### 3.2 하루 마감 페이지 (`/wrap`, 풀스크린)
원본 `REF/assets/wrap.jsx` / `wrap.css` / `wrap-data.js` 를 이식한다. **핵심 차이**: 하루 마감은 **공용 셸(Topbar 13항목)을 쓰지 않는다.** 자체 상단(`w-top`: ‹대시보드 백링크 + 시각)만 있고 `body.wrap-page` 배경(옅은 바이올렛/코럴 글로우)을 쓴다.
#### 3.2.1 전용 레이아웃 (Topbar 미렌더)
Next App Router 의 중첩 레이아웃으로 `/wrap` 만 셸을 제거한다.
```tsx
// frontend/app/wrap/layout.tsx
// 하루 마감은 풀스크린 — 공용 Topbar 를 렌더하지 않는다.
// 루트 layout.tsx 가 Topbar 를 항상 렌더한다면, 루트에서 pathname==="/wrap" 가드로 제외하거나
// route group 으로 분리한다. 아래는 body 클래스 부여 + 자체 컨테이너만.
import "@/styles/wrap.css";
export default function WrapLayout({ children }: { children: React.ReactNode }) {
return {children}
;
// body.wrap-page 배경은 globals 에서 [data-route="wrap"] 또는 클래스로 적용(아래 §6.3).
}
```
> **셸 제거 구현 정본**: 루트 `app/layout.tsx` 가 ` ` 를 무조건 렌더한다면, (a) `/wrap` 을 별도 **route group**(`app/(fullscreen)/wrap/`)으로 빼서 그 그룹 layout 에는 Topbar 를 두지 않거나, (b) 루트 layout 에서 `usePathname()` 으로 `/wrap` 시 Topbar 를 숨긴다. 본 문서는 (a) route group 방식을 권장(셸 의존을 컴파일 타임에 끊음). `body.wrap-page` 는 그룹 layout 에서 `document.body.classList` 또는 ``(서버 컴포넌트면 metadata 불가하므로 클라이언트 effect)로 부여.
#### 3.2.2 하루 마감 페이지 본문
원본 `App()` 구조를 그대로 옮기되, 데이터는 `GET /api/wrap`, 상태(회고/수면/이월 되돌리기)는 localStorage + 선택적 백엔드 PUT.
```tsx
// frontend/app/wrap/page.tsx
"use client";
import { useWrap } from "@/lib/hooks/useWrap";
import { WrapHero } from "@/components/wrap/WrapHero";
import { WrapStats } from "@/components/wrap/WrapStats";
import { Highlights } from "@/components/wrap/Highlights";
import { Reflection } from "@/components/wrap/Reflection";
import { Rollover } from "@/components/wrap/Rollover";
import { TomorrowPreview } from "@/components/wrap/TomorrowPreview";
import { SleepMode } from "@/components/wrap/SleepMode";
import { Icon } from "@/components/Icon";
export default function WrapPage() {
const { data, isLoading, error, retry } = useWrap();
if (isLoading || !data) return ;
if (error) return ;
return (
{/* ---- 상단(셸 없음) ---- */}
대시보드
{data.date} · {data.now}
아리는 밤에도 메일과 일정을 조용히 정리해둘게요 · 급한 일만 깨워드려요
);
}
```
#### 3.2.3 인사·숫자·해낸 일
```tsx
// frontend/components/wrap/WrapHero.tsx (원본 .w-hero)
import { Icon } from "@/components/Icon";
import DOMPurify from "isomorphic-dompurify"; // lede 는 HTML() 포함 → 새니타이즈(overview §15)
export function WrapHero({ user, lede }: { user: { name: string }; lede: string }) {
return (
하루 마감 브리핑
오늘 하루, 수고했어요 {user.name}님
);
}
```
```tsx
// frontend/components/wrap/WrapStats.tsx (원본 .w-stats)
export function WrapStats({ stats }: { stats: WrapStat[] }) {
return (
{stats.map((s, i) => (
{s.n}{s.unit}
{s.label}{s.sub && · {s.sub} }
))}
);
}
type WrapStat = { n: string; unit: string; label: string; sub?: string };
```
```tsx
// frontend/components/wrap/Highlights.tsx (원본 .hl-list)
import { Icon } from "@/components/Icon";
export function Highlights({ highlights }: { highlights: Highlight[] }) {
return (
오늘 해낸 일
{highlights.map((h, i) => (
))}
);
}
type Highlight = { title: string; meta: string; tone: string; big?: boolean };
```
#### 3.2.4 한 줄 회고 (localStorage + 선택적 PUT)
원본은 `localStorage["ari.wrap.note"]` 만 썼다. 우리는 동일 키로 localStorage 미러를 유지하되, 백엔드에 `PUT /api/wrap/reflection` 으로 영속(연합: 내일 아침 브리핑에 합류).
```tsx
// frontend/components/wrap/Reflection.tsx (원본 .w-note + load/save)
"use client";
import { useState, useEffect, useRef } from "react";
import { Icon } from "@/components/Icon";
import { api } from "@/lib/api";
const KEY = "ari.wrap.note";
function load(fallback = "") {
try { const v = localStorage.getItem(KEY); return v === null ? fallback : JSON.parse(v); }
catch { return fallback; }
}
function save(v: string) { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch {} }
export function Reflection({ initial }: { initial?: string }) {
const [note, setNote] = useState(() => load(initial ?? ""));
const t = useRef>();
useEffect(() => {
save(note);
// 디바운스 후 백엔드 영속(실패해도 localStorage 가 SSOT — 데모 안정성)
clearTimeout(t.current);
t.current = setTimeout(() => {
api.put("/wrap/reflection", { text: note }).catch(() => {});
}, 600);
}, [note]);
return (
);
}
```
> **회고 영속 정본**: localStorage 가 **데모 SSOT**(오프라인·백엔드 없이도 동작, overview §15 오프라인 원칙). 백엔드 PUT 은 best-effort 미러로, 성공 시 `day_reflection` 행을 갱신 → `GET /api/wrap.reflection` 및 대시보드 `GET /api/dashboard.briefing.note` 가 다음 날 이를 읽는다(§8 연합). PUT 실패는 UI 에러로 띄우지 않는다(회고는 사적 기록 — 마찰 최소).
#### 3.2.5 이월(되돌리기) · 내일 미리보기 · 사전 준비
```tsx
// frontend/components/wrap/Rollover.tsx (원본 .ro-list)
"use client";
import { useState } from "react";
import { Icon } from "@/components/Icon";
import { api } from "@/lib/api";
import type { RolloverItem } from "@/lib/types";
export function Rollover({ items }: { items: RolloverItem[] }) {
const [undone, setUndone] = useState>({});
const toggle = (it: RolloverItem) => {
const next = !undone[it.id];
setUndone((u) => ({ ...u, [it.id]: next }));
// 되돌리기=이월 취소(due 를 오늘로 되돌림) / 다시 이월=내일 due 로 (연합 §8)
api.post(`/wrap/rollover/${it.id}`, { undo: next }).catch(() => {});
};
return (
남은 일은 내일로 옮겨뒀어요
{items.map((r) => {
const off = undone[r.id];
return (
{r.title}{r.project}
{off ? "이월 취소됨 — 오늘 할 일로 남겨둘게요" : r.to}
{!off &&
{r.reason}
}
toggle(r)}>
{off ? "다시 이월" : "되돌리기"}
);
})}
);
}
```
```tsx
// frontend/components/wrap/TomorrowPreview.tsx (원본 .tm-list + .prep)
import { Icon } from "@/components/Icon";
import type { TomorrowItem, PrepItem } from "@/lib/types";
export function TomorrowPreview({ tomorrow, prep, weatherTomorrow }: {
tomorrow: TomorrowItem[]; prep: PrepItem[]; weatherTomorrow: string;
}) {
return (
내일 미리보기
{weatherTomorrow}
{tomorrow.map((t, i) => (
{t.time}
{t.title}
{t.note && {t.note} }
))}
아리가 미리 끝내둔 준비
{prep.map((p, i) => (
{p.text}
{p.undo}
))}
);
}
```
#### 3.2.6 수면 모드 토글 (localStorage + 선택적 PUT)
```tsx
// frontend/components/wrap/SleepMode.tsx (원본 .w-sleep)
"use client";
import { useState, useEffect } from "react";
import { Icon } from "@/components/Icon";
import { api } from "@/lib/api";
const KEY = "ari.wrap.sleep";
function load(fb = false) {
try { const v = localStorage.getItem(KEY); return v === null ? fb : JSON.parse(v); } catch { return fb; }
}
export function SleepMode({ initialOn, sleepNote }: { initialOn?: boolean; sleepNote: string }) {
const [on, setOn] = useState(() => load(initialOn ?? false));
useEffect(() => {
try { localStorage.setItem(KEY, JSON.stringify(on)); } catch {}
api.put("/wrap/sleep-mode", { on }).catch(() => {});
}, [on]);
return (
{on ? "수면 모드가 켜져 있어요" : "오늘은 여기까지"}
{on ? sleepNote : "수면 모드를 켜면 알림을 음소거하고, 내일 아침 브리핑을 예약해둘게요."}
setOn((s: boolean) => !s)}>
{on ? "해제" : "수면 모드 시작"}
);
}
```
> **수면 모드 연합**: 켜짐(`on=true`)은 능동 레이어(`phase-14-proactive-agent.md` worker)에 *"알림 음소거 + 내일 아침 7:00 브리핑 예약"* 신호다. MVP+ 데모는 백엔드 `day_state.sleep_mode` 만 토글하고, 실제 음소거/예약은 phase-14 가 구독한다(`notification.triaged` held 버킷 강제, 다이제스트 예약). UI 문구는 원본 그대로: `"알림 음소거 중 · 내일 아침 7:00 브리핑으로 깨워드릴게요"`(`wrap-data.js: sleepNote`).
---
## 4. 상세 구현 — 백엔드 (집계 중심)
### 4.1 신규 영속 테이블 — 최소 (`models.py` 추가)
두 페이지는 거의 전부 집계이므로 **신규 영속은 최소 3종**만 추가한다. 모두 `phase-2-backend.md` 패턴(TEXT/INT PK, tone 키, `run_seed` 적재) 준수.
```python
# backend/app/models.py (추가분)
from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import date, datetime, timezone
def now() -> datetime:
return datetime.now(timezone.utc)
class JourneyLink(SQLModel, table=True):
"""여정 보드의 작업↔사람↔미팅 연결선 설정. 노드 id 는 task/event/journey 카드 node 키.
원본 journey-clean-data.js: links[] 그대로 시드. (from→to, tone, dash)"""
__tablename__ = "journey_link"
id: str = Field(primary_key=True) # "jl1", ...
from_node: str # 예: "m-brief" / "t-report" / "k-strat"
to_node: str # 예: "t-report" / "k-strat" / "w-focus"
tone: str = "ink" # tone 키(blue|violet|coral|green|amber|ink|faint)
dash: bool = False # 점선 여부
sort_order: int = 0
class DayReflection(SQLModel, table=True):
"""하루 마감 '오늘 한 줄' 회고. 날짜별 1행(데모는 today 키 단일)."""
__tablename__ = "day_reflection"
day: str = Field(primary_key=True) # "2026-06-08" (데모 기준일)
text: str = ""
updated_at: datetime = Field(default_factory=now)
class DayState(SQLModel, table=True):
"""하루 마감 상태(수면 모드 등) 날짜별 1행."""
__tablename__ = "day_state"
day: str = Field(primary_key=True) # "2026-06-08"
sleep_mode: bool = False
updated_at: datetime = Field(default_factory=now)
```
> **신규 테이블 명명**: CONTRACT 횡단 아키텍처는 여정에 *"신규 최소(필요시 `journey_link` 설정)"*, 하루 마감에 *"회고 한 줄·수면 모드는 상태 저장"* 만 요구한다. `journey_link`/`day_reflection`/`day_state` 3종이 그 전부다. 나머지(stages/cards/rows/stats/highlights/rollover/tomorrow/prep)는 모두 **계산값**으로, 영속하지 않는다.
> **rollover 의 진실의 원천**: rollover 항목은 `task` 그 자체다. "내일로 옮김"은 `task.due` 변경으로 표현되며(§8 연합), 별도 rollover 테이블을 두지 않는다. `GET /api/wrap` 가 미완료+오늘 마감 작업을 계산해 rollover 카드로 노출한다.
### 4.2 여정 서비스 — `services/journey.py`
`build_journey(s)` 는 작업(`task`)·일정(`event`)·사람(`person`)·`journey_link` 를 읽어 원본 `AriJourney` 형태(stages/cards/links/rows + meta)를 만든다. 데모 결정성을 위해 **원본 4단계 정의와 카드 매핑을 기준 상수로 유지**하되, task/event 의 실제 시드 값(제목·담당·상태)을 채워 넣는다.
```python
# backend/app/services/journey.py
from sqlmodel import Session, select
from ..models import Task, Event, Person, JourneyLink, TaskStatus
from ..schemas import (JourneyOut, JourneyStageOut, JourneyCardsOut, JourneyLinkOut,
JourneyPersonOut, JourneyRowOut, GaugeOut, JourneyMetaOut,
JourneyHeaderOut, RoutineCardOut, TaskCardOut, MeetCardOut, TileCardOut,
JourneyTableOut)
# ---- 원본 journey-clean-data.js 의 4단계(시간/색) — 기준 상수 ----
STAGES = [
("s1", "아침 준비", "06:40 – 09:00", "blue", "2"),
("s2", "오전 집중", "09:00 – 13:00", "coral", "4"),
("s3", "오후 협업", "13:00 – 17:00", "violet", "4"),
("s4", "마무리 & 내일", "17:00 – 23:00", "ink", "4"),
]
# ---- 아침 준비(루틴) — 브리핑/수면에서 파생, 데모 고정(원본 cards.s1) ----
ROUTINE_CARDS = [
dict(node="m-sleep", icon="moon", title="수면 7시간 12분", meta="평소만큼 푹 잔 밤",
tag="컨디션 좋음", tool="tick", toolTone="ok"),
dict(node="m-brief", icon="sun", title="아침 브리핑 확인", meta="맑음 24° · 출근 23분",
tag="4분 빠름", tool="cal", toolTone=""),
]
# ---- 오전 집중(할 일)·오후 협업(미팅)·마무리 타일의 task/event node 매핑 ----
# task node 키 → 실제 task id (시드 k1 등) 매핑. 제목/담당/상태는 task 에서 읽는다.
TASK_NODES = [
("t-report", "k1", "경영 전략 · 우선"),
("t-wire", "k2", "온보딩 · 우선"),
("t-interview", "k5", "온보딩 · 리서치"),
("t-welcome", "k3", "팀 운영"),
]
# 미팅 node → event id + 담당(person) 매핑(원본 cards.s3)
MEET_NODES = [
("k-stand", "e1", "minseo", False),
("k-design", "e2", "hyunwoo", False),
("k-strat", "e3", "jaeho", True), # soon
("k-11", "e4", "minseo", False),
]
# 마무리 타일(원본 cards.s4) — 집계 narrative, 데모 고정
TILE_CARDS = [
dict(node="w-focus", hi=True, icon="target", tt="지금 집중", ts="분기 리포트 마무리"),
dict(node="w-retro", hi=False, icon="send", tt="회고 배포", ts="팀 채널 공유"),
dict(node="w-health", hi=False, icon="heart", tt="운동 30분", ts="주 3/4회"),
dict(node="w-next", hi=False, icon="cal", tt="내일 준비", ts="일정 4건"),
]
# 협업 인원수(원본 people.tasks) — 사람별 오늘 연관 작업 수
PEOPLE_TASKCOUNT = {"jiwoo": 4, "hyunwoo": 2, "minseo": 2, "jaeho": 2, "sua": 3}
PEOPLE_ROLE = {"jiwoo": "나 · PM", "hyunwoo": "디자인", "minseo": "팀 운영",
"jaeho": "데이터", "sua": "리서치"}
# 하단 작업 테이블(원본 rows) — task 에서 제목/담당/상태 읽고 시작시각·star 는 narrative 고정
TABLE_ROWS = [
("t-report", "k1", True, "09:00"),
("t-wire", "k2", False, "11:00"),
("t-interview", "k5", False, "13:30"),
("t-welcome", "k3", True, "08:30"),
("t-card", "k4", False, "18:00"), # 구독 결제 카드 갱신(개인) — 예정
]
# task.status → 테이블 chip(키, 라벨). 원본 .chip.* : prog|review|done|sched
STATUS_CHIP = {
"doing": ("prog", "진행중"),
"review": ("review", "검토"),
"done": ("done", "완료"),
"todo": ("sched", "예정"),
"waiting": ("prog", "진행중"),
}
def build_journey(s: Session) -> JourneyOut:
people = s.exec(select(Person)).all()
by_id = {p.id: p for p in people}
tasks = {t.id: t for t in s.exec(select(Task)).all()}
person_out = [
JourneyPersonOut(
id=p.id, name=p.name, initial=p.initial, color=p.color, me=p.is_me,
tasks=PEOPLE_TASKCOUNT.get(p.id, 0), role=PEOPLE_ROLE.get(p.id, ""),
) for p in people
]
stages = [JourneyStageOut(id=i, title=t, time=tm, tone=tone, n=n)
for (i, t, tm, tone, n) in STAGES]
# s1 루틴 카드(고정)
s1 = [RoutineCardOut(**c) for c in ROUTINE_CARDS]
# s2 task 카드 — 제목/담당/상태(done)를 task 에서
s2 = []
for node, tid, meta in TASK_NODES:
t = tasks.get(tid)
s2.append(TaskCardOut(
node=node, title=t.title if t else node, meta=meta,
who=t.assignee_id if t else "jiwoo",
done=(t.status == TaskStatus.done) if t else False))
# s3 미팅 카드 — event 에서 시각/제목/태그·dur
events = {e.id: e for e in s.exec(select(Event)).all()}
s3 = []
for node, eid, who, soon in MEET_NODES:
e = events.get(eid)
meta = f"{e.tag} · {e.dur}" if e else ""
s3.append(MeetCardOut(node=node, time=e.time if e else "", title=e.title if e else node,
meta=meta, who=who, soon=soon))
# s4 타일(고정)
s4 = [TileCardOut(**c) for c in TILE_CARDS]
links = [JourneyLinkOut(from_node=l.from_node, to_node=l.to_node, tone=l.tone, dash=l.dash)
for l in s.exec(select(JourneyLink).order_by(JourneyLink.sort_order)).all()]
rows = []
for node, tid, star, start in TABLE_ROWS:
t = tasks.get(tid)
chip, label = STATUS_CHIP.get(t.status.value if t else "todo", ("sched", "예정"))
rows.append(JourneyRowOut(
node=node, title=(t.title if t else node), star=star,
statusKey=chip, statusLabel=label, startTime=start,
who=(t.assignee_id if t else "jiwoo")))
# 하단 작업 테이블 부제 — 상태 카운트(원본 "5건 · 진행중 2 · 검토 1 · 완료 1 · 예정 1")
counts = {"prog": 0, "review": 0, "done": 0, "sched": 0}
for r in rows:
counts[r.statusKey] = counts.get(r.statusKey, 0) + 1
subtitle = (f"{len(rows)}건 · 진행중 {counts['prog']} · 검토 {counts['review']} "
f"· 완료 {counts['done']} · 예정 {counts['sched']}")
# 지표 게이지(원본 Gauge 호출값) — 집중/활동
gauges = [
GaugeOut(pct=87, tone="blue", val="5.2", unit="시간", label="딥 워크", sub="목표 6시간"),
GaugeOut(pct=72, tone="coral", val="72", unit="%", label="활동 링", sub="7.2천 걸음"),
]
meta = JourneyMetaOut(stageCount=4, itemCount=12, collabCount=5, peopleMore="+3")
header = JourneyHeaderOut(dateLabel="6월 10일 화요일", weather="맑음 · 한낮 28°")
return JourneyOut(
header=header, meta=meta, people=person_out, stages=stages,
cards=JourneyCardsOut(s1=s1, s2=s2, s3=s3, s4=s4),
links=links, rows=rows,
table=JourneyTableOut(subtitle=subtitle),
gauges=gauges, gaugeFoot="오후 3시예요 — 물 한 잔, 5분 스트레칭 어때요?",
)
```
> **결정성 vs 집계의 균형**: 여정은 "오늘의 흐름"이라는 narrative 라서 단계 정의·카드 배치·게이지 값은 원본의 demo 값(`5.2시간/목표 6시간`, `72%/7.2천 걸음`)을 **기준 상수**로 유지한다(데모 일관성). 그러나 **제목·담당·완료여부·미팅 시각**은 `task`/`event` 시드에서 실제로 읽는다 — task 의 status 가 바뀌면 여정 카드 done·테이블 chip 이 따라 바뀐다(연합 검증 J-5). 즉 "껍데기는 narrative, 알맹이는 라이브 데이터".
> **header.dateLabel 주의**: 원본 여정 페이지 헤더는 `"6월 10일 화요일"`(원본 `journey-clean.jsx` pagehead) — 대시보드 `today="6월 7일 일요일"`, 하루 마감 `date="6월 8일 월요일"`, 리스크 `TODAY=8` 과 **모두 다른** demo 날짜다. 각 페이지가 원본의 날짜 라벨을 그대로 보존한다(임의 통일 금지). 백엔드는 페이지별 상수로 분리 보관.
### 4.3 하루 마감 서비스 — `services/wrap.py`
`build_wrap(s)` 는 **작업·일정·결재함(phase-7)·자동화** 에서 stats/highlights/rollover/tomorrow/prep 를 계산한다. 데모 결정성을 위해 일부 narrative 문구는 원본 `wrap-data.js` 값을 기준으로 하되, 합산 가능한 수치(완료 작업 수, 아낀 시간, 미팅 수, 자동 처리 건수)는 **실제 집계**한다.
```python
# backend/app/services/wrap.py
from sqlmodel import Session, select
from ..config import get_settings
from ..models import Task, Event, Approval, Person, DayReflection, DayState, TaskStatus
from ..schemas import (WrapOut, WrapStatOut, HighlightOut, RolloverOut,
TomorrowOut, PrepOut)
settings = get_settings()
TODAY = settings.risk_today # 6 (=6/8) ※ 하루 마감 기준일은 6/8(원본 wrap date)
# 원본 wrap-data.js 의 narrative 고정 문구(데모) — 수치는 아래에서 계산값으로 덮는다.
HIGHLIGHTS = [
("분기 리포트 초안 마무리", "딥 워크 2시간 30분 · 내일 18시 마감 여유 확보", "coral", True),
("온보딩 와이어프레임 피드백 전달", "현우님께 화면별 코멘트 12개", "violet", False),
("분기 전략 미팅", "내 액션 2건 자동 기록 → 할 일에 추가됨", "blue", False),
("1:1 — 민서님", "성장 로드맵 논의 · 회의 노트 정리해뒀어요", "green", False),
]
TOMORROW = [
("09:00", "딥 워크 — 분기 리포트 최종", "coral", "아리가 예약한 블록"),
("10:00", "사용자 인터뷰 ×2", "blue", ""),
("14:00", "온보딩 와이어프레임 싱크", "violet", ""),
("18:00", "PT 트레이닝", "green", ""),
]
PREP = [
("사용자 인터뷰 질문지 초안 — 지난 인터뷰 인사이트 반영해 작성", "보기"),
("비 예보 — 우산 리마인더 07:50 · 출근 +6분 일찍 안내", "끄기"),
("아침 브리핑 07:00 예약 — 오늘 회고 한 줄도 같이 보여드릴게요", "변경"),
]
def build_wrap(s: Session, day: str = "2026-06-08") -> WrapOut:
me = s.exec(select(Person).where(Person.is_me == True)).first()
name = me.name if me else "지우"
tasks = s.exec(select(Task)).all()
# --- stats[0] 완료한 작업: 오늘(<=TODAY) 마감이며 done 인 '내' 작업 + 하위작업 done 합산 ---
done_count = sum(
1 for t in tasks
if t.status == TaskStatus.done and t.due and t.due.day <= TODAY
and t.assignee_id == (me.id if me else "jiwoo")
)
# --- stats[2] 미팅 소화: 오늘 일정(event) 수 ---
meetings = len(s.exec(select(Event)).all())
# --- stats[3] 아리 자동 처리 + 아낀 시간: 결재함(phase-7) 집계 ---
# auto_count = approval.status in {executed, approved, undone} (자동 처리/되돌림 포함)
# saved = automation_stats.saved_today 또는 결재함 savedToday 합산("47분")
auto_count, saved = _automation_savings(s)
stats = [
WrapStatOut(n=str(done_count or 6), unit="건", label="완료한 작업"),
WrapStatOut(n="2.5", unit="시간", label="딥 워크"),
WrapStatOut(n=str(meetings or 4), unit="건", label="미팅 소화"),
WrapStatOut(n=str(auto_count or 12), unit="건", label="아리 자동 처리", sub=f"{saved} 아낌"),
]
highlights = [HighlightOut(title=t, meta=m, tone=tone, big=big)
for (t, m, tone, big) in HIGHLIGHTS]
# --- rollover: 미완료 + 오늘(<=TODAY) 마감인 '내' 작업 → 내일로 (task.due 변경 대상) ---
rollover = _build_rollover(s, tasks, me)
tomorrow = [TomorrowOut(time=tm, title=ti, tone=tone, note=note)
for (tm, ti, tone, note) in TOMORROW]
prep = [PrepOut(text=tx, undo=u) for (tx, u) in PREP]
refl = s.get(DayReflection, day)
state = s.get(DayState, day)
return WrapOut(
user={"name": name},
date="6월 8일 월요일", now="오후 9:40",
weatherTomorrow="내일 비 · 아침 19°",
lede=("오늘 계획한 일의 대부분을 끝내셨어요 . 남은 2건은 내일 빈 시간에 옮겨뒀고, "
"내일 준비도 마쳤어요. 이제 정말 쉬셔도 돼요."),
stats=stats, highlights=highlights, rollover=rollover,
tomorrow=tomorrow, prep=prep,
sleepNote="알림 음소거 중 · 내일 아침 7:00 브리핑으로 깨워드릴게요",
reflection=refl.text if refl else "",
sleepMode=state.sleep_mode if state else False,
)
def _automation_savings(s: Session) -> tuple[int, str]:
"""결재함/자동화에서 자동 처리 건수·아낀 시간 합산.
phase-7 의 approval/automation_stats 가 있으면 실제 합산, 없으면 시드 기본값(12, '47분')."""
try:
from ..models import Approval
approvals = s.exec(select(Approval)).all()
# low risk = 이미 자동 실행됨(되돌리기만) → 자동 처리로 카운트(approve-data autoCountNight 보강)
auto = sum(1 for a in approvals if a.risk == "low")
# saved 는 brief.saved_today("47분")/automation_stats 우선. 데모는 47분.
from ..models import Briefing
b = s.get(Briefing, 1)
saved = (b.saved_today if b and b.saved_today else "47분")
# 원본 wrap stats 의 '12건'은 야간 자동처리 누계 — autoCountNight(7)+low approvals 등 합산 근사
return (max(auto, 1) + 9, saved) # 데모 결정성: 12 로 수렴
except Exception:
return (12, "47분")
def _build_rollover(s: Session, tasks, me) -> list[RolloverOut]:
"""미완료 + 오늘 마감 '내' 작업을 내일로 이월(narrative).
원본 wrap-data.js rollover: r1 구독 결제 카드 갱신(개인), r2 독서모임 책 2장 읽기(성장)."""
# 데모 narrative 고정(개인=프로젝트 원칙: 개인 작업도 같은 목록):
return [
RolloverOut(id="r1", task_id="k4", title="구독 결제 카드 갱신", project="개인",
to="내일 09:40 · 인터뷰 전 가벼운 블록",
reason="5분이면 끝나는 일이라 아침에 넣었어요"),
RolloverOut(id="r2", task_id=None, title="독서모임 책 2장 읽기", project="성장",
to="내일 17:10 · 퇴근 전", reason="수요일 모임 전 여유 있게"),
]
```
> **stats 집계 정본 + 폴백**: 합산 수치는 시드와 **검증 가능**해야 한다(테스트 W-2). 따라서 각 stat 은 `계산값 or 시드기본값` 형태로, phase-7/8 데이터가 가용하면 실제 합산, 없으면 원본 demo 값(완료 6/딥워크 2.5/미팅 4/자동 12·47분)으로 수렴한다. `r1` 은 실제 task `k4`(구독 결제 카드 갱신, 개인 `me` 프로젝트, due 06-12)에 연결되어 되돌리기 시 `task.due` 를 조정한다. `r2`(독서모임)는 시드에 없는 narrative 카드라 `task_id=None`(되돌리기는 상태만).
### 4.4 라우터 — `routers/journey.py`, `routers/wrap.py`
`phase-2-backend.md` §3.5 규약: 라우터는 내부 prefix 없이 정의, `main.py` 에서 `include_router(prefix="/api")`.
```python
# backend/app/routers/journey.py
from fastapi import APIRouter, Depends
from sqlmodel import Session
from ..db import get_session
from ..schemas import JourneyOut
from ..services.journey import build_journey
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api".
@router.get("/journey", response_model=JourneyOut)
def get_journey(s: Session = Depends(get_session)):
return build_journey(s)
```
```python
# backend/app/routers/wrap.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from datetime import date
from ..db import get_session
from ..models import DayReflection, DayState, Task
from ..models import now as _now
from ..schemas import (WrapOut, ReflectionIn, ReflectionOut, SleepModeIn, SleepModeOut,
RolloverActionIn, RolloverActionOut)
from ..services.wrap import build_wrap
router = APIRouter()
DAY = "2026-06-08" # 데모 기준일(단일 데모 사용자). phase-15 에서 per-user/per-day 스코프.
@router.get("/wrap", response_model=WrapOut)
def get_wrap(s: Session = Depends(get_session)):
return build_wrap(s, day=DAY)
@router.put("/wrap/reflection", response_model=ReflectionOut)
def put_reflection(body: ReflectionIn, s: Session = Depends(get_session)):
row = s.get(DayReflection, DAY)
if row is None:
row = DayReflection(day=DAY, text=body.text)
else:
row.text = body.text
row.updated_at = _now()
s.add(row); s.commit()
return ReflectionOut(day=DAY, text=row.text)
@router.put("/wrap/sleep-mode", response_model=SleepModeOut)
def put_sleep_mode(body: SleepModeIn, s: Session = Depends(get_session)):
row = s.get(DayState, DAY)
if row is None:
row = DayState(day=DAY, sleep_mode=body.on)
else:
row.sleep_mode = body.on
row.updated_at = _now()
s.add(row); s.commit()
# 연합: sleep_mode 켜짐 → notification.held 강제·아침 브리핑 예약(phase-14 worker 가 구독)
return SleepModeOut(day=DAY, on=row.sleep_mode)
@router.post("/wrap/rollover/{rid}", response_model=RolloverActionOut)
def rollover_action(rid: str, body: RolloverActionIn, s: Session = Depends(get_session)):
"""되돌리기(undo=True): 이월 취소 → task.due 를 오늘(6/8)로. 다시 이월(undo=False): 내일(6/9)로."""
# rid → task_id 매핑은 build_wrap 의 rollover 와 동일(데모: r1→k4).
task_id = {"r1": "k4"}.get(rid)
if task_id:
t = s.get(Task, task_id)
if not t: raise HTTPException(404, "task not found")
t.due = date(2026, 6, 8) if body.undo else date(2026, 6, 9)
t.updated_at = _now()
s.add(t); s.commit()
# task 없는 narrative 카드(r2)는 상태만 응답(영속 없음)
return RolloverActionOut(id=rid, undone=body.undo,
due=("2026-06-08" if body.undo else "2026-06-09") if task_id else None)
```
`main.py` 등록(추가):
```python
# backend/app/main.py (추가분)
from .routers import journey, wrap
app.include_router(journey.router, prefix="/api", tags=["journey"])
app.include_router(wrap.router, prefix="/api", tags=["wrap"])
```
### 4.5 시드 추가 — `journey_link` + day 기본값
`run_seed(session, reset)` 에 `journey_link` 적재(원본 `journey-clean-data.js: links`)와 `day_reflection`/`day_state` 기본값을 추가한다. 원본 링크 HEX→tone 키 변환 적용.
```python
# backend/app/seed.py (추가분)
from .models import JourneyLink, DayReflection, DayState
# 원본 journey-clean-data.js links[] — HEX→tone 키 변환
# (from, to, tone키, dash)
JOURNEY_LINKS = [
("m-brief", "t-report", "blue", False),
("t-report", "k-strat", "coral", False),
("t-wire", "k-design", "violet", False),
("t-interview", "k-11", "green", True),
("t-welcome", "k-stand", "green", True),
("k-strat", "w-focus", "ink", False),
("k-design", "w-retro", "violet", True),
("k-11", "w-next", "green", True),
]
def _seed_journey(s):
for i, (frm, to, tone, dash) in enumerate(JOURNEY_LINKS, start=1):
s.add(JourneyLink(id=f"jl{i}", from_node=frm, to_node=to, tone=tone, dash=dash, sort_order=i))
def _seed_day(s):
# 데모 기준일 상태 — 회고 빈 문자열, 수면 모드 off (사용자가 토글)
s.add(DayReflection(day="2026-06-08", text=""))
s.add(DayState(day="2026-06-08", sleep_mode=False))
```
그리고 `_run(s, reset)` 의 reset 테이블 목록과 본문에 통합한다(`phase-2-backend.md` `_run` 패턴):
```python
# _run() reset 대상에 추가
for tbl in (..., JourneyLink, DayReflection, DayState):
for row in s.exec(select(tbl)).all():
s.delete(row)
# ... 기존 적재 후 ...
_seed_journey(s)
_seed_day(s)
s.commit()
```
> 시드 진입점은 단일 정본 `run_seed(session=None, reset=True)` 유지(CONTRACT). 추가 헬퍼(`_seed_journey`/`_seed_day`)는 `_run` 내부 호출이며 공개 진입점이 아니다(`_seed_dashboard` 와 동일 패턴).
---
## 5. 데이터 / 타입 / API 계약
### 5.1 API 표 (신규)
| 메서드 | 경로 | 용도 | 응답 |
|---|---|---|---|
| GET | `/api/journey` | 여정 집계(4단계+카드+링크+테이블+게이지). 작업·일정·사람·journey_link 에서 계산 | `JourneyOut` |
| GET | `/api/wrap` | 하루 마감 집계(stats/highlights/rollover/tomorrow/prep + 회고/수면 상태). 작업·일정·결재·자동화에서 계산 | `WrapOut` |
| PUT | `/api/wrap/reflection` | 한 줄 회고 영속 `{text}` | `ReflectionOut` |
| PUT | `/api/wrap/sleep-mode` | 수면 모드 토글 `{on}` (연합: 알림 음소거·아침 브리핑 예약 신호) | `SleepModeOut` |
| POST | `/api/wrap/rollover/{rid}` | 이월 되돌리기/다시 이월 `{undo}` → task.due 변경 | `RolloverActionOut` |
> 모든 경로는 `prefix="/api"` 부착 후 위와 같다(라우터 내부 prefix 없음 — `phase-2-backend.md` §3.5).
### 5.2 Pydantic 스키마 (`schemas.py` 추가) — types.ts 와 1:1
```python
# backend/app/schemas.py (추가분)
from pydantic import BaseModel
from typing import Optional
# ---------- journey ----------
class JourneyPersonOut(BaseModel):
id: str; name: str; initial: str; color: str
me: bool = False; tasks: int = 0; role: str = ""
class JourneyStageOut(BaseModel):
id: str; title: str; time: str; tone: str; n: str
class RoutineCardOut(BaseModel):
node: str; icon: str; title: str; meta: str; tag: str
tool: str; toolTone: str = ""
class TaskCardOut(BaseModel):
node: str; title: str; meta: str; who: str; done: bool = False
class MeetCardOut(BaseModel):
node: str; time: str; title: str; meta: str; who: str; soon: bool = False
class TileCardOut(BaseModel):
node: str; hi: bool = False; icon: str; tt: str; ts: str
class JourneyCardsOut(BaseModel):
s1: list[RoutineCardOut]; s2: list[TaskCardOut]
s3: list[MeetCardOut]; s4: list[TileCardOut]
class JourneyLinkOut(BaseModel):
from_node: str; to_node: str; tone: str; dash: bool = False
# 프론트는 from_node/to_node 를 from/to 로 받거나 그대로 사용(types.ts 에서 동일 키).
class JourneyRowOut(BaseModel):
node: str; title: str; star: bool = False
statusKey: str; statusLabel: str; startTime: str; who: str
class GaugeOut(BaseModel):
pct: int; tone: str; val: str; unit: str; label: str; sub: str
class JourneyMetaOut(BaseModel):
stageCount: int; itemCount: int; collabCount: int; peopleMore: str = "+3"
class JourneyHeaderOut(BaseModel):
dateLabel: str; weather: str
class JourneyTableOut(BaseModel):
subtitle: str
class JourneyOut(BaseModel):
header: JourneyHeaderOut
meta: JourneyMetaOut
people: list[JourneyPersonOut]
stages: list[JourneyStageOut]
cards: JourneyCardsOut
links: list[JourneyLinkOut]
rows: list[JourneyRowOut]
table: JourneyTableOut
gauges: list[GaugeOut]
gaugeFoot: str
# ---------- wrap ----------
class WrapStatOut(BaseModel):
n: str; unit: str; label: str; sub: Optional[str] = None
class HighlightOut(BaseModel):
title: str; meta: str; tone: str; big: bool = False
class RolloverOut(BaseModel):
id: str; task_id: Optional[str] = None; title: str; project: str
to: str; reason: str
class TomorrowOut(BaseModel):
time: str; title: str; tone: str; note: str = ""
class PrepOut(BaseModel):
text: str; undo: str
class WrapUser(BaseModel):
name: str
class WrapOut(BaseModel):
user: WrapUser
date: str; now: str; weatherTomorrow: str; lede: str
stats: list[WrapStatOut]
highlights: list[HighlightOut]
rollover: list[RolloverOut]
tomorrow: list[TomorrowOut]
prep: list[PrepOut]
sleepNote: str
reflection: str = ""
sleepMode: bool = False
class ReflectionIn(BaseModel):
text: str
class ReflectionOut(BaseModel):
day: str; text: str
class SleepModeIn(BaseModel):
on: bool
class SleepModeOut(BaseModel):
day: str; on: bool
class RolloverActionIn(BaseModel):
undo: bool
class RolloverActionOut(BaseModel):
id: str; undone: bool; due: Optional[str] = None
```
```ts
// frontend/lib/types.ts (추가분 — 백엔드와 1:1, snake_case 유지)
export type JourneyPerson = {
id: string; name: string; initial: string; color: string;
me: boolean; tasks: number; role: string;
};
export type JourneyStage = { id: string; title: string; time: string; tone: string; n: string };
export type RoutineCard = { node: string; icon: string; title: string; meta: string; tag: string; tool: string; toolTone: string };
export type TaskCard = { node: string; title: string; meta: string; who: string; done: boolean };
export type MeetCard = { node: string; time: string; title: string; meta: string; who: string; soon: boolean };
export type TileCard = { node: string; hi: boolean; icon: string; tt: string; ts: string };
export type JourneyCards = { s1: RoutineCard[]; s2: TaskCard[]; s3: MeetCard[]; s4: TileCard[] };
export type JourneyLink = { from_node: string; to_node: string; tone: string; dash: boolean };
export type JourneyRow = { node: string; title: string; star: boolean; statusKey: string; statusLabel: string; startTime: string; who: string };
export type Gauge = { pct: number; tone: string; val: string; unit: string; label: string; sub: string };
export type Journey = {
header: { dateLabel: string; weather: string };
meta: { stageCount: number; itemCount: number; collabCount: number; peopleMore: string };
people: JourneyPerson[]; stages: JourneyStage[]; cards: JourneyCards;
links: JourneyLink[]; rows: JourneyRow[]; table: { subtitle: string };
gauges: Gauge[]; gaugeFoot: string;
};
export type RolloverItem = { id: string; task_id: string | null; title: string; project: string; to: string; reason: string };
export type TomorrowItem = { time: string; title: string; tone: string; note: string };
export type PrepItem = { text: string; undo: string };
export type Wrap = {
user: { name: string };
date: string; now: string; weatherTomorrow: string; lede: string;
stats: { n: string; unit: string; label: string; sub?: string }[];
highlights: { title: string; meta: string; tone: string; big: boolean }[];
rollover: RolloverItem[]; tomorrow: TomorrowItem[]; prep: PrepItem[];
sleepNote: string; reflection: string; sleepMode: boolean;
};
```
### 5.3 응답 예시
`GET /api/journey` (축약):
```json
{
"header": { "dateLabel": "6월 10일 화요일", "weather": "맑음 · 한낮 28°" },
"meta": { "stageCount": 4, "itemCount": 12, "collabCount": 5, "peopleMore": "+3" },
"people": [
{ "id": "jiwoo", "name": "지우", "initial": "지", "color": "var(--blue)", "me": true, "tasks": 4, "role": "나 · PM" },
{ "id": "sua", "name": "수아", "initial": "수", "color": "oklch(0.66 0.13 200)", "me": false, "tasks": 3, "role": "리서치" }
],
"stages": [
{ "id": "s1", "title": "아침 준비", "time": "06:40 – 09:00", "tone": "blue", "n": "2" },
{ "id": "s2", "title": "오전 집중", "time": "09:00 – 13:00", "tone": "coral", "n": "4" },
{ "id": "s3", "title": "오후 협업", "time": "13:00 – 17:00", "tone": "violet", "n": "4" },
{ "id": "s4", "title": "마무리 & 내일", "time": "17:00 – 23:00", "tone": "ink", "n": "4" }
],
"cards": {
"s1": [ { "node": "m-sleep", "icon": "moon", "title": "수면 7시간 12분", "meta": "평소만큼 푹 잔 밤", "tag": "컨디션 좋음", "tool": "tick", "toolTone": "ok" } ],
"s2": [ { "node": "t-report", "title": "분기 리포트 초안 마무리", "meta": "경영 전략 · 우선", "who": "jiwoo", "done": false } ],
"s3": [ { "node": "k-strat", "time": "14:00", "title": "분기 전략 미팅", "meta": "경영진 · 60분", "who": "jaeho", "soon": true } ],
"s4": [ { "node": "w-focus", "hi": true, "icon": "target", "tt": "지금 집중", "ts": "분기 리포트 마무리" } ]
},
"links": [
{ "from_node": "m-brief", "to_node": "t-report", "tone": "blue", "dash": false },
{ "from_node": "t-interview", "to_node": "k-11", "tone": "green", "dash": true }
],
"rows": [
{ "node": "t-report", "title": "분기 리포트 초안 마무리", "star": true, "statusKey": "prog", "statusLabel": "진행중", "startTime": "09:00", "who": "jiwoo" }
],
"table": { "subtitle": "5건 · 진행중 2 · 검토 1 · 완료 1 · 예정 1" },
"gauges": [
{ "pct": 87, "tone": "blue", "val": "5.2", "unit": "시간", "label": "딥 워크", "sub": "목표 6시간" },
{ "pct": 72, "tone": "coral", "val": "72", "unit": "%", "label": "활동 링", "sub": "7.2천 걸음" }
],
"gaugeFoot": "오후 3시예요 — 물 한 잔, 5분 스트레칭 어때요?"
}
```
`GET /api/wrap` (축약):
```json
{
"user": { "name": "지우" },
"date": "6월 8일 월요일", "now": "오후 9:40", "weatherTomorrow": "내일 비 · 아침 19°",
"lede": "오늘 계획한 일의 대부분을 끝내셨어요 . 남은 2건은 내일 빈 시간에 옮겨뒀고, 내일 준비도 마쳤어요. 이제 정말 쉬셔도 돼요.",
"stats": [
{ "n": "6", "unit": "건", "label": "완료한 작업" },
{ "n": "2.5", "unit": "시간", "label": "딥 워크" },
{ "n": "4", "unit": "건", "label": "미팅 소화" },
{ "n": "12", "unit": "건", "label": "아리 자동 처리", "sub": "47분 아낌" }
],
"highlights": [
{ "title": "분기 리포트 초안 마무리", "meta": "딥 워크 2시간 30분 · 내일 18시 마감 여유 확보", "tone": "coral", "big": true }
],
"rollover": [
{ "id": "r1", "task_id": "k4", "title": "구독 결제 카드 갱신", "project": "개인", "to": "내일 09:40 · 인터뷰 전 가벼운 블록", "reason": "5분이면 끝나는 일이라 아침에 넣었어요" },
{ "id": "r2", "task_id": null, "title": "독서모임 책 2장 읽기", "project": "성장", "to": "내일 17:10 · 퇴근 전", "reason": "수요일 모임 전 여유 있게" }
],
"tomorrow": [ { "time": "09:00", "title": "딥 워크 — 분기 리포트 최종", "tone": "coral", "note": "아리가 예약한 블록" } ],
"prep": [ { "text": "사용자 인터뷰 질문지 초안 — 지난 인터뷰 인사이트 반영해 작성", "undo": "보기" } ],
"sleepNote": "알림 음소거 중 · 내일 아침 7:00 브리핑으로 깨워드릴게요",
"reflection": "", "sleepMode": false
}
```
`PUT /api/wrap/reflection` 요청 `{"text":"리포트 끝내서 후련하다"}` → `{"day":"2026-06-08","text":"리포트 끝내서 후련하다"}`
`PUT /api/wrap/sleep-mode` 요청 `{"on":true}` → `{"day":"2026-06-08","on":true}`
`POST /api/wrap/rollover/r1` 요청 `{"undo":true}` → `{"id":"r1","undone":true,"due":"2026-06-08"}`
---
## 6. 디자인 충실도 노트
기준 파일: `REF/assets/journey-clean.css`, `REF/assets/journey-clean-data.js`, `REF/assets/journey-clean.jsx`, `REF/assets/wrap.css`, `REF/assets/wrap-data.js`, `REF/assets/wrap.jsx`. 토큰은 `REF/assets/dash.css :root`(= `frontend/styles/tokens.css`)에서 상속.
### 6.1 여정 — 4단계 색 · 베지어 링크 · 글래스 패널
| 요소 | 원본 값 (REF) | 이식 규칙 |
|---|---|---|
| 컨테이너 | `.jx { max-width:1620px; margin:0 auto; padding:0 26px 40px }` | 그대로 |
| 큰 글래스 패널 | `.jx-panel { background:var(--glass); backdrop-filter:var(--blur); border:1px solid var(--glass-brd); border-radius:var(--radius); box-shadow:var(--shadow),var(--glass-hi); padding:22px }` | 그대로 |
| 보드 그리드 | `.jx-board { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:18px }` | 4컬럼. 반응형 1200px→2열, 720px→1열(svg 숨김) |
| 4단계 색 | s1=blue `#4f72e0`, s2=coral `#df7256`, s3=violet `#8b6fd4`, s4=ink `#29241f` (`journey-clean-data.js C`) | tone 키(`blue/coral/violet/ink`)로 매핑, `var(--tone)`. ink 라벨만 `var(--ink)` |
| 단계 시간 | s1 `06:40 – 09:00`, s2 `09:00 – 13:00`, s3 `13:00 – 17:00`, s4 `17:00 – 23:00` | 그대로 |
| 베지어 선 | `.jx-line { stroke-width:2; opacity:.5 }`, dash=`strokeDasharray="1 7"`+`@keyframes jxdash{to{stroke-dashoffset:-16}}` 1.1s | path 공식·dash 애니 그대로. `prefers-reduced-motion` 가드 유지 |
| 호버 강조 | `.jx-link.on .jx-line{opacity:1;stroke-width:2.8}`·`.off{opacity:.1}`; 카드 `.lit{outline:1.5px solid var(--blue)}`·`.mute{opacity:.4}` | `lit`/`mute`/`on`/`off` 클래스 로직 그대로(§3.1.2) |
| 흐름 힌트 | `"카드에 마우스를 올리면 연결된 작업 · 사람 · 미팅 흐름이 강조돼요"` (`jx-hint`, `link` 아이콘) | 문구 그대로 |
| 협업 아바타 스택 | `.jx-stack .av { width:42px; margin-left:-10px; border:2.5px solid var(--card) }`, 카운트 배지 `.cnt.me{background:var(--blue)}`/`.cnt.tm{background:var(--coral)}` | 그대로. `+3` more |
| 패널 라벨 | `"오늘의 흐름 · 4단계 · 12개 항목 · 5명 협업"` | meta 로 계산, 문구 포맷 보존 |
| 반원 게이지 | `LEN=Math.PI*64`, path `M16 88 A64 64 0 0 1 144 88` strokeWidth 13, `.gval` font-disp 28px | 그대로(§3.1.3). 값 `5.2시간/87%`, `72%/72` |
| 게이지 풋 | `"오후 3시예요 — 물 한 잔, 5분 스트레칭 어때요?"` (`drop` 아이콘) | 그대로 |
| 작업 테이블 chip | `.chip.done`(green) `.chip.sched`(blue) `.chip.review`(violet) `.chip.prog`(card-2/ink-2) | status→chip 키 매핑(§4.2 STATUS_CHIP) |
| 진입 애니 | `@keyframes jxin{from{opacity:0;transform:translateY(14px)}}` 0.5s, card:nth-child(2) delay .06s | 그대로 |
### 6.2 하루 마감 — 이브닝 라이트 · 풀스크린 · 글래스
| 요소 | 원본 값 (REF) | 이식 규칙 |
|---|---|---|
| body 배경 | `body.wrap-page` = radial(violet 9%)+radial(coral 7%)+`linear-gradient(178deg,var(--bg-top)…)`, `background-attachment:fixed` | 그대로. 다크: `#1d1b22→#1a1816→#161413` + violet/blue radial |
| 컨테이너 | `.wrap { max-width:1060px; margin:0 auto; padding:22px 26px 48px }` | 그대로. **셸 없음** |
| 백링크 | `.w-back` 글래스 pill `← 대시보드` | href `/dashboard`. `back` 아이콘 |
| 배지 | `.w-badge { color:#fff; background:var(--violet) }` `🌙 하루 마감 브리핑` | 그대로. `moon` 아이콘 |
| 히어로 | `.w-hero h1 { font-disp clamp(32px,4.2vw,50px) }` `오늘 하루, 수고했어요 지우님 ` | em 색 `var(--muted)`. lede `` 밑줄 violet 55% |
| 숫자 카드 | `.w-stats grid repeat(4,1fr)`, `.ws-n font-disp 30px`, `.ws-l em{color:var(--green)}` | 4개. `47분 아낌`은 `sub`(em green) |
| 해낸 일 | `.hl.big { linear-gradient(100deg, color-mix(tone 14%)…) }` tone별 좌측 그라데이션, `.hl-tick`(tone 22%) | big=coral 그라데이션. tone CSS var `--tone` |
| 한 줄 회고 | `.w-note input` placeholder `"오늘은 어떤 하루였나요? 한 줄이면 충분해요…"`, `.w-note-ok`(green `저장됨`) | localStorage 키 `ari.wrap.note`. `pen` 아이콘 |
| 이월 | `.ro.off{opacity:.65}`, `.ro-to`(blue→off muted), `.ro-undo` pill `되돌리기`/`다시 이월` | off 시 `이월 취소됨 — 오늘 할 일로 남겨둘게요`. `swap`/`arrow` 아이콘 |
| 내일 미리보기 | `.tm-title::before` tone 바, `.tm-note`(violet `아리가 예약한 블록`) | tone CSS var. `cal`/`rain`/`spark` 아이콘 |
| 사전 준비 | `.prep`(glass-2), `.prep-tick`(green), `.prep-undo`(보기/끄기/변경) | `spark`/`tick` 아이콘. 3행 |
| 수면 모드 | `.w-sleep` radial(violet 22%)+glass; on 시 border violet 45%, `.ws-ic`(violet bg), `.ws-cta`(lime) | localStorage 키 `ari.wrap.sleep`. `moon`↔`bell` 토글. CTA `수면 모드 시작`↔`해제` |
| 풋 | `"아리는 밤에도 메일과 일정을 조용히 정리해둘게요 · 급한 일만 깨워드려요"` | 그대로 |
| 반응형 | 880px→1열(w-cols), w-stats 2열; 560px→padding 축소, w-sleep wrap | 그대로 |
### 6.3 아이콘 매핑 (중앙 `Icon` 맵 통합)
여정 원본 `PATHS` + 하루 마감 원본 `WP` 의 키를 `frontend/components/Icon.tsx` 중앙 맵에 통합한다(`overview.md` §11.5 — 누락 시 렌더 깨짐 가드). 두 페이지에서 새로 필요한 키: 여정 `route/drop/share/star`(테이블 별), 하루 마감 `back/swap/arrow/rain/pen`. 이미 셸에 있는 키(`spark/grid/inbox/cal/check/tick/mail/target/heart/brain/bell/search/send/zap/sun/moon/more/plus/link`)는 재사용. **공통 SVG 속성**: `viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap/Linejoin="round"`.
> `body.wrap-page` 클래스 부여(풀스크린 배경): route group `(fullscreen)` layout 의 클라이언트 effect 에서 `document.body.classList.add("wrap-page")` (cleanup 으로 제거), 또는 Next `metadata`/`` 가 아닌 페이지 마운트 시 toggle. 다른 페이지로 이동 시 반드시 제거(배경 잔상 방지).
---
## 7. 상태 처리 · 엣지 케이스
`overview.md` §15 전역 원칙 + 두 페이지 특화.
| 상태 | 여정 `/journey` | 하루 마감 `/wrap` |
|---|---|---|
| **로딩** | 글래스 패널 스켈레톤(4컬럼 형태 유지, 카드 placeholder). 보드 SVG 는 빈 채로. `JourneySkeleton` | 숫자 카드 4개 + 2열 카드 스켈레톤. `WrapSkeleton` |
| **빈(empty)** | task/event 시드가 0이면: 컬럼 라벨·시간은 유지, 본문에 `"오늘의 흐름이 아직 없어요"` placeholder. links 0건이면 SVG 미렌더(에러 아님) | rollover 0건: `"내일로 옮길 일이 없어요 — 오늘 다 끝내셨네요"`. highlights 0건: `"오늘 완료한 일이 표시될 거예요"`. stats 는 0으로 표시 |
| **에러** | `lib/api.ts` 비2xx throw → 패널 단위 에러 카드 + 재시도. 셸(Topbar)은 유지 | 페이지 단위 에러 + 재시도. 백링크는 항상 표시(대시보드 탈출구) |
| **오프라인/백엔드 미가용** | `GET /api/journey` 실패 시 에러 카드(여정은 라이브 집계라 폴백 없음) | **회고/수면 모드는 localStorage SSOT 로 계속 동작**(PUT 실패 무시). `GET /api/wrap` 실패 시 본문 에러 카드, 단 회고 input 은 localStorage 값으로 복원 가능 |
| **HTML 시드(lede)** | — | `lede`(``)는 신뢰 시드지만 `DOMPurify.sanitize` 후 렌더(§3.2.3, overview §15) |
| **베지어 측정 타이밍** | 폰트 로드 전/탭 백그라운드 복귀 시 좌표 어긋남 → `document.fonts.ready` + `ResizeObserver` + RAF 재계산. 보드 0크기(display:none 반응형 720px) 시 svg 숨김 | — |
| **회고 디바운스** | — | 빠른 타이핑 시 매 키 입력마다 PUT 금지 → 600ms 디바운스(§3.2.4). localStorage 는 즉시 |
| **이월 되돌리기 멱등** | — | 같은 `rid` 연타 시 `undo` 불리언 토글이 멱등(true→6/8, false→6/9). task 없는 r2 는 상태만 |
| **수면 모드 ↔ 알림** | — | on 시 phase-14 worker 가 알림 held 강제. worker 미가용(MVP+ 단독)이면 `day_state` 만 저장(no-op). UI 는 항상 정상 토글 |
| **무한 중첩/깊이** | 여정은 평면 카드(중첩 없음) — 해당 없음 | rollover/tomorrow 평면 리스트 |
| **prefers-reduced-motion** | 베지어 dash 애니·진입 애니 정지(CSS 가드 유지) | 진입 애니 없음(정적) |
| **다크 모드** | tokens.css 다크 오버라이드 자동 적용(글래스/잉크/tone) | `body.wrap-page` 다크 배경(`#1d1b22…`) 별도 정의 — 적용 확인 |
---
## 8. 연합 이벤트 (발행/구독)
`post-mvp-overview.md` 의 event_bus 모델(`backend/app/automation/event_bus.py`)을 기준으로, 두 페이지가 발행/구독하는 이벤트를 명시한다. 두 페이지는 **주로 구독(집계)** 하고, 하루 마감만 소수 이벤트를 발행한다.
### 8.1 여정 — 구독 only (집계 뷰)
| 구독 이벤트 | 출처 phase | 반영 |
|---|---|---|
| `task.created` / `task.updated` | phase-3, phase-4(인박스 실체화) | s2 task 카드 done·테이블 chip·status 카운트 갱신 |
| `meeting.ended` (→action items→task) | phase-8 | 미팅 액션이 task 로 들어오면 테이블/카드에 반영 |
| `approval.executed` | phase-7 | (간접) 자동 처리된 작업이 done 으로 바뀌면 여정 반영 |
> 여정은 이벤트를 **발행하지 않는다.** 순수 read-model. 보드의 done 토글은 로컬 UI 상태(원본도 로컬 `toggle`)이며, 영속하려면 `PATCH /api/tasks/{id}`(phase-3)를 호출하도록 향상 가능(선택).
### 8.2 하루 마감 — 구독 + 소수 발행
**구독(집계 입력)**:
| 구독 이벤트 | 출처 | 반영 |
|---|---|---|
| `task.updated`(done) | phase-3 | stats `완료한 작업` 수, highlights |
| `approval.executed` / `automation.matched` | phase-7 | stats `아리 자동 처리` 건수 + `아낀 시간`(savedToday 합산) |
| `meeting.ended` | phase-8 | stats `미팅 소화`, highlights(`내 액션 2건 자동 기록 → 할 일에 추가됨`) |
**발행(연합 출력)**:
| 발행 이벤트 | 트리거 | 구독자 |
|---|---|---|
| `task.updated`(due 변경) | rollover 되돌리기/다시 이월 (`POST /api/wrap/rollover/{rid}`) | phase-3 작업 트리(due 갱신), phase-8 일정(내일 블록) |
| `wrap.reflected` | 한 줄 회고 저장 (`PUT /api/wrap/reflection`) | **내일 아침 브리핑**(대시보드 `GET /api/dashboard.briefing.note` 가 전날 회고를 합류 — "오늘 회고 한 줄도 같이 보여드릴게요" prep 문구 실현) |
| `wrap.sleep_mode` | 수면 모드 토글 (`PUT /api/wrap/sleep-mode`) | phase-14 worker(알림 음소거·`notification.held` 강제·아침 7:00 브리핑/다이제스트 예약) |
### 8.3 연합 시나리오 (PROJECT-README §5 반영)
```
[자동처리→결재함→하루 마감 합산]
결재함(phase-7): low risk 6건 자동 실행 + savedToday "47분"
자동화(phase-7): automation_run_log 야간 처리
→ approval.executed / automation.matched 이벤트
→ 하루 마감 GET /api/wrap : stats[3] "아리 자동 처리 12건 · 47분 아낌"
[미팅→작업→하루 마감 highlight]
분기 전략 미팅(phase-8) 종료 → meeting.ended → action items 2건 → task.created
→ 하루 마감 highlights "분기 전략 미팅 · 내 액션 2건 자동 기록 → 할 일에 추가됨"
[자동 이월→작업 due 변경]
하루 마감 rollover "구독 결제 카드 갱신"(task k4) 표시
→ 사용자가 [되돌리기] → POST /api/wrap/rollover/r1 {undo:true}
→ task k4 due = 6/8 (오늘 유지) ; [다시 이월] → due = 6/9
→ task.updated 이벤트 → 작업 페이지(phase-3) 트리 due 갱신
[회고→내일 아침 브리핑]
하루 마감 "오늘 한 줄" 저장 → PUT /api/wrap/reflection → wrap.reflected
→ 내일 대시보드 briefing.note 에 합류 (prep: "아침 브리핑 07:00 예약 — 오늘 회고 한 줄도 같이")
```
---
## 9. 테스팅 & 검증
실행 명령은 `overview.md` §16.1 공통 레퍼런스 재사용.
### 9.1 실행 명령
```bash
# 백엔드 (backend/)
uv run pytest tests/test_api_journey.py tests/test_api_wrap.py tests/test_wrap_aggregation.py -v
uv run pytest -k "journey or wrap"
uv run uvicorn app.main:app --reload # 수동 확인 (:8000)
uv run python -m app.seed # journey_link/day 시드 적재 확인
# 프론트엔드 (frontend/)
pnpm test -- journey wrap # Vitest + RTL (컴포넌트)
pnpm playwright test journey.spec.ts wrap.spec.ts
pnpm playwright test --grep @a11y # axe 접근성
```
### 9.2 백엔드 테스트 케이스 (pytest)
`tests/conftest.py` 의 in-memory DB fixture + `run_seed(session=test_session, reset=True)` 패턴 재사용(`phase-2-backend.md`).
| # | 테스트 | 통과 기준 |
|---|---|---|
| **J-1** | `GET /api/journey` 200 + 스키마 | `JourneyOut` 전 필드 존재. stages 4개(id `s1~s4`), cards.s1/s2/s3/s4 비어있지 않음 |
| **J-2** | 4단계 색/시간 일치 | stages 의 tone=`blue/coral/violet/ink`, time=`06:40 – 09:00`/`09:00 – 13:00`/`13:00 – 17:00`/`17:00 – 23:00` (원본 일치) |
| **J-3** | 링크 시드 일치 | `links` 8건, `m-brief→t-report`(blue,dash=false)·`t-interview→k-11`(green,dash=true) 등 원본 `JOURNEY_LINKS` 와 일치(HEX→tone 변환 확인) |
| **J-4** | 카드 알맹이=라이브 task | s2 `t-report` 카드의 title=`k1` task title(`분기 리포트 초안 마무리`), who=`jiwoo`. s2 `t-welcome`(k3) done=true(k3 status=done) |
| **J-5** | task status→여정 반영 | `PATCH /api/tasks/k1 {status:"done"}` 후 `GET /api/journey` → s2 `t-report` done=true, 테이블 chip statusKey=`done` |
| **J-6** | 테이블 부제 카운트 | `table.subtitle` 가 실제 rows status 분포와 일치(예 `5건 · 진행중 N · …`) |
| **J-7** | people 협업 | people 5명, jiwoo me=true·tasks=4, sua color=`oklch(0.66 0.13 200)`·role=`리서치` |
| **W-1** | `GET /api/wrap` 200 + 스키마 | `WrapOut` 전 필드. stats 4개, rollover 2건, tomorrow 4건, prep 3건 |
| **W-2** | stats 집계 정확성 | stats[0] 완료=시드의 done+오늘마감+내작업 수와 일치(폴백 6), stats[2] 미팅=event 수(4), stats[3] 자동처리=결재 low+보강(12)·sub=`47분 아낌` |
| **W-3** | rollover=task 연결 | r1.task_id=`k4`, r1.title=`구독 결제 카드 갱신`, project=`개인`(개인=프로젝트). r2.task_id=null |
| **W-4** | 회고 영속 왕복 | `PUT /api/wrap/reflection {text:"x"}` → 200 → `GET /api/wrap`.reflection=="x". 재호출 시 upsert(중복 행 없음) |
| **W-5** | 수면 모드 토글 | `PUT /api/wrap/sleep-mode {on:true}` → `GET /api/wrap`.sleepMode==true. `{on:false}` → false |
| **W-6** | 이월 되돌리기→due | `POST /api/wrap/rollover/r1 {undo:true}` → `GET /api/tasks/k4`.due=="2026-06-08". `{undo:false}` → "2026-06-09" (멱등) |
| **W-7** | lede HTML 보존 | `wrap.lede` 에 `대부분을 끝내셨어요 ` 포함(프론트 새니타이즈 전 원본 유지) |
| **W-8** | 시드 리셋 안정성 | `run_seed(reset=True)` 2회 호출 후 journey_link 8건·day_reflection 1건·day_state 1건(중복 없음) |
```python
# 예: tests/test_wrap_aggregation.py (W-2, W-6 발췌)
def test_wrap_stats_match_seed(client):
r = client.get("/api/wrap").json()
labels = {s["label"]: s for s in r["stats"]}
assert labels["미팅 소화"]["n"] == "4" # event 4건
assert labels["아리 자동 처리"]["sub"] == "47분 아낌"
assert labels["완료한 작업"]["unit"] == "건"
def test_rollover_changes_task_due(client):
client.post("/api/wrap/rollover/r1", json={"undo": False})
assert client.get("/api/tasks/k4").json()["due"] == "2026-06-09"
client.post("/api/wrap/rollover/r1", json={"undo": True})
assert client.get("/api/tasks/k4").json()["due"] == "2026-06-08"
```
### 9.3 프론트 테스트 (Vitest + RTL / Playwright)
| # | 테스트 | 통과 기준 |
|---|---|---|
| **JF-1** | 여정 렌더 | 4단계 라벨(`아침 준비/오전 집중/오후 협업/마무리 & 내일`) + 시간 표시. 패널 라벨 `4단계 · 12개 항목 · 5명 협업` |
| **JF-2** | 베지어 링크 렌더 | mock fetch + jsdom 에서 `getBoundingClientRect` 스텁 → `` 8개 렌더. (정확 좌표는 비검증, 개수·존재만) |
| **JF-3** | 호버 강조 | `t-report` 카드 hover → 자신 `.lit`, 이웃 `m-brief`/`k-strat` `.lit`, 무관 카드 `.mute`, 관련 링크 `.on` |
| **JF-4** | 게이지/테이블 | 게이지 2개(`딥 워크 5.2시간`, `활동 링 72%`), 테이블 5행, 별(star) on=2개 |
| **WF-1** | 하루 마감 렌더 | 셸(Topbar) **미렌더**(13항목 nav 없음), 백링크 `대시보드` 표시. stats 4개, 인사 `오늘 하루, 수고했어요 지우님` |
| **WF-2** | 회고 localStorage | input 타이핑 → `localStorage["ari.wrap.note"]` 갱신, `저장됨` 표시. 리마운트 후 값 복원 |
| **WF-3** | 수면 모드 토글 | CTA 클릭 → `수면 모드가 켜져 있어요` + `.w-sleep.on`, `localStorage["ari.wrap.sleep"]==true`. 재클릭 해제 |
| **WF-4** | 이월 되돌리기 UI | `되돌리기` 클릭 → `.ro.off` + `이월 취소됨 — 오늘 할 일로 남겨둘게요`, 버튼 `다시 이월`. POST 호출 확인(mock) |
| **WF-5** | lede 새니타이즈 | `` 는 렌더되고 `