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

92 KiB

Phase 8 — 일정 + 회의 도우미

한 줄 요약: 주/월/일 캘린더(cal.jsx/cal.css 픽셀 재현) · 회의 도우미 드로어 4상태(done/live/upcoming/1:1, cal-meet.jsx) · 아리가 빈 시간에 자동 배치하는 집중 모드 블록 · 회의 액션아이템 → 작업 실체화(federation) 를, mock-first 커넥터 위에 구현한다.

이 문서는 포스트-MVP 세트의 일부 — 먼저 dev/overview.mddev/post-mvp-overview.md 를 읽으세요. 선행: phase-2-backend.md(모델/스키마/라우터/시드/LLM 추상화 규약), phase-3-tasks.md(작업 트리·칸반·드로어, materialized 연합), phase-7-approvals-automation.md(승인 큐·자동화 엔진·event_bus). 후속: phase-9-mail-notifications.md.


1. 개요 & 목표

이 phase가 끝나면 일정 페이지(/calendar)와 회의 도우미가 동작한다. 구체적으로:

  • 주/월/일 3개 뷰cal.jsx/cal.css를 픽셀 충실하게 재현한다. 오늘 = 6월 8일(월), 이번 주 = 6/7(일)~6/13(토). 시간 그리드 7~22시, PXH=52px, HSTART=7.
  • 좌측 SubRail에 미니 달력(MiniCal, 2026년 6월) + 캘린더 카테고리 토글(업무/미팅/개인/팀/건강 5종)이 표시되고 토글로 이벤트가 필터된다.
  • 집중 모드 블록(focusBlocks)이 아리가 빈 시간에 자동 배치한 딥워크/가벼운 일로 주/일 뷰의 빈 슬롯에 점선 블록으로 렌더되고, 상단 집중 블록 토글로 켜고 끈다.
  • 일 뷰 어젠다/이벤트 팝오버에서 회의 도우미 드로어를 연다. 드로어는 4상태:
    • done: 음성 기록 → 요약·결정·액션 아이템 추출 → "작업으로"/"남은 N건 모두 작업으로".
    • live: 실시간 기록(지금까지 논의 + pulse 배지).
    • upcoming: 사전 브리핑(안건·지난 회의·지난 액션 체크·아리 인사이트·docs).
    • 1:1: 지난 1:1 요약·지난 약속 체크·상대 시그널·대화 주제.
  • 액션 아이템 → 작업 실체화(federation): 드로어의 "작업으로" 버튼이 POST /api/calendar/meetings/{id}/actions/{idx}/materialize 를 호출 → task 생성(materialized_task_id로 액션과 연결) → 작업 페이지(phase-3)에 즉시 등장. meeting.ended 이벤트가 event_bus(phase-7)로 발행된다.
  • calendar 커넥터는 mock-first: MockCalendarConnector(시드 기반)가 이벤트/회의 데이터를 공급한다. 실제 Google Calendar(RealCalendarConnector)는 phase-13. STT(회의 음성 → 노트)는 phase-14, 여기선 시드 노트(meet.summary/meet.actions)를 그대로 쓴다.
  • 신규 백엔드: 모델 4종(calendar/event(확장)/focus_block/meeting) + 시드(cal-data.js 그대로) + REST API 6종.
  • pytest(백엔드)/Vitest+RTL(드로어 4상태)/Playwright(액션→작업 왕복 E2E)/axe(드로어 a11y)가 전부 green.

이 phase는 "철학의 실현(회의에서 나온 일을 사용자가 손으로 옮기지 않는다 — 아리가 작업으로 보낸다)"을 일정 도메인에서 구현한다. 필드명·엔드포인트·동작은 CONTRACT 와 phase-2 패턴을 그대로 따른다.


2. 선행 조건 / 산출물

2.1 선행 조건 (의존 phase)

의존 내용
phase-2-backend.md models.py/schemas.py/라우터 prefix 규약(/api는 main.py에서만 부착), run_seed(session=None, reset=True), LLM get_provider(), TEXT PK 규약, tone 집합. 본 phase의 신규 테이블/엔드포인트는 이 패턴을 그대로 따른다.
phase-3-tasks.md Task 모델, POST /api/tasks, materialized_* 연합 패턴, frontend/lib/types.ts(Task/Tone/Person), 작업 트리 등장. 회의 액션 → 작업은 phase-3의 Task 생성을 재사용한다.
phase-7-approvals-automation.md backend/app/automation/event_bus.py(내부 이벤트 발행/구독), approval 모델·승인 큐. meeting.ended 이벤트를 발행하고, 사전 브리핑의 "빈 블록 비워둠" 같은 자동 동작이 approval로 흐를 수 있다. 선택 통합(없어도 동작하되, 있으면 event_bus로 발행).
phase-1-design-system.md 디자인 토큰(tokens.css), Topbar(13항목), Icon(중앙 paths 맵), SubRail. 본 phase는 cal 항목을 active 로 표시한다.

2.2 산출물 (Deliverables)

