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. 반복 일정

반복 일정

RRULE 전개, occurrence 모델, this/following/all 인스턴스 단위 편집 시퀀스

reopt design · Updated Jul 2, 2026

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

1. RRULE 지원 범위

반복은 시리즈 master 이벤트의 rrule 필드로 표현합니다. 값은 RFC 5545 RRULE body이며 RRULE: 접두사는 붙이지 않습니다. 전개는 read-time 렌더 관심사라 뷰가 그릴 때 계산되며, 기본 엔진 minimalRecurrenceEngine은 런타임 의존성이 없습니다.

기본 엔진이 지원하는 범위는 FREQ(DAILY / WEEKLY / MONTHLY / YEARLY), INTERVAL, BYDAY, COUNT, UNTIL입니다. occurrence는 master의 wall-clock 시간을 보존하므로 "매일 9시"는 DST 경계를 지나도 9시로 유지됩니다(자세한 내용은 타임존 문서 참고).

tsx
import { createEmptyCalendarSpec } from "@reopt-ai/opt-calendar";

const spec = createEmptyCalendarSpec("Asia/Seoul");

// master 이벤트의 rrule 필드에 RRULE body를 저장 ("RRULE:" 접두사 없이)
spec.events.standup = {
  id: "standup",
  title: "데일리 스탠드업",
  start: "2026-07-06T09:00:00+09:00",
  end: "2026-07-06T09:15:00+09:00",
  rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", // 평일 매주
};

// minimalRecurrenceEngine가 지원하는 RRULE 조합 (zero-dep)
const supported = [
  "FREQ=DAILY;INTERVAL=2", // 이틀마다
  "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10", // 월·수, 10회
  "FREQ=MONTHLY;INTERVAL=1;UNTIL=20261231", // 매월, 2026-12-31까지
  "FREQ=YEARLY", // 매년
];

BYMONTHDAY · BYSETPOS 같은 전체 RFC 5545 표현이 필요하면 RecurrenceEngine seam으로 npm rrule 백엔드 엔진을 주입할 수 있습니다. 엔진은 절대 hard dependency가 아니며, expand(master, range)가 윈도우 안의 occurrence 시작 문자열 배열을 반환하면 됩니다. RecurrenceRange는 { start: Date, end: Date }인 half-open 윈도우입니다.

tsx
import { expandEvents, type RecurrenceEngine } from "@reopt-ai/opt-calendar";
import { RRule } from "rrule";

// 같은 seam으로 rrule 백엔드를 주입 — 기본값은 minimalRecurrenceEngine.
// expand는 윈도우 안 occurrence 시작 문자열 배열을 반환해야 한다.
const rruleEngine: RecurrenceEngine = {
  expand(master, range) {
    const rule = RRule.fromString("RRULE:" + master.rrule);
    return rule.between(range.start, range.end, true).map((d) => d.toISOString());
  },
};

// expandEvents의 3번째 인자로 주입
const instances = expandEvents(spec, range, rruleEngine);

2. expandEvents로 occurrence 전개

뷰는 spec을 직접 그리지 않습니다. expandEvents(spec, range, engine?, adapter?)가 윈도우 [range.start, range.end) 안의 구체적인 occurrence 배열(CalendarEventInstance[])로 전개하고, 이것이 모든 뷰가 렌더하는 단일 소스입니다. master는 엔진으로 전개되고, EXDATE는 제거, RDATE는 추가, 분리된 override는 자기 시간에 덮입니다. 단일 이벤트는 윈도우와 겹치면 그대로 통과합니다.

기본 엔진 + 기본 어댑터 경로에서는 결과가 spec + range 기준으로 memoize됩니다. spec은 매 patch마다 새 identity를 갖기 때문에 캐시가 자동으로 무효화되고 과거 spec은 GC됩니다. 반환된 배열은 공유되므로 read-only로 다루세요.

tsx
import { expandEvents } from "@reopt-ai/opt-calendar";

const range = {
  start: new Date("2026-07-01"),
  end: new Date("2026-08-01"), // end는 배타적 (half-open)
};

// spec을 윈도우 안의 구체적 occurrence로 전개 (뷰가 그리는 단일 소스)
const instances = expandEvents(spec, range);

for (const inst of instances) {
  inst.instanceKey; // "standup@2026-07-06T09:00:00+09:00" — 리스트 key
  inst.occurrenceStart; // 엔진 canonical 시작 — EXDATE / override 매칭 키
  inst.isRecurring; // true (rrule에서 전개됨)
  inst.isOverride; // false (분리 override 아님)
  inst.masterId; // "standup" — 시리즈 master id
  inst.start; // 해당 날짜의 정확한 절대 instant로 materialize된 값
}

인스턴스의 start/end는 그 occurrence 날짜의 오프셋으로 materialize된 절대 instant이고, occurrenceStart는 엔진이 만든 canonical 키입니다. EXDATE·override 매칭은 canonical 키로 이뤄지므로, 아래 편집 빌더에는 materialize된 start가 아니라 occurrenceStart를 넘겨야 합니다.

3. exdate · rdate · override 모델

