# Phase 12 — 여정 + 하루 마감 (집계 narrative) > 한 줄 요약: 흩어진 작업·일정·사람·결재·자동화 데이터를 **하나의 이야기**로 엮는 두 페이지 — **여정**(아침→오전→오후→마무리 4단계 흐름 + 작업↔사람↔미팅 베지어 협업 맵)과 **하루 마감**(이브닝 브리핑: 오늘 숫자·해낸 일·내일 이월·아리 사전 준비·한 줄 회고·수면 모드). 두 페이지 모두 **거의 전부가 집계(read-only aggregation)** 이므로 포스트-MVP의 사실상 마지막 페이지군이다. > 이 문서는 **포스트-MVP 세트의 일부** — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요. > 선행: `phase-7-approvals-automation.md`(결재함 `savedToday`·자동 처리 건수·`approval`/`automation_run_log`), `phase-8-calendar-meetings.md`(미팅 액션→작업), `phase-3-tasks.md`(작업 트리·status), `phase-2-backend.md`(모델/라우터/시드 규약), `phase-1-design-system.md`(토큰·Icon·셸). 후속: `phase-13-integrations.md`. --- ## 0. 목차 1. [개요 & 목표](#1-개요--목표) 2. [선행 조건 / 산출물](#2-선행-조건--산출물) 3. [상세 구현 — 프론트엔드](#3-상세-구현--프론트엔드) - 3.1 여정 페이지 (`/journey`) - 3.2 하루 마감 페이지 (`/wrap`, 풀스크린) 4. [상세 구현 — 백엔드 (집계 중심)](#4-상세-구현--백엔드-집계-중심) 5. [데이터 / 타입 / API 계약](#5-데이터--타입--api-계약) 6. [디자인 충실도 노트](#6-디자인-충실도-노트) 7. [상태 처리 · 엣지 케이스](#7-상태-처리--엣지-케이스) 8. [연합 이벤트 (발행/구독)](#8-연합-이벤트-발행구독) 9. [테스팅 & 검증](#9-테스팅--검증) 10. [완료 기준 (DoD)](#10-완료-기준-dod) 11. [다음 단계](#11-다음-단계) --- ## 1. 개요 & 목표 ### 1.1 이 phase가 끝나면 무엇이 동작하는가 - **여정 `/journey`**: 하루를 4단계(`아침 준비 → 오전 집중 → 오후 협업 → 마무리 & 내일`)로 보여주는 큰 글래스 패널. 단계별 카드(routine/task/meet/tile)와 **작업↔사람↔미팅을 잇는 실시간 베지어 커넥터**(DOM 위치 측정 → SVG 곡선)가 렌더되고, 카드에 호버하면 연결된 흐름이 강조(`lit`)·나머지는 흐려진다(`mute`). 하단에 오늘 작업 테이블 + 반원 게이지 2개. 데이터는 `GET /api/journey` 가 작업·일정·사람을 집계해 공급한다. - **하루 마감 `/wrap`**: 상단 메뉴 없는 **전용 풀스크린**(라이트 글래스 + 옅은 바이올렛/코럴 글로우). 오늘 숫자 4개(완료/딥워크/미팅/아리 자동 처리·아낀 시간), 해낸 일(highlights), 미완료→내일 자동 이월(rollover, 되돌리기), 내일 미리보기(tomorrow), 아리 사전 준비(prep), 한 줄 회고(localStorage 영속), 수면 모드 토글. 데이터는 `GET /api/wrap` 가 **작업·일정·결재함(phase-7)·자동화**에서 계산한다. - 회고 한 줄과 수면 모드는 **상태 저장**(MVP 데모는 localStorage 미러 + 선택적 백엔드 `PUT /api/wrap/reflection`·`PUT /api/wrap/sleep-mode` 영속). 신규 영속 테이블은 **최소**(`day_reflection`, `day_state`)로만 추가. ### 1.2 왜 마지막인가 (집계 의존성) 여정·하루 마감은 **자신의 1차 데이터를 거의 갖지 않는다.** 두 페이지는 다음을 읽어 집계한다. ``` 작업(phase-3 task) ─┐ 일정/미팅(phase-8 event) ─┤ 사람(phase-2 person) ─┼─▶ GET /api/journey ─▶ 4단계 + 카드 + 베지어 링크 + 작업테이블 결재함(phase-7 approval) ─┤ 자동화(phase-7 automation_*) ─┘ └─▶ GET /api/wrap ─▶ stats(완료·딥워크·미팅·아리자동+아낀시간) + highlights + rollover + tomorrow + prep ``` 따라서 빌드 순서상 **phase-7~11 이후**에 위치한다(`post-mvp-overview.md` 로드맵: `7→8→9→10→11→12→13→14→15`). overview.md §12.3 의 "대시보드 = 집계 화면이라 마지막에 빌드" 와 동일한 원칙을, 더 넓은 데이터 표면(결재·자동화·미팅까지)으로 확장한 것이다. ### 1.3 제품 철학과의 정합 (`overview.md` §3, `PROJECT-README.md` §5) - **"이미 해뒀어요"**: 하루 마감의 rollover/prep 은 *질문*이 아니라 *완료 보고*다 — `"남은 일은 내일로 옮겨뒀어요"`, `"아리가 미리 끝내둔 준비"`. 사용자는 **확인과 되돌리기**만 한다(원본 `wrap-data.js` 주석: *"사용자가 할 일은 '확인'과 '되돌리기'뿐"*). - **"개인=프로젝트"**: 여정 작업 테이블·rollover 모두 업무(`분기 리포트`)와 개인(`구독 결제 카드 갱신`/`독서모임 책 2장`)을 같은 목록에서 보여준다 — 개인을 숨기지 않는다. - **연합으로 만드는 가치**: 하루 마감 stats 의 `아리 자동 처리 12건 · 47분 아낌` 은 결재함(`savedToday`)·자동화 실행 로그의 **합산**이다. 회고 한 줄은 **내일 아침 브리핑**(대시보드 `briefing.note`)으로 흘러간다. --- ## 2. 선행 조건 / 산출물 ### 2.1 선행 조건 (의존 phase) | 의존 | 무엇을 가져오는가 | |---|---| | `phase-2-backend.md` | 모델/스키마/라우터/시드 규약, `person`/`task`/`event`(읽기전용)/`run_seed(session, reset)` 패턴, TEXT PK, tone 키 규약. | | `phase-3-tasks.md` | `task`(status 5단·due·assignee·prio)·`columns` accent. 여정 작업 테이블·하루 마감 완료 수가 이 데이터를 읽는다. | | `phase-7-approvals-automation.md` | `approval`(savedToday 합산)·`autonomy_setting`·`automation_run_log`/`automation_stats`. 하루 마감 `아리 자동 처리`·`아낀 시간` 의 출처. | | `phase-8-calendar-meetings.md` | `event`/`meeting`(미팅 액션→작업). 여정 오후 협업 단계·하루 마감 `미팅 소화`·미팅 액션 highlight 의 출처. | | `phase-1-design-system.md` | `tokens.css`(글래스/잉크/라운드/tone), `Icon`(paths 맵), 13항목 `Topbar`. 여정은 셸 사용, 하루 마감은 셸 **미사용**(자체 상단바). | > phase-9~11(메일/알림/리서치/여행/라이프)는 **선택 의존**이다. 가용하면 하루 마감 stats/prep 에 신호를 더 보탤 수 있으나(예: prep 의 `우산 리마인더`), MVP 데모는 시드 고정값(`wrap-data.js`)으로 결정적으로 채운다. 미가용 시 해당 항목은 생략(빈 상태 §7). ### 2.2 산출물 (Deliverables) ``` frontend/ ├─ app/ │ ├─ journey/page.tsx 여정 (앱 셸 안, current="journey") │ └─ wrap/ │ ├─ layout.tsx 하루 마감 전용 레이아웃 (Topbar 미렌더, body.wrap-page) │ └─ page.tsx 하루 마감 풀스크린 ├─ components/journey/ │ ├─ JourneyBoard.tsx 4컬럼 보드 + 베지어 SVG 커넥터 (원본 Board) │ ├─ JourneyColumn.tsx 단계 컬럼 (원본 Col) │ ├─ FlowCard.tsx 흐름 카드 (routine/task/meet) │ ├─ FinishTile.tsx 마무리 타일 (원본 tile) │ ├─ PeopleStack.tsx 협업 아바타 스택 (원본 jx-stack) │ ├─ JourneyTable.tsx 하단 작업 테이블 (원본 jtable) │ └─ Gauge.tsx 반원 게이지 (원본 Gauge) ├─ components/wrap/ │ ├─ WrapHero.tsx 인사 + lede │ ├─ WrapStats.tsx 오늘 숫자 4개 │ ├─ Highlights.tsx 오늘 해낸 일 │ ├─ Rollover.tsx 내일로 이월 (되돌리기) │ ├─ TomorrowPreview.tsx 내일 미리보기 + prep │ ├─ Reflection.tsx 한 줄 회고 (localStorage + PUT) │ └─ SleepMode.tsx 수면 모드 토글 ├─ lib/hooks/ │ ├─ useJourney.ts GET /api/journey │ └─ useWrap.ts GET /api/wrap (+ reflection/sleep mutate) └─ styles/ ├─ journey.css 원본 journey-clean.css 이식 └─ wrap.css 원본 wrap.css 이식 backend/ ├─ app/ │ ├─ models.py (+ DayReflection, DayState — 신규 최소) │ ├─ schemas.py (+ Journey*, Wrap* 응답 스키마) │ ├─ routers/ │ │ ├─ journey.py GET /api/journey │ │ └─ wrap.py GET /api/wrap, PUT reflection, PUT sleep-mode, POST rollover/{id} │ ├─ services/ │ │ ├─ journey.py build_journey(): 작업·일정·사람 → 4단계+카드+링크 │ │ └─ wrap.py build_wrap(): stats/highlights/rollover/tomorrow/prep 집계 │ └─ seed.py (+ journey_link 설정, day 상태 기본값) └─ tests/ ├─ test_api_journey.py ├─ test_api_wrap.py └─ test_wrap_aggregation.py stats 가 시드와 일치하는지 ``` --- ## 3. 상세 구현 — 프론트엔드 ### 3.1 여정 페이지 (`/journey`) 원본 `REF/assets/journey-clean.jsx` / `journey-clean.css` / `journey-clean-data.js` 를 React+TS 컴포넌트로 픽셀 충실 이식한다. 데이터는 시드 하드코딩이 아니라 `GET /api/journey` 응답을 그대로 소비한다. #### 3.1.1 페이지 컨테이너 + 페이지 헤더 원본 `App()` 의 구조: 상단바(셸) → 페이지 헤더(`pagehead`) → 흐름 패널(`jx-panel`) → 하단 2열(`jx-bottom`). 상단바는 **공용 셸**(`Topbar current="journey"`, 13항목)을 사용한다. 원본 `MAIN` 배열의 여정 항목은 `{ id:"journey", label:"여정", icon:"route", href:"여정.html" }` — Next 라우트는 `/journey`. ```tsx // frontend/app/journey/page.tsx "use client"; import { useJourney } from "@/lib/hooks/useJourney"; import { Icon } from "@/components/Icon"; import { JourneyBoard } from "@/components/journey/JourneyBoard"; import { PeopleStack } from "@/components/journey/PeopleStack"; import { JourneyTable } from "@/components/journey/JourneyTable"; import { Gauge } from "@/components/journey/Gauge"; import "@/styles/journey.css"; export default function JourneyPage() { const { data, isLoading, error, retry } = useJourney(); // 로딩/에러 처리는 §7 참조. 아래는 성공 경로. if (isLoading || !data) return ; if (error) return ; const { header, meta, people, stages, cards, links, rows, table, gauges, gaugeFoot } = data; return (
{/* ===== 페이지 헤더 (원본 .pagehead) ===== */}
{header.dateLabel} {header.weather}

