diff --git a/docs/api-reference/calendar.md b/docs/api-reference/calendar.md index ddd6773cb..ed523d307 100644 --- a/docs/api-reference/calendar.md +++ b/docs/api-reference/calendar.md @@ -216,7 +216,7 @@ interface CalendarHand { undo(): void; redo(): void; copy(): CalendarClipboard | null; - cut(): EditingResult | null; + cut(clipboard?: CalendarClipboard): EditingResult | null; paste(clipboard: CalendarClipboard): EditingResult; } ``` @@ -226,6 +226,7 @@ interface CalendarHand { type CalendarHandOptions = { readonly initialOccurrence?: CalendarOccurrenceRange; readonly defaultTitle?: string; + readonly onResult?: (result: EditingResult) => void; }; ``` ## `CalendarKeyboardOptions` @@ -348,6 +349,8 @@ type CalendarPeriod = "day" | CalendarGrain; ```ts interface CalendarPointerInteractions { + /** Bind to one Calendar surface; canonical grids attach it automatically. */ + readonly rootRef: RefObject; readonly hoveredTime: { readonly day: string; readonly instant: string; readonly minutes: number } | null; instantAt(day: string, clientY: number, grid: Element): string | null; timePointerDown(event: PointerEvent, day: string, id: string | null, start: string | null, end: string | null, handle: CalendarTimeGridHandle | null): void; diff --git a/docs/api-reference/editing.md b/docs/api-reference/editing.md index 2137c922d..b79ee7d54 100644 --- a/docs/api-reference/editing.md +++ b/docs/api-reference/editing.md @@ -240,8 +240,8 @@ interface CalendarEditor { ): CalendarSelectionDragSource | null; dispatch(intent: CalendarIntent): EditingResult; copy(occurrences?: ReadonlyArray): CalendarClipboard | null; - cut(occurrences?: ReadonlyArray): EditingClipboardCut> | null; - paste(clipboard: CalendarClipboard, target?: string): EditingResult; + cut(source?: ReadonlyArray | CalendarClipboard): EditingClipboardCut> | null; + paste(clipboard: CalendarClipboard, target?: string, options?: { readonly calendarId?: string }): EditingResult; undo(): EditingResult; redo(): EditingResult; subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 25fd93e34..9cd63d0a9 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -287,11 +287,12 @@ const misplacedMarkdown = filesUnder("").filter((path) => { return path.endsWith(".md") && !path.startsWith("docs/") && !path.startsWith("standards/") + && !rootPackage.workspaces.some((workspace) => workspace.startsWith("packages/") && path.startsWith(`${workspace}/docs/`)) && name !== "README.md" && name !== "AGENTS.md"; }); if (misplacedMarkdown.length > 0) { - fail(`docs layout: non-README markdown must live under docs/: ${misplacedMarkdown.join(", ")}.`); + fail(`docs layout: non-README markdown must live under docs/, standards/, or a registered package's docs/: ${misplacedMarkdown.join(", ")}.`); } for (const [name, source] of Object.entries(surfaces)) { diff --git a/package-lock.json b/package-lock.json index d8fb94424..e3e60a52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6544,6 +6544,9 @@ "name": "@interactive-os/json-document-calendar", "version": "0.1.0-rc.0", "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "devDependencies": { "@interactive-os/json-document-affordance": "*", "@interactive-os/json-document-editing": "*", diff --git a/packages/json-document-calendar/README.md b/packages/json-document-calendar/README.md index 8c961bb8d..9eedc1df3 100644 --- a/packages/json-document-calendar/README.md +++ b/packages/json-document-calendar/README.md @@ -12,19 +12,43 @@ time-grid policy. const editor = createCalendarEditor(document); const calendar = useCalendarHand(editor); -calendar.dispatch({ type: "selection.set", eventIds: [eventId] }); +calendar.dispatch({ + type: "selection.set", + point: { eventId, occurrenceStart }, + topology: calendarOccurrenceTopology(document, rangeStart, rangeEnd), +}); calendar.applySelectedPatch({ title: "Planning" }); const payload = calendar.copy(); -calendar.paste(payload); +if (payload !== null) calendar.paste(payload); const titleInput = useCalendarRenameInput(calendar); -useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRemove }); +useCalendarKeyboard({ active: true, onView, onShift, onToday, onCreate, onRename, onRemove }); ``` -The Hand resolves the currently focused occurrence as the copy/cut source and -paste target. The Host selects Web representations; Calendar schema, +The Hand resolves the currently focused occurrence as the copy source and +paste target. Bind `cut: calendar.cut` directly to the Web clipboard surface: +the Hand accepts the payload already written by Web and removes that captured +target even if selection changes during the write. The Host selects Web representations; Calendar schema, occurrence projection, temporal placement, selection, and history remain in their canonical owners. +Pass `onResult` to `useCalendarHand` to observe domain rejection codes and +present product-owned feedback. `commitIntent` runs occurrence/rename aftercare +only on success. Web clipboard decoding/writing failures remain Web results; +observe the surface's `onResult` as well. Unexpected provider/programmer errors +are not converted into a successful edit. + +`useCalendarPointerInteractions` exposes `rootRef`. `CalendarTimeGrid` and +`CalendarMonthGrid` attach it automatically; a custom Calendar surface must +attach it to its own root. Use one interaction instance per mounted surface. +Hit tests and all-day column measurements never fall back to global document +queries. Keyboard listeners can also use the existing `target` option when a +Host embeds multiple active calendars. + +The domain-owned [Calendar protocol profile](../json-document-editing/docs/calendar-profile.md) +defines temporal values, recurrence scopes, stale-source rejection and clipboard +compatibility. On the site it is visible under [Editing API](/docs/api/editing#calendar-protocol-profile-rc); +the Calendar Hand does not introduce a second domain protocol. + Date and time controls belong to this Calendar owner rather than the generic UI Primitive package: diff --git a/packages/json-document-calendar/package.json b/packages/json-document-calendar/package.json index 01111c04a..c2a9ab90d 100644 --- a/packages/json-document-calendar/package.json +++ b/packages/json-document-calendar/package.json @@ -19,6 +19,9 @@ "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + }, "peerDependencies": { "@interactive-os/json-document-affordance": ">=0.1.0-rc.0 <1", "@interactive-os/json-document-editing": ">=0.1.0-rc.0 <1", diff --git a/packages/json-document-calendar/src/calendar-month-grid.tsx b/packages/json-document-calendar/src/calendar-month-grid.tsx index 9dd398be1..64e93c434 100644 --- a/packages/json-document-calendar/src/calendar-month-grid.tsx +++ b/packages/json-document-calendar/src/calendar-month-grid.tsx @@ -124,6 +124,7 @@ export const CalendarMonthGrid = forwardRef) => void; }; export interface CalendarHand { @@ -88,12 +90,14 @@ export interface CalendarHand { undo(): void; redo(): void; copy(): CalendarClipboard | null; - cut(): EditingResult | null; + cut(clipboard?: CalendarClipboard): EditingResult | null; paste(clipboard: CalendarClipboard): EditingResult; } export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOptions = {}): CalendarHand { const snapshot = useEditingSnapshot(editor); + const optionsRef = useRef(options); + optionsRef.current = options; const selectedEvent = editor.selectedEvents[0] ?? null; const selectedOccurrences = editor.selectedOccurrences; const [occurrence, setOccurrence] = useState( @@ -112,15 +116,13 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt if (current === undefined) return; const next = draft.trim() || (options.defaultTitle ?? "Event"); if (next !== current.title) { - editor.dispatch(calendarUpdateIntent(current, occurrenceRef.current.start, scopeRef.current, { title: next })); - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + if (dispatch(calendarUpdateIntent(current, occurrenceRef.current.start, scopeRef.current, { title: next }))) rememberSelection(); } }, onCancel(key, draft) { const fallback = options.defaultTitle ?? "Event"; if (createdRenameKeyRef.current === key && (draft.trim() === "" || draft.trim() === fallback)) { - editor.dispatch({ type: "selection.remove" }); - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + if (dispatch({ type: "selection.remove" })) rememberSelection(); } }, onFinish(key) { @@ -156,11 +158,16 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt : selectedEvent?.title ?? ""; function dispatch(intent: CalendarIntent | null): boolean { - return intent !== null && editor.dispatch(intent).ok; + return intent !== null && observe(editor.dispatch(intent)).ok; + } + + function observe(result: EditingResult): EditingResult { + optionsRef.current.onResult?.(result); + return result; } function rememberSelection(): void { - setOccurrence(calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null)); + setOccurrence(calendarOccurrenceFromSelection(editor.primaryOccurrence)); } function commitIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): boolean { @@ -170,7 +177,7 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt } function rememberIntent(intent: CalendarIntent | null, origin: CalendarOccurrenceRange): void { - const committed = calendarOccurrenceFromSelection(editor.selectedEvents[0] ?? null); + const committed = calendarOccurrenceFromSelection(editor.primaryOccurrence); setOccurrence(calendarOccurrenceAfterIntent(intent, origin, committed)); if (intent?.type === "event.create") { setScope("this"); @@ -287,28 +294,29 @@ export function useCalendarHand(editor: CalendarEditor, options: CalendarHandOpt function undo(): void { setSelectionDragPreview(null); - editor.undo(); - rememberSelection(); + if (observe(editor.undo()).ok) rememberSelection(); } function redo(): void { setSelectionDragPreview(null); - editor.redo(); - rememberSelection(); + if (observe(editor.redo()).ok) rememberSelection(); } function copy(): CalendarClipboard | null { return editor.copy(); } - function cut(): EditingResult | null { - const cut = editor.cut(); - if (cut?.result.ok) rememberSelection(); - return cut?.result ?? null; + function cut(clipboard?: CalendarClipboard): EditingResult | null { + if (clipboard !== undefined && calendarClipboardFormat.parse(clipboard) === null) return observe({ ok: false, code: "clipboard.invalid" }); + const cut = editor.cut(clipboard); + if (cut === null) return null; + const result = observe(cut.result); + if (result.ok) rememberSelection(); + return result; } function paste(clipboard: CalendarClipboard): EditingResult { - const result = editor.paste(clipboard, occurrence.start ?? selectedEvent?.start); + const result = observe(editor.paste(clipboard, occurrence.start ?? selectedEvent?.start)); if (result.ok) rememberSelection(); return result; } diff --git a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts index 940b88829..589066b8c 100644 --- a/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts +++ b/packages/json-document-calendar/src/use-calendar-pointer-interactions.ts @@ -1,10 +1,10 @@ -import { useRef, useState, type PointerEvent } from "react"; +import { useRef, useState, type PointerEvent, type RefObject } from "react"; import { createGestureSession } from "@interactive-os/json-document-affordance"; import { addCalendarDate, bindCalendarAllDayIntent, bindCalendarMonthIntent, bindCalendarTimeGridIntent, calendarEventsOnDay, calendarInstantAt, calendarShiftInstant, calendarVisibleEvents, interpretCalendarAllDayPointer, interpretCalendarMonthPointer, interpretCalendarTimeGridPointer, - type CalendarAllDayPointerRelease, type CalendarIntent, type CalendarTimeGridHandle, + type CalendarAllDayPointerRelease, type CalendarTimeGridHandle, type CalendarTimeGridPointerRelease, type CalendarSelectionDragSource, } from "@interactive-os/json-document-editing"; @@ -43,6 +43,8 @@ type CalendarSelectionDragGesture = { }; export interface CalendarPointerInteractions { + /** Bind to one Calendar surface; canonical grids attach it automatically. */ + readonly rootRef: RefObject; readonly hoveredTime: { readonly day: string; readonly instant: string; readonly minutes: number } | null; instantAt(day: string, clientY: number, grid: Element): string | null; timePointerDown(event: PointerEvent, day: string, id: string | null, start: string | null, end: string | null, handle: CalendarTimeGridHandle | null): void; @@ -66,6 +68,7 @@ export interface CalendarPointerInteractions { /** Owns Calendar's Web pointer preview, commit, cancel, and resize lifecycle. */ export function useCalendarPointerInteractions(hand: CalendarHand, policy: CalendarPointerPolicy): CalendarPointerInteractions { + const rootRef = useRef(null); const [timePointer] = useState(() => createWebPointerSession()); const [allDayPointer] = useState(() => createWebPointerSession()); const [monthPointer] = useState(() => createWebPointerSession()); @@ -76,9 +79,8 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const document = hand.document; const visibleEvents = calendarVisibleEvents(document); - function remember(intent: CalendarIntent | null, start: string | null, end: string | null): void { - hand.dispatch(intent); - hand.rememberIntent(intent, { start, end }); + function pointTarget(selector: string, event: { clientX: number; clientY: number }): Element | null { + return rootRef.current === null ? null : findWebPointTarget(selector, { x: event.clientX, y: event.clientY }, rootRef.current); } function bindTime(intent: ReturnType, occurrenceStart: string | null) { @@ -97,7 +99,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen if (release.originEventId === null) return; const event = document.events.find((item) => item.id === release.originEventId); const intent = bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), event, release.originEventStart, hand.scope); - remember(intent, release.originEventStart, event?.end ?? null); + hand.commitIntent(intent, { start: release.originEventStart, end: event?.end ?? null }); } function timePointerDown(event: PointerEvent, day: string, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null, originHandle: CalendarTimeGridHandle | null): void { @@ -116,7 +118,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } function timePointerMove(event: PointerEvent): void { - const grid = findWebPointTarget('[data-calendar-grid="time"]', { x: event.clientX, y: event.clientY }); + const grid = pointTarget('[data-calendar-grid="time"]', event); const day = grid?.getAttribute("data-calendar-day"); if (grid == null || day == null) return; if (timePointer.getSnapshot()?.pointerId !== event.pointerId) { @@ -144,7 +146,11 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen if (next?.dragSource !== null && next?.dragSource !== undefined) { const originAnchor = next.dragSource.anchor.occurrenceStart; const move = interpretCalendarTimeGridPointer(next); - if (move?.type !== "event.move") return; + if (move?.type !== "event.move") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "instant", instant: originAnchor } }); const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "instant", instant: move.start } })); hand.previewSelectionDrag(gesture); @@ -168,7 +174,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen hand.setOccurrence({ start: release.targetInstant, end: release.targetInstant }); return; } - remember(bindTime(interpretCalendarTimeGridPointer(release), release.originEventStart), release.originEventStart, release.originEventEnd); + hand.commitIntent(bindTime(interpretCalendarTimeGridPointer(release), release.originEventStart), { start: release.originEventStart, end: release.originEventEnd }); } function allDayPointerDown(event: PointerEvent, originDay: string, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null, originHandle: "body" | "start" | "end" | null): void { @@ -184,7 +190,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen function allDayPointerMove(event: PointerEvent): void { if (allDayPointer.getSnapshot()?.pointerId !== event.pointerId) return; - const targetDay = findWebPointTarget("[data-calendar-allday-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-allday-day"); + const targetDay = pointTarget("[data-calendar-allday-day]", event)?.getAttribute("data-calendar-allday-day"); if (targetDay == null) return; const next = allDayPointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetDay !== state.originDay @@ -193,8 +199,14 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetDay, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { + const move = interpretCalendarAllDayPointer(next); + if (move?.type !== "event.move-day") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "day", day: next.originDay } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: targetDay } })); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: move.day } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setAllDayPreview(next); } @@ -203,13 +215,19 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = allDayPointer.commit(event.pointerId); hand.setAllDayPreview(null); if (release === null) return; - const targetDay = findWebPointTarget("[data-calendar-allday-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-allday-day"); - if (targetDay == null) return; + const targetDay = pointTarget("[data-calendar-allday-day]", event)?.getAttribute("data-calendar-allday-day"); + if (targetDay == null) { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (release.dragSource !== null) { suppressEventClick.current = true; suppressDoubleClickBriefly(); const gesture = selectionDrag.commit(); - if (gesture !== null) hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: targetDay } }); + const move = interpretCalendarAllDayPointer({ ...release, targetDay }); + hand.previewSelectionDrag(null); + if (gesture !== null && move?.type === "event.move-day") hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: move.day } }); return; } if (release.dragCandidate !== null) return; @@ -221,7 +239,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } const id = raw?.type === "event.move-day" || raw?.type === "event.resize" ? raw.eventId : null; const intent = bindCalendarAllDayIntent(raw, id === null ? undefined : document.events.find((item) => item.id === id), release.originEventStart, hand.scope); - remember(intent, release.originEventStart, release.originEventEnd); + hand.commitIntent(intent, { start: release.originEventStart, end: release.originEventEnd }); } function monthPointerDown(event: PointerEvent, fallbackDay: string, rowDays: ReadonlyArray, originEventId: string | null, originEventStart: string | null, originEventEnd: string | null): void { @@ -242,7 +260,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen function monthPointerMove(event: PointerEvent): void { if (monthPointer.getSnapshot()?.pointerId !== event.pointerId) return; - const targetDay = findWebPointTarget("[data-calendar-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-day"); + const targetDay = pointTarget("[data-calendar-day]", event)?.getAttribute("data-calendar-day"); if (targetDay == null) return; const next = monthPointer.preview(event.pointerId, (state) => { const dragSource = state.dragSource ?? (state.dragCandidate !== null && targetDay !== state.originDay @@ -251,8 +269,14 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen return { ...state, targetDay, dragSource }; }); if (next?.dragSource !== null && next?.dragSource !== undefined) { + const move = interpretCalendarMonthPointer(next); + if (move?.type !== "event.move-day") { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (selectionDrag.getActive() === null) selectionDrag.begin({ type: "calendar-selection-drag", source: next.dragSource, target: { type: "day", day: next.originDay } }); - const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: targetDay } })); + const gesture = selectionDrag.preview((active) => ({ ...active, target: { type: "day", day: move.day } })); hand.previewSelectionDrag(gesture); } else if (next !== null) hand.setMonthPreview({ ...next, eventsOnTargetDay: [] }); } @@ -261,13 +285,19 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = monthPointer.commit(event.pointerId); hand.setMonthPreview(null); if (release === null) return; - const targetDay = findWebPointTarget("[data-calendar-day]", { x: event.clientX, y: event.clientY })?.getAttribute("data-calendar-day"); - if (targetDay == null) return; + const targetDay = pointTarget("[data-calendar-day]", event)?.getAttribute("data-calendar-day"); + if (targetDay == null) { + selectionDrag.cancel("pointer-cancel"); + hand.previewSelectionDrag(null); + return; + } if (release.dragSource !== null) { suppressEventClick.current = true; suppressDoubleClickBriefly(); const gesture = selectionDrag.commit(); - if (gesture !== null) hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: targetDay } }); + const move = interpretCalendarMonthPointer({ ...release, targetDay }); + hand.previewSelectionDrag(null); + if (gesture !== null && move?.type === "event.move-day") hand.commitSelectionDrag({ ...gesture, target: { type: "day", day: move.day } }); return; } if (release.dragCandidate !== null) return; @@ -279,7 +309,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } const id = raw?.type === "event.move-day" ? raw.eventId : null; const intent = bindCalendarMonthIntent(raw, id === null ? undefined : document.events.find((item) => item.id === id), release.originEventStart, hand.scope); - remember(intent, release.originEventStart, release.originEventEnd); + hand.commitIntent(intent, { start: release.originEventStart, end: release.originEventEnd }); } function resizeTimed(id: string, edge: "start" | "end", occurrenceStart: string, origin: string, delta: number, phase: Phase): void { @@ -289,12 +319,15 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen const release = { originInstant: origin, originEventId: id, originEventStart: occurrenceStart, originHandle: edge, targetInstant }; if (phase === "preview") return hand.setTimePreview(release); hand.setTimePreview(null); - remember(bindTime(interpretCalendarTimeGridPointer(release), occurrenceStart), occurrenceStart, targetInstant); + hand.commitIntent(bindTime(interpretCalendarTimeGridPointer(release), occurrenceStart), { start: occurrenceStart, end: targetInstant }); } function resizeAllDay(id: string, edge: "start" | "end", originDay: string, occurrenceStart: string, delta: number, phase: Phase): void { - const column = globalThis.document.querySelector("[data-calendar-allday-day]") ?? globalThis.document.querySelector("[data-calendar-week] [data-calendar-day]"); - const targetDay = addCalendarDate(originDay, calendarDayDeltaFromWebWidth(delta, column?.getBoundingClientRect().width ?? 0)); + const column = rootRef.current?.querySelector("[data-calendar-allday-day]") ?? rootRef.current?.querySelector("[data-calendar-week] [data-calendar-day]"); + if (column == null) return; + const width = column.getBoundingClientRect().width; + if (width <= 0) return; + const targetDay = addCalendarDate(originDay, calendarDayDeltaFromWebWidth(delta, width)); if (targetDay === null) return; const release = { originDay, originEventId: id, originEventStart: occurrenceStart, originHandle: edge, targetDay }; if (phase === "preview") return hand.setAllDayPreview(release); @@ -328,6 +361,7 @@ export function useCalendarPointerInteractions(hand: CalendarHand, policy: Calen } return { + rootRef, hoveredTime, instantAt, timePointerDown, diff --git a/packages/json-document-calendar/tests/calendar-protocol.test.tsx b/packages/json-document-calendar/tests/calendar-protocol.test.tsx new file mode 100644 index 000000000..1c68ca4c8 --- /dev/null +++ b/packages/json-document-calendar/tests/calendar-protocol.test.tsx @@ -0,0 +1,145 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { calendarClipboardFormat, createCalendarEditor, type CalendarDocument } from "@interactive-os/json-document-editing"; +import { createWebClipboardBinding, createWebJSONClipboardRepresentation } from "@interactive-os/json-document-web"; +import { useCalendarHand, useCalendarPointerInteractions } from "../src/index.js"; + +afterEach(cleanup); +const initial: CalendarDocument = { + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], + events: [{ id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", allDay: false, calendarId: "work", recurrence: null, excludeDates: [] }], +}; +const policy = { hourStart: 0, hourEnd: 24, stepMinutes: 15, pixelsPerHour: 60 }; +const rect = (width: number) => ({ left: 0, right: width, top: 0, bottom: 1440, width, height: 1440, x: 0, y: 0, toJSON: () => ({}) }); +function surface(width: number, day: string) { + const root = document.createElement("div"); + const grid = document.createElement("div"); + grid.dataset.calendarGrid = "time"; + grid.dataset.calendarDay = day; + grid.dataset.calendarAlldayDay = day; + grid.getBoundingClientRect = () => rect(width); + root.append(grid); + document.body.append(root); + return { root, grid }; +} + +describe("Calendar Hand protocol composition", () => { + test.each(["time", "allDay", "month"] as const)("returning a %s drag to its origin clears preview and does not commit", (kind) => { + const sourceEvent = kind === "time" ? initial.events[0]! : { ...initial.events[0]!, start: "2026-08-01", end: "2026-08-02", allDay: true }; + const editor = createCalendarEditor({ ...initial, events: [sourceEvent] }); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + const { root, grid } = surface(100, kind === "time" ? "2026-08-01" : "2026-08-02"); + try { + result.current.pointer.rootRef.current = root; + const target = { closest: () => kind === "time" ? grid : null, focus() {}, setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} }; + const down = { button: 0, clientX: 50, clientY: 540, currentTarget: target, pointerId: 1 } as never; + act(() => { + if (kind === "time") result.current.pointer.timePointerDown(down, "2026-08-01", "a", sourceEvent.start, sourceEvent.end, "body"); + else if (kind === "allDay") result.current.pointer.allDayPointerDown(down, "2026-08-01", "a", sourceEvent.start, sourceEvent.end, "body"); + else result.current.pointer.monthPointerDown(down, "2026-08-01", ["2026-08-01"], "a", sourceEvent.start, sourceEvent.end); + }); + act(() => result.current.pointer[`${kind}PointerMove`]({ pointerId: 1, clientX: 50, clientY: 600, target: grid } as never)); + expect(result.current.hand.selectionDragPreview).not.toBeNull(); + grid.dataset.calendarDay = grid.dataset.calendarAlldayDay = "2026-08-01"; + act(() => result.current.pointer[`${kind}PointerMove`]({ pointerId: 1, clientX: 50, clientY: 540, target: grid } as never)); + expect(result.current.hand.selectionDragPreview).toBeNull(); + act(() => result.current.pointer[`${kind}PointerUp`]({ pointerId: 1, clientX: 50, clientY: 540 } as never)); + expect(result.current.hand.document.events).toEqual([sourceEvent]); + expect(editor.snapshot.canUndo).toBe(false); + } finally { root.remove(); } + }); + + test("cuts the written payload even when the writer re-enters selection", () => { + const editor = createCalendarEditor({ ...initial, events: [...initial.events, { ...structuredClone(initial.events[0]!), id: "b", title: "B" }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + const data = new Map(); + const binding = createWebClipboardBinding({ + codec: createWebJSONClipboardRepresentation(calendarClipboardFormat), + read: result.current.copy, cut: result.current.cut, paste: result.current.paste, + }); + act(() => { + expect(binding.cut({ + clipboardData: { + types: [], + getData: (type) => data.get(type) ?? "", + setData(type, value) { + data.set(type, value); + editor.dispatch({ type: "selection.set", point: { eventId: "b", occurrenceStart: "2026-08-01T09:00" } }); + }, + }, + preventDefault() {}, + }).ok).toBe(true); + }); + expect(JSON.parse(data.get(calendarClipboardFormat.mimeType)!).items[0].sourceEventId).toBe("a"); + expect(result.current.document.events.map((event) => event.id)).toEqual(["b"]); + }); + + test("reports a rejected pointer edit without success aftercare", () => { + const editor = createCalendarEditor(initial, { createId: () => "draft" }); + const onResult = vi.fn(); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor, { onResult }); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + act(() => result.current.hand.createInterval("2026-08-01T11:00", "2026-08-01T12:00")); + const before = editor.snapshot; + const occurrence = result.current.hand.occurrence; + act(() => result.current.pointer.resizeTimed("draft", "end", "2026-08-01T11:00", "2026-08-01T12:00", -120, "commit")); + expect(editor.snapshot).toEqual(before); + expect(result.current.hand.occurrence).toEqual(occurrence); + expect(result.current.hand.renaming).toBe(true); + expect(onResult).toHaveBeenLastCalledWith(expect.objectContaining({ ok: false, code: "event.invalid-interval" })); + }); + + test("keeps the edited later occurrence focused for all-scope Inspector follow-up", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, recurrence: { freq: "daily", interval: 1, until: "2026-08-08" } }] }); + const { result } = renderHook(() => useCalendarHand(editor)); + act(() => result.current.selectOccurrence("a", "2026-08-03T09:00", "2026-08-03T10:00")); + act(() => result.current.setScope("all")); + act(() => result.current.applySelectedPatch({ start: "2026-08-03T11:00" })); + expect(result.current.inspectedInterval).toEqual({ start: "2026-08-03T11:00", end: "2026-08-03T12:00" }); + act(() => result.current.applySelectedPatch({ end: "2026-08-03T13:00" })); + expect(result.current.document.events[0]).toMatchObject({ start: "2026-08-01T11:00", end: "2026-08-01T13:00" }); + }); + + test("two overlapping Calendar instances hit-test and resize within their own roots", () => { + const allDay = { ...initial, events: [{ ...initial.events[0]!, start: "2026-08-01", end: "2026-08-02", allDay: true }] }; + const first = createCalendarEditor(allDay), second = createCalendarEditor(allDay); + const { result } = renderHook(() => { + const firstHand = useCalendarHand(first), secondHand = useCalendarHand(second); + return { first: useCalendarPointerInteractions(firstHand, policy), second: useCalendarPointerInteractions(secondHand, policy) }; + }); + const left = surface(100, "2026-08-01"), right = surface(200, "2026-08-02"); + try { + result.current.first.rootRef.current = left.root; + result.current.second.rootRef.current = right.root; + act(() => result.current.second.timePointerMove({ pointerId: 1, clientX: 50, clientY: 540, target: right.grid } as never)); + expect(result.current.second.hoveredTime?.day).toBe("2026-08-02"); + act(() => result.current.first.resizeAllDay("a", "end", "2026-08-01", "2026-08-01", 200, "commit")); + act(() => result.current.second.resizeAllDay("a", "end", "2026-08-01", "2026-08-01", 200, "commit")); + expect((first.snapshot.value as CalendarDocument).events[0]?.end).toBe("2026-08-04"); + expect((second.snapshot.value as CalendarDocument).events[0]?.end).toBe("2026-08-03"); + } finally { left.root.remove(); right.root.remove(); } + }); + + test("all-day body dragging preserves the grab offset inside a multi-day span", () => { + const editor = createCalendarEditor({ ...initial, events: [{ ...initial.events[0]!, start: "2026-08-01", end: "2026-08-04", allDay: true }] }); + const { result } = renderHook(() => { + const hand = useCalendarHand(editor); + return { hand, pointer: useCalendarPointerInteractions(hand, policy) }; + }); + const { root, grid } = surface(100, "2026-08-04"); + try { + result.current.pointer.rootRef.current = root; + const target = { focus() {}, setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} }; + act(() => result.current.pointer.allDayPointerDown({ button: 0, currentTarget: target, pointerId: 1 } as never, "2026-08-03", "a", "2026-08-01", "2026-08-04", "body")); + act(() => result.current.pointer.allDayPointerMove({ pointerId: 1, clientX: 50, clientY: 50, target: grid } as never)); + expect(result.current.hand.paintedEvents[0]?.start).toBe("2026-08-02"); + act(() => result.current.pointer.allDayPointerUp({ pointerId: 1, clientX: 50, clientY: 50 } as never)); + expect(result.current.hand.document.events[0]).toMatchObject({ start: "2026-08-02", end: "2026-08-05" }); + } finally { root.remove(); } + }); +}); diff --git a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx index 999ec4bf4..e5505b9e4 100644 --- a/packages/json-document-calendar/tests/use-calendar-hand.test.tsx +++ b/packages/json-document-calendar/tests/use-calendar-hand.test.tsx @@ -201,7 +201,10 @@ describe("useCalendarHand", () => { grid.dataset.calendarGrid = "time"; grid.dataset.calendarDay = "2026-08-03"; grid.getBoundingClientRect = () => ({ left: 0, right: 100, top: 0, bottom: 1440, width: 100, height: 1440, x: 0, y: 0, toJSON: () => ({}) }); - document.body.append(grid); + const root = document.createElement("div"); + root.append(grid); + document.body.append(root); + result.current.pointer.rootRef.current = root; const target = { closest: () => grid, focus: () => undefined, @@ -227,7 +230,7 @@ describe("useCalendarHand", () => { expect(result.current.hand.document.events.map((item) => item.start)).toEqual([ "2026-08-03T09:00", "2026-08-04T11:00", ]); - grid.remove(); + root.remove(); }); test("finishes an outstanding create rename when selection drag commits", () => { diff --git a/packages/json-document-editing/README.md b/packages/json-document-editing/README.md index eef058567..a71d50fe9 100644 --- a/packages/json-document-editing/README.md +++ b/packages/json-document-editing/README.md @@ -180,6 +180,9 @@ It publishes rows with hierarchy/ARIA facts and the matching `TreeTopology`. `gridPointKey` and `gridPointFromKey` provide the canonical reversible string identity when a selection or rendering adapter needs to key a `GridPoint`. +`Calendar` follows the owner-local [Calendar protocol profile](docs/calendar-profile.md). +The profile is also rendered on the site's Editing API page; it documents the +current RC implementation, not a frozen Official Hands wire standard. `Calendar` keeps interval events `{ id, title, start, end, allDay }`. Timed events use datetime-local strings; all-day events use exclusive-end dates. `parseCalendarView` validates untrusted runtime values against the canonical @@ -210,7 +213,8 @@ the public editors; each domain still owns its projection and removal plan. `interpretCalendarAllDayPointer`, and `interpretCalendarMonthPointer` map a press-release to those intents from the origin event, not the current selection. `calendarTimedLayout` places a timed event on its `start`/`end` -span. Pixel grids and view chrome stay in the Host. +span. Calendar Hands own reusable grids and lifecycle, Web owns pixel-coordinate +translation, and Hosts retain visual composition and policy values. `Annotation` keeps a target selector separate from its presentation. Point targets may use numbered `marker` presentations for instructions or a diff --git a/packages/json-document-editing/docs/calendar-profile.md b/packages/json-document-editing/docs/calendar-profile.md new file mode 100644 index 000000000..430347697 --- /dev/null +++ b/packages/json-document-editing/docs/calendar-profile.md @@ -0,0 +1,105 @@ +## Calendar protocol profile (RC) + +정본 소유자는 `@interactive-os/json-document-editing`이다. 이 문서는 현재 +`0.1.0-rc.0` Calendar 구현의 계약과 한계를 명시한다. Core Stable 프로파일이나 +EditingSession 계약을 변경하지 않으며, Draft인 Official Hands / Editing Grammar를 +동결된 wire 표준으로 승격하지 않는다. Calendar Hands는 이 계약을 입력과 화면에 연결한다. + +### 값과 시간 + +- 일정은 고유한 비어 있지 않은 `id`, 문자열 `title`, `start < end`인 구간을 갖는다. +- timed 값은 정확히 `YYYY-MM-DDTHH:mm`인 local date-time이다. UTC Instant, + offset, timezone, 초 단위는 이 프로파일에 포함하지 않는다. +- all-day 값은 `YYYY-MM-DD`이다. timed와 all-day 모두 종료는 exclusive이다. + 8월 1일 하루는 `start: "2026-08-01", end: "2026-08-02"`다. +- 현재 문서에 calendars가 있으면 비어 있지 않은 `calendarId`는 실제 calendar를 + 참조해야 한다. 기존 간단한 문서의 생략된 calendars / allDay / calendarId / + recurrence / excludeDates는 각각 빈 목록 / timed / 미지정 / 반복 없음 / 제외 없음으로 + 읽는 호환 경로를 유지한다. 새 clipboard 출력은 완전한 필드를 갖는다. +- recurrence의 `freq`는 daily / weekly / monthly / yearly, `interval`은 양의 + safe integer다. `until: ""`은 무기한, 그 외에는 발생 시작일 기준 inclusive 날짜다. + `excludeDates`는 발생 시작일을 제외한다. 월말·윤년의 발생 시작은 Temporal constrain + 규칙을 따르고, 종료는 원본의 local duration을 보존한다. 시작·종료를 따로 constrain해 + 구간 길이가 0이나 음수가 되지 않는다. +- projection은 요청한 `[rangeStart, rangeEnd)`와 겹치는 발생분을 계산한다. 과거 + 발생분을 처음부터 순회하거나 400회에서 수명을 잘라내지 않고 요청 구간 근처로 이동한다. + 매우 넓은 조회의 출력량 자체를 제한하는 pagination 계약은 아직 없다. + +### 선택, 범위와 편집 + +`{ eventId, occurrenceStart }`가 발생분의 정체성이다. Selection 정본의 materialized +targets를 사용하며, 화면 밖에 있어도 현재 반복 규칙에 존재하는 선택은 유지된다. + +| scope | 의미 | +| --- | --- | +| this | 해당 발생분만 제외하고 독립 일정으로 분리 | +| this-and-following | 이전 시리즈를 기준 발생일 전날까지 자르고 이후를 새 시리즈로 분리 | +| all | 선택한 발생분의 변경량을 원본 시리즈 구간에 적용 | + +시작만 바꾸면 해당 발생분의 길이를 보존한다. resize는 지정한 경계만 바꾸며 +반복 시리즈의 원본 날짜와 선택 발생 날짜를 혼동하지 않는다. Inspector, 포인터, +preview, 그룹 이동은 같은 순수 event/series 계획을 사용한다. preview ID는 임시이며 +commit ID와 같을 필요는 없지만 실제 일정 구간·반복 결과는 같아야 한다. + +그룹 이동은 선택 전체에 하나의 시간/일 변화량을 적용한다. this에서는 발생분별로 +분리하고, all / following에서는 같은 시리즈를 한 번만 편집한다. following 기준은 +선택·primary 순서와 무관하게 그 시리즈에서 선택된 가장 이른 발생분이다. +월/년 주기를 재기준화해 모든 선택점의 같은 변화량을 표현할 수 없으면 +`selection.unrepresentable-series-move`로 그룹 전체를 거절한다. 단일 발생분의 all 편집도 +요청한 구간이 결과 시리즈에 실제로 존재해야 하며 같은 규칙으로 거절한다. + +시리즈 이동은 유한한 until과 제외 날짜도 시작일 변화량만큼 옮긴다. following은 +기존 종료일과 이후 제외 날짜를 보존하며 무조건 무기한으로 늘리지 않는다. +allDay / calendarId / recurrence 자체의 Inspector 변경은 시리즈 속성 변경이다. +`selection.remove`는 선택된 원본 일정을 삭제한다. 발생분 범위 삭제는 +`occurrence.remove`와 scope, clipboard cut은 캡처된 발생분을 사용한다. + +### 거절과 원자성 + +생성·update·발생분 편집·paste는 생성자와 같은 도메인 불변식을 검증한다. +잘못된 구간, 미등록 calendar, 소수 recurrence, 알 수 없는 Intent type은 +`{ ok: false, code, reason? }`로 끝나며 값·선택·undo/redo·알림을 바꾸지 않는다. +성공한 문서는 다시 editor로 읽고 projection할 수 있어야 한다. + +드래그의 캡처 구간은 현재 사실이 아니라 precondition이다. commit 시 실제 발생분의 +존재와 start/end를 다시 확인한다. 삭제·제외되었거나 길이가 바뀐 발생분은 +`selection.stale-occurrence`로 거절한다. 제목만 바뀌거나 선택·뷰가 달라진 것은 +드래그를 무효화하지 않으며 새 제목을 보존한다. 그룹 실패는 일부만 적용하지 않는다. + +한 번의 편집/cut/paste/그룹 이동은 하나의 EditingSession 이력 단위다. +입력 거절과 잘못된 provider/programmer 예외는 다르다. 예를 들어 ID provider가 +100회 충돌하면 공통 bounded allocator가 예외를 던지고 문서는 변경하지 않는다. +Hands는 성공한 결과에만 선택/rename aftercare를 수행하고 `onResult`로 결과를 전달한다. + +### Clipboard 호환성과 외부 경계 + +`application/vnd.interactive-os.calendar+json`은 현재 materialized occurrence +clipboard 형식이다. `calendarClipboardFormat.parse(unknown)`는 잘못된 날짜·역전 +구간·비정규 event를 거절하고, 직접 호출한 `paste`도 같은 검증을 수행한다. +anchor가 생략된 기존 payload는 첫 occurrence를 anchor로 읽는다. +알 수 없는 추가 JSON 필드는 유지하지만 새 버전/새 의미의 호환을 보장하지 않는다. + +`copy()`는 빈 선택에서 null이다. `cut(capturedClipboard)`는 이미 기록한 payload의 +발생분만 삭제한다. 그 사이 선택이 달라져도 대상을 다시 선택하지 않으며, 기록하지 +않은 내용 변경이나 사라진 발생분이 있으면 거절한다. Web은 기록 실패 시 cut을 +호출하지 않는다. Web의 별도 이벤트 기본 동작 정책은 이 프로파일을 확장하지 않는다. + +paste는 길이와 상대 위치를 보존하고 새 ID를 부여한다. 외부 문서의 calendarId를 +자동으로 다른 calendar로 치환하지 않는다. 명시적 재배치가 필요하면 기존 Editing API로 +목적지를 지정한다. + +```ts +import { createCalendarEditor } from "@interactive-os/json-document-editing"; + +const destination = createCalendarEditor(destinationDocument); +const result = destination.paste(clipboard, "2026-08-02T12:00", { calendarId: "personal" }); +if (!result.ok) showError(result.code); +``` + +timezone/DST 변환, 서버 저장·동기화의 revision/충돌/재시도, AI command wire, +외부 calendar connector, recurrence의 범용 RRULE 호환, 프로파일 버전 협상과 +독립 구현 conformance는 **TBD**다. 현재 앱에서 검증한 로컬 계약과 구분한다. + +Usage는 [Calendar](/demo/calendar), public API는 [Editing API](/docs/api/editing)와 +[Calendar Hands API](/docs/api/calendar)에 있다. Usage Source는 Editing의 validation, +event/series plan, selection move, projection과 Calendar Hands의 입력 연결까지 추적한다. diff --git a/packages/json-document-editing/package.json b/packages/json-document-editing/package.json index 85d2cf621..7a9a86b7c 100644 --- a/packages/json-document-editing/package.json +++ b/packages/json-document-editing/package.json @@ -17,7 +17,7 @@ "provenance": true, "tag": "next" }, - "files": ["dist", "!dist/.tsbuildinfo", "README.md", "LICENSE"], + "files": ["dist", "!dist/.tsbuildinfo", "README.md", "docs/calendar-profile.md", "LICENSE"], "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/json-document-editing/src/calendar-allday-pointer.ts b/packages/json-document-editing/src/calendar-allday-pointer.ts index 9aa141046..539ff3b6e 100644 --- a/packages/json-document-editing/src/calendar-allday-pointer.ts +++ b/packages/json-document-editing/src/calendar-allday-pointer.ts @@ -1,5 +1,5 @@ import type { CalendarEvent, CalendarIntent } from "./calendar.js"; -import { calendarEventRecurrence } from "./calendar-occurrence.js"; +import { calendarEventRecurrence, resolveCalendarOccurrence } from "./calendar-occurrence.js"; import { bindCalendarMonthIntent } from "./calendar-month-pointer.js"; import { addCalendarDate, calendarAllDaySpan, calendarDaysBetween, parseCalendarDate } from "./calendar-validation.js"; @@ -75,12 +75,12 @@ export function bindCalendarAllDayIntent( if (intent.type !== "event.resize") return intent; if (event === undefined || calendarEventRecurrence(event) === null) return intent; const start = occurrenceStart ?? event.start; - if (scope === "all") return intent; + const end = resolveCalendarOccurrence([event], { eventId: event.id, occurrenceStart: start })?.end; return { type: "occurrence.edit", eventId: intent.eventId, occurrenceStart: start, scope, - ...(intent.edge === "start" ? { start: intent.instant } : { end: intent.instant }), + ...(intent.edge === "start" ? { start: intent.instant, ...(end === undefined ? {} : { end }) } : { end: intent.instant }), }; } diff --git a/packages/json-document-editing/src/calendar-event-plan.ts b/packages/json-document-editing/src/calendar-event-plan.ts new file mode 100644 index 000000000..497aca321 --- /dev/null +++ b/packages/json-document-editing/src/calendar-event-plan.ts @@ -0,0 +1,159 @@ +import { buildPointer, type JSONPatchOperation } from "@interactive-os/json-document"; +import type { CalendarEvent, CalendarIntent, CalendarOccurrencePoint } from "./calendar.js"; +import { calendarEventExcludeDates, calendarEventRecurrence, resolveCalendarOccurrence } from "./calendar-occurrence.js"; +import { + addCalendarDate, calendarAllDaySpan, calendarDatePart, calendarDaysBetween, + calendarEventIntervalAt, calendarMinutesBetween, formatCalendarInstant, + parseCalendarDate, parseCalendarInstant, validateCalendarEvent, +} from "./calendar-validation.js"; + +type CalendarEventIntent = Extract; + +type CalendarEventPlan = { + readonly ok: true; + readonly events: ReadonlyArray; + readonly operations: ReadonlyArray; + readonly selected: CalendarOccurrencePoint; +} | { readonly ok: false; readonly code: string; readonly reason?: string }; + +/** Calendar's single event/series semantics, shared by commit, preview and group moves. */ +export function planCalendarEventEdit( + events: ReadonlyArray, + intent: CalendarEventIntent, + options: { readonly allocateId: () => string; readonly calendarIds?: ReadonlySet; readonly defaultCalendarId?: string }, +): CalendarEventPlan { + const index = intent.type === "event.create" ? -1 : events.findIndex((event) => event.id === intent.eventId); + const event = events[index]; + + function replace(next: CalendarEvent, selectedStart = next.start, fields?: ReadonlyArray<"start" | "end">): CalendarEventPlan { + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + return { + ok: true, + events: events.map((item, position) => position === index ? next : item), + operations: fields === undefined + ? [{ op: "replace", path: buildPointer(["events", index]), value: next }] + : fields.map((field) => ({ op: "replace", path: buildPointer(["events", index, field]), value: next[field] })), + selected: { eventId: next.id, occurrenceStart: selectedStart }, + }; + } + + function append(next: CalendarEvent, preceding: ReadonlyArray = [], previous?: CalendarEvent): CalendarEventPlan { + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + if (previous !== undefined) { + const previousValidation = validateCalendarEvent(previous, options.calendarIds); + if (!previousValidation.ok) return previousValidation; + } + // Detached records own their JSON subtrees, including extension metadata. + const appended = JSON.parse(JSON.stringify(next)) as CalendarEvent; + return { + ok: true, + events: [...events.map((item, position) => position === index && previous !== undefined ? previous : item), appended], + operations: [...preceding, { op: "add", path: `/events/${events.length}`, value: appended }], + selected: { eventId: next.id, occurrenceStart: next.start }, + }; + } + + if (intent.type === "event.create") { + const candidate: CalendarEvent = { + id: "pending", title: intent.title ?? "Event", start: intent.start, end: intent.end, + allDay: intent.allDay ?? false, calendarId: intent.calendarId ?? options.defaultCalendarId ?? "", + recurrence: intent.recurrence ?? null, excludeDates: [], + }; + const validation = validateCalendarEvent(candidate, options.calendarIds); + if (!validation.ok) return validation; + return append({ ...candidate, id: options.allocateId() }); + } + if (event === undefined) return failure("selection.event-not-found"); + if (intent.type === "event.resize") { + if (intent.edge !== "start" && intent.edge !== "end") return failure("event.invalid-edge"); + return replace({ ...event, [intent.edge]: intent.instant }, intent.edge === "start" ? intent.instant : event.start, [intent.edge]); + } + if (intent.type === "event.move" || intent.type === "event.move-day") { + if (intent.type === "event.move" && event.allDay) return failure("event.all-day-move"); + if (intent.type === "event.move-day" && parseCalendarDate(intent.day) === null) return failure("event.invalid-day"); + const start = intent.type === "event.move" ? intent.start + : event.allDay ? intent.day : `${intent.day}T${event.start.slice(11)}`; + const interval = calendarEventIntervalAt(event, start); + return interval === null ? failure("event.invalid-instant") : replace({ ...event, ...interval }, start, ["start", "end"]); + } + if (intent.type === "event.update") { + let start = intent.start ?? event.start; + let end = intent.end ?? event.end; + if (intent.allDay === true && !event.allDay) { + start = calendarDatePart(event.start); + end = calendarAllDaySpan(start, start)?.end ?? start; + } else if (intent.allDay === false && event.allDay) { + start = `${calendarDatePart(event.start)}T09:00`; + end = `${calendarDatePart(event.start)}T10:00`; + } else if (intent.start !== undefined && intent.end === undefined) { + const interval = calendarEventIntervalAt(event, intent.start); + if (interval === null) return failure("event.invalid-instant"); + ({ start, end } = interval); + } + return replace({ ...event, start, end, allDay: intent.allDay ?? event.allDay, + title: intent.title ?? event.title, calendarId: intent.calendarId ?? event.calendarId, + recurrence: intent.recurrence === undefined ? event.recurrence : intent.recurrence }); + } + + if (intent.scope !== "this" && intent.scope !== "this-and-following" && intent.scope !== "all") return failure("occurrence.invalid-scope"); + const occurrence = resolveCalendarOccurrence(events, { eventId: event.id, occurrenceStart: intent.occurrenceStart }); + if (occurrence === null) return failure("selection.stale-occurrence"); + const interval = intent.end === undefined + ? calendarEventIntervalAt({ ...event, start: occurrence.start, end: occurrence.end }, intent.start ?? occurrence.start) + : { start: intent.start ?? occurrence.start, end: intent.end }; + if (interval === null) return failure("event.invalid-instant"); + const next = { ...event, ...interval, title: intent.title ?? event.title }; + const validation = validateCalendarEvent(next, options.calendarIds); + if (!validation.ok) return validation; + const recurrence = calendarEventRecurrence(event); + if (recurrence === null) return replace(next); + + if (intent.scope === "all") { + const start = shiftValue(event.start, occurrence.start, interval.start); + const end = shiftValue(event.end, occurrence.end, interval.end); + if (start === null || end === null) return failure("event.invalid-instant"); + const shifted = shiftRecurrence(event, occurrence.start, interval.start); + const plan = replace({ ...next, start, end, ...shifted }, interval.start); + if (!plan.ok) return plan; + return resolveCalendarOccurrence(plan.events, plan.selected)?.end === interval.end + ? plan : failure("selection.unrepresentable-series-move"); + } + const day = calendarDatePart(occurrence.start); + const id = options.allocateId(); + if (intent.scope === "this") { + const excludeDates = [...new Set([...calendarEventExcludeDates(event), day])]; + return append({ ...next, id, recurrence: null, excludeDates: [] }, [ + { op: "add", path: buildPointer(["events", index, "excludeDates"]), value: excludeDates }, + ], { ...event, excludeDates }); + } + const previous = { ...event, recurrence: { ...recurrence, until: addCalendarDate(day, -1)! } }; + const shifted = shiftRecurrence({ ...event, excludeDates: calendarEventExcludeDates(event).filter((date) => date >= day) }, occurrence.start, interval.start); + return append({ ...next, id, ...shifted }, [ + { op: "replace", path: buildPointer(["events", index, "recurrence"]), value: previous.recurrence }, + ], previous); +} + +function shiftValue(value: string, origin: string, next: string): string | null { + if (value.length === 10) { + const from = parseCalendarDate(origin), to = parseCalendarDate(next); + return from === null || to === null ? null : addCalendarDate(value, calendarDaysBetween(from, to)); + } + const start = parseCalendarInstant(value), from = parseCalendarInstant(origin), to = parseCalendarInstant(next); + return start === null || from === null || to === null ? null + : formatCalendarInstant(start.add({ minutes: calendarMinutesBetween(from, to) })); +} + +function shiftRecurrence(event: CalendarEvent, origin: string, next: string): Pick { + const recurrence = calendarEventRecurrence(event)!; + const delta = calendarDaysBetween(parseCalendarDate(calendarDatePart(origin))!, parseCalendarDate(calendarDatePart(next))!); + return { + recurrence: { ...recurrence, until: recurrence.until === "" ? "" : addCalendarDate(recurrence.until, delta)! }, + excludeDates: calendarEventExcludeDates(event).map((date) => addCalendarDate(date, delta)!), + }; +} + +function failure(code: string): CalendarEventPlan { return { ok: false, code }; } diff --git a/packages/json-document-editing/src/calendar-month-pointer.ts b/packages/json-document-editing/src/calendar-month-pointer.ts index 2dbb5902c..2e35633d0 100644 --- a/packages/json-document-editing/src/calendar-month-pointer.ts +++ b/packages/json-document-editing/src/calendar-month-pointer.ts @@ -48,15 +48,6 @@ export function bindCalendarMonthIntent( if (intent.type !== "event.move-day") return intent; if (event === undefined || calendarEventRecurrence(event) === null) return intent; const start = occurrenceStart ?? event.start; - const occDay = calendarDatePart(start); - const origin = parseCalendarDate(occDay); - const next = parseCalendarDate(intent.day); - if (origin === null || next === null) return intent; - if (scope === "all") { - const day = addCalendarDate(calendarDatePart(event.start), calendarDaysBetween(origin, next)); - if (day === null) return intent; - return { type: "event.move-day", eventId: intent.eventId, day }; - } return { type: "occurrence.edit", eventId: intent.eventId, diff --git a/packages/json-document-editing/src/calendar-occurrence.ts b/packages/json-document-editing/src/calendar-occurrence.ts index 80bb8d5df..363f83cac 100644 --- a/packages/json-document-editing/src/calendar-occurrence.ts +++ b/packages/json-document-editing/src/calendar-occurrence.ts @@ -1,12 +1,16 @@ import { Temporal } from "@js-temporal/polyfill"; -import type { CalendarEvent, CalendarRecurrence } from "./calendar.js"; +import type { CalendarEvent, CalendarOccurrencePoint, CalendarOccurrenceSelection, CalendarRecurrence } from "./calendar.js"; import { addCalendarDate, calendarDatePart, calendarEventBounds, + calendarEventIntervalAt, + calendarDaysBetween, + formatCalendarDate, + formatCalendarInstant, isCalendarAllDay, + isCalendarRecurrence, parseCalendarDate, - parseCalendarInstant, } from "./calendar-validation.js"; export type CalendarOccurrence = { @@ -16,17 +20,7 @@ export type CalendarOccurrence = { }; export function calendarEventRecurrence(event: CalendarEvent): CalendarRecurrence | null { - const value = event.recurrence; - if (value === null || typeof value !== "object" || Array.isArray(value)) return null; - const freq = value.freq; - const interval = value.interval; - if ( - (freq !== "daily" && freq !== "weekly" && freq !== "monthly" && freq !== "yearly") - || typeof interval !== "number" - || interval < 1 - ) return null; - const until = typeof value.until === "string" ? value.until : ""; - return { freq, interval, until }; + return isCalendarRecurrence(event.recurrence) ? event.recurrence : null; } export function calendarRecurrenceWithFrequency( @@ -82,8 +76,21 @@ export function projectCalendarOccurrences( } const excluded = new Set(calendarEventExcludeDates(event)); const until = recurrence.until === "" ? null : parseCalendarDate(recurrence.until); - for (let index = 0; index < 400; index += 1) { - const shifted = shiftOccurrence(event, recurrence.freq, recurrence.interval * index); + const end = parseCalendarDate(calendarDatePart(event.end)); + if (end === null) continue; + // Seek by the interval end, not start, so long occurrences overlapping the + // window are retained. One preceding period covers constrained month/year ends. + const distance = recurrence.freq === "monthly" ? (from.year - end.year) * 12 + from.month - end.month + : recurrence.freq === "yearly" ? from.year - end.year + : calendarDaysBetween(end, from) / (recurrence.freq === "weekly" ? 7 : 1); + const first = Math.max(0, Math.floor(distance / recurrence.interval) - 1); + for (let index = first; ; index += 1) { + let shifted: ReturnType; + try { shifted = shiftOccurrence(event, recurrence.freq, recurrence.interval * index); } + catch (error) { + if (!(error instanceof RangeError)) throw error; + break; // Beyond the finite Temporal date domain, not a truncated result. + } if (shifted === null) break; const bounds = calendarEventBounds({ ...event, start: shifted.start, end: shifted.end }); if (bounds === null) break; @@ -97,37 +104,31 @@ export function projectCalendarOccurrences( return occurrences; } +/** Resolve against the current recurrence/exclusion rules, including off-screen occurrences. */ +export function resolveCalendarOccurrence( + events: ReadonlyArray, + point: CalendarOccurrencePoint, +): CalendarOccurrenceSelection | null { + const event = events.find((candidate) => candidate.id === point.eventId); + if (event === undefined || typeof point.occurrenceStart !== "string") return null; + const day = calendarDatePart(point.occurrenceStart); + const next = addCalendarDate(day, 1); + if (next === null) return null; + const occurrence = projectCalendarOccurrences([event], day, next).find((candidate) => candidate.start === point.occurrenceStart); + return occurrence === undefined ? null : { eventId: event.id, start: occurrence.start, end: occurrence.end }; +} + function shiftOccurrence( event: CalendarEvent, freq: CalendarRecurrence["freq"], steps: number, ): { readonly start: string; readonly end: string } | null { if (steps === 0) return { start: event.start, end: event.end }; - if (isCalendarAllDay(event)) { - const start = shiftDate(event.start, freq, steps); - const end = shiftDate(event.end, freq, steps); - if (start === null || end === null) return null; - return { start, end }; - } - const start = shiftInstant(event.start, freq, steps); - const end = shiftInstant(event.end, freq, steps); - if (start === null || end === null) return null; - return { start, end }; -} - -function shiftDate(value: string, freq: CalendarRecurrence["freq"], steps: number): string | null { - if (freq === "daily") return addCalendarDate(value, steps); - if (freq === "weekly") return addCalendarDate(value, steps * 7); - if (parseCalendarDate(value) === null) return null; - const duration = freq === "monthly" ? { months: steps } : { years: steps }; - return Temporal.PlainDate.from(value).add(duration, { overflow: "constrain" }).toString(); -} - -function shiftInstant(value: string, freq: CalendarRecurrence["freq"], steps: number): string | null { - const dateTime = parseCalendarInstant(value); - if (dateTime === null) return null; - if (freq === "daily") return dateTime.add({ days: steps }).toString({ smallestUnit: "minute" }); - if (freq === "weekly") return dateTime.add({ weeks: steps }).toString({ smallestUnit: "minute" }); - const duration = freq === "monthly" ? { months: steps } : { years: steps }; - return Temporal.PlainDateTime.from(value).add(duration, { overflow: "constrain" }).toString({ smallestUnit: "minute" }); + const bounds = calendarEventBounds(event); + if (bounds === null) return null; + const duration = freq === "daily" ? { days: steps } : freq === "weekly" ? { weeks: steps } + : freq === "monthly" ? { months: steps } : { years: steps }; + const shifted = bounds.from.add(duration, { overflow: "constrain" }); + const start = isCalendarAllDay(event) ? formatCalendarDate(shifted.toPlainDate()) : formatCalendarInstant(shifted); + return calendarEventIntervalAt(event, start); } diff --git a/packages/json-document-editing/src/calendar-preview.ts b/packages/json-document-editing/src/calendar-preview.ts index 6f414b7e0..f7dacf72c 100644 --- a/packages/json-document-editing/src/calendar-preview.ts +++ b/packages/json-document-editing/src/calendar-preview.ts @@ -1,94 +1,17 @@ -import type { CalendarEvent } from "./calendar.js"; -import { calendarEventExcludeDates, calendarEventRecurrence } from "./calendar-occurrence.js"; -import { - bindCalendarAllDayIntent, - interpretCalendarAllDayPointer, - type CalendarAllDayPointerRelease, -} from "./calendar-allday-pointer.js"; -import { - bindCalendarMonthIntent, - interpretCalendarMonthPointer, - type CalendarMonthPointerRelease, -} from "./calendar-month-pointer.js"; -import { - bindCalendarTimeGridIntent, - interpretCalendarTimeGridPointer, - type CalendarTimeGridPointerIntent, - type CalendarTimeGridPointerRelease, -} from "./calendar-time-grid-pointer.js"; -import { - addCalendarDate, - calendarDatePart, - calendarDaysBetween, - calendarMinutesBetween, - formatCalendarDate, - formatCalendarInstant, - isCalendarAllDay, - parseCalendarDate, - parseCalendarInstant, -} from "./calendar-validation.js"; +import type { CalendarEvent, CalendarIntent } from "./calendar.js"; +import { planCalendarEventEdit } from "./calendar-event-plan.js"; +import { createEditingIdAllocator } from "./identity.js"; +import { bindCalendarAllDayIntent, interpretCalendarAllDayPointer, type CalendarAllDayPointerRelease } from "./calendar-allday-pointer.js"; +import { bindCalendarMonthIntent, interpretCalendarMonthPointer, type CalendarMonthPointerRelease } from "./calendar-month-pointer.js"; +import { bindCalendarTimeGridIntent, interpretCalendarTimeGridPointer, type CalendarTimeGridPointerRelease } from "./calendar-time-grid-pointer.js"; export function previewCalendarAllDay( events: ReadonlyArray, release: CalendarAllDayPointerRelease, scope: "this" | "this-and-following" | "all" = "this", ): ReadonlyArray { - const intent = interpretCalendarAllDayPointer(release); - if (intent === null || intent.type === "selection.set" || intent.type === "selection.clear") return events; - if (intent.type === "event.create") { - return [...events, { - id: "preview", - title: "Event", - start: intent.start, - end: intent.end, - allDay: true, - calendarId: "", - recurrence: null, - excludeDates: [], - }]; - } - const event = events.find((item) => item.id === intent.eventId); - const bound = bindCalendarAllDayIntent(intent, event, release.originEventStart, scope) ?? intent; - if (bound.type === "occurrence.edit" && event !== undefined && release.originEventStart !== null) { - const excluded = calendarDatePart(release.originEventStart); - const start = bound.start ?? release.originEventStart; - const end = bound.end ?? event.end; - return [ - ...events.map((item) => item.id === event.id - ? { ...item, excludeDates: [...calendarEventExcludeDates(item), excluded] } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: bound.scope === "this" ? null : event.recurrence, - excludeDates: [], - }, - ]; - } - if (intent.type === "event.move-day") { - return events.map((item) => { - if (item.id !== intent.eventId || !isCalendarAllDay(item)) return item; - const from = parseCalendarDate(item.start); - const to = parseCalendarDate(item.end); - const nextDay = parseCalendarDate(intent.day); - if (from === null || to === null || nextDay === null) return item; - const delta = calendarDaysBetween(from, nextDay); - return { - ...item, - start: formatCalendarDate(from.add({ days: delta })), - end: formatCalendarDate(to.add({ days: delta })), - }; - }); - } - return events.map((item) => { - if (item.id !== intent.eventId) return item; - const start = intent.edge === "start" ? intent.instant : item.start; - const end = intent.edge === "end" ? intent.instant : item.end; - if (start >= end) return item; - return { ...item, start, end }; - }); + const event = events.find((item) => item.id === release.originEventId); + return preview(events, bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), event, release.originEventStart, scope)); } export function previewCalendarTimeGrid( @@ -96,50 +19,8 @@ export function previewCalendarTimeGrid( release: CalendarTimeGridPointerRelease, scope: "this" | "this-and-following" | "all" = "this", ): ReadonlyArray { - const intent = interpretCalendarTimeGridPointer(release); - if (intent === null || intent.type === "selection.set" || intent.type === "selection.clear") return events; - if (intent.type === "event.create") { - return [...events, { - id: "preview", - title: "Event", - start: intent.start, - end: intent.end, - allDay: false, - calendarId: "", - recurrence: null, - excludeDates: [], - }]; - } - const event = events.find((item) => item.id === intent.eventId); - const bound = bindCalendarTimeGridIntent(intent, event, release.originEventStart, scope) ?? intent; - if ( - bound.type === "occurrence.edit" - && event !== undefined - && release.originEventStart !== null - ) { - if (bound.scope === "this-and-following") { - return previewFollowingOccurrences(events, event, release.originEventStart, intent); - } - return previewRecurringOccurrence(events, event, release.originEventStart, intent); - } - if (bound.type === "event.move") { - return events.map((item) => { - if (item.id !== bound.eventId || isCalendarAllDay(item)) return item; - const from = parseCalendarInstant(item.start); - const to = parseCalendarInstant(item.end); - const nextStart = parseCalendarInstant(bound.start); - if (from === null || to === null || nextStart === null) return item; - return { ...item, start: bound.start, end: formatCalendarInstant(nextStart.add({ minutes: calendarMinutesBetween(from, to) })) }; - }); - } - if (bound.type !== "event.resize") return events; - return events.map((item) => { - if (item.id !== bound.eventId) return item; - const start = bound.edge === "start" ? bound.instant : item.start; - const end = bound.edge === "end" ? bound.instant : item.end; - if (start >= end) return item; - return { ...item, start, end }; - }); + const event = events.find((item) => item.id === release.originEventId); + return preview(events, bindCalendarTimeGridIntent(interpretCalendarTimeGridPointer(release), event, release.originEventStart, scope)); } export function previewCalendarMonth( @@ -147,148 +28,16 @@ export function previewCalendarMonth( release: CalendarMonthPointerRelease, scope: "this" | "this-and-following" | "all" = "this", ): ReadonlyArray { - const intent = interpretCalendarMonthPointer(release); - if (intent === null || intent.type === "selection.set" || intent.type === "selection.clear") return events; - if (intent.type === "event.create") { - return [...events, { - id: "preview", - title: "Event", - start: intent.start, - end: intent.end, - allDay: true, - calendarId: "", - recurrence: null, - excludeDates: [], - }]; - } - const event = events.find((item) => item.id === intent.eventId); - const bound = bindCalendarMonthIntent(intent, event, release.originEventStart ?? null, scope) ?? intent; - if (bound.type === "occurrence.edit" && event !== undefined && (release.originEventStart ?? null) !== null) { - const occurrenceStart = release.originEventStart ?? event.start; - const excluded = calendarDatePart(occurrenceStart); - const start = bound.start ?? occurrenceStart; - const end = bound.end ?? event.end; - return [ - ...events.map((item) => item.id === event.id - ? { ...item, excludeDates: [...calendarEventExcludeDates(item), excluded] } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: bound.scope === "this" ? null : event.recurrence, - excludeDates: [], - }, - ]; - } - if (intent.type !== "event.move-day") return events; - return events.map((item) => { - if (item.id !== intent.eventId) return item; - if (isCalendarAllDay(item)) { - const from = parseCalendarDate(item.start); - const to = parseCalendarDate(item.end); - const nextDay = parseCalendarDate(intent.day); - if (from === null || to === null || nextDay === null) return item; - const delta = calendarDaysBetween(from, nextDay); - return { - ...item, - start: formatCalendarDate(from.add({ days: delta })), - end: formatCalendarDate(to.add({ days: delta })), - }; - } - const from = parseCalendarInstant(item.start); - const to = parseCalendarInstant(item.end); - const currentDay = parseCalendarInstant(`${calendarDatePart(item.start)}T00:00`); - const nextDay = parseCalendarInstant(`${intent.day}T00:00`); - if (from === null || to === null || currentDay === null || nextDay === null) return item; - const delta = calendarMinutesBetween(currentDay, nextDay); - return { - ...item, - start: formatCalendarInstant(from.add({ minutes: delta })), - end: formatCalendarInstant(to.add({ minutes: delta })), - }; - }); -} - -function previewFollowingOccurrences( - events: ReadonlyArray, - event: CalendarEvent, - occurrenceStart: string, - intent: Extract, -): ReadonlyArray { - const recurrence = calendarEventRecurrence(event); - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occStart = parseCalendarInstant(occurrenceStart); - if (recurrence === null || from === null || to === null || occStart === null) return events; - const duration = calendarMinutesBetween(from, to); - let start = occurrenceStart; - let end = formatCalendarInstant(occStart.add({ minutes: duration })); - if (intent.type === "event.move") { - const nextStart = parseCalendarInstant(intent.start); - if (nextStart === null) return events; - start = intent.start; - end = formatCalendarInstant(nextStart.add({ minutes: duration })); - } else if (intent.edge === "start") { - start = intent.instant; - } else { - end = intent.instant; - } - if (start >= end) return events; - const until = addCalendarDate(calendarDatePart(occurrenceStart), -1); - if (until === null) return events; - return [ - ...events.map((item) => item.id === event.id - ? { ...item, recurrence: { ...recurrence, until } } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: { ...recurrence, until: "" }, - excludeDates: [], - }, - ]; + const event = events.find((item) => item.id === release.originEventId); + return preview(events, bindCalendarMonthIntent(interpretCalendarMonthPointer(release), event, release.originEventStart ?? null, scope)); } -function previewRecurringOccurrence( - events: ReadonlyArray, - event: CalendarEvent, - occurrenceStart: string, - intent: Extract, -): ReadonlyArray { - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occStart = parseCalendarInstant(occurrenceStart); - if (from === null || to === null || occStart === null) return events; - const duration = calendarMinutesBetween(from, to); - let start = occurrenceStart; - let end = formatCalendarInstant(occStart.add({ minutes: duration })); - if (intent.type === "event.move") { - const nextStart = parseCalendarInstant(intent.start); - if (nextStart === null) return events; - start = intent.start; - end = formatCalendarInstant(nextStart.add({ minutes: duration })); - } else if (intent.edge === "start") { - start = intent.instant; - } else { - end = intent.instant; - } - if (start >= end) return events; - const excluded = calendarDatePart(occurrenceStart); - return [ - ...events.map((item) => item.id === event.id - ? { ...item, excludeDates: [...calendarEventExcludeDates(item), excluded] } - : item), - { - ...event, - id: "preview", - start, - end, - recurrence: null, - excludeDates: [], - }, - ]; +function preview(events: ReadonlyArray, intent: CalendarIntent | null): ReadonlyArray { + if (intent === null || !(intent.type === "event.create" || intent.type === "event.update" || intent.type === "event.move" + || intent.type === "event.move-day" || intent.type === "event.resize" || intent.type === "occurrence.edit")) return events; + let sequence = 0; + const plan = planCalendarEventEdit(events, intent, { + allocateId: createEditingIdAllocator(events.map((event) => event.id), () => sequence++ === 0 ? "preview" : `preview-${sequence}`, "calendar preview"), + }); + return plan.ok ? plan.events : events; } diff --git a/packages/json-document-editing/src/calendar-selection-move.ts b/packages/json-document-editing/src/calendar-selection-move.ts index 00bfb5ff2..35d40b006 100644 --- a/packages/json-document-editing/src/calendar-selection-move.ts +++ b/packages/json-document-editing/src/calendar-selection-move.ts @@ -1,4 +1,6 @@ -import { calendarEventExcludeDates, calendarEventRecurrence } from "./calendar-occurrence.js"; +import { calendarEventRecurrence, resolveCalendarOccurrence } from "./calendar-occurrence.js"; +import { planCalendarEventEdit } from "./calendar-event-plan.js"; +import { createEditingIdAllocator } from "./identity.js"; import type { CalendarEvent, CalendarOccurrencePoint, @@ -29,7 +31,7 @@ export type CalendarSelectionMovePlan = } | { readonly ok: false; readonly code: string }; -/** Plans one atomic temporal translation for a materialized occurrence selection. */ +/** Plans one atomic temporal translation; captured intervals are preconditions, not current truth. */ export function planCalendarSelectionMove( events: ReadonlyArray, occurrences: ReadonlyArray, @@ -41,75 +43,61 @@ export function planCalendarSelectionMove( readonly primary?: CalendarOccurrencePoint; } = {}, ): CalendarSelectionMovePlan { - const anchorOccurrence = occurrences.find((item) => ( - item.eventId === anchor.eventId && item.start === anchor.occurrenceStart - )); - if (anchorOccurrence === undefined || occurrences.length === 0) { - return { ok: false, code: "selection.drag-source-not-found" }; + const matches = (item: CalendarOccurrenceSelection, point: CalendarOccurrencePoint) => + item.eventId === point.eventId && item.start === point.occurrenceStart; + const anchorOccurrence = occurrences.find((item) => matches(item, anchor)); + const primaryIndex = occurrences.findIndex((item) => matches(item, options.primary ?? anchor)); + if (anchorOccurrence === undefined || primaryIndex < 0) return { ok: false, code: "selection.drag-source-not-found" }; + const scope = options.scope ?? "this"; + if (scope !== "this" && scope !== "this-and-following" && scope !== "all") return { ok: false, code: "occurrence.invalid-scope" }; + const seen = new Set(); + for (const occurrence of occurrences) { + const key = JSON.stringify([occurrence.eventId, occurrence.start]); + if (seen.has(key)) return { ok: false, code: "selection.duplicate-occurrence" }; + seen.add(key); + const current = resolveCalendarOccurrence(events, { eventId: occurrence.eventId, occurrenceStart: occurrence.start }); + if (current === null || current.end !== occurrence.end) return { ok: false, code: "selection.stale-occurrence" }; } const delta = resolveDelta(anchorOccurrence.start, target); if (delta === null) return { ok: false, code: "selection.invalid-drop-target" }; - if (target.type === "instant" && occurrences.some((item) => { - const event = events.find((candidate) => candidate.id === item.eventId); - return event === undefined || isCalendarAllDay(event); - })) return { ok: false, code: "selection.incompatible-drop-target" }; + if (target.type === "instant" && occurrences.some((item) => ( + isCalendarAllDay(events.find((event) => event.id === item.eventId)!) + ))) return { ok: false, code: "selection.incompatible-drop-target" }; - const next = [...events]; + let next = events; + let sequence = 0; + const allocateId = createEditingIdAllocator(events.map((event) => event.id), options.createId ?? (() => `preview-${++sequence}`), "calendar event"); const moved: CalendarOccurrenceSelection[] = []; - const exclusions = new Map>(); - const scope = options.scope ?? "this"; - const createId = options.createId ?? (() => `preview-${moved.length + 1}`); - const seenSeries = new Set(); - - for (const occurrence of occurrences) { - const index = events.findIndex((event) => event.id === occurrence.eventId); - const event = events[index]; - if (event === undefined) return { ok: false, code: "selection.event-not-found" }; + const seriesIds = new Map(); + // Following starts at the earliest selected occurrence, independent of selection/primary order. + const ordered = occurrences.map((occurrence, index) => ({ occurrence, index })) + .sort((left, right) => left.occurrence.start.localeCompare(right.occurrence.start)); + for (const { occurrence, index } of ordered) { + const event = events.find((item) => item.id === occurrence.eventId)!; const shifted = shiftInterval(occurrence.start, occurrence.end, delta); if (shifted === null) return { ok: false, code: "event.invalid-instant" }; - const recurrence = calendarEventRecurrence(event); - if (recurrence === null) { - if (seenSeries.has(event.id)) return { ok: false, code: "selection.duplicate-event" }; - seenSeries.add(event.id); - next[index] = { ...event, start: shifted.start, end: shifted.end }; - moved.push({ eventId: event.id, ...shifted }); - continue; + const recurring = calendarEventRecurrence(event) !== null; + let eventId = recurring && scope !== "this" ? seriesIds.get(event.id) : undefined; + if (eventId === undefined) { + const plan = planCalendarEventEdit(next, recurring ? { + type: "occurrence.edit", eventId: event.id, occurrenceStart: occurrence.start, scope, ...shifted, + } : { type: "event.update", eventId: event.id, ...shifted }, { allocateId }); + if (!plan.ok) return plan; + next = plan.events; + eventId = plan.selected.eventId; + if (recurring && scope !== "this") seriesIds.set(event.id, eventId); } - if (scope !== "this") return { ok: false, code: "selection.recurring-group-scope-unsupported" }; - const dates = exclusions.get(event.id) ?? new Set(calendarEventExcludeDates(event)); - dates.add(calendarDatePart(occurrence.start)); - exclusions.set(event.id, dates); - const detached: CalendarEvent = { - ...event, - id: uniqueId(next, createId), - start: shifted.start, - end: shifted.end, - recurrence: null, - excludeDates: [], - }; - next.push(detached); - moved.push({ eventId: detached.id, ...shifted }); - } - for (const [eventId, dates] of exclusions) { - const index = next.findIndex((event) => event.id === eventId); - next[index] = { ...next[index]!, excludeDates: [...dates] }; + moved[index] = { eventId, ...shifted }; } - const points = moved.map((item): CalendarOccurrencePoint => ({ - eventId: item.eventId, - occurrenceStart: item.start, - })); - const primary = options.primary ?? anchor; - const primaryIndex = occurrences.findIndex((item) => ( - item.eventId === primary.eventId && item.start === primary.occurrenceStart - )); + // A monthly/yearly re-anchor may not represent every translated point: fail atomically. + if (moved.some((item) => resolveCalendarOccurrence(next, { + eventId: item.eventId, occurrenceStart: item.start, + })?.end !== item.end)) return { ok: false, code: "selection.unrepresentable-series-move" }; + const points = moved.map((item): CalendarOccurrencePoint => ({ eventId: item.eventId, occurrenceStart: item.start })); return { - ok: true, - events: next, - movedOccurrences: moved, + ok: true, events: next, movedOccurrences: moved, selectionAfter: { - kind: "range", - ranges: points.map((point) => ({ anchor: point, focus: point, points: [point] })), - primaryIndex: Math.max(0, primaryIndex), + kind: "range", ranges: points.map((point) => ({ anchor: point, focus: point, points: [point] })), primaryIndex, }, }; } @@ -146,9 +134,3 @@ function shiftInterval(start: string, end: string, delta: Delta): { start: strin end: formatCalendarInstant(to.add({ minutes })), }; } - -function uniqueId(events: ReadonlyArray, createId: () => string): string { - let id = createId(); - while (events.some((event) => event.id === id)) id = createId(); - return id; -} diff --git a/packages/json-document-editing/src/calendar-time-grid-pointer.ts b/packages/json-document-editing/src/calendar-time-grid-pointer.ts index ae9e3321c..a91942a0b 100644 --- a/packages/json-document-editing/src/calendar-time-grid-pointer.ts +++ b/packages/json-document-editing/src/calendar-time-grid-pointer.ts @@ -1,5 +1,5 @@ import type { CalendarEvent, CalendarIntent } from "./calendar.js"; -import { calendarEventRecurrence } from "./calendar-occurrence.js"; +import { calendarEventRecurrence, resolveCalendarOccurrence } from "./calendar-occurrence.js"; import { calendarDatePart, calendarMinutesBetween, calendarShiftInstant, parseCalendarInstant } from "./calendar-validation.js"; export type CalendarTimeGridHandle = "body" | "start" | "end"; @@ -64,7 +64,6 @@ export function bindCalendarTimeGridIntent( if (intent.type !== "event.move" && intent.type !== "event.resize") return intent; if (event === undefined || calendarEventRecurrence(event) === null) return intent; const start = occurrenceStart ?? event.start; - if (scope === "all") return bindRecurringSeriesTimeGridIntent(intent, event, start); if (intent.type === "event.move") { return { type: "occurrence.edit", @@ -75,11 +74,7 @@ export function bindCalendarTimeGridIntent( }; } if (intent.edge === "start") { - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occurrenceEnd = from === null || to === null - ? null - : calendarShiftInstant(start, calendarMinutesBetween(from, to)); + const occurrenceEnd = resolveCalendarOccurrence([event], { eventId: event.id, occurrenceStart: start })?.end ?? null; return { type: "occurrence.edit", eventId: intent.eventId, @@ -98,35 +93,6 @@ export function bindCalendarTimeGridIntent( }; } -function bindRecurringSeriesTimeGridIntent( - intent: Extract, - event: CalendarEvent, - occurrenceStart: string, -): CalendarIntent { - if (intent.type === "event.move") { - const origin = parseCalendarInstant(occurrenceStart); - const next = parseCalendarInstant(intent.start); - if (origin === null || next === null) return intent; - const start = calendarShiftInstant(event.start, calendarMinutesBetween(origin, next)); - if (start === null) return intent; - return { type: "event.move", eventId: intent.eventId, start }; - } - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const occStart = parseCalendarInstant(occurrenceStart); - const instant = parseCalendarInstant(intent.instant); - if (from === null || to === null || occStart === null || instant === null) return intent; - if (intent.edge === "start") { - const start = calendarShiftInstant(event.start, calendarMinutesBetween(occStart, instant)); - if (start === null) return intent; - return { type: "event.resize", eventId: intent.eventId, edge: "start", instant: start }; - } - const occurrenceEnd = occStart.add({ minutes: calendarMinutesBetween(from, to) }); - const end = calendarShiftInstant(event.end, calendarMinutesBetween(occurrenceEnd, instant)); - if (end === null) return intent; - return { type: "event.resize", eventId: intent.eventId, edge: "end", instant: end }; -} - function timePart(instant: string): string | null { if (parseCalendarInstant(instant) === null) return null; return instant.slice(11); diff --git a/packages/json-document-editing/src/calendar-validation.ts b/packages/json-document-editing/src/calendar-validation.ts index 4c9107154..3d0da3a44 100644 --- a/packages/json-document-editing/src/calendar-validation.ts +++ b/packages/json-document-editing/src/calendar-validation.ts @@ -1,5 +1,5 @@ import { Temporal } from "@js-temporal/polyfill"; -import type { CalendarCalendar, CalendarDocument, CalendarEvent, CalendarView } from "./calendar.js"; +import type { CalendarCalendar, CalendarDocument, CalendarEvent, CalendarRecurrence, CalendarView } from "./calendar.js"; const DATE = /^(\d{4})-(\d{2})-(\d{2})$/; const DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/; @@ -22,6 +22,7 @@ export function calendarDocumentEvents(document: CalendarDocument): ReadonlyArra } export function assertCalendarDocument(document: CalendarDocument): void { + if (!Array.isArray(document.events)) throw new TypeError("Calendar events must be an array."); const calendarIds = new Set(); for (const calendar of calendarDocumentCalendars(document)) { if (calendar.id.length === 0) throw new Error("Calendar ids must not be empty."); @@ -33,24 +34,64 @@ export function assertCalendarDocument(document: CalendarDocument): void { } const ids = new Set(); for (const event of calendarDocumentEvents(document)) { - if (event.id.length === 0) throw new Error("Calendar event ids must not be empty."); + const result = validateCalendarEvent(event, calendarIds); + if (!result.ok) throw new TypeError(result.reason); if (ids.has(event.id)) throw new Error(`Calendar event id must be unique: ${JSON.stringify(event.id)}.`); - const calendarId = typeof event.calendarId === "string" ? event.calendarId : ""; - if (calendarId.length > 0 && calendarIds.size > 0 && !calendarIds.has(calendarId)) { - throw new Error(`Calendar event must belong to a calendar: ${JSON.stringify(event.id)}.`); - } - if (isCalendarAllDay(event)) { - if (parseCalendarDate(event.start) === null || parseCalendarDate(event.end) === null) { - throw new Error(`All-day calendar events must use date strings: ${JSON.stringify(event.id)}.`); - } - } else if (parseCalendarInstant(event.start) === null || parseCalendarInstant(event.end) === null) { - throw new Error(`Calendar event times must be datetime-local strings: ${JSON.stringify(event.id)}.`); - } - if (event.start >= event.end) throw new Error(`Calendar event must end after it starts: ${JSON.stringify(event.id)}.`); ids.add(event.id); } } +type CalendarValidationResult = { readonly ok: true } | { + readonly ok: false; readonly code: string; readonly reason: string; +}; + +/** One domain invariant shared by construction, edit planning and clipboard ingress. */ +export function validateCalendarEvent(value: unknown, calendarIds?: ReadonlySet): CalendarValidationResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { ok: false, code: "event.invalid", reason: "Calendar events must be objects." }; + } + const event = value as Record; + if (typeof event.id !== "string" || event.id.length === 0 || typeof event.title !== "string") { + return { ok: false, code: "event.invalid", reason: "Calendar events require a nonempty id and a string title." }; + } + if (typeof event.start !== "string" || typeof event.end !== "string" + || (event.allDay !== undefined && typeof event.allDay !== "boolean")) { + return { ok: false, code: "event.invalid-instant", reason: "Calendar events require canonical temporal values." }; + } + const parse = event.allDay === true ? parseCalendarDate : parseCalendarInstant; + if (parse(event.start) === null || parse(event.end) === null) { + return { ok: false, code: "event.invalid-instant", reason: event.allDay === true + ? `All-day calendar events must use date strings: ${JSON.stringify(event.id)}.` + : `Calendar event times must be datetime-local strings: ${JSON.stringify(event.id)}.` }; + } + if (event.start >= event.end) { + return { ok: false, code: "event.invalid-interval", reason: `Calendar event must end after it starts: ${JSON.stringify(event.id)}.` }; + } + if (event.calendarId !== undefined && typeof event.calendarId !== "string") { + return { ok: false, code: "calendar.not-found", reason: "Calendar references must be strings." }; + } + if (typeof event.calendarId === "string" && event.calendarId.length > 0 + && calendarIds !== undefined && calendarIds.size > 0 && !calendarIds.has(event.calendarId)) { + return { ok: false, code: "calendar.not-found", reason: `Calendar event must belong to a calendar: ${JSON.stringify(event.id)}.` }; + } + if (event.recurrence != null && !isCalendarRecurrence(event.recurrence)) { + return { ok: false, code: "event.invalid-recurrence", reason: "Calendar recurrence requires a supported frequency, positive safe integer interval and canonical until date." }; + } + if (event.excludeDates !== undefined && (!Array.isArray(event.excludeDates) + || !event.excludeDates.every((date) => typeof date === "string" && parseCalendarDate(date) !== null))) { + return { ok: false, code: "event.invalid-exclusions", reason: "Calendar exclusions must be canonical dates." }; + } + return { ok: true }; +} + +export function isCalendarRecurrence(value: unknown): value is CalendarRecurrence { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const rule = value as Record; + return (rule.freq === "daily" || rule.freq === "weekly" || rule.freq === "monthly" || rule.freq === "yearly") + && typeof rule.interval === "number" && Number.isSafeInteger(rule.interval) && rule.interval >= 1 + && typeof rule.until === "string" && (rule.until === "" || parseCalendarDate(rule.until) !== null); +} + export function isCalendarAllDay(event: Pick): boolean { return event.allDay === true; } @@ -145,3 +186,15 @@ export function calendarDaysBetween(from: Temporal.PlainDate, to: Temporal.Plain export function calendarMinutesBetween(from: Temporal.PlainDateTime, to: Temporal.PlainDateTime): number { return from.until(to, { largestUnit: "minutes" }).total("minutes"); } + +/** Move a Calendar interval without changing its local duration. */ +export function calendarEventIntervalAt( + event: Pick, + start: string, +): { readonly start: string; readonly end: string } | null { + const bounds = calendarEventBounds(event); + const next = isCalendarAllDay(event) ? parseCalendarDate(start)?.toPlainDateTime() : parseCalendarInstant(start); + if (bounds === null || next == null) return null; + const end = next.add({ minutes: calendarMinutesBetween(bounds.from, bounds.to) }); + return { start, end: isCalendarAllDay(event) ? formatCalendarDate(end.toPlainDate()) : formatCalendarInstant(end) }; +} diff --git a/packages/json-document-editing/src/calendar.ts b/packages/json-document-editing/src/calendar.ts index ae2385771..722ece770 100644 --- a/packages/json-document-editing/src/calendar.ts +++ b/packages/json-document-editing/src/calendar.ts @@ -23,7 +23,6 @@ import type { EditingHistoryOptions } from "./history.js"; import { addCalendarDate, assertCalendarDocument, - calendarAllDaySpan, calendarDatePart, calendarDaysBetween, calendarDocumentCalendars, @@ -36,8 +35,10 @@ import { isCalendarAllDay, parseCalendarDate, parseCalendarInstant, + validateCalendarEvent, } from "./calendar-validation.js"; -import { calendarEventExcludeDates, calendarEventRecurrence, projectCalendarOccurrences } from "./calendar-occurrence.js"; +import { calendarEventExcludeDates, calendarEventRecurrence, projectCalendarOccurrences, resolveCalendarOccurrence } from "./calendar-occurrence.js"; +import { planCalendarEventEdit } from "./calendar-event-plan.js"; import { planCalendarSelectionMove, type CalendarSelectionMoveTarget, @@ -126,16 +127,13 @@ export const calendarClipboardFormat = { if (!Array.isArray(value.items) || value.items.length === 0) return null; if (!value.items.every((item) => ( isRecord(item) - && typeof item.sourceEventId === "string" - && typeof item.occurrenceStart === "string" + && typeof item.sourceEventId === "string" && item.sourceEventId.length > 0 && isCalendarClipboardEvent(item.event) + && item.occurrenceStart === item.event.start ))) return null; - return { - ...value, - anchorOccurrenceStart: typeof value.anchorOccurrenceStart === "string" - ? value.anchorOccurrenceStart - : (value.items[0] as CalendarClipboardItem).occurrenceStart, - } as CalendarClipboard; + const anchorOccurrenceStart = value.anchorOccurrenceStart ?? (value.items[0] as CalendarClipboardItem).occurrenceStart; + if (!value.items.some((item: CalendarClipboardItem) => item.occurrenceStart === anchorOccurrenceStart)) return null; + return { ...value, anchorOccurrenceStart } as CalendarClipboard; }, }; @@ -206,8 +204,8 @@ export interface CalendarEditor { ): CalendarSelectionDragSource | null; dispatch(intent: CalendarIntent): EditingResult; copy(occurrences?: ReadonlyArray): CalendarClipboard | null; - cut(occurrences?: ReadonlyArray): EditingClipboardCut> | null; - paste(clipboard: CalendarClipboard, target?: string): EditingResult; + cut(source?: ReadonlyArray | CalendarClipboard): EditingClipboardCut> | null; + paste(clipboard: CalendarClipboard, target?: string, options?: { readonly calendarId?: string }): EditingResult; undo(): EditingResult; redo(): EditingResult; subscribe(listener: (snapshot: EditingSnapshot) => void): () => void; @@ -312,6 +310,10 @@ export function createCalendarEditor( if (intent.type === "selection.clear") return success(session.select(emptyCalendarSelection())); if (intent.type === "selection.move") { + if (intent.source.points.length !== intent.source.occurrences.length || intent.source.points.some((point, index) => { + const occurrence = intent.source.occurrences[index]; + return occurrence === undefined || point.eventId !== occurrence.eventId || point.occurrenceStart !== occurrence.start; + })) return failure("selection.invalid-drag-source"); const plan = planCalendarSelectionMove( value().events, intent.source.occurrences, @@ -327,50 +329,22 @@ export function createCalendarEditor( }); } - if (intent.type === "event.create") { - if (intent.start >= intent.end) return failure("event.invalid-interval"); - const allDay = intent.allDay === true; - const start = allDay ? parseCalendarDate(intent.start) : parseCalendarInstant(intent.start); - const end = allDay ? parseCalendarDate(intent.end) : parseCalendarInstant(intent.end); - if (start === null || end === null) return failure("event.invalid-instant"); - const events = value().events; - const event: CalendarEvent = { - id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), - title: intent.title ?? "Event", - start: intent.start, - end: intent.end, - allDay, - calendarId: intent.calendarId ?? calendarDocumentCalendars(value())[0]?.id ?? "", - recurrence: intent.recurrence ?? null, - excludeDates: [], - }; + if (intent.type === "event.create" || intent.type === "event.move" || intent.type === "event.resize" + || intent.type === "event.move-day" || intent.type === "event.update" || intent.type === "occurrence.edit") { + const current = value(); + const plan = planCalendarEventEdit(current.events, intent, { + allocateId: createEditingIdAllocator(current.events.map((event) => event.id), createId, "calendar event"), + calendarIds: new Set(calendarDocumentCalendars(current).map((calendar) => calendar.id)), + defaultCalendarId: calendarDocumentCalendars(current)[0]?.id ?? "", + }); + if (!plan.ok) return plan; return session.apply({ - operations: [{ op: "add", path: `/events/${events.length}`, value: event }], - selectionAfter: selectionForEvents([...events, event], [event.id]), + operations: plan.operations, + selectionAfter: selectionForOccurrence(plan.selected.eventId, plan.selected.occurrenceStart), origin: intent.type, }); } - if (intent.type === "event.move") { - return moveEvent(intent.eventId, intent.start); - } - - if (intent.type === "event.resize") { - return resizeEvent(intent.eventId, intent.edge, intent.instant); - } - - if (intent.type === "event.move-day") { - return moveEventDay(intent.eventId, intent.day); - } - - if (intent.type === "event.update") { - return updateEvent(intent); - } - - if (intent.type === "occurrence.edit") { - return editOccurrence(intent); - } - if (intent.type === "occurrence.remove") { return removeOccurrence(intent); } @@ -379,212 +353,12 @@ export function createCalendarEditor( return setCalendarHidden(intent.calendarId, intent.hidden); } + if (intent.type !== "selection.remove") return failure("intent.unsupported"); const selected = selectedEvents(); if (selected.length === 0) return failure("selection.empty"); return removeSelected(selected.map((event) => event.id)); } - function moveEvent(eventId: string, start: string): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - if (isCalendarAllDay(event)) return failure("event.all-day-move"); - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const nextStart = parseCalendarInstant(start); - if (from === null || to === null || nextStart === null) return failure("event.invalid-instant"); - const nextEnd = formatCalendarInstant(nextStart.add({ minutes: calendarMinutesBetween(from, to) })); - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "start"]), value: start }, - { op: "replace", path: buildPointer(["events", index, "end"]), value: nextEnd }, - ], - selectionAfter: selectionForOccurrence(eventId, start), - origin: "event.move", - }); - } - - function resizeEvent( - eventId: string, - edge: "start" | "end", - instant: string, - ): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - const parsed = isCalendarAllDay(event) ? parseCalendarDate(instant) : parseCalendarInstant(instant); - if (parsed === null) return failure("event.invalid-instant"); - const start = edge === "start" ? instant : event.start; - const end = edge === "end" ? instant : event.end; - if (start >= end) return failure("event.invalid-interval"); - return session.apply({ - operations: [{ op: "replace", path: buildPointer(["events", index, edge]), value: instant }], - selectionAfter: selectionForOccurrence(eventId, start), - origin: "event.resize", - }); - } - - function moveEventDay(eventId: string, day: string): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - if (parseCalendarDate(day) === null) return failure("event.invalid-day"); - if (isCalendarAllDay(event)) { - const from = parseCalendarDate(event.start); - const to = parseCalendarDate(event.end); - const nextDay = parseCalendarDate(day); - if (from === null || to === null || nextDay === null) return failure("event.invalid-instant"); - const delta = calendarDaysBetween(from, nextDay); - const movedStart = formatCalendarDate(from.add({ days: delta })); - const movedEnd = formatCalendarDate(to.add({ days: delta })); - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "start"]), value: movedStart }, - { op: "replace", path: buildPointer(["events", index, "end"]), value: movedEnd }, - ], - selectionAfter: selectionForOccurrence(eventId, movedStart), - origin: "event.move-day", - }); - } - const from = parseCalendarInstant(event.start); - const to = parseCalendarInstant(event.end); - const currentDay = parseCalendarInstant(`${calendarDatePart(event.start)}T00:00`); - const nextDay = parseCalendarInstant(`${day}T00:00`); - if (from === null || to === null || currentDay === null || nextDay === null) return failure("event.invalid-instant"); - const delta = calendarMinutesBetween(currentDay, nextDay); - const movedStart = formatCalendarInstant(from.add({ minutes: delta })); - const movedEnd = formatCalendarInstant(to.add({ minutes: delta })); - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "start"]), value: movedStart }, - { op: "replace", path: buildPointer(["events", index, "end"]), value: movedEnd }, - ], - selectionAfter: selectionForOccurrence(eventId, movedStart), - origin: "event.move-day", - }); - } - - function updateEvent(intent: Extract): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === intent.eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - let start = intent.start ?? event.start; - let end = intent.end ?? event.end; - const allDay = intent.allDay ?? event.allDay; - if (intent.allDay === true && !event.allDay) { - start = calendarDatePart(event.start); - end = calendarAllDaySpan(start, start)?.end ?? start; - } else if (intent.allDay === false && event.allDay) { - start = `${calendarDatePart(event.start)}T09:00`; - end = `${calendarDatePart(event.start)}T10:00`; - } else if (intent.start !== undefined && intent.end === undefined) { - const times = shiftedOccurrenceTimes(event, event.start, intent.start, undefined); - if (times === null) return failure("event.invalid-interval"); - start = times.start; - end = times.end; - } - if (start >= end) return failure("event.invalid-interval"); - const next: CalendarEvent = { - ...event, - title: intent.title ?? event.title, - start, - end, - allDay, - calendarId: intent.calendarId ?? event.calendarId, - recurrence: intent.recurrence === undefined ? event.recurrence : intent.recurrence, - }; - if (isCalendarAllDay(next) ? parseCalendarDate(next.start) === null : parseCalendarInstant(next.start) === null) { - return failure("event.invalid-instant"); - } - return session.apply({ - operations: [{ op: "replace", path: buildPointer(["events", index]), value: next }], - selectionAfter: selectionForOccurrence(event.id, next.start), - origin: intent.type, - }); - } - - function editOccurrence(intent: Extract): EditingResult { - const events = value().events; - const index = events.findIndex((event) => event.id === intent.eventId); - const event = events[index]; - if (!event) return failure("selection.event-not-found"); - const recurrence = calendarEventRecurrence(event); - if (recurrence === null) { - return updateEvent({ - type: "event.update", - eventId: intent.eventId, - ...(intent.title === undefined ? {} : { title: intent.title }), - ...(intent.start === undefined ? {} : { start: intent.start }), - ...(intent.end === undefined ? {} : { end: intent.end }), - }); - } - if (intent.scope === "all") { - const start = intent.start === undefined - ? undefined - : shiftSeriesValue(event.start, intent.occurrenceStart, intent.start); - const end = intent.end === undefined - ? undefined - : shiftSeriesValue(event.end, occurrenceEndOf(event, intent.occurrenceStart), intent.end); - if (intent.start !== undefined && start === undefined) return failure("event.invalid-instant"); - if (intent.end !== undefined && end === undefined) return failure("event.invalid-instant"); - return updateEvent({ - type: "event.update", - eventId: intent.eventId, - ...(intent.title === undefined ? {} : { title: intent.title }), - ...(start === undefined ? {} : { start }), - ...(end === undefined ? {} : { end }), - }); - } - const times = shiftedOccurrenceTimes(event, intent.occurrenceStart, intent.start, intent.end); - if (times === null) return failure("event.invalid-interval"); - const occurrenceDate = calendarDatePart(intent.occurrenceStart); - if (intent.scope === "this") { - const split: CalendarEvent = { - ...event, - id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), - title: intent.title ?? event.title, - start: times.start, - end: times.end, - recurrence: null, - excludeDates: [], - }; - return session.apply({ - operations: [ - { - op: "replace", - path: buildPointer(["events", index, "excludeDates"]), - value: [...calendarEventExcludeDates(event), occurrenceDate], - }, - { op: "add", path: `/events/${events.length}`, value: split }, - ], - selectionAfter: selectionForEvents([...events, split], [split.id]), - origin: intent.type, - }); - } - const until = addCalendarDate(occurrenceDate, -1) ?? occurrenceDate; - const following: CalendarEvent = { - ...event, - id: createEditingIdAllocator(events.map((event) => event.id), createId, "calendar event")(), - title: intent.title ?? event.title, - start: times.start, - end: times.end, - recurrence: { ...recurrence, until: "" }, - excludeDates: [], - }; - return session.apply({ - operations: [ - { op: "replace", path: buildPointer(["events", index, "recurrence"]), value: { ...recurrence, until } }, - { op: "add", path: `/events/${events.length}`, value: following }, - ], - selectionAfter: selectionForEvents([...events, following], [following.id]), - origin: intent.type, - }); - } - function removeOccurrence( intent: Extract, ): EditingResult { @@ -592,6 +366,8 @@ export function createCalendarEditor( const index = events.findIndex((event) => event.id === intent.eventId); const event = events[index]; if (!event) return failure("selection.event-not-found"); + if (intent.scope !== "this" && intent.scope !== "this-and-following" && intent.scope !== "all") return failure("occurrence.invalid-scope"); + if (resolveCalendarOccurrence(events, { eventId: event.id, occurrenceStart: intent.occurrenceStart }) === null) return failure("selection.stale-occurrence"); const recurrence = calendarEventRecurrence(event); if (recurrence === null || intent.scope === "all") { return removeSelected([event.id]); @@ -600,7 +376,7 @@ export function createCalendarEditor( if (intent.scope === "this") { return session.apply({ operations: [{ - op: "replace", + op: "add", path: buildPointer(["events", index, "excludeDates"]), value: [...calendarEventExcludeDates(event), occurrenceDate], }], @@ -624,6 +400,7 @@ export function createCalendarEditor( } function setCalendarHidden(calendarId: string, hidden: boolean): EditingResult { + if (typeof hidden !== "boolean") return failure("calendar.invalid-hidden"); const calendars = calendarDocumentCalendars(value()); const index = calendars.findIndex((item) => item.id === calendarId); if (index < 0) return failure("calendar.not-found"); @@ -655,7 +432,8 @@ export function createCalendarEditor( const source = occurrences ?? selectedOccurrences(); const items = source.flatMap((occurrence): CalendarClipboardItem[] => { const event = value().events.find((candidate) => candidate.id === occurrence.eventId); - if (event === undefined || occurrence.start >= occurrence.end) return []; + const current = resolveCalendarOccurrence(value().events, { eventId: occurrence.eventId, occurrenceStart: occurrence.start }); + if (event === undefined || current === null || current.end !== occurrence.end) return []; return [{ sourceEventId: event.id, occurrenceStart: occurrence.start, @@ -663,12 +441,14 @@ export function createCalendarEditor( ...event, start: occurrence.start, end: occurrence.end, + allDay: isCalendarAllDay(event), + calendarId: event.calendarId ?? "", recurrence: null, excludeDates: [], }, }]; }); - if (items.length === 0) return null; + if (items.length === 0 || items.length !== source.length) return null; return { type: "application/vnd.interactive-os.calendar+json", anchorOccurrenceStart: items[0]!.occurrenceStart, @@ -704,9 +484,19 @@ export function createCalendarEditor( const events = value().events; const removals = new Set(); const exclusions = new Map>(); + const operations: JSONPatchOperation[] = []; for (const item of clipboard.items) { const event = events.find((candidate) => candidate.id === item.sourceEventId); if (event === undefined) return failure("selection.event-not-found"); + const current = resolveCalendarOccurrence(events, { eventId: event.id, occurrenceStart: item.occurrenceStart }); + if (current === null || current.end !== item.event.end) return failure("selection.stale-occurrence"); + const expected: Record = { ...item.event, start: event.start, end: event.end }; + // Clipboard events are materialized; keep the source's legacy optional-field shape. + for (const field of ["allDay", "calendarId", "recurrence", "excludeDates"] as const) { + if (event[field] === undefined) delete expected[field]; + else if (field === "recurrence" || field === "excludeDates") expected[field] = event[field]; + } + operations.push({ op: "test", path: buildPointer(["events", events.indexOf(event)]), value: expected }); if (calendarEventRecurrence(event) === null) removals.add(event.id); else { const dates = exclusions.get(event.id) ?? new Set(calendarEventExcludeDates(event)); @@ -714,10 +504,9 @@ export function createCalendarEditor( exclusions.set(event.id, dates); } } - const operations: JSONPatchOperation[] = []; for (const [eventId, dates] of exclusions) { const index = events.findIndex((event) => event.id === eventId); - operations.push({ op: "replace", path: buildPointer(["events", index, "excludeDates"]), value: [...dates] }); + operations.push({ op: "add", path: buildPointer(["events", index, "excludeDates"]), value: [...dates] }); } for (const index of events.map((event, index) => removals.has(event.id) ? index : -1).filter((index) => index >= 0).sort((a, b) => b - a)) { operations.push({ op: "remove", path: buildPointer(["events", index]) }); @@ -725,13 +514,17 @@ export function createCalendarEditor( return session.apply({ operations, selectionAfter: emptyCalendarSelection(), origin: "clipboard.cut" }); } - function paste(clipboard: CalendarClipboard, target?: string): EditingResult { - if (clipboard.items.length === 0) return failure("clipboard.empty"); + function paste(clipboard: CalendarClipboard, target?: string, options: { readonly calendarId?: string } = {}): EditingResult { + const parsed = calendarClipboardFormat.parse(clipboard); + if (parsed === null) return failure("clipboard.invalid"); + clipboard = parsed; + const calendarIds = new Set(calendarDocumentCalendars(value()).map((calendar) => calendar.id)); + if (options.calendarId !== undefined && !calendarIds.has(options.calendarId)) return failure("calendar.not-found"); const resolvedTarget = target ?? selectedEvents()[0]?.start; if (resolvedTarget === undefined) return failure("clipboard.invalid-target"); const targetInstant = parseCalendarInstant(resolvedTarget); const targetDate = parseCalendarDate(calendarDatePart(resolvedTarget)); - if (targetInstant === null && targetDate === null) return failure("clipboard.invalid-target"); + if (targetInstant === null && parseCalendarDate(resolvedTarget) === null) return failure("clipboard.invalid-target"); const timedAnchor = parseCalendarInstant(clipboard.anchorOccurrenceStart) ?? clipboard.items.map((item) => parseCalendarInstant(item.event.start)).find((item) => item !== null) ?? null; @@ -763,8 +556,10 @@ export function createCalendarEditor( start = source.allDay ? formatCalendarDate(nextStart) : `${formatCalendarDate(nextStart)}T${source.start.slice(11)}`; end = source.allDay ? formatCalendarDate(nextStart.add({ days: duration })) : `${formatCalendarDate(nextStart.add({ days: duration }))}T${source.end.slice(11)}`; } - const event = { ...source, id: allocateId(), start, end, recurrence: null, excludeDates: [] }; - pasted.push(event); + const event = { ...source, start, end, calendarId: options.calendarId ?? source.calendarId, recurrence: null, excludeDates: [] }; + const validation = validateCalendarEvent(event, calendarIds); + if (!validation.ok) return validation; + pasted.push({ ...event, id: allocateId() }); } return session.apply({ operations: pasted.map((event, offset) => ({ op: "add", path: `/events/${existing.length + offset}`, value: event })), @@ -781,8 +576,8 @@ export function createCalendarEditor( prepareSelectionDrag, dispatch, copy, - cut: (occurrences) => cutEditingClipboard( - () => copy(occurrences), + cut: (source) => cutEditingClipboard( + () => source !== undefined && "type" in source ? calendarClipboardFormat.parse(source) : copy(source), removeClipboard, ), paste, @@ -793,16 +588,9 @@ export function createCalendarEditor( } function isCalendarClipboardEvent(value: unknown): value is CalendarEvent { - return isRecord(value) - && typeof value.id === "string" - && typeof value.title === "string" - && typeof value.start === "string" - && typeof value.end === "string" - && typeof value.allDay === "boolean" - && typeof value.calendarId === "string" - && value.recurrence === null - && Array.isArray(value.excludeDates) - && value.excludeDates.every((date) => typeof date === "string"); + return isRecord(value) && validateCalendarEvent(value).ok + && typeof value.allDay === "boolean" && typeof value.calendarId === "string" + && value.recurrence === null && Array.isArray(value.excludeDates) && value.excludeDates.length === 0; } function isRecord(value: unknown): value is Record { @@ -868,25 +656,6 @@ function compareCalendarOccurrencePoints( || left.eventId.localeCompare(right.eventId); } -function resolveCalendarOccurrence( - events: ReadonlyArray, - point: CalendarOccurrencePoint, -): CalendarOccurrenceSelection | null { - const event = events.find((candidate) => candidate.id === point.eventId); - if (event === undefined) return null; - const day = calendarDatePart(point.occurrenceStart); - const next = addCalendarDate(day, 1); - if (next === null) return null; - const occurrence = projectCalendarOccurrences([event], day, next).find((candidate) => ( - candidate.start === point.occurrenceStart - )); - return occurrence === undefined ? null : { - eventId: event.id, - start: occurrence.start, - end: occurrence.end, - }; -} - export function calendarNowMarker(nowInstant: string, day: string): { readonly minutes: number } | null { if (calendarDatePart(nowInstant) !== day) return null; const start = parseCalendarInstant(`${day}T00:00`); @@ -1156,58 +925,6 @@ export function calendarEventsInMonth( })); } -function occurrenceEndOf(event: CalendarEvent, occurrenceStart: string): string { - const bounds = calendarEventBounds(event); - if (bounds === null) return occurrenceStart; - if (isCalendarAllDay(event)) { - const from = parseCalendarDate(calendarDatePart(occurrenceStart)); - if (from === null) return occurrenceStart; - return formatCalendarDate(from.add({ days: calendarDaysBetween(bounds.from.toPlainDate(), bounds.to.toPlainDate()) })); - } - const from = parseCalendarInstant(occurrenceStart); - if (from === null) return occurrenceStart; - return formatCalendarInstant(from.add({ minutes: calendarMinutesBetween(bounds.from, bounds.to) })); -} - -function shiftSeriesValue(seriesValue: string, origin: string, next: string): string | undefined { - const originInstant = parseCalendarInstant(origin); - const nextInstant = parseCalendarInstant(next); - const seriesInstant = parseCalendarInstant(seriesValue); - if (originInstant !== null && nextInstant !== null && seriesInstant !== null) { - return formatCalendarInstant(seriesInstant.add({ minutes: calendarMinutesBetween(originInstant, nextInstant) })); - } - const originDate = parseCalendarDate(calendarDatePart(origin)); - const nextDate = parseCalendarDate(calendarDatePart(next)); - const seriesDate = parseCalendarDate(calendarDatePart(seriesValue)); - if (originDate === null || nextDate === null || seriesDate === null) return undefined; - return formatCalendarDate(seriesDate.add({ days: calendarDaysBetween(originDate, nextDate) })); -} - -function shiftedOccurrenceTimes( - event: CalendarEvent, - occurrenceStart: string, - start: string | undefined, - end: string | undefined, -): { readonly start: string; readonly end: string } | null { - const nextStart = start ?? occurrenceStart; - if (end !== undefined) return nextStart < end ? { start: nextStart, end } : null; - const bounds = calendarEventBounds(event); - if (bounds === null) return null; - if (isCalendarAllDay(event)) { - const from = parseCalendarDate(calendarDatePart(nextStart)); - if (from === null) return null; - const duration = calendarDaysBetween(bounds.from.toPlainDate(), bounds.to.toPlainDate()); - const nextEnd = formatCalendarDate(from.add({ days: duration })); - const dateStart = calendarDatePart(nextStart); - return dateStart < nextEnd ? { start: dateStart, end: nextEnd } : null; - } - const from = parseCalendarInstant(nextStart); - if (from === null) return null; - const duration = calendarMinutesBetween(bounds.from, bounds.to); - const nextEnd = formatCalendarInstant(from.add({ minutes: duration })); - return nextStart < nextEnd ? { start: nextStart, end: nextEnd } : null; -} - function emptyCalendarSelection(): CalendarSelection { return { kind: "range", ranges: [], primaryIndex: null }; } diff --git a/packages/json-document-editing/tests/calendar-pointer.test.ts b/packages/json-document-editing/tests/calendar-pointer.test.ts index 11adcf75b..8e0a390d8 100644 --- a/packages/json-document-editing/tests/calendar-pointer.test.ts +++ b/packages/json-document-editing/tests/calendar-pointer.test.ts @@ -314,7 +314,7 @@ describe("previewCalendarTimeGrid", () => { expect(preview.find((item) => item.id === "preview")).toMatchObject({ start: "2026-08-04T11:00", end: "2026-08-04T11:30", - recurrence: { freq: "daily", interval: 1, until: "" }, + recurrence: { freq: "daily", interval: 1, until: "2026-08-05" }, }); }); }); @@ -473,9 +473,11 @@ describe("bindCalendarTimeGridIntent", () => { }); const intent = bindCalendarTimeGridIntent(move, series, "2026-08-04T09:00", "all"); expect(intent).toEqual({ - type: "event.move", + type: "occurrence.edit", eventId: "standup", - start: "2026-08-03T11:00", + occurrenceStart: "2026-08-04T09:00", + scope: "all", + start: "2026-08-04T11:00", }); const editor = createCalendarEditor({ calendars: [{ id: "home", title: "Home", hidden: false, color: "subtle" }], diff --git a/packages/json-document-editing/tests/calendar-protocol.test.ts b/packages/json-document-editing/tests/calendar-protocol.test.ts new file mode 100644 index 000000000..3f2911450 --- /dev/null +++ b/packages/json-document-editing/tests/calendar-protocol.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test } from "vitest"; +import { + addCalendarDate, bindCalendarAllDayIntent, bindCalendarMonthIntent, bindCalendarTimeGridIntent, + calendarClipboardFormat, calendarOccurrenceTopology, calendarUpdateIntent, createCalendarEditor, + interpretCalendarAllDayPointer, interpretCalendarMonthPointer, interpretCalendarTimeGridPointer, + previewCalendarAllDay, previewCalendarMonth, previewCalendarTimeGrid, planCalendarSelectionMove, + projectCalendarOccurrences, + type CalendarDocument, type CalendarEvent, type CalendarIntent, +} from "../src/index.js"; + +const event = (patch: Partial = {}): CalendarEvent => ({ + id: "a", title: "A", start: "2026-08-01T09:00", end: "2026-08-01T10:00", + allDay: false, calendarId: "work", recurrence: null, excludeDates: [], ...patch, +}); +const document = (events = [event()]): CalendarDocument => ({ + calendars: [{ id: "work", title: "Work", hidden: false, color: "accent" }], events, +}); +function editor(events = [event()]) { + let sequence = 0; + return createCalendarEditor(document(events), { createId: () => `new-${++sequence}` }); +} +const recurring = (allDay = false) => event({ + ...(allDay ? { start: "2026-08-01", end: "2026-08-02", allDay } : {}), + recurrence: { freq: "daily", interval: 1, until: "2026-08-08" }, +}); +function capture(instance: ReturnType, start = "2026-08-01T09:00") { + const point = { eventId: "a", occurrenceStart: start }; + const topology = calendarOccurrenceTopology(instance.snapshot.value as CalendarDocument, "2026-08-01", "2026-08-09"); + instance.dispatch({ type: "selection.set", point, topology }); + return instance.prepareSelectionDrag(point, topology)!; +} +function visible(events: ReadonlyArray) { + return projectCalendarOccurrences(events, "2026-08-01", "2026-08-09") + .map(({ event, start, end }) => ({ title: event.title, start, end })) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); +} + +describe("Calendar protocol rejection", () => { + test.each([ + { type: "event.typo" }, + { type: "event.update", eventId: "a", end: "zz" }, + { type: "event.update", eventId: "a", calendarId: "unknown" }, + { type: "event.create", start: "2026-08-01T11:00", end: "2026-08-01T12:00", calendarId: "unknown" }, + { type: "event.update", eventId: "a", recurrence: { freq: "daily", interval: 1.5, until: "" } }, + { type: "event.update", eventId: "a", recurrence: { freq: "daily", interval: 1, until: "bad-date" } }, + ])("rejects $type without changing value, selection, history or notifications", (intent) => { + const instance = editor(); + instance.dispatch({ type: "event.update", eventId: "a", title: "Before undo" }); + instance.undo(); + const before = instance.snapshot; + const observed: unknown[] = []; + const release = instance.subscribe((snapshot) => observed.push(snapshot)); + expect(instance.dispatch(intent as CalendarIntent)).toMatchObject({ ok: false, code: expect.any(String) }); + expect(instance.snapshot).toEqual(before); + expect(observed).toEqual([]); + expect(() => createCalendarEditor(instance.snapshot.value as CalendarDocument)).not.toThrow(); + expect(instance.redo().ok).toBe(true); + release(); + }); + + test("rejects inverted clipboard intervals at parse and direct paste boundaries", () => { + const payload = structuredClone(editor().copy()!); + const invalid = { ...payload, items: payload.items.map((item) => ({ + ...item, event: { ...item.event, start: "2026-08-01T11:00", end: "2026-08-01T10:00" }, + })) }; + expect(calendarClipboardFormat.parse(invalid)).toBeNull(); + const instance = editor([]); + const before = instance.snapshot; + expect(instance.paste(invalid, "2026-08-02T12:00").ok).toBe(false); + expect(instance.snapshot).toEqual(before); + }); + + test("rejects a foreign calendar reference without silently changing its owner", () => { + const payload = editor().copy()!; + const instance = createCalendarEditor({ + calendars: [{ id: "personal", title: "Personal", hidden: false, color: "subtle" }], events: [], + }); + const before = instance.snapshot; + expect(instance.paste(payload, "2026-08-02T12:00").ok).toBe(false); + expect(instance.snapshot).toEqual(before); + expect(instance.paste(payload, "2026-08-02T12:00", { calendarId: "personal" }).ok).toBe(true); + expect((instance.snapshot.value as CalendarDocument).events[0]?.calendarId).toBe("personal"); + expect(() => createCalendarEditor(instance.snapshot.value as CalendarDocument)).not.toThrow(); + }); + + test("captured cut rejects content changed after the clipboard write", () => { + const instance = editor(); + const payload = instance.copy()!; + instance.dispatch({ type: "event.update", eventId: "a", title: "Not written to clipboard" }); + const before = instance.snapshot; + expect(instance.cut(payload)?.result.ok).toBe(false); + expect(instance.snapshot).toEqual(before); + }); + + test("legacy documents emit canonical clipboard events and can still cut", () => { + const instance = createCalendarEditor({ events: [{ id: "legacy", title: "Legacy", start: "2026-08-01T09:00", end: "2026-08-01T10:00" }] } as unknown as CalendarDocument); + expect(calendarClipboardFormat.parse(instance.copy())).not.toBeNull(); + expect(instance.cut()?.result.ok).toBe(true); + }); + + test("rejects inconsistent captured points and duplicate occurrences atomically", () => { + const instance = editor(); + const source = capture(instance); + const before = instance.snapshot; + for (const invalid of [ + { ...source, points: [] }, + { ...source, points: [...source.points, ...source.points], occurrences: [...source.occurrences, ...source.occurrences] }, + ]) { + expect(instance.dispatch({ type: "selection.move", source: invalid, target: { type: "day", day: "2026-08-02" } }).ok).toBe(false); + expect(instance.snapshot).toEqual(before); + } + }); + + test("rejects an occurrence removed after capture", () => { + const instance = editor([recurring()]); + const source = capture(instance, "2026-08-03T09:00"); + instance.dispatch({ type: "occurrence.remove", eventId: "a", occurrenceStart: "2026-08-03T09:00", scope: "this" }); + const before = instance.snapshot; + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-03T12:00" } })).toMatchObject({ ok: false }); + expect(instance.snapshot).toEqual(before); + }); + + test("rejects a drag whose captured interval changed but preserves unrelated edits", () => { + const instance = editor(); + const source = capture(instance); + instance.dispatch({ type: "event.update", eventId: "a", end: "2026-08-01T11:00" }); + const before = instance.snapshot; + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-01T12:00" } })).toMatchObject({ ok: false }); + expect(instance.snapshot).toEqual(before); + const fresh = capture(instance); + instance.dispatch({ type: "event.update", eventId: "a", title: "New title" }); + expect(instance.dispatch({ type: "selection.move", source: fresh, target: { type: "instant", instant: "2026-08-01T12:00" } }).ok).toBe(true); + expect((instance.snapshot.value as CalendarDocument).events[0]).toMatchObject({ title: "New title", start: "2026-08-01T12:00", end: "2026-08-01T14:00" }); + }); + + test("bounds allocator collisions before mutation", () => { + let calls = 0; + const instance = createCalendarEditor(document([recurring()]), { createId: () => ++calls <= 100 ? "a" : "new" }); + const source = capture(instance, "2026-08-03T09:00"); + const before = instance.snapshot; + expect(() => instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-03T12:00" } })).toThrow(/unique/); + expect(calls).toBe(100); + expect(instance.snapshot).toEqual(before); + }); +}); + +describe("Calendar recurrence and input parity", () => { + const scopes = ["this", "this-and-following", "all"] as const; + test.each(scopes)("time resize preview matches commit: %s", (scope) => { + const original = recurring(); + const instance = editor([original]); + const release = { originInstant: "2026-08-03T10:00", targetInstant: "2026-08-03T11:00", originEventId: "a", originEventStart: "2026-08-03T09:00", originHandle: "end" as const }; + const preview = previewCalendarTimeGrid([original], release, scope); + expect(instance.dispatch(bindCalendarTimeGridIntent(interpretCalendarTimeGridPointer(release), original, release.originEventStart, scope)!).ok).toBe(true); + expect(visible(preview)).toEqual(visible((instance.snapshot.value as CalendarDocument).events)); + }); + test.each(scopes)("all-day resize preview matches commit: %s", (scope) => { + const original = recurring(true); + const instance = editor([original]); + const release = { originDay: "2026-08-03", targetDay: "2026-08-04", originEventId: "a", originEventStart: "2026-08-03", originHandle: "end" as const }; + const preview = previewCalendarAllDay([original], release, scope); + expect(instance.dispatch(bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), original, release.originEventStart, scope)!).ok).toBe(true); + expect(visible(preview)).toEqual(visible((instance.snapshot.value as CalendarDocument).events)); + }); + test.each(scopes)("month move preview matches commit: %s", (scope) => { + const original = recurring(true); + const instance = editor([original]); + const release = { originDay: "2026-08-03", targetDay: "2026-08-04", originEventId: "a", originEventStart: "2026-08-03", eventsOnTargetDay: [] }; + const preview = previewCalendarMonth([original], release, scope); + expect(instance.dispatch(bindCalendarMonthIntent(interpretCalendarMonthPointer(release), original, release.originEventStart, scope)!).ok).toBe(true); + expect(visible(preview)).toEqual(visible((instance.snapshot.value as CalendarDocument).events)); + }); + test("all-day all-scope resize and Inspector preserve the same two-day interval", () => { + const original = recurring(true); + const pointer = editor([original]); + const inspector = editor([original]); + const release = { originDay: "2026-08-03", targetDay: "2026-08-04", originEventId: "a", originEventStart: "2026-08-03", originHandle: "end" as const }; + pointer.dispatch(bindCalendarAllDayIntent(interpretCalendarAllDayPointer(release), original, release.originEventStart, "all")!); + inspector.dispatch(calendarUpdateIntent(original, "2026-08-03", "all", { end: "2026-08-05" })); + expect(pointer.snapshot.value).toEqual(inspector.snapshot.value); + expect((pointer.snapshot.value as CalendarDocument).events[0]).toMatchObject({ start: "2026-08-01", end: "2026-08-03" }); + }); + test.each(scopes)("single recurring selection drag supports %s and one-step undo", (scope) => { + const instance = editor([recurring()]); + const source = capture(instance, "2026-08-03T09:00"); + const before = instance.snapshot; + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-03T11:00" }, scope }).ok).toBe(true); + expect(instance.undo().ok).toBe(true); + expect(instance.snapshot.value).toEqual(before.value); + expect(instance.snapshot.selection).toEqual(before.selection); + }); + test.each([399, 400, 600, 100_000])("projects a narrow window after occurrence %s without a hidden lifetime cap", (index) => { + const original = event({ recurrence: { freq: "daily", interval: 1, until: "" } }); + const day = addCalendarDate("2026-08-01", index)!; + expect(projectCalendarOccurrences([original], day, addCalendarDate(day, 1)!)).toMatchObject([{ start: `${day}T09:00`, end: `${day}T10:00` }]); + }); + + test.each(scopes)("moves duplicate series once for scope %s, retaining every selected point", (scope) => { + const original = recurring(); + const second = { ...structuredClone(original), id: "b", title: "B" }; + const instance = editor([original, second]); + const points = [ + { eventId: "a", occurrenceStart: "2026-08-04T09:00" }, + { eventId: "a", occurrenceStart: "2026-08-03T09:00" }, + { eventId: "b", occurrenceStart: "2026-08-03T09:00" }, + ]; + points.forEach((point, index) => instance.dispatch({ type: "selection.set", point, topology: { points }, mode: index === 0 ? "replace" : "toggle" })); + const source = instance.prepareSelectionDrag(points[0]!, { points })!; + const before = instance.snapshot; + const target = { type: "instant" as const, instant: "2026-08-04T11:00" }; + const preview = planCalendarSelectionMove([original, second], source.occurrences, source.anchor, target, { scope, primary: source.primary }); + expect(preview.ok).toBe(true); + expect(instance.dispatch({ type: "selection.move", source, target, scope }).ok).toBe(true); + const current = (instance.snapshot.value as CalendarDocument).events; + expect(current).toHaveLength(scope === "this" ? 5 : scope === "all" ? 2 : 4); + expect(instance.selectedOccurrences.map(({ start }) => start).sort()).toEqual(["2026-08-03T11:00", "2026-08-03T11:00", "2026-08-04T11:00"]); + if (preview.ok) expect(visible(preview.events)).toEqual(visible(current)); + if (scope === "this-and-following") expect(current[0]?.recurrence?.until).toBe("2026-08-02"); + expect(instance.undo().ok).toBe(true); + expect(instance.snapshot.value).toEqual(before.value); + expect(instance.snapshot.selection).toEqual(before.selection); + }); + + test.each(["all", "this-and-following"] as const)("translates the finite lifetime and future exclusions for %s", (scope) => { + const instance = editor([{ ...recurring(), excludeDates: ["2026-08-05"] }]); + expect(instance.dispatch({ type: "occurrence.edit", eventId: "a", occurrenceStart: "2026-08-03T09:00", start: "2026-08-04T09:00", scope }).ok).toBe(true); + const series = (instance.snapshot.value as CalendarDocument).events.at(-1)!; + expect(series.recurrence?.until).toBe("2026-08-09"); + expect(series.excludeDates).toEqual(["2026-08-06"]); + expect(projectCalendarOccurrences([series], "2026-08-06", "2026-08-07")).toEqual([]); + expect(projectCalendarOccurrences([series], "2026-08-10", "2026-08-11")).toEqual([]); + }); + + test.each([ + ["weekly", "2026-01-01", "2038-07-01", "2038-07-02"], + ["monthly", "2026-01-31", "2076-02-29", "2076-03-01"], + ["yearly", "2000-02-29", "2600-02-28", "2600-03-01"], + ] as const)("seeks late %s occurrences, including constrained dates", (freq, start, from, to) => { + const original = event({ start: `${start}T09:00`, end: `${start}T10:00`, recurrence: { freq, interval: 1, until: "" } }); + expect(projectCalendarOccurrences([original], from, to)).toHaveLength(1); + }); + + test("seeking retains long intervals already in progress", () => { + const original = event({ start: "2026-01-01", end: "2026-01-31", allDay: true, recurrence: { freq: "weekly", interval: 2, until: "" } }); + const late = projectCalendarOccurrences([original], "2050-01-15", "2050-01-16"); + expect(late.length).toBeGreaterThanOrEqual(2); + expect(late.every((item) => item.start <= "2050-01-15" && item.end > "2050-01-15")).toBe(true); + }); + + test("preview ID collisions do not alias an existing event", () => { + const original = { ...recurring(), id: "preview" }; + const preview = previewCalendarTimeGrid([original], { + originInstant: "2026-08-03T09:00", targetInstant: "2026-08-03T11:00", + originEventId: "preview", originEventStart: "2026-08-03T09:00", originHandle: "body", + }); + expect(new Set(preview.map((item) => item.id)).size).toBe(2); + }); + + test("detached group occurrences preserve independent extension metadata", () => { + const instance = editor([{ ...recurring(), metadata: { note: "Keep me" } }]); + const source = capture(instance); + expect(instance.dispatch({ type: "selection.move", source, target: { type: "instant", instant: "2026-08-01T11:00" } }).ok).toBe(true); + const current = instance.snapshot.value as CalendarDocument; + expect(current.events.at(-1)?.metadata).toEqual({ note: "Keep me" }); + expect(() => createCalendarEditor(current)).not.toThrow(); + }); + + test("rejects an unrepresentable constrained monthly group instead of losing points", () => { + const original = event({ start: "2026-01-30T09:00", end: "2026-01-30T10:00", recurrence: { freq: "monthly", interval: 1, until: "" } }); + const plan = planCalendarSelectionMove([original], [ + { eventId: "a", start: "2026-01-30T09:00", end: "2026-01-30T10:00" }, + { eventId: "a", start: "2026-02-28T09:00", end: "2026-02-28T10:00" }, + ], { eventId: "a", occurrenceStart: original.start }, { type: "day", day: "2026-01-31" }, { scope: "all" }); + expect(plan).toEqual({ ok: false, code: "selection.unrepresentable-series-move" }); + }); + + test("single all-scope edits also reject a monthly anchor that cannot represent the requested occurrence", () => { + const original = event({ start: "2026-01-30T09:00", end: "2026-01-30T10:00", recurrence: { freq: "monthly", interval: 1, until: "" } }); + const instance = editor([original]); + const before = instance.snapshot; + expect(instance.dispatch({ type: "occurrence.edit", eventId: "a", occurrenceStart: "2026-02-28T09:00", start: "2026-03-01T09:00", scope: "all" })) + .toEqual({ ok: false, code: "selection.unrepresentable-series-move" }); + expect(instance.snapshot).toEqual(before); + expect(previewCalendarTimeGrid([original], { + originInstant: "2026-02-28T09:00", targetInstant: "2026-03-01T09:00", + originEventId: "a", originEventStart: "2026-02-28T09:00", originHandle: "body", + }, "all")).toEqual([original]); + }); + + test.each([ + ["monthly", true, "2026-01-30", "2026-01-31", "2026-02-28", "2026-03-01"], + ["monthly", false, "2026-01-30T23:30", "2026-01-31T00:15", "2026-02-28T23:30", "2026-03-01T00:15"], + ["yearly", true, "2028-02-28", "2028-02-29", "2029-02-28", "2029-03-01"], + ] as const)("%s projection preserves duration across constrained dates (allDay=%s)", (freq, allDay, start, end, nextStart, nextEnd) => { + const original = event({ start, end, allDay, recurrence: { freq, interval: 1, until: "" } }); + const projected = projectCalendarOccurrences([original], nextStart.slice(0, 10), addCalendarDate(nextEnd.slice(0, 10), 1)!); + expect(projected.map(({ start, end }) => ({ start, end }))).toEqual([{ start: nextStart, end: nextEnd }]); + expect(() => createCalendarEditor(document(projected.map(({ event, start, end }) => ({ ...event, start, end }))))).not.toThrow(); + }); +}); diff --git a/site/scripts/check-calendar-allday-span.mjs b/site/scripts/check-calendar-allday-span.mjs index 7a40663ae..179ea4a8e 100644 --- a/site/scripts/check-calendar-allday-span.mjs +++ b/site/scripts/check-calendar-allday-span.mjs @@ -6,6 +6,7 @@ const owner = read("packages/json-document-editing/src/calendar-validation.ts"); const ownerIndex = read("packages/json-document-editing/src/index.ts"); const ownerTest = read("packages/json-document-editing/tests/calendar-validation.test.ts"); const editor = read("packages/json-document-editing/src/calendar.ts"); +const eventPlan = read("packages/json-document-editing/src/calendar-event-plan.ts"); const allDayPointer = read("packages/json-document-editing/src/calendar-allday-pointer.ts"); const monthPointer = read("packages/json-document-editing/src/calendar-month-pointer.ts"); const host = read("site/src/routes/calendar-demo/CalendarDemoRoute.tsx"); @@ -16,14 +17,15 @@ const usage = read("site/src/shared/demo-workbench/demo-sources.ts"); requireText(owner, "export function calendarAllDaySpan"); requireText(ownerIndex, "calendarAllDaySpan"); requireText(ownerTest, 'calendarAllDaySpan("2026-05-27", "2026-05-25")'); -requireText(editor, "calendarAllDaySpan(start, start)?.end"); +requireText(editor, "planCalendarEventEdit(current.events, intent"); +requireText(eventPlan, "calendarAllDaySpan(start, start)?.end"); requireCount(allDayPointer, "calendarAllDaySpan(", 2); requireCount(monthPointer, "calendarAllDaySpan(", 1); requireCount(host, "calendarAllDaySpan(", 0); requireCount(inspector, "calendarAllDaySpan(", 1); requireCount(timeGrid, "calendarAllDaySpan(", 1); forbid(allDayPointer, /addCalendarDate\(release\.targetDay, 1\)/); -forbid(editor, /addCalendarDate\(start, 1\)/); +forbid(eventPlan, /addCalendarDate\(start, 1\)/); forbid(host, /addCalendarDate\((?:day|value), 1\)/); requireText(usage, 'symbol: "calendarAllDaySpan"'); requireText(usage, 'sourcePath: "packages/json-document-editing/src/calendar-validation.ts"'); diff --git a/site/src/app/routeTree.gen.ts b/site/src/app/routeTree.gen.ts index 4251679d2..e1ead5778 100644 --- a/site/src/app/routeTree.gen.ts +++ b/site/src/app/routeTree.gen.ts @@ -61,7 +61,6 @@ import { Route as PageDocsAdapterKeyboardRouteImport } from "./routes/_page/docs import { Route as PageDocsAdapterVirtualSelectionRouteImport } from "./routes/_page/docs/adapter-virtual-selection"; import { Route as PageDocsAdaptersRouteImport } from "./routes/_page/docs/adapters"; import { Route as PageDocsAnimationRouteImport } from "./routes/_page/docs/animation"; -import { Route as PageDocsApiRouteImport } from "./routes/_page/docs/api"; import { Route as PageDocsClipboardRouteImport } from "./routes/_page/docs/clipboard"; import { Route as PageDocsComposerRouteImport } from "./routes/_page/docs/composer"; import { Route as PageDocsConceptsRouteImport } from "./routes/_page/docs/concepts"; @@ -128,6 +127,7 @@ import { Route as PageDocsAffordanceSnapRouteImport } from "./routes/_page/docs/ import { Route as PageDocsAffordanceTripleClickRouteImport } from "./routes/_page/docs/affordance/triple-click"; import { Route as PageDocsAffordanceTypeaheadRouteImport } from "./routes/_page/docs/affordance/typeahead"; import { Route as PageDocsAffordanceZoomRouteImport } from "./routes/_page/docs/affordance/zoom"; +import { Route as PageDocsApiIndexRouteImport } from "./routes/_page/docs/api/index"; import { Route as PageDocsApiA2uiRouteImport } from "./routes/_page/docs/api/a2ui"; import { Route as PageDocsApiAffordanceRouteImport } from "./routes/_page/docs/api/affordance"; import { Route as PageDocsApiAjvRouteImport } from "./routes/_page/docs/api/ajv"; @@ -436,11 +436,6 @@ const PageDocsAnimationRoute = PageDocsAnimationRouteImport.update({ path: "/docs/animation", getParentRoute: () => PageRoute, } as any); -const PageDocsApiRoute = PageDocsApiRouteImport.update({ - id: "/docs/api", - path: "/docs/api", - getParentRoute: () => PageRoute, -} as any); const PageDocsClipboardRoute = PageDocsClipboardRouteImport.update({ id: "/docs/clipboard", path: "/docs/clipboard", @@ -792,169 +787,174 @@ const PageDocsAffordanceZoomRoute = PageDocsAffordanceZoomRouteImport.update({ path: "/docs/affordance/zoom", getParentRoute: () => PageRoute, } as any); +const PageDocsApiIndexRoute = PageDocsApiIndexRouteImport.update({ + id: "/docs/api/", + path: "/docs/api/", + getParentRoute: () => PageRoute, +} as any); const PageDocsApiA2uiRoute = PageDocsApiA2uiRouteImport.update({ - id: "/a2ui", - path: "/a2ui", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/a2ui", + path: "/docs/api/a2ui", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAffordanceRoute = PageDocsApiAffordanceRouteImport.update({ - id: "/affordance", - path: "/affordance", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/affordance", + path: "/docs/api/affordance", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAjvRoute = PageDocsApiAjvRouteImport.update({ - id: "/ajv", - path: "/ajv", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/ajv", + path: "/docs/api/ajv", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAnimationReactRoute = PageDocsApiAnimationReactRouteImport.update({ - id: "/animation-react", - path: "/animation-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/animation-react", + path: "/docs/api/animation-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiAnnotationRoute = PageDocsApiAnnotationRouteImport.update({ - id: "/annotation", - path: "/annotation", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/annotation", + path: "/docs/api/annotation", + getParentRoute: () => PageRoute, } as any); const PageDocsApiCalendarRoute = PageDocsApiCalendarRouteImport.update({ - id: "/calendar", - path: "/calendar", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/calendar", + path: "/docs/api/calendar", + getParentRoute: () => PageRoute, } as any); const PageDocsApiCollaborationRoute = PageDocsApiCollaborationRouteImport.update({ - id: "/collaboration", - path: "/collaboration", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/collaboration", + path: "/docs/api/collaboration", + getParentRoute: () => PageRoute, } as any); const PageDocsApiComposerRoute = PageDocsApiComposerRouteImport.update({ - id: "/composer", - path: "/composer", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/composer", + path: "/docs/api/composer", + getParentRoute: () => PageRoute, } as any); const PageDocsApiComposerReactRoute = PageDocsApiComposerReactRouteImport.update({ - id: "/composer-react", - path: "/composer-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/composer-react", + path: "/docs/api/composer-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiContenteditableRoute = PageDocsApiContenteditableRouteImport.update({ - id: "/contenteditable", - path: "/contenteditable", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/contenteditable", + path: "/docs/api/contenteditable", + getParentRoute: () => PageRoute, } as any); const PageDocsApiContenteditableCollaborationRoute = PageDocsApiContenteditableCollaborationRouteImport.update({ - id: "/contenteditable-collaboration", - path: "/contenteditable-collaboration", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/contenteditable-collaboration", + path: "/docs/api/contenteditable-collaboration", + getParentRoute: () => PageRoute, } as any); const PageDocsApiDatabaseRoute = PageDocsApiDatabaseRouteImport.update({ - id: "/database", - path: "/database", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/database", + path: "/docs/api/database", + getParentRoute: () => PageRoute, } as any); const PageDocsApiEditingRoute = PageDocsApiEditingRouteImport.update({ - id: "/editing", - path: "/editing", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/editing", + path: "/docs/api/editing", + getParentRoute: () => PageRoute, } as any); const PageDocsApiFileIntakeRoute = PageDocsApiFileIntakeRouteImport.update({ - id: "/file-intake", - path: "/file-intake", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/file-intake", + path: "/docs/api/file-intake", + getParentRoute: () => PageRoute, } as any); const PageDocsApiJsonDocumentRoute = PageDocsApiJsonDocumentRouteImport.update({ - id: "/json-document", - path: "/json-document", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/json-document", + path: "/docs/api/json-document", + getParentRoute: () => PageRoute, } as any); const PageDocsApiMarkdownReactRoute = PageDocsApiMarkdownReactRouteImport.update({ - id: "/markdown-react", - path: "/markdown-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/markdown-react", + path: "/docs/api/markdown-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiReactRoute = PageDocsApiReactRouteImport.update({ - id: "/react", - path: "/react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/react", + path: "/docs/api/react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiReactHookFormRoute = PageDocsApiReactHookFormRouteImport.update({ - id: "/react-hook-form", - path: "/react-hook-form", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/react-hook-form", + path: "/docs/api/react-hook-form", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextRoute = PageDocsApiRichTextRouteImport.update({ - id: "/rich-text", - path: "/rich-text", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text", + path: "/docs/api/rich-text", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextMentionRoute = PageDocsApiRichTextMentionRouteImport.update({ - id: "/rich-text-mention", - path: "/rich-text-mention", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-mention", + path: "/docs/api/rich-text-mention", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextMentionReactRoute = PageDocsApiRichTextMentionReactRouteImport.update({ - id: "/rich-text-mention-react", - path: "/rich-text-mention-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-mention-react", + path: "/docs/api/rich-text-mention-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextReactRoute = PageDocsApiRichTextReactRouteImport.update({ - id: "/rich-text-react", - path: "/rich-text-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-react", + path: "/docs/api/rich-text-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextSuggestionRoute = PageDocsApiRichTextSuggestionRouteImport.update({ - id: "/rich-text-suggestion", - path: "/rich-text-suggestion", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-suggestion", + path: "/docs/api/rich-text-suggestion", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextSuggestionReactRoute = PageDocsApiRichTextSuggestionReactRouteImport.update({ - id: "/rich-text-suggestion-react", - path: "/rich-text-suggestion-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-suggestion-react", + path: "/docs/api/rich-text-suggestion-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiRichTextWebRoute = PageDocsApiRichTextWebRouteImport.update({ - id: "/rich-text-web", - path: "/rich-text-web", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/rich-text-web", + path: "/docs/api/rich-text-web", + getParentRoute: () => PageRoute, } as any); const PageDocsApiSelectionRoute = PageDocsApiSelectionRouteImport.update({ - id: "/selection", - path: "/selection", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/selection", + path: "/docs/api/selection", + getParentRoute: () => PageRoute, } as any); const PageDocsApiTanstackTableRoute = PageDocsApiTanstackTableRouteImport.update({ - id: "/tanstack-table", - path: "/tanstack-table", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/tanstack-table", + path: "/docs/api/tanstack-table", + getParentRoute: () => PageRoute, } as any); const PageDocsApiUiPrimitivesReactRoute = PageDocsApiUiPrimitivesReactRouteImport.update({ - id: "/ui-primitives-react", - path: "/ui-primitives-react", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/ui-primitives-react", + path: "/docs/api/ui-primitives-react", + getParentRoute: () => PageRoute, } as any); const PageDocsApiWebRoute = PageDocsApiWebRouteImport.update({ - id: "/web", - path: "/web", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/web", + path: "/docs/api/web", + getParentRoute: () => PageRoute, } as any); const PageDocsApiZodRoute = PageDocsApiZodRouteImport.update({ - id: "/zod", - path: "/zod", - getParentRoute: () => PageDocsApiRoute, + id: "/docs/api/zod", + path: "/docs/api/zod", + getParentRoute: () => PageRoute, } as any); const PageDocsCollaborationIndexRoute = PageDocsCollaborationIndexRouteImport.update({ @@ -1052,7 +1052,6 @@ export interface FileRoutesByFullPath { "/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/docs/adapters": typeof PageDocsAdaptersRoute; "/docs/animation": typeof PageDocsAnimationRoute; - "/docs/api": typeof PageDocsApiRouteWithChildren; "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/composer": typeof PageDocsComposerRoute; "/docs/concepts": typeof PageDocsConceptsRoute; @@ -1158,6 +1157,7 @@ export interface FileRoutesByFullPath { "/docs/document-types/$candidate": typeof PageDocsDocumentTypesCandidateRoute; "/connectors/zod/": typeof PageConnectorsZodIndexRoute; "/docs/affordance/": typeof PageDocsAffordanceIndexRoute; + "/docs/api/": typeof PageDocsApiIndexRoute; "/docs/collaboration/": typeof PageDocsCollaborationIndexRoute; "/docs/document-types/": typeof PageDocsDocumentTypesIndexRoute; "/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; @@ -1210,7 +1210,6 @@ export interface FileRoutesByTo { "/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/docs/adapters": typeof PageDocsAdaptersRoute; "/docs/animation": typeof PageDocsAnimationRoute; - "/docs/api": typeof PageDocsApiRouteWithChildren; "/docs/clipboard": typeof PageDocsClipboardRoute; "/docs/composer": typeof PageDocsComposerRoute; "/docs/concepts": typeof PageDocsConceptsRoute; @@ -1316,6 +1315,7 @@ export interface FileRoutesByTo { "/docs/document-types/$candidate": typeof PageDocsDocumentTypesCandidateRoute; "/connectors/zod": typeof PageConnectorsZodIndexRoute; "/docs/affordance": typeof PageDocsAffordanceIndexRoute; + "/docs/api": typeof PageDocsApiIndexRoute; "/docs/collaboration": typeof PageDocsCollaborationIndexRoute; "/docs/document-types": typeof PageDocsDocumentTypesIndexRoute; "/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; @@ -1370,7 +1370,6 @@ export interface FileRoutesById { "/_page/docs/adapter-virtual-selection": typeof PageDocsAdapterVirtualSelectionRoute; "/_page/docs/adapters": typeof PageDocsAdaptersRoute; "/_page/docs/animation": typeof PageDocsAnimationRoute; - "/_page/docs/api": typeof PageDocsApiRouteWithChildren; "/_page/docs/clipboard": typeof PageDocsClipboardRoute; "/_page/docs/composer": typeof PageDocsComposerRoute; "/_page/docs/concepts": typeof PageDocsConceptsRoute; @@ -1476,6 +1475,7 @@ export interface FileRoutesById { "/_page/docs/document-types/$candidate": typeof PageDocsDocumentTypesCandidateRoute; "/_page/connectors/zod/": typeof PageConnectorsZodIndexRoute; "/_page/docs/affordance/": typeof PageDocsAffordanceIndexRoute; + "/_page/docs/api/": typeof PageDocsApiIndexRoute; "/_page/docs/collaboration/": typeof PageDocsCollaborationIndexRoute; "/_page/docs/document-types/": typeof PageDocsDocumentTypesIndexRoute; "/_page/docs/collaboration/text/lease": typeof PageDocsCollaborationTextLeaseRoute; @@ -1530,7 +1530,6 @@ export interface FileRouteTypes { | "/docs/adapter-virtual-selection" | "/docs/adapters" | "/docs/animation" - | "/docs/api" | "/docs/clipboard" | "/docs/composer" | "/docs/concepts" @@ -1636,6 +1635,7 @@ export interface FileRouteTypes { | "/docs/document-types/$candidate" | "/connectors/zod/" | "/docs/affordance/" + | "/docs/api/" | "/docs/collaboration/" | "/docs/document-types/" | "/docs/collaboration/text/lease" @@ -1688,7 +1688,6 @@ export interface FileRouteTypes { | "/docs/adapter-virtual-selection" | "/docs/adapters" | "/docs/animation" - | "/docs/api" | "/docs/clipboard" | "/docs/composer" | "/docs/concepts" @@ -1794,6 +1793,7 @@ export interface FileRouteTypes { | "/docs/document-types/$candidate" | "/connectors/zod" | "/docs/affordance" + | "/docs/api" | "/docs/collaboration" | "/docs/document-types" | "/docs/collaboration/text/lease" @@ -1847,7 +1847,6 @@ export interface FileRouteTypes { | "/_page/docs/adapter-virtual-selection" | "/_page/docs/adapters" | "/_page/docs/animation" - | "/_page/docs/api" | "/_page/docs/clipboard" | "/_page/docs/composer" | "/_page/docs/concepts" @@ -1953,6 +1952,7 @@ export interface FileRouteTypes { | "/_page/docs/document-types/$candidate" | "/_page/connectors/zod/" | "/_page/docs/affordance/" + | "/_page/docs/api/" | "/_page/docs/collaboration/" | "/_page/docs/document-types/" | "/_page/docs/collaboration/text/lease" @@ -2330,13 +2330,6 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsAnimationRouteImport; parentRoute: typeof PageRoute; }; - "/_page/docs/api": { - id: "/_page/docs/api"; - path: "/docs/api"; - fullPath: "/docs/api"; - preLoaderRoute: typeof PageDocsApiRouteImport; - parentRoute: typeof PageRoute; - }; "/_page/docs/clipboard": { id: "/_page/docs/clipboard"; path: "/docs/clipboard"; @@ -2799,215 +2792,222 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof PageDocsAffordanceZoomRouteImport; parentRoute: typeof PageRoute; }; + "/_page/docs/api/": { + id: "/_page/docs/api/"; + path: "/docs/api"; + fullPath: "/docs/api/"; + preLoaderRoute: typeof PageDocsApiIndexRouteImport; + parentRoute: typeof PageRoute; + }; "/_page/docs/api/a2ui": { id: "/_page/docs/api/a2ui"; - path: "/a2ui"; + path: "/docs/api/a2ui"; fullPath: "/docs/api/a2ui"; preLoaderRoute: typeof PageDocsApiA2uiRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/affordance": { id: "/_page/docs/api/affordance"; - path: "/affordance"; + path: "/docs/api/affordance"; fullPath: "/docs/api/affordance"; preLoaderRoute: typeof PageDocsApiAffordanceRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/ajv": { id: "/_page/docs/api/ajv"; - path: "/ajv"; + path: "/docs/api/ajv"; fullPath: "/docs/api/ajv"; preLoaderRoute: typeof PageDocsApiAjvRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/animation-react": { id: "/_page/docs/api/animation-react"; - path: "/animation-react"; + path: "/docs/api/animation-react"; fullPath: "/docs/api/animation-react"; preLoaderRoute: typeof PageDocsApiAnimationReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/annotation": { id: "/_page/docs/api/annotation"; - path: "/annotation"; + path: "/docs/api/annotation"; fullPath: "/docs/api/annotation"; preLoaderRoute: typeof PageDocsApiAnnotationRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/calendar": { id: "/_page/docs/api/calendar"; - path: "/calendar"; + path: "/docs/api/calendar"; fullPath: "/docs/api/calendar"; preLoaderRoute: typeof PageDocsApiCalendarRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/collaboration": { id: "/_page/docs/api/collaboration"; - path: "/collaboration"; + path: "/docs/api/collaboration"; fullPath: "/docs/api/collaboration"; preLoaderRoute: typeof PageDocsApiCollaborationRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/composer": { id: "/_page/docs/api/composer"; - path: "/composer"; + path: "/docs/api/composer"; fullPath: "/docs/api/composer"; preLoaderRoute: typeof PageDocsApiComposerRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/composer-react": { id: "/_page/docs/api/composer-react"; - path: "/composer-react"; + path: "/docs/api/composer-react"; fullPath: "/docs/api/composer-react"; preLoaderRoute: typeof PageDocsApiComposerReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/contenteditable": { id: "/_page/docs/api/contenteditable"; - path: "/contenteditable"; + path: "/docs/api/contenteditable"; fullPath: "/docs/api/contenteditable"; preLoaderRoute: typeof PageDocsApiContenteditableRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/contenteditable-collaboration": { id: "/_page/docs/api/contenteditable-collaboration"; - path: "/contenteditable-collaboration"; + path: "/docs/api/contenteditable-collaboration"; fullPath: "/docs/api/contenteditable-collaboration"; preLoaderRoute: typeof PageDocsApiContenteditableCollaborationRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/database": { id: "/_page/docs/api/database"; - path: "/database"; + path: "/docs/api/database"; fullPath: "/docs/api/database"; preLoaderRoute: typeof PageDocsApiDatabaseRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/editing": { id: "/_page/docs/api/editing"; - path: "/editing"; + path: "/docs/api/editing"; fullPath: "/docs/api/editing"; preLoaderRoute: typeof PageDocsApiEditingRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/file-intake": { id: "/_page/docs/api/file-intake"; - path: "/file-intake"; + path: "/docs/api/file-intake"; fullPath: "/docs/api/file-intake"; preLoaderRoute: typeof PageDocsApiFileIntakeRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/json-document": { id: "/_page/docs/api/json-document"; - path: "/json-document"; + path: "/docs/api/json-document"; fullPath: "/docs/api/json-document"; preLoaderRoute: typeof PageDocsApiJsonDocumentRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/markdown-react": { id: "/_page/docs/api/markdown-react"; - path: "/markdown-react"; + path: "/docs/api/markdown-react"; fullPath: "/docs/api/markdown-react"; preLoaderRoute: typeof PageDocsApiMarkdownReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/react": { id: "/_page/docs/api/react"; - path: "/react"; + path: "/docs/api/react"; fullPath: "/docs/api/react"; preLoaderRoute: typeof PageDocsApiReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/react-hook-form": { id: "/_page/docs/api/react-hook-form"; - path: "/react-hook-form"; + path: "/docs/api/react-hook-form"; fullPath: "/docs/api/react-hook-form"; preLoaderRoute: typeof PageDocsApiReactHookFormRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text": { id: "/_page/docs/api/rich-text"; - path: "/rich-text"; + path: "/docs/api/rich-text"; fullPath: "/docs/api/rich-text"; preLoaderRoute: typeof PageDocsApiRichTextRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-mention": { id: "/_page/docs/api/rich-text-mention"; - path: "/rich-text-mention"; + path: "/docs/api/rich-text-mention"; fullPath: "/docs/api/rich-text-mention"; preLoaderRoute: typeof PageDocsApiRichTextMentionRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-mention-react": { id: "/_page/docs/api/rich-text-mention-react"; - path: "/rich-text-mention-react"; + path: "/docs/api/rich-text-mention-react"; fullPath: "/docs/api/rich-text-mention-react"; preLoaderRoute: typeof PageDocsApiRichTextMentionReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-react": { id: "/_page/docs/api/rich-text-react"; - path: "/rich-text-react"; + path: "/docs/api/rich-text-react"; fullPath: "/docs/api/rich-text-react"; preLoaderRoute: typeof PageDocsApiRichTextReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-suggestion": { id: "/_page/docs/api/rich-text-suggestion"; - path: "/rich-text-suggestion"; + path: "/docs/api/rich-text-suggestion"; fullPath: "/docs/api/rich-text-suggestion"; preLoaderRoute: typeof PageDocsApiRichTextSuggestionRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-suggestion-react": { id: "/_page/docs/api/rich-text-suggestion-react"; - path: "/rich-text-suggestion-react"; + path: "/docs/api/rich-text-suggestion-react"; fullPath: "/docs/api/rich-text-suggestion-react"; preLoaderRoute: typeof PageDocsApiRichTextSuggestionReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/rich-text-web": { id: "/_page/docs/api/rich-text-web"; - path: "/rich-text-web"; + path: "/docs/api/rich-text-web"; fullPath: "/docs/api/rich-text-web"; preLoaderRoute: typeof PageDocsApiRichTextWebRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/selection": { id: "/_page/docs/api/selection"; - path: "/selection"; + path: "/docs/api/selection"; fullPath: "/docs/api/selection"; preLoaderRoute: typeof PageDocsApiSelectionRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/tanstack-table": { id: "/_page/docs/api/tanstack-table"; - path: "/tanstack-table"; + path: "/docs/api/tanstack-table"; fullPath: "/docs/api/tanstack-table"; preLoaderRoute: typeof PageDocsApiTanstackTableRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/ui-primitives-react": { id: "/_page/docs/api/ui-primitives-react"; - path: "/ui-primitives-react"; + path: "/docs/api/ui-primitives-react"; fullPath: "/docs/api/ui-primitives-react"; preLoaderRoute: typeof PageDocsApiUiPrimitivesReactRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/web": { id: "/_page/docs/api/web"; - path: "/web"; + path: "/docs/api/web"; fullPath: "/docs/api/web"; preLoaderRoute: typeof PageDocsApiWebRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/api/zod": { id: "/_page/docs/api/zod"; - path: "/zod"; + path: "/docs/api/zod"; fullPath: "/docs/api/zod"; preLoaderRoute: typeof PageDocsApiZodRouteImport; - parentRoute: typeof PageDocsApiRoute; + parentRoute: typeof PageRoute; }; "/_page/docs/collaboration/": { id: "/_page/docs/collaboration/"; @@ -3068,78 +3068,6 @@ declare module "@tanstack/react-router" { } } -interface PageDocsApiRouteChildren { - PageDocsApiA2uiRoute: typeof PageDocsApiA2uiRoute; - PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; - PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; - PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; - PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute; - PageDocsApiCalendarRoute: typeof PageDocsApiCalendarRoute; - PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute; - PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute; - PageDocsApiComposerReactRoute: typeof PageDocsApiComposerReactRoute; - PageDocsApiContenteditableRoute: typeof PageDocsApiContenteditableRoute; - PageDocsApiContenteditableCollaborationRoute: typeof PageDocsApiContenteditableCollaborationRoute; - PageDocsApiDatabaseRoute: typeof PageDocsApiDatabaseRoute; - PageDocsApiEditingRoute: typeof PageDocsApiEditingRoute; - PageDocsApiFileIntakeRoute: typeof PageDocsApiFileIntakeRoute; - PageDocsApiJsonDocumentRoute: typeof PageDocsApiJsonDocumentRoute; - PageDocsApiMarkdownReactRoute: typeof PageDocsApiMarkdownReactRoute; - PageDocsApiReactRoute: typeof PageDocsApiReactRoute; - PageDocsApiReactHookFormRoute: typeof PageDocsApiReactHookFormRoute; - PageDocsApiRichTextRoute: typeof PageDocsApiRichTextRoute; - PageDocsApiRichTextMentionRoute: typeof PageDocsApiRichTextMentionRoute; - PageDocsApiRichTextMentionReactRoute: typeof PageDocsApiRichTextMentionReactRoute; - PageDocsApiRichTextReactRoute: typeof PageDocsApiRichTextReactRoute; - PageDocsApiRichTextSuggestionRoute: typeof PageDocsApiRichTextSuggestionRoute; - PageDocsApiRichTextSuggestionReactRoute: typeof PageDocsApiRichTextSuggestionReactRoute; - PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; - PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; - PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; - PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; - PageDocsApiWebRoute: typeof PageDocsApiWebRoute; - PageDocsApiZodRoute: typeof PageDocsApiZodRoute; -} - -const PageDocsApiRouteChildren: PageDocsApiRouteChildren = { - PageDocsApiA2uiRoute: PageDocsApiA2uiRoute, - PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute, - PageDocsApiAjvRoute: PageDocsApiAjvRoute, - PageDocsApiAnimationReactRoute: PageDocsApiAnimationReactRoute, - PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute, - PageDocsApiCalendarRoute: PageDocsApiCalendarRoute, - PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute, - PageDocsApiComposerRoute: PageDocsApiComposerRoute, - PageDocsApiComposerReactRoute: PageDocsApiComposerReactRoute, - PageDocsApiContenteditableRoute: PageDocsApiContenteditableRoute, - PageDocsApiContenteditableCollaborationRoute: - PageDocsApiContenteditableCollaborationRoute, - PageDocsApiDatabaseRoute: PageDocsApiDatabaseRoute, - PageDocsApiEditingRoute: PageDocsApiEditingRoute, - PageDocsApiFileIntakeRoute: PageDocsApiFileIntakeRoute, - PageDocsApiJsonDocumentRoute: PageDocsApiJsonDocumentRoute, - PageDocsApiMarkdownReactRoute: PageDocsApiMarkdownReactRoute, - PageDocsApiReactRoute: PageDocsApiReactRoute, - PageDocsApiReactHookFormRoute: PageDocsApiReactHookFormRoute, - PageDocsApiRichTextRoute: PageDocsApiRichTextRoute, - PageDocsApiRichTextMentionRoute: PageDocsApiRichTextMentionRoute, - PageDocsApiRichTextMentionReactRoute: PageDocsApiRichTextMentionReactRoute, - PageDocsApiRichTextReactRoute: PageDocsApiRichTextReactRoute, - PageDocsApiRichTextSuggestionRoute: PageDocsApiRichTextSuggestionRoute, - PageDocsApiRichTextSuggestionReactRoute: - PageDocsApiRichTextSuggestionReactRoute, - PageDocsApiRichTextWebRoute: PageDocsApiRichTextWebRoute, - PageDocsApiSelectionRoute: PageDocsApiSelectionRoute, - PageDocsApiTanstackTableRoute: PageDocsApiTanstackTableRoute, - PageDocsApiUiPrimitivesReactRoute: PageDocsApiUiPrimitivesReactRoute, - PageDocsApiWebRoute: PageDocsApiWebRoute, - PageDocsApiZodRoute: PageDocsApiZodRoute, -}; - -const PageDocsApiRouteWithChildren = PageDocsApiRoute._addFileChildren( - PageDocsApiRouteChildren, -); - interface PageRouteChildren { PageDemosRoute: typeof PageDemosRoute; PageEditorsRoute: typeof PageEditorsRoute; @@ -3186,7 +3114,6 @@ interface PageRouteChildren { PageDocsAdapterVirtualSelectionRoute: typeof PageDocsAdapterVirtualSelectionRoute; PageDocsAdaptersRoute: typeof PageDocsAdaptersRoute; PageDocsAnimationRoute: typeof PageDocsAnimationRoute; - PageDocsApiRoute: typeof PageDocsApiRouteWithChildren; PageDocsClipboardRoute: typeof PageDocsClipboardRoute; PageDocsComposerRoute: typeof PageDocsComposerRoute; PageDocsConceptsRoute: typeof PageDocsConceptsRoute; @@ -3256,12 +3183,43 @@ interface PageRouteChildren { PageDocsAffordanceTripleClickRoute: typeof PageDocsAffordanceTripleClickRoute; PageDocsAffordanceTypeaheadRoute: typeof PageDocsAffordanceTypeaheadRoute; PageDocsAffordanceZoomRoute: typeof PageDocsAffordanceZoomRoute; + PageDocsApiA2uiRoute: typeof PageDocsApiA2uiRoute; + PageDocsApiAffordanceRoute: typeof PageDocsApiAffordanceRoute; + PageDocsApiAjvRoute: typeof PageDocsApiAjvRoute; + PageDocsApiAnimationReactRoute: typeof PageDocsApiAnimationReactRoute; + PageDocsApiAnnotationRoute: typeof PageDocsApiAnnotationRoute; + PageDocsApiCalendarRoute: typeof PageDocsApiCalendarRoute; + PageDocsApiCollaborationRoute: typeof PageDocsApiCollaborationRoute; + PageDocsApiComposerRoute: typeof PageDocsApiComposerRoute; + PageDocsApiComposerReactRoute: typeof PageDocsApiComposerReactRoute; + PageDocsApiContenteditableRoute: typeof PageDocsApiContenteditableRoute; + PageDocsApiContenteditableCollaborationRoute: typeof PageDocsApiContenteditableCollaborationRoute; + PageDocsApiDatabaseRoute: typeof PageDocsApiDatabaseRoute; + PageDocsApiEditingRoute: typeof PageDocsApiEditingRoute; + PageDocsApiFileIntakeRoute: typeof PageDocsApiFileIntakeRoute; + PageDocsApiJsonDocumentRoute: typeof PageDocsApiJsonDocumentRoute; + PageDocsApiMarkdownReactRoute: typeof PageDocsApiMarkdownReactRoute; + PageDocsApiReactRoute: typeof PageDocsApiReactRoute; + PageDocsApiReactHookFormRoute: typeof PageDocsApiReactHookFormRoute; + PageDocsApiRichTextRoute: typeof PageDocsApiRichTextRoute; + PageDocsApiRichTextMentionRoute: typeof PageDocsApiRichTextMentionRoute; + PageDocsApiRichTextMentionReactRoute: typeof PageDocsApiRichTextMentionReactRoute; + PageDocsApiRichTextReactRoute: typeof PageDocsApiRichTextReactRoute; + PageDocsApiRichTextSuggestionRoute: typeof PageDocsApiRichTextSuggestionRoute; + PageDocsApiRichTextSuggestionReactRoute: typeof PageDocsApiRichTextSuggestionReactRoute; + PageDocsApiRichTextWebRoute: typeof PageDocsApiRichTextWebRoute; + PageDocsApiSelectionRoute: typeof PageDocsApiSelectionRoute; + PageDocsApiTanstackTableRoute: typeof PageDocsApiTanstackTableRoute; + PageDocsApiUiPrimitivesReactRoute: typeof PageDocsApiUiPrimitivesReactRoute; + PageDocsApiWebRoute: typeof PageDocsApiWebRoute; + PageDocsApiZodRoute: typeof PageDocsApiZodRoute; PageDocsCollaborationHistoryRoute: typeof PageDocsCollaborationHistoryRoute; PageDocsCollaborationLifecycleRoute: typeof PageDocsCollaborationLifecycleRoute; PageDocsCollaborationReplicaRoute: typeof PageDocsCollaborationReplicaRoute; PageDocsDocumentTypesCandidateRoute: typeof PageDocsDocumentTypesCandidateRoute; PageConnectorsZodIndexRoute: typeof PageConnectorsZodIndexRoute; PageDocsAffordanceIndexRoute: typeof PageDocsAffordanceIndexRoute; + PageDocsApiIndexRoute: typeof PageDocsApiIndexRoute; PageDocsCollaborationIndexRoute: typeof PageDocsCollaborationIndexRoute; PageDocsDocumentTypesIndexRoute: typeof PageDocsDocumentTypesIndexRoute; PageDocsCollaborationTextLeaseRoute: typeof PageDocsCollaborationTextLeaseRoute; @@ -3314,7 +3272,6 @@ const PageRouteChildren: PageRouteChildren = { PageDocsAdapterVirtualSelectionRoute: PageDocsAdapterVirtualSelectionRoute, PageDocsAdaptersRoute: PageDocsAdaptersRoute, PageDocsAnimationRoute: PageDocsAnimationRoute, - PageDocsApiRoute: PageDocsApiRouteWithChildren, PageDocsClipboardRoute: PageDocsClipboardRoute, PageDocsComposerRoute: PageDocsComposerRoute, PageDocsConceptsRoute: PageDocsConceptsRoute, @@ -3384,12 +3341,45 @@ const PageRouteChildren: PageRouteChildren = { PageDocsAffordanceTripleClickRoute: PageDocsAffordanceTripleClickRoute, PageDocsAffordanceTypeaheadRoute: PageDocsAffordanceTypeaheadRoute, PageDocsAffordanceZoomRoute: PageDocsAffordanceZoomRoute, + PageDocsApiA2uiRoute: PageDocsApiA2uiRoute, + PageDocsApiAffordanceRoute: PageDocsApiAffordanceRoute, + PageDocsApiAjvRoute: PageDocsApiAjvRoute, + PageDocsApiAnimationReactRoute: PageDocsApiAnimationReactRoute, + PageDocsApiAnnotationRoute: PageDocsApiAnnotationRoute, + PageDocsApiCalendarRoute: PageDocsApiCalendarRoute, + PageDocsApiCollaborationRoute: PageDocsApiCollaborationRoute, + PageDocsApiComposerRoute: PageDocsApiComposerRoute, + PageDocsApiComposerReactRoute: PageDocsApiComposerReactRoute, + PageDocsApiContenteditableRoute: PageDocsApiContenteditableRoute, + PageDocsApiContenteditableCollaborationRoute: + PageDocsApiContenteditableCollaborationRoute, + PageDocsApiDatabaseRoute: PageDocsApiDatabaseRoute, + PageDocsApiEditingRoute: PageDocsApiEditingRoute, + PageDocsApiFileIntakeRoute: PageDocsApiFileIntakeRoute, + PageDocsApiJsonDocumentRoute: PageDocsApiJsonDocumentRoute, + PageDocsApiMarkdownReactRoute: PageDocsApiMarkdownReactRoute, + PageDocsApiReactRoute: PageDocsApiReactRoute, + PageDocsApiReactHookFormRoute: PageDocsApiReactHookFormRoute, + PageDocsApiRichTextRoute: PageDocsApiRichTextRoute, + PageDocsApiRichTextMentionRoute: PageDocsApiRichTextMentionRoute, + PageDocsApiRichTextMentionReactRoute: PageDocsApiRichTextMentionReactRoute, + PageDocsApiRichTextReactRoute: PageDocsApiRichTextReactRoute, + PageDocsApiRichTextSuggestionRoute: PageDocsApiRichTextSuggestionRoute, + PageDocsApiRichTextSuggestionReactRoute: + PageDocsApiRichTextSuggestionReactRoute, + PageDocsApiRichTextWebRoute: PageDocsApiRichTextWebRoute, + PageDocsApiSelectionRoute: PageDocsApiSelectionRoute, + PageDocsApiTanstackTableRoute: PageDocsApiTanstackTableRoute, + PageDocsApiUiPrimitivesReactRoute: PageDocsApiUiPrimitivesReactRoute, + PageDocsApiWebRoute: PageDocsApiWebRoute, + PageDocsApiZodRoute: PageDocsApiZodRoute, PageDocsCollaborationHistoryRoute: PageDocsCollaborationHistoryRoute, PageDocsCollaborationLifecycleRoute: PageDocsCollaborationLifecycleRoute, PageDocsCollaborationReplicaRoute: PageDocsCollaborationReplicaRoute, PageDocsDocumentTypesCandidateRoute: PageDocsDocumentTypesCandidateRoute, PageConnectorsZodIndexRoute: PageConnectorsZodIndexRoute, PageDocsAffordanceIndexRoute: PageDocsAffordanceIndexRoute, + PageDocsApiIndexRoute: PageDocsApiIndexRoute, PageDocsCollaborationIndexRoute: PageDocsCollaborationIndexRoute, PageDocsDocumentTypesIndexRoute: PageDocsDocumentTypesIndexRoute, PageDocsCollaborationTextLeaseRoute: PageDocsCollaborationTextLeaseRoute, diff --git a/site/src/app/routes/_page/docs/api.tsx b/site/src/app/routes/_page/docs/api.tsx deleted file mode 100644 index efaf4142a..000000000 --- a/site/src/app/routes/_page/docs/api.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ApiReferenceRoute } from "../../../../routes/docs/DocsRoute"; - -export const Route = createFileRoute("/_page/docs/api")({ - component: ApiReferenceRoute, -}); diff --git a/site/src/app/routes/_page/docs/api/index.tsx b/site/src/app/routes/_page/docs/api/index.tsx new file mode 100644 index 000000000..95c6d7b34 --- /dev/null +++ b/site/src/app/routes/_page/docs/api/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ApiReferenceRoute } from "../../../../../routes/docs/DocsRoute"; + +export const Route = createFileRoute("/_page/docs/api/")({ + component: ApiReferenceRoute, +}); diff --git a/site/src/routes/calendar-demo/CalendarDemoRoute.tsx b/site/src/routes/calendar-demo/CalendarDemoRoute.tsx index 05417e829..55e1daf07 100644 --- a/site/src/routes/calendar-demo/CalendarDemoRoute.tsx +++ b/site/src/routes/calendar-demo/CalendarDemoRoute.tsx @@ -132,16 +132,18 @@ export function CalendarDemoRoute(props: { initialEventIds: [], }); }); + const [errorCode, setErrorCode] = useState(null); const hand = useCalendarHand(editor, { initialOccurrence: { start: null, end: null }, defaultTitle: "Event", + onResult: (result) => setErrorCode(result.ok ? null : result.code), }); const clipboard = createWebClipboardSurface({ codec: createWebJSONClipboardRepresentation(calendarClipboardFormat), read: hand.copy, cut: hand.cut, paste: hand.paste, - onResult: () => {}, + onResult: (result) => { if (result.ok) setErrorCode(null); else if (result.code !== "editing.rejected") setErrorCode(result.code); }, }); const [viewState, setViewState] = useState(calendarSearchDefaults.view); const [visibleDateState, setVisibleDateState] = useState(calendarSearchDefaults.date); @@ -404,6 +406,9 @@ export function CalendarDemoRoute(props: { >
+ {errorCode === null ? null :

+ 변경하지 못했습니다. 선택과 입력을 확인한 뒤 다시 시도하세요. ({errorCode}) +

} {eventInspectorVisible ? ([ ["packages/json-document-editing/src/calendar-time-grid-pointer.ts", calendarTimeGridPointerSource], ["packages/json-document-editing/src/calendar-occurrence.ts", calendarOccurrenceSource], ["packages/json-document-editing/src/calendar-preview.ts", calendarPreviewSource], + ["packages/json-document-editing/src/calendar-event-plan.ts", calendarEventPlanSource], ["packages/json-document-editing/src/calendar-selection.ts", calendarSelectionSource], ["packages/json-document-editing/src/calendar-selection-move.ts", calendarSelectionMoveSource], ["packages/json-document-editing/src/calendar-validation.ts", calendarValidationSource], @@ -396,6 +399,11 @@ const registeredPublicUsages = [ symbol: "createCalendarEditor", sourcePath: "packages/json-document-editing/src/calendar.ts", }, + { + packageName: "@interactive-os/json-document-editing", + symbol: "createCalendarEditor", + sourcePath: "packages/json-document-editing/src/calendar-event-plan.ts", + }, { packageName: "@interactive-os/json-document-editing", symbol: "calendarOccurrenceTopology", diff --git a/site/tests/unit/demo-workbench.test.tsx b/site/tests/unit/demo-workbench.test.tsx index c90d3d57a..5f10bc4b0 100644 --- a/site/tests/unit/demo-workbench.test.tsx +++ b/site/tests/unit/demo-workbench.test.tsx @@ -55,6 +55,16 @@ describe("DemoWorkbench", () => { }); describe("Demo definition and source discovery", () => { + test("Calendar Usage exposes the shared edit plan and each canonical owner's API", async () => { + const sources = await discoverDemoSources("routes/calendar-demo/CalendarDemoRoute.tsx"); + const plan = sources.find((file) => file.path === "packages/json-document-editing/src/calendar-event-plan.ts"); + expect(plan?.referencePath).toBe("/docs/api/editing"); + expect(await plan!.load()).toContain("export function planCalendarEventEdit"); + const hand = sources.find((file) => file.path === "packages/json-document-calendar/src/use-calendar-hand.ts"); + expect(hand?.referencePath).toBe("/docs/api/calendar"); + expect(await hand!.load()).toContain("export function useCalendarHand"); + }); + test("exercises and exposes the canonical ID allocator in Clipboard Usage", async () => { const hook = renderHook(useClipboardLab); act(() => { hook.result.current.copy(); }); diff --git a/site/tests/unit/docs-route.test.tsx b/site/tests/unit/docs-route.test.tsx index c4868eadd..25ba39922 100644 --- a/site/tests/unit/docs-route.test.tsx +++ b/site/tests/unit/docs-route.test.tsx @@ -14,12 +14,33 @@ beforeEach(() => { '', ].join(""); Object.defineProperty(window, "scrollTo", { configurable: true, value: vi.fn() }); + Object.defineProperty(Element.prototype, "scrollIntoView", { configurable: true, value: vi.fn() }); window.history.pushState(null, "", "/"); }); afterEach(cleanup); describe("documentation routes", () => { + test("renders package APIs and the Calendar profile without the API index masking them", async () => { + window.history.pushState(null, "", "/docs/api/editing#calendar-protocol-profile-rc"); + render(); + + expect(await screen.findByRole("heading", { level: 1, name: "API · Editing" }, { timeout: 10000 })).toBeTruthy(); + expect(screen.getByRole("heading", { level: 2, name: "Calendar protocol profile (RC)" }).id).toBe("calendar-protocol-profile-rc"); + expect(screen.queryByRole("heading", { level: 1, name: "json-document API" })).toBeNull(); + + window.history.pushState(null, "", "/docs/api/calendar"); + window.dispatchEvent(new Event("popstate")); + expect(await screen.findByRole("heading", { level: 1, name: "API · Calendar" }, { timeout: 10000 })).toBeTruthy(); + expect(screen.getByRole("link", { name: "시간·반복·거절 계약: Editing의 Calendar protocol profile" }).getAttribute("href")) + .toBe("/docs/api/editing#calendar-protocol-profile-rc"); + + window.history.pushState(null, "", "/docs/api"); + window.dispatchEvent(new Event("popstate")); + expect(await screen.findByRole("heading", { level: 1, name: "json-document API" }, { timeout: 10000 })).toBeTruthy(); + expect(screen.queryByRole("heading", { level: 2, name: "Calendar protocol profile (RC)" })).toBeNull(); + }, 15000); + test("navigates across the keyed documentation registry", async () => { render(); const user = userEvent.setup();