# 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` 고정 해제).

*끝.*
