|
|
# Phase 8 — 일정 + 회의 도우미
|
|
|
|
|
|
> 한 줄 요약: 주/월/일 캘린더(`cal.jsx`/`cal.css` 픽셀 재현) · 회의 도우미 드로어 4상태(done/live/upcoming/1:1, `cal-meet.jsx`) · 아리가 빈 시간에 자동 배치하는 집중 모드 블록 · **회의 액션아이템 → 작업 실체화(federation)** 를, mock-first 커넥터 위에 구현한다.
|
|
|
|
|
|
> 이 문서는 포스트-MVP 세트의 일부 — 먼저 `dev/overview.md` 와 `dev/post-mvp-overview.md` 를 읽으세요.
|
|
|
> 선행: `phase-2-backend.md`(모델/스키마/라우터/시드/LLM 추상화 규약), `phase-3-tasks.md`(작업 트리·칸반·드로어, `materialized` 연합), `phase-7-approvals-automation.md`(승인 큐·자동화 엔진·event_bus). 후속: `phase-9-mail-notifications.md`.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. 개요 & 목표
|
|
|
|
|
|
이 phase가 끝나면 **일정 페이지(`/calendar`)와 회의 도우미가 동작**한다. 구체적으로:
|
|
|
|
|
|
- 주/월/일 **3개 뷰**가 `cal.jsx`/`cal.css`를 픽셀 충실하게 재현한다. 오늘 = **6월 8일(월)**, 이번 주 = 6/7(일)~6/13(토). 시간 그리드 7~22시, `PXH=52px`, `HSTART=7`.
|
|
|
- 좌측 SubRail에 **미니 달력**(`MiniCal`, 2026년 6월) + **캘린더 카테고리 토글**(`업무/미팅/개인/팀/건강` 5종)이 표시되고 토글로 이벤트가 필터된다.
|
|
|
- **집중 모드 블록**(`focusBlocks`)이 아리가 빈 시간에 자동 배치한 딥워크/가벼운 일로 주/일 뷰의 빈 슬롯에 점선 블록으로 렌더되고, 상단 `집중 블록` 토글로 켜고 끈다.
|
|
|
- 일 뷰 어젠다/이벤트 팝오버에서 **회의 도우미 드로어**를 연다. 드로어는 4상태:
|
|
|
- **done**: 음성 기록 → 요약·결정·**액션 아이템 추출** → "작업으로"/"남은 N건 모두 작업으로".
|
|
|
- **live**: 실시간 기록(지금까지 논의 + pulse 배지).
|
|
|
- **upcoming**: 사전 브리핑(안건·지난 회의·지난 액션 체크·아리 인사이트·docs).
|
|
|
- **1:1**: 지난 1:1 요약·지난 약속 체크·상대 시그널·대화 주제.
|
|
|
- **액션 아이템 → 작업 실체화(federation)**: 드로어의 "작업으로" 버튼이 `POST /api/calendar/meetings/{id}/actions/{idx}/materialize` 를 호출 → `task` 생성(`materialized_task_id`로 액션과 연결) → **작업 페이지(`phase-3`)에 즉시 등장**. `meeting.ended` 이벤트가 `event_bus`(phase-7)로 발행된다.
|
|
|
- **calendar 커넥터**는 mock-first: `MockCalendarConnector`(시드 기반)가 이벤트/회의 데이터를 공급한다. 실제 Google Calendar(`RealCalendarConnector`)는 **phase-13**. STT(회의 음성 → 노트)는 **phase-14**, 여기선 시드 노트(`meet.summary`/`meet.actions`)를 그대로 쓴다.
|
|
|
- 신규 백엔드: 모델 4종(`calendar`/`event`(확장)/`focus_block`/`meeting`) + 시드(`cal-data.js` 그대로) + REST API 6종.
|
|
|
- pytest(백엔드)/Vitest+RTL(드로어 4상태)/Playwright(액션→작업 왕복 E2E)/axe(드로어 a11y)가 전부 green.
|
|
|
|
|
|
> 이 phase는 "철학의 실현(회의에서 나온 일을 사용자가 손으로 옮기지 않는다 — 아리가 작업으로 보낸다)"을 일정 도메인에서 구현한다. **필드명·엔드포인트·동작은 CONTRACT 와 phase-2 패턴을 그대로 따른다.**
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2. 선행 조건 / 산출물
|
|
|
|
|
|
### 2.1 선행 조건 (의존 phase)
|
|
|
|
|
|
| 의존 | 내용 |
|
|
|
|---|---|
|
|
|
| `phase-2-backend.md` | `models.py`/`schemas.py`/라우터 prefix 규약(`/api`는 main.py에서만 부착), `run_seed(session=None, reset=True)`, LLM `get_provider()`, TEXT PK 규약, tone 집합. 본 phase의 신규 테이블/엔드포인트는 **이 패턴을 그대로 따른다**. |
|
|
|
| `phase-3-tasks.md` | `Task` 모델, `POST /api/tasks`, `materialized_*` 연합 패턴, `frontend/lib/types.ts`(`Task`/`Tone`/`Person`), 작업 트리 등장. 회의 액션 → 작업은 phase-3의 `Task` 생성을 재사용한다. |
|
|
|
| `phase-7-approvals-automation.md` | `backend/app/automation/event_bus.py`(내부 이벤트 발행/구독), `approval` 모델·승인 큐. `meeting.ended` 이벤트를 발행하고, 사전 브리핑의 "빈 블록 비워둠" 같은 자동 동작이 `approval`로 흐를 수 있다. **선택 통합**(없어도 동작하되, 있으면 event_bus로 발행). |
|
|
|
| `phase-1-design-system.md` | 디자인 토큰(`tokens.css`), `Topbar`(13항목), `Icon`(중앙 paths 맵), SubRail. 본 phase는 `cal` 항목을 active 로 표시한다. |
|
|
|
|
|
|
### 2.2 산출물 (Deliverables)
|
|
|
|
|
|
```
|
|
|
backend/
|
|
|
├─ app/
|
|
|
│ ├─ models.py (+ Calendar, CalEvent, FocusBlock, Meeting, MeetingAction)
|
|
|
│ ├─ schemas.py (+ CalendarOut, CalEventOut, FocusBlockOut, MeetingOut, ...)
|
|
|
│ ├─ seed_calendar.py cal-data.js 이식 (run_seed 에서 호출)
|
|
|
│ ├─ connectors/
|
|
|
│ │ ├─ __init__.py
|
|
|
│ │ ├─ base.py CalendarConnector 추상 인터페이스 (+ 도메인 베이스)
|
|
|
│ │ ├─ calendar_mock.py MockCalendarConnector (시드 기반)
|
|
|
│ │ └─ calendar_real.py RealCalendarConnector (phase-13 stub)
|
|
|
│ ├─ services/
|
|
|
│ │ ├─ focus.py 빈 블록 계산 → 집중 블록 제안 (free-slot finder)
|
|
|
│ │ └─ meetings.py 회의 데이터 조립 + 액션 → 작업 실체화
|
|
|
│ └─ routers/
|
|
|
│ └─ calendar.py GET events/day · GET meetings/{id} · POST actions materialize · POST focus/suggest
|
|
|
│ + create_event_from_extract(s, ev, source)->str (메일→일정 federation 헬퍼; phase-9 가 import)
|
|
|
├─ tests/
|
|
|
│ ├─ test_seed_calendar.py
|
|
|
│ ├─ test_api_calendar.py
|
|
|
│ ├─ test_focus.py
|
|
|
│ └─ test_meeting_materialize.py
|
|
|
frontend/
|
|
|
├─ app/calendar/page.tsx 일정 페이지 (week/month/day + 드로어)
|
|
|
├─ components/calendar/
|
|
|
│ ├─ MiniCal.tsx CalToggleList.tsx
|
|
|
│ ├─ WeekView.tsx MonthView.tsx DayView.tsx
|
|
|
│ ├─ EvBlock.tsx FocusBlock.tsx EvPopover.tsx
|
|
|
│ └─ MeetDrawer.tsx (cal-meet.jsx 이식, 4상태)
|
|
|
├─ lib/calendar/
|
|
|
│ ├─ types.ts CalEvent/Calendar/FocusBlock/Meeting/MeetingAction
|
|
|
│ ├─ api.ts calendarApi
|
|
|
│ ├─ time.ts toMin/fmt/topOf (cal.jsx 이식)
|
|
|
│ └─ store.ts localStorage (ari.cal.)
|
|
|
├─ tests/calendar/*.test.tsx Vitest + RTL
|
|
|
└─ playwright/calendar.spec.ts 액션→작업 왕복 E2E + axe
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3. 상세 구현
|
|
|
|
|
|
### 3.1 백엔드 모델 — `models.py` (추가분)
|
|
|
|
|
|
phase-2 의 `models.py` 패턴(TEXT PK, `str, Enum`, `now()` 기본값, tone 집합)을 그대로 따른다. **주의: phase-2 에 이미 대시보드용 읽기전용 `Event` 테이블이 있다**(시드 `e1`~`e4`, `time`/`title`/`tag`/`dur`/`tone`/`soon`). 일정 페이지의 이벤트는 시간 그리드(`day`/`start`/`end`/`cal`/`loc`/`people`/`note`/`soon`/`actions`)가 필요해 모델이 다르므로 **별도 테이블 `CalEvent`(`__tablename__="cal_event"`)** 로 신설한다(기존 `event` 테이블·대시보드 계약을 깨지 않음).
|
|
|
|
|
|
```python
|
|
|
# backend/app/models.py (Phase 8 추가분)
|
|
|
from __future__ import annotations
|
|
|
from enum import Enum
|
|
|
from typing import Optional
|
|
|
from sqlmodel import SQLModel, Field, Relationship
|
|
|
# 기존: now(), Sphere, ClsType 등은 phase-2 정의 재사용
|
|
|
|
|
|
# ---------- 캘린더 카테고리 (cal-data.js: cals) ----------
|
|
|
class Calendar(SQLModel, table=True):
|
|
|
__tablename__ = "calendar"
|
|
|
id: str = Field(primary_key=True) # "work"/"meeting"/"personal"/"team"/"health"
|
|
|
name: str # "업무"/"미팅"/"개인"/"팀"/"건강"
|
|
|
tone: str = "ink" # tone 집합: blue|violet|coral|green|amber|ink|faint
|
|
|
on: bool = True # 기본 표시 여부 (프론트는 localStorage 로 사용자 토글 영속)
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
# ---------- 캘린더 이벤트 (cal-data.js: events) — 시간 그리드용 ----------
|
|
|
class CalEvent(SQLModel, table=True):
|
|
|
__tablename__ = "cal_event"
|
|
|
id: str = Field(primary_key=True) # "e1".."e22" (cal-data.js 그대로; 대시보드 event 와 별개 테이블)
|
|
|
day: int # 6월 날짜 (7..13)
|
|
|
start: str # "HH:MM"
|
|
|
end: str # "HH:MM"
|
|
|
title: str
|
|
|
cal: str = Field(foreign_key="calendar.id") # 카테고리
|
|
|
loc: str = "" # 장소 (옵션)
|
|
|
note: str = "" # 한 줄 메모 (옵션)
|
|
|
soon: bool = False # '곧' 배지
|
|
|
people: str = "" # "대표님, 재무팀장, 나 외 3명" (콤마구분 문자열로 저장)
|
|
|
sort_order: int = 0
|
|
|
# actions(이벤트에 직접 달린 '내가 맡기로 한 일')는 MeetingAction(source="event")로 통합 관리
|
|
|
|
|
|
|
|
|
# ---------- 집중 모드 블록 (cal-data.js: focusBlocks) ----------
|
|
|
class FocusBlock(SQLModel, table=True):
|
|
|
__tablename__ = "focus_block"
|
|
|
id: str = Field(primary_key=True) # "f1".."f4"
|
|
|
day: int # 6월 날짜 (8 = 오늘)
|
|
|
start: str # "HH:MM"
|
|
|
end: str # "HH:MM"
|
|
|
title: str # "분기 리포트 초안 마무리"
|
|
|
type: str = "light" # "light" | "deep"
|
|
|
tag: str = "" # "메일"/"개발"/"딥 워크"/"성장"
|
|
|
task_id: Optional[str] = Field(default=None, foreign_key="task.id") # 연결된 작업(있으면)
|
|
|
auto: bool = True # 아리 자동 배치 여부
|
|
|
sort_order: int = 0
|
|
|
|
|
|
|
|
|
# ---------- 회의 (cal-data.js: meets[evId]) ----------
|
|
|
class MeetPhase(str, Enum):
|
|
|
done = "done"
|
|
|
live = "live"
|
|
|
upcoming = "upcoming"
|
|
|
|
|
|
|
|
|
class Meeting(SQLModel, table=True):
|
|
|
__tablename__ = "meeting"
|
|
|
id: str = Field(primary_key=True) # event id 와 동일 키 사용 ("e3","e4","e6","e7")
|
|
|
event_id: str = Field(foreign_key="cal_event.id")
|
|
|
phase: MeetPhase = MeetPhase.upcoming
|
|
|
one_on_one: bool = False
|
|
|
meta: str = "" # "09:30–09:45 · 15분 · 음성 기록 → 노트 자동 정리됨"
|
|
|
starts_in: str = "" # upcoming: "2시간 50분 후 시작"
|
|
|
# JSON 직렬화 필드(리스트/딕트) — SQLite 는 JSON 컬럼을 TEXT 로 저장
|
|
|
summary_json: str = "[]" # list[str] (done/live)
|
|
|
decisions_json: str = "[]" # list[str] (done)
|
|
|
agenda_json: str = "[]" # list[str] (upcoming)
|
|
|
last_meeting_json: str = "{}" # {when,note}
|
|
|
last_actions_json: str = "[]" # [{text,who,done}]
|
|
|
insights_json: str = "[]" # list[str(HTML)] (upcoming)
|
|
|
docs_json: str = "[]" # list[str]
|
|
|
# 1:1 전용
|
|
|
person_json: str = "{}" # {name,initial,tone}
|
|
|
promises_json: str = "[]" # [{text,done}]
|
|
|
signals_json: str = "[]" # [{k,v,tone}]
|
|
|
talking_points_json: str = "[]" # list[str]
|
|
|
|
|
|
actions: list["MeetingAction"] = Relationship(back_populates="meeting")
|
|
|
|
|
|
|
|
|
# ---------- 회의/이벤트 액션 아이템 (작업으로 실체화 대상) ----------
|
|
|
class MeetingAction(SQLModel, table=True):
|
|
|
__tablename__ = "meeting_action"
|
|
|
id: str = Field(primary_key=True) # "ma1", ...
|
|
|
meeting_id: Optional[str] = Field(default=None, foreign_key="meeting.id")
|
|
|
event_id: Optional[str] = Field(default=None, foreign_key="cal_event.id") # source="event"인 어젠다 액션
|
|
|
source: str = "meeting" # "meeting" | "event"
|
|
|
idx: int = 0 # 회의 내 순번 (UI add-one 인덱스)
|
|
|
text: str
|
|
|
who: str = "나" # 담당자 표시명 (cal-data.js 의 who 그대로: 나/현우/수아...)
|
|
|
when_text: str = "" # "오늘"/"내일"/"이번 주"
|
|
|
added: bool = False # 작업으로 보냈는가
|
|
|
materialized_task_id: Optional[str] = Field(default=None, foreign_key="task.id")
|
|
|
sort_order: int = 0
|
|
|
|
|
|
meeting: Optional[Meeting] = Relationship(back_populates="actions")
|
|
|
```
|
|
|
|
|
|
> **JSON 컬럼 규약**: 회의 데이터는 중첩 리스트/딕트가 많아 정규화하면 테이블이 과도해진다. phase-2 의 단순 스키마 철학을 유지하기 위해 **표시 전용(읽기) 데이터는 `*_json` TEXT 컬럼**에 `json.dumps`로 저장하고, 응답 스키마에서 `json.loads`로 펼친다. **단, `actions`(작업으로 실체화되는 상태가 있는 데이터)만 정규 테이블 `MeetingAction`** 으로 분리한다 — 실체화/되돌리기/연합이 걸리기 때문이다. `who`/`when`/`added`/`materialized_task_id` 가 그 상태다.
|
|
|
|
|
|
> **`who` → `assignee_id` 매핑**: 액션의 `who`는 cal-data.js 표시명(`나`/`현우`/`수아`/`재무팀장`)이다. 실체화 시 `services/meetings.py`의 `_person_id(who)`가 person 으로 매핑한다(`나→jiwoo`, `현우→hyunwoo`, `민서→minseo`, `재호→jaeho`, `수아→sua`, 매핑 실패(`대표님`/`재무팀장` 등)는 `assignee_id=None`).
|
|
|
|
|
|
---
|
|
|
|
|
|
### 3.2 백엔드 스키마 — `schemas.py` (추가분)
|
|
|
|
|
|
phase-2 의 `*Out` 패턴을 따른다. 응답은 프론트가 그대로 쓰는 형태(중첩/펼친 JSON).
|
|
|
|
|
|
```python
|
|
|
# backend/app/schemas.py (Phase 8 추가분)
|
|
|
from __future__ import annotations
|
|
|
from typing import Optional, Any
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
class CalendarOut(BaseModel):
|
|
|
id: str
|
|
|
name: str
|
|
|
tone: str
|
|
|
on: bool
|
|
|
count: int = 0 # 해당 카테고리 이벤트 수 (SubRail 우측 숫자)
|
|
|
|
|
|
|
|
|
class EventActionOut(BaseModel):
|
|
|
text: str
|
|
|
when: str # cal.jsx 어젠다 액션의 when (오늘/내일/이번 주)
|
|
|
|
|
|
|
|
|
class CalEventOut(BaseModel):
|
|
|
id: str
|
|
|
day: int
|
|
|
start: str
|
|
|
end: str
|
|
|
title: str
|
|
|
cal: str
|
|
|
loc: str = ""
|
|
|
note: str = ""
|
|
|
soon: bool = False
|
|
|
people: list[str] = [] # 콤마 문자열 → 배열로 펼침
|
|
|
actions: list[EventActionOut] = [] # source="event" 액션
|
|
|
has_meeting: bool = False # meets[ev.id] 존재 → 회의 도우미 진입 버튼 노출
|
|
|
meet_label: str = "" # "회의 노트 · 액션 보기" 등 (서버에서 계산)
|
|
|
|
|
|
|
|
|
class FocusBlockOut(BaseModel):
|
|
|
id: str
|
|
|
day: int
|
|
|
start: str
|
|
|
end: str
|
|
|
title: str
|
|
|
type: str # light | deep
|
|
|
tag: str
|
|
|
task_id: Optional[str] = None
|
|
|
auto: bool = True
|
|
|
|
|
|
|
|
|
class MeetingActionOut(BaseModel):
|
|
|
id: str
|
|
|
idx: int
|
|
|
text: str
|
|
|
who: str
|
|
|
when: str = "" # when_text → when (UI 표기 통일)
|
|
|
added: bool
|
|
|
materialized_task_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
class MeetingOut(BaseModel):
|
|
|
id: str
|
|
|
event_id: str
|
|
|
phase: str # done | live | upcoming
|
|
|
one_on_one: bool = False
|
|
|
meta: str = ""
|
|
|
starts_in: str = ""
|
|
|
# 펼친 JSON
|
|
|
summary: list[str] = []
|
|
|
decisions: list[str] = []
|
|
|
agenda: list[str] = []
|
|
|
last_meeting: dict[str, Any] = {} # {when,note}
|
|
|
last_actions: list[dict[str, Any]] = [] # [{text,who,done}]
|
|
|
insights: list[str] = []
|
|
|
docs: list[str] = []
|
|
|
person: dict[str, Any] = {} # {name,initial,tone}
|
|
|
promises: list[dict[str, Any]] = [] # [{text,done}]
|
|
|
signals: list[dict[str, Any]] = [] # [{k,v,tone}]
|
|
|
talking_points: list[str] = []
|
|
|
actions: list[MeetingActionOut] = [] # 상태 있는 액션(작업으로 실체화 대상)
|
|
|
|
|
|
|
|
|
class DayBundleOut(BaseModel):
|
|
|
"""일 뷰 한 번에: 그 날의 이벤트 + 집중 블록."""
|
|
|
day: int
|
|
|
events: list[CalEventOut]
|
|
|
focus_blocks: list[FocusBlockOut]
|
|
|
|
|
|
|
|
|
class WeekBundleOut(BaseModel):
|
|
|
"""주 뷰: 캘린더 카테고리 + 이벤트(전 주) + 집중 블록 + today."""
|
|
|
today: int
|
|
|
week: list[int] # [7,8,9,10,11,12,13]
|
|
|
weekdays: list[str] # ["일","월","화","수","목","금","토"]
|
|
|
calendars: list[CalendarOut]
|
|
|
events: list[CalEventOut]
|
|
|
focus_blocks: list[FocusBlockOut]
|
|
|
|
|
|
|
|
|
class MaterializeResponse(BaseModel):
|
|
|
action: MeetingActionOut
|
|
|
task: Optional["TaskNode"] = None # phase-2 TaskNode 재사용
|
|
|
all_added: bool = False # 모든 액션이 처리됐는지(드로어 '모두 등록됨' 표시)
|
|
|
|
|
|
|
|
|
class FocusSuggestRequest(BaseModel):
|
|
|
day: int
|
|
|
min_minutes: int = 20 # 최소 빈 블록 길이
|
|
|
create: bool = False # True 면 FocusBlock 실제 저장
|
|
|
|
|
|
|
|
|
class FocusSuggestResponse(BaseModel):
|
|
|
day: int
|
|
|
suggestions: list[FocusBlockOut]
|
|
|
created: bool = False
|
|
|
```
|
|
|
|
|
|
> `MaterializeResponse.task` 는 phase-2 `TaskNode` 를 그대로 참조한다(전방참조 후 `MaterializeResponse.model_rebuild()`). 작업 페이지가 동일한 `TaskNode` 형태로 새 작업을 받으므로 연합이 깨지지 않는다.
|
|
|
|
|
|
---
|
|
|
|
|
|
### 3.3 커넥터 추상화 — `connectors/`
|
|
|
|
|
|
CONTRACT 횡단 아키텍처: `backend/app/connectors/` — 도메인별 base 인터페이스 + `MockConnector`(시드 기반) + `RealConnector`(phase-13). 설정 `CONNECTOR_<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: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 목록에도 신규 테이블 포함):
|
|
|
|
|
|
```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: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):
|
|
|
```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`** 에서 다룬다.
|