Skip to content

Commit df6bb71

Browse files
committed
fix(calendar): propagate event-link priority through same-batch merge
processCalendarEventsFn/processOutlookEventsFn coalesce same-source links within one sync batch via addLink(), but the merge didn't copy `priority`. Recurring-event instance/exception links never set a priority; when an instance is coalesced before its master in the same batch, the merged link's priority ended up undefined (server default 0), letting a bundled email tie/win primacy depending on ordering within the page. Fix: addLink() now sets priority = max(existing, incoming), so the master's floor (>= 1) survives regardless of instance-vs-master order. Adds a regression test per connector proving a same-batch, instance- before-master pair coalesces to priority >= 1.
1 parent d7a12b3 commit df6bb71

4 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎connectors/google-calendar/src/sync.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1647,4 +1647,54 @@ describe("processCalendarEventsFn — event link priority", () => {
16471647
.find((l) => l.source === "google-calendar:uid-cancelled");
16481648
expect(link?.priority).toBe(1);
16491649
});
1650+
1651+
it("floors the coalesced priority when a recurring instance is merged before its master in the same batch", async () => {
1652+
// Regression test: addLink() coalesces same-source links within one
1653+
// sync batch but previously didn't propagate `priority`. Recurring
1654+
// instance/exception links never set `priority` at all, so when an
1655+
// instance is processed (and becomes the batch's `existing` entry)
1656+
// before its master arrives, the merge left `existing.priority`
1657+
// `undefined` (-> server default 0) even though the master carries a
1658+
// floor of >= 1 — letting a bundled email tie/win primacy depending on
1659+
// event-vs-instance ordering within the page.
1660+
const host = makeFakeHost({ calendarId });
1661+
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));
1662+
1663+
// Instance/exception link — no `iCalUID`, so it falls back to
1664+
// `recurringEventId` for its canonical (master) source, and it carries
1665+
// no `priority` field at all.
1666+
const instance = {
1667+
id: "instance-1",
1668+
recurringEventId: "master-1",
1669+
originalStartTime: { dateTime: isoDaysFromNow(2) },
1670+
status: "confirmed" as const,
1671+
start: { dateTime: isoDaysFromNow(2) },
1672+
end: { dateTime: isoDaysFromNow(2) },
1673+
};
1674+
1675+
// Master — no `iCalUID` either, so its canonical source is
1676+
// `google-calendar:master-1`, matching the instance's fallback above.
1677+
// Organizer is someone else and there are no attendees, so this floors
1678+
// to priority 1 (the lowest non-zero floor).
1679+
const master = {
1680+
id: "master-1",
1681+
status: "confirmed" as const,
1682+
summary: "Recurring sync",
1683+
organizer: { email: "boss@x.com", self: false },
1684+
attendees: [],
1685+
start: { dateTime: isoDaysFromNow(1) },
1686+
end: { dateTime: isoDaysFromNow(1) },
1687+
};
1688+
1689+
// Instance BEFORE master, in one batch — the exact ordering that
1690+
// previously dropped the priority floor.
1691+
await processCalendarEventsFn(host, [instance, master], calendarId, false);
1692+
1693+
const saved = host.savedLinks.flat();
1694+
// Coalesced onto a single link, not two.
1695+
expect(saved.filter((l) => l.source === "google-calendar:master-1")).toHaveLength(1);
1696+
1697+
const link = saved.find((l) => l.source === "google-calendar:master-1");
1698+
expect(link?.priority).toBeGreaterThanOrEqual(1);
1699+
});
16501700
});

‎connectors/google-calendar/src/sync.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,11 @@ export async function processCalendarEventsFn(
644644
existing.meta = { ...(existing.meta || {}), ...(link.meta || {}) };
645645
if (link.unread !== undefined) existing.unread = link.unread;
646646
if (link.archived !== undefined) existing.archived = link.archived;
647+
// Never let the merge LOWER an already-set priority. Recurring-event
648+
// instance links carry no priority; when an instance is coalesced
649+
// before its master in the same batch, this floor ensures the
650+
// master's priority (>= 1) survives regardless of ordering.
651+
existing.priority = Math.max(existing.priority ?? 0, link.priority ?? 0);
647652
}
648653
};
649654

‎connectors/outlook-calendar/src/sync.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,4 +331,49 @@ describe("processOutlookEventsFn — event link priority", () => {
331331
.find((l) => l.source === `outlook-calendar:${calendarId}:e2`);
332332
expect(link?.priority).toBeGreaterThanOrEqual(1);
333333
});
334+
335+
it("floors the coalesced priority when a recurring instance is merged before its master in the same batch", async () => {
336+
// Regression test: addLink() coalesces same-source links within one
337+
// sync batch but previously didn't propagate `priority`. Recurring
338+
// instance/exception links never set `priority` at all, so when an
339+
// instance is processed (and becomes the batch's `existing` entry)
340+
// before its master arrives, the merge left `existing.priority`
341+
// `undefined` (-> server default 0) even though the master carries a
342+
// constant floor of 1 — letting a bundled email tie/win primacy
343+
// depending on event-vs-instance ordering within the page.
344+
const host = makeFakeHost();
345+
346+
const instance = outlookEventFixture({
347+
id: "instance-1",
348+
type: "occurrence",
349+
seriesMasterId: "master-1",
350+
originalStart: isoDaysFromNow(2),
351+
});
352+
353+
const master = outlookEventFixture({
354+
id: "master-1",
355+
subject: "Recurring sync",
356+
organizer: { emailAddress: { address: "org@x.com", name: "Org" } },
357+
attendees: [
358+
{ emailAddress: { address: "bob@x.com" }, type: "required" },
359+
],
360+
});
361+
362+
// Instance BEFORE master, in one batch — the exact ordering that
363+
// previously dropped the priority floor.
364+
await processOutlookEventsFn(
365+
host,
366+
[instance, master],
367+
calendarId,
368+
false
369+
);
370+
371+
const saved = host.savedLinks.flat();
372+
const masterSource = `outlook-calendar:${calendarId}:master-1`;
373+
// Coalesced onto a single link, not two.
374+
expect(saved.filter((l) => l.source === masterSource)).toHaveLength(1);
375+
376+
const link = saved.find((l) => l.source === masterSource);
377+
expect(link?.priority).toBeGreaterThanOrEqual(1);
378+
});
334379
});

‎connectors/outlook-calendar/src/sync.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,11 @@ export async function processOutlookEventsFn(
701701
existing.meta = { ...(existing.meta || {}), ...(link.meta || {}) };
702702
if (link.unread !== undefined) existing.unread = link.unread;
703703
if (link.archived !== undefined) existing.archived = link.archived;
704+
// Never let the merge LOWER an already-set priority. Recurring-event
705+
// instance links carry no priority; when an instance is coalesced
706+
// before its master in the same batch, this floor ensures the
707+
// master's priority (>= 1) survives regardless of ordering.
708+
existing.priority = Math.max(existing.priority ?? 0, link.priority ?? 0);
704709
}
705710
};
706711

0 commit comments

Comments
 (0)