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

95 KiB

Phase 12 — 여정 + 하루 마감 (집계 narrative)

한 줄 요약: 흩어진 작업·일정·사람·결재·자동화 데이터를 하나의 이야기로 엮는 두 페이지 — 여정(아침→오전→오후→마무리 4단계 흐름 + 작업↔사람↔미팅 베지어 협업 맵)과 하루 마감(이브닝 브리핑: 오늘 숫자·해낸 일·내일 이월·아리 사전 준비·한 줄 회고·수면 모드). 두 페이지 모두 거의 전부가 집계(read-only aggregation) 이므로 포스트-MVP의 사실상 마지막 페이지군이다.

이 문서는 포스트-MVP 세트의 일부 — 먼저 dev/overview.mddev/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. 개요 & 목표
  2. 선행 조건 / 산출물
  3. 상세 구현 — 프론트엔드
    • 3.1 여정 페이지 (/journey)
    • 3.2 하루 마감 페이지 (/wrap, 풀스크린)
  4. 상세 구현 — 백엔드 (집계 중심)
  5. 데이터 / 타입 / API 계약
  6. 디자인 충실도 노트
  7. 상태 처리 · 엣지 케이스
  8. 연합 이벤트 (발행/구독)
  9. 테스팅 & 검증
  10. 완료 기준 (DoD)
  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.

// 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.

진입 애니메이션: 원본은 useEffectref.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}`
// 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 단계 컬럼 · 게이지 · 작업 테이블

// 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>
  );
}
// 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>
  );
}
// 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) JourneyBoardJourneyTable 에 공유하거나, 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 만 셸을 제거한다.

// 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.

// 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 인사·숫자·해낸 일

// 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>
  );
}
// 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 };
// 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 으로 영속(연합: 내일 아침 브리핑에 합류).

// 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 이월(되돌리기) · 내일 미리보기 · 사전 준비

// 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>
  );
}
// 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)

// 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 적재) 준수.

# 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)·사람(personjourney_link 를 읽어 원본 AriJourney 형태(stages/cards/links/rows + meta)를 만든다. 데모 결정성을 위해 원본 4단계 정의와 카드 매핑을 기준 상수로 유지하되, task/event 의 실제 시드 값(제목·담당·상태)을 채워 넣는다.

# 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 값을 기준으로 하되, 합산 가능한 수치(완료 작업 수, 아낀 시간, 미팅 수, 자동 처리 건수)는 실제 집계한다.

# 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").

# 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)
# 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 등록(추가):

# 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"])

run_seed(session, reset)journey_link 적재(원본 journey-clean-data.js: links)와 day_reflection/day_state 기본값을 추가한다. 원본 링크 HEX→tone 키 변환 적용.

# 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 패턴):

# _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

# 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
// 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 (축약):

{
  "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 (축약):

{
  "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. moonbell 토글. 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 실행 명령

# 백엔드 (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건(중복 없음)
# 예: 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.pytypes.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 고정 해제).

끝.