backend/
├─ app/
│  ├─ models.py                 (+ Calendar, CalEvent, FocusBlock, Meeting, MeetingAction)
│  ├─ schemas.py                (+ CalendarOut, CalEventOut, FocusBlockOut, MeetingOut, ...)
│  ├─ seed_calendar.py          cal-data.js 이식 (run_seed 에서 호출)
│  ├─ connectors/
│  │  ├─ __init__.py
│  │  ├─ base.py                CalendarConnector 추상 인터페이스 (+ 도메인 베이스)
│  │  ├─ calendar_mock.py       MockCalendarConnector (시드 기반)
│  │  └─ calendar_real.py       RealCalendarConnector (phase-13 stub)
│  ├─ services/
│  │  ├─ focus.py               빈 블록 계산 → 집중 블록 제안 (free-slot finder)
│  │  └─ meetings.py            회의 데이터 조립 + 액션 → 작업 실체화
│  └─ routers/
│     └─ calendar.py            GET events/day · GET meetings/{id} · POST actions materialize · POST focus/suggest
│                               + create_event_from_extract(s, ev, source)->str (메일→일정 federation 헬퍼; phase-9 가 import)
├─ tests/
│  ├─ test_seed_calendar.py
│  ├─ test_api_calendar.py
│  ├─ test_focus.py
│  └─ test_meeting_materialize.py
frontend/
├─ app/calendar/page.tsx        일정 페이지 (week/month/day + 드로어)
├─ components/calendar/
│  ├─ MiniCal.tsx  CalToggleList.tsx
│  ├─ WeekView.tsx  MonthView.tsx  DayView.tsx
│  ├─ EvBlock.tsx  FocusBlock.tsx  EvPopover.tsx
│  └─ MeetDrawer.tsx            (cal-meet.jsx 이식, 4상태)
├─ lib/calendar/
│  ├─ types.ts                  CalEvent/Calendar/FocusBlock/Meeting/MeetingAction
│  ├─ api.ts                    calendarApi
│  ├─ time.ts                   toMin/fmt/topOf (cal.jsx 이식)
│  └─ store.ts                  localStorage (ari.cal.)
├─ tests/calendar/*.test.tsx    Vitest + RTL
└─ playwright/calendar.spec.ts  액션→작업 왕복 E2E + axe

3. 상세 구현

3.1 백엔드 모델 — models.py (추가분)

phase-2 의 models.py 패턴(TEXT PK, str, Enum, now() 기본값, tone 집합)을 그대로 따른다. 주의: phase-2 에 이미 대시보드용 읽기전용 Event 테이블이 있다(시드 e1~e4, time/title/tag/dur/tone/soon). 일정 페이지의 이벤트는 시간 그리드(day/start/end/cal/loc/people/note/soon/actions)가 필요해 모델이 다르므로 별도 테이블 CalEvent(__tablename__="cal_event") 로 신설한다(기존 event 테이블·대시보드 계약을 깨지 않음).

# backend/app/models.py (Phase 8 추가분)
from __future__ import annotations
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship
# 기존: now(), Sphere, ClsType 등은 phase-2 정의 재사용

# ---------- 캘린더 카테고리 (cal-data.js: cals) ----------
class Calendar(SQLModel, table=True):
    __tablename__ = "calendar"
    id: str = Field(primary_key=True)            # "work"/"meeting"/"personal"/"team"/"health"
    name: str                                     # "업무"/"미팅"/"개인"/"팀"/"건강"
    tone: str = "ink"                             # tone 집합: blue|violet|coral|green|amber|ink|faint
    on: bool = True                               # 기본 표시 여부 (프론트는 localStorage 로 사용자 토글 영속)
    sort_order: int = 0


# ---------- 캘린더 이벤트 (cal-data.js: events) — 시간 그리드용 ----------
class CalEvent(SQLModel, table=True):
    __tablename__ = "cal_event"
    id: str = Field(primary_key=True)            # "e1".."e22" (cal-data.js 그대로; 대시보드 event 와 별개 테이블)
    day: int                                      # 6월 날짜 (7..13)
    start: str                                    # "HH:MM"
    end: str                                      # "HH:MM"
    title: str
    cal: str = Field(foreign_key="calendar.id")  # 카테고리
    loc: str = ""                                 # 장소 (옵션)
    note: str = ""                                # 한 줄 메모 (옵션)
    soon: bool = False                            # '곧' 배지
    people: str = ""                              # "대표님, 재무팀장, 나 외 3명" (콤마구분 문자열로 저장)
    sort_order: int = 0
    # actions(이벤트에 직접 달린 '내가 맡기로 한 일')는 MeetingAction(source="event")로 통합 관리


# ---------- 집중 모드 블록 (cal-data.js: focusBlocks) ----------
class FocusBlock(SQLModel, table=True):
    __tablename__ = "focus_block"
    id: str = Field(primary_key=True)            # "f1".."f4"
    day: int                                      # 6월 날짜 (8 = 오늘)
    start: str                                    # "HH:MM"
    end: str                                      # "HH:MM"
    title: str                                    # "분기 리포트 초안 마무리"
    type: str = "light"                           # "light" | "deep"
    tag: str = ""                                 # "메일"/"개발"/"딥 워크"/"성장"
    task_id: Optional[str] = Field(default=None, foreign_key="task.id")  # 연결된 작업(있으면)
    auto: bool = True                             # 아리 자동 배치 여부
    sort_order: int = 0


# ---------- 회의 (cal-data.js: meets[evId]) ----------
class MeetPhase(str, Enum):
    done = "done"
    live = "live"
    upcoming = "upcoming"


class Meeting(SQLModel, table=True):
    __tablename__ = "meeting"
    id: str = Field(primary_key=True)            # event id 와 동일 키 사용 ("e3","e4","e6","e7")
    event_id: str = Field(foreign_key="cal_event.id")
    phase: MeetPhase = MeetPhase.upcoming
    one_on_one: bool = False
    meta: str = ""                                # "09:3009:45 · 15분 · 음성 기록 → 노트 자동 정리됨"
    starts_in: str = ""                           # upcoming: "2시간 50분 후 시작"
    # JSON 직렬화 필드(리스트/딕트) — SQLite 는 JSON 컬럼을 TEXT 로 저장
    summary_json: str = "[]"                      # list[str]      (done/live)
    decisions_json: str = "[]"                    # list[str]      (done)
    agenda_json: str = "[]"                       # list[str]      (upcoming)
    last_meeting_json: str = "{}"                 # {when,note}
    last_actions_json: str = "[]"                 # [{text,who,done}]
    insights_json: str = "[]"                     # list[str(HTML)] (upcoming)
    docs_json: str = "[]"                         # list[str]
    # 1:1 전용
    person_json: str = "{}"                       # {name,initial,tone}
    promises_json: str = "[]"                     # [{text,done}]
    signals_json: str = "[]"                      # [{k,v,tone}]
    talking_points_json: str = "[]"               # list[str]

    actions: list["MeetingAction"] = Relationship(back_populates="meeting")


# ---------- 회의/이벤트 액션 아이템 (작업으로 실체화 대상) ----------
class MeetingAction(SQLModel, table=True):
    __tablename__ = "meeting_action"
    id: str = Field(primary_key=True)            # "ma1", ...
    meeting_id: Optional[str] = Field(default=None, foreign_key="meeting.id")
    event_id: Optional[str] = Field(default=None, foreign_key="cal_event.id")  # source="event"인 어젠다 액션
    source: str = "meeting"                       # "meeting" | "event"
    idx: int = 0                                  # 회의 내 순번 (UI add-one 인덱스)
    text: str
    who: str = "나"                               # 담당자 표시명 (cal-data.js 의 who 그대로: 나/현우/수아...)
    when_text: str = ""                           # "오늘"/"내일"/"이번 주"
    added: bool = False                           # 작업으로 보냈는가
    materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id")
    sort_order: int = 0

    meeting: Optional[Meeting] = Relationship(back_populates="actions")

JSON 컬럼 규약: 회의 데이터는 중첩 리스트/딕트가 많아 정규화하면 테이블이 과도해진다. phase-2 의 단순 스키마 철학을 유지하기 위해 표시 전용(읽기) 데이터는 *_json TEXT 컬럼json.dumps로 저장하고, 응답 스키마에서 json.loads로 펼친다. 단, actions(작업으로 실체화되는 상태가 있는 데이터)만 정규 테이블 MeetingAction 으로 분리한다 — 실체화/되돌리기/연합이 걸리기 때문이다. who/when/added/materialized_task_id 가 그 상태다.

whoassignee_id 매핑: 액션의 who는 cal-data.js 표시명(/현우/수아/재무팀장)이다. 실체화 시 services/meetings.py_person_id(who)가 person 으로 매핑한다(나→jiwoo, 현우→hyunwoo, 민서→minseo, 재호→jaeho, 수아→sua, 매핑 실패(대표님/재무팀장 등)는 assignee_id=None).


3.2 백엔드 스키마 — schemas.py (추가분)

phase-2 의 *Out 패턴을 따른다. 응답은 프론트가 그대로 쓰는 형태(중첩/펼친 JSON).

# backend/app/schemas.py (Phase 8 추가분)
from __future__ import annotations
from typing import Optional, Any
from pydantic import BaseModel


class CalendarOut(BaseModel):
    id: str
    name: str
    tone: str
    on: bool
    count: int = 0                # 해당 카테고리 이벤트 수 (SubRail 우측 숫자)


class EventActionOut(BaseModel):
    text: str
    when: str                     # cal.jsx 어젠다 액션의 when (오늘/내일/이번 주)


class CalEventOut(BaseModel):
    id: str
    day: int
    start: str
    end: str
    title: str
    cal: str
    loc: str = ""
    note: str = ""
    soon: bool = False
    people: list[str] = []        # 콤마 문자열 → 배열로 펼침
    actions: list[EventActionOut] = []  # source="event" 액션
    has_meeting: bool = False     # meets[ev.id] 존재 → 회의 도우미 진입 버튼 노출
    meet_label: str = ""          # "회의 노트 · 액션 보기" 등 (서버에서 계산)


class FocusBlockOut(BaseModel):
    id: str
    day: int
    start: str
    end: str
    title: str
    type: str                     # light | deep
    tag: str
    task_id: Optional[str] = None
    auto: bool = True


class MeetingActionOut(BaseModel):
    id: str
    idx: int
    text: str
    who: str
    when: str = ""                # when_text → when (UI 표기 통일)
    added: bool
    materialized_task_id: Optional[str] = None


class MeetingOut(BaseModel):
    id: str
    event_id: str
    phase: str                    # done | live | upcoming
    one_on_one: bool = False
    meta: str = ""
    starts_in: str = ""
    # 펼친 JSON
    summary: list[str] = []
    decisions: list[str] = []
    agenda: list[str] = []
    last_meeting: dict[str, Any] = {}     # {when,note}
    last_actions: list[dict[str, Any]] = []  # [{text,who,done}]
    insights: list[str] = []
    docs: list[str] = []
    person: dict[str, Any] = {}           # {name,initial,tone}
    promises: list[dict[str, Any]] = []   # [{text,done}]
    signals: list[dict[str, Any]] = []    # [{k,v,tone}]
    talking_points: list[str] = []
    actions: list[MeetingActionOut] = []  # 상태 있는 액션(작업으로 실체화 대상)


class DayBundleOut(BaseModel):
    """일 뷰 한 번에: 그 날의 이벤트 + 집중 블록."""
    day: int
    events: list[CalEventOut]
    focus_blocks: list[FocusBlockOut]


class WeekBundleOut(BaseModel):
    """주 뷰: 캘린더 카테고리 + 이벤트(전 주) + 집중 블록 + today."""
    today: int
    week: list[int]               # [7,8,9,10,11,12,13]
    weekdays: list[str]           # ["일","월","화","수","목","금","토"]
    calendars: list[CalendarOut]
    events: list[CalEventOut]
    focus_blocks: list[FocusBlockOut]


class MaterializeResponse(BaseModel):
    action: MeetingActionOut
    task: Optional["TaskNode"] = None     # phase-2 TaskNode 재사용
    all_added: bool = False               # 모든 액션이 처리됐는지(드로어 '모두 등록됨' 표시)


class FocusSuggestRequest(BaseModel):
    day: int
    min_minutes: int = 20                 # 최소 빈 블록 길이
    create: bool = False                  # True 면 FocusBlock 실제 저장


class FocusSuggestResponse(BaseModel):
    day: int
    suggestions: list[FocusBlockOut]
    created: bool = False

MaterializeResponse.task 는 phase-2 TaskNode 를 그대로 참조한다(전방참조 후 MaterializeResponse.model_rebuild()). 작업 페이지가 동일한 TaskNode 형태로 새 작업을 받으므로 연합이 깨지지 않는다.


3.3 커넥터 추상화 — connectors/

CONTRACT 횡단 아키텍처: backend/app/connectors/ — 도메인별 base 인터페이스 + MockConnector(시드 기반) + RealConnector(phase-13). 설정 CONNECTOR_<DOMAIN>=mock|real(기본 mock).

# backend/app/connectors/base.py
from __future__ import annotations
from abc import ABC, abstractmethod


class CalendarConnector(ABC):
    """calendar 도메인 커넥터 인터페이스.
    mock = 시드(cal-data.js) 기반, real = Google Calendar(phase-13).
    페이지는 이 인터페이스만 의존 → phase-13 이 같은 시그니처로 real 을 끼운다."""

    @abstractmethod
    def list_events(self, session, day: int | None = None) -> list[dict]: ...

    @abstractmethod
    def get_meeting(self, session, meeting_id: str) -> dict | None: ...

    @abstractmethod
    def write_event(self, session, payload: dict) -> dict: ...
    # write_event 는 phase-13(real) 에서 양방향 동기화에 사용. mock 은 DB 저장만.
# backend/app/connectors/calendar_mock.py
from sqlmodel import Session, select
from .base import CalendarConnector
from ..models import CalEvent, Meeting


class MockCalendarConnector(CalendarConnector):
    """시드(seed_calendar.py)로 적재된 DB 를 그대로 읽는다. STT/외부 API 없음."""

    def list_events(self, s: Session, day=None):
        q = select(CalEvent)
        if day is not None:
            q = q.where(CalEvent.day == day)
        rows = s.exec(q.order_by(CalEvent.day, CalEvent.start)).all()
        return [r.model_dump() for r in rows]

    def get_meeting(self, s: Session, meeting_id: str):
        m = s.get(Meeting, meeting_id)
        return m.model_dump() if m else None

    def write_event(self, s: Session, payload: dict):
        # mock: DB 에만 저장 (외부 동기화 없음). phase-13 real 이 Google API 호출.
        ev = CalEvent(**payload)
        s.add(ev); s.commit()
        return ev.model_dump()
# backend/app/connectors/calendar_real.py  (phase-13 목표 — 지금은 stub)
from .base import CalendarConnector


class RealCalendarConnector(CalendarConnector):
    """Google Calendar API 연동 (phase-13). 지금은 NotImplemented stub.
    동일 인터페이스라 페이지/서비스 코드는 그대로 유지된다."""
    def list_events(self, s, day=None):
        raise NotImplementedError("phase-13: Google Calendar 연동")
    def get_meeting(self, s, meeting_id):
        raise NotImplementedError("phase-13")
    def write_event(self, s, payload):
        raise NotImplementedError("phase-13")
# backend/app/connectors/__init__.py
from functools import lru_cache
from ..config import get_settings
from .calendar_mock import MockCalendarConnector
from .calendar_real import RealCalendarConnector

@lru_cache
def get_calendar_connector():
    mode = getattr(get_settings(), "connector_calendar", "mock")
    return RealCalendarConnector() if mode == "real" else MockCalendarConnector()

config.py 에 추가(phase-2 Settings 확장 — 환경변수 규약 CONNECTOR_CALENDAR=mock|real):

# backend/app/config.py (추가)
    connector_calendar: str = "mock"   # CONNECTOR_CALENDAR=mock|real (기본 mock)

이 phase 는 mock-firstget_calendar_connector() 는 항상 MockCalendarConnector 를 반환한다. 라우터/서비스는 커넥터 인터페이스만 의존하므로, phase-13 에서 CONNECTOR_CALENDAR=real 로 바꾸면 코드 변경 없이 Google Calendar 로 전환된다.


3.4 집중 블록 서비스 — services/focus.py

CONTRACT: "집중모드: 빈 블록 계산 → 블록 제안". 그 날의 이벤트들 사이 빈 슬롯(free slot) 을 찾아 min_minutes 이상이면 집중 블록으로 제안한다. 오후 큰 빈 블록은 deep(딥 워크), 짧은 미팅 사이는 light로 분류한다.

# backend/app/services/focus.py
from __future__ import annotations
from sqlmodel import Session, select
from ..models import CalEvent, FocusBlock

DAY_START_MIN = 9 * 60      # 09:00 (근무 시작)
DAY_END_MIN = 18 * 60       # 18:00
DEEP_THRESHOLD = 60         # 60분 이상 빈 블록 → deep, 미만 → light


def _to_min(t: str) -> int:
    h, m = map(int, t.split(":"))
    return h * 60 + m


def _to_hhmm(mins: int) -> str:
    return f"{mins // 60:02d}:{mins % 60:02d}"


def free_slots(s: Session, day: int) -> list[tuple[int, int]]:
    """그 날 09:00~18:00 중 이벤트가 없는 [start_min, end_min) 구간 목록."""
    evs = s.exec(select(CalEvent).where(CalEvent.day == day)).all()
    busy = sorted([(_to_min(e.start), _to_min(e.end)) for e in evs])
    slots: list[tuple[int, int]] = []
    cur = DAY_START_MIN
    for bs, be in busy:
        if bs > cur:
            slots.append((cur, min(bs, DAY_END_MIN)))
        cur = max(cur, be)
        if cur >= DAY_END_MIN:
            break
    if cur < DAY_END_MIN:
        slots.append((cur, DAY_END_MIN))
    return [(a, b) for a, b in slots if b > a]


def suggest_focus(s: Session, day: int, min_minutes: int = 20) -> list[FocusBlock]:
    """빈 슬롯에 집중 블록 제안. 길이로 deep/light 결정. (저장은 라우터에서 create 옵션으로)"""
    out: list[FocusBlock] = []
    for i, (a, b) in enumerate(free_slots(s, day)):
        length = b - a
        if length < min_minutes:
            continue
        is_deep = length >= DEEP_THRESHOLD
        out.append(FocusBlock(
            id=f"fs{day}-{i}",
            day=day, start=_to_hhmm(a),
            end=_to_hhmm(min(a + (75 if is_deep else 20), b)),
            title="딥 워크 블록" if is_deep else "가벼운 일 처리",
            type="deep" if is_deep else "light",
            tag="딥 워크" if is_deep else "가벼운 일",
            auto=True, sort_order=i,
        ))
    return out

시드(f1~f4)는 cal-data.js 값을 그대로 적재(아래 §3.6). suggest_focus 는 "다시 계산해줘" 같은 동적 제안용이다. 기본 일정 페이지는 시드 focusBlocks 를 표시하고, POST /api/calendar/focus/suggest 는 빈 블록 재계산 데모용.


3.5 회의 서비스 — services/meetings.py (액션 → 작업 실체화)

핵심 연합 로직. cal-meet.jsx 의 "작업으로"/"모두 작업으로"가 호출하는 실체화를 구현한다.

# backend/app/services/meetings.py
from __future__ import annotations
import json, uuid
from sqlmodel import Session, select
from ..models import Meeting, MeetingAction, CalEvent, Task, TaskStatus, Prio, Project

# cal-data.js who(표시명) → person id
WHO_TO_PERSON = {
    "나": "jiwoo", "현우": "hyunwoo", "민서": "minseo",
    "재호": "jaeho", "수아": "sua",
}
# when 표시 → 표시 유지(작업 due 는 별도 추론하지 않음. 데모 결정성 위해 None)
DEFAULT_PROJECT = "me"          # 회의 액션 기본 프로젝트(개인 일상). 안건 키워드로 보정 가능.


def _person_id(who: str) -> str | None:
    return WHO_TO_PERSON.get(who.strip())


def _tid() -> str:
    return "mt-" + uuid.uuid4().hex[:8]


def materialize_action(s: Session, meeting_id: str, idx: int) -> tuple[MeetingAction, Task]:
    """회의 액션 1건 → task 생성 + materialized_task_id 연결."""
    action = s.exec(
        select(MeetingAction).where(
            MeetingAction.meeting_id == meeting_id, MeetingAction.idx == idx)
    ).first()
    if action is None:
        raise ValueError("action not found")
    if action.added and action.materialized_task_id:
        # 이미 작업으로 보냄 — 멱등 처리
        return action, s.get(Task, action.materialized_task_id)

    # 프로젝트 결정: 회의가 연결된 event 의 cal 로 work/life 힌트, 기본 me
    ev = s.get(CalEvent, s.get(Meeting, meeting_id).event_id)
    project_id = DEFAULT_PROJECT
    if ev and ev.cal in ("work", "meeting", "team"):
        project_id = "biz"          # 업무성 회의 → 경영 전략 루트(데모 결정성)
    # 프로젝트 존재 보장
    if not s.get(Project, project_id):
        project_id = DEFAULT_PROJECT

    siblings = s.exec(select(Task).where(Task.project_id == project_id,
                                         Task.parent_id == None)).all()  # noqa: E711
    order = max([t.sort_order for t in siblings] + [-1]) + 1
    task = Task(
        id=_tid(), project_id=project_id, parent_id=None, title=action.text,
        status=TaskStatus.todo, assignee_id=_person_id(action.who),
        due=None, prio=Prio.normal,
        notes=f"<p>회의 <b>{ev.title if ev else ''}</b>의 액션 아이템에서 아리가 만든 작업이에요.</p>",
        est="", sort_order=order,
    )
    s.add(task)
    action.added = True
    action.materialized_task_id = task.id
    s.add(action)
    s.commit(); s.refresh(task); s.refresh(action)
    return action, task


def materialize_all(s: Session, meeting_id: str) -> list[tuple[MeetingAction, Task]]:
    """남은(added=False) 액션 전부 실체화 → '모두 작업으로'."""
    pending = s.exec(
        select(MeetingAction).where(
            MeetingAction.meeting_id == meeting_id, MeetingAction.added == False)  # noqa: E712
    ).all()
    return [materialize_action(s, meeting_id, a.idx) for a in pending]


def assemble_meeting(s: Session, meeting_id: str) -> dict | None:
    """Meeting + actions → MeetingOut dict (JSON 펼침)."""
    m = s.get(Meeting, meeting_id)
    if not m:
        return None
    actions = s.exec(
        select(MeetingAction).where(MeetingAction.meeting_id == meeting_id)
        .order_by(MeetingAction.idx)).all()
    j = json.loads
    return dict(
        id=m.id, event_id=m.event_id, phase=m.phase.value if hasattr(m.phase, "value") else m.phase,
        one_on_one=m.one_on_one, meta=m.meta, starts_in=m.starts_in,
        summary=j(m.summary_json), decisions=j(m.decisions_json), agenda=j(m.agenda_json),
        last_meeting=j(m.last_meeting_json), last_actions=j(m.last_actions_json),
        insights=j(m.insights_json), docs=j(m.docs_json),
        person=j(m.person_json), promises=j(m.promises_json),
        signals=j(m.signals_json), talking_points=j(m.talking_points_json),
        actions=[dict(id=a.id, idx=a.idx, text=a.text, who=a.who, when=a.when_text,
                      added=a.added, materialized_task_id=a.materialized_task_id) for a in actions],
    )

meeting.ended 발행(event_bus): materialize_all 또는 done 회의 첫 진입 시 정본 모듈 app.automation.event_bus(phase-7)의 bus.publish("meeting.ended", {meeting_id, action_ids, task_ids}) 를 호출한다(from ..automation.event_bus import busbus.publish(...); 모듈 편의 export emit = publish 별칭이라 emit("meeting.ended", {...}) 도 동일). 구독자: 하루 마감(phase-12)이 "회의 N건 → 작업 M건" 집계, 자동화(phase-7)가 패턴 탐지.


3.6 시드 — seed_calendar.py (cal-data.js 이식)

cal-data.js 의 값을 그대로 적재한다. run_seed(phase-2) 안에서 _seed_calendar(s) 로 호출.

# backend/app/seed_calendar.py
import json
from sqlmodel import Session, select
from .models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction, MeetPhase

# ---- 캘린더 카테고리 (cal-data.js: cals) ----
CALS = [
    ("work",     "업무", "blue",   True,  0),
    ("meeting",  "미팅", "amber",  True,  1),
    ("personal", "개인", "violet", True,  2),
    ("team",     "팀",   "green",  True,  3),
    ("health",   "건강", "coral",  True,  4),
]

# ---- 이벤트 (cal-data.js: events) — (id, day, start, end, title, cal, loc, note, soon, people) ----
EVENTS = [
    ("e1",  7, "10:00", "11:00", "모닝 러닝 5km",          "health",   "한강공원", "", False, ""),
    ("e2",  7, "15:00", "16:30", "독서모임 — 몰입",         "personal", "북카페",   "", False, ""),
    ("e3",  8, "09:30", "09:45", "팀 데일리 스탠드업",       "team",     "Zoom",    "", False, ""),
    ("e4",  8, "11:00", "11:45", "디자인 리뷰 — 온보딩 플로우","work",    "회의실 B", "", False, ""),
    ("e5",  8, "13:00", "13:30", "점심 — 현우님",           "personal", "1층 식당", "", False, ""),
    ("e6",  8, "14:00", "15:00", "분기 전략 미팅",          "meeting",  "대회의실",
     "지난 회의 액션 4/5 완료 · 리텐션 +6%p · 예산 재배분 안건", True, "대표님, 재무팀장, 나 외 3명"),
    ("e7",  8, "16:30", "17:00", "1:1 — 민서님",            "team",     "라운지",   "", False, ""),
    ("e8",  8, "20:00", "20:30", "스트레칭 & 명상",         "health",   "",        "", False, ""),
    ("e9",  9, "10:00", "11:30", "사용자 인터뷰 ×2",        "work",     "리서치룸", "", False, ""),
    ("e10", 9, "14:00", "15:00", "온보딩 와이어프레임 싱크", "work",     "Figma",   "", False, ""),
    ("e11", 9, "18:00", "19:00", "PT 트레이닝",             "health",   "피트니스", "", False, ""),
    ("e12",10, "09:30", "09:45", "팀 데일리 스탠드업",       "team",     "Zoom",    "", False, ""),
    ("e13",10, "11:00", "12:00", "예산 재배분 검토",        "meeting",  "회의실 A", "", False, ""),
    ("e14",10, "15:00", "16:00", "분기 리포트 작성 블록",    "work",     "",  "딥 워크 — 방해 금지", False, ""),
    ("e15",11, "10:00", "11:00", "OKR 중간 점검",           "meeting",  "대회의실", "", False, ""),
    ("e16",11, "13:30", "14:30", "음성 인터페이스 톤 워크숍","work",     "회의실 C", "", False, ""),
    ("e17",11, "19:30", "21:00", "저녁 — 가족 모임",        "personal", "본가",    "", False, ""),
    ("e18",12, "09:30", "09:45", "팀 데일리 스탠드업",       "team",     "Zoom",    "", False, ""),
    ("e19",12, "11:00", "12:00", "스프린트 리뷰 & 회고",    "team",     "대회의실", "", False, ""),
    ("e20",12, "14:00", "15:30", "신규 입사자 온보딩 세션",  "work",     "교육장",   "", False, ""),
    ("e21",12, "16:00", "16:30", "치과 예약",               "health",   "강남 치과","", False, ""),
    ("e22",13, "11:00", "13:00", "브런치 — 수아님",         "personal", "성수",    "", False, ""),
]

# ---- 이벤트 직접 액션 (cal.jsx 어젠다 '내가 맡기로 한 일' — e3, e4) ----
EVENT_ACTIONS = {
    "e3": [("온보딩 와이어프레임 피드백 정리", "오늘"), ("푸시 알림 QA 결과 공유", "내일")],
    "e4": [("온보딩 3번 화면 CTA 수정안 반영", "내일"), ("음성 안내 카피 검토", "이번 주")],
}

# ---- 집중 블록 (cal-data.js: focusBlocks, day 8) ----
FOCUS = [
    ("f1", 8, "09:00", "09:20", "현우님 온보딩 시안 회신", "light", "메일",    0),
    ("f2", 8, "10:05", "10:25", "PR #482 빠른 리뷰",      "light", "개발",    1),
    ("f3", 8, "15:10", "16:25", "분기 리포트 초안 마무리",  "deep",  "딥 워크", 2),
    ("f4", 8, "17:10", "17:35", "독서모임 책 2장 읽기",    "light", "성장",    3),
]

# ---- 회의 (cal-data.js: meets) ----
# done: e3 / live: e4 / upcoming: e6 / 1:1 upcoming: e7
MEETINGS = {
    "e3": dict(phase="done", one_on_one=False,
        meta="09:3009:45 · 15분 · 음성 기록 → 노트 자동 정리됨",
        summary=["온보딩 와이어프레임 피드백이 중심 안건 — 3번 화면 CTA 수정안으로 정리",
                 "푸시 알림 QA는 iOS 완료 · Android 진행 중",
                 "분기 리포트는 매출 섹션만 남음 — 현우님이 오늘 중 데이터 전달"],
        decisions=["온보딩 3번 화면 CTA는 하단 고정형으로 확정"],
        actions=[("온보딩 와이어프레임 피드백 정리", "나", "오늘", True),
                 ("푸시 알림 QA 결과 공유", "수아", "내일", True),
                 ("매출 데이터 전달", "현우", "오늘", False)]),
    "e4": dict(phase="live", one_on_one=False,
        meta="11:0011:45 · 진행 중 · 아리가 실시간으로 받아적고 있어요",
        summary=["환영 화면 카피 톤 — ‘친근하게, 두 문장 이내’로 의견 수렴 중",
                 "권한 요청 화면 재배치안 공유 — 이탈률 데이터 근거로 논의"],
        decisions=[],
        actions=[("온보딩 3번 화면 CTA 수정안 반영", "나", "내일", False)]),
    "e6": dict(phase="upcoming", one_on_one=False, starts_in="2시간 50분 후 시작",
        agenda=["Q2 핵심 지표 리뷰 — 리텐션 · 매출",
                "예산 재배분 안건 — 오늘 의사결정 필요",
                "다음 분기 OKR 방향 합의"],
        last_meeting={"when": "지난주 월 14:00 · 분기 전략 미팅",
                      "note": "리텐션 개선 실험 3종 승인 · 예산 재배분은 데이터 보강 후 재논의하기로"},
        last_actions=[{"text": "리텐션 코호트 분석", "who": "수아", "done": True},
                      {"text": "MRR / ARR 표 정리", "who": "나", "done": True},
                      {"text": "예산 시나리오 2안 작성", "who": "재무팀장", "done": True},
                      {"text": "경쟁사 가격 조사", "who": "현우", "done": True},
                      {"text": "예측 시나리오 3종", "who": "나", "done": False}],
        insights=["리텐션 KPI가 전분기 대비 <b>+6%p</b> — 첫 화두로 추천드려요",
                  "내 액션 중 <b>‘예측 시나리오 3종</b>이 미완 — 미팅 전 12:00 빈 블록을 비워뒀어요"],
        docs=["분기 리포트 초안 · 75%", "예산 재배분 시나리오 v2"]),
    "e7": dict(phase="upcoming", one_on_one=True, starts_in="오늘 16:30",
        person={"name": "민서", "initial": "민", "tone": "green"},
        last_meeting={"when": "5월 25일 · 지난 1:1",
                      "note": "성장 로드맵 — 데이터 분석 역량을 키우고 싶다는 의사. 분석 과제 1건을 맡겨보기로 약속"},
        promises=[{"text": "데이터 전처리 파이프라인 과제 위임", "done": True},
                  {"text": "분석 강의 수강 지원 품의 올리기", "done": False}],
        signals=[{"k": "업무량", "v": "진행 4건 · 평균보다 +1건", "tone": "amber"},
                 {"k": "위임 과제", "v": "전처리 파이프라인 60% · 순항", "tone": "green"},
                 {"k": "최근 성과", "v": "회고 문서 정리 — 팀 반응 좋음", "tone": "blue"}],
        talking_points=["전처리 과제에서 막히는 부분이 있는지",
                        "강의 지원 품의 — 지난 약속, 진행 상황 공유",
                        "업무량이 살짝 높아요 — 우선순위 조정이 필요한지"]),
}


def _seed_calendar(s: Session) -> None:
    """일정 시드 — run_seed 내부에서 호출하는 헬퍼."""
    for cid_, name, tone, on, order in CALS:
        s.add(Calendar(id=cid_, name=name, tone=tone, on=on, sort_order=order))
    for i, (eid, day, st, en, title, cal, loc, note, soon, people) in enumerate(EVENTS):
        s.add(CalEvent(id=eid, day=day, start=st, end=en, title=title, cal=cal,
                       loc=loc, note=note, soon=soon, people=people, sort_order=i))
    # 이벤트 직접 액션(source="event")
    macnt = 0
    for eid, acts in EVENT_ACTIONS.items():
        for j, (text, when) in enumerate(acts):
            macnt += 1
            s.add(MeetingAction(id=f"ea{macnt}", event_id=eid, source="event",
                                idx=j, text=text, who="나", when_text=when,
                                added=False, sort_order=j))
    for (fid, day, st, en, title, typ, tag, order) in FOCUS:
        s.add(FocusBlock(id=fid, day=day, start=st, end=en, title=title,
                         type=typ, tag=tag, auto=True, sort_order=order))
    s.commit()
    # 회의 + 회의 액션(source="meeting")
    mcnt = 0
    for eid, d in MEETINGS.items():
        s.add(Meeting(
            id=eid, event_id=eid, phase=MeetPhase(d["phase"]),
            one_on_one=d.get("one_on_one", False), meta=d.get("meta", ""),
            starts_in=d.get("starts_in", ""),
            summary_json=json.dumps(d.get("summary", []), ensure_ascii=False),
            decisions_json=json.dumps(d.get("decisions", []), ensure_ascii=False),
            agenda_json=json.dumps(d.get("agenda", []), ensure_ascii=False),
            last_meeting_json=json.dumps(d.get("last_meeting", {}), ensure_ascii=False),
            last_actions_json=json.dumps(d.get("last_actions", []), ensure_ascii=False),
            insights_json=json.dumps(d.get("insights", []), ensure_ascii=False),
            docs_json=json.dumps(d.get("docs", []), ensure_ascii=False),
            person_json=json.dumps(d.get("person", {}), ensure_ascii=False),
            promises_json=json.dumps(d.get("promises", []), ensure_ascii=False),
            signals_json=json.dumps(d.get("signals", []), ensure_ascii=False),
            talking_points_json=json.dumps(d.get("talking_points", []), ensure_ascii=False),
        ))
        for k, (text, who, when, added) in enumerate(d.get("actions", [])):
            mcnt += 1
            s.add(MeetingAction(id=f"ma{mcnt}", meeting_id=eid, source="meeting",
                                idx=k, text=text, who=who, when_text=when,
                                added=added, sort_order=k))
    s.commit()

run_seed(phase-2 seed.py)의 _run() 마지막에 한 줄 추가(reset 목록에도 신규 테이블 포함):

# backend/app/seed.py (수정)
from .seed_calendar import _seed_calendar
from .models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
# _run(reset) 의 reset 테이블 목록에 추가:
#   MeetingAction, Meeting, FocusBlock, CalEvent, Calendar  (FK 역순 삭제)
# _run() 본문 끝(_seed_dashboard 다음)에:
    _seed_calendar(s)
    s.commit()

시드 진입 규약은 phase-2 그대로 run_seed(session=None, reset=True) — 신규 헬퍼는 그 하위 호출이다(공개 진입점 추가 금지).


3.7 라우터 — routers/calendar.py

라우터 prefix 규약(phase-2): 라우터 내부 prefix 없이 정의, main.pyinclude_router(prefix="/api", tags=["calendar"]) 에서만 /api 부착.

# backend/app/routers/calendar.py
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
from ..schemas import (CalendarOut, CalEventOut, EventActionOut, FocusBlockOut,
                       WeekBundleOut, DayBundleOut, MeetingOut, MeetingActionOut,
                       MaterializeResponse, FocusSuggestRequest, FocusSuggestResponse)
from ..services.focus import suggest_focus
from ..services.meetings import materialize_action, materialize_all, assemble_meeting
from ..connectors import get_calendar_connector
from ..routers.tasks import to_node       # phase-2 TaskNode 빌더 재사용

router = APIRouter()                       # prefix 없음. main.py 에서 prefix="/api" 등록.

WEEK = [7, 8, 9, 10, 11, 12, 13]
WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]
TODAY = 8


def _end_from(start: str, dur: int) -> str:
    """start("HH:MM") + dur(분) → end("HH:MM")."""
    h, m = map(int, start.split(":"))
    total = h * 60 + m + dur
    return f"{total // 60:02d}:{total % 60:02d}"


def meet_label(m: Meeting | None) -> str:
    if m is None:
        return ""
    if m.one_on_one:
        return "1:1 어시스턴트 브리핑"
    if m.phase == "upcoming" or getattr(m.phase, "value", m.phase) == "upcoming":
        return "사전 브리핑 보기"
    if m.phase == "live" or getattr(m.phase, "value", m.phase) == "live":
        return "실시간 기록 보기"
    return "회의 노트 · 액션 보기"


def create_event_from_extract(s: Session, ev: dict, source: str) -> str:
    """메일→일정 federation 헬퍼. ev = {title, date|day, time, dur, place?} →
    CalEvent 생성(연합 출처 source 기록) 후 id 반환. phase-9 routers/mail.py 가
    `from ..routers.calendar import create_event_from_extract` 로 사용한다.
    (write_event 커넥터를 통한 저장이며, source 는 ApprovalSource 허용값: mail/calendar/...)"""
    eid = "ce-" + uuid.uuid4().hex[:8]
    day = int(ev.get("day") or ev.get("date"))          # date|day → 6월 날짜(int)
    start = ev["time"]
    end = _end_from(start, int(ev.get("dur", 60)))       # time + dur(분) → "HH:MM"
    conn = get_calendar_connector()
    conn.write_event(s, dict(                            # mock=DB 저장 / real(phase-13)=Google 동기화
        id=eid, day=day, start=start, end=end, title=ev["title"],
        cal="meeting", loc=ev.get("place", ""), note=f"연합 출처: {source}",
        soon=False, people="", sort_order=0,
    ))
    return eid


def _event_out(s: Session, e: CalEvent) -> CalEventOut:
    m = s.get(Meeting, e.id)
    eacts = s.exec(select(MeetingAction).where(
        MeetingAction.event_id == e.id, MeetingAction.source == "event")
        .order_by(MeetingAction.idx)).all()
    return CalEventOut(
        id=e.id, day=e.day, start=e.start, end=e.end, title=e.title, cal=e.cal,
        loc=e.loc, note=e.note, soon=e.soon,
        people=[p.strip() for p in e.people.split(",") if p.strip()],
        actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts],
        has_meeting=m is not None, meet_label=meet_label(m),
    )


@router.get("/calendar/week", response_model=WeekBundleOut)
def get_week(s: Session = Depends(get_session)):
    conn = get_calendar_connector()                 # mock-first
    rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)]
    cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all()
    counts: dict[str, int] = {}
    for e in rows:
        counts[e.cal] = counts.get(e.cal, 0) + 1
    fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all()
    return WeekBundleOut(
        today=TODAY, week=WEEK, weekdays=WEEKDAYS,
        calendars=[CalendarOut(id=c.id, name=c.name, tone=c.tone, on=c.on,
                               count=counts.get(c.id, 0)) for c in cals],
        events=[_event_out(s, e) for e in rows],
        focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs],
    )


@router.get("/calendar/day/{day}", response_model=DayBundleOut)
def get_day(day: int, s: Session = Depends(get_session)):
    evs = s.exec(select(CalEvent).where(CalEvent.day == day)
                 .order_by(CalEvent.start)).all()
    fbs = s.exec(select(FocusBlock).where(FocusBlock.day == day)
                 .order_by(FocusBlock.start)).all()
    return DayBundleOut(day=day, events=[_event_out(s, e) for e in evs],
                        focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs])


@router.get("/calendar/meetings/{mid}", response_model=MeetingOut)
def get_meeting(mid: str, s: Session = Depends(get_session)):
    data = assemble_meeting(s, mid)
    if data is None:
        raise HTTPException(404, "meeting not found")
    return MeetingOut(**data)


@router.post("/calendar/meetings/{mid}/actions/{idx}/materialize",
             response_model=MaterializeResponse)
def materialize_one(mid: str, idx: int, s: Session = Depends(get_session)):
    try:
        action, task = materialize_action(s, mid, idx)
    except ValueError:
        raise HTTPException(404, "action not found")
    remaining = s.exec(select(MeetingAction).where(
        MeetingAction.meeting_id == mid, MeetingAction.added == False)).all()  # noqa: E712
    return MaterializeResponse(
        action=MeetingActionOut(id=action.id, idx=action.idx, text=action.text,
                                who=action.who, when=action.when_text, added=action.added,
                                materialized_task_id=action.materialized_task_id),
        task=to_node(s, task, s.exec(select(__import__('app.models', fromlist=['Task']).Task)).all()),
        all_added=len(remaining) == 0,
    )


@router.post("/calendar/meetings/{mid}/actions/materialize-all",
             response_model=list[MaterializeResponse])
def materialize_remaining(mid: str, s: Session = Depends(get_session)):
    from ..models import Task
    pairs = materialize_all(s, mid)
    all_tasks = s.exec(select(Task)).all()
    return [MaterializeResponse(
        action=MeetingActionOut(id=a.id, idx=a.idx, text=a.text, who=a.who,
                                when=a.when_text, added=a.added,
                                materialized_task_id=a.materialized_task_id),
        task=to_node(s, t, all_tasks), all_added=True) for a, t in pairs]


@router.post("/calendar/focus/suggest", response_model=FocusSuggestResponse)
def focus_suggest(body: FocusSuggestRequest, s: Session = Depends(get_session)):
    blocks = suggest_focus(s, body.day, body.min_minutes)
    if body.create:
        for b in blocks:
            s.add(b)
        s.commit()
    return FocusSuggestResponse(
        day=body.day, created=body.create,
        suggestions=[FocusBlockOut(**b.model_dump()) for b in blocks])

main.py 등록(phase-2 패턴):

# backend/app/main.py (추가)
from .routers import calendar
app.include_router(calendar.router, prefix="/api", tags=["calendar"])

API 계약 표

메서드 경로 용도
GET /api/calendar/week 주 뷰 번들: 카테고리(+count)·전 주 이벤트·집중 블록·today/week/weekdays
GET /api/calendar/day/{day} 일 뷰 번들: 그 날 이벤트 + 집중 블록
GET /api/calendar/meetings/{mid} 회의 도우미 데이터(펼친 JSON + 액션)
POST /api/calendar/meetings/{mid}/actions/{idx}/materialize 액션 1건 → task 실체화(federation)
POST /api/calendar/meetings/{mid}/actions/materialize-all 남은 액션 모두 → task
POST /api/calendar/focus/suggest 빈 블록 계산 → 집중 블록 제안(create로 저장)

월 뷰는 별도 엔드포인트 없이 GET /api/calendar/weekevents(전 주)를 프론트가 day 별로 묶어 렌더한다(원본 MonthViewbyDay 로 묶는 방식 동일). 6월 전체가 필요하면 events 가 7~13일만 가지므로 월 뷰의 다른 날은 빈 셀로 표시된다(원본도 동일: events 는 한 주만 존재).

federation 헬퍼 (HTTP 외부 노출 아님 — 모듈 함수 계약)

시그니처 용도
create_event_from_extract(s: Session, ev: dict, source: str) -> str 메일→일정 federation. ev = {title, date|day, time, dur, place?}CalEvent 생성(연합 출처 source 기록) 후 id 반환

phase-9 가 import 한다: from ..routers.calendar import create_event_from_extract. phase-9 routers/mail.py 의 메일 AI 추출(email.ai.events[])이 이 함수를 호출해 cal_event 로 실체화한다(액션→작업 패턴과 대칭). source 는 ApprovalSource 허용값(mail/calendar/...)을 그대로 쓴다. 내부적으로 커넥터 write_event 를 통해 저장하므로 phase-13 real 전환 시 Google Calendar 양방향 동기화로 자동 승계된다.

요청/응답 예시

GET /api/calendar/day/8 (축약):

{
  "day": 8,
  "events": [
    {"id":"e3","day":8,"start":"09:30","end":"09:45","title":"팀 데일리 스탠드업",
     "cal":"team","loc":"Zoom","note":"","soon":false,"people":[],
     "actions":[{"text":"온보딩 와이어프레임 피드백 정리","when":"오늘"},
                {"text":"푸시 알림 QA 결과 공유","when":"내일"}],
     "has_meeting":true,"meet_label":"회의 노트 · 액션 보기"},
    {"id":"e6","day":8,"start":"14:00","end":"15:00","title":"분기 전략 미팅",
     "cal":"meeting","loc":"대회의실",
     "note":"지난 회의 액션 4/5 완료 · 리텐션 +6%p · 예산 재배분 안건",
     "soon":true,"people":["대표님","재무팀장","나 외 3명"],
     "actions":[],"has_meeting":true,"meet_label":"사전 브리핑 보기"},
    {"id":"e7","day":8,"start":"16:30","end":"17:00","title":"1:1 — 민서님",
     "cal":"team","loc":"라운지","note":"","soon":false,"people":[],
     "actions":[],"has_meeting":true,"meet_label":"1:1 어시스턴트 브리핑"}
  ],
  "focus_blocks": [
    {"id":"f1","day":8,"start":"09:00","end":"09:20","title":"현우님 온보딩 시안 회신",
     "type":"light","tag":"메일","task_id":null,"auto":true},
    {"id":"f3","day":8,"start":"15:10","end":"16:25","title":"분기 리포트 초안 마무리",
     "type":"deep","tag":"딥 워크","task_id":null,"auto":true}
  ]
}

POST /api/calendar/meetings/e3/actions/2/materialize (e3 의 idx=2 "매출 데이터 전달" → task):

{
  "action": {"id":"ma3","idx":2,"text":"매출 데이터 전달","who":"현우","when":"오늘",
             "added":true,"materialized_task_id":"mt-9af3c1d0"},
  "task": {"id":"mt-9af3c1d0","project_id":"biz","parent_id":null,"title":"매출 데이터 전달",
           "status":"todo","assignee_id":"hyunwoo","due":null,"prio":"보통",
           "notes":"<p>회의 <b>‘팀 데일리 스탠드업’</b>의 액션 아이템에서 아리가 만든 작업이에요.</p>",
           "est":"","delegated":false,"sort_order":7,"comments":[],"children":[]},
  "all_added": true
}

GET /api/calendar/meetings/e7 (1:1, 축약):

{
  "id":"e7","event_id":"e7","phase":"upcoming","one_on_one":true,"starts_in":"오늘 16:30",
  "person":{"name":"민서","initial":"민","tone":"green"},
  "last_meeting":{"when":"5월 25일 · 지난 1:1","note":"성장 로드맵 — ..."},
  "promises":[{"text":"데이터 전처리 파이프라인 과제 위임","done":true},
              {"text":"분석 강의 수강 지원 품의 올리기","done":false}],
  "signals":[{"k":"업무량","v":"진행 4건 · 평균보다 +1건","tone":"amber"}],
  "talking_points":["전처리 과제에서 막히는 부분이 있는지", "..."],
  "summary":[],"decisions":[],"agenda":[],"last_actions":[],"insights":[],"docs":[],"actions":[]
}

3.8 프론트엔드 — 타입/API/시간 유틸

// frontend/lib/calendar/types.ts
import type { Tone, Task } from '@/lib/types';   // phase-3 재사용

export interface Calendar { id: string; name: string; tone: Tone; on: boolean; count: number; }
export interface EventAction { text: string; when: string; }
export interface CalEvent {
  id: string; day: number; start: string; end: string; title: string;
  cal: string; loc: string; note: string; soon: boolean;
  people: string[]; actions: EventAction[]; has_meeting: boolean; meet_label: string;
}
export interface FocusBlock {
  id: string; day: number; start: string; end: string;
  title: string; type: 'light' | 'deep'; tag: string; task_id: string | null; auto: boolean;
}
export interface MeetingAction {
  id: string; idx: number; text: string; who: string; when: string;
  added: boolean; materialized_task_id: string | null;
}
export interface Meeting {
  id: string; event_id: string; phase: 'done' | 'live' | 'upcoming'; one_on_one: boolean;
  meta: string; starts_in: string;
  summary: string[]; decisions: string[]; agenda: string[];
  last_meeting: { when?: string; note?: string };
  last_actions: { text: string; who: string; done: boolean }[];
  insights: string[]; docs: string[];
  person: { name?: string; initial?: string; tone?: Tone };
  promises: { text: string; done: boolean }[];
  signals: { k: string; v: string; tone: Tone }[];
  talking_points: string[];
  actions: MeetingAction[];
}
export interface WeekBundle {
  today: number; week: number[]; weekdays: string[];
  calendars: Calendar[]; events: CalEvent[]; focus_blocks: FocusBlock[];
}
export interface DayBundle { day: number; events: CalEvent[]; focus_blocks: FocusBlock[]; }
export interface MaterializeResult { action: MeetingAction; task: Task | null; all_added: boolean; }
// frontend/lib/calendar/api.ts
import type { WeekBundle, DayBundle, Meeting, MaterializeResult, FocusBlock } from './types';
const J = { 'Content-Type': 'application/json' };
async function ok<T>(r: Response): Promise<T> {
  if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
  return r.json() as Promise<T>;
}
export const calendarApi = {
  week: () => fetch('/api/calendar/week').then(ok<WeekBundle>),
  day: (d: number) => fetch(`/api/calendar/day/${d}`).then(ok<DayBundle>),
  meeting: (id: string) => fetch(`/api/calendar/meetings/${id}`).then(ok<Meeting>),
  materialize: (mid: string, idx: number) =>
    fetch(`/api/calendar/meetings/${mid}/actions/${idx}/materialize`, { method: 'POST' })
      .then(ok<MaterializeResult>),
  materializeAll: (mid: string) =>
    fetch(`/api/calendar/meetings/${mid}/actions/materialize-all`, { method: 'POST' })
      .then(ok<MaterializeResult[]>),
  focusSuggest: (day: number, min_minutes = 20, create = false) =>
    fetch('/api/calendar/focus/suggest', { method: 'POST', headers: J,
      body: JSON.stringify({ day, min_minutes, create }) })
      .then(ok<{ day: number; suggestions: FocusBlock[]; created: boolean }>),
};
// frontend/lib/calendar/time.ts  (cal.jsx 의 toMin/fmt/topOf 이식)
export const HSTART = 7;
export const PXH = 52;
export const HOURS = Array.from({ length: 16 }, (_, i) => 7 + i);   // 7..22

export const toMin = (t: string): number => {
  const [h, m] = t.split(':').map(Number);
  return h * 60 + m;
};
export const fmt = (t: string): string => {
  const [h, m] = t.split(':').map(Number);
  const ap = h < 12 ? '오전' : '오후';
  const hh = h % 12 === 0 ? 12 : h % 12;
  return `${ap} ${hh}:${String(m).padStart(2, '0')}`;
};
export const topOf = (t: string): number => ((toMin(t) - HSTART * 60) / 60) * PXH;
// frontend/lib/calendar/store.ts  (cal.jsx 의 load/save, 접두사 ariC. → ari.cal.)
const PREFIX = 'ari.cal.';
export function loadLS<T>(key: string, fb: T): T {
  if (typeof window === 'undefined') return fb;
  try { const v = localStorage.getItem(PREFIX + key); return v === null ? fb : (JSON.parse(v) as T); }
  catch { return fb; }
}
export function saveLS<T>(key: string, v: T): void {
  if (typeof window === 'undefined') return;
  try { localStorage.setItem(PREFIX + key, JSON.stringify(v)); } catch { /* quota */ }
}

