반복 일정
RRULE 전개, occurrence 모델, this/following/all 인스턴스 단위 편집 시퀀스
reopt design업데이트
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시로 유지됩니다(자세한 내용은 타임존 문서 참고).
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 윈도우입니다.
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로 다루세요.
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 키는 건너뜁니다.
// 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별 제목 변경).
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" }]// "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은 후속 과제).