You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ari_assistant/dev/phase-12-daily-narrative.md

1769 lines
95 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 <JourneySkeleton />;
if (error) return <JourneyError onRetry={retry} />;
const { header, meta, people, stages, cards, links, rows, table, gauges, gaugeFoot } = data;
return (
<div className="jx entered">
{/* ===== 페이지 헤더 (원본 .pagehead) ===== */}
<div className="pagehead">
<div>
<div className="ph-eyebrow">
<span>{header.dateLabel}</span>
<span className="sep" />
<span className="wx"><Icon name="sun" />{header.weather}</span>
</div>
<h1 className="ph-title">오늘의 여정</h1>
</div>
<div className="ph-search">
<Icon name="search" />
<input placeholder="흐름에서 작업·사람 찾기…" />
</div>
</div>
{/* ===== 흐름 패널 ===== */}
<section className="jx-panel">
<div className="jx-top">
<div className="jx-label">
오늘의 흐름 <span>· {meta.stageCount}단계 · {meta.itemCount} 항목 · {meta.collabCount} 협업</span>
</div>
<PeopleStack people={people} more={meta.peopleMore} />
<div className="jx-actions">
<button className="jx-act"><Icon name="plus" /></button>
<button className="jx-act"><Icon name="share" /></button>
<button className="jx-act fill"><Icon name="cal" /></button>
</div>
</div>
<JourneyBoard stages={stages} cards={cards} links={links} people={people} />
<div className="jx-labels">
{stages.map((s) => (
<div className="cl" key={s.id}
style={{ color: s.tone === "ink" ? "var(--ink)" : `var(--${s.tone})` }}>
{s.title}<span>{s.time}</span>
</div>
))}
</div>
<div className="jx-hint">
<Icon name="link" /> 카드에 마우스를 올리면 연결된 작업 · 사람 · 미팅 흐름이 강조돼요
</div>
</section>
{/* ===== 하단 2열 ===== */}
<section className="jx-bottom">
<div className="card">
<div className="jx-phead">
<div>
<div className="pt">오늘의 작업</div>
<div className="ps">{table.subtitle}</div>
</div>
<div className="pa">
<button className="jx-act"><Icon name="plus" /></button>
<button className="jx-act"><Icon name="share" /></button>
</div>
</div>
<JourneyTable rows={rows} people={people} />
</div>
<div className="card">
<div className="jx-phead">
<div><div className="pt">오늘의 지표</div><div className="ps">집중 · 활동</div></div>
<div className="pa"><button className="jx-act"><Icon name="more" /></button></div>
</div>
<div className="gauges">
{gauges.map((g, i) => <Gauge key={i} {...g} />)}
</div>
<div className="gauge-foot"><Icon name="drop" />{gaugeFoot}</div>
</div>
</section>
</div>
);
}
```
> 원본 `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<HTMLDivElement>(null);
const nodes = useRef<Record<string, HTMLElement | null>>({});
const [paths, setPaths] = useState<ComputedPath[]>([]);
const [size, setSize] = useState({ w: 0, h: 0 });
const [hover, setHover] = useState<string | null>(null);
// 오전 집중(s2) 작업의 done 토글은 로컬 state (원본 toggle)
const [done, setDone] = useState<Record<string, boolean>>(
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<string, Set<string>> = {};
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 (
<div className="jx-board" ref={boardRef}>
<svg className="jx-svg" width={size.w} height={size.h}
viewBox={`0 0 ${size.w} ${size.h}`}>
{paths.map((p) => (
<g key={p.key} className={"jx-link" + pathState(p)}>
<path className="jx-line" d={p.d} stroke={p.color} fill="none"
strokeDasharray={p.dash ? "1 7" : "none"} />
<circle cx={p.x1} cy={p.y1} r="3.4" fill={p.color} className="jx-dot" />
<circle cx={p.x2} cy={p.y2} r="3.4" fill={p.color} className="jx-dot" />
</g>
))}
</svg>
{/* 1 — 아침 준비 (routine) */}
<JourneyColumn stage={stages[0]}>
{cards.s1.map((c) => (
<div className={cls(c.node, "fcard")} key={c.node} ref={setNode(c.node)} {...on(c.node)}>
<div className="fcard-row">
<div className="fc-ic"><Icon name={c.icon} /></div>
<div className="fc-main"><div className="fc-title">{c.title}</div><div className="fc-meta">{c.meta}</div></div>
<div className={"mini " + (c.toolTone || "")}><Icon name={c.tool} /></div>
</div>
<div className="fcard-foot"><span className="tag">{c.tag}</span></div>
</div>
))}
</JourneyColumn>
{/* 2 — 오전 집중 (task, 체크 토글) */}
<JourneyColumn stage={stages[1]} count={`${cards.s2.filter((t) => !done[t.node]).length}/4`}>
{cards.s2.map((t) => {
const pe = peopleById[t.who];
const d = done[t.node];
return (
<div className={cls(t.node, "fcard task" + (d ? " done" : ""))} key={t.node}
ref={setNode(t.node)} {...on(t.node)}>
<div className="fcard-row">
<div className="fcheck" onClick={(e) => { e.stopPropagation(); toggle(t.node); }}>
<Icon name="tick" />
</div>
<div className="fc-main"><div className="fc-title">{t.title}</div><div className="fc-meta">{t.meta}</div></div>
<div className="fc-who" style={{ background: pe.color }} title={pe.name}>{pe.initial}</div>
</div>
</div>
);
})}
</JourneyColumn>
{/* 3 — 오후 협업 (meet) */}
<JourneyColumn stage={stages[2]}>
{cards.s3.map((e) => {
const pe = peopleById[e.who];
return (
<div className={cls(e.node, "fcard")} key={e.node} ref={setNode(e.node)} {...on(e.node)}>
<div className="fcard-row">
<div className="fc-av" style={{ background: pe.color }}>{pe.initial}</div>
<div className="fc-main"><div className="fc-title">{e.title}</div><div className="fc-meta">{e.meta}</div></div>
{e.soon ? <span className="mini soon"></span> : <div className="fc-time">{e.time}</div>}
</div>
</div>
);
})}
</JourneyColumn>
{/* 4 — 마무리 & 내일 (tile) */}
<JourneyColumn stage={stages[3]}>
<div className="tiles">
{cards.s4.map((t) => (
<div className={cls(t.node, "tile" + (t.hi ? " hi" : ""))} key={t.node}
ref={setNode(t.node)} {...on(t.node)}>
<div className="tic"><Icon name={t.icon} /></div>
<div className="tt">{t.tt}</div>
<div className="ts">{t.ts}</div>
</div>
))}
</div>
</JourneyColumn>
</div>
);
}
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 (
<div className="jx-col">
<div className="jx-chead">
<span className="dot" style={{ background: stage.tone === "ink" ? "var(--ink)" : `var(--${stage.tone})` }} />
<span className="t">{stage.title}</span>
<span className="n">{count || stage.n}</span>
</div>
<div className="jx-cbody">{children}</div>
</div>
);
}
```
```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 (
<div className="gauge">
<svg viewBox="0 0 160 104">
<path d="M16 88 A64 64 0 0 1 144 88" fill="none" stroke="var(--card-2)" strokeWidth="13" strokeLinecap="round" />
<path d="M16 88 A64 64 0 0 1 144 88" fill="none" stroke={color} strokeWidth="13" strokeLinecap="round"
strokeDasharray={`${(LEN * pct) / 100} ${LEN}`} />
</svg>
<div style={{ marginTop: "-50px", display: "flex", flexDirection: "column", alignItems: "center" }}>
<div className="gval">{val}</div>
<div className="gunit">{unit}</div>
</div>
<div className="glabel">{label}</div>
<div className="gsub">{sub}</div>
</div>
);
}
```
```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 (
<table className="jtable">
<thead><tr><th>작업</th><th>상태</th><th>시작</th><th>담당</th></tr></thead>
<tbody>
{rows.map((r) => {
const pe = by[r.who];
return (
<tr key={r.node}>
<td>
<div className="t-subj">
<svg className={"ic star" + (r.star ? " on" : "")} viewBox="0 0 24 24"
fill={r.star ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.7">
<path d="M12 2l2.9 6.3 6.9.8-5.1 4.7 1.4 6.8L12 18l-6 3.4 1.4-6.8L2.3 9.9l6.9-.8z" />
</svg>
<span className="nm">{r.title}</span>
</div>
</td>
<td><span className={"chip " + r.statusKey}>{r.statusLabel}</span></td>
<td><span className="t-date">{r.startTime}</span></td>
<td><div className="t-user"><div className="ua" style={{ background: pe.color }}>{pe.initial}</div><span className="un">{pe.name}</span></div></td>
</tr>
);
})}
</tbody>
</table>
);
}
```
> **작업 테이블 ↔ 베지어 호버 연동(원본)**: 원본은 테이블 행 호버 시 `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 <div className="wrap-root">{children}</div>;
// body.wrap-page 배경은 globals 에서 [data-route="wrap"] 또는 클래스로 적용(아래 §6.3).
}
```
> **셸 제거 구현 정본**: 루트 `app/layout.tsx` 가 `<Topbar/>` 를 무조건 렌더한다면, (a) `/wrap` 을 별도 **route group**(`app/(fullscreen)/wrap/`)으로 빼서 그 그룹 layout 에는 Topbar 를 두지 않거나, (b) 루트 layout 에서 `usePathname()` 으로 `/wrap` 시 Topbar 를 숨긴다. 본 문서는 (a) route group 방식을 권장(셸 의존을 컴파일 타임에 끊음). `body.wrap-page` 는 그룹 layout 에서 `document.body.classList` 또는 `<body className>`(서버 컴포넌트면 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 <WrapSkeleton />;
if (error) return <WrapError onRetry={retry} />;
return (
<div className="wrap">
{/* ---- 상단(셸 없음) ---- */}
<header className="w-top">
<a className="w-back" href="/dashboard"><Icon name="back" />대시보드</a>
<div className="w-now">{data.date} · {data.now}</div>
</header>
<WrapHero user={data.user} lede={data.lede} />
<WrapStats stats={data.stats} />
<div className="w-cols">
<div className="w-col">
<Highlights highlights={data.highlights} />
<Reflection initial={data.reflection} />
</div>
<div className="w-col">
<Rollover items={data.rollover} />
<TomorrowPreview tomorrow={data.tomorrow} prep={data.prep}
weatherTomorrow={data.weatherTomorrow} />
</div>
</div>
<SleepMode initialOn={data.sleepMode} sleepNote={data.sleepNote} />
<footer className="w-foot">아리는 밤에도 메일과 일정을 조용히 정리해둘게요 · 급한 일만 깨워드려요</footer>
</div>
);
}
```
#### 3.2.3 인사·숫자·해낸 일
```tsx
// frontend/components/wrap/WrapHero.tsx (원본 .w-hero)
import { Icon } from "@/components/Icon";
import DOMPurify from "isomorphic-dompurify"; // lede 는 HTML(<b>) 포함 → 새니타이즈(overview §15)
export function WrapHero({ user, lede }: { user: { name: string }; lede: string }) {
return (
<section className="w-hero">
<span className="w-badge"><Icon name="moon" />하루 마감 브리핑</span>
<h1>오늘 하루, 수고했어요 <em>{user.name}</em></h1>
<p className="w-lede" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(lede) }} />
</section>
);
}
```
```tsx
// frontend/components/wrap/WrapStats.tsx (원본 .w-stats)
export function WrapStats({ stats }: { stats: WrapStat[] }) {
return (
<section className="w-stats">
{stats.map((s, i) => (
<div className="ws" key={i}>
<div className="ws-n">{s.n}<span className="u">{s.unit}</span></div>
<div className="ws-l">{s.label}{s.sub && <em> · {s.sub}</em>}</div>
</div>
))}
</section>
);
}
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 (
<section className="w-card">
<div className="w-ch"><Icon name="tick" /><h3>오늘 해낸 </h3></div>
<div className="hl-list">
{highlights.map((h, i) => (
<div className={"hl" + (h.big ? " big" : "")} key={i}
style={{ ["--tone" as any]: `var(--${h.tone})` }}>
<span className="hl-tick"><Icon name="tick" /></span>
<div className="hl-body">
<div className="hl-title">{h.title}</div>
<div className="hl-meta">{h.meta}</div>
</div>
</div>
))}
</div>
</section>
);
}
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<ReturnType<typeof setTimeout>>();
useEffect(() => {
save(note);
// 디바운스 후 백엔드 영속(실패해도 localStorage 가 SSOT — 데모 안정성)
clearTimeout(t.current);
t.current = setTimeout(() => {
api.put("/wrap/reflection", { text: note }).catch(() => {});
}, 600);
}, [note]);
return (
<section className="w-card">
<div className="w-ch">
<Icon name="pen" /><h3>오늘 </h3>
<span className="w-hint">내일 아침 브리핑에 함께 보여드려요</span>
</div>
<div className="w-note">
<input value={note} onChange={(e) => setNote(e.target.value)}
placeholder="오늘은 어떤 하루였나요? 한 줄이면 충분해요…" />
{note.trim() && <span className="w-note-ok"><Icon name="tick" />저장됨</span>}
</div>
</section>
);
}
```
> **회고 영속 정본**: 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<Record<string, boolean>>({});
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 (
<section className="w-card">
<div className="w-ch"><Icon name="swap" /><h3>남은 일은 내일로 옮겨뒀어요</h3></div>
<div className="ro-list">
{items.map((r) => {
const off = undone[r.id];
return (
<div className={"ro" + (off ? " off" : "")} key={r.id}>
<div className="ro-body">
<div className="ro-title">{r.title}<span className="ro-proj">{r.project}</span></div>
<div className="ro-to">
<Icon name="arrow" />
{off ? "이월 취소됨 — 오늘 할 일로 남겨둘게요" : r.to}
</div>
{!off && <div className="ro-why">{r.reason}</div>}
</div>
<button className="ro-undo" onClick={() => toggle(r)}>
{off ? "다시 이월" : "되돌리기"}
</button>
</div>
);
})}
</div>
</section>
);
}
```
```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 (
<section className="w-card">
<div className="w-ch">
<Icon name="cal" /><h3>내일 미리보기</h3>
<span className="w-hint"><Icon name="rain" />{weatherTomorrow}</span>
</div>
<div className="tm-list">
{tomorrow.map((t, i) => (
<div className="tm" key={i} style={{ ["--tone" as any]: `var(--${t.tone})` }}>
<span className="tm-time">{t.time}</span>
<span className="tm-title">{t.title}</span>
{t.note && <span className="tm-note"><Icon name="spark" />{t.note}</span>}
</div>
))}
</div>
<div className="prep">
<div className="prep-label"><Icon name="spark" />아리가 미리 끝내둔 준비</div>
{prep.map((p, i) => (
<div className="prep-row" key={i}>
<span className="prep-tick"><Icon name="tick" /></span>
<span className="prep-text">{p.text}</span>
<button className="prep-undo">{p.undo}</button>
</div>
))}
</div>
</section>
);
}
```
#### 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 (
<section className={"w-sleep" + (on ? " on" : "")}>
<div className="ws-ic"><Icon name={on ? "bell" : "moon"} /></div>
<div className="ws-body">
<div className="ws-title">{on ? "수면 모드가 켜져 있어요" : "오늘은 여기까지"}</div>
<div className="ws-sub">
{on ? sleepNote : "수면 모드를 켜면 알림을 음소거하고, 내일 아침 브리핑을 예약해둘게요."}
</div>
</div>
<button className="ws-cta" onClick={() => setOn((s: boolean) => !s)}>
{on ? "해제" : "수면 모드 시작"}
</button>
</section>
);
}
```
> **수면 모드 연합**: 켜짐(`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=("오늘 계획한 일의 <b>대부분을 끝내셨어요</b>. 남은 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": "오늘 계획한 일의 <b>대부분을 끝내셨어요</b>. 남은 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>지우님</em>` | em 색 `var(--muted)`. lede `<b>` 밑줄 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`/`<body>` 가 아닌 페이지 마운트 시 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`(`<b>`)는 신뢰 시드지만 `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``<b>대부분을 끝내셨어요</b>` 포함(프론트 새니타이즈 전 원본 유지) |
| **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` 스텁 → `<path className="jx-line">` 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 새니타이즈 | `<b>` 는 렌더되고 `<script>` 주입은 제거(DOMPurify) |
| **E2E-1** | 회고→대시보드 연합 | `/wrap` 회고 저장 → (다음 날 시뮬) `/dashboard` briefing.note 에 회고 합류 표시(연합 §8.3) |
| **E2E-2** | 이월→작업 연합 | `/wrap` rollover `다시 이월``/tasks` 트리에서 k4 due=6/9 확인 |
| **A11y** | axe | 여정·하루 마감 각 페이지 axe 위반 0(serious 이상). 베지어 svg 는 `aria-hidden`, 작업 테이블 `<th>` scope, 토글 버튼 접근명 |
### 9.4 수동 QA 체크리스트
여정:
- [ ] `/journey` 진입 시 상단 셸 13항목 표시·여정(`route`) active.
- [ ] 4단계 컬럼(아침 준비/오전 집중/오후 협업/마무리 & 내일) + 단계 색 점(blue/coral/violet/ink).
- [ ] 카드↔카드 베지어 곡선 8개 렌더, 점선(dash) 4개는 흐르는 애니메이션.
- [ ] 카드 호버 시 연결 흐름 강조(`lit`)·나머지 흐림(`mute`)·관련 선 진해짐(`on`).
- [ ] 오전 집중 작업 체크박스 클릭 → 완료선(취소선) 토글 + 카운트 `n/4` 갱신 + 베지어 재계산(어긋남 없음).
- [ ] 창 리사이즈 시 베지어 곡선이 카드 위치를 따라 즉시 재계산.
- [ ] 하단 작업 테이블 5행 + 상태 chip 색(진행중/검토/완료/예정) + 별 표시.
- [ ] 반원 게이지 2개(딥 워크 5.2시간/87%, 활동 링 72%) + 풋 문구.
- [ ] 반응형: 1200px→2열 보드, 720px→1열 + svg 숨김.
하루 마감:
- [ ] `/wrap` 진입 시 **상단 셸 없음**(풀스크린), 백링크 `← 대시보드`, 우측 `6월 8일 월요일 · 오후 9:40`.
- [ ] 옅은 바이올렛/코럴 글로우 배경(`wrap-page`), 다크 모드 시 어두운 배경.
- [ ] 인사 `오늘 하루, 수고했어요 지우님` + lede(`<b>` 밑줄 강조).
- [ ] 숫자 4개(완료 6건/딥워크 2.5시간/미팅 4건/아리 자동 12건·47분 아낌, 마지막 sub 초록).
- [ ] 해낸 일 4개(첫 항목 big=coral 그라데이션), 각 tone 체크 아이콘.
- [ ] 한 줄 회고 입력 → `저장됨` 표시, 새로고침 후 값 유지(localStorage).
- [ ] 이월 2건(구독 결제 카드 갱신/독서모임 책 2장), `되돌리기` → 취소 문구 + `다시 이월` 버튼.
- [ ] 내일 미리보기 4건(첫 항목 `아리가 예약한 블록` violet 배지) + 비 예보 힌트.
- [ ] 아리 사전 준비 3행(보기/끄기/변경).
- [ ] 수면 모드 시작 → 카드 강조(violet border) + 음소거 안내, 재새로고침 후 유지.
- [ ] 한국어 줄바꿈 `word-break: keep-all`.
### 9.5 통과 기준 (요약)
- 백엔드: `pytest -k "journey or wrap"` 전건 green. **stats 집계가 시드와 일치**(W-2), **이월 되돌리기가 task.due 를 정확히 변경**(W-6), **회고/수면 영속 왕복**(W-4/W-5), **시드 2회 멱등**(W-8).
- 프론트: `pnpm test` 컴포넌트 green, `pnpm playwright test` 연합 E2E(회고→대시보드, 이월→작업) green, **베지어 링크 렌더**(JF-2), axe 위반 0.
- 디자인 충실도: 4단계 색·베지어 dash·이브닝 라이트 배경·풀스크린(셸 없음)이 원본 REF 와 일치, 라이트/다크 토큰 적용.
---
## 10. 완료 기준 (DoD)
- [ ] **여정 `/journey`**: 4단계 보드 + 베지어 커넥터(실시간 DOM 측정) + 호버 강조/흐림 + 협업 아바타 스택 + 하단 작업 테이블 + 반원 게이지 2개, 원본 `journey-clean.*` 픽셀 충실 재현.
- [ ] **하루 마감 `/wrap`**: 풀스크린(셸 없음, `wrap-page` 배경) + 숫자 4 + 해낸 일 + 이월(되돌리기) + 내일 미리보기 + 사전 준비 + 한 줄 회고(localStorage) + 수면 모드 토글, 원본 `wrap.*` 재현.
- [ ] **백엔드 집계**: `GET /api/journey`(작업·일정·사람·journey_link), `GET /api/wrap`(작업·일정·결재·자동화 합산). 알맹이는 라이브 task/event, 껍데기 narrative 는 원본 demo 값.
- [ ] **신규 영속 최소**: `journey_link`/`day_reflection`/`day_state` 3종만 추가. `run_seed` 적재·2회 멱등.
- [ ] **상태 저장 API**: `PUT /api/wrap/reflection`, `PUT /api/wrap/sleep-mode`, `POST /api/wrap/rollover/{rid}` 동작 + 멱등.
- [ ] **연합**: 회고→내일 아침 브리핑(`wrap.reflected`→dashboard briefing.note), 이월→task.due 변경(`task.updated`), 수면 모드→phase-14 worker 신호(`wrap.sleep_mode`), stats→결재/자동화/미팅 합산.
- [ ] **계약 정합**: `schemas.py``types.ts` 1:1, 라우터 `prefix="/api"` 규약(내부 prefix 없음), tone 키 규약(HEX→tone 변환), TEXT/문자열 PK.
- [ ] **테스팅**: §9 백엔드 J/W 케이스 + 프론트 JF/WF/E2E/A11y 통과. stats 시드 일치·이월 되돌리기·회고 영속·베지어 렌더 검증 포함.
- [ ] **상태/엣지**: 로딩/빈/에러/오프라인(회고 localStorage SSOT)/HTML 새니타이즈/베지어 측정 타이밍/다크 모드 처리.
- [ ] **상호 참조**: `overview.md`·`post-mvp-overview.md`·`phase-7-approvals-automation.md`·`phase-8-calendar-meetings.md`·`phase-3-tasks.md`·`phase-2-backend.md`·`phase-13-integrations.md` 정확한 파일명 링크.
---
## 11. 다음 단계
다음 문서: **`phase-13-integrations.md`** — 외부 연동 & 커넥터(mock→real 교체). 지금까지(phase-7~12) 모든 페이지는 **mock-first** 커넥터(`backend/app/connectors/` 의 MockConnector, 시드 기반)로 동작했다. phase-13 은 동일 인터페이스에 `RealConnector` 를 끼워 mail(Gmail/Workspace/HEY)·calendar(Google Calendar)·chat·finance·health·knowledge 를 실제 연동한다(`CONNECTOR_<DOMAIN>=mock|real`, 기본 mock). 여정·하루 마감의 집계 입력(작업·일정·결재·자동화)이 real 데이터로 바뀌어도 **본 phase 의 집계 서비스(`build_journey`/`build_wrap`)와 API 계약은 변경 없이 그대로 동작**해야 한다(인터페이스 고정의 가치).
이후: `phase-14-proactive-agent.md`(능동형 에이전트 + 멀티모달 — 수면 모드가 트리거하는 야간 worker·아침 브리핑 예약이 여기서 실제 구현), `phase-15-production.md`(인증/멀티유저 — `day_reflection`/`day_state` 의 per-user/per-day 스코프, 데모 기준일 `2026-06-08` 고정 해제).
*끝.*