Hook 레퍼런스
useCalendar, useCalendarRender, useCalendarNavigation, useCalendarRemoteSource, useCalendarSuggestion 시그니처와 예제
reopt designUpdated
1. Provider 컨텍스트 훅
아래 다섯 훅은 CalendarProvider 컨텍스트를 읽습니다. <Calendar>가 내부에서 provider를 렌더하므로, 그 아래 뷰·컴포넌트에서 바로 호출할 수 있습니다. 커스텀 뷰를 직접 조합할 땐 <CalendarProvider>로 감싸세요. Provider 밖에서 호출하면 예외를 던집니다.
| Hook | 반환 | 설명 |
|---|---|---|
useCalendarContext() | CalendarContextValue | 컨텍스트 원본(store · catalog · timeZone · weekStartsOn · messages · onEventActivate · suggestion) |
useCalendarSpec() | CalendarSpec | 라이브 spec 구독. 모든 mutation마다 리렌더 (useSyncExternalStore) |
useCalendarRender() | CalendarRender | 뷰가 렌더할 spec. AI 초안이 활성이면 draft 오버레이를 반환 |
useCalendarStore() | CalendarStore | 하부 store(안정 참조). applyPatch/undo/redo 접근 |
useCalendarMessages() | CalendarMessages | 해석된 UI 메시지(로케일 오버라이드 반영) |
CalendarRender
useCalendarRender()는 라이브 spec과 AI 초안 오버레이를 하나로 합쳐 뷰가 렌더할 대상을 돌려줍니다.
interface CalendarRender {
/** 렌더할 spec — 초안이 활성일 땐 draft, 아니면 라이브 spec. */
spec: CalendarSpec;
/** 활성 초안이 바꾼 "container/id" 키 집합 (없으면 빈 Set). */
changedIds: ReadonlySet<string>;
/** AI 초안 오버레이가 렌더되는 중인지. */
isDraft: boolean;
}"use client";
import {
CalendarProvider,
MonthView,
useCalendarRender,
useCalendarStore,
} from "@reopt-ai/opt-calendar";
import type { CalendarStore } from "@reopt-ai/opt-calendar";
// CalendarProvider 아래에서는 컨텍스트 훅을 바로 읽을 수 있습니다.
function DraftBadge() {
const { spec, changedIds, isDraft } = useCalendarRender();
const store = useCalendarStore();
const eventCount = Object.keys(spec.events).length;
return (
<div className="flex items-center gap-2">
<span>이벤트 {eventCount}개</span>
{isDraft && <span>AI 초안 {changedIds.size}건 대기</span>}
<button onClick={() => store.undo()} disabled={!store.canUndo()}>
되돌리기
</button>
</div>
);
}
export function CustomCalendar({ store }: { store: CalendarStore }) {
return (
<CalendarProvider store={store} timeZone="Asia/Seoul">
<DraftBadge />
<MonthView date={new Date()} />
</CalendarProvider>
);
}3. useCalendarRemoteSource
보이는 날짜 윈도우만 원격에서 fetch하고, 로컬/AI 편집을 낙관적으로 write-back하는 원격 이벤트 소스입니다. opt-datagrid의 원격 소스를 "행 페이지" 대신 "날짜 범위"로 바꾼 모델이며, 첫 인자로 store를 받습니다.
function useCalendarRemoteSource(
store: CalendarStore,
options: CalendarRemoteSourceOptions,
): CalendarRemoteSourceResult;Options
| 속성 | 타입 | 설명 |
|---|---|---|
loadEvents | (p) => Promise<CalendarRemoteLoadResult> | 윈도우의 이벤트를 fetch (필수). window/reason/snapshotVersion/signal 전달 |
savePatches | (p) => Promise<… | void> | 로컬/AI 패치를 저장 (선택). 지정 시 낙관적 write-back 활성화 |
subscribeToInvalidations | (handler) => unsubscribe | 서버 push 무효화 구독. 알림 시 현재 윈도우 재로드 |
onTelemetry | (t: CalendarRemoteTelemetry) => void | 로드/저장/무효화 카운터 sink |
Returns
| 속성 | 타입 | 설명 |
|---|---|---|
onWindowChanged | (w: CalendarRemoteWindow) => void | 보이는 윈도우 변경 시 호출(내비/뷰 전환). <Calendar>는 자동 연결 |
applyLocalOps | (ops, changedEventIds?) => void | 로컬 ops를 낙관적으로 적용하고 저장을 큐잉 |
isLoading | boolean | 로드 진행 중 여부 |
lastError | Error | null | 마지막 로드 오류 |
snapshotVersion | CalendarSnapshotVersion | 현재 스냅샷 버전(낙관적 동시성 토큰) |
refresh | () => void | 현재 윈도우 재로드 |
telemetry | CalendarRemoteTelemetry | loadCount/saveCount/staleDropCount 등 스냅샷 |
"use client";
import {
Calendar,
useCalendarRemoteSource,
} from "@reopt-ai/opt-calendar";
import type { CalendarStore } from "@reopt-ai/opt-calendar";
function RemoteCalendar({ store }: { store: CalendarStore }) {
const remote = useCalendarRemoteSource(store, {
loadEvents: async ({ window, signal }) => {
const res = await fetch(
`/api/events?from=${window.start}&to=${window.end}`,
{ signal },
);
return { events: await res.json() };
},
savePatches: async ({ ops, snapshotVersion }) => {
const res = await fetch("/api/events/patch", {
method: "POST",
body: JSON.stringify({ ops, snapshotVersion }),
});
return res.json(); // { events?, snapshotVersion?, rejected? }
},
subscribeToInvalidations: (handler) => {
const ws = new WebSocket("wss://api/events/live");
ws.onmessage = () => handler();
return () => ws.close();
},
});
// <Calendar>가 뷰/날짜 변경 시 remote.onWindowChanged를 자동 호출합니다.
return (
<Calendar store={store} timeZone="Asia/Seoul" remoteSource={remote} />
);
}4. useCalendarSuggestion
AI 패치를 라이브 spec이 아닌 shadow draft로 스트리밍하는 리뷰 플로우입니다. 소비자는 초안을 미리 본 뒤 전체를 승인(하나의 undo 엔트리로 적용)·폐기·추가 스트리밍하거나, 엔티티 단위로 승인/거부할 수 있습니다. core 훅이라 ai 의존성이 없습니다(이미 생성된 패치 스트림을 소비).
function useCalendarSuggestion(
store: CalendarStore,
): UseCalendarSuggestionResult;Returns
| 속성 | 타입 | 설명 |
|---|---|---|
suggestion | CalendarSuggestion | null | 현재 초안(없으면 null). <Calendar>의 suggestion prop에 전달 |
start | (source: PatchSource) => Promise<void> | 격리된 초안으로 패치 스트리밍 시작(라이브 spec 불변) |
approve | () => void | 초안을 하나의 undo 엔트리로 라이브 store에 적용 |
reject | () => void | 초안 폐기 |
refine | (source: PatchSource) => Promise<void> | 기존 초안에 패치를 이어서 스트리밍 |
abort | () => void | 진행 중인 스트림 중단(초안은 ready로 유지) |
approveEntity | (ref: EntityRef) => void | 초안의 단일 엔티티 변경만 적용 |
rejectEntity | (ref: EntityRef) => void | 초안의 단일 엔티티를 base로 되돌림 |
CalendarSuggestion
interface CalendarSuggestion {
/** 스트리밍 상태. */
status: "streaming" | "ready" | "error";
/** AI 변경이 적용된 초안 spec. */
draftSpec: CalendarSpec;
/** base와 다른 "container/id" 키 집합. */
changedIds: ReadonlySet<string>;
/** AI가 누적한 패치. */
patches: JsonPatchOp[];
/** 스트리밍 실패 시 오류. */
error: Error | null;
/** 지금까지 적용된 op 수. */
appliedCount: number;
}"use client";
import {
Calendar,
useCalendarSuggestion,
} from "@reopt-ai/opt-calendar";
import type { CalendarStore } from "@reopt-ai/opt-calendar";
function AiCalendar({ store }: { store: CalendarStore }) {
const ai = useCalendarSuggestion(store);
async function ask(prompt: string) {
const res = await fetch("/api/calendar/ai", {
method: "POST",
body: JSON.stringify({ prompt }),
});
// NDJSON 패치 스트림을 shadow draft로 흘려보냄
await ai.start(res.body!);
}
return (
<>
{ai.suggestion && (
<div className="flex gap-2">
<span>{ai.suggestion.changedIds.size}건 제안</span>
<button onClick={ai.approve}>승인</button>
<button onClick={ai.reject}>폐기</button>
</div>
)}
<Calendar
store={store}
timeZone="Asia/Seoul"
suggestion={ai.suggestion}
/>
</>
);
}NDJSON 스트림 생성과 서버 핸들러 연동은 AI 스트리밍 문서를 참고하세요.
5. 상호작용 훅
useEventDrag/useEventResize는 시간 그리드 칩의 포인터 드래그·엣지 리사이즈를 처리하는 저수준 훅입니다. <Calendar>가 내부에서 이미 연결하므로, 커스텀 타임 그리드를 직접 만들 때만 필요합니다. 둘 다 드롭/릴리스 시점에 단 하나의 replace 패치를 onCommit으로 커밋하여 드래그·AI와 같은 단일 undo 경로를 공유합니다.
useEventDrag
function useEventDrag(options: UseEventDragOptions): UseEventDragResult;| Option | 타입 | 설명 |
|---|---|---|
event | CalendarEvent | 드래그 대상 이벤트 |
geometry | TimeGridGeometry | px↔분 매퍼(컬럼 기하) |
onCommit | (ops: JsonPatchOp[]) => void | 드롭 시 move 패치 커밋(보통 applyPatch) |
disabled | boolean? | 드래그 비활성화 |
timeZone | string? | tz 정확한 move 수식용 표시 타임존 |
buildOps | (start, end) => JsonPatchOp[] | 커밋 패치 커스터마이즈(반복 인스턴스는 override 시퀀스 주입) |
dayIndex / dayCount | number? | 지정 시 가로 드래그가 요일 이동이 됩니다(주간 뷰 크로스-데이 드래그, deltaDays·dayOffsetPx 반환) |
반환: onPointerDown(draggable에 연결), isDragging, deltaMinutes(드래그 중 스냅된 분 델타 — ghost 렌더용, 그 외 0), deltaDays/dayOffsetPx(가로 요일 이동분).
useEventResize
useEventDrag와 동일하되 edge: "start" | "end" 옵션이 추가되고, 반환은 onPointerDown, isResizing, deltaMinutes입니다.
function useEventResize(
options: UseEventResizeOptions,
): UseEventResizeResult;useSlotDragCreate
빈 타임 그리드에서 드래그로 이벤트를 생성하는 훅입니다. day 컬럼 요소에 onPointerDown을 연결하면, 빈 슬롯을 누르고 끄는 동안 스냅된 선택 범위(selection)를 추적하고 놓는 순간 timed 이벤트 add 패치를 onCommit으로 커밋합니다. 기존 이벤트 블록 위에서 시작한 프레스와 최소 길이(기본 스냅 단위) 미만의 클릭은 무시합니다. <Calendar> 주/일 뷰가 기본으로 연결합니다.
function useSlotDragCreate(
options: UseSlotDragCreateOptions,
): UseSlotDragCreateResult;
// options: { dayKey, geometry, timeZone?, onCommit, title,
// disabled?, minDurationMinutes?, onCreated? }
// result: { onPointerDown, selection }"use client";
import {
createTimeGridGeometry,
useEventDrag,
} from "@reopt-ai/opt-calendar";
import type { CalendarEvent, CalendarStore } from "@reopt-ai/opt-calendar";
function DraggableChip({
event,
store,
}: {
event: CalendarEvent;
store: CalendarStore;
}) {
// pxPerMinute=0.8 ⇒ 48px/시간, 15분 스냅 (모두 기본값)
const geometry = createTimeGridGeometry({
pxPerMinute: 0.8,
snapMinutes: 15,
});
const { onPointerDown, isDragging, deltaMinutes } = useEventDrag({
event,
geometry,
timeZone: "Asia/Seoul",
// 드래그·리사이즈·AI가 공유하는 단일 커밋 경로
onCommit: (ops) => store.applyPatch(ops, { source: "drag" }),
});
return (
<div
onPointerDown={onPointerDown}
data-dragging={isDragging}
style={{ transform: `translateY(${geometry.minuteToY(deltaMinutes)}px)` }}
>
{event.title}
</div>
);
}