# Phase 8 — 일정 + 회의 도우미 > 한 줄 요약: 주/월/일 캘린더(`cal.jsx`/`cal.css` 픽셀 재현) · 회의 도우미 드로어 4상태(done/live/upcoming/1:1, `cal-meet.jsx`) · 아리가 빈 시간에 자동 배치하는 집중 모드 블록 · **회의 액션아이템 → 작업 실체화(federation)** 를, mock-first 커넥터 위에 구현한다. > 이 문서는 포스트-MVP 세트의 일부 — 먼저 `dev/overview.md` 와 `dev/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` 테이블·대시보드 계약을 깨지 않음). ```python # 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:30–09: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` 가 그 상태다. > **`who` → `assignee_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). ```python # 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_=mock|real`(기본 mock). ```python # 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 저장만. ``` ```python # 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() ``` ```python # 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") ``` ```python # 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`): ```python # backend/app/config.py (추가) connector_calendar: str = "mock" # CONNECTOR_CALENDAR=mock|real (기본 mock) ``` > 이 phase 는 **mock-first** — `get_calendar_connector()` 는 항상 `MockCalendarConnector` 를 반환한다. 라우터/서비스는 커넥터 인터페이스만 의존하므로, phase-13 에서 `CONNECTOR_CALENDAR=real` 로 바꾸면 코드 변경 없이 Google Calendar 로 전환된다. --- ### 3.4 집중 블록 서비스 — `services/focus.py` CONTRACT: "집중모드: 빈 블록 계산 → 블록 제안". 그 날의 이벤트들 사이 **빈 슬롯(free slot)** 을 찾아 `min_minutes` 이상이면 집중 블록으로 제안한다. 오후 큰 빈 블록은 `deep`(딥 워크), 짧은 미팅 사이는 `light`로 분류한다. ```python # 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` 의 "작업으로"/"모두 작업으로"가 호출하는 실체화를 구현한다. ```python # 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"

회의 ‘{ev.title if ev else ''}’의 액션 아이템에서 아리가 만든 작업이에요.

", 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 bus` 후 `bus.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)` 로 호출. ```python # 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:30–09: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:00–11: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가 전분기 대비 +6%p — 첫 화두로 추천드려요", "내 액션 중 ‘예측 시나리오 3종’이 미완 — 미팅 전 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 목록에도 신규 테이블 포함): ```python # 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.py` 의 `include_router(prefix="/api", tags=["calendar"])` 에서만 `/api` 부착. ```python # 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 패턴): ```python # 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/week` 의 `events`(전 주)를 프론트가 `day` 별로 묶어 렌더한다(원본 `MonthView` 가 `byDay` 로 묶는 방식 동일). 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` (축약): ```json { "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): ```json { "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":"

회의 ‘팀 데일리 스탠드업’의 액션 아이템에서 아리가 만든 작업이에요.

", "est":"","delegated":false,"sort_order":7,"comments":[],"children":[]}, "all_added": true } ``` `GET /api/calendar/meetings/e7` (1:1, 축약): ```json { "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/시간 유틸 ```ts // 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; } ``` ```ts // frontend/lib/calendar/api.ts import type { WeekBundle, DayBundle, Meeting, MaterializeResult, FocusBlock } from './types'; const J = { 'Content-Type': 'application/json' }; async function ok(r: Response): Promise { if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); return r.json() as Promise; } export const calendarApi = { week: () => fetch('/api/calendar/week').then(ok), day: (d: number) => fetch(`/api/calendar/day/${d}`).then(ok), meeting: (id: string) => fetch(`/api/calendar/meetings/${id}`).then(ok), materialize: (mid: string, idx: number) => fetch(`/api/calendar/meetings/${mid}/actions/${idx}/materialize`, { method: 'POST' }) .then(ok), materializeAll: (mid: string) => fetch(`/api/calendar/meetings/${mid}/actions/materialize-all`, { method: 'POST' }) .then(ok), 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 }>), }; ``` ```ts // 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; ``` ```ts // frontend/lib/calendar/store.ts (cal.jsx 의 load/save, 접두사 ariC. → ari.cal.) const PREFIX = 'ari.cal.'; export function loadLS(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(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"`) — 뷰 전환/드로어/팝오버 상태가 필요. ```tsx // 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(null); const [view, setView] = useState(() => loadLS('view', 'week')); const [selDay, setSelDay] = useState(() => loadLS('selDay', 8)); const [calOn, setCalOn] = useState>(() => loadLS('calOn', {})); const [focusOn, setFocusOn] = useState(() => loadLS('focusOn', true)); const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null); const [meetEv, setMeetEv] = useState(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 ; // §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 (
{/* Topbar 는 layout 에서. 여기선 cmain. */}

{title}

{VIEWS.map((v) => ( ))}

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