저장 키(원본 ariC. 매핑): theme(전역은 next-themes 가 관리하므로 미사용), view('week'), selDay(8), calOn({work:true,...}), focusOn(true). 데이터가 아니라 UI 상태만 저장.


3.9 프론트엔드 — 페이지 & 컴포넌트

cal.jsx 의 구조를 컴포넌트로 분해한다. 페이지는 client component("use client") — 뷰 전환/드로어/팝오버 상태가 필요.

// frontend/app/calendar/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { calendarApi } from '@/lib/calendar/api';
import { loadLS, saveLS } from '@/lib/calendar/store';
import type { WeekBundle, CalEvent } from '@/lib/calendar/types';
import { MiniCal } from '@/components/calendar/MiniCal';
import { CalToggleList } from '@/components/calendar/CalToggleList';
import { WeekView } from '@/components/calendar/WeekView';
import { MonthView } from '@/components/calendar/MonthView';
import { DayView } from '@/components/calendar/DayView';
import { EvPopover } from '@/components/calendar/EvPopover';
import { MeetDrawer } from '@/components/calendar/MeetDrawer';
import { Icon } from '@/components/Icon';

const VIEWS = [{ id: 'week', label: '주' }, { id: 'month', label: '월' }, { id: 'day', label: '일' }];

