fix(09): IN-02 extract shared resolveFinalRrule helper to deduplicate the update/create RRULE decision tree

This commit is contained in:
Lucas Berger
2026-06-12 20:50:52 -04:00
parent b8bb6e7671
commit d3163f2281
+64 -54
View File
@@ -143,6 +143,56 @@ export function assembleRruleString(
return s; return s;
} }
/**
* IN-02: shared RRULE-resolution decision tree for the update and create branches.
*
* The two branches differ only in the SOURCE of `preservedRrule` (the update branch
* re-reads it from calendarEvents.rawVevent; the create branch reads the
* `_preservedRrule` payload field threaded by the edit-as-move route). The precedence
* logic is identical and was previously copy-pasted, risking drift between the two
* copies of the RFC-5545 bound-strip (`;(UNTIL|COUNT)=` removal) — see Pitfall 3.
*
* Precedence:
* 1. Explicit recurrence on the payload wins (recurrence:'none' clears the RRULE).
* 2. Else, if a preserved RRULE exists and the payload changes only the bound
* (UNTIL/COUNT), strip the preserved RRULE's existing bound and re-apply the new
* one — never naive-concatenate (would produce a double-UNTIL/COUNT).
* 3. Else fall back to the payload's preset (rruleFromPayload), or the preserved
* RRULE unchanged when no bound change was requested.
*/
function resolveFinalRrule(
fields: OutboxPayloadFields,
hasExplicitRecurrence: boolean,
rruleFromPayload: string | undefined,
preservedRrule: string | undefined,
): string | undefined {
if (hasExplicitRecurrence) {
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted)
return rruleFromPayload
? assembleRruleString(
rruleFromPayload,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
: undefined;
}
if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Bound change only: strip existing UNTIL/COUNT, then re-apply the new bound (Pitfall 3)
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
return assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
);
}
return preservedRrule;
}
return rruleFromPayload;
}
// ── Drain concurrency guard (CR-05) ────────────────────────────────────────── // ── Drain concurrency guard (CR-05) ──────────────────────────────────────────
/** /**
@@ -422,33 +472,13 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// concatenate onto `FREQ=WEEKLY;BYDAY=...` which would produce double-UNTIL. // concatenate onto `FREQ=WEEKLY;BYDAY=...` which would produce double-UNTIL.
// WR-01 note: preservedRrule is only set when !hasExplicitRecurrence (see above), // WR-01 note: preservedRrule is only set when !hasExplicitRecurrence (see above),
// so the hasExplicitRecurrence branch always takes precedence over preserved RRULE. // so the hasExplicitRecurrence branch always takes precedence over preserved RRULE.
let finalRruleString: string | undefined; // IN-02: shared decision tree extracted to resolveFinalRrule (mirrored in create branch).
if (hasExplicitRecurrence) { const finalRruleString = resolveFinalRrule(
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted) fields,
finalRruleString = rruleFromPayload hasExplicitRecurrence,
? assembleRruleString( rruleFromPayload,
rruleFromPayload, preservedRrule,
fields.recurrenceUntil, );
fields.recurrenceCount,
fields.allDay,
)
: undefined;
} else if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Series edit with bound change only: strip existing UNTIL/COUNT, then re-apply
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
);
} else {
finalRruleString = preservedRrule;
}
} else {
finalRruleString = rruleFromPayload;
}
const { icsString } = buildVeventString({ const { icsString } = buildVeventString({
uid: row.uid, uid: row.uid,
@@ -520,33 +550,13 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
// CR-01: an explicit recurrence preset wins over _preservedRrule (deliberate user choice). // CR-01: an explicit recurrence preset wins over _preservedRrule (deliberate user choice).
// recurrence:'none' explicitly clears any RRULE — including when _preservedRrule is present. // recurrence:'none' explicitly clears any RRULE — including when _preservedRrule is present.
// If no explicit recurrence, fall back to _preservedRrule (edit-as-move RRULE carry-through). // If no explicit recurrence, fall back to _preservedRrule (edit-as-move RRULE carry-through).
let finalRruleString: string | undefined; // IN-02: shared decision tree extracted to resolveFinalRrule (mirrored in update branch).
if (hasExplicitRecurrence) { const finalRruleString = resolveFinalRrule(
// Explicit recurrence wins — recurrence:'none' yields undefined (no RRULE emitted) fields,
finalRruleString = rruleFromPayload hasExplicitRecurrence,
? assembleRruleString( rruleFromPayload,
rruleFromPayload, preservedRrule,
fields.recurrenceUntil, );
fields.recurrenceCount,
fields.allDay,
)
: undefined;
} else if (preservedRrule) {
if (fields.recurrenceUntil || fields.recurrenceCount !== undefined) {
// Bound change on preserved RRULE: strip existing UNTIL/COUNT first (Pitfall 3)
const strippedPreset = preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, '');
finalRruleString = assembleRruleString(
strippedPreset,
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
);
} else {
finalRruleString = preservedRrule;
}
} else {
finalRruleString = rruleFromPayload;
}
const { icsString } = buildVeventString({ const { icsString } = buildVeventString({
uid: row.uid, uid: row.uid,