{view === 'week' && ( setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })} /> )} {view === 'month' && ( setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })} onSelDay={(n) => { setSelDay(n); setView('day'); }} /> )} {view === 'day' && ( setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })} onMeet={(ev) => { setPop(null); setMeetEv(ev); }} /> )}
{pop && setPop(null)} onMeet={(ev) => { setPop(null); setMeetEv(ev); }} />} {meetEv && setMeetEv(null)} />}
); } function countByDay(events: CalEvent[]): Record { const m: Record = {}; events.forEach((e) => { m[e.day] = (m[e.day] || 0) + 1; }); return m; } ``` **`MeetDrawer.tsx`** — `cal-meet.jsx` 의 4상태 렌더를 그대로 이식하되, 액션은 **서버 실체화 API**를 호출한다(원본은 로컬 state 만 갱신; 여기선 낙관적 업데이트 + API). ```tsx // 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(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' ? 회의 노트 정리됨 : meet.phase === 'live' ? 기록 중 : {meet.starts_in}; return ( <>
); } ``` > `OneOnOneBody`/`UpcomingBody`/`DoneLiveBody` 는 `cal-meet.jsx` 의 각 분기(`` 들)를 그대로 옮긴다 — 섹션 아이콘/제목/hint/문구는 §5 디자인 충실도 표 참조. `ActionRow` 는 `md-act`/`md-act-add`/`md-act-done` 클래스를 그대로 쓴다. > **EvBlock/FocusBlock/WeekView/MonthView/DayView/MiniCal/EvPopover** 는 `cal.jsx` 의 동명 컴포넌트를 1:1 이식한다. `topOf`/`toMin`/`fmt` 는 `lib/calendar/time.ts` 에서 import. tone 클래스(`tint-blue` 등)·CSS는 `cal.css` 를 `frontend/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`. `f3` 만 `type="deep"`("분기 리포트 초안 마무리", 15:10–16: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:10` → `topOf("11:10")`) | | 월 셀 | `.mo-grid { grid-auto-rows: minmax(108px,1fr); }`, 셀당 이벤트 최대 3개 + `+N개 더` | | 일 뷰 | `.day-wrap { grid-template-columns: 1.55fr 1fr; }` (그리드 + 사이드: 집중 카드 + 어젠다) | | 월/주 네비 화살표 | `.ch-arrow` — 다음(next)=``, 이전(prev)=``(CSS `.flip { transform: rotate(180deg); }` 180° 회전). 중앙 paths 맵에 `prev`/`next` 글리프를 추가하지 않고 `chev` 단일 글리프로 통일 | ### 5.2 이벤트/집중 블록 색 이벤트 tint 토큰(cal.css): ```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); }`(분기 전략 미팅 `e6` 가 `soon: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 ``) · `.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). `` 는 coral 밑줄(`border-bottom: 2px solid color-mix(...coral 55%...)`). ### 5.5 집중 카드 / 어젠다 문구 (일 뷰 사이드) - 집중 카드 제목: `오늘의 집중 모드` / 서브 `빈 시간에 맞춰 자동 배치했어요` / 노트 `오전 미팅 사이엔 가벼운 일을, 오후 빈 블록엔 딥 워크를 넣었어요.` - 어젠다 헤더: `6월 {day}일 {wd}요일` / `일정 {n}개 · 오늘`. 빈 날: `이 날은 일정이 없어요.` - 어젠다 액션: `내가 맡기로 한 일`(`.aga-head` violet) — `e3`/`e4` 의 `actions`. --- ## 6. 상태 처리 (로딩/빈/에러/오프라인) · 엣지케이스 | 상황 | 처리 | |---|---| | 로딩 | `bundle===null` → `CalendarSkeleton`(시간 그리드 골격 + 사이드바 placeholder, `aria-busy="true"`). 드로어 로딩: `meet===null` 동안 드로어 골격 또는 spinner. | | 빈 일정(특정 날) | 일 뷰 어젠다 `이 날은 일정이 없어요.`(`.ag-empty`). 빈 주는 발생 안 함(시드 고정). | | 에러(API 실패) | `calendarApi.week()` reject → `` ("일정을 불러오지 못했어요 · 다시 시도"). 드로어 회의 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`/`@keyframes`는 `prefers-reduced-motion: reduce` 시 정지(cal.css 보유). 드로어/팝오버 진입 애니메이션도 동일 가드 적용. | | 새니탤이즈 | `meet.insights`/`task.notes` 는 `` 등 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 | 하루 마감 집중 시간 집계 | 발행 코드(정본 모듈 사용): ```python # 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/materialize` → `task`(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 실행 명령 ```bash # 백엔드 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 를 재사용한다. ```python # 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" ``` ```python # 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 ``` ```python # 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 ``` ```python # 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) ```tsx // 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( {}} />); 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가 전분기 대비 +6%p'], last_actions: [{ text: 'x', who: '나', done: false }], last_meeting: { when: '지난주 월', note: 'n' }, summary: [], decisions: [], actions: [], person: {}, promises: [], signals: [], talking_points: [], meta: '', }); render( {}} />); 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( {}} />); 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( {}} />); expect(await screen.findByText('1:1 어시스턴트')).toBeInTheDocument(); expect(screen.getByText('민서님 시그널')).toBeInTheDocument(); expect(screen.getByText('전처리 과제에서 막히는 부분이 있는지')).toBeInTheDocument(); }); ``` ```tsx // 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) — 액션 → 작업 왕복 ```ts // 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.py` 에 `Calendar`/`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_bus` 의 `bus.publish` 로 `meeting.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`** 에서 다룬다.