오늘의 여정

{/* ===== 흐름 패널 ===== */}
오늘의 흐름 · {meta.stageCount}단계 · {meta.itemCount}개 항목 · {meta.collabCount}명 협업
{stages.map((s) => (
{s.title}{s.time}
))}
카드에 마우스를 올리면 연결된 작업 · 사람 · 미팅 흐름이 강조돼요
{/* ===== 하단 2열 ===== */}
오늘의 작업
{table.subtitle}
오늘의 지표
집중 · 활동
{gauges.map((g, i) => )}
{gaugeFoot}
); } ``` > 원본 `App()` 은 자체 `topbar`/`theme` state 를 가졌지만, 우리 셸(`phase-1-design-system.md` Topbar + next-themes)이 이를 대체한다 — 페이지는 본문만 렌더한다. 원본 `MAIN` 13항목·badge(결재함3/작업4/알림6)는 셸에 이미 이식되어 있다(`overview.md` §5.3, §11.6). **여정은 `route` 아이콘으로 active**. > **진입 애니메이션**: 원본은 `useEffect` 로 `ref.current.classList.add("entered")`. 우리는 컨테이너에 `entered` 를 즉시 부여하거나(SSR 후 hydration 시 1프레임 지연 부여) `@keyframes jxin`(0.5s)을 그대로 사용. `prefers-reduced-motion` 가드는 CSS 에 이미 존재. #### 3.1.2 베지어 커넥터 보드 — `JourneyBoard.tsx` 원본 `Board()` 의 핵심: **DOM 위치를 실시간 측정해 SVG 베지어 곡선을 그린다.** 각 카드에 `ref` 를 붙여(`nodes.current[id]`), `getBoundingClientRect()` 로 시작/끝 좌표를 구해 cubic Bézier path 를 만든다. resize/폰트로드/탭변경에 대응하기 위해 `ResizeObserver` + `requestAnimationFrame` + `document.fonts.ready` 로 재계산한다. 원본 path 공식(그대로 이식): ``` x1 = a.right - boardLeft, y1 = a.top - boardTop + a.height/2 // from 카드의 오른쪽 중앙 x2 = b.left - boardLeft, y2 = b.top - boardTop + b.height/2 // to 카드의 왼쪽 중앙 dx = max(36, (x2 - x1) * 0.5) d = `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}` ``` ```tsx // frontend/components/journey/JourneyBoard.tsx "use client"; import { useRef, useState, useCallback, useLayoutEffect, useMemo } from "react"; import { JourneyColumn } from "./JourneyColumn"; import { FlowCard } from "./FlowCard"; import { FinishTile } from "./FinishTile"; import type { JourneyStage, JourneyCards, JourneyLink, JourneyPerson } from "@/lib/types"; type Props = { stages: JourneyStage[]; cards: JourneyCards; // { s1:[], s2:[], s3:[], s4:[] } links: JourneyLink[]; // [{ from, to, tone, dash }] people: JourneyPerson[]; }; export function JourneyBoard({ stages, cards, links, people }: Props) { const boardRef = useRef(null); const nodes = useRef>({}); const [paths, setPaths] = useState([]); const [size, setSize] = useState({ w: 0, h: 0 }); const [hover, setHover] = useState(null); // 오전 집중(s2) 작업의 done 토글은 로컬 state (원본 toggle) const [done, setDone] = useState>( Object.fromEntries(cards.s2.map((c) => [c.node, !!c.done])) ); const peopleById = useMemo(() => Object.fromEntries(people.map((p) => [p.id, p])), [people]); const setNode = (id: string) => (el: HTMLElement | null) => { if (el) nodes.current[id] = el; }; const compute = useCallback(() => { const board = boardRef.current; if (!board) return; const br = board.getBoundingClientRect(); const out: ComputedPath[] = []; for (const lk of links) { const a = nodes.current[lk.from], b = nodes.current[lk.to]; if (!a || !b) continue; const ar = a.getBoundingClientRect(), bb = b.getBoundingClientRect(); const x1 = ar.right - br.left, y1 = ar.top - br.top + ar.height / 2; const x2 = bb.left - br.left, y2 = bb.top - br.top + bb.height / 2; const dx = Math.max(36, (x2 - x1) * 0.5); out.push({ key: lk.from + ">" + lk.to, from: lk.from, to: lk.to, color: `var(--${lk.tone})`, dash: lk.dash, d: `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`, x1, y1, x2, y2, }); } setPaths(out); setSize({ w: br.width, h: br.height }); }, [links]); useLayoutEffect(() => { compute(); const raf = requestAnimationFrame(compute); const onR = () => compute(); window.addEventListener("resize", onR); const ro = new ResizeObserver(compute); if (boardRef.current) ro.observe(boardRef.current); if (document.fonts?.ready) document.fonts.ready.then(compute); return () => { cancelAnimationFrame(raf); window.removeEventListener("resize", onR); ro.disconnect(); }; }, [compute, done]); // 이웃 맵(양방향) — 호버 강조용 (원본 neighbors) const neighbors = useMemo(() => { const m: Record> = {}; for (const l of links) { (m[l.from] = m[l.from] || new Set()).add(l.to); (m[l.to] = m[l.to] || new Set()).add(l.from); } return m; }, [links]); const isLit = (node: string) => !!hover && (node === hover || neighbors[hover]?.has(node)); const cls = (node: string, base: string) => base + (isLit(node) ? " lit" : "") + (hover && !isLit(node) ? " mute" : ""); const pathState = (p: ComputedPath) => !hover ? "" : (p.from === hover || p.to === hover) ? " on" : " off"; const on = (node: string) => ({ onMouseEnter: () => setHover(node), onMouseLeave: () => setHover(null), }); const toggle = (node: string) => setDone((d) => ({ ...d, [node]: !d[node] })); return (
{paths.map((p) => ( ))} {/* 1 — 아침 준비 (routine) */} {cards.s1.map((c) => (
{c.title}
{c.meta}
{c.tag}
))}
{/* 2 — 오전 집중 (task, 체크 토글) */} !done[t.node]).length}/4`}> {cards.s2.map((t) => { const pe = peopleById[t.who]; const d = done[t.node]; return (
{ e.stopPropagation(); toggle(t.node); }}>
{t.title}
{t.meta}
{pe.initial}
); })}
{/* 3 — 오후 협업 (meet) */} {cards.s3.map((e) => { const pe = peopleById[e.who]; return (
{pe.initial}
{e.title}
{e.meta}
{e.soon ? :
{e.time}
}
); })}
{/* 4 — 마무리 & 내일 (tile) */}
{cards.s4.map((t) => (
{t.tt}
{t.ts}
))}
); } type ComputedPath = { key: string; from: string; to: string; color: string; dash?: boolean; d: string; x1: number; y1: number; x2: number; y2: number; }; ``` > **tone 매핑 정본**: 원본 데이터(`journey-clean-data.js`)의 링크 색은 HEX(`C.blue` = `#4f72e0` 등) 였다. CONTRACT 의 tone 규약(`overview.md` §9.3)을 따라 백엔드는 **tone 키**(`blue|violet|coral|green|amber|ink|faint`)로 내려주고, 프론트가 `var(--${tone})` 로 변환한다. 원본 HEX→tone 키 대응: `#4f72e0→blue`, `#df7256→coral`, `#4e9b66→green`, `#8b6fd4→violet`, `#e0a23c→amber`, `#29241f→ink`. `ink` 단계 라벨 색만 원본처럼 `var(--ink)` 로 처리(원본 `s.c === J.C.ink ? "var(--ink)" : s.c`). > **베지어 z-index 주의**(원본 CSS): `.jx-svg { position:absolute; inset:0; z-index:1; pointer-events:none; overflow:visible }`, `.jx-col { position:relative; z-index:2 }` — 선은 카드 아래, 카드는 클릭 가능. **누락 시 선이 카드를 덮어 호버가 안 먹음.** #### 3.1.3 단계 컬럼 · 게이지 · 작업 테이블 ```tsx // frontend/components/journey/JourneyColumn.tsx (원본 Col) export function JourneyColumn({ stage, count, children }: { stage: { title: string; tone: string; n: string }; count?: string; children: React.ReactNode; }) { return (
{stage.title} {count || stage.n}
{children}
); } ``` ```tsx // frontend/components/journey/Gauge.tsx (원본 Gauge — 반원 게이지) const LEN = Math.PI * 64; export function Gauge({ pct, tone, val, unit, label, sub }: { pct: number; tone: string; val: string; unit: string; label: string; sub: string; }) { const color = tone === "ink" ? "var(--ink)" : `var(--${tone})`; return (
{val}
{unit}
{label}
{sub}
); } ``` ```tsx // frontend/components/journey/JourneyTable.tsx (원본 jtable) import { Icon } from "@/components/Icon"; import type { JourneyRow, JourneyPerson } from "@/lib/types"; export function JourneyTable({ rows, people }: { rows: JourneyRow[]; people: JourneyPerson[] }) { const by = Object.fromEntries(people.map((p) => [p.id, p])); // 상태 chip 클래스: prog|review|done|sched (원본 .chip.* 그대로) return ( {rows.map((r) => { const pe = by[r.who]; return ( ); })}
작업상태시작담당
{r.title}
{r.statusLabel} {r.startTime}
{pe.initial}
{pe.name}
); } ``` > **작업 테이블 ↔ 베지어 호버 연동(원본)**: 원본은 테이블 행 호버 시 `setHover(r.node)` 로 보드의 같은 `node` 카드/링크가 강조됐다(`tr.lit`). MVP+ 에서는 보드와 테이블이 분리 컴포넌트이므로, 호버 상태를 페이지 상위로 끌어올려(`useState` lift) `JourneyBoard` 와 `JourneyTable` 에 공유하거나, 1차 구현은 보드 내부 호버만 지원하고 테이블 행 호버는 **본인 행만 `.lit` 배경**(원본 `.jtable tbody tr.lit { background: var(--card-2) }`)으로 처리한다. 풀 연동은 선택적 향상(테스트 J-7). ### 3.2 하루 마감 페이지 (`/wrap`, 풀스크린) 원본 `REF/assets/wrap.jsx` / `wrap.css` / `wrap-data.js` 를 이식한다. **핵심 차이**: 하루 마감은 **공용 셸(Topbar 13항목)을 쓰지 않는다.** 자체 상단(`w-top`: ‹대시보드 백링크 + 시각)만 있고 `body.wrap-page` 배경(옅은 바이올렛/코럴 글로우)을 쓴다. #### 3.2.1 전용 레이아웃 (Topbar 미렌더) Next App Router 의 중첩 레이아웃으로 `/wrap` 만 셸을 제거한다. ```tsx // frontend/app/wrap/layout.tsx // 하루 마감은 풀스크린 — 공용 Topbar 를 렌더하지 않는다. // 루트 layout.tsx 가 Topbar 를 항상 렌더한다면, 루트에서 pathname==="/wrap" 가드로 제외하거나 // route group 으로 분리한다. 아래는 body 클래스 부여 + 자체 컨테이너만. import "@/styles/wrap.css"; export default function WrapLayout({ children }: { children: React.ReactNode }) { return
{children}
; // body.wrap-page 배경은 globals 에서 [data-route="wrap"] 또는 클래스로 적용(아래 §6.3). } ``` > **셸 제거 구현 정본**: 루트 `app/layout.tsx` 가 `` 를 무조건 렌더한다면, (a) `/wrap` 을 별도 **route group**(`app/(fullscreen)/wrap/`)으로 빼서 그 그룹 layout 에는 Topbar 를 두지 않거나, (b) 루트 layout 에서 `usePathname()` 으로 `/wrap` 시 Topbar 를 숨긴다. 본 문서는 (a) route group 방식을 권장(셸 의존을 컴파일 타임에 끊음). `body.wrap-page` 는 그룹 layout 에서 `document.body.classList` 또는 ``(서버 컴포넌트면 metadata 불가하므로 클라이언트 effect)로 부여. #### 3.2.2 하루 마감 페이지 본문 원본 `App()` 구조를 그대로 옮기되, 데이터는 `GET /api/wrap`, 상태(회고/수면/이월 되돌리기)는 localStorage + 선택적 백엔드 PUT. ```tsx // frontend/app/wrap/page.tsx "use client"; import { useWrap } from "@/lib/hooks/useWrap"; import { WrapHero } from "@/components/wrap/WrapHero"; import { WrapStats } from "@/components/wrap/WrapStats"; import { Highlights } from "@/components/wrap/Highlights"; import { Reflection } from "@/components/wrap/Reflection"; import { Rollover } from "@/components/wrap/Rollover"; import { TomorrowPreview } from "@/components/wrap/TomorrowPreview"; import { SleepMode } from "@/components/wrap/SleepMode"; import { Icon } from "@/components/Icon"; export default function WrapPage() { const { data, isLoading, error, retry } = useWrap(); if (isLoading || !data) return ; if (error) return ; return (
{/* ---- 상단(셸 없음) ---- */}
대시보드
{data.date} · {data.now}
아리는 밤에도 메일과 일정을 조용히 정리해둘게요 · 급한 일만 깨워드려요
); } ``` #### 3.2.3 인사·숫자·해낸 일 ```tsx // frontend/components/wrap/WrapHero.tsx (원본 .w-hero) import { Icon } from "@/components/Icon"; import DOMPurify from "isomorphic-dompurify"; // lede 는 HTML() 포함 → 새니타이즈(overview §15) export function WrapHero({ user, lede }: { user: { name: string }; lede: string }) { return (
하루 마감 브리핑

