reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Start
Playground
Core Concepts
Core Concepts
Calendar
Calendar 개요
이벤트 편집
반복 일정
타임존
예약 · 가용성
AI 스트리밍
원격 소스
App Composition
Build & Operate
스킬
Release Notes
Reference
Hook Reference
Type Reference
Oopt-calendar
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

Calendar
  1. Docs
  2. /
  3. Calendar
  4. /
  5. 원격 소스

원격 소스

useCalendarRemoteSource 윈도우 fetch/save, id-merge, 낙관적 pending 처리

reopt design · Updated Jul 2, 2026

시작하기핵심 개념개요이벤트 편집반복 일정타임존예약 · 가용성AI 스트리밍원격 소스앱 조합스킬릴리즈 노트Hook 레퍼런스타입 레퍼런스

1. 윈도우 모델

원격 소스는 전체 이벤트를 한 번에 sync하지 않고 보이는 날짜 범위(윈도우)만 fetch합니다. getVisibleWindow는 뷰와 앵커 날짜로부터 day-aligned ISO 윈도우를 계산하는 순수 함수입니다. start는 포함, end는 배타입니다.

tsx
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됩니다. 커스텀 내비게이션을 직접 구현하거나 미리 프리페치할 때만 수동으로 호출하면 됩니다.

tsx
// 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로 이전 요청이 취소됩니다.

tsx
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를 유지합니다.

tsx
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가 자동으로 배선됩니다.

tsx
"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를 그대로 읽어 스피너·재시도·관측 지표를 붙일 수 있습니다.

tsx
remote.isLoading; // load in flight 여부
remote.lastError; // 마지막 load 에러(Error | null)
remote.refresh(); // 현재 윈도우 재로드
remote.telemetry; // { loadCount, saveCount, staleDropCount, ... }

5. 다음 단계

이벤트 편집

CRUD, 팝오버 에디터, 드래그·리사이즈와 단일 undo 경로

Hook 레퍼런스

useCalendarRemoteSource, useCalendarNavigation, suggestion 훅

PreviousAI 스트리밍useCalendarSuggestion shadow draft, NDJSON 패치 스트림, 승인 전 라이브 오버레이Calendar
Go to AI 스트리밍
NextApp CompositionShellCalendarAdapter로 opt-shell 제품 프레임 안에 캘린더를 배치하는 경계Calendar