95 KiB
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. 목차
- 개요 & 목표
- 선행 조건 / 산출물
- 상세 구현 — 프론트엔드
- 3.1 여정 페이지 (
/journey) - 3.2 하루 마감 페이지 (
/wrap, 풀스크린)
- 3.1 여정 페이지 (
- 상세 구현 — 백엔드 (집계 중심)
- 데이터 / 타입 / API 계약
- 디자인 충실도 노트
- 상태 처리 · 엣지 케이스
- 연합 이벤트 (발행/구독)
- 테스팅 & 검증
- 완료 기준 (DoD)
- 다음 단계
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/themestate 를 가졌지만, 우리 셸(phase-1-design-system.mdTopbar + next-themes)이 이를 대체한다 — 페이지는 본문만 렌더한다. 원본MAIN13항목·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}`
// 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+ 에서는 보드와 테이블이 분리 컴포넌트이므로, 호버 상태를 페이지 상위로 끌어올려(useStatelift)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 만 셸을 제거한다.
// 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.mdworker)에 "알림 음소거 + 내일 아침 7:00 브리핑 예약" 신호다. MVP+ 데모는 백엔드day_state.sleep_mode만 토글하고, 실제 음소거/예약은 phase-14 가 구독한다(notification.triagedheld 버킷 강제, 다이제스트 예약). 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_state3종이 그 전부다. 나머지(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 의 실제 시드 값(제목·담당·상태)을 채워 넣는다.
# 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.jsxpagehead) — 대시보드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은 실제 taskk4(구독 결제 카드 갱신, 개인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"])
4.5 시드 추가 — journey_link + day 기본값
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. 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 으로 제거), 또는 Nextmetadata/<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_state3종만 추가.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.ts1: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 고정 해제).
끝.