오늘 하루, 수고했어요 {user.name}님

); } ``` ```tsx // frontend/components/wrap/WrapStats.tsx (원본 .w-stats) export function WrapStats({ stats }: { stats: WrapStat[] }) { return (
{stats.map((s, i) => (
{s.n}{s.unit}
{s.label}{s.sub && · {s.sub}}
))}
); } type WrapStat = { n: string; unit: string; label: string; sub?: string }; ``` ```tsx // frontend/components/wrap/Highlights.tsx (원본 .hl-list) import { Icon } from "@/components/Icon"; export function Highlights({ highlights }: { highlights: Highlight[] }) { return (

오늘 해낸 일

{highlights.map((h, i) => (
{h.title}
{h.meta}
))}
); } type Highlight = { title: string; meta: string; tone: string; big?: boolean }; ``` #### 3.2.4 한 줄 회고 (localStorage + 선택적 PUT) 원본은 `localStorage["ari.wrap.note"]` 만 썼다. 우리는 동일 키로 localStorage 미러를 유지하되, 백엔드에 `PUT /api/wrap/reflection` 으로 영속(연합: 내일 아침 브리핑에 합류). ```tsx // frontend/components/wrap/Reflection.tsx (원본 .w-note + load/save) "use client"; import { useState, useEffect, useRef } from "react"; import { Icon } from "@/components/Icon"; import { api } from "@/lib/api"; const KEY = "ari.wrap.note"; function load(fallback = "") { try { const v = localStorage.getItem(KEY); return v === null ? fallback : JSON.parse(v); } catch { return fallback; } } function save(v: string) { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch {} } export function Reflection({ initial }: { initial?: string }) { const [note, setNote] = useState(() => load(initial ?? "")); const t = useRef>(); useEffect(() => { save(note); // 디바운스 후 백엔드 영속(실패해도 localStorage 가 SSOT — 데모 안정성) clearTimeout(t.current); t.current = setTimeout(() => { api.put("/wrap/reflection", { text: note }).catch(() => {}); }, 600); }, [note]); return (