반복은 master 하나와 예외들로 표현합니다. master가 직접 갖는 예외 필드는 세 가지입니다.

  • rrule — 시리즈 규칙
  • rdates — 규칙 밖의 일회성 occurrence 추가 (RDATE)
  • exdates — 특정 occurrence 제외 (EXDATE)

특정 occurrence를 개별적으로 편집하려면 별도 이벤트로 분리(detach)합니다. 이 override 이벤트는 recurringEventId(master id)와 originalStart(대체하는 occurrence의 canonical 시작)를 가집니다. expandEvents는 이 이벤트를 자기 시간에 standalone으로 렌더하고(isOverride: true), master 전개에서 같은 id@originalStart 키는 건너뜁니다.

tsx
// master: 규칙 + 예외
spec.events.standup = {
  id: "standup",
  title: "데일리 스탠드업",
  start: "2026-07-06T09:00:00+09:00",
  end: "2026-07-06T09:15:00+09:00",
  rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
  exdates: ["2026-07-08T09:00:00+09:00"], // 이 occurrence 제외
  rdates: ["2026-07-11T09:00:00+09:00"], // 규칙 밖 1회 추가
};

// 분리된 override: 특정 occurrence를 개별 이벤트로 대체
spec.events["standup-0709"] = {
  id: "standup-0709",
  title: "스탠드업 (30분 확장)",
  start: "2026-07-09T09:00:00+09:00",
  end: "2026-07-09T09:30:00+09:00",
  recurringEventId: "standup", // master id
  originalStart: "2026-07-09T09:00:00+09:00", // 대체하는 occurrence
};

4. 인스턴스 단위 편집 (this / following / all)

반복 시리즈를 편집할 때는 범위(scope)를 고릅니다. EditScope = "this" | "following" | "all". 모든 빌더는 순수 함수로 JsonPatchOp[]를 반환하고, 그 배열을 store.applyPatch(ops)로 커밋하면 드래그·AI가 만드는 것과 동일한 단일 경로/undo를 공유합니다.

  • "this" — 이 occurrence 하나. 삭제는 EXDATE만 추가하고, 이동·제목 변경은 EXDATE + override 이벤트로 분리합니다.
  • "following" — 이 occurrence와 이후 전부. master의 rrule을 이 occurrence 직전으로 UNTIL 캡핑합니다.
  • "all" — 시리즈 전체. master를 직접 편집·삭제합니다.

빌더는 목적별로 나뉩니다. buildExdateOps(occurrence 제외), buildUntilOps(rrule을 UNTIL로 캡핑), buildOverrideOps(EXDATE 후 override 이벤트 추가), buildMoveOccurrenceOps(이동·리사이즈, 내부적으로 buildOverrideOps), buildDeleteScopeOps(scope별 삭제), buildTitleScopeOps(scope별 제목 변경).

tsx
import {
  buildDeleteScopeOps,
  buildMoveOccurrenceOps,
} from "@reopt-ai/opt-calendar";

const master = spec.events.standup;
const occurrenceStart = "2026-07-06T09:00:00+09:00"; // 인스턴스의 canonical 키

// scope별 삭제 — 각 scope가 서로 다른 패치를 만든다
buildDeleteScopeOps("this", master, occurrenceStart);
// → [{ op: "add", path: "/events/standup/exdates/-",
//      value: "2026-07-06T09:00:00+09:00" }]

buildDeleteScopeOps("following", master, occurrenceStart);
// → [{ op: "replace", path: "/events/standup/rrule",
//      value: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20260705" }]

buildDeleteScopeOps("all", master, occurrenceStart);
// → [{ op: "remove", path: "/events/standup" }]
tsx
// "this"만 이동 — buildMoveOccurrenceOps는 EXDATE 먼저, 그다음 override 이벤트
const ops = buildMoveOccurrenceOps(
  master,
  occurrenceStart, // canonical 매칭 키 (materialize된 start 아님)
  "2026-07-06T10:00:00+09:00", // 새 시작
  "2026-07-06T10:15:00+09:00", // 새 끝
);
// → [{ op: "add", path: "/events/standup/exdates/-", value: occurrenceStart },
//    { op: "add", path: "/events/<newId>", value: {
//        recurringEventId: "standup", originalStart: occurrenceStart, ... } }]

// 드래그·AI와 동일한 단일 경로로 커밋 (하나의 undo)
store.applyPatch(ops);

buildOverrideOps는 EXDATE를 먼저 방출해 중간 상태에 원본과 override가 동시에 보이지 않게 합니다. 또한 buildTitleScopeOps의 "following"은 v1에서 "all"과 동일하게 동작합니다(규칙 분할 retitle은 후속 과제).

5. 다음 단계

타임존

TimeZoneAdapter, wall-clock↔instant, DST 정확성

이벤트 편집

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

Previous이벤트 편집이벤트 CRUD, 팝오버 에디터, 드래그·리사이즈 상호작용과 단일 undo 경로Calendar
Go to 이벤트 편집
Next타임존TimeZoneAdapter, wall-clock↔instant 변환, DST 경계에서의 렌더/반복 정확성Calendar