핵심 개념
CalendarSpec 5-맵 모델, 단일 변경 경로(applyPatch), 엔진/뷰 레이어 아키텍처
reopt design업데이트
1. CalendarSpec — 5개 flat 맵
캘린더의 모든 상태는 CalendarSpec 하나에 담깁니다. spec은 5개의 id-keyed 맵(events, calendars, availability, eventTypes, bookings)에 기본 timeZone과 낙관적 동시성용 version을 더한 평평한 문서입니다. 중첩 트리가 아니라 각 축이 독립된 맵이라, RFC 6902 패치로 부분 편집하기에 이상적입니다.
| 맵 | 축 / 역할 | 엔티티 타입 |
|---|---|---|
events | 시간 축 — 그리드에 렌더되는 모든 이벤트 | CalendarEvent |
calendars | 사이드바 그룹 + 색상 | Calendar |
availability | 이름 붙은 주간 예약 스케줄 | AvailabilitySchedule |
eventTypes | 예약 가능한 미팅 타입 카탈로그 | EventType |
bookings | 예약(reservation) 레코드 큐 | Booking |
import type { CalendarSpec } from "@reopt-ai/opt-calendar";
const spec: CalendarSpec = {
timeZone: "Asia/Seoul",
version: 0,
// 시간 축 — 그리드에 렌더되는 모든 이벤트
events: {
lunch: {
id: "lunch",
title: "점심",
start: "2026-07-01T12:00:00+09:00",
end: "2026-07-01T13:00:00+09:00",
calendarId: "work",
},
},
// 사이드바 그룹 + 색상
calendars: {
work: { id: "work", name: "업무", color: "#3b82f6" },
},
// 예약(booking) 축 — 필요할 때만 채움
availability: {},
eventTypes: {},
bookings: {},
};왜 5개 맵인가 — `kind`-맵이 아닌 이유
에디터 문서와 달리 캘린더에는 단일 순서(ordering)가 없습니다. 그래서 하나의 kind-맵으로 합치지 않고 축마다 별도 맵을 둡니다. 덕분에 패치 경로가 self-describing(/events/{id}/start)이고, 읽는 쪽에서 런타임 switch 없이 곧바로 해당 맵을 참조할 수 있습니다.
Additive-only 불변식 (HARD): 필드를 제거하거나 용도를 바꾸지 않고, 최상위 맵 이름도 바꾸지 않습니다. 필수는 오직 id(이벤트는 title·start·end 추가)뿐이고, 나머지는 모두 하위 호환 기본값을 갖는 optional입니다. 모든 엔티티가 meta를 실어 통합이 스키마 bump를 강제하지 않습니다. 오래된 spec은 새 코드에서 그대로 적용되고, 새 spec은 옛 렌더러에서 우아하게 degrade합니다.
2. 단일 변경 경로 — applyPatch
spec은 오직 store.applyPatch(ops) 한 경로로만 바뀝니다. 드래그·리사이즈, 팝오버 편집, AI 스트림, 직접 편집이 모두 같은 함수로 흘러 하나의 undo 히스토리를 공유합니다. ops는 RFC 6902 JSON Patch(JsonPatchOp[])이며, store 내부에서 applyCalendarPatch가 불변(immutable)으로 적용합니다.
| 편집 원천 | 커밋 방식 |
|---|---|
| 드래그 / 리사이즈 | pointerup에 useEventDrag / useEventResize가 단일 패치 |
| 팝오버 편집 | EventEditorPopover가 store.applyPatch |
| AI 스트림 | beginExternalStream → 여러 패치 → endExternalStream (하나의 undo) |
| 직접 편집 | 코드가 store.applyPatch(ops) 직접 호출 |
import {
buildReplaceFieldPatch,
createCalendarStore,
createEmptyCalendarSpec,
} from "@reopt-ai/opt-calendar";
const store = createCalendarStore(createEmptyCalendarSpec("Asia/Seoul"));
// 빌더로 만든 패치 — 드래그/AI/직접 편집이 모두 쓰는 그 경로
store.applyPatch(buildReplaceFieldPatch("lunch", "title", "팀 점심"), {
source: "edit", // 텔레메트리/디버깅용 자유 형식 태그
});
// 직접 JsonPatchOp[]를 넘겨도 동일 — 경로가 self-describing
store.applyPatch([
{ op: "replace", path: "/events/lunch/start", value: "2026-07-01T12:30:00+09:00" },
{ op: "replace", path: "/events/lunch/end", value: "2026-07-01T13:30:00+09:00" },
]);
// 마지막 변경으로 바뀐 "container/id" 키 집합
store.getLastChangedIds(); // ReadonlySet<string>: { "events/lunch" }AI처럼 여러 패치를 연속으로 흘려보낼 때는 beginExternalStream()과 endExternalStream()으로 감쌉니다. 스트림 중 개별 applyPatch는 히스토리 기록을 건너뛰고, 스트림 전체가 하나의 undo 엔트리로 collapse됩니다.
// AI 패치 스트림 — 전체를 한 번의 undo로 collapse
store.beginExternalStream();
for (const op of patchOps) {
store.applyPatch([op]); // 스트림 중에는 개별 히스토리 기록을 건너뜀
}
store.endExternalStream(); // 스냅샷을 단일 undo 엔트리로 커밋
store.undo(); // 스트림 전체를 한 번에 되돌림드래그 도중 AI 패치가 도착하는 경우처럼 뷰를 강제 remount해야 하면 applyPatch(ops, { forceRender: true })로 엔티티 version을 올리고, getEntityVersions()가 반환한 "container/id" → n 맵을 React key에 섞어 remount를 유도합니다.
3. 2-레이어 아키텍처
패키지는 Core(AI/spec)와 View/integration 두 레이어로 나뉩니다. Core는 런타임 의존성이 0이며(ai·zod는 optional peer), View는 오직 CalendarStore 계약(store.applyPatch)을 통해서만 Core를 소비합니다.
core (zero-runtime-dep)
spec/ CalendarSpec · applyCalendarPatch · diff · validate · store
stream/ StreamCompiler · normalizeCalendarPatchSource (NDJSON)
catalog defineCalendarCatalog (엔티티 종류 + AI 프롬프트 + jsonSchema)
ai/ useCalendarSuggestion (shadow draft, single-undo commit)
view / integration (CalendarStore 계약만 소비)
engine/ recurrence 전개 · timezone 해석 · occurrence · booking slot
views/ MonthView · WeekView · DayView · AgendaView · BookingView
components/ Calendar · CalendarToolbar · EventEditorPopover · EventBlock
interactions/ useEventDrag · useEventResize → store.applyPatch
remote/ · hooks/ · lib/ · i18n/engine이 단일 소유자. RRULE 전개와 타임존 해석은 engine/에만 존재합니다. 뷰는 expandEvents 같은 엔진 함수의 결과를 렌더할 뿐, occurrence·tz 계산을 중복 구현하지 않습니다. 반복 엔진(minimalRecurrenceEngine)과 타임존 어댑터(intlTimeZoneAdapter)는 주입 가능한 seam이라, 필요하면 full RFC 5545 rrule이나 Temporal polyfill로 교체할 수 있습니다.
4. Catalog
CalendarCatalog는 엔티티 종류(kind) 레지스트리로, AI 레이어를 구동합니다. 각 kind는 자신의 인스턴스가 어느 CalendarSpec 맵(container)에 들어가는지와 attrsSchema를 선언합니다. 카탈로그는 이로부터 AI 시스템 프롬프트와 구조화 출력용 JSON Schema를 생성합니다.
import {
defineCalendarCatalog,
defaultCalendarCatalog,
} from "@reopt-ai/opt-calendar";
// 기본 카탈로그: event · all-day-event · task · calendar ·
// availability · event-type · booking 종류를 이미 등록
const catalog = defaultCalendarCatalog;
const systemPrompt = catalog.prompt(); // AI 시스템 프롬프트 (NDJSON 프로토콜 포함)
const schema = catalog.jsonSchema(); // 구조화 출력용 JSON Schema
const errors = catalog.validateAttrs(store.getSpec()); // 종류별 필드 검증
// 커스텀 종류가 필요하면 직접 정의
const custom = defineCalendarCatalog({
event: {
kind: "event",
container: "events",
prompt: "A timed event. start/end are ISO datetimes WITH offset.",
attrsSchema: {
title: "string",
start: "iso-datetime",
end: "iso-datetime",
status: { type: "string", default: "confirmed" },
},
},
});필드 정의는 bare 타입이거나 기본값을 가진 객체입니다. getFieldType(def)와 getFieldDefault(def)로 두 형태를 균일하게 읽을 수 있습니다. Calendar에 catalog prop을 넘기지 않으면 defaultCalendarCatalog가 쓰입니다.