export default function CalendarPage() {
  const [bundle, setBundle] = useState<WeekBundle | null>(null);
  const [view, setView] = useState<string>(() => loadLS('view', 'week'));
  const [selDay, setSelDay] = useState<number>(() => loadLS('selDay', 8));
  const [calOn, setCalOn] = useState<Record<string, boolean>>(() => loadLS('calOn', {}));
  const [focusOn, setFocusOn] = useState<boolean>(() => loadLS('focusOn', true));
  const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null);
  const [meetEv, setMeetEv] = useState<CalEvent | null>(null);

  useEffect(() => { calendarApi.week().then((b) => {
    setBundle(b);
    setCalOn((cur) => Object.keys(cur).length ? cur
      : Object.fromEntries(b.calendars.map((c) => [c.id, c.on])));
  }); }, []);
  useEffect(() => saveLS('view', view), [view]);
  useEffect(() => saveLS('selDay', selDay), [selDay]);
  useEffect(() => saveLS('calOn', calOn), [calOn]);
  useEffect(() => saveLS('focusOn', focusOn), [focusOn]);

  if (!bundle) return <CalendarSkeleton />;        // §6 로딩 스켈레톤

  const calMap = Object.fromEntries(bundle.calendars.map((c) => [c.id, c]));
  const events = bundle.events.filter((e) => calOn[e.cal] !== false);
  const focusBlocks = focusOn ? bundle.focus_blocks : [];
  const title = view === 'month' ? '2026년 6월'
    : view === 'day' ? `6월 ${selDay}일` : '6월 7일  13일';

  return (
    <div className="tpage">
      {/* Topbar 는 layout 에서. 여기선 cmain. */}
      <div className="twork">
        <aside className="subnav">
          <div className="sn-title">일정</div>
          <button className="sn-new"><Icon name="plus" />  일정</button>
          <MiniCal sel={selDay} today={bundle.today} eventsByDay={countByDay(events)}
            onSel={(n) => { setSelDay(n); if (view !== 'day') setView('day'); }} />
          <div className="sn-label"> 캘린더</div>
          <CalToggleList calendars={bundle.calendars} calOn={calOn}
            onToggle={(id) => setCalOn((s) => ({ ...s, [id]: s[id] === false }))} />
          <div className="sn-foot">
            <div className="av"></div>
            <div className="txt"><b>지우님</b><span>Pro 플랜</span></div>
          </div>
        </aside>

        <main className="cmain">
          <div className="chead">
            <button className="ch-arrow" aria-label="이전"><Icon name="chev" className="flip" /></button>
            <button className="ch-arrow" aria-label="다음"><Icon name="chev" /></button>
            <h1 className="ch-title">{title}</h1>
            <button className="ch-today" onClick={() => setSelDay(bundle.today)}>오늘</button>
            <span className="ch-spacer" />
            <button className={'ch-focus' + (focusOn ? ' on' : '')}
              onClick={() => setFocusOn((v) => !v)} aria-pressed={focusOn}>
              <Icon name="spark" />집중 블록
            </button>
            <div className="view-seg" role="tablist">
              {VIEWS.map((v) => (
                <button key={v.id} role="tab" aria-selected={view === v.id}
                  className={view === v.id ? 'on' : ''} onClick={() => setView(v.id)}>{v.label}</button>
              ))}
            </div>
          </div>

          <div className="ai-strip">
            <div className="ai-ic"><Icon name="spark" /></div>
            <p>오늘 일정  <b>14:00 분기 전략 미팅</b> 가장 중요해요. 미팅 사이  시간엔 가벼운 일을,
              <b>오후 15:10  블록</b> ‘분기 리포트’  워크를 자동으로 배치해뒀어요.
              16:00 ‘치과 예약’은 1:1 가까워 15 앞당기길 추천드려요.</p>
          </div>

          {view === 'week' && (
            <WeekView events={events} focusBlocks={focusBlocks} calMap={calMap}
              week={bundle.week} weekdays={bundle.weekdays} today={bundle.today}
              onOpen={(ev, e) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })} />
          )}
          {view === 'month' && (
            <MonthView events={events} calMap={calMap} weekdays={bundle.weekdays} today={bundle.today}
              onOpen={(ev, e) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })}
              onSelDay={(n) => { setSelDay(n); setView('day'); }} />
          )}
          {view === 'day' && (
            <DayView events={events} focusBlocks={focusBlocks} calMap={calMap}
              day={selDay} week={bundle.week} weekdays={bundle.weekdays} today={bundle.today}
              onOpen={(ev, e) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })}
              onMeet={(ev) => { setPop(null); setMeetEv(ev); }} />
          )}
        </main>
      </div>

      {pop && <EvPopover ev={pop.ev} x={pop.x} y={pop.y} cal={calMap[pop.ev.cal]}
        onClose={() => setPop(null)} onMeet={(ev) => { setPop(null); setMeetEv(ev); }} />}
      {meetEv && <MeetDrawer ev={meetEv} cal={calMap[meetEv.cal]} onClose={() => setMeetEv(null)} />}
    </div>
  );
}

