원격 소스
useCalendarRemoteSource 윈도우 fetch/save, id-merge, 낙관적 pending 처리
reopt design업데이트
1. 윈도우 모델
원격 소스는 전체 이벤트를 한 번에 sync하지 않고 보이는 날짜 범위(윈도우)만 fetch합니다. getVisibleWindow는 뷰와 앵커 날짜로부터 day-aligned ISO 윈도우를 계산하는 순수 함수입니다. start는 포함, end는 배타입니다.
import { getVisibleWindow } from "@reopt-ai/opt-calendar";
// 뷰 + 앵커 날짜 → day-aligned ISO 윈도우 (start 포함 / end 배타)
const window = getVisibleWindow("week", new Date(2026, 6, 8), 1);
// → { start: "2026-07-06T00:00:00", end: "2026-07-13T00:00:00" }
// 뷰별 범위:
// month → 월 매트릭스 전체(앞뒤 달 포함, 6주)
// week → 7일
// day → 1일
// agenda → 14일(기본, 4번째 인자 agendaDays로 조정)Calendar는 활성 뷰·날짜·주 시작일이 바뀔 때마다 이 윈도우를 계산해 remoteSource.onWindowChanged(...)를 자동 호출합니다. 따라서 remoteSource만 넘기면 보이는 범위만 알아서 fetch됩니다. 커스텀 내비게이션을 직접 구현하거나 미리 프리페치할 때만 수동으로 호출하면 됩니다.
// Calendar 내부에서 자동 실행되는 배선(개념):
// remoteSource.onWindowChanged(getVisibleWindow(view, date, weekStartsOn))
// 직접 내비게이션을 구현할 때만 수동 호출
remote.onWindowChanged({
start: "2026-07-06T00:00:00",
end: "2026-07-13T00:00:00",
});
remote.refresh(); // 현재 윈도우를 다시 로드2. load 계약
loadEvents는 CalendarRemoteLoadParams를 받아 CalendarRemoteLoadResult를 반환합니다. reason은 로드가 트리거된 이유(window / refresh / revalidate / push)이고, signal로 이전 요청이 취소됩니다.
import type {
CalendarRemoteLoadParams,
CalendarRemoteLoadResult,
} from "@reopt-ai/opt-calendar";
// CalendarRemoteLoadParams → CalendarRemoteLoadResult
async function loadEvents({
window, // { start(포함), end(배타) }
reason, // "window" | "refresh" | "revalidate" | "push"
snapshotVersion, // 낙관적 동시성 토큰(string | number | null)
signal, // 이전 요청을 취소하는 AbortSignal
}: CalendarRemoteLoadParams): Promise<CalendarRemoteLoadResult> {
const params = new URLSearchParams({
start: window.start,
end: window.end,
});
const res = await fetch("/api/calendar/events?" + params, { signal });
const data = await res.json();
return { events: data.events, snapshotVersion: data.version };
}반환된 이벤트는 라이브 spec을 통째로 갈아끼우지 않고 id 기준으로 병합(id-merge)됩니다.
- 윈도우 밖 이벤트는 건드리지 않고 그대로 보존합니다.
- 윈도우 안에 있지만 서버가 더 이상 반환하지 않고 pending도 아닌 이벤트는 stale로 간주해 remove합니다.
- 서버가 반환한 이벤트는 id 기준으로 upsert합니다 (신규는 add, 기존은 replace — server wins).
- pending(낙관적 편집 중) id는 서버 응답이 덮어쓰지 않고 보존합니다. 저장이 확정될 때까지 로컬 편집이 우선합니다.
- 병합은 한 번의 store.applyPatch(ops, { source: "remote" })로 커밋되며, 전체 교체(wholesale replace)는 하지 않습니다.
응답은 요청 시퀀스로 게이팅됩니다. 늦게 도착한 stale 응답은 폐기되고(staleDropCount 증가), 새 윈도우 요청은 직전 요청을 AbortSignal로 취소합니다.
3. save 계약
로컬·AI 편집은 applyLocalOps로 커밋합니다. 로컬에 즉시 반영(낙관적)한 뒤 savePatches가 있으면 ops batch를 서버로 저장합니다. CalendarRemoteSaveResult는 canonical events, snapshotVersion, rejected를 함께 돌려주는 shape를 유지합니다.
import type {
CalendarRemoteSaveParams,
CalendarRemoteSaveResult,
} from "@reopt-ai/opt-calendar";
// 낙관적 편집: 로컬에 먼저 적용하고 changedEventIds를 pending으로 표시 → save 큐잉
remote.applyLocalOps(ops, ["evt_123"]);
// CalendarRemoteSaveParams → CalendarRemoteSaveResult | void
async function savePatches({
ops, // readonly JsonPatchOp[]
changedEventIds, // readonly string[]
snapshotVersion,
signal,
}: CalendarRemoteSaveParams): Promise<CalendarRemoteSaveResult> {
const res = await fetch("/api/calendar/save", {
method: "POST",
body: JSON.stringify({ ops, changedEventIds, snapshotVersion }),
signal,
});
const data = await res.json();
return {
events: data.canonical, // 로컬 위에 upsert되어 화해(server wins)
snapshotVersion: data.version,
rejected: data.rejected, // [{ eventId?, message? }] — 거절 op 보고
};
}- applyLocalOps(ops, changedEventIds)는 ops를 { source: "local" }로 즉시 적용해 낙관적 렌더를 보장합니다.
- changedEventIds는 pending으로 표시되어, 저장이 왕복하는 동안 load 병합에서 보호됩니다.
- 저장 성공 시 응답의 events(canonical)가 로컬 위에 upsert되고, snapshotVersion이 갱신되며, 해당 id의 pending 표시가 해제됩니다.
- rejected는 서버가 거절한 op를 eventId·message로 보고하는 채널입니다. 실제 롤백은 canonical events에 편집 이전 값을 담아 되돌립니다.
- savePatches를 생략하면 applyLocalOps는 로컬 낙관 적용만 하고 원격 화해는 하지 않습니다.
4. Calendar 연결
useCalendarRemoteSource는 첫 번째 인자로 store, 두 번째 인자로 옵션(loadEvents·savePatches 등)을 받습니다. 반환값을 Calendar의 remoteSource prop에 넘기면 윈도우 fetch가 자동으로 배선됩니다.
"use client";
import {
Calendar,
createCalendarStore,
createEmptyCalendarSpec,
useCalendarRemoteSource,
} from "@reopt-ai/opt-calendar";
import "@reopt-ai/opt-calendar/styles.css";
import { useState } from "react";
export function RemoteCalendar() {
const [store] = useState(() =>
createCalendarStore(createEmptyCalendarSpec("Asia/Seoul")),
);
// store가 첫 인자, 옵션이 두 번째 인자
const remote = useCalendarRemoteSource(store, {
// 보이는 윈도우만 fetch (reason·snapshotVersion·signal도 함께 전달됨)
loadEvents: async ({ window, signal }) => {
const params = new URLSearchParams({
start: window.start,
end: window.end,
});
const res = await fetch("/api/calendar/events?" + params, { signal });
const data = await res.json();
return { events: data.events, snapshotVersion: data.version };
},
// 편집 ops를 batch로 저장하고 canonical 이벤트로 화해
savePatches: async ({ ops, changedEventIds, signal }) => {
const res = await fetch("/api/calendar/save", {
method: "POST",
body: JSON.stringify({ ops, changedEventIds }),
signal,
});
const data = await res.json();
return {
events: data.canonical,
snapshotVersion: data.version,
rejected: data.rejected,
};
},
});
return (
<Calendar
store={store}
remoteSource={remote}
timeZone="Asia/Seoul"
weekStartsOn={1}
// remoteSource만 넘겨도 뷰/날짜가 바뀔 때 보이는 윈도우를 자동 fetch.
// onDateChange는 URL·미니월 등 외부 UI 동기화를 위한 관찰 지점.
onDateChange={(date) => syncDateParam(date)}
/>
);
}반환값의 나머지 필드로 상태 UX를 구성합니다. isLoading·lastError·snapshotVersion·refresh()·telemetry를 그대로 읽어 스피너·재시도·관측 지표를 붙일 수 있습니다.
remote.isLoading; // load in flight 여부
remote.lastError; // 마지막 load 에러(Error | null)
remote.refresh(); // 현재 윈도우 재로드
remote.telemetry; // { loadCount, saveCount, staleDropCount, ... }