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

1751 lines
92 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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: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` 가 그 상태다.
> **`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_<DOMAIN>=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"<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 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: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 목록에도 신규 테이블 포함):
```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":"<p>회의 <b>‘팀 데일리 스탠드업’</b>의 액션 아이템에서 아리가 만든 작업이에요.</p>",
"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<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 }>),
};
```
```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<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"`) — 뷰 전환/드로어/팝오버 상태가 필요.
```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<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.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<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`/`DoneLiveBody` 는 `cal-meet.jsx` 의 각 분기(`<Sec>` 들)를 그대로 옮긴다 — 섹션 아이콘/제목/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: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: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)=`<Icon name="chev" />`, 이전(prev)=`<Icon name="chev" className="flip" />`(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 `<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`/`e4` 의 `actions`.
---
## 6. 상태 처리 (로딩/빈/에러/오프라인) · 엣지케이스
| 상황 | 처리 |
|---|---|
| 로딩 | `bundle===null``CalendarSkeleton`(시간 그리드 골격 + 사이드바 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`/`@keyframes`는 `prefers-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 | 하루 마감 집중 시간 집계 |
발행 코드(정본 모듈 사용):
```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(<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();
});
```
```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`** 에서 다룬다.