92 KiB
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 테이블·대시보드 계약을 깨지 않음).
# 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 의 단순 스키마 철학을 유지하기 위해 표시 전용(읽기) 데이터는
*_jsonTEXT 컬럼에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).
# 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-2TaskNode를 그대로 참조한다(전방참조 후MaterializeResponse.model_rebuild()). 작업 페이지가 동일한TaskNode형태로 새 작업을 받으므로 연합이 깨지지 않는다.
3.3 커넥터 추상화 — connectors/
CONTRACT 횡단 아키텍처: backend/app/connectors/ — 도메인별 base 인터페이스 + MockConnector(시드 기반) + RealConnector(phase-13). 설정 CONNECTOR_<DOMAIN>=mock|real(기본 mock).
# backend/app/connectors/base.py
from __future__ import annotations
from abc import ABC, abstractmethod
class CalendarConnector(ABC):
"""calendar 도메인 커넥터 인터페이스.
mock = 시드(cal-data.js) 기반, real = Google Calendar(phase-13).
페이지는 이 인터페이스만 의존 → phase-13 이 같은 시그니처로 real 을 끼운다."""
@abstractmethod
def list_events(self, session, day: int | None = None) -> list[dict]: ...
@abstractmethod
def get_meeting(self, session, meeting_id: str) -> dict | None: ...
@abstractmethod
def write_event(self, session, payload: dict) -> dict: ...
# write_event 는 phase-13(real) 에서 양방향 동기화에 사용. mock 은 DB 저장만.
# backend/app/connectors/calendar_mock.py
from sqlmodel import Session, select
from .base import CalendarConnector
from ..models import CalEvent, Meeting
class MockCalendarConnector(CalendarConnector):
"""시드(seed_calendar.py)로 적재된 DB 를 그대로 읽는다. STT/외부 API 없음."""
def list_events(self, s: Session, day=None):
q = select(CalEvent)
if day is not None:
q = q.where(CalEvent.day == day)
rows = s.exec(q.order_by(CalEvent.day, CalEvent.start)).all()
return [r.model_dump() for r in rows]
def get_meeting(self, s: Session, meeting_id: str):
m = s.get(Meeting, meeting_id)
return m.model_dump() if m else None
def write_event(self, s: Session, payload: dict):
# mock: DB 에만 저장 (외부 동기화 없음). phase-13 real 이 Google API 호출.
ev = CalEvent(**payload)
s.add(ev); s.commit()
return ev.model_dump()
# backend/app/connectors/calendar_real.py (phase-13 목표 — 지금은 stub)
from .base import CalendarConnector
class RealCalendarConnector(CalendarConnector):
"""Google Calendar API 연동 (phase-13). 지금은 NotImplemented stub.
동일 인터페이스라 페이지/서비스 코드는 그대로 유지된다."""
def list_events(self, s, day=None):
raise NotImplementedError("phase-13: Google Calendar 연동")
def get_meeting(self, s, meeting_id):
raise NotImplementedError("phase-13")
def write_event(self, s, payload):
raise NotImplementedError("phase-13")
# backend/app/connectors/__init__.py
from functools import lru_cache
from ..config import get_settings
from .calendar_mock import MockCalendarConnector
from .calendar_real import RealCalendarConnector
@lru_cache
def get_calendar_connector():
mode = getattr(get_settings(), "connector_calendar", "mock")
return RealCalendarConnector() if mode == "real" else MockCalendarConnector()
config.py 에 추가(phase-2 Settings 확장 — 환경변수 규약 CONNECTOR_CALENDAR=mock|real):
# backend/app/config.py (추가)
connector_calendar: str = "mock" # CONNECTOR_CALENDAR=mock|real (기본 mock)
이 phase 는 mock-first —
get_calendar_connector()는 항상MockCalendarConnector를 반환한다. 라우터/서비스는 커넥터 인터페이스만 의존하므로, phase-13 에서CONNECTOR_CALENDAR=real로 바꾸면 코드 변경 없이 Google Calendar 로 전환된다.
3.4 집중 블록 서비스 — services/focus.py
CONTRACT: "집중모드: 빈 블록 계산 → 블록 제안". 그 날의 이벤트들 사이 빈 슬롯(free slot) 을 찾아 min_minutes 이상이면 집중 블록으로 제안한다. 오후 큰 빈 블록은 deep(딥 워크), 짧은 미팅 사이는 light로 분류한다.
# backend/app/services/focus.py
from __future__ import annotations
from sqlmodel import Session, select
from ..models import CalEvent, FocusBlock
DAY_START_MIN = 9 * 60 # 09:00 (근무 시작)
DAY_END_MIN = 18 * 60 # 18:00
DEEP_THRESHOLD = 60 # 60분 이상 빈 블록 → deep, 미만 → light
def _to_min(t: str) -> int:
h, m = map(int, t.split(":"))
return h * 60 + m
def _to_hhmm(mins: int) -> str:
return f"{mins // 60:02d}:{mins % 60:02d}"
def free_slots(s: Session, day: int) -> list[tuple[int, int]]:
"""그 날 09:00~18:00 중 이벤트가 없는 [start_min, end_min) 구간 목록."""
evs = s.exec(select(CalEvent).where(CalEvent.day == day)).all()
busy = sorted([(_to_min(e.start), _to_min(e.end)) for e in evs])
slots: list[tuple[int, int]] = []
cur = DAY_START_MIN
for bs, be in busy:
if bs > cur:
slots.append((cur, min(bs, DAY_END_MIN)))
cur = max(cur, be)
if cur >= DAY_END_MIN:
break
if cur < DAY_END_MIN:
slots.append((cur, DAY_END_MIN))
return [(a, b) for a, b in slots if b > a]
def suggest_focus(s: Session, day: int, min_minutes: int = 20) -> list[FocusBlock]:
"""빈 슬롯에 집중 블록 제안. 길이로 deep/light 결정. (저장은 라우터에서 create 옵션으로)"""
out: list[FocusBlock] = []
for i, (a, b) in enumerate(free_slots(s, day)):
length = b - a
if length < min_minutes:
continue
is_deep = length >= DEEP_THRESHOLD
out.append(FocusBlock(
id=f"fs{day}-{i}",
day=day, start=_to_hhmm(a),
end=_to_hhmm(min(a + (75 if is_deep else 20), b)),
title="딥 워크 블록" if is_deep else "가벼운 일 처리",
type="deep" if is_deep else "light",
tag="딥 워크" if is_deep else "가벼운 일",
auto=True, sort_order=i,
))
return out
시드(
f1~f4)는 cal-data.js 값을 그대로 적재(아래 §3.6).suggest_focus는 "다시 계산해줘" 같은 동적 제안용이다. 기본 일정 페이지는 시드 focusBlocks 를 표시하고,POST /api/calendar/focus/suggest는 빈 블록 재계산 데모용.
3.5 회의 서비스 — services/meetings.py (액션 → 작업 실체화)
핵심 연합 로직. cal-meet.jsx 의 "작업으로"/"모두 작업으로"가 호출하는 실체화를 구현한다.
# backend/app/services/meetings.py
from __future__ import annotations
import json, uuid
from sqlmodel import Session, select
from ..models import Meeting, MeetingAction, CalEvent, Task, TaskStatus, Prio, Project
# cal-data.js who(표시명) → person id
WHO_TO_PERSON = {
"나": "jiwoo", "현우": "hyunwoo", "민서": "minseo",
"재호": "jaeho", "수아": "sua",
}
# when 표시 → 표시 유지(작업 due 는 별도 추론하지 않음. 데모 결정성 위해 None)
DEFAULT_PROJECT = "me" # 회의 액션 기본 프로젝트(개인 일상). 안건 키워드로 보정 가능.
def _person_id(who: str) -> str | None:
return WHO_TO_PERSON.get(who.strip())
def _tid() -> str:
return "mt-" + uuid.uuid4().hex[:8]
def materialize_action(s: Session, meeting_id: str, idx: int) -> tuple[MeetingAction, Task]:
"""회의 액션 1건 → task 생성 + materialized_task_id 연결."""
action = s.exec(
select(MeetingAction).where(
MeetingAction.meeting_id == meeting_id, MeetingAction.idx == idx)
).first()
if action is None:
raise ValueError("action not found")
if action.added and action.materialized_task_id:
# 이미 작업으로 보냄 — 멱등 처리
return action, s.get(Task, action.materialized_task_id)
# 프로젝트 결정: 회의가 연결된 event 의 cal 로 work/life 힌트, 기본 me
ev = s.get(CalEvent, s.get(Meeting, meeting_id).event_id)
project_id = DEFAULT_PROJECT
if ev and ev.cal in ("work", "meeting", "team"):
project_id = "biz" # 업무성 회의 → 경영 전략 루트(데모 결정성)
# 프로젝트 존재 보장
if not s.get(Project, project_id):
project_id = DEFAULT_PROJECT
siblings = s.exec(select(Task).where(Task.project_id == project_id,
Task.parent_id == None)).all() # noqa: E711
order = max([t.sort_order for t in siblings] + [-1]) + 1
task = Task(
id=_tid(), project_id=project_id, parent_id=None, title=action.text,
status=TaskStatus.todo, assignee_id=_person_id(action.who),
due=None, prio=Prio.normal,
notes=f"<p>회의 <b>‘{ev.title if ev else ''}’</b>의 액션 아이템에서 아리가 만든 작업이에요.</p>",
est="", sort_order=order,
)
s.add(task)
action.added = True
action.materialized_task_id = task.id
s.add(action)
s.commit(); s.refresh(task); s.refresh(action)
return action, task
def materialize_all(s: Session, meeting_id: str) -> list[tuple[MeetingAction, Task]]:
"""남은(added=False) 액션 전부 실체화 → '모두 작업으로'."""
pending = s.exec(
select(MeetingAction).where(
MeetingAction.meeting_id == meeting_id, MeetingAction.added == False) # noqa: E712
).all()
return [materialize_action(s, meeting_id, a.idx) for a in pending]
def assemble_meeting(s: Session, meeting_id: str) -> dict | None:
"""Meeting + actions → MeetingOut dict (JSON 펼침)."""
m = s.get(Meeting, meeting_id)
if not m:
return None
actions = s.exec(
select(MeetingAction).where(MeetingAction.meeting_id == meeting_id)
.order_by(MeetingAction.idx)).all()
j = json.loads
return dict(
id=m.id, event_id=m.event_id, phase=m.phase.value if hasattr(m.phase, "value") else m.phase,
one_on_one=m.one_on_one, meta=m.meta, starts_in=m.starts_in,
summary=j(m.summary_json), decisions=j(m.decisions_json), agenda=j(m.agenda_json),
last_meeting=j(m.last_meeting_json), last_actions=j(m.last_actions_json),
insights=j(m.insights_json), docs=j(m.docs_json),
person=j(m.person_json), promises=j(m.promises_json),
signals=j(m.signals_json), talking_points=j(m.talking_points_json),
actions=[dict(id=a.id, idx=a.idx, text=a.text, who=a.who, when=a.when_text,
added=a.added, materialized_task_id=a.materialized_task_id) for a in actions],
)
meeting.ended발행(event_bus):materialize_all또는 done 회의 첫 진입 시 정본 모듈app.automation.event_bus(phase-7)의bus.publish("meeting.ended", {meeting_id, action_ids, task_ids})를 호출한다(from ..automation.event_bus import bus후bus.publish(...); 모듈 편의 exportemit=publish별칭이라emit("meeting.ended", {...})도 동일). 구독자: 하루 마감(phase-12)이 "회의 N건 → 작업 M건" 집계, 자동화(phase-7)가 패턴 탐지.
3.6 시드 — seed_calendar.py (cal-data.js 이식)
cal-data.js 의 값을 그대로 적재한다. run_seed(phase-2) 안에서 _seed_calendar(s) 로 호출.
# backend/app/seed_calendar.py
import json
from sqlmodel import Session, select
from .models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction, MeetPhase
# ---- 캘린더 카테고리 (cal-data.js: cals) ----
CALS = [
("work", "업무", "blue", True, 0),
("meeting", "미팅", "amber", True, 1),
("personal", "개인", "violet", True, 2),
("team", "팀", "green", True, 3),
("health", "건강", "coral", True, 4),
]
# ---- 이벤트 (cal-data.js: events) — (id, day, start, end, title, cal, loc, note, soon, people) ----
EVENTS = [
("e1", 7, "10:00", "11:00", "모닝 러닝 5km", "health", "한강공원", "", False, ""),
("e2", 7, "15:00", "16:30", "독서모임 — 몰입", "personal", "북카페", "", False, ""),
("e3", 8, "09:30", "09:45", "팀 데일리 스탠드업", "team", "Zoom", "", False, ""),
("e4", 8, "11:00", "11:45", "디자인 리뷰 — 온보딩 플로우","work", "회의실 B", "", False, ""),
("e5", 8, "13:00", "13:30", "점심 — 현우님", "personal", "1층 식당", "", False, ""),
("e6", 8, "14:00", "15:00", "분기 전략 미팅", "meeting", "대회의실",
"지난 회의 액션 4/5 완료 · 리텐션 +6%p · 예산 재배분 안건", True, "대표님, 재무팀장, 나 외 3명"),
("e7", 8, "16:30", "17:00", "1:1 — 민서님", "team", "라운지", "", False, ""),
("e8", 8, "20:00", "20:30", "스트레칭 & 명상", "health", "", "", False, ""),
("e9", 9, "10:00", "11:30", "사용자 인터뷰 ×2", "work", "리서치룸", "", False, ""),
("e10", 9, "14:00", "15:00", "온보딩 와이어프레임 싱크", "work", "Figma", "", False, ""),
("e11", 9, "18:00", "19:00", "PT 트레이닝", "health", "피트니스", "", False, ""),
("e12",10, "09:30", "09:45", "팀 데일리 스탠드업", "team", "Zoom", "", False, ""),
("e13",10, "11:00", "12:00", "예산 재배분 검토", "meeting", "회의실 A", "", False, ""),
("e14",10, "15:00", "16:00", "분기 리포트 작성 블록", "work", "", "딥 워크 — 방해 금지", False, ""),
("e15",11, "10:00", "11:00", "OKR 중간 점검", "meeting", "대회의실", "", False, ""),
("e16",11, "13:30", "14:30", "음성 인터페이스 톤 워크숍","work", "회의실 C", "", False, ""),
("e17",11, "19:30", "21:00", "저녁 — 가족 모임", "personal", "본가", "", False, ""),
("e18",12, "09:30", "09:45", "팀 데일리 스탠드업", "team", "Zoom", "", False, ""),
("e19",12, "11:00", "12:00", "스프린트 리뷰 & 회고", "team", "대회의실", "", False, ""),
("e20",12, "14:00", "15:30", "신규 입사자 온보딩 세션", "work", "교육장", "", False, ""),
("e21",12, "16:00", "16:30", "치과 예약", "health", "강남 치과","", False, ""),
("e22",13, "11:00", "13:00", "브런치 — 수아님", "personal", "성수", "", False, ""),
]
# ---- 이벤트 직접 액션 (cal.jsx 어젠다 '내가 맡기로 한 일' — e3, e4) ----
EVENT_ACTIONS = {
"e3": [("온보딩 와이어프레임 피드백 정리", "오늘"), ("푸시 알림 QA 결과 공유", "내일")],
"e4": [("온보딩 3번 화면 CTA 수정안 반영", "내일"), ("음성 안내 카피 검토", "이번 주")],
}
# ---- 집중 블록 (cal-data.js: focusBlocks, day 8) ----
FOCUS = [
("f1", 8, "09:00", "09:20", "현우님 온보딩 시안 회신", "light", "메일", 0),
("f2", 8, "10:05", "10:25", "PR #482 빠른 리뷰", "light", "개발", 1),
("f3", 8, "15:10", "16:25", "분기 리포트 초안 마무리", "deep", "딥 워크", 2),
("f4", 8, "17:10", "17:35", "독서모임 책 2장 읽기", "light", "성장", 3),
]
# ---- 회의 (cal-data.js: meets) ----
# done: e3 / live: e4 / upcoming: e6 / 1:1 upcoming: e7
MEETINGS = {
"e3": dict(phase="done", one_on_one=False,
meta="09: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가 전분기 대비 <b>+6%p</b> — 첫 화두로 추천드려요",
"내 액션 중 <b>‘예측 시나리오 3종’</b>이 미완 — 미팅 전 12:00 빈 블록을 비워뒀어요"],
docs=["분기 리포트 초안 · 75%", "예산 재배분 시나리오 v2"]),
"e7": dict(phase="upcoming", one_on_one=True, starts_in="오늘 16:30",
person={"name": "민서", "initial": "민", "tone": "green"},
last_meeting={"when": "5월 25일 · 지난 1:1",
"note": "성장 로드맵 — 데이터 분석 역량을 키우고 싶다는 의사. 분석 과제 1건을 맡겨보기로 약속"},
promises=[{"text": "데이터 전처리 파이프라인 과제 위임", "done": True},
{"text": "분석 강의 수강 지원 품의 올리기", "done": False}],
signals=[{"k": "업무량", "v": "진행 4건 · 평균보다 +1건", "tone": "amber"},
{"k": "위임 과제", "v": "전처리 파이프라인 60% · 순항", "tone": "green"},
{"k": "최근 성과", "v": "회고 문서 정리 — 팀 반응 좋음", "tone": "blue"}],
talking_points=["전처리 과제에서 막히는 부분이 있는지",
"강의 지원 품의 — 지난 약속, 진행 상황 공유",
"업무량이 살짝 높아요 — 우선순위 조정이 필요한지"]),
}
def _seed_calendar(s: Session) -> None:
"""일정 시드 — run_seed 내부에서 호출하는 헬퍼."""
for cid_, name, tone, on, order in CALS:
s.add(Calendar(id=cid_, name=name, tone=tone, on=on, sort_order=order))
for i, (eid, day, st, en, title, cal, loc, note, soon, people) in enumerate(EVENTS):
s.add(CalEvent(id=eid, day=day, start=st, end=en, title=title, cal=cal,
loc=loc, note=note, soon=soon, people=people, sort_order=i))
# 이벤트 직접 액션(source="event")
macnt = 0
for eid, acts in EVENT_ACTIONS.items():
for j, (text, when) in enumerate(acts):
macnt += 1
s.add(MeetingAction(id=f"ea{macnt}", event_id=eid, source="event",
idx=j, text=text, who="나", when_text=when,
added=False, sort_order=j))
for (fid, day, st, en, title, typ, tag, order) in FOCUS:
s.add(FocusBlock(id=fid, day=day, start=st, end=en, title=title,
type=typ, tag=tag, auto=True, sort_order=order))
s.commit()
# 회의 + 회의 액션(source="meeting")
mcnt = 0
for eid, d in MEETINGS.items():
s.add(Meeting(
id=eid, event_id=eid, phase=MeetPhase(d["phase"]),
one_on_one=d.get("one_on_one", False), meta=d.get("meta", ""),
starts_in=d.get("starts_in", ""),
summary_json=json.dumps(d.get("summary", []), ensure_ascii=False),
decisions_json=json.dumps(d.get("decisions", []), ensure_ascii=False),
agenda_json=json.dumps(d.get("agenda", []), ensure_ascii=False),
last_meeting_json=json.dumps(d.get("last_meeting", {}), ensure_ascii=False),
last_actions_json=json.dumps(d.get("last_actions", []), ensure_ascii=False),
insights_json=json.dumps(d.get("insights", []), ensure_ascii=False),
docs_json=json.dumps(d.get("docs", []), ensure_ascii=False),
person_json=json.dumps(d.get("person", {}), ensure_ascii=False),
promises_json=json.dumps(d.get("promises", []), ensure_ascii=False),
signals_json=json.dumps(d.get("signals", []), ensure_ascii=False),
talking_points_json=json.dumps(d.get("talking_points", []), ensure_ascii=False),
))
for k, (text, who, when, added) in enumerate(d.get("actions", [])):
mcnt += 1
s.add(MeetingAction(id=f"ma{mcnt}", meeting_id=eid, source="meeting",
idx=k, text=text, who=who, when_text=when,
added=added, sort_order=k))
s.commit()
run_seed(phase-2 seed.py)의 _run() 마지막에 한 줄 추가(reset 목록에도 신규 테이블 포함):
# backend/app/seed.py (수정)
from .seed_calendar import _seed_calendar
from .models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
# _run(reset) 의 reset 테이블 목록에 추가:
# MeetingAction, Meeting, FocusBlock, CalEvent, Calendar (FK 역순 삭제)
# _run() 본문 끝(_seed_dashboard 다음)에:
_seed_calendar(s)
s.commit()
시드 진입 규약은 phase-2 그대로
run_seed(session=None, reset=True)— 신규 헬퍼는 그 하위 호출이다(공개 진입점 추가 금지).
3.7 라우터 — routers/calendar.py
라우터 prefix 규약(phase-2): 라우터 내부 prefix 없이 정의, main.py 의 include_router(prefix="/api", tags=["calendar"]) 에서만 /api 부착.
# backend/app/routers/calendar.py
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ..db import get_session
from ..models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
from ..schemas import (CalendarOut, CalEventOut, EventActionOut, FocusBlockOut,
WeekBundleOut, DayBundleOut, MeetingOut, MeetingActionOut,
MaterializeResponse, FocusSuggestRequest, FocusSuggestResponse)
from ..services.focus import suggest_focus
from ..services.meetings import materialize_action, materialize_all, assemble_meeting
from ..connectors import get_calendar_connector
from ..routers.tasks import to_node # phase-2 TaskNode 빌더 재사용
router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
WEEK = [7, 8, 9, 10, 11, 12, 13]
WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]
TODAY = 8
def _end_from(start: str, dur: int) -> str:
"""start("HH:MM") + dur(분) → end("HH:MM")."""
h, m = map(int, start.split(":"))
total = h * 60 + m + dur
return f"{total // 60:02d}:{total % 60:02d}"
def meet_label(m: Meeting | None) -> str:
if m is None:
return ""
if m.one_on_one:
return "1:1 어시스턴트 브리핑"
if m.phase == "upcoming" or getattr(m.phase, "value", m.phase) == "upcoming":
return "사전 브리핑 보기"
if m.phase == "live" or getattr(m.phase, "value", m.phase) == "live":
return "실시간 기록 보기"
return "회의 노트 · 액션 보기"
def create_event_from_extract(s: Session, ev: dict, source: str) -> str:
"""메일→일정 federation 헬퍼. ev = {title, date|day, time, dur, place?} →
CalEvent 생성(연합 출처 source 기록) 후 id 반환. phase-9 routers/mail.py 가
`from ..routers.calendar import create_event_from_extract` 로 사용한다.
(write_event 커넥터를 통한 저장이며, source 는 ApprovalSource 허용값: mail/calendar/...)"""
eid = "ce-" + uuid.uuid4().hex[:8]
day = int(ev.get("day") or ev.get("date")) # date|day → 6월 날짜(int)
start = ev["time"]
end = _end_from(start, int(ev.get("dur", 60))) # time + dur(분) → "HH:MM"
conn = get_calendar_connector()
conn.write_event(s, dict( # mock=DB 저장 / real(phase-13)=Google 동기화
id=eid, day=day, start=start, end=end, title=ev["title"],
cal="meeting", loc=ev.get("place", ""), note=f"연합 출처: {source}",
soon=False, people="", sort_order=0,
))
return eid
def _event_out(s: Session, e: CalEvent) -> CalEventOut:
m = s.get(Meeting, e.id)
eacts = s.exec(select(MeetingAction).where(
MeetingAction.event_id == e.id, MeetingAction.source == "event")
.order_by(MeetingAction.idx)).all()
return CalEventOut(
id=e.id, day=e.day, start=e.start, end=e.end, title=e.title, cal=e.cal,
loc=e.loc, note=e.note, soon=e.soon,
people=[p.strip() for p in e.people.split(",") if p.strip()],
actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts],
has_meeting=m is not None, meet_label=meet_label(m),
)
@router.get("/calendar/week", response_model=WeekBundleOut)
def get_week(s: Session = Depends(get_session)):
conn = get_calendar_connector() # mock-first
rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)]
cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all()
counts: dict[str, int] = {}
for e in rows:
counts[e.cal] = counts.get(e.cal, 0) + 1
fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all()
return WeekBundleOut(
today=TODAY, week=WEEK, weekdays=WEEKDAYS,
calendars=[CalendarOut(id=c.id, name=c.name, tone=c.tone, on=c.on,
count=counts.get(c.id, 0)) for c in cals],
events=[_event_out(s, e) for e in rows],
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs],
)
@router.get("/calendar/day/{day}", response_model=DayBundleOut)
def get_day(day: int, s: Session = Depends(get_session)):
evs = s.exec(select(CalEvent).where(CalEvent.day == day)
.order_by(CalEvent.start)).all()
fbs = s.exec(select(FocusBlock).where(FocusBlock.day == day)
.order_by(FocusBlock.start)).all()
return DayBundleOut(day=day, events=[_event_out(s, e) for e in evs],
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs])
@router.get("/calendar/meetings/{mid}", response_model=MeetingOut)
def get_meeting(mid: str, s: Session = Depends(get_session)):
data = assemble_meeting(s, mid)
if data is None:
raise HTTPException(404, "meeting not found")
return MeetingOut(**data)
@router.post("/calendar/meetings/{mid}/actions/{idx}/materialize",
response_model=MaterializeResponse)
def materialize_one(mid: str, idx: int, s: Session = Depends(get_session)):
try:
action, task = materialize_action(s, mid, idx)
except ValueError:
raise HTTPException(404, "action not found")
remaining = s.exec(select(MeetingAction).where(
MeetingAction.meeting_id == mid, MeetingAction.added == False)).all() # noqa: E712
return MaterializeResponse(
action=MeetingActionOut(id=action.id, idx=action.idx, text=action.text,
who=action.who, when=action.when_text, added=action.added,
materialized_task_id=action.materialized_task_id),
task=to_node(s, task, s.exec(select(__import__('app.models', fromlist=['Task']).Task)).all()),
all_added=len(remaining) == 0,
)
@router.post("/calendar/meetings/{mid}/actions/materialize-all",
response_model=list[MaterializeResponse])
def materialize_remaining(mid: str, s: Session = Depends(get_session)):
from ..models import Task
pairs = materialize_all(s, mid)
all_tasks = s.exec(select(Task)).all()
return [MaterializeResponse(
action=MeetingActionOut(id=a.id, idx=a.idx, text=a.text, who=a.who,
when=a.when_text, added=a.added,
materialized_task_id=a.materialized_task_id),
task=to_node(s, t, all_tasks), all_added=True) for a, t in pairs]
@router.post("/calendar/focus/suggest", response_model=FocusSuggestResponse)
def focus_suggest(body: FocusSuggestRequest, s: Session = Depends(get_session)):
blocks = suggest_focus(s, body.day, body.min_minutes)
if body.create:
for b in blocks:
s.add(b)
s.commit()
return FocusSuggestResponse(
day=body.day, created=body.create,
suggestions=[FocusBlockOut(**b.model_dump()) for b in blocks])
main.py 등록(phase-2 패턴):
# backend/app/main.py (추가)
from .routers import calendar
app.include_router(calendar.router, prefix="/api", tags=["calendar"])
API 계약 표
| 메서드 | 경로 | 용도 |
|---|---|---|
| GET | /api/calendar/week |
주 뷰 번들: 카테고리(+count)·전 주 이벤트·집중 블록·today/week/weekdays |
| GET | /api/calendar/day/{day} |
일 뷰 번들: 그 날 이벤트 + 집중 블록 |
| GET | /api/calendar/meetings/{mid} |
회의 도우미 데이터(펼친 JSON + 액션) |
| POST | /api/calendar/meetings/{mid}/actions/{idx}/materialize |
액션 1건 → task 실체화(federation) |
| POST | /api/calendar/meetings/{mid}/actions/materialize-all |
남은 액션 모두 → task |
| POST | /api/calendar/focus/suggest |
빈 블록 계산 → 집중 블록 제안(create로 저장) |
월 뷰는 별도 엔드포인트 없이
GET /api/calendar/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-9routers/mail.py의 메일 AI 추출(email.ai.events[])이 이 함수를 호출해cal_event로 실체화한다(액션→작업 패턴과 대칭).source는 ApprovalSource 허용값(calendar/...)을 그대로 쓴다. 내부적으로 커넥터write_event를 통해 저장하므로 phase-13 real 전환 시 Google Calendar 양방향 동기화로 자동 승계된다.
요청/응답 예시
GET /api/calendar/day/8 (축약):
{
"day": 8,
"events": [
{"id":"e3","day":8,"start":"09:30","end":"09:45","title":"팀 데일리 스탠드업",
"cal":"team","loc":"Zoom","note":"","soon":false,"people":[],
"actions":[{"text":"온보딩 와이어프레임 피드백 정리","when":"오늘"},
{"text":"푸시 알림 QA 결과 공유","when":"내일"}],
"has_meeting":true,"meet_label":"회의 노트 · 액션 보기"},
{"id":"e6","day":8,"start":"14:00","end":"15:00","title":"분기 전략 미팅",
"cal":"meeting","loc":"대회의실",
"note":"지난 회의 액션 4/5 완료 · 리텐션 +6%p · 예산 재배분 안건",
"soon":true,"people":["대표님","재무팀장","나 외 3명"],
"actions":[],"has_meeting":true,"meet_label":"사전 브리핑 보기"},
{"id":"e7","day":8,"start":"16:30","end":"17:00","title":"1:1 — 민서님",
"cal":"team","loc":"라운지","note":"","soon":false,"people":[],
"actions":[],"has_meeting":true,"meet_label":"1:1 어시스턴트 브리핑"}
],
"focus_blocks": [
{"id":"f1","day":8,"start":"09:00","end":"09:20","title":"현우님 온보딩 시안 회신",
"type":"light","tag":"메일","task_id":null,"auto":true},
{"id":"f3","day":8,"start":"15:10","end":"16:25","title":"분기 리포트 초안 마무리",
"type":"deep","tag":"딥 워크","task_id":null,"auto":true}
]
}
POST /api/calendar/meetings/e3/actions/2/materialize (e3 의 idx=2 "매출 데이터 전달" → task):
{
"action": {"id":"ma3","idx":2,"text":"매출 데이터 전달","who":"현우","when":"오늘",
"added":true,"materialized_task_id":"mt-9af3c1d0"},
"task": {"id":"mt-9af3c1d0","project_id":"biz","parent_id":null,"title":"매출 데이터 전달",
"status":"todo","assignee_id":"hyunwoo","due":null,"prio":"보통",
"notes":"<p>회의 <b>‘팀 데일리 스탠드업’</b>의 액션 아이템에서 아리가 만든 작업이에요.</p>",
"est":"","delegated":false,"sort_order":7,"comments":[],"children":[]},
"all_added": true
}
GET /api/calendar/meetings/e7 (1:1, 축약):
{
"id":"e7","event_id":"e7","phase":"upcoming","one_on_one":true,"starts_in":"오늘 16:30",
"person":{"name":"민서","initial":"민","tone":"green"},
"last_meeting":{"when":"5월 25일 · 지난 1:1","note":"성장 로드맵 — ..."},
"promises":[{"text":"데이터 전처리 파이프라인 과제 위임","done":true},
{"text":"분석 강의 수강 지원 품의 올리기","done":false}],
"signals":[{"k":"업무량","v":"진행 4건 · 평균보다 +1건","tone":"amber"}],
"talking_points":["전처리 과제에서 막히는 부분이 있는지", "..."],
"summary":[],"decisions":[],"agenda":[],"last_actions":[],"insights":[],"docs":[],"actions":[]
}
3.8 프론트엔드 — 타입/API/시간 유틸
// frontend/lib/calendar/types.ts
import type { Tone, Task } from '@/lib/types'; // phase-3 재사용
export interface Calendar { id: string; name: string; tone: Tone; on: boolean; count: number; }
export interface EventAction { text: string; when: string; }
export interface CalEvent {
id: string; day: number; start: string; end: string; title: string;
cal: string; loc: string; note: string; soon: boolean;
people: string[]; actions: EventAction[]; has_meeting: boolean; meet_label: string;
}
export interface FocusBlock {
id: string; day: number; start: string; end: string;
title: string; type: 'light' | 'deep'; tag: string; task_id: string | null; auto: boolean;
}
export interface MeetingAction {
id: string; idx: number; text: string; who: string; when: string;
added: boolean; materialized_task_id: string | null;
}
export interface Meeting {
id: string; event_id: string; phase: 'done' | 'live' | 'upcoming'; one_on_one: boolean;
meta: string; starts_in: string;
summary: string[]; decisions: string[]; agenda: string[];
last_meeting: { when?: string; note?: string };
last_actions: { text: string; who: string; done: boolean }[];
insights: string[]; docs: string[];
person: { name?: string; initial?: string; tone?: Tone };
promises: { text: string; done: boolean }[];
signals: { k: string; v: string; tone: Tone }[];
talking_points: string[];
actions: MeetingAction[];
}
export interface WeekBundle {
today: number; week: number[]; weekdays: string[];
calendars: Calendar[]; events: CalEvent[]; focus_blocks: FocusBlock[];
}
export interface DayBundle { day: number; events: CalEvent[]; focus_blocks: FocusBlock[]; }
export interface MaterializeResult { action: MeetingAction; task: Task | null; all_added: boolean; }
// frontend/lib/calendar/api.ts
import type { WeekBundle, DayBundle, Meeting, MaterializeResult, FocusBlock } from './types';
const J = { 'Content-Type': 'application/json' };
async function ok<T>(r: Response): Promise<T> {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json() as Promise<T>;
}
export const calendarApi = {
week: () => fetch('/api/calendar/week').then(ok<WeekBundle>),
day: (d: number) => fetch(`/api/calendar/day/${d}`).then(ok<DayBundle>),
meeting: (id: string) => fetch(`/api/calendar/meetings/${id}`).then(ok<Meeting>),
materialize: (mid: string, idx: number) =>
fetch(`/api/calendar/meetings/${mid}/actions/${idx}/materialize`, { method: 'POST' })
.then(ok<MaterializeResult>),
materializeAll: (mid: string) =>
fetch(`/api/calendar/meetings/${mid}/actions/materialize-all`, { method: 'POST' })
.then(ok<MaterializeResult[]>),
focusSuggest: (day: number, min_minutes = 20, create = false) =>
fetch('/api/calendar/focus/suggest', { method: 'POST', headers: J,
body: JSON.stringify({ day, min_minutes, create }) })
.then(ok<{ day: number; suggestions: FocusBlock[]; created: boolean }>),
};
// frontend/lib/calendar/time.ts (cal.jsx 의 toMin/fmt/topOf 이식)
export const HSTART = 7;
export const PXH = 52;
export const HOURS = Array.from({ length: 16 }, (_, i) => 7 + i); // 7..22
export const toMin = (t: string): number => {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
};
export const fmt = (t: string): string => {
const [h, m] = t.split(':').map(Number);
const ap = h < 12 ? '오전' : '오후';
const hh = h % 12 === 0 ? 12 : h % 12;
return `${ap} ${hh}:${String(m).padStart(2, '0')}`;
};
export const topOf = (t: string): number => ((toMin(t) - HSTART * 60) / 60) * PXH;
// frontend/lib/calendar/store.ts (cal.jsx 의 load/save, 접두사 ariC. → ari.cal.)
const PREFIX = 'ari.cal.';
export function loadLS<T>(key: string, fb: T): T {
if (typeof window === 'undefined') return fb;
try { const v = localStorage.getItem(PREFIX + key); return v === null ? fb : (JSON.parse(v) as T); }
catch { return fb; }
}
export function saveLS<T>(key: string, v: T): void {
if (typeof window === 'undefined') return;
try { localStorage.setItem(PREFIX + key, JSON.stringify(v)); } catch { /* quota */ }
}
저장 키(원본 ariC. 매핑): theme(전역은 next-themes 가 관리하므로 미사용), view('week'), selDay(8), calOn({work:true,...}), focusOn(true). 데이터가 아니라 UI 상태만 저장.
3.9 프론트엔드 — 페이지 & 컴포넌트
cal.jsx 의 구조를 컴포넌트로 분해한다. 페이지는 client component("use client") — 뷰 전환/드로어/팝오버 상태가 필요.
// frontend/app/calendar/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { calendarApi } from '@/lib/calendar/api';
import { loadLS, saveLS } from '@/lib/calendar/store';
import type { WeekBundle, CalEvent } from '@/lib/calendar/types';
import { MiniCal } from '@/components/calendar/MiniCal';
import { CalToggleList } from '@/components/calendar/CalToggleList';
import { WeekView } from '@/components/calendar/WeekView';
import { MonthView } from '@/components/calendar/MonthView';
import { DayView } from '@/components/calendar/DayView';
import { EvPopover } from '@/components/calendar/EvPopover';
import { MeetDrawer } from '@/components/calendar/MeetDrawer';
import { Icon } from '@/components/Icon';
const VIEWS = [{ id: 'week', label: '주' }, { id: 'month', label: '월' }, { id: 'day', label: '일' }];
export default function CalendarPage() {
const [bundle, setBundle] = useState<WeekBundle | null>(null);
const [view, setView] = useState<string>(() => loadLS('view', 'week'));
const [selDay, setSelDay] = useState<number>(() => loadLS('selDay', 8));
const [calOn, setCalOn] = useState<Record<string, boolean>>(() => loadLS('calOn', {}));
const [focusOn, setFocusOn] = useState<boolean>(() => loadLS('focusOn', true));
const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null);
const [meetEv, setMeetEv] = useState<CalEvent | null>(null);
useEffect(() => { calendarApi.week().then((b) => {
setBundle(b);
setCalOn((cur) => Object.keys(cur).length ? cur
: Object.fromEntries(b.calendars.map((c) => [c.id, c.on])));
}); }, []);
useEffect(() => saveLS('view', view), [view]);
useEffect(() => saveLS('selDay', selDay), [selDay]);
useEffect(() => saveLS('calOn', calOn), [calOn]);
useEffect(() => saveLS('focusOn', focusOn), [focusOn]);
if (!bundle) return <CalendarSkeleton />; // §6 로딩 스켈레톤
const calMap = Object.fromEntries(bundle.calendars.map((c) => [c.id, c]));
const events = bundle.events.filter((e) => calOn[e.cal] !== false);
const focusBlocks = focusOn ? bundle.focus_blocks : [];
const title = view === 'month' ? '2026년 6월'
: view === 'day' ? `6월 ${selDay}일` : '6월 7일 – 13일';
return (
<div className="tpage">
{/* Topbar 는 layout 에서. 여기선 cmain. */}
<div className="twork">
<aside className="subnav">
<div className="sn-title">일정</div>
<button className="sn-new"><Icon name="plus" /> 새 일정</button>
<MiniCal sel={selDay} today={bundle.today} eventsByDay={countByDay(events)}
onSel={(n) => { setSelDay(n); if (view !== 'day') setView('day'); }} />
<div className="sn-label">내 캘린더</div>
<CalToggleList calendars={bundle.calendars} calOn={calOn}
onToggle={(id) => setCalOn((s) => ({ ...s, [id]: s[id] === false }))} />
<div className="sn-foot">
<div className="av">지</div>
<div className="txt"><b>지우님</b><span>Pro 플랜</span></div>
</div>
</aside>
<main className="cmain">
<div className="chead">
<button className="ch-arrow" aria-label="이전"><Icon name="chev" className="flip" /></button>
<button className="ch-arrow" aria-label="다음"><Icon name="chev" /></button>
<h1 className="ch-title">{title}</h1>
<button className="ch-today" onClick={() => setSelDay(bundle.today)}>오늘</button>
<span className="ch-spacer" />
<button className={'ch-focus' + (focusOn ? ' on' : '')}
onClick={() => setFocusOn((v) => !v)} aria-pressed={focusOn}>
<Icon name="spark" />집중 블록
</button>
<div className="view-seg" role="tablist">
{VIEWS.map((v) => (
<button key={v.id} role="tab" aria-selected={view === v.id}
className={view === v.id ? 'on' : ''} onClick={() => setView(v.id)}>{v.label}</button>
))}
</div>
</div>
<div className="ai-strip">
<div className="ai-ic"><Icon name="spark" /></div>
<p>오늘 일정 중 <b>14:00 분기 전략 미팅</b>이 가장 중요해요. 미팅 사이 빈 시간엔 가벼운 일을,
<b>오후 15:10 빈 블록</b>엔 ‘분기 리포트’ 딥 워크를 자동으로 배치해뒀어요.
16:00 ‘치과 예약’은 1:1과 가까워 15분 앞당기길 추천드려요.</p>
</div>
{view === 'week' && (
<WeekView events={events} focusBlocks={focusBlocks} calMap={calMap}
week={bundle.week} weekdays={bundle.weekdays} today={bundle.today}
onOpen={(ev, e) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })} />
)}
{view === 'month' && (
<MonthView events={events} calMap={calMap} weekdays={bundle.weekdays} today={bundle.today}
onOpen={(ev, e) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })}
onSelDay={(n) => { setSelDay(n); setView('day'); }} />
)}
{view === 'day' && (
<DayView events={events} focusBlocks={focusBlocks} calMap={calMap}
day={selDay} week={bundle.week} weekdays={bundle.weekdays} today={bundle.today}
onOpen={(ev, e) => setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 })}
onMeet={(ev) => { setPop(null); setMeetEv(ev); }} />
)}
</main>
</div>
{pop && <EvPopover ev={pop.ev} x={pop.x} y={pop.y} cal={calMap[pop.ev.cal]}
onClose={() => setPop(null)} onMeet={(ev) => { setPop(null); setMeetEv(ev); }} />}
{meetEv && <MeetDrawer ev={meetEv} cal={calMap[meetEv.cal]} onClose={() => setMeetEv(null)} />}
</div>
);
}
function countByDay(events: CalEvent[]): Record<number, number> {
const m: Record<number, number> = {};
events.forEach((e) => { m[e.day] = (m[e.day] || 0) + 1; });
return m;
}
MeetDrawer.tsx — cal-meet.jsx 의 4상태 렌더를 그대로 이식하되, 액션은 서버 실체화 API를 호출한다(원본은 로컬 state 만 갱신; 여기선 낙관적 업데이트 + API).
// frontend/components/calendar/MeetDrawer.tsx
'use client';
import { useEffect, useState } from 'react';
import { calendarApi } from '@/lib/calendar/api';
import type { CalEvent, Calendar, Meeting } from '@/lib/calendar/types';
import { Icon } from '@/components/Icon';
export function MeetDrawer({ ev, cal, onClose }:
{ ev: CalEvent; cal: Calendar; onClose: () => void }) {
const [meet, setMeet] = useState<Meeting | null>(null);
useEffect(() => { calendarApi.meeting(ev.id).then(setMeet); }, [ev.id]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [onClose]);
if (!meet) return null;
const tone = meet.one_on_one && meet.person.tone ? meet.person.tone : cal.tone;
const addOne = async (idx: number) => {
// 낙관적 업데이트
setMeet((m) => m && ({ ...m, actions: m.actions.map((a) =>
a.idx === idx ? { ...a, added: true } : a) }));
await calendarApi.materialize(ev.id, idx); // federation → 작업 페이지에 등장
};
const addAll = async () => {
setMeet((m) => m && ({ ...m, actions: m.actions.map((a) => ({ ...a, added: true })) }));
await calendarApi.materializeAll(ev.id);
};
const remaining = meet.actions.filter((a) => !a.added).length;
const phaseBadge = meet.phase === 'done'
? <span className="md-phase done"><Icon name="tick" />회의 노트 정리됨</span>
: meet.phase === 'live'
? <span className="md-phase live"><span className="md-pulse" />기록 중</span>
: <span className="md-phase up"><Icon name="clock" />{meet.starts_in}</span>;
return (
<>
<div className="dp-backdrop" onClick={onClose} />
<aside className={'mdrawer tint-' + tone} role="dialog" aria-label="회의 도우미" aria-modal="true">
<div className="md-head">
<div className="md-ic"><Icon name={meet.one_on_one ? 'heart' : 'spark'} /></div>
<div className="md-ht">
<span className="md-k">{meet.one_on_one ? '1:1 어시스턴트'
: meet.phase === 'upcoming' ? '사전 브리핑' : '회의 도우미'}</span>
<b>{ev.title}</b>
<span className="md-sub">{ev.start}–{ev.end}{ev.loc ? ' · ' + ev.loc : ''} · {cal.name}</span>
</div>
<button className="md-x" onClick={onClose} aria-label="닫기"><Icon name="x" /></button>
</div>
<div className="md-bar">{phaseBadge}{meet.meta && <span className="md-meta">{meet.meta}</span>}</div>
<div className="md-body">
{/* 1:1 / upcoming / done|live 분기는 cal-meet.jsx 그대로 (§5 디자인 충실도) */}
{meet.one_on_one && <OneOnOneBody meet={meet} />}
{!meet.one_on_one && meet.phase === 'upcoming' && <UpcomingBody meet={meet} />}
{!meet.one_on_one && meet.phase !== 'upcoming' && (
<DoneLiveBody meet={meet} remaining={remaining} onAddOne={addOne} onAddAll={addAll} />
)}
</div>
</aside>
</>
);
}
OneOnOneBody/UpcomingBody/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: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)=<Icon name="chev" />, 이전(prev)=<Icon name="chev" className="flip" />(CSS .flip { transform: rotate(180deg); } 180° 회전). 중앙 paths 맵에 prev/next 글리프를 추가하지 않고 chev 단일 글리프로 통일 |
5.2 이벤트/집중 블록 색
이벤트 tint 토큰(cal.css):
.tint-blue { --evc: var(--blue); --evbg: color-mix(in oklab, var(--blue) 15%, var(--card)); --evtx: color-mix(in oklab, var(--blue) 72%, var(--ink)); }
.tint-amber { --evc: var(--amber); --evbg: color-mix(in oklab, var(--amber) 17%, var(--card)); ... }
/* violet / green / coral 동일 패턴 */
- 이벤트 블록:
.ev { border-left: 3px solid var(--evc); background: var(--evbg); color: var(--evtx); },.ev.soon { box-shadow: 0 0 0 1.5px var(--evc); }(분기 전략 미팅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-headviolet) —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 |
하루 마감 집중 시간 집계 |
발행 코드(정본 모듈 사용):
# 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 반영)
- 회의 → 작업:
e3(스탠드업, done) 드로어 → "매출 데이터 전달"(현우) "작업으로" →POST .../actions/2/materialize→task(assignee=hyunwoo) 생성 → 작업 페이지biz프로젝트에 등장. 데모: phase-3 작업 보드에서 즉시 확인. - 일정 ↔ 작업 집중모드: 작업(
k1분기 리포트, due 6/8 진행 중)이 일정의 오후 15:10 빈 블록(f3, deep)에 자동 배치 — AI 브리핑이 그 연결을 말로 설명. - 분기 전략 미팅 사전 브리핑:
e6(upcoming) 드로어의 docs(분기 리포트 초안 · 75%)·인사이트(예측 시나리오 3종 미완 → 12:00 빈 블록 비워둠)가 작업/리포트와 연결(phase-3k1/biz-report, phase-10 리서치 리포트와도 교차).
8. 테스팅 & 검증
8.1 실행 명령
# 백엔드
cd backend
uv run python -m app.seed # 시드(일정 포함) 적재
uv run uvicorn app.main:app --reload # 서버
uv run pytest tests/test_seed_calendar.py tests/test_api_calendar.py \
tests/test_focus.py tests/test_meeting_materialize.py -v
# 프론트
cd frontend
pnpm dev # http://localhost:3000/calendar
pnpm vitest run tests/calendar # 컴포넌트 단위
pnpm playwright test playwright/calendar.spec.ts # E2E + axe
8.2 백엔드 테스트 케이스 (pytest)
conftest 는 phase-2 의 in-memory DB + run_seed(session=test_session, reset=True) fixture 를 재사용한다.
# tests/test_seed_calendar.py
def test_calendar_seed_counts(session):
from app.models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction
from sqlmodel import select
assert len(session.exec(select(Calendar)).all()) == 5
assert len(session.exec(select(CalEvent)).all()) == 22
assert len(session.exec(select(FocusBlock)).all()) == 4
assert {m.id for m in session.exec(select(Meeting)).all()} == {"e3", "e4", "e6", "e7"}
# e3 액션 3건, 그 중 added=False 는 1건(매출 데이터 전달)
e3 = [a for a in session.exec(select(MeetingAction)).all()
if a.meeting_id == "e3"]
assert len(e3) == 3
assert sum(1 for a in e3 if not a.added) == 1
def test_focus_seed_deep(session):
from app.models import FocusBlock
from sqlmodel import select
deep = [f for f in session.exec(select(FocusBlock)).all() if f.type == "deep"]
assert len(deep) == 1 and deep[0].id == "f3"
assert deep[0].start == "15:10" and deep[0].end == "16:25"
# tests/test_api_calendar.py
def test_week_bundle(client):
r = client.get("/api/calendar/week"); assert r.status_code == 200
b = r.json()
assert b["today"] == 8 and b["week"] == [7,8,9,10,11,12,13]
assert b["weekdays"] == ["일","월","화","수","목","금","토"]
assert len(b["calendars"]) == 5 and len(b["events"]) == 22 and len(b["focus_blocks"]) == 4
# 카테고리 count: team 카테고리 이벤트 수 검증
team = next(c for c in b["calendars"] if c["id"] == "team")
assert team["count"] == sum(1 for e in b["events"] if e["cal"] == "team")
def test_day_bundle_has_meeting_and_actions(client):
r = client.get("/api/calendar/day/8"); b = r.json()
e3 = next(e for e in b["events"] if e["id"] == "e3")
assert e3["has_meeting"] is True
assert e3["meet_label"] == "회의 노트 · 액션 보기"
assert [a["text"] for a in e3["actions"]] == ["온보딩 와이어프레임 피드백 정리", "푸시 알림 QA 결과 공유"]
e6 = next(e for e in b["events"] if e["id"] == "e6")
assert e6["soon"] is True and e6["meet_label"] == "사전 브리핑 보기"
assert e6["people"] == ["대표님", "재무팀장", "나 외 3명"]
def test_meeting_phases(client):
assert client.get("/api/calendar/meetings/e3").json()["phase"] == "done"
assert client.get("/api/calendar/meetings/e4").json()["phase"] == "live"
up = client.get("/api/calendar/meetings/e6").json()
assert up["phase"] == "upcoming" and len(up["agenda"]) == 3 and len(up["docs"]) == 2
oo = client.get("/api/calendar/meetings/e7").json()
assert oo["one_on_one"] is True and oo["person"]["name"] == "민서"
assert len(oo["talking_points"]) == 3
def test_meeting_404(client):
assert client.get("/api/calendar/meetings/nope").status_code == 404
# tests/test_meeting_materialize.py
def test_materialize_one_creates_task(client, session):
from app.models import Task
# e3 idx=2 "매출 데이터 전달" (현우, added=False)
r = client.post("/api/calendar/meetings/e3/actions/2/materialize")
assert r.status_code == 200
data = r.json()
assert data["action"]["added"] is True
assert data["task"]["title"] == "매출 데이터 전달"
assert data["task"]["assignee_id"] == "hyunwoo" # who 나매핑
assert data["task"]["project_id"] == "biz" # team/work/meeting → biz
assert data["all_added"] is True # e3 의 마지막 미처리 액션이었음
# 작업 페이지에 등장 (federation)
tid = data["task"]["id"]
assert session.get(Task, tid) is not None
def test_materialize_idempotent(client):
r1 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
t1 = r1.json()["task"]["id"]
r2 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
assert r2.json()["task"]["id"] == t1 # 중복 생성 없음
def test_materialize_all(client):
# e4(live) 액션 1건 모두
r = client.post("/api/calendar/meetings/e4/actions/materialize-all")
arr = r.json()
assert len(arr) == 1
assert arr[0]["task"]["title"] == "온보딩 3번 화면 CTA 수정안 반영"
assert arr[0]["all_added"] is True
# tests/test_focus.py
def test_free_slots_day8(session):
from app.services.focus import free_slots, suggest_focus
slots = free_slots(session, 8)
# 09:00 시작, 이벤트들(09:30,11:00,13:00,14:00,16:30) 사이 빈 구간 존재
assert any(b - a >= 60 for a, b in slots) # 큰 빈 블록 1+ 존재
sug = suggest_focus(session, 8, min_minutes=20)
assert any(f.type == "deep" for f in sug) # deep 제안 존재
8.3 프론트 컴포넌트 테스트 (Vitest + RTL)
// tests/calendar/MeetDrawer.test.tsx — 4상태 렌더 + 액션 왕복
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MeetDrawer } from '@/components/calendar/MeetDrawer';
import { calendarApi } from '@/lib/calendar/api';
vi.mock('@/lib/calendar/api');
const ev = { id: 'e3', title: '팀 데일리 스탠드업', start: '09:30', end: '09:45', loc: 'Zoom' } as any;
const cal = { id: 'team', name: '팀', tone: 'green', on: true, count: 4 } as any;
test('done 회의: 요약·결정·액션 + 작업으로', async () => {
(calendarApi.meeting as any).mockResolvedValue({
id: 'e3', phase: 'done', one_on_one: false, meta: '...',
summary: ['s1'], decisions: ['d1'], actions: [
{ idx: 2, text: '매출 데이터 전달', who: '현우', when: '오늘', added: false, materialized_task_id: null },
], agenda: [], last_meeting: {}, last_actions: [], insights: [], docs: [],
person: {}, promises: [], signals: [], talking_points: [], starts_in: '',
});
(calendarApi.materialize as any).mockResolvedValue({ action: {}, task: {}, all_added: true });
render(<MeetDrawer ev={ev} cal={cal} onClose={() => {}} />);
expect(await screen.findByText('회의 노트 정리됨')).toBeInTheDocument();
expect(screen.getByText('매출 데이터 전달')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /작업으로/ }));
await waitFor(() => expect(calendarApi.materialize).toHaveBeenCalledWith('e3', 2));
});
test('upcoming: 안건·지난액션·인사이트·docs', async () => {
(calendarApi.meeting as any).mockResolvedValue({
id: 'e6', phase: 'upcoming', one_on_one: false, starts_in: '2시간 50분 후 시작',
agenda: ['Q2 핵심 지표 리뷰 — 리텐션 · 매출'], docs: ['분기 리포트 초안 · 75%'],
insights: ['리텐션 KPI가 전분기 대비 <b>+6%p</b>'], last_actions: [{ text: 'x', who: '나', done: false }],
last_meeting: { when: '지난주 월', note: 'n' }, summary: [], decisions: [], actions: [],
person: {}, promises: [], signals: [], talking_points: [], meta: '',
});
render(<MeetDrawer ev={{ ...ev, id: 'e6', title: '분기 전략 미팅' }} cal={cal} onClose={() => {}} />);
expect(await screen.findByText('2시간 50분 후 시작')).toBeInTheDocument();
expect(screen.getByText(/Q2 핵심 지표 리뷰/)).toBeInTheDocument();
expect(screen.getByText(/분기 리포트 초안 · 75%/)).toBeInTheDocument();
});
test('live: 기록 중 배지', async () => {
(calendarApi.meeting as any).mockResolvedValue({
id: 'e4', phase: 'live', one_on_one: false, summary: ['논의1'],
decisions: [], actions: [{ idx: 0, text: 'a', who: '나', when: '내일', added: false, materialized_task_id: null }],
agenda: [], last_meeting: {}, last_actions: [], insights: [], docs: [],
person: {}, promises: [], signals: [], talking_points: [], meta: '', starts_in: '',
});
render(<MeetDrawer ev={{ ...ev, id: 'e4' }} cal={cal} onClose={() => {}} />);
expect(await screen.findByText('기록 중')).toBeInTheDocument();
});
test('1:1: 약속·시그널·대화 주제', async () => {
(calendarApi.meeting as any).mockResolvedValue({
id: 'e7', phase: 'upcoming', one_on_one: true, starts_in: '오늘 16:30',
person: { name: '민서', initial: '민', tone: 'green' },
promises: [{ text: '강의 지원 품의', done: false }],
signals: [{ k: '업무량', v: '진행 4건', tone: 'amber' }],
talking_points: ['전처리 과제에서 막히는 부분이 있는지'],
last_meeting: { when: '5월 25일', note: 'n' }, summary: [], decisions: [], agenda: [],
last_actions: [], insights: [], docs: [], actions: [], meta: '',
});
render(<MeetDrawer ev={{ ...ev, id: 'e7', title: '1:1 — 민서님' }} cal={cal} onClose={() => {}} />);
expect(await screen.findByText('1:1 어시스턴트')).toBeInTheDocument();
expect(screen.getByText('민서님 시그널')).toBeInTheDocument();
expect(screen.getByText('전처리 과제에서 막히는 부분이 있는지')).toBeInTheDocument();
});
// tests/calendar/views.test.tsx — 뷰 전환 + 집중 블록 토글
test('뷰 세그먼트 전환 week→month→day', async () => { /* role=tab aria-selected 검증 */ });
test('집중 블록 토글 off 시 .fblock 미렌더', () => { /* ch-focus.on 해제 → focusBlocks 빈 */ });
test('카테고리 토글 off 시 해당 cal 이벤트 숨김', () => { /* calOn[health]=false → health 이벤트 미표시 */ });
8.4 E2E (Playwright) — 액션 → 작업 왕복
// playwright/calendar.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('회의 액션 → 작업 페이지 등장 (federation)', async ({ page }) => {
await page.goto('/calendar');
// 일 뷰로 (오늘 6/8)
await page.getByRole('tab', { name: '일' }).click();
// 스탠드업(e3) 어젠다의 회의 도우미 진입
await page.getByRole('button', { name: /회의 노트 · 액션 보기/ }).first().click();
await expect(page.getByRole('dialog', { name: '회의 도우미' })).toBeVisible();
await expect(page.getByText('매출 데이터 전달')).toBeVisible();
// "작업으로"
await page.getByRole('button', { name: /작업으로/ }).click();
await expect(page.getByText('작업에 있음')).toBeVisible(); // md-act-done
// 작업 페이지에서 확인
await page.goto('/tasks');
await page.getByRole('link', { name: '업무' }).click(); // work 영역
await expect(page.getByText('매출 데이터 전달')).toBeVisible();
});
test('모두 작업으로 → md-allok', async ({ page }) => {
await page.goto('/calendar');
await page.getByRole('tab', { name: '일' }).click();
await page.getByRole('button', { name: /회의 노트 · 액션 보기/ }).first().click();
const all = page.getByRole('button', { name: /모두 작업으로 보내기/ });
if (await all.isVisible()) await all.click();
await expect(page.getByText('모든 액션이 담당자 작업으로 등록됐어요')).toBeVisible();
});
test('드로어 a11y (axe)', async ({ page }) => {
await page.goto('/calendar');
await page.getByRole('tab', { name: '일' }).click();
await page.getByRole('button', { name: /사전 브리핑 보기/ }).first().click();
const results = await new AxeBuilder({ page }).include('[role="dialog"]').analyze();
expect(results.violations).toEqual([]);
});
test('Escape 로 드로어 닫힘', async ({ page }) => {
await page.goto('/calendar');
await page.getByRole('tab', { name: '일' }).click();
await page.getByRole('button', { name: /1:1 어시스턴트 브리핑/ }).first().click();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toBeHidden();
});
8.5 수동 QA 체크리스트
/calendar진입 시 주 뷰가 기본, 6/8(월) 헤더 원형이 coral, 현재 시각선이 11:10 위치.- 시간 라벨 7:00~22:00, 한 시간 = 52px. 이벤트 블록이 start/end 에 정확히 위치.
- 분기 전략 미팅(
e6)에soonring(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.pyfree-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 에서 다룬다.