function countByDay(events: CalEvent[]): Record<number, number> {
  const m: Record<number, number> = {};
  events.forEach((e) => { m[e.day] = (m[e.day] || 0) + 1; });
  return m;
}

MeetDrawer.tsxcal-meet.jsx 의 4상태 렌더를 그대로 이식하되, 액션은 서버 실체화 API를 호출한다(원본은 로컬 state 만 갱신; 여기선 낙관적 업데이트 + API).

// frontend/components/calendar/MeetDrawer.tsx
'use client';
import { useEffect, useState } from 'react';
import { calendarApi } from '@/lib/calendar/api';
import type { CalEvent, Calendar, Meeting } from '@/lib/calendar/types';
import { Icon } from '@/components/Icon';

export function MeetDrawer({ ev, cal, onClose }:
  { ev: CalEvent; cal: Calendar; onClose: () => void }) {
  const [meet, setMeet] = useState<Meeting | null>(null);
  useEffect(() => { calendarApi.meeting(ev.id).then(setMeet); }, [ev.id]);
  useEffect(() => {
    const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [onClose]);

  if (!meet) return null;
  const tone = meet.one_on_one && meet.person.tone ? meet.person.tone : cal.tone;

  const addOne = async (idx: number) => {
    // 낙관적 업데이트
    setMeet((m) => m && ({ ...m, actions: m.actions.map((a) =>
      a.idx === idx ? { ...a, added: true } : a) }));
    await calendarApi.materialize(ev.id, idx);     // federation → 작업 페이지에 등장
  };
  const addAll = async () => {
    setMeet((m) => m && ({ ...m, actions: m.actions.map((a) => ({ ...a, added: true })) }));
    await calendarApi.materializeAll(ev.id);
  };
  const remaining = meet.actions.filter((a) => !a.added).length;

  const phaseBadge = meet.phase === 'done'
    ? <span className="md-phase done"><Icon name="tick" />회의 노트 정리됨</span>
    : meet.phase === 'live'
      ? <span className="md-phase live"><span className="md-pulse" />기록 </span>
      : <span className="md-phase up"><Icon name="clock" />{meet.starts_in}</span>;

  return (
    <>
      <div className="dp-backdrop" onClick={onClose} />
      <aside className={'mdrawer tint-' + tone} role="dialog" aria-label="회의 도우미" aria-modal="true">
        <div className="md-head">
          <div className="md-ic"><Icon name={meet.one_on_one ? 'heart' : 'spark'} /></div>
          <div className="md-ht">
            <span className="md-k">{meet.one_on_one ? '1:1 어시스턴트'
              : meet.phase === 'upcoming' ? '사전 브리핑' : '회의 도우미'}</span>
            <b>{ev.title}</b>
            <span className="md-sub">{ev.start}{ev.end}{ev.loc ? ' · ' + ev.loc : ''} · {cal.name}</span>
          </div>
          <button className="md-x" onClick={onClose} aria-label="닫기"><Icon name="x" /></button>
        </div>
        <div className="md-bar">{phaseBadge}{meet.meta && <span className="md-meta">{meet.meta}</span>}</div>
        <div className="md-body">
          {/* 1:1 / upcoming / done|live 분기는 cal-meet.jsx 그대로 (§5 디자인 충실도) */}
          {meet.one_on_one && <OneOnOneBody meet={meet} />}
          {!meet.one_on_one && meet.phase === 'upcoming' && <UpcomingBody meet={meet} />}
          {!meet.one_on_one && meet.phase !== 'upcoming' && (
            <DoneLiveBody meet={meet} remaining={remaining} onAddOne={addOne} onAddAll={addAll} />
          )}
        </div>
      </aside>
    </>
  );
}

OneOnOneBody/UpcomingBody/DoneLiveBodycal-meet.jsx 의 각 분기(<Sec> 들)를 그대로 옮긴다 — 섹션 아이콘/제목/hint/문구는 §5 디자인 충실도 표 참조. ActionRowmd-act/md-act-add/md-act-done 클래스를 그대로 쓴다.

EvBlock/FocusBlock/WeekView/MonthView/DayView/MiniCal/EvPopovercal.jsx 의 동명 컴포넌트를 1:1 이식한다. topOf/toMin/fmtlib/calendar/time.ts 에서 import. tone 클래스(tint-blue 등)·CSS는 cal.cssfrontend/styles/calendar.css 로 무손실 이식(토큰은 tokens.css 공유).


4. 데이터 / 타입 / API 계약

4.1 신규 테이블 요약

테이블 PK 핵심 필드 출처
calendar id(text) name, tone, on, sort_order cal-data.js: cals
cal_event id(text, e1~e22) day, start, end, title, cal→calendar, loc, note, soon, people cal-data.js: events
focus_block id(text, f1~f4) day, start, end, title, type(light|deep), tag, task_id→task, auto cal-data.js: focusBlocks
meeting id(text, =event id) event_id→cal_event, phase, one_on_one, meta, starts_in, *_json cal-data.js: meets
meeting_action id(text, ma*/ea*) meeting_id, event_id, source, idx, text, who, when_text, added, materialized_task_id→task meets[].actions + events[].actions

4.2 enum (고정값)

enum
meeting.phase done · live · upcoming
focus_block.type light · deep
meeting_action.source meeting · event
calendar.tone (tone 집합) cals: work=blue, meeting=amber, personal=violet, team=green, health=coral

tone 집합은 CONTRACT 고정 blue|violet|coral|green|amber|ink|faint. cal-data.js 의 카테고리 tone 을 그대로 사용한다(임의 변경 금지).

4.3 시드 값 인용(검증용 고정값)

  • 카테고리: 업무(blue)·미팅(amber)·개인(violet)·팀(green)·건강(coral) — 정확히 5종.
  • 이벤트 22건(e1~e22), 오늘(day=8)에 8건(e3~e8 + ... 실제 e3,e4,e5,e6,e7,e8 = 6건).
  • 집중 블록 4건(f1~f4), 전부 day=8. f3type="deep"("분기 리포트 초안 마무리", 15:1016:25).
  • 회의 4건: e3(done)·e4(live)·e6(upcoming)·e7(1:1 upcoming).
  • e3 액션 3건 중 2건 added:true, 1건(매출 데이터 전달/현우/오늘) added:false ← E2E 실체화 대상.

5. 디자인 충실도 노트 (원본 토큰/레이아웃/문구 인용)

REF: design-reference/assets/cal.jsx, cal-meet.jsx, cal-data.js, cal.css. 모든 px/HEX/클래스명/한국어 문구는 이 파일들에서 그대로 인용한다.

5.1 레이아웃 / 그리드 (cal.css)

요소 값 (cal.css)
페이지 컨테이너 .tpage { max-width: 1620px; padding: 0 26px 26px; }
본문 .twork { display:flex; gap:18px; padding-top:12px; }
사이드바 .subnav { width:256px; sticky top:86px; } 글래스 카드
시간 그리드 .wk-thour { height:52px; } = PXH 52px, 시간 라벨 7:00~22:00 (HOURS 7..22), font-family: var(--font-mono)
주 헤더 .wk-head { grid-template-columns: 56px repeat(7,1fr); }
오늘 강조 .wk-dh.today .dn { background: var(--coral); color:#fff; } 원형
현재 시각선 .wk-now { border-top: 2px solid var(--coral); } (오늘 6/8 가상 현재 11:10topOf("11:10"))
월 셀 .mo-grid { grid-auto-rows: minmax(108px,1fr); }, 셀당 이벤트 최대 3개 + +N개 더
일 뷰 .day-wrap { grid-template-columns: 1.55fr 1fr; } (그리드 + 사이드: 집중 카드 + 어젠다)
월/주 네비 화살표 .ch-arrow — 다음(next)=<Icon name="chev" />, 이전(prev)=<Icon name="chev" className="flip" />(CSS .flip { transform: rotate(180deg); } 180° 회전). 중앙 paths 맵에 prev/next 글리프를 추가하지 않고 chev 단일 글리프로 통일

5.2 이벤트/집중 블록 색

이벤트 tint 토큰(cal.css):

.tint-blue   { --evc: var(--blue);   --evbg: color-mix(in oklab, var(--blue) 15%, var(--card));   --evtx: color-mix(in oklab, var(--blue) 72%, var(--ink)); }
.tint-amber  { --evc: var(--amber);  --evbg: color-mix(in oklab, var(--amber) 17%, var(--card));  ... }
/* violet / green / coral 동일 패턴 */
  • 이벤트 블록: .ev { border-left: 3px solid var(--evc); background: var(--evbg); color: var(--evtx); }, .ev.soon { box-shadow: 0 0 0 1.5px var(--evc); }(분기 전략 미팅 e6soon:true).
  • 집중 블록: .fblock { border: 1.5px dashed color-mix(in oklab, var(--violet) 52%, transparent); } (light=violet 점선), .fblock.deep { border-color: ...coral...; } (deep=coral 점선).
  • 집중 토글 켜짐: .ch-focus.on { background: var(--violet); color:#fff; }.

5.3 회의 도우미 드로어 (cal.css + cal-meet.jsx)

요소 값/문구
드로어 .mdrawer { position:fixed; right:0; width: min(440px, 94vw); } 우측 슬라이드 @keyframes md-in { translateX(36px)→0 }
phase 배지 done=회의 노트 정리됨(green), live=기록 중+.md-pulse(coral, 1.4s 애니, prefers-reduced-motion 시 정지), upcoming={starts_in}(blue)
헤더 kicker done/live=회의 도우미, upcoming=사전 브리핑, 1:1=1:1 어시스턴트 (.md-k 대문자)
done 섹션 핵심 논의 요약(hint 음성 기록 기반) · 결정 사항(.md-decision green) · 액션 아이템(hint 아리가 대화에서 추출)
live 섹션 지금까지 논의(hint 실시간, icon mic)
upcoming 섹션 오늘 안건(list) · 지난 회의에서(msg+when) · 지난 액션 아이템(hint N/M 완료, 미완 미완 태그) · 아리 인사이트(.md-insight, HTML <b>) · .md-docs(file 칩)
1:1 섹션 지난 1:1 요약(msg) · 지난번 약속한 것(flag, 미완 아직) · {name}님 시그널(hint 아리가 작업·메일에서 정리, .md-sig 점) · 이런 이야기를 나눠보세요(.md-points ol)
액션 한 줄 .md-act — 체크박스 + 텍스트 + {who} · {when} + (작업으로 버튼 / 작업에 있음 done)
모두 보내기 남은 {remaining}건 모두 작업으로 보내기(.md-addall, fill 버튼). 0건이면 모든 액션이 담당자 작업으로 등록됐어요(.md-allok green)
진입 버튼 .meet-open — 어젠다/팝오버에서 meet_label로 라벨(회의 노트 · 액션 보기/사전 브리핑 보기/실시간 기록 보기/1:1 어시스턴트 브리핑)

5.4 AI 한 줄 브리핑 (cal.jsx 그대로)

"오늘 일정 중 14:00 분기 전략 미팅이 가장 중요해요. 미팅 사이 빈 시간엔 가벼운 일을, 오후 15:10 빈 블록엔 '분기 리포트' 딥 워크를 자동으로 배치해뒀어요. 16:00 '치과 예약'은 1:1과 가까워 15분 앞당기길 추천드려요."

.ai-strip — 글래스 + spark 아이콘(coral 16% bg). <b> 는 coral 밑줄(border-bottom: 2px solid color-mix(...coral 55%...)).

5.5 집중 카드 / 어젠다 문구 (일 뷰 사이드)

  • 집중 카드 제목: 오늘의 집중 모드 / 서브 빈 시간에 맞춰 자동 배치했어요 / 노트 오전 미팅 사이엔 가벼운 일을, 오후 빈 블록엔 딥 워크를 넣었어요.
  • 어젠다 헤더: 6월 {day}일 {wd}요일 / 일정 {n}개 · 오늘. 빈 날: 이 날은 일정이 없어요.
  • 어젠다 액션: 내가 맡기로 한 일(.aga-head violet) — e3/e4actions.

6. 상태 처리 (로딩/빈/에러/오프라인) · 엣지케이스

상황 처리
로딩 bundle===nullCalendarSkeleton(시간 그리드 골격 + 사이드바 placeholder, aria-busy="true"). 드로어 로딩: meet===null 동안 드로어 골격 또는 spinner.
빈 일정(특정 날) 일 뷰 어젠다 이 날은 일정이 없어요.(.ag-empty). 빈 주는 발생 안 함(시드 고정).
에러(API 실패) calendarApi.week() reject → <ErrorCard> ("일정을 불러오지 못했어요 · 다시 시도"). 드로어 회의 fetch 실패 → 드로어 내 에러 + 닫기.
오프라인 localStorage UI 상태(view/selDay/calOn/focusOn)는 유지되어 셸은 렌더. 데이터는 마지막 성공 응답을 SWR 캐시로(선택) 보여주거나 에러 카드. 백엔드 LLM/커넥터는 mock 이라 오프라인에서도 시드로 동작.
회의 없는 이벤트 has_meeting:false.meet-open 버튼 미노출(원본 C.meets[ev.id] 가드와 동일).
액션 멱등 이미 added 인 액션 재실체화 → 서버가 기존 materialized_task_id 반환(중복 task 생성 안 함, §3.5).
materialize-all 0건 남은 액션 없으면 빈 배열 반환 → UI 는 .md-allok 유지.
who 매핑 실패 대표님/재무팀장 등은 assignee_id=None(미배정 작업). 작업 페이지에서 담당자 없는 카드로 표시.
카테고리 전부 off events 빈 배열 → 그리드는 시간 라벨만, 어젠다 빈 메시지. (정상)
월 뷰 다른 주 events 는 7~13일만 → 다른 날 셀은 빈 셀(원본 동일).
reduced motion .md-pulse/@keyframesprefers-reduced-motion: reduce 시 정지(cal.css 보유). 드로어/팝오버 진입 애니메이션도 동일 가드 적용.
새니탤이즈 meet.insights/task.notes<b> 등 HTML 포함(신뢰된 시드). dangerouslySetInnerHTML 사용 시 overview §15 새니타이즈 원칙 준수(허용 태그 화이트리스트).

7. 연합 이벤트 (발행 / 구독)

CONTRACT 연합 이벤트 모델(event_bus, phase-7). 이 페이지가 발행/구독하는 이벤트:

7.1 발행 (publish)

이벤트 페이로드 발행 시점 구독자(예상)
meeting.ended {meeting_id, action_ids[], task_ids[]} done 회의 액션 실체화(materialize/materialize-all) 하루 마감(phase-12) "회의→작업" 집계, 자동화(phase-7) 패턴 탐지
task.created {task_id, source:"meeting", meeting_id} 액션 → task 생성 시 (phase-3 와 동일 이벤트 재사용) 작업 보드(phase-3) 갱신, 대시보드(phase-5) task_summary
calendar.focus_scheduled {day, block_ids[]} focus/suggest create=true 하루 마감 집중 시간 집계

발행 코드(정본 모듈 사용):

# backend/app/services/meetings.py (실체화 후)
from ..automation.event_bus import bus              # phase-7 정본 모듈
# (모듈 편의 export 도 동일하게 사용 가능: from ..automation.event_bus import publish  # emit 은 publish 별칭)

# materialize_all 끝:
bus.publish("meeting.ended", {"meeting_id": mid,
                              "action_ids": [a.id for a, _ in pairs],
                              "task_ids": [t.id for _, t in pairs]})

7.2 구독 (subscribe)

이벤트(타 phase 발행) 이 페이지의 반응
task.created(인박스/메일) 작업이 집중 블록의 빈 슬롯에 배치될 후보가 됨(일정↔작업 집중모드 연결, §3.4 free-slot)
mail.received → extract → task/event(phase-9) 메일에서 추출된 일정이 cal_event 로 등장(phase-9 가 calendar 커넥터 write_event 호출)
automation.matched(phase-7) 자동화가 "치과 예약 15분 앞당김" 같은 일정 변경을 approval(결재함)로 enqueue → 승인 시 cal_event 갱신

7.3 연합 시나리오 (PROJECT-README §5 반영)

  1. 회의 → 작업: e3(스탠드업, done) 드로어 → "매출 데이터 전달"(현우) "작업으로" → POST .../actions/2/materializetask(assignee=hyunwoo) 생성 → 작업 페이지 biz 프로젝트에 등장. 데모: phase-3 작업 보드에서 즉시 확인.
  2. 일정 ↔ 작업 집중모드: 작업(k1 분기 리포트, due 6/8 진행 중)이 일정의 오후 15:10 빈 블록(f3, deep)에 자동 배치 — AI 브리핑이 그 연결을 말로 설명.
  3. 분기 전략 미팅 사전 브리핑: e6(upcoming) 드로어의 docs(분기 리포트 초안 · 75%)·인사이트(예측 시나리오 3종 미완 → 12:00 빈 블록 비워둠)가 작업/리포트와 연결(phase-3 k1/biz-report, phase-10 리서치 리포트와도 교차).

8. 테스팅 & 검증

8.1 실행 명령

# 백엔드
cd backend
uv run python -m app.seed                      # 시드(일정 포함) 적재
uv run uvicorn app.main:app --reload           # 서버
uv run pytest tests/test_seed_calendar.py tests/test_api_calendar.py \
              tests/test_focus.py tests/test_meeting_materialize.py -v

# 프론트
cd frontend
pnpm dev                                        # http://localhost:3000/calendar
pnpm vitest run tests/calendar                  # 컴포넌트 단위
pnpm playwright test playwright/calendar.spec.ts # E2E + axe

8.2 백엔드 테스트 케이스 (pytest)

conftest 는 phase-2 의 in-memory DB + run_seed(session=test_session, reset=True) fixture 를 재사용한다.

# tests/test_seed_calendar.py
def test_calendar_seed_counts(session):
    from app.models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
    from sqlmodel import select
    assert len(session.exec(select(Calendar)).all()) == 5
    assert len(session.exec(select(CalEvent)).all()) == 22
    assert len(session.exec(select(FocusBlock)).all()) == 4
    assert {m.id for m in session.exec(select(Meeting)).all()} == {"e3", "e4", "e6", "e7"}
    # e3 액션 3건, 그 중 added=False 는 1건(매출 데이터 전달)
    e3 = [a for a in session.exec(select(MeetingAction)).all()
          if a.meeting_id == "e3"]
    assert len(e3) == 3
    assert sum(1 for a in e3 if not a.added) == 1

def test_focus_seed_deep(session):
    from app.models import FocusBlock
    from sqlmodel import select
    deep = [f for f in session.exec(select(FocusBlock)).all() if f.type == "deep"]
    assert len(deep) == 1 and deep[0].id == "f3"
    assert deep[0].start == "15:10" and deep[0].end == "16:25"
# tests/test_api_calendar.py
def test_week_bundle(client):
    r = client.get("/api/calendar/week"); assert r.status_code == 200
    b = r.json()
    assert b["today"] == 8 and b["week"] == [7,8,9,10,11,12,13]
    assert b["weekdays"] == ["일","월","화","수","목","금","토"]
    assert len(b["calendars"]) == 5 and len(b["events"]) == 22 and len(b["focus_blocks"]) == 4
    # 카테고리 count: team 카테고리 이벤트 수 검증
    team = next(c for c in b["calendars"] if c["id"] == "team")
    assert team["count"] == sum(1 for e in b["events"] if e["cal"] == "team")

def test_day_bundle_has_meeting_and_actions(client):
    r = client.get("/api/calendar/day/8"); b = r.json()
    e3 = next(e for e in b["events"] if e["id"] == "e3")
    assert e3["has_meeting"] is True
    assert e3["meet_label"] == "회의 노트 · 액션 보기"
    assert [a["text"] for a in e3["actions"]] == ["온보딩 와이어프레임 피드백 정리", "푸시 알림 QA 결과 공유"]
    e6 = next(e for e in b["events"] if e["id"] == "e6")
    assert e6["soon"] is True and e6["meet_label"] == "사전 브리핑 보기"
    assert e6["people"] == ["대표님", "재무팀장", "나 외 3명"]

def test_meeting_phases(client):
    assert client.get("/api/calendar/meetings/e3").json()["phase"] == "done"
    assert client.get("/api/calendar/meetings/e4").json()["phase"] == "live"
    up = client.get("/api/calendar/meetings/e6").json()
    assert up["phase"] == "upcoming" and len(up["agenda"]) == 3 and len(up["docs"]) == 2
    oo = client.get("/api/calendar/meetings/e7").json()
    assert oo["one_on_one"] is True and oo["person"]["name"] == "민서"
    assert len(oo["talking_points"]) == 3

def test_meeting_404(client):
    assert client.get("/api/calendar/meetings/nope").status_code == 404
# tests/test_meeting_materialize.py
def test_materialize_one_creates_task(client, session):
    from app.models import Task
    # e3 idx=2 "매출 데이터 전달" (현우, added=False)
    r = client.post("/api/calendar/meetings/e3/actions/2/materialize")
    assert r.status_code == 200
    data = r.json()
    assert data["action"]["added"] is True
    assert data["task"]["title"] == "매출 데이터 전달"
    assert data["task"]["assignee_id"] == "hyunwoo"        # who 나매핑
    assert data["task"]["project_id"] == "biz"             # team/work/meeting → biz
    assert data["all_added"] is True                       # e3 의 마지막 미처리 액션이었음
    # 작업 페이지에 등장 (federation)
    tid = data["task"]["id"]
    assert session.get(Task, tid) is not None

def test_materialize_idempotent(client):
    r1 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
    t1 = r1.json()["task"]["id"]
    r2 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
    assert r2.json()["task"]["id"] == t1                   # 중복 생성 없음

def test_materialize_all(client):
    # e4(live) 액션 1건 모두
    r = client.post("/api/calendar/meetings/e4/actions/materialize-all")
    arr = r.json()
    assert len(arr) == 1
    assert arr[0]["task"]["title"] == "온보딩 3번 화면 CTA 수정안 반영"
    assert arr[0]["all_added"] is True
# tests/test_focus.py
def test_free_slots_day8(session):
    from app.services.focus import free_slots, suggest_focus
    slots = free_slots(session, 8)
    # 09:00 시작, 이벤트들(09:30,11:00,13:00,14:00,16:30) 사이 빈 구간 존재
    assert any(b - a >= 60 for a, b in slots)               # 큰 빈 블록 1+ 존재
    sug = suggest_focus(session, 8, min_minutes=20)
    assert any(f.type == "deep" for f in sug)               # deep 제안 존재

8.3 프론트 컴포넌트 테스트 (Vitest + RTL)

// tests/calendar/MeetDrawer.test.tsx — 4상태 렌더 + 액션 왕복
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MeetDrawer } from '@/components/calendar/MeetDrawer';
import { calendarApi } from '@/lib/calendar/api';
vi.mock('@/lib/calendar/api');

const ev = { id: 'e3', title: '팀 데일리 스탠드업', start: '09:30', end: '09:45', loc: 'Zoom' } as any;
const cal = { id: 'team', name: '팀', tone: 'green', on: true, count: 4 } as any;

test('done 회의: 요약·결정·액션 + 작업으로', async () => {
  (calendarApi.meeting as any).mockResolvedValue({
    id: 'e3', phase: 'done', one_on_one: false, meta: '...',
    summary: ['s1'], decisions: ['d1'], actions: [
      { idx: 2, text: '매출 데이터 전달', who: '현우', when: '오늘', added: false, materialized_task_id: null },
    ], agenda: [], last_meeting: {}, last_actions: [], insights: [], docs: [],
    person: {}, promises: [], signals: [], talking_points: [], starts_in: '',
  });
  (calendarApi.materialize as any).mockResolvedValue({ action: {}, task: {}, all_added: true });
  render(<MeetDrawer ev={ev} cal={cal} onClose={() => {}} />);
  expect(await screen.findByText('회의 노트 정리됨')).toBeInTheDocument();
  expect(screen.getByText('매출 데이터 전달')).toBeInTheDocument();
  fireEvent.click(screen.getByRole('button', { name: /작업으로/ }));
  await waitFor(() => expect(calendarApi.materialize).toHaveBeenCalledWith('e3', 2));
});

test('upcoming: 안건·지난액션·인사이트·docs', async () => {
  (calendarApi.meeting as any).mockResolvedValue({
    id: 'e6', phase: 'upcoming', one_on_one: false, starts_in: '2시간 50분 후 시작',
    agenda: ['Q2 핵심 지표 리뷰 — 리텐션 · 매출'], docs: ['분기 리포트 초안 · 75%'],
    insights: ['리텐션 KPI가 전분기 대비 <b>+6%p</b>'], last_actions: [{ text: 'x', who: '나', done: false }],
    last_meeting: { when: '지난주 월', note: 'n' }, summary: [], decisions: [], actions: [],
    person: {}, promises: [], signals: [], talking_points: [], meta: '',
  });
  render(<MeetDrawer ev={{ ...ev, id: 'e6', title: '분기 전략 미팅' }} cal={cal} onClose={() => {}} />);
  expect(await screen.findByText('2시간 50분 후 시작')).toBeInTheDocument();
  expect(screen.getByText(/Q2 핵심 지표 리뷰/)).toBeInTheDocument();
  expect(screen.getByText(/분기 리포트 초안 · 75%/)).toBeInTheDocument();
});

test('live: 기록 중 배지', async () => {
  (calendarApi.meeting as any).mockResolvedValue({
    id: 'e4', phase: 'live', one_on_one: false, summary: ['논의1'],
    decisions: [], actions: [{ idx: 0, text: 'a', who: '나', when: '내일', added: false, materialized_task_id: null }],
    agenda: [], last_meeting: {}, last_actions: [], insights: [], docs: [],
    person: {}, promises: [], signals: [], talking_points: [], meta: '', starts_in: '',
  });
  render(<MeetDrawer ev={{ ...ev, id: 'e4' }} cal={cal} onClose={() => {}} />);
  expect(await screen.findByText('기록 중')).toBeInTheDocument();
});

test('1:1: 약속·시그널·대화 주제', async () => {
  (calendarApi.meeting as any).mockResolvedValue({
    id: 'e7', phase: 'upcoming', one_on_one: true, starts_in: '오늘 16:30',
    person: { name: '민서', initial: '민', tone: 'green' },
    promises: [{ text: '강의 지원 품의', done: false }],
    signals: [{ k: '업무량', v: '진행 4건', tone: 'amber' }],
    talking_points: ['전처리 과제에서 막히는 부분이 있는지'],
    last_meeting: { when: '5월 25일', note: 'n' }, summary: [], decisions: [], agenda: [],
    last_actions: [], insights: [], docs: [], actions: [], meta: '',
  });
  render(<MeetDrawer ev={{ ...ev, id: 'e7', title: '1:1 — 민서님' }} cal={cal} onClose={() => {}} />);
  expect(await screen.findByText('1:1 어시스턴트')).toBeInTheDocument();
  expect(screen.getByText('민서님 시그널')).toBeInTheDocument();
  expect(screen.getByText('전처리 과제에서 막히는 부분이 있는지')).toBeInTheDocument();
});
// tests/calendar/views.test.tsx — 뷰 전환 + 집중 블록 토글
test('뷰 세그먼트 전환 week→month→day', async () => { /* role=tab aria-selected 검증 */ });
test('집중 블록 토글 off 시 .fblock 미렌더', () => { /* ch-focus.on 해제 → focusBlocks 빈 */ });
test('카테고리 토글 off 시 해당 cal 이벤트 숨김', () => { /* calOn[health]=false → health 이벤트 미표시 */ });

8.4 E2E (Playwright) — 액션 → 작업 왕복

// playwright/calendar.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('회의 액션 → 작업 페이지 등장 (federation)', async ({ page }) => {
  await page.goto('/calendar');
  // 일 뷰로 (오늘 6/8)
  await page.getByRole('tab', { name: '일' }).click();
  // 스탠드업(e3) 어젠다의 회의 도우미 진입
  await page.getByRole('button', { name: /회의 노트 · 액션 보기/ }).first().click();
  await expect(page.getByRole('dialog', { name: '회의 도우미' })).toBeVisible();
  await expect(page.getByText('매출 데이터 전달')).toBeVisible();
  // "작업으로"
  await page.getByRole('button', { name: /작업으로/ }).click();
  await expect(page.getByText('작업에 있음')).toBeVisible();   // md-act-done
  // 작업 페이지에서 확인
  await page.goto('/tasks');
  await page.getByRole('link', { name: '업무' }).click();      // work 영역
  await expect(page.getByText('매출 데이터 전달')).toBeVisible();
});