오늘 한 줄

내일 아침 브리핑에 함께 보여드려요
setNote(e.target.value)} placeholder="오늘은 어떤 하루였나요? 한 줄이면 충분해요…" /> {note.trim() && 저장됨}
); } ``` > **회고 영속 정본**: localStorage 가 **데모 SSOT**(오프라인·백엔드 없이도 동작, overview §15 오프라인 원칙). 백엔드 PUT 은 best-effort 미러로, 성공 시 `day_reflection` 행을 갱신 → `GET /api/wrap.reflection` 및 대시보드 `GET /api/dashboard.briefing.note` 가 다음 날 이를 읽는다(§8 연합). PUT 실패는 UI 에러로 띄우지 않는다(회고는 사적 기록 — 마찰 최소). #### 3.2.5 이월(되돌리기) · 내일 미리보기 · 사전 준비 ```tsx // frontend/components/wrap/Rollover.tsx (원본 .ro-list) "use client"; import { useState } from "react"; import { Icon } from "@/components/Icon"; import { api } from "@/lib/api"; import type { RolloverItem } from "@/lib/types"; export function Rollover({ items }: { items: RolloverItem[] }) { const [undone, setUndone] = useState>({}); const toggle = (it: RolloverItem) => { const next = !undone[it.id]; setUndone((u) => ({ ...u, [it.id]: next })); // 되돌리기=이월 취소(due 를 오늘로 되돌림) / 다시 이월=내일 due 로 (연합 §8) api.post(`/wrap/rollover/${it.id}`, { undo: next }).catch(() => {}); }; return (

남은 일은 내일로 옮겨뒀어요

{items.map((r) => { const off = undone[r.id]; return (
{r.title}{r.project}
{off ? "이월 취소됨 — 오늘 할 일로 남겨둘게요" : r.to}
{!off &&
{r.reason}
}
); })}
); } ``` ```tsx // frontend/components/wrap/TomorrowPreview.tsx (원본 .tm-list + .prep) import { Icon } from "@/components/Icon"; import type { TomorrowItem, PrepItem } from "@/lib/types"; export function TomorrowPreview({ tomorrow, prep, weatherTomorrow }: { tomorrow: TomorrowItem[]; prep: PrepItem[]; weatherTomorrow: string; }) { return (

내일 미리보기

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