test('모두 작업으로 → md-allok', async ({ page }) => {
  await page.goto('/calendar');
  await page.getByRole('tab', { name: '일' }).click();
  await page.getByRole('button', { name: /회의 노트 · 액션 보기/ }).first().click();
  const all = page.getByRole('button', { name: /모두 작업으로 보내기/ });
  if (await all.isVisible()) await all.click();
  await expect(page.getByText('모든 액션이 담당자 작업으로 등록됐어요')).toBeVisible();
});

test('드로어 a11y (axe)', async ({ page }) => {
  await page.goto('/calendar');
  await page.getByRole('tab', { name: '일' }).click();
  await page.getByRole('button', { name: /사전 브리핑 보기/ }).first().click();
  const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze();
  expect(results.violations).toEqual([]);
});

test('Escape 로 드로어 닫힘', async ({ page }) => {
  await page.goto('/calendar');
  await page.getByRole('tab', { name: '일' }).click();
  await page.getByRole('button', { name: /1:1 어시스턴트 브리핑/ }).first().click();
  await page.keyboard.press('Escape');
  await expect(page.getByRole('dialog')).toBeHidden();
});

8.5 수동 QA 체크리스트

  • /calendar 진입 시 주 뷰가 기본, 6/8(월) 헤더 원형이 coral, 현재 시각선이 11:10 위치.
  • 시간 라벨 7:00~22:00, 한 시간 = 52px. 이벤트 블록이 start/end 에 정확히 위치.
  • 분기 전략 미팅(e6)에 soon ring(coral) 표시.
  • 집중 블록 4개(점선): light=violet, deep(분기 리포트)=coral. 상단 집중 블록 토글로 on/off.
  • 카테고리 토글(건강 off) → 모닝 러닝/스트레칭 이벤트 사라짐, 우측 숫자 유지.
  • 미니 달력 6/8 today(coral), 이벤트 있는 날 evdot, 날짜 클릭 시 일 뷰 전환.
  • 월 뷰: 셀당 이벤트 ≤3 + +N개 더. 6/8 today 원형.
  • 일 뷰: 좌측 그리드 + 우측 집중 카드("오늘의 집중 모드") + 어젠다("6월 8일 월요일 · 일정 6개 · 오늘").
  • 어젠다 스탠드업/디자인 리뷰에 "내가 맡기로 한 일" 액션 표시.
  • 회의 도우미 4상태: 스탠드업(done, 요약·결정·액션)·디자인 리뷰(live, 기록 중 pulse)·분기 전략(upcoming, 안건·인사이트·docs)·1:1 민서(약속·시그널·대화 주제).
  • "작업으로" 클릭 → 버튼이 "작업에 있음"으로, 작업 페이지에 새 작업 등장.
  • 다크 모드 토글 시 토큰 전환(글래스/이벤트 tint 정상).
  • 모바일 ≤1080px: 사이드바 숨김, 일 뷰 1열. ≤760px: mainnav 숨김, 뷰 세그 하단 중앙.

8.6 통과 기준

  • 백엔드: §8.2 pytest 전부 green. GET /api/calendar/week 가 5/22/4 카운트, materialize 가 task 생성+멱등, focus free-slot 계산 정상.
  • 프론트: §8.3 Vitest(4상태 렌더 + 액션 호출) green, §8.4 Playwright(federation 왕복 + axe 0 violations + Escape) green.
  • 디자인: §5 인용 값(52px/HEX/클래스/문구)이 렌더 결과와 일치(수동 QA).

9. 완료 기준 (Definition of Done)

  • models.pyCalendar/CalEvent/FocusBlock/Meeting/MeetingAction 추가, Alembic 마이그레이션 생성·왕복(downgrade base && upgrade head) 통과.
  • seed_calendar.py 가 cal-data.js 값(5 카테고리·22 이벤트·4 집중블록·4 회의·액션)을 그대로 적재, run_seed 에 통합(reset 목록 포함).
  • REST API 6종 동작, 응답 스키마가 §3.2 와 1:1. materialize 가 federation(task 생성 + materialized_task_id)·멱등.
  • connectors/ 추상화 + MockCalendarConnector 동작, CONNECTOR_CALENDAR 설정 반영(기본 mock). real 은 stub.
  • services/focus.py free-slot 계산, services/meetings.py 액션 실체화 + who→person 매핑 + 정본 app.automation.event_busbus.publishmeeting.ended 발행.
  • 프론트 /calendar 페이지: 주/월/일 3뷰 픽셀 재현, 미니 달력·카테고리 토글·집중 블록 토글·AI 브리핑·팝오버·드로어 4상태.
  • localStorage UI 상태(view/selDay/calOn/focusOn) 영속, 테마는 next-themes.
  • 회의 액션 → 작업 왕복이 E2E 로 검증(작업 페이지 등장).
  • 로딩/빈/에러/오프라인/reduced-motion 상태 처리(§6).
  • 연합 이벤트(meeting.ended/task.created/calendar.focus_scheduled) 발행 + 구독 시나리오 문서화(§7).
  • §8 모든 테스트 green, 수동 QA 체크리스트 통과.

10. 다음 단계

일정 + 회의 도우미가 완성되면 phase-9-mail-notifications.md(메일 + 알림 트리아지)로 진행한다. phase-9 는:

  • 메일 → 작업/일정 연합: 메일 AI 추출(email.ai.tasks[]/events[])이 본 phase 의 cal_event(커넥터 write_event)와 phase-3 의 task 로 실체화된다 — 이 phase 의 액션→작업 패턴을 그대로 재사용.
  • 알림 → 자동화 제안: 알림 트리아지가 phase-7 자동화 규칙 제안으로 연결.
  • 메일/일정/작업의 공통 인물(현우/민서/수아)·프로젝트 일관성을 유지한다(데모 페르소나 §overview 4).

또한 본 phase 에서 만든 meeting.ended 발행과 집중 블록은 phase-12-daily-narrative.md(여정 + 하루 마감)에서 "오늘 회의 N건 → 작업 M건", "집중 시간 Xh" 집계로 합산되고, 커넥터 real 전환은 phase-13-integrations.md, 회의 음성 STT 실연동은 phase-14-proactive-agent.md 에